← back to Answer Cockpit
TK-11793: scale fix at 51 sessions — background async pane watcher (request paths read a cache; one sync fresh read only in the POST guard), stale-while-revalidate build cache so concurrent polls never stack behind a long build
0ace03e46558800a7922ecc8ae1b00f68976ac32 · 2026-09-16 08:48:03 -0700 · Steve Abrams
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Files touched
M lib/queue.jsM lib/transcript.jsM server.js
Diff
commit 0ace03e46558800a7922ecc8ae1b00f68976ac32
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 16 08:48:03 2026 -0700
TK-11793: scale fix at 51 sessions — background async pane watcher (request paths read a cache; one sync fresh read only in the POST guard), stale-while-revalidate build cache so concurrent polls never stack behind a long build
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---
lib/queue.js | 17 ++++++++++++++
lib/transcript.js | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
server.js | 4 +++-
3 files changed, 89 insertions(+), 2 deletions(-)
diff --git a/lib/queue.js b/lib/queue.js
index c370f20..0982b12 100644
--- a/lib/queue.js
+++ b/lib/queue.js
@@ -187,7 +187,24 @@ function buildItem(row, memosByTk, opts) {
/**
* build({orphans}) → { items, remaining, orphanMemos, scannedAt, cost, stale, source, error }
*/
+// Stale-while-revalidate: at 51 live sessions a full build takes many seconds; concurrent
+// /api/queue polls (several open tabs × 8s) must not stack behind it. One build runs at a
+// time; while it runs, callers get the LAST result immediately with building:true.
+const lastBuild = new Map(); // orphans-flag → result
+const building = new Map(); // orphans-flag → Promise
async function build(opts = {}) {
+ const k = opts.orphans ? 'o' : 'n';
+ if (building.has(k)) {
+ const prev = lastBuild.get(k);
+ if (prev) return { ...prev, building: true };
+ return building.get(k);
+ }
+ const p = buildUncached(opts).then((r) => { lastBuild.set(k, r); building.delete(k); return r; })
+ .catch((e) => { building.delete(k); throw e; });
+ building.set(k, p);
+ return p;
+}
+async function buildUncached(opts = {}) {
const res = await scan();
const memos = memo.list();
const byTk = memo.byTicket(memos);
diff --git a/lib/transcript.js b/lib/transcript.js
index c16a87e..91ea107 100644
--- a/lib/transcript.js
+++ b/lib/transcript.js
@@ -241,8 +241,76 @@ let paneBatch = { ts: 0, map: new Map() };
// Writes never rely on this: guardTarget() calls invalidatePanes() before resolving.
const PANE_BATCH_MS = 12000;
function invalidatePanes() { paneBatch = { ts: 0, map: paneBatch.map }; }
+
+// ---- background watcher (scale fix, 2026-09-16: 51 live sessions / 45 panes) ----------
+// The batch read is ~5s at 45 panes. Done synchronously on the request path it blocked the
+// single event loop, so /api/queue ran past 60s and even /api/health waited behind it. The
+// watcher refreshes the batch ASYNCHRONOUSLY on a timer; request paths read the cache only.
+// A click (guardTarget) still forces ONE fresh sync read via refreshPanesSync() — rare, and
+// that is the moment freshness actually matters.
+let watcher = null, refreshing = false;
+function paneScript(BEGIN, END) {
+ return `tell application "iTerm2"
+set out to ""
+repeat with w in windows
+ repeat with t in tabs of w
+ repeat with s in sessions of t
+ try
+ -- contents is the ENTIRE scrollback (can be MBs). Substring to the last 4000 chars
+ -- inside AppleScript so the concatenation stays small and fast (quadratic otherwise).
+ set c to (contents of s)
+ set L to length of c
+ if L > 4000 then set c to text (L - 3999) thru L of c
+ set out to out & "${BEGIN}" & (tty of s) & linefeed & c & linefeed & "${END}" & linefeed
+ end try
+ end repeat
+ end repeat
+end repeat
+return out
+end tell`;
+}
+function parseBatch(raw, BEGIN, END) {
+ const map = new Map();
+ const re = new RegExp(BEGIN.replace(/[-\s]/g, (ch) => ch === ' ' ? ' ' : '\\-') + '\\/dev\\/(ttys\\d{3})\\n([\\s\\S]*?)\\n' + END.replace(/-/g, '\\-') + '\\n', 'g');
+ let m; while ((m = re.exec(raw))) map.set(m[1], m[2]);
+ return map;
+}
+function refreshPanesAsync() {
+ if (refreshing) return;
+ refreshing = true;
+ const nonce = crypto.randomBytes(6).toString('hex');
+ const BEGIN = '@@TTY-' + nonce + ' ', END = '@@END-' + nonce;
+ const { execFile } = require('child_process');
+ const child = execFile('/usr/bin/osascript', [], { timeout: 20000, maxBuffer: 32 << 20 }, (err, stdout) => {
+ refreshing = false;
+ if (err || !stdout) return; // keep the previous batch; a failed read is not an empty fleet
+ paneBatch = { ts: Date.now(), map: parseBatch(stdout, BEGIN, END) };
+ });
+ child.stdin.on('error', () => {});
+ child.stdin.end(paneScript(BEGIN, END));
+}
+function startPaneWatcher(intervalMs = PANE_BATCH_MS) {
+ if (watcher) return watcher;
+ refreshPanesAsync();
+ watcher = setInterval(refreshPanesAsync, intervalMs);
+ if (watcher.unref) watcher.unref();
+ return watcher;
+}
+/** refreshPanesSync() — ONE blocking fresh read; used only by the POST guard before typing. */
+function refreshPanesSync() {
+ const nonce = crypto.randomBytes(6).toString('hex');
+ const BEGIN = '@@TTY-' + nonce + ' ', END = '@@END-' + nonce;
+ try {
+ const raw = execFileSync('/usr/bin/osascript', [], { input: paneScript(BEGIN, END), encoding: 'utf8', timeout: 20000, maxBuffer: 32 << 20 });
+ paneBatch = { ts: Date.now(), map: parseBatch(raw, BEGIN, END) };
+ } catch { /* keep previous batch */ }
+ return paneBatch.map;
+}
function allPaneContents() {
if (Date.now() - paneBatch.ts < PANE_BATCH_MS) return paneBatch.map;
+ // With the watcher running, NEVER block a request path: serve the last batch (≤ a few
+ // seconds older than the TTL while a refresh is in flight) and let the timer catch up.
+ if (watcher) { refreshPanesAsync(); return paneBatch.map; }
// Per-call NONCE delimiters: a pane that happens to print the literal delimiter (e.g. a session
// reviewing this file) can no longer truncate its own capture (Cody FIX-FIRST #4).
const nonce = crypto.randomBytes(6).toString('hex');
@@ -446,4 +514,4 @@ function resolveUncached(row, pid, tty) {
return { sessionId: null, transcriptPath: null, cwd, confidence: 'ambiguous', how: survivors.length ? `validator kept ${survivors.length}` : 'validator kept 0', candidates: paths, detail: null };
}
-module.exports = { resolve, parseTail, tailJsonl, pendingState, cwdOf, candidateDirs, parsePane, paneContents, allPaneContents, invalidatePanes, MAP_DIR, _cache: cache };
+module.exports = { resolve, parseTail, tailJsonl, pendingState, cwdOf, candidateDirs, parsePane, paneContents, allPaneContents, invalidatePanes, refreshPanesSync, startPaneWatcher, MAP_DIR, _cache: cache };
diff --git a/server.js b/server.js
index 8e8ed96..586e186 100644
--- a/server.js
+++ b/server.js
@@ -92,7 +92,7 @@ async function guardTarget(body, { needsSteveOnly = true, requireKey = true } =
// menu is live on screen (a pane-detected question is answerable even if the dot is none/green).
// GENUINELY fresh: drop the 4s pane batch + this pid's 30s resolve cache before resolving, so a
// menu that closed/advanced since the last poll cannot digest-match a stale card (Cody #3).
- transcript.invalidatePanes(); transcript._cache.delete(String(row.pid));
+ transcript.refreshPanesSync(); transcript._cache.delete(String(row.pid)); // one blocking fresh read — only here
const sess = transcript.resolve(row);
const liveMenu = !!(sess.detail && sess.detail.question && !sess.detail.queued);
if (requireKey) {
@@ -239,4 +239,6 @@ server.listen(PORT, HOST, () => {
console.log(`Answer Cockpit up: http://${HOST}:${PORT} (admin / ${PASS}) selfTty=${writeback.SELF_TTY || '-'} cost=$0 (local)`);
// first heartbeat so latest.json exists before the first request
queue.scan().catch(() => {});
+ // background pane watcher: request paths read a cache, never block on osascript
+ transcript.startPaneWatcher();
});
← 813fd6a chore: lint, refactor, v0.2.0 (session close)
·
back to Answer Cockpit
·
chore: v0.2.1 — scale fix (async pane watcher, stale-while-r 3dfd1b3 →