mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
* feat(tui): warm pane pool — single-use pre-booted panes, opt-in via OCP_TUI_POOL_SIZE Backlog item #3 of docs/plans/2026-07-13-tui-latency/README.md. Every TUI request currently cold-boots a tmux+claude pane. This adds an OPT-IN pool of pre-booted panes. Recorded as ADR 0008 (docs/adr/0008-tui-warm-pane-pool.md), which extends ADR 0007. MEASURED (this host, Sonnet 4.6, --effort low, through a real OCP instance; a sample counts only if HTTP 200 AND the body carries the demanded marker): pool off (main code) n= 6 p50 10.17s [9164 9499 9760 10572 10774 11281] pool on, warm hits n=12 p50 6.00s [5286 5289 5520 5584 5621 5969 6040 6098 6280 7846 8036 11053] pool on, warm hits (post- n= 6 p50 5.62s [4729 4753 5236 6004 7548 9548] review-fix re-run) -> -4.17s / -41%. 12 hits / 1 miss / 0 bootFailures over 13 requests (and 6/1/0 on the post-fix re-run). Robust to counting the miss: n=13 p50 -> -40.6%. The plan doc predicted only -1.0s (the boot). It is ~4.2s because the cold path also pays ~2.9s INSIDE the first turn beyond claude's own reported turn_duration — post- input-bar init that an idle pane has already finished. Phase decomposition of the cold path (n=6 medians): prep 2ms | tmux spawn 27ms | boot->input-ready 1232ms | paste 8ms | paste-verify 426ms | submit->terminal 8458ms | teardown 8ms = 10162ms total, vs native turn_duration 5539ms => 4490ms of OCP-side overhead, of which the pool recovers ~1.26s of boot and ~2.9s of in-claude cold start. (The 426ms paste-verify is one 400ms poll tick; a real paste lands in ~80ms. Not addressed here — separate item.) DESIGN - SINGLE-USE panes. A pooled pane serves exactly ONE turn, then is killed and replaced in the background. Each carries its OWN fresh --session-id fixed at boot, so one session still holds one exchange. This is what keeps transcript.mjs's extractLatestAssistantText correct; its warning about a future warm pool reusing a session is answered in-place (comment updated) and left standing for anyone who later wants a second turn on a pane — that would be a cross-request TEXT LEAK and needs user-line scoping in the transcript reader first. - Pool keyed by model; --model is fixed at spawn. A miss falls back to the cold path with zero behaviour change. The pool warms the most recently requested model, so the first request after start (and after a model switch) is always a cold miss. - REAPER COEXISTENCE (the crux). An idle warm pane IS ours, and the periodic sweep runs precisely when we are idle. reapStaleTuiSessions() takes a `spare` set of EXACT live session names, and server.mjs DRAINS the pool immediately before the sweep: 1. a live pooled pane is never reaped — INCLUDING one still BOOTING (see below); 2. an orphaned pooled pane IS still reaped — membership is by exact name from a live in-memory registry, never by name shape, so a pane from a dead process generation has nothing claiming it. Omitting `spare` reaps MORE, never less (fail-safe); 3. kill-server is suppressed while any pane is spared — hence the drain, so the sweep still flushes <defunct> claude zombies (the only mechanism that can). - THE POOL TRACKS ITS IN-FLIGHT BOOT BY NAME, NOT AS A COUNT. bootTuiPane creates the tmux session SYNCHRONOUSLY and only then waits up to POOL_BOOT_MS (20s) for the input bar, so a pooled session can be LIVE for ~20s before its boot resolves. Tracking boots as a count meant the pool could not name that session, which caused two real bugs (found in review, reproduced, fixed, and now regression-tested): * the reap sweep KILLED the booting pane (it could not be spared), left the pool empty with nothing scheduled, and logged the exact tui_pool_boot_failed WARN operators are told to alert on — for a completely healthy drain; * graceful shutdown ORPHANED a live authenticated idle `claude`: gracefulShutdown calls process.exit(0) in the SAME TICK as the drain (TUI panes are tmux children, so activeProcesses is empty and the wait-for-children path exits immediately), so cleanup deferred to a .then() never ran. Fix: the pool mints each pane's identity up front ({sessionId, name}) and holds it in _bootingPane. liveNames() includes it; drain() kills it SYNCHRONOUSLY. A generation counter distinguishes "cancelled by us" from "genuinely failed", so a drain never inflates bootFailures and resume() reliably starts a fresh boot. Deriving the name from the session-id also makes `tmux ls` correlate to the transcript file. - SLOT ACCOUNTING. Refill boots take NO TuiSemaphore slot (those bound real turns and would be starved); they cannot leak one either, since they never hold one. Refills are SERIALIZED, one boot at a time — live at size=2, two cold boots racing an in-flight turn overran the readiness cap and a refill was discarded. A genuinely failed boot does not re-kick the chain (backoff; a broken claude must not respawn forever). Background boots get a more generous readiness cap (POOL_BOOT_MS = 5x BOOT_MS): BOOT_MS is tight because a client is blocked on it, which is not true of a pre-boot. - BOUNDED COST. A warm pane is a LIVE idle claude process held whether or not a request arrives. Peak processes = pool size + OCP_TUI_MAX_CONCURRENT + 1 booting replacement. Size clamped to POOL_MAX_SIZE=4; garbage values disable rather than guess. Panes have a 10-min TTL and a health check at hand-out (dead/degraded pane => miss, never a hang). Missing collaborators throw at CONSTRUCTION, not on a live request (refill() is called synchronously from the request path). DEFAULT OFF (OCP_TUI_POOL_SIZE=0). This is a stable production path and the pool holds standing processes, so the operator opts in. With the pool off, runTuiTurn takes the IDENTICAL code path as before (the `pool ? pool.acquire() : null` branch yields null, and tuiPool is null so no observer is attached and no new log line is emitted) — that is what establishes the default path is unchanged. A pool-off control run (n=6, p50 9.40s) is consistent with the 10.17s baseline but had 2/6 samples >12s, so it is corroboration, NOT proof: n=6 cannot establish "unregressed" on its own. The code-path equivalence can. BANNER: NO SPAWN ARGUMENT CHANGED. buildTuiCmd is byte-identical to main (verified by extracting the function body from both revisions and comparing). Live banner captured from two real POOLED panes anyway: "Sonnet 4.6 with low effort · Claude Max" — the subscription pool, never "API Usage Billing". /health: `tui.pool` added (null when off), incl. `cancelled` (boots WE killed — not a fault; do not alert on it). The tui block is ADR-0007-owned and post-dates ADR 0006's v3.16.4 grandfather snapshot; the addition is purely additive — every pre-existing key keeps a byte-identical value. Authorization recorded in ADR 0008. ALIGNMENT: Class B / ADR 0007 + ADR 0008 (OCP-owned TUI spawn machinery). cli.js does NOT perform this operation — there is no cli.js citation and none is required: this is not an Anthropic API surface, it is OCP's own process management around the claude CLI, exactly as the existing tmux session lifecycle and reaper already are (ALIGNMENT.md Rule 2). TESTS: 294 passed / 0 failed (was 267). +27 covering acquire/hit/miss, single-use (a pane is never handed out twice), bounded + serialized refill, TTL + health-check drops, model retarget, drain/resume, boot-failure backoff, identity linkage, all three reaper invariants incl. post-drain kill-server restoration, and — the coverage gap that let both bugs ship — FIVE mid-boot tests: the booting pane is nameable/spareable, the sweep's drain kills it and resume starts a fresh boot with no bogus WARN, shutdown kills it synchronously (asserted WITHOUT awaiting, since process.exit runs in the same tick), a stale settle cannot clear a newer boot's slot, and a model switch cancels an in-flight boot for the old model. Live verification (temporary 20s reap interval, reverted): sweep drained both panes -> reaped -> refilled with NEW panes; a foreign tmux session survived untouched; with no foreign session kill-server fired and the pool still recovered and served the next request. Both review bugs reproduced against a PRIVATE tmux server (-L pr3repro, so the reaper's internal kill-server could not touch the host) before and after the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tui): kill a cancelled boot's pane when it settles + make async tests actually count Folds in the independent review's remaining nit — and, in proving the nit's fix, uncovers two defects in the test suite itself. ## The nit (latent M1b, second costume) `_cancelBooting` kills BY NAME, but the tmux session only EXISTS once `bootPane` has run — and `bootPane` is queued on a microtask. So a caller doing `refill()` then `drain()` in the SAME synchronous block leaves `_cancelBooting` with nothing to kill (a no-op); it bumps the generation, and the boot microtask then CREATES the session, succeeds, and — under the old bare `return` on a stale generation — walked away from a LIVE authenticated `claude` that nothing owns. Reproduced: reverted: drain() kills nothing (no session yet) -> boot creates it -> ORPHAN: ['p1'] fixed : drain() kills nothing (no session yet) -> boot creates it -> boot kills it -> [] Not reachable from any current call site, so this is defense-in-depth — but ADR 0008 and the reap-tick comment in server.mjs BOTH explicitly contemplate a boot-time pre-warm, which is exactly the shape that reaches it. Killing an already-dead session is a harmless no-op, so the fix is idempotent whichever way the race lands. ## Defect 1 in the suite: async tests were never awaited (44 of them) Writing the regression guard exposed this. `test()` called `fn()`, got a promise back, and IMMEDIATELY printed ✓ and incremented `passed` — without awaiting it. For all 44 tests written as `test("...", async () => {...})`: - ✓ meant "did not throw SYNCHRONOUSLY", not "passed"; - a failed assertion escaped as an unhandled rejection, crashing the process (CI stays red on the non-zero exit) but never being COUNTED — so the summary could print "0 failed" and be wrong. The suite's headline number was therefore not evidence for ANY async test, including this PR's own M1a/M1b guards. `test()` now settles an async body before counting it, and the summary awaits them. ## Defect 2, exposed the instant defect 1 was fixed: a false guard `"a boot that resolves AFTER a drain kills its own pane ... no orphan process left behind"` asserted `killed.length === 1` — i.e. that kill was CALLED once. But `_cancelBooting`'s kill-by-name on a not-yet-existent session is a NO-OP that still increments that counter. So "kill was called once" and "a live session is orphaned" were both true at the same time: a test named for the absence of an orphan was passing while the orphan was present. Now asserts LIVENESS (`live.size === 0`) — the only honest question. ## Evidence fix present : 295 passed, 0 failed, exit 0 fix reverted: 293 passed, 2 failed <- BOTH liveness guards fire (the old kill-count guard did not) Also: `dropped`'s doc comment now lists `cancelled` (a cancelled in-flight boot lands there via _drop). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
292 lines
16 KiB
JavaScript
292 lines
16 KiB
JavaScript
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
|
|
// and returns the latest assistant turn's text once the turn is terminal.
|
|
//
|
|
// Authority: claude CLI v2.1.157 — interactive session transcript at
|
|
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
|
|
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
|
|
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
|
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
// Locate a session's transcript by its UUID across every projects subdir, without
|
|
// reconstructing the encoded cwd. Robust to whatever encoding claude applies.
|
|
// Returns the path, or null if not present yet (it appears once the turn starts).
|
|
// TODO: add a CI fixture-contract test (a captured real transcript) so schema drift
|
|
// in the claude JSONL format fails loudly rather than silently degrading.
|
|
export function findTranscriptPath(home, sessionId) {
|
|
if (!home || !sessionId) return null;
|
|
const root = `${home}/.claude/projects`;
|
|
let dirs;
|
|
try { dirs = readdirSync(root); } catch { return null; }
|
|
for (const d of dirs) {
|
|
const candidate = `${root}/${d}/${sessionId}.jsonl`;
|
|
if (existsSync(candidate)) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
|
|
// (the live transcript is read mid-write, so the last line may be incomplete).
|
|
export function parseTranscriptLines(text) {
|
|
const out = [];
|
|
for (const line of text.split("\n")) {
|
|
const t = line.trim();
|
|
if (!t) continue;
|
|
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// A line marks the assistant turn complete when EITHER:
|
|
// (a) {type:"system", subtype:"turn_duration"} — emitted by newer claude builds
|
|
// (e.g. 2.1.159), OR
|
|
// (b) {type:"assistant"} whose message.stop_reason is a FINAL reason
|
|
// ("end_turn" / "stop_sequence" / "max_tokens"). This is the API-level
|
|
// end-of-turn signal, present across claude builds whose transcripts do NOT
|
|
// emit turn_duration (e.g. 2.1.114 — verified live on the cloud host). Without
|
|
// it OCP can't detect completion on those builds and hangs to the wallclock,
|
|
// then returns only partial text (issue #130, cloud/server-side symptom).
|
|
//
|
|
// stop_reason "tool_use" is deliberately NOT terminal: the model is mid-turn (it will
|
|
// run a tool and continue with a later assistant entry). Matching on a FINAL
|
|
// stop_reason — not on the mere presence of a tool_use — keeps tool-using turns intact.
|
|
// (The v3.17.1 narrowing dropped a buggy "tool_use is terminal" rule; this restores
|
|
// cross-version completion detection without bringing that bug back.)
|
|
const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"]);
|
|
export function isTerminalLine(obj) {
|
|
if (!obj || typeof obj !== "object") return false;
|
|
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
|
|
if (obj.type === "assistant" && obj.message && typeof obj.message === "object") {
|
|
return TERMINAL_STOP_REASONS.has(obj.message.stop_reason);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Text of the LAST assistant turn: concatenate its text content blocks
|
|
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
|
|
// Fixture-confirmed shape: top-level type:"assistant", message.content[] array.
|
|
//
|
|
// Scoping: this returns the FINAL text-bearing assistant entry in the whole file,
|
|
// not "text since the matching user line" (spec §4.2). Those are equivalent ONLY
|
|
// under OCP's one-session-per-request model (a fresh --session-id => a fresh
|
|
// transcript holding one logical exchange). If a future warm-pool ever reuses a
|
|
// session WITHOUT a fresh session-id / clear, earlier-turn text could leak — that
|
|
// author must add user-line scoping here. See spec §7.2.
|
|
//
|
|
// STATUS (warm pool, lib/tui/pool.mjs — the "future warm-pool" this note anticipated):
|
|
// the pool does NOT reuse sessions, so the precondition above still holds and no
|
|
// user-line scoping was added. Each pooled pane is booted with its OWN fresh
|
|
// randomUUID() --session-id (bootTuiPane) and is SINGLE-USE: it serves exactly one turn
|
|
// and is then killed and replaced. One session still means one logical exchange, so the
|
|
// last assistant entry is still that request's answer.
|
|
// The warning therefore stands UNCHANGED for anyone who later wants a pane to serve a
|
|
// SECOND turn (or to reset one with /clear and reuse it): that is a leak, and it needs
|
|
// user-line scoping HERE before it can be safe. Do not relax pool.mjs's single-use rule
|
|
// without doing that work first.
|
|
export function extractLatestAssistantText(events) {
|
|
let text = "";
|
|
for (const ev of events) {
|
|
if (!ev || ev.type !== "assistant") continue;
|
|
const content = ev.message && ev.message.content;
|
|
if (!Array.isArray(content)) continue;
|
|
const parts = content
|
|
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
.map((b) => b.text);
|
|
if (parts.length) text = parts.join("");
|
|
}
|
|
return text;
|
|
}
|
|
|
|
// Returns the entrypoint string (e.g. "cli") used for the billing-pool assertion,
|
|
// or null if absent. Lets callers assert the subscription-classified path.
|
|
//
|
|
// Resolution order (C-3, issue #133):
|
|
// 1. PREFER the turn_duration system line's `entrypoint` — the authoritative
|
|
// end-of-turn classifier emitted by builds that produce turn_duration
|
|
// (e.g. claude-2.1.104/2.1.157 on PI231).
|
|
// 2. FALL BACK to the `entrypoint` field on ANY ordinary transcript line
|
|
// (assistant / user / attachment / system) — present on BOTH emitting and
|
|
// non-emitting builds. Some claude builds (e.g. certain Mac mini transcripts)
|
|
// do NOT emit a turn_duration line at all; reading ONLY turn_duration made the
|
|
// caller's tui_entrypoint_mismatch assertion (server.mjs) get got:null every
|
|
// turn and go blind. The entrypoint value is identical across line types within
|
|
// a single interactive session (fixture-confirmed: every line in
|
|
// complete-haiku.jsonl carrying `entrypoint` reads "cli"), so the fallback
|
|
// yields the same classifier. Last-writer-wins on the fallback.
|
|
export function verifyEntrypoint(events) {
|
|
let fallback = null;
|
|
for (const ev of events) {
|
|
if (!ev || typeof ev !== "object") continue;
|
|
if (ev.type === "system" && ev.subtype === "turn_duration" && ev.entrypoint != null) {
|
|
return ev.entrypoint; // authoritative — short-circuit
|
|
}
|
|
if (ev.entrypoint != null) fallback = ev.entrypoint;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
// ── C-1: honest AUTH-FAILURE banner detection (issue #133) ───────────────
|
|
// When the interactive `claude` CLI hits an in-session error it does NOT crash —
|
|
// it renders the error as ordinary assistant text in the transcript. The specific
|
|
// failure C-1 exists to catch is R-1: EXPIRED / INVALID credentials, where every
|
|
// turn comes back as the same one-line auth-failure banner and OCP, none the wiser,
|
|
// caches that banner (server.mjs setCachedResponse), shares it via singleflight, and
|
|
// records a model SUCCESS — so a hard auth error is silently served (and cached for
|
|
// the 5-min TTL) as a real answer. The two live-reproduced banners on PI231
|
|
// (2026-06-10) are:
|
|
// "Please run /login · API Error: 401 Invalid authentication credentials" (69 chars)
|
|
// "Failed to authenticate. API Error: 401 Invalid authentication credentials" (73 chars)
|
|
//
|
|
// WHY THE SCOPE IS NARROW (conservatism — the load-bearing design choice).
|
|
// An earlier generalised rule (^<short-prefix>?API Error:\s*\d{3}\b.*$) was TOO
|
|
// BROAD: its unbounded `.*` tail let any short prefix + "API Error: NNN" + an
|
|
// arbitrarily long sentence match, so it KILLED legitimate long answers that merely
|
|
// DISCUSS an API error (e.g. "API Error: 500 happened because the server was
|
|
// overloaded. To fix this, retry with exponential backoff …"). That is the worst
|
|
// outcome: a false-positive costs the user a missing answer AND a double-burn retry,
|
|
// whereas the rare false-negative (caching one transient error for the 5-min TTL) is
|
|
// cheap and self-healing. So C-1 is reframed from "detect ANY API error" to "detect
|
|
// a claude-CLI AUTHENTICATION-FAILURE banner", and when unsure it PASSES (does not
|
|
// kill). Transient 5xx server errors are deliberately NOT detected — they are not the
|
|
// R-1 case and the conservative choice is to let them through.
|
|
//
|
|
// THE SIGNAL — a turn is an auth-failure banner only if ALL of these hold over the
|
|
// WHOLE trimmed assistant text (a conjunction; any one failing => PASS):
|
|
// 1. SHORT whole-message. Real banners are one short line (the two live samples are
|
|
// 69 and 73 chars). Cap = TUI_ERR_MAX_LEN (100) — headroom over 73 for a
|
|
// slightly longer future banner, while still rejecting multi-sentence prose. A
|
|
// long answer that happens to discuss auth (no code chars, e.g. 226 chars) is
|
|
// rejected on length alone.
|
|
// 2. Contains "API Error: 4\d{2}" — auth failures are 4xx (401/403). This rejects
|
|
// transient 5xx ("API Error: 500/503 …") and bare "HTTP 401 means unauthorized."
|
|
// (no "API Error:" core).
|
|
// 3. Contains an auth KEYWORD — authenticat | /login | credential (case-insensitive).
|
|
// This rejects answers that quote a 4xx but are not auth banners, e.g.
|
|
// "To debug a 401: the server returns API Error: 401 Unauthorized …"
|
|
// ("Unauthorized" is authoriz-, not authenticat-; no /login, no credential).
|
|
// 4. Contains NO backtick or quote char (` ' "). A real CLI banner is plain text;
|
|
// backticked/quoted text signals an answer that is QUOTING the error rather than
|
|
// being the banner, e.g. "You'll see `API Error: 401` … run /login to fix it."
|
|
// (75 chars — passes 1-3 but is excluded here). This is the conservative tie-
|
|
// breaker for short instructional answers.
|
|
//
|
|
// Worked matrix (all required cases pass — see test-features.mjs C-1 block):
|
|
// KILL: "Please run /login · API Error: 401 Invalid authentication credentials"
|
|
// KILL: "Failed to authenticate. API Error: 401 Invalid authentication credentials"
|
|
// PASS: "API Error: 500 happened because the server was overloaded. …" (not 4xx)
|
|
// PASS: "Failed to parse the config. Here are the API Error: 401 details …" (too long + no auth-kw)
|
|
// PASS: "To debug a 401: … API Error: 401 Unauthorized, then you refresh …" (no auth-kw)
|
|
// PASS: "Here is the handler … It logs the string API Error: 503 …" (not 4xx)
|
|
// PASS: "You'll see `API Error: 401` … run /login to fix it." (has backtick)
|
|
// PASS: "HTTP 401 means unauthorized." (no API Error core)
|
|
// PASS: "The capital of France is Paris." (nothing matches)
|
|
//
|
|
// OPERATOR OVERRIDE (unchanged): CLAUDE_TUI_ERROR_PATTERNS lets an operator REPLACE
|
|
// the default auth-banner detector with their own newline- or `||`-separated JS regex
|
|
// source strings (each auto-anchored ^…$ over the trimmed text, case-insensitive). A
|
|
// non-empty override uses ONLY those regexes (the narrowed default is bypassed); an
|
|
// empty / whitespace-only override DISABLES detection entirely (escape hatch).
|
|
|
|
// Whole-message length cap for the default auth-banner detector. Real banners are
|
|
// 69/73 chars; 100 gives headroom while still rejecting multi-sentence prose.
|
|
const TUI_ERR_MAX_LEN = 100;
|
|
// 4xx "API Error:" core — auth failures are 4xx (401/403), never 5xx.
|
|
const TUI_ERR_4XX = /API Error:\s*4\d{2}\b/i;
|
|
// Auth keyword — the message must be about authentication, not just quote a 4xx.
|
|
const TUI_ERR_AUTH_KW = /authenticat|\/login|credential/i;
|
|
// Code/quote chars — their presence signals prose QUOTING an error, not the banner.
|
|
const TUI_ERR_CODE_CHAR = /[`'"]/;
|
|
|
|
// Default detector: returns true iff `trimmed` IS a claude-CLI auth-failure banner
|
|
// (all four signals above). Conservative — any signal failing => false (PASS).
|
|
function isDefaultAuthFailureBanner(trimmed) {
|
|
if (trimmed.length > TUI_ERR_MAX_LEN) return false; // 1. short whole-message
|
|
if (!TUI_ERR_4XX.test(trimmed)) return false; // 2. 4xx API Error core
|
|
if (!TUI_ERR_AUTH_KW.test(trimmed)) return false; // 3. auth keyword
|
|
if (TUI_ERR_CODE_CHAR.test(trimmed)) return false; // 4. no code/quote chars
|
|
return true;
|
|
}
|
|
|
|
// Compile an OPERATOR-SUPPLIED pattern set (override path only). Each source is
|
|
// anchored ^…$ over the trimmed text and matched case-insensitively (`s` so `.` spans
|
|
// a multi-line banner). A pattern that fails to compile is skipped (never throws into
|
|
// the request path).
|
|
function compileTuiErrorPatterns(raw) {
|
|
const sources = String(raw).split(/\r?\n|\|\|/).map((s) => s.trim()).filter(Boolean);
|
|
const out = [];
|
|
for (const src of sources) {
|
|
try { out.push(new RegExp(`^(?:${src})$`, "is")); } catch { /* skip bad pattern */ }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Returns the matched banner text (the trimmed assistant text) if `text` IS a claude-
|
|
// CLI auth-failure banner in its entirety, else null. `patternsRaw` defaults to
|
|
// process.env.CLAUDE_TUI_ERROR_PATTERNS:
|
|
// - undefined → narrowed default auth-banner detector (isDefaultAuthFailureBanner).
|
|
// - non-empty → operator regex override REPLACES the default.
|
|
// - empty/ws → detection disabled (escape hatch).
|
|
export function detectTuiUpstreamError(text, patternsRaw = process.env.CLAUDE_TUI_ERROR_PATTERNS) {
|
|
if (typeof text !== "string") return null;
|
|
const trimmed = text.trim();
|
|
if (!trimmed) return null;
|
|
if (patternsRaw == null) {
|
|
return isDefaultAuthFailureBanner(trimmed) ? trimmed : null;
|
|
}
|
|
// Operator override path: empty/whitespace disables; otherwise use only their regexes.
|
|
const patterns = compileTuiErrorPatterns(patternsRaw);
|
|
if (patterns.length === 0) return null;
|
|
for (const re of patterns) {
|
|
if (re.test(trimmed)) return trimmed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Block until the session transcript is terminal (turn_duration / final
|
|
// stop_reason) or the wall-clock cap elapses, polling the file (no fs.watch —
|
|
// robust over NFS / editors). Returns { text, entrypoint, truncated }:
|
|
// - text: latest assistant text.
|
|
// - entrypoint: billing-pool classifier (see verifyEntrypoint), or null.
|
|
// - truncated: FALSE when a terminal marker was reached (the turn completed);
|
|
// TRUE when the wall-clock cap was hit with partial text but NO
|
|
// terminal marker (the turn is INCOMPLETE — what we have is a
|
|
// cut-off prefix). (C-2, issue #133.)
|
|
//
|
|
// Why `truncated` matters: previously the terminal-marker path and the
|
|
// cap-with-partial-text path BOTH returned `{text, entrypoint}` identically, so
|
|
// callClaudeTui could not tell a complete answer from a truncated one and cached +
|
|
// returned the partial as finish_reason:stop (silent success). The caller now
|
|
// throws on `truncated` so a cut-off turn is neither cached nor counted as success.
|
|
// The field is additive — existing call sites that ignore it keep working.
|
|
//
|
|
// On cap with NO text at all, still throws (unchanged) — there is nothing to return.
|
|
//
|
|
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
|
|
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
|
|
// Resolution: pass an explicit `transcriptPath` (used by unit tests), OR pass
|
|
// `home` + `sessionId` to resolve by glob each poll (production) — the transcript
|
|
// file does not exist until the turn starts, so resolution happens inside the loop.
|
|
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
|
|
const deadline = Date.now() + wallclockMs;
|
|
let lastText = "";
|
|
let lastEntrypoint = null;
|
|
while (Date.now() < deadline) {
|
|
const resolved = p || findTranscriptPath(home, sessionId);
|
|
if (resolved && existsSync(resolved)) {
|
|
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
|
|
lastText = extractLatestAssistantText(events) || lastText;
|
|
const ep = verifyEntrypoint(events);
|
|
if (ep != null) lastEntrypoint = ep;
|
|
// Terminal marker reached → the turn is COMPLETE.
|
|
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint, truncated: false };
|
|
}
|
|
await sleep(pollMs);
|
|
}
|
|
// Cap elapsed with no terminal marker. If we have partial text, flag it truncated
|
|
// so the caller rejects it (don't cache / don't count as success). No text at all
|
|
// → throw (nothing to return).
|
|
if (lastText) return { text: lastText, entrypoint: lastEntrypoint, truncated: true };
|
|
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
|
}
|