fix: read the real commit key (hash) and audit the restore-unblock path

- The auto-pause checkpoint and _extract_first_commit_sha read commit dicts by
  key 'sha', but persisted commits are keyed 'hash' (CommitRef.hash) — the prior
  change stopped the crash but silently dropped every ref. Read 'hash' (sha
  fallback) at both sites; the test now uses the production dict shape so the
  regression can't hide.
- unblock_with_restore set status directly and skipped the audit log; emit the
  status-transition audit there too, like the other direct-set paths.
This commit is contained in:
Renn F
2026-06-08 02:45:03 +02:00
parent f58fd4530e
commit 3c02dc7f03
4 changed files with 61 additions and 12 deletions
@@ -144,7 +144,10 @@ async def test_i_am_idle_with_commits_includes_last_three_in_remaining_work() ->
agent_id = uuid4()
task_id = uuid4()
commits = [MagicMock(sha=f"sha{i}") for i in range(5)]
# Production shape: task.commits is a JSON list[dict] keyed by `hash`
# (CommitRef.hash) — NOT `sha`. A prior fix read `sha` and silently lost
# every ref; this test uses the real shape so the regression can't hide.
commits = [{"hash": f"hash{i}", "message": f"c{i}"} for i in range(5)]
task_obj = MagicMock()
task_obj.id = task_id
task_obj.status = "in_progress"
@@ -163,12 +166,10 @@ async def test_i_am_idle_with_commits_includes_last_three_in_remaining_work() ->
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
)
# Last 3 commit hashes (the real persisted key) must appear in remaining_work.
last_3 = {c["hash"] for c in commits[-3:]}
mentioned = {entry for entry in remaining if isinstance(entry, str)}
assert last_3 & mentioned or any(h in str(remaining) for h in last_3)
@pytest.mark.asyncio
@@ -393,3 +393,32 @@ async def test_apply_escalation_emits_blocked_audit_event() -> None:
assert kwargs["event_type"] == "task.blocked"
assert kwargs["details"]["from_status"] == "in_progress"
assert kwargs["details"]["to_status"] == "blocked"
@pytest.mark.asyncio
async def test_unblock_with_restore_emits_audit_event() -> None:
"""The PM restore path sets status directly (bypassing the validated
transition) and used to skip the audit log; it must record the transition."""
svc = _service()
task = MagicMock(
id=uuid4(),
status=TaskStatus.BLOCKED,
pre_block_state="in_progress",
pre_block_assignee=None,
claimed_by=uuid4(),
)
_bind(svc, "get", AsyncMock(return_value=task))
audit_mock = MagicMock(log_task_event=AsyncMock())
with patch("roboco.services.audit.get_audit_service", return_value=audit_mock):
await svc.unblock_with_restore(uuid4(), uuid4(), restore=True)
pending = list(svc._background_tasks)
if pending:
await asyncio.gather(*pending, return_exceptions=True)
assert task.status == TaskStatus.IN_PROGRESS
audit_mock.log_task_event.assert_awaited_once()
kwargs = audit_mock.log_task_event.await_args.kwargs
assert kwargs["event_type"] == "task.in_progress"
assert kwargs["details"]["from_status"] == "blocked"
assert kwargs["details"]["to_status"] == "in_progress"