Skip to content

feat(items): idempotency keys for patch writes (TASK-3585) - #2062

Merged
xarmian merged 2 commits into
mainfrom
rook/task-3585
Oct 11, 2026
Merged

xarmian merged 2 commits into
mainfrom
rook/task-3585

Conversation

@xarmian

@xarmian xarmian commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator

TASK-3585: idempotency keys for patch writes

As ruled on TASK-3585 (lead, day 91: all six asks, plus the §5 correction). Patch first; create, append, copy and push follow in their own PRs.

Mechanism

  • The key: an Idempotency-Key header, or op_key in the body (excluded from the request hash).
  • Claimed per (workspace, user, key) BEFORE any write-path guard, so a retry of a committed write replays it instead of meeting its own success as a conflict. The claim holds a 60s lease.
  • Recorded atomically: done and the answer are written INSIDE the write's own transaction (ItemUpdate.AfterWriteTx), guarded by the claim token. done therefore holds exactly when the write committed. A claim whose lease expired committed nothing, so a retry takes it over with a new token, and the stale writer's late completion matches no row, which rolls its write back.
  • Answers:
    • the same request again → the stored answer, with Idempotent-Replayed: true;
    • another request under the key → 422 idempotency_key_reused;
    • a live claim → 409 idempotency_in_flight with Retry-After;
    • a refused write releases the key (refusals are not stored).

The unknown outcome (lead's §5 correction)

  • A patch that reached an open editor and was never confirmed records its patch_id and a boot-scoped reference to that connection. Connection ids restart with the process, so a bare id could name another tab after a restart.
  • A retry goes ONLY to that connection (ApplyExternalPatchOn). Its tab remembers the last 32 patch ids it applied, recorded even when its own ack was too late, and re-acks a repeat instead of applying it again.
  • With that connection gone: 409 patch_outcome_unknown with seq, base_seq and the diff since. Nothing is re-planned, because an insert-shaped edit keeps its anchor elsewhere.
  • Builds on BUG-3586 (fix(collab): a patch whose applier times out is not re-sent to another tab (BUG-3586) #2059): a timed-out patch is never re-sent to another tab.

Storage

idempotency_keys:

  • migrations 139 (SQLite) and 110 (PG), with the generated NUL trigger file 140;
  • key, response and base_body are NUL-protected; ScanNUL figures 142/27;
  • a fixed-width time format, because the lease and TTL compare as text and RFC3339Nano would sort wrongly;
  • a workspace purge row, and a 24h sweep on the maintenance tick. Not exported.

Surfaces

  • CLI: a key per invocation (--op-key overrides), and ONE same-key retry on a transport error or 5xx, never on a 4xx.
  • MCP 0.89 → 0.90 (ADDITIVE): op_key, plus three allow-listed codes with hints shared and parity-tested. A reused key comes back as its own code, not validation_failed.
  • Capability: idempotency_keys. Docs: api.md, mcp.md, instructions.md, both SKILL.md copies.

Tests (each shown red by a mutant)

  • server:
    • an insert-shaped replay applies once;
    • reuse gives 422; a refusal releases the key; in-flight;
    • the unknown outcome resolves through the same tab (applied once);
    • the unknown outcome with the tab gone is not re-planned, and the other tab is sent nothing.
    • Mutants: no claim; retry on any tab; unknown not recorded.
  • store:
    • claim / complete / replay / mismatch / per-user;
    • the kill-between-claim-and-write takeover (lead's 2): the stale writer is refused both while the new claim is pending and after it completes. A first version missed the pending ordering; the token-guard mutant survived it until the test was fixed.
    • release / unknown / sweep.
    • Mutants: the completion token guard; the takeover lease check.
  • provider: re-acks an applied patch; remembers one it applied too late to ack.
  • CLI: a 5xx is retried once with the same key; a 4xx is not; a new key per invocation.
  • MCP: replay over HTTP; the reused code; hint parity.

Gates (local, on the pushed head 8fc941e, rebased onto 78729db)

https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

…resolves only through its own tab (TASK-3585)

A patch may carry an idempotency key (Idempotency-Key header, or op_key).
The key is claimed per (workspace, user, key) before any write-path guard,
with a lease, and its answer is recorded inside the write's own
transaction, guarded by the claim token: done holds exactly when the write
committed, so an expired claim committed nothing and may be taken over. A
retry of the same request replays the answer (Idempotent-Replayed); a key
reused for another request is 422; a live claim is 409 in flight; a
refused write releases the key.

When an open editor received the patch and never confirmed it, the key
records the patch id and a boot-scoped reference to that connection. A
retry goes only to that connection, whose tab remembers the patches it
applied and re-acks one it is sent again; with it gone the answer is
patch_outcome_unknown with the diff since, and nothing is re-planned, since
an insert-shaped edit would keep its anchor elsewhere.

The CLI sends a key per invocation and retries once with it on a
transport error or 5xx. MCP 0.90 adds op_key and three codes.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
…ency_keys (TASK-3585)

An older server ignores op_key, so the CLI's single same-key retry of a
5xx could apply a patch twice against a server that serves patch but not
keys. The retry now requires the idempotency_keys capability.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
@xarmian
xarmian merged commit 7ec0191 into main Oct 11, 2026
12 checks passed
@xarmian
xarmian deleted the rook/task-3585 branch October 11, 2026 15:33
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.

1 participant