[object Object]

← 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

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 →