Files
gstack/lib/spend-ledger.ts
T
Garry Tanandcapy-ai[bot] c71d764ba4 v1.91.115.0 feat: field list 2 wave A: autoplan round cap and public export, eval-plan diagnostics and model rules, --for review-voice (#3234)
* fix(capy): the install trailer says behind=<n> and its fix, so a stale install shows in every relayed trailer (field list 2 item 13, #2378)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(capy): a lease records its holder's start identity, so a pid reused after a wake is reclaimed; a skipped session-start upgrade ends fail behind, never ok (GBRA-105, GBRA-106)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): stale needs proof (unreadable or malformed identities keep the lease), ps identity is timezone-independent, and reclaim never deletes a concurrent renewal

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(autoplan): enforced round cap and spend budget, deferred close, public export without machine paths, opt-in durable checkpoints (field list 2 W5-W7)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): one directory lock for publish, prune and reclaim; reclaim rereads the record judged stale, so a renewal is never deleted or hidden

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): the directory lock names its holder; broken only when the holder is dead or its pid reused, unlock removes only its own lock, touch exits 3 under a live holder

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): breakers serialize on .lock.break and reread the lock before removing it; gstack-skill-start reports a lease the held lock kept out

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(lease): name the double-crash limit of breaking a dead lock

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(capy): --for review-voice install profile, no-Drive bootstrap command, reviewer prompt trailer (field list 2 W11)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 89bf556c11)

* test: pin review-voice row, helpers row and the no-Drive fallback in existing install tests

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 7f6c70459a)

* test: the DRIVER template names the reviewer trailer relay (pregate lane)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 2545d9a6d2)

* test(context-budget): capy skill eager ceiling for the review-voice subagent paragraph (W11; ratchet protocol, capture value)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(eval-plan): prereg diagnostics dry run (W8) and .gstack/eval-models.json lint (W9), WIP

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit b509096218)

* feat(eval-plan): W8/W9 tests, docs/eval-models.md, troubleshooting anchors, /eval-plan steps

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 9335e45de2)

* test(eval-plan): eval-plan/SKILL.md names only defined codes and accepted flags

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit a7a974d268)

* test(review-voice): the launch line points at the verdict's bun-only directory (#3233's launch contract)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(third-party-actions): the Aside drive-offer detector matches the product name, not the English idiom (paid run 38988927989 tpa-absent-linux false positive)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): export refuses artifact paths outside the run before writing; a deferred close keeps earlier undecided findings; reopening CEO grants Eng its rerun (review of #3234)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(autoplan-budget): bind the reservation expectation to lib/autoplan-budget's default estimate (test-of-test ratchet)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): the docs observer accepts the spawned-prefixed gstack-skill-start --help read

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(windows): curate capy-review-voice out of the Windows subset (POSIX launcher contract)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: changelog entry for field list 2 wave A

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: stamp v1.91.115.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-17 02:40:45 +00:00

176 lines
9.1 KiB
TypeScript

/**
* spend-ledger — the minimal shared spend ledger (plan B2; E1 extends it).
* One JSON file per run (`<out>/spend.json`), mutated only under a file lock
* so concurrent workers never admit past the cap: `reserve` admits an attempt
* only when `spent + reserved + next_estimate <= cap`; `settle` moves a
* reservation to `spent` (or to `unknown`, kept at its reservation, when the
* host dispatched the work and no price is known: host-subagent spend is
* `unknown`, never zero); `release` drops a reservation that never ran.
* Reservations are plain rows in the file, so a crash between reserve and
* settle leaves the attempt reserved until someone settles or releases it;
* nothing is forgotten on restart. Admission is an estimate: the price table
* is incomplete and actual cost reconciles at settle time, which is why the
* summary shows `worst_case` (spent + reserved + unknown) beside `spent`.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
export interface Reservation { attempt: string; estimate_usd: number; at: string; label?: string }
export interface Settlement { attempt: string; usd: number | 'unknown'; reserved_usd: number; at: string; label?: string }
export interface SpendLedger {
schema_version: 1;
cap_usd: number | null;
spent_usd: number;
reserved: Reservation[];
settled: Settlement[];
released: Array<{ attempt: string; estimate_usd: number; at: string }>;
}
export interface SpendSummary {
cap_usd: number | null; spent_usd: number; reserved_usd: number; unknown_usd: number; unknown_count: number;
worst_case_usd: number; headroom_usd: number | null; open_attempts: string[];
}
export class SpendCapExceeded extends Error {
code = 'SPEND_CAP_EXCEEDED' as const;
constructor(public readonly detail: { cap_usd: number; spent_usd: number; reserved_usd: number; estimate_usd: number }) {
super(`spent ${detail.spent_usd} + reserved ${detail.reserved_usd} + estimate ${detail.estimate_usd} > cap ${detail.cap_usd}`);
}
}
const round = (n: number) => Math.round(n * 1e6) / 1e6;
export function emptyLedger(cap: number | null): SpendLedger {
return { schema_version: 1, cap_usd: cap, spent_usd: 0, reserved: [], settled: [], released: [] };
}
export function readLedger(file: string, cap: number | null = null): SpendLedger {
if (!fs.existsSync(file)) return emptyLedger(cap);
const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as SpendLedger;
if (parsed.schema_version !== 1) throw new Error(`${file}: schema_version ${parsed.schema_version} is not 1`);
return parsed;
}
function writeLedger(file: string, ledger: SpendLedger): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(ledger, null, 2) + '\n');
fs.renameSync(tmp, file);
}
/** Exclusive-create lock with a stale-owner check; the lock holds for one mutation only. */
export function withFileLock<T>(file: string, fn: () => T, opts: { timeoutMs?: number; staleMs?: number } = {}): T {
const lock = `${file}.lock`;
const timeoutMs = opts.timeoutMs ?? 10_000;
const staleMs = opts.staleMs ?? 60_000;
const deadline = Date.now() + timeoutMs;
fs.mkdirSync(path.dirname(file), { recursive: true });
// The lock appears with its owner record already written (link of a complete
// temp file), so a contender never reads a half-written lock and mistakes a
// live owner for a stale one.
const tmp = `${lock}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
for (;;) {
try {
fs.writeFileSync(tmp, JSON.stringify({ pid: process.pid, host: os.hostname(), at: new Date().toISOString() }));
fs.linkSync(tmp, lock);
fs.unlinkSync(tmp);
break;
} catch (e: any) {
try { fs.unlinkSync(tmp); } catch {}
if (e.code !== 'EEXIST') throw e;
let stale = false;
try {
const owner = JSON.parse(fs.readFileSync(lock, 'utf8')) as { pid?: number; host?: string; at?: string };
const age = Date.now() - Date.parse(owner.at ?? '');
const sameHost = owner.host === os.hostname();
let alive = true;
if (sameHost && typeof owner.pid === 'number') {
try { process.kill(owner.pid, 0); } catch (k: any) { alive = k.code === 'EPERM'; }
}
stale = (sameHost && !alive) || (Number.isFinite(age) && age > staleMs);
} catch (read: any) {
// Unreadable lock: only its age can justify breaking it; a vanished lock means retry.
if (read.code !== 'ENOENT') { try { stale = Date.now() - fs.statSync(lock).mtimeMs > staleMs; } catch {} }
}
if (stale) { try { fs.unlinkSync(lock); } catch {} continue; }
if (Date.now() > deadline) throw new Error(`timed out waiting for ${lock}`);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5 + Math.floor(Math.random() * 20));
}
}
try { return fn(); } finally { try { fs.unlinkSync(lock); } catch {} }
}
export function summarize(ledger: SpendLedger): SpendSummary {
const reserved = round(ledger.reserved.reduce((s, r) => s + r.estimate_usd, 0));
const unknown = ledger.settled.filter(s => s.usd === 'unknown');
const unknownUsd = round(unknown.reduce((s, r) => s + r.reserved_usd, 0));
const worst = round(ledger.spent_usd + reserved + unknownUsd);
return {
cap_usd: ledger.cap_usd, spent_usd: round(ledger.spent_usd), reserved_usd: reserved, unknown_usd: unknownUsd,
unknown_count: unknown.length, worst_case_usd: worst,
headroom_usd: ledger.cap_usd === null ? null : round(ledger.cap_usd - worst),
open_attempts: ledger.reserved.map(r => r.attempt),
};
}
/** Admit one attempt: `spent + reserved + unknown + estimate <= cap`, atomically under the lock. */
export function reserve(file: string, attempt: string, estimateUsd: number, opts: { cap?: number | null; label?: string; now?: Date } = {}): SpendLedger {
if (!Number.isFinite(estimateUsd) || estimateUsd < 0) throw new Error(`estimate must be a non-negative number, got ${estimateUsd}`);
return withFileLock(file, () => {
const ledger = readLedger(file, opts.cap ?? null);
if (opts.cap !== undefined) ledger.cap_usd = opts.cap;
if (ledger.reserved.some(r => r.attempt === attempt)) return ledger;
if (ledger.settled.some(s => s.attempt === attempt)) throw new Error(`attempt ${attempt} is already settled`);
const summary = summarize(ledger);
if (ledger.cap_usd !== null && round(summary.worst_case_usd + estimateUsd) > ledger.cap_usd) {
throw new SpendCapExceeded({ cap_usd: ledger.cap_usd, spent_usd: summary.spent_usd, reserved_usd: round(summary.reserved_usd + summary.unknown_usd), estimate_usd: estimateUsd });
}
ledger.reserved.push({ attempt, estimate_usd: round(estimateUsd), at: (opts.now ?? new Date()).toISOString(), ...(opts.label ? { label: opts.label } : {}) });
writeLedger(file, ledger);
return ledger;
});
}
/** Move a reservation to spent (actual cost) or to unknown (kept at its reservation). Idempotent per attempt. */
export function settle(file: string, attempt: string, usd: number | 'unknown', opts: { now?: Date } = {}): SpendLedger {
if (usd !== 'unknown' && (!Number.isFinite(usd) || usd < 0)) throw new Error(`usd must be 'unknown' or a non-negative number, got ${usd}`);
return withFileLock(file, () => {
const ledger = readLedger(file);
if (ledger.settled.some(s => s.attempt === attempt)) return ledger;
const i = ledger.reserved.findIndex(r => r.attempt === attempt);
const reservation = i >= 0 ? ledger.reserved.splice(i, 1)[0]! : { attempt, estimate_usd: 0, at: '' };
if (usd !== 'unknown') ledger.spent_usd = round(ledger.spent_usd + usd);
ledger.settled.push({ attempt, usd: usd === 'unknown' ? 'unknown' : round(usd), reserved_usd: reservation.estimate_usd, at: (opts.now ?? new Date()).toISOString(), ...(reservation.label ? { label: reservation.label } : {}) });
writeLedger(file, ledger);
return ledger;
});
}
/** Drop a reservation for an attempt that never ran (cancelled before dispatch). */
export function release(file: string, attempt: string, opts: { now?: Date } = {}): SpendLedger {
return withFileLock(file, () => {
const ledger = readLedger(file);
const i = ledger.reserved.findIndex(r => r.attempt === attempt);
if (i < 0) return ledger;
const [row] = ledger.reserved.splice(i, 1);
ledger.released.push({ attempt, estimate_usd: row!.estimate_usd, at: (opts.now ?? new Date()).toISOString() });
writeLedger(file, ledger);
return ledger;
});
}
/** Change the cap (an owner's budget raise); every later admission checks the new cap. */
export function setCap(file: string, cap: number | null): SpendLedger {
return withFileLock(file, () => {
const ledger = readLedger(file, cap);
ledger.cap_usd = cap;
writeLedger(file, ledger);
return ledger;
});
}
export function renderSpend(summary: SpendSummary): string {
const cap = summary.cap_usd === null ? 'none' : summary.cap_usd.toFixed(2);
return `SPEND: spent=${summary.spent_usd.toFixed(2)} reserved=${summary.reserved_usd.toFixed(2)} unknown=${summary.unknown_count}(${summary.unknown_usd.toFixed(2)}) worst_case=${summary.worst_case_usd.toFixed(2)} cap=${cap}`;
}