mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
feat(tui): warm pane pool — single-use pre-booted panes, opt-in via OCP_TUI_POOL_SIZE (−41%) (#158)
* 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>
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
// 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 */ }
|
||||
this._log("info", "tui_pool_pane_dropped", { name: pane.name, reason });
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,12 @@ export function recordTuiEntrypoint(tuiStats, observed, expectedMode = "cli") {
|
||||
// Build the additive /health `tui` block (ADR 0007 PR-B amendment). Pure: given the
|
||||
// config + live counters, returns the exact object embedded in /health. New fields only —
|
||||
// behaviour-preserving for existing /health consumers (grandfathered B.2 under ADR 0006).
|
||||
export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent }, tuiStats, semaphore) {
|
||||
//
|
||||
// `pool` (optional, warm pane pool — lib/tui/pool.mjs): a TuiPanePool, or null/undefined
|
||||
// when the pool is off (the default). Reported as `pool: null` when off so the block's
|
||||
// shape stays stable, and as the pool's stats (size / warm / hits / misses / …) when on —
|
||||
// the operator's window onto both the hit rate and the standing idle-process cost.
|
||||
export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent }, tuiStats, semaphore, pool = null) {
|
||||
return {
|
||||
enabled,
|
||||
entrypointMode, // cli | auto | off
|
||||
@@ -148,5 +153,6 @@ export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent },
|
||||
inflight: semaphore.inflight, // current concurrent TUI turns
|
||||
queued: semaphore.queued, // turns waiting for a slot
|
||||
maxConcurrent,
|
||||
pool: pool ? pool.stats() : null, // warm pane pool, or null when disabled
|
||||
};
|
||||
}
|
||||
|
||||
+186
-62
@@ -73,6 +73,36 @@ const defaultTmux = (args, opts = {}) =>
|
||||
// `port` (required) is this instance's own listen port (server.mjs's PORT / lib/constants.mjs
|
||||
// DEFAULT_PORT resolution) — the SPOT for "which sessions are ours."
|
||||
//
|
||||
// ── POOL/REAPER INVARIANT (warm pane pool — lib/tui/pool.mjs) ───────────────────────────
|
||||
// A warm pooled pane is one of OUR OWN `ocp-tui-<port>-*` sessions that is ALIVE AND IDLE
|
||||
// BY DESIGN — and the periodic sweep runs precisely when the instance is idle, i.e. exactly
|
||||
// when the pool is full. Without an exemption the sweep would kill every warm pane on every
|
||||
// tick (and kill-server on top). The exemption is `spare`: a set of EXACT session names the
|
||||
// caller declares live. Three properties, all load-bearing:
|
||||
//
|
||||
// 1. A LIVE POOLED PANE IS NEVER REAPED — INCLUDING ONE THAT IS STILL BOOTING. It is in
|
||||
// `spare` (the pool's live registry), so it is skipped by name. The booting case is not
|
||||
// a footnote, it is the one that bit us: bootTuiPane creates the tmux session
|
||||
// SYNCHRONOUSLY and only then waits up to POOL_BOOT_MS for the input bar, so a pooled
|
||||
// session can be live for ~20 s before its boot resolves. The pool therefore mints the
|
||||
// pane's NAME up front and holds it in `_bootingPane`, so liveNames() can name — and
|
||||
// spare — a session whose boot has not finished. (An earlier version tracked only a
|
||||
// COUNT of in-flight boots; the sweep could not name that session and killed it.)
|
||||
// 2. A LEAKED/ORPHANED POOLED PANE IS STILL REAPED. Membership is by EXACT NAME from a
|
||||
// live in-memory registry — NOT by "looks pooled" (name shape). A pane the pool no
|
||||
// longer owns (handed out, dropped, cancelled, or left behind by a previous process
|
||||
// generation — whose registry died with it) is absent from `spare` and is killed like
|
||||
// any other stale session. Fail-safe: forgetting to pass `spare` reaps MORE, never less.
|
||||
// 3. KILL-SERVER NEVER KILLS A LIVE POOL PANE. A spared session suppresses kill-server
|
||||
// exactly as a foreign session does (it is a live child of the tmux server). The
|
||||
// consequence — that a permanently-full pool would permanently disable the defunct-
|
||||
// zombie reaping that ONLY kill-server can do — is resolved in server.mjs by DRAINING
|
||||
// the pool immediately before the sweep, so `spare` is empty on the normal tick and
|
||||
// kill-server still fires. `spare` is the belt-and-braces: a reap call site that
|
||||
// forgets to drain still cannot kill a live pane.
|
||||
//
|
||||
// `spare` (default: none) — iterable of session names, or a Set. Ignored when the pool is off.
|
||||
//
|
||||
// `includeLegacy` (default false): when true, sessions matching the exact OLD bare-prefix
|
||||
// shape (LEGACY_SESSION_NAME_RE) are ALSO treated as ours for kill-session purposes. This is
|
||||
// the boot-time legacy migration: an operator upgrading past this fix could otherwise be left
|
||||
@@ -88,14 +118,20 @@ const defaultTmux = (args, opts = {}) =>
|
||||
// same class of residual risk the audit finding itself accepts ("no live instance of the new
|
||||
// version creates them"); this PR does not regress that scenario, it only removes the far
|
||||
// more common same-version collision (the actual F7 finding).
|
||||
export function reapStaleTuiSessions({ tmux = defaultTmux, port, includeLegacy = false } = {}) {
|
||||
export function reapStaleTuiSessions({ tmux = defaultTmux, port, includeLegacy = false, spare = null } = {}) {
|
||||
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
|
||||
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
|
||||
const names = String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
const ownPrefix = sessionPrefixForPort(port);
|
||||
const spared = spare instanceof Set ? spare : new Set(spare || []);
|
||||
let killed = 0;
|
||||
let othersRemain = false;
|
||||
let sparedLive = 0;
|
||||
for (const name of names) {
|
||||
// Property 1+2: exemption is by EXACT NAME from the pool's live registry. A pooled-
|
||||
// LOOKING name that is not in the registry is an orphan and falls through to the
|
||||
// normal kill path below.
|
||||
if (spared.has(name)) { sparedLive++; continue; }
|
||||
const isOwn = name.startsWith(ownPrefix);
|
||||
const isLegacyOwn = includeLegacy && LEGACY_SESSION_NAME_RE.test(name);
|
||||
if (isOwn || isLegacyOwn) {
|
||||
@@ -109,7 +145,11 @@ export function reapStaleTuiSessions({ tmux = defaultTmux, port, includeLegacy =
|
||||
// Reap defunct `claude` zombies: safe ONLY when the server is now ours-only/empty.
|
||||
// kill-server is what actually reaps (server exit reparents survivors to init); a
|
||||
// per-session kill cannot, since node is not the zombies' parent.
|
||||
if (!othersRemain) {
|
||||
//
|
||||
// Property 3: a SPARED session is a live child of this tmux server, so kill-server would
|
||||
// kill it — it therefore suppresses kill-server exactly as a foreign session does. On the
|
||||
// normal sweep the pool is drained first, so sparedLive is 0 and kill-server still fires.
|
||||
if (!othersRemain && sparedLive === 0) {
|
||||
tmux(["kill-server"]);
|
||||
}
|
||||
return killed;
|
||||
@@ -119,6 +159,12 @@ export function reapStaleTuiSessions({ tmux = defaultTmux, port, includeLegacy =
|
||||
|
||||
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
|
||||
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); // max wait for input-ready
|
||||
// Readiness cap for a POOL pre-boot. Deliberately far more generous than BOOT_MS: BOOT_MS is
|
||||
// tight because a client is blocked on it, whereas a warm-pane boot happens in the background
|
||||
// with nobody waiting. Observed live at size=2: a refill booting alongside an in-flight turn
|
||||
// exceeded 4000 ms and was discarded (tui_pool_boot_failed), quietly costing hit rate for a
|
||||
// pane that was merely slow, not broken. Scales with OCP_TUI_BOOT_MS if an operator raises it.
|
||||
export const POOL_BOOT_MS = BOOT_MS * 5;
|
||||
const READY_POLL_MS = parseInt(process.env.OCP_TUI_READY_POLL_MS || "400", 10); // readiness / paste-verify poll interval
|
||||
const PASTE_VERIFY_MS = parseInt(process.env.OCP_TUI_PASTE_VERIFY_MS || "5000", 10); // max wait for pasted prompt to render
|
||||
|
||||
@@ -412,38 +458,60 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
// Full per-request TUI lifecycle:
|
||||
// 1. Pre-trust the scratch cwd (no trust dialog will appear).
|
||||
// 2. Write prompt to a 0600 temp file (no shell injection from prompt content).
|
||||
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd; poll
|
||||
// capture-pane until the `? for shortcuts` input bar appears (readiness-poll
|
||||
// replaces the old blind boot sleep). BOOT_MS is the max wait, not a fixed delay.
|
||||
// 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) —
|
||||
// reliable for large multi-line prompts where send-keys -l is not (issue #130).
|
||||
// Poll-verify the prompt landed in the input (placeholder gone / [Pasted text]);
|
||||
// fast-fail with tui_paste_not_landed if it never lands (prevents the 120s
|
||||
// wallclock "stuck typing" hang). Then submit with a SEPARATE Enter key event.
|
||||
// 5. Block on the native JSONL transcript (located by session-id) until terminal
|
||||
// marker or wall-clock cap.
|
||||
// 6. Always teardown: kill session + rm temp dir (even on throw).
|
||||
// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
|
||||
// classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
|
||||
export async function runTuiTurn({
|
||||
prompt,
|
||||
model,
|
||||
claudeBin,
|
||||
home,
|
||||
realHome,
|
||||
cwd,
|
||||
port,
|
||||
wallclockMs = 120000,
|
||||
entrypointMode = "cli",
|
||||
tmux = defaultTmux,
|
||||
// Is a pane alive AND still sitting at its input bar? Used by the warm pool to decide,
|
||||
// at hand-out time, whether a pre-booted pane is still usable (a dead/degraded pane must
|
||||
// become a MISS → cold path, never a hung turn). capture-pane exits non-zero when the
|
||||
// session no longer exists, so this covers "pane gone" and "pane not ready" in one call.
|
||||
export function tuiPaneHealthy(tmux, tmuxName) {
|
||||
const r = tmux(["capture-pane", "-p", "-t", tmuxName]);
|
||||
if (!r || r.status !== 0 || typeof r.stdout !== "string") return false;
|
||||
return tuiInputReady(r.stdout);
|
||||
}
|
||||
|
||||
// Pool pane names carry a "p" marker after the port-scoped prefix:
|
||||
// turn pane: ocp-tui-<port>-<8hex> (unchanged)
|
||||
// pool pane: ocp-tui-<port>-p<8hex>
|
||||
// Purely for operator legibility (`tmux ls` shows which panes are warm). It is NOT the
|
||||
// reaper's exemption mechanism — that is the exact-name spare set (see the POOL/REAPER
|
||||
// INVARIANT above), so a pooled-LOOKING orphan is still reaped. Both shapes start with
|
||||
// sessionPrefixForPort(port), so both remain reapable as "ours", and neither can match
|
||||
// LEGACY_SESSION_NAME_RE.
|
||||
export function poolPaneName(port, sessionId) {
|
||||
return sessionPrefixForPort(port) + "p" + sessionId.slice(0, 8);
|
||||
}
|
||||
|
||||
// Boot ONE interactive `claude` pane and wait for its input bar. Shared by the cold
|
||||
// request path (runTuiTurn) and the warm pool (lib/tui/pool.mjs) so a pooled pane is
|
||||
// spawned with byte-for-byte the same argv, HOME, cwd and trust preparation as a
|
||||
// cold-booted one — the pool must not become a second, drifting spawn path.
|
||||
//
|
||||
// Each pane gets its OWN fresh randomUUID() --session-id, fixed at boot. That is what
|
||||
// keeps a pooled pane single-use-safe: its transcript holds exactly one exchange.
|
||||
//
|
||||
// requireReady: the cold path tolerates a readiness timeout (it falls through and lets
|
||||
// the paste-verify decide — pre-existing behaviour, unchanged). The POOL sets it, because
|
||||
// a pane that never reached its input bar is worthless as a warm pane and must not be
|
||||
// enlisted: throw, let the pool count a bootFailure, and leave the request path to
|
||||
// cold-boot as usual.
|
||||
// bootMs: max wait for the input bar. Defaults to BOOT_MS (the REQUEST path's cap, which is
|
||||
// deliberately tight — a client is blocked on it). The POOL passes POOL_BOOT_MS instead: a
|
||||
// background pre-boot has nobody waiting on it, and capping it at the request-path's 4 s
|
||||
// made real refills fail (observed live: a refill booting alongside an in-flight turn took
|
||||
// >4 s and was discarded, silently lowering the hit rate). Slow != broken for a pre-boot.
|
||||
// `sessionId` / `name` (both optional): the caller may supply the pane's identity instead of
|
||||
// letting bootTuiPane mint it. The POOL does, because it must know the tmux session's NAME
|
||||
// before this function runs — the session is created synchronously below, well before the
|
||||
// readiness wait returns, so a pool that only learned the name on resolve could neither spare
|
||||
// the session from the reaper nor kill it on shutdown. Supplying BOTH also keeps the name's
|
||||
// hex suffix equal to the session-id's, so `tmux ls` correlates to the transcript file.
|
||||
export async function bootTuiPane({
|
||||
model, claudeBin, home, realHome, cwd, port, entrypointMode = "cli",
|
||||
tmux = defaultTmux, sessionId = null, name = null, requireReady = false, bootMs = BOOT_MS,
|
||||
}) {
|
||||
const sessionId = randomUUID();
|
||||
const sid = sessionId || randomUUID();
|
||||
// Port-scoped session name (F7 fix) — see sessionPrefixForPort / reapStaleTuiSessions
|
||||
// for why this instance's own listen port is the namespace discriminator.
|
||||
const tmuxName = sessionPrefixForPort(port) + sessionId.slice(0, 8);
|
||||
const tmuxName = name || (sessionPrefixForPort(port) + sid.slice(0, 8));
|
||||
const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real)
|
||||
const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source)
|
||||
|
||||
@@ -460,42 +528,97 @@ export async function runTuiTurn({
|
||||
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
||||
prepareTuiHome(rhome, ehome, cwd, { envTokenMode });
|
||||
|
||||
// Write prompt to a temp file (mode 0600) so the content never touches argv.
|
||||
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||
const promptFile = `${tmpDir}/prompt.txt`;
|
||||
writeFileSync(promptFile, prompt, { mode: 0o600 });
|
||||
|
||||
// Minimal env for spawnSync (tmux itself). The pane's claude env comes exclusively
|
||||
// from the `env` prefix string built inside buildTuiCmd — tmux does NOT forward the
|
||||
// spawning process's env to the pane, so the {env} here is intentionally minimal.
|
||||
const env = { ...process.env };
|
||||
env.HOME = ehome; // tmux needs HOME; all claude-specific vars go via buildTuiCmd prefix
|
||||
|
||||
// Boot the interactive session inside tmux, rooted at the scratch cwd.
|
||||
// Capture the result: if tmux new-session fails (status !== 0) there is no PTY, no
|
||||
// interactive spawn — abort BEFORE the boot wait rather than paste into a non-existent
|
||||
// session or issue a billing request without a verified interactive context.
|
||||
const spawnResult = tmux(
|
||||
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||
buildTuiCmd(claudeBin, model, sid, ehome, entrypointMode)],
|
||||
{ env },
|
||||
);
|
||||
if (!spawnResult || spawnResult.status !== 0) {
|
||||
throw new Error("tui_spawn_failed: tmux session not created");
|
||||
}
|
||||
|
||||
// Wait until claude's input bar is actually ready (not a blind sleep).
|
||||
// bootMs is the MAX readiness wait, not a fixed delay.
|
||||
const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
|
||||
{ timeoutMs: bootMs, intervalMs: READY_POLL_MS });
|
||||
if (!ready) {
|
||||
if (requireReady) {
|
||||
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||
throw new Error("tui_pane_not_ready: input bar did not appear within " + bootMs + "ms");
|
||||
}
|
||||
// Cold path (pre-existing behaviour): readiness timed out; rely on paste-verify.
|
||||
console.error("[tui] input_not_ready", tmuxName);
|
||||
}
|
||||
return { name: tmuxName, sessionId: sid, model, ehome, bootedAt: Date.now() };
|
||||
}
|
||||
|
||||
// Full per-request TUI lifecycle:
|
||||
// 1. Take a WARM pane from the pool if one is available for this model (opt-in;
|
||||
// OCP_TUI_POOL_SIZE=0 => always null => steps 2-3 below are exactly today's path).
|
||||
// A pooled pane is SINGLE-USE: it already carries its own fresh --session-id, it
|
||||
// serves this one turn, and it is killed in the finally like any other pane.
|
||||
// 2. On a MISS: pre-trust the scratch cwd, boot an interactive `claude` in a fresh tmux
|
||||
// session in the scratch cwd, poll capture-pane until the `? for shortcuts` input bar
|
||||
// appears (bootTuiPane). BOOT_MS is the max wait, not a fixed delay.
|
||||
// 3. Write prompt to a 0600 temp file (no shell injection from prompt content).
|
||||
// 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) —
|
||||
// reliable for large multi-line prompts where send-keys -l is not (issue #130).
|
||||
// Poll-verify the prompt landed in the input (placeholder gone / [Pasted text]);
|
||||
// fast-fail with tui_paste_not_landed if it never lands (prevents the 120s
|
||||
// wallclock "stuck typing" hang). Then submit with a SEPARATE Enter key event.
|
||||
// 5. Block on the native JSONL transcript (located by THIS pane's session-id) until
|
||||
// terminal marker or wall-clock cap.
|
||||
// 6. Always teardown: kill session + rm temp dir (even on throw), and kick a background
|
||||
// pool refill so the next request finds a warm pane.
|
||||
// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
|
||||
// classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
|
||||
export async function runTuiTurn({
|
||||
prompt,
|
||||
model,
|
||||
claudeBin,
|
||||
home,
|
||||
realHome,
|
||||
cwd,
|
||||
port,
|
||||
wallclockMs = 120000,
|
||||
entrypointMode = "cli",
|
||||
tmux = defaultTmux,
|
||||
pool = null, // TuiPanePool | null — null (default) === today's cold-boot-only path
|
||||
onPane = null, // optional observer: ({ warm }) => void, for logging/metrics
|
||||
}) {
|
||||
// 1. Warm pane, or cold boot. A MISS is never an error — it is exactly today's path.
|
||||
let pane = pool ? pool.acquire(model) : null;
|
||||
const warm = !!pane;
|
||||
// Kick the refill IMMEDIATELY (not after the turn): the replacement pane then boots
|
||||
// CONCURRENTLY with this turn and is warm by the time the next request arrives. Also
|
||||
// runs on a MISS — acquire() has just retargeted the pool to this model, so the miss
|
||||
// that cold-boots today warms the pool for the next caller. Fire-and-forget; it takes
|
||||
// no TuiSemaphore slot (see pool.refill's SLOT ACCOUNTING note).
|
||||
if (pool) pool.refill();
|
||||
if (onPane) { try { onPane({ warm }); } catch { /* observer must never break a turn */ } }
|
||||
if (!pane) {
|
||||
pane = await bootTuiPane({ model, claudeBin, home, realHome, cwd, port, entrypointMode, tmux });
|
||||
}
|
||||
const tmuxName = pane.name;
|
||||
const sessionId = pane.sessionId; // THIS pane's own session-id — one session, one turn
|
||||
const ehome = pane.ehome || home || process.env.HOME;
|
||||
|
||||
// Write prompt to a temp file (mode 0600) so the content never touches argv.
|
||||
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||
const promptFile = `${tmpDir}/prompt.txt`;
|
||||
writeFileSync(promptFile, prompt, { mode: 0o600 });
|
||||
|
||||
try {
|
||||
// 1. Boot the interactive session inside tmux, rooted at the scratch cwd.
|
||||
// Capture the result: if tmux new-session fails (status !== 0) there is no
|
||||
// PTY, no interactive spawn — abort BEFORE the boot sleep rather than paste
|
||||
// into a non-existent session or issue a billing request without a verified
|
||||
// interactive context. The finally teardown is still harmless (kill-session
|
||||
// is a no-op when the session never existed).
|
||||
const spawnResult = tmux(
|
||||
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||
buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)],
|
||||
{ env },
|
||||
);
|
||||
if (!spawnResult || spawnResult.status !== 0) {
|
||||
throw new Error("tui_spawn_failed: tmux session not created");
|
||||
}
|
||||
|
||||
// 2. Wait until claude's input bar is actually ready (was: blind sleep(BOOT_MS)).
|
||||
// BOOT_MS is now the MAX readiness wait, not a fixed delay.
|
||||
const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
|
||||
{ timeoutMs: BOOT_MS, intervalMs: READY_POLL_MS });
|
||||
if (!ready) {
|
||||
// (readiness timed out; relying on paste-verify)
|
||||
console.error("[tui] input_not_ready", tmuxName);
|
||||
}
|
||||
|
||||
// 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT
|
||||
// `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the
|
||||
// embedded newlines arrive as separate key events (effectively repeated Enter),
|
||||
@@ -521,11 +644,12 @@ export async function runTuiTurn({
|
||||
// Submit (separate Enter key event).
|
||||
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||
|
||||
// 4. Block on the native transcript (resolved by session-id) until terminal.
|
||||
// 5. Block on the native transcript (resolved by THIS pane's session-id) until terminal.
|
||||
// Returns { text, entrypoint } from readTuiTranscript.
|
||||
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
|
||||
} finally {
|
||||
// 5. Teardown — always, even on throw.
|
||||
// 6. Teardown — always, even on throw. A pooled pane is torn down here exactly like a
|
||||
// cold-booted one: SINGLE-USE, never returned to the pool (see pool.mjs).
|
||||
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
@@ -73,6 +73,17 @@ export function isTerminalLine(obj) {
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user