From ed7ffb4eeb00a32e81ef0cd4eb79f7d8cc314164 Mon Sep 17 00:00:00 2001 From: DJC1412 <108855841+DJC1412@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:57:42 +0800 Subject: [PATCH 1/3] fix(periodic-report): report Goal evidence, not one lane's A project-progress snapshot selected only Todos whose claimed_by equalled the requesting agent, so a Goal worked by several lanes reported each lane its own slice and silently omitted peer progress. Selection is now Goal-level and claimed_by only ranks the reporter's own outcomes and next action first, which also keeps the bounded outcome cap from evicting them. Part of #3859: the machine-configuration base and the Goal-level delivery identity have already landed; this closes the evidence-aggregation step and leaves per-agent publication cursors and per-item provenance to the next cut. Signed-off-by: DJC1412 <108855841+DJC1412@users.noreply.github.com> --- .../reference/protocols/periodic-report-v0.md | 13 +- .../project_progress_snapshot.py | 26 ++- .../test_periodic_report_incremental.py | 162 +++++++++++++++++- 3 files changed, 193 insertions(+), 8 deletions(-) diff --git a/docs/reference/protocols/periodic-report-v0.md b/docs/reference/protocols/periodic-report-v0.md index b5a95f6dd2..3463a7f256 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: +every Agent's eligible facts are selectable, and the requesting `agent_id` +ranks that Agent's own outcomes and next action ahead of a peer lane's and an +unclaimed one's, so the audience-item cap cannot evict the reporter's own +progress. 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..4b369f6ef9 100644 --- a/loopx/capabilities/periodic_report/project_progress_snapshot.py +++ b/loopx/capabilities/periodic_report/project_progress_snapshot.py @@ -55,6 +55,21 @@ def _outcome_completed_at( ) +def _reporting_agent_rank(item: Mapping[str, Any], reporting_agent_id: str) -> int: + """Rank one Goal evidence row by its relation to the reporting Agent. + + The report covers the Goal, so every lane's eligible evidence is in scope + and ``claimed_by`` only decides order: the reporting Agent's own rows lead, + a peer's rows follow, and unowned Goal work ranks last instead of being + absent from every lane's report. + """ + + claimed_by = str(item.get("claimed_by") or "").strip() + if not claimed_by: + return 2 + return 0 if claimed_by == reporting_agent_id else 1 + + def build_project_progress_snapshot( *, registry_path: Path, @@ -102,6 +117,11 @@ 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. + 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 @@ -137,11 +157,13 @@ 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 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: _reporting_agent_rank(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 +191,6 @@ 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 not_after_stage(item) and item.get("task_class") != "continuous_monitor" and item.get("action_kind") @@ -178,6 +199,7 @@ def not_after_stage(item: Mapping[str, Any]) -> bool: "repair_periodic_report_intent_consumption", } ] + open_items.sort(key=lambda item: _reporting_agent_rank(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..1f20e90ba9 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,163 @@ 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_agent_outcomes_for_a_shared_goal( + 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", + "todo:todo_unclaimed", + ] + + +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_falls_back_to_peer_then_unowned_todo( + tmp_path: Path, +) -> None: + peer_and_unowned = _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", + ), + ] + ) + with_peer = build_project_progress_snapshot_from_state( + **_snapshot_call(tmp_path, peer_and_unowned) + ) + assert with_peer is not None + assert _next_action_refs(with_peer) == ["todo:todo_peer"] + + unowned_only = 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", + ) + ] + ), + ) + ) + assert unowned_only is not None + assert _next_action_refs(unowned_only) == ["todo:todo_unclaimed"] From 8342ca984f14f733424325205d18f883f207e0ff Mon Sep 17 00:00:00 2001 From: DJC1412 <108855841+DJC1412@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:01:42 +0800 Subject: [PATCH 2/3] test(periodic-report): pin peer progress at the post-writeback hook Covers the aggregation through the shipped projection entry point rather than the snapshot builder alone: a Goal whose lanes split their Todos now reaches the report with both outcomes, ordered reporter-first. Signed-off-by: DJC1412 <108855841+DJC1412@users.noreply.github.com> --- .../test_post_writeback_capability_hooks.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) 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"), + ] From 321b9615f21de25d41e7d3fb5b0753537eea1de8 Mon Sep 17 00:00:00 2001 From: DJC1412 <108855841+DJC1412@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:53:54 +0800 Subject: [PATCH 3/3] fix(periodic-report): keep unclaimed rows out of report evidence Aggregating by Goal also admitted Todos no Agent claimed, which turned an unowned frontier row into a next action. Two pending-intent regressions pin the opposite contract on purpose: an unclaimed advancement row keeps successor ownership in the frontier while the durable progress snapshot stays empty. Peer lanes still report; a row with no producer has no provenance to report. Signed-off-by: DJC1412 <108855841+DJC1412@users.noreply.github.com> --- .../reference/protocols/periodic-report-v0.md | 8 ++-- .../project_progress_snapshot.py | 29 +++++-------- .../test_periodic_report_incremental.py | 43 ++++++------------- 3 files changed, 29 insertions(+), 51 deletions(-) diff --git a/docs/reference/protocols/periodic-report-v0.md b/docs/reference/protocols/periodic-report-v0.md index 3463a7f256..9f61e01cd6 100644 --- a/docs/reference/protocols/periodic-report-v0.md +++ b/docs/reference/protocols/periodic-report-v0.md @@ -291,10 +291,10 @@ drift. 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. The snapshot covers the whole Goal: -every Agent's eligible facts are selectable, and the requesting `agent_id` -ranks that Agent's own outcomes and next action ahead of a peer lane's and an -unclaimed one's, so the audience-item cap cannot evict the reporter's own -progress. Issue Fix has no special standing in either schema. It may register a +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. diff --git a/loopx/capabilities/periodic_report/project_progress_snapshot.py b/loopx/capabilities/periodic_report/project_progress_snapshot.py index 4b369f6ef9..5147f8252b 100644 --- a/loopx/capabilities/periodic_report/project_progress_snapshot.py +++ b/loopx/capabilities/periodic_report/project_progress_snapshot.py @@ -55,21 +55,6 @@ def _outcome_completed_at( ) -def _reporting_agent_rank(item: Mapping[str, Any], reporting_agent_id: str) -> int: - """Rank one Goal evidence row by its relation to the reporting Agent. - - The report covers the Goal, so every lane's eligible evidence is in scope - and ``claimed_by`` only decides order: the reporting Agent's own rows lead, - a peer's rows follow, and unowned Goal work ranks last instead of being - absent from every lane's report. - """ - - claimed_by = str(item.get("claimed_by") or "").strip() - if not claimed_by: - return 2 - return 0 if claimed_by == reporting_agent_id else 1 - - def build_project_progress_snapshot( *, registry_path: Path, @@ -120,7 +105,8 @@ def build_project_progress_snapshot_from_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. + 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 @@ -143,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: @@ -157,13 +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 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: _reporting_agent_rank(item, agent_id)) + 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) @@ -191,6 +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 produced_by(item) and not_after_stage(item) and item.get("task_class") != "continuous_monitor" and item.get("action_kind") @@ -199,7 +192,7 @@ def not_after_stage(item: Mapping[str, Any]) -> bool: "repair_periodic_report_intent_consumption", } ] - open_items.sort(key=lambda item: _reporting_agent_rank(item, agent_id)) + 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 1f20e90ba9..bcbfe995a4 100644 --- a/tests/capabilities/test_periodic_report_incremental.py +++ b/tests/capabilities/test_periodic_report_incremental.py @@ -967,7 +967,7 @@ def _outcome_refs(snapshot: dict[str, object]) -> list[str]: ] -def test_snapshot_reports_peer_agent_outcomes_for_a_shared_goal( +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.""" @@ -998,11 +998,7 @@ def test_snapshot_reports_peer_agent_outcomes_for_a_shared_goal( ) assert snapshot is not None - assert _outcome_refs(snapshot) == [ - "todo:todo_own", - "todo:todo_peer", - "todo:todo_unclaimed", - ] + assert _outcome_refs(snapshot) == ["todo:todo_own", "todo:todo_peer"] def test_snapshot_ranks_peer_outcomes_after_the_reporter_and_honors_the_stage_window( @@ -1079,29 +1075,12 @@ def test_snapshot_outcome_cap_keeps_every_reporting_agent_outcome( assert refs[5:] == ["todo:todo_peer_3"] -def test_snapshot_next_action_falls_back_to_peer_then_unowned_todo( +def test_snapshot_next_action_prefers_the_peer_lane_over_an_unowned_row( tmp_path: Path, ) -> None: - peer_and_unowned = _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", - ), - ] - ) - with_peer = build_project_progress_snapshot_from_state( - **_snapshot_call(tmp_path, peer_and_unowned) - ) - assert with_peer is not None - assert _next_action_refs(with_peer) == ["todo:todo_peer"] + """A row no Agent claimed keeps frontier ownership, and reports nothing.""" - unowned_only = build_project_progress_snapshot_from_state( + snapshot = build_project_progress_snapshot_from_state( **_snapshot_call( tmp_path, _agent_todo_state( @@ -1110,10 +1089,16 @@ def test_snapshot_next_action_falls_back_to_peer_then_unowned_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 unowned_only is not None - assert _next_action_refs(unowned_only) == ["todo:todo_unclaimed"] + + assert snapshot is not None + assert _next_action_refs(snapshot) == ["todo:todo_peer"]