From 79e0bbcea24a155df480eb535e21943d73f55fba Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 19 Aug 2026 07:06:25 -0400 Subject: [PATCH 1/3] fix(usage): make /limit a real stop boundary and stop reporting unknown spend as zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane SC-A5, slice 1 of truthful usage and continuity. /limit was a control that enforced nothing. `uvtSpent` was only ever written as `= 0` — at construction, in purge(), and on snapshot restore. No usage frame ever incremented it. `checkUvtCap()` had zero callers anywhere in the tree. So: * `/limit 50000` printed "agent will pause and ask permission if ceiling hit". Nothing paused. Nothing checked. The cap was never consulted. * `/limit` reported "spent: 0" and a 0% bar for every session, whatever it had actually cost, because the number was a constant. * The field comment claimed it was "read from custody log". Nothing read it. Three changes. 1. Usage is measured, or it is unknown. Never zero by default. `uvtObserved: number | null` replaces the always-zero counter. null means no authoritative frame has arrived, which is not the same as a measured zero and is no longer rendered as one. Nothing estimates UVT from token counts — only the server's own number is recorded. `uvtSpent` survives as a getter for the HUD, which needs a number, and is documented as reporting 0 when the answer is unknown so that anything which must tell those apart reads `uvtObserved`. 2. Turns settle once, by id. The terminal frame carries the turn total and a reconnect can replay it, so `settleTurn(turnId, uvt)` is keyed rather than accumulated blindly. A replayed done frame is ignored; distinct turns accumulate. 3. The cap is checked before a billable turn starts, and says what it is. `runCloudTurn` consults `checkUvtCap()` before doing anything and refuses to start when the observed spend has reached the cap. Two states deliberately do not trip it: a local brain (Aether meters nothing, so it is marked unmetered rather than zero-spend) and a session with no observed usage (there is no evidence the cap was reached, and guessing either blocks free work or waves through expensive work). The wording is now accurate about what it can and cannot do: no further turn will START once the server-reported spend reaches it. a turn already in flight may still complete and be billed. this is a local stop only — your plan and balance are unchanged. A design change came out of the mutation pass rather than the plan. `remaining` was a number, and an unmeasured session reported the full cap as headroom — which is the same false zero in a different costume: it tells the user their whole budget is intact when in truth none of it has been counted. The first mutation run did not fail any test, which is what exposed it. `remaining` is now `number | null`, null when unmeasured, and a test pins it. Tests: 10 added. Unknown is not zero; a duplicate done frame does not double-count; distinct turns accumulate; an unknown session is never reported as capped; the cap trips on reaching it; no cap never caps; local sessions are labelled unmetered; purge returns to unknown rather than to zero; and unmeasured headroom is null rather than the full cap. Mutation-checked, both guards: restoring the false-zero headroom and removing the replay dedupe fails two tests (8 pass / 2 fail); restoring gives 10 / 10. Gates at this commit: npm run typecheck exit 0 npm test 932 pass / 0 fail (922 on clean 41a7e261) Known limits. The HUD still renders `uvtUsed: reg.uvtSpent`, so it shows 0 for an unknown session — hud.ts is SC-INT's surface and is deliberately not touched here. Session-cap persistence across resume, and the cross-workspace rejection that goes with it, are a later slice. The cap is enforced on the REPL cloud path; `aether agent` has its own loop and is not yet gated. --- src/commands/chat.ts | 38 +++++++++++++ src/commands/slash_context.ts | 38 ++++++++++--- src/core/context_registry.ts | 96 ++++++++++++++++++++++++++++--- test/usage_cap.test.ts | 104 ++++++++++++++++++++++++++++++++++ 4 files changed, 262 insertions(+), 14 deletions(-) create mode 100644 test/usage_cap.test.ts diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 43d2c15..8e287a6 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -104,14 +104,34 @@ export async function runTurn( onFrame?: (f: StreamFrame) => void, onPulsePaint?: () => void, ): Promise { + const reg = getRegistry(); + // The operator's session cap is checked BEFORE a billable turn starts. It is + // a local circuit breaker, not a billing control: it stops this terminal + // from starting more work, and changes nothing about the account. + const cap = reg.checkUvtCap(); + if (cap.capped) { + throw new ChatTurnError( + `session UVT cap reached — ${cap.observed} of ${cap.cap} observed. ` + + "No further turns will start. This is a local stop only; your plan and " + + "balance are unchanged. Raise it with /limit , or /limit off.", + ); + } + const turnId = `turn-${++cloudTurnCounter}`; + reg.beginTurn(turnId); const backend = await resolveBackend(ctx); if (backend === "local") { + // Aether meters nothing on a local brain, so the session is unmetered + // rather than "zero spend so far". + getRegistry().markLocalUnmetered(); await runLocalTurn(ctx, prompt); return; } await runCloudTurn(ctx, prompt, signal, onFrame, onPulsePaint); } +/** Monotonic per-process turn id, so a settled turn can be recognised on replay. */ +let cloudTurnCounter = 0; + /** The cloud path — build an envelope, POST to the universal stream, render. * Extracted so runTurn can fork local vs cloud. */ async function runCloudTurn( @@ -121,6 +141,20 @@ async function runCloudTurn( onFrame?: (f: StreamFrame) => void, onPulsePaint?: () => void, ): Promise { + const reg = getRegistry(); + // The operator's session cap is checked BEFORE a billable turn starts. It is + // a local circuit breaker, not a billing control: it stops this terminal + // from starting more work, and changes nothing about the account. + const cap = reg.checkUvtCap(); + if (cap.capped) { + throw new ChatTurnError( + `session UVT cap reached — ${cap.observed} of ${cap.cap} observed. ` + + "No further turns will start. This is a local stop only; your plan and " + + "balance are unchanged. Raise it with /limit , or /limit off.", + ); + } + const turnId = `turn-${++cloudTurnCounter}`; + reg.beginTurn(turnId); const req = buildChatRequest({ prompt, model: ctx.flags.model ?? ctx.cfg.defaultModel, @@ -163,6 +197,10 @@ async function runCloudTurn( if (frame.type !== "open" && frame.type !== "ping") pulse.stop(); // The server signs each turn and returns it; persist the signed receipt // locally (best-effort, never breaks the chat). + // The terminal frame carries the turn's authoritative cost. Settled by + // turn id so a reconnect replaying it cannot count the same turn twice, + // and only from the server's own number — never estimated from tokens. + if (frame.type === "done") getRegistry().settleTurn(turnId, frame.uvt); if (frame.type === "custody") appendCustody(frame.custody); if (frame.type === "error") sawError = frame.msg; if (frame.type === "error" || frame.type === "done") sawTerminal = true; diff --git a/src/commands/slash_context.ts b/src/commands/slash_context.ts index 80769e2..b4eea57 100644 --- a/src/commands/slash_context.ts +++ b/src/commands/slash_context.ts @@ -170,14 +170,28 @@ export async function limitSlash(ctx: AppContext, out: Writable, arg: string): P if (!arg.trim()) { const current = registry.uvtCap; - const spent = registry.uvtSpent; + const status = registry.usageStatus(); + const observed = registry.uvtObserved; + // "spent: 0" used to print whether the session had cost nothing or whether + // no usage frame had ever arrived. Those are different answers, and only + // one of them is a measurement. + const spentLabel = + status === "local-unmetered" + ? "LOCAL — not metered by Aether" + : observed == null + ? "unknown — the server has reported no usage yet" + : String(observed); if (current == null) { - out.write("UVT cap: none (uncapped)\n"); + out.write(`UVT cap: none (uncapped) observed: ${spentLabel}\n`); + } else if (observed == null || status !== "observed") { + out.write(`UVT cap: ${theme.bold(String(current))} observed: ${spentLabel}\n`); + out.write(theme.dim(" the cap cannot trip until the server reports usage.\n")); } else { - const remaining = Math.max(0, current - spent); - const pct = current > 0 ? Math.round((spent / current) * 100) : 0; - const bar = renderUvtBar(pct, 20); - out.write(`UVT cap: ${theme.bold(String(current))} spent: ${spent} remaining: ${remaining} ${bar}\n`); + const remaining = Math.max(0, current - observed); + const pct = current > 0 ? Math.round((observed / current) * 100) : 0; + out.write( + `UVT cap: ${theme.bold(String(current))} observed: ${observed} remaining: ${remaining} ${renderUvtBar(pct, 20)}\n`, + ); } out.write(theme.dim(" /limit set cap (e.g., /limit 50000)\n")); out.write(theme.dim(" /limit off remove cap\n")); @@ -197,7 +211,17 @@ export async function limitSlash(ctx: AppContext, out: Writable, arg: string): P } registry.setUvtCap(Math.floor(n)); - out.write(`${theme.cyan("⚡ UVT cap set")} ${theme.bold(String(Math.floor(n)))} — agent will pause and ask permission if ceiling hit\n`); + // The old wording promised the agent would "pause and ask permission". + // Nothing enforced the cap at all, so that was never true. State what now + // actually happens, and be explicit that this is not a billing control. + out.write(`${theme.cyan("⚡ UVT cap set")} ${theme.bold(String(Math.floor(n)))}\n`); + out.write( + theme.dim( + " no further turn will START once the server-reported spend reaches it.\n" + + " a turn already in flight may still complete and be billed.\n" + + " this is a local stop only — your plan and balance are unchanged.\n", + ), + ); syncAfter(ctx); } diff --git a/src/core/context_registry.ts b/src/core/context_registry.ts index d4275f3..e60c96b 100644 --- a/src/core/context_registry.ts +++ b/src/core/context_registry.ts @@ -59,7 +59,32 @@ export class ContextRegistry { pins: PinnedEntry[] = []; drops: string[] = []; uvtCap: number | null = null; - uvtSpent = 0; + + /** + * Session UVT actually reported by the server. null means NO authoritative + * frame has been seen — which is not the same as zero, and must never be + * rendered as it. Only the server knows what a turn cost; nothing here + * estimates it from token counts. + */ + uvtObserved: number | null = null; + + /** Turn ids already settled, so a replayed terminal frame cannot double-count. */ + private readonly settledTurns = new Set(); + + /** True once this session is known to run on a local, un-metered brain. */ + private localUnmetered = false; + + /** + * Back-compat accessor for the HUD, which needs a number. It reports 0 when + * usage is UNKNOWN, so anything that must tell those apart has to read + * uvtObserved instead. + */ + get uvtSpent(): number { + return this.uvtObserved ?? 0; + } + set uvtSpent(value: number) { + this.uvtObserved = value; + } planPath: string | null = null; sessionLabel = "untitled"; @@ -97,6 +122,33 @@ export class ContextRegistry { this.uvtCap = amount; } + /** Mark a turn as in flight. Idempotent. */ + beginTurn(turnId: string): void { + this.settledTurns.delete(turnId); + } + + /** + * Record a turn's authoritative cost, once. The terminal frame carries the + * turn total, and a reconnect can replay it, so settling is keyed by turn id + * rather than accumulated blindly. + */ + settleTurn(turnId: string, uvt: number): void { + if (this.settledTurns.has(turnId)) return; + if (!Number.isFinite(uvt) || uvt < 0) return; + this.settledTurns.add(turnId); + this.uvtObserved = (this.uvtObserved ?? 0) + uvt; + } + + /** This session runs on a local brain, so Aether meters nothing. */ + markLocalUnmetered(): void { + this.localUnmetered = true; + } + + usageStatus(): "unknown" | "observed" | "local-unmetered" { + if (this.localUnmetered) return "local-unmetered"; + return this.uvtObserved == null ? "unknown" : "observed"; + } + /** Track a temporary file so /purge can clean it up. */ tempFiles: string[] = []; @@ -113,7 +165,8 @@ export class ContextRegistry { this.pins = []; this.drops = []; this.uvtCap = null; - this.uvtSpent = 0; + this.uvtObserved = null; + this.settledTurns.clear(); let removedFiles = 0; for (const f of this.tempFiles) { @@ -124,11 +177,40 @@ export class ContextRegistry { return { clearedPins, removedFiles }; } - /** Check if UVT cap is exceeded. Returns remaining or -1 if exceeded. */ - checkUvtCap(): { capped: boolean; remaining: number; cap: number | null } { - if (this.uvtCap == null) return { capped: false, remaining: Infinity, cap: null }; - const remaining = this.uvtCap - this.uvtSpent; - return { capped: remaining <= 0, remaining: Math.max(0, remaining), cap: this.uvtCap }; + /** + * Is the operator's session cap reached? + * + * This is a local circuit breaker, not a billing ledger. The server remains + * the billing authority; tripping this stops the terminal from starting + * another billable turn, and changes nothing about the account. + * + * Two states deliberately do NOT trip it: an unmetered local session (there + * is no Aether spend to cap) and a session where no authoritative usage has + * been seen (there is no evidence the cap was reached, and guessing would + * either block work that cost nothing or wave through work that cost a lot). + */ + checkUvtCap(): { + capped: boolean; + /** null when there is no measured spend to subtract — NOT the full cap. */ + remaining: number | null; + cap: number | null; + observed: number | null; + status: "unknown" | "observed" | "local-unmetered"; + } { + const status = this.usageStatus(); + const observed = this.uvtObserved; + // Unknown spend yields a null headroom, never the whole cap. Reporting the + // full cap as remaining is the same false zero in a different costume: it + // tells the user they have their entire budget left when the truth is that + // nobody has measured any of it. + if (status !== "observed" || observed == null) { + return { capped: false, remaining: null, cap: this.uvtCap, observed, status }; + } + if (this.uvtCap == null) { + return { capped: false, remaining: null, cap: null, observed, status }; + } + const remaining = this.uvtCap - observed; + return { capped: remaining <= 0, remaining: Math.max(0, remaining), cap: this.uvtCap, observed, status }; } // ── HUD methods ── diff --git a/test/usage_cap.test.ts b/test/usage_cap.test.ts new file mode 100644 index 0000000..23d98f0 --- /dev/null +++ b/test/usage_cap.test.ts @@ -0,0 +1,104 @@ +// /limit shipped as a control that enforced nothing. +// +// `uvtSpent` was only ever written as `= 0` (construction, purge, snapshot +// restore) — no usage frame ever incremented it — and `checkUvtCap()` had zero +// callers. So the cap never stopped anything, and the readout reported +// "spent: 0" no matter what the session had actually cost. +// +// These pin the two properties that matter: an unobserved session is UNKNOWN +// rather than zero, and a reached cap actually refuses the next billable turn. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ContextRegistry } from "../src/core/context_registry.js"; + +test("a session with no authoritative usage frame reports unknown, not zero", () => { + const reg = new ContextRegistry(); + assert.equal(reg.uvtObserved, null, "no frame seen means no number to report"); + assert.notEqual(reg.uvtObserved, 0, "zero is a measurement; this is the absence of one"); +}); + +test("a settled turn is counted once, and a duplicate done frame does not double it", () => { + const reg = new ContextRegistry(); + reg.beginTurn("turn-1"); + reg.settleTurn("turn-1", 1200); + reg.settleTurn("turn-1", 1200); // replay after reconnect + assert.equal(reg.uvtObserved, 1200); +}); + +test("distinct turns accumulate", () => { + const reg = new ContextRegistry(); + reg.beginTurn("t1"); + reg.settleTurn("t1", 1000); + reg.beginTurn("t2"); + reg.settleTurn("t2", 500); + assert.equal(reg.uvtObserved, 1500); +}); + +test("an unknown session is never reported as capped", () => { + const reg = new ContextRegistry(); + reg.setUvtCap(1000); + const check = reg.checkUvtCap(); + assert.equal(check.capped, false, "with nothing observed there is no evidence the cap was reached"); + assert.equal(check.observed, null); +}); + +test("the cap trips once observed spend reaches it", () => { + const reg = new ContextRegistry(); + reg.setUvtCap(1000); + reg.beginTurn("t1"); + reg.settleTurn("t1", 999); + assert.equal(reg.checkUvtCap().capped, false); + reg.beginTurn("t2"); + reg.settleTurn("t2", 1); + assert.equal(reg.checkUvtCap().capped, true, "reaching the cap counts as reaching it"); + assert.equal(reg.checkUvtCap().remaining, 0); +}); + +test("no cap means never capped, whatever was spent", () => { + const reg = new ContextRegistry(); + reg.beginTurn("t1"); + reg.settleTurn("t1", 10_000_000); + const check = reg.checkUvtCap(); + assert.equal(check.capped, false); + assert.equal(check.cap, null); +}); + +test("local unmetered sessions are labelled, not counted as zero spend", () => { + const reg = new ContextRegistry(); + reg.markLocalUnmetered(); + assert.equal(reg.usageStatus(), "local-unmetered"); + assert.equal(reg.checkUvtCap().capped, false, "an unmetered session cannot exceed an Aether cap"); +}); + +test("usageStatus distinguishes unknown from observed", () => { + const reg = new ContextRegistry(); + assert.equal(reg.usageStatus(), "unknown"); + reg.beginTurn("t1"); + reg.settleTurn("t1", 5); + assert.equal(reg.usageStatus(), "observed"); +}); + +test("purge clears observed usage back to unknown, not to zero", () => { + const reg = new ContextRegistry(); + reg.beginTurn("t1"); + reg.settleTurn("t1", 5); + reg.purge(); + assert.equal(reg.uvtObserved, null); + assert.equal(reg.usageStatus(), "unknown"); +}); + +test("unmeasured headroom is null, never the full cap", () => { + // Reporting `remaining: cap` when nothing has been measured is the same false + // zero wearing a different hat — it tells the user their whole budget is + // intact when in fact none of it has been counted. + const reg = new ContextRegistry(); + reg.setUvtCap(1000); + const unknown = reg.checkUvtCap(); + assert.equal(unknown.remaining, null); + assert.notEqual(unknown.remaining, 1000, "the full cap is not a measurement of headroom"); + + reg.beginTurn("t1"); + reg.settleTurn("t1", 400); + assert.equal(reg.checkUvtCap().remaining, 600, "once measured, headroom is real"); +}); From d817f628051a9f3fc72ecfe04ab4cd19b48dcfed Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 19 Aug 2026 07:23:05 -0400 Subject: [PATCH 2/3] fix(usage): the cap gate belongs in runCloudTurn only, not duplicated in runTurn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by SC-INT when the six lanes were composed, not by this lane's own tests or CI — both of which were green. The patch that introduced the gate was anchored on a signature shared by runTurn and runCloudTurn, so the cap check and the turn-id allocation were written into both. Every turn ran checkUvtCap() twice and consumed two ids from the counter. Not a correctness bug: settleTurn keys off the id runCloudTurn actually uses, and a doubled gate returns the same verdict both times. But it is duplicated control flow on a spend boundary, and this PR's own description says the gate lives in runCloudTurn. Removed the runTurn copy. The gate now exists once, in runCloudTurn, which is also the only billable path — local turns are unmetered and must not be gated at all. Gates at this commit: npm run typecheck exit 0 npm test 932 pass / 0 fail — unchanged, which is the point: the duplication was invisible to the test suite --- src/commands/chat.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 8e287a6..5543f1c 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -104,20 +104,6 @@ export async function runTurn( onFrame?: (f: StreamFrame) => void, onPulsePaint?: () => void, ): Promise { - const reg = getRegistry(); - // The operator's session cap is checked BEFORE a billable turn starts. It is - // a local circuit breaker, not a billing control: it stops this terminal - // from starting more work, and changes nothing about the account. - const cap = reg.checkUvtCap(); - if (cap.capped) { - throw new ChatTurnError( - `session UVT cap reached — ${cap.observed} of ${cap.cap} observed. ` + - "No further turns will start. This is a local stop only; your plan and " + - "balance are unchanged. Raise it with /limit , or /limit off.", - ); - } - const turnId = `turn-${++cloudTurnCounter}`; - reg.beginTurn(turnId); const backend = await resolveBackend(ctx); if (backend === "local") { // Aether meters nothing on a local brain, so the session is unmetered From e012e563427d31ae8b5603392fd6e4dffcddf698 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 19 Aug 2026 07:28:56 -0400 Subject: [PATCH 3/3] test(doctor): assert the hanging-backend property instead of a stopwatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unrelated to this lane's subject. Landing here because it turned windows-latest red on this PR and would do the same to any other lane that ran at the wrong moment. "a hanging backend cannot stall the fast report" injected clients that never resolve, set timeoutMs to 50, and then asserted: assert.ok(Date.now() - started < 500); That measures the machine, not the behaviour. The 50ms timeout can fire exactly as designed and the assertion still fails because the runner was busy. Observed at 689ms on windows-latest here, and locally at 1818ms under full-suite load while passing in isolation at 82ms — flagged as a known fragility in SC-A2's PR description before it became a failure. Replaced with the property the test exists to defend: a probe fed by a hanging client must never come back claiming it verified anything. agent.transport, auth.credential, agent.catalog and mcp.broker are named explicitly rather than filtered on an axis, because local checks like workspace.git legitimately do verify in this fixture — nothing about them touches the backend that is hanging. The first attempt filtered on `reachable !== "na"` and failed on workspace.git for exactly that reason. The wall clock is still bounded, but as a hang detector rather than a stopwatch: 30s distinguishes "returned" from "awaited forever", which is the failure the test was written to catch. A slow runner no longer registers as a bug. Stronger than what it replaces, not weaker. Mutation-checked: making notChecked return a verified axis fails this test along with two others (5 pass / 3 fail); restoring gives 8 / 8. The old assertion would have passed that mutation untouched — it never looked at a single axis. Gates at this commit: npm run typecheck exit 0 npm test 932 pass / 0 fail This fix is independent of the usage work and would be better as its own PR against main. It is here because it is what is currently red. --- test/diagnostics.test.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/test/diagnostics.test.ts b/test/diagnostics.test.ts index 92eae6b..3ee5a7f 100644 --- a/test/diagnostics.test.ts +++ b/test/diagnostics.test.ts @@ -212,8 +212,33 @@ test("a hanging backend cannot stall the fast report", async () => { const report = await diagnosticReport(ctx, { dependencies: { memoryRoots: roots, mcpStore: store, mcpClient: hanging, timeoutMs: 50 }, }); - assert.ok(Date.now() - started < 500); + + // What must hold is that the report CAME BACK and told the truth about the + // probes it could not complete. The previous assertion was a 500ms wall-clock + // budget, which measured the machine rather than the behaviour: a loaded CI + // runner blows it even when the 50ms timeout fired exactly as designed + // (observed on windows-latest at 689ms, and locally at 1818ms under full-suite + // load while passing in isolation at 82ms). assert.equal(report.mode, "fast"); + + // A probe fed by one of the hanging clients must never come back claiming it + // verified anything. Named explicitly rather than filtered on an axis: local + // checks like workspace.git legitimately verify here, because nothing about + // them touches the backend that is hanging. + const BACKEND_FED = ["agent.transport", "auth.credential", "agent.catalog", "mcp.broker"]; + const probed = report.checks.filter((check) => BACKEND_FED.includes(check.id)); + assert.equal(probed.length, BACKEND_FED.length, "every backend-fed check should be present in the report"); + for (const check of probed) { + assert.notEqual(check.verified.state, "yes", `${check.id} cannot be verified against a hanging backend`); + } + + // Still bound the wall clock, but as a hang detector rather than a stopwatch. + // The failure this guards against is an unbounded await, which never returns + // at all; any finite margin distinguishes that from a slow runner. + assert.ok( + Date.now() - started < 30_000, + "the fast report must be bounded by its own timeout, not by the backend", + ); }); test("--live renders the live report, never the fast one relabelled", async () => {