fix(capy): a --for review-voice install never self-upgrades the shared checkout (GST-290) (#3667)

Also: the Captain digest drops an archived thread from In progress in the same turn (GST-371).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-31 07:35:05 +00:00
1 parent b6cb70aea1
commit 99706a47bb
13 files changed
+110 -16

No files matched your search

+11
View File
@@ -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: <root>/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.
+1 -1
View File
@@ -1 +1 @@
1.91.355.0
1.91.356.0
+1 -1
View File
@@ -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,
+13 -2
View File
@@ -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 <old> -> <new>` | `skipped (<reason>)`
# 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 <old> -> <new>` | `skipped (<reason>)`
# | `none (current)` | `disabled (<the variable: GSTACK_CAPY_SELF_UPGRADE=off or
# GSTACK_CAPY_AUTO_UPGRADE=0>)`.
# --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" ] \
+2 -1
View File
@@ -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.
+2 -1
View File
@@ -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.
+2
View File
@@ -27,5 +27,7 @@ ask format is gstack docs/captain.md, The ask format.
|---|---|---|---|
| <code> | <files or area> | <building / READY / train slot i / blocked on …> | <age> |
A thread the controller archives leaves this table in the same turn.
Stalls to act on: a lane with no report for longer than <N hours>, a READY older than
`FILL_HOURS` with no train, a slot call unanswered for longer than <N minutes>.
+2 -1
View File
@@ -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
+12 -1
View File
@@ -47,7 +47,13 @@ upgrade: a reviewer later asked to run a workflow runs the same installer
install in place to --for <skill> (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=<n> … (UPGRADE_SKIPPED); fix: <root>/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 <skill>` 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: <root>/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 (<variable>)`. `--check` never moves the checkout;
behind origin it prints `gstack-capy-session: behind origin/<branch>:
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+8 -5
View File
@@ -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', () => {
+53 -1
View File
@@ -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);