Description
Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment on this issue.
Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: 0cbab9084d364946a62fcc0a24fb58d3
Branch: dev/1-track-tasks-rollback
Base: f2ef4df
At: 2026-11-10T00:42:08Z
Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment.
Written-by: FlyBot Lead
Bot: FlyBot Product
rt.trackBotTasks (core/src/host/tasks.js:1123 at f2ef4dfa) commits the whole staged batch into state.tasks before it calls await save(), so a store write that is refused leaves cards in memory the store never took — and the next unrelated write persists them. #2597 fixed the other half (a batch refused by the list validation, before it reaches save()); this is the storage-refusal half.
What happens
At the end of a batch, :1299-1300 commit staged into board (State.tasks), :1301 calls announceClaim() (the room line), and :1302 is await save(). save() (core/src/host/store.js:999-1052) throws on every refusal — tempWritable (:1022), ELOCKLOST (:1028-1031), a failed stage/rename — and nothing restores the in-memory batch.
Probe (probe/tasks-refused-write.mjs, soft link state.json.<pid>.tmp → a file outside the store): track answers Not saved: …, but board already shows Ghost card while disk is []; after the link is moved away, one unrelated track_tasks writes both cards. The link target stays untouched (OUTSIDE\n), so the refusal happens before any byte lands.
Reachable on the ordinary path: tempWritable refusal, ELOCKLOST (multi-window is normal), ENOSPC/EACCES. track_tasks is the tool every team round uses.
Expected
- After a refused save,
state.tasksis byte-identical to the call's start (the same byte-identical standard track_tasks creates the first card before refusing the second item of the batch #2597 set), and a remount reads no card from the batch. - The claim line is not posted when the save is refused:
postClaimLineis called zero times on refusal, exactly once on success. revisionstays monotonic — the rollback does not rewind it.
Scope
core/src/host/tasks.js + core/test/track-tasks-batch-refusal.test.js. Five save points share the same helper:
- main path
:1299-1302(announceClaimbefore:1302 await save()); claimQueued:1025-1035(announceClaimat:1034);claimHandoff:1067-1076(announceClaimat:1075);returnToProducer:1094-1095(openReworkmutates first);releaseQueued:1115-1116(requeuemutates first).
Implementation constraint: the batch is staged on staged = structuredClone(board) (:1163-1164), so the commit only moves references and key sets — a shallow { ...board } snapshot is enough; no deep clone. Restore = delete keys not in the snapshot, write back every old reference. announceClaim moves to after a successful save. Do not touch the client.
Tests that should exist (7, all red at f2ef4dfa)
- Main path refused:
/Not saved/rejects,JSON.stringify(rt.state.tasks)is unchanged,saves.length === 1. - After the refusal,
Object.keys(rt.state.tasks)is empty and a freshtrackBotTasks('d1', {})answers[]. - Move the link away, write
Unrelated card:state.jsonholds only that card. - Claim-line contract: refused →
postClaimLinezero calls; success → exactly one. claim: truerefused: card still open,claimedByunset,state.tasksunchanged, room line unchanged,postClaimLinezero calls.releaserefused: card still claimed and has noretryAt.reworkrefused: no child card, original card unchanged.
Dedupe
Open issues/PRs for this face: 0 (ghost card, rollback tasks, track_tasks refused, Not saved). #2597 and its PR #2602 are closed and covered only the validation-refusal half before save(); this is the store-refusal half on the same batch.
Not claimed: any UI change, and anything about track_tasks validation (that is #2597's, closed).
Activity
track_tasks: a refused batch save leaves the board mutated, so a ghos…