diff --git a/CHANGELOG.md b/CHANGELOG.md index 06fcf426f..2982fc998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.91.356.0] - 2026-10-31 + +**A reviewer's install no longer upgrades the gstack checkout a lane shares.** On a Capy machine, an /autoplan reviewer runs `install.sh --for review-voice` against the same checkout as the lane that dispatched it. Once the lane's workflow lease was more than 120 minutes old, that install fast-forwarded the checkout and rewrote the install verdict as `for=review-voice`. The lane's next workflow was then refused with `INSTALL_UNVERIFIED`. + +### Fixed +- **`--for review-voice` never self-upgrades the checkout.** When origin is ahead, the session prints `SELF_UPGRADE: skipped (review-voice never self-upgrades a shared checkout; fix: /bin/gstack-upgrade when no other session uses this checkout (UPGRADE_SKIPPED))`, leaves HEAD where it was, and the trailer stays ok with a `behind=` note. This holds with or without a lease, and `GSTACK_UPGRADE_FORCE=1` does not override it. If `bin/gstack-doctor-components.sh` cannot be read, the check still treats `review-voice` as a reviewer install. The bootstrapper still fast-forwards a checkout below the supported minimum. +- **`--check --for review-voice` names `bin/gstack-upgrade` as the fix** instead of the install rerun, which would not move a reviewer checkout. + +### Changed +- **The Captain's digest drops an archived thread from In progress in the same turn** (docs/captain.md, the DIGEST.md template and /captain). + ## [1.91.355.0] - 2026-10-31 **A driven `/ship` on Capy no longer pays one agent turn per finished reviewer, and it can count its own calls.** Replays of gstack-evals #34 and #36 (GSTA-167) made 30 to about 92 parent calls against a target of 12. In the 9-reviewer replay, 23 of 25 waits were the parent waking once per finished reviewer, because the driver told it to end its turn on `wait` while reviewers ran. What reviewers see, which reviewers run, their depth and the blocking floor are unchanged. diff --git a/VERSION b/VERSION index 22e4cdb43..f118f635f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.355.0 +1.91.356.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 210e3d647..26467173d 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.355.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.356.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-capy-session b/bin/gstack-capy-session index a8c0b82f0..e269de2e2 100755 --- a/bin/gstack-capy-session +++ b/bin/gstack-capy-session @@ -29,7 +29,10 @@ # inside a workflow (/ship's docs step) must not change gstack under it. The age # bound matters on Capy, where the leased harness pid is the machine's # long-lived capy-server and a live lease alone would never end the deferral; -# each gstack-skill-start renews it (UPGRADE_BUSY; GSTACK_UPGRADE_FORCE=1 overrides). Lines: `SELF_UPGRADE: done -> ` | `skipped ()` +# each gstack-skill-start renews it (UPGRADE_BUSY; GSTACK_UPGRADE_FORCE=1 overrides). +# An install profile (`--for review-voice`, a dispatched reviewer on a machine +# whose checkout a lane shares) never self-upgrades, lease or not, and +# GSTACK_UPGRADE_FORCE does not override it (UPGRADE_SKIPPED, GST-290). Lines: `SELF_UPGRADE: done -> ` | `skipped ()` # | `none (current)` | `disabled ()`. # --check never moves the checkout; behind origin it prints @@ -70,6 +73,8 @@ fi [ "${GSTACK_CAPY_REMOTE_MIN:-}" = unknown ] && echo "gstack-capy-session: remote minimum unknown (fetch failed); the revision row is local only" # shellcheck source=bin/gstack-install-verdict.sh . "$ROOT/bin/gstack-install-verdict.sh" +# shellcheck source=bin/gstack-doctor-components.sh +. "$ROOT/bin/gstack-doctor-components.sh" 2>/dev/null || gstack_doctor_install_profile() { [ "$1" = review-voice ]; } MIN_WRAPPER=$(gstack_support_env_get "$(gstack_source_root "$ROOT/bin")" GSTACK_CAPY_MIN_WRAPPER) if [ -n "$MIN_WRAPPER" ] && [ "${GSTACK_CAPY_WRAPPER_REV:-0}" -lt "$MIN_WRAPPER" ] 2>/dev/null; then echo "gstack-capy-skill: owner action — Drive copy is wrapper ${GSTACK_CAPY_WRAPPER_REV:-0}, minimum $MIN_WRAPPER; copy $ROOT/capy/skill/gstack/ (CAPY_WRAPPER_STALE)" @@ -108,7 +113,8 @@ if [ "$CHECK" -eq 1 ]; then if behind; then # The rerun fast-forwards only what gstack-upgrade would move; otherwise # gstack-upgrade itself names the refusal and its fix. - if [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then report_behind "unset GSTACK_CAPY_SELF_UPGRADE, then $RERUN" + if gstack_doctor_install_profile "$SKILL"; then report_behind "$ROOT/bin/gstack-upgrade" + elif [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then report_behind "unset GSTACK_CAPY_SELF_UPGRADE, then $RERUN" elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then report_behind "unset GSTACK_CAPY_AUTO_UPGRADE, then $RERUN" elif [ "$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null)" != "$(sed -n "1s/.*branch '\([^']*\)' of .*/\1/p" "$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null)/FETCH_HEAD" 2>/dev/null)" ] \ || [ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ] \ @@ -123,6 +129,11 @@ elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then disabled GSTACK_CAPY_AUTO_UPGRADE elif [ -z "${GSTACK_CAPY_REMOTE_MIN:-}" ] || [ "$GSTACK_CAPY_REMOTE_MIN" = unknown ]; then echo "SELF_UPGRADE: none (remote minimum unknown)" elif ! behind; then echo "SELF_UPGRADE: none (current)" + elif gstack_doctor_install_profile "$SKILL"; then + REASON="$SKILL never self-upgrades a shared checkout; fix: $ROOT/bin/gstack-upgrade when no other session uses this checkout (UPGRADE_SKIPPED)" + echo "SELF_UPGRADE: skipped ($REASON)" + report_behind "$ROOT/bin/gstack-upgrade" + export GSTACK_CAPY_BEHIND_BLOCKED="$REASON" elif [ ! -x "$ROOT/bin/gstack-upgrade" ]; then echo "SELF_UPGRADE: skipped ($ROOT/bin/gstack-upgrade is missing)"; report_behind "$RERUN" export GSTACK_CAPY_BEHIND_BLOCKED="$ROOT/bin/gstack-upgrade is missing; fix: $RERUN (UPGRADE_REFUSED)" elif [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$ROOT/bin/gstack-lease" ] \ diff --git a/captain/SKILL.md b/captain/SKILL.md index 419f294fd..9cfb80418 100644 --- a/captain/SKILL.md +++ b/captain/SKILL.md @@ -329,7 +329,8 @@ turn, three sections in this order, each row with its thread, one line, and an a 2. **New**: what landed or changed since the last digest (merges with versions, READYs received, trains built, incidents), newest first. 3. **In progress**: every working lane, what it owns, its state (building, READY, in - train slot i, blocked on …) and how long since it last reported. + train slot i, blocked on …) and how long since it last reported. A thread the Captain + archives leaves this list in the same turn. Ages make stalls visible: a lane in progress with no report for hours, or a READY waiting longer than `FILL_HOURS`, is the controller's next action. diff --git a/captain/SKILL.md.tmpl b/captain/SKILL.md.tmpl index 4ccf4b36e..6e895baaf 100644 --- a/captain/SKILL.md.tmpl +++ b/captain/SKILL.md.tmpl @@ -257,7 +257,8 @@ turn, three sections in this order, each row with its thread, one line, and an a 2. **New**: what landed or changed since the last digest (merges with versions, READYs received, trains built, incidents), newest first. 3. **In progress**: every working lane, what it owns, its state (building, READY, in - train slot i, blocked on …) and how long since it last reported. + train slot i, blocked on …) and how long since it last reported. A thread the Captain + archives leaves this list in the same turn. Ages make stalls visible: a lane in progress with no report for hours, or a READY waiting longer than `FILL_HOURS`, is the controller's next action. diff --git a/captain/templates/DIGEST.md b/captain/templates/DIGEST.md index 81cf2d3d2..23cfc1357 100644 --- a/captain/templates/DIGEST.md +++ b/captain/templates/DIGEST.md @@ -27,5 +27,7 @@ ask format is gstack docs/captain.md, The ask format. |---|---|---|---| | | | | | +A thread the controller archives leaves this table in the same turn. + Stalls to act on: a lane with no report for longer than , a READY older than `FILL_HOURS` with no train, a slot call unanswered for longer than . diff --git a/docs/captain.md b/docs/captain.md index a57c1066a..201ce60f6 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -377,7 +377,8 @@ field"). `DIGEST.md` is rewritten every controller turn: **Needs you** (decisions only the owner can make, in [the ask format](#the-ask-format); empty is the goal), **New** (what landed or changed, newest first), **In progress** (every lane, what it owns, its state, the age of -its last report). Ages turn stalls into the controller's next action. +its last report). Ages turn stalls into the controller's next action. When the controller +archives a thread, it drops that thread from **In progress** in the same turn. ## The daily flaky pass diff --git a/docs/capy-install.md b/docs/capy-install.md index 8187c7c34..d1f2f5a45 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -47,7 +47,13 @@ upgrade: a reviewer later asked to run a workflow runs the same installer install in place to --for (same checkout ...; no second clone)`), applies the project's pins, runs `./setup` and both host profiles, and writes the workflow's verdict over the reviewer's. Nothing is removed and nothing is -cloned twice. Every /autoplan reviewer prompt +cloned twice. A review-voice session never self-upgrades the checkout, because +the lane on the same machine may be in the middle of a workflow on it: when +origin is ahead it prints `SELF_UPGRADE: skipped (review-voice never +self-upgrades a shared checkout; …)`, certifies the installed revision and +stays ok with `behind= … (UPGRADE_SKIPPED); fix: /bin/gstack-upgrade`. +The bootstrapper still fast-forwards a checkout below the supported minimum. +Every /autoplan reviewer prompt ends with this command (`GSTACK TRAILER`); the warm run's budget, under 20 s, is measured by `test/capy-review-voice.test.ts`. @@ -101,6 +107,11 @@ there is refused with the usual `--for ` fix. note, and a project that needs the newer gstack says so with a floor (next section). `SELF_UPGRADE: none (current)` when nothing is ahead; + `--for review-voice` prints `SELF_UPGRADE: skipped (review-voice never + self-upgrades a shared checkout; fix: /bin/gstack-upgrade when no other + session uses this checkout (UPGRADE_SKIPPED))` whenever origin is ahead, + with or without a lease, and `GSTACK_UPGRADE_FORCE=1` does not change that + ([reviewer machines](#reviewer-machines---for-review-voice)); `GSTACK_CAPY_SELF_UPGRADE=off` (or `GSTACK_CAPY_AUTO_UPGRADE=0`) prints `SELF_UPGRADE: disabled ()`. `--check` never moves the checkout; behind origin it prints `gstack-capy-session: behind origin/: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e5119eb6c..471282f4e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1525,7 +1525,8 @@ yourself; or change the pin in the project. **Meaning.** An install-mode session start above the supported minimum found the checkout behind origin and could not fast-forward it: the checkout is dirty, diverged or pinned (`UPGRADE_REFUSED`), another live session holds a -lease (`UPGRADE_BUSY`), or `bin/gstack-upgrade` is missing +lease (`UPGRADE_BUSY`), `bin/gstack-upgrade` is missing, or the session is a +`--for review-voice` reviewer, which never moves a checkout a lane may share (`UPGRADE_SKIPPED`). Nothing was changed, the doctor certified the installed revision, and the trailer stays `gstack: ok` with the notes above: a gap above the minimum is never a hard fail. The verdict records the installed diff --git a/package.json b/package.json index 5ee04070f..e850982dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.355", + "version": "1.91.356", "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/capy-review-voice.test.ts b/test/capy-review-voice.test.ts index 74d852c11..6d6861bdd 100644 --- a/test/capy-review-voice.test.ts +++ b/test/capy-review-voice.test.ts @@ -150,16 +150,19 @@ describe('--for review-voice through the no-Drive brief', () => { expect(warm.ms).toBeLessThan(WARM_BUDGET_MS); }, 300_000); - test('origin moves: the same brief fast-forwards the checkout before it certifies', () => { + // GST-290: the reviewer's machine may share this checkout with a lane mid-workflow. + test('origin moves: the same brief never moves the checkout; it certifies the installed revision and reports behind', () => { + const head = git(['rev-parse', 'HEAD'], root); fs.writeFileSync(path.join(origin, 'REVIEW_VOICE_FIXTURE.md'), 'moved\n'); git(['add', '-A'], origin); git(['commit', '-qm', 'origin moves'], origin); - const tip = git(['rev-parse', 'HEAD'], origin); const moved = brief(); expect(moved.status, moved.out + moved.stderr).toBe(0); - expect(moved.out).toContain(`SELF_UPGRADE: done ${VERSION} -> ${VERSION}`); - expect(git(['rev-parse', 'HEAD'], root)).toBe(tip); - expect(trailer(moved.out)).toBe(OK_TRAILER); + expect(moved.out).toContain(`SELF_UPGRADE: skipped (review-voice never self-upgrades a shared checkout; fix: ${root}/bin/gstack-upgrade when no other session uses this checkout (UPGRADE_SKIPPED))`); + expect(moved.out).not.toContain('SELF_UPGRADE: done'); + expect(git(['rev-parse', 'HEAD'], root)).toBe(head); + expect(trailer(moved.out)).toStartWith(`gstack: ok ${VERSION} for=review-voice (pins: skipped (helper runtime); behind=1 `); + expect(trailer(moved.out)).toContain('(UPGRADE_SKIPPED)'); }, 300_000); test('a review-voice check never narrows an ok verdict a fuller install wrote for this HEAD and project', () => { diff --git a/test/capy-skill-wrapper.test.ts b/test/capy-skill-wrapper.test.ts index 90ff0882c..c80cdbb13 100644 --- a/test/capy-skill-wrapper.test.ts +++ b/test/capy-skill-wrapper.test.ts @@ -85,7 +85,7 @@ function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B' } = {}) { if ((opts.cloneAt ?? 'A') === 'A') git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base); write(path.join(seed, 'VERSION'), '1.0.1\n'); write(path.join(seed, 'capy/support.env'), `GSTACK_MIN_REVISION=1.0.1\nGSTACK_CAPY_MIN_WRAPPER=${opts.minWrapper ?? 1}\n`); - for (const rel of ['capy/skill/gstack/scripts/install.sh', 'capy/skill/gstack/SKILL.md', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh', ...UPGRADE_DEPS]) { + for (const rel of ['capy/skill/gstack/scripts/install.sh', 'capy/skill/gstack/SKILL.md', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh', 'bin/gstack-doctor-components.sh', ...UPGRADE_DEPS]) { if (fs.existsSync(path.join(REPO, rel))) write(path.join(seed, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755); } write(path.join(seed, 'bin/gstack-capy-install'), STUB_INSTALLER, 0o755); @@ -420,6 +420,58 @@ describe('capy skill: the session hands the installer GSTACK_CAPY_BEHIND', () => expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c); }); + // GST-290: a dispatched reviewer's install ran with no lease of its own, fast-forwarded the + // shared checkout and rewrote the verdict to for=review-voice, so the lane's next autoplan + // admission refused (INSTALL_UNVERIFIED). A review-voice install never moves the checkout. + describe('--for review-voice never self-upgrades a shared checkout', () => { + const blocked = (root: string) => `review-voice never self-upgrades a shared checkout; fix: ${root}/bin/gstack-upgrade when no other session uses this checkout (UPGRADE_SKIPPED)`; + const behindFixture = () => { + const f = fixture({ cloneAt: 'B' }); + write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755); + f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']); + return f; + }; + + test('install mode behind origin with no lease: skipped with the reason, HEAD stays, and the installer gets behind, gstack-upgrade as the fix and the blocked reason', () => { + const f = behindFixture(); + const r = f.run(['--for', 'review-voice']); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain(`SELF_UPGRADE: skipped (${blocked(f.root)})\n`); + expect(r.stdout).not.toContain('SELF_UPGRADE: done'); + expect(r.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade blocked=${blocked(f.root)}`); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + expect(f.run(['--for', 'review-voice'], { GSTACK_UPGRADE_FORCE: '1' }).stdout).toContain(`SELF_UPGRADE: skipped (${blocked(f.root)})\n`); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + }); + + test('install mode behind origin with bin/gstack-doctor-components.sh missing: still skipped (the profile check fails closed for review-voice)', () => { + const f = behindFixture(); + f.git(['update-index', '--assume-unchanged', 'bin/gstack-doctor-components.sh']); + fs.rmSync(path.join(f.root, 'bin/gstack-doctor-components.sh')); + const r = f.run(['--for', 'review-voice']); + expect(r.stdout).toContain(`SELF_UPGRADE: skipped (${blocked(f.root)})\n`); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + }); + + test('install mode on a current checkout: none (current), and no blocked reason', () => { + const f = fixture({ cloneAt: 'B' }); + f.git(['fetch', '-q', 'origin', 'main']); + f.git(['merge', '--ff-only', '-q', 'FETCH_HEAD']); + write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755); + f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']); + const r = f.run(['--for', 'review-voice']); + expect(r.stdout).toMatch(/^SELF_UPGRADE: none \(current\)$/m); + expect(r.last).toBe('gstack: ok 1.0.2 for=autoplan behind=none to=none fix=none'); + }); + + test('--check behind origin: the fix is gstack-upgrade, not the install rerun (which would not move a review-voice checkout)', () => { + const f = behindFixture(); + const r = f.run(['--check', '--for', 'review-voice']); + expect(r.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade`); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + }); + }); + test('an older bootstrapper handing --for none to the session in install mode: the session certifies nothing and moves nothing (no self-upgrade, HEAD unchanged), and reports behind like --check', () => { const f = fixture({ cloneAt: 'B' }); write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);