[object Object]

← back to Ticket System

add stale-doing reaper: flags DOING tickets idle >Nh with no live worker + suggests disposition (durable fix for zombie-ticket root cause)

1b3f83b8466d86bdb7fa40fbefa30209010f1b70 · 2026-07-27 18:43:04 -0700 · Steve Abrams

Report-only by default; --block sets the safe default (→blocked). Root cause found this session: nothing transitions a ticket out of doing when its owning Claude session ends.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 1b3f83b8466d86bdb7fa40fbefa30209010f1b70
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 27 18:43:04 2026 -0700

    add stale-doing reaper: flags DOING tickets idle >Nh with no live worker + suggests disposition (durable fix for zombie-ticket root cause)
    
    Report-only by default; --block sets the safe default (→blocked). Root cause found this session: nothing transitions a ticket out of doing when its owning Claude session ends.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 reaper.js | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 65 insertions(+)

diff --git a/reaper.js b/reaper.js
new file mode 100644
index 0000000..a46b435
--- /dev/null
+++ b/reaper.js
@@ -0,0 +1,65 @@
+#!/usr/bin/env node
+// stale-doing reaper — the durable fix for zombie DOING tickets.
+// Root cause (found 2026-07-27): nothing transitions a ticket out of `doing`
+// when its owning Claude session ends, so `doing` silently accumulates zombies.
+// This finds DOING tickets that are (a) idle > THRESHOLD_H and (b) have NO live
+// `claude … Work ticket <id>` process, then SUGGESTS a disposition from the last
+// action text. Report-only by default; --block sets the safe default (→ blocked).
+//
+//   node reaper.js                 # report zombies + suggested dispositions
+//   node reaper.js --block         # additionally set each zombie → blocked (reversible)
+//
+const { execSync } = require('child_process');
+const THRESHOLD_H = Number(process.env.REAPER_IDLE_H || 6);
+const BLOCK = process.argv.includes('--block');
+const BOARD = 'http://127.0.0.1:9794', AUTH = 'admin:DW2024!';
+
+const now = Date.now();
+const sh = c => { try { return execSync(c, { encoding: 'utf8' }); } catch { return ''; } };
+
+// live worker processes, keyed by the TK id in their command line
+const ps = sh(`ps -Ao command`).split('\n');
+const liveIds = new Set();
+for (const line of ps) { const m = line.match(/Work ticket (TK-[0-9]+)/i); if (m) liveIds.add(m[1].toUpperCase()); }
+
+const tickets = JSON.parse(sh(`curl -s -u ${AUTH} ${BOARD}/api/tickets`) || '[]');
+const shortId = id => (id.match(/^(TK-\d+)/) || [id, id])[1].toUpperCase();
+
+// disposition heuristic from the most-recent action text
+function suggest(t, lastText) {
+  const s = (lastText || '').toLowerCase();
+  if (/\b(complete|completed|final verify|shipped|deployed|verified|done|live-verified)\b/.test(s)) return 'done';
+  if (/pending-approval|memo|gated|awaiting steve|signed off|blocked on|go-live gated/.test(s)) return 'blocked';
+  return 'open'; // session died mid-work, not clearly finished or gated
+}
+
+const zombies = [];
+for (const t of tickets) {
+  if (t.status !== 'doing') continue;
+  const acts = (t.actions || []).map(a => ({ ts: +new Date(a.ts), text: a.text })).sort((a, b) => b.ts - a.ts);
+  const last = acts[0] || { ts: +new Date(t.updated_at || t.created_at || now), text: '' };
+  const idleH = (now - last.ts) / 3600000;
+  const sid = shortId(t.id);
+  const live = liveIds.has(sid);
+  if (idleH >= THRESHOLD_H && !live) {
+    zombies.push({ id: sid, fullId: t.id, assignee: t.assignee, idleH: idleH.toFixed(1),
+      suggest: suggest(t, last.text), last: (last.text || '').slice(0, 80) });
+  }
+}
+
+if (!zombies.length) { console.log(`✅ no zombie DOING tickets (idle ≥ ${THRESHOLD_H}h with no live worker).`); process.exit(0); }
+console.log(`⚠ ${zombies.length} zombie DOING ticket(s) — idle ≥ ${THRESHOLD_H}h, no live worker:\n`);
+for (const z of zombies)
+  console.log(`  ${z.id.padEnd(11)} idle ${String(z.idleH).padStart(6)}h  @${(z.assignee||'—').padEnd(22)} → ${z.suggest.padEnd(7)}  «${z.last}»`);
+
+if (BLOCK) {
+  console.log(`\n--block: setting each zombie → blocked (reversible; safe default)…`);
+  const TK = require('path').join(__dirname, 'tk');
+  for (const z of zombies) {
+    sh(`TK_AGENT=reaper node ${TK} comment ${z.fullId} "reaper: DOING but idle ${z.idleH}h with no live worker → auto-blocked (suggested: ${z.suggest}). Owning session ended without tk done/status." 2>/dev/null`);
+    sh(`TK_AGENT=reaper node ${TK} status ${z.fullId} blocked 2>/dev/null`);
+    console.log(`  ⛔ ${z.id} → blocked`);
+  }
+} else {
+  console.log(`\n(report-only — re-run with --block to auto-block, or reconcile by hand.)`);
+}

← 6c74133 office clock v4: legibility pass (taller chip, halo dot, tru  ·  back to Ticket System  ·  schedule ticket-reaper hourly (com.steve.ticket-reaper, --bl ae90cac →