From a563f476793b61edbb064b853d52cb125a1996c4 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 17:50:24 -0800 Subject: [PATCH 1/6] v1.92.51.0 ci: windows-smoke is the required Windows check; the six-shard suite runs nightly (GSTA-203 PR 1) (#3806) * ci: windows-smoke becomes the required Windows check; the six-shard Windows suite runs nightly on main (GSTA-203 PR 1, R50) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (dispatch-ci skips a workflow the branch predates; harness decision; changelog fragment) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CONTRIBUTING names windows-smoke; TODOS for the round-2 review notes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .github/workflows/free-durations-record.yml | 4 +- .github/workflows/windows-free-tests.yml | 38 +++-- .github/workflows/windows-setup-e2e.yml | 12 +- .github/workflows/windows-smoke.yml | 170 ++++++++++++++++++++ .gstack/pregate.json | 3 +- .gstack/ship-policy.json | 2 +- AGENTS.md | 8 +- CHANGELOG.md | 16 ++ CONTRIBUTING.md | 6 +- TODOS.md | 6 + VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/TESTING_INTERNALS.md | 9 +- docs/pregate.md | 4 +- docs/ship-policy.md | 20 +-- lib/ship-queue-dispatch-ci.ts | 11 +- lib/ship-queue-train-cli.ts | 2 +- lib/ship-queue.ts | 2 +- package.json | 2 +- scripts/harness-version.json | 2 +- scripts/test-health-report.ts | 6 +- scripts/test-pr-profile.ts | 1 + test/ci-reuse-workflow-wiring.test.ts | 8 +- test/free-durations-record-workflow.test.ts | 2 +- test/gstack-ship-queue.test.ts | 4 +- test/ship-queue-train-ops.test.ts | 27 ++++ test/test-health-report.test.ts | 10 +- test/windows-native-workflows.test.ts | 53 +++++- 28 files changed, 356 insertions(+), 76 deletions(-) create mode 100644 .github/workflows/windows-smoke.yml diff --git a/.github/workflows/free-durations-record.yml b/.github/workflows/free-durations-record.yml index 8faa4598f..85749eea0 100644 --- a/.github/workflows/free-durations-record.yml +++ b/.github/workflows/free-durations-record.yml @@ -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" diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index 33e896c42..6146e927d 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -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: | diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml index 6b2b928fe..2ca74e7b4 100644 --- a/.github/workflows/windows-setup-e2e.yml +++ b/.github/workflows/windows-setup-e2e.yml @@ -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: | diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml new file mode 100644 index 000000000..525f50553 --- /dev/null +++ b/.github/workflows/windows-smoke.yml @@ -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("gstack smoke

gstack-windows-smoke-ok

", { 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 diff --git a/.gstack/pregate.json b/.gstack/pregate.json index c424957cc..74a99f1ad 100644 --- a/.gstack/pregate.json +++ b/.gstack/pregate.json @@ -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" }, diff --git a/.gstack/ship-policy.json b/.gstack/ship-policy.json index 2ae73849b..a37b11ca4 100644 --- a/.gstack/ship-policy.json +++ b/.gstack/ship-policy.json @@ -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" } diff --git a/AGENTS.md b/AGENTS.md index febab17af..1b724461b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 33db702f7..0b195eea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6132d56ac..634968ea6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 -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 -f 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 | — | diff --git a/TODOS.md b/TODOS.md index a95320ccd..eb40e432b 100644 --- a/TODOS.md +++ b/TODOS.md @@ -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. diff --git a/VERSION b/VERSION index 89ada282a..fdd84fc4e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.50.0 +1.92.51.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index beefb3031..306c1f587 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.50.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.51.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md index caa047811..ea9a9f4ca 100644 --- a/docs/TESTING_INTERNALS.md +++ b/docs/TESTING_INTERNALS.md @@ -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 diff --git a/docs/pregate.md b/docs/pregate.md index 057788ade..def98ef6a 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -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. ## Guards and the lane-done rule diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 4e0488229..0d3fc4493 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -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 ` 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 ]` (under a train policy): it dispatches each named workflow on the branch (`gh workflow run --ref `, skipped when a run at this head already -concluded), waits for the runs (`CI_DISPATCH: --ref run -`), clears every requires-remote pregate lane whose workflow is one -of them with the run URL (`PREGATE_CLEARED: `; 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: --ref run `), clears +every requires-remote pregate lane whose workflow is one of them with the run +URL (`PREGATE_CLEARED: `; 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 --ref `, wait, `gstack-pregate clear --remote --run-url diff --git a/lib/ship-queue-dispatch-ci.ts b/lib/ship-queue-dispatch-ci.ts index 0c496ab8c..d099b75c7 100644 --- a/lib/ship-queue-dispatch-ci.ts +++ b/lib/ship-queue-dispatch-ci.ts @@ -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'})` } }; diff --git a/lib/ship-queue-train-cli.ts b/lib/ship-queue-train-cli.ts index fe561b3c9..a4ce4e5a6 100644 --- a/lib/ship-queue-train-cli.ts +++ b/lib/ship-queue-train-cli.ts @@ -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'); diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index ae3925ece..09471df27 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -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 '; 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 --run-url ), 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 --run-url ), 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; } diff --git a/package.json b/package.json index 59700dfe3..56d0c665c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.50", + "version": "1.92.51", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/harness-version.json b/scripts/harness-version.json index 99304ffaf..1e091511b 100644 --- a/scripts/harness-version.json +++ b/scripts/harness-version.json @@ -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}, diff --git a/scripts/test-health-report.ts b/scripts/test-health-report.ts index 8d9e55168..7d89e60c9 100644 --- a/scripts/test-health-report.ts +++ b/scripts/test-health-report.ts @@ -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)', () => { diff --git a/scripts/test-pr-profile.ts b/scripts/test-pr-profile.ts index 911e393d0..25e2d88dd 100644 --- a/scripts/test-pr-profile.ts +++ b/scripts/test-pr-profile.ts @@ -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; /** diff --git a/test/ci-reuse-workflow-wiring.test.ts b/test/ci-reuse-workflow-wiring.test.ts index 0ad60e306..6d9b3b8d5 100644 --- a/test/ci-reuse-workflow-wiring.test.ts +++ b/test/ci-reuse-workflow-wiring.test.ts @@ -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; @@ -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', () => { diff --git a/test/free-durations-record-workflow.test.ts b/test/free-durations-record-workflow.test.ts index 2ba947cbf..85f15f216 100644 --- a/test/free-durations-record-workflow.test.ts +++ b/test/free-durations-record-workflow.test.ts @@ -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'); } diff --git a/test/gstack-ship-queue.test.ts b/test/gstack-ship-queue.test.ts index 9b9248631..fab39a52a 100644 --- a/test/gstack-ship-queue.test.ts +++ b/test/gstack-ship-queue.test.ts @@ -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 --run-url ), 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 --run-url ), 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 && gh workflow run windows-free-tests.yml --ref '); + expect(line(odd.r.out, 'ci-hint: ')).toContain('gh workflow run free-tests.yml --ref && gh workflow run windows-smoke.yml --ref '); } 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)`); diff --git a/test/ship-queue-train-ops.test.ts b/test/ship-queue-train-ops.test.ts index 61df3d0dd..c20468fb8 100644 --- a/test/ship-queue-train-ops.test.ts +++ b/test/ship-queue-train-ops.test.ts @@ -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', () => { diff --git a/test/test-health-report.test.ts b/test/test-health-report.test.ts index 0d654dba8..41f7d2736 100644 --- a/test/test-health-report.test.ts +++ b/test/test-health-report.test.ts @@ -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 = {}) => 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', () => { diff --git a/test/windows-native-workflows.test.ts b/test/windows-native-workflows.test.ts index b35bbe6c2..48ce50d9d 100644 --- a/test/windows-native-workflows.test.ts +++ b/test/windows-native-workflows.test.ts @@ -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; + 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(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@')); From dac71371a29256835c2f436b734c8ff9e1d26674 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 19:01:48 -0800 Subject: [PATCH 2/6] v1.92.52.0 ship: the parent runs Step 9.2.1's QA probes; the pass refuses a QA reader (GST-450) (#3686) * fix(ship): the parent runs Step 9.2.1 exploratory QA itself, as its section says; the pass program refuses a QA extra (GST-450) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship driver: trim the 9.2.1 lines so the #3670 merge stays under 12 KB Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (Step 11) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (pass 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: GST-450 changelog says the parent ran the probes after the refusal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(review-policy): the pass stage holds audit readers only; a QA extra is refused (GST-450) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: the Capy skill lists Steps 7, 8 and 14.5 as extras after the merge of main Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 +++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- capy/skill/gstack/SKILL.md | 2 +- docs/review-policy.md | 5 ++-- lib/review-pass.ts | 10 ++++++-- package.json | 2 +- ship/DRIVER.md | 10 ++++---- ship/DRIVER.md.tmpl | 10 ++++---- test/review-pass.test.ts | 42 ++++++++++++++++++++++++++-------- test/ship-driver.test.ts | 25 ++++++++++++++++++-- 11 files changed, 89 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b195eea2..bbb8e5a32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [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. diff --git a/VERSION b/VERSION index fdd84fc4e..f38f1222d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.51.0 +1.92.52.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 306c1f587..513868411 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.51.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.52.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 7c87a2786..c6d85c010 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -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 `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_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 --credits `, 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: ". '' && 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: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", 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: ""`. 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 ` 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: ". '' && 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: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", 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: ""`. 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 ` on your jam's transcript since /ship began. A small waiting child saves nothing. ## Plan, approve, ship diff --git a/docs/review-policy.md b/docs/review-policy.md index 1d62b14de..1d4e3612b 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -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 --out --pass -- ` runs one pass from a staged manifest (`/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 diff --git a/lib/review-pass.ts b/lib/review-pass.ts index 29928d8e9..365069720 100644 --- a/lib/review-pass.ts +++ b/lib/review-pass.ts @@ -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'); diff --git a/package.json b/package.json index 56d0c665c..48e84b84b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.51", + "version": "1.92.52", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/ship/DRIVER.md b/ship/DRIVER.md index 3dec39134..ac19dd0bd 100644 --- a/ship/DRIVER.md +++ b/ship/DRIVER.md @@ -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 diff --git a/ship/DRIVER.md.tmpl b/ship/DRIVER.md.tmpl index d4a874ae6..0298fb93e 100644 --- a/ship/DRIVER.md.tmpl +++ b/ship/DRIVER.md.tmpl @@ -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 diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index 46f3df0f7..9d440c612 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -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); }); diff --git a/test/ship-driver.test.ts b/test/ship-driver.test.ts index 70cf50cc4..58f9dc471 100644 --- a/test/ship-driver.test.ts +++ b/test/ship-driver.test.ts @@ -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', () => { From 693ee7132a9d1d9f08a90f476aff9762618a67e7 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 19:11:57 -0800 Subject: [PATCH 3/6] v1.92.53.0 fix(ubicloud): reap destroys only the caller's own VMs (GST-573) (#3756) * fix(ubicloud): pack and usage on macOS, empty projects (#3680, #3679, #3678) pack builds its archive in two tar passes plus gzip, which bsdtar and GNU tar both accept, with COPYFILE_DISABLE=1 so Apple's tar adds no ._* files. vm_rows skips the blank row Ubicloud returns for a project with no VMs. usage parenthesizes its ternary for the one-true-awk and exits 1 on an unreadable list or size; the quota preflight logs that and defers to the API. Co-authored-by: Jared <182125291+yolo-jared@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (usage skips a VM destroyed since the list; spawn timeout) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (quota wait retries an unreadable usage) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog fragment for the ubicloud macOS and empty-project fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: TESTING_INTERNALS notes the quota preflight's unreadable-usage path Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ubicloud): reap destroys only the caller's own VMs (GST-573) reap, which every up runs first, destroyed any VM in the project whose TTL had ended, whatever owner created it. That broke the owner-scoped cloud-deletes rule. It now destroys only the caller's own expired and old TTL-less VMs and logs a count of other owners' expired VMs. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ubicloud): trim the owner-scoped reap prose back under the context budget Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: TESTING_INTERNALS says the TTL reaper covers only this owner's VMs Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ubicloud): restore the expiry and reap conditions in the trimmed reap prose Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Jared <182125291+yolo-jared@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/TESTING_INTERNALS.md | 16 +++++----- docs/skills.md | 6 ++-- package.json | 2 +- scripts/ubicloud/ubi-runner.sh | 37 +++++++++++++---------- test/ubicloud-runner-run.test.ts | 1 + test/ubicloud-runner-verify-quota.test.ts | 6 ++-- test/ubicloud-runner.test.ts | 36 +++++++++++++++++----- ubicloud/SKILL.md | 16 +++++----- ubicloud/SKILL.md.tmpl | 16 +++++----- 12 files changed, 91 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb8e5a32..e75ec6b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [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). diff --git a/VERSION b/VERSION index f38f1222d..222a2a9f5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.52.0 +1.92.53.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 513868411..716c0c598 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.52.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.53.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md index ea9a9f4ca..8c913e020 100644 --- a/docs/TESTING_INTERNALS.md +++ b/docs/TESTING_INTERNALS.md @@ -1002,7 +1002,7 @@ with `UBICLOUD: left=<0|1|unknown> owner= vm= others=`, 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