v1.92.12.0 feat: wave PRs land 3 to 6 queued PRs as one train slot (gstack-ship-queue wave) (GST-574) (#3772)

* ship-queue: wave PRs — group 3-6 same-area READY PRs into one train slot (GST-574)

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

* ship-queue wave: review fixes — stamped wave re-READY, constituent double-land, drop dry-run, settle names hand-resolved merges, one settled row per PR, ready refuses an unreadable PR

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

* ship-queue wave: release a wave (drop under --min, or --release), and accept stacked train stamps after the last constituent

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

* ship-queue wave: Step 11 fixes — settle binds to a merge on the base, drop fetches pins first, close re-reads the head, drop and release wait for the wave PR to close, stacked constituents refused, conflict worktree derived from the nonce

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

* docs: wave PRs — drop/release wait for a closed wave PR, settle's base checks, more QUEUE_WAVE_* cases

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

* ship-queue wave: DROP line and QUEUE_WAVE_MOVED text match the closed-PR rule

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

* changelog: wave PRs fragment covers release, stacked-PR refusal and settle checks

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

* captain: keep the skill generic and under its context budget (wave paragraph trimmed)

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

* fix(wave): settle a wave PR the train merged directly onto a moved main

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

* docs(wave): settle accepts a wave PR the train merged directly

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-01 05:29:22 +00:00
1 parent 71acd83aa6
commit 44fb9376e4
20 files changed
+1260 -14

No files matched your search

+14
View File
@@ -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 <n>@<sha>,...`** makes the integrator branch `wave/<nonce>`. 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 <pr>` 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 <nonce>`** 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.
+1 -1
View File
@@ -1 +1 @@
1.92.11.0
1.92.12.0
+1 -1
View File
@@ -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,
+7
View File
@@ -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' ;;
+4
View File
@@ -18,6 +18,7 @@
* gstack-ship-queue land --pr <n> --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 <branch> (default origin/HEAD, else main) and
* ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=<path>.
@@ -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 <branch>: 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<number> {
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');
+7 -4
View File
@@ -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
#<pr>`), 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
+7 -4
View File
@@ -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
#<pr>`), 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
+1
View File
@@ -17,6 +17,7 @@ Homes are paths in the gstack checkout the lane checked at session start.
| `REQUIRED_CHECKS` | `<check names that must be green at a READY head>` |
| `FULL_GATE_CHECK` | `<the check the train gate publishes>` |
| `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` | `<squash \| merge>` |
| `STAMP_FILES` | `<VERSION, package.json, CHANGELOG.md, …>` |
| `KNOWN_INFRA_FLAKES` | `<named jobs a lane may rerun once per PR, or none>` |
+8
View File
@@ -20,6 +20,14 @@ SHIP_RECEIPT: pr=<n> head=<sha12> version=- base=<BASE> tree=<tree> session_kind
READY_RECEIPT: <path the tool wrote> (<local and notes-ref detail>)
```
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: <nonce> area=<area> branch=wave/<nonce> constituents=<k> integrator_commits=<i>
CONSTITUENTS: #<n>@<sha12>,#<n>@<sha12>,... (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/<slug>.md`, never an `[Unreleased]` section or a version, and the
controller merges with `train land` or `land`, so no slot call follows this block.
+12
View File
@@ -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 <nonce> --drop <pr>`) 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`
+82
View File
@@ -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.
<a id="wave-prs"></a>
### 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@<sha>,42@<sha>,43@<sha>
gstack-ship-queue wave build --wave <nonce> --continue # after resolving a conflict in the printed worktree
gstack-ship-queue wave build --wave <nonce> --drop 42 # rebuild without #42 as a new wave
gstack-ship-queue wave build --wave <nonce> --release # end the wave unlanded; its PRs ride alone
gstack-ship-queue wave status [--wave <nonce>]
gstack-ship-queue wave settle --wave <nonce> [--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/<nonce>` 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 <pr>` 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 <wave pr>` and `train build` read the
wave record for a `wave/<nonce>` 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.
<a id="ship-before-ready"></a>
**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,
+85
View File
@@ -4932,6 +4932,91 @@ trains and other trains' merges are refused until a repair lands.
<n>@<sha>`). 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.
<a id="queue-wave-conflict"></a>
### `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 <worktree> commit
--no-edit`, then `gstack-ship-queue wave build --wave <nonce> --continue`. Or
build the wave without that PR.
<a id="queue-wave-moved"></a>
### `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 <nonce> --drop <pr>`, 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.
<a id="queue-wave-duplicate"></a>
### `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.
<a id="queue-wave-size"></a>
### `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.
<a id="queue-wave-unknown"></a>
### `QUEUE_WAVE_UNKNOWN`
**Meaning.** A PR's head branch is `wave/<nonce>`, 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`.
<a id="queue-wave-dependent"></a>
### `QUEUE_WAVE_DEPENDENT`
**Meaning.** `wave build --drop <pr>` 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.
<a id="queue-wave-not-merged"></a>
### `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 <nonce>`.
<a id="queue-receipt-missing"></a>
### `QUEUE_RECEIPT_MISSING`
+36
View File
@@ -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 <n>@<sha>); 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 <nonce> --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 <nonce> --drop <pr>) 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/<nonce> 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 <nonce>',
},
// 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',
+6 -1
View File
@@ -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<Tr
const baseSha = git(ctx.repoRoot, ['rev-parse', `${env.remote}/${ctx.base}`]).out;
const kind = classifyTrainHead({ repoRoot: ctx.repoRoot, baseRef: ctx.base, head });
if (kind.kind === 'invalid') return outRefused({ code: 'QUEUE_NOT_READY', message: `the Version Gate refuses ${head.slice(0, 12)}: ${kind.reason}` }, base.lines, `fix the head, push, then ${Q} ready --pr ${o.pr} --screen|--no-screen`, 'none');
const info = prInfo(ctx, o.pr);
if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, base.lines, `${Q} ready --pr ${o.pr} --screen|--no-screen once gh can read the PR`, 'none');
const wave = checkWavePin(env, o.pr, info);
if (wave && 'failure' in wave) return outRefused(wave.failure, base.lines, `${Q} wave status (rebuild the wave without a moved constituent: ${Q} wave build --wave <nonce> --drop <pr>)`, '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
+32 -1
View File
@@ -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/<nonce>/t<i> (or s<i>) 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 /<owner>/<repo> (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<ReadyReceipt>(readyPath(key, pr, head), 'ready'));
export const activeFreeze = (repoKeys: RepoKeys): FreezeRecord | null => firstOf(repoKeys, key => { const f = readJson<FreezeRecord>(freezePath(key), 'freeze'); return f && !f.cleared_at ? f : null; });
export const loadWave = (nonce: string) => (/^[A-Za-z0-9._-]{1,128}$/.test(nonce) ? readJson<WaveRecord>(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;
+11 -1
View File
@@ -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 <pins with each PR once>`, '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}`) }));
+624
View File
@@ -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/<nonce>: 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) => `<!-- gstack-wave:${nonce}:${kind} -->`;
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/<dir>/…, 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<string, number>();
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<string, string>();
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<string, PlanPr[]>();
for (const p of sorted) clusters.set(find(p.pr), [...(clusters.get(find(p.pr)) ?? []), p]);
const byArea = new Map<string, PlanPr[][]>();
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/<nonce> 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<string, ReadyReceipt>();
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<string, string>();
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 <pins>`, '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 <pins>`, '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<string, string>();
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 <pins with #${p.pr}@${short(info.headRefOid)}>`, '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 <wave 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 <pr>`, 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 <n> --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 <nonce>`, '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 <n>] [--min <n>] [--max <n>]
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 <n>@<sha>,... [--area <name>] [--min <n>] [--max <n>] [--dry-run]
the integrator branch wave/<nonce>: 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 <nonce> [--continue | --drop <pr> | --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 <nonce>] records, the branch chain, each constituent's GitHub head
wave settle --wave <nonce> [--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 <wave 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<number> {
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 <nonce>');
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 <n>@<sha>,... or --wave <nonce>');
if (wave && flag(rest, '--prs')) return usage('pass --prs for a new wave or --wave <nonce> for an existing one, not both');
if ((drop !== undefined || cont || releaseIt) && !wave) return usage('--continue, --drop and --release need --wave <nonce>');
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;
}
}
+1 -1
View File
@@ -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",
+3
View File
@@ -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';
}
+318
View File
@@ -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 <wave 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<string, any>;
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<string, any>;
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<string, any>;
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<string, any>;
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);