v1.91.361.0 fix(capy): reviewer installs leave the lane's launch environment alone; gstack-upgrade counts capy-server leases (GST-395/396/401/402) (#3673)

* fix(capy): reviewer installs leave the lane's launch environment alone; gstack-upgrade counts capy-server leases (GST-395/396/401/402)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(capy-install): restore the header comment prefixes; borrow the lane's launch file only when it reaches a Bun (GST-401)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(capy-install): gate the store-download borrow test to Linux like its siblings

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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-31 10:23:41 +00:00
1 parent 5276afe3f2
commit 4f02cb5784
19 files changed
+331 -44

No files matched your search

+13
View File
@@ -1,5 +1,18 @@
# Changelog
## [1.91.361.0] - 2026-10-31
**A reviewer's install no longer rewrites the lane's launch environment, and a hand-run `gstack-upgrade` on Capy no longer moves a checkout under another session.** These are follow-ups to the v1.91.356.0 fix that stopped reviewer installs from upgrading the shared checkout.
### Fixed
- **A `--for review-voice` install that keeps the lane's fuller install verdict now borrows that install's launch environment.** It writes no launch file and announces the lane's own `GSTACK_LAUNCH_FILE` and `GSTACK_LAUNCH_PATH`. Before, it rewrote the lane's `<verdict>.env` and could repoint `<verdict>.launch/bun` at gstack's minimum Bun instead of the project's pinned Bun. A reviewer's own launch directory now lives under `<verdict>.profile/review-voice.launch` (mode 0700), and a failing review-voice check removes `<verdict>.profile/`.
- **`bin/gstack-upgrade` on Capy counts a fresh `capy-server` lease as another session's.** Every session on a Capy machine runs under one `capy-server` process, so its own-session exclusion used to drop a sibling's lease and move the checkout at once. It now refuses with `a shared harness (capy-server, pid <p>) holds a lease <n>m old; … (UPGRADE_BUSY)` until that lease is 120 minutes old. `GSTACK_UPGRADE_FORCE=1` still overrides. `gstack-lease live` keeps its meaning for the session start and the team-mode hook; only `gstack-upgrade` passes the new `--count-shared-harness` flag.
- **Fix hints after a skipped upgrade name the 120-minute wait or `GSTACK_UPGRADE_FORCE=1`** instead of a bare `bin/gstack-upgrade`, which on Capy now refuses while a lease is fresh.
- **The /autoplan reviewer prompt no longer says its install fast-forwards the checkout.** It clones when absent and otherwise uses the checkout as it is.
### Changed
- **Documented: the bootstrapper still fast-forwards a checkout below the supported minimum, for a reviewer too.** Below it, every session's trailer on that checkout fails, and the minimum rises only for install-honesty or security fixes. A test locks this rule.
## [1.91.360.0] - 2026-10-31
**A PR's changelog.d fragment no longer brings back every paid gate case.** Since fragments became the way every PR records its CHANGELOG entry (v1.91.342.0), the PR eval selector read each `changelog.d/<slug>.md` as an unknown dependency and restored the full gate, so it saved nothing on any PR (GST-400, found on #3668).
+1 -1
View File
@@ -1 +1 @@
1.91.360.0
1.91.361.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.360.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.361.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,
+22 -3
View File
@@ -29,7 +29,10 @@
# reuse, Bun at gstack's own supported minimum with the project's pins never
# applied, no ./setup, no host profiles, no shim; the doctor certifies only the
# read-only helpers, and an ok verdict a fuller install wrote for this HEAD is
# kept. Without the Drive skill, gstack_capy_bootstrap_cmd prints the command
# kept: the profile then writes no launch file and announces that install's
# own launch file and path, so the lane's environment is never rewritten
# (GST-401). A profile's own launch directory lives under <verdict>.profile/,
# never <verdict>.launch. Without the Drive skill, gstack_capy_bootstrap_cmd prints the command
# that clones into an explicit root and runs the checkout's own bootstrapper.
# Chromium is lazy by default (bin/gstack-browser-ensure
# installs it on first use); --with-browser installs it now. The last lines
@@ -382,6 +385,11 @@ if [ -n "$RES_BIN" ]; then
esac
if [ -z "$LAUNCH_DIR" ]; then
LAUNCH_DIR="${VERDICT%.json}.launch"
if [ "$PROFILE" -eq 1 ]; then
LAUNCH_DIR="${VERDICT%.json}.profile/$FOR.launch"
(umask 077 && mkdir -p "${VERDICT%.json}.profile") \
|| stop INSTALL_PREFLIGHT_FAILED "could not create ${VERDICT%.json}.profile" "make $(dirname "$VERDICT") writable, then re-run"
fi
gstack_launch_dir "$LAUNCH_DIR" "$RES_BIN" "$_launch_node" \
|| stop INSTALL_PREFLIGHT_FAILED "could not publish the launch directory $LAUNCH_DIR" "make $(dirname "$LAUNCH_DIR") writable, then re-run"
fi
@@ -684,14 +692,25 @@ write_launch_file() {
if [ -n "${GSTACK_PREFERENCES_FILE:-}" ]; then echo "export GSTACK_PREFERENCES_FILE=$(shq "$GSTACK_PREFERENCES_FILE")"; fi
} > "$tmp" && chmod 0600 "$tmp" && mv -f "$tmp" "$LAUNCH_FILE" || { rm -f "$tmp"; return 1; }
}
if [ "$RC" -eq 0 ] && ! write_launch_file; then
# A profile check that keeps a fuller ok verdict borrows that install's launch
# file and path instead of writing over them (GST-401), unless that install
# recorded no launch path while this shell needs one to reach its Bun.
BORROWED=0 _kept_launch=""
verdict_kept && _kept_launch=$(gstack_install_verdict_field "$VERDICT" launch_path)
if [ "$RC" -eq 0 ] && verdict_kept && [ -f "$LAUNCH_FILE" ] && { [ -n "$_kept_launch" ] || [ -z "$LAUNCH_DIR" ]; }; then
BORROWED=1 LAUNCH_DIR=$_kept_launch
elif [ "$RC" -eq 0 ] && verdict_kept; then
LAUNCH_FILE="${VERDICT%.json}.profile/$FOR.env"
(umask 077 && mkdir -p "${VERDICT%.json}.profile") || true
fi
if [ "$RC" -eq 0 ] && [ "$BORROWED" -eq 0 ] && ! write_launch_file; then
TRAILER="gstack: fail launch-file — could not write the launch file $LAUNCH_FILE; fix: make $(dirname "$LAUNCH_FILE") writable, or set GSTACK_STATE_ROOT to a writable directory (LAUNCH_FILE_UNWRITABLE)"
RC=1
fi
# A failing run announces no launch file and removes the one its verdict owned
# (where the directory lets it); a profile check that keeps a fuller ok verdict
# leaves that verdict's file in place.
[ "$RC" -eq 0 ] || { verdict_kept || rm -f "$LAUNCH_FILE" 2>/dev/null; LAUNCH_FILE=""; }
[ "$RC" -eq 0 ] || { [ "$PROFILE" -eq 1 ] && rm -rf "${VERDICT%.json}.profile" 2>/dev/null; verdict_kept || rm -f "$LAUNCH_FILE" 2>/dev/null; LAUNCH_FILE=""; }
record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)"
[ "$CHECK_ONLY" -eq 1 ] || [ "$PROFILE" -eq 1 ] || write_shim
printf '%s\n' "$OUT" | grep -v '^gstack: '
+2 -2
View File
@@ -130,7 +130,7 @@ elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then
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)"
REASON="$SKILL never self-upgrades a shared checkout; fix: GSTACK_UPGRADE_FORCE=1 $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"
@@ -144,7 +144,7 @@ elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then
while read -r _pid _age; do HOLDERS="$HOLDERS${HOLDERS:+, }pid $_pid ($_age)"; done <<EOF_OWN
$OWN
EOF_OWN
REASON="a gstack run in this session holds a workflow lease on this installation: $HOLDERS, so the upgrade waits until the lease is 120 minutes old; fix: $ROOT/bin/gstack-upgrade once the run ends (UPGRADE_BUSY)"
REASON="a gstack run in this session holds a workflow lease on this installation: $HOLDERS, so the upgrade waits until the lease is 120 minutes old; fix: $ROOT/bin/gstack-upgrade once the lease is 120 minutes old, or GSTACK_UPGRADE_FORCE=1 $ROOT/bin/gstack-upgrade when no other session uses this checkout (UPGRADE_BUSY)"
echo "SELF_UPGRADE: skipped ($REASON)"
report_behind "$ROOT/bin/gstack-upgrade"
export GSTACK_CAPY_BEHIND_BLOCKED="$REASON"
+12
View File
@@ -24,3 +24,15 @@ gstack_harness_pid() {
done
printf '%s\n' "$_hp_pid"
}
# Harnesses that run every session on a machine under one process: Capy runs
# each tool call as a fresh shell under capy-server, so every session's lease
# names the same pid and a caller's own ancestry includes every sibling's
# holder. Matched on comm, path and a leading - stripped as above.
GSTACK_SHARED_HARNESSES="capy-server"
# gstack_shared_harness PID — true when PID's command is a shared harness.
gstack_shared_harness() {
_sh_comm=$(ps -o comm= -p "$1" 2>/dev/null | head -1) || return 1
_sh_comm=${_sh_comm##*/}; _sh_comm=${_sh_comm#-}
case " $GSTACK_SHARED_HARNESSES " in *" $_sh_comm "*) [ -n "$_sh_comm" ] ;; *) return 1 ;; esac
}
+15 -7
View File
@@ -16,11 +16,14 @@
# passes its parent pid); leases older than 120 min are pruned. Exits 3
# and writes nothing when a live process holds the directory lock or
# the lock cannot be taken
# gstack-lease live [--root <checkout>] [--exclude-ancestry-of <pid>]
# gstack-lease live [--root <checkout>] [--exclude-ancestry-of <pid>] [--count-shared-harness]
# print `<pid> <age>m <command>` per lease held by a live process that
# is neither the given pid nor one of its ancestors (the caller's own
# session); exit 0 when any live lease exists, 1 when none. A stale lease
# is reclaimed (deleted, one `gstack-lease: reclaimed …` line on stderr)
# is reclaimed (deleted, one `gstack-lease: reclaimed …` line on stderr).
# --count-shared-harness (bin/gstack-upgrade only) still prints an
# ancestor that is a shared harness (capy-server: every session on the
# machine runs under it) while its lease is under 120 minutes old
# gstack-lease list [--root <checkout>]
# print `<pid> <age>m alive|dead|stale` for every lease
# A pid alone is not a holder: pids are reused after a reboot or a machine
@@ -38,23 +41,27 @@ BIN="$(cd "$(dirname "$0")" && pwd -P)"
# shellcheck source=bin/gstack-install-verdict.sh
. "$BIN/gstack-install-verdict.sh" 2>/dev/null || gstack_source_root() { (cd "$1/.." && pwd -P); }
# shellcheck source=bin/gstack-harness-pid.sh
. "$BIN/gstack-harness-pid.sh" 2>/dev/null || gstack_harness_pid() { printf '%s\n' "$1"; }
. "$BIN/gstack-harness-pid.sh" 2>/dev/null || {
gstack_harness_pid() { printf '%s\n' "$1"; }
gstack_shared_harness() { [ "$(ps -o comm= -p "$1" 2>/dev/null | head -1 | sed 's|.*/||; s/^-//')" = capy-server ]; }
}
VERB="${1:-}"; [ $# -gt 0 ] && shift
ROOT="" PID="" EXCLUDE="" FROM=""
ROOT="" PID="" EXCLUDE="" FROM="" SHARED=0
while [ $# -gt 0 ]; do
case "$1" in
--root) ROOT="${2:-}"; shift ;;
--pid) PID="${2:-}"; shift ;;
--from) FROM="${2:-}"; shift ;;
--exclude-ancestry-of) EXCLUDE="${2:-}"; shift ;;
--count-shared-harness) SHARED=1 ;;
*) echo "gstack-lease: unknown argument '$1'" >&2; exit 2 ;;
esac
shift
done
case "$VERB" in
touch|live|list) ;;
-h|--help|'') sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
-h|--help|'') sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "gstack-lease: unknown verb '$VERB'" >&2; exit 2 ;;
esac
[ -n "$ROOT" ] || ROOT=$(gstack_source_root "$BIN")
@@ -290,8 +297,9 @@ FOUND=1
for f in "$DIR"/*; do
[ -f "$f" ] || continue
p=${f##*/}
case "$ANCESTRY" in *" $p "*) continue ;; esac
_rec=$(head -1 "$f" 2>/dev/null) _age=$(age_min "$f")
_age=$(age_min "$f")
case "$ANCESTRY" in *" $p "*) [ "$SHARED" -eq 1 ] && [ "$_age" -lt 120 ] && gstack_shared_harness "$p" || continue ;; esac
_rec=$(head -1 "$f" 2>/dev/null)
case "$(state "$p" "$_rec" "$_age")" in
dead) continue ;;
stale)
+17 -5
View File
@@ -64,6 +64,8 @@ bounded() { if command -v timeout >/dev/null 2>&1; then timeout "${GSTACK_UPGRAD
# Git for Windows answers --show-toplevel as C:/x while pwd -P in Git Bash is
# /c/x; compare the paths, not their spellings.
# shellcheck source=bin/gstack-harness-pid.sh
. "$ROOT/bin/gstack-harness-pid.sh" 2>/dev/null || GSTACK_SHARED_HARNESSES="capy-server"
# shellcheck source=bin/gstack-path-spelling.sh
. "$ROOT/bin/gstack-path-spelling.sh" 2>/dev/null || gstack_path_same() { [ "$1" = "$2" ]; }
TOPLEVEL=$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null)
@@ -208,14 +210,24 @@ if ! git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null; then
fi
# Workflow leases (bin/gstack-lease): sessions sharing this installation that
# are alive and not this process's own chain. Moving the checkout under them
# is what the lease exists to prevent; the caller's own session never counts.
# is what the lease exists to prevent; the caller's own session never counts,
# except under a shared harness (capy-server), where every session on the
# machine is in the caller's chain: its lease counts until it is 120 minutes
# old (GST-396).
if [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$ROOT/bin/gstack-lease" ] \
&& BUSY=$("$ROOT/bin/gstack-lease" live --root "$ROOT" --exclude-ancestry-of $$ 2>/dev/null); then
HOLDERS=""
while read -r _pid _age; do HOLDERS="$HOLDERS${HOLDERS:+, }pid $_pid ($_age)"; done <<EOF_BUSY
&& BUSY=$("$ROOT/bin/gstack-lease" live --root "$ROOT" --exclude-ancestry-of $$ --count-shared-harness 2>/dev/null); then
HOLDERS="" SHARED_HOLDER=""
while read -r _pid _age _comm; do
HOLDERS="$HOLDERS${HOLDERS:+, }pid $_pid ($_age${_comm:+ $_comm})"
case " $GSTACK_SHARED_HARNESSES " in *" ${_comm:-none} "*) SHARED_HOLDER="$_comm, pid $_pid) holds a lease $_age old" ;; esac
done <<EOF_BUSY
$BUSY
EOF_BUSY
echo "gstack-upgrade: live gstack sessions hold this installation: $HOLDERS; nothing was changed; fix: wait for those sessions to finish, or re-run with GSTACK_UPGRADE_FORCE=1 when you know they are idle (UPGRADE_BUSY)" >&2
if [ -n "$SHARED_HOLDER" ]; then
echo "gstack-upgrade: a shared harness ($SHARED_HOLDER; every session on this machine runs under it, so the upgrade waits until the lease is 120 minutes old (counted from the last renewal by any session); nothing was changed; fix: re-run then, or with GSTACK_UPGRADE_FORCE=1 when no other session uses this checkout (UPGRADE_BUSY)" >&2
else
echo "gstack-upgrade: live gstack sessions hold this installation: $HOLDERS; nothing was changed; fix: wait for those sessions to finish, or re-run with GSTACK_UPGRADE_FORCE=1 when you know they are idle (UPGRADE_BUSY)" >&2
fi
exit 3
fi
# shellcheck source=bin/gstack-bun-version.sh
+26 -7
View File
@@ -31,7 +31,8 @@ the root they checked.
A dispatched reviewer (an /autoplan voice, a second opinion) reads a plan and
reports; it runs no workflow. `--for review-voice` gives its machine a real
trailer in seconds: clone or fast-forward, Bun at gstack's own supported
trailer in seconds: a clone when absent (an existing checkout is used as it
is, unless it is below the supported minimum), Bun at gstack's own supported
minimum (the project's pins are not applied, since no repo tests run there; the
trailer says `pins: skipped (helper runtime)`), no `./setup`, no host profiles
and no bun shim. The doctor certifies only the read-only helpers (`PASS
@@ -52,8 +53,18 @@ 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
The bootstrapper still fast-forwards a checkout below the supported minimum,
for a reviewer too (GST-395): below it every session's trailer on that checkout
fails (REVISION_BELOW_MINIMUM), the lane's own next install would move it
anyway, and the minimum rises only for install-honesty or security fixes.
When a reviewer keeps a fuller verdict, it also leaves that install's launch
environment alone (GST-401): it writes no launch file and announces the fuller
install's own `GSTACK_LAUNCH_FILE` and `GSTACK_LAUNCH_PATH`, so the lane's
`<verdict>.env` and `<verdict>.launch` are never rewritten and the reviewer
runs the helpers on the Bun that verdict certified. A launch directory the
reviewer needs for its own Bun lives under `<verdict>.profile/review-voice.launch`
(mode 0700), never `<verdict>.launch`; a failing review-voice check removes
`<verdict>.profile/`. 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`.
@@ -108,8 +119,9 @@ there is refused with the usual `--for <skill>` fix.
(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,
self-upgrades a shared checkout; fix: GSTACK_UPGRADE_FORCE=1
<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
@@ -174,8 +186,15 @@ session holds a workflow lease on this installation: ... (UPGRADE_BUSY))`,
leaves the checkout where the run started and stays ok with `behind=`. Each
`gstack-skill-start` renews the lease. On Capy the leased pid is the machine's
long-lived `capy-server`, so the deferral ends when the lease turns 120
minutes old, not when the process exits; `bin/gstack-upgrade` run by hand
moves the checkout sooner. The team-mode SessionStart hook
minutes old, not when the process exits. Every session on the machine runs
under that same `capy-server`, so `bin/gstack-upgrade` run by hand also
counts a `capy-server` lease under 120 minutes old (it may be a sibling's) and
refuses with `a shared harness (capy-server, pid <p>) holds a lease <n>m old;
… (UPGRADE_BUSY)`; `GSTACK_UPGRADE_FORCE=1` moves it sooner when no other
session uses the checkout (GST-396). The age counts from the last renewal by
any session, so a workflow idle for more than 120 minutes since its last
`gstack-skill-start` can be moved by any session on the machine: Capy gives
the shell no per-session identity to tell the leases apart. The team-mode SessionStart hook
(`bin/gstack-session-update`) honors the same leases: it logs
`SKIP leased pid=<p>/<age>m` to `analytics/session-update.log` and tries again at
the next session start, without writing its hourly throttle stamp. A lease whose
+12 -5
View File
@@ -1554,7 +1554,7 @@ branch), then re-run `bin/gstack-upgrade`. A reinstall that failed after the
fast-forward is completed by the next run (`reinstall pending from <version>`).
<a id="upgrade-busy"></a>
### `gstack-upgrade: live gstack sessions hold this installation: pid <p> (<age>m), ...; nothing was changed; fix: ... (UPGRADE_BUSY)`
### `gstack-upgrade: live gstack sessions hold this installation: pid <p> (<age>m), ...; nothing was changed; fix: ... (UPGRADE_BUSY)` / `gstack-upgrade: a shared harness (capy-server, pid <p>) holds a lease <n>m old; ... (UPGRADE_BUSY)`
**Meaning.** Several agents share this checkout (Capy subagents on one
machine, say). Each session start writes a workflow lease,
@@ -1567,20 +1567,27 @@ process has exited never blocks, and neither does a stale one: each lease
records its process's start identity, so a pid reused after a reboot or a
machine wake (GBRA-105's lease named pid 379, which had become dbus-daemon)
is reclaimed instead of blocking. The upgrader's own session (the pid chain it
runs under) never counts for `bin/gstack-upgrade` run by hand. A Capy install
runs under) never counts for `bin/gstack-upgrade` run by hand, except under a
shared harness: on Capy every tool call of every session runs under one
`capy-server`, so a `capy-server` lease in the caller's chain may be a
sibling's. `bin/gstack-upgrade` counts it until it is 120 minutes old, counted
from the last renewal by any session on the machine (`a shared harness
(capy-server, pid <p>) holds a lease <n>m old; …`, GST-396). A Capy install
in install mode (`bin/gstack-capy-session`) counts it while that lease is under
120 minutes old: an install run from inside a workflow that holds the lease
(/ship's docs step, #3571) prints `SELF_UPGRADE: skipped (a gstack run in this
session holds a workflow lease on this installation: pid <p> (<age>m
<command>), so the upgrade waits until the lease is 120 minutes old; fix:
<checkout>/bin/gstack-upgrade once the run ends (UPGRADE_BUSY))` and leaves the
<checkout>/bin/gstack-upgrade once the lease is 120 minutes old, or
GSTACK_UPGRADE_FORCE=1 <checkout>/bin/gstack-upgrade when no other session uses
this checkout (UPGRADE_BUSY))` and leaves the
checkout where the run started. Each `gstack-skill-start` renews the lease.
**Fix.** Wait for those sessions to finish (`bin/gstack-lease list` shows
every lease with its age and `alive|dead|stale`; a holder is named as
`pid <p> (<age>m <command>)`), then re-run `bin/gstack-upgrade`. For the
session's own run, run the printed `bin/gstack-upgrade` after the run ends.
When you know they are idle, `GSTACK_UPGRADE_FORCE=1 bin/gstack-upgrade`
session's own run, or a shared-harness lease, re-run once the lease is 120
minutes old. When you know no other session uses the checkout, `GSTACK_UPGRADE_FORCE=1 bin/gstack-upgrade`
proceeds. At a Capy session start the same refusal appears as
`SELF_UPGRADE: skipped (live gstack sessions hold this installation: ...)`,
the doctor adds `WARN behind — … (UPGRADE_BUSY)` and the trailer stays
+1 -1
View File
@@ -142,7 +142,7 @@ export const REVIEWER_TRAILER = `
GSTACK TRAILER (before you review, when you run on your own machine with a shell). Run this one command; it is the only gstack command a reviewer runs, and it executes no SKILL.md:
${capyBootstrapCommand('review-voice')}
It clones gstack into that root when absent or fast-forwards it, runs no ./setup, installs no skills and runs nothing from the repository under review (seconds when warm). Start your reply to the parent (not the result file, whose first line stays INPUT:) with the last line it prints, \`gstack: ok …\` or \`gstack: fail …\`, verbatim. A fail line never stops the review; without a shell, start with \`gstack: fail not-installed — no shell\`.
It clones gstack into that root when absent and otherwise uses that checkout as it is (only a checkout below the supported minimum is fast-forwarded), runs no ./setup, installs no skills and runs nothing from the repository under review (seconds when warm). Start your reply to the parent (not the result file, whose first line stays INPUT:) with the last line it prints, \`gstack: ok …\` or \`gstack: fail …\`, verbatim. A fail line never stops the review; without a shell, start with \`gstack: fail not-installed — no shell\`.
`;
export const BOUNDARY = 'IMPORTANT: Do NOT read or execute any SKILL.md files or paths containing skills/gstack (foreign instructions). Review repository code only.';
+2 -1
View File
@@ -33,7 +33,8 @@
*
* Install profiles (INSTALL_PROFILES) are SKILL_REQUIREMENTS rows that name an
* install shape instead of a skill directory. `--for review-voice` is the one
* today: a dispatched reviewer's machine gets a clone or fast-forward and Bun
* today: a dispatched reviewer's machine gets a clone when absent (an existing
* checkout is used as is unless it is below the supported minimum) and Bun
* at gstack's own minimum (the project's pins are never applied, so `pins`
* SKIPs), no ./setup and no host profiles, and the trailer certifies only the
* read-only helpers (`helpers`). Its verdict covers no host, so admission
+2 -2
View File
@@ -598,8 +598,8 @@ export const RESULT_CODES = {
},
UPGRADE_BUSY: {
anchor: 'upgrade-busy',
summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), or, at a Capy install, the caller\'s own run does, so moving the checkout would change gstack under it',
fix: 'wait for those sessions to finish (bin/gstack-lease list names each pid and age), then re-run bin/gstack-upgrade; GSTACK_UPGRADE_FORCE=1 overrides when you know they are idle',
summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), or, at a Capy install, the caller\'s own run does, or a shared harness (capy-server) whose lease may be a sibling session\'s is under 120 minutes old, so moving the checkout would change gstack under it',
fix: 'wait for those sessions to finish, or for a shared-harness lease to turn 120 minutes old (bin/gstack-lease list names each pid and age), then re-run bin/gstack-upgrade; GSTACK_UPGRADE_FORCE=1 overrides when you know no other session uses the checkout',
},
UPGRADE_FETCH_FAILED: {
anchor: 'upgrade-fetch-failed',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.360",
"version": "1.91.361",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+44 -6
View File
@@ -17,7 +17,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { writePrompts } from '../lib/autoplan-prompts';
import { CAPY_INSTALL_ROOT, capyBootstrapCommand } from '../lib/doctor-components';
import { capyHost, type CapyHost } from './helpers/capy-host';
import { capyHost, writeBunStub, type CapyHost } from './helpers/capy-host';
const REPO = path.resolve(import.meta.dir, '..');
const VERSION = fs.readFileSync(path.join(REPO, 'VERSION'), 'utf8').split('\n')[0]!.trim();
@@ -91,7 +91,8 @@ describe('--for review-voice through the no-Drive brief', () => {
expect(out).toMatch(/^SKIP claude \(not needed; /m);
expect(out).not.toContain('gstack ready');
const launchDir = out.match(/GSTACK_LAUNCH_PATH: (\S+)/)?.[1] ?? '';
expect(launchDir).toBe(out.match(/GSTACK_INSTALL_VERDICT=(\S+)\.json/)![1] + '.launch');
expect(launchDir).toBe(out.match(/GSTACK_INSTALL_VERDICT=(\S+)\.json/)![1] + '.profile/review-voice.launch');
expect(fs.statSync(path.dirname(launchDir)).mode & 0o777).toBe(0o700);
expect(out).toContain(`GSTACK_LAUNCH: PATH=${launchDir}:$PATH `);
expect(fs.realpathSync(path.join(launchDir, 'bun'))).toBe(fs.realpathSync(path.join(host.bunInstallBin, 'bun')));
expect(trailer(out)).toBe(OK_TRAILER);
@@ -158,21 +159,58 @@ describe('--for review-voice through the no-Drive brief', () => {
git(['commit', '-qm', 'origin moves'], origin);
const moved = brief();
expect(moved.status, moved.out + moved.stderr).toBe(0);
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).toContain(`SELF_UPGRADE: skipped (review-voice never self-upgrades a shared checkout; fix: GSTACK_UPGRADE_FORCE=1 ${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', () => {
const file = launchEnv(cold.out).GSTACK_INSTALL_VERDICT!;
const wider = { ...JSON.parse(fs.readFileSync(file, 'utf8')), head: git(['rev-parse', 'HEAD'], root), for: 'review', required: ['claude', 'codex', 'patch', 'runtime', 'pins'], trailer: `gstack: ok ${VERSION} for=review` };
// GST-401: the reviewer used to rewrite the lane's launch file and repoint its launch directory at its own Bun.
test('a review-voice check never narrows an ok verdict a fuller install wrote for this HEAD and project, and borrows that install\'s launch file and directory without rewriting them', () => {
const coldEnv = launchEnv(cold.out);
const file = coldEnv.GSTACK_INSTALL_VERDICT!;
const base = file.slice(0, -'.json'.length);
const laneBun = path.join(host.base, 'lane-bun', 'bun');
writeBunStub(laneBun, '1.4.3');
const laneLaunch = `${base}.launch`;
fs.rmSync(laneLaunch, { recursive: true, force: true });
fs.mkdirSync(laneLaunch, { recursive: true });
fs.symlinkSync(laneBun, path.join(laneLaunch, 'bun'));
const laneEnv = `${base}.env`;
const laneEnvText = [
'# the lane\'s launch file (test sentinel)',
`PATH='${laneLaunch}':"$PATH"; export PATH`,
`export GSTACK_STATE_ROOT='${coldEnv.GSTACK_STATE_ROOT}'`,
'export GSTACK_CAPY=1',
`export GSTACK_INSTALL_VERDICT='${file}'`,
].join('\n') + '\n';
fs.writeFileSync(laneEnv, laneEnvText, { mode: 0o600 });
const wider = {
...JSON.parse(fs.readFileSync(file, 'utf8')), head: git(['rev-parse', 'HEAD'], root), for: 'review',
required: ['claude', 'codex', 'patch', 'runtime', 'pins', 'helpers'], trailer: `gstack: ok ${VERSION} for=review`,
bun_path: fs.realpathSync(laneBun), bun_version: '1.4.3', launch_path: laneLaunch,
};
fs.writeFileSync(file, JSON.stringify(wider, null, 1) + '\n');
const again = brief();
expect(again.status, again.out + again.stderr).toBe(0);
expect(again.out).toContain(`gstack-capy-install: kept the install verdict for=review at ${file}`);
expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual(wider);
expect(fs.readFileSync(laneEnv, 'utf8')).toBe(laneEnvText);
expect(fs.readdirSync(laneLaunch)).toEqual(['bun']);
expect(fs.readlinkSync(path.join(laneLaunch, 'bun'))).toBe(laneBun);
expect(again.out).toContain(`GSTACK_LAUNCH_FILE: ${laneEnv}\n`);
expect(again.out).toContain(`GSTACK_LAUNCH_PATH: ${laneLaunch}\n`);
const helpers = run([
`. "${laneEnv}"`,
`"${root}/bin/gstack-pregate" sealed`,
`"${root}/bin/gstack-plan-reality" --help >/dev/null`,
`"${root}/bin/gstack-artifact" --help >/dev/null`,
`"${root}/bin/gstack-autoplan" contract >/dev/null`,
'echo HELPERS_OK',
].join(' && '), sub, host.env, 120_000);
expect(helpers.stdout, helpers.stdout + helpers.stderr).toContain('HELPERS_OK');
expect(helpers.stdout + helpers.stderr).not.toContain('BUN_RESOLUTION_DRIFT');
}, 300_000);
});
+30 -2
View File
@@ -412,7 +412,7 @@ describe('capy skill: the session hands the installer GSTACK_CAPY_BEHIND', () =>
const r = spawnSync('bash', ['-c', script, 'run', path.join(f.root, 'bin/gstack-lease'), f.root, BOOT, leaseDir, process.execPath], { cwd: f.host.home, encoding: 'utf8', env: { ...f.host.env, GSTACK_CAPY_ROOT: f.root, GSTACK_CAPY_REPO: `file://${f.bare}`, WRAP_LOG: path.join(f.host.base, 'wrap.log'), GSTACK_LEASE_DIR: leaseDir }, timeout: 120_000 });
const [inRun, aged] = r.stdout.split(/^exit=0$/m);
expect(r.stdout.match(/^exit=0$/gm), r.stdout + r.stderr).toHaveLength(2);
expect(inRun).toMatch(/^SELF_UPGRADE: skipped \(a gstack run in this session holds a workflow lease on this installation: pid \d+ \(0m bash\), so the upgrade waits until the lease is 120 minutes old; fix: .+bin\/gstack-upgrade once the run ends \(UPGRADE_BUSY\)\)$/m);
expect(inRun).toMatch(/^SELF_UPGRADE: skipped \(a gstack run in this session holds a workflow lease on this installation: pid \d+ \(0m bash\), so the upgrade waits until the lease is 120 minutes old; fix: .+bin\/gstack-upgrade once the lease is 120 minutes old, or GSTACK_UPGRADE_FORCE=1 .+bin\/gstack-upgrade when no other session uses this checkout \(UPGRADE_BUSY\)\)$/m);
expect(inRun).not.toContain('SELF_UPGRADE: done');
expect(inRun).toMatch(/blocked=a gstack run in this session holds a workflow lease .+ \(UPGRADE_BUSY\)$/m);
expect(aged).toContain(f.b);
@@ -424,7 +424,7 @@ describe('capy skill: the session hands the installer GSTACK_CAPY_BEHIND', () =>
// 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 blocked = (root: string) => `review-voice never self-upgrades a shared checkout; fix: GSTACK_UPGRADE_FORCE=1 ${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);
@@ -464,6 +464,17 @@ describe('capy skill: the session hands the installer GSTACK_CAPY_BEHIND', () =>
expect(r.last).toBe('gstack: ok 1.0.2 for=autoplan behind=none to=none fix=none');
});
// GST-395: below the supported minimum every session's trailer on this checkout fails, so the
// bootstrapper's fast-forward applies to a reviewer too; only above the floor does review-voice hold still.
test('install mode below the floor: the bootstrapper fast-forwards to the minimum even for review-voice, and the session then has nothing to do', () => {
const f = fixture();
const r = f.run(['--for', 'review-voice']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toContain('gstack-capy-skill: upgraded 1.0.0 -> 1.0.1');
expect(r.stdout).toMatch(/^SELF_UPGRADE: none \(current\)$/m);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
});
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']);
@@ -472,6 +483,23 @@ describe('capy skill: the session hands the installer GSTACK_CAPY_BEHIND', () =>
});
});
// GST-396: gstack-upgrade now counts a fresh capy-server lease, but the session's own #3571 arm still decides first on Capy.
test('install mode under a capy-server harness holding a fresh lease: the #3571 deferral message still wins (live keeps its meaning for the session)', () => {
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']);
const leaseDir = path.join(f.host.base, 'leases');
const harness = path.join(f.host.base, 'harness', 'capy-server');
fs.mkdirSync(path.dirname(harness), { recursive: true });
fs.copyFileSync(fs.realpathSync(spawnSync('bash', ['-c', 'command -v bash'], { encoding: 'utf8', timeout: 30_000 }).stdout.trim()), harness);
fs.chmodSync(harness, 0o755);
const script = ['bash "$1" touch --pid $$ --root "$2" || exit 9', 'bash "$3" --for document-release; echo "exit=$?"'].join('\n');
const r = spawnSync(harness, ['-c', script, 'run', path.join(f.root, 'bin/gstack-lease'), f.root, BOOT], { cwd: f.host.home, encoding: 'utf8', env: { ...f.host.env, GSTACK_CAPY_ROOT: f.root, GSTACK_CAPY_REPO: `file://${f.bare}`, WRAP_LOG: path.join(f.host.base, 'wrap.log'), GSTACK_LEASE_DIR: leaseDir }, timeout: 120_000 });
expect(r.stdout, r.stdout + r.stderr).toContain('exit=0');
expect(r.stdout).toMatch(/^SELF_UPGRADE: skipped \(a gstack run in this session holds a workflow lease on this installation: pid \d+ \(0m capy-server\), so the upgrade waits until the lease is 120 minutes old; fix: .+ \(UPGRADE_BUSY\)\)$/m);
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);
+59
View File
@@ -616,6 +616,65 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
expect(JSON.parse(fs.readFileSync(launchFile.replace(/\.env$/, '.json'), 'utf8'))).toMatchObject({ ok: true });
});
// protects (GST-401): a profile run that keeps a fuller ok verdict borrows that install's launch
// file (announced, never rewritten) and a failing profile check removes only the profile's own
// <verdict>.profile/ directory. fails_when: the kept profile run writes the launch file again, or
// the failure cleanup leaves the profile directory behind or touches the lane's files.
test('a kept profile run borrows the fuller install\'s launch file without rewriting it; a later failing profile check removes only <verdict>.profile/', () => {
const f = fixture();
const first = run(f, ['--for', 'autoplan']);
expect(first.status, first.stdout + first.stderr).toBe(0);
const launchFile = first.stdout.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)?.[1] ?? '';
fs.appendFileSync(launchFile, '# lane sentinel\n');
const before = fs.readFileSync(launchFile, 'utf8');
const ok = run(f, ['--for', 'review-voice']);
expect(ok.status, ok.stdout + ok.stderr).toBe(0);
expect(ok.stdout).toContain('gstack-capy-install: kept the install verdict for=autoplan');
expect(ok.stdout).toContain(`GSTACK_LAUNCH_FILE: ${launchFile}\n`);
expect(fs.readFileSync(launchFile, 'utf8')).toBe(before);
const profileDir = launchFile.replace(/\.env$/, '.profile');
expect(fs.existsSync(path.join(profileDir, 'review-voice.env'))).toBe(false);
fs.mkdirSync(path.join(profileDir, 'review-voice.launch'), { recursive: true });
const failed = run(f, ['--check', '--for', 'review-voice'], { STUB_DOCTOR_FAIL: '1' });
expect(failed.status, failed.stdout + failed.stderr).toBe(1);
expect(fs.existsSync(profileDir)).toBe(false);
expect(fs.readFileSync(launchFile, 'utf8')).toBe(before);
expect(JSON.parse(fs.readFileSync(launchFile.replace(/\.env$/, '.json'), 'utf8'))).toMatchObject({ ok: true, for: 'autoplan' });
});
// protects (GST-401): a kept profile run borrows only a launch file that reaches a Bun; when the
// fuller install recorded no launch path but this run needs one, it writes its own
// <verdict>.profile/<for>.env. fails_when: the run announces the lane's file anyway (its shell
// would resolve an uncertified Bun) or rewrites the lane's file.
test.skipIf(process.platform !== 'linux')('a kept profile run whose fuller install recorded no launch path, while this run needs one, writes <verdict>.profile/<for>.env and leaves the lane\'s file alone', () => {
const f = fixture();
const zip = zipBun(f, '1.4.4');
const first = run(f, ['--for', 'autoplan']);
expect(first.status, first.stdout + first.stderr).toBe(0);
const launchFile = first.stdout.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)?.[1] ?? '';
expect(JSON.parse(fs.readFileSync(launchFile.replace(/\.env$/, '.json'), 'utf8')).launch_path).toBe('');
const before = fs.readFileSync(launchFile, 'utf8');
fs.rmSync(path.join(f.stub, 'bun'));
const ok = run(f, ['--for', 'review-voice'], { STUB_CURL_ZIP: zip });
expect(ok.stdout).toContain(`GSTACK_LAUNCH_PATH: ${store(f, '1.4.4')}`);
expect(ok.status, ok.stdout + ok.stderr).toBe(0);
expect(ok.stdout).toContain('gstack-capy-install: kept the install verdict for=autoplan');
const own = path.join(launchFile.replace(/\.env$/, '.profile'), 'review-voice.env');
expect(ok.stdout).toContain(`GSTACK_LAUNCH_FILE: ${own}\n`);
expect(fs.readFileSync(own, 'utf8')).toContain(`PATH='${store(f, '1.4.4')}':`);
expect(fs.readFileSync(launchFile, 'utf8')).toBe(before);
});
// protects: everything above `set -u` in bin/gstack-capy-install is the shebang, a comment or blank,
// so header prose never runs as shell. fails_when: a header line loses its `#` (f75c448 ran
// `never <verdict>.launch.` as a command with redirections in the caller's directory).
test('the installer header runs nothing: every line before set -u is a comment or blank', () => {
const lines = fs.readFileSync(path.join(REPO, 'bin/gstack-capy-install'), 'utf8').split('\n');
const end = lines.findIndex(l => l.startsWith('set -u'));
expect(end).toBeGreaterThan(0);
expect(lines.slice(0, end).filter(l => l.trim() !== '' && !l.startsWith('#'))).toEqual([]);
});
// protects: when the launch file's temp file IS created and the final mv fails, write_launch_file
// removes its own temp file and nothing else (a sibling temp file from a concurrent run survives),
// the run fails with the launch-file trailer, prints no GSTACK_LAUNCH_FILE line, removes the
+25
View File
@@ -654,6 +654,31 @@ describe('gstack-lease', () => {
expect(mixed.stdout.trim().split('\n')).toEqual([expect.stringMatching(new RegExp(`^${foreign} \\d+m sleep$`))]);
});
// GST-396: on Capy every session runs under one capy-server, so its lease is in every caller's ancestry.
test('live --count-shared-harness keeps a capy-server ancestor\'s lease while it is under 120 minutes old; without the flag that ancestor is still dropped', () => {
const { dir, leases } = checkout();
const harness = path.join(dir, 'harness', 'capy-server');
fs.mkdirSync(path.dirname(harness), { recursive: true });
fs.copyFileSync(fs.realpathSync(spawnSync('bash', ['-c', 'command -v bash'], { encoding: 'utf8', timeout: 30_000 }).stdout.trim()), harness);
fs.chmodSync(harness, 0o755);
const script = [
'"$LEASE" touch --root "$ROOT" --pid $$ || exit 9',
'[ "$AGE" -gt 0 ] && "$BUN" -e "const t = Date.now() / 1000 - Number(process.argv[2]) * 60; require(\'fs\').utimesSync(process.argv[1], t, t)" "$ROOT/.git/gstack-leases/$$" "$AGE"',
'echo "plain=$(bash -c \'"$LEASE" live --root "$ROOT" --exclude-ancestry-of $$\')"',
'echo "shared=$(bash -c \'"$LEASE" live --root "$ROOT" --exclude-ancestry-of $$ --count-shared-harness\')"',
'echo "pid=$$"',
].join('\n');
const go = (age: number) => spawnSync(harness, ['-c', script], { encoding: 'utf8', env: { ...baseEnv, LEASE, ROOT: dir, AGE: String(age), BUN: process.execPath }, timeout: 30_000 }).stdout;
const fresh = go(0);
const pid = fresh.match(/^pid=(\d+)$/m)![1];
expect(fresh).toMatch(/^plain=$/m);
expect(fresh).toMatch(new RegExp(`^shared=${pid} 0m capy-server$`, 'm'));
fs.rmSync(leases, { recursive: true, force: true });
const aged = go(121);
expect(aged).toMatch(/^plain=$/m);
expect(aged).toMatch(/^shared=$/m);
});
test('touch without --pid uses the same harness pid gstack-skill-start derives (this process past the tool-call shell)', () => {
const { dir, leases } = checkout();
const r = spawnSync('bash', ['-c', `"$LEASE" touch --root "$ROOT"; true`], {
+46
View File
@@ -202,6 +202,52 @@ describe('gstack-upgrade', () => {
expect(f.calls()).toEqual(['setup --refresh-registered']);
});
// GST-396: on Capy every tool call is a fresh shell under one capy-server, so
// every session's lease names capy-server and the caller's own-ancestry
// exclusion used to drop a sibling's fresh lease: a hand-run upgrade moved the checkout.
describe('a lease held by a shared harness (capy-server) ancestor', () => {
const underHarness = (f: ReturnType<typeof fixture>, harness: string, ageMinutes: number, extra: Record<string, string> = {}) => {
const bin = path.join(f.host.base, 'harness');
fs.mkdirSync(bin, { recursive: true });
const exe = path.join(bin, harness);
if (!fs.existsSync(exe)) fs.copyFileSync(fs.realpathSync(spawnSync('bash', ['-c', 'command -v bash'], { encoding: 'utf8', timeout: 30_000 }).stdout.trim()), exe);
fs.chmodSync(exe, 0o755);
const script = [
'bash "$1" touch --root "$2" --pid $$ || exit 9',
'[ "$4" -gt 0 ] && "$5" -e "const t = Date.now() / 1000 - Number(process.argv[2]) * 60; require(\'fs\').utimesSync(process.argv[1], t, t)" "${GSTACK_LEASE_DIR:-$(git -C "$2" rev-parse --absolute-git-dir)/gstack-leases}/$$" "$4"',
'bash "$3"',
'echo "exit=$?"',
].join('\n');
return spawnSync(exe, ['-c', script, harness, path.join(f.root, 'bin/gstack-lease'), f.root, path.join(f.root, 'bin/gstack-upgrade'), String(ageMinutes), process.execPath], { cwd: f.host.home, encoding: 'utf8', env: { ...f.host.env, UPGRADE_LOG: path.join(f.host.base, 'calls.log'), ...extra }, timeout: 60_000 });
};
test('a fresh lease refuses UPGRADE_BUSY naming the shared harness, HEAD unchanged; GSTACK_UPGRADE_FORCE=1 proceeds', () => {
const f = fixture();
const r = underHarness(f, 'capy-server', 0);
expect(r.stdout, r.stdout + r.stderr).toContain('exit=3');
expect(r.stderr).toMatch(/^gstack-upgrade: a shared harness \(capy-server, pid \d+\) holds a lease 0m old; every session on this machine runs under it, so the upgrade waits until the lease is 120 minutes old \(counted from the last renewal by any session\); nothing was changed; fix: re-run then, or with GSTACK_UPGRADE_FORCE=1 when no other session uses this checkout \(UPGRADE_BUSY\)$/m);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
const forced = underHarness(f, 'capy-server', 0, { GSTACK_UPGRADE_FORCE: '1' });
expect(forced.stdout, forced.stdout + forced.stderr).toContain('exit=0');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
});
test('once that lease is 120 minutes old the upgrade proceeds', () => {
const f = fixture();
const r = underHarness(f, 'capy-server', 121);
expect(r.stdout, r.stdout + r.stderr).toContain('exit=0');
expect(r.stderr).not.toContain('UPGRADE_BUSY');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
});
test('a per-session harness (a plain shell, the Claude Code shape) still never blocks its own upgrade', () => {
const f = fixture();
const r = underHarness(f, 'claude', 0);
expect(r.stdout, r.stdout + r.stderr).toContain('exit=0');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
});
});
test('a dead-pid lease does not block', () => {
const f = fixture();
const dead = spawnSync('true', [], { timeout: 30_000 }).pid!;