mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* v1.91.70.0 feat(unattended): artifact contract, unattended session kind, findings-derived review log, gate list Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.72.0 feat(autoplan): unattended runner next|submit, pluggable outside voice, review checklists, spec-loop cap Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(b-runner): atomic lock creation in spend-ledger, judge bundle skips runner-only sections, fixture env spelling - lib/spend-ledger.ts: create the lock by linking a complete temp file so a contender never reads an empty lock and breaks a live owner (CI admitted 20 of a cap of 8 under contention); unreadable locks break only by mtime age. - test/helpers/workflow-judge-input.ts: exclude manifest runner_only sections (the B4 checklists) from the judge bundle; the judge rates the interactive workflow. test/workflow-judge-input.test.ts expects the same set. - test/helpers/autoplan-run-fixture.ts: spell the runner's session kind without the literal the free-suite environment scan rejects. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(spend-ledger): accept Windows' status-only report of the killed child 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>
111 lines
5.7 KiB
TypeScript
111 lines
5.7 KiB
TypeScript
/**
|
|
* lib/spend-ledger.ts (plan B2): admission is `spent + reserved + unknown +
|
|
* estimate <= cap` under a file lock; reservations survive a crash; unknown
|
|
* settlements count at their reservation; concurrent writers never admit past
|
|
* the cap. Pins the wire contract (file shape, codes, counts), not prose.
|
|
*/
|
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { SpendCapExceeded, readLedger, release, renderSpend, reserve, settle, summarize } from '../lib/spend-ledger';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'spend-ledger-'));
|
|
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
|
let n = 0;
|
|
const ledgerFile = () => path.join(TMP, `run-${++n}`, 'spend.json');
|
|
|
|
describe('spend ledger admission', () => {
|
|
test('reserve admits up to the cap and refuses the attempt that would cross it, leaving the file unchanged', () => {
|
|
const file = ledgerFile();
|
|
reserve(file, 'a1', 0.4, { cap: 1 });
|
|
reserve(file, 'a2', 0.5, { cap: 1 });
|
|
expect(() => reserve(file, 'a3', 0.2)).toThrow(SpendCapExceeded);
|
|
const ledger = readLedger(file);
|
|
expect(ledger.reserved.map(r => r.attempt)).toEqual(['a1', 'a2']);
|
|
expect(summarize(ledger)).toMatchObject({ cap_usd: 1, spent_usd: 0, reserved_usd: 0.9, worst_case_usd: 0.9, headroom_usd: 0.1 });
|
|
try { reserve(file, 'a3', 0.2); } catch (e: any) { expect(e.code).toBe('SPEND_CAP_EXCEEDED'); expect(e.detail).toEqual({ cap_usd: 1, spent_usd: 0, reserved_usd: 0.9, estimate_usd: 0.2 }); }
|
|
});
|
|
|
|
test('settle moves a reservation to spent; an unknown settlement keeps its reservation in worst_case, never zero', () => {
|
|
const file = ledgerFile();
|
|
reserve(file, 'native-1', 0.3, { cap: 2 });
|
|
reserve(file, 'outside-1', 0.5);
|
|
settle(file, 'outside-1', 0.42);
|
|
settle(file, 'native-1', 'unknown');
|
|
const s = summarize(readLedger(file));
|
|
expect(s).toMatchObject({ spent_usd: 0.42, reserved_usd: 0, unknown_usd: 0.3, unknown_count: 1, worst_case_usd: 0.72, open_attempts: [] });
|
|
expect(renderSpend(s)).toBe('SPEND: spent=0.42 reserved=0.00 unknown=1(0.30) worst_case=0.72 cap=2.00');
|
|
// a later reservation is admitted against worst_case, so the unknown charge still counts
|
|
expect(() => reserve(file, 'outside-2', 1.3)).toThrow(SpendCapExceeded);
|
|
reserve(file, 'outside-2', 1.28);
|
|
});
|
|
|
|
test('reserve, settle and release are idempotent per attempt', () => {
|
|
const file = ledgerFile();
|
|
reserve(file, 'x', 0.1, { cap: 1 });
|
|
reserve(file, 'x', 0.9);
|
|
expect(readLedger(file).reserved).toHaveLength(1);
|
|
settle(file, 'x', 0.05);
|
|
settle(file, 'x', 0.05);
|
|
expect(readLedger(file).spent_usd).toBe(0.05);
|
|
expect(() => reserve(file, 'x', 0.1)).toThrow(/already settled/);
|
|
reserve(file, 'y', 0.2);
|
|
release(file, 'y');
|
|
release(file, 'y');
|
|
const ledger = readLedger(file);
|
|
expect(ledger.reserved).toEqual([]);
|
|
expect(ledger.released).toHaveLength(1);
|
|
});
|
|
|
|
test('a reservation written before a crash is still held by the next process', () => {
|
|
const file = ledgerFile();
|
|
const crash = spawnSync('bun', ['-e', `
|
|
const { reserve } = await import(${JSON.stringify(path.join(ROOT, 'lib/spend-ledger.ts'))});
|
|
reserve(process.argv[1], 'crashed', 0.6, { cap: 1 });
|
|
process.kill(process.pid, 'SIGKILL');
|
|
`, file], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 });
|
|
// POSIX reports the signal; Windows reports a non-zero status for a killed process.
|
|
expect(crash.signal === 'SIGKILL' || (crash.signal === null && crash.status !== 0), JSON.stringify({ signal: crash.signal, status: crash.status })).toBe(true);
|
|
expect(fs.existsSync(`${file}.lock`)).toBe(false);
|
|
const ledger = readLedger(file);
|
|
expect(ledger.reserved.map(r => r.attempt)).toEqual(['crashed']);
|
|
expect(() => reserve(file, 'next', 0.5)).toThrow(SpendCapExceeded);
|
|
reserve(file, 'next', 0.4);
|
|
});
|
|
|
|
test('a stale lock left by a dead pid is reclaimed', () => {
|
|
const file = ledgerFile();
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(`${file}.lock`, JSON.stringify({ pid: 999999, host: os.hostname(), at: new Date().toISOString() }));
|
|
reserve(file, 'after-stale', 0.1, { cap: 1 });
|
|
expect(readLedger(file).reserved).toHaveLength(1);
|
|
expect(fs.existsSync(`${file}.lock`)).toBe(false);
|
|
});
|
|
|
|
test('eight concurrent writers never admit past the cap', () => {
|
|
const file = ledgerFile();
|
|
const script = `
|
|
const { reserve } = await import(${JSON.stringify(path.join(ROOT, 'lib/spend-ledger.ts'))});
|
|
let ok = 0;
|
|
for (let i = 0; i < 5; i++) {
|
|
try { reserve(process.argv[1], process.argv[2] + '-' + i, 0.25, { cap: 2 }); ok++; } catch (e) { if (e.code !== 'SPEND_CAP_EXCEEDED') throw e; }
|
|
}
|
|
console.log(ok);
|
|
`;
|
|
const procs = Array.from({ length: 8 }, (_, i) => Bun.spawn(['bun', '-e', script, file, `w${i}`], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' }));
|
|
const results = procs.map(p => ({ code: p.exited, out: new Response(p.stdout).text(), err: new Response(p.stderr).text() }));
|
|
return Promise.all(results.map(async r => ({ code: await r.code, out: await r.out, err: await r.err }))).then(all => {
|
|
for (const r of all) expect(r.code, r.err).toBe(0);
|
|
const admitted = all.reduce((s, r) => s + Number(r.out.trim()), 0);
|
|
expect(admitted).toBe(8);
|
|
const ledger = readLedger(file);
|
|
expect(ledger.reserved).toHaveLength(8);
|
|
expect(summarize(ledger).worst_case_usd).toBe(2);
|
|
expect(fs.existsSync(`${file}.lock`)).toBe(false);
|
|
});
|
|
});
|
|
});
|