Files
Garry Tanandcapy-ai[bot] 398618259d v1.91.286.0 feat(ship-queue): the ship size cap composes across a merge train; verify refuses a stale all-merged train (#3519) (#3522)
* feat(ship-queue): the ship size cap composes across a merge train; verify refuses a stale all-merged train (#3519 b, c)

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

* fix: pre-landing review fixes (runnable clear-time NEXT, stale exit only on a fetched main, open-slot freeze control, LF size-ledger fixture)

- verify's clear-time NEXT names gh pr view <n> --json state,headRefOid per unreadable slot instead of a pasteable #n@sha that views the current branch's PR
- QUEUE_TRAIN_STALE needs a fetched main (never under --no-fetch) and reports a freeze restored from the notes ref instead of saying the queue is not frozen
- freezeQueue docstring matches the unreadable field
- new test: a train with a slot still OPEN freezes even when another train verified main's sha
- test/size-ledger.test.ts fixture repo sets core.autocrlf false (Windows Free Tests shard 4 counted CRLF bytes)

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

* docs: CHANGELOG [Unreleased] entry for the ship size ledger and QUEUE_TRAIN_STALE

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

* test: run scripts/size-ledger.ts (check on this tree, refusals write nothing)

The remote pregate refused 82f160c with PREGATE_NO_LANE: no test named scripts/size-ledger.ts. The new case runs the CLI itself.

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

* test: spawn scripts/size-ledger.ts with bunIsolationArgs() so native Windows Bun starts it

The CLI test passed --config=/dev/null, which native Bun on Windows cannot open
(#3415); Windows Free Tests shard 6 failed at 4143373 with exit 1 and empty stdout.

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

* FILES: CHANGELOG.md VERSION agents-digest/gstack-AGENTS.md package.json
MERGE_CONDITION: sha=1b7c8b3af32a093924205f4894cba6c5d224630a
next: NEW_VERSION=1.91.286.0; commit and push only the stamp diff above (FILES), with no edit of your own: the train's verify accepts the CHANGELOG only as the base's plus this PR's entry re-headed; merge with the expected head 1b7c8b3af3 and recompute on a stale queue.
GSTACK_RESULT: skill=restamp status=complete run=/workspace/gstack/.gstack/tmp/restamp-journal.json

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-28 00:27:22 +00:00

88 lines
5.0 KiB
TypeScript

/**
* Size ledger for a carved skill's union bytes (GSTA-132).
*
* A guard with `sizeLedger` replaces the one-tree `maxSizeRatio` pin: the
* skill's union bytes must equal `base.json`'s `union_bytes` plus the sum of
* one entry per branch that changed them (`<branch>.json`: `{ bytes, why }`).
* An entry is a property of its branch's own diff, so it survives base merges
* and composes across a merge train: a clean three-way merge adds byte deltas
* exactly, and overlapping hunks are a train conflict. Equality, not a ceiling:
* an undeclared shrink would leave headroom a later branch could spend without
* declaring it. Record an entry with
* `bun --no-env-file --config=/dev/null scripts/size-ledger.ts record <skill> --why "<what changed>"`.
*/
import * as fs from 'fs';
import * as path from 'path';
export interface SizeLedger {
dir: string;
base: { union_bytes: number; measured_at: string };
entries: Array<{ file: string; bytes: number; why: string }>;
}
export const LEDGER_ENTRY = /^[a-z0-9][a-z0-9._-]*\.json$/;
export function recordCommand(skill: string): string {
return `bun --no-env-file --config=/dev/null scripts/size-ledger.ts record ${skill} --why "<what changed>"`;
}
/** Branch name to entry filename: lowercase, [a-z0-9._-] only. */
export function entryFile(branch: string): string {
const slug = branch.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[^a-z0-9]+|-+$/g, '');
if (!slug || slug === 'base') throw new Error(`branch ${JSON.stringify(branch)} gives no usable ledger entry name; pass --branch <name>`);
return `${slug}.json`;
}
export function readSizeLedger(repoRoot: string, dir: string): SizeLedger | { error: string } {
const abs = path.join(repoRoot, dir);
let base: unknown;
try { base = JSON.parse(fs.readFileSync(path.join(abs, 'base.json'), 'utf-8')); } catch (err) { return { error: `${dir}/base.json unreadable: ${(err as Error).message}` }; }
const b = base as { union_bytes?: unknown; measured_at?: unknown };
if (!Number.isSafeInteger(b.union_bytes) || (b.union_bytes as number) <= 0 || typeof b.measured_at !== 'string' || !b.measured_at.trim()) {
return { error: `${dir}/base.json needs a positive integer union_bytes and a measured_at` };
}
const entries: SizeLedger['entries'] = [];
for (const file of fs.readdirSync(abs).sort()) {
if (!file.endsWith('.json') || file === 'base.json') continue;
if (!LEDGER_ENTRY.test(file)) return { error: `${dir}/${file}: entry names are [a-z0-9._-] ending in .json` };
let e: { bytes?: unknown; why?: unknown };
try { e = JSON.parse(fs.readFileSync(path.join(abs, file), 'utf-8')); } catch (err) { return { error: `${dir}/${file} unreadable: ${(err as Error).message}` }; }
if (!Number.isSafeInteger(e.bytes) || e.bytes === 0 || typeof e.why !== 'string' || !e.why.trim()) {
return { error: `${dir}/${file} needs a non-zero integer bytes and a why` };
}
entries.push({ file, bytes: e.bytes as number, why: e.why });
}
return { dir, base: { union_bytes: b.union_bytes as number, measured_at: b.measured_at }, entries };
}
export const ledgerExpected = (l: SizeLedger) => l.base.union_bytes + l.entries.reduce((n, e) => n + e.bytes, 0);
/** Set `branch`'s entry to what `measured` differs from the base plus every other entry; zero deletes it. */
export function recordEntry(repoRoot: string, ledger: SizeLedger, measured: number, branch: string, why: string): { file: string; bytes: number } {
const file = entryFile(branch);
const bytes = measured - ledger.base.union_bytes - ledger.entries.filter(e => e.file !== file).reduce((n, e) => n + e.bytes, 0);
const target = path.join(repoRoot, ledger.dir, file);
if (bytes === 0) fs.rmSync(target, { force: true });
else fs.writeFileSync(target, JSON.stringify({ bytes, why: why.trim() }, null, 2) + '\n');
return { file, bytes };
}
/** Move the entries' sum into base.json and delete them; the caller checks the ledger holds first. */
export function foldLedger(repoRoot: string, ledger: SizeLedger, measuredAt: string): number {
const total = ledgerExpected(ledger);
const abs = path.join(repoRoot, ledger.dir);
fs.writeFileSync(path.join(abs, 'base.json'), JSON.stringify({ union_bytes: total, measured_at: measuredAt.trim() }, null, 2) + '\n');
for (const e of ledger.entries) fs.rmSync(path.join(abs, e.file));
return total;
}
/** Null when `measured` is what the ledger accounts for, else the failure line. */
export function ledgerFailure(skill: string, measured: number, ledger: SizeLedger | { error: string }, baselineBytes?: number): string | null {
if ('error' in ledger) return `size ledger: ${ledger.error}`;
const expected = ledgerExpected(ledger);
if (measured === expected) return null;
const ratio = baselineBytes ? ` (${(measured / baselineBytes).toFixed(4)}x baseline)` : '';
const d = measured - expected;
return `size union ${measured} bytes${ratio} != ledger ${ledger.base.union_bytes} + ${expected - ledger.base.union_bytes} (${ledger.entries.length} entries in ${ledger.dir}) = ${expected}; this tree is ${d > 0 ? '+' : ''}${d} bytes off: ${recordCommand(skill)}`;
}