From 7fc73e889244acc7ef9062e4edaaee6186ebb2c8 Mon Sep 17 00:00:00 2001 From: garrytan Date: Fri, 16 Oct 2026 23:35:07 +0000 Subject: [PATCH] docs(ship-policy): Run a shared merge queue; new keys and compatibility rule; troubleshooting anchors; /ship pointer Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-doctor-components.sh | 7 ++ docs/ship-policy.md | 129 +++++++++++++++++++++++++++++++- docs/troubleshooting.md | 97 ++++++++++++++++++++++++ ship/SKILL.md | 5 ++ ship/SKILL.md.tmpl | 5 ++ 5 files changed, 242 insertions(+), 1 deletion(-) diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 2399f6a84..5400f942d 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -213,6 +213,13 @@ gstack_result_anchor() { BASH_UNAVAILABLE) printf '%s' 'bash-unavailable' ;; IOS_LAUNCH_ENV_INVALID) printf '%s' 'ios-launch-env-invalid' ;; OUTSIDE_VOICE_UNAVAILABLE) printf '%s' 'outside-voice-unavailable' ;; + POLICY_GSTACK_TOO_OLD) printf '%s' 'policy-gstack-too-old' ;; + QUEUE_NOT_READY) printf '%s' 'queue-not-ready' ;; + QUEUE_PROOF_MISSING) printf '%s' 'queue-proof-missing' ;; + QUEUE_STAMP_NOT_STAMP_ONLY) printf '%s' 'queue-stamp-not-stamp-only' ;; + QUEUE_VERIFY_MISMATCH) printf '%s' 'queue-verify-mismatch' ;; + QUEUE_GATE_MISSING) printf '%s' 'queue-gate-missing' ;; + GATE_AHEAD_ATTEMPT_INVALID) printf '%s' 'gate-ahead-attempt-invalid' ;; *) return 1 ;; esac } diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 10bc6eb9c..be02b863c 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -18,6 +18,7 @@ introduces or changes the policy runs under the reviewed bootstrap path Tools: `gstack-ship-policy init|validate|show-effective|check-history`, `gstack-restamp`, `gstack-tree-receipt`, `gstack-ship-receipt`, +`gstack-ship-queue` ([shared merge queue](#run-a-shared-merge-queue)), `gstack-evidence bundle|ancestor`. They share the exit table (`0` ok, `1` fail, `2` usage, `3` refused or needs a flag) and end every error line with a code from [troubleshooting.md](troubleshooting.md#queue-aware-ship-policy-restamp-tree-receipt-ship-receipt). @@ -27,7 +28,14 @@ code from [troubleshooting.md](troubleshooting.md#queue-aware-ship-policy-restam `gstack-ship-policy init` writes every key at its default; `init --explain` prints this table. JSON carries no comments, so examples live in -`.gstack/ship-policy.example.json`. Unknown keys are errors. +`.gstack/ship-policy.example.json`. Unknown keys are errors, and there is no +extension mechanism: a key from a newer gstack, or a misspelled one, fails +closed (`POLICY_INVALID`) instead of being ignored. Every key has an +introducing version (`init --explain` and `validate` print it). A key newer +than 1.91.98.0 set to a non-default value requires `min_gstack_version` at +least at its introducing version, and a gstack older than `min_gstack_version` +refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with +`MIN_READER: gstack `, the floor every lane reading the policy needs. | Key | Values | Default | Meaning | |---|---|---|---| @@ -47,6 +55,9 @@ prints this table. JSON carries no comments, so examples live in | `stamp_patterns` | literal templates | `[]` | At most 16 templates of at most 128 characters, each with exactly one `{version}` slot (`"version": "{version}"`, `since: '{version}'`, ``); matched literally at a version boundary, never as a regex. The restamp writer and the tree receipt use them beside the built-in forms. | | `restamp_command` | command or `null` | `null` | The repo's own restamp, run first in the staging worktree; see [restamp_command](#restamp-command). | | `migrations` | `{ dir, id }` or `null` | `null` | The migration directory and a regex with exactly one capture group for the number in a file name (`"^v(\\d+)-"`); `gstack-pregate regate` reports `MIGRATION_COLLISION`. | +| `gate_command` | command or `null` | `null` | The repo's full pre-merge gate. `gstack-ship-queue gate-ahead` and `ready` print it for the caller to run (in the background or in CI); gstack never executes it. Needs `min_gstack_version`. | +| `full_e2e` | `true` \| `false` | `false` | `true`: every merge needs a passing full-gate result bound to a tree equal to the merging one modulo stamps; `gstack-ship-queue stamp` and `verify` refuse without one (`QUEUE_GATE_MISSING`). Needs `min_gstack_version`. | +| `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. | Containment, checked by `validate` and again at write time: paths and globs are repo-relative with no `..` segment, under 256 characters, at most 64 per list, @@ -125,6 +136,122 @@ Until this policy is on `origin/main`, `gstack-restamp --next` reads the base's `bump: ask` and falls back to a MICRO bump; pass `--bump patch` on the first PR stamped after the flip if its base predates it. + +## Run a shared merge queue + +`gstack-ship-queue` strings `gstack-restamp`, `gstack-tree-receipt` and the +ship receipt into the coordinator protocol, so nobody proves a stamp diff by +hand. Lanes own their PRs and never merge; the coordinator owns the order and +every merge. Each step ends in a line the other side acts on, then +`GSTACK_RESULT: skill=ship-queue status=complete|refused run=`. Nothing +here pushes. + +**1. Lane: report ready (unstamped).** After the gate and pregate pass on the +pushed head: + +```bash +gstack-pregate run --allow-repo-commands +git push +gstack-ship-queue ready --pr 42 +``` + +``` +READY #42 STAMP_VERSION=unstamped +pr: https://github.com///pull/42 +head: +version: unstamped +base: origin/main@ +ci: green at (passed=31 failed=0 pending=0 skipped=2) +PREGATE: pass tree= head= checks=6 +gate: command="" full_e2e=no gate-ahead=none +SHIP_RECEIPT: pr=42 head= … +``` + +It refuses (`QUEUE_NOT_READY`) when the working tree has tracked edits, HEAD +is not the PR's pushed head, CI at that exact head is not green, or the pregate +result is not a current pass. Send the block as printed. + +**2. Lane behind another PR: gate ahead.** Gate the tree the queue will have +once the predecessor lands, while it is still open: + +```bash +git fetch origin pull/41/head +gstack-ship-queue gate-ahead --after 41 --pr 42 --predecessor-ref FETCH_HEAD --allow-repo-commands +``` + +prints `GATE_AHEAD: pr=42 attempt= gated= tree= merge_tree= +predecessor=#41@ version=`, the attempt's own `worktree:`, its +`record:` file, `GATE_COMMAND:` (the policy's `gate_command`) and the exact +`next:` line. Run the gate there, in the background or in CI, then bind it: + +```bash +(cd && ) > gate.log 2>&1 +gstack-ship-queue gate-ahead --record --pr 42 --attempt --exit $? --log gate.log +``` + +`GATE_RECORD: pr=42 attempt= exit=0 gated_tree= log_sha256= +current=yes`. The record keeps the log and removes only that attempt's +worktree, and refuses (`GATE_AHEAD_ATTEMPT_INVALID`) when the worktree no +longer holds the gated commit. If the predecessor moves, prepare a new +attempt; attempts never share a worktree, the newest is current, and +recording an older one later prints `current=no (superseded by )`. +`gate-ahead --list --pr 42` shows them all. Send the record file with READY. + +**3. Coordinator: call the slot.** Name the version, the base sha and the +order: "#42 next at 1.2.7.0, base ``, order 41,42". + +**4. Lane: stamp.** A gate-ahead lane first merges `origin/main` (with the +predecessor in it) into its branch, never a rebase. Then: + +```bash +gstack-ship-queue stamp --pr 42 --version 1.2.7.0 --expect-gated \ + --expect-base --expect-order 41,42 --allow-repo-commands +``` + +`` is the head the gate ran on: the pushed head the lane gated, or the +record's `gated=` after a gate-ahead. The preflight refuses before anything is +written unless the tree to stamp equals the gated one modulo stamps, the base +is `` and, under `full_e2e`, a passing gate result is bound to the gated +head. Then it runs the restamp, commits, reruns the tree receipt and prints + +``` +QUEUE_STAMP: 42 1.2.7.0 head= gated= tree=same-modulo-stamps receipt= +``` + +Each stage is journaled in `.gstack/tmp/ship-queue/stamp-42.json`; the +identical command resumes at the first unfinished stage, or returns the stored +`QUEUE_STAMP` when it already finished. A refusal prints +`QUEUE_STAMP_REFUSED: 42 head= committed=no|yes stage=` +and the coded line (`QUEUE_STALE` carries a `recompute:` command). Push the +stamp commit, wait for CI on it, run `ready` again +(`READY #42 STAMP_VERSION=1.2.7.0`) and send it with the +`QUEUE_STAMP` line. + +**5. Coordinator: verify, then merge.** From the coordinator's own clone: + +```bash +gstack-ship-queue verify --pr 42 --gated --receipt --expect-head +gstack-ship-queue verify --pr 42 --record --receipt --expect-head +``` + +It fetches `refs/pull/42/head` and reruns the tree receipt there, so +`QUEUE_STAMP` is never a pasted claim: `VERIFY: 42 1.2.7.0 head= +gated= tree=same-modulo-stamps receipt=`. With a gate-ahead +record, whose gated commit never left the lane's machine, it first recomputes +the merge with `git merge-tree --write-tree ` +and compares it with the recorded merge tree, rebuilds the gated tree from +the recorded stamp patch and compares it with the recorded `gated_tree`, +proves that patch is stamps only, checks the predecessor did not move, and +prints `merge_tree:`, `gated_tree:` and `gate:` lines. The last line is +`MERGE_CONDITION: sha=`: merge with that expected head. Any +`QUEUE_VERIFY_MISMATCH` means do not merge. + +**Rolling out `gate_command` and `full_e2e`.** Commit them (with +`min_gstack_version` at their introducing version, which `init --explain` +prints) only after every lane's gstack is at least that version, the crew's +install floor (`GSTACK_MIN_REVISION`). A lane on an older gstack refuses the +policy by name rather than reading it differently. + ## What `/ship` reads from it diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b87e11067..9f63d7a27 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3334,3 +3334,100 @@ unset, not in bare mode). Then re-run the review. **Expected result.** An `OUTSIDE VOICE (...)` report, or `CODEX SAYS (plan review — outside voice)` when the provider runs. + + +### `POLICY_GSTACK_TOO_OLD` + +**Meaning.** `.gstack/ship-policy.json` on the base branch sets +`min_gstack_version` above the gstack reading it. The policy uses keys this +gstack would read differently or not at all, so every ship-policy reader +(`gstack-restamp`, `gstack-tree-receipt`, `gstack-ship-queue`, +`gstack-ship-policy`) refuses it instead of guessing. A gstack older than +`min_gstack_version` itself sees that key as unknown and fails with +`POLICY_INVALID`, which names the key. + +**Fix.** Upgrade gstack to the version the message names +(`bin/gstack-upgrade`, or rerun the session install), then rerun. A crew lead +raising `min_gstack_version` raises the crew's install floor at the same +time; `gstack-ship-policy validate` prints `MIN_READER:` for exactly that. + + +### `QUEUE_NOT_READY` + +**Meaning.** `gstack-ship-queue ready` will not print a READY block the +coordinator could misread: the working tree has uncommitted tracked changes, +HEAD is not the PR's pushed head (read through `gh pr view`), CI at that +exact head is not green (failed, pending or unreadable), or the pregate result +is missing, failed or recorded on another tree (`PREGATE_STALE`). + +**Fix.** Commit and push, wait for CI on that head, rerun +`gstack-pregate run --allow-repo-commands` when the tree changed, then rerun +`gstack-ship-queue ready --pr `. The block it prints before refusing shows +which line is not yet true. + + +### `QUEUE_PROOF_MISSING` + +**Meaning.** A queue step lacks an input it needs to prove its claim, or an +input does not match: the gated head is not a commit here, HEAD differs from +the gated head beyond stamps (the gate never covered this tree), an earlier +stamp with other inputs is unfinished, the stamp journal's head moved, or a +gate-ahead merge could not be recomputed with `git merge-tree`. Nothing was +written unless the `QUEUE_STAMP_REFUSED:` line says `committed=yes`. + +**Fix.** Supply the named input (fetch the gated head, pass the current base +sha), or regate the tree you mean to stamp. For an unfinished stamp, rerun the +identical command to resume, or revert its files and delete +`.gstack/tmp/ship-queue/stamp-.json`. + + +### `QUEUE_STAMP_NOT_STAMP_ONLY` + +**Meaning.** `gstack-ship-queue stamp` committed the stamp, and the tree +receipt between the gated head and the stamped head found a difference +outside the stamp rules (the residual paths are printed). The gate's result +does not cover that head. The refusal line says `committed=yes `. + +**Fix.** Inspect the residual paths: drop the stray edit and stamp again, or +run the gate on the stamped head. + + +### `QUEUE_VERIFY_MISMATCH` + +**Meaning.** The coordinator's `gstack-ship-queue verify` could not +reproduce the lane's claim from the PR's pushed commits: the head moved, the +head differs from the gated tree beyond stamps, the receipt digest differs +from the one in `QUEUE_STAMP`, or, for a gate-ahead record, `git merge-tree +--write-tree` gives another merge tree, the recorded stamp patch does not +rebuild the recorded gated tree or changes more than stamps, or the +predecessor PR moved after the gate. + +**Fix.** Do not merge. Ask the lane to rerun `gstack-ship-queue stamp` (or +`gate-ahead`) on the current queue and report again. + + +### `QUEUE_GATE_MISSING` + +**Meaning.** The ship policy sets `full_e2e: true`, and no passing full-gate +result is bound to the tree being stamped or verified: no recorded gate-ahead +attempt for that gated head, no `--gate-exit 0 --gate-log ` from a run +on it, or the recorded run exited non-zero. + +**Fix.** Run the gate the policy's `gate_command` names in the gate-ahead +worktree and bind it with `gstack-ship-queue gate-ahead --record`, or pass the +exit code and log of a passing run on the gated head to `stamp`. + + +### `GATE_AHEAD_ATTEMPT_INVALID` + +**Meaning.** `gstack-ship-queue gate-ahead --record` could not bind the gate +result: the attempt id is unknown for that PR, the attempt already holds a +different result, the log file is missing, or the attempt's worktree no longer +holds the gated commit (its HEAD moved or a tracked file changed), so the +result would not describe the gated tree. + +**Fix.** `gstack-ship-queue gate-ahead --list --pr ` shows every attempt +and the current one. Record the live attempt, or prepare a new one with +`gate-ahead --after ` and run the gate there without editing tracked +files. + diff --git a/ship/SKILL.md b/ship/SKILL.md index fcc7d06ae..d3b21dd0b 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -770,6 +770,11 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. `ship: queue_mode stamp-at-merge` replaces items 1–4. +**Shared merge queue:** when a coordinator runs the queue, use +`~/.claude/skills/gstack/bin/gstack-ship-queue ready | gate-ahead | stamp` +(docs/ship-policy.md#run-a-shared-merge-queue): stamp only when your slot is +called, and send the printed READY and `QUEUE_STAMP:` lines. + 1. **Classify state** — pure reader, never writes: ```bash bun run ~/.claude/skills/gstack/bin/gstack-version-bump classify --base diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 68021885b..da69f0768 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -235,6 +235,11 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. `ship: queue_mode stamp-at-merge` replaces items 1–4. +**Shared merge queue:** when a coordinator runs the queue, use +`~/.claude/skills/gstack/bin/gstack-ship-queue ready | gate-ahead | stamp` +(docs/ship-policy.md#run-a-shared-merge-queue): stamp only when your slot is +called, and send the printed READY and `QUEUE_STAMP:` lines. + 1. **Classify state** — pure reader, never writes: ```bash bun run ~/.claude/skills/gstack/bin/gstack-version-bump classify --base