From 4f02cb57849c24cdb8a541aefc558bdd36ab56e9 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 31 Oct 2026 03:23:41 -0700 Subject: [PATCH] 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> --- CHANGELOG.md | 13 +++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-capy-install | 25 ++++++++++++-- bin/gstack-capy-session | 4 +-- bin/gstack-harness-pid.sh | 12 +++++++ bin/gstack-lease | 22 ++++++++---- bin/gstack-upgrade | 22 +++++++++--- docs/capy-install.md | 33 ++++++++++++++---- docs/troubleshooting.md | 17 ++++++--- lib/autoplan-prompts.ts | 2 +- lib/doctor-components.ts | 3 +- lib/result-codes.ts | 4 +-- package.json | 2 +- test/capy-review-voice.test.ts | 50 +++++++++++++++++++++++---- test/capy-skill-wrapper.test.ts | 32 +++++++++++++++-- test/gstack-capy-install.test.ts | 59 ++++++++++++++++++++++++++++++++ test/gstack-lease.test.ts | 25 ++++++++++++++ test/gstack-upgrade-bin.test.ts | 46 +++++++++++++++++++++++++ 19 files changed, 331 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04cad5798..e0f4366e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `.env` and could repoint `.launch/bun` at gstack's minimum Bun instead of the project's pinned Bun. A reviewer's own launch directory now lives under `.profile/review-voice.launch` (mode 0700), and a failing review-voice check removes `.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

) holds a lease 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/.md` as an unknown dependency and restored the full gate, so it saved nothing on any PR (GST-400, found on #3668). diff --git a/VERSION b/VERSION index 7348e7a64..0fda5582a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.360.0 +1.91.361.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index c24fa5687..a650e1616 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -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, diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index 93b94e254..4afc9a153 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -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 .profile/, +# never .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: ' diff --git a/bin/gstack-capy-session b/bin/gstack-capy-session index e269de2e2..3050b63a3 100755 --- a/bin/gstack-capy-session +++ b/bin/gstack-capy-session @@ -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 </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 +} diff --git a/bin/gstack-lease b/bin/gstack-lease index 2073af92d..7a0aa3348 100755 --- a/bin/gstack-lease +++ b/bin/gstack-lease @@ -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 ] [--exclude-ancestry-of ] +# gstack-lease live [--root ] [--exclude-ancestry-of ] [--count-shared-harness] # print ` m ` 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 ] # print ` 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) diff --git a/bin/gstack-upgrade b/bin/gstack-upgrade index 0fde3bf72..c658f175b 100755 --- a/bin/gstack-upgrade +++ b/bin/gstack-upgrade @@ -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 </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 <&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 diff --git a/docs/capy-install.md b/docs/capy-install.md index d1f2f5a45..22a3481b0 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -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= … (UPGRADE_SKIPPED); fix: /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 +`.env` and `.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 `.profile/review-voice.launch` +(mode 0700), never `.launch`; a failing review-voice check removes +`.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 ` 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: /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 + /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

) holds a lease 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=

/m` to `analytics/session-update.log` and tries again at the next session start, without writing its hourly throttle stamp. A lease whose diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 471282f4e..2df3ad9d5 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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 `). -### `gstack-upgrade: live gstack sessions hold this installation: pid

(m), ...; nothing was changed; fix: ... (UPGRADE_BUSY)` +### `gstack-upgrade: live gstack sessions hold this installation: pid

(m), ...; nothing was changed; fix: ... (UPGRADE_BUSY)` / `gstack-upgrade: a shared harness (capy-server, pid

) holds a lease 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

) holds a lease 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

(m ), so the upgrade waits until the lease is 120 minutes old; fix: -/bin/gstack-upgrade once the run ends (UPGRADE_BUSY))` and leaves the +/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))` 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

(m )`), 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 diff --git a/lib/autoplan-prompts.ts b/lib/autoplan-prompts.ts index 011feba2c..f2d922d5f 100644 --- a/lib/autoplan-prompts.ts +++ b/lib/autoplan-prompts.ts @@ -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.'; diff --git a/lib/doctor-components.ts b/lib/doctor-components.ts index 36ab94e79..bb9636eb3 100644 --- a/lib/doctor-components.ts +++ b/lib/doctor-components.ts @@ -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 diff --git a/lib/result-codes.ts b/lib/result-codes.ts index 04dfa6408..082924de1 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -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', diff --git a/package.json b/package.json index fb64cc5c6..9f6272928 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/capy-review-voice.test.ts b/test/capy-review-voice.test.ts index 6d6861bdd..da7820419 100644 --- a/test/capy-review-voice.test.ts +++ b/test/capy-review-voice.test.ts @@ -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); }); diff --git a/test/capy-skill-wrapper.test.ts b/test/capy-skill-wrapper.test.ts index c80cdbb13..4fbc82da3 100644 --- a/test/capy-skill-wrapper.test.ts +++ b/test/capy-skill-wrapper.test.ts @@ -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); diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index ea485fa4d..491b1ae20 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -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 + // .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 .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 + // .profile/.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 .profile/.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 .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 diff --git a/test/gstack-lease.test.ts b/test/gstack-lease.test.ts index 05cb4945d..1736530f5 100644 --- a/test/gstack-lease.test.ts +++ b/test/gstack-lease.test.ts @@ -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`], { diff --git a/test/gstack-upgrade-bin.test.ts b/test/gstack-upgrade-bin.test.ts index a9676603e..b116d315f 100644 --- a/test/gstack-upgrade-bin.test.ts +++ b/test/gstack-upgrade-bin.test.ts @@ -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, harness: string, ageMinutes: number, extra: Record = {}) => { + 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!;