[object Object]

← back to Ticket System

reaper: fix silent no-op — read store via lib.js (not flaky :9794 API) + detect liveness via TK_AGENT owner join

e10b7ec89e660cab367876eb0a3eea144ebe0cfb · 2026-09-14 08:26:05 -0700 · Steve Abrams

The reaper was reporting 0 zombies while 59 orphaned DOING tickets piled up.
Two bugs: (1) it read the board's mtime-cache-keyed HTTP API which intermittently
returns an empty payload under concurrent appends -> '0 doing -> no zombies';
(2) liveness was keyed only on a 'Work ticket TK-N' cmdline string that loop/
subagent sessions never emit, so their owners were invisible. Now reads lib.js
tickets() (Map->values, stable) and joins ticket.assignee against live TK_AGENT
owners parsed from ps -E (anchored regex to avoid self-match). Report-only default
unchanged; verified it now finds 59 zombies reliably.

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

Files touched

Diff

commit e10b7ec89e660cab367876eb0a3eea144ebe0cfb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Sep 14 08:26:05 2026 -0700

    reaper: fix silent no-op — read store via lib.js (not flaky :9794 API) + detect liveness via TK_AGENT owner join
    
    The reaper was reporting 0 zombies while 59 orphaned DOING tickets piled up.
    Two bugs: (1) it read the board's mtime-cache-keyed HTTP API which intermittently
    returns an empty payload under concurrent appends -> '0 doing -> no zombies';
    (2) liveness was keyed only on a 'Work ticket TK-N' cmdline string that loop/
    subagent sessions never emit, so their owners were invisible. Now reads lib.js
    tickets() (Map->values, stable) and joins ticket.assignee against live TK_AGENT
    owners parsed from ps -E (anchored regex to avoid self-match). Report-only default
    unchanged; verified it now finds 59 zombies reliably.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Ua2TVTdVcyufh968ZCZXdc
---
 reaper.js | 34 +++++++++++++++++++++++++++-------
 1 file changed, 27 insertions(+), 7 deletions(-)

diff --git a/reaper.js b/reaper.js
index a46b435d..298ab414 100644
--- a/reaper.js
+++ b/reaper.js
@@ -10,19 +10,37 @@
 //   node reaper.js --block         # additionally set each zombie → blocked (reversible)
 //
 const { execSync } = require('child_process');
+const { tickets: loadTickets } = require('./lib.js'); // read the store directly
 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()); }
+// live worker processes, detected two ways:
+//  (1) a "Work ticket TK-N" string in the command line (legacy convention), and
+//  (2) the TK_AGENT=<owner-label> env var that every live claude/codex session
+//      carries — the real signal, since loop/subagent sessions (night-tk*, the
+//      vp-* subagents, yoloforever-*, etc.) never use the "Work ticket" cmdline
+//      form. Without (2) the reaper's liveness check was effectively blind to
+//      those owners, letting their orphaned DOING tickets accumulate (found
+//      2026-09-14: 52 orphans surviving because assignee-liveness was unchecked).
+// `ps -E` appends each process's environment to the command column.
+const ps = sh(`ps -EAww -o command=`).split('\n');
+const liveIds = new Set();     // short TK ids seen in a command line
+const liveOwners = new Set();  // TK_AGENT owner labels of live processes
+for (const line of ps) {
+  const m = line.match(/Work ticket (TK-[0-9]+)/i); if (m) liveIds.add(m[1].toUpperCase());
+  // anchor on a space/start + restrict to real label chars so we don't self-match
+  // the literal "TK_AGENT=" that appears in this script's own argv or a ps/grep.
+  const e = line.match(/(?:^| )TK_AGENT=([A-Za-z0-9._-]+)/); if (e) liveOwners.add(e[1]);
+}
 
-const tickets = JSON.parse(sh(`curl -s -u ${AUTH} ${BOARD}/api/tickets`) || '[]');
+// Read the ticket store DIRECTLY via lib.js (the same source tk + the board use),
+// not over the board's HTTP API — that API is mtime-cache-keyed and intermittently
+// returns an empty/truncated payload under concurrent appends, which silently made
+// the reaper a no-op ("0 doing → no zombies"). tickets() returns a Map, so values().
+const tickets = [...loadTickets().values()];
 const shortId = id => (id.match(/^(TK-\d+)/) || [id, id])[1].toUpperCase();
 
 // disposition heuristic from the most-recent action text
@@ -40,7 +58,9 @@ for (const t of tickets) {
   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);
+  // live if a worker names this ticket on its cmdline OR a live process carries
+  // this ticket's owner label in TK_AGENT (the assignee-liveness join).
+  const live = liveIds.has(sid) || (!!t.assignee && liveOwners.has(t.assignee));
   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) });

← 21be74ab Record verified empty-queue monitoring cycle yf1501  ·  back to Ticket System  ·  Record bounded empty-queue monitoring cycle yf1520 321f4cfd →