Skip to content

BACK-575 - Fail fast instead of silently losing concurrent task edits - #860

Merged
MrLesk merged 4 commits into
mainfrom
tasks/back-575-fail-fast-task-edit-lock
Aug 7, 2026
Merged

BACK-575 - Fail fast instead of silently losing concurrent task edits#860
MrLesk merged 4 commits into
mainfrom
tasks/back-575-fail-fast-task-edit-lock

Conversation

@MrLesk

@MrLesk MrLesk commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Fixes #843.

updateTaskFromInput was an unlocked read-modify-write serving CLI task edit, MCP task_edit, and the web PUT: two concurrent edits of the same task silently lost one write while both reported success (measured: 7 of 8 concurrent writes lost pre-fix).

The edit funnel now runs inside a filesystem-level task lock (proper-lockfile, retries: 0) with an in-lock re-read. On contention the second writer fails fast with Edit failed: TASK-X is being modified by another process; retry if appropriate. — no waiting, no merging, no auto-retry; the caller decides. CLI exits non-zero, the web server returns 409, MCP returns OPERATION_FAILED. The Draft-demotion branch is locked at demoteTaskWithUpdates itself so both its entry points (web funnel and MCP's direct call) are covered. Locks live under backlog/.locks/ (per-checkout, so sibling worktrees never falsely contend); a crashed holder's stale lock auto-breaks after 10s.

Design reference and test-harness shape come from @iRonin's withdrawn PR #852 and the excellent #843 report; semantics differ deliberately (fail-fast per maintainer decision, vs wait-and-re-read).

Verification: 9-test concurrency suite plus a parallel smoke script (8 concurrent real-CLI writers: exactly one winner, losers loud, file content matches the winners exactly); each test confirmed to fail on the unfixed code; kill -9 stale-lock recovery verified; independently reviewed including a live exploit of the demote race, which is now closed. Full suite 1906 pass / 0 fail on the rebased head.

MrLesk added 4 commits August 7, 2026 20:44
updateTaskFromInput was an unlocked read-modify-write shared by CLI task edit,
MCP task_edit and the web PUT handler, so two concurrent edits of one task
silently dropped a write while both callers were told they succeeded.

Adds FileSystem.withTaskLock beside withCreateLock, sharing its proper-lockfile
mechanics through a new withLockTarget. The task lock fails fast (no retries):
on contention the loser gets 'Edit failed: <id> is being modified by another
process; retry if appropriate' and the caller decides whether to retry - nothing
waits, merges or retries automatically. The CLI exits non-zero with that message,
the web PUT returns 409 and MCP task_edit reports OPERATION_FAILED.

The lock target is the task file itself because proper-lockfile keys its
in-process registry by target path. Its lockfile lives under the project's
backlog directory rather than the shared git common dir: an edit protects one
file, so sibling worktrees editing their own copy of a task must not fail each
other, and edits no longer spawn git rev-parse.

The snapshot is re-read inside the lock. Locking only the write would still lose
an update whenever one writer releases before the next acquires, because the
second would apply its changes to a pre-lock snapshot.

Out of scope, left as follow-ups: reorderTask/updateTasksBulk, draft edits via
updateDraftFromInput, and the TUI external-editor write path.

Concurrency proof reported by iRonin in withdrawn PR #852, whose test harness
shape this adapts to fail-fast semantics.

Refs #843
The Draft-status branch of updateTaskFromInput delegated to demoteTaskWithUpdates
before the task lock was taken, so demotion's read-modify-write - apply input to a
pre-lock snapshot, save the draft, unlink the task file - stayed unprotected. A
concurrent edit could be told it succeeded and then have its write erased by the
demote's stale snapshot. Reachable from the web PUT and MCP task_edit, whose
status enum includes 'Draft'; plain CLI task edit rejects 'Draft'.

The lock now lives inside demoteTaskWithUpdates with the same in-lock re-read.
That placement, rather than wrapping the branch in updateTaskFromInput, is what
closes the funnel: editTaskOrDraft calls demoteTaskWithUpdates directly, skipping
updateTaskFromInput entirely, so the MCP path would otherwise stay open. The two
locks never nest.

Inside the task lock the demote waits on the create lock, so the order is always
task lock then create lock. Every withCreateLock body was checked and none takes
a task lock or re-enters the edit funnel, so the order is acyclic.

Also maps ENOENT during lock acquisition - the task file moved or was removed
between snapshot load and lock acquisition - to a clear message instead of a raw
errno, and therefore to 409 / OPERATION_FAILED like other contention.

Tests: the demote race, stretched by holding the create lock so the demote parks
in its draft-id allocation, asserting the edit either fails loudly or survives in
the draft; and the editTaskOrDraft entry point, asserting a held task lock blocks
the demotion and leaves the task file intact.

Refs #843

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e3dd0a43e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/core/backlog.ts
@MrLesk

MrLesk commented Aug 7, 2026

Copy link
Copy Markdown
Owner Author

Alex's agent: We verified this scenario empirically with three schedules: the reported interleaving, strict sequential A-then-B, and the proposed lock-before-read ordering all end in the identical state (labels: []), so the proposed change does not alter the outcome of its own reproduction. B's labels: [] is an explicit replacement applied to the task as re-read inside the lock — nothing from the stale snapshot is written; the result is ordinary last-writer-wins, the same state as if B's request had arrived a moment later with no concurrency at all. (Acquiring the lock "before any lookup" also isn't possible as stated: the lock targets the task file, and resolving the ID to a path is a lookup.)

The lock's contract is fail-fast on overlapping writers plus an in-lock re-read, which guarantees every final state is explainable by a sequential ordering of the operations that reported success, with overlapping losers failing loudly. What remains is a client deciding labels: [] from a stale view and then writing it — that lives at the caller and would need optimistic versioning (e.g. an If-Match/updatedDate precondition on the web PUT), a separate feature rather than a lock-placement change, noted as a follow-up candidate.

@MrLesk
MrLesk merged commit 3b4fab0 into main Aug 7, 2026
10 checks passed
@MrLesk
MrLesk deleted the tasks/back-575-fail-fast-task-edit-lock branch August 7, 2026 20:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: task edit loses concurrent writes silently — #565's lock was never applied to the edit path

1 participant