← back to Ticket System
TK-11660: claim-at-enqueue guard on /api/run to stop double-fire duplicate runs
a05b5c1172295d485df1abe04145a4fb1c7580bc · 2026-09-14 00:28:10 -0700 · Steve Abrams
A double-fire (double-click / rapid re-POST / the TK-11678 duplicate-queue
class) enqueued the SAME ticket twice: a ticket's status only flips to 'doing'
once its launched session runs `tk take` (seconds-to-minutes after enqueue), so
two rapid POSTs both saw status!=='doing' and both appended to the runner queue,
spawning duplicate iTerm2 runner windows that duplicate gated memos.
Add a live claim (id -> enqueue-ts, ~/.claude/ticket-run-claims.json, 10min TTL):
a second enqueue while a run is pending-but-not-yet-doing is a no-op skip
(why:'already-queued'). The claim is cleared on doing/done/stopped or TTL lapse,
so legit re-runs (and reopen-after-doing) still work. Check-and-set is atomic —
the handler body runs to completion synchronously on Node's single thread.
Reproduced before/after (isolated harness, os.homedir->scratch, real drainer +
event log untouched): 2 rapid POSTs went 2 enqueues -> 1. 22 existing lib tests
pass; regression suite covers batch, TTL retry, doing-skip, reopen-after-doing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbgdchXZM8b5QmhnbaesMx
Files touched
Diff
commit a05b5c1172295d485df1abe04145a4fb1c7580bc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Sep 14 00:28:10 2026 -0700
TK-11660: claim-at-enqueue guard on /api/run to stop double-fire duplicate runs
A double-fire (double-click / rapid re-POST / the TK-11678 duplicate-queue
class) enqueued the SAME ticket twice: a ticket's status only flips to 'doing'
once its launched session runs `tk take` (seconds-to-minutes after enqueue), so
two rapid POSTs both saw status!=='doing' and both appended to the runner queue,
spawning duplicate iTerm2 runner windows that duplicate gated memos.
Add a live claim (id -> enqueue-ts, ~/.claude/ticket-run-claims.json, 10min TTL):
a second enqueue while a run is pending-but-not-yet-doing is a no-op skip
(why:'already-queued'). The claim is cleared on doing/done/stopped or TTL lapse,
so legit re-runs (and reopen-after-doing) still work. Check-and-set is atomic —
the handler body runs to completion synchronously on Node's single thread.
Reproduced before/after (isolated harness, os.homedir->scratch, real drainer +
event log untouched): 2 rapid POSTs went 2 enqueues -> 1. 22 existing lib tests
pass; regression suite covers batch, TTL retry, doing-skip, reopen-after-doing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbgdchXZM8b5QmhnbaesMx
---
server.js | 27 +++++++++++++++++++++++++--
1 file changed, 25 insertions(+), 2 deletions(-)
diff --git a/server.js b/server.js
index 28ad4e23..e6cb44b1 100644
--- a/server.js
+++ b/server.js
@@ -13,6 +13,23 @@ const VERDICTS = path.join(DATA_DIR, 'dtd-verdicts.json'); // last batched
const DTD_RUNNING = path.join(DATA_DIR, 'dtd-verdicts.running'); // present while a sweep is in flight
const RUN_SH = path.join(__dirname, 'run-ticket.sh'); // opens an iTerm2 Claude session
const RUNNER_QUEUE = path.join(os.homedir(), '.claude', 'ticket-runner-queue.jsonl'); // TK-11565: GUI-session drainer (com.steve.ticket-runner) picks this up
+// TK-11660: claim-at-enqueue guard against the double-fire (double-click / rapid re-POST).
+// A ticket's status only flips to 'doing' once its launched session runs `tk take` — seconds
+// to minutes after enqueue — so two rapid /api/run POSTs both see status!=='doing' and both
+// enqueue, spawning duplicate runner windows + duplicate gated memos. A claim bridges that gap:
+// id -> enqueue-ts, cleared once the ticket reaches doing/done/stopped or the TTL lapses (a run
+// that never took the ticket may be retried). The check-and-set is atomic — /api/run's handler
+// body runs synchronously to completion (no awaits between load and save) on Node's single thread.
+const RUN_CLAIMS = path.join(os.homedir(), '.claude', 'ticket-run-claims.json');
+const RUN_CLAIM_TTL_MS = Number(process.env.TK_RUN_CLAIM_TTL_MS) || 10 * 60 * 1000;
+function loadRunClaims() {
+ try {
+ const c = JSON.parse(fs.readFileSync(RUN_CLAIMS, 'utf8')); const now = Date.now(); const out = {};
+ for (const [id, ts] of Object.entries(c)) if (now - Date.parse(ts) < RUN_CLAIM_TTL_MS) out[id] = ts; // prune stale
+ return out;
+ } catch { return {}; }
+}
+function saveRunClaims(c) { try { fs.writeFileSync(RUN_CLAIMS, JSON.stringify(c)); } catch {} }
const DTD_RUN = path.join(__dirname, 'dtd-run.js'); // batched panel.sh sweep
const RUN_PROFILES = new Set(['claude-sonnet', 'claude-opus', 'claude-haiku', 'claude-opus-5', 'claude-sonnet-5', 'claude-fable', 'codex', 'codex-gpt6', 'codex-gpt52', 'local-qwen-27b', 'local-qwen-14b']);
const DEFAULT_RUN_PROFILE = 'codex';
@@ -517,11 +534,15 @@ http.createServer((req, res) => {
if (!RUN_PROFILES.has(profile)) return json(res, 400, { error: 'invalid run profile' });
const map = cachedTickets(); const ids = resolveList(body.ids, map);
const launched = [], skipped = [];
+ const claims = loadRunClaims(); // TK-11660: pruned to live (within-TTL) claims only
ids.forEach((id, i) => {
const t = map.get(id);
- if (t && t.status === 'stopped') { skipped.push({ id, why: 'stopped' }); return; }
- if (t && t.status === 'doing') { skipped.push({ id, why: 'already-doing' }); return; }
+ if (t && t.status === 'stopped') { delete claims[id]; skipped.push({ id, why: 'stopped' }); return; }
+ if (t && t.status === 'doing') { delete claims[id]; skipped.push({ id, why: 'already-doing' }); return; }
if (t && (t.kind || 'task') !== 'task') { skipped.push({ id, why: 'designation-' + t.kind }); return; }
+ // TK-11660: a live claim means this ticket already has a pending/in-flight run that
+ // hasn't reached 'doing' yet — a double-fire. No-op instead of a second enqueue.
+ if (claims[id]) { skipped.push({ id, why: 'already-queued' }); return; }
let cwd = os.homedir();
const proj = t && t.project;
if (proj && /^[a-z0-9._-]+$/i.test(proj)) { const p = path.join(os.homedir(), 'Projects', proj); if (fs.existsSync(p)) cwd = p; }
@@ -530,6 +551,7 @@ http.createServer((req, res) => {
// which launches each ticket in its own iTerm2 window via run-ticket.sh.
try {
fs.appendFileSync(RUNNER_QUEUE, JSON.stringify({ ts: new Date().toISOString(), id, cwd, profile }) + '\n');
+ claims[id] = new Date().toISOString(); // TK-11660: claim set only on a real enqueue
withLock(() => append({ ts: new Date().toISOString(), type: 'action', id, agent: 'board',
text: `▶ RUN NOW — queued for ticket-runner (own iTerm2 window) · profile=${profile}` }));
} catch (e2) {
@@ -539,6 +561,7 @@ http.createServer((req, res) => {
invalidateViews();
launched.push(id);
});
+ saveRunClaims(claims);
json(res, 200, { launched, skipped, profile });
});
}
← 94b9bb86 run-ticket: close finished ticket window via /cs + /compact
·
back to Ticket System
·
TK-11678: regression test proving TK-11660's claim guard clo a2d1a0c1 →