diff --git a/README.md b/README.md index d031078..81e3f94 100644 --- a/README.md +++ b/README.md @@ -725,7 +725,7 @@ The canonical list lives in [`models.json`](./models.json) — the single source |----------|--------|-------------| | `/v1/models` | GET | List available models | | `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) | -| `/health` | GET | Comprehensive health check | +| `/health` | GET | Comprehensive health check (includes a `tui` block for TUI-mode drift/concurrency monitoring) | | `/usage` | GET | Plan usage limits + per-model stats | | `/status` | GET | Combined overview (usage + health) | | `/settings` | GET/PATCH | View or update settings at runtime | @@ -898,6 +898,7 @@ Future `ocp update` invocations sync automatically. | `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `/.claude/projects//`. Created automatically. | | `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. | | `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. | +| `OCP_TUI_MAX_CONCURRENT` | `2` | (TUI-mode) Max concurrent interactive TUI turns. **Independent** of `CLAUDE_MAX_CONCURRENT` (which bounds the `-p`/stream-json path; TUI never uses it). A TUI turn is heavy (per-request cold-boot of tmux+claude + up to `CLAUDE_TUI_WALLCLOCK_MS` wallclock), so the default is low to keep small hosts (e.g. a Pi 4) alive under a burst. Excess turns **queue** (bounded); a full queue yields a 503. See ADR 0007 PR-B amendment. | | `OCP_SKIP_AUTH_TEST` | *(unset)* | When `=1`, skip the `claude -p` auth probe during `setup.mjs`. After 2026-06-15 this probe draws from the Agent SDK credit pool; set this to avoid burning a metered credit on re-installs or `ocp update` runs. Auth is validated at the first real request. | | `OCP_TUI_FULL_TOOLS` | *(unset)* | (TUI-mode, **single-user only**) When `=1`, grant the interactive session the **same tool surface as the `-p` path** — `--allowedTools` (+ optional `--mcp-config` / `--dangerously-skip-permissions`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG` / `CLAUDE_SKIP_PERMISSIONS`) — instead of the default MCP-walled, built-in-tools-only set. Lets a trusted single-operator TUI deployment run a **tool-using / MCP agent** (e.g. an OpenClaw assistant) on the subscription pool. Safe because TUI **refuses to boot under `AUTH_MODE=multi`** (hard exit) — no guest key can ever reach the TUI path, so this gate cannot expose tools to an untrusted caller. (Under `AUTH_MODE=shared` + `OCP_TUI_ALLOW_LAN=1`, anyone holding the single shared key reaches it — that is the existing TUI trust model, unchanged.) See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007. | @@ -965,6 +966,25 @@ Then restart OCP. At boot you will see: - **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely. - **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled. - **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today. +- **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once. + +### Monitoring drift via `/health` + +`GET /health` includes a `tui` block so you can poll for a silent billing-pool drift (the top risk after the 6/15 flip — a lost TTY flipping `cc_entrypoint` from `cli` to the metered `sdk-cli` pool would still return answers but burn metered credits). The block is **always present** (with `enabled:false` when TUI-mode is off): + +```jsonc +"tui": { + "enabled": true, // CLAUDE_TUI_MODE === "true" + "entrypointMode": "cli", // OCP_TUI_ENTRYPOINT (cli | auto | off) + "lastEntrypoint": "cli", // last cc_entrypoint observed in a transcript, or null + "entrypointMismatches": 0, // count of cli-expected-but-got-other turns — ALERT if this climbs + "inflight": 1, // TUI turns running right now + "queued": 0, // TUI turns waiting for a concurrency slot + "maxConcurrent": 2 // OCP_TUI_MAX_CONCURRENT +} +``` + +Alert on `entrypointMismatches > 0` (or `lastEntrypoint !== "cli"`): it means a turn drew from the metered Agent SDK pool instead of the subscription. `inflight` / `queued` show how close the TUI path is to its concurrency cap. ### Kill-switch diff --git a/docs/adr/0007-tui-interactive-mode.md b/docs/adr/0007-tui-interactive-mode.md index c4f4e0c..ce12e67 100644 --- a/docs/adr/0007-tui-interactive-mode.md +++ b/docs/adr/0007-tui-interactive-mode.md @@ -139,6 +139,101 @@ B-path is **deferred** and is not implemented in this ADR. Until B-path lands, T --- +## Observability and concurrency (PR-B amendment) + +**Date:** 2026-06-10 +**Status:** Accepted — amends ADR 0007. +**Motivation:** the post-PR-A code audit, findings C-4 (P1) and C-5 (P1). + +### C-4 — independent concurrency bound for the TUI path + +The global `MAX_CONCURRENT` gate lives in `spawnClaudeProcess()` (the `-p` / stream-json +path). `callClaudeTui()` never calls `spawnClaudeProcess` — it calls `runTuiTurn()`, which +cold-boots a full interactive `claude` inside a fresh tmux session. So the TUI path had **no** +concurrency bound: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude +processes. On a small host (e.g. a Pi 4 serving a family) a burst of ~5 is an OOM risk and +also multiplies subscription rate-limit pressure. + +PR-B adds an **independent** limiter for the TUI path (`lib/tui/semaphore.mjs`, +`TuiSemaphore`): + +- **`OCP_TUI_MAX_CONCURRENT`, default `2`.** Rationale: a TUI turn is heavy — a per-request + cold-boot of tmux+claude plus up to `CLAUDE_TUI_WALLCLOCK_MS` (120 s) of wallclock — so a + small host cannot run many at once. `2` is the conservative default that keeps a Pi-class + host alive under a family burst while still allowing some overlap. It is deliberately **not** + the same knob as `MAX_CONCURRENT` (default 8): the two pools have different shapes (a + stream-json spawn is cheap and fast; a TUI turn is a heavy cold-boot + long wallclock), so + coupling them would mis-size one of the two paths. +- **Queue, don't reject.** The limiter **queues** (awaits a slot), mirroring the spirit of + `MAX_CONCURRENT` — requests are not dropped on contention. To bound memory against a runaway + client, the wait queue itself is capped (`maxQueue`, default 32× the limit); when the queue + is full `run()` rejects with `tui_queue_full`, surfaced as a 503 — deterministic backpressure + rather than silent OOM. +- **Slot released in a `finally`.** `TuiSemaphore.run(fn)` releases the slot in a `finally`, so + any throw — PR-A's honesty gates (`tui_wallclock_truncated`, `tui_upstream_error`), a + `tui_paste_not_landed`, or a `tui_spawn_failed` — can never leak a slot. + +This limiter has **zero effect when `TUI_MODE` is off**: `callClaudeTui` is never reached, so +the semaphore is never entered. The default stream-json path is untouched. + +### C-5 — operator-visible drift surface on `/health` (additive) + +The `tui_entrypoint_mismatch` warning only reached journald. After the 2026-06-15 flip, a +silent `sdk-cli` drift (the documented top risk in this ADR — a lost TTY flipping the +self-classification to the metered Agent SDK pool) would drain metered credits **invisibly**. +PR-B adds a `tui` block to the `/health` JSON response so an operator can poll it: + +``` +tui: { + enabled: , + entrypointMode: , // cli | auto | off + lastEntrypoint: , + entrypointMismatches: , + inflight: , + queued: , + maxConcurrent: +} +``` + +`lastEntrypoint` is recorded and `entrypointMismatches` incremented inside `callClaudeTui` in +the same mismatch branch that already emits the journald warning (via `recordTuiEntrypoint`). +`inflight` / `queued` / `maxConcurrent` come from the C-4 semaphore. When `TUI_MODE` is off the +block still appears with `enabled:false` (cheap, harmless) so the response shape is stable for +consumers regardless of mode. + +### ALIGNMENT authorization for the `/health` change + +`/health` is a **grandfathered B.2 endpoint** under ADR 0006, frozen at its v3.16.4 behaviour. +`ALIGNMENT.md`'s grandfather provision states: *"Any change to the contract (request shape, +response shape, semantics) of a grandfathered B.2 endpoint is treated as a new authorization +request and requires either a behaviour-preserving refactor PR or its own ADR."* + +This amendment **is** that authorization. The argument: + +- The change is **additive**: it adds one new top-level field (`tui`) containing only new + sub-fields. **No existing `/health` field is changed, renamed, removed, or re-typed**, and no + existing semantics change. Existing `/health` consumers (the dashboard, `ocp-connect`, + monitoring) read the fields they already read and are unaffected — the change is + **behaviour-preserving** for them, which is exactly the bar the grandfather provision sets for + a non-ADR contract change. +- The TUI observability surface is an **intrinsic part of the TUI feature** whose authorizing + authority is **this ADR (0007)**, not a brand-new B.2 endpoint. We are not adding a new B.2 + endpoint or a new method (which would each require their own fresh ADR under the New Class B + endpoint procedure) — we are extending the response of an existing grandfathered endpoint with + fields that report state owned by an ADR-0007 feature. ADR 0007 is the natural home for that + authority, and this amendment records it explicitly. +- `cli.js` does not perform this operation — `/health` is OCP-owned (Class B), so no `cli.js` + citation applies; the citation is this ADR + ADR 0006 (grandfathered B.2) per + `ALIGNMENT.md`'s Class B citation requirement. + +### `OCP_TUI_MAX_CONCURRENT` summary + +| Env var | Default | Meaning | +|---|---|---| +| `OCP_TUI_MAX_CONCURRENT` | `2` | Max concurrent interactive TUI turns. Independent of `CLAUDE_MAX_CONCURRENT` (the stream-json path). Excess turns queue (bounded); a full queue yields a 503. | + +--- + ## Consequences ### Positive diff --git a/lib/tui/semaphore.mjs b/lib/tui/semaphore.mjs new file mode 100644 index 0000000..56ddab7 --- /dev/null +++ b/lib/tui/semaphore.mjs @@ -0,0 +1,102 @@ +// TUI-path concurrency limiter (audit finding C-4). +// +// WHY THIS EXISTS, SEPARATE FROM server.mjs's MAX_CONCURRENT: +// The global MAX_CONCURRENT gate lives in spawnClaudeProcess() (the -p / stream-json +// path). callClaudeTui() NEVER calls spawnClaudeProcess — it calls runTuiTurn(), which +// boots a full interactive `claude` inside a fresh tmux session. So nothing bounded the +// TUI path: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude +// processes. On a small host (a Pi 4 serving a family) a burst of ~5 is an OOM risk, and +// it also multiplies subscription rate-limit pressure. This is an INDEPENDENT limiter for +// the TUI path that mirrors MAX_CONCURRENT's intent without coupling to it (the two pools +// are different shapes: a stream-json spawn is cheap and fast; a TUI turn is a heavy +// cold-boot + up to 120s wallclock). +// +// QUEUE vs REJECT: we QUEUE (await a slot), mirroring the spirit of MAX_CONCURRENT's +// intent not to drop requests, rather than rejecting immediately. To avoid unbounded +// memory growth from a runaway client, the wait queue itself is bounded by maxQueue +// (default: a generous multiple of the concurrency limit). When the queue is full, run() +// rejects with a tui_queue_full error (the caller surfaces it as a 503) — a deterministic +// backpressure signal rather than silent OOM. +// +// Pure + importable so test-features.mjs can assert the bound directly (no server boot). + +export class TuiSemaphore { + // limit: max concurrent slots. maxQueue: max waiters before run() rejects with backpressure. + constructor(limit, { maxQueue } = {}) { + this.limit = Math.max(1, parseInt(limit, 10) || 1); + // Default queue cap: 32× the limit. Large enough that real family-burst traffic never + // hits it, small enough that a pathological flood can't grow the queue without bound. + this.maxQueue = Number.isFinite(maxQueue) ? maxQueue : this.limit * 32; + this._inflight = 0; + this._waiters = []; // FIFO queue of resolve callbacks waiting for a slot + } + + get inflight() { return this._inflight; } + get queued() { return this._waiters.length; } + + // Acquire a slot. Resolves once a slot is free (immediately if under the limit, otherwise + // when an in-flight task releases). Rejects synchronously-ish if the wait queue is full. + acquire() { + if (this._inflight < this.limit) { + this._inflight++; + return Promise.resolve(); + } + if (this._waiters.length >= this.maxQueue) { + return Promise.reject(new Error( + `tui_queue_full: TUI concurrency limit (${this.limit}) reached and wait queue ` + + `(${this.maxQueue}) is full`)); + } + return new Promise((resolve) => { this._waiters.push(resolve); }); + } + + // Release a slot. If a waiter is queued, hand the slot directly to it (inflight stays + // constant across the handoff); otherwise decrement. + release() { + const next = this._waiters.shift(); + if (next) { + next(); // the woken waiter already "owns" the slot — inflight unchanged + } else if (this._inflight > 0) { + this._inflight--; + } + } + + // Run fn() under one slot. Releases in a finally so a throw (PR-A's honesty gates, + // wallclock truncation, paste-not-landed, tmux spawn failure) NEVER leaks a slot. + async run(fn) { + await this.acquire(); + try { + return await fn(); + } finally { + this.release(); + } + } +} + +// ── TUI drift observability (audit C-5) — pure helpers, importable for testing ── + +// Record an observed cc_entrypoint into the (mutable) tuiStats counter. Sets lastEntrypoint +// unconditionally and increments entrypointMismatches when the spawn was supposed to be +// subscription-pool ("cli") but the transcript reported something else (a silent drift to +// the metered Agent SDK pool — the audit's top risk after the 6/15 billing flip). +// Returns true iff this observation was a mismatch (so the caller can also emit a log). +export function recordTuiEntrypoint(tuiStats, observed, expectedMode = "cli") { + tuiStats.lastEntrypoint = observed ?? null; + const mismatch = expectedMode === "cli" && observed !== "cli"; + if (mismatch) tuiStats.entrypointMismatches++; + return mismatch; +} + +// 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) { + return { + enabled, + entrypointMode, // cli | auto | off + lastEntrypoint: tuiStats.lastEntrypoint, // last observed cc_entrypoint, or null + entrypointMismatches: tuiStats.entrypointMismatches, + inflight: semaphore.inflight, // current concurrent TUI turns + queued: semaphore.queued, // turns waiting for a slot + maxConcurrent, + }; +} diff --git a/server.mjs b/server.mjs index 70b332e..ad9bad6 100644 --- a/server.mjs +++ b/server.mjs @@ -19,7 +19,8 @@ * CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests * CLAUDE_MCP_CONFIG — path to MCP server config JSON file * CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h) - * CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 8) + * CLAUDE_MAX_CONCURRENT — max concurrent claude processes, -p/stream-json path (default: 8) + * OCP_TUI_MAX_CONCURRENT — max concurrent interactive TUI turns, TUI-mode path (default: 2) * CLAUDE_BREAKER_THRESHOLD — failures in window before circuit opens (default: 6) * CLAUDE_BREAKER_COOLDOWN — base ms to wait before retrying after circuit opens (default: 120000) * CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min) @@ -39,6 +40,7 @@ import { DEFAULT_PORT } from "./lib/constants.mjs"; import { isLoopbackBind } from "./lib/net.mjs"; import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs"; import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs"; +import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")); @@ -300,6 +302,22 @@ const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000 const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`; const TUI_HOME = process.env.OCP_TUI_HOME || process.env.HOME; const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007 +// Independent concurrency bound for the TUI path (audit C-4). Default 2: a TUI turn is +// HEAVY (per-request cold-boot of a tmux+claude session + up to TUI_WALLCLOCK_MS=120s of +// wallclock), so a small host (e.g. a Pi 4 serving a family) cannot run many at once +// without OOM + multiplied subscription rate-limit pressure. This is NOT the global +// MAX_CONCURRENT gate (that lives in spawnClaudeProcess, the -p/stream-json path, which +// callClaudeTui never reaches). See ADR 0007 PR-B amendment + lib/tui/semaphore.mjs. +const TUI_MAX_CONCURRENT = parseInt(process.env.OCP_TUI_MAX_CONCURRENT || "2", 10); +const tuiSemaphore = new TuiSemaphore(TUI_MAX_CONCURRENT); +// Operator-visible TUI drift surface (audit C-5). lastEntrypoint + entrypointMismatches +// let the operator poll /health to catch a silent metered-pool drift (the audit's top +// risk: after the 6/15 flip a TTY-loss could flip cc_entrypoint cli→sdk-cli and drain +// metered credits invisibly — the warning currently only reaches journald). +const tuiStats = { + lastEntrypoint: null, // last observed cc_entrypoint from the transcript ("cli" | "sdk-cli" | null) + entrypointMismatches: 0, // count of cli-expected-but-got-other turns +}; // SECURITY fail-loud: TUI-mode is incompatible with any configuration that allows // non-operator prompts to reach the interactive claude session. Three cases: @@ -918,7 +936,11 @@ function callClaudeTui(model, messages, _conversationId, _keyName) { const cliModel = MODEL_MAP[model] || model; const prompt = messagesToPrompt(messages); // includes system as [System] inline recordModelRequest(cliModel, prompt.length); - return runTuiTurn({ + // C-4: gate the heavy interactive boot behind the TUI semaphore. run() acquires a slot + // (queuing if all are busy, up to maxQueue), then releases in a finally so any throw from + // runTuiTurn (tmux spawn failure, paste-not-landed) OR from the honesty gates below + // (truncation / error banner) can NEVER leak a slot. tuiSemaphore.inflight feeds /health. + return tuiSemaphore.run(() => runTuiTurn({ prompt, model: cliModel, claudeBin: CLAUDE, @@ -955,14 +977,19 @@ function callClaudeTui(model, messages, _conversationId, _keyName) { // Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli // (subscription pool); a silent degrade to sdk-cli (metered Agent SDK pool) would still // return text but cost money — warn loudly so it's visible. (issue #115) - if (TUI_ENTRYPOINT === "cli" && entrypoint !== "cli") { + // C-5: also surface the observation on /health. recordTuiEntrypoint sets lastEntrypoint + // unconditionally (operators can poll it to confirm cli) and increments + // entrypointMismatches when expected=cli but observed≠cli — the same condition the + // journald warning already covers — so a silent metered-pool drift is visible on /health + // without tailing logs. + if (recordTuiEntrypoint(tuiStats, entrypoint, TUI_ENTRYPOINT)) { logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel }); } return text; }).catch((err) => { recordModelError(cliModel, false); throw err; - }); + })); } // ── SSE heartbeat (opt-in idle watchdog) ──────────────────────────────── @@ -2014,6 +2041,17 @@ const server = createServer(async (req, res) => { circuitBreaker: "disabled", sessions: sessionList, recentErrors: recentErrors.slice(-5), + // ── TUI observability (audit C-5) — ADDITIVE block (ADR 0007 PR-B amendment) ── + // /health is a grandfathered B.2 endpoint (ADR 0006). This block is NEW fields only; + // every existing field above is byte-identical → behaviour-preserving for existing + // consumers per ALIGNMENT.md's grandfather provision. When TUI_MODE is off the block + // still appears with enabled:false (cheap, harmless) so the shape is stable. + // entrypointMismatches/lastEntrypoint exist so an operator can poll /health to catch a + // silent metered-pool drift (the audit's top risk after the 6/15 billing flip). + tui: buildTuiHealthBlock( + { enabled: TUI_MODE, entrypointMode: TUI_ENTRYPOINT, maxConcurrent: TUI_MAX_CONCURRENT }, + tuiStats, tuiSemaphore, + ), }); } @@ -2304,7 +2342,7 @@ server.listen(PORT, BIND_ADDRESS, () => { else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`); if (TUI_MODE) { console.warn(`⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP (guest prompts would run claude with operator filesystem access). See ADR 0007.`); - console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms`); + console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms maxConcurrent=${TUI_MAX_CONCURRENT}`); try { const n = reapStaleTuiSessions(); if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n }); diff --git a/test-features.mjs b/test-features.mjs index 265dc7a..718d41f 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -1851,6 +1851,147 @@ test("default mode (no second arg) behaves like 'cli'", () => { assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli"); }); +// ── TUI concurrency limiter + drift observability (PR-B: audit C-4 / C-5) ── +import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs"; + +console.log("\nTUI concurrency limiter (C-4):"); + +const deferred = () => { let resolve, reject; const p = new Promise((res, rej) => { resolve = res; reject = rej; }); return { p, resolve, reject }; }; + +await asyncTest("limit=1 serializes two overlapping calls (second waits for the first)", async () => { + const sem = new TuiSemaphore(1); + const order = []; + const g1 = deferred(); + // First task acquires the only slot and blocks on g1. + const t1 = sem.run(async () => { order.push("t1-start"); await g1.p; order.push("t1-end"); }); + await new Promise((r) => setImmediate(r)); // let t1 acquire + assert.equal(sem.inflight, 1, "t1 holds the only slot"); + // Second task must QUEUE — it has not started yet. + const t2 = sem.run(async () => { order.push("t2-start"); }); + await new Promise((r) => setImmediate(r)); + assert.equal(sem.queued, 1, "t2 is queued, not running"); + assert.deepEqual(order, ["t1-start"], "t2 has not started while t1 holds the slot"); + // Release t1 → t2 runs. + g1.resolve(); + await t1; await t2; + assert.deepEqual(order, ["t1-start", "t1-end", "t2-start"], "t2 ran only after t1 finished"); + assert.equal(sem.inflight, 0, "all slots released"); + assert.equal(sem.queued, 0, "queue drained"); +}); + +await asyncTest("limit=2 allows two concurrent, queues the third", async () => { + const sem = new TuiSemaphore(2); + const g = [deferred(), deferred(), deferred()]; + const started = []; + const tasks = g.map((d, i) => sem.run(async () => { started.push(i); await d.p; })); + await new Promise((r) => setImmediate(r)); + assert.equal(sem.inflight, 2, "exactly 2 run concurrently"); + assert.equal(sem.queued, 1, "the third is queued"); + assert.deepEqual(started.sort(), [0, 1], "only the first two started"); + g.forEach((d) => d.resolve()); + await Promise.all(tasks); + assert.equal(sem.inflight, 0); +}); + +await asyncTest("slot is RELEASED on throw (finally) — a rejecting task never leaks its slot", async () => { + const sem = new TuiSemaphore(1); + await assert.rejects(sem.run(async () => { throw new Error("boom"); }), /boom/); + assert.equal(sem.inflight, 0, "throwing task released its slot"); + // Prove the slot is reusable: a subsequent task acquires immediately. + let ran = false; + await sem.run(async () => { ran = true; }); + assert.equal(ran, true); + assert.equal(sem.inflight, 0); +}); + +await asyncTest("wait queue is bounded — run() rejects with tui_queue_full when full (backpressure, not OOM)", async () => { + const sem = new TuiSemaphore(1, { maxQueue: 1 }); + const g1 = deferred(); + const t1 = sem.run(async () => { await g1.p; }); // holds the slot + await new Promise((r) => setImmediate(r)); + const t2 = sem.run(async () => {}); // fills the 1-deep queue + await new Promise((r) => setImmediate(r)); + assert.equal(sem.queued, 1, "queue is full"); + await assert.rejects(sem.run(async () => {}), /tui_queue_full/, "third request rejects"); + g1.resolve(); + await t1; await t2; + assert.equal(sem.inflight, 0); +}); + +console.log("\nTUI drift observability (C-5):"); + +test("recordTuiEntrypoint: observed 'cli' is NOT a mismatch and sets lastEntrypoint", () => { + const ts = { lastEntrypoint: null, entrypointMismatches: 0 }; + const mism = recordTuiEntrypoint(ts, "cli", "cli"); + assert.equal(mism, false); + assert.equal(ts.lastEntrypoint, "cli"); + assert.equal(ts.entrypointMismatches, 0); +}); + +test("recordTuiEntrypoint: expected cli but observed 'sdk-cli' increments the mismatch counter (drift)", () => { + const ts = { lastEntrypoint: null, entrypointMismatches: 0 }; + assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "cli"), true); + assert.equal(ts.lastEntrypoint, "sdk-cli"); + assert.equal(ts.entrypointMismatches, 1); + // A second drift increments again (counter accumulates across turns). + assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "cli"), true); + assert.equal(ts.entrypointMismatches, 2); +}); + +test("recordTuiEntrypoint: null observation → lastEntrypoint null, counts as mismatch when expected cli", () => { + const ts = { lastEntrypoint: "cli", entrypointMismatches: 0 }; + assert.equal(recordTuiEntrypoint(ts, null, "cli"), true); + assert.equal(ts.lastEntrypoint, null); + assert.equal(ts.entrypointMismatches, 1); +}); + +test("recordTuiEntrypoint: non-cli expected mode (auto) never counts a mismatch", () => { + const ts = { lastEntrypoint: null, entrypointMismatches: 0 }; + assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "auto"), false); + assert.equal(ts.lastEntrypoint, "sdk-cli"); + assert.equal(ts.entrypointMismatches, 0); +}); + +test("buildTuiHealthBlock: shape + live counters (the additive /health tui block)", () => { + const sem = new TuiSemaphore(2); + const ts = { lastEntrypoint: "cli", entrypointMismatches: 3 }; + const block = buildTuiHealthBlock( + { enabled: true, entrypointMode: "cli", maxConcurrent: 2 }, ts, sem); + assert.deepEqual(Object.keys(block).sort(), + ["enabled", "entrypointMismatches", "entrypointMode", "inflight", "lastEntrypoint", "maxConcurrent", "queued"]); + assert.equal(block.enabled, true); + assert.equal(block.entrypointMode, "cli"); + assert.equal(block.lastEntrypoint, "cli"); + assert.equal(block.entrypointMismatches, 3); + assert.equal(block.inflight, 0); + assert.equal(block.queued, 0); + assert.equal(block.maxConcurrent, 2); +}); + +test("buildTuiHealthBlock: TUI off → enabled:false but block still present (stable shape)", () => { + const sem = new TuiSemaphore(2); + const ts = { lastEntrypoint: null, entrypointMismatches: 0 }; + const block = buildTuiHealthBlock( + { enabled: false, entrypointMode: "cli", maxConcurrent: 2 }, ts, sem); + assert.equal(block.enabled, false); + assert.equal(block.lastEntrypoint, null); + assert.equal(block.entrypointMismatches, 0); +}); + +await asyncTest("buildTuiHealthBlock reflects live inflight/queued while turns are in flight", async () => { + const sem = new TuiSemaphore(1); + const ts = { lastEntrypoint: null, entrypointMismatches: 0 }; + const g1 = deferred(); + const t1 = sem.run(async () => { await g1.p; }); + const t2 = sem.run(async () => {}); // queued behind t1 + await new Promise((r) => setImmediate(r)); + const block = buildTuiHealthBlock({ enabled: true, entrypointMode: "cli", maxConcurrent: 1 }, ts, sem); + assert.equal(block.inflight, 1, "one turn in flight"); + assert.equal(block.queued, 1, "one turn queued"); + g1.resolve(); + await t1; await t2; +}); + // ── TUI session driver: runTuiTurn (live-only, guarded) ────────────────── console.log("\nTUI session driver:");