diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 7f04b53..31b4143 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -108,14 +108,20 @@ export async function runTurn( ): Promise { const backend = await resolveBackend(ctx); if (backend === "local") { - // The signal was dropped here, so the REPL's Ctrl+C controller could not - // reach a local turn at all — the abort fired and nothing observed it. + // Aether meters nothing on a local brain, so the session is unmetered + // rather than "zero spend so far". + getRegistry().markLocalUnmetered(); + // The signal used to be dropped here, so the REPL Ctrl+C controller could + // not reach a local turn at all: the abort fired and nothing observed it. await runLocalTurn(ctx, prompt, signal); 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( @@ -125,6 +131,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, @@ -167,6 +187,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"); +});