mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
@@ -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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user