[F061] audit status-transition rows now written in-session (F061/F073/F075)

_emit_status_transition_audit now writes AuditLogTable rows into
self.session synchronously (session.add) instead of dispatching
AuditService.log_task_event fire-and-forget on its own connection.

The audit row now commits/rolls back atomically with the status
transition in the caller's transaction, closing three facets at once:
- F061: audit commit no longer decoupled from the transition commit
- F073: a committed transition can no longer have NO audit row
  (the row rides the same transaction; a swallowed persist can't drop it)
- F075: a transition rolled back inside a verb savepoint no longer
  leaves a phantom audit row (the row is in the savepoint too)

log_task_event is now called only from this helper (narrow blast
radius verified); revision_count increment stays at this single
chokepoint. Cycle-time/bottleneck reconstruction from task.<status>
events is no longer silently corruptible.

Tests: test_emit_status_transition_audit_writes_in_session_atomically,
test_finalize_claim_rollback_emits_reversal_audit, escalation-audit
tests retargeted to in-session AuditLogTable rows.

Also: _canonical_bump_files grep-looseness follow-on (F058) -- filter
by subject, not body; git log --grep matches any message line, so a
non-release commit whose body references chore(release): shadowed the
real release commit. Test
test_canonical_bump_files_ignores_body_only_chore_release_match.
This commit is contained in:
Renn F
2026-06-28 16:32:31 +02:00
parent 532261fed6
commit 7f63e50623
5 changed files with 192 additions and 62 deletions
@@ -12,11 +12,11 @@ stranded on a board role.
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.db.tables import AuditLogTable
from roboco.models.base import AgentRole, TaskStatus, TaskType, Team
from roboco.services.task import (
TaskService,
@@ -528,8 +528,14 @@ async def test_apply_escalation_blocks_non_terminal_task() -> None:
async def test_apply_escalation_emits_blocked_audit_event() -> None:
"""A non-divert escalation sets BLOCKED and MUST record a task.blocked audit
row. The escalate path sets status directly (bypassing the validated
transition), and used to skip the audit log entirely."""
transition), and used to skip the audit log entirely.
The audit row is written into the caller's session (F061/F073/F075: it
commits atomically with the transition, not fire-and-forget on a separate
connection), so we assert on the ``AuditLogTable`` added to the session."""
svc = _service()
added: list[object] = []
svc.session.add = MagicMock(side_effect=added.append) # type: ignore[assignment]
task = MagicMock(
id=uuid4(),
parent_task_id=uuid4(),
@@ -542,56 +548,52 @@ async def test_apply_escalation_emits_blocked_audit_event() -> None:
status=TaskStatus.IN_PROGRESS,
)
_bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=False))
audit_mock = MagicMock(log_task_event=AsyncMock())
with patch("roboco.services.audit.get_audit_service", return_value=audit_mock):
await svc.apply_escalation(
task=task,
target_agent_id=uuid4(),
escalator_slug="be-pm",
target_slug="main-pm",
reason="needs a decision",
)
# Drain the fire-and-forget audit task so the assertion sees the call.
pending = list(svc._background_tasks)
if pending:
await asyncio.gather(*pending, return_exceptions=True)
await svc.apply_escalation(
task=task,
target_agent_id=uuid4(),
escalator_slug="be-pm",
target_slug="main-pm",
reason="needs a decision",
)
assert task.status == TaskStatus.BLOCKED
audit_mock.log_task_event.assert_awaited_once()
kwargs = audit_mock.log_task_event.await_args.kwargs
assert kwargs["event_type"] == "task.blocked"
assert kwargs["details"]["from_status"] == "in_progress"
assert kwargs["details"]["to_status"] == "blocked"
rows = [r for r in added if isinstance(r, AuditLogTable)]
assert any(r.event_type == "task.blocked" for r in rows)
blocked = next(r for r in rows if r.event_type == "task.blocked")
assert blocked.details["from_status"] == "in_progress"
assert blocked.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."""
transition) and used to skip the audit log; it must record the transition.
The audit row is written into the caller's session (F061/F073/F075: atomic
with the transition, not fire-and-forget), so we assert on the
``AuditLogTable`` added to the session."""
svc = _service()
added: list[object] = []
svc.session.add = MagicMock(side_effect=added.append) # type: ignore[assignment]
task = MagicMock(
id=uuid4(),
status=TaskStatus.BLOCKED,
pre_block_state="in_progress",
pre_block_assignee=None,
claimed_by=uuid4(),
team=Team.BACKEND,
)
_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)
await svc.unblock_with_restore(uuid4(), uuid4(), restore=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"
rows = [r for r in added if isinstance(r, AuditLogTable)]
assert any(r.event_type == "task.in_progress" for r in rows)
restored = next(r for r in rows if r.event_type == "task.in_progress")
assert restored.details["from_status"] == "blocked"
assert restored.details["to_status"] == "in_progress"
# ---------------------------------------------------------------------------