Files
gstack/lib/ship-queue.ts
T
cadbd0af15 v1.91.116.0 feat: field list 2 wave B: gstack-ship-queue, approval classes, state-machine review, pregate timing check, update-check main moves (#3235)
* 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>

* fix(restamp): gate-ahead keeps the lane CHANGELOG entry when both PRs took the same tentative stamp (GBRA-102 diagnosis fixture)

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

* test(tree-receipt): a real gbrain stamp pair is same-modulo-stamps under gbrain committed policy (W1 acceptance)

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

* feat(ship-policy): gate_command, full_e2e and min_gstack_version; unknown keys stay fail-closed; validate prints introducing versions

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

* feat(ship-queue): gstack-ship-queue ready, stamp, verify and gate-ahead (WIP, tests next)

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

* test(ship-queue): stamp journal and resume, verify from a separate clone, gate-ahead attempts, ready

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

* docs(ship-policy): Run a shared merge queue; new keys and compatibility rule; troubleshooting anchors; /ship pointer

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

* fix(ship): shared-queue pointer rides the show-effective ship: line (ship skeleton has 2 bytes of parity headroom)

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

* test(windows): curate the shared-queue tests out of the Windows subset (shebang spawns)

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

* test(fixtures): register the gbrain stamp-pair fixture as free-only

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

* test(ship-queue): receipt digest contract names lib/ship-queue.ts for its pregate lane

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

* feat(pregate): timing check flags elapsed-time bounds and sleep-then-assert in added test lines (W4)

lib/pregate/checks/timing.ts prints TIMING_ASSERTION: <file>:<line> <pattern>
and a TIMING_ASSERTIONS summary; gstack-timing-ok: <reason> in any comment
syntax on the line or the line above escapes one. It warns
(PREGATE_TIMING_ASSERTION): measured on gstack (9 of 47 test-touching
commits, 263 hits in the tree) and gbrain (37 of 95 commits; 31 of 38 sites
of the five timing-flake fixes), blocking would stop a fifth to two fifths
of test changes. Adds the /review checklist rule and the /investigate
timing-flake playbook.

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

* feat(update-check): an equal VERSION with a moved main is an upgrade, via the compare API (W12)

When VERSION matches but ls-remote's main SHA differs from this checkout's
HEAD, bin/gstack-update-check asks the GitHub compare API (status, ahead_by)
through the existing receipted curl and prints
UPGRADE_AVAILABLE <ver> <ver>+<n> <sha>; a diverged or unreadable compare is
CHECK_FAILED (unknown), and behind/identical is up to date. Never fetches.

The cache gains an IDENTITY line (head, compared remote SHA, upstream): an
outcome replays only for the same HEAD and upstream, a same-remote refresh
reuses it without another compare, and a git checkout ignores a legacy
one-line cache (non-git installs keep the one-line format). The snooze is
keyed by the SHA. gstack-upgrade's question, snooze note and What's New step
handle the four-field line; gstack-skill-end reports it.

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

* fix(pregate): treat planning and evidence markdown as an empty lane

A docs-only diff with no .gstack/pregate.json failed lanes as
PREGATE_NO_LANE. Planning and evidence Markdown is now a docs/no-tests
empty lane. Unmapped source files still fail closed.

Co-authored-by: Nick <surlymo@apache.org>
(cherry picked from commit e8793e59e8)

* fix(pregate): keep test-read markdown on its test lane

Unmarked Markdown that no test depends on is a docs/no-tests empty lane.
A file a test reads, including a fixture, still selects that test through
the import graph. Unmapped source and unreferenced test-tree files still
fail closed.

Co-authored-by: Nick <surlymo@apache.org>
(cherry picked from commit 7aabbddf3d)

* test(pregate): reviewer edges for the docs/no-tests empty lane (#3215)

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

* test(pregate): requires-remote on a gbrain-shaped repo (ci:ubicloud, UBI_OWNER) (W10)

A base pregate.json with gbrain's dependencies and remote_gate
"bun run ci:ubicloud" (a package script whose stub needs UBICLOUD_API_KEY,
names VMs from UBI_OWNER and prints PREGATE_REMOTE). Under 8 cores the test
tier is one obligation naming the command; --remote passes the caller's
UBI_OWNER and key through and clears it on the report; without the key the
gate's own error shows and the obligation stays uncleared. #3190's path
needed no change.

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

* test(pregate): the ship receipt counts the timing check (7 checks)

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

* test(context-budget): gstack-upgrade eager ceiling for the W12 equal-version upgrade question (ratchet protocol, capture value)

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

* feat(approval-classes): gstack-approval-classes and ship-receipt citation enforcement (W2, WIP)

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

* feat(approval-classes): tests, docs, /review and /ship wiring (W2)

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

* feat(review): state-machines specialist and gstack-state-matrix round-trip check with a captured #6792 regression (W3)

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

* fix(review-army): keep the --simplification force flag pinned; approval test timeouts

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

* test(free-fixtures): register the state-matrix-6792 fixture

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

* test(state-matrix): cover state-machines NEVER_GATE in gstack-specialist-stats

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

* test(fixtures): register the pregate-timing fixtures as free-only (W4)

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

* fix(ship-queue): full_e2e needs bound gate evidence in ready and verify, CI reads every check-run page and commit status, next: quotes its paths (review #3235 P1-1, P1-5, P2-1)

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

* fix(pregate): timing reads a native diff and never passes an unread one

The timing check's git diff honored diff.external, textconv and
diff.noprefix, so a configured driver hid every added line and the check
passed. Run it with --no-ext-diff --no-textconv and explicit a/ b/
prefixes, and report a git diff that fails or does not finish as
incomplete (PREGATE_INCOMPLETE), which a warn downgrade does not soften.

Review of #3235, sibling of P1-4 (lib/pregate/checks/timing.ts:189).

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

* fix(approval-classes): only affirmative APPROVED evidence counts, explicit tokens are authoritative, native diffs fail closed (review #3235 P1-2, P1-3, P1-4)

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

* fix(state-matrix): detect reads the native diff, ignoring diff.external and textconv (review #3235 P1-4)

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

* test(e2e): review-army-state-matrix behavior case on the #6792 fixture (field list 2, W3)

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

* fix(review-army): tighten the State Machines dispatch line so /ship stays under its parity size ratio with main's #3231 review-army text

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

* fix(approval-classes): only top-level approval lines count; quoted, fenced, indented-code and commented text never does (review #3235 r2)

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

* fix(approval-classes): approval evidence is the first non-blank line only, at column 0 (review #3235 r3)

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: keep gstack-upgrade under its size budget, exempt state-matrix's fetch hint, fit the periodic plan's 37 slices

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>

* test: the scope-signal clarity check follows helper-printed signals; the hermetic update-check fixture writes the identity cache

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

* refactor: move the release-path result codes to lib/result-codes-release.ts (module-size ratchet); fit the periodic plan's 38 slices

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

* docs: changelog entry for field list 2 wave B

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

* chore: stamp v1.91.116.0

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

* test: copy lib/result-codes-release.ts wherever a fixture copies lib/result-codes.ts; the scope-signal check requires the helper's computed emission

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

* test: state-matrix stats test resolves its slug under its own state root (home containment)

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

* fix(windows): curate pregate-docs-lane-review out of the Windows subset (runBin shebang, like the other pregate fixtures)

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

* fix(windows): curate pregate-remote-gbrain out of the Windows subset (runBin shebang, like pregate-remote)

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>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Nick <surlymo@apache.org>
2026-10-17 03:32:16 +00:00

398 lines
30 KiB
TypeScript

/**
* ship-queue — the shared merge queue's protocol steps (field list 2, W1),
* behind bin/gstack-ship-queue. gstack-restamp stays the primitive; this
* module strings it together with gstack-tree-receipt and the ship receipt
* so every step ends in a line a coordinator can act on:
*
* ready READY #<pr> <head> STAMP_VERSION=<X|unstamped> plus head, base,
* CI at that exact head, the PREGATE line and the gate state;
* refused when the tree is not the pushed PR head.
* stamp preflight (gated head equals HEAD modulo stamps, base, policy,
* gate record under full_e2e), restamp, commit, tree receipt,
* each stage journaled; QUEUE_STAMP: <pr> <version> head= gated=
* tree=same-modulo-stamps receipt=<sha256>. An identical retry
* resumes or returns the stored outcome. Never pushes.
* verify the coordinator's check: fetch the PR head, rerun the tree
* receipt there (and for a gate-ahead record, recompute the
* merge with git merge-tree and rebuild the gated tree from the
* recorded stamp patch), so QUEUE_STAMP is never a pasted claim.
* gate-ahead lib/ship-queue-gate.ts.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index';
import { ensureTmpExcluded, restamp, versionAtRef } from './restamp';
import type { ResultCodeName } from './result-codes';
import { extractReceipt, receiptSummaryLine } from './ship-receipt';
import { isUnavailable, loadPolicyFromBase, type LoadedPolicy } from './ship-policy';
import {
QUEUE_DIR_REL, applyStampPatch, commitForTree, currentAttempt, git, listAttempts, recomputeMergeTree, sha256, writeJsonAtomic,
type AttemptRecord, type QueueFailure,
} from './ship-queue-gate';
import { buildReceipt, type TreeReceipt } from './tree-receipt';
export type Gh = (args: string[]) => { ok: boolean; out: string; err?: string };
export interface QueueContext { repoRoot: string; base: string; gh: Gh; remote?: string }
export interface StepOutput { ok: boolean; lines: string[]; failure?: QueueFailure; run: string }
const fail = (failure: QueueFailure, lines: string[], run: string): StepOutput => ({ ok: false, lines, failure, run });
export function loadBasePolicy(ctx: QueueContext): LoadedPolicy | QueueFailure {
const loaded = loadPolicyFromBase(ctx.repoRoot, ctx.base);
if (isUnavailable(loaded)) return { code: 'POLICY_SOURCE_UNAVAILABLE', message: `policy source unavailable (${loaded.detail})`, fix: `git fetch origin ${ctx.base} --depth=1` };
if (loaded.errors.length) return { code: loaded.errors[0]!.code, message: `${loaded.source.label}: ${loaded.errors[0]!.message} at ${loaded.errors[0]!.path}` };
return loaded;
}
/**
* The receipt's portable digest: the trees, versions, per-file verdicts and
* stamp diff, never a commit id or the machine identity, so the lane's
* QUEUE_STAMP and the coordinator's verify agree exactly when the trees do.
*/
export function receiptDigest(r: TreeReceipt): string {
const files = [...r.files].sort((a, b) => a.path.localeCompare(b.path)).map(f => ({ path: f.path, rule: f.rule, verdict: f.verdict }));
return sha256(JSON.stringify({ gated_tree: r.gated_tree, current_tree: r.current_tree, old_version: r.old_version, new_version: r.new_version, files, tree: r.tree, stamp_diff: r.stamp_diff }));
}
function treeReceipt(ctx: QueueContext, policy: LoadedPolicy, gated: string, current: string): TreeReceipt | QueueFailure {
const r = buildReceipt({ repoRoot: ctx.repoRoot, gated, current, policy: policy.policy, policyLabel: policy.source.label });
return 'error' in r ? { code: 'QUEUE_PROOF_MISSING', message: r.error } : r;
}
const CI_PAGE = 100;
const CI_MAX_PAGES = 10;
/** Every page of one GitHub list for a commit; anything short of exactly total_count is an error, never a partial answer. */
function readAllPages(ctx: QueueContext, endpoint: string, key: 'check_runs' | 'statuses'): { items: Array<Record<string, unknown>> } | { error: string } {
const items: Array<Record<string, unknown>> = [];
for (let page = 1; page <= CI_MAX_PAGES; page++) {
const r = ctx.gh(['api', `${endpoint}?per_page=${CI_PAGE}&page=${page}`]);
let body: { total_count?: unknown; [k: string]: unknown } | null = null;
try { body = r.ok ? JSON.parse(r.out) : null; } catch {}
const list = body?.[key];
const total = body?.total_count;
if (!Array.isArray(list) || typeof total !== 'number') return { error: `${key} page ${page} unreadable (gh: ${(r.err ?? r.out).trim().slice(0, 120) || 'no total_count'})` };
items.push(...list);
if (items.length >= total || list.length < CI_PAGE) {
return items.length === total ? { items } : { error: `${key}: read ${items.length} of total_count=${total}` };
}
}
return { error: `${key}: more than ${CI_PAGE * CI_MAX_PAGES} entries, not all read` };
}
/** CI at one exact SHA: every check run and every commit-status context; unreadable or incomplete is unknown, never green. */
function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: boolean } {
const at = sha.slice(0, 12);
const runs = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/check-runs`, 'check_runs');
if ('error' in runs) return { line: `unknown at ${at} (${runs.error})`, green: false };
const statuses = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/status`, 'statuses');
if ('error' in statuses) return { line: `unknown at ${at} (${statuses.error})`, green: false };
const verdicts = [
...runs.items.map(c => c.status !== 'completed' ? 'pending' : c.conclusion === 'success' ? 'passed' : c.conclusion === 'skipped' || c.conclusion === 'neutral' ? 'skipped' : 'failed'),
...statuses.items.map(s => s.state === 'success' ? 'passed' : s.state === 'pending' ? 'pending' : 'failed'),
];
const count = (v: string) => verdicts.filter(x => x === v).length;
const [passed, failed, pending, skipped] = [count('passed'), count('failed'), count('pending'), count('skipped')];
const green = verdicts.length > 0 && pending === 0 && failed === 0;
return { line: `${green ? 'green' : pending ? 'pending' : verdicts.length ? 'red' : 'none'} at ${at} (passed=${passed} failed=${failed} pending=${pending} skipped=${skipped})`, green };
}
/** Under full_e2e: a passing gate bound to this head (the stamp journal, a gate-ahead attempt on it) or the lane's own run on it. */
function gateEvidence(ctx: QueueContext, pr: string, head: string, attempt: AttemptRecord | null, gateExit?: number, gateLog?: string): { ok: boolean; line: string } {
const j = readJournal(path.join(ctx.repoRoot, stampJournalRel(pr)));
if (j?.outcome?.head === head && j.gate) return { ok: j.gate.exit === 0, line: `gate-evidence: exit ${j.gate.exit} (${j.gate.source}, stamped onto ${head.slice(0, 12)})` };
if (attempt?.result && attempt.branch_head === head) return { ok: attempt.result.exit === 0, line: `gate-evidence: exit ${attempt.result.exit} (gate-ahead attempt ${attempt.attempt})` };
if (gateExit !== undefined && gateLog && fs.existsSync(gateLog)) return { ok: gateExit === 0, line: `gate-evidence: exit ${gateExit} log_sha256=${sha256(fs.readFileSync(gateLog))} (lane run on ${head.slice(0, 12)})` };
return { ok: false, line: `gate-evidence: missing for ${head.slice(0, 12)} (gate-ahead --record, or --gate-exit 0 --gate-log <file>)` };
}
function gateLine(policy: LoadedPolicy, attempt: AttemptRecord | null): string {
const p = policy.policy;
const rec = attempt ? `attempt=${attempt.attempt} gated_tree=${attempt.gated_tree.slice(0, 12)} ${attempt.result ? `exit=${attempt.result.exit}` : 'not recorded'}` : 'none';
return `gate: command=${p.gate_command ? JSON.stringify(p.gate_command) : 'none'} full_e2e=${p.full_e2e ? 'yes' : 'no'} gate-ahead=${rec}`;
}
/** READY: one block, identifiers first, about the exact pushed PR head. */
export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gateExit?: number; gateLog?: string }): StepOutput {
const run = PREGATE_OUT_REL;
const policy = loadBasePolicy(ctx);
if ('message' in policy) return fail(policy, [], run);
const head = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out;
const dirty = git(ctx.repoRoot, ['status', '--porcelain', '--untracked-files=no']).out;
if (dirty) return fail({ code: 'QUEUE_NOT_READY', message: `uncommitted tracked changes: ${dirty.split('\n').map(l => l.slice(3)).slice(0, 8).join(', ')}` }, [], run);
const view = ctx.gh(['pr', 'view', o.pr, '--json', 'headRefOid,url,body']);
let info: { headRefOid?: string; url?: string; body?: string } = {};
try { info = view.ok ? JSON.parse(view.out) : {}; } catch {}
if (!info.headRefOid) return fail({ code: 'QUEUE_NOT_READY', message: `cannot read PR #${o.pr}'s head through gh (${(view.err ?? view.out).trim().slice(0, 120)})` }, [], run);
if (info.headRefOid !== head) return fail({ code: 'QUEUE_NOT_READY', message: `HEAD ${head.slice(0, 12)} is not PR #${o.pr}'s pushed head ${info.headRefOid.slice(0, 12)}` }, [], run);
const atHead = versionAtRef(ctx.repoRoot, head);
const baseSha = git(ctx.repoRoot, ['rev-parse', `origin/${ctx.base}`]).out;
const atBase = versionAtRef(ctx.repoRoot, baseSha);
const version = 'version' in atHead && 'version' in atBase && atHead.version !== atBase.version ? atHead.version : 'unstamped';
const ci = ciAtHead(ctx, head);
const pregateFile = path.resolve(ctx.repoRoot, o.pregate ?? PREGATE_OUT_REL);
let pregateLine = `PREGATE: missing (${path.relative(ctx.repoRoot, pregateFile)}; run gstack-pregate run --allow-repo-commands)`;
let pregateOk = false;
if (fs.existsSync(pregateFile)) {
const read = readPregateFile(pregateFile);
if (read.file) {
const v = verifyPregate(ctx.repoRoot, read.file);
pregateOk = v.ok;
pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length}${v.ok ? '' : ` — ${v.message} (${v.code})`}`;
} else pregateLine = `PREGATE: unreadable (${read.errors[0]?.message ?? 'invalid'})`;
}
const receipt = extractReceipt(info.body ?? '');
const attempt = currentAttempt(ctx.repoRoot, o.pr);
const lines = [
`READY #${o.pr} ${head} STAMP_VERSION=${version}`,
`pr: ${info.url ?? `#${o.pr}`}`,
`head: ${head}`,
`version: ${version}`,
`base: origin/${ctx.base}@${baseSha}`,
`ci: ${ci.line}`,
pregateLine,
gateLine(policy, attempt),
receipt.found && receipt.receipt && !receipt.errors.length ? receiptSummaryLine(receipt.receipt) : `SHIP_RECEIPT: ${receipt.found ? 'invalid' : 'none'} in the PR body`,
];
const gate = policy.policy.full_e2e ? gateEvidence(ctx, o.pr, head, attempt, o.gateExit, o.gateLog) : null;
if (gate) lines.push(gate.line);
if (!ci.green) return fail({ code: 'QUEUE_NOT_READY', message: `CI is not green at ${head.slice(0, 12)}: ${ci.line}` }, lines, run);
if (!pregateOk) return fail({ code: 'QUEUE_NOT_READY', message: `the pregate result is not a current pass: ${pregateLine}` }, lines, run);
if (gate && !gate.ok) return fail({ code: 'QUEUE_GATE_MISSING', message: `full_e2e is set and no passing gate is bound to ${head.slice(0, 12)}: ${gate.line}` }, lines, run);
return { ok: true, lines, run };
}
export interface StampOptions {
pr: string; version: string; expectGated: string; expectBase: string; expectOrder?: string[]; allowRepoCommands: boolean;
gateExit?: number; gateLog?: string; failAfter?: string;
}
interface StageRow { name: 'preflight' | 'restamp' | 'commit' | 'receipt'; status: 'done' | 'failed'; detail: string }
interface StampJournal {
schema_version: 1; key: string; inputs: Record<string, unknown>; start_head: string; files: string[]; stages: StageRow[];
gate?: { source: string; exit: number; log_sha256?: string }; outcome?: { head: string; gated: string; receipt_sha256: string; line: string; committed: boolean };
}
export function stampJournalRel(pr: string): string { return `${QUEUE_DIR_REL}/stamp-${pr}.json`; }
const stampMessage = (o: StampOptions, base: string) => `v${o.version} chore(release): queue stamp onto ${base} (#${o.pr})`;
function readJournal(file: string): StampJournal | null {
try { const j = JSON.parse(fs.readFileSync(file, 'utf8')); return j?.schema_version === 1 ? j : null; } catch { return null; }
}
/** Preflight: nothing is written unless every proof input is present and the tree to stamp is the gated tree modulo stamps. */
function stampPreflight(ctx: QueueContext, o: StampOptions, policy: LoadedPolicy, head: string, gated: string): QueueFailure | { detail: string; gate?: StampJournal['gate'] } {
if (!/^\d+(\.\d+){2,3}$/.test(o.version)) return { code: 'QUEUE_PROOF_MISSING', message: `--version ${o.version} is not MAJOR.MINOR.PATCH[.MICRO]` };
const dirty = git(ctx.repoRoot, ['status', '--porcelain', '--untracked-files=no']).out;
if (dirty) return { code: 'QUEUE_PROOF_MISSING', message: `uncommitted tracked changes (${dirty.split('\n').map(l => l.slice(3)).slice(0, 8).join(', ')}); commit or stash them first` };
const baseSha = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${ctx.base}^{commit}`]).out;
if (!baseSha) return { code: 'POLICY_SOURCE_UNAVAILABLE', message: `origin/${ctx.base} is not a resolvable commit`, fix: `git fetch origin ${ctx.base}` };
if (!baseSha.startsWith(o.expectBase)) return { code: 'QUEUE_STALE', message: `base moved: expected ${o.expectBase}, origin/${ctx.base} is ${baseSha}` };
const pre = treeReceipt(ctx, policy, gated, head);
if ('message' in pre) return pre;
if (pre.tree !== 'same-modulo-stamps') return { code: 'QUEUE_PROOF_MISSING', message: `HEAD differs from the gated head ${gated.slice(0, 12)} beyond stamps (${pre.residual.join(', ')}); the gate does not cover this tree, regate first` };
const attempt = listAttempts(ctx.repoRoot, o.pr).filter(a => a.gated_head === gated && a.result).at(-1);
let gate: StampJournal['gate'];
if (attempt?.result) gate = { source: `gate-ahead attempt ${attempt.attempt}`, exit: attempt.result.exit, log_sha256: attempt.result.log_sha256 };
else if (o.gateExit !== undefined) {
if (!o.gateLog || !fs.existsSync(o.gateLog)) return { code: 'QUEUE_PROOF_MISSING', message: '--gate-exit needs --gate-log <file> from that run' };
gate = { source: 'lane run on the gated head', exit: o.gateExit, log_sha256: sha256(fs.readFileSync(o.gateLog)) };
}
if (policy.policy.full_e2e && gate?.exit !== 0) {
return { code: 'QUEUE_GATE_MISSING', message: gate ? `the gate on ${gated.slice(0, 12)} exited ${gate.exit} (${gate.source})` : `no gate result bound to ${gated.slice(0, 12)} (gate-ahead --record, or --gate-exit 0 --gate-log <file>)` };
}
return { detail: `gated ${gated.slice(0, 12)} = HEAD modulo stamps; base ${baseSha.slice(0, 12)}${gate ? `; gate exit ${gate.exit} (${gate.source})` : ''}`, gate };
}
/** Commit the restamp's files, or adopt a stamp commit an interrupted run already made. */
function stampCommit(ctx: QueueContext, o: StampOptions, j: StampJournal): QueueFailure | string {
const head = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out;
const message = stampMessage(o, ctx.base);
if (head !== j.start_head) {
const parent = git(ctx.repoRoot, ['rev-parse', 'HEAD^']).out;
const subject = git(ctx.repoRoot, ['log', '-1', '--format=%s']).out;
if (parent === j.start_head && subject === message) return `adopted ${head.slice(0, 12)} (committed before the journal saved)`;
return { code: 'QUEUE_PROOF_MISSING', message: `HEAD moved from ${j.start_head.slice(0, 12)} to ${head.slice(0, 12)} during the stamp; inspect it, then revert to ${j.start_head.slice(0, 12)} or start over` };
}
const changed = git(ctx.repoRoot, ['status', '--porcelain', '--untracked-files=all']).out.split('\n').filter(Boolean).map(l => l.slice(3));
const expected = new Set(j.files);
const stray = changed.filter(p => !expected.has(p) && !p.startsWith('.gstack/tmp/'));
if (stray.length) return { code: 'QUEUE_PROOF_MISSING', message: `the working tree holds edits beyond the restamp's files: ${stray.slice(0, 8).join(', ')}` };
const add = git(ctx.repoRoot, ['add', '--', ...j.files]);
const commit = add.ok ? git(ctx.repoRoot, ['commit', '-q', '-m', message, '--', ...j.files]) : add;
if (!commit.ok) return { code: 'QUEUE_PROOF_MISSING', message: `git commit failed: ${commit.err.slice(0, 200)}` };
return `committed ${git(ctx.repoRoot, ['rev-parse', 'HEAD']).out.slice(0, 12)} (${j.files.length} file(s))`;
}
/**
* QUEUE_STAMP: preflight, restamp, commit, receipt, journaled in
* .gstack/tmp/ship-queue/stamp-<pr>.json. The same inputs resume at the first
* unfinished stage or return the stored outcome. Never pushes.
*/
export function stamp(ctx: QueueContext, o: StampOptions): StepOutput {
const run = stampJournalRel(o.pr);
const file = path.join(ctx.repoRoot, run);
ensureTmpExcluded(ctx.repoRoot);
const inputs = { pr: o.pr, version: o.version, expect_gated: o.expectGated, expect_base: o.expectBase, expect_order: o.expectOrder ?? [], base: ctx.base };
const key = sha256(JSON.stringify(inputs));
const head0 = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out;
let j = readJournal(file);
if (j?.key === key && j.outcome) {
if (j.outcome.head === head0) return { ok: true, lines: [j.outcome.line, 'note: identical retry; the stored outcome is returned, nothing ran'], run };
return fail({ code: 'QUEUE_PROOF_MISSING', message: `this stamp completed at ${j.outcome.head.slice(0, 12)} and HEAD is now ${head0.slice(0, 12)}; stamp again with the current inputs` }, [], run);
}
if (j && j.key !== key) {
if (j.stages.some(s => s.name === 'restamp' && s.status === 'done') && !j.outcome) {
return fail({ code: 'QUEUE_PROOF_MISSING', message: `an earlier stamp with other inputs (${JSON.stringify(j.inputs)}) is unfinished; rerun it identically, or revert its files (${j.files.join(', ') || 'none'}) and delete ${run}` }, [], run);
}
j = null;
}
const journal: StampJournal = j ?? { schema_version: 1, key, inputs, start_head: head0, files: [], stages: [] };
const save = () => writeJsonAtomic(file, journal);
const done = (name: StageRow['name']) => journal.stages.some(s => s.name === name && s.status === 'done');
const mark = (name: StageRow['name'], status: StageRow['status'], detail: string) => { journal.stages = journal.stages.filter(s => s.name !== name); journal.stages.push({ name, status, detail }); save(); };
const stopped = (stage: StageRow['name'], failure: QueueFailure): StepOutput => {
const now = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out;
return fail(failure, [`QUEUE_STAMP_REFUSED: ${o.pr} head=${now} committed=${now !== journal.start_head ? `yes ${now}` : 'no'} stage=${stage}`, ...journal.stages.map(s => `stage ${s.name}: ${s.status} (${s.detail})`)], run);
};
const refusal = (stage: StageRow['name'], failure: QueueFailure): StepOutput => { mark(stage, 'failed', failure.message); return stopped(stage, failure); };
const injected = (stage: StageRow['name']) => (o.failAfter === stage ? stopped(stage, { code: 'QUEUE_PROOF_MISSING', message: `injected failure after ${stage}; rerun the identical command to resume` }) : null);
const policy = loadBasePolicy(ctx);
if ('message' in policy) return refusal('preflight', policy);
const gated = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `${o.expectGated}^{commit}`]).out;
if (!gated) return refusal('preflight', { code: 'QUEUE_PROOF_MISSING', message: `gated head ${o.expectGated} is not a commit here (fetch it, or name the gate-ahead record's gated_head)` });
if (!done('preflight')) {
const pre = stampPreflight(ctx, o, policy, head0, gated);
if ('message' in pre) return refusal('preflight', pre);
journal.gate = pre.gate;
mark('preflight', 'done', pre.detail);
const stop = injected('preflight');
if (stop) return stop;
}
if (!done('restamp')) {
const r = restamp({ repoRoot: ctx.repoRoot, base: ctx.base, version: o.version, pr: o.pr, expectBase: o.expectBase, expectOrder: o.expectOrder, policyFrom: 'base', allowRepoCommands: o.allowRepoCommands, dryRun: false });
if (r.status === 'refused' || r.status === 'failed') return refusal('restamp', { code: r.code ?? 'RESTAMP_CONFLICT', message: r.message ?? r.status, fix: r.recompute ?? r.fix });
journal.files = r.files ?? [];
mark('restamp', 'done', `${r.status}: ${r.oldVersion} -> ${r.newVersion}, ${journal.files.length} file(s)`);
const stop = injected('restamp');
if (stop) return stop;
}
if (!done('commit')) {
const c = journal.files.length ? stampCommit(ctx, o, journal) : 'nothing to commit (restamp was a noop)';
if (typeof c !== 'string') return refusal('commit', c);
mark('commit', 'done', c);
const stop = injected('commit');
if (stop) return stop;
}
const head = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out;
const rcpt = treeReceipt(ctx, policy, gated, head);
if ('message' in rcpt) return refusal('receipt', rcpt);
if (rcpt.tree !== 'same-modulo-stamps') return refusal('receipt', { code: 'QUEUE_STAMP_NOT_STAMP_ONLY', message: `${head.slice(0, 12)} differs from the gated head beyond stamps: ${rcpt.residual.join(', ')}` });
if (rcpt.new_version !== o.version) return refusal('receipt', { code: 'QUEUE_PROOF_MISSING', message: `the stamped head carries version ${rcpt.new_version}, not ${o.version}` });
const digest = receiptDigest(rcpt);
const receiptFile = path.join(ctx.repoRoot, QUEUE_DIR_REL, `tree-receipt-${o.pr}.json`);
writeJsonAtomic(receiptFile, rcpt);
const line = `QUEUE_STAMP: ${o.pr} ${o.version} head=${head} gated=${gated} tree=same-modulo-stamps receipt=${digest}`;
journal.outcome = { head, gated, receipt_sha256: digest, line, committed: head !== journal.start_head };
mark('receipt', 'done', `same-modulo-stamps (${rcpt.files.length} stamp file(s))`);
return {
ok: true, run,
lines: [line, ...journal.stages.map(s => `stage ${s.name}: ${s.status} (${s.detail})`), `receipt-file: ${path.relative(ctx.repoRoot, receiptFile)}`,
`next: push ${head.slice(0, 12)} (this command never pushes), wait for CI on it, then gstack-ship-queue ready --pr ${o.pr}; the coordinator runs gstack-ship-queue verify --pr ${o.pr} --gated ${gated} --receipt ${digest}`],
};
}
export interface VerifyOptions { pr: string; gated?: string; record?: string; receipt?: string; expectHead?: string; ref?: string; noFetch?: boolean; gateExit?: number; gateLog?: string }
/** Rebuild a gate-ahead record's gated tree from the PR's own commits: merge-tree, then the recorded stamp patch. */
function reproduceGateAhead(ctx: QueueContext, policy: LoadedPolicy, rec: AttemptRecord, head: string): QueueFailure | { gatedCommit: string; lines: string[] } {
const mismatch = (message: string): QueueFailure => ({ code: 'QUEUE_VERIFY_MISMATCH', message });
for (const [what, sha] of [['branch head', rec.branch_head], ['predecessor head', rec.predecessor_head]] as const) {
if (!git(ctx.repoRoot, ['cat-file', '-e', `${sha}^{commit}`]).ok) return { code: 'QUEUE_PROOF_MISSING', message: `the record's ${what} ${sha.slice(0, 12)} is not here after the fetch` };
}
const predNow = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `refs/gstack/queue/pr-${rec.predecessor_pr}`]).out;
if (predNow && predNow !== rec.predecessor_head) return mismatch(`PREDECESSOR MOVED: #${rec.predecessor_pr} is at ${predNow.slice(0, 12)}, the gate assumed ${rec.predecessor_head.slice(0, 12)}`);
const at = versionAtRef(ctx.repoRoot, head);
const merged = recomputeMergeTree(ctx.repoRoot, rec.branch_head, rec.predecessor_head, policy.policy, 'rel' in at ? at.rel : 'VERSION');
if ('error' in merged) return mismatch(`git merge-tree cannot recompute the gate-ahead merge: ${merged.error}`);
if (merged.tree !== rec.merge_tree) return mismatch(`git merge-tree gives ${merged.tree.slice(0, 12)}, the record says ${rec.merge_tree.slice(0, 12)}`);
const gatedTree = applyStampPatch(ctx.repoRoot, merged.tree, rec.stamp_patch);
if ('error' in gatedTree) return mismatch(gatedTree.error);
if (gatedTree.tree !== rec.gated_tree) return mismatch(`the merge plus the recorded stamp patch gives ${gatedTree.tree.slice(0, 12)}, not the recorded gated tree ${rec.gated_tree.slice(0, 12)}`);
const mergeCommit = commitForTree(ctx.repoRoot, merged.tree, `recomputed gate-ahead merge for #${rec.pr}`);
const gatedCommit = commitForTree(ctx.repoRoot, gatedTree.tree, `recomputed gated tree for #${rec.pr}`);
if (!mergeCommit || !gatedCommit) return { code: 'QUEUE_PROOF_MISSING', message: 'cannot write the recomputed commits' };
const stampOnly = treeReceipt(ctx, policy, mergeCommit, gatedCommit);
if ('message' in stampOnly) return stampOnly;
if (stampOnly.tree !== 'same-modulo-stamps') return mismatch(`the recorded stamp patch changes more than stamps: ${stampOnly.residual.join(', ')}`);
if (rec.result && rec.result.exit !== 0) return { code: 'QUEUE_GATE_MISSING', message: `the recorded gate on ${rec.gated_tree.slice(0, 12)} exited ${rec.result.exit}` };
if (policy.policy.full_e2e && !rec.result) return { code: 'QUEUE_GATE_MISSING', message: `attempt ${rec.attempt} has no recorded gate result` };
return {
gatedCommit,
lines: [
`merge_tree: ${merged.tree} reproduced by git merge-tree --write-tree ${rec.branch_head.slice(0, 12)} ${rec.predecessor_head.slice(0, 12)} (#${rec.predecessor_pr})`,
`gated_tree: ${gatedTree.tree} reproduced from the recorded stamp patch (stamps only, ${stampOnly.files.length} file(s))`,
`gate: ${rec.result ? `exit ${rec.result.exit} log_sha256=${rec.result.log_sha256}` : 'not recorded'} (attempt ${rec.attempt})`,
],
};
}
/** The coordinator's check of a lane's stamp claim, from the PR's pushed commits alone. */
export function verify(ctx: QueueContext, o: VerifyOptions): StepOutput {
const run = `refs/gstack/queue/pr-${o.pr}`;
const remote = ctx.remote ?? 'origin';
let rec: AttemptRecord | null = null;
if (o.record) {
try { rec = JSON.parse(fs.readFileSync(o.record, 'utf8')); } catch {}
if (rec?.kind !== 'gate-ahead' || rec.schema_version !== 1) return fail({ code: 'QUEUE_PROOF_MISSING', message: `${o.record} is not a gate-ahead record` }, [], run);
if (rec.pr !== o.pr) return fail({ code: 'QUEUE_VERIFY_MISMATCH', message: `the record is for #${rec.pr}, not #${o.pr}` }, [], run);
}
if (!o.gated && !rec) return fail({ code: 'QUEUE_PROOF_MISSING', message: 'pass --gated <sha> (the head the gate ran on) or --record <gate-ahead record>' }, [], run);
if (!o.noFetch) {
const specs = [`+${o.ref ?? `refs/pull/${o.pr}/head`}:${run}`, `+refs/heads/${ctx.base}:refs/remotes/${remote}/${ctx.base}`];
if (rec) specs.push(`+refs/pull/${rec.predecessor_pr}/head:refs/gstack/queue/pr-${rec.predecessor_pr}`);
const f = git(ctx.repoRoot, ['fetch', '--no-tags', '-q', remote, ...specs]);
if (!f.ok) return fail({ code: 'QUEUE_PROOF_MISSING', message: `git fetch ${remote} failed: ${f.err.slice(0, 200)}` }, [], run);
}
const head = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', run]).out;
if (!head) return fail({ code: 'QUEUE_PROOF_MISSING', message: `PR #${o.pr}'s head is not fetched (${run})` }, [], run);
if (o.expectHead && !head.startsWith(o.expectHead)) return fail({ code: 'QUEUE_VERIFY_MISMATCH', message: `PR #${o.pr}'s head is ${head.slice(0, 12)}, the lane reported ${o.expectHead}` }, [], run);
const policy = loadBasePolicy(ctx);
if ('message' in policy) return fail(policy, [], run);
const lines: string[] = [];
let gatedCommit: string;
let gatedLabel: string;
if (rec) {
if (o.gated && !rec.gated_tree.startsWith(o.gated) && !rec.gated_head.startsWith(o.gated)) return fail({ code: 'QUEUE_VERIFY_MISMATCH', message: `--gated ${o.gated} is neither the record's gated head nor its gated tree` }, [], run);
const g = reproduceGateAhead(ctx, policy, rec, head);
if ('message' in g) return fail(g, [], run);
gatedCommit = g.gatedCommit;
gatedLabel = rec.gated_head;
lines.push(...g.lines);
} else {
let sha = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `${o.gated}^{commit}`]).out;
if (!sha && !o.noFetch) { git(ctx.repoRoot, ['fetch', '--no-tags', '-q', remote, o.gated!]); sha = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `${o.gated}^{commit}`]).out; }
if (!sha) return fail({ code: 'QUEUE_PROOF_MISSING', message: `gated head ${o.gated} is not reachable from ${remote}; a gate-ahead claim needs --record` }, [], run);
gatedCommit = sha;
gatedLabel = sha;
if (o.gateExit === undefined) {
if (policy.policy.full_e2e) return fail({ code: 'QUEUE_GATE_MISSING', message: `full_e2e is set and no gate result is bound to the gated head ${sha.slice(0, 12)}; pass the lane's --gate-exit 0 --gate-log <file> from its gate run there, or verify a gate-ahead --record` }, [], run);
lines.push('gate: not required by the policy');
} else {
if (!o.gateLog || !fs.existsSync(o.gateLog) || !fs.statSync(o.gateLog).size) return fail({ code: 'QUEUE_GATE_MISSING', message: `--gate-exit needs --gate-log <file>, the non-empty log of the gate run on ${sha.slice(0, 12)}` }, [], run);
if (o.gateExit !== 0) return fail({ code: 'QUEUE_GATE_MISSING', message: `the gate on ${sha.slice(0, 12)} exited ${o.gateExit}` }, [], run);
lines.push(`gate: exit 0 log_sha256=${sha256(fs.readFileSync(o.gateLog))} (lane run on gated ${sha.slice(0, 12)})`);
}
}
const rcpt = treeReceipt(ctx, policy, gatedCommit, head);
if ('message' in rcpt) return fail(rcpt, lines, run);
if (rcpt.tree !== 'same-modulo-stamps') return fail({ code: 'QUEUE_VERIFY_MISMATCH', message: `PR #${o.pr}'s head ${head.slice(0, 12)} differs from the gated tree beyond stamps: ${rcpt.residual.join(', ')}` }, lines, run);
const digest = receiptDigest(rcpt);
if (o.receipt && digest !== o.receipt) return fail({ code: 'QUEUE_VERIFY_MISMATCH', message: `the receipt digest here is ${digest}, the lane reported ${o.receipt}` }, lines, run);
return {
ok: true, run,
lines: [`VERIFY: ${o.pr} ${rcpt.new_version ?? '-'} head=${head} gated=${gatedLabel} tree=same-modulo-stamps receipt=${digest}`, ...lines,
...rcpt.files.map(f => ` ${f.verdict} ${f.path} (${f.rule})`), `MERGE_CONDITION: sha=${head}`],
};
}