Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions docs/agent-coordination.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,8 @@ The surface, by scope:
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).
It adds/removes the `claimable` label in place, preserving all other labels,
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.
Expand Down
71 changes: 71 additions & 0 deletions tests/test_task_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,26 @@ async def test_held_task_none_after_close(tmp_path):
await s.close()


@pytest.mark.asyncio
async def test_claim_after_close_and_reopen(tmp_path):
"""Red-first: claim -> close -> reopen -> claim by another worker must succeed after fix.

This test verifies that reopen_task properly clears claimed_by/claimed_at,
making a task claimable again after it was claimed, closed, and reopened.
"""
s = await _store(tmp_path)
task = await s.create_task("prj-1", "Task", "alice")
await s.claim_task(task["id"], "worker-1")
await s.close_task(task["id"], "worker-1")
await s.reopen_task(task["id"], "alice")
ok = await s.claim_task(task["id"], "worker-2")
assert ok is True
fetched = await s.get_task(task["id"])
assert fetched["claimed_by"] == "worker-2"
assert fetched["status"] == "claimed"
await s.close()


@pytest.mark.asyncio
async def test_update_task_title(tmp_path):
s = await _store(tmp_path)
Expand Down Expand Up @@ -620,3 +640,54 @@ async def test_quarantine_claimed_task_records_actual_from_status(tmp_path):
finally:
await s.close()
await audit.close()


@pytest.mark.asyncio
async def test_quarantine_unclaimed_task_records_from_status_open(tmp_path):
"""Open-path assertion: unclaimed task -> audit from_status='open'.

This test verifies that quarantine_task records from_status='open' when
called on an unclaimed task, complementing the existing claimed-task test.
"""
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")
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"] == "open"
finally:
await s.close()
await audit.close()


@pytest.mark.asyncio
async def test_quarantine_after_reopen_records_from_status_open(tmp_path):
"""Quarantine-after-reopen records from_status='open'.

This test verifies that quarantine_task records from_status='open' when
called on a reopened task (that was previously claimed, closed, and reopened).
"""
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")
await s.close_task(task["id"], "worker-1")
await s.reopen_task(task["id"], "alice")
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"] == "open"
finally:
await s.close()
await audit.close()
Loading