Skip to content

PATCH /api/v1/tasks/:podId/:taskId can clobber a live lease — the guard is in a prompt, not the route #1025

Description

@lilyshen0722

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions