feat(tui): per-path concurrency limit + /health observability (PR-B) (#139)

Two P1 audit fixes for the TUI subscription-pool bridge (ADR 0007). The
default path (CLAUDE_TUI_MODE unset) is unchanged except the additive
/health `tui` block (enabled:false when off).

C-4 — TUI path had NO concurrency bound. The global MAX_CONCURRENT gate
lives in spawnClaudeProcess (the -p/stream-json path); callClaudeTui never
calls it — it calls runTuiTurn, which cold-boots a full interactive claude
in tmux. So N concurrent TUI requests spawned N simultaneous cold boots (a
family burst of 5 on a Pi 4 = OOM risk + subscription rate-limit pressure).
Adds an independent in-process limiter (lib/tui/semaphore.mjs, TuiSemaphore)
gating callClaudeTui: OCP_TUI_MAX_CONCURRENT (default 2 — a TUI turn is heavy:
per-request cold-boot + up to 120s wallclock). Queues rather than rejects
(mirrors MAX_CONCURRENT intent), with a bounded wait queue (default 32x the
limit) → tui_queue_full (503) on overflow rather than unbounded growth. The
slot releases in a finally, so PR-A's honesty-gate throws / timeouts / paste
failures never leak a slot.

C-5 — no operator-visible TUI drift surface. The tui_entrypoint_mismatch
warning only reached journald; after the 6/15 flip a silent sdk-cli drift
(the documented top risk) would drain metered credits invisibly. Adds an
additive `tui` block to /health: { enabled, entrypointMode, lastEntrypoint,
entrypointMismatches, inflight, queued, maxConcurrent }. lastEntrypoint /
entrypointMismatches are recorded in callClaudeTui (same mismatch branch the
journald warning covers); inflight/queued come from the C-4 semaphore.

ALIGNMENT (Class B): cli.js does NOT perform this operation — both the TUI
path and /health are OCP-owned, so no cli.js citation applies. /health is a
grandfathered B.2 endpoint (ADR 0006, frozen at v3.16.4). The response-shape
change is authorized by the ADR 0007 PR-B amendment added in this commit and
is behaviour-preserving: the `tui` block is NEW fields only — no existing
/health field is changed, renamed, removed, or re-typed, and no existing
semantics change, so existing consumers (dashboard, ocp-connect, monitoring)
are unaffected. Per ALIGNMENT.md's grandfather provision, an additive
behaviour-preserving change to a grandfathered B.2 endpoint is authorized by
an ADR; ADR 0007 is the authority for the TUI observability surface. No
Class A forwarding path, no alignment.yml, no models.json touched — alignment
blacklist is unaffected (zero new network tokens).

Tests: 11 new (lib/tui/semaphore.mjs is importable, so the semaphore + the
two pure /health helpers are tested directly): limit=1 serializes two
overlapping calls; limit=2 runs two + queues the third; slot released on
throw; bounded queue → tui_queue_full; mismatch counter increments on
cli→other drift; auto mode never counts a mismatch; /health tui block shape
+ live inflight/queued. npm test: 235 passed, 0 failed (was 224).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-06-10 21:54:07 +10:00
committed by GitHub
co-authored by taodeng Claude <claude-opus> <noreply@anthropic.com>
parent 79c1d61e1d
commit 3322d7bdae
5 changed files with 402 additions and 6 deletions
+141
View File
@@ -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:");