mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* ship: token cap with real meters and fresh-context delegation (GSTA-152 PR A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (stopped or lost capped run no longer blocks uncapped review starts; short child return reconciles incomplete) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: carry estimate spend across a meter epoch change; calibration docs match their evidence Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: load the committed calibration file by its repo path so pregate maps it to a lane Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: fix fence placeholders and register claude-usage fixture (GSTA-152 PR A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: run ship-budget and review-log bins through their interpreters so Windows can launch them 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>
128 lines
6.8 KiB
TypeScript
128 lines
6.8 KiB
TypeScript
/**
|
|
* The Claude Code meter (GSTA-152 plan item 3): per (message.id, requestId)
|
|
* maxima, compaction copies, subagent and child journals, explicit session
|
|
* binding, and the incremental reader's offset, rewrite and timing guards.
|
|
*/
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { newMeterState, nonceSessions, readClaudeUsage, sessionJournals } from '../lib/claude-usage';
|
|
import { budgetHarness, type Harness } from './helpers/ship-budget-harness';
|
|
|
|
const FIXTURE = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'claude-usage-2.1.292.json'), 'utf8'));
|
|
const S = FIXTURE.sessions as Record<string, string>;
|
|
const roots: string[] = [];
|
|
const harnesses: Harness[] = [];
|
|
afterEach(() => { for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); while (harnesses.length) harnesses.pop()!.cleanup(); });
|
|
|
|
function configDir(): string {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-usage-'));
|
|
roots.push(root);
|
|
const project = path.join(root, 'projects', '-tmp-repo');
|
|
fs.mkdirSync(path.join(project, S.parent!, 'subagents'), { recursive: true });
|
|
const write = (file: string, lines: string[]) => fs.writeFileSync(file, lines.map(l => l + '\n').join(''));
|
|
write(path.join(project, `${S.parent}.jsonl`), FIXTURE.journals.parent);
|
|
write(path.join(project, `${S.fork}.jsonl`), FIXTURE.journals.fork);
|
|
write(path.join(project, S.parent!, 'subagents', 'agent-a1b2c3.jsonl'), FIXTURE.journals.subagent);
|
|
write(path.join(project, `${S.child}.jsonl`), FIXTURE.journals.child);
|
|
write(path.join(project, `${S.decoy}.jsonl`), FIXTURE.journals.decoy);
|
|
return root;
|
|
}
|
|
|
|
describe('claude-usage meter', () => {
|
|
test('streaming duplicates count once at the max of each field; sidechain and subagent charges count; the last root call is known', () => {
|
|
const state = newMeterState(configDir(), [S.parent!]);
|
|
const r = readClaudeUsage(state);
|
|
expect(r.total).toBe(20_071 + 550);
|
|
expect(r.lastCall).toBe(7_362);
|
|
expect(r.files).toBe(2);
|
|
});
|
|
|
|
test('a compaction fork\'s copies of earlier calls are not counted twice; a headless child adds its own calls', () => {
|
|
const dir = configDir();
|
|
expect(readClaudeUsage(newMeterState(dir, [S.parent!, S.fork!])).total).toBe(20_621 + 36);
|
|
expect(readClaudeUsage(newMeterState(dir, [S.parent!, S.child!])).total).toBe(20_621 + 100);
|
|
});
|
|
|
|
test('binding: a session id names one journal; a nonce must match exactly one journal', () => {
|
|
const dir = configDir();
|
|
expect(sessionJournals(dir, S.parent!)).toHaveLength(1);
|
|
expect(sessionJournals(dir, 'not-a-session')).toEqual([]);
|
|
expect(nonceSessions(dir, 'nonce0abc123')).toEqual([S.parent]);
|
|
expect(nonceSessions(dir, 'nonce-nowhere-1')).toEqual([]);
|
|
fs.appendFileSync(path.join(dir, 'projects', '-tmp-repo', `${S.decoy}.jsonl`), JSON.stringify({ type: 'user', message: { content: 'GSTACK_BUDGET_NONCE=nonce0abc123' } }) + '\n');
|
|
expect(nonceSessions(dir, 'nonce0abc123').sort()).toEqual([S.parent, S.decoy].sort());
|
|
});
|
|
|
|
test('incremental: an append reads only new complete lines; a partial line waits; a rewrite reads from the start', () => {
|
|
const dir = configDir();
|
|
const file = path.join(dir, 'projects', '-tmp-repo', `${S.parent}.jsonl`);
|
|
const state = newMeterState(dir, [S.parent!]);
|
|
expect(readClaudeUsage(state).total).toBe(20_621);
|
|
const extra = JSON.parse(FIXTURE.journals.child[1]);
|
|
extra.sessionId = S.parent;
|
|
const line = JSON.stringify(extra);
|
|
fs.appendFileSync(file, line.slice(0, 40));
|
|
expect(readClaudeUsage(state).total).toBe(20_621);
|
|
fs.appendFileSync(file, line.slice(40) + '\n');
|
|
expect(readClaudeUsage(state).total).toBe(20_721);
|
|
expect(readClaudeUsage(state).lastCall).toBe(100);
|
|
fs.writeFileSync(file, FIXTURE.journals.parent.slice(0, 2).map((l: string) => l + '\n').join(''));
|
|
expect(readClaudeUsage(state).total).toBe(6_253 + 550 - 240);
|
|
});
|
|
|
|
test('timing guard: after a 30 MB journal is read once, a small append is read incrementally', () => {
|
|
const dir = configDir();
|
|
const file = path.join(dir, 'projects', '-tmp-repo', `${S.parent}.jsonl`);
|
|
const template = JSON.parse(FIXTURE.journals.parent[3]);
|
|
template.message.content = [{ type: 'text', text: 'x'.repeat(900) }];
|
|
const chunks: string[] = [];
|
|
let bytes = fs.statSync(file).size;
|
|
for (let i = 0; bytes < 30 * 1024 * 1024; i++) {
|
|
template.message.id = `msg_big_${i}`;
|
|
template.requestId = `req_big_${i}`;
|
|
const line = JSON.stringify(template) + '\n';
|
|
chunks.push(line);
|
|
bytes += line.length;
|
|
}
|
|
fs.appendFileSync(file, chunks.join(''));
|
|
const state = newMeterState(dir, [S.parent!]);
|
|
let t0 = performance.now();
|
|
const first = readClaudeUsage(state);
|
|
const full = performance.now() - t0;
|
|
expect(first.total).toBeGreaterThan(20_621);
|
|
fs.appendFileSync(file, chunks[0]!.replace('msg_big_0', 'msg_tail').replace('req_big_0', 'req_tail'));
|
|
t0 = performance.now();
|
|
const second = readClaudeUsage(state);
|
|
const incremental = performance.now() - t0;
|
|
expect(second.total).toBe(first.total + 6_245);
|
|
expect(full).toBeLessThan(15_000);
|
|
expect(incremental).toBeLessThan(Math.max(250, full / 4));
|
|
}, 60_000);
|
|
|
|
test('gstack-ship-budget start --meter claude binds a session and meters it', () => {
|
|
const h = budgetHarness();
|
|
harnesses.push(h);
|
|
const project = path.join(h.root, 'claude', 'projects', '-tmp-repo');
|
|
fs.mkdirSync(project, { recursive: true });
|
|
fs.writeFileSync(path.join(project, `${S.parent}.jsonl`), FIXTURE.journals.parent.map((l: string) => l + '\n').join(''));
|
|
const record = h.record();
|
|
const none = h.sb(['start', '--record', record, '--token-cap', '25M', '--meter', 'claude']);
|
|
expect(none.code).toBe(2);
|
|
expect(none.err).toMatch(/echo GSTACK_BUDGET_NONCE=[0-9a-f]{16} once.*\(BUDGET_METER_UNAVAILABLE\)/);
|
|
const r = h.sb(['start', '--record', record, '--token-cap', '25M', '--nonce', 'nonce0abc123']);
|
|
expect(r.code).toBe(0);
|
|
expect(h.field(r.out, 'BUDGET', 'meter')).toBe('claude');
|
|
expect(h.field(r.out, 'BUDGET', 'unit')).toBe('claude-usage-tokens');
|
|
expect(h.field(r.out, 'BUDGET', 'call')).toBe('7362');
|
|
expect(h.field(r.out, 'BUDGET', 'used')).toBe('0');
|
|
const run = h.field(r.out, 'BUDGET_RUN', 'id');
|
|
fs.appendFileSync(path.join(project, `${S.parent}.jsonl`), FIXTURE.journals.child[1].replaceAll(S.child!, S.parent!) + '\n');
|
|
const c = h.sb(['check', '--run', run, '--step', '1', '--next', '2']);
|
|
expect(c.code).toBe(0);
|
|
expect(h.field(c.out, 'BUDGET', 'used')).toBe('100');
|
|
expect(h.sb(['check', '--run', run, '--step', '2', '--next', '3', '--reading', '5']).err).toContain('(BUDGET_READING_REJECTED)');
|
|
});
|
|
});
|