Filed so a withdrawn PR blocker doesn't evaporate. #1023 closed the rescue sweep's version of this race; the route-level hole is still open for every other caller.
The gap
PATCH /:podId/:taskId is findOneAndUpdate({ podId, taskId }, update) (tasksApi.ts) — no status or lease precondition. Any caller can write {status: 'pending', assignee: null} over a task whose holder renewed its lease microseconds earlier. The lease is enforced in the claim CAS and nowhere else.
Why it isn't closed today
#1023's rescue step claims the task before rewriting it, so commonly_claim_task's CAS arbitrates: a renewal produces a 409 and the sweep moves on. That genuinely closes the window for theo, and it uses a compare-and-set that already exists rather than adding one.
But it closes it by instruction, not by construction. The interlock lives in presets.ts heartbeat text — a model reading a status code and choosing to stop. It protects exactly one caller following exactly one prompt. It does not protect:
- a backend reaper (the intended long-term owner of rescue-to-pending)
- an MCP seat — note
commonly_update_task there is a different tool that only appends a note, so this is latent rather than live
- a human or ops script doing the same triage by hand
- any future preset that reassigns work
Proposed fix (sprint-review's, credited)
An opt-in precondition on PATCH that ANDs claimableConditions (exported by #1022) into the match, so a rescue write arriving after a legitimate renewal matches nothing and no-ops. Opt-in, because a blanket CAS would break ordinary edits to held tasks — a title change on a live-claimed row must still work.
Do not infer it from the update's shape. Treating {status: 'pending', assignee: cleared} as implicitly meaning "rescue" would silently no-op a human unassigning a held task, with no error explaining why.
Delivery note
The openclaw gateway's commonly_update_task accepts exactly podId, taskId, assignee, status, dep, prUrl, notes, title — no precondition slot. So a moltbot cannot send the flag without an extension change, a submodule pin bump and a gateway rebuild. That is fine: the intended consumer is the reaper, which calls the route directly.
Not verified
Whether any current non-preset caller performs a rescue-shaped write. I believe none does today, which is why this is a latent hole rather than a live bug — but that is an argument for fixing it before the reaper exists, not after.
Filed so a withdrawn PR blocker doesn't evaporate. #1023 closed the rescue sweep's version of this race; the route-level hole is still open for every other caller.
The gap
PATCH /:podId/:taskIdisfindOneAndUpdate({ podId, taskId }, update)(tasksApi.ts) — no status or lease precondition. Any caller can write{status: 'pending', assignee: null}over a task whose holder renewed its lease microseconds earlier. The lease is enforced in the claim CAS and nowhere else.Why it isn't closed today
#1023's rescue step claims the task before rewriting it, so
commonly_claim_task's CAS arbitrates: a renewal produces a 409 and the sweep moves on. That genuinely closes the window for theo, and it uses a compare-and-set that already exists rather than adding one.But it closes it by instruction, not by construction. The interlock lives in
presets.tsheartbeat text — a model reading a status code and choosing to stop. It protects exactly one caller following exactly one prompt. It does not protect:commonly_update_taskthere is a different tool that only appends a note, so this is latent rather than liveProposed fix (sprint-review's, credited)
An opt-in precondition on PATCH that ANDs
claimableConditions(exported by #1022) into the match, so a rescue write arriving after a legitimate renewal matches nothing and no-ops. Opt-in, because a blanket CAS would break ordinary edits to held tasks — a title change on a live-claimed row must still work.Do not infer it from the update's shape. Treating
{status: 'pending', assignee: cleared}as implicitly meaning "rescue" would silently no-op a human unassigning a held task, with no error explaining why.Delivery note
The openclaw gateway's
commonly_update_taskaccepts exactlypodId, taskId, assignee, status, dep, prUrl, notes, title— no precondition slot. So a moltbot cannot send the flag without an extension change, a submodule pin bump and a gateway rebuild. That is fine: the intended consumer is the reaper, which calls the route directly.Not verified
Whether any current non-preset caller performs a rescue-shaped write. I believe none does today, which is why this is a latent hole rather than a live bug — but that is an argument for fixing it before the reaper exists, not after.