diff --git a/docs/agent-coordination.md b/docs/agent-coordination.md index 9e72a0ce4..3fad449dc 100644 --- a/docs/agent-coordination.md +++ b/docs/agent-coordination.md @@ -229,6 +229,11 @@ The surface, by scope: `POST .../tasks/{id}/(claim|release|close|reopen)`, and `GET /api/projects/tasks/{id}/context`. This is read + lifecycle + comments only. Granting project_tasks also makes the agent a project member. + `POST .../tasks/{id}/claimable` is also reachable, but LEAD-only: the + route (`_authorize_project_lead`) refuses a plain project_tasks worker. + It toggles only the `claimable` label (the fleet-pickup flag), preserving + every other label, so it does not widen the scope into free field edits + (cf. PATCH). `POST .../tasks/{id}/unquarantine` is also reachable, but LEAD-only: the route (`_authorize_project_lead`) refuses a plain project_tasks worker. It returns a quarantined card to the open pool and clears its strikes. diff --git a/tests/test_task_store.py b/tests/test_task_store.py index b20b8719e..61331f44f 100644 --- a/tests/test_task_store.py +++ b/tests/test_task_store.py @@ -2,6 +2,7 @@ import pytest +from tinyagentos.board_audit import BoardAuditLog from tinyagentos.projects import task_store as task_store_mod from tinyagentos.projects.task_store import ProjectTaskStore @@ -599,3 +600,23 @@ async def test_no_broker_no_error(tmp_path): await s.claim_task(task["id"], "worker-1") await s.close_task(task["id"], "worker-1") await s.close() + + +@pytest.mark.asyncio +async def test_quarantine_claimed_task_records_actual_from_status(tmp_path): + audit = BoardAuditLog(tmp_path / "audit.db") + await audit.init() + s = ProjectTaskStore(tmp_path / "tasks.db", audit=audit) + await s.init() + try: + task = await s.create_task("prj-1", "Task", "alice") + await s.claim_task(task["id"], "worker-1") + ok = await s.quarantine_task(task["id"], "system") + assert ok is True + history = await audit.history(task["id"]) + quarantined = [h for h in history if h["event"] == "task.quarantined"] + assert len(quarantined) == 1 + assert quarantined[0]["from_status"] == "claimed" + finally: + await s.close() + await audit.close() diff --git a/tinyagentos/projects/task_store.py b/tinyagentos/projects/task_store.py index fd039fcd9..b71507267 100644 --- a/tinyagentos/projects/task_store.py +++ b/tinyagentos/projects/task_store.py @@ -446,8 +446,13 @@ async def quarantine_task(self, task_id: str, actor: str) -> bool: "task.quarantined", {"id": task_id, "actor": actor}, ) + # Derive the pre-quarantine status race-free from the committed row + # rather than a separate pre-read (which would have a TOCTOU gap). + # quarantine does not clear claimed_by, so a set claimer means it was + # 'claimed' (cf. close_task's derivation). + from_status = "claimed" if existing and existing.get("claimed_by") else "open" await self._record_audit( - task_id, "task.quarantined", actor, "open", "quarantined", + task_id, "task.quarantined", actor, from_status, "quarantined", project_id=existing["project_id"] if existing else "", ) return changed