mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
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:
@@ -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:");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user