feat(gateway): C7 synthetic checkpoint on auto-pause

Smoke run 3 showed agents auto-pausing on i_am_idle (correct behavior
for non-terminal tasks) but capturing no checkpoint — panel's
Checkpoints column stayed empty. Pre-gateway parity: the auto-pause
path now writes a synthetic checkpoint summarizing state at pause-time
so the panel reflects reality.

Manual i_will_pause (G8a, deferred) will eventually let agents pass
their own checkpoint_summary; for now this synthetic write covers the
bare i_am_idle case which is what all current agents do.

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
section C7.
This commit is contained in:
Renn F
2026-05-12 05:45:31 +02:00
parent 1ab9ccabd8
commit 89eacf028e
2 changed files with 255 additions and 0 deletions
@@ -2065,14 +2065,54 @@ class Choreographer:
Returns the list of task IDs that were paused (as strings) so Returns the list of task IDs that were paused (as strings) so
``i_am_idle`` can tell the agent which ``resume(task_id)`` calls ``i_am_idle`` can tell the agent which ``resume(task_id)`` calls
await it on the next respawn. Empty list when nothing was active. await it on the next respawn. Empty list when nothing was active.
Wave C7 (2026-05-12) — pre-gateway parity: a synthetic checkpoint is
written for each paused task so the panel's Checkpoints column reflects
reality. Checkpoint failure is swallowed; it must never block the pause.
""" """
in_progress = await self.task.list_in_progress_for_agent(agent_id) in_progress = await self.task.list_in_progress_for_agent(agent_id)
paused_ids: list[str] = [] paused_ids: list[str] = []
for t in in_progress: for t in in_progress:
await self.task.pause_for_agent(agent_id, t.id) await self.task.pause_for_agent(agent_id, t.id)
paused_ids.append(str(t.id)) paused_ids.append(str(t.id))
await self._write_auto_pause_checkpoint(agent_id, t)
return paused_ids return paused_ids
async def _write_auto_pause_checkpoint(
self, agent_id: UUID, task: Any
) -> None:
"""Write a synthetic checkpoint for a task that was auto-paused on i_am_idle.
Wave C7 (2026-05-12) — captures state-at-pause so the panel's
Checkpoints column is never empty after an auto-pause. Agents that
want an explicit checkpoint before idling can call note(scope='note',
text='checkpoint: ...') first; this synthetic write covers the bare
i_am_idle case which is what all current agents do.
Failure is logged and swallowed — the pause already happened and the
caller must not be affected by a checkpoint DB error.
"""
commit_refs = [c.sha for c in (task.commits or [])[-3:]]
commit_count = len(task.commits or [])
state_summary = (
f"auto-paused on i_am_idle (commits: {commit_count})"
)
remaining_work = commit_refs if commit_refs else ["no commits yet"]
try:
await self.task.add_checkpoint(
task_id=task.id,
agent_id=agent_id,
state_summary=state_summary,
remaining_work=remaining_work,
)
except Exception:
log = structlog.get_logger(__name__)
log.warning(
"auto_pause_checkpoint_failed",
task_id=str(task.id),
agent_id=str(agent_id),
)
# --- Phase 2 (QA) verbs moved to ``qa.py`` (audit P2-2). --- # --- Phase 2 (QA) verbs moved to ``qa.py`` (audit P2-2). ---
# --- Phase 3 (documenter + PM) verbs --- # --- Phase 3 (documenter + PM) verbs ---
@@ -0,0 +1,215 @@
"""Wave C7 (2026-05-12): auto-pause on i_am_idle writes a synthetic checkpoint.
Smoke run 3 showed agents auto-pausing on i_am_idle (correct behavior for
non-terminal tasks) but capturing no checkpoint panel's Checkpoints column
stayed empty. Pre-gateway parity: the auto-pause path now writes a synthetic
checkpoint summarizing state at pause-time so the panel reflects reality.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
def _make_deps(**overrides: Any) -> ChoreographerDeps:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base.update(overrides)
repo = base["evidence_repo"]
for method in (
"list_unread_a2a",
"list_unread_mentions",
"list_pending_notifications",
"task_metadata_gaps",
"recent_team_activity",
"blockers_in_lane",
"journal_highlights_for_task",
):
getattr(repo, method).return_value = []
return ChoreographerDeps(**base)
@pytest.mark.asyncio
async def test_i_am_idle_with_in_progress_task_writes_checkpoint() -> None:
"""When i_am_idle auto-pauses an in_progress task, a synthetic checkpoint is
written with the correct task_id, agent_id, and a summary mentioning auto-pause.
"""
agent_id = uuid4()
task_id = uuid4()
task_obj = MagicMock()
task_obj.id = task_id
task_obj.status = "in_progress"
task_obj.assigned_to = agent_id
task_obj.commits = []
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = []
task_svc.list_in_progress_for_agent.return_value = [task_obj]
task_svc.pause_for_agent = AsyncMock()
task_svc.add_checkpoint = AsyncMock()
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
body = env.as_dict()
assert body["error"] is None
assert body["status"] == "idle"
task_svc.add_checkpoint.assert_awaited_once()
call_kwargs = task_svc.add_checkpoint.await_args
assert call_kwargs is not None
# task_id and agent_id must be present
assert call_kwargs.kwargs.get("task_id") == task_id or (
len(call_kwargs.args) >= 1 and call_kwargs.args[0] == task_id
)
second_arg_index = 1
assert call_kwargs.kwargs.get("agent_id") == agent_id or (
len(call_kwargs.args) > second_arg_index
and call_kwargs.args[second_arg_index] == agent_id
)
# Summary must mention auto-pause
state_summary = call_kwargs.kwargs.get("state_summary", "")
assert "auto-pause" in state_summary or "auto_pause" in state_summary
@pytest.mark.asyncio
async def test_i_am_idle_multiple_in_progress_tasks_each_get_checkpoint() -> None:
"""Each auto-paused task gets its own synthetic checkpoint."""
agent_id = uuid4()
task_id_1 = uuid4()
task_id_2 = uuid4()
commit_a = MagicMock()
commit_a.sha = "aaa111"
commit_b = MagicMock()
commit_b.sha = "bbb222"
task_1 = MagicMock()
task_1.id = task_id_1
task_1.status = "in_progress"
task_1.commits = [commit_a, commit_b]
task_2 = MagicMock()
task_2.id = task_id_2
task_2.status = "in_progress"
task_2.commits = []
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = []
task_svc.list_in_progress_for_agent.return_value = [task_1, task_2]
task_svc.pause_for_agent = AsyncMock()
task_svc.add_checkpoint = AsyncMock()
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
await c.i_am_idle(agent_id)
expected_checkpoint_count = 2
assert task_svc.add_checkpoint.await_count == expected_checkpoint_count
called_task_ids = {
kw.kwargs.get("task_id") or kw.args[0]
for kw in task_svc.add_checkpoint.await_args_list
}
assert task_id_1 in called_task_ids
assert task_id_2 in called_task_ids
@pytest.mark.asyncio
async def test_i_am_idle_with_commits_includes_last_three_in_remaining_work() -> None:
"""Checkpoint's remaining_work contains refs for the last 3 commits."""
agent_id = uuid4()
task_id = uuid4()
commits = [MagicMock(sha=f"sha{i}") for i in range(5)]
task_obj = MagicMock()
task_obj.id = task_id
task_obj.status = "in_progress"
task_obj.commits = commits
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = []
task_svc.list_in_progress_for_agent.return_value = [task_obj]
task_svc.pause_for_agent = AsyncMock()
task_svc.add_checkpoint = AsyncMock()
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
await c.i_am_idle(agent_id)
call_kwargs = task_svc.add_checkpoint.await_args
remaining = call_kwargs.kwargs.get("remaining_work", [])
# Last 3 commit SHAs should appear somewhere in remaining_work entries
last_3_shas = {c.sha for c in commits[-3:]}
mentioned_shas = {entry for entry in remaining if isinstance(entry, str)}
assert last_3_shas & mentioned_shas or any(
sha in str(remaining) for sha in last_3_shas
)
@pytest.mark.asyncio
async def test_i_am_idle_checkpoint_failure_does_not_block_auto_pause() -> None:
"""If add_checkpoint raises, the auto-pause and idle response still succeed."""
agent_id = uuid4()
task_id = uuid4()
task_obj = MagicMock()
task_obj.id = task_id
task_obj.status = "in_progress"
task_obj.commits = []
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = []
task_svc.list_in_progress_for_agent.return_value = [task_obj]
task_svc.pause_for_agent = AsyncMock()
task_svc.add_checkpoint = AsyncMock(side_effect=RuntimeError("DB timeout"))
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
body = env.as_dict()
# The idle response must still succeed even though checkpoint write failed
assert body["error"] is None
assert body["status"] == "idle"
# The pause must still have happened
task_svc.pause_for_agent.assert_awaited_once_with(agent_id, task_id)
task_svc.mark_agent_idle.assert_awaited_once()
@pytest.mark.asyncio
async def test_i_am_idle_with_no_active_task_skips_checkpoint() -> None:
"""No active in_progress task → no auto-pause, no checkpoint written."""
agent_id = uuid4()
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = []
task_svc.list_in_progress_for_agent.return_value = []
task_svc.add_checkpoint = AsyncMock()
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
body = env.as_dict()
assert body["error"] is None
assert body["status"] == "idle"
task_svc.add_checkpoint.assert_not_awaited()