diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad7312db..291359636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.92.12.0] - 2026-11-01 + +**When the merge queue gets long, 3 to 6 PRs from one part of the code can now land as one wave PR: one /ship, one READY, one train slot and one version.** Every slot in a train pays for its own stamp commit, its own round of about 12 required workflow runs (about 15 minutes, one slot after another) and its own version. The last 10 merged trains landed 26 PRs this way, with 16 to 24 READY PRs waiting at every build (refs/notes/gstack-trains; gate runs 41165841909 to 41217563527). Garry's rule R46 (2026-10-31) asks every Captain to group functional areas into wave PRs when queues get long. `gstack-ship-queue wave` now does this inside gstack's own train tooling, so crews get it from gstack instead of from prose (GST-574). + +### Added +- **`gstack-ship-queue wave plan`** (read-only) counts the live READY PRs (open, head equal to their READY receipt). Over 6, it prints `WAVE_ADVISED` and suggested groups of 3 to 6. PRs that change a common file always share a group. Each group takes its largest PR's area (a skill directory, the first word of a `lib/`, `bin/` or `test/` module name, or `docs`), and its integrator is the PR with the most changed lines. `train build` prints the same `WAVE_ADVISED` line when handed more than 6 pins. It is advice, never a refusal. +- **`wave build --prs @,...`** makes the integrator branch `wave/`. It merges each constituent's pinned head onto main with the train's merge rules, as a merge commit whose second parent is the pin, so no constituent is rebased or squashed. It writes the wave record to `refs/notes/gstack-trains` before pushing and leaves one "frozen in wave" comment on each constituent. A conflict stops with `QUEUE_WAVE_CONFLICT` and leaves the merge in a worktree under the state root. `--continue` takes the integrator's committed resolution only when that commit merges exactly that pin. `--drop ` rebuilds the wave without one PR on a new branch (never a force-push), and refuses with `QUEUE_WAVE_DEPENDENT` when another pin contains the dropped one. A drop that would leave fewer than 3 PRs, or `--release`, ends the wave so its PRs ride alone. Both wait until the wave PR is closed, because a train that already admitted it would still land it. Stacked PRs (whose base is another branch) cannot join a wave. +- **`ready` and `train build` check a wave PR before it gets a slot.** They walk the wave branch back to its recorded base: every pin must be merged unchanged and in order, any other merge is refused, and every constituent must still be open at its pin. Otherwise they refuse with `QUEUE_WAVE_MOVED`. Train land's stamp commits after the last constituent are allowed, so a stamped wave is re-READY'd like any stamped PR. They refuse with `QUEUE_WAVE_UNKNOWN` when a `wave/` branch has no record, and with `QUEUE_WAVE_DUPLICATE` when a constituent of a live wave is READY'd or trained on its own, so it cannot land twice. READY gains `WAVE:` and `CONSTITUENTS:` lines. After that the wave PR is an ordinary slot: `train land`, `verify`, the stamp and the backstop are unchanged. +- **`wave settle --wave `** runs after the wave PR merges. It takes only a wave PR merged into the base branch, and checks that the merge commit is on that branch and carries the wave head (its tree, or the head merged onto the commit before it when the train merged the PR directly). Each constituent still at its pin gets a "Landed in wave #W as vX" comment, naming any integrator commits or hand-resolved merges that touched its files, and is closed if its head has not moved since. A constituent whose head moved stays open with a comment saying its newer work did not land. Reruns post nothing twice, and `--dry-run` shows what it would do. +- `docs/ship-policy.md#wave-prs`, the merge-train section of `docs/captain.md`, the `/captain` skill, the READY template's `WAVE` lines and a `WAVE_TRIGGER` crew parameter. Seven new result codes, each with a troubleshooting entry. + +### Tests +- `test/ship-queue-wave.test.ts` runs the real bin against the train fixture. It covers area and grouping rules; a wave build's merge chain, record, push and notices; size and moved refusals; a conflict, an early `--continue` refused, then a resolution; `--drop` as a new wave, its dry run, its dependent refusal and a drop under the minimum releasing the wave; a stacked constituent refused; `ready` and `train build` reading a wave from a record rebuilt from notes, with moved, duplicate (a constituent alone included) and unknown refusals; a wave head carrying train stamps; `WAVE_ADVISED` and `wave plan`; and `wave settle` refusing a merge into another branch, walking two stacked stamps, naming a hand-resolved merge, closing, leaving open and staying idempotent. + ## [1.92.11.0] - 2026-11-01 - **Design mockups with Gemini when there's no OpenAI key (#990).** `/design-shotgun`, `/design-review`, `/plan-design-review` and `/design-html` used to fall back to HTML wireframes without an OpenAI key, and since OpenAI began requiring ID verification for every image model, even a new OpenAI key often made nothing. The design binary now uses Google's Gemini (`gemini-nano-banana-2.1` for images, `gemini-3.8-flash` for reading screenshots) when no OpenAI key is set. Get a free key at https://aistudio.google.com/apikey, then `export GEMINI_API_KEY=...` or run `$D setup --provider gemini`. OpenAI stays the default whenever its key exists; `GSTACK_DESIGN_PROVIDER=openai|gemini` picks one explicitly, and a failure on one provider is never retried on the other. Google calls are recorded in the egress ledger as `design-gemini` (hash and byte count only). On Google's free tier, prompts and images may be used to improve Google's products, and `$D` says so once per run. `$D setup` now takes `--provider` and `--key-stdin`, so an agent with no terminal can store a key. Thanks to @nbzy1995 for the proposal and to the people in #990, #998, #946, #881 and #777 for the ideas; no outside code was carried over. diff --git a/VERSION b/VERSION index 1d60451a4..a56519961 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.11.0 +1.92.12.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index bcbdb27b9..f8ef69a2a 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.11.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.12.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, diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 3fa027cb3..fa6811817 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -290,6 +290,13 @@ gstack_result_anchor() { QUEUE_BASE_RED_NO_TRAIN) printf '%s' 'queue-base-red-no-train' ;; SHIP_BASE_RED_FIXER) printf '%s' 'ship-base-red-fixer' ;; QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;; + QUEUE_WAVE_CONFLICT) printf '%s' 'queue-wave-conflict' ;; + QUEUE_WAVE_MOVED) printf '%s' 'queue-wave-moved' ;; + QUEUE_WAVE_DUPLICATE) printf '%s' 'queue-wave-duplicate' ;; + QUEUE_WAVE_SIZE) printf '%s' 'queue-wave-size' ;; + QUEUE_WAVE_UNKNOWN) printf '%s' 'queue-wave-unknown' ;; + QUEUE_WAVE_DEPENDENT) printf '%s' 'queue-wave-dependent' ;; + QUEUE_WAVE_NOT_MERGED) printf '%s' 'queue-wave-not-merged' ;; QUEUE_RECEIPT_MISSING) printf '%s' 'queue-receipt-missing' ;; QUEUE_RECEIPT_STALE) printf '%s' 'queue-receipt-stale' ;; QUEUE_RECEIPT_CONDITIONAL) printf '%s' 'queue-receipt-conditional' ;; diff --git a/bin/gstack-ship-queue b/bin/gstack-ship-queue index 0584c3bba..f72bd0e8f 100755 --- a/bin/gstack-ship-queue +++ b/bin/gstack-ship-queue @@ -18,6 +18,7 @@ * gstack-ship-queue land --pr --repair (after QUEUE_LAND_DRIFT: open the outside merge's release PR) * gstack-ship-queue land --census * gstack-ship-queue train build|status|verdict|bisect|rebuild|watch|prune ... (merge trains; train --help) + * gstack-ship-queue wave plan|build|status|settle ... (wave PRs: 3-6 same-area PRs in one train slot; wave --help) * * Every subcommand takes --base (default origin/HEAD, else main) and * ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=. @@ -65,6 +66,8 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc train build|status|verdict|bisect|rebuild|watch|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 + wave plan|build|status|settle ... wave PRs under a train policy: 3-6 same-area PRs in one slot and one version + (wave --help; docs/ship-policy.md#wave-prs) --base : base branch (default: origin/HEAD, else main). Nothing here pushes, except land (the stamp commit and the refs/notes/gstack-land lock), ready under changelog_fragments (its READY receipt note) and merge trains. @@ -162,6 +165,7 @@ async function main(argv: string[]): Promise { 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 (cmd === 'wave') return (await import('../lib/ship-queue-wave')).waveMain(ctx, args); if (cmd === 'verify' && flag(args, '--post-merge') !== undefined) return (await import('../lib/ship-queue-train-cli')).postMergeMain(ctx, args); if (cmd === 'land') { const l = await import('../lib/ship-queue-land'); diff --git a/captain/SKILL.md b/captain/SKILL.md index 9cfb80418..3299ad4d7 100644 --- a/captain/SKILL.md +++ b/captain/SKILL.md @@ -422,10 +422,13 @@ Lesson into a rule in the rules file, a line in the standing-rules file (`captain/templates/AGENTS.md`), or a filed tool fix. The controller's own log is append-only and timestamped (`captain/templates/NOTES.md`); the digest is derived from it. -Serial slots cost tens of minutes each, so small fixes in one area travel together: when -a lane's diff is small and open PRs touch the same area, fold them into one wave PR with -every contributor credited (`Contributed by @handle`, `Co-authored-by`, `Supersedes -#`), and never merge a contributor's PR directly from its fork. +Serial slots cost tens of minutes each, so PRs in one area travel together. When more +than `WAVE_TRIGGER` (6) PRs are READY, run `gstack-ship-queue wave plan` for area groups +of 3 to 6, each with an integrator who runs `wave build`, /ship once and READY on the wave +PR: one slot, one version. Constituent heads freeze, `--drop` sends a changed one back to +its owner, and `wave settle` closes the rest after the merge (docs/ship-policy.md#wave-prs). +Credit every contributor (`Contributed by @handle`, `Co-authored-by`), and never merge a +contributor's PR directly from its fork. ## Where the protocols differ, ship both diff --git a/captain/SKILL.md.tmpl b/captain/SKILL.md.tmpl index 6e895baaf..1a5fb0e40 100644 --- a/captain/SKILL.md.tmpl +++ b/captain/SKILL.md.tmpl @@ -350,10 +350,13 @@ Lesson into a rule in the rules file, a line in the standing-rules file (`captain/templates/AGENTS.md`), or a filed tool fix. The controller's own log is append-only and timestamped (`captain/templates/NOTES.md`); the digest is derived from it. -Serial slots cost tens of minutes each, so small fixes in one area travel together: when -a lane's diff is small and open PRs touch the same area, fold them into one wave PR with -every contributor credited (`Contributed by @handle`, `Co-authored-by`, `Supersedes -#`), and never merge a contributor's PR directly from its fork. +Serial slots cost tens of minutes each, so PRs in one area travel together. When more +than `WAVE_TRIGGER` (6) PRs are READY, run `gstack-ship-queue wave plan` for area groups +of 3 to 6, each with an integrator who runs `wave build`, /ship once and READY on the wave +PR: one slot, one version. Constituent heads freeze, `--drop` sends a changed one back to +its owner, and `wave settle` closes the rest after the merge (docs/ship-policy.md#wave-prs). +Credit every contributor (`Contributed by @handle`, `Co-authored-by`), and never merge a +contributor's PR directly from its fork. ## Where the protocols differ, ship both diff --git a/captain/templates/CREW.md b/captain/templates/CREW.md index 311750b23..1d0165ae5 100644 --- a/captain/templates/CREW.md +++ b/captain/templates/CREW.md @@ -17,6 +17,7 @@ Homes are paths in the gstack checkout the lane checked at session start. | `REQUIRED_CHECKS` | `` | | `FULL_GATE_CHECK` | `` | | `TRAIN_MAX`, `FILL_HOURS` | `<5>`, `<3>` | +| `WAVE_TRIGGER` | `<6>` (READY PRs over this run `wave plan` and travel as wave PRs of 3–6) | | `MERGE_METHOD` | `` | | `STAMP_FILES` | `` | | `KNOWN_INFRA_FLAKES` | `` | diff --git a/captain/templates/READY.md b/captain/templates/READY.md index bbeb09fa3..0129ea56b 100644 --- a/captain/templates/READY.md +++ b/captain/templates/READY.md @@ -20,6 +20,14 @@ SHIP_RECEIPT: pr= head= version=- base= tree= session_kind READY_RECEIPT: () ``` +A wave PR (docs/ship-policy.md#wave-prs) adds two lines after `READY`, and the controller checks +that every constituent named is one the wave was planned for: + +```text +WAVE: area= branch=wave/ constituents= integrator_commits= +CONSTITUENTS: #@,#@,... (heads frozen; fixes go on the wave branch) +``` + Under `changelog_fragments` (docs/ship-policy.md#changelog-fragments) the PR's entry is a fragment, `changelog.d/.md`, never an `[Unreleased]` section or a version, and the controller merges with `train land` or `land`, so no slot call follows this block. diff --git a/docs/captain.md b/docs/captain.md index 35b4670ba..313e006f6 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -300,6 +300,18 @@ anything else goes to a repair lane. An infra-only census earns one re-dispatch. backstop gate runs on every push to the base branch and freezes the queue when it finds a tree no green train covered; one repair PR at its exact head clears the freeze. +**Wave PRs when the queue is long.** Every slot pays its own +stamp, its own round of required checks and its own version, one after another. When more +than 6 PRs are READY, `gstack-ship-queue wave plan` prints `WAVE_ADVISED` and suggested +area groups of 3 to 6 (`train build` prints the same line when handed more than 6 pins). The +controller asks the owner of each group's largest PR to integrate: `wave build` makes the +wave branch with every constituent merged unchanged, the integrator runs /ship once and +READYs the wave PR, which then takes one slot and one version. Constituent heads are frozen +once in; fixes go through the integrator, and a constituent that must change is dropped +(`wave build --wave --drop `) and goes back to its owner. After the wave lands, +`wave settle` closes each constituent with a link. While the queue stays long, start new +work as area waves. The full flow is in [ship-policy.md](ship-policy.md#wave-prs). + **Slot 2 and later.** The base branch already carries the earlier slots' stamps, so a stamp on the bare pin leaves the PR conflicting on the CHANGELOG. The lane merges the base into its pin first (expect a CHANGELOG-only conflict; resolve it with `mergeChangelogs` diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 2a28abc1a..20920e124 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -551,6 +551,88 @@ a command gate cap below what a full train reserves, a merge method GitHub has turned off, and `rebase` as the merge method (`QUEUE_TRAIN_DOCTOR_FINDINGS`). Neither command writes anything. + +### Wave PRs: several PRs in one slot + +When the queue is long, PRs that touch the same part of the code travel +together as one **wave PR**: one integrator branch, one /ship, one READY, one +train slot and one version (Garry's rule R46, 2026-10-31: "consolidating +functional areas into larger wave PRs ... so we can land them faster with fewer +test passes"). Each slot otherwise costs its own stamp commit and a full round +of required checks before it merges, one slot after another, so a wave of +three saves two of those rounds and two versions. + +```bash +gstack-ship-queue wave plan # read-only: QUEUE, WAVE_ADVISED, WAVE_GROUP lines +gstack-ship-queue wave build --area ship --prs 41@,42@,43@ +gstack-ship-queue wave build --wave --continue # after resolving a conflict in the printed worktree +gstack-ship-queue wave build --wave --drop 42 # rebuild without #42 as a new wave +gstack-ship-queue wave build --wave --release # end the wave unlanded; its PRs ride alone +gstack-ship-queue wave status [--wave ] +gstack-ship-queue wave settle --wave [--dry-run] # after the wave PR merged +``` + +- **When.** `wave plan` counts the live READY PRs (open, head equal to their + READY receipt). Over the trigger (6) it prints `WAVE_ADVISED` and suggested + groups of 3 to 6: PRs that change a common file always share a group (their + conflicts are resolved once), each group takes its largest PR's functional + area (a skill directory, the first word of a `lib/`, `bin/` or `test/` + module name, or `docs`), smaller groups ride solo, and the integrator is + the PR with the most changed lines. `train build` handed more than 6 pins + prints the same `WAVE_ADVISED` line. It is advice: urgent and repair PRs + still ride alone. While the queue stays long, plan new work as area waves + from the start, so each piece skips its own /ship. +- **Build.** `wave build` checks every pin is an open, same-repository PR at + that head (`QUEUE_WAVE_MOVED`, `QUEUE_TRAIN_UNTRUSTED`), not already in a + live wave (`QUEUE_WAVE_DUPLICATE`), 3 to 6 of them (`QUEUE_WAVE_SIZE`; + `--min`/`--max` change it). It merges the heads in order onto main with the + train's merge rules, each as a merge commit whose second parent is the pin, + so no constituent is rebased or squashed. The wave record (kind `wave`) is + written locally and to `refs/notes/gstack-trains` before `wave/` is + pushed, and each constituent gets one "frozen in wave" comment. Then the + integrator opens the wave PR (the printed `gh pr create` and body file, or + the host's PR tool), runs /ship on it and READYs it. +- **Conflicts.** A merge that conflicts outside the CHANGELOG and the stamp + files stops with `QUEUE_WAVE_CONFLICT` and leaves the merge in progress in a + worktree under the state root. The integrator resolves and commits it there, + then `--continue` checks that commit merges exactly that pin onto the + recorded commit and finishes the chain. +- **Frozen heads.** Once in, a constituent's head does not move. Fixes go on + the wave branch as integrator commits, which the chain check allows. A + constituent that has to change goes back to its owner: `--drop ` builds + a new wave on a new branch (never a force-push; the integrator closes the + old wave PR first, and the old record is marked superseded), refused with + `QUEUE_WAVE_DEPENDENT` when another pin contains the dropped one. A drop + that would leave fewer PRs than the minimum releases the wave instead, and + `--release` does the same for a wave PR closed without merging: the record + is marked released and every constituent can be READY'd on its own again. + Both wait until the wave PR is closed, since a train that already admitted + it would still land it. A constituent must target the base branch; a + stacked PR is refused, because its pin carries its parent's commits. +- **READY and the train.** `ready --pr ` and `train build` read the + wave record for a `wave/` head branch (rebuilt from the notes ref on a + fresh machine; `QUEUE_WAVE_UNKNOWN` when there is none). They walk the + branch's first parents back to the recorded base: every pin must be merged + unchanged, in order, and any other merge is refused. Every constituent must + still be open at its pin. READY gains `WAVE:` and `CONSTITUENTS:` lines. + `ready` and `train build` refuse a constituent of a live wave on its own + (`QUEUE_WAVE_DUPLICATE`), so it cannot land twice. A wave head may carry + train land's stamp commits after its last constituent, so a stamped wave + is re-READY'd like any stamped PR. From there the wave PR is an ordinary slot: `train + land`, `verify`, the stamp and the backstop treat it like any PR, and under + `changelog_fragments` its one stamp folds every constituent's fragment into + one version section. +- **After the merge.** `wave settle` finds the wave PR merged into the base + branch, checks that its merge commit is on that branch and carries its head + (the head's tree, or the head merged onto the merge's parent when the train + merged the PR directly), and walks its chain (train land's stamp or update + commits on top are allowed). A + constituent still at its pin gets a "Landed in wave #W as vX (sha)" comment + naming any integrator commits or hand-resolved merges that touched its + files, and is closed. One whose head moved after the wave froze stays open + with a comment saying its newer work did not land. Comments carry a marker, so a + rerun posts nothing twice; `--dry-run` shows the plan. + **0. Lane: /ship before READY.** Every READY follows a /ship run on the branch at the head being READY'd, including a re-READY after a push (Garry, diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c720b0d86..3a0d3d8ca 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4932,6 +4932,91 @@ trains and other trains' merges are refused until a repair lands. @`). The freeze clears when verify finds main's tree after that merge in a green record; `NEXT:` then builds a train of the PRs still unmerged. + +### `QUEUE_WAVE_CONFLICT` + +**Meaning.** `wave build` merged the constituents in order with the train's +merge rules, and one conflicts with the wave so far outside the CHANGELOG and +the stamp files. Nothing was pushed. The record (state `conflict`) names the +PR, the files and a worktree under the state root where the merge is in +progress. `--continue` refuses until that worktree holds one committed merge +of the constituent's pin onto the recorded commit with a clean tree. + +**Fix.** Resolve the files in the printed worktree, `git -C commit +--no-edit`, then `gstack-ship-queue wave build --wave --continue`. Or +build the wave without that PR. + + +### `QUEUE_WAVE_MOVED` + +**Meaning.** A wave no longer lands what its record names: a constituent was +pushed, closed or merged after it joined (heads freeze once in), the wave +branch carries a merge that is not a constituent's pin (main merged in, for +example), a pin is missing from the branch, or the wave was rebuilt and this +PR is the old one. The same code refuses a constituent that targets a branch +other than the base (a stacked PR's pin carries its parent's commits), a +wave that was released, and a `--drop` or `--release` while the wave PR is +still open, since a train that already admitted it would still land it. + +**Fix.** Close the wave PR if one is open, then rebuild without the moved PR, +`wave build --wave --drop `, which makes a new wave branch and PR (never a +force-push); run /ship and READY the new wave PR. The moved PR goes back to +its owner and rides on its own or in a later wave. A stacked PR rides on its +own; after a release, each constituent is READY'd on its own. + + +### `QUEUE_WAVE_DUPLICATE` + +**Meaning.** A PR would land twice or waves would nest: it is already a +constituent of another live wave, `train build` was handed both a wave PR and +one of its constituents (or two waves sharing one), a wave PR was named as a +constituent, or a constituent of a live wave was READY'd or handed to `train +build` on its own. + +**Fix.** Keep each PR in one place: drop the separate pin from `train build`, +or rebuild one wave without the shared PR. A constituent of a live wave lands +through the wave PR. + + +### `QUEUE_WAVE_SIZE` + +**Meaning.** A wave carries 3 to 6 PRs by default (R46). `--min` and `--max` +change the bounds for one command. + +**Fix.** Add or remove PRs; `wave plan` suggests area groups. Fewer PRs than +the minimum ride as single train slots. + + +### `QUEUE_WAVE_UNKNOWN` + +**Meaning.** A PR's head branch is `wave/`, or a command named a +nonce, but no wave record exists in local state or on +`refs/notes/gstack-trains`. `ready` and `train build` refuse rather than +treat the PR as an ordinary one, since its constituents cannot be checked. + +**Fix.** `gstack-ship-queue wave status` lists the known waves. When the +record is gone, rebuild the wave with `wave build --prs`. + + +### `QUEUE_WAVE_DEPENDENT` + +**Meaning.** `wave build --drop ` was asked to drop a PR whose pin is an +ancestor of another constituent's pin (a stacked PR), so the rebuilt wave would +still land the dropped code. + +**Fix.** Drop the containing PR too, or keep the PR in the wave. + + +### `QUEUE_WAVE_NOT_MERGED` + +**Meaning.** `wave settle` found no PR merged into the base branch whose head +branch is the wave branch, its merge commit is not on the base branch, or that +merge's tree is neither the PR's head tree nor that head merged onto the +merge's parent, so it cannot say the constituents landed. Nothing was commented or closed. + +**Fix.** Land the wave PR through its train (`train land`), then rerun +`gstack-ship-queue wave settle --wave `. + ### `QUEUE_RECEIPT_MISSING` diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index 7476d2e82..fdb569a31 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -177,6 +177,42 @@ export const RELEASE_RESULT_CODES = { summary: 'a train stopped partway through publication, so main holds a prefix no gate covered on its own; ordinary trains are refused until a repair lands', fix: 'admit one repair PR at its exact head (train build --repair --prs @); the freeze clears when verify finds main’s tree after that merge in a green record', }, + // Wave PRs (Garry R46, GST-574; docs/ship-policy.md#wave-prs): 3-6 same-area PRs in one train slot. + QUEUE_WAVE_CONFLICT: { + anchor: 'queue-wave-conflict', + summary: 'a constituent conflicts with the wave built so far beyond the CHANGELOG and the stamp files; nothing was pushed', + fix: 'resolve the merge in the printed worktree, commit it, then wave build --wave --continue; or rebuild the wave without that PR', + }, + QUEUE_WAVE_MOVED: { + anchor: 'queue-wave-moved', + summary: 'a constituent is no longer open at its pinned head or targets another branch, the wave branch no longer merges every pin unchanged, the wave was rebuilt or released, or its wave PR is still open for a drop or release', + fix: 'close the wave PR, rebuild without the moved PR (wave build --wave --drop ) and READY the new wave PR; the moved PR goes back to its owner', + }, + QUEUE_WAVE_DUPLICATE: { + anchor: 'queue-wave-duplicate', + summary: 'a PR would land twice: it is already in another live wave, pinned as its own slot beside its wave, or is itself a wave PR', + fix: 'keep each PR in one place: drop the separate pin from the train, or rebuild one of the waves without it', + }, + QUEUE_WAVE_SIZE: { + anchor: 'queue-wave-size', + summary: 'a wave carries 3 to 6 PRs by default (--min, --max), and this one has fewer or more', + fix: 'add or remove PRs (wave plan suggests area groups); fewer than the minimum ride as single slots', + }, + QUEUE_WAVE_UNKNOWN: { + anchor: 'queue-wave-unknown', + summary: 'a wave/ branch or nonce has no wave record in local state or refs/notes/gstack-trains, so its constituents cannot be checked', + fix: 'run wave status to list known waves; rebuild the wave with wave build --prs when the record is gone', + }, + QUEUE_WAVE_DEPENDENT: { + anchor: 'queue-wave-dependent', + summary: 'the PR to drop is contained in another constituent’s pin, so the rebuilt wave would still land its code', + fix: 'drop the containing PR too, or keep the PR in the wave', + }, + QUEUE_WAVE_NOT_MERGED: { + anchor: 'queue-wave-not-merged', + summary: 'wave settle found no merged PR for the wave branch, or main’s merge does not carry the wave head', + fix: 'land the wave PR through its train (train land), then rerun wave settle --wave ', + }, // CREW rule 22 (Garry, 2026-10-25): /ship runs before every READY; the queue refuses a PR whose body has no current receipt. QUEUE_RECEIPT_MISSING: { anchor: 'queue-receipt-missing', diff --git a/lib/ship-queue-train-ready.ts b/lib/ship-queue-train-ready.ts index 4146ef970..7600a81fc 100644 --- a/lib/ship-queue-train-ready.ts +++ b/lib/ship-queue-train-ready.ts @@ -31,6 +31,7 @@ import { bindTrainReceipt } from './ship-queue-train-gate'; import { extractReceipt } from './ship-receipt'; import { loadReady, nowIso, persist, readyPath, rebuildFromNotes, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record'; import { baseOverlap, fetchBase, mergeCommit, outOk, outPending, outRefused, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train'; +import { checkWavePin } from './ship-queue-wave'; const Q = 'gstack-ship-queue'; @@ -235,8 +236,12 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise --drop )`, 'none'); const sel = await screenSelection(env, head, baseSha); - const lines = [...base.lines, ...sel.lines]; + const lines = [...base.lines, ...(wave ? wave.lines : []), ...sel.lines]; // gstack-pr-wait's base-overlap event, read from git at READY (handoff GSTA-119): the lane learns here that the base // moved onto its files, and a merge the train would stop at is refused before the pin is pooled. Under --screen the // scratch merge below reports the same conflict as QUEUE_SCREEN_STALE_REF. A comparison git could not make is printed as diff --git a/lib/ship-queue-train-record.ts b/lib/ship-queue-train-record.ts index 6a57d49cd..a35811f99 100644 --- a/lib/ship-queue-train-record.ts +++ b/lib/ship-queue-train-record.ts @@ -40,6 +40,8 @@ export interface TrainSlot { base_red?: BaseRedCondition; /** The fixer's READY (`ready --fixes-base-red`): the gate cases T0 may be red on, and only those. */ fixes_base_red?: string[]; + /** The slot is a wave PR (lib/ship-queue-wave.ts) carrying these constituents at their pins. */ + wave?: { nonce: string; constituents: string[] }; } /** One prefix a generic train gates (lib/ship-queue-train-gate.ts), on its own train-prefix//t (or s) ref. */ export interface GatedPrefix { index: number; ref: string; commit: string; tree: string; outcome: GateOutcome | null; red: string[]; detail: string | null } @@ -96,11 +98,26 @@ export interface FreezeRecord { main: { sha: string; tree: string }; reason: string; backstop: { nonce: string; run: number | null; state: string } | null; repair: { pr: string; head: string; train: string | null } | null; created_at: string; cleared_at: string | null; cleared_by: string | null; updated_at: string; } +/** A constituent of a wave PR (lib/ship-queue-wave.ts): the PR, its pinned head and the wave-branch merge commit whose second parent is that head. */ +export interface WaveConstituent { + pr: string; head: string; merge_commit: string | null; + /** Files the integrator resolved by hand in this constituent's merge (wave build --continue); absent for a clean merge. */ + resolved?: string[]; +} +export type WaveState = 'conflict' | 'built' | 'pushed' | 'superseded' | 'released' | 'settled'; +export interface WaveRecord { + schema: 1; kind: 'wave'; nonce: string; repo_key: string; area: string; base: { ref: string; sha: string }; branch: string; + constituents: WaveConstituent[]; head: string | null; state: WaveState; + /** The merge the integrator resolves by hand: the constituent, its files, the worktree under the state root and the commit it merges onto. */ + conflict: { pr: string; files: string[]; worktree: string; pred: string } | null; + superseded_by: string | null; settled: Array<{ pr: string; outcome: string; at: string }>; + effects: Array<{ at: string; what: string }>; created_at: string; updated_at: string; +} export interface ReadyReceipt { schema: 1; kind: 'ready'; repo_key: string; pr: string; head: string; base_sha: string; ready_at: string; screen: ScreenChoice; updated_at: string; base_red?: BaseRedCondition; fixes_base_red?: string[]; } -export type AnyRecord = TrainRecord | AttemptRecord | BisectRecord | FreezeRecord | ReadyReceipt; +export type AnyRecord = TrainRecord | AttemptRecord | BisectRecord | FreezeRecord | ReadyReceipt | WaveRecord; /** Clock seam for fill-time and record tests (`GSTACK_SHIP_QUEUE_NOW`, ISO-8601). */ export function nowIso(): string { @@ -123,6 +140,7 @@ const attemptPath = (nonce: string, id: string) => path.join(trainDir(nonce), 'a const bisectPath = (nonce: string) => path.join(trainDir(nonce), 'bisect.json'); const freezePath = (repoKey: string) => path.join(queueRoot(), 'freeze', `${repoKey}.json`); export const readyPath = (repoKey: string, pr: string, head: string) => path.join(queueRoot(), 'ready', repoKey, `${pr}-${head}.json`); +export const wavePath = (nonce: string) => path.join(queueRoot(), 'waves', `${nonce}.json`); /** Hosts that serve GitHub repositories at // (git.capy.ai is Capy's GitHub proxy, set through url.insteadOf). */ const GITHUB_HOSTS = ['github.com', 'www.github.com', 'ssh.github.com', 'git.capy.ai']; @@ -170,6 +188,16 @@ export const loadFreeze = (repoKeys: RepoKeys) => firstOf(repoKeys, key => readJ export const loadReady = (repoKeys: RepoKeys, pr: string, head: string) => firstOf(repoKeys, key => readJson(readyPath(key, pr, head), 'ready')); export const activeFreeze = (repoKeys: RepoKeys): FreezeRecord | null => firstOf(repoKeys, key => { const f = readJson(freezePath(key), 'freeze'); return f && !f.cleared_at ? f : null; }); +export const loadWave = (nonce: string) => (/^[A-Za-z0-9._-]{1,128}$/.test(nonce) ? readJson(wavePath(nonce), 'wave') : null); +/** Every wave of this repository, newest first. */ +export function listWaves(repoKeys: RepoKeys): WaveRecord[] { + const dir = path.join(queueRoot(), 'waves'); + if (!fs.existsSync(dir)) return []; + const keys = keyList(repoKeys); + return fs.readdirSync(dir).filter(n => n.endsWith('.json')).map(n => loadWave(n.slice(0, -5))).filter((w): w is WaveRecord => !!w && keys.includes(w.repo_key)) + .sort((a, b) => b.created_at.localeCompare(a.created_at) || b.nonce.localeCompare(a.nonce)); +} + export function attemptsOf(train: TrainRecord): AttemptRecord[] { return train.attempts.map(id => loadAttempt(train.nonce, id)).filter((a): a is AttemptRecord => !!a); } @@ -188,6 +216,7 @@ export function recordPath(r: AnyRecord): string { if (r.kind === 'train-attempt') return attemptPath(r.train, r.id); if (r.kind === 'train-bisect') return bisectPath(r.train); if (r.kind === 'freeze') return freezePath(r.repo_key); + if (r.kind === 'wave') return wavePath(r.nonce); return readyPath(r.repo_key, r.pr, r.head); } @@ -196,6 +225,7 @@ export function noteName(r: AnyRecord): string { if (r.kind === 'train-attempt') return `gstack-trains:attempt:${r.train}:${r.id}`; if (r.kind === 'train-bisect') return `gstack-trains:bisect:${r.train}`; if (r.kind === 'freeze') return `gstack-trains:freeze:${r.repo_key}`; + if (r.kind === 'wave') return `gstack-trains:wave:${r.nonce}`; return `gstack-trains:ready:${r.repo_key}:${r.pr}:${r.head}`; } @@ -259,6 +289,7 @@ function parseNote(text: string): AnyRecord | null { if (r.kind === 'train' && ids.test(r.nonce)) return r; if ((r.kind === 'train-attempt' && ids.test(r.train) && ids.test(r.id)) || (r.kind === 'train-bisect' && ids.test(r.train))) return r; if (r.kind === 'freeze' && ids.test(r.repo_key)) return r; + if (r.kind === 'wave' && ids.test(r.nonce) && ids.test(r.repo_key) && Array.isArray(r.constituents)) return r; if (r.kind === 'ready' && ids.test(r.repo_key) && /^\d+$/.test(r.pr) && /^[0-9a-f]{40,64}$/.test(r.head)) return r; } catch {} return null; diff --git a/lib/ship-queue-train.ts b/lib/ship-queue-train.ts index b09810b61..b589ddfa9 100644 --- a/lib/ship-queue-train.ts +++ b/lib/ship-queue-train.ts @@ -27,6 +27,7 @@ import { git, sha256, withIndex, type QueueFailure } from './ship-queue-gate'; import { recordVerdict, screenVerdict, verdictLines } from './ship-queue-screen'; import { stackTestLine, stackTests } from './ship-queue-stack-tests'; import { stampChain } from './ship-queue-train-stamp'; +import { WAVE_TRIGGER, checkWavePin } from './ship-queue-wave'; import { advanceGeneric, baseRedAdmission, bindTrainReceipt, gatedChain, genericNext } from './ship-queue-train-gate'; import { asRun, execFor, newestBranchRun, prInfo, remoteBranchSha, repoSlug, runView, checkRun } from './ship-queue-train-github'; import { @@ -377,11 +378,19 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>, dry return outRefused({ code: 'QUEUE_SCREEN_RED', message: `#${p.pr}'s screen run ${screen.run} is RED on ${read.red.join(', ') || 'its gate binding'} (${read.detail}); a red head is not seated` }, [...lines, ...verdictLines(p.pr, screen.run!, read)], without ? `${Q} train build --prs ${without}` : 'none', 'none'); } + const wave = checkWavePin(env, p.pr, info); + if (wave && 'failure' in wave) return outRefused(wave.failure, lines, without ? `${Q} train build --prs ${without} (or rebuild the wave: ${Q} wave status)` : `${Q} wave status`, 'none'); + if (wave) lines.push(...wave.lines); if (read?.kind === 'not-run' || read?.kind === 'incomplete') { lines.push(`SCREEN: #${p.pr} ${read.detail}; counted as not-run, the slot rides unscreened`); screen = { ...screen, choice: 'no-screen' }; } else if (read?.kind === 'unknown') lines.push(`SCREEN: #${p.pr} ${read.detail}; counted as screened, unverified`); else if (read?.kind === 'green') lines.push(`SCREEN: #${p.pr} run ${screen.run} GREEN${read.flaky.length ? ` (flaky: ${read.flaky.join(', ')})` : ''}`); else if (read?.kind === 'pending') lines.push(`SCREEN: #${p.pr} run ${screen.run} still running; counted as screened`); - slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen, ...(ready.base_red ? { base_red: ready.base_red } : {}), ...(ready.fixes_base_red ? { fixes_base_red: ready.fixes_base_red } : {}) }); + slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen, ...(ready.base_red ? { base_red: ready.base_red } : {}), ...(ready.fixes_base_red ? { fixes_base_red: ready.fixes_base_red } : {}), ...(wave ? { wave: { nonce: wave.wave.nonce, constituents: wave.wave.constituents.map(c => c.pr) } } : {}) }); + } + const seen = new Map(slots.map(s => [s.pr, `its own slot`])); + for (const s of slots) for (const c of s.wave?.constituents ?? []) { + if (seen.has(c)) return outRefused({ code: 'QUEUE_WAVE_DUPLICATE', message: `#${c} would land twice: in wave #${s.pr} and in ${seen.get(c)}` }, lines, `${Q} train build --prs `, 'none'); + seen.set(c, `wave #${s.pr}`); } return { slots, lines }; } @@ -407,6 +416,7 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor ...admitted.lines, ...admission.lines, `ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}${explicit ? ' (explicit --order)' : ''}`, ...(order.held.length ? [`HELD: ${order.held.map(s => '#' + s.pr).join(',')} (over the train size ${env.train.max_size})`] : []), + ...(pins.length > WAVE_TRIGGER ? [`WAVE_ADVISED: ${pins.length} READY PRs over the wave trigger ${WAVE_TRIGGER}; group same-area PRs into wave PRs: ${Q} wave plan`] : []), ]; const baseSha = git(env.ctx.repoRoot, ['rev-parse', `${env.remote}/${env.ctx.base}`]).out; const key = sha256(JSON.stringify({ base: baseSha, slots: order.train.map(s => `${s.pr}@${s.head}`) })); diff --git a/lib/ship-queue-wave.ts b/lib/ship-queue-wave.ts new file mode 100644 index 000000000..73aec5177 --- /dev/null +++ b/lib/ship-queue-wave.ts @@ -0,0 +1,624 @@ +/** + * Wave PRs for merge trains (docs/ship-policy.md#wave-prs; Garry's R46, GST-574): + * when the queue is long, 3-6 PRs of one functional area travel as one wave PR + * that takes one train slot, one /ship, one READY and one version. + * + * wave plan read-only: live READY PRs, WAVE_ADVISED over the trigger, suggested area groups + * wave build the integrator branch wave/: main plus each constituent's pinned head merged + * unchanged with the train's merge rules (a merge commit whose second parent is the + * pin); a conflict stops in a worktree under the state root (--continue resumes); + * --drop rebuilds without one constituent as a new wave (never a force-push) + * wave status the wave's record, its branch chain and each constituent's GitHub head + * wave settle after the wave PR merged: close each constituent whose head is still its pin with a + * link to the wave and version; a moved constituent stays open with a comment + * + * The wave record (kind 'wave', lib/ship-queue-train-record.ts) is written locally and to + * refs/notes/gstack-trains before the branch push, so ready and train build on any machine + * check the same pins (checkWavePin). Constituent heads freeze once in: a moved head refuses + * the wave (QUEUE_WAVE_MOVED) and fixes go on the wave branch as integrator commits. + */ +import { randomBytes } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT, formatResult } from './headless-artifacts'; +import { RESULT_CODES } from './result-codes'; +import { versionAtRef } from './restamp'; +import { globToRegExp, type ShipPolicy } from './ship-policy'; +import type { QueueContext } from './ship-queue'; +import { git, sha256, type QueueFailure } from './ship-queue-gate'; +import { fetchBase, mergeCommit, outFailed, outOk, outRefused, parsePins, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train'; +import { prInfo, type PrInfo } from './ship-queue-train-github'; +import { listWaves, loadWave, nowIso, persist, queueRoot, rebuildFromNotes, redactCause, wavePath, type ReadyReceipt, type WaveRecord } from './ship-queue-train-record'; + +export const WAVE_TRIGGER = 6; +export const WAVE_MIN = 3; +export const WAVE_MAX = 6; +const Q = 'gstack-ship-queue'; +const LIVE = new Set(['conflict', 'built', 'pushed']); +const marker = (nonce: string, kind: string) => ``; +const short = (sha: string) => sha.slice(0, 12); +const pinsOf = (w: WaveRecord) => w.constituents.map(c => `#${c.pr}@${short(c.head)}`).join(','); +const fail = (code: QueueFailure['code'], message: string): QueueFailure => ({ code, message }); + +/** Files that never decide an area or a conflict: version files, the CHANGELOG, fragments and release outputs. */ +export function isReleasePath(file: string, policy: ShipPolicy): boolean { + if (file === policy.changelog || file === 'VERSION' || file === 'package.json') return true; + if (policy.changelog_fragments && file.startsWith(policy.changelog_fragments.dir + '/')) return true; + return policy.release_outputs.some(glob => globToRegExp(glob).test(file)); +} + +/** + * A file's functional area: the skill directory for files in a skill, the subdirectory for + * lib//…, the first word of the module name for lib, bin, test and scripts files + * (lib/ship-queue-train.ts and test/ship-receipt.test.ts are both "ship"), docs for docs/**. + */ +export function areaKey(file: string, isSkillDir: (dir: string) => boolean): string { + const parts = file.split('/'); + const top = parts[0]!; + if (parts.length > 1 && isSkillDir(top)) return top; + if (top === 'docs') return 'docs'; + if (['lib', 'bin', 'test', 'scripts'].includes(top) && parts.length > 1) { + const inner = parts.slice(1).filter(p => p !== 'helpers' && p !== 'fixtures'); + if (inner.length > 1) return inner[0]!; + const name = (inner[0] ?? top).replace(/^gstack-/, '').replace(/(\.test)?\.[a-z]+$/, ''); + return name.split(/[-_.]/)[0] || top; + } + return parts.length > 1 ? top : 'root'; +} + +export interface PlanPr { pr: string; head: string; files: string[]; lines: number } +export interface WaveGroup { area: string; prs: PlanPr[]; integrator: string } + +/** The most common area across files, ties broken by name; 'misc' with no files. */ +export function dominantArea(files: string[], areaOf: (f: string) => string): string { + const counts = new Map(); + for (const f of files) counts.set(areaOf(f), (counts.get(areaOf(f)) ?? 0) + 1); + return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? 'misc'; +} + +const largestFirst = (a: PlanPr, b: PlanPr) => b.lines - a.lines || Number(a.pr) - Number(b.pr); + +/** + * Group PRs into waves: PRs that change a common file always travel together (one integrator + * resolves their conflicts once), each cluster takes its largest PR's area, clusters of one + * area are packed in PR order into waves of at most max, and a wave below min rides solo. + * The integrator is the PR with the most changed lines. + */ +export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = WAVE_MIN, max = WAVE_MAX): { groups: WaveGroup[]; solo: string[] } { + const sorted = [...prs].sort((a, b) => Number(a.pr) - Number(b.pr)); + const root = new Map(sorted.map(p => [p.pr, p.pr])); + const find = (x: string): string => { const r = root.get(x)!; return r === x ? x : find(r); }; + const owner = new Map(); + for (const p of sorted) for (const f of p.files) { + const o = owner.get(f); + if (o && find(o) !== find(p.pr)) root.set(find(p.pr), find(o)); else if (!o) owner.set(f, p.pr); + } + const clusters = new Map(); + for (const p of sorted) clusters.set(find(p.pr), [...(clusters.get(find(p.pr)) ?? []), p]); + const byArea = new Map(); + for (const c of clusters.values()) { + const area = dominantArea([...c].sort(largestFirst)[0]!.files, areaOf); + byArea.set(area, [...(byArea.get(area) ?? []), c]); + } + const groups: WaveGroup[] = []; + const solo: string[] = []; + for (const [area, list] of [...byArea].sort((a, b) => a[0].localeCompare(b[0]))) { + let cur: PlanPr[] = []; + const flush = () => { + if (cur.length >= min) groups.push({ area, prs: cur, integrator: [...cur].sort(largestFirst)[0]!.pr }); + else solo.push(...cur.map(p => p.pr)); + cur = []; + }; + for (const c of list) for (const p of c) { if (cur.length >= max) flush(); cur.push(p); } + flush(); + } + return { groups, solo: solo.sort((a, b) => Number(a) - Number(b)) }; +} + +export interface WaveChain { integrator: string[]; merges: string[]; stamps: string[] } + +/** + * Walk the wave branch's first parents from tip back to the recorded base. Every constituent + * must appear, in order and exactly once, as the second parent of a two-parent commit; a + * one-parent commit is an integrator commit; any other merge (an unknown head, main merged in + * before the last constituent) is refused. With stampBase, merges of commits already on + * stampBase (train land's stamps, or main merged in) may follow the last constituent. + */ +export function waveChain(repoRoot: string, w: WaveRecord, tip: string, stampBase: string | null = null): WaveChain | { error: string } { + const r = git(repoRoot, ['rev-list', '--first-parent', '--parents', `${w.base.sha}..${tip}`]); + if (!r.ok) return { error: `cannot walk ${short(tip)} back to the wave base ${short(w.base.sha)} (${r.err.slice(0, 120)})` }; + const rows = r.out.split('\n').filter(Boolean).map(l => l.split(' ')).reverse(); + if (!rows.length || rows[0]![1] !== w.base.sha) return { error: `the branch at ${short(tip)} does not start at the wave base ${short(w.base.sha)}` }; + const out: WaveChain = { integrator: [], merges: [], stamps: [] }; + let next = 0; + for (const [commit, , second, ...more] of rows) { + if (!second) { out.integrator.push(commit!); continue; } + const want = w.constituents[next]; + if (!more.length && want && second === want.head) { out.merges.push(commit!); next++; continue; } + if (!more.length && stampBase && next === w.constituents.length && git(repoRoot, ['merge-base', '--is-ancestor', second, stampBase]).ok) { out.stamps.push(commit!); continue; } + const expected = want ? `#${want.pr}'s pin ${short(want.head)}` : 'a constituent'; + return { error: `${short(commit!)} merges ${short(second)}, which is not ${expected} (only constituent merges and integrator commits belong on a wave branch)` }; + } + if (next < w.constituents.length) return { error: `#${w.constituents[next]!.pr}'s pin ${short(w.constituents[next]!.head)} is not merged on the branch at ${short(tip)}` }; + return out; +} + +/** The conflict worktree is derived from the nonce, never read from a record another machine wrote. */ +const worktreeOf = (w: WaveRecord) => path.join(queueRoot(), 'waves', `${w.nonce}-work`); +const waveByBranch = (env: TrainEnv, branch: string) => listWaves(env.repo.keys).find(w => w.branch === branch) ?? null; + +function ensureCommit(env: TrainEnv, sha: string, pr: string): boolean { + if (git(env.ctx.repoRoot, ['cat-file', '-e', `${sha}^{commit}`]).ok) return true; + git(env.ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, `+refs/pull/${pr}/head:refs/gstack/wave/pr-${pr}`]); + return git(env.ctx.repoRoot, ['cat-file', '-e', `${sha}^{commit}`]).ok; +} + +/** One constituent's GitHub state against its pin: null when it is open, same-repo and unmoved. */ +function constituentProblem(ctx: QueueContext, pr: string, pin: string): { failure: QueueFailure } | null { + const info = prInfo(ctx, pr); + if ('error' in info) return { failure: fail('QUEUE_PROOF_MISSING', info.error) }; + if (info.isCrossRepository) return { failure: fail('QUEUE_TRAIN_UNTRUSTED', `#${pr}'s head repository is not the base repository; fork work stays on the trusted-branch path`) }; + if (info.state !== 'OPEN') return { failure: fail('QUEUE_WAVE_MOVED', `#${pr} is ${info.state.toLowerCase()}; a wave carries open PRs only`) }; + if (info.baseRefName && info.baseRefName !== ctx.base) return { failure: fail('QUEUE_WAVE_MOVED', `#${pr} targets ${info.baseRefName}, not ${ctx.base}; a stacked PR would carry its parent's commits into the wave`) }; + if (info.headRefOid !== pin) return { failure: fail('QUEUE_WAVE_MOVED', `#${pr} moved: pinned ${short(pin)}, head is now ${short(info.headRefOid)}`) }; + return null; +} + +/** + * ready and train build: a PR whose head branch is wave/ must have a readable wave + * record (refused QUEUE_WAVE_UNKNOWN otherwise, never skipped), a branch chain that merges every + * pin unchanged, and constituents still open at their pins. Any other PR is null, unless a live + * wave lists it as a constituent: then it is refused QUEUE_WAVE_DUPLICATE so it cannot land twice. + */ +export function checkWavePin(env: TrainEnv, pr: string, info: PrInfo): { wave: WaveRecord; lines: string[] } | { failure: QueueFailure } | null { + if (!info.headRefName.startsWith('wave/')) { + const holder = listWaves(env.repo.keys).find(x => LIVE.has(x.state) && x.constituents.some(c => c.pr === pr)); + return holder ? { failure: fail('QUEUE_WAVE_DUPLICATE', `#${pr} is a constituent of live wave ${holder.nonce}; it lands through the wave PR, not on its own`) } : null; + } + let w = waveByBranch(env, info.headRefName); + if (!w) { rebuildFromNotes(env.ctx.repoRoot, env.remote); w = waveByBranch(env, info.headRefName); } + if (!w) return { failure: fail('QUEUE_WAVE_UNKNOWN', `#${pr}'s branch ${info.headRefName} has no wave record in local state or refs/notes/gstack-trains`) }; + if (w.state === 'superseded') return { failure: fail('QUEUE_WAVE_MOVED', `wave ${w.nonce} was rebuilt as ${w.superseded_by}; READY the new wave PR`) }; + if (w.state === 'released') return { failure: fail('QUEUE_WAVE_MOVED', `wave ${w.nonce} was released; its PRs ride as single slots`) }; + if (!ensureCommit(env, info.headRefOid, pr)) return { failure: fail('QUEUE_PROOF_MISSING', `cannot fetch #${pr}'s head ${short(info.headRefOid)}`) }; + const chain = waveChain(env.ctx.repoRoot, w, info.headRefOid, `${env.remote}/${env.ctx.base}`); + if ('error' in chain) return { failure: fail('QUEUE_WAVE_MOVED', `wave ${w.nonce}: ${chain.error}`) }; + for (const c of w.constituents) { + const p = constituentProblem(env.ctx, c.pr, c.head); + if (p) return { failure: { ...p.failure, message: `wave ${w.nonce}: ${p.failure.message}` } }; + } + return { wave: w, lines: [ + `WAVE: ${w.nonce} area=${w.area} branch=${w.branch} constituents=${w.constituents.length} integrator_commits=${chain.integrator.length}`, + `CONSTITUENTS: ${pinsOf(w)} (heads frozen; fixes go on the wave branch)`, + ] }; +} + +function readyReceipts(env: TrainEnv): ReadyReceipt[] { + const newest = new Map(); + for (const key of env.repo.keys) { + const dir = path.join(queueRoot(), 'ready', key); + if (!fs.existsSync(dir)) continue; + for (const n of fs.readdirSync(dir)) { + try { + const r = JSON.parse(fs.readFileSync(path.join(dir, n), 'utf8')) as ReadyReceipt; + if (r.kind !== 'ready') continue; + const cur = newest.get(r.pr); + if (!cur || r.ready_at > cur.ready_at) newest.set(r.pr, r); + } catch {} + } + } + return [...newest.values()]; +} + +function changed(env: TrainEnv, head: string): { files: string[]; lines: number } | null { + const fork = git(env.ctx.repoRoot, ['merge-base', `${env.remote}/${env.ctx.base}`, head]); + const stat = fork.ok ? git(env.ctx.repoRoot, ['diff', '--numstat', fork.out, head]) : fork; + if (!stat.ok) return null; + let lines = 0; + const files: string[] = []; + for (const row of stat.out.split('\n').filter(Boolean)) { + const [add, del, file] = row.split('\t'); + if (!file || isReleasePath(file, env.policy.policy)) continue; + files.push(file); + lines += (Number(add) || 0) + (Number(del) || 0); + } + return { files, lines }; +} + +const areaOf = (env: TrainEnv) => (f: string) => areaKey(f, dir => fs.existsSync(path.join(env.ctx.repoRoot, dir, 'SKILL.md.tmpl'))); +const buildCmd = (area: string, pins: Array<{ pr: string; head: string }>) => `${Q} wave build --area ${area} --prs ${pins.map(p => `${p.pr}@${p.head}`).join(',')}`; + +function setup(ctx: QueueContext): TrainEnv | TrainOut { + const fetchErr = fetchBase(ctx); + const env = trainEnv(ctx); + if ('code' in env) return outRefused(env, [], 'none', 'none'); + if (fetchErr) return outRefused({ code: 'POLICY_SOURCE_UNAVAILABLE', message: fetchErr }, [], 'none', 'none'); + rebuildFromNotes(ctx.repoRoot, env.remote); + return env; +} + +export function wavePlan(ctx: QueueContext, o: { trigger: number; min: number; max: number }): TrainOut { + const env = setup(ctx); + if ('exit' in env) return env; + const inWave = new Map(); + for (const w of listWaves(env.repo.keys)) if (LIVE.has(w.state)) for (const c of w.constituents) inWave.set(c.pr, w.nonce); + const live: PlanPr[] = []; + const lines: string[] = []; + const unreadable: string[] = []; + for (const r of readyReceipts(env).sort((a, b) => Number(a.pr) - Number(b.pr))) { + if (inWave.has(r.pr)) { lines.push(`IN_WAVE: #${r.pr} (wave ${inWave.get(r.pr)})`); continue; } + const info = prInfo(ctx, r.pr); + if ('error' in info) { unreadable.push(`#${r.pr} (${info.error})`); continue; } + if (info.state !== 'OPEN' || info.headRefOid !== r.head || info.isCrossRepository || info.headRefName.startsWith('wave/')) continue; + if (!ensureCommit(env, r.head, r.pr)) { unreadable.push(`#${r.pr} (head ${short(r.head)} not fetchable)`); continue; } + const c = changed(env, r.head); + if (!c) { unreadable.push(`#${r.pr} (diff unreadable)`); continue; } + live.push({ pr: r.pr, head: r.head, ...c }); + } + if (unreadable.length) return outFailed(fail('QUEUE_PROOF_MISSING', `cannot read ${unreadable.length} READY PR(s): ${unreadable.join('; ')}; a plan from a partial queue would mislead`), lines, `${Q} wave plan (after gh and git can read every PR)`, 'none'); + lines.push(`QUEUE: ${live.length} live READY PR(s) (open, head equal to its READY receipt); wave trigger ${o.trigger}`); + if (live.length <= o.trigger) return outOk([...lines, `WAVE: not needed (${live.length} <= ${o.trigger}); build trains of single PRs`], `${Q} train build --prs `, 'none'); + const plan = groupWaves(live, areaOf(env), o.min, o.max); + lines.push(`WAVE_ADVISED: ${live.length} READY PRs over the wave trigger ${o.trigger}; group by area into waves of ${o.min}-${o.max} (Garry R46)`); + for (const g of plan.groups) { + lines.push(`WAVE_GROUP: area=${g.area} prs=${g.prs.map(p => '#' + p.pr).join(',')} integrator=#${g.integrator} (most changed lines)`); + lines.push(` build: ${buildCmd(g.area, g.prs)}`); + } + if (plan.solo.length) lines.push(`SOLO: ${plan.solo.map(p => '#' + p).join(',')} (no area group of ${o.min}+; they ride as single slots)`); + const first = plan.groups[0]; + return outOk(lines, first ? `#${first.integrator}'s owner integrates: ${buildCmd(first.area, first.prs)}` : `${Q} train build --prs `, 'none'); +} + +export interface WaveBuildOptions { pins?: string; area?: string; wave?: string; cont?: boolean; drop?: string; release?: boolean; dryRun: boolean; min: number; max: number } + +export function waveBuild(ctx: QueueContext, o: WaveBuildOptions): TrainOut { + const env = setup(ctx); + if ('exit' in env) return env; + if (!o.wave) return newWave(env, o, null); + const w = loadWave(o.wave); + if (!w || !env.repo.keys.includes(w.repo_key)) return outRefused(fail('QUEUE_WAVE_UNKNOWN', `no wave record ${o.wave} in local state or refs/notes/gstack-trains`), [], `${Q} wave status`, 'none'); + if (o.release) return release(env, w, [], o.dryRun); + if (o.drop) return dropFrom(env, w, o); + if (o.cont) return continueWave(env, w, o.dryRun); + if (w.state === 'conflict') return outRefused(fail('QUEUE_WAVE_CONFLICT', `wave ${w.nonce} is stopped at #${w.conflict!.pr}; resolve in ${w.conflict!.worktree}`), [], `${Q} wave build --wave ${w.nonce} --continue`, wavePath(w.nonce)); + if (w.state !== 'built') return outOk([`WAVE: ${w.nonce} is ${w.state}`], `${Q} wave status --wave ${w.nonce}`, wavePath(w.nonce)); + return publish(env, w, [], o.dryRun); +} + +function newWave(env: TrainEnv, o: WaveBuildOptions, supersedes: WaveRecord | null, given?: Array<{ pr: string; sha: string }>): TrainOut { + const parsed = given ?? parsePins(o.pins); + if (typeof parsed === 'string') return outRefused(fail('QUEUE_PROOF_MISSING', parsed), [], 'none', 'none'); + if (parsed.length < o.min || parsed.length > o.max) return outRefused(fail('QUEUE_WAVE_SIZE', `a wave carries ${o.min}-${o.max} PRs (got ${parsed.length})`), [], `${Q} wave plan`, 'none'); + const taken = new Map(); + for (const w of listWaves(env.repo.keys)) if (LIVE.has(w.state) && w.nonce !== supersedes?.nonce) for (const c of w.constituents) taken.set(c.pr, w.nonce); + const pins: Array<{ pr: string; head: string }> = []; + for (const p of parsed) { + if (taken.has(p.pr)) return outRefused(fail('QUEUE_WAVE_DUPLICATE', `#${p.pr} is already in wave ${taken.get(p.pr)}`), [], `${Q} wave status --wave ${taken.get(p.pr)}`, 'none'); + const info = prInfo(env.ctx, p.pr); + if ('error' in info) return outRefused(fail('QUEUE_PROOF_MISSING', info.error), [], 'none', 'none'); + if (!info.headRefOid.startsWith(p.sha)) return outRefused(fail('QUEUE_WAVE_MOVED', `#${p.pr} moved: pinned ${p.sha.slice(0, 12)}, head is now ${short(info.headRefOid)}`), [], `${Q} wave build --prs `, 'none'); + const problem = constituentProblem(env.ctx, p.pr, info.headRefOid); + if (problem) return outRefused(problem.failure, [], 'none', 'none'); + if (info.headRefName.startsWith('wave/')) return outRefused(fail('QUEUE_WAVE_DUPLICATE', `#${p.pr} is itself a wave PR; waves do not nest`), [], 'none', 'none'); + if (!ensureCommit(env, info.headRefOid, p.pr)) return outRefused(fail('QUEUE_PROOF_MISSING', `cannot fetch #${p.pr}'s head`), [], 'none', 'none'); + pins.push({ pr: p.pr, head: info.headRefOid }); + } + const baseSha = git(env.ctx.repoRoot, ['rev-parse', `${env.remote}/${env.ctx.base}`]).out; + const files = pins.flatMap(p => changed(env, p.head)?.files ?? []); + const area = (o.area ?? dominantArea(files, areaOf(env))).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'misc'; + const nonce = `${area}-${sha256(JSON.stringify({ baseSha, pins, salt: randomBytes(4).toString('hex') })).slice(0, 12)}`; + const now = nowIso(); + const w: WaveRecord = { + schema: 1, kind: 'wave', nonce, repo_key: env.repo.key, area, base: { ref: env.ctx.base, sha: baseSha }, branch: `wave/${nonce}`, + constituents: pins.map(p => ({ pr: p.pr, head: p.head, merge_commit: null })), head: null, state: 'built', conflict: null, + superseded_by: null, settled: [], effects: supersedes ? [{ at: now, what: `rebuilt from ${supersedes.nonce}` }] : [], created_at: now, updated_at: now, + }; + const lines = [`WAVE: ${nonce} area=${area} base=${env.ctx.base}@${short(baseSha)} constituents=${pinsOf(w)}`]; + return chainFrom(env, w, 0, baseSha, lines, o.dryRun, supersedes); +} + +function chainFrom(env: TrainEnv, w: WaveRecord, start: number, pred: string, lines: string[], dryRun: boolean, supersedes: WaveRecord | null): TrainOut { + const repo = env.ctx.repoRoot; + for (let i = start; i < w.constituents.length; i++) { + const c = w.constituents[i]!; + const m = mergeCommit(repo, pred, c.head, env.policy.policy, `wave ${w.nonce}: merge #${c.pr} (${short(c.head)})`); + if ('commit' in m) { c.merge_commit = m.commit; pred = m.commit; lines.push(`MERGED: #${c.pr} at ${short(c.head)} -> ${short(m.commit)}`); continue; } + const conflictLine = `QUEUE_WAVE_CONFLICT: #${c.pr} conflicts with the wave so far (${m.conflicts.join(', ')})`; + if (dryRun) return outFailed(fail('QUEUE_WAVE_CONFLICT', `#${c.pr} conflicts with the wave so far`), [...lines, conflictLine, 'DRY-RUN: no record, no worktree, nothing pushed'], `build the wave without --dry-run to resolve it in a worktree, or leave #${c.pr} out`, 'none'); + const worktree = worktreeOf(w); + fs.rmSync(worktree, { recursive: true, force: true }); + git(repo, ['worktree', 'prune']); + const add = git(repo, ['worktree', 'add', '-q', '--detach', worktree, pred]); + if (!add.ok) return outFailed(fail('QUEUE_WAVE_CONFLICT', `#${c.pr} conflicts and the resolution worktree could not be created: ${add.err.slice(0, 160)}`), [...lines, conflictLine], 'none', 'none'); + git(worktree, ['merge', '--no-ff', '--no-commit', c.head]); + w.state = 'conflict'; + w.conflict = { pr: c.pr, files: m.conflicts, worktree, pred }; + w.effects.push({ at: nowIso(), what: `stopped at #${c.pr}; worktree ${worktree}` }); + w.updated_at = nowIso(); + const p = persist(repo, [w], { remote: env.remote }); + if (supersedes) supersede(env, supersedes, w); + return outFailed(fail('QUEUE_WAVE_CONFLICT', `#${c.pr} conflicts with the wave so far; nothing pushed`), [...lines, conflictLine, + `WORKTREE: ${worktree} (merge of #${c.pr} onto ${short(pred)} in progress; record ${wavePath(w.nonce)}, ${p.detail})`], + `resolve the conflicts in ${worktree}, git -C ${worktree} commit --no-edit, then ${Q} wave build --wave ${w.nonce} --continue`, wavePath(w.nonce)); + } + w.head = pred; + w.state = 'built'; + w.conflict = null; + if (dryRun) return outOk([...lines, `WAVE_HEAD: ${short(pred)} (dry run in object storage)`, 'DRY-RUN: no record written, nothing pushed'], buildCmd(w.area, w.constituents), 'none'); + w.effects.push({ at: nowIso(), what: `built ${short(pred)}` }); + w.updated_at = nowIso(); + persist(repo, [w], { remote: env.remote }); + if (supersedes) supersede(env, supersedes, w); + return publish(env, w, lines, false); +} + +function supersede(env: TrainEnv, old: WaveRecord, w: WaveRecord): void { + old.state = 'superseded'; + old.superseded_by = w.nonce; + old.effects.push({ at: nowIso(), what: `superseded by ${w.nonce}` }); + old.updated_at = nowIso(); + persist(env.ctx.repoRoot, [old], { remote: env.remote }); +} + +function comments(ctx: QueueContext, pr: string): string[] | null { + const r = ctx.gh(['pr', 'view', pr, '--json', 'comments']); + if (!r.ok) return null; + try { return ((JSON.parse(r.out).comments ?? []) as Array<{ body?: string }>).map(c => c.body ?? ''); } catch { return null; } +} + +/** Post a comment once: a body carrying the marker is never posted twice. */ +function commentOnce(ctx: QueueContext, pr: string, mark: string, body: string): 'posted' | 'present' | 'failed' { + const seen = comments(ctx, pr); + if (seen === null) return 'failed'; + if (seen.some(b => b.includes(mark))) return 'present'; + return ctx.gh(['pr', 'comment', pr, '--body', `${body}\n\n${mark}`]).ok ? 'posted' : 'failed'; +} + +function publish(env: TrainEnv, w: WaveRecord, lines: string[], dryRun: boolean): TrainOut { + const repo = env.ctx.repoRoot; + const remote = git(repo, ['ls-remote', env.remote, `refs/heads/${w.branch}`]); + if (!remote.ok) return outFailed(fail('QUEUE_PROOF_MISSING', `cannot read ${env.remote} refs/heads/${w.branch}: ${remote.err.slice(0, 120)}`), lines, `${Q} wave build --wave ${w.nonce}`, wavePath(w.nonce)); + const at = remote.out.split(/\s/)[0] || null; + if (dryRun) return outOk([...lines, `DRY-RUN: would push ${short(w.head!)} to ${w.branch}`], `${Q} wave build --wave ${w.nonce}`, wavePath(w.nonce)); + if (at && at !== w.head) return outFailed(fail('QUEUE_WAVE_MOVED', `${w.branch} already exists at ${short(at)}, not the built head ${short(w.head!)}; nothing overwritten`), lines, `${Q} wave status --wave ${w.nonce}`, wavePath(w.nonce)); + if (!at) { + const p = git(repo, ['push', '-q', `--force-with-lease=refs/heads/${w.branch}:`, env.remote, `${w.head}:refs/heads/${w.branch}`]); + if (!p.ok) return outFailed(fail('QUEUE_PROOF_MISSING', `git push ${w.branch} failed (${p.err.replace(/\s+/g, ' ').slice(0, 160)}); the record says built, nothing else changed`), lines, `${Q} wave build --wave ${w.nonce}`, wavePath(w.nonce)); + } + w.state = 'pushed'; + w.effects.push({ at: nowIso(), what: `pushed ${w.branch} at ${short(w.head!)}` }); + w.updated_at = nowIso(); + const saved = persist(repo, [w], { remote: env.remote }); + const notices = w.constituents.map(c => `#${c.pr} ${commentOnce(env.ctx, c.pr, marker(w.nonce, 'frozen'), `Frozen in wave \`${w.nonce}\` (branch \`${w.branch}\`) at ${short(c.head)}. Push nothing here: a moved head drops this PR from the wave. Fixes go on the wave branch through its integrator.`)}`); + const body = path.join(queueRoot(), 'waves', `${w.nonce}-pr-body.md`); + fs.writeFileSync(body, [`## Wave ${w.nonce}`, '', `One slot and one version for ${w.constituents.length} PRs in area \`${w.area}\`, each merged unchanged with a merge commit:`, '', + ...w.constituents.map(c => `- #${c.pr} at ${c.head}`), '', 'Each constituent is closed with a link to this PR after it lands (`gstack-ship-queue wave settle`).', ''].join('\n')); + return outOk([...lines, `PUSHED: ${w.branch} at ${w.head} (${saved.detail})`, `FROZEN_NOTICE: ${notices.join(' ')}`, `PR_BODY: ${body}`], + `open the wave PR (gh pr create --base ${env.ctx.base} --head ${w.branch} --title "wave(${w.area}): ${w.constituents.map(c => '#' + c.pr).join(' ')}" --body-file ${body}, or the host's PR tool), run /ship on it, then ${Q} ready --pr --screen|--no-screen`, wavePath(w.nonce)); +} + +function continueWave(env: TrainEnv, w: WaveRecord, dryRun: boolean): TrainOut { + if (w.state !== 'conflict' || !w.conflict) return outRefused(fail('QUEUE_WAVE_CONFLICT', `wave ${w.nonce} is ${w.state}, not stopped at a conflict`), [], `${Q} wave status --wave ${w.nonce}`, wavePath(w.nonce)); + const { pred, pr } = w.conflict; + const worktree = worktreeOf(w); + const idx = w.constituents.findIndex(c => c.pr === pr); + const c = w.constituents[idx]!; + const dirty = git(worktree, ['status', '--porcelain']); + const parents = git(worktree, ['rev-list', '--parents', '-n', '1', 'HEAD']).out.split(' '); + if (!dirty.ok || dirty.out.trim() || parents.length !== 3 || parents[1] !== pred || parents[2] !== c.head) { + return outRefused(fail('QUEUE_WAVE_CONFLICT', `${worktree} must hold one committed merge of #${pr} (${short(c.head)}) onto ${short(pred)} with a clean tree`), [`WORKTREE: ${worktree}`], + `resolve the conflicts in ${worktree}, git -C ${worktree} commit --no-edit, then ${Q} wave build --wave ${w.nonce} --continue`, wavePath(w.nonce)); + } + const problem = w.constituents.map(x => constituentProblem(env.ctx, x.pr, x.head)).find(Boolean); + if (problem) return outRefused(problem.failure, [], `${Q} wave build --wave ${w.nonce} --drop `, wavePath(w.nonce)); + c.merge_commit = parents[0]!; + c.resolved = w.conflict.files; + const lines = [`RESOLVED: #${pr} -> ${short(parents[0]!)} (integrator resolution of ${w.conflict.files.join(', ')})`]; + if (!dryRun) { git(env.ctx.repoRoot, ['worktree', 'remove', '--force', worktree]); w.conflict = null; } + return chainFrom(env, w, idx + 1, parents[0]!, lines, dryRun, null); +} + +function dropFrom(env: TrainEnv, w: WaveRecord, o: WaveBuildOptions): TrainOut { + if (!LIVE.has(w.state)) return outRefused(fail('QUEUE_WAVE_MOVED', `wave ${w.nonce} is ${w.state}; only a live wave is rebuilt`), [], `${Q} wave status --wave ${w.nonce}`, wavePath(w.nonce)); + const dropped = w.constituents.find(c => c.pr === o.drop); + if (!dropped) return outRefused(fail('QUEUE_PROOF_MISSING', `#${o.drop} is not a constituent of wave ${w.nonce}`), [], `${Q} wave status --wave ${w.nonce}`, wavePath(w.nonce)); + const open = openWavePr(env, w); + if (open && !o.dryRun) return outRefused(open, [], `close the wave PR on ${w.branch} (a train may still land it), then rerun the drop`, wavePath(w.nonce)); + const rest = w.constituents.filter(c => c !== dropped); + const missing = w.constituents.find(c => !ensureCommit(env, c.head, c.pr)); + if (missing) return outRefused(fail('QUEUE_PROOF_MISSING', `cannot fetch #${missing.pr}'s pin ${short(missing.head)} to check what the drop leaves behind`), [], 'none', wavePath(w.nonce)); + const carrier = rest.find(c => git(env.ctx.repoRoot, ['merge-base', '--is-ancestor', dropped.head, c.head]).ok); + if (carrier) return outRefused(fail('QUEUE_WAVE_DEPENDENT', `#${carrier.pr}'s pin contains #${dropped.pr}'s pin, so dropping #${dropped.pr} would still land its code`), [], `drop #${carrier.pr} too, or keep #${dropped.pr}`, wavePath(w.nonce)); + if (rest.length < o.min) return release(env, w, [`DROP: #${dropped.pr} from ${w.nonce} leaves ${rest.length} PR(s), under the wave minimum ${o.min}`], o.dryRun); + const out = newWave(env, { ...o, area: w.area }, w, rest.map(c => ({ pr: c.pr, sha: c.head }))); + out.lines.unshift(`DROP: #${dropped.pr} from ${w.nonce}; the rebuilt wave gets a new branch and PR (${w.branch}'s PR is already closed)`); + return out; +} + +/** A wave PR still open can still land through a train that admitted it, so drop and release wait for it to close. */ +function openWavePr(env: TrainEnv, w: WaveRecord): QueueFailure | null { + const list = env.ctx.gh(['pr', 'list', '--head', w.branch, '--state', 'open', '--json', 'number']); + let prs: Array<{ number: number }> | null = null; + try { prs = list.ok ? JSON.parse(list.out) : null; } catch {} + if (!prs) return fail('QUEUE_PROOF_MISSING', `gh cannot list PRs for ${w.branch}`); + return prs.length ? fail('QUEUE_WAVE_MOVED', `wave PR #${prs[0]!.number} on ${w.branch} is still open; a train that admitted it would still land it`) : null; +} + +/** End a live wave without landing it (a drop under the minimum, or a wave PR closed unmerged): every constituent rides on its own again. */ +function release(env: TrainEnv, w: WaveRecord, lines: string[], dryRun: boolean): TrainOut { + if (!LIVE.has(w.state)) return outRefused(fail('QUEUE_WAVE_MOVED', `wave ${w.nonce} is ${w.state}; only a live wave is released`), lines, `${Q} wave status --wave ${w.nonce}`, wavePath(w.nonce)); + const open = openWavePr(env, w); + if (open) return outRefused(open, lines, `close the wave PR on ${w.branch}, then rerun`, wavePath(w.nonce)); + const done = [...lines, `RELEASED: ${w.nonce}: ${w.constituents.map(c => '#' + c.pr).join(',')} ride as single slots; close the wave PR on ${w.branch}`]; + if (dryRun) return outOk([...done, 'DRY-RUN: no record written'], `${Q} wave build --wave ${w.nonce} --release`, wavePath(w.nonce)); + if (w.conflict) { git(env.ctx.repoRoot, ['worktree', 'remove', '--force', worktreeOf(w)]); w.conflict = null; } + w.state = 'released'; + w.effects.push({ at: nowIso(), what: 'released' }); + w.updated_at = nowIso(); + const saved = persist(env.ctx.repoRoot, [w], { remote: env.remote }); + return outOk([...done, `RECORD: ${saved.detail}`], `each owner READYs its PR alone: ${Q} ready --pr --screen|--no-screen`, wavePath(w.nonce)); +} + +export function waveStatus(ctx: QueueContext, o: { wave?: string }): TrainOut { + const env = setup(ctx); + if ('exit' in env) return env; + if (!o.wave) { + const all = listWaves(env.repo.keys); + if (!all.length) return outOk(['WAVE: none recorded'], `${Q} wave plan`, 'none'); + return outOk(all.map(w => `WAVE: ${w.nonce} ${w.state} area=${w.area} ${pinsOf(w)} branch=${w.branch}${w.superseded_by ? ` superseded_by=${w.superseded_by}` : ''}`), `${Q} wave status --wave `, 'none'); + } + const w = loadWave(o.wave); + if (!w || !env.repo.keys.includes(w.repo_key)) return outRefused(fail('QUEUE_WAVE_UNKNOWN', `no wave record ${o.wave}`), [], `${Q} wave status`, 'none'); + const lines = [`WAVE: ${w.nonce} ${w.state} area=${w.area} base=${short(w.base.sha)} branch=${w.branch} head=${w.head ? short(w.head) : 'none'}`]; + if (w.conflict) lines.push(`CONFLICT: #${w.conflict.pr} (${w.conflict.files.join(', ')}) in ${w.conflict.worktree}`); + const tip = git(env.ctx.repoRoot, ['ls-remote', env.remote, `refs/heads/${w.branch}`]).out.split(/\s/)[0]; + if (tip && git(env.ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, `+refs/heads/${w.branch}:refs/gstack/wave/${w.nonce}`]).ok) { + const chain = waveChain(env.ctx.repoRoot, w, tip, `${env.remote}/${env.ctx.base}`); + lines.push('error' in chain ? `CHAIN: invalid at ${short(tip)}: ${chain.error}` : `CHAIN: ok at ${short(tip)}: ${chain.merges.length} constituent merges, ${chain.integrator.length} integrator commit(s)`); + } + for (const c of w.constituents) { + const p = constituentProblem(ctx, c.pr, c.head); + lines.push(`CONSTITUENT: #${c.pr} pin=${short(c.head)} ${p ? `${p.failure.code}: ${p.failure.message}` : 'open at its pin'}`); + } + for (const s of w.settled) lines.push(`SETTLED: #${s.pr} ${s.outcome} at ${s.at}`); + const next = w.state === 'conflict' ? `${Q} wave build --wave ${w.nonce} --continue` : w.state === 'built' ? `${Q} wave build --wave ${w.nonce}` : w.state === 'pushed' ? `after the wave PR merges: ${Q} wave settle --wave ${w.nonce}` : 'none'; + return outOk(lines, next, wavePath(w.nonce)); +} + +/** + * After the wave PR merged: bind to the merged PR's own head (main's merge must carry its + * tree), walk its chain, and settle each constituent once. A constituent still at its pin is + * closed with the link, the version and any integrator commits touching its files; a moved one + * stays open with a comment (its newer work did not land); a closed one is skipped. + */ +export function waveSettle(ctx: QueueContext, o: { wave: string; dryRun: boolean }): TrainOut { + const env = setup(ctx); + if ('exit' in env) return env; + const w = loadWave(o.wave); + if (!w || !env.repo.keys.includes(w.repo_key)) return outRefused(fail('QUEUE_WAVE_UNKNOWN', `no wave record ${o.wave}`), [], `${Q} wave status`, 'none'); + const list = ctx.gh(['pr', 'list', '--head', w.branch, '--state', 'all', '--json', 'number,state,headRefOid,mergeCommit,baseRefName']); + let prs: Array<{ number: number; state: string; headRefOid: string; baseRefName?: string; mergeCommit?: { oid?: string } | null }> = []; + try { prs = JSON.parse(list.out); } catch {} + const merged = list.ok ? prs.find(p => p.state === 'MERGED' && p.mergeCommit?.oid && p.baseRefName === w.base.ref) : undefined; + if (!merged) return outRefused(fail('QUEUE_WAVE_NOT_MERGED', list.ok ? `no merged PR has head branch ${w.branch}` : `gh cannot list PRs for ${w.branch}: ${(list.err ?? '').slice(0, 120)}`), [], `land the wave PR through its train, then ${Q} wave settle --wave ${w.nonce}`, wavePath(w.nonce)); + const mergeSha = merged.mergeCommit!.oid!; + const repo = ctx.repoRoot; + if (!ensureCommit(env, merged.headRefOid, String(merged.number)) || !git(repo, ['cat-file', '-e', `${mergeSha}^{commit}`]).ok) return outRefused(fail('QUEUE_PROOF_MISSING', `cannot fetch #${merged.number}'s head or merge commit`), [], 'none', wavePath(w.nonce)); + if (!git(repo, ['merge-base', '--is-ancestor', mergeSha, `${env.remote}/${w.base.ref}`]).ok) { + return outRefused(fail('QUEUE_WAVE_NOT_MERGED', `#${merged.number}'s merge ${short(mergeSha)} is not on ${env.remote}/${w.base.ref}`), [], 'none', wavePath(w.nonce)); + } + const mergedTree = git(repo, ['rev-parse', `${mergeSha}^{tree}`]).out; + const onParent = git(repo, ['merge-tree', '--write-tree', `${mergeSha}^1`, merged.headRefOid]); + if (mergedTree !== git(repo, ['rev-parse', `${merged.headRefOid}^{tree}`]).out && !(onParent.ok && onParent.out.split('\n')[0] === mergedTree)) { + return outRefused(fail('QUEUE_WAVE_NOT_MERGED', `main's merge ${short(mergeSha)} is neither #${merged.number}'s head tree nor that head merged onto its parent; what landed is not the wave`), [], 'none', wavePath(w.nonce)); + } + const chain = waveChain(repo, w, merged.headRefOid, mergeSha); + if ('error' in chain) return outRefused(fail('QUEUE_WAVE_MOVED', `the merged head ${short(merged.headRefOid)}: ${chain.error}`), [], 'none', wavePath(w.nonce)); + const at = versionAtRef(repo, mergeSha); + const version = 'version' in at ? `v${at.version}` : 'its version'; + const integratorFiles = new Map(chain.integrator.map(c => [c, git(repo, ['diff-tree', '--no-commit-id', '--name-only', '-r', c]).out.split('\n').filter(Boolean)])); + const lines = [`WAVE_MERGED: #${merged.number} as ${short(mergeSha)} (${version}); chain ok: ${chain.merges.length} constituent merges, ${chain.integrator.length} integrator commit(s)`]; + let incomplete = 0; + for (const c of w.constituents) { + const info = prInfo(ctx, c.pr); + if ('error' in info) { incomplete++; lines.push(`SETTLED: #${c.pr} unreadable (${info.error}); rerun settle`); continue; } + if (info.state !== 'OPEN') { lines.push(`SETTLED: #${c.pr} skipped (${info.state.toLowerCase()})`); continue; } + const own = new Set(changed(env, c.head)?.files ?? []); + const touched = [ + ...w.constituents.flatMap((x, i) => { + const why = chain.merges[i] !== x.merge_commit ? 'not the recorded merge' : x.resolved ? `resolved by hand: ${x.resolved.join(', ')}` : null; + return why && (x === c || x.resolved?.some(f => own.has(f))) ? [`${short(chain.merges[i]!)} (merge of #${x.pr}, ${why})`] : []; + }), + ...chain.integrator.filter(x => integratorFiles.get(x)!.some(f => own.has(f))).map(short), + ]; + const moved = info.headRefOid !== c.head; + const body = moved + ? `Wave #${merged.number} landed this PR's pin ${short(c.head)} as ${version} (${short(mergeSha)}). The head moved to ${short(info.headRefOid)} after the wave froze, so that newer work did not land. Left open for its owner.` + : `Landed in wave #${merged.number} as ${version} (${short(mergeSha)}), merged from this PR's pin ${short(c.head)}. Integrator changes touching this PR's files: ${touched.length ? touched.join(', ') : 'none'}.`; + if (o.dryRun) { lines.push(`SETTLED: #${c.pr} would be ${moved ? 'commented, left open' : 'commented and closed'} (dry run)`); continue; } + const posted = commentOnce(ctx, c.pr, marker(w.nonce, moved ? 'moved' : 'landed'), body); + const still = moved || posted === 'failed' ? null : prInfo(ctx, c.pr); + const stillAtPin = !!still && !('error' in still) && still.headRefOid === c.head; + const closed = stillAtPin ? ctx.gh(['pr', 'close', c.pr]).ok : false; + const outcome = posted === 'failed' || (!moved && !closed) ? 'incomplete' : moved ? 'moved-left-open' : 'landed-closed'; + if (outcome === 'incomplete') incomplete++; + lines.push(`SETTLED: #${c.pr} ${outcome} (comment ${posted})`); + w.settled = [...w.settled.filter(s => s.pr !== c.pr), { pr: c.pr, outcome, at: nowIso() }]; + } + if (o.dryRun) return outOk([...lines, 'DRY-RUN: nothing commented or closed'], `${Q} wave settle --wave ${w.nonce}`, wavePath(w.nonce)); + if (!incomplete) w.state = 'settled'; + w.updated_at = nowIso(); + persist(repo, [w], { remote: env.remote }); + if (incomplete) return outFailed(fail('QUEUE_PROOF_MISSING', `${incomplete} constituent(s) not settled; every other one is recorded`), lines, `${Q} wave settle --wave ${w.nonce}`, wavePath(w.nonce)); + return outOk(lines, 'none', wavePath(w.nonce)); +} + +export const WAVE_HELP = `gstack-ship-queue wave: wave PRs for merge trains (docs/ship-policy.md#wave-prs) + + wave plan [--trigger ] [--min ] [--max ] + read-only: live READY PRs; over the trigger (default ${WAVE_TRIGGER}) WAVE_ADVISED and + WAVE_GROUP lines (area, PRs, integrator) with each group's build command + wave build --prs @,... [--area ] [--min ] [--max ] [--dry-run] + the integrator branch wave/: main plus each pin merged unchanged (train merge + rules), the wave record in refs/notes/gstack-trains, the branch pushed, a frozen notice + on each constituent; a conflict stops in a worktree under the state root + wave build --wave [--continue | --drop | --release] [--dry-run] + resume (retry the push), continue after the integrator committed the resolution, or + rebuild without one constituent as a new wave (new branch; never a force-push), or + release the wave so its PRs ride alone (a drop under --min releases too) + wave status [--wave ] records, the branch chain, each constituent's GitHub head + wave settle --wave [--dry-run] after the wave PR merged: close constituents still at their pins with the link and + version; a moved constituent stays open with a comment; resumable + A wave PR is READY'd and trained like any PR: ready --pr adds WAVE and CONSTITUENTS lines.`; + +function flag(args: string[], name: string): string | undefined { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : undefined; +} + +function usage(message: string): number { + process.stderr.write(`gstack-ship-queue: ${message}\n${WAVE_HELP}\n`); + return EXIT.usage; +} + +function print(cmd: string, out: TrainOut): number { + for (const l of out.lines) process.stdout.write(l + '\n'); + if (out.failure) process.stderr.write(`gstack-ship-queue ${cmd}: ${out.failure.message}; fix: ${out.failure.fix ?? RESULT_CODES[out.failure.code].fix} (${out.failure.code})\n`); + process.stdout.write(`NEXT: ${out.next}\n`); + process.stdout.write(formatResult('ship-queue', out.status, out.run) + '\n'); + return out.exit; +} + +export async function waveMain(ctx: QueueContext, args: string[]): Promise { + const [sub, ...rest] = args; + if (!sub || sub.startsWith('-')) { process.stdout.write(WAVE_HELP + '\n'); return sub === '--help' || sub === '-h' ? EXIT.ok : EXIT.usage; } + const count = (name: string, fallback: number) => { const v = flag(rest, name); return v === undefined ? fallback : /^\d+$/.test(v) && Number(v) > 0 ? Number(v) : NaN; }; + const min = count('--min', WAVE_MIN); + const max = count('--max', WAVE_MAX); + const trigger = count('--trigger', WAVE_TRIGGER); + if ([min, max, trigger].some(Number.isNaN) || min > max) return usage('--min, --max and --trigger take positive whole numbers, with --min <= --max'); + const wave = flag(rest, '--wave'); + const dryRun = rest.includes('--dry-run'); + try { + if (sub === 'plan') return print('wave plan', wavePlan(ctx, { trigger, min, max })); + if (sub === 'status') return print('wave status', waveStatus(ctx, { wave })); + if (sub === 'settle') return wave ? print('wave settle', waveSettle(ctx, { wave, dryRun })) : usage('wave settle needs --wave '); + if (sub === 'build') { + const drop = flag(rest, '--drop'); + const cont = rest.includes('--continue'); + const releaseIt = rest.includes('--release'); + if (!wave && !flag(rest, '--prs')) return usage('wave build needs --prs @,... or --wave '); + if (wave && flag(rest, '--prs')) return usage('pass --prs for a new wave or --wave for an existing one, not both'); + if ((drop !== undefined || cont || releaseIt) && !wave) return usage('--continue, --drop and --release need --wave '); + if ([drop !== undefined, cont, releaseIt].filter(Boolean).length > 1) return usage('pass one of --continue, --drop or --release'); + if (drop !== undefined && !/^\d+$/.test(drop)) return usage('--drop needs a PR number'); + return print('wave build', waveBuild(ctx, { pins: flag(rest, '--prs'), area: flag(rest, '--area'), wave, cont, drop, release: releaseIt, dryRun, min, max })); + } + return usage(`unknown wave subcommand ${sub}`); + } catch (error) { + process.stderr.write(`gstack-ship-queue wave ${sub}: unexpected failure: ${redactCause(error instanceof Error ? error.message : String(error))}\n`); + process.stdout.write(`NEXT: ${Q} wave status${wave ? ` --wave ${wave}` : ''} (the record shows what was written before the failure)\n`); + process.stdout.write(formatResult('ship-queue', 'interrupted', wave ? wavePath(wave) : 'none') + '\n'); + return EXIT.fail; + } +} diff --git a/package.json b/package.json index f22ef23c8..b12bf086d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.11", + "version": "1.92.12", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/helpers/ship-queue-train-fixture.ts b/test/helpers/ship-queue-train-fixture.ts index 7a7ffd589..e1baa0cfe 100644 --- a/test/helpers/ship-queue-train-fixture.ts +++ b/test/helpers/ship-queue-train-fixture.ts @@ -209,6 +209,9 @@ function shimSource(): string { " if (paged) { const list = paged[2] === 'check-runs' ? (d.ci[paged[1]] || []) : ((d.statuses || {})[paged[1]] || []); const key = paged[2] === 'check-runs' ? 'check_runs' : 'statuses';", " out(paged[3] === '1' ? { total_count: list.length, state: 'success', [key]: list } : { total_count: list.length, [key]: [] }); }", "}", + "if (a[0] === 'pr' && a[1] === 'comment') { const p = d.prs[a[2]]; if (!p) fail('no pull request found'); p.comments = [...(p.comments || []), { body: opt('--body') }]; out({}); }", + "if (a[0] === 'pr' && a[1] === 'close') { const p = d.prs[a[2]]; if (!p) fail('no pull request found'); p.state = 'CLOSED'; out({}); }", + "if (a[0] === 'pr' && a[1] === 'list') { const head = opt('--head'); const st = opt('--state'); out(Object.entries(d.prs).filter(([, p]) => (!head || p.headRefName === head) && (st !== 'open' || p.state === 'OPEN')).map(([n, p]) => ({ number: Number(n), state: p.state, headRefOid: p.headRefOid, baseRefName: p.baseRefName || 'main', mergeCommit: p.mergeCommit || null }))); }", "fail('gh shim: unsupported ' + a.join(' '));", ].join('\n') + '\n'; } diff --git a/test/ship-queue-wave.test.ts b/test/ship-queue-wave.test.ts new file mode 100644 index 000000000..e2132fd7c --- /dev/null +++ b/test/ship-queue-wave.test.ts @@ -0,0 +1,318 @@ +/** + * Wave PRs (lib/ship-queue-wave.ts; docs/ship-policy.md#wave-prs; Garry R46, GST-574) + * through the real bin/gstack-ship-queue against the train fixture (bare origin, gh shim): + * the area and grouping rules, wave build (merge chain, record on the notes ref, frozen + * notices, size and moved refusals), the conflict worktree and --continue, --drop as a new + * wave and its dependent refusal, ready and train build reading a wave PR (WAVE and + * CONSTITUENTS lines, a record rebuilt from notes on a clean state root, moved, duplicate and + * unknown refusals, WAVE_ADVISED over the trigger), wave plan, and wave settle. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { WAVE_HELP, areaKey, groupWaves, type PlanPr } from '../lib/ship-queue-wave'; +import { cleanup, runBin } from './helpers/restamp-fixture'; +import { line, openPr, queue, readyReceipt, shippedBody, trainFixture, type TrainFixture } from './helpers/ship-queue-train-fixture'; + +const fixtures: TrainFixture[] = []; +const make = () => { const f = trainFixture(); fixtures.push(f); return f; }; +afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); }); + +const nonceOf = (out: string) => /^WAVE: (\S+)/m.exec(out)?.[1] ?? ''; +const waveJson = (fx: TrainFixture, nonce: string) => JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'waves', `${nonce}.json`), 'utf8')); +const gitIn = (fx: TrainFixture, cwd: string, ...args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } }); + +function threePrs(fx: TrainFixture) { + return ['1', '2', '3'].map(pr => openPr(fx, pr, { file: `lib/ship-${pr}.ts`, changelog: pr === '1' })); +} + +/** A wave PR #10 whose head branch is the wave branch, with a /ship receipt and a READY receipt at its head. */ +function waveAsPr(fx: TrainFixture, nonce: string, pr = '10', tip?: string): string { + const head = tip ?? fx.repo.git('ls-remote', 'origin', `refs/heads/wave/${nonce}`).split(/\s/)[0]!; + fx.repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/${pr}/head`); + fx.write(d => { d.prs[pr] = { headRefOid: head, isCrossRepository: false, state: 'OPEN', headRefName: `wave/${nonce}`, url: `https://github.com/fixture/repo/pull/${pr}`, body: shippedBody(pr, head) }; }); + readyReceipt(fx, pr, head); + return head; +} + +describe('areas and grouping', () => { + const skill = (dir: string) => dir === 'ship' || dir === 'review'; + test('areaKey maps skills, lib modules, tests, bins and docs to one functional area', () => { + expect(areaKey('ship/SKILL.md.tmpl', skill)).toBe('ship'); + expect(areaKey('lib/ship-queue-train.ts', skill)).toBe('ship'); + expect(areaKey('test/ship-receipt.test.ts', skill)).toBe('ship'); + expect(areaKey('test/helpers/ship-queue-train-fixture.ts', skill)).toBe('ship'); + expect(areaKey('bin/gstack-pregate', skill)).toBe('pregate'); + expect(areaKey('lib/pregate/checks/guards.ts', skill)).toBe('pregate'); + expect(areaKey('docs/captain.md', skill)).toBe('docs'); + expect(areaKey('README.md', skill)).toBe('root'); + }); + + test('groupWaves keeps PRs sharing a file together, packs one area into waves of at most max, leaves small groups solo and picks the largest PR as integrator', () => { + const pr = (n: string, files: string[], lines = 10): PlanPr => ({ pr: n, head: n.repeat(40).slice(0, 40), files, lines }); + const areaOf = (f: string) => areaKey(f, () => false); + const prs = [ + pr('1', ['lib/ship-a.ts']), pr('2', ['lib/ship-b.ts'], 90), pr('3', ['lib/ship-c.ts']), pr('4', ['lib/ship-d.ts']), + pr('5', ['lib/ship-e.ts']), pr('6', ['lib/ship-f.ts']), pr('7', ['lib/ship-g.ts']), + pr('8', ['docs/a.md']), pr('9', ['docs/b.md']), + pr('11', ['lib/pregate-x.ts', 'docs/shared.md'], 5), pr('12', ['docs/shared.md', 'docs/c.md'], 50), + ]; + const { groups, solo } = groupWaves(prs, areaOf, 3, 6); + expect(groups.map(g => [g.area, g.prs.map(p => p.pr), g.integrator])).toEqual([ + ['docs', ['8', '9', '11', '12'], '12'], + ['ship', ['1', '2', '3', '4', '5', '6'], '2'], + ]); + expect(solo).toEqual(['7']); + }); +}); + +describe('wave build', () => { + test('merges each pin unchanged as a merge commit, records the wave on the notes ref, pushes the branch and posts one frozen notice per constituent', () => { + const fx = make(); + const heads = threePrs(fx); + const r = queue(fx, ['wave', 'build', '--area', 'ship', '--prs', heads.map((h, i) => `${i + 1}@${h}`).join(',')]); + expect(r.status).toBe(EXIT.ok); + const nonce = nonceOf(r.out); + expect(nonce).toMatch(/^ship-[0-9a-f]{12}$/); + expect(line(r.out, 'PUSHED:')).toContain(`wave/${nonce}`); + expect(line(r.out, 'NEXT:')).toContain('run /ship on it, then gstack-ship-queue ready --pr '); + const tip = fx.repo.git('ls-remote', 'origin', `refs/heads/wave/${nonce}`).split(/\s/)[0]!; + fx.repo.git('fetch', '-q', 'origin', `refs/heads/wave/${nonce}`); + const seconds = fx.repo.git('rev-list', '--first-parent', '--parents', `origin/main..${tip}`).split('\n').map(l => l.split(' ')[2]).reverse(); + expect(seconds).toEqual(heads); + const w = waveJson(fx, nonce); + expect([w.state, w.head, w.constituents.map((c: any) => c.head)]).toEqual(['pushed', tip, heads]); + expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains')).not.toBe(''); + const prs = fx.read().prs as Record; + for (const pr of ['1', '2', '3']) expect(prs[pr].comments.map((c: any) => c.body).join('\n')).toContain(`gstack-wave:${nonce}:frozen`); + const again = queue(fx, ['wave', 'build', '--wave', nonce]); + expect([again.status, line(again.out, 'WAVE:')]).toEqual([EXIT.ok, `WAVE: ${nonce} is pushed`]); + }, 120_000); + + test('refuses a wave outside 3-6 PRs and a moved pin before writing anything', () => { + const fx = make(); + const heads = threePrs(fx); + const two = queue(fx, ['wave', 'build', '--prs', `1@${heads[0]},2@${heads[1]}`]); + expect([two.status, two.err]).toEqual([EXIT.refused, expect.stringContaining('(QUEUE_WAVE_SIZE')]); + fx.write(d => { (d.prs['2'] as any).headRefOid = heads[0]; }); + const moved = queue(fx, ['wave', 'build', '--prs', heads.map((h, i) => `${i + 1}@${h}`).join(',')]); + expect([moved.status, moved.err]).toEqual([EXIT.refused, expect.stringContaining('(QUEUE_WAVE_MOVED')]); + fx.write(d => { Object.assign(d.prs['2'] as any, { headRefOid: heads[1], baseRefName: 'pr-1' }); }); + const stacked = queue(fx, ['wave', 'build', '--prs', heads.map((h, i) => `${i + 1}@${h}`).join(',')]); + expect([stacked.status, stacked.err]).toEqual([EXIT.refused, expect.stringContaining('#2 targets pr-1, not main')]); + expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'waves'))).toBe(false); + }, 120_000); + + test('a conflict stops in a worktree; --continue adopts the committed resolution and finishes the chain', () => { + const fx = make(); + const h1 = openPr(fx, '1', { file: 'lib/ship-a.ts' }); + const h2 = openPr(fx, '2', { file: 'lib/ship-shared.ts', text: 'two\n', changelog: false }); + const h3 = openPr(fx, '3', { file: 'lib/ship-shared.ts', text: 'three\n', changelog: false }); + const r = queue(fx, ['wave', 'build', '--prs', `1@${h1},2@${h2},3@${h3}`]); + expect([r.status, r.err]).toEqual([EXIT.fail, expect.stringContaining('(QUEUE_WAVE_CONFLICT')]); + const nonce = nonceOf(r.out); + const worktree = line(r.out, 'WORKTREE:').split(' ')[1]!; + expect(waveJson(fx, nonce).state).toBe('conflict'); + expect(fx.repo.git('ls-remote', 'origin', `refs/heads/wave/${nonce}`)).toBe(''); + const early = queue(fx, ['wave', 'build', '--wave', nonce, '--continue']); + expect([early.status, early.err]).toEqual([EXIT.refused, expect.stringContaining('(QUEUE_WAVE_CONFLICT')]); + fs.writeFileSync(path.join(worktree, 'lib/ship-shared.ts'), 'two\nthree\n'); + gitIn(fx, worktree, 'add', '-A'); + expect(gitIn(fx, worktree, 'commit', '-q', '--no-edit').status).toBe(0); + const done = queue(fx, ['wave', 'build', '--wave', nonce, '--continue']); + expect(done.status).toBe(EXIT.ok); + expect(done.out).toContain('RESOLVED: #3'); + expect(fs.existsSync(worktree)).toBe(false); + const status = queue(fx, ['wave', 'status', '--wave', nonce]); + expect(line(status.out, 'CHAIN:')).toContain('ok at'); + expect(line(status.out, 'CHAIN:')).toContain('3 constituent merges'); + const head = waveAsPr(fx, nonce); + const merge = fx.repo.git('commit-tree', `${head}^{tree}`, '-p', 'origin/main', '-m', 'wave (#10)'); + fx.repo.git('push', '-q', 'origin', `${merge}:refs/heads/main`); + fx.write(d => { Object.assign(d.prs['10'] as any, { state: 'MERGED', mergeCommit: { oid: merge } }); }); + expect(queue(fx, ['wave', 'settle', '--wave', nonce]).status).toBe(EXIT.ok); + const prs = fx.read().prs as Record; + expect(prs['3'].comments.at(-1).body).toContain('(merge of #3, resolved by hand: lib/ship-shared.ts)'); + expect(prs['2'].comments.at(-1).body).toContain('(merge of #3, resolved by hand: lib/ship-shared.ts)'); + expect(prs['1'].comments.at(-1).body).toContain("Integrator changes touching this PR's files: none."); + }, 180_000); + + test('--drop builds a new wave on a new branch and supersedes the old one; a dropped pin another pin contains is refused', () => { + const fx = make(); + const heads = [...threePrs(fx), openPr(fx, '4', { file: 'lib/ship-4.ts', changelog: false })]; + const h5 = openPr(fx, '5', { file: 'lib/ship-5.ts', changelog: false, from: 'pr-4' }); + const first = queue(fx, ['wave', 'build', '--prs', [...heads, h5].map((h, i) => `${i + 1}@${h}`).join(',')]); + expect(first.status).toBe(EXIT.ok); + const old = nonceOf(first.out); + const dry = queue(fx, ['wave', 'build', '--wave', old, '--drop', '5', '--dry-run']); + expect([dry.status, dry.out]).toEqual([EXIT.ok, expect.stringContaining('DRY-RUN: no record written')]); + expect(waveJson(fx, old).state).toBe('pushed'); + const dependent = queue(fx, ['wave', 'build', '--wave', old, '--drop', '4']); + expect([dependent.status, dependent.err]).toEqual([EXIT.refused, expect.stringContaining('(QUEUE_WAVE_DEPENDENT')]); + const dropped = queue(fx, ['wave', 'build', '--wave', old, '--drop', '5']); + expect(dropped.status).toBe(EXIT.ok); + const fresh = /^WAVE: (\S+) area=/m.exec(dropped.out)![1]!; + expect(fresh).not.toBe(old); + expect(waveJson(fx, old)).toMatchObject({ state: 'superseded', superseded_by: fresh }); + expect(waveJson(fx, fresh).constituents.map((c: any) => c.pr)).toEqual(['1', '2', '3', '4']); + expect(fx.repo.git('ls-remote', 'origin', `refs/heads/wave/${old}`)).not.toBe(''); + }, 120_000); + + test('a drop under the minimum releases the wave, and its other PRs are no longer refused as constituents', () => { + const fx = make(); + const heads = threePrs(fx); + const nonce = nonceOf(queue(fx, ['wave', 'build', '--prs', heads.map((h, i) => `${i + 1}@${h}`).join(',')]).out); + readyReceipt(fx, '1', heads[0]!); + const held = queue(fx, ['train', 'build', '--prs', `1@${heads[0]}`, '--dry-run']); + expect([held.status, held.err]).toEqual([EXIT.refused, expect.stringContaining('(QUEUE_WAVE_DUPLICATE')]); + waveAsPr(fx, nonce); + const whileOpen = queue(fx, ['wave', 'build', '--wave', nonce, '--drop', '3']); + expect([whileOpen.status, whileOpen.err]).toEqual([EXIT.refused, expect.stringContaining('wave PR #10 on wave/')]); + fx.write(d => { (d.prs['10'] as any).state = 'CLOSED'; }); + const dropped = queue(fx, ['wave', 'build', '--wave', nonce, '--drop', '3']); + expect([dropped.status, line(dropped.out, 'RELEASED:')]).toEqual([EXIT.ok, expect.stringContaining('#1,#2,#3 ride as single slots')]); + expect(waveJson(fx, nonce).state).toBe('released'); + expect(queue(fx, ['train', 'build', '--prs', `1@${heads[0]}`, '--dry-run']).status).toBe(EXIT.ok); + expect(queue(fx, ['wave', 'build', '--wave', nonce, '--release']).err).toContain('only a live wave is released'); + }, 120_000); +}); + +describe('ready and train build read the wave', () => { + test('train build prints WAVE and CONSTITUENTS from a record rebuilt from notes, and refuses a moved constituent, a duplicate pin and an unknown wave branch', () => { + const fx = make(); + const heads = threePrs(fx); + const nonce = nonceOf(queue(fx, ['wave', 'build', '--prs', heads.map((h, i) => `${i + 1}@${h}`).join(',')]).out); + fs.rmSync(path.join(fx.state, 'ship-queue', 'waves'), { recursive: true }); + const head = waveAsPr(fx, nonce); + const ok = queue(fx, ['train', 'build', '--prs', `10@${head}`, '--dry-run']); + expect(ok.status).toBe(EXIT.ok); + expect(line(ok.out, 'WAVE:')).toContain(`WAVE: ${nonce} area=`); + expect(line(ok.out, 'CONSTITUENTS:')).toContain(`#1@${heads[0]!.slice(0, 12)},#2@${heads[1]!.slice(0, 12)},#3@${heads[2]!.slice(0, 12)}`); + readyReceipt(fx, '1', heads[0]!); + const dup = queue(fx, ['train', 'build', '--prs', `10@${head},1@${heads[0]}`, '--dry-run']); + expect([dup.status, dup.err]).toEqual([EXIT.refused, expect.stringContaining('(QUEUE_WAVE_DUPLICATE')]); + const alone = queue(fx, ['train', 'build', '--prs', `1@${heads[0]}`, '--dry-run']); + expect([alone.status, alone.err]).toEqual([EXIT.refused, expect.stringContaining(`#1 is a constituent of live wave ${nonce}`)]); + const stamp = fx.repo.git('commit-tree', `${head}^{tree}`, '-p', head, '-p', 'origin/main', '-m', 'stamp'); + const stamped = queue(fx, ['train', 'build', '--prs', `10@${waveAsPr(fx, nonce, '10', stamp)}`, '--dry-run']); + expect([stamped.status, line(stamped.out, 'WAVE:')]).toEqual([EXIT.ok, expect.stringContaining(`WAVE: ${nonce}`)]); + const offBase = fx.repo.git('commit-tree', `${head}^{tree}`, '-p', head, '-p', heads[0]!, '-m', 'not a stamp'); + const foreign = queue(fx, ['train', 'build', '--prs', `10@${waveAsPr(fx, nonce, '10', offBase)}`, '--dry-run']); + expect([foreign.status, foreign.err]).toEqual([EXIT.refused, expect.stringContaining('(QUEUE_WAVE_MOVED')]); + waveAsPr(fx, nonce); + fx.write(d => { (d.prs['2'] as any).headRefOid = heads[0]; }); + const moved = queue(fx, ['train', 'build', '--prs', `10@${head}`, '--dry-run']); + expect([moved.status, moved.err]).toEqual([EXIT.refused, expect.stringContaining('#2 moved')]); + expect(moved.err).toContain('(QUEUE_WAVE_MOVED'); + fx.write(d => { (d.prs['10'] as any).headRefName = 'wave/nope-000000000000'; }); + const unknown = queue(fx, ['train', 'build', '--prs', `10@${head}`, '--dry-run']); + expect([unknown.status, unknown.err]).toEqual([EXIT.refused, expect.stringContaining('(QUEUE_WAVE_UNKNOWN')]); + }, 180_000); + + test('ready on the wave PR adds WAVE and CONSTITUENTS to the READY block and refuses once a constituent moves', () => { + const fx = make(); + const heads = threePrs(fx); + const nonce = nonceOf(queue(fx, ['wave', 'build', '--prs', heads.map((h, i) => `${i + 1}@${h}`).join(',')]).out); + const head = waveAsPr(fx, nonce); + fx.repo.git('checkout', '-q', '-B', 'wave-local', head); + fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; }); + const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim(); + const pregate = path.join(fx.repo.root, 'pregate.json'); + fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] })); + const ok = queue(fx, ['ready', '--pr', '10', '--pregate', pregate, '--no-screen']); + expect(ok.status).toBe(EXIT.ok); + expect(line(ok.out, 'WAVE:')).toContain(`WAVE: ${nonce} area=`); + expect(line(ok.out, 'CONSTITUENTS:')).toContain('(heads frozen; fixes go on the wave branch)'); + fx.write(d => { (d.prs['3'] as any).headRefOid = heads[0]; }); + const moved = queue(fx, ['ready', '--pr', '10', '--pregate', pregate, '--no-screen']); + expect([moved.status, moved.err]).toEqual([EXIT.refused, expect.stringContaining('(QUEUE_WAVE_MOVED')]); + }, 180_000); + + test('train build handed more than 6 pins prints WAVE_ADVISED, and wave plan groups the live READY PRs', () => { + const fx = make(); + const pins = ['1', '2', '3', '4', '5', '6', '7'].map(pr => { + const h = openPr(fx, pr, { file: pr === '7' ? 'docs/seven.md' : `lib/ship-${pr}.ts`, changelog: false }); + readyReceipt(fx, pr, h); + return `${pr}@${h}`; + }); + const build = queue(fx, ['train', 'build', '--prs', pins.join(','), '--dry-run']); + expect(line(build.out, 'WAVE_ADVISED:')).toContain('7 READY PRs over the wave trigger 6'); + const plan = queue(fx, ['wave', 'plan']); + expect(plan.status).toBe(EXIT.ok); + expect(line(plan.out, 'QUEUE:')).toContain('7 live READY PR(s)'); + expect(line(plan.out, 'WAVE_GROUP:')).toContain('area=ship prs=#1,#2,#3,#4,#5,#6'); + expect(line(plan.out, 'SOLO:')).toContain('#7'); + expect(line(queue(fx, ['wave', 'plan', '--trigger', '7']).out, 'WAVE:')).toContain('not needed'); + }, 180_000); +}); + +describe('wave settle', () => { + test('closes constituents still at their pins with the link, leaves a moved one open with a comment, and is idempotent', () => { + const fx = make(); + const heads = threePrs(fx); + const nonce = nonceOf(queue(fx, ['wave', 'build', '--prs', heads.map((h, i) => `${i + 1}@${h}`).join(',')]).out); + const head = waveAsPr(fx, nonce); + const early = queue(fx, ['wave', 'settle', '--wave', nonce]); + expect([early.status, early.err]).toEqual([EXIT.refused, expect.stringContaining('(QUEUE_WAVE_NOT_MERGED')]); + const elsewhere = fx.repo.git('commit-tree', `${head}^{tree}`, '-p', head, '-m', 'merged into staging'); + fx.repo.git('push', '-q', 'origin', `${elsewhere}:refs/heads/staging`); + fx.write(d => { Object.assign(d.prs['10'] as any, { state: 'MERGED', baseRefName: 'staging', mergeCommit: { oid: elsewhere } }); }); + const staging = queue(fx, ['wave', 'settle', '--wave', nonce]); + expect([staging.status, staging.err]).toEqual([EXIT.refused, expect.stringContaining('(QUEUE_WAVE_NOT_MERGED')]); + expect((fx.read().prs as any)['1'].state).toBe('OPEN'); + fx.write(d => { Object.assign(d.prs['10'] as any, { state: 'OPEN', baseRefName: 'main', mergeCommit: null }); }); + const s1 = fx.repo.git('commit-tree', `${head}^{tree}`, '-p', head, '-p', 'origin/main', '-m', 'stamp 1'); + const s2 = waveAsPr(fx, nonce, '10', fx.repo.git('commit-tree', `${head}^{tree}`, '-p', s1, '-p', 'origin/main', '-m', 'stamp 2')); + const merge = fx.repo.git('commit-tree', `${s2}^{tree}`, '-p', 'origin/main', '-m', 'wave (#10)'); + fx.repo.git('push', '-q', 'origin', `${merge}:refs/heads/main`); + fx.write(d => { Object.assign(d.prs['10'] as any, { state: 'MERGED', mergeCommit: { oid: merge } }); (d.prs['2'] as any).headRefOid = heads[0]; }); + const dry = queue(fx, ['wave', 'settle', '--wave', nonce, '--dry-run']); + expect(dry.out).toContain('DRY-RUN: nothing commented or closed'); + expect((fx.read().prs as any)['1'].state).toBe('OPEN'); + const r = queue(fx, ['wave', 'settle', '--wave', nonce]); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toContain('SETTLED: #1 landed-closed'); + expect(r.out).toContain('SETTLED: #2 moved-left-open'); + const prs = fx.read().prs as Record; + expect([prs['1'].state, prs['2'].state, prs['3'].state]).toEqual(['CLOSED', 'OPEN', 'CLOSED']); + expect(prs['1'].comments.at(-1).body).toContain('Landed in wave #10 as v1.2.3.0'); + expect(prs['2'].comments.at(-1).body).toContain('did not land'); + const count = prs['2'].comments.length; + const again = queue(fx, ['wave', 'settle', '--wave', nonce]); + expect(again.out).toContain('SETTLED: #1 skipped (closed)'); + expect((fx.read().prs as any)['2'].comments.length).toBe(count); + expect(waveJson(fx, nonce).state).toBe('settled'); + expect(waveJson(fx, nonce).settled.map((s: any) => s.pr).sort()).toEqual(['1', '2', '3']); + }, 180_000); + + test('settles a wave the train merged directly onto a main that moved after the build, and still refuses a merge that lost the wave', () => { + const fx = make(); + const heads = threePrs(fx); + const nonce = nonceOf(queue(fx, ['wave', 'build', '--prs', heads.map((h, i) => `${i + 1}@${h}`).join(',')]).out); + const head = waveAsPr(fx, nonce); + const other = openPr(fx, '4', { file: 'lib/ship-4.ts', changelog: false }); + const moved = fx.repo.git('commit-tree', `${other}^{tree}`, '-p', 'origin/main', '-m', 'an earlier slot (#4)'); + const lost = fx.repo.git('commit-tree', `${moved}^{tree}`, '-p', moved, '-m', 'wave (#10) without its content'); + fx.repo.git('push', '-q', 'origin', `${lost}:refs/heads/main`); + fx.write(d => { Object.assign(d.prs['10'] as any, { state: 'MERGED', baseRefName: 'main', mergeCommit: { oid: lost } }); }); + const refused = queue(fx, ['wave', 'settle', '--wave', nonce]); + expect([refused.status, refused.err]).toEqual([EXIT.refused, expect.stringContaining('(QUEUE_WAVE_NOT_MERGED')]); + const tree = fx.repo.git('merge-tree', '--write-tree', moved, head).split('\n')[0]!; + const squash = fx.repo.git('commit-tree', tree, '-p', moved, '-m', 'wave (#10)'); + fx.repo.git('push', '-q', '-f', 'origin', `${squash}:refs/heads/main`); + fx.write(d => { Object.assign(d.prs['10'] as any, { mergeCommit: { oid: squash } }); }); + const r = queue(fx, ['wave', 'settle', '--wave', nonce]); + expect([r.status, r.err]).toEqual([EXIT.ok, '']); + expect(r.out).toContain('SETTLED: #1 landed-closed'); + const prs = fx.read().prs as Record; + expect([prs['1'].state, prs['2'].state, prs['3'].state]).toEqual(['CLOSED', 'CLOSED', 'CLOSED']); + }, 180_000); +}); + +test('wave with no subcommand prints the wave help and exits usage', () => { + const fx = make(); + const r = queue(fx, ['wave']); + expect([r.status, r.out]).toEqual([EXIT.usage, WAVE_HELP + '\n']); +}, 60_000);