## Summary
Makes Linear the claim of record for drone dispatch, so two firing hosts — the operator's laptop and the EC2 orchestrator on its timer — cannot fire two implementers at the same drone-ready ticket. Before firing, the dispatcher atomically claims the ticket in Linear (removes drone-ready, moves state to an in-flight state), then writes lifecycle state back as the work progresses (PR link on open, comment on park, Done on merge). Linear's native blocks relations now gate selection alongside frontmatter depends_on.
This is a claim, not a mutex — deliberately. Linear has no compare-and-swap, so the sequence is read-then-write with a residual race window between the re-read and the issueUpdate. The window is narrowed by claiming as late as possible (immediately before fire, after the per-host filesystem lock) and re-reading as close to the write as possible. A losing host observes the ticket is no longer ready and skips with an accounted reason rather than firing anyway. That residual race is stated in the code (claimTicketForFire, src/linear-api.ts:830-844) and is stated here rather than papered over — overstating it would be worse than the race itself, because the next change would be built on the overstatement.
Closes AI-203. Folds in the scope of the cancelled AI-206 (Linear blockedBy gating).
## Why It's Needed
src/dispatch-lock.ts closes the AI-152 double-fire TOCTOU with a filesystem lock (mkdir under locks/ as the atomic acquire). That is correct and sufficient for one host — and as of the EC2 orchestrator it became false comfort: two hosts, two locks/ directories, one Linear queue. Nothing prevented both from selecting the same ticket in the same window. The harness has already paid for a double-fire once (three concurrent addressers on one PR → triple commits + 32 duplicate replies); the new failure mode is the same thing happening unattended, overnight.
The write-back half is the same gap seen from the queue's side: nothing wrote back to Linear. A ticket stayed drone-ready while its implementer ran, while its PR sat in review, and after its PR merged — so an operator away for 12 hours with a 4-hourly timer got three polls over one ticket. The already-fired preflight is a local snapshot over local receipts, so it cannot see another host's fires at all.
The claim and the write-back are one mechanism, which is why they are one PR: claiming *is* a write-back (state → in-flight, label removed), and the later transitions are the rest of that lifecycle. A claim without the lifecycle leaves tickets claimed forever; a lifecycle without the claim leaves the race open.
## Changes
Linear mutation surface (src/linear-api.ts, +1088)
- claimTicketForFire / releaseTicketClaim — the cross-host claim and best-effort release when a fire fails after claiming.
- writeBackPrLink / writeBackParkComment / writeBackIssueDone — the lifecycle transitions, each idempotent (attachment de-dupe, an HTML-comment park marker, and a terminal-state short-circuit respectively).
- updateIssue — the single issueUpdate mutation path (state + label add/remove), with 429/5xx backoff in runGraphQL.
- Blockers come from inverseRelations(type: blocks), not Issue.blockedBy — that field does not exist on Linear's Issue type. pageInfo.hasNextPage is surfaced so a truncated relation page fails *closed* instead of silently reporting "no blockers".
- isLinearWorkflowTerminal is the one terminal-state predicate shared by the gates. It encodes a workspace quirk: the state named Blocked is typed canceled, so it stays *unresolved* while other canceled / duplicate / completed states clear the gate.
- Default in-flight state is Today (DEFAULT_IN_FLIGHT_STATE) — Builder teams have no In Progress state. Fallback is the lowest-position type=started state, then label-only.
- probeIssueFieldsSchema — a live probe so a schema drift in the shared ISSUE_FIELDS selection is caught by doctor rather than by every Linear read failing at once.
Dispatch (src/dispatcher.ts, +627)
- Claim is issued immediately before fire, and after the task spec is parsed — a parse failure cannot strand a ticket claimed-but-never-fired.
- Filesystem lock retained as the intra-host first gate; Linear is the cross-host authority layered on top.
- Receipt accounting is recomputed after runtime claim skips so the documented polled === selected + skipped invariant holds.
- Write-back details pass through the key-aware redactor before reaching console or receipt sinks.
New skip reasons, all members of the closed DispatchSkipReason union and present in the AI-159 histogram: in-flight, claim-lost, claim-failed, blockers-unavailable. claim-failed and blockers-unavailable set exitCode = 1 — a workspace-wide Linear read failure must not render as "everything is legitimately blocked, exit 0".
Supporting: src/redact.ts (new — sanitizeWithLinearKey extracted so post-merge does not import the dispatch module); --in-flight-state / --done-state flags plus DRONES_IN_FLIGHT_STATE / DRONES_DONE_STATE env defaults; doctor probes the schema and warns when the configured in-flight state is missing on the team; post-merge Done write-back hoisted out of the !alreadyTerminal guard so a re-run retries a failed transition.
### Contract surface
| Signature | Change | Consumers |
|---|---|---|
| DispatchSkipReason / DISPATCH_SKIP_REASONS | +4 members (in-flight, claim-lost, claim-failed, blockers-unavailable) | buildDispatchAccounting, skip histogram, DispatchReceipt.skipped[], guidelines/dispatch-scheduled-runner.md |
| LinearIssue | +blockedBy, blockedByTruncated, stateType, labels | fetchIssue, fetchReadyIssues, selectDispatchable, evaluateGates |
| LinearDispatchState | blockedBy distinguishes unknown from empty (opposite gate outcomes) | evaluateGates, fetchLinearState injection seam |
| ISSUE_FIELDS | shared selection; inverseRelations replaces invalid blockedBy | fetchIssue, fetchReadyIssues, probeIssueFieldsSchema |
| PostMergeInput | +linearApiKey, +doneState, linearDone on result; exitCode 1 on failed write-back | drones post-merge, CI recovery re-runs |
| sanitizeWithLinearKey | moved dispatcher.ts → src/redact.ts | dispatcher.ts, post-merge.ts |
## Breaking Changes
None to existing invocations. Every new flag has a default that preserves current behaviour, and the filesystem lock is unchanged.
Two operational notes that are not code-breaking but change what an operator must know:
1. LINEAR_API_KEY now needs issue-write scope. It previously only read issues and created comments/attachments; it now mutates workflow state and removes labels on any issue it can reach. A leaked key is no longer read-only. .env.example and the README security-posture section are updated to say so, so the least-privilege decision is an informed one.
2. drones post-merge now exits 1 when the Linear Done write-back fails, so the re-run is retryable rather than silently terminal. Any wrapper treating a non-zero post-merge exit as fatal-and-unrecoverable should treat it as retry-me.
## Test Plan
Run on the merged head (f594a22, base merged in via drones resolve-conflicts):
pnpm typecheck → clean (tsc --noEmit, no output)pnpm test → Test Files 64 passed (64)
Tests 1560 passed | 1 skipped (1561)
Duration 5.38s
python tests: OK
The single skip is the live-Linear schema probe, which uses it.skipIf(!process.env.LINEAR_API_KEY). That matters: an earlier revision guarded it with a bare return, which made it pass vacuously in CI (the workflow injects no such secret) while being the only guard for the blockedBy schema regression. It is now skipIf'd and backed by hermetic tests that do run in CI:
- ISSUE_FIELDS string assertion — selects inverseRelations, does not select blockedBy.
- mapBlockedByFromInverseRelations over mixed blocks / related / duplicate inverse nodes, pinning both the type filter and blocker-identity mapping.
- Terminal-state tests pinning name-Blocked-typed-canceled as unresolved.
- selectDispatchable cases for the fail-closed blockedBy === undefined and truncated-page arms.
- Two-host claim race → exactly one fire, loser records a named skip.
- Idempotency: each lifecycle transition run twice → one comment / one link / one state.
- Dry-run against a mutation-rejecting mock client → zero Linear mutations.
## Verification Artifact
Full suite, merged head — pnpm typecheck clean; pnpm test 64 files / 1560 passed / 1 skipped; Python tests OK. Independently re-run by drones resolve-conflicts' AI-151 full-suite gate (gate_status: passed) before it fast-forward-pushed merge f594a22, and by CI on the merged head (ci ✓ 41s).
Review loops: two reviewer fan-out rounds, 67 inline findings total. All 10 Critical/High threads answered and resolved. Round 2 was substantive — it live-queried the Linear workspace and caught four defects that only a live workspace reveals: In Progress exists on no team, Today is a general planning state rather than a claim marker, Blocked is typed canceled, and team.states.nodes is unordered so the "first started state" fallback was non-deterministic. Fixed in 9a936ac, 81dd15e, b1b21c8, 53c14e4, 95c6312, eed438a.
Known, tracked gaps — 7 Low-severity findings on this PR were never delivered to the addresser because gh api .../comments was unpaginated and capped each review at 30 comments (AI-217, since fixed on main). All 7 are polish (typed mutation payload, .env.example wording, docstring enumeration, trim-on-compare); they are captured in AI-221 rather than left only as PR threads.
<div><a href="https://cursor.com/agents/bc-b64f437e-89e5-41c5-97da-02c3adc8f46d"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-b64f437e-89e5-41c5-97da-02c3adc8f46d"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div>