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