Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## [1.91.146.0] - 2026-10-19

**`gstack-ship-queue` can run merge trains: several READY PRs share one full gate, merge one by one only while main's tree matches the prefix that gate tested, and a red train is bisected on its red cases alone.** The surface is off unless the base revision's `.gstack/ship-policy.json` declares a `train` block. Without one, `ready --pr <n>` prints exactly the block it prints today. gstack's own policy does not turn trains on yet: that waits for the workflow side (the train-nonce record, the `cases` and `prefix_shas` inputs and the dispatched screen) and one dry-run train. Walkthrough: [docs/ship-policy.md#run-a-merge-train](docs/ship-policy.md#run-a-merge-train). Plan GSTA-49, lane T1.

### Added
- **`train build --prs <n>@<sha>,…`** merges the pinned, unstamped heads in slot order onto main inside object storage (version files take the prefix's side, the CHANGELOG merges by section) and records every prefix tree T0..Tk. Screened PRs go first in READY order. When `max_size` or more unscreened PRs wait, they form their own train. A train below `max_size` waits up to `fill_hours` for its oldest READY, unless `--urgent`. It writes `train.json` before pushing `train/<nonce>`, requires a green `free_workflow` run on that exact SHA, then dispatches one full gate through eval-bg's nonce. `--dry-run` writes, pushes and dispatches nothing. Fork heads, moved heads, PRs without a READY receipt and conflicts are refused before any spend; a conflict line names the build without that PR.
- **`train status`** never spends. It rebuilds local records from `refs/notes/gstack-trains`, where every train, attempt, READY receipt and freeze is mirrored before any remote effect. It reconciles an unconfirmed dispatch by nonce, and only a dispatch it finds absent is sent again, under the same nonce. **`train verdict`** is GREEN only for a complete census bound to the train's nonce, head and tree, with the `full_gate_check` check run published. INFRA/INCOMPLETE panels, unbound, partial or cancelled shards, slices killed before upload and missing records are never green.
- **`train bisect --spend-cap <usd>`** runs only the red cases on every prefix, after an ancestry check against `train/<nonce>`. It reserves every case×prefix trial, launches what fits the cap and resumes without repeating a prefix. `ATTRIBUTED #<pr>` is reported only for a monotone pattern, and red on every prefix with T0 included is red on main. An all-green bisect earns exactly one unchanged `train rebuild`, with both runs reported. An INFRA/INCOMPLETE-only census earns the single re-dispatch `EVAL_POLICY.infraRedispatch` allows (`--infra`). A second red stays red.
- **`verify --pr <n> --train <nonce>`** prints `MERGE_CONDITION` only when main's tree equals the prefix before the PR modulo stamps and the PR is still at its pin, and confirms each merge against the prefix after it. When a train stops partway after earlier slots merged, the queue freezes. The freeze is recorded locally and on the notes ref first, then the backstop gate runs on main's SHA. One `train build --repair` PR is admitted, and verify clears the freeze when it lands.
- **`ready --screen [--broad] | --no-screen`** under a train policy. Without a choice, `ready` prints the screen selection and recommendation, then refuses. `--screen` dispatches one screen on the PR's merge SHA with `screen_pr`, `screen_merge_sha` and `screen_policy`. Dollar figures arrive with the cost replay. **`train prune`** deletes the branches of merged, superseded and dropped trains.
- **The `train` policy key** (introduced in 1.91.146.0; needs `min_gstack_version`) and 18 result codes with troubleshooting anchors: `QUEUE_TRAIN_*` and `READY_SCREEN_CHOICE_REQUIRED`.

### Changed
- **eval-bg's nonce key** can carry a purpose (screen, train, bisect, backstop) and a screen policy, so a broad screen never adopts a narrow run and a train never adopts a bisect. Plain lane keys are unchanged. Every dispatch input name now lives in `DISPATCH_INPUTS`.
- **Gate integrity:** `test/ship-policy-compat.test.ts` now reads the newest version in `POLICY_KEY_INTRODUCED` instead of hard-coding the ship-queue keys' version, so each new key doesn't need another edit. It is exactly as strict: an unknown key is still `POLICY_INVALID`, naming the newest version this gstack reads. The rebuild-once count is defined in the train module (`REBUILD_ONCE = 1`) until `EVAL_POLICY` records it. No threshold, trial count or case kind changed.

### If you…
- **…coordinate a queue:** add a `train` block with `min_gstack_version` on the base branch only after every lane runs this gstack. Remove it to return every lane to the per-PR queue at once.

## [1.91.145.0] - 2026-10-19

**The qa-only deadline fixture accepts pure local reads, so `qa-only-no-fix` no longer fails when the agent checks a skill file with `wc -l …; ls …`.** Opus 5.5's first Bash call in TM1's focused `qa-only-no-fix` run at e03bfeb was `wc -l qa-only/SKILL.md; ls qa-only qa-only/sections qa/sections qa/templates`. The fixture's narrow list rejected it with `QA deadline: unsupported outer shell composition`, though it only read inside the fixture. The fixture now opts into the shared read-only grammar from 1.91.143.0, as the docs-sync fixture did. A focused Opus run with the opt-in then failed on one more read, `ls; ls qa/templates; cat TODOS.md 2>/dev/null | head -20`, so the grammar now also lets a read discard or merge its stderr. PR #3272's Opus gate (run 39217387697) then failed the case after the agent stopped the browser inside the guard with `"$B" stop`, which the checkpoint rule counted as a third probe. This PR's own gate (run 39223031058) failed `ship-docsync-current` after Opus called the docs candidate helper at a path that does not exist, got exit 127, and reran the same command at the declared path.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.91.145.0
1.91.146.0
2 changes: 1 addition & 1 deletion agents-digest/gstack-AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# gstack digest v1.91.145.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.146.0 — regenerate/re-copy after upgrading gstack

Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
Expand Down
18 changes: 18 additions & 0 deletions bin/gstack-doctor-components.sh
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,24 @@ gstack_result_anchor() {
QUEUE_VERIFY_MISMATCH) printf '%s' 'queue-verify-mismatch' ;;
QUEUE_GATE_MISSING) printf '%s' 'queue-gate-missing' ;;
GATE_AHEAD_ATTEMPT_INVALID) printf '%s' 'gate-ahead-attempt-invalid' ;;
QUEUE_TRAIN_OFF) printf '%s' 'queue-train-off' ;;
QUEUE_TRAIN_UNTRUSTED) printf '%s' 'queue-train-untrusted' ;;
QUEUE_TRAIN_MOVED) printf '%s' 'queue-train-moved' ;;
QUEUE_TRAIN_CONFLICT) printf '%s' 'queue-train-conflict' ;;
QUEUE_TRAIN_FREE_RED) printf '%s' 'queue-train-free-red' ;;
QUEUE_TRAIN_DISPATCH_UNKNOWN) printf '%s' 'queue-train-dispatch-unknown' ;;
QUEUE_TRAIN_WAITING) printf '%s' 'queue-train-waiting' ;;
QUEUE_TRAIN_PENDING) printf '%s' 'queue-train-pending' ;;
QUEUE_TRAIN_INCOMPLETE) printf '%s' 'queue-train-incomplete' ;;
QUEUE_TRAIN_RED) printf '%s' 'queue-train-red' ;;
QUEUE_TRAIN_RED_ON_MAIN) printf '%s' 'queue-train-red-on-main' ;;
QUEUE_TRAIN_UNATTRIBUTED) printf '%s' 'queue-train-unattributed' ;;
QUEUE_TRAIN_BISECT_CAPPED) printf '%s' 'queue-train-bisect-capped' ;;
QUEUE_TRAIN_PREFIX_INVALID) printf '%s' 'queue-train-prefix-invalid' ;;
QUEUE_TRAIN_REBUILD_REFUSED) printf '%s' 'queue-train-rebuild-refused' ;;
QUEUE_TRAIN_TREE_MISMATCH) printf '%s' 'queue-train-tree-mismatch' ;;
QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;;
READY_SCREEN_CHOICE_REQUIRED) printf '%s' 'ready-screen-choice-required' ;;
PREGATE_TIMING_ASSERTION) printf '%s' 'pregate-timing-assertion' ;;
APPROVAL_CLASSES_INVALID) printf '%s' 'approval-classes-invalid' ;;
APPROVAL_CLASS_FOUND) printf '%s' 'approval-class-found' ;;
Expand Down
28 changes: 23 additions & 5 deletions bin/gstack-ship-queue
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@
* gstack-ship-queue gate-ahead --after <pr> --pr <n> [--predecessor-ref <ref>] [--allow-repo-commands]
* gstack-ship-queue gate-ahead --record --pr <n> --attempt <id> --exit <rc> --log <file>
* gstack-ship-queue gate-ahead --list --pr <n>
* gstack-ship-queue train build|status|verdict|bisect|rebuild|prune ... (merge trains; train --help)
*
* Every subcommand takes --base <branch> (default origin/HEAD, else main) and
* ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=<path>.
* gstack-restamp stays the primitive. Nothing here pushes. Logic:
* lib/ship-queue.ts, lib/ship-queue-gate.ts.
* gstack-restamp stays the primitive. Nothing here pushes, except under a
* ship policy with a `train` block: merge trains push train/<nonce> branches
* and the refs/notes/gstack-trains records, and dispatch through eval-bg.
* Logic: lib/ship-queue.ts, lib/ship-queue-gate.ts, lib/ship-queue-train*.ts
* (loaded only on the train paths).
*/
import { spawnSync } from 'node:child_process';
import * as path from 'node:path';
Expand All @@ -41,6 +45,9 @@ 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)
train build|status|verdict|bisect|rebuild|prune ...
merge trains, when the base policy declares a train block (train --help;
docs/ship-policy.md#run-a-merge-train); ready then takes --screen|--no-screen

--base <branch>: base branch (default: origin/HEAD, else main). Nothing here pushes.
${exitTableHelp()}`;
Expand Down Expand Up @@ -122,7 +129,7 @@ function gateAhead(ctx: QueueContext, args: string[]): number {
});
}

function main(argv: string[]): number {
async function main(argv: string[]): Promise<number> {
const [cmd, ...args] = argv;
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 15_000 });
Expand All @@ -132,11 +139,21 @@ function main(argv: string[]): number {
const ctx: QueueContext = { repoRoot, base: resolveBaseBranch(repoRoot, flag(args, '--base')), gh };
const pr = flag(args, '--pr');
if (cmd === 'gate-ahead') return gateAhead(ctx, args);
if (cmd === 'train') {
const train = await import('../lib/ship-queue-train-cli');
if (!args[0] || args[0].startsWith('-')) { process.stdout.write(train.TRAIN_HELP + '\n'); return args.includes('--help') || args.includes('-h') ? EXIT.ok : EXIT.usage; }
return train.trainMain(ctx, args);
}
if (!pr || !/^\d+$/.test(pr)) return usage(`${cmd} needs --pr <number>`);
const gateExit = flag(args, '--gate-exit');
if (gateExit !== undefined && !/^-?\d+$/.test(gateExit)) return usage('--gate-exit needs an exit code');
const gate = { gateExit: gateExit === undefined ? undefined : Number(gateExit), gateLog: flag(args, '--gate-log') };
if (cmd === 'ready') return finish('ready', ready(ctx, { pr, pregate: flag(args, '--pregate'), ...gate }));
if (cmd === 'ready') {
const loaded = loadBasePolicy(ctx);
if (!('message' in loaded) && loaded.policy.train) return (await import('../lib/ship-queue-train-cli')).readyMain(ctx, args, { pr, pregate: flag(args, '--pregate'), ...gate });
if (['--screen', '--no-screen', '--broad'].some(f => args.includes(f))) process.stderr.write('gstack-ship-queue ready: --screen/--no-screen/--broad ignored: the base policy declares no train block\n');
return finish('ready', ready(ctx, { pr, pregate: flag(args, '--pregate'), ...gate }));
}
if (cmd === 'stamp') {
const version = flag(args, '--version');
const expectGated = flag(args, '--expect-gated');
Expand All @@ -149,6 +166,7 @@ function main(argv: string[]): number {
}));
}
if (cmd === 'verify') {
if (flag(args, '--train')) return (await import('../lib/ship-queue-train-cli')).verifyMain(ctx, args, pr);
const record = flag(args, '--record');
return finish('verify', verify(ctx, {
pr, gated: flag(args, '--gated'), record: record ? path.resolve(record) : undefined, receipt: flag(args, '--receipt'),
Expand All @@ -158,4 +176,4 @@ function main(argv: string[]): number {
return usage(`unknown subcommand ${cmd}`);
}

process.exitCode = main(process.argv.slice(2));
process.exitCode = await main(process.argv.slice(2));
Loading
Loading