From 4bbad24221ff519d1f02942a4f538f2b3e1d1645 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 04:58:56 -0800 Subject: [PATCH 1/6] v1.92.77.0 wave(github): #3724 #3771 #3833 (#3846) * test-free-shards: --ci-run --jobs k sub-shards inside one CI job (k=1 default), per-file JUnit counts (GSTA-203 PR 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ci): ship ffmpeg in the eval image so the marathon design-motion case can run (#3687) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * shard-jobs: serial sub-shard gets what is left of the shard wall; register shard-jobs.ts in ship-measure touchfiles Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * shard-jobs: validate every sub-shard summary, bounded JUnit read, stricter --ci-verify records, unique count paths, no serial launch without wall Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * shard-jobs tests: non-hex plan id fixture; harness-version: shard-jobs FREE_ONLY entry is non-behavioral Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(test:free): exact root-bound JUnit path matching; serial timeout text names the wall the parallel phase left Adversarial pass 2 (GSTA-203 PR 2): a plain relative path now matches a planned file exactly, and an absolute or ../-prefixed path matches only when its prefix is the child's root, so an unplanned browse/test/x.test.ts is never credited to test/x.test.ts. The serial sub-shard's FREE_SUBSHARD_TIMEOUT text says how much of the shard wall the parallel phase left it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(testing): real-home tripwire covers --jobs k>1 parallel sub-shards (docs audit docs-203-1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(free): per-shard sub-shard k in the CI plan (--shard-jobs), free_jobs and windows_jobs dispatch inputs (k=1 defaults) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(free-tests): 10 x ubicloud-standard-8 at 4 sub-shards per shard (GSTA-203 PR 3 A/B winner); Windows stays at k=1 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (changelog names main's 20 x standard-4 baseline and the unchanged 80 vCPUs) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(testing): sub-shard default is the plan's per-shard k (docs audit docs-pr3-2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(free): run the #3680/#3679 BSD-userland regression on macos-named-regressions Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: macos-named-regressions runs the BSD-userland ubi-runner regression (#3680) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: Windows k=1 applies to the nightly suite since #3806 (docs re-audit after the main merge) 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/docker/Dockerfile.ci | 15 +++++ .github/workflows/free-tests.yml | 35 ++++++++---- .github/workflows/windows-free-tests.yml | 21 ++++++- CHANGELOG.md | 12 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/TESTING_INTERNALS.md | 55 +++++++++++++------ package.json | 2 +- scripts/lib/shard-jobs.ts | 31 +++++++++-- scripts/test-free-shards.ts | 50 ++++++++++++----- test/ci-image-cli-pin.test.ts | 19 +++++++ test/free-tests-workflow-wiring.test.ts | 14 +++-- ...on-issue3680-ubicloud-bsd-userland.test.ts | 10 ++++ test/shard-jobs.test.ts | 55 +++++++++++++++++-- test/windows-native-workflows.test.ts | 36 +++++++++++- 15 files changed, 297 insertions(+), 62 deletions(-) diff --git a/.github/docker/Dockerfile.ci b/.github/docker/Dockerfile.ci index 841291cca..1ed8c693d 100644 --- a/.github/docker/Dockerfile.ci +++ b/.github/docker/Dockerfile.ci @@ -193,6 +193,21 @@ RUN for i in 1 2 3; do \ && fc-cache -f \ && rm -rf /var/lib/apt/lists/* +# ffmpeg (with ffprobe): /design-motion's checks and exports shell out to it +# (lib/motion-encode.ts), and the marathon design-motion-showcase case runs +# that flow end to end. The image had none, so every trial stopped at +# FFMPEG_MISSING (#3687). The build fails if the packaged ffmpeg ever lacks a +# filter or encoder the checks and exports use. +RUN for i in 1 2 3; do \ + apt-get update && apt-get install -y --no-install-recommends ffmpeg && break || \ + (echo "ffmpeg install retry $i/3"; sleep 10); \ + done \ + && rm -rf /var/lib/apt/lists/* \ + && command -v ffprobe \ + && filters="$(ffmpeg -hide_banner -filters)" && encoders="$(ffmpeg -hide_banner -encoders)" \ + && for f in psnr gblur palettegen paletteuse signalstats tile; do echo "$filters" | grep -qw "$f" || { echo "ERROR: ffmpeg lacks the $f filter" >&2; exit 1; }; done \ + && for e in libx264 gif; do echo "$encoders" | grep -qw "$e" || { echo "ERROR: ffmpeg lacks the $e encoder" >&2; exit 1; }; done + # Pre-install dependencies (cached layer — only rebuilds when package.json or # bun.lock changes). Copy BOTH so install is deterministic and matches local # resolution. Without bun.lock here, bun install resolved transitive deps diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml index 966419ad3..0d3cff19e 100644 --- a/.github/workflows/free-tests.yml +++ b/.github/workflows/free-tests.yml @@ -41,13 +41,21 @@ on: description: 'Runner-sizing experiments only: shard count, 1-40 (empty keeps FREE_SUITE_SHARDS)' type: string default: '' + free_jobs: + description: 'Runner-sizing experiments only: sub-shards per CI shard (--shard-jobs), 1-8 (empty keeps FREE_SUITE_JOBS)' + type: string + default: '' -# The one source of the free-suite runner and shard count. pull_request and -# push runs never see workflow_dispatch inputs, so free-plan resolves these -# (or a dispatch override) into outputs that free-suite reads. +# The one source of the free-suite runner, shard count and sub-shards per +# shard. pull_request and push runs never see workflow_dispatch inputs, so +# free-plan resolves these (or a dispatch override) into outputs and the plan +# that free-suite reads. FREE_SUITE_JOBS is k in `--ci-plan --shard-jobs k`: +# each shard runs min(k, its parallel files) sandboxed sub-shards side by side +# (scripts/lib/shard-jobs.ts); the exclusive host-state shard stays one child. env: - FREE_SUITE_RUNNER: ubicloud-standard-4 - FREE_SUITE_SHARDS: '20' + FREE_SUITE_RUNNER: ubicloud-standard-8 + FREE_SUITE_SHARDS: '10' + FREE_SUITE_JOBS: '4' # Keyed on the PR number, not head_ref: a bare branch name carries no fork # prefix, so same-name branches from two forks would share one group and a @@ -120,10 +128,12 @@ jobs: env: RUNNER_OVERRIDE: ${{ inputs.free_runner }} SHARDS_OVERRIDE: ${{ inputs.free_shards }} + JOBS_OVERRIDE: ${{ inputs.free_jobs }} run: | set -euo pipefail runner="${RUNNER_OVERRIDE:-$FREE_SUITE_RUNNER}" shards="${SHARDS_OVERRIDE:-$FREE_SUITE_SHARDS}" + jobs="${JOBS_OVERRIDE:-$FREE_SUITE_JOBS}" case "$runner" in ubicloud-standard-2|ubicloud-standard-4|ubicloud-standard-8) ;; *) echo "::error::free_runner '$runner' is not ubicloud-standard-2, -4 or -8"; exit 1 ;; @@ -132,10 +142,14 @@ jobs: ''|*[!0-9]*) echo "::error::free_shards '$shards' is not a whole number"; exit 1 ;; esac if [ "$shards" -lt 1 ] || [ "$shards" -gt 40 ]; then echo "::error::free_shards $shards is outside 1-40"; exit 1; fi - if [ "$runner" != "$FREE_SUITE_RUNNER" ] || [ "$shards" != "$FREE_SUITE_SHARDS" ]; then - echo "- Runner-sizing experiment: $shards shards on $runner (defaults $FREE_SUITE_SHARDS on $FREE_SUITE_RUNNER); not a PR or train Free Tests result" >> "$GITHUB_STEP_SUMMARY" + case "$jobs" in + ''|*[!0-9]*) echo "::error::free_jobs '$jobs' is not a whole number"; exit 1 ;; + esac + if [ "$jobs" -lt 1 ] || [ "$jobs" -gt 8 ]; then echo "::error::free_jobs $jobs is outside 1-8"; exit 1; fi + if [ "$runner" != "$FREE_SUITE_RUNNER" ] || [ "$shards" != "$FREE_SUITE_SHARDS" ] || [ "$jobs" != "$FREE_SUITE_JOBS" ]; then + echo "- Runner-sizing experiment: $shards shards on $runner at $jobs sub-shards each (defaults $FREE_SUITE_SHARDS on $FREE_SUITE_RUNNER at $FREE_SUITE_JOBS); not a PR or train Free Tests result" >> "$GITHUB_STEP_SUMMARY" fi - matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards "$shards") + matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards "$shards" --shard-jobs "$jobs") echo "matrix=$matrix" >> "$GITHUB_OUTPUT" echo "runner=$runner" >> "$GITHUB_OUTPUT" - name: Seed growth ratchet (60 s ceiling, shrink-only allowlist) @@ -186,7 +200,8 @@ jobs: # code that compares a realpath with an unresolved path passes on Linux's # /tmp and fails here (#2982). TMPDIR is deliberately left at the runner's # default, and the job fails if that layout is not symlinked. It also sources - # the Codex probe from zsh, macOS's default shell (#3024; no paid call). + # the Codex probe from zsh, macOS's default shell (#3024; no paid call), and + # runs ubi-runner under macOS's bsdtar and one-true-awk (#3680, #3679). macos-named-regressions: needs: reuse if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} @@ -229,7 +244,7 @@ jobs: resolved="$(realpath "$tmp")" echo "temp root: $tmp -> $resolved" test "$resolved" != "${tmp%/}" - files=(test/gstack-design-detect.test.ts browse/test/chromium-profile-isolation.test.ts test/cso-git-hardening.test.ts test/plan-count-file-permission.test.ts test/spec-quality-gate-secret-sink.test.ts test/gstack-memory-ingest.test.ts test/regression-issue3024-zsh-sourced-helpers.test.ts test/codex-probe-subcommands.test.ts browse/test/proxy-env-daemon.test.ts browse/test/proxy-direct-launch-args.test.ts) + files=(test/gstack-design-detect.test.ts browse/test/chromium-profile-isolation.test.ts test/cso-git-hardening.test.ts test/plan-count-file-permission.test.ts test/spec-quality-gate-secret-sink.test.ts test/gstack-memory-ingest.test.ts test/regression-issue3024-zsh-sourced-helpers.test.ts test/codex-probe-subcommands.test.ts browse/test/proxy-env-daemon.test.ts browse/test/proxy-direct-launch-args.test.ts test/regression-issue3680-ubicloud-bsd-userland.test.ts) for file in "${files[@]}"; do test -f "$file"; done bun test "${files[@]}" diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index 6146e927d..d94d1d6f2 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -42,6 +42,10 @@ on: description: Record per-file Windows durations (artifact) instead of running the lane type: boolean default: false + windows_jobs: + description: 'Sub-shard experiments only: sub-shards per Windows shard (--shard-jobs), 1-4 (empty keeps WINDOWS_FREE_JOBS). Never cite such a run as a PR result.' + type: string + default: '' # free-durations-record.yml calls the recording path weekly. workflow_call: inputs: @@ -49,6 +53,11 @@ on: type: boolean default: false +# k in `--ci-plan --shard-jobs k`: each Windows shard runs min(k, its parallel +# files) sandboxed sub-shards side by side (scripts/lib/shard-jobs.ts). +env: + WINDOWS_FREE_JOBS: '1' + concurrency: group: windows-free-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: true @@ -105,8 +114,18 @@ jobs: # 4-vCPU Windows runner, so the lane lands near 4 min with setup. env: GSTACK_FREE_TEST_DURATIONS: scripts/free-test-durations-windows.json + JOBS_OVERRIDE: ${{ inputs.windows_jobs }} run: | - matrix=$(bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/windows-plan.json" --shards 6) + set -euo pipefail + jobs="${JOBS_OVERRIDE:-$WINDOWS_FREE_JOBS}" + case "$jobs" in + ''|*[!0-9]*) echo "::error::windows_jobs '$jobs' is not a whole number"; exit 1 ;; + esac + if [ "$jobs" -lt 1 ] || [ "$jobs" -gt 4 ]; then echo "::error::windows_jobs $jobs is outside 1-4"; exit 1; fi + if [ "$jobs" != "$WINDOWS_FREE_JOBS" ]; then + echo "- Sub-shard experiment: $jobs sub-shards per Windows shard (default $WINDOWS_FREE_JOBS); not a PR Windows Free Tests result" >> "$GITHUB_STEP_SUMMARY" + fi + matrix=$(bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/windows-plan.json" --shards 6 --shard-jobs "$jobs") echo "matrix=$matrix" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c95b691a..bf0437f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [1.92.77.0] - 2026-11-02 + +**The weekly marathon evals can run /design-motion again: the CI eval image now ships ffmpeg (#3687).** The `design-motion-showcase` case joined the marathon tier on 2026-10-26, and its first scheduled run (Actions run 41200316994, main @ 088bfc5) failed 0/3. Every trial built and captured the page, then stopped at `FFMPEG_MISSING`, because the image had no ffmpeg and the skill rightly refuses to export before its checks run. The model did nothing wrong; the case could not pass on that image. + +### Fixed +- **`.github/docker/Dockerfile.ci`** installs Ubuntu's `ffmpeg` (6.1.1, with `ffprobe`). The build fails if that ffmpeg lacks a filter or encoder the motion checks and exports use (`psnr`, `gblur`, `palettegen`, `paletteuse`, `signalstats`, `tile`, `libx264`, `gif`). +- **`test/ci-image-cli-pin.test.ts`** fails the free suite if the image stops installing ffmpeg or stops checking it. + +The macOS `macos-named-regressions` CI job now runs `test/regression-issue3680-ubicloud-bsd-userland.test.ts`, so the ubi-runner cases for #3680 (bsdtar) and #3679 (macOS awk) run on a real Mac against `/usr/bin/tar` and `/usr/bin/awk`. Before, the file ran only on Linux CI, where bsdtar and one-true-awk aren't installed, so every case skipped and a regression in either could merge green. A new wiring test fails if the macOS job stops naming the file. + +The Linux free suite now runs on 10 `ubicloud-standard-8` machines, each running its tests as 4 sandboxed sub-shards side by side, instead of main's 20 `ubicloud-standard-4` machines (set by #3710) running one file at a time. Both use 80 vCPUs per run. In a 3-run A/B at one commit against that main setup, the slowest shard dropped from 389–450 s to 224–258 s and the Ubicloud cost per run dropped from about $0.39 to $0.32 at premium prices, with the same per-file test counts and no new failures. The CI plan now stores each shard's sub-shard count (`--ci-plan --shard-jobs k`), keeps the exclusive host-state shard at one child, and `--ci-verify` rejects a shard that ran at a different count. The nightly Windows suite stays at one child per shard, because two sub-shards made Windows file-lock cleanup failures show up in two of three runs. Dispatch-only `free_jobs` and `windows_jobs` inputs exist for future experiments. See docs/TESTING_INTERNALS.md#sub-shards-inside-one-ci-job. + ## [1.92.76.0] - 2026-11-02 **/retro now remembers its history across git worktrees, so a run in a linked worktree compares against last week instead of announcing "First retro recorded".** Retro snapshots used to live in `.context/retros/` inside the working tree. A worktree (Conductor, Claude Code) saw none of the main checkout's history, and its own snapshot was deleted along with the worktree (#3749). diff --git a/VERSION b/VERSION index 730cd3e00..463685b58 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.76.0 +1.92.77.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index f297fb296..ac6c1e65e 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.76.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.77.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 91c400897..441525762 100644 --- a/docs/TESTING_INTERNALS.md +++ b/docs/TESTING_INTERNALS.md @@ -195,15 +195,18 @@ fallback; unknown files get 99th-percentile pessimism, and both full-suite and the long pole. Packed shards get duration-aware walls (`max(base, predicted × 3, files × 5s)`). The legacy `--shards N --shard i` path keeps stable hash indices. Required CI uses -one duration-packed `--ci-plan`, 20 ordinary `--ci-run` shards plus a separate +one duration-packed `--ci-plan`, 10 ordinary `--ci-run` shards plus a separate exclusive-fixture shard, and a `--ci-verify` aggregate. CI shards still run on -independent machines without ordering unrelated jobs. Two workflow-level +independent machines without ordering unrelated jobs. Three workflow-level constants in `free-tests.yml` set that size for every event: `FREE_SUITE_RUNNER` -(`ubicloud-standard-4`) and `FREE_SUITE_SHARDS` (`'20'`), which `free-plan` -resolves into the shards' runner and shard count. A `workflow_dispatch` run may -override them with `free_runner` (`ubicloud-standard-2`, `-4` or `-8`) and -`free_shards` (1–40) for runner-sizing experiments only; its job summary says -so, and it is never cited as a PR or train Free Tests result. Each shard prints +(`ubicloud-standard-8`), `FREE_SUITE_SHARDS` (`'10'`) and `FREE_SUITE_JOBS` +(`'4'`, the [sub-shards](#sub-shards-inside-one-ci-job) each shard runs side by +side), which `free-plan` resolves into the shards' runner, shard count and plan. +GSTA-203 chose them by a 3-run A/B against 20 × `ubicloud-standard-4` at k = 1 +(the PR that set them has the table). A `workflow_dispatch` run may +override them with `free_runner` (`ubicloud-standard-2`, `-4` or `-8`), +`free_shards` (1–40) and `free_jobs` (1–8) for runner-sizing experiments only; +its job summary says so, and it is never cited as a PR or train Free Tests result. Each shard prints `RESOURCES: peak_used_mib=… min_free_disk_mib=… samples=…` from a memory and disk sample every 2 seconds, even when the suite fails. The public `TREE_MUTATING` map now classifies exclusive host-state fixtures; its sole @@ -1553,7 +1556,7 @@ refresh to a maintainer. `scripts/ubicloud/setup-free-suite.sh` and the Windows seed through `windows-free-tests.yml`'s `record_durations` path. The recorder stays on `ubicloud-standard-8` while the free-suite shards run on `FREE_SUITE_RUNNER` - (`ubicloud-standard-4`); both are Ubicloud standard runners. When either recording adds + (`ubicloud-standard-8`); 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 Smoke and Quality gate on it. Those @@ -1575,14 +1578,24 @@ k sub-shards side by side on the same machine (`scripts/lib/shard-jobs.ts`). Eac sub-shard is an ordinary free shard child: one `bun test` process, serial inside, with its own sandbox, process-group kill and log. Bun's worker `--parallel` mode is still not used (decision V3/D6 in -`scripts/test-free-shards.ts`). The default is `k = 1`, which runs today's one -child, so no workflow changes behavior until it passes `--jobs`. +`scripts/test-free-shards.ts`). Without `--jobs`, a shard runs at the plan's +per-shard k (below); a shard with no planned k runs one child (`k = 1`). -- `--jobs` applies only to `--ci-run`. It takes a positive whole number up to the - shard's file count (an empty shard takes 1); anything else stops with - `FREE_JOBS_INVALID`. `GSTACK_FREE_JOBS` sets the local full-suite process count - and never changes `--ci-run`. The exclusive host-state shard has one file, so - a workflow passes it `--jobs 1` or no `--jobs`. +- Workflows set k in the plan, per shard: `--ci-plan --shards N + --shard-jobs k` stores `jobs = min(k, the shard's files that may run in + parallel)` on each shard, and only when that is above 1. The exclusive + host-state shard holds only `TREE_MUTATING` files, so it stays one child, and + a plan made at k = 1 is byte-identical to one made without `--shard-jobs`. + `--ci-run` runs the shard at the plan's k, and `--ci-verify` rejects any + shard result that ran at a different k. `free-tests.yml` reads k from + `FREE_SUITE_JOBS` and `windows-free-tests.yml` from `WINDOWS_FREE_JOBS`; the + `free_jobs` and `windows_jobs` dispatch inputs override them for experiments + only. +- `--jobs` applies only to `--ci-run` and overrides the plan's k, for local + reproduction. It takes a positive whole number up to the shard's file count + (an empty shard takes 1); anything else stops with `FREE_JOBS_INVALID`, as + does a bad `--shard-jobs` or one without `--ci-plan`. `GSTACK_FREE_JOBS` sets + the local full-suite process count and never changes `--ci-run`. - Files are split into disjoint sub-shards packed by the duration seed. Each sub-shard has an id (`:.`) bound to its file list. Files in `TREE_MUTATING` or `SLOW_UNDER_LOAD` run after the parallel @@ -1606,6 +1619,13 @@ reproduce command. If the file passes serially, add it to `SLOW_UNDER_LOAD` in `scripts/lib/shard-jobs.ts` with that run id and a reason. Never raise the timeout. `SLOW_UNDER_LOAD` is empty today. +The nightly Windows lane stays at `WINDOWS_FREE_JOBS: '1'`. In GSTA-203's A/B, k = 2 +made the slowest Windows shard faster, but two of three runs needed the flaky +retry for file-lock cleanup errors (`EBUSY` in +`browse/test/stealth-webdriver.test.ts` and a native fixture cleanup in +`browse/test/cookie-import-native-job.test.ts`), which k = 1 never showed. Fix +those cleanups before raising it. + Every `--ci-run` result, at any k, records per-file test and `expect()` counts in `outcome.counts` (`tests`, `pass`, `fail`, `skip`, `todo`, `expects` per file, plus shard totals). They come from bun's JUnit reporter, which the runner @@ -1624,8 +1644,9 @@ 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 +`windows-free-tests.yml` plans on Linux (`--windows-only --ci-plan --shards 6 +--shard-jobs $WINDOWS_FREE_JOBS`), runs one strict serial shard per +`windows-latest` job (`bun run test:windows --ci-run`), and verifies every shard receipt on Linux (`--windows-only --ci-verify`). Packing uses Windows-measured durations from `scripts/free-test-durations-windows.json`; the weekly diff --git a/package.json b/package.json index c5271f2da..8d40c09f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.76", + "version": "1.92.77", "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/lib/shard-jobs.ts b/scripts/lib/shard-jobs.ts index c96100e01..d0510fb77 100644 --- a/scripts/lib/shard-jobs.ts +++ b/scripts/lib/shard-jobs.ts @@ -45,16 +45,39 @@ export const FREE_JOBS_INVALID = 'FREE_JOBS_INVALID'; export const FREE_SUBSHARD_TIMEOUT = 'FREE_SUBSHARD_TIMEOUT'; export const FREE_SUBSHARD_REJECTED = 'FREE_SUBSHARD_REJECTED'; const DOCS = 'docs/TESTING_INTERNALS.md#sub-shards-inside-one-ci-job'; -const JOBS_FIX = `--jobs applies only to --ci-run and defaults to 1; GSTACK_FREE_JOBS sets the local full-suite process count and never changes --ci-run. See ${DOCS}`; +const JOBS_FIX = `--jobs applies only to --ci-run and defaults to the plan's per-shard k (1 when the plan has none); --shard-jobs applies only to --ci-plan; GSTACK_FREE_JOBS sets the local full-suite process count and never changes --ci-run. See ${DOCS}`; -/** `--jobs ` at parse time: digits only, at least 1. */ -export function parseJobsFlag(raw: string | undefined): number { +/** `--jobs ` or `--shard-jobs ` at parse time: digits only, at least 1. */ +export function parseJobsFlag(raw: string | undefined, flag = '--jobs'): number { if (raw === undefined || !/^\d+$/.test(raw) || Number(raw) < 1) { - throw new Error(`${FREE_JOBS_INVALID}: --jobs needs a positive whole number; got ${JSON.stringify(raw ?? '')}. ${JOBS_FIX}`); + throw new Error(`${FREE_JOBS_INVALID}: ${flag} needs a positive whole number; got ${JSON.stringify(raw ?? '')}. ${JOBS_FIX}`); } return Number(raw); } +export function rejectShardJobsWithoutCiPlan(): never { + throw new Error(`${FREE_JOBS_INVALID}: --shard-jobs needs --ci-plan. ${JOBS_FIX}`); +} + +/** + * One planned shard's k under `--ci-plan --shard-jobs k`: at most one parallel + * sub-shard per file that may run in parallel, so a shard of only serial files + * (the exclusive TREE_MUTATING shard) keeps k = 1. Undefined means k = 1, and + * the plan then carries no `jobs` key, so its id matches a plan without k. + */ +export function plannedShardJobs(files: readonly string[], shardJobs: number, isSerial: (file: string) => boolean): number | undefined { + const jobs = Math.min(shardJobs, files.filter(file => !isSerial(file)).length); + return jobs > 1 ? jobs : undefined; +} + +/** Why a plan's per-shard k cannot stand; null when absent or valid. */ +export function plannedShardJobsProblem(shard: { shard: number; files: readonly string[]; jobs?: unknown }, isSerial: (file: string) => boolean): string | null { + if (shard.jobs === undefined) return null; + const parallel = shard.files.filter(file => !isSerial(file)).length; + return Number.isInteger(shard.jobs) && (shard.jobs as number) >= 2 && (shard.jobs as number) <= parallel ? null + : `CI shard ${shard.shard} plans --jobs ${JSON.stringify(shard.jobs)} for ${parallel} parallel file(s)`; +} + export function rejectJobsWithoutCiRun(): never { throw new Error(`${FREE_JOBS_INVALID}: --jobs needs --ci-run. ${JOBS_FIX}`); } diff --git a/scripts/test-free-shards.ts b/scripts/test-free-shards.ts index deda761cf..d61ca6112 100755 --- a/scripts/test-free-shards.ts +++ b/scripts/test-free-shards.ts @@ -77,11 +77,15 @@ * bun run scripts/test-free-shards.ts --attribute-home # name real-home writers: each file alone, private HOME * bun run scripts/test-free-shards.ts --ci-plan plan.json --shards 20 * bun run scripts/test-free-shards.ts --ci-run plan.json --shard 1 --result result.json + * bun run scripts/test-free-shards.ts --ci-plan plan.json --shards 20 --shard-jobs 2 + * # the plan stores a per-shard k: min(k, the shard's files that may run in parallel), + * # kept only when above 1, so the exclusive TREE_MUTATING shard stays one child. * bun run scripts/test-free-shards.ts --ci-run plan.json --shard 1 --result result.json --jobs 4 - * # k sub-shards side by side inside one CI job (default 1 = one child, today's behavior; - * # scripts/lib/shard-jobs.ts). --jobs applies only to --ci-run: a positive whole number up - * # to the shard's file count, else FREE_JOBS_INVALID. GSTACK_FREE_JOBS sets the local - * # full-suite process count and never changes --ci-run; --jobs is its only knob. + * # k sub-shards side by side inside one CI job (default: the plan's per-shard k, else 1 = + * # one child, today's behavior; scripts/lib/shard-jobs.ts). --jobs applies only to + * # --ci-run and overrides the plan: a positive whole number up to the shard's file count, + * # else FREE_JOBS_INVALID. --ci-verify requires every result to have run at the plan's k. + * # GSTACK_FREE_JOBS sets the local full-suite process count and never changes --ci-run. * bun run scripts/test-free-shards.ts --ci-verify plan.json --results results/ */ @@ -97,8 +101,8 @@ import { attributeFreeHomeWriters, guardFreeHome, sharedFreeHome, type FreeHomeG import { appendStepSummary, ciHealthSummary, SEED_REFRESH_COMMAND, unseededWarning, windowsCurationLine } from './lib/free-ci-health'; import { curateWindowsSafe, type CurationResult } from './lib/windows-curation'; import { - assertSoloSeedSample, ciRunContext, parseJobsFlag, parseJunitCounts, readJunitReport, rejectJobsWithoutCiRun, runCiShard, SLOW_UNDER_LOAD, subShardRecordProblems, - validateJobsForShard, type ShardTestCounts, type SubShardRecord, + assertSoloSeedSample, ciRunContext, parseJobsFlag, parseJunitCounts, plannedShardJobs, plannedShardJobsProblem, readJunitReport, rejectJobsWithoutCiRun, + rejectShardJobsWithoutCiPlan, runCiShard, SLOW_UNDER_LOAD, subShardRecordProblems, validateJobsForShard, type ShardTestCounts, type SubShardRecord, } from './lib/shard-jobs'; export { curateWindowsSafe, detectWindowsFragility, KNOWN_WINDOWS_INCOMPATIBLE, type CurationResult } from './lib/windows-curation'; import { @@ -424,19 +428,27 @@ export interface FreeCiPlan { version: 1; revision: string; id: string; - shards: Array<{ shard: number; files: string[]; predictedMs: number }>; + /** `jobs` is the shard's k from --shard-jobs, present only when above 1. */ + shards: Array<{ shard: number; files: string[]; predictedMs: number; jobs?: number }>; } +/** Files that run in the trailing serial sub-shard under k > 1, never beside another child. */ +const runsSerialUnderJobs = (file: string) => file in TREE_MUTATING || file in SLOW_UNDER_LOAD; + const planDigest = (plan: Omit): string => createHash('sha256').update(JSON.stringify(plan)).digest('hex'); /** One immutable plan is shared by isolated CI machines; never repack per job. */ -export function createFreeCiPlan(files: string[], count: number, durations: Record, revision: string): FreeCiPlan { +export function createFreeCiPlan(files: string[], count: number, durations: Record, revision: string, shardJobs = 1): FreeCiPlan { const readers = files.filter(file => !(file in TREE_MUTATING)); const exclusive = files.filter(file => file in TREE_MUTATING).sort(); const packed = packShardsByDuration(readers, count, durations); - const shards = packed.shards.map((files, index) => ({ shard: index + 1, files, predictedMs: packed.predictedMs[index] })); - if (exclusive.length) shards.push({ shard: shards.length + 1, files: exclusive, predictedMs: exclusive.reduce((ms, file) => ms + (durations[file] ?? 0), 0) }); + const planned = packed.shards.map((files, index) => ({ files, predictedMs: packed.predictedMs[index] })); + if (exclusive.length) planned.push({ files: exclusive, predictedMs: exclusive.reduce((ms, file) => ms + (durations[file] ?? 0), 0) }); + const shards = planned.map(({ files, predictedMs }, index) => { + const jobs = plannedShardJobs(files, shardJobs, runsSerialUnderJobs); + return { shard: index + 1, files, predictedMs, ...(jobs ? { jobs } : {}) }; + }); const body = { version: 1 as const, revision, shards }; return { ...body, id: planDigest(body) }; } @@ -447,6 +459,8 @@ export function validateFreeCiPlan(plan: FreeCiPlan, files: string[], revision: || id !== planDigest({ version, revision: plan.revision, shards })) throw new Error('CI plan identity or revision mismatch'); if (shards.some((shard, index) => shard.shard !== index + 1 || !Array.isArray(shard.files) || !Number.isFinite(shard.predictedMs) || shard.predictedMs < 0)) throw new Error('Invalid CI shard plan'); + const jobsProblems = shards.map(shard => plannedShardJobsProblem(shard, runsSerialUnderJobs)).filter(Boolean); + if (jobsProblems.length) throw new Error(`Invalid CI shard plan: ${jobsProblems.join('; ')}`); const planned = shards.flatMap(shard => shard.files).sort(); if (new Set(planned).size !== planned.length || JSON.stringify(planned) !== JSON.stringify([...files].sort())) throw new Error('CI plan must cover every free file exactly once'); } @@ -486,6 +500,7 @@ export function verifyFreeCiResults(plan: FreeCiPlan, results: FreeCiResult[]): if (!hasCompleteCiSummary(outcome)) throw new Error('Missing or incomplete CI execution summary'); const subShardProblems = subShardRecordProblems(result); if (subShardProblems.length) throw new Error(`CI shard ${outcome.shard} sub-shards rejected: ${subShardProblems.join('; ')}`); + if ((result.jobs ?? 1) !== (shard.jobs ?? 1)) throw new Error(`CI shard ${outcome.shard} ran at --jobs ${result.jobs ?? 1}; the plan says ${shard.jobs ?? 1}`); if (outcome.status === 'passed') { if (outcome.exitCode !== 0 || outcome.failingFiles.length || outcome.unattributedFailures || result.retry) throw new Error('Inconsistent passing CI result'); } else { @@ -556,8 +571,10 @@ type CliOptions = { result: string | null; results: string | null; filesFrom: string | null; - /** Set only by --jobs (with --ci-run); absent means k = 1. */ + /** Set only by --jobs (with --ci-run); absent means the plan's per-shard k. */ jobs?: number; + /** Set only by --shard-jobs (with --ci-plan); absent means k = 1 in every shard. */ + shardJobs?: number; }; export function parseCliOptions(argv: string[]): CliOptions { @@ -573,6 +590,7 @@ export function parseCliOptions(argv: string[]): CliOptions { let quick = false; let attributeHome = false; let jobs: number | undefined; + let shardJobs: number | undefined; const paths: Record<'ciPlan' | 'ciRun' | 'ciVerify' | 'result' | 'results' | 'filesFrom', string | null> = { ciPlan: null, ciRun: null, ciVerify: null, result: null, results: null, filesFrom: null, }; @@ -597,6 +615,7 @@ export function parseCliOptions(argv: string[]): CliOptions { '--results': pathFlag('--results', 'results'), '--files-from': pathFlag('--files-from', 'filesFrom'), '--jobs': (next) => { jobs = parseJobsFlag(next()); }, + '--shard-jobs': (next) => { shardJobs = parseJobsFlag(next(), '--shard-jobs'); }, '--shards': (next) => { const value = next(); if (!value) throw new Error('Missing value for --shards'); @@ -621,8 +640,9 @@ export function parseCliOptions(argv: string[]): CliOptions { if (paths.ciRun && (shardIndex === null || !paths.result)) throw new Error('--ci-run requires --shard and --result'); if (paths.ciVerify && !paths.results) throw new Error('--ci-verify requires --results'); if (jobs !== undefined && !paths.ciRun) rejectJobsWithoutCiRun(); + if (shardJobs !== undefined && !paths.ciPlan) rejectShardJobsWithoutCiPlan(); if (quick && (recordDurations || windowsOnly || shardIndex !== null)) throw new Error('--quick cannot change recording, Windows or shard selection'); - return { dryRun, listOnly, recordDurations, windowsOnly, verbose, shardCount, shardIndex, wallTimeoutMs, wallTimeoutExplicit, quick, attributeHome, ...paths, ...(jobs === undefined ? {} : { jobs }) }; + return { dryRun, listOnly, recordDurations, windowsOnly, verbose, shardCount, shardIndex, wallTimeoutMs, wallTimeoutExplicit, quick, attributeHome, ...paths, ...(jobs === undefined ? {} : { jobs }), ...(shardJobs === undefined ? {} : { shardJobs }) }; } const describeLines = (lines: string[]) => `${lines.slice(0, 5).join(', ')}${lines.length > 5 ? ` (+${lines.length - 5} more)` : ''}`; @@ -1954,7 +1974,7 @@ async function main(): Promise { appendStepSummary(line); } warnUnseededFreeFiles(ciFiles, durations); - const plan = createFreeCiPlan(ciFiles, options.shardCount, durations, revision); + const plan = createFreeCiPlan(ciFiles, options.shardCount, durations, revision, options.shardJobs); validateFreeCiPlan(plan, ciFiles, revision); writeJson(options.ciPlan, plan); console.log(JSON.stringify({ shard: plan.shards.map(shard => shard.shard) })); @@ -1975,7 +1995,7 @@ async function main(): Promise { } const shard = plan.shards[options.shardIndex! - 1]; if (!shard || shard.shard !== options.shardIndex) throw new Error('CI shard index is outside the plan'); - const jobs = options.jobs ?? 1; + const jobs = options.jobs ?? shard.jobs ?? 1; validateJobsForShard(jobs, shard.files.length, shard.shard); const durations = loadFreeTestDurations() ?? {}; const { result, exitCode } = await runCiShard({ @@ -1986,7 +2006,7 @@ async function main(): Promise { runShard: (files, shardNumber, totalShards, runOptions) => runFreeShard(files, shardNumber, totalShards, runOptions), retry: outcomes => retryFailedFreeFiles(outcomes, plan.shards.length, options), pack: (files, count) => packShardsByDuration(files, count, durations).shards, - isSerial: file => file in TREE_MUTATING || file in SLOW_UNDER_LOAD, + isSerial: runsSerialUnderJobs, terminationRequested: isTerminationRequested, }); writeJson(options.result!, result); diff --git a/test/ci-image-cli-pin.test.ts b/test/ci-image-cli-pin.test.ts index c4a84f21a..1a94c107c 100644 --- a/test/ci-image-cli-pin.test.ts +++ b/test/ci-image-cli-pin.test.ts @@ -256,3 +256,22 @@ describe('CI Node.js installs match the image and are verified (#3354)', () => { expect(unpack).toBeGreaterThan(verify); }); }); + +// Value: protects=the eval image ships an ffmpeg that runs /design-motion's checks and exports; +// fails_when=Dockerfile.ci stops installing ffmpeg or stops failing the build on a missing filter or encoder; +// why_new=the marathon design-motion-showcase case ran 0/3 on an image without ffmpeg (#3687); seam=none; +// no_claim=reads the Dockerfile only; the image build runs the check itself +describe('ci image ffmpeg for the design-motion evals (#3687)', () => { + const lines = logicalLines(fs.readFileSync(DOCKERFILE, 'utf-8')).filter((line) => !/^\s*#/.test(line)); + + test('apt installs ffmpeg, which the marathon design-motion-showcase case needs', () => { + expect(lines.some((line) => /apt-get install\b[^&|;]*\sffmpeg\b/.test(line))).toBe(true); + }); + + test('the build fails when that ffmpeg cannot run the motion checks and exports', () => { + const verify = lines.find((line) => /\bffmpeg\b.*-filters/.test(line)) ?? ''; + for (const need of ['ffprobe', 'psnr', 'gblur', 'palettegen', 'paletteuse', 'signalstats', 'tile', 'libx264', 'gif']) { + expect(verify, `Dockerfile.ci must check ffmpeg for ${need}`).toContain(need); + } + }); +}); diff --git a/test/free-tests-workflow-wiring.test.ts b/test/free-tests-workflow-wiring.test.ts index b4426e205..0b36507d7 100644 --- a/test/free-tests-workflow-wiring.test.ts +++ b/test/free-tests-workflow-wiring.test.ts @@ -174,7 +174,8 @@ describe('free-tests workflow wiring', () => { expect(workflow.env.FREE_SUITE_SHARDS).toMatch(/^[1-9][0-9]?$/); expect(planner.outputs.runner).toBe('${{ steps.plan.outputs.runner }}'); expect(workflow.jobs['free-suite']['runs-on']).toBe('${{ needs.free-plan.outputs.runner }}'); - expect(plan.env).toEqual({ RUNNER_OVERRIDE: '${{ inputs.free_runner }}', SHARDS_OVERRIDE: '${{ inputs.free_shards }}' }); + expect(workflow.env.FREE_SUITE_JOBS).toMatch(/^[1-8]$/); + expect(plan.env).toEqual({ RUNNER_OVERRIDE: '${{ inputs.free_runner }}', SHARDS_OVERRIDE: '${{ inputs.free_shards }}', JOBS_OVERRIDE: '${{ inputs.free_jobs }}' }); expect(plan.run).not.toContain('${{'); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'free plan ')); const bin = path.join(directory, 'bin'); @@ -185,7 +186,7 @@ describe('free-tests workflow wiring', () => { const output = path.join(directory, 'output'); fs.writeFileSync(output, ''); const result = spawnSync('bash', ['-c', plan.run], { - env: { ...process.env, ...workflow.env, RUNNER_OVERRIDE: '', SHARDS_OVERRIDE: '', ...overrides, + env: { ...process.env, ...workflow.env, RUNNER_OVERRIDE: '', SHARDS_OVERRIDE: '', JOBS_OVERRIDE: '', ...overrides, PATH: `${toShell(bin)}:${process.env.PATH}`, RUNNER_TEMP: toShell(directory), GITHUB_OUTPUT: toShell(output), GITHUB_STEP_SUMMARY: toShell(path.join(directory, 'summary')) }, encoding: 'utf8', timeout: 5000, @@ -199,12 +200,13 @@ describe('free-tests workflow wiring', () => { const unset = resolve({}); expect(unset.status).toBe(0); expect(unset.outputs.runner).toBe(workflow.env.FREE_SUITE_RUNNER); - expect(unset.args).toContain(`--shards ${workflow.env.FREE_SUITE_SHARDS}`); - const override = resolve({ RUNNER_OVERRIDE: 'ubicloud-standard-4', SHARDS_OVERRIDE: '16' }); + expect(unset.args).toContain(`--shards ${workflow.env.FREE_SUITE_SHARDS} --shard-jobs ${workflow.env.FREE_SUITE_JOBS}`); + const override = resolve({ RUNNER_OVERRIDE: 'ubicloud-standard-4', SHARDS_OVERRIDE: '16', JOBS_OVERRIDE: '3' }); expect(override.status).toBe(0); expect(override.outputs.runner).toBe('ubicloud-standard-4'); - expect(override.args).toContain('--shards 16'); - const refusals: Record[] = [{ RUNNER_OVERRIDE: 'ubuntu-24.04' }, { RUNNER_OVERRIDE: 'ubicloud-standard-8 x' }, { SHARDS_OVERRIDE: '0' }, { SHARDS_OVERRIDE: '41' }, { SHARDS_OVERRIDE: '16; true' }]; + expect(override.args).toContain('--shards 16 --shard-jobs 3'); + const refusals: Record[] = [{ RUNNER_OVERRIDE: 'ubuntu-24.04' }, { RUNNER_OVERRIDE: 'ubicloud-standard-8 x' }, { SHARDS_OVERRIDE: '0' }, { SHARDS_OVERRIDE: '41' }, { SHARDS_OVERRIDE: '16; true' }, + { JOBS_OVERRIDE: '0' }, { JOBS_OVERRIDE: '9' }, { JOBS_OVERRIDE: '2; true' }, { JOBS_OVERRIDE: '-1' }]; for (const bad of refusals) { const refused = resolve(bad); expect(refused.status, JSON.stringify(bad)).not.toBe(0); diff --git a/test/regression-issue3680-ubicloud-bsd-userland.test.ts b/test/regression-issue3680-ubicloud-bsd-userland.test.ts index a6fd98a86..85561a029 100644 --- a/test/regression-issue3680-ubicloud-bsd-userland.test.ts +++ b/test/regression-issue3680-ubicloud-bsd-userland.test.ts @@ -120,3 +120,13 @@ printf 200 } finally { rmSync(f.root, { recursive: true, force: true }); } }, 60_000); }); + +describe('CI runs these cases on the BSD userland itself', () => { + // Value: protects=the cases above run where /usr/bin/tar and /usr/bin/awk are the BSD tools; fails_when=macos-named-regressions stops naming this file, so Linux CI (no bsdtar or one-true-awk installed) skips every case and a #3680 or #3679 regression ships green; why_new=the file was only in the Linux free suite; seam=.github/workflows/free-tests.yml; no_claim=the job's runner image or its tool versions + test('free-tests.yml macos-named-regressions names this file', () => { + const workflow = Bun.YAML.parse(readFileSync(join(ROOT, '.github/workflows/free-tests.yml'), 'utf8')) as any; + const mac = workflow.jobs['macos-named-regressions']; + expect(mac['runs-on']).toMatch(/^macos-/); + expect((mac.steps as any[]).map(step => String(step.run ?? '')).join('\n')).toContain('test/regression-issue3680-ubicloud-bsd-userland.test.ts'); + }); +}); diff --git a/test/shard-jobs.test.ts b/test/shard-jobs.test.ts index 83714013d..af18dfa54 100644 --- a/test/shard-jobs.test.ts +++ b/test/shard-jobs.test.ts @@ -10,11 +10,11 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { - collectFreeTestFiles, createFreeCiPlan, eligibleFreeRetryFiles, parseCliOptions, runFreeShard, TREE_MUTATING, verifyFreeCiResults, + collectFreeTestFiles, createFreeCiPlan, eligibleFreeRetryFiles, parseCliOptions, runFreeShard, TREE_MUTATING, validateFreeCiPlan, verifyFreeCiResults, type FreeCiResult, type FreeShardOutcome, } from '../scripts/test-free-shards'; import { - assertSoloSeedSample, assignSubShards, ciRunContext, FREE_JOBS_INVALID, mergeSubShardOutcomes, parseJobsFlag, parseJunitCounts, + assertSoloSeedSample, assignSubShards, ciRunContext, FREE_JOBS_INVALID, mergeSubShardOutcomes, parseJobsFlag, parseJunitCounts, plannedShardJobs, plannedShardJobsProblem, readJunitReport, runCiShard, SLOW_UNDER_LOAD, subShardRecordProblems, subShardTimeoutText, validateJobsForShard, type CiShardDeps, type RunShardOptions, } from '../scripts/lib/shard-jobs'; @@ -89,6 +89,52 @@ describe('--jobs validation (FREE_JOBS_INVALID)', () => { }); }); +describe('per-shard k in the plan (--ci-plan --shard-jobs)', () => { + test('--shard-jobs belongs to --ci-plan only and takes the same positive whole numbers', () => { + const base = ['--ci-plan', 'plan.json', '--shards', '4']; + expect(parseCliOptions([...base, '--shard-jobs', '2']).shardJobs).toBe(2); + expect('shardJobs' in parseCliOptions(base)).toBe(false); + for (const raw of ['0', '2.5', 'x']) expect(() => parseCliOptions([...base, '--shard-jobs', raw])).toThrow(`${FREE_JOBS_INVALID}: --shard-jobs needs a positive whole number`); + expect(() => parseCliOptions(['--shard-jobs', '2'])).toThrow(`${FREE_JOBS_INVALID}: --shard-jobs needs --ci-plan`); + expect(() => parseCliOptions(['--ci-run', 'p.json', '--shard', '1', '--result', 'r.json', '--shard-jobs', '2'])).toThrow(FREE_JOBS_INVALID); + }); + + test('each shard plans min(k, its parallel files); the exclusive shard and one-file shards stay one child, and k = 1 keeps the plan id', () => { + const withExclusive = [...files, EXCLUSIVE]; + const plain = createFreeCiPlan(withExclusive, 3, durations, 'revision-a'); + expect(createFreeCiPlan(withExclusive, 3, durations, 'revision-a', 1)).toEqual(plain); + expect(plain.shards.every(shard => !('jobs' in shard))).toBe(true); + const planned = createFreeCiPlan(withExclusive, 3, durations, 'revision-a', 4); + expect(planned.id).not.toBe(plain.id); + expect(planned.shards.map(shard => shard.files)).toEqual(plain.shards.map(shard => shard.files)); + const exclusiveShard = planned.shards.at(-1)!; + expect(exclusiveShard.files).toEqual([EXCLUSIVE]); + expect('jobs' in exclusiveShard).toBe(false); + for (const shard of planned.shards.slice(0, -1)) expect(shard.jobs).toBe(Math.min(4, shard.files.length) > 1 ? Math.min(4, shard.files.length) : undefined); + expect(() => validateFreeCiPlan(planned, withExclusive, 'revision-a')).not.toThrow(); + expect(plannedShardJobs(['a', 'b', 'c'], 2, file => file === 'a')).toBe(2); + expect(plannedShardJobs(['a', 'b'], 4, file => file === 'a')).toBeUndefined(); + expect(plannedShardJobs([], 4, () => false)).toBeUndefined(); + }); + + test('a plan whose per-shard k is out of range is invalid, and every shard result must have run at its planned k', () => { + const isSerial = (file: string) => file === EXCLUSIVE; + expect(plannedShardJobsProblem({ shard: 1, files }, isSerial)).toBeNull(); + expect(plannedShardJobsProblem({ shard: 1, files, jobs: 7 }, isSerial)).toBeNull(); + for (const jobs of [1, 0, 8, 2.5, '2']) expect(plannedShardJobsProblem({ shard: 1, files, jobs }, isSerial)).toContain('CI shard 1 plans --jobs'); + expect(plannedShardJobsProblem({ shard: 2, files: [EXCLUSIVE, files[0]], jobs: 2 }, isSerial)).toContain('for 1 parallel file(s)'); + const plan = createFreeCiPlan(files, 1, durations, 'revision-a', 2); + expect(plan.shards[0].jobs).toBe(2); + const tampered = structuredClone(plan); + tampered.shards[0].jobs = 99; + expect(() => validateFreeCiPlan(tampered, files, 'revision-a')).toThrow(); + const k1: FreeCiResult = { planId: plan.id, revision: 'revision-a', outcome: passed(plan.shards[0].files), retry: null }; + expect(() => verifyFreeCiResults(plan, [k1])).toThrow('CI shard 1 ran at --jobs 1; the plan says 2'); + const plainPlan = createFreeCiPlan(files, 1, durations, 'revision-a'); + expect(() => verifyFreeCiResults(plainPlan, [{ ...k1, planId: plainPlan.id }])).not.toThrow(); + }); +}); + describe('k = 1 golden: PR 2 changes nothing in CI', () => { test('one child over the whole planned shard with the same wall, one retry pass, and the same result keys', async () => { const h = harness(files); @@ -160,7 +206,8 @@ describe('k > 1: merge, rejection and one retry pass per CI shard', () => { summary: { testsRan: 16, filesRan: 8, sawTerminalSummary: true } }); expect(result.subShards!.map(record => record.status)).toEqual(['passed', 'passed', 'passed', 'passed']); expect(h.retries).toHaveLength(1); - expect(() => verifyFreeCiResults(h.plan, [result])).not.toThrow(); + expect(() => verifyFreeCiResults({ ...h.plan, shards: [{ ...h.shard, jobs: 3 }] }, [result])).not.toThrow(); + expect(() => verifyFreeCiResults(h.plan, [result])).toThrow('CI shard 1 ran at --jobs 3; the plan says 1'); }); test('failures from every sub-shard are collected first, so k never multiplies the five-file retry cap', async () => { @@ -263,7 +310,7 @@ describe('k > 1: merge, rejection and one retry pass per CI shard', () => { }); test('--ci-verify rejects tampered sub-shard records', () => { - const plan = createFreeCiPlan(files, 1, durations, 'revision-a'); + const plan = createFreeCiPlan(files, 1, durations, 'revision-a', 2); const shard = plan.shards[0]; const assignments = assignSubShards(plan.id, shard, 2, () => false, packFiles => [packFiles.slice(0, 3), packFiles.slice(3)]); const merged = mergeSubShardOutcomes(shard, assignments.map(assignment => ({ assignment, outcome: passed(assignment.files) })), 10); diff --git a/test/windows-native-workflows.test.ts b/test/windows-native-workflows.test.ts index 48ce50d9d..a111dc605 100644 --- a/test/windows-native-workflows.test.ts +++ b/test/windows-native-workflows.test.ts @@ -4,7 +4,9 @@ * that moved out of it. */ import { expect, test } from 'bun:test'; -import { existsSync, readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { collectFreeTestFiles, createFreeCiPlan, curateWindowsSafe, parseCliOptions, validateFreeCiPlan } from '../scripts/test-free-shards'; import { windowsCurationLine } from '../scripts/lib/free-ci-health'; @@ -27,7 +29,7 @@ const WINDOWS_CURATED_FLOOR = 795; 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_dispatch.inputs)).toEqual(['record_durations', 'windows_jobs']); expect(Object.keys(windows.on.workflow_call.inputs)).toEqual(['record_durations']); const plan = windows.jobs['windows-plan']; expect(plan['runs-on']).toBe('ubuntu-24.04'); @@ -50,6 +52,36 @@ test('the Windows lane runs nightly and by hand, plans, runs one strict shard pe expect(aggregate.steps.find((step: any) => step.name === 'Require exact coverage and complete strict results').run).toContain('--windows-only --ci-verify "$RUNNER_TEMP/windows-plan.json" --results '); }); +test.skipIf(!Bun.which('bash'))('windows-plan passes the one sub-shard constant, or a validated dispatch override, to --shard-jobs', () => { + const planStep = windows.jobs['windows-plan'].steps.find((step: any) => step.id === 'plan'); + expect(windows.env.WINDOWS_FREE_JOBS).toMatch(/^[1-4]$/); + expect(planStep.env.JOBS_OVERRIDE).toBe('${{ inputs.windows_jobs }}'); + expect(planStep.run).not.toContain('${{'); + const directory = mkdtempSync(path.join(os.tmpdir(), 'windows plan ')); + const bin = path.join(directory, 'bin'); + mkdirSync(bin); + writeFileSync(path.join(bin, 'bun'), '#!/bin/sh\nprintf "%s\\n" "$*" > "$RUNNER_TEMP/bun-args"\necho \'{"shard":[1]}\'\n', { mode: 0o755 }); + const toShell = (p: string) => p.split(path.sep).join('/'); + const resolve = (override: string) => { + rmSync(path.join(directory, 'bun-args'), { force: true }); + const result = spawnSync('bash', ['-c', planStep.run], { + env: { ...process.env, ...windows.env, JOBS_OVERRIDE: override, PATH: `${toShell(bin)}:${process.env.PATH}`, RUNNER_TEMP: toShell(directory), + GITHUB_OUTPUT: toShell(path.join(directory, 'output')), GITHUB_STEP_SUMMARY: toShell(path.join(directory, 'summary')) }, + encoding: 'utf8', timeout: 5000, + }); + return { status: result.status, args: existsSync(path.join(directory, 'bun-args')) ? readFileSync(path.join(directory, 'bun-args'), 'utf8') : '' }; + }; + try { + expect(resolve('')).toEqual({ status: 0, args: expect.stringContaining(`--shards 6 --shard-jobs ${windows.env.WINDOWS_FREE_JOBS}`) }); + expect(resolve('2')).toEqual({ status: 0, args: expect.stringContaining('--shards 6 --shard-jobs 2') }); + for (const bad of ['0', '5', '2; true', 'x']) { + const refused = resolve(bad); + expect(refused.status, bad).not.toBe(0); + expect(refused.args, bad).toBe(''); + } + } finally { rmSync(directory, { recursive: true, force: true }); } +}); + test('a Windows CI plan binds the curated file set, so it never verifies as the full Linux suite', () => { for (const argv of [['--windows-only', '--ci-plan', 'p.json'], ['--windows-only', '--ci-run', 'p.json', '--shard', '1', '--result', 'r.json'], ['--windows-only', '--ci-verify', 'p.json', '--results', 'd']]) { expect(parseCliOptions(argv).windowsOnly).toBe(true); From eff19e267854c8fba5a254d8c6d419d9335e9e59 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 05:07:54 -0800 Subject: [PATCH 2/6] v1.92.78.0 test: seven flaky-test repairs and six failure diagnostics from the 2026-11-01 flaky pass (GSTA-240) (#3804) * test: eight flaky-test repairs and five failure diagnostics from the 2026-11-01 flaky pass (GSTA-240) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(free-shards): keep failing a shard on an unseen browser child, and name it and whether it exited (review finding) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: pregate-regate copies through gitFixtureTemplate after the main merge 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 | 17 +++++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-codex-probe | 4 +- bin/gstack-evidence | 2 +- package.json | 2 +- scripts/lib/free-home-guard.ts | 9 +++-- scripts/test-free-shards.ts | 2 + test/auq-parallel.test.ts | 3 +- test/autoplan-publication-guard.test.ts | 4 +- test/bootstrap-retention-shard.test.ts | 2 +- test/codex-probe-cache.test.ts | 25 ++++++++++++ test/evidence.test.ts | 8 ++++ test/free-home-guard.test.ts | 28 ++++++++++++++ test/gstack-design-detect.test.ts | 7 +++- test/office-hours-attempt.test.ts | 3 +- test/pregate-regate.test.ts | 51 +++++++++++++++++++++---- test/session-update-autostash.test.ts | 3 +- test/shard-engine.test.ts | 5 ++- test/ship-budget.test.ts | 2 +- test/ship-queue-train.test.ts | 7 +++- 21 files changed, 162 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf0437f2c..517e1d270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.92.78.0] - 2026-11-02 + +**Seven flaky tests from the 2026-11-01 flaky pass stop failing correct runs, and six more now name their cause when they fail.** One product lock broke a fresh lock it had misjudged as abandoned, a /ship lane tripped the free runner's home check with its own log, and the rest were fixtures that raced their own setup, leaned on wall-clock sleeps or read the caller's state root. + +### Fixed +- **`bin/gstack-codex-probe`**: a waiter that cannot read an owner-less lock's age (the holder removed it in that instant, or `stat -f` printed filesystem text on Linux) now treats the lock as fresh instead of abandoned, so it no longer deletes a live owner's lock on a misread age and the probe no longer ends `MODEL_PROBE_INCONCLUSIVE` (Free Tests 41216564482, 41235671867, 41263831361). A stat stub for both race windows fails without the fix; 64 stress runs went from 2 failures to 0. +- **`scripts/lib/free-home-guard.ts`, `bin/gstack-evidence`**: `gstack-evidence run` names its own lane log in `GSTACK_EVIDENCE_LOG`, and the real-home tripwire ignores exactly that file, so a /ship lane no longer trips over its own output. A foreign write under the home's `~/.gstack` still fails. +- **`test/autoplan-publication-guard.test.ts`**: the render-root cases clear the state-root variables they don't name, so a `GSTACK_STATE_ROOT` exported by the gstack launch file no longer picks the root. Three cases failed every time under that variable, and the lookalike controls passed for the wrong reason. +- **`test/gstack-design-detect.test.ts`**: the directory scan proves one batch by counting engine calls instead of sleeping near the 1,500 ms detect timeout (macOS job 124813933194). The product timeout is unchanged. +- **`test/pregate-regate.test.ts`**: the two fixtures are built once and copied per test through the `gitFixtureTemplate` helper from #3586, so 8 builds become 2. +- **`test/ship-queue-train.test.ts`**: the planted seam has its own name, so the fixture's own background `git maintenance` lock no longer collides with it (EEXIST, Free Tests 41288253305, 41288417894), and the test now holds a real `maintenance.lock` beside it. +- **`test/ship-budget.test.ts`**: the fake child writes its args to a temporary file and renames it, so the poll never reads an empty file (Free Tests 41242243205). + +### Changed +- `scripts/test-free-shards.ts` names the recorded browser child it never saw in the daemon's process tree, and whether it was still alive, when it fails a shard for unconfirmed ownership (Free Tests 41264845860 shard 7). The shard still fails. +- `test/bootstrap-retention-shard.test.ts`, `test/office-hours-attempt.test.ts`, `test/session-update-autostash.test.ts`, `test/shard-engine.test.ts` and `test/auq-parallel.test.ts` print the process state, exit status or cleanup events behind their assertion, so the next red names its cause. + ## [1.92.77.0] - 2026-11-02 **The weekly marathon evals can run /design-motion again: the CI eval image now ships ffmpeg (#3687).** The `design-motion-showcase` case joined the marathon tier on 2026-10-26, and its first scheduled run (Actions run 41200316994, main @ 088bfc5) failed 0/3. Every trial built and captured the page, then stopped at `FFMPEG_MISSING`, because the image had no ffmpeg and the skill rightly refuses to export before its checks run. The model did nothing wrong; the case could not pass on that image. diff --git a/VERSION b/VERSION index 463685b58..66231b754 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.77.0 +1.92.78.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index ac6c1e65e..9f27e6a29 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.77.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.78.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe index 0292e5674..981ae1499 100755 --- a/bin/gstack-codex-probe +++ b/bin/gstack-codex-probe @@ -662,8 +662,8 @@ _gstack_codex_lock_acquire() { if [ "$_ohost" = "$_host" ] && ! kill -0 "$_pid" 2>/dev/null; then _dead=1; fi [ $((_now - _ts)) -ge "$_stale" ] && _dead=1 else - _ts=$(stat -c %Y "$_dir" 2>/dev/null || stat -f %m "$_dir" 2>/dev/null || echo 0) - case "$_ts" in ''|*[!0-9]*) _ts=0 ;; esac + _ts=$(stat -c %Y "$_dir" 2>/dev/null || stat -f %m "$_dir" 2>/dev/null || echo "$_now") + case "$_ts" in ''|*[!0-9]*) _ts="$_now" ;; esac [ $((_now - _ts)) -ge 5 ] && _dead=1 fi [ "$_dead" -eq 1 ] && _gstack_codex_lock_break "$_dir" "$_owner" diff --git a/bin/gstack-evidence b/bin/gstack-evidence index 59f8bbe30..35ca01d7b 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -400,7 +400,7 @@ async function cmdRun(argv: string[]): Promise { try { if (shell && !shell.ok) throw new Error(shell.error); const spawnArgv = shell?.ok ? [shell.cmd, ...shell.argv] : cmdArgv; - proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe" }); + proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe", env: log ? { ...process.env, GSTACK_EVIDENCE_LOG: log.path } : process.env }); } catch (e: any) { // Spawn failure (ENOENT on argv-direct form): record exit 127, propagate 127. exitCode = 127; diff --git a/package.json b/package.json index 8d40c09f7..bce0fb11b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.77", + "version": "1.92.78", "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/lib/free-home-guard.ts b/scripts/lib/free-home-guard.ts index 69b4bfd17..fb07555d3 100644 --- a/scripts/lib/free-home-guard.ts +++ b/scripts/lib/free-home-guard.ts @@ -5,8 +5,10 @@ * the real home before and after each free shard, and names the shard's files * when one of those entries changed. It watches only these surfaces; it is not * a universal write-containment check. Live agent-session logs that a - * concurrently running host keeps writing are excluded, and directory mtimes - * are ignored, so only file, link and directory-set changes count. + * concurrently running host keeps writing are excluded, as is the one lane log + * `gstack-evidence run` tees this run's own output into (its exact path, from + * GSTACK_EVIDENCE_LOG). Directory mtimes are ignored, so only file, link and + * directory-set changes count. * * A shard that runs alone owns its window, so its guard names its files. * Concurrent shards share one HOME and cannot tell whose write a change was, so @@ -104,6 +106,7 @@ export type FreeHomeGuardFactory = (files: string[], env: NodeJS.ProcessEnv, sta /** Take the baseline now; verify() returns null or the failure to report. */ export function guardFreeHome(files: string[], env: NodeJS.ProcessEnv = process.env, scope: FreeHomeScope = { kind: 'shard' }): FreeHomeGuard { const home = freeHomeDir(env); + const ownLog = env.GSTACK_EVIDENCE_LOG && path.isAbsolute(env.GSTACK_EVIDENCE_LOG) ? path.relative(home, env.GSTACK_EVIDENCE_LOG) : null; const unreadable = (error: unknown) => `real home surfaces could not be snapshotted (${(error as NodeJS.ErrnoException).code ?? 'error'}); shard files: ${files.join(', ')}`; let before: FreeHomeSnapshot | undefined; let baselineError: string | undefined; @@ -112,7 +115,7 @@ export function guardFreeHome(files: string[], env: NodeJS.ProcessEnv = process. verify() { if (!before) return baselineError!; try { - const changed = diffFreeHome(before, snapshotFreeHome(home)); + const changed = diffFreeHome(before, snapshotFreeHome(home)).filter(entry => entry !== ownLog); return changed.length ? formatFreeHomeChange(changed, files, scope) : null; } catch (error) { return unreadable(error); } }, diff --git a/scripts/test-free-shards.ts b/scripts/test-free-shards.ts index d61ca6112..b4012a60a 100755 --- a/scripts/test-free-shards.ts +++ b/scripts/test-free-shards.ts @@ -1370,6 +1370,8 @@ function trackShardBrowser(stateDir: string, env: NodeJS.ProcessEnv) { check(operation); if (!Number.isSafeInteger(pid) || (pid as number) <= 1) throw new BrowserCleanupError('invalid browser child identity'); const identity = identities.get(pid as number); + if (!identity && identities.get(daemon as number)?.root) throw new BrowserCleanupError( + `browser child ownership is unconfirmed: recorded pid ${pid} (${await inspect(operation, pid as number) ? 'alive' : 'exited'}) was never seen in daemon ${daemon}'s process tree`); if (!identity || identity.daemon !== daemon || identity.root) throw new BrowserCleanupError('browser child ownership is unconfirmed'); if (await live(operation, identity) && (typeof start !== 'string' || !start || await nativeStart(operation, identity) !== start.replace(/\s+/g, ' '))) throw new BrowserCleanupError('browser child identity was replaced'); diff --git a/test/auq-parallel.test.ts b/test/auq-parallel.test.ts index d7296a5b1..5a6facc94 100644 --- a/test/auq-parallel.test.ts +++ b/test/auq-parallel.test.ts @@ -111,7 +111,8 @@ test.each([ expect(facts.receipts.filter((receipt: any) => receipt.outcome === 'timeout')).toHaveLength(3 - scenario.passed); expect(facts.receipts.every((receipt: any) => receipt.timeoutMs === 240_000 && !receipt.answered)).toBe(true); expect(facts.active).toBe(0); expect(facts.answers).toBe(0); expect(facts.pendingTimers).toBe(0); - expect(facts.leftovers).toEqual([]); expect(facts.ownedStateLeftovers).toEqual([]); + expect(facts.leftovers).toEqual([]); + expect(facts.ownedStateLeftovers, JSON.stringify({ fixtures: facts.fixtures, inputs: facts.inputs.map((input: any) => [input.id, input.config, input.state]), events: facts.events.filter((event: any) => ['query-close', 'capture-settle', 'cleanup'].includes(event.kind)) })).toEqual([]); expect(facts.events.filter((event: any) => event.kind === 'query-close')).toHaveLength(scenario.queries); expect(facts.events.filter((event: any) => event.kind === 'cleanup')).toHaveLength(3); }, 20_000); diff --git a/test/autoplan-publication-guard.test.ts b/test/autoplan-publication-guard.test.ts index 44a375db4..d39a6baa2 100644 --- a/test/autoplan-publication-guard.test.ts +++ b/test/autoplan-publication-guard.test.ts @@ -1326,7 +1326,9 @@ describe('Autoplan phase entry through the gbrain :user render (#2569)', () => { edit(rewriteSectionBase(fs.readFileSync(path.join(ROOT, 'autoplan/sections', name), 'utf8'), linkRoot))); return root; } - function withEnv(env: Record, work: () => T): T { + /** The state-root chain the case does not name is cleared, so an ambient GSTACK_STATE_ROOT (every gstack launch file exports one) cannot pick the root. */ + function withEnv(given: Record, work: () => T): T { + const env = { GSTACK_STATE_ROOT: undefined, GSTACK_STATE_DIR: undefined, CLAUDE_PLUGIN_DATA: undefined, ...given }; const previous = Object.fromEntries(Object.keys(env).map(k => [k, process.env[k]])); for (const [k, v] of Object.entries(env)) if (v === undefined) delete process.env[k]; else process.env[k] = v; try { return work(); } diff --git a/test/bootstrap-retention-shard.test.ts b/test/bootstrap-retention-shard.test.ts index cbeac1275..2b120bd4e 100644 --- a/test/bootstrap-retention-shard.test.ts +++ b/test/bootstrap-retention-shard.test.ts @@ -62,7 +62,7 @@ describe.skipIf(process.platform !== 'linux')('bootstrap paid-shard cleanup inte fs.writeFileSync(path.join(root, 'controller.stdout.log'), controller.stdout ?? '', { mode: 0o600 }); fs.writeFileSync(path.join(root, 'controller.stderr.log'), controller.stderr ?? '', { mode: 0o600 }); expect(controller.error).toBeUndefined(); - expect(controller.status).toBe(0); + expect(controller.status, `controller exited ${controller.status} (signal ${controller.signal}); stderr tail: ${(controller.stderr ?? '').slice(-600).replace(/\s*\n\s*/g, ' | ')}`).toBe(0); const outcome = JSON.parse(fs.readFileSync(outcomePath, 'utf8')); if (scenario === 'ack-failure') { expect(controller.stdout).toContain('(fail) qa-bootstrap'); diff --git a/test/codex-probe-cache.test.ts b/test/codex-probe-cache.test.ts index 002fdb921..981fde6dd 100644 --- a/test/codex-probe-cache.test.ts +++ b/test/codex-probe-cache.test.ts @@ -247,6 +247,31 @@ describe('probe lock recovery and failure', () => { expect(calls(f)).toBe(1); })); + test('a lock released while a waiter reads its age is not abandoned; the next owner\'s fresh lock survives', withFixture((f) => { + const sig = sigOf(f); + const lock = path.join(f.locks, sig); + const once = path.join(f.home, 'vanish-once'); + const real = JSON.stringify(Bun.which('stat')); + fs.mkdirSync(lock, { recursive: true }); + fs.writeFileSync(path.join(f.home, 'bin/stat'), `#!/usr/bin/env bash +if [ "$3" = "$RACE_LOCK" ] && [ -f "$RACE_ONCE" ]; then + case "$1" in + -c) rmdir "$RACE_LOCK"; [ "$RACE_PHASE" = early ] && mkdir "$RACE_LOCK"; exit 1 ;; + -f) ${real} "$@"; rc=$?; rm -f "$RACE_ONCE"; [ -d "$RACE_LOCK" ] || mkdir "$RACE_LOCK"; exit "$rc" ;; + esac +fi +exec ${real} "$@" +`, { mode: 0o755 }); + for (const phase of ['early', 'late']) { + fs.writeFileSync(once, 'armed'); + const r = probe(f, { _GSTACK_CODEX_LOCK_WAIT: '1', RACE_LOCK: lock, RACE_ONCE: once, RACE_PHASE: phase }); + expect(fs.existsSync(once)).toBe(false); + expect(r.stdout).toContain('MODEL_PROBE_INCONCLUSIVE (another probe of this model still holds its lock)'); + expect(fs.existsSync(lock)).toBe(true); + expect(calls(f)).toBe(0); + } + })); + test('an unreadable cache or uncreatable lock is unverified and launches no paid probe', withFixture((f) => { fs.mkdirSync(f.cache); const dir = probe(f); diff --git a/test/evidence.test.ts b/test/evidence.test.ts index e40d6769c..0617ef8ed 100644 --- a/test/evidence.test.ts +++ b/test/evidence.test.ts @@ -74,6 +74,14 @@ describe('gstack-evidence run', () => { expect(fs.readFileSync(rec.log_path, 'utf-8')).toContain('ok'); }); + test('the lane command sees its own log path as GSTACK_EVIDENCE_LOG (the free runner\'s home tripwire excludes exactly that file)', () => { + const r = run(['run', '--label', 'focused', '--', 'printf "%s" "$GSTACK_EVIDENCE_LOG"']); + expect(r.status).toBe(0); + const rec = records().pop(); + expect(path.isAbsolute(rec.log_path)).toBe(true); + expect(r.stdout).toBe(rec.log_path); + }); + test('propagates a failing exit code and records it', () => { const r = run(['run', '--label', 'tests', '--', 'exit 3']); expect(r.status).toBe(3); diff --git a/test/free-home-guard.test.ts b/test/free-home-guard.test.ts index d01c8c979..4f387b5bd 100644 --- a/test/free-home-guard.test.ts +++ b/test/free-home-guard.test.ts @@ -78,6 +78,34 @@ describe('free-suite home-write tripwire (#2895)', () => { expect(failure).toContain('only'); }); + test.skipIf(process.platform === 'win32')('the wrapping gstack-evidence lane log is excluded by its exact path; a foreign write beside it still trips', async () => { + const home = fakeHome(); + const logs = path.join(home, '.gstack/projects/p/logs'); + fs.mkdirSync(logs, { recursive: true }); + const laneLog = path.join(logs, 'focused.log'); + fs.writeFileSync(laneLog, 'lane start\n'); + const unexcluded = guardFreeHome(['test/a.test.ts'], { HOME: home }); + const options = (script: string, name: string) => ({ env: { ...process.env, HOME: home, GSTACK_EVIDENCE_LOG: laneLog }, quiet: true, log: () => {}, + logFilePath: path.join(home, `${name}.log`), + commandFor: () => ({ command: process.execPath, args: ['-e', `${script}console.log('Ran 1 tests across 1 files. [1.00ms]')`] }) }); + const grow = `require('fs').appendFileSync(${JSON.stringify(laneLog)}, 'lane output\\n');`; + const errors: string[] = []; + const spy = spyOn(console, 'error').mockImplementation((...args: unknown[]) => { errors.push(args.join(' ')); }); + try { + expect((await runFreeShard(['test/a.test.ts'], 1, 1, options(grow, 'lane'))).status).toBe('passed'); + expect(unexcluded.verify()).toContain('~/.gstack/projects/p/logs/focused.log'); + const foreign = `${grow}require('fs').writeFileSync(${JSON.stringify(path.join(logs, 'other.log'))}, 'x');`; + expect((await runFreeShard(['test/a.test.ts'], 1, 1, options(foreign, 'foreign'))).status).toBe('failed'); + expect(errors.join('\n')).toContain('~/.gstack/projects/p/logs/other.log'); + expect(errors.join('\n')).not.toContain('focused.log'); + } finally { + spy.mockRestore(); + for (const retained of errors.flatMap(line => /retained (\S+)$/.exec(line)?.[1] ?? [])) { + if (/^gstack-free-shard-/.test(path.basename(retained))) fs.rmSync(retained, { recursive: true, force: true }); + } + } + }); + test('a concurrent phase guard names no shard and points at attribution', () => { const home = fakeHome(); const guard = guardFreeHome(['test/a.test.ts', 'test/b.test.ts'], { HOME: home }, { kind: 'concurrent', shards: 3 }); diff --git a/test/gstack-design-detect.test.ts b/test/gstack-design-detect.test.ts index 6349ec7d9..39388ac41 100644 --- a/test/gstack-design-detect.test.ts +++ b/test/gstack-design-detect.test.ts @@ -1460,8 +1460,13 @@ describe('scan: option-like bases and page-controlled inline ignores', () => { fs.mkdirSync(many, { recursive: true }); for (let i = 0; i < 1100; i++) fs.writeFileSync(path.join(many, `f${i}.css`), 'a{}'); try { - const r = run(['scan', '--format', 'gstack', 'src/many'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_SLEEP_MS: '1000', GSTACK_DESIGN_DETECT_TIMEOUT_MS: '1500' } }); + const log = path.join(SANDBOX, 'argv-many.log'); + fs.rmSync(log, { force: true }); + const r = run(['scan', '--format', 'gstack', 'src/many'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_LOG: log } }); expect(r.code).toBe(2); // a directory target is one batch (the fake reports findings): the budget test needs files + const calls = fs.readFileSync(log, 'utf-8').trim().split('\n').map(l => JSON.parse(l).argv as string[]); + expect(calls).toHaveLength(1); + expect(calls[0].some(x => x.endsWith(path.join('src', 'many')))).toBe(true); const files = fs.readdirSync(many).map(f => path.join('src', 'many', f)); const r2 = run(['scan', '--format', 'gstack', ...files], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_SLEEP_MS: '1000', GSTACK_DESIGN_DETECT_TIMEOUT_MS: '1500' } }); expect(r2.err).toMatch(/DETECT_TIMEOUT: whole-scan budget 7500ms exceeded, \d+ of 11 batches not run/); diff --git a/test/office-hours-attempt.test.ts b/test/office-hours-attempt.test.ts index c36da4486..c96bc8889 100644 --- a/test/office-hours-attempt.test.ts +++ b/test/office-hours-attempt.test.ts @@ -312,7 +312,8 @@ describe('Office Hours real session runner with fake processes', () => { expect(captured.output).toBe('captured output'); const diagnostic = JSON.parse(fs.readFileSync(path.join(dir, '.gstack/test-transcripts/drain-failure.json'), 'utf8')); expect(diagnostic.stderr).toContain('fixture auth failure'); - expect(running(Number(fs.readFileSync(path.join(dir, 'child.pid'), 'utf8')))).toBe(false); + const child = fs.readFileSync(path.join(dir, 'child.pid'), 'utf8').trim(); + expect(running(Number(child)), `held-pipe child after the runner returned (group ${fs.readFileSync(path.join(dir, 'parent.pid'), 'utf8').trim()}): ${spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,stat=,etime=', '-p', child], { encoding: 'utf8', timeout: 5_000 }).stdout.trim() || 'gone now'}`).toBe(false); }); }, 10_000); } diff --git a/test/pregate-regate.test.ts b/test/pregate-regate.test.ts index 2e31a4c5f..09f01a004 100644 --- a/test/pregate-regate.test.ts +++ b/test/pregate-regate.test.ts @@ -6,11 +6,12 @@ * the base branch and every other open PR, and a lookup that cannot finish is * `unknown`, which withholds `REGATE: not-needed`. */ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; -import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture'; +import { gitFixtureTemplate, rebindFile, rebindGitDir, type GitFixtureTemplate } from './helpers/git-fixture-template'; +import { BASE_POLICY, cleanup, fixtureRepoAt, makePr, recordAndBundle, restamp, runBin, seedFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture'; const repos: FixtureRepo[] = []; afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); @@ -39,10 +40,14 @@ console.error("gh shim: unsupported "+a.join(" ")); process.exit(1)' "${file}" " } const ghCalls = (repo: FixtureRepo) => fs.readFileSync(path.join(repo.root, 'gh-regate', 'calls.log'), 'utf8'); +type Lanes = Array<{ label: string; command: string }>; +const TESTS_LANE: Lanes = [{ label: 'tests', command: 'true' }]; +const DIGEST_LANES: Lanes = [...TESTS_LANE, { label: 'digest', command: 'cat generated/digest.md' }]; + /** pr-a adds migration v240, is stamped 1.2.4.0 and gated (lanes recorded and bundled); then restamped to 1.2.5.0. */ -function gatedThenRestamped(lanes: Array<{ label: string; command: string }> = [{ label: 'tests', command: 'true' }]) { - const repo = makeFixtureRepo({ prefix: 'pregate-regate-', policy: POLICY }); - repos.push(repo); +function buildGated(root: string, lanes: Lanes) { + seedFixtureRepo(root, { policy: POLICY }); + const repo = fixtureRepoAt(root); makePr(repo, 'pr-a', { expects: '1.2.4.0' }); fs.mkdirSync(path.join(repo.work, 'migrations'), { recursive: true }); fs.writeFileSync(path.join(repo.work, 'migrations', 'v240-add-index.sql'), 'create index a;\n'); @@ -54,7 +59,39 @@ function gatedThenRestamped(lanes: Array<{ label: string; command: string }> = [ const head = repo.git('rev-parse', 'HEAD'); expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok); repo.git('commit', '-qam', 'stamp 1.2.5.0'); - return { repo, head, bundle, current: repo.git('rev-parse', 'HEAD') }; + return { head, current: repo.git('rev-parse', 'HEAD') }; +} + +// Each fixture costs four gstack bin runs (two restamps, an evidence run and a bundle); build each lane set once +// and give every test its own copy instead of rebuilding it per test. +const templates = new Map>>(); +beforeAll(() => { + for (const lanes of [TESTS_LANE, DIGEST_LANES]) { + const template = gitFixtureTemplate({ + prefix: 'pregate-regate-', + build: root => buildGated(root, lanes), + rebind: (copy, tpl) => { + rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy); + rebindFile(path.join(copy, 'bundle.json'), tpl, copy); + const projects = path.join(copy, 'home', 'projects'); + for (const slug of fs.existsSync(projects) ? fs.readdirSync(projects) : []) { + const dir = path.join(projects, slug.split(path.basename(tpl)).join(path.basename(copy))); + if (dir !== path.join(projects, slug)) fs.renameSync(path.join(projects, slug), dir); + for (const file of fs.readdirSync(dir, { recursive: true, withFileTypes: true })) if (file.isFile()) rebindFile(path.join(file.parentPath, file.name), tpl, copy); + } + fs.rmSync(path.join(copy, 'home', '.capy'), { recursive: true, force: true }); + }, + }); + template.copy().root && templates.set(lanes, template); + } +}, 60_000); +afterAll(() => { for (const t of templates.values()) t.dispose(); }); + +function gatedThenRestamped(lanes: Lanes = TESTS_LANE) { + const { root, meta: { head, current } } = templates.get(lanes)!.copy(); + const repo = fixtureRepoAt(root); + repos.push(repo); + return { repo, head, bundle: path.join(repo.root, 'bundle.json'), current }; } const regate = (repo: FixtureRepo, args: string[], env: Record) => runBin(repo, 'gstack-pregate', ['regate', '--base', 'main', ...args], env); @@ -97,7 +134,7 @@ describe('gstack-pregate regate: evidence, stamps and lane verdicts', () => { }); test('a stamp-sensitive lane reruns, so a regate is needed; a code edit is not stamp-only and names the file and line', () => { - const { repo, head, bundle } = gatedThenRestamped([{ label: 'tests', command: 'true' }, { label: 'digest', command: 'cat generated/digest.md' }]); + const { repo, head, bundle } = gatedThenRestamped(DIGEST_LANES); const env = ghShim(repo, { list: [] }); const r = regate(repo, ['--against', head, '--bundle', bundle], env); expect(r.status).toBe(EXIT.fail); diff --git a/test/session-update-autostash.test.ts b/test/session-update-autostash.test.ts index f2ab223d9..d5a0cff24 100644 --- a/test/session-update-autostash.test.ts +++ b/test/session-update-autostash.test.ts @@ -284,7 +284,8 @@ describe('gstack-session-update lock identity + TTL (#2613)', () => { const r = runScript(base, install, state); expect(r.status).toBe(0); const log = await waitForLog(state, /UP_TO_DATE|UPDATING/); - expect(log).toMatch(/UP_TO_DATE|UPDATING/); + const lockPid = fs.existsSync(path.join(lockDir, 'pid')) ? fs.readFileSync(path.join(lockDir, 'pid'), 'utf8').trim() : 'no lock'; + expect(log, `planted dead pid ${dead.pid} (alive now: ${isAlive(dead.pid)}); lock pid after 15 s: ${lockPid} (alive: ${isAlive(Number(lockPid))}); script exit ${r.status}, stderr: ${r.stderr}`).toMatch(/UP_TO_DATE|UPDATING/); } finally { fs.rmSync(base, { recursive: true, force: true }); } diff --git a/test/shard-engine.test.ts b/test/shard-engine.test.ts index 272d5d740..60c99de61 100644 --- a/test/shard-engine.test.ts +++ b/test/shard-engine.test.ts @@ -171,7 +171,10 @@ describe('shard engine: process lifecycle', () => { }); expect(paid.status).toBe('timed-out'); expect(summaryExitCode(summarize([paid]))).toBe(1); - expect(await waitDead(Number(fs.readFileSync(pidFile, 'utf8')))).toBe(true); + const paidChild = Number(fs.readFileSync(pidFile, 'utf8')); + const paidChildDead = await waitDead(paidChild); + const procStat = (pid: number) => { try { return fs.readFileSync(`/proc/${pid}/stat`, 'utf8').trim(); } catch { return 'no /proc entry'; } }; + expect(paidChildDead, `paid grandchild ${paidChild} of group ${paid.groupPid} 2 s after the timeout kill: ${procStat(paidChild)}`).toBe(true); }, 30_000); test('win32 semantics: no process group and never a negative-pid kill', async () => { diff --git a/test/ship-budget.test.ts b/test/ship-budget.test.ts index 2ce43a144..5043da5a1 100644 --- a/test/ship-budget.test.ts +++ b/test/ship-budget.test.ts @@ -308,7 +308,7 @@ describe('Claude Code child launch', () => { const file = ho.out.match(/^HANDOFF: (\S+)/m)![1]!; const argsFile = path.join(h.root, 'claude-args.txt'); const fake = path.join(h.root, 'fake-claude'); - fs.writeFileSync(fake, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsFile}"\n`, { mode: 0o755 }); + fs.writeFileSync(fake, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsFile}.tmp" && mv "${argsFile}.tmp" "${argsFile}"\n`, { mode: 0o755 }); const r = h.sb(['launch', '--in', file], { GSTACK_CLAUDE_BIN: fake }); expect(r.code).toBe(0); const session = h.field(r.out, 'CHILD', 'session'); diff --git a/test/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index ad5c056a3..9a46e65be 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -35,13 +35,16 @@ const result = (out: string) => line(out, 'GSTACK_RESULT:'); const next = (out: string) => line(out, 'NEXT:'); const remoteRefs = (fx: TrainFixture) => fx.repo.git('ls-remote', 'origin'); -// fails_when=coordinatorClone copies origin.git/objects file by file again, so an entry that appears or vanishes there mid-copy (the detached maintenance.lock every push into the fixture origin leaves) fails the clone, as in two Free Tests reds on 2026-10-28 (GSTA-139 ledger); why_new=no other test clones while origin's objects directory changes; seam=a dangling symlink planted under objects/, which a local copy refuses on every run; no_claim=which process removed the loose object those runs named +// fails_when=coordinatorClone copies origin.git/objects file by file again, so an entry that appears or vanishes there mid-copy (the detached maintenance.lock every push into the fixture origin leaves) fails the clone, as in two Free Tests reds on 2026-10-28 (GSTA-139 ledger); why_new=no other test clones while origin's objects directory changes; seam=a dangling symlink planted under objects/ beside a held maintenance.lock, which a local copy refuses on every run; the seam has its own name because the fixture push's own detached maintenance can hold objects/maintenance.lock at that moment (EEXIST in Free Tests 41288253305 and 41288417894, 2026-11-01); no_claim=which process removed the loose object those runs named test('the coordinator clone reads origin through git, never by copying its objects directory (GSTA-139)', () => { const fx = make(); - fs.symlinkSync(path.join(fx.repo.root, 'gone'), path.join(fx.repo.origin, 'objects', 'maintenance.lock')); + const objects = path.join(fx.repo.origin, 'objects'); + fs.writeFileSync(path.join(objects, 'maintenance.lock'), ''); + fs.symlinkSync(path.join(fx.repo.root, 'gone'), path.join(objects, 'gstack-dangling-seam')); const coord = coordinatorClone(fx.repo); expect(spawnSync('git', ['-C', coord, 'rev-parse', 'origin/main'], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } }).stdout.trim()).toBe(fx.repo.git('rev-parse', 'origin/main')); expect(fs.existsSync(path.join(coord, '.git', 'objects', 'maintenance.lock'))).toBe(false); + expect(fs.readdirSync(path.join(coord, '.git', 'objects'))).not.toContain('gstack-dangling-seam'); }); describe('the train command surface', () => { From 322906ab2b0975c6b009ae4a2804259be9e8ed70 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 05:17:17 -0800 Subject: [PATCH 3/6] v1.92.79.0 fix: /plan-tune stops reporting mismatches with no observed answers behind them (#3436) (#3856) * fix(plan-tune): compare declared and inferred only with 3+ observed answers (#3436) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: derive keeps the gap at 3 events; changelog fragment for #3436 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(plan-tune): profile view and design doc honor the 3-answer floor (#3436) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(plan-tune): keep the 10-answer floor in --check-mismatch beside the per-dimension floor 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 | 11 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-developer-profile | 23 +++++-- docs/designs/PLAN_TUNING_V0.md | 2 +- package.json | 2 +- plan-tune/SKILL.md | 5 ++ plan-tune/SKILL.md.tmpl | 5 ++ test/gstack-developer-profile.test.ts | 92 +++++++++++++++++++++++++++ 9 files changed, 135 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 517e1d270..1fc172340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.92.79.0] - 2026-11-02 + +**/plan-tune no longer reports a mismatch between your stated and observed preferences when it has no observed answers behind it (#3436).** `--check-mismatch` compared a declared value against an inferred value that had never moved from its 0.5 starting point, so a strong declaration with no matching answers printed `scope_appetite: declared 0.9 vs inferred 0.5 (gap 0.4)` even though no logged answer touched that dimension. + +### Fixed +- **`bin/gstack-developer-profile`**: `--check-mismatch`, `--gap` and the gap that `--derive` stores compare a dimension only when at least 3 logged answers moved it. `--check-mismatch` still needs 10 logged answers overall before it reports anything. Below the 3-answer floor, `--check-mismatch` prints `no observed evidence` or `too little evidence (n of 3 events)` instead of a gap, and `--gap` lists the dimension under a new `low_evidence` field. A profile derived before `contributing_events` existed keeps the old behavior until its next derive. +- **/plan-tune "Show gap"**: says "not enough observed answers yet" for those dimensions instead of calling them drift. + +### Tests +- `test/gstack-developer-profile.test.ts`: a declared dimension with no evidence is not flagged by `--check-mismatch` or `--gap` (both failed on main), 1 or 2 events report too little evidence, 3 events are compared, three same-day answers that agree with the declaration still report not enough data, and `--derive` stores no gap without evidence. + ## [1.92.78.0] - 2026-11-02 **Seven flaky tests from the 2026-11-01 flaky pass stop failing correct runs, and six more now name their cause when they fail.** One product lock broke a fresh lock it had misjudged as abandoned, a /ship lane tripped the free runner's home check with its own log, and the rest were fixtures that raced their own setup, leaned on wall-clock sleeps or read the caller's state root. diff --git a/VERSION b/VERSION index 66231b754..dfb847afe 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.78.0 +1.92.79.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 9f27e6a29..192027e8e 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.78.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.79.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-developer-profile b/bin/gstack-developer-profile index bc489c5c1..409c4f56e 100755 --- a/bin/gstack-developer-profile +++ b/bin/gstack-developer-profile @@ -54,6 +54,7 @@ case "$(uname -s)" in esac PROFILE_FILE="$GSTACK_HOME/developer-profile.json" LEGACY_FILE="$GSTACK_HOME/builder-profile.jsonl" +MIN_EVIDENCE=3 eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" SLUG="${SLUG:-unknown}" @@ -512,14 +513,19 @@ do_gap() { const p = JSON.parse(await Bun.stdin.text()); const declared = p.declared || {}; const inferred = (p.inferred && p.inferred.values) || {}; + const contributing = p.inferred && p.inferred.contributing_events; const dims = ['scope_appetite','risk_tolerance','detail_preference','autonomy','architecture_care']; const gap = {}; + const low_evidence = {}; for (const d of dims) { - if (declared[d] !== undefined && inferred[d] !== undefined) { - gap[d] = +(Math.abs(declared[d] - inferred[d])).toFixed(3); + if (declared[d] === undefined || inferred[d] === undefined) continue; + if (contributing && (contributing[d] || 0) < $MIN_EVIDENCE) { + low_evidence[d] = contributing[d] || 0; + continue; } + gap[d] = +(Math.abs(declared[d] - inferred[d])).toFixed(3); } - console.log(JSON.stringify({ declared, inferred, gap }, null, 2)); + console.log(JSON.stringify({ declared, inferred, gap, low_evidence }, null, 2)); " } @@ -657,6 +663,7 @@ do_derive() { // Recompute gap. const gap = {}; for (const d of Object.keys(values)) { + if ((contributing[d] || 0) < $MIN_EVIDENCE) continue; if (profile.declared && profile.declared[d] !== undefined) { gap[d] = +(Math.abs(profile.declared[d] - values[d])).toFixed(3); } @@ -727,9 +734,8 @@ do_check_mismatch() { const declared = p.declared || {}; const inferred = (p.inferred && p.inferred.values) || {}; const sampleSize = (p.inferred && p.inferred.sample_size) || 0; - const diversity = (p.inferred && p.inferred.diversity) || {}; + const contributing = p.inferred && p.inferred.contributing_events; - // Require enough data before reporting mismatch. if (sampleSize < 10) { console.log('MISMATCH: not enough data (' + sampleSize + ' events; need 10+)'); process.exit(0); @@ -737,8 +743,14 @@ do_check_mismatch() { const THRESHOLD = 0.3; const flagged = []; + const thin = []; for (const d of Object.keys(declared)) { if (inferred[d] === undefined) continue; + const n = contributing ? (contributing[d] || 0) : undefined; + if (n !== undefined && n < $MIN_EVIDENCE) { + thin.push(' ' + d + ': ' + (n === 0 ? 'no observed evidence' : 'too little evidence (' + n + ' of $MIN_EVIDENCE events)')); + continue; + } const gap = Math.abs(declared[d] - inferred[d]); if (gap > THRESHOLD) { flagged.push({ dim: d, declared: declared[d], inferred: inferred[d], gap: +gap.toFixed(3) }); @@ -753,6 +765,7 @@ do_check_mismatch() { console.log(' ' + f.dim + ': declared ' + f.declared + ' vs inferred ' + f.inferred + ' (gap ' + f.gap + ')'); } } + for (const line of thin) console.log(line); " } diff --git a/docs/designs/PLAN_TUNING_V0.md b/docs/designs/PLAN_TUNING_V0.md index e9bf2b440..77bf95e8d 100644 --- a/docs/designs/PLAN_TUNING_V0.md +++ b/docs/designs/PLAN_TUNING_V0.md @@ -105,7 +105,7 @@ After weighing Codex's argument, we chose to roll back CEO EXPANSION and ship an } ``` -`signal_events` counts the logged answers that moved any dimension, and `contributing_events` counts, per dimension, the logged answers that moved it (an answer that writes two deltas to one dimension counts once), so a dimension with no evidence (0) is visible beside one whose evidence balances out; both print 0.5 (#2623). Each value is the sigmoid of its accumulated deltas, and each UTC day's sum per dimension (by the row's `ts`) is clamped to the largest single delta in the signal map (currently ±0.06), so one burst of answers can't pin a dimension; `--trace ` lists every contributing answer, each clamped day and the total. The bound keys on the day, not on `session_id`: the PostToolUse hook and the agent log different ids for the same review (the host's conversation id and the preamble's run id), and an agent can leave the `SESSION_ID` placeholder unsubstituted, so no logged id reliably marks one review. The cost is that several reviews on one day count as one. `contributing_events` still counts an answer logged by both writers twice. Only an ISO 8601 date or date-time `ts` counts as dated (a time without a zone is read as UTC), every other row shares one undated bucket, and `diversity.days_span` counts the same UTC days. Nothing displays `contributing_events` yet (#3436). +`signal_events` counts the logged answers that moved any dimension, and `contributing_events` counts, per dimension, the logged answers that moved it (an answer that writes two deltas to one dimension counts once), so a dimension with no evidence (0) is visible beside one whose evidence balances out; both print 0.5 (#2623). Each value is the sigmoid of its accumulated deltas, and each UTC day's sum per dimension (by the row's `ts`) is clamped to the largest single delta in the signal map (currently ±0.06), so one burst of answers can't pin a dimension; `--trace ` lists every contributing answer, each clamped day and the total. The bound keys on the day, not on `session_id`: the PostToolUse hook and the agent log different ids for the same review (the host's conversation id and the preamble's run id), and an agent can leave the `SESSION_ID` placeholder unsubstituted, so no logged id reliably marks one review. The cost is that several reviews on one day count as one. `contributing_events` still counts an answer logged by both writers twice. Only an ISO 8601 date or date-time `ts` counts as dated (a time without a zone is read as UTC), every other row shares one undated bucket, and `diversity.days_span` counts the same UTC days. `--check-mismatch`, `--gap`, the stored `gap` and /plan-tune's profile view compare a dimension only when its `contributing_events` is at least 3; `--gap` lists the others under `low_evidence` and the stored `gap` omits them (#3436). **Diversity check** (Codex #13): `inferred` is considered "enough data" only when `sample_size >= 20 AND skills_covered >= 3 AND question_ids_covered >= 8 AND days_span >= 7`. Below this, `/plan-tune profile` shows "not enough observed data yet" instead of a potentially-misleading inferred value. diff --git a/package.json b/package.json index bce0fb11b..b8a73016c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.78", + "version": "1.92.79", "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/plan-tune/SKILL.md b/plan-tune/SKILL.md index 86b2cbdb7..481d7764e 100644 --- a/plan-tune/SKILL.md +++ b/plan-tune/SKILL.md @@ -341,6 +341,8 @@ Parse the JSON. Present in **plain English**, not raw floats: the inferred column next to declared: "**scope_appetite:** declared 0.8 (boil the ocean) ↔ observed 0.72 (close)" Use words for the gap: 0.0-0.1 "close", 0.1-0.3 "drift", 0.3+ "mismatch". + When `inferred.contributing_events[dim]` is below 3, show "not enough + observed answers yet" for that dimension instead of an observed value or gap word. This display gate is intentionally lower than the E1 **promotion gate** (90+ days stable across 3+ skills, per `docs/designs/PLAN_TUNING_V0.md`). @@ -491,6 +493,9 @@ Parse the JSON. For each dimension where both declared and inferred exist: Consider updating your declared value, or reflect on whether your behavior is actually what you want." +For each dimension in `low_evidence` (fewer than 3 observed answers moved it), +say "not enough observed answers yet" instead of a gap. + Never auto-update declared based on the gap. In v1 the gap is reporting only — the user decides whether declared is wrong or behavior is wrong. diff --git a/plan-tune/SKILL.md.tmpl b/plan-tune/SKILL.md.tmpl index 748dfad62..88b7a8eb3 100644 --- a/plan-tune/SKILL.md.tmpl +++ b/plan-tune/SKILL.md.tmpl @@ -272,6 +272,8 @@ Parse the JSON. Present in **plain English**, not raw floats: the inferred column next to declared: "**scope_appetite:** declared 0.8 (boil the ocean) ↔ observed 0.72 (close)" Use words for the gap: 0.0-0.1 "close", 0.1-0.3 "drift", 0.3+ "mismatch". + When `inferred.contributing_events[dim]` is below 3, show "not enough + observed answers yet" for that dimension instead of an observed value or gap word. This display gate is intentionally lower than the E1 **promotion gate** (90+ days stable across 3+ skills, per `docs/designs/PLAN_TUNING_V0.md`). @@ -422,6 +424,9 @@ Parse the JSON. For each dimension where both declared and inferred exist: Consider updating your declared value, or reflect on whether your behavior is actually what you want." +For each dimension in `low_evidence` (fewer than 3 observed answers moved it), +say "not enough observed answers yet" instead of a gap. + Never auto-update declared based on the gap. In v1 the gap is reporting only — the user decides whether declared is wrong or behavior is wrong. diff --git a/test/gstack-developer-profile.test.ts b/test/gstack-developer-profile.test.ts index df23a9c3b..cffaadebc 100644 --- a/test/gstack-developer-profile.test.ts +++ b/test/gstack-developer-profile.test.ts @@ -622,6 +622,98 @@ describe('gstack-developer-profile --check-mismatch', () => { expect(r.stdout).toContain('scope_appetite'); expect(r.stdout).toContain('autonomy'); }); + + const deriveWithOneSignal = () => { + logQuestion({ skill: 'plan-eng-review', question_id: 'plan-eng-review-arch-finding', question_summary: 'arch?', user_choice: 'fix-now', session_id: 's0' }); + for (let i = 1; i < 13; i++) { + logQuestion({ skill: 'plan-eng-review', question_id: `adhoc-unregistered-${i}`, question_summary: 'x', user_choice: 'anything', session_id: `s${i}` }); + } + expect(runDev('--derive').status).toBe(0); + const file = path.join(tmpHome, 'developer-profile.json'); + const p = readProfile() as any; + expect(p.inferred.contributing_events.scope_appetite).toBe(0); + p.declared = { scope_appetite: 0.9 }; + fs.writeFileSync(file, JSON.stringify(p)); + }; + + test('a declared dimension with no observed evidence is not flagged (#3436)', () => { + deriveWithOneSignal(); + const r = runDev('--check-mismatch'); + expect(r.status).toBe(0); + expect(r.stdout).not.toContain('scope_appetite: declared'); + expect(r.stdout).toContain('scope_appetite: no observed evidence'); + }); + + test('--gap leaves out a dimension with no observed evidence (#3436)', () => { + deriveWithOneSignal(); + const out = JSON.parse(runDev('--gap').stdout); + expect(out.gap.scope_appetite).toBeUndefined(); + expect(out.low_evidence).toEqual({ scope_appetite: 0 }); + }); + + test('a dimension with 1 or 2 contributing events reports too little evidence', () => { + runDev('--read'); + const file = path.join(tmpHome, 'developer-profile.json'); + const p = readProfile() as any; + p.declared = { scope_appetite: 0.9 }; + p.inferred.values.scope_appetite = 0.45; + p.inferred.sample_size = 10; + p.inferred.contributing_events = { scope_appetite: 2 }; + fs.writeFileSync(file, JSON.stringify(p)); + const r = runDev('--check-mismatch'); + expect(r.stdout).toContain('MISMATCH: none'); + expect(r.stdout).toContain('scope_appetite: too little evidence (2 of 3 events)'); + expect(JSON.parse(runDev('--gap').stdout).low_evidence).toEqual({ scope_appetite: 2 }); + }); + + test('a dimension with 3 contributing events is compared', () => { + runDev('--read'); + const file = path.join(tmpHome, 'developer-profile.json'); + const p = readProfile() as any; + p.declared = { scope_appetite: 0.9 }; + p.inferred.values.scope_appetite = 0.45; + p.inferred.sample_size = 10; + p.inferred.contributing_events = { scope_appetite: 3 }; + fs.writeFileSync(file, JSON.stringify(p)); + expect(runDev('--check-mismatch').stdout).toContain('scope_appetite: declared 0.9 vs inferred 0.45 (gap 0.45)'); + expect(JSON.parse(runDev('--gap').stdout).gap.scope_appetite).toBeCloseTo(0.45, 2); + }); + + test('three same-day answers that agree with the declaration are not a mismatch', () => { + for (let i = 0; i < 3; i++) { + logQuestion({ skill: 'plan-ceo-review', question_id: 'plan-ceo-review-mode', question_summary: 'mode?', user_choice: 'expand', session_id: `s${i}`, ts: '2026-10-20T12:00:00Z' }); + } + expect(runDev('--derive').status).toBe(0); + const file = path.join(tmpHome, 'developer-profile.json'); + const p = readProfile() as any; + expect(p.inferred.contributing_events.scope_appetite).toBe(3); + p.declared = { scope_appetite: 0.9 }; + fs.writeFileSync(file, JSON.stringify(p)); + const r = runDev('--check-mismatch'); + expect(r.stdout).toContain('not enough data'); + expect(r.stdout).not.toContain('scope_appetite: declared'); + }); + + test('derive stores the gap for a dimension with 3 contributing events', () => { + for (let i = 0; i < 3; i++) { + logQuestion({ skill: 'plan-eng-review', question_id: 'plan-eng-review-arch-finding', question_summary: 'arch?', user_choice: 'fix-now', session_id: `s${i}`, ts: `2026-10-2${i}T12:00:00Z` }); + } + expect(runDev('--derive').status).toBe(0); + const file = path.join(tmpHome, 'developer-profile.json'); + const p = readProfile() as any; + expect(p.inferred.contributing_events.architecture_care).toBe(3); + p.declared = { architecture_care: 0.1 }; + fs.writeFileSync(file, JSON.stringify(p)); + expect(runDev('--derive').status).toBe(0); + expect((readProfile() as any).gap.architecture_care).toBeGreaterThan(0.3); + }); + + test('derive stores no gap for a dimension without evidence', () => { + deriveWithOneSignal(); + expect(runDev('--derive').status).toBe(0); + const p = readProfile() as any; + expect(p.gap.scope_appetite).toBeUndefined(); + }); }); // ----------------------------------------------------------------------- From 0542230e632b5e6b2d776aadbe21fb4b8109d0d5 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 05:28:00 -0800 Subject: [PATCH 4/6] test(fixtures): leak scan skips Capy's git shim log in fixture homes (#3860) 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 +- package.json | 2 +- test/git-fixture-template.test.ts | 17 +++++++++++++++++ test/helpers/git-fixture-template.ts | 8 ++++++-- 6 files changed, 34 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fc172340..f32c13f1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.92.80.0] - 2026-11-02 + +**On a Capy machine, the git fixture tests no longer fail locally because of Capy's git shim log.** Capy's `/usr/bin/git` shim appends to `$HOME/.capy/git-shim.log`, so each fixture home that ran git during its template build ended up with a log naming the template. The leak scan in `test/helpers/git-fixture-template.ts` flagged it, and 34 tests in `test/train-backstop.test.ts`, `test/restamp-mentions.test.ts` and `test/pregate-user-policy.test.ts` failed on every Capy lane, though they passed on the remote gate. + +### Tests +- `templateReferences` skips `.capy/git-shim.log`. Nothing reads that file back, and every other file is still scanned. +- `test/git-fixture-template.test.ts` plants the shim log in a template's `home/.capy/` (the copy succeeds), and plants `home/.capy/state.json` as a control (the copy still throws). + ## [1.92.79.0] - 2026-11-02 **/plan-tune no longer reports a mismatch between your stated and observed preferences when it has no observed answers behind it (#3436).** `--check-mismatch` compared a declared value against an inferred value that had never moved from its 0.5 starting point, so a strong declaration with no matching answers printed `scope_appetite: declared 0.9 vs inferred 0.5 (gap 0.4)` even though no logged answer touched that dimension. diff --git a/VERSION b/VERSION index dfb847afe..f17ba7e50 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.79.0 +1.92.80.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 192027e8e..835c2c05f 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.79.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.80.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/package.json b/package.json index b8a73016c..2da268dcb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.79", + "version": "1.92.80", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/git-fixture-template.test.ts b/test/git-fixture-template.test.ts index 76375466c..f63a6c002 100644 --- a/test/git-fixture-template.test.ts +++ b/test/git-fixture-template.test.ts @@ -71,6 +71,23 @@ describe('git fixture template', () => { expect(() => leaky.copy()).toThrow(/still references its template [^\n]*\n.*config/); }); + test("Capy's git shim log in a fixture home (home/.capy/git-shim.log) is not a leak; any other file naming the template still is", () => { + const plant = (rel: string) => gitFixtureTemplate({ prefix: 'gstack-fixture-template-shim-', build: (root) => { + const meta = build(root); + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), '# capy git shim v2: ts cwd ppid parent verb\n2026-11-02T02:15:30Z\t' + path.join(root, 'work') + '\t1\tbun\tcat-file\n'); + return meta; + }, rebind: (copy, tpl) => rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy) }); + const shim = plant(path.join('home', '.capy', 'git-shim.log')); + const other = plant(path.join('home', '.capy', 'state.json')); + try { + const c = shim.copy(); + roots.push(c.root); + expect(git(path.join(c.root, 'work'), 'remote', 'get-url', 'origin')).toBe(path.join(c.root, 'origin.git')); + expect(() => other.copy()).toThrow(/still references its template [^\n]*\n.*state\.json/); + } finally { shim.dispose(); other.dispose(); } + }); + test('native, forward-slash, backslash-escaped and Git Bash spellings are each rewritten, and each one left behind is found', () => { const to = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-fixture-spellings-')); roots.push(to); diff --git a/test/helpers/git-fixture-template.ts b/test/helpers/git-fixture-template.ts index a646747ab..5fb9f72eb 100644 --- a/test/helpers/git-fixture-template.ts +++ b/test/helpers/git-fixture-template.ts @@ -52,7 +52,11 @@ export function rebindGitDir(gitDir: string, from: string, to: string): void { } } -/** Files and symlinks under dir (and the template's meta value) that still name templateRoot; zlib-compressed object storage is skipped. */ +/** + * Files and symlinks under dir (and the template's meta value) that still name templateRoot. Skipped: zlib-compressed object + * storage, and the log Capy's /usr/bin/git shim appends to in whatever HOME it runs under (.capy/git-shim.log), which names + * the template whenever a fixture home ran git during build; nothing reads it back. + */ export function templateReferences(dir: string, templateRoot: string, meta?: unknown): string[] { const needles = pathSpellings(templateRoot).map(s => Buffer.from(s)); const names = (buf: Buffer) => needles.some(n => buf.includes(n)); @@ -62,7 +66,7 @@ export function templateReferences(dir: string, templateRoot: string, meta?: unk const p = path.join(d, entry.name); if (entry.isSymbolicLink()) { if (names(Buffer.from(fs.readlinkSync(p)))) hits.push(p); } else if (entry.isDirectory()) { if (!(path.basename(d) === 'objects' && /^(pack|[0-9a-f]{2})$/.test(entry.name))) walk(p); } - else if (entry.isFile() && names(fs.readFileSync(p))) hits.push(p); + else if (entry.isFile() && !(entry.name === 'git-shim.log' && path.basename(d) === '.capy') && names(fs.readFileSync(p))) hits.push(p); } }; walk(dir); From 1022f4fc51a4771e3838ce6bbf7c19b7e94e67fe Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 05:38:02 -0800 Subject: [PATCH 5/6] v1.92.81.0 feat(ship-release): finish accepts the owner's edit after a fix-check (GST-965) (#3861) * feat(ship-release): finish accepts the owner's edit after a fix-check (GST-965) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-release): fix_check.files narrows the accepted scope; gate-edited and over-cap paths stay behavior (GST-965) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(review-policy): the gate-edited and over-cap exclusions hold with --reviewed-base (GST-965) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-release): a gate-edited test is never owner-accepted; an empty fix_check.files stops (GST-965) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(review-policy): the over-cap exclusion holds with --reviewed-base (GST-965 fix-check note) 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 | 10 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/review-policy.md | 9 +++- lib/ship-release.ts | 57 ++++++++++++++++------ package.json | 2 +- test/ship-rereview-triggers.test.ts | 76 +++++++++++++++++++++++++++++ 7 files changed, 140 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f32c13f1e..209155077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.92.81.0] - 2026-11-02 + +**`gstack-ship-release finish` now accepts the owner's edit after a fix-check instead of asking for another review round (GST-965).** On gbrain #8425, the fix-check found a blocker, the owner's pre-agreed fallback was applied and "then ship" was granted. Finish still classed the edit as behavior and stopped with `insert 5–11.5 → 12–14 → 16`, a third round the R51 cap forbids, so /ship could not get through Step 16 without running its stages by hand. + +### Added +- **`finish --review-loop-exception `** (`lib/ship-release.ts`) takes the same record `gstack-ship-receipt write --review-loop-exception` takes. Paths changed since `fix_check.head` that the branch already touched at that commit (narrowed to the record's `fix_check.files` when it lists them) route as `accepted=` on the `ROUTE:` line, a `REVIEW_LOOP_EXCEPTION:` line names them with the owner, date and channel, and the docs stage carries them (`accepted=` in the DOCS line). A path outside that scope and a gate-edited test (with or without `--reviewed-base`) still route as behavior, and with `--reviewed-base` so does a file too large for the scoped review. A malformed record, or a `fix_check.head` that is not HEAD or an ancestor of it, stops before the release commit. Without the flag nothing changes. + +### Tests +- `test/ship-rereview-triggers.test.ts`: an owner fallback edit to a source file and a doc after the fix-check stops without the record and passes Step 16 with it; a new file outside the scope (even when `fix_check.files` lists it), a fix-check head off the branch, a malformed record, an unreadable record file and an empty `fix_check.files` stop; a `fix_check.files` list narrows what is accepted; a test weakened after the fix-check stops with and without `--reviewed-base`. + ## [1.92.80.0] - 2026-11-02 **On a Capy machine, the git fixture tests no longer fail locally because of Capy's git shim log.** Capy's `/usr/bin/git` shim appends to `$HOME/.capy/git-shim.log`, so each fixture home that ran git during its template build ended up with a log naming the template. The leak scan in `test/helpers/git-fixture-template.ts` flagged it, and 34 tests in `test/train-backstop.test.ts`, `test/restamp-mentions.test.ts` and `test/pregate-user-policy.test.ts` failed on every Capy lane, though they passed on the remote gate. diff --git a/VERSION b/VERSION index f17ba7e50..9a1e28b7f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.80.0 +1.92.81.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 835c2c05f..45e52d15a 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.80.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.81.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/review-policy.md b/docs/review-policy.md index db10c3753..16e93e891 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -429,7 +429,14 @@ rules hash, base and registry commits, the gate scan bound to the head and the r the queue refuses a receipt below the depth its head needs ([ship-policy.md#ship-receipt](ship-policy.md#ship-receipt)). A stop the owner accepted after a fix-check edit goes in `review_loop_exception`, never in prose -under `REVIEW_LOOP:`. +under `REVIEW_LOOP:`. Step 16 takes the same record: +`gstack-ship-release finish --review-loop-exception ` routes the +paths the branch touched at `fix_check.head` (narrowed to the record's +`fix_check.files` when it lists them) and changed since as `accepted=` in +`ROUTE:`, prints them on a `REVIEW_LOOP_EXCEPTION:` line, and carries them +through the docs stage. A path outside that scope and a gate-edited test still +route as behavior, with `--reviewed-base` (as /ship passes it) so does a file +too large for the scoped review, and without the record nothing changes. The receipt's summary line prints `passes= blocking_found= deferred=`, which is what a scorecard tracks (target: a median of 1 pass per PR). A diff --git a/lib/ship-release.ts b/lib/ship-release.ts index bdce14414..02f5218a7 100644 --- a/lib/ship-release.ts +++ b/lib/ship-release.ts @@ -15,6 +15,7 @@ import { mergeChangelogs, normalizeChangelog } from './changelog-check'; import { versionAtRef } from './restamp'; import { classifyPath } from './review-depth'; import { comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type PatchSides } from './ship-patch'; +import { parseReviewLoopException, type ReviewLoopException } from './ship-receipt-depth'; import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs'; import { resolveStateRoot } from './state-root'; import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source'; @@ -27,10 +28,11 @@ export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (s --keep-current (or --version ) keeps VERSION; under bump ask a session no person answers needs --answer before a bump is written finish --base --reviewed-tree [--reviewed-base ] --docs-post [--lane