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