ship-budget: a re-start asking for tree upgrades an open run; tree labels reach the return file, drift and reconcile lines; reading-missing fix names the subagent route

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 14:14:29 +00:00
1 parent e05c77089c
commit a3e80c09ac
5 files changed
+49 -7

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ export const BUDGET_RESULT_CODES = {
BUDGET_READING_MISSING: {
anchor: 'budget-reading-missing',
summary: 'the Capy meter needs a fresh counter reading and none was passed',
fix: 'read cost.tokens from tools.thread.get({ thread_id: "<your own jam id>" }) (your own jam, its subagents included, never the root thread) inside the execute program that runs the command and pass it with --reading <n> (ship/sections/budget-handoff.md has the recipe)',
fix: 'read cost.tokens from tools.thread.get({ thread_id: "<your own jam id>" }) (your own jam, its subagents included; a subagent that can read only the root thread starts with --subagent --reading-from this --meter-scope tree) inside the execute program that runs the command and pass it with --reading <n> (ship/sections/budget-handoff.md has the recipe)',
},
METER_SCOPE_PARENT: {
anchor: 'meter-scope-parent',
+4 -2
View File
@@ -254,8 +254,10 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
if (delegate !== 'auto' && delegate !== 'never') throw usageError(`--delegate '${delegate}' is not auto or never`);
const existing = parseRecordBlock(record);
if (existing?.ledger && fs.existsSync(existing.ledger) && !readLedger(existing.ledger).closed_at) {
const l = readLedger(existing.ledger);
return labelScope(l, { stdout: [`BUDGET_RUN: id=${l.run} ledger=${l.file}`, budgetLine(l, { call: null, next: '1', projected: 0, verdict: l.stopped ? 'stop' : 'continue' })], exit: l.stopped ? 3 : 0 });
return mutateLedger(existing.ledger, ctx.env, l => {
if (scope === 'tree' || parentCounter) l.meter_scope = 'tree';
return { stdout: [`BUDGET_RUN: id=${l.run} ledger=${l.file}`, budgetLine(l, { call: null, next: '1', projected: 0, verdict: l.stopped ? 'stop' : 'continue' })], exit: l.stopped ? 3 : 0 };
});
}
const meter = pickMeter(o);
if (!meter) {
+1 -1
View File
@@ -370,7 +370,7 @@ export const TREE_METER = 'METER: tree (root counter, upper bound)';
/** A tree-scoped run never prints a cost line without TREE_METER on the line after it. */
export function labelScope<T>(l: Ledger, out: T): T {
if (l.meter_scope !== 'tree') return out;
const label = (lines: string[]) => lines.flatMap(line => /^(BUDGET|BUDGET_FINAL|BUDGET_TIMELINE|NOTE):/.test(line) ? [line, TREE_METER] : [line]);
const label = (lines: string[]) => lines.flatMap((line, i) => /^(BUDGET|BUDGET_FINAL|BUDGET_TIMELINE|BUDGET_DRIFT|NOTE|RECONCILED):/.test(line) && lines[i + 1] !== TREE_METER ? [line, TREE_METER] : [line]);
if (Array.isArray(out)) return label(out) as T;
const o = out as { stdout?: unknown };
if (Array.isArray(o?.stdout)) o.stdout = label(o.stdout);
+2 -2
View File
@@ -229,11 +229,11 @@ export function cmdReturn(argv: string[], ctx: Ctx): Outcome {
const unreconciled = l.handoffs.filter(h => !h.reconciled_at);
const dropped = l.reservations.filter(r => r.remaining?.length && !accepted.has(r.id));
const result = status === 'complete' && (unreconciled.length || dropped.length) ? 'incomplete' : status;
const lines = [`RETURN: handoff=${l.parent.handoff_sha} status=${result} steps=${l.parent.from}-${to} head=${head} tree=${tree}`,
const lines = labelScope(l, [`RETURN: handoff=${l.parent.handoff_sha} status=${result} steps=${l.parent.from}-${to} head=${head} tree=${tree}`,
...unreconciled.map(h => `UNRECONCILED: handoff=${h.sha} steps=${h.from}-${h.to} (reconcile --run ${l.run} --return ${h.return_file} before returning)`),
...dropped.map(r => `DROPPED: step=${r.step} reservation=${r.id} roles=${r.remaining!.join(',')} (ADMIT named them; no later check requested them)`),
`RECORD_CHANGED: ${str(o, 'changed') ?? 'none'}`, ...(l.parent_metered ? [PARENT_METERED] : []), budgetLine(l, { call: null, next: '-', projected: 0, verdict: 'continue' }),
`GSTACK_RESULT: skill=ship status=${result} run=${l.dir}`];
`GSTACK_RESULT: skill=ship status=${result} run=${l.dir}`]);
writeReturnFile(l.parent.return_file, lines);
l.closed_at = new Date().toISOString();
return { stdout: lines, exit: 0 };
+41 -1
View File
@@ -8,11 +8,12 @@ import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { RESULT_CODES } from '../lib/result-codes';
import { labelScope, type Ledger } from '../lib/ship-budget';
import { budgetHarness, type Harness } from './helpers/ship-budget-harness';
const ROOT = path.resolve(import.meta.dir, '..');
const TREE = 'METER: tree (root counter, upper bound)';
const COST = /^(BUDGET|BUDGET_FINAL|BUDGET_TIMELINE|NOTE):/;
const COST = /^(BUDGET|BUDGET_FINAL|BUDGET_TIMELINE|BUDGET_DRIFT|NOTE|RECONCILED):/;
const harnesses: Harness[] = [];
const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; };
afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); });
@@ -91,6 +92,45 @@ describe('meter scope (GST-467)', () => {
}
});
// Value: protects=re-running start with tree flags on an open self run upgrades it, and a tree child's saved return file keeps the label the parent reads; fails_when=the re-start returns before applying the scope, or the return file is written before labelScope (outside voice, ship gst467-p1);
// why_new=start's resume branch and the return file were never driven with tree scope; seam=none; no_claim=a downgrade, which tree never allows
test('a re-start asking for tree upgrades an open run, and a tree child return file carries the label once', () => {
const h = fresh();
const record = h.record();
const s = h.sb(['start', '--record', record, '--mode', 'observe', '--reading', '1000000']);
expect(s.out).not.toContain(TREE);
const again = h.sb(['start', '--record', record, '--mode', 'observe', '--reading', '1100000', '--subagent', '--reading-from', 'this', '--meter-scope', 'tree']);
const close = h.sb(['close', '--record', record, '--reading', '2000000']);
for (const out of [again, close]) {
expect(out.code, out.out + out.err).toBe(0);
const labels = costLinesLabeled(out.out);
expect(labels.length, out.out).toBeGreaterThan(0);
expect(labels.every(Boolean), out.out).toBe(true);
}
const p = h.sb(['start', '--record', h.record(), '--token-cap', '200M', '--reading', '1000000', '--meter-scope', 'tree']);
const run = h.field(p.out, 'BUDGET_RUN', 'id');
const ho = h.sb(['handoff', '--run', run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '1300000']);
const returnFile = ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!;
const child = h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+) /m)![1]!]);
const ret = h.sb(['return', '--run', h.field(child.out, 'BUDGET_RUN', 'id'), '--to', '11.5']);
expect(ret.code, ret.out + ret.err).toBe(0);
for (const text of [ret.out, fs.readFileSync(returnFile, 'utf8')]) {
const labels = costLinesLabeled(text);
expect(labels.length, text).toBeGreaterThan(0);
expect(labels.every(Boolean), text).toBe(true);
expect(text.split(TREE).length - 1, text).toBe(labels.length);
}
});
// Value: protects=every root-counter line a tree run prints is labeled once, drift and reconcile lines included; fails_when=labelScope misses BUDGET_DRIFT or RECONCILED, or labels an already-labeled line twice (adversarial, ship gst467-p1);
// why_new=no CLI case above prints drift or reconcile lines; seam=labelScope; no_claim=which commands print them
test('labelScope labels drift and reconcile lines too, and never twice', () => {
const tree = { meter_scope: 'tree' } as Ledger;
const lines = ['RECONCILED: handoff=x child=complete child_used=5', 'BUDGET: used=1', TREE, 'BUDGET_DRIFT: steps=1 step_actual=2', 'GSTACK_RESULT: skill=ship'];
expect(labelScope(tree, [...lines])).toEqual(['RECONCILED: handoff=x child=complete child_used=5', TREE, 'BUDGET: used=1', TREE, 'BUDGET_DRIFT: steps=1 step_actual=2', TREE, 'GSTACK_RESULT: skill=ship']);
expect(labelScope({} as Ledger, [...lines])).toEqual(lines);
});
// Value: protects=a subagent cannot claim its own cost while reading the root counter; fails_when=start accepts --meter-scope self from a subagent metering "this", or the refusal lacks its code and fix;
// why_new=GST-467; seam=none; no_claim=whether the brief sets the subagent flag
test('a subagent metering "this" that asks for a self scope is refused with METER_SCOPE_PARENT and a fix', () => {