The lander must accept each owner-approved gate change before a PR merges (GST-2491) (#4304)

* Let an owner recorded approval close a gate finding in unattended ship (GST-2491)

An unattended /ship logs every gate finding open and nothing could close it, so a PR with an owner-approved gate change could never finish. gstack-approvals gate records the owner disposition outside the run; it closes only that fingerprint on a review row whose range holds the head, and finish, the receipt, the PR body and READY all carry it.

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

* Keep the pinned review brief unchanged; document the resume steps for a dispositioned gate finding (GST-2491)

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

* Bind a gate disposition to its gate change and show it as kept to the rest of /ship (GST-2491)

A disposition now names the finding's gate_id as well as its fingerprint, so a later loosening of the same line stays open. gstack-review-read shows a closed gate finding as kept with the disposition's approval id, so the existing --blocking-open, --gate-open, draft and Gate edits rules carry it without prose changes, and an unreadable approval record applies no disposition instead of failing the reader.

Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: s11/outside-voice#1 reader=outside-voice
Review-Finding: s11/outside-voice#2 reader=outside-voice
Review-Finding: s11/adversarial-native#1 reader=adversarial-native
Review-Finding: s11/adversarial-native#2 reader=adversarial-native
Review-Finding: s11/adversarial-native#4 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* List the Gate disposition line in the receipt and prose troubleshooting entries (GST-2491)

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

* Require the lander to accept each owner gate disposition before merging (GST-2491)

Land and a new read-only verify --pin re-derive the dispositions the approval store applies at the pin and merge only with --accept-gate-disposition for each one. The gate writer refuses while any worktree holds an open /ship run on the branch, under a lock; a row counts only in the writer's full shape with its digest; receipt write needs --body when a disposition applies; ready and land cross-check the receipt and body against the store. Supersedes #4299.

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

* Regenerate the result-code anchors for the gate-disposition refusal (GST-2491)

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

* Always record the receipt's gate dispositions and refuse a store that disagrees at merge (GST-2491)

The receipt always carries gate_dispositions (an empty list when none apply) and write refuses on an unreadable store or a git error. Land, verify and ready refuse a receipt id the merge-time store lacks, naming the likely wrong state root or mirror, and a field-less receipt beside an applied row. A git ancestry error while deriving dispositions refuses instead of reading as not applied.

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

* Count a disposition whose head a rewrite removed as not applied, and expect the receipt to end with gate_dispositions (GST-2491)

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

* Trim and check the gate disposition recorder name, and let a parked run's ended lease stop blocking it (GST-2491)

Review-Finding: s11/outside-voice#3 reader=outside-voice
Review-Finding: s11/outside-voice#2 reader=outside-voice
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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-12 01:49:08 +00:00
1 parent 842fef1f95
commit b1f78301db
28 files changed
+1050 -38

No files matched your search

+25 -4
View File
@@ -14,7 +14,8 @@
* gstack-ship-queue gate-ahead --list --pr <n>
* gstack-ship-queue verify --pr <n> --screen (train policy: the head's screen verdict from its run)
* gstack-ship-queue verify --post-merge <sha> [--timeout <s>] (after a merge: main's push workflows at that commit until they settle)
* gstack-ship-queue land --pr <n> --pin <sha> [--repair] [--dry-run] [--allow-repo-commands] [--break-lock] (merge: direct; lib/ship-queue-land-direct.ts)
* gstack-ship-queue land --pr <n> --pin <sha> [--accept-gate-disposition <id>]... [--repair] [--dry-run] [--allow-repo-commands] [--break-lock] (merge: direct; lib/ship-queue-land-direct.ts)
* gstack-ship-queue verify --pr <n> --pin <sha> [--accept-gate-disposition <id>]... (land's receipt and gate-disposition acceptance, read-only, for a lander that merges another way)
* gstack-ship-queue land --pr <n> [--dry-run] [--no-wait] [--break-lock [--break-train-lock]] (the merge step; lib/ship-queue-land.ts)
* gstack-ship-queue land --pr <n> --repair (after QUEUE_LAND_DRIFT: open the outside merge's release PR)
* gstack-ship-queue land --census
@@ -59,15 +60,20 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
gate-ahead --record --pr <n> --attempt <id> --exit <rc> --log <file>
bind the gate result to that attempt's gated tree; removes its worktree
gate-ahead --list --pr <n> attempts for a PR, newest last (the current one)
verify --pr <n> --pin <sha> [--accept-gate-disposition <id>]...
land --pin's receipt and gate-disposition check, read-only, for a lander that merges
another way: prints each GATE_DISPOSITION line and exits 0 only when every one is accepted
verify --pr <n> --screen (train policy) the PR head's screen verdict read from its run (SCREEN_VERDICT)
verify --post-merge <sha> [--timeout <s>] wait for every check run at a merged commit to settle: POST_MERGE: green|red|pending
and one CHECK: line per workflow; resumable, default timeout 2700 s
land --pr <n> --pin <sha> [--repair] [--dry-run] [--allow-repo-commands] [--break-lock]
land --pr <n> --pin <sha> [--accept-gate-disposition <id>]... [--repair] [--dry-run] [--allow-repo-commands] [--break-lock]
merge: direct: merge the PR at its own green head (receipt and READY at the pin, no stamp
files, checks green, mergeable clean), verify the tree, write a land note; a stale base
runs land.merged_checks, repo commands, only with --allow-repo-commands (never --dry-run);
it holds the land lock from the re-fetch to the merge (--break-lock: a stale one);
--repair: while frozen, land the one repair PR the freeze admits (recorded under the lock)
--repair: while frozen, land the one repair PR the freeze admits (recorded under the lock);
an owner gate disposition the approval store applies at the pin merges only when the
lander passes --accept-gate-disposition <its approval id> (docs/ship-policy.md#gate-dispositions)
land --pr <n> [--dry-run] [--no-wait] [--timeout <min>] [--break-lock [--break-train-lock]] [--allow-repo-commands]
the merge step: lock, merge the base, stamp (fragments folded), push the stamp commit,
wait for its checks, squash with --match-head-commit, confirm the base tree; resumable
@@ -89,6 +95,12 @@ function flag(args: string[], name: string): string | undefined {
return i >= 0 ? args[i + 1] : undefined;
}
/** Every --accept-gate-disposition value, or the first one that is not an approval id. */
function acceptedIds(args: string[]): { ids: string[]; bad?: string } {
const ids = args.flatMap((a, i) => a === '--accept-gate-disposition' ? [args[i + 1] ?? ''] : []);
return { ids, bad: ids.find(id => !/^a-[0-9a-f]{12}$/.test(id)) };
}
function finish(cmd: string, out: StepOutput): number {
for (const line of out.lines) process.stdout.write(line + '\n');
if (out.failure) {
@@ -187,7 +199,9 @@ async function main(argv: string[]): Promise<number> {
if (args.includes('--pin')) {
const pin = flag(args, '--pin') ?? '';
if (!/^[0-9a-f]{40,64}$/.test(pin)) return usage('--pin needs the full head sha the READY names');
return finish('land', (await import('../lib/ship-queue-land-direct')).landDirect(ctx, { pr, pin, dryRun: args.includes('--dry-run'), allowRepoCommands: args.includes('--allow-repo-commands'), breakLock: args.includes('--break-lock'), repair: args.includes('--repair') }));
const accept = acceptedIds(args);
if (accept.bad !== undefined) return usage(`--accept-gate-disposition needs an approval id (a-<12 hex>), not ${JSON.stringify(accept.bad)}`);
return finish('land', (await import('../lib/ship-queue-land-direct')).landDirect(ctx, { pr, pin, acceptGateDispositions: accept.ids, dryRun: args.includes('--dry-run'), allowRepoCommands: args.includes('--allow-repo-commands'), breakLock: args.includes('--break-lock'), repair: args.includes('--repair') }));
}
const timeout = flag(args, '--timeout');
return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), breakTrainLock: args.includes('--break-train-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined }));
@@ -215,6 +229,13 @@ async function main(argv: string[]): Promise<number> {
failAfter: process.env.GSTACK_SHIP_QUEUE_FAIL_AFTER,
}));
}
if (cmd === 'verify' && args.includes('--pin')) {
const pin = flag(args, '--pin') ?? '';
if (!/^[0-9a-f]{40,64}$/.test(pin)) return usage('--pin needs the full head sha the READY names');
const accept = acceptedIds(args);
if (accept.bad !== undefined) return usage(`--accept-gate-disposition needs an approval id (a-<12 hex>), not ${JSON.stringify(accept.bad)}`);
return finish('verify', (await import('../lib/ship-queue-land-direct')).verifyPin(ctx, { pr, pin, acceptGateDispositions: accept.ids }));
}
if (cmd === 'verify') {
if (flag(args, '--train') || args.includes('--screen')) return (await import('../crew/lib/ship-queue-train-cli')).verifyMain(ctx, args, pr);
const record = flag(args, '--record');