← back to Ticket System

reaper.js

66 lines

#!/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.)`);
}