diff --git a/docs/reference/protocols/periodic-report-v0.md b/docs/reference/protocols/periodic-report-v0.md index b5a95f6dd2..9f61e01cd6 100644 --- a/docs/reference/protocols/periodic-report-v0.md +++ b/docs/reference/protocols/periodic-report-v0.md @@ -290,11 +290,14 @@ drift. `periodic_report_project_progress_projection_v0` is the built-in, domain-neutral source input. It groups typed project facts into progress, capability evolution, risks, next actions, and supporting evidence, with no -more than eight primary audience items. Issue Fix has no special standing in -either schema. It may register a peer source adapter under the same contract as -release, research, operations, or another domain. OpenViking is likewise an -optional archive/query provider behind a sink extension; it does not own -trigger, selection, rendering, or delivery. +more than eight primary audience items. The snapshot covers the whole Goal: +facts produced by any Agent lane are selectable, the requesting `agent_id` +ranks that Agent's own outcomes and next action ahead of a peer lane's, so the +audience-item cap cannot evict the reporter's own progress, and a row no Agent +claimed stays out because it has no producer. Issue Fix has no special standing in either schema. It may register a +peer source adapter under the same contract as release, research, operations, or +another domain. OpenViking is likewise an optional archive/query provider behind +a sink extension; it does not own trigger, selection, rendering, or delivery. `periodic_report_v0` is the LoopX control contract for one bounded report run. It binds a period window and a profile to typed source snapshots, one rendered diff --git a/loopx/capabilities/periodic_report/project_progress_snapshot.py b/loopx/capabilities/periodic_report/project_progress_snapshot.py index 7c0bfc3d49..5147f8252b 100644 --- a/loopx/capabilities/periodic_report/project_progress_snapshot.py +++ b/loopx/capabilities/periodic_report/project_progress_snapshot.py @@ -102,6 +102,12 @@ def build_project_progress_snapshot_from_state( ) -> dict[str, Any] | None: """Build a progress snapshot from one already-read authoritative state. + Evidence is selected for the Goal, not for the calling lane: every Agent's + eligible rows are reportable and ``agent_id`` only ranks the reporting + Agent's own rows first, so a multi-Agent Goal does not lose peer progress + and the bounded outcome cap never evicts the reporter's own outcomes. A row + no Agent claimed has no producer and stays out of the report. + Resume-gated todos are judged with the same typed resume evidence the scheduler consumes: ``rollout_events`` feeds ``pr_merged`` gates and ``available_capabilities`` feeds ``capacity_available`` gates. Callers @@ -123,6 +129,11 @@ def build_project_progress_snapshot_from_state( if stage_time is None: raise ValueError("periodic-report stage completion timestamp is invalid") + def produced_by(item: Mapping[str, Any]) -> str: + """Return the Agent whose lane produced this row, or empty for none.""" + + return str(item.get("claimed_by") or "").strip() + def not_after_stage(item: Mapping[str, Any]) -> bool: raw = str(item.get("updated_at") or item.get("completed_at") or "").strip() if not raw: @@ -137,11 +148,14 @@ def not_after_stage(item: Mapping[str, Any]) -> bool: for item in items or [] if isinstance(item, Mapping) and item.get("status") == "done" - and str(item.get("claimed_by") or "") == agent_id + and produced_by(item) and not_after_stage(item) and str(item.get("action_kind") or "") not in _META_ACTION_KINDS ] + # Tier order must not disturb recency order inside a tier, so the lane sort + # runs last over the already-newest-first list. done.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True) + done.sort(key=lambda item: produced_by(item) != agent_id) progress_items: list[dict[str, Any]] = [] for index, item in enumerate(done): outcome_completed_at = _outcome_completed_at(item, stage_time=stage_time) @@ -169,7 +183,7 @@ def not_after_stage(item: Mapping[str, Any]) -> bool: for item in items or [] if isinstance(item, Mapping) and todo_item_is_actionable_open(dict(item)) - and str(item.get("claimed_by") or "") == agent_id + and produced_by(item) and not_after_stage(item) and item.get("task_class") != "continuous_monitor" and item.get("action_kind") @@ -178,6 +192,7 @@ def not_after_stage(item: Mapping[str, Any]) -> bool: "repair_periodic_report_intent_consumption", } ] + open_items.sort(key=lambda item: produced_by(item) != agent_id) if open_items: next_item = open_items[0] progress_items.append( diff --git a/tests/capabilities/test_periodic_report_incremental.py b/tests/capabilities/test_periodic_report_incremental.py index bd6c6e964e..bcbfe995a4 100644 --- a/tests/capabilities/test_periodic_report_incremental.py +++ b/tests/capabilities/test_periodic_report_incremental.py @@ -874,7 +874,7 @@ def test_snapshot_next_action_ignores_done_markers_and_report_meta_kinds( ] -def test_snapshot_next_action_scopes_to_the_reporting_agent_and_stage_window( +def test_snapshot_next_action_prefers_the_reporting_agent_within_the_stage_window( tmp_path: Path, ) -> None: state = _agent_todo_state( @@ -957,3 +957,148 @@ def test_snapshot_next_action_excludes_continuous_monitor_but_keeps_blocker_clas ) assert snapshot is not None assert _next_action_refs(snapshot) == ["todo:todo_blocker"] + + +def _outcome_refs(snapshot: dict[str, object]) -> list[str]: + return [ + str(item["source_ref"]) + for item in snapshot["items"] + if isinstance(item, dict) and item.get("content_kind") == "outcome" + ] + + +def test_snapshot_reports_peer_outcomes_and_keeps_unowned_rows_out( + tmp_path: Path, +) -> None: + """A Goal-level report must not silently drop another lane's progress.""" + + state = _agent_todo_state( + [ + _todo( + "- [x] Landed the reporting lane's outcome.", + "todo_id=todo_own status=done task_class=advancement_task " + f"claimed_by={AGENT_ID} updated_at=2026-08-01T07:00:00Z " + "completed_at=2026-08-01T07:00:00Z", + ), + _todo( + "- [x] Landed a peer lane's outcome.", + "todo_id=todo_peer status=done task_class=advancement_task " + "claimed_by=peer-agent updated_at=2026-08-01T07:30:00Z " + "completed_at=2026-08-01T07:30:00Z", + ), + _todo( + "- [x] Landed an unclaimed outcome.", + "todo_id=todo_unclaimed status=done task_class=advancement_task " + "updated_at=2026-08-01T07:40:00Z completed_at=2026-08-01T07:40:00Z", + ), + ] + ) + snapshot = build_project_progress_snapshot_from_state( + **_snapshot_call(tmp_path, state) + ) + + assert snapshot is not None + assert _outcome_refs(snapshot) == ["todo:todo_own", "todo:todo_peer"] + + +def test_snapshot_ranks_peer_outcomes_after_the_reporter_and_honors_the_stage_window( + tmp_path: Path, +) -> None: + state = _agent_todo_state( + [ + _todo( + "- [x] Older outcome by the reporting lane.", + "todo_id=todo_own_older status=done task_class=advancement_task " + f"claimed_by={AGENT_ID} updated_at=2026-08-01T06:00:00Z " + "completed_at=2026-08-01T06:00:00Z", + ), + _todo( + "- [x] Newer outcome by a peer lane.", + "todo_id=todo_peer_newer status=done task_class=advancement_task " + "claimed_by=peer-agent updated_at=2026-08-01T07:50:00Z " + "completed_at=2026-08-01T07:50:00Z", + ), + _todo( + "- [x] Peer outcome completed after the stage boundary.", + "todo_id=todo_peer_future status=done task_class=advancement_task " + "claimed_by=peer-agent updated_at=2026-08-01T09:00:00Z " + "completed_at=2026-08-01T09:00:00Z", + ), + _todo( + "- [x] Peer report-intent bookkeeping.", + "todo_id=todo_peer_meta status=done task_class=advancement_task " + "claimed_by=peer-agent action_kind=consume_periodic_report_intent " + "updated_at=2026-08-01T07:10:00Z completed_at=2026-08-01T07:10:00Z", + ), + ] + ) + snapshot = build_project_progress_snapshot_from_state( + **_snapshot_call(tmp_path, state) + ) + + assert snapshot is not None + assert _outcome_refs(snapshot) == [ + "todo:todo_own_older", + "todo:todo_peer_newer", + ] + + +def test_snapshot_outcome_cap_keeps_every_reporting_agent_outcome( + tmp_path: Path, +) -> None: + own_rows = [ + _todo( + f"- [x] Reporting outcome {index}.", + f"todo_id=todo_own_{index} status=done task_class=advancement_task " + f"claimed_by={AGENT_ID} updated_at=2026-08-01T06:0{index}:00Z " + f"completed_at=2026-08-01T06:0{index}:00Z", + ) + for index in range(5) + ] + peer_rows = [ + _todo( + f"- [x] Peer outcome {index}.", + f"todo_id=todo_peer_{index} status=done task_class=advancement_task " + f"claimed_by=peer-agent updated_at=2026-08-01T07:0{index}:00Z " + f"completed_at=2026-08-01T07:0{index}:00Z", + ) + for index in range(4) + ] + snapshot = build_project_progress_snapshot_from_state( + **_snapshot_call(tmp_path, _agent_todo_state(own_rows + peer_rows)) + ) + + assert snapshot is not None + refs = _outcome_refs(snapshot) + assert len(refs) == 6 + assert refs[:5] == [f"todo:todo_own_{index}" for index in range(4, -1, -1)] + assert refs[5:] == ["todo:todo_peer_3"] + + +def test_snapshot_next_action_prefers_the_peer_lane_over_an_unowned_row( + tmp_path: Path, +) -> None: + """A row no Agent claimed keeps frontier ownership, and reports nothing.""" + + snapshot = build_project_progress_snapshot_from_state( + **_snapshot_call( + tmp_path, + _agent_todo_state( + [ + _todo( + "- [ ] Continue the unclaimed work.", + "todo_id=todo_unclaimed status=open" + " task_class=advancement_task", + ), + _todo( + "- [ ] Continue the peer lane's work.", + "todo_id=todo_peer status=open" + " task_class=advancement_task claimed_by=peer-agent", + ), + ] + ), + ) + ) + + assert snapshot is not None + assert _next_action_refs(snapshot) == ["todo:todo_peer"] diff --git a/tests/control_plane/test_post_writeback_capability_hooks.py b/tests/control_plane/test_post_writeback_capability_hooks.py index eaf330544e..8fdfc8edfa 100644 --- a/tests/control_plane/test_post_writeback_capability_hooks.py +++ b/tests/control_plane/test_post_writeback_capability_hooks.py @@ -2571,3 +2571,45 @@ def capture_hooks( assert exit_code == 7 assert captured["runtime_root"] == runtime_override + + +def test_periodic_report_projection_carries_peer_lane_progress( + tmp_path, +) -> None: + runtime_root, registry_path = _projection_goal_fixture( + tmp_path, + state_text="""# Goal + +## User Todo + +## Agent Todo + +- [x] Land the reporting lane's change. + +- [x] Land a peer lane's change. + +- [ ] Continue the reporting lane's next step. + +""", + runs=[ + _successor_ack_run(), + _closed_vision_run(), + ], + ) + + projection = build_periodic_report_post_writeback_projection( + payload={"state": {"path": str(tmp_path / "goal.md")}}, + registry_path=registry_path, + runtime_root=runtime_root, + goal_id="goal-1", + agent_id="agent-1", + ) + + items = projection["project_progress"]["items"] + assert [ + (item["content_kind"], item["title"]) for item in items + ] == [ + ("outcome", "Land the reporting lane's change."), + ("outcome", "Land a peer lane's change."), + ("next_action", "Next action"), + ]