[object Object]

← 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 →