Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions docs/reference/protocols/periodic-report-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions loopx/capabilities/periodic_report/project_progress_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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(
Expand Down
147 changes: 146 additions & 1 deletion tests/capabilities/test_periodic_report_incremental.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"]
42 changes: 42 additions & 0 deletions tests/control_plane/test_post_writeback_capability_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<!-- loopx:todo status=done task_class=advancement_task claimed_by=agent-1 updated_at=2026-08-30T10:10:00Z -->
- [x] Land a peer lane's change.
<!-- loopx:todo status=done task_class=advancement_task claimed_by=agent-2 updated_at=2026-08-30T10:20:00Z -->
- [ ] Continue the reporting lane's next step.
<!-- loopx:todo status=open task_class=advancement_task claimed_by=agent-1 -->
""",
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"),
]