diff --git a/docs/status-data-contract.md b/docs/status-data-contract.md index 2873cabfe9..ec6478918f 100644 --- a/docs/status-data-contract.md +++ b/docs/status-data-contract.md @@ -1188,6 +1188,15 @@ goal-level refresh in a multi-agent goal must use a registered peer with Use agent-lane scope for a peer-local recommendation that should not replace the durable goal route. +`connected_without_run` means a connected Goal has no recorded runs. When all +recorded runs are agent-lane notes or status-neutral events, `latest_status_run` +remains null; this does not mean the Goal never ran. Attention uses the current +authoritative active-state Todo projection when available, otherwise it retains +`unknown`. The full-history `unique_runs` count preserves this distinction when +the displayed `latest_runs` window is empty. Neither history nor an open Todo +proves a host is currently executing; Agent execution state still requires its +separate lane, worker and lease facts. + If a peer self-merged slice materially advanced the public product or case path, that peer or another registered peer should also write a project-level refresh with the matching `delivery_outcome=outcome_progress` (or skip the diff --git a/loopx/control_plane/work_items/attention_queue.py b/loopx/control_plane/work_items/attention_queue.py index 320a2eb59b..fb9976b9b9 100644 --- a/loopx/control_plane/work_items/attention_queue.py +++ b/loopx/control_plane/work_items/attention_queue.py @@ -240,7 +240,11 @@ def build_attention_queue( item = active_state_item else: item = context.goal_attention(goal) - if not item: + if not item or ( + current_status_run is None + and item.get("status") == "unknown" + and active_state_item + ): item = active_state_item if item: item, projection_warning = _reconcile_active_state_item( diff --git a/loopx/control_plane/work_items/attention_routing.py b/loopx/control_plane/work_items/attention_routing.py index d4d680a066..8ea4b42466 100644 --- a/loopx/control_plane/work_items/attention_routing.py +++ b/loopx/control_plane/work_items/attention_routing.py @@ -63,12 +63,18 @@ def goal_attention( adapter_status in connected_adapter_statuses or adapter_status in connected_delivery_adapter_statuses ): + # Lane notes and neutral runs do not establish Goal status, but are history. + has_history = bool(goal.get("unique_runs") or goal.get("latest_runs")) return attention_item( goal_id=goal_id, - status="connected_without_run", + status="unknown" if has_history else "connected_without_run", waiting_on="codex", severity="action", - recommended_action="run the first read-only adapter tick and save a compact run record", + recommended_action=( + "inspect the active goal state; existing history has no goal-level status run" + if has_history + else "run the first read-only adapter tick and save a compact run record" + ), source="run_history", **lifecycle_fields, ) diff --git a/tests/control_plane/test_status_no_run_projection.py b/tests/control_plane/test_status_no_run_projection.py new file mode 100644 index 0000000000..261aba7863 --- /dev/null +++ b/tests/control_plane/test_status_no_run_projection.py @@ -0,0 +1,104 @@ +"""Missing Goal-level status is distinct from missing execution history.""" +from __future__ import annotations + +import json + +import pytest + +from canonical_authority_fixture import initialize_canonical_source_fixture +from loopx.status import collect_status, goal_attention, latest_run + + +GOAL_RUN = { + "classification": "state_refreshed", + "recommended_action": "Inspect the current deliverable.", + "json_exists": True, + "markdown_exists": True, +} +LANE_RUN = {**GOAL_RUN, "progress_scope": "agent_lane", "agent_id": "peer"} +CONNECTED_GOAL = {"id": "goal-a", "adapter_status": "connected-read-only"} + + +@pytest.mark.parametrize("history", [ + {"latest_status_run": GOAL_RUN, "latest_runs": [LANE_RUN]}, + {"latest_runs": [LANE_RUN, GOAL_RUN]}, +]) +def test_new_and_legacy_run_fields_keep_goal_status(history): + goal = {**CONNECTED_GOAL, **history} + assert latest_run(goal) == GOAL_RUN + assert goal_attention(goal)["status"] == "state_refreshed" + + +@pytest.mark.parametrize("history", [ + {"latest_status_run": None, "latest_runs": [LANE_RUN], "unique_runs": 1}, + {"latest_runs": [LANE_RUN]}, + {"latest_runs": [{"classification": "quota_slot_spent"}]}, + {"latest_status_run": None, "latest_runs": [], "unique_runs": 4}, +]) +def test_history_without_goal_status_remains_unknown(history): + goal = {**CONNECTED_GOAL, **history} + assert latest_run(goal) is None + item = goal_attention(goal) + assert item["status"] == "unknown" + assert item["source"] == "run_history" + + +@pytest.mark.parametrize("history", [ + {}, + {"latest_status_run": None, "latest_runs": [], "unique_runs": 0}, +]) +def test_genuinely_unrun_connected_goal_keeps_initial_tick(history): + assert goal_attention({**CONNECTED_GOAL, **history})["status"] == "connected_without_run" + + +def test_unclassified_goal_run_stays_unknown(): + run = {"json_exists": True, "markdown_exists": True} + item = goal_attention({**CONNECTED_GOAL, "latest_status_run": run}) + assert item["status"] == "unknown" + assert item["source"] == "latest_run" + + +@pytest.mark.parametrize("role,expected", [ + (None, "unknown"), + ("agent", "active_state_agent_todo"), + ("user", "active_state_user_gate"), +]) +def test_status_uses_canonical_work_without_promoting_agent_history(tmp_path, role, expected): + registry, runtime, state = tmp_path / "registry.json", tmp_path / "runtime", tmp_path / "state.md" + state.write_text( + "# Goal\n\n## Agent Todo\n\n" + ( + ("## User Todo\n\n" if role == "user" else "") + + "- [ ] Inspect the current deliverable.\n" + " \n" + if role else "" + ), encoding="utf-8", + ) + registry.write_text(json.dumps({ + "schema_version": 1, "common_runtime_root": str(runtime), + "goals": [{"id": "goal-a", "repo": str(tmp_path), "state_file": str(state), + "adapter": {"kind": "read_only_project_map_v0", "status": "connected-read-only"}, + "coordination": {"registered_agents": ["peer"]}}], + }), encoding="utf-8") + initialize_canonical_source_fixture(runtime, "goal-a", state_path=state, provider="sqlite") + runs = runtime / "goals/goal-a/runs" + runs.mkdir(parents=True, exist_ok=True) + artifact = runs / "lane.json" + artifact.write_text("{}", encoding="utf-8") + index = runs / "index.jsonl" + index.write_text(json.dumps({ + **LANE_RUN, "goal_id": "goal-a", "generated_at": "2026-01-01T00:00:00Z", + "json_path": str(artifact), "markdown_path": str(artifact), + }) + "\n", encoding="utf-8") + before = {path: path.read_bytes() for path in (registry, state, artifact, index)} + + payload = collect_status(registry_path=registry, runtime_root_override=str(runtime), + goal_id="goal-a", limit=1) + + item, = (row for row in payload["attention_queue"]["items"] if row["goal_id"] == "goal-a") + assert item["status"] == expected + assert item["source"] == ("active_state" if role else "run_history") + if role == "user": + assert item["waiting_on"] == "controller" + assert payload["run_history"]["goals"][0]["latest_status_run"] is None + assert {path: path.read_bytes() for path in before} == before