mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
* feat(tui): real SSE streaming via claude's MessageDisplay hook (OCP_TUI_STREAM, default off) Backlog #2. TUI-mode `stream:true` turns can now emit real SSE `delta.content` chunks as `claude` generates them, instead of buffering the turn and replaying it with streamStringAsSSE. Opt-in: with OCP_TUI_STREAM unset/0 the spawn argv, the SSE bytes and the cache behaviour are byte-for-byte unchanged (asserted by test). This PR does NOT mirror any cli.js function, so no `cli.js:NNNN` citation applies, and per CLAUDE.md's hard requirement #1 that is stated explicitly here rather than left implicit: - We consume claude's OWN `MessageDisplay` hook surface AS EMITTED — forwarding, not inventing. No new endpoint, no fabricated protocol, no new field. - The TUI spawn is OCP-owned surface: ADR 0007 owns it, not cli.js. - The SSE wire shapes are the OpenAI chat/completions streaming spec, adopted by ADR 0006. Every frame emitted here (role chunk, content-delta chunk, stop chunk, `[DONE]`, and the post-header {error:{message,type}} frame) is COPIED from callClaudeStreaming, the -p path. /health gains additive fields only (streamEnabled + 4 counters) — same grandfathered B.2 rationale as the existing tui block (ADR 0006). Existing keys are untouched. `claude` fires MessageDisplay per rendered block, handing the hook the RAW MARKDOWN SOURCE of an incremental delta on stdin. The hook is registered with `--settings` on the ordinary interactive spawn (no -p, no --bare) — verified to leave the billing pool alone. Sink: a static sh hook script appends each payload to `<streamDir>/<session_id>.jsonl`; OCP polls that file and forwards deltas as SSE. The per-session-id keying is MANDATORY, not an optimization — OCP_TUI_MAX_CONCURRENT defaults to 2, so two claude panes already run at once and a shared sink would splice one client's deltas into another's stream. Warm-pool compatible (a separate in-flight PR depends on this): the hook script AND the settings file are static — nothing request-specific is baked in at spawn time. The sink path reaches the pane through its own env (OCP_TUI_STREAM_FILE) and derives from the session-id, which a pre-booted pane fixes at boot. The hook is SYNCHRONOUS (forceSyncExecution: claude blocks on it), so the script writes and exits: one `cat` append, nothing else. Measured p50 7.2ms / p90 14.7ms per fire, ~50ms across a whole turn — noise against a 6-10s turn. It remains the terminal-turn signal, the source of the returned/cached text T, and the input to the honesty gates. The delta stream is a low-latency MIRROR, never a replacement: - the truncation gate (C-2) and auth-banner gate (C-1, issue #133) run BEFORE anything is committed or flushed, unchanged; - at end of turn the streamed bytes are asserted against T. Equal -> serve. A strict PREFIX of T -> top up from the transcript so the client still receives exactly T (counted). NOT a prefix -> REFUSE the turn: SSE error frame, no cache, no success, streamDivergences++. Serving text the transcript disagrees with is the failure class ALIGNMENT.md exists to prevent, so this fails loud rather than degrading quietly; - only T is ever cached — never the concatenated deltas. The auth banner needs prevention, not just detection (SSE deltas cannot be un-sent), so the first OCP_TUI_STREAM_HOLDBACK (100) chars are withheld: the default banner detector cannot match a message longer than 100 chars, so releasing past that provably cannot leak a banner. A custom CLAUDE_TUI_ERROR_PATTERNS has no such bound — OCP warns at boot. - BANNER, before/after the spawn change: `Sonnet 4.6 with low effort · Claude Max` both, including on the pane the server itself spawns. Never `API Usage Billing`. Transcript entrypoint stays "cli". --settings is not a --bare-class flag. - --settings MERGES with <HOME>/.claude/settings.json rather than clobbering it (the user-level settings' `env` block still reached the hook), so the isolated-HOME settings story (permissions / additionalDirectories) survives. - EXACTNESS: 8/8 varied prompts (short, long, markdown, code fence, multilingual, JSON, table, unicode) byte-exact vs transcript T, streamed AND buffered. 0 top-ups, 0 divergences over 15 streamed turns. - TTFT: buffered delivers NOTHING until the turn ends (TTFB == total, 7.5-15.8s). Streamed sends headers at ~25ms (heartbeat covers the pre-first-delta silence) and first content mid-generation, e.g. markdown 7.9s first chunk / 12.8s total; long 9.7s / 17.4s. - CONCURRENCY DEMUX: two concurrent streamed turns (ALPHA/BRAVO), tui.inflight peaked at 2, each read its own session-keyed transcript, ZERO cross-contamination. - AUTH-BANNER GATE under streaming, both layers: a short banner-like turn reached the client as 0 content chunks + an SSE error frame (never emitted); a long one was streamed but still ended on an error frame, not finish_reason:"stop", and was not cached. - DISCONNECT mid-turn: pane torn down and semaphore slot released within 1s (info-logged, not booked as a model error). - THINKING: not leaked. Opus 4.8 + xhigh turns carry a thinking block with a signature but `thinking:""` (the reasoning text is not persisted in interactive mode), both MessageDisplay text-extraction sites in the 2.1.207 bundle filter type==="text", and no reasoning prose appeared in any delta; concat===T held exactly on the single-message turn. - npm test: 282 passed, 0 failed (was 267 on main; +15). The transcript keeps only the model's LAST assistant message. A turn where the model narrates before calling a tool therefore has two messages, and T is only the second. If the narration exceeds the holdback it has already been streamed and cannot be retracted -> the turn is REFUSED. Reproduced live: Opus narrated 475 chars before a Bash call. The assembler discards a prior message's text when nothing has been emitted yet (so short narration is handled correctly and stays exact), and raising OCP_TUI_STREAM_HOLDBACK above the narration length rescues the turn — verified on that exact transcript: holdback>=500 -> served, exact=true. Documented in README and ADR 0007; this is why streaming is opt-in and off by default. ADR 0007 line 59 ("no real token streaming — deliberate") is amended, not silently contradicted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tui): re-integrate streaming onto the warm-pane pool (#158) — install the hook at BOOT Rebasing backlog #2 (streaming) onto #158 (warm pane pool) is not a textual merge: #158 split the monolithic runTuiTurn into bootTuiPane + runTuiTurn, and streaming had patched the monolith. Re-integrating it in the OLD shape would have compiled, passed every existing test, and been WRONG. The bug that shape would have shipped: the sink was derived at TURN time from a streamDir argument. But a POOLED pane is pre-booted long before any request exists — so on a pool HIT runTuiTurn never cold-boots, no hook was ever registered on that pane, and the turn would silently serve BUFFERED. Every miss streams, every hit does not; no error, no failing test. The operator sees "streaming does nothing in production" and has nothing to grep for. Fix — install the hook where the pane is born: - bootTuiPane({ streamDir }) registers the MessageDisplay hook at spawn and returns the pane's own sink (pane.streamFile), keyed by the pane's own --session-id. The hook script and settings file are STATIC (one pair per streamDir); the only per-turn thing is the sink path, and it is fixed at boot. So nothing request-specific is baked into a spawn. - runTuiTurn reads pane.streamFile — never recomputes it — so a warm pane and a cold pane stream through byte-for-byte the same path. - server.mjs threads the same streamDir into the pool's bootPane closure, so pre-booted panes carry the hook too. TUI_STREAM/TUI_STREAM_DIR now declare before the pool needs them. Three regression guards added (test-features.mjs), and the third was MUTATION-TESTED: with the fix reverted to the turn-time shape it fails ("the pooled pane's deltas must reach the client"), with the fix in place it passes. A guard nobody has watched fail is not a guard. /health: pool + stream* fields are now a union — the shape assertion asserts CONTAINMENT of the seven grandfathered keys plus an exact added-set, so a future field that silently REPLACED an original key cannot pass. Class B (ADR 0007, OCP-owned TUI spawn) — cli.js does NOT perform this operation. npm test: 313 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR * fix(tui): close the streaming auth-banner leak + 6 further review findings (PR #159) Independent review (Iron Rule 10) found a HIGH bug by EXECUTING the code, not reading it. All seven findings fixed. F1 and F3 were merge-blocking. F1 (HIGH) — the auth-banner holdback was bypassed after the first release. TuiDeltaAssembler.released was set once and never reset at a message_id boundary, so the holdback + detectError predicate guarded only the FIRST message of a turn. In production's own configuration (OCP_TUI_FULL_TOOLS=1, where multi-message tool-using turns are the norm): the model narrates past the holdback before a tool call -> released; credentials expire mid-turn -> claude renders the 401 as ordinary assistant TEXT as a NEW message -> push() took the `if (this.released)` branch and handed the banner verbatim to the client. That is precisely the silent-error case the C-1 gate exists to prevent. Detection survived (the turn was still refused at finalize) but PREVENTION did not. Fix: once a message boundary follows an emit, the turn is already unrecoverable — finalize() will refuse it — so push() now emits NOTHING further for the rest of the turn. Second hole in the same predicate: detectTuiUpstreamError() trims before applying its <=100-char rule, so 101 whitespace chars trimmed to "" -> detector had nothing to classify -> returned null -> release fired having screened nothing. Release now gates on the TRIMMED length, so both sides of the check talk about the same string. F2 — the "provably safe" claim in stream.mjs, ADR 0007 and README was unsound as written. Restated with both required halves: (i) nothing is emitted until the trimmed accumulation exceeds the detector's max banner length, AND (ii) no emission at all once a message boundary follows an emit. Half (i) alone only ever covered a turn's first message. F3 (blocker) — prepareStreamHook was write-if-missing, so md-hook.sh could never be updated OR repaired: a host that booted once under an older version was stuck on that HOOK_SCRIPT forever, and a non-atomic write interrupted mid-flight left a TRUNCATED script that existsSync() called fine — on a hook claude BLOCKS on synchronously. Now written unconditionally via tmp+renameSync (the pattern already used by ensureTuiCwdTrusted). F4 — the two spawn paths differed for non-streaming requests: the pool installed the hook whenever OCP_TUI_STREAM was on (correct — a pre-booted pane cannot know what request it will serve), but the cold path gated it on this turn's onDelta. So one stream:false request got --settings on a pool HIT and not on a MISS: two spawn argvs for the identical request, on this project's billing-classification surface. Both paths now gate on TUI_STREAM alone; whether the sink is POLLED remains correctly gated on onDelta. F5 — pool._drop() killed the pane but orphaned its sink file; the reap tick drains the whole pool, so sinks accumulated with no GC path. Now removed best-effort on every drop path. F6 — /health counters did not measure what they documented: streamTurns was incremented only AFTER the honesty gates, hiding exactly the turns an operator most wants to see (and making streamDivergences/streamTurns a meaningless ratio); streamDeltas counted every fire while claiming to count forwarded ones. Counters and docs now agree. F7 — total hook failure was silent: zero fires per turn still yields ok:true/exact:false and a normal, fully-buffered answer. Only streamTopUps moved, which the code itself calls benign. Added streamZeroDeltaTurns (+ a tui_stream_zero_deltas warning) to separate "the hook is dead" from "one fire was dropped". Tests: 316 passed, 0 failed (was 313). Every new guard MUTATION-TESTED — with each fix reverted the guard named for it fails, and passes with the fix restored: - drop the restartedAfterEmit guard -> 2 failed (incl. the strengthened old test) - revert trim() in the release gate -> 1 failed - revert F3 to write-if-missing -> 1 failed The pre-existing test "new message_id AFTER an emit" asserted finalize().ok === false but never checked what push() RETURNED — so it passed while F1 was live, documenting the leak instead of catching it. Strengthened to assert the emission, not just the verdict. Class B (ADR 0007, OCP-owned TUI spawn) — cli.js does NOT perform this operation. Co-Authored-By: Claude Opus 4.8 <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>
322 lines
18 KiB
JavaScript
322 lines
18 KiB
JavaScript
import { rmSync } from "node:fs";
|
|
|
|
// TUI warm pane pool (docs/plans/2026-07-13-tui-latency backlog #3).
|
|
//
|
|
// WHAT IT IS: a small set of PRE-BOOTED `claude` panes, each already sitting at its
|
|
// input bar, so a request does not pay the cold boot. Opt-in: OCP_TUI_POOL_SIZE=0
|
|
// (default) disables it entirely and the request path is byte-for-byte today's.
|
|
//
|
|
// ── SINGLE-USE IS THE LOAD-BEARING RULE ─────────────────────────────────────
|
|
// A pooled pane serves EXACTLY ONE turn and is then killed and replaced in the
|
|
// background. Each pane carries its OWN fresh `--session-id`, fixed at boot, and the
|
|
// turn locates its transcript by that id. So OCP's one-session-per-request model is
|
|
// preserved: a session's transcript still holds exactly one logical exchange.
|
|
// That is what keeps lib/tui/transcript.mjs's extractLatestAssistantText (which returns
|
|
// the LAST text-bearing assistant entry in the whole file, not "text since the matching
|
|
// user line") correct — see the scoping note there. A pane MUST NEVER serve a second
|
|
// turn, and a session MUST NEVER be reset with /clear and reused: either would put two
|
|
// exchanges in one transcript and leak the earlier turn's text into the later turn's
|
|
// answer. Nothing here reuses a pane; keep it that way.
|
|
//
|
|
// ── WHY IT'S WORTH MORE THAN THE BOOT TIME ──────────────────────────────────
|
|
// Measured on this host (n=6 through OCP, Sonnet 4.6, --effort low): the cold path
|
|
// spends ~1.23 s reaching the input bar, but ALSO ~2.9 s inside the first turn beyond
|
|
// what claude itself reports as the turn duration — post-input-bar init that a pane
|
|
// which has been idle for a few seconds has already finished. A warm pane recovers both.
|
|
//
|
|
// ── COST (bounded, and paid whether or not a request arrives) ───────────────
|
|
// Each warm pane is a LIVE `claude` process (plus its tmux pane) sitting idle. Peak
|
|
// process count is (pool size) + (OCP_TUI_MAX_CONCURRENT in-flight turns) + (panes
|
|
// currently booting as replacements). Pool size is clamped to POOL_MAX_SIZE.
|
|
//
|
|
// Pure + injectable (bootPane / killPane / paneHealthy / now) so test-features.mjs can
|
|
// assert acquire / miss / refill / TTL / reaper-exemption with no tmux and no claude.
|
|
|
|
// Hard cap on OCP_TUI_POOL_SIZE. Each pane is an idle claude process; 4 is already a
|
|
// lot of resident memory on a small host (a Pi serving a family) for zero in-flight work.
|
|
export const POOL_MAX_SIZE = 4;
|
|
|
|
// A warm pane older than this is dropped on acquire rather than handed out. The periodic
|
|
// reap tick (server.mjs) drains the pool every 15 min anyway, so this only bites when
|
|
// that tick kept getting skipped because the TUI path was never idle. Guards against
|
|
// handing out a pane whose `claude` has been sitting so long it may have drifted
|
|
// (auto-compaction prompts, an idle-disconnect banner, an expired in-pane token).
|
|
export const POOL_MAX_AGE_MS = 10 * 60 * 1000;
|
|
|
|
// Clamp the operator-supplied size into [0, POOL_MAX_SIZE]. A garbage value disables the
|
|
// pool rather than guessing — an unparseable size must never silently boot 4 processes.
|
|
export function resolvePoolSize(raw) {
|
|
const n = parseInt(raw, 10);
|
|
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
return Math.min(n, POOL_MAX_SIZE);
|
|
}
|
|
|
|
export class TuiPanePool {
|
|
// size: target number of warm panes (0 = disabled).
|
|
// maxAgeMs: per-pane TTL (see POOL_MAX_AGE_MS).
|
|
// mintPane: () => ({ sessionId, name }) — mints the identity of the NEXT pane. The POOL,
|
|
// not the boot function, owns this: the tmux session springs into existence the
|
|
// instant bootPane starts, so the pool must already know its NAME (see
|
|
// _bootingPane below). Deriving the name from the sessionId also makes `tmux ls`
|
|
// correlate to the transcript file.
|
|
// bootPane: async (model, {sessionId, name}) => { name, sessionId, model, bootedAt } —
|
|
// boots ONE pane under exactly that identity and resolves only once it is
|
|
// input-ready; throws if it never becomes ready.
|
|
// killPane: (name) => void — tmux kill-session. MUST be synchronous (see drain).
|
|
// paneHealthy:(name) => bool — pane still exists AND is still at its input bar.
|
|
constructor({ size, maxAgeMs = POOL_MAX_AGE_MS, mintPane, bootPane, killPane, paneHealthy, now = Date.now, log = () => {} }) {
|
|
this.size = Math.max(0, Math.min(parseInt(size, 10) || 0, POOL_MAX_SIZE));
|
|
// Fail fast at CONSTRUCTION, not at request time. refill() is called synchronously from
|
|
// the request path (runTuiTurn), so a missing collaborator would otherwise surface as a
|
|
// 500 on a live request instead of a loud error at boot.
|
|
if (this.size > 0) {
|
|
for (const [k, fn] of [["mintPane", mintPane], ["bootPane", bootPane], ["killPane", killPane], ["paneHealthy", paneHealthy]]) {
|
|
if (typeof fn !== "function") throw new TypeError(`TuiPanePool: ${k} must be a function`);
|
|
}
|
|
}
|
|
this.maxAgeMs = maxAgeMs;
|
|
this._mintPane = mintPane;
|
|
this._bootPane = bootPane;
|
|
this._killPane = killPane;
|
|
this._paneHealthy = paneHealthy;
|
|
this._now = now;
|
|
this._log = log;
|
|
|
|
this._panes = []; // warm, available panes: { name, sessionId, model, bootedAt }
|
|
// The pane currently BOOTING, BY NAME ({sessionId, name, model}) — or null.
|
|
//
|
|
// WHY A NAME AND NOT A COUNT (this is a fixed bug, don't regress it): bootTuiPane creates
|
|
// the tmux session SYNCHRONOUSLY and only THEN waits up to POOL_BOOT_MS (20 s) for the
|
|
// input bar. So for up to 20 s there is a LIVE pooled tmux session. When the pool tracked
|
|
// only a count, it could not NAME that session, so:
|
|
// - liveNames() could not spare it and the periodic reap sweep KILLED it (and
|
|
// kill-server'd on top), leaving the pool empty with nothing scheduled and firing the
|
|
// very tui_pool_boot_failed WARN operators are told to alert on; and
|
|
// - drain() could not kill it, so on shutdown it ORPHANED a live authenticated `claude`
|
|
// (the boot's .then that was supposed to clean up never runs — gracefulShutdown calls
|
|
// process.exit in the same tick).
|
|
// Both are fixed by holding the identity here, before the session exists.
|
|
this._bootingPane = null;
|
|
// Generation counter. Bumped whenever an in-flight boot is CANCELLED (drain / model
|
|
// switch). A boot compares the generation it started under against the current one:
|
|
// if they differ, its pane was already killed by us and its settle is inert — in
|
|
// particular a rejection is a CANCELLATION, not an operator-visible boot failure.
|
|
this._gen = 0;
|
|
this._paused = false; // true while drained; refill() is a no-op until resume()
|
|
this.warmModel = null; // the model the pool currently warms — learned from traffic (see acquire)
|
|
|
|
this.hits = 0; // requests served by a warm pane
|
|
this.misses = 0; // requests that fell back to the cold path
|
|
this.boots = 0; // panes successfully pre-booted
|
|
this.bootFailures = 0; // pre-boots that genuinely never reached the input bar
|
|
this.cancelled = 0; // in-flight boots WE killed (drain / model switch) — not failures
|
|
this.dropped = 0; // panes discarded unused (unhealthy / expired / wrong model / drained /
|
|
// cancelled — a cancelled in-flight boot also lands here via _drop)
|
|
}
|
|
|
|
get enabled() { return this.size > 0; }
|
|
get warm() { return this._panes.length; }
|
|
get booting() { return this._bootingPane ? 1 : 0; }
|
|
|
|
// The reaper's spare set: the EXACT names of every pane the pool currently owns and has NOT
|
|
// handed out — the warm ones AND the one currently booting (whose tmux session is already
|
|
// live; see _bootingPane). See the POOL/REAPER INVARIANT in lib/tui/session.mjs.
|
|
// Fail-safe by construction: a pane leaves this set the instant it is acquired, dropped, or
|
|
// cancelled, and if the pool is empty (or the process restarted) the set is empty — so an
|
|
// orphaned pooled pane looks exactly like any other stale session and IS reaped.
|
|
liveNames() {
|
|
const names = new Set(this._panes.map((p) => p.name));
|
|
if (this._bootingPane) names.add(this._bootingPane.name);
|
|
return names;
|
|
}
|
|
|
|
// Take a warm pane for `model`, or null (caller must fall back to the cold path — a MISS
|
|
// is always safe, never an error). Synchronous: paneHealthy is a cheap tmux capture.
|
|
//
|
|
// The pool warms the MOST RECENTLY REQUESTED model (`warmModel`). There is no boot-time
|
|
// pre-warm and no configured model: OCP cannot know which model the next caller wants, and
|
|
// pre-booting a process for a model nobody asks for is pure waste. Consequence, stated
|
|
// plainly: the FIRST request after start (and the first after a model switch) is always a
|
|
// MISS. The pool pays off for the steady repeat traffic it exists to serve.
|
|
acquire(model) {
|
|
if (!this.enabled) return null;
|
|
|
|
// Retarget on a model switch: --model is fixed at spawn, so panes for another model are
|
|
// useless. Drop them now (they are replaced by the next refill) rather than holding
|
|
// processes for a model that is no longer being asked for. This includes any pane
|
|
// currently BOOTING for the old model — its tmux session already exists, so leaving it to
|
|
// die on resolve would both hold a useless process and block the next refill (one boot at
|
|
// a time) for up to POOL_BOOT_MS.
|
|
if (model !== this.warmModel) {
|
|
for (const p of this._panes) { this._drop(p, "model_switch"); }
|
|
this._panes = [];
|
|
this._cancelBooting("model_switch");
|
|
this.warmModel = model;
|
|
}
|
|
|
|
while (this._panes.length) {
|
|
const p = this._panes.shift();
|
|
if (this._now() - p.bootedAt > this.maxAgeMs) { this._drop(p, "expired"); continue; }
|
|
if (!this._paneHealthy(p.name)) { this._drop(p, "unhealthy"); continue; }
|
|
this.hits++;
|
|
return p; // caller OWNS it now: it is out of the registry (so out of the spare set),
|
|
// and the caller's finally MUST kill it. Single-use — never returned here.
|
|
}
|
|
this.misses++;
|
|
return null;
|
|
}
|
|
|
|
// Bring the pool back up to `size` warm panes for `warmModel`. Fire-and-forget: never
|
|
// awaited on the request path and never throws into it.
|
|
//
|
|
// SLOT ACCOUNTING: a refill boot deliberately does NOT take a TuiSemaphore slot. Those
|
|
// slots bound concurrent *turns* (each up to the 120 s wallclock) and belong to real
|
|
// requests; charging a background pre-boot against them would let the pool starve the
|
|
// traffic it exists to speed up. It cannot leak a slot either, because it never holds one.
|
|
//
|
|
// SERIALIZED, ONE BOOT AT A TIME (and re-kicked on success until the pool is at target).
|
|
// An earlier version launched all `want` boots at once; live at size=2 that put two cold
|
|
// `claude` boots plus an in-flight turn on the CPU together, and a refill overran even the
|
|
// generous pool readiness cap (tui_pool_boot_failed). Booting sequentially keeps each boot
|
|
// near its uncontended ~1.2 s, bounds the CPU burst the pool can cause, and still has the
|
|
// replacement pane warm long before the next request arrives.
|
|
//
|
|
// A genuinely FAILED boot deliberately does NOT re-kick the chain — that is the backoff. A
|
|
// persistently failing boot (bad claude binary, no auth) would otherwise spin, respawning
|
|
// forever. The next natural trigger (the following request's refill, or the reap tick's
|
|
// resume) retries it. A CANCELLED boot is different: we killed it on purpose, nothing is
|
|
// wrong, and resume() is expected to start a fresh one immediately.
|
|
refill() {
|
|
if (!this.enabled || this._paused || !this.warmModel) return;
|
|
if (this._bootingPane) return; // one boot in flight at a time
|
|
if (this._panes.length >= this.size) return; // already at target
|
|
|
|
const model = this.warmModel;
|
|
const gen = this._gen;
|
|
// Mint the identity BEFORE booting: bootPane creates the tmux session synchronously, so
|
|
// the pool must be able to name (and therefore spare, and kill) it from this moment on.
|
|
const ident = this._mintPane();
|
|
this._bootingPane = { ...ident, model };
|
|
let enlisted = false;
|
|
Promise.resolve()
|
|
.then(() => this._bootPane(model, ident))
|
|
.then((pane) => {
|
|
// The world may have moved while we booted. If our generation was cancelled, kill the
|
|
// pane here rather than ASSUMING _cancelBooting already did.
|
|
//
|
|
// Why not just `return`: _cancelBooting kills by name, but the tmux session only EXISTS
|
|
// once _bootPane has actually run — and _bootPane is queued on a microtask (above). A
|
|
// caller that does refill() and then drain() in the SAME synchronous block would have
|
|
// _cancelBooting find nothing to kill (a no-op), bump the generation, and then this
|
|
// microtask would create the session, boot it fine, and — under a bare `return` — walk
|
|
// away from a LIVE authenticated `claude` that nothing owns. That is M1b in a new costume.
|
|
// No current call site does that, so this is defense-in-depth, not a live bug — 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 would reach it.
|
|
//
|
|
// Killing an already-dead session is a harmless no-op (_drop swallows it), so this is
|
|
// idempotent whether or not _cancelBooting got there first.
|
|
if (gen !== this._gen) { this._drop(pane, "cancelled_late"); return; }
|
|
// Otherwise: still possible the pool filled or retargeted without a cancellation.
|
|
if (this._paused || model !== this.warmModel || this._panes.length >= this.size) {
|
|
this._drop(pane, "stale_boot");
|
|
return;
|
|
}
|
|
this._panes.push(pane);
|
|
this.boots++;
|
|
enlisted = true;
|
|
})
|
|
.catch((e) => {
|
|
// A rejection from a CANCELLED generation is not a fault: it is almost always
|
|
// "tui_pane_not_ready", thrown because WE killed the pane out from under the boot.
|
|
// Counting it as a bootFailure would fire the exact WARN operators are told to alert
|
|
// on, for a completely healthy drain. Stay silent — _cancelBooting already counted
|
|
// this as a cancellation, so do NOT count it again here.
|
|
if (gen !== this._gen) return;
|
|
this.bootFailures++;
|
|
this._log("warn", "tui_pool_boot_failed", { model, error: e && e.message });
|
|
})
|
|
.finally(() => {
|
|
// ONLY the current generation's boot owns the booting slot. A stale settle must not
|
|
// clear a slot that a newer boot (started by resume()) already holds.
|
|
if (gen === this._gen) this._bootingPane = null;
|
|
if (enlisted) this.refill(); // continue toward target, still one at a time
|
|
});
|
|
}
|
|
|
|
// Kill the in-flight boot's pane, SYNCHRONOUSLY, and invalidate its generation. Returns 1
|
|
// if there was one, else 0. The tmux session already exists (bootPane created it before it
|
|
// started waiting for readiness), so this is a real kill, not a cancellation flag.
|
|
_cancelBooting(reason) {
|
|
if (!this._bootingPane) return 0;
|
|
this._gen++; // the in-flight boot's settle is now inert
|
|
this._drop(this._bootingPane, reason); // synchronous kill-session
|
|
this._bootingPane = null;
|
|
this.cancelled++;
|
|
return 1;
|
|
}
|
|
|
|
// Kill every pane the pool owns — warm AND currently booting — and stop refilling. Returns
|
|
// how many were killed.
|
|
//
|
|
// Called (a) before the periodic reap sweep — reapStaleTuiSessions can only reap defunct
|
|
// `claude` zombies via kill-server, and kill-server is suppressed while any live pooled pane
|
|
// exists (including a booting one), so without this drain the pool would permanently disable
|
|
// zombie reaping; and (b) on graceful shutdown, so no pane outlives the process as an orphan.
|
|
//
|
|
// EVERY KILL HERE IS SYNCHRONOUS, and that is load-bearing. It is NOT safe to leave the
|
|
// booting pane to clean itself up on resolve: gracefulShutdown calls process.exit() in the
|
|
// same tick as this drain (TUI panes are children of the tmux SERVER, not of node, so
|
|
// node's activeProcesses set is empty on a TUI host and the "wait for children" path exits
|
|
// immediately). A .then()/.catch() scheduled here would never run, and the pane would
|
|
// survive as an orphaned, authenticated, idle `claude`.
|
|
drain() {
|
|
this._paused = true;
|
|
let n = this._panes.length;
|
|
for (const p of this._panes) this._drop(p, "drain");
|
|
this._panes = [];
|
|
n += this._cancelBooting("drain_booting");
|
|
return n;
|
|
}
|
|
|
|
// Undo drain() and start refilling again. Because drain() CANCELLED the in-flight boot
|
|
// (rather than leaving it pending), the booting slot is free and this really does start a
|
|
// fresh boot — the pool is never left empty with nothing scheduled.
|
|
resume() {
|
|
this._paused = false;
|
|
this.refill();
|
|
}
|
|
|
|
// /health surface (additive).
|
|
stats() {
|
|
return {
|
|
size: this.size,
|
|
warm: this._panes.length,
|
|
booting: this.booting,
|
|
model: this.warmModel,
|
|
hits: this.hits,
|
|
misses: this.misses,
|
|
boots: this.boots,
|
|
bootFailures: this.bootFailures,
|
|
cancelled: this.cancelled,
|
|
dropped: this.dropped,
|
|
};
|
|
}
|
|
|
|
_drop(pane, reason) {
|
|
this.dropped++;
|
|
try { this._killPane(pane.name); } catch { /* already gone */ }
|
|
// F5: every drop path (expired / unhealthy / model_switch / drain / cancelled_late /
|
|
// stale_boot) ends up here, and the reap tick drains the WHOLE pool on every tick — so
|
|
// without this, every warm pane's sink orphans in streamDir with no GC path (killPane only
|
|
// reaches the tmux session, never the pane's OWN files). Best-effort: pane.streamFile is
|
|
// undefined for a still-booting identity (the sink path is only known once bootPane
|
|
// resolves) and rmSync(force:true) is already a no-op on a missing file, so this never
|
|
// throws into the reaper regardless of which drop path got here.
|
|
if (pane.streamFile) {
|
|
try { rmSync(pane.streamFile, { force: true }); } catch { /* best-effort GC */ }
|
|
}
|
|
this._log("info", "tui_pool_pane_dropped", { name: pane.name, reason });
|
|
}
|
|
}
|