[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"
# ---------------------------------------------------------------------------
@@ -130,5 +130,48 @@ def test_first_release_emits_no_version_ref_gap_for_planned_files(
assert not any(g.category == "version_ref" for g in report.gaps)
def test_canonical_bump_files_ignores_body_only_chore_release_match(
tmp_path: Path,
) -> None:
"""A non-release commit whose message BODY references ``chore(release):``
(a body line starting with ``chore(release):``) must NOT be misidentified
as the last release commit.
``git log --grep "^chore(release):"`` matches ANY message line, so a
fix/docs commit that explains the release process (body line
``chore(release): the canonical set ...``) is matched and being newer
shadows the real release commit. The canonical set must come from the
commit whose SUBJECT starts with ``chore(release):`` (the real release
shape ``chore(release): X.Y.Z``), not a body-only match. (Regression: an
earlier fix commit's body referencing ``chore(release):`` was picked up,
so the bump plan listed that fix's files instead of the release's.)
"""
root = _first_release_repo(tmp_path)
# Real release commit (older) touching the version-embedding file + marker.
(root / "RELEASE_MARKER.txt").write_text("released\n", encoding="utf-8")
(root / "pyproject.toml").write_text('version = "0.2.0"\n', encoding="utf-8")
_git(root, "add", "-A")
_git(root, "commit", "-m", "chore(release): 0.2.0")
# A NEWER non-release commit whose BODY has a line starting with the
# release-commit prefix.
(root / "other.py").write_text("x = 1\n", encoding="utf-8")
_git(root, "add", "-A")
_git(
root,
"commit",
"-m",
"fix: tighten release-readiness derivation",
"-m",
"chore(release): the canonical set derives from this commit type",
)
files = _canonical_bump_files(root, "0.2.0")
# The canonical set is the REAL release commit's files...
assert "RELEASE_MARKER.txt" in files
assert "pyproject.toml" in files
# ...NOT the body-only-matching fix commit's file.
assert "other.py" not in files
if __name__ == "__main__":
pytest.main([__file__, "-q"])
+50
View File
@@ -14,6 +14,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.db.tables import AuditLogTable
from roboco.models.base import (
AgentRole,
AgentStatus,
@@ -1117,6 +1118,55 @@ async def test_finalize_claim_rollback_emits_reversal_audit() -> None:
assert {"from": "claimed", "to": "pending"} in audit_calls
@pytest.mark.asyncio
async def test_emit_status_transition_audit_writes_in_session_atomically() -> None:
"""F061/F073/F075: the status-transition audit row is written into the
CALLER's session (same transaction as the transition), not fire-and-forget
on a separate connection.
Fire-and-forget decouples the audit commit from the transition commit:
a transition that rolls back inside a verb savepoint leaves a PHANTOM audit
row (F075), and a swallowed persist failure means a committed transition
can have NO audit row (F073) silently corrupting the cycle-time /
bottleneck metrics reconstructed from ``task.<status>`` events (F061).
Writing the row in-session makes it commit/roll back atomically with the
transition, closing all three. Asserted at the unit level: the row is
``session.add``-ed (same txn) with the metric-reconstruction details, and
NO fire-and-forget background task is spawned.
"""
svc = TaskService(MagicMock())
added: list[object] = []
svc.session.add = MagicMock(side_effect=added.append) # type: ignore[assignment]
prior_bg = set(svc._background_tasks)
task = MagicMock(id=uuid4(), claimed_by=uuid4(), team=Team.BACKEND)
svc._emit_status_transition_audit(
task,
from_status="pending",
to_status="claimed",
agent_role="developer",
audit_agent_id=None,
)
# The audit row is added to the CALLER's session (same transaction) — not
# dispatched to a separate fire-and-forget connection.
rows = [r for r in added if isinstance(r, AuditLogTable)]
assert len(rows) == 1
row = rows[0]
assert row.event_type == "task.claimed"
assert row.target_type == "task"
assert row.target_id == task.id
assert row.details["from_status"] == "pending"
assert row.details["to_status"] == "claimed"
assert row.details["agent_role"] == "developer"
assert row.details["team"] == "backend"
# The claiming agent is attributed (resolved from claimed_by).
assert row.agent_id == task.claimed_by
# No fire-and-forget audit task was spawned (the old decoupled path).
assert svc._background_tasks == prior_bg
# ---------------------------------------------------------------------------
# _resolve_doc_abspath — normalize documenter-supplied paths under /app/docs
# ---------------------------------------------------------------------------