diff --git a/loopx/presentation/projections/session_dash.py b/loopx/presentation/projections/session_dash.py index 40bca91886..f397eb4f72 100644 --- a/loopx/presentation/projections/session_dash.py +++ b/loopx/presentation/projections/session_dash.py @@ -19,6 +19,7 @@ from __future__ import annotations +import re from collections.abc import Mapping, Sequence from typing import Any @@ -48,13 +49,47 @@ "keepalive", "relaunched", ) +# ``controller`` is a human gate in the runtime contract: the attention +# emitters write ``waiting_on="controller"`` (see ``session_runtime``) while +# ``acceptance_observation`` and ``project_agent_context`` read +# ``{"controller", "user_or_controller"}`` as the human-gate set. Leaving it out +# dropped those goals into ``other`` instead of ``needs_user``. _USER_WAITING_MARKERS = ( "user", + "controller", "operator", "human", "owner", "user_gate", ) +# A status that names the user is waiting on them, the same way a ``waiting_on`` +# that names a human is. +_USER_STATUS_MARKERS = ("user",) + + +# Statuses are free-form: goal registries, run classifiers, and attention +# emitters each write their own words, so the bucket fold has to read them as +# words instead of raw substrings. Otherwise ``installed`` reads as ``stalled`` +# and ``incomplete`` reads as ``complete`` -- the prose-substring defect class +# reported in #4336, where a healthy ``installed`` goal was folded into a +# stalled one. +_MARKER_BOUNDARY_TEMPLATE = r"(? bool: + """True when one of ``markers`` appears in ``text`` as a whole word.""" + + return any( + re.search(_MARKER_BOUNDARY_TEMPLATE.format(marker=re.escape(marker)), text) + for marker in markers + ) + + +def _is_negated_status(status_text: str) -> bool: + """True for a status that negates the word it carries (``not_completed``).""" + + return status_text.startswith(_NEGATED_STATUS_PREFIXES) def _as_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]: @@ -97,17 +132,26 @@ def _status_bucket(*, status: str | None, waiting_on: str | None) -> str: Buckets: ``active`` (agent is working), ``needs_user`` (waiting on a human), ``blocked`` (stuck on a gate/blocker), ``done`` (finished), ``other`` (not enough signal to classify). + + Markers are matched as whole words, so a status that merely contains a + marker's letters keeps its own bucket, and a negated status + (``not_completed``) is not a done signal. ``waiting_on`` of ``controller`` + is a human gate, matching the set the runtime emitters and + ``acceptance_observation`` already agree on. """ status_text = (status or "").lower() waiting = (waiting_on or "").lower() - if any(marker in status_text for marker in _DONE_STATUS_MARKERS): + done_markers = () if _is_negated_status(status_text) else _DONE_STATUS_MARKERS + if _has_status_marker(status_text, done_markers): return "done" - if any(marker in status_text for marker in _BLOCKED_STATUS_MARKERS): + if _has_status_marker(status_text, _BLOCKED_STATUS_MARKERS): return "blocked" - if any(marker in waiting for marker in _USER_WAITING_MARKERS) or "user" in status_text: + if _has_status_marker(waiting, _USER_WAITING_MARKERS) or _has_status_marker( + status_text, _USER_STATUS_MARKERS + ): return "needs_user" - if any(marker in status_text for marker in _ACTIVE_STATUS_MARKERS): + if _has_status_marker(status_text, _ACTIVE_STATUS_MARKERS): return "active" return "other" diff --git a/tests/presentation/test_session_dash_status_bucket.py b/tests/presentation/test_session_dash_status_bucket.py new file mode 100644 index 0000000000..3de0dbbf29 --- /dev/null +++ b/tests/presentation/test_session_dash_status_bucket.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import pytest + +from loopx.presentation.projections.session_dash import ( + _status_bucket, + build_session_dash_projection, +) + +# Statuses the runtime actually writes, plus the words the buckets document. +# Every one of these has to keep the bucket it has today. +DOCUMENTED_STATUSES = [ + ("done", "done"), + ("complete", "done"), + ("completed", "done"), + ("closed", "done"), + ("shipped", "done"), + ("archived", "done"), + ("success", "done"), + ("blocked", "blocked"), + ("blocked_by_safety", "blocked"), + ("stalled", "blocked"), + ("stuck", "blocked"), + ("failed", "blocked"), + ("compose_setup_blocked_before_agent_rounds", "blocked"), + ("active", "active"), + ("active-progress", "active"), + ("in_progress", "active"), + ("running", "active"), + ("keepalive", "active"), + ("relaunched", "active"), + ("planned", "other"), + ("unknown", "other"), + ("no_change", "other"), + ("unregistered_runtime_goal", "other"), + ("operator_gate_approved", "other"), +] + +# A marker is a word, not a substring: these read as the *opposite* of the +# marker they contain. #4336 is the same defect one layer down, where a prose +# matcher read `installed` as `stalled`. +MARKER_INSIDE_A_LONGER_WORD = [ + ("installed", "other"), + ("uninstalled", "other"), + ("reinstalled", "other"), + ("incomplete", "other"), + ("undone", "other"), + ("unsuccessful", "other"), + ("not_completed", "other"), + ("not_shipped", "other"), +] + + +def test_documented_statuses_keep_their_bucket() -> None: + for status, expected in DOCUMENTED_STATUSES: + assert _status_bucket(status=status, waiting_on=None) == expected, status + + +def test_a_marker_inside_a_longer_word_does_not_set_the_bucket() -> None: + for status, expected in MARKER_INSIDE_A_LONGER_WORD: + assert _status_bucket(status=status, waiting_on=None) == expected, status + + +@pytest.mark.parametrize( + "waiting_on", + ["user", "controller", "user_or_controller", "operator", "human", "owner", "user_gate"], +) +def test_a_human_gate_is_needs_user(waiting_on: str) -> None: + assert _status_bucket(status="unregistered_runtime_goal", waiting_on=waiting_on) == "needs_user" + + +@pytest.mark.parametrize("waiting_on", ["agent", "codex", "auto", "external_evidence"]) +def test_an_agent_or_evidence_gate_is_not_needs_user(waiting_on: str) -> None: + assert _status_bucket(status="unregistered_runtime_goal", waiting_on=waiting_on) == "other" + + +def test_a_blocked_status_outranks_the_waiting_reason() -> None: + assert _status_bucket(status="blocked_by_safety", waiting_on="controller") == "blocked" + + +def test_projection_counts_a_controller_gate_as_needs_user() -> None: + """``session_runtime`` emits ``waiting_on="controller"``; the page's + needs-you column has to count it the same way it counts + ``user_or_controller``.""" + + def projection_for(waiting_on: str) -> dict[str, object]: + return build_session_dash_projection( + status_payload={ + "attention_queue": { + "items": [ + { + "goal_id": "runtime-goal", + "status": "unregistered_runtime_goal", + "waiting_on": waiting_on, + } + ] + } + }, + run_history={"goals": [{"id": "runtime-goal", "display_name": "runtime goal"}]}, + ) + + controller = projection_for("controller") + user_or_controller = projection_for("user_or_controller") + + expected = {"active": 0, "needs_user": 1, "blocked": 0, "done": 0, "other": 0} + assert controller["overview"]["goals_by_status"] == expected + assert user_or_controller["overview"]["goals_by_status"] == expected + + +def test_projection_does_not_fold_a_healthy_install_goal_into_blocked() -> None: + projection = build_session_dash_projection( + run_history={ + "goals": [{"id": "install-goal", "display_name": "installer", "status": "installed"}] + }, + ) + + assert projection["overview"]["goals_by_status"]["blocked"] == 0 + entry = projection["unassigned_goals"][0] + assert entry["status"] == "installed" + assert entry["status_bucket"] == "other"