From 2aabf1336e71c9adc70f8fc3201c5f0e660381c1 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 12 Aug 2026 03:40:26 +0000 Subject: [PATCH 1/2] fix(tasks): unquarantine returns the card to a claimable pool unquarantine_task set status back to 'open' but kept claimed_by, and claim_task requires claimed_by IS NULL - so a claimed-then-quarantined card came back permanently unclaimable. Clear claimed_by/claimed_at on unquarantine, as reopen_task and release_task already do. Card: tsk-y6x6s4 --- changelog.d/tsk-y6x6s4-unquarantine-claimer.md | 8 ++++++++ tests/projects/test_task_store.py | 16 ++++++++++++++++ tinyagentos/projects/task_store.py | 8 +++++--- 3 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 changelog.d/tsk-y6x6s4-unquarantine-claimer.md diff --git a/changelog.d/tsk-y6x6s4-unquarantine-claimer.md b/changelog.d/tsk-y6x6s4-unquarantine-claimer.md new file mode 100644 index 000000000..9b0b9ccce --- /dev/null +++ b/changelog.d/tsk-y6x6s4-unquarantine-claimer.md @@ -0,0 +1,8 @@ +### Fixed + +- **Un-quarantined cards return to a genuinely claimable pool**: + `unquarantine_task` set the card back to `open` but kept the old + `claimed_by`, and `claim_task` requires an unclaimed row -- so a + claimed-then-quarantined card came back permanently unclaimable. + Un-quarantine now clears the claimer, matching `reopen_task` and + `release_task`. diff --git a/tests/projects/test_task_store.py b/tests/projects/test_task_store.py index afaba3e0c..ccaa0083b 100644 --- a/tests/projects/test_task_store.py +++ b/tests/projects/test_task_store.py @@ -152,6 +152,22 @@ async def test_reopen_task_is_noop_when_not_closed(store): assert (await store.get_task(t["id"]))["status"] == "open" +@pytest.mark.asyncio +async def test_unquarantine_returns_claimed_task_to_claimable_pool(store): + t = await store.create_task(project_id="p", title="A", created_by="u") + await store.claim_task(t["id"], claimer_id="agent-1") + assert await store.quarantine_task(t["id"], actor="lead") is True + assert await store.unquarantine_task(t["id"], actor="lead") is True + back = await store.get_task(t["id"]) + assert back["status"] == "open" + # unquarantined task must return to the claimable pool, so the old claimer clears + assert back["claimed_by"] is None + assert back["claimed_at"] is None + # the pool must be real: claim_task requires claimed_by IS NULL, so a stale + # claimer would leave the card open-but-unclaimable forever + assert await store.claim_task(t["id"], claimer_id="agent-2") is True + + @pytest.mark.asyncio async def test_add_relationship_and_list(store): a = await store.create_task(project_id="p", title="A", created_by="u") diff --git a/tinyagentos/projects/task_store.py b/tinyagentos/projects/task_store.py index b71507267..1e9597290 100644 --- a/tinyagentos/projects/task_store.py +++ b/tinyagentos/projects/task_store.py @@ -462,13 +462,15 @@ async def unquarantine_task(self, task_id: str, actor: str) -> bool: This is the explicit un-quarantine / retry action: the card re-enters the ready pool so the fleet may pick it up again. Strikes are cleared - so a fresh failure count starts from zero. Only acts on a quarantined - task; returns False otherwise. + so a fresh failure count starts from zero, and the claimer clears (as + in ``reopen_task``) -- claim_task requires ``claimed_by IS NULL``, so + a stale claimer would leave the card open but unclaimable forever. + Only acts on a quarantined task; returns False otherwise. """ now = time.time() cursor = await self._db.execute( """UPDATE project_tasks - SET status = 'open', updated_at = ? + SET status = 'open', claimed_by = NULL, claimed_at = NULL, updated_at = ? WHERE id = ? AND status = 'quarantined'""", (now, task_id), ) From da43b411780a9a511057a91cd336b9930bde9a65 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 12 Aug 2026 03:58:20 +0000 Subject: [PATCH 2/2] fix(tasks): update_task status='open' also clears the claimer Same class via the generic edit path: the owner/admin PATCH route passes status through update_task, so setting a claimed card back to 'open' left claimed_by set and the card unclaimable. Clear claim metadata and include it in the task.updated patch. Found by review on #2371. --- changelog.d/tsk-y6x6s4-unquarantine-claimer.md | 4 +++- tests/projects/test_task_store.py | 14 ++++++++++++++ tinyagentos/projects/task_store.py | 6 ++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/changelog.d/tsk-y6x6s4-unquarantine-claimer.md b/changelog.d/tsk-y6x6s4-unquarantine-claimer.md index 9b0b9ccce..a53a36070 100644 --- a/changelog.d/tsk-y6x6s4-unquarantine-claimer.md +++ b/changelog.d/tsk-y6x6s4-unquarantine-claimer.md @@ -5,4 +5,6 @@ `claimed_by`, and `claim_task` requires an unclaimed row -- so a claimed-then-quarantined card came back permanently unclaimable. Un-quarantine now clears the claimer, matching `reopen_task` and - `release_task`. + `release_task`. The generic `update_task` edit path (owner/admin PATCH) + had the same gap when setting a claimed card's status back to `open`; + it now clears the claimer too. diff --git a/tests/projects/test_task_store.py b/tests/projects/test_task_store.py index ccaa0083b..d08cdd6f4 100644 --- a/tests/projects/test_task_store.py +++ b/tests/projects/test_task_store.py @@ -168,6 +168,20 @@ async def test_unquarantine_returns_claimed_task_to_claimable_pool(store): assert await store.claim_task(t["id"], claimer_id="agent-2") is True +@pytest.mark.asyncio +async def test_update_task_status_open_returns_task_to_claimable_pool(store): + # Same class via the generic edit path: the owner/admin PATCH route can + # set status='open' on a claimed card directly through update_task. + t = await store.create_task(project_id="p", title="A", created_by="u") + await store.claim_task(t["id"], claimer_id="agent-1") + await store.update_task(t["id"], status="open") + back = await store.get_task(t["id"]) + assert back["status"] == "open" + assert back["claimed_by"] is None + assert back["claimed_at"] is None + assert await store.claim_task(t["id"], claimer_id="agent-2") is True + + @pytest.mark.asyncio async def test_add_relationship_and_list(store): a = await store.create_task(project_id="p", title="A", created_by="u") diff --git a/tinyagentos/projects/task_store.py b/tinyagentos/projects/task_store.py index 1e9597290..cc9c6c277 100644 --- a/tinyagentos/projects/task_store.py +++ b/tinyagentos/projects/task_store.py @@ -287,6 +287,12 @@ async def update_task( patch["element_id"] = element_id if not sets: return + # A generic edit back to 'open' must also clear the claimer (as the + # dedicated to-open transitions do): claim_task requires + # claimed_by IS NULL, so a stale claimer leaves the card unclaimable. + if status == "open": + sets.append("claimed_by = ?"); params.append(None); patch["claimed_by"] = None + sets.append("claimed_at = ?"); params.append(None); patch["claimed_at"] = None sets.append("updated_at = ?"); params.append(time.time()) params.append(task_id) await self._db.execute(