[object Object]

← back to Ticket System

TK-11678: regression test proving TK-11660's claim guard closes the double-queue

a2d1a0c1c54b200e8f26574e55d4a9312f7a1be5 · 2026-09-14 00:36:25 -0700 · Steve Abrams

TK-11678 ("ticket-board can queue the same ticket twice, spawning duplicate
runner windows that duplicate gated memos") is the SAME single enqueue path
(server.js /api/run, the one appendFileSync to ticket-runner-queue.jsonl) that
TK-11660's claim-at-enqueue guard now covers — verified by reproduction, not
inference:

  main (guardless): two /api/run POSTs for one ticket -> 2 queue lines ->
    2 runner windows -> 2 gated memos (the reported bug).
  fix branch: same-tick double-fire skipped (already-queued); the literal
    TK-11678 timing (two RUN NOW actions 60s apart) still skipped because the
    claim is disk-persisted with a 10-min TTL, not a within-request lock; a
    TTL-lapsed retry re-enqueues so legit re-runs still work; a 'doing' ticket
    skips as before.

The guard shipped in a05b5c1 with no committed test. This adds the missing
integration regression (drives the real server.js on a scratch HOME) that
encodes all four cases and — proven by swapping in main's guardless server.js —
goes RED on assertion "second POST enqueues nothing" when the guard is absent.
No new guard code: TK-11678 is resolved-by-TK-11660 (dedup). Ships with the
same board reload as TK-11660; belongs to that gated activation
(pending-approval/2026-09-14-TK-11660-activate-run-enqueue-guard-GATED.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbgdchXZM8b5QmhnbaesMx

Files touched

Diff

commit a2d1a0c1c54b200e8f26574e55d4a9312f7a1be5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Sep 14 00:36:25 2026 -0700

    TK-11678: regression test proving TK-11660's claim guard closes the double-queue
    
    TK-11678 ("ticket-board can queue the same ticket twice, spawning duplicate
    runner windows that duplicate gated memos") is the SAME single enqueue path
    (server.js /api/run, the one appendFileSync to ticket-runner-queue.jsonl) that
    TK-11660's claim-at-enqueue guard now covers — verified by reproduction, not
    inference:
    
      main (guardless): two /api/run POSTs for one ticket -> 2 queue lines ->
        2 runner windows -> 2 gated memos (the reported bug).
      fix branch: same-tick double-fire skipped (already-queued); the literal
        TK-11678 timing (two RUN NOW actions 60s apart) still skipped because the
        claim is disk-persisted with a 10-min TTL, not a within-request lock; a
        TTL-lapsed retry re-enqueues so legit re-runs still work; a 'doing' ticket
        skips as before.
    
    The guard shipped in a05b5c1 with no committed test. This adds the missing
    integration regression (drives the real server.js on a scratch HOME) that
    encodes all four cases and — proven by swapping in main's guardless server.js —
    goes RED on assertion "second POST enqueues nothing" when the guard is absent.
    No new guard code: TK-11678 is resolved-by-TK-11660 (dedup). Ships with the
    same board reload as TK-11660; belongs to that gated activation
    (pending-approval/2026-09-14-TK-11660-activate-run-enqueue-guard-GATED.md).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01CbgdchXZM8b5QmhnbaesMx
---
 test/tk11660-run-enqueue-claim-guard.test.js | 114 +++++++++++++++++++++++++++
 1 file changed, 114 insertions(+)

diff --git a/test/tk11660-run-enqueue-claim-guard.test.js b/test/tk11660-run-enqueue-claim-guard.test.js
new file mode 100644
index 00000000..482147a6
--- /dev/null
+++ b/test/tk11660-run-enqueue-claim-guard.test.js
@@ -0,0 +1,114 @@
+'use strict';
+// Integration regression test for the TK-11660 claim-at-enqueue guard on /api/run,
+// which also closes TK-11678 ("ticket-board can queue the same ticket twice, spawning
+// duplicate runner windows that duplicate gated memos"). The guard shipped in a05b5c1
+// with no committed test; this locks its behavior in.
+//
+// It drives the REAL server.js in a child process with HOME (and TICKET_DATA_DIR)
+// pointed at a throwaway scratch dir, so os.homedir()-derived paths (the runner queue
+// + the run-claims file) and the ticket store all live in the sandbox — the real
+// ~/.claude is never touched. Assertions are made against the runner queue the drainer
+// would consume: one enqueue == one iTerm2 window == one gated memo.
+//
+// The guard is the ONLY dedup for the enqueue; remove it and assertions 1-3 see two
+// queue lines and go RED (verified on `main`, which produces 2). That is the negative
+// seam required by the "a check ships with a test that reddens on the fault" rule.
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const os = require('os');
+const net = require('net');
+const path = require('path');
+const http = require('http');
+const { spawn } = require('child_process');
+
+const SERVER = path.join(__dirname, '..', 'server.js');
+const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
+
+const freePort = () => new Promise((resolve, reject) => {
+  const s = net.createServer();
+  s.once('error', reject);
+  s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
+});
+
+const req = (port, method, url, body) => new Promise((resolve, reject) => {
+  const data = body ? Buffer.from(JSON.stringify(body)) : null;
+  const r = http.request({ host: '127.0.0.1', port, method, path: url,
+    headers: { Authorization: AUTH, 'Content-Type': 'application/json',
+      ...(data ? { 'Content-Length': data.length } : {}) } }, res => {
+    let b = ''; res.on('data', d => b += d); res.on('end', () => resolve({ code: res.statusCode, body: b }));
+  });
+  r.on('error', reject); if (data) r.write(data); r.end();
+});
+const post = (port, ids) => req(port, 'POST', '/api/run', { ids, profile: 'claude-opus' }).then(r => JSON.parse(r.body));
+
+const waitHealthy = async (port) => {
+  for (let i = 0; i < 100; i++) {
+    try { const r = await req(port, 'GET', '/healthz'); if (r.code === 200) return; } catch {}
+    await new Promise(r => setTimeout(r, 100));
+  }
+  throw new Error('server never became healthy');
+};
+
+test('TK-11660 guard: /api/run enqueues a ticket at most once while a run is pending (closes TK-11678)', async (t) => {
+  const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'tk11660-'));
+  const dataDir = path.join(scratch, '.claude', 'tickets');
+  fs.mkdirSync(dataDir, { recursive: true });
+  // One OPEN task ticket (status stays 'open' until a launched session runs `tk take`,
+  // which is exactly the window in which the double-fire duplicates), plus one that is
+  // already 'doing' to exercise the pre-existing already-doing skip + claim clear.
+  const ts = new Date().toISOString();
+  fs.writeFileSync(path.join(dataDir, 'events.jsonl'),
+    JSON.stringify({ type: 'create', id: 'TK-99001', title: 'repro double-queue', project: 'ticket-system', agent: 'tester', kind: 'task', ts }) + '\n' +
+    JSON.stringify({ type: 'create', id: 'TK-99002', title: 'already doing', project: 'ticket-system', agent: 'tester', kind: 'task', ts }) + '\n' +
+    JSON.stringify({ type: 'status', id: 'TK-99002', status: 'doing', ts }) + '\n');
+
+  const port = await freePort();
+  const srv = spawn(process.execPath, [SERVER], {
+    env: { ...process.env, HOME: scratch, PORT: String(port), TICKET_DATA_DIR: dataDir },
+    stdio: 'ignore',
+  });
+  t.after(() => { try { srv.kill('SIGKILL'); } catch {} try { fs.rmSync(scratch, { recursive: true, force: true }); } catch {} });
+  await waitHealthy(port);
+
+  const queue = path.join(scratch, '.claude', 'ticket-runner-queue.jsonl');
+  const claimsFile = path.join(scratch, '.claude', 'ticket-run-claims.json');
+  const queueLines = () => (fs.existsSync(queue) ? fs.readFileSync(queue, 'utf8').split('\n').filter(Boolean).length : 0);
+  const ageClaim = (id, ms) => {
+    const c = JSON.parse(fs.readFileSync(claimsFile, 'utf8'));
+    c[id] = new Date(Date.now() - ms).toISOString();
+    fs.writeFileSync(claimsFile, JSON.stringify(c));
+  };
+
+  // 1. same-tick double-fire (double-click / rapid re-POST): 2nd is a no-op skip.
+  const r1 = await post(port, ['TK-99001']);
+  const r2 = await post(port, ['TK-99001']);
+  assert.deepEqual(r1.launched, ['TK-99001'], 'first POST enqueues');
+  assert.deepEqual(r2.launched, [], 'second POST enqueues nothing');
+  assert.equal(r2.skipped[0].why, 'already-queued', 'second POST skipped as already-queued');
+  assert.equal(queueLines(), 1, 'exactly one runner-queue line == one window == one gated memo');
+
+  // 2. THE TK-11678 TIMING: the two RUN NOW actions in the incident were 60s apart, not
+  //    same-tick. The claim is disk-persisted with a 10-min TTL, so a re-POST 60s later
+  //    is still skipped. (A within-request lock would NOT have covered this.)
+  ageClaim('TK-99001', 60 * 1000);
+  const r3 = await post(port, ['TK-99001']);
+  assert.equal(r3.skipped[0].why, 'already-queued', '60s-apart re-POST still skipped');
+  assert.equal(queueLines(), 1, 'still one line after the 60s-apart re-POST');
+
+  // 3. once the claim's TTL lapses, a legit retry (a run that never took the ticket) may
+  //    re-enqueue — the guard must not over-block.
+  ageClaim('TK-99001', 11 * 60 * 1000);
+  const r4 = await post(port, ['TK-99001']);
+  assert.deepEqual(r4.launched, ['TK-99001'], 'TTL-lapsed retry re-enqueues');
+  assert.equal(queueLines(), 2, 'a second line only after the TTL lapses');
+
+  // 4. a ticket already 'doing' is skipped (pre-existing behavior) and never enqueues.
+  const r5 = await post(port, ['TK-99002']);
+  assert.deepEqual(r5.launched, [], 'doing ticket not enqueued');
+  assert.equal(r5.skipped[0].why, 'already-doing', 'doing ticket skipped as already-doing');
+
+  // 5. the guard is per-ticket, not a global lock — an unrelated ticket still enqueues.
+  const r6 = await post(port, ['TK-99002']); // still skipped (doing), proves independence from TK-99001's claim
+  assert.equal(r6.skipped[0].why, 'already-doing', 'guard keyed per-id, unaffected by TK-99001 claim');
+});

← a05b5c11 TK-11660: claim-at-enqueue guard on /api/run to stop double-  ·  back to Ticket System  ·  fix(codex-yoloforever): self-heal stale cycle.lock from dead d29c0f72 →