From 7fa9a1bdcf5034d2e7bf023b8800e5a8a14fc86e Mon Sep 17 00:00:00 2001 From: garrytan Date: Fri, 16 Oct 2026 23:24:02 +0000 Subject: [PATCH] fix(capy): a lease records its holder's start identity, so a pid reused after a wake is reclaimed; a skipped session-start upgrade ends fail behind, never ok (GBRA-105, GBRA-106) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-capy-install | 13 ++++++-- bin/gstack-capy-session | 9 +++-- bin/gstack-lease | 57 +++++++++++++++++++++++++------- capy/skill/gstack/SKILL.md | 2 +- docs/capy-install.md | 20 +++++++---- docs/troubleshooting.md | 13 +++++--- test/capy-skill-wrapper.test.ts | 34 ++++++++++++++++--- test/gstack-capy-install.test.ts | 11 ++++++ test/gstack-lease.test.ts | 36 ++++++++++++++++++-- test/gstack-upgrade-bin.test.ts | 2 +- 10 files changed, 162 insertions(+), 35 deletions(-) diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index ab1cb2c7b..05961a411 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -473,7 +473,10 @@ EOF_SHIM # (bin/gstack-capy-session exports GSTACK_CAPY_BEHIND, _BEHIND_TO, _BEHIND_FIX), or # `latest` when the bootstrapper read a newer VERSION from origin in this run, # exported as GSTACK_CAPY_REMOTE_VERSION. The failure head (`gstack: fail `) -# is unchanged. +# is unchanged. An install-mode session whose self-upgrade was skipped +# (the session exports GSTACK_CAPY_BEHIND_BLOCKED) is never ok: its ok trailer becomes +# gstack: fail behind — installed is commits behind origin (latest ) +# and the session-start upgrade was skipped: ; fix: () INSTALLED=$(head -1 "$ROOT/VERSION") TRAILER="$(printf '%s\n' "$OUT" | grep '^gstack: ' | tail -1)" NOTES="" @@ -493,7 +496,13 @@ elif [ -n "$REMOTE_VERSION" ] && [ "$REMOTE_VERSION" != "$INSTALLED" ] \ && [ "$(printf '%s\n%s\n' "$INSTALLED" "$REMOTE_VERSION" | sort -V | head -1)" = "$INSTALLED" ]; then note "latest: $REMOTE_VERSION available" fi -case "$TRAILER" in "gstack: ok "*) [ -n "$NOTES" ] && TRAILER="$TRAILER ($NOTES)" ;; esac +BLOCKED="${GSTACK_CAPY_BEHIND_BLOCKED:-}" +case "$TRAILER" in "gstack: ok "*) if [ -n "$BLOCKED" ]; then + case "$BLOCKED" in *"; fix: "*) ;; *) BLOCKED="$BLOCKED; fix: ${GSTACK_CAPY_BEHIND_FIX:-$ROOT/bin/gstack-upgrade}" ;; esac + case "$BLOCKED" in *"("[A-Z]*_[A-Z]*")") ;; *) BLOCKED="$BLOCKED (UPGRADE_REFUSED)" ;; esac + TRAILER="gstack: fail behind — installed $INSTALLED is ${GSTACK_CAPY_BEHIND:-?} commits behind origin (latest ${GSTACK_CAPY_BEHIND_TO:-unknown}) and the session-start upgrade was skipped: $BLOCKED" + RC=1 +elif [ -n "$NOTES" ]; then TRAILER="$TRAILER ($NOTES)"; fi ;; esac record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)" [ "$CHECK_ONLY" -eq 1 ] || write_shim printf '%s\n' "$OUT" | grep -v '^gstack: ' diff --git a/bin/gstack-capy-session b/bin/gstack-capy-session index 434f81094..017af4685 100755 --- a/bin/gstack-capy-session +++ b/bin/gstack-capy-session @@ -33,7 +33,10 @@ # VERSION> and GSTACK_CAPY_BEHIND_FIX= for the installer's trailer notes # `behind= (latest: ); fix: `. The command never contains # "; " (the note separator): after a refused self-upgrade it is bin/gstack-upgrade, -# which prints its own refusal and fix. +# which prints its own refusal and fix. A skipped self-upgrade (refused, busy, +# or gstack-upgrade missing) also exports GSTACK_CAPY_BEHIND_BLOCKED=, and the installer turns an ok trailer into `gstack: fail behind`: a +# session that meant to upgrade and could not is never certified ok. # # Usage: gstack-capy-session [--check] --root [gstack-capy-install arguments] # --check certify only (bin/gstack-capy-install --check: the doctor with the @@ -78,7 +81,7 @@ report_behind() { export GSTACK_CAPY_BEHIND="$(git -C "$ROOT" rev-list --count HEAD..FETCH_HEAD 2>/dev/null || echo '?')" \ GSTACK_CAPY_BEHIND_TO="$(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1)" GSTACK_CAPY_BEHIND_FIX="$1" } -unset GSTACK_CAPY_BEHIND GSTACK_CAPY_BEHIND_TO GSTACK_CAPY_BEHIND_FIX +unset GSTACK_CAPY_BEHIND GSTACK_CAPY_BEHIND_TO GSTACK_CAPY_BEHIND_FIX GSTACK_CAPY_BEHIND_BLOCKED RERUN="bash ${GSTACK_CAPY_WRAPPER_PATH:-/scripts/install.sh}${SKILL:+ --for $SKILL}" if [ "$CHECK" -eq 1 ]; then if behind; then @@ -100,6 +103,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 [ ! -x "$ROOT/bin/gstack-upgrade" ]; then echo "SELF_UPGRADE: skipped ($ROOT/bin/gstack-upgrade is missing)"; report_behind "$RERUN" + export GSTACK_CAPY_BEHIND_BLOCKED="$ROOT/bin/gstack-upgrade is missing; fix: $RERUN (UPGRADE_REFUSED)" else OLD=$(head -1 "$ROOT/VERSION" 2>/dev/null) UP_OUT=$("$ROOT/bin/gstack-upgrade" --ff-only-no-reinstall 2>&1); UP_RC=$? @@ -113,6 +117,7 @@ elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then [ -n "$REASON" ] || REASON=$(printf '%s\n' "$UP_OUT" | sed -n 's/^gstack-upgrade: //p' | tail -1) echo "SELF_UPGRADE: skipped (${REASON:-gstack-upgrade exit $UP_RC})" report_behind "$ROOT/bin/gstack-upgrade" + export GSTACK_CAPY_BEHIND_BLOCKED="${REASON:-gstack-upgrade exit $UP_RC; fix: $ROOT/bin/gstack-upgrade (UPGRADE_REFUSED)}" fi fi exec "$INSTALLER" "$@" diff --git a/bin/gstack-lease b/bin/gstack-lease index 431b3fba8..a04c50c20 100755 --- a/bin/gstack-lease +++ b/bin/gstack-lease @@ -9,16 +9,23 @@ # # Usage: # gstack-lease touch [--root ] [--pid

| --from ] -# write /gstack-leases/; pid defaults to the harness pid -# behind this call (bin/gstack-harness-pid.sh, the same derivation the -# session marker uses), or behind --from (a caller whose own parent is -# short-lived passes its parent pid); leases older than 120 min are pruned +# write /gstack-leases/ holding the pid's start identity; +# pid defaults to the harness pid behind this call +# (bin/gstack-harness-pid.sh, the same derivation the session marker +# uses), or behind --from (a caller whose own parent is short-lived +# passes its parent pid); leases older than 120 min are pruned # gstack-lease live [--root ] [--exclude-ancestry-of ] -# print ` m` per lease whose pid is alive and 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 +# 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) # gstack-lease list [--root ] -# print ` m alive|dead` for every lease +# print ` m alive|dead|stale` for every lease +# A pid alone is not a holder: pids are reused after a reboot or a machine +# wake. A lease records its process's start identity (Linux: boot id and +# /proc start time; elsewhere `ps -o lstart`), and a lease whose pid now +# belongs to another process is stale. A lease without an identity (written by +# an older gstack) is stale once it is older than the 120-minute prune window. # defaults to the source checkout behind this bin (an installed # runtime root's .source-path, else ../). A root that is not a git checkout # exits 2 and writes nothing. GSTACK_LEASE_DIR overrides the lease directory @@ -54,6 +61,27 @@ GIT_DIR_ABS=$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null) \ DIR="${GSTACK_LEASE_DIR:-$GIT_DIR_ABS/gstack-leases}" alive() { kill -0 "$1" 2>/dev/null || ps -p "$1" >/dev/null 2>&1; } +identity() { # PID — the process's start identity, empty when unknown + if [ -r "/proc/$1/stat" ] && [ -r /proc/sys/kernel/random/boot_id ]; then + _st=$(cat "/proc/$1/stat" 2>/dev/null) || return 0 + _st=${_st##*) } + printf 'linux %s %s\n' "$(cat /proc/sys/kernel/random/boot_id)" "$(printf '%s\n' "$_st" | awk '{print $20}')" + else + _ls=$(ps -o lstart= -p "$1" 2>/dev/null | sed 's/^ *//; s/ *$//') + [ -n "$_ls" ] && printf 'ps %s\n' "$_ls" + fi + return 0 +} +# state FILE PID — alive | dead | stale (the pid now belongs to another process) +state() { + alive "$2" || { echo dead; return; } + _rec=$(head -1 "$1" 2>/dev/null) + if [ -n "$_rec" ]; then + [ "$_rec" = "$(identity "$2")" ] && echo alive || echo stale + elif [ "$(age_min "$1")" -ge 120 ]; then echo stale + else echo alive + fi +} age_min() { # FILE — minutes since its mtime _m=$(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0) echo $(( ( $(date +%s) - _m ) / 60 )) @@ -63,14 +91,14 @@ case "$VERB" in touch) [ -n "$PID" ] || PID=$(gstack_harness_pid "${FROM:-$PPID}") mkdir -p "$DIR" || exit 2 - : > "$DIR/$PID" || exit 2 + identity "$PID" > "$DIR/$PID" || exit 2 find "$DIR" -mmin +120 -type f -exec rm -f {} + 2>/dev/null exit 0 ;; list) for f in "$DIR"/*; do [ -f "$f" ] || continue p=${f##*/} - if alive "$p"; then echo "$p $(age_min "$f")m alive"; else echo "$p $(age_min "$f")m dead"; fi + echo "$p $(age_min "$f")m $(state "$f" "$p")" done exit 0 ;; esac @@ -89,8 +117,13 @@ for f in "$DIR"/*; do [ -f "$f" ] || continue p=${f##*/} case "$ANCESTRY" in *" $p "*) continue ;; esac - alive "$p" || continue - echo "$p $(age_min "$f")m" + case "$(state "$f" "$p")" in + dead) continue ;; + stale) + echo "gstack-lease: reclaimed stale lease $p ($(age_min "$f")m; recorded '$(head -1 "$f")', pid $p is now $(ps -o comm= -p "$p" 2>/dev/null | head -1) '$(identity "$p")')" >&2 + rm -f "$f"; continue ;; + esac + echo "$p $(age_min "$f")m $(ps -o comm= -p "$p" 2>/dev/null | head -1 | sed 's|.*/||')" FOUND=0 done exit $FOUND diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 80a9a6056..457748c21 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -10,7 +10,7 @@ description: Install and use gstack in Capy and Claude Code. Load at session sta Resolve `scripts/install.sh` relative to this SKILL.md, not the working repository. It is a frozen bootstrapper: it prints `gstack-capy-skill: wrapper ()` first, learns the supported minimum from gstack's remote, and hands over to the checkout's own wrapper (`bin/gstack-capy-session`). 1. At session start run `bash scripts/install.sh --check`, adding `--for ` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP|WARN ` row per component (`WARN` is an owner action), a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok for=[ ()]` (exit 0) or `gstack: fail …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout. -2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or an ok trailer carries `behind= (latest: ); fix: ` (the checkout is commits behind origin; the output also has a `gstack-capy-session: behind origin/: …` line), run the same command without `--check` (900-second timeout), or the `fix:` command when it names another. Report the trailer verbatim, `behind=` included: a lane that is behind says so. For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Any clean, unpinned checkout behind origin is fast-forwarded in install mode (below the `GSTACK_MIN_REVISION` floor by the bootstrapper, `gstack-capy-skill: upgraded -> `; above it by the checkout, `SELF_UPGRADE: done -> `), reinstalled and rechecked in one locked step. A pinned (`--revision`, detached), dirty or diverged checkout, or one another live session holds a lease on (`UPGRADE_BUSY`), is never moved: below the floor the trailer names the cause and the exact commands; above it the run prints `SELF_UPGRADE: skipped ()` and continues on the installed revision. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one. +2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or an ok trailer carries `behind= (latest: ); fix: ` (the checkout is commits behind origin; the output also has a `gstack-capy-session: behind origin/: …` line), run the same command without `--check` (900-second timeout), or the `fix:` command when it names another. Report the trailer verbatim, `behind=` included: a lane that is behind says so. For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Any clean, unpinned checkout behind origin is fast-forwarded in install mode (below the `GSTACK_MIN_REVISION` floor by the bootstrapper, `gstack-capy-skill: upgraded -> `; above it by the checkout, `SELF_UPGRADE: done -> `), reinstalled and rechecked in one locked step. A pinned (`--revision`, detached), dirty or diverged checkout, or one another live session holds a lease on (`UPGRADE_BUSY`), is never moved: the trailer names the cause and the exact commands (above the floor it is a `fail behind` trailer after `SELF_UPGRADE: skipped ()`, with `UPGRADE_BUSY` or `UPGRADE_REFUSED`). A lease whose pid was reused after a wake is reclaimed on its own. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one. 3. If the trailer is `gstack: fail not-installed …` and this session runs no gstack workflow, record that line and continue; nothing is installed. Install right before the first workflow with `bash scripts/install.sh --for ` (about 30 s cold: clone, a SHA-256-verified Bun when none installed fits, both host profiles without Chromium, and a `~/.local/bin/bun` shim so fresh shells get the recorded Bun). 4. Any other `FAIL` row carries a `fix:` command and a reason code with a troubleshooting anchor; run the fix, then `--check` again. `SKIP codex-cli (optional; …)`, `SKIP browser (lazy; …)` and `SKIP pins (planning-only; …)` are not failures. diff --git a/docs/capy-install.md b/docs/capy-install.md index f424e1cf0..2377a68ee 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -42,8 +42,10 @@ git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.lo refusal of `gstack-upgrade` applies), prints `SELF_UPGRADE: done -> ` and re-execs the new checkout's wrapper, which installs from the new revision (`GSTACK_CAPY_UPGRADED_FROM` set, so setup is never skipped). - A refusal prints `SELF_UPGRADE: skipped ()` and the session - continues on the installed revision; the trailer stays the doctor's. + A refusal prints `SELF_UPGRADE: skipped ()`, and the session is + never certified ok: the trailer is `gstack: fail behind — installed is + commits behind origin (latest ) and the session-start upgrade was + skipped: ; fix: (UPGRADE_BUSY|UPGRADE_REFUSED)`, exit 1. `SELF_UPGRADE: none (current)` when nothing is ahead; `GSTACK_CAPY_SELF_UPGRADE=off` (or `GSTACK_CAPY_AUTO_UPGRADE=0`) prints `SELF_UPGRADE: disabled ()`. `--check` never moves the checkout; @@ -76,10 +78,16 @@ resets. Several agents sharing one checkout (eight Capy subagents on a machine) hold workflow leases on it: every `bin/gstack-skill-start` touches `/gstack-leases/` (`bin/gstack-lease touch`; pruned after -120 minutes). `bin/gstack-upgrade`, and so the session-start self-upgrade, -refuses with [UPGRADE_BUSY](troubleshooting.md#upgrade-busy) while a lease -whose pid is alive and outside its own process chain exists; -`bin/gstack-lease list` shows them and `GSTACK_UPGRADE_FORCE=1` overrides. +120 minutes). Each lease records its process's start identity (Linux: boot id +and `/proc` start time; elsewhere `ps -o lstart`), because a pid is reused after +a reboot or a machine wake. `bin/gstack-upgrade`, and so the session-start +self-upgrade, refuses with [UPGRADE_BUSY](troubleshooting.md#upgrade-busy) +while a lease held by a live process outside its own chain exists, naming each +holder as `pid

(m )`. A lease whose pid now belongs to another +process, or an identity-less lease from an older gstack past 120 minutes, is +stale: `bin/gstack-lease live` reclaims it (`gstack-lease: reclaimed stale +lease …`). `bin/gstack-lease list` shows `alive|dead|stale`, and +`GSTACK_UPGRADE_FORCE=1` overrides. ## What the installer does diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b87e11067..01de5cb7f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1403,15 +1403,20 @@ machine, say). Each session start writes a workflow lease, anything because a lease whose pid is alive and not in its own process ancestry exists: fast-forwarding would change gstack under a session mid-workflow. Leases older than 120 minutes are pruned; a lease whose -process has exited never blocks. The upgrader's own session (the pid chain it +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. **Fix.** Wait for those sessions to finish (`bin/gstack-lease list` shows -every lease with its age and `alive|dead`), then re-run `bin/gstack-upgrade`. +every lease with its age and `alive|dead|stale`; a holder is named as +`pid

(m )`), then re-run `bin/gstack-upgrade`. When you know they are idle, `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: ...)` -and the session continues on the installed revision. +`SELF_UPGRADE: skipped (live gstack sessions hold this installation: ...)`, +and the trailer is `gstack: fail behind — … (UPGRADE_BUSY)`: a session that +could not upgrade is never certified ok. ### `gstack-upgrade: could not reach origin (...) (UPGRADE_FETCH_FAILED)` diff --git a/test/capy-skill-wrapper.test.ts b/test/capy-skill-wrapper.test.ts index 12164f48d..88422021e 100644 --- a/test/capy-skill-wrapper.test.ts +++ b/test/capy-skill-wrapper.test.ts @@ -14,7 +14,7 @@ * depth-1 file:// clone at A, on the simulated Capy host. */ import { afterEach, describe, expect, test } from 'bun:test'; -import { spawnSync } from 'child_process'; +import { spawn, spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { RESULT_CODES } from '../lib/result-codes'; @@ -311,7 +311,7 @@ describe('capy skill bootstrapper: GSTACK_CAPY_REMOTE_VERSION', () => { // Field list 2 item 13: lanes relay only the trailer, so a session that ends // behind origin hands the installer the gap and the fix for its trailer note. describe('capy skill: the session hands the installer GSTACK_CAPY_BEHIND', () => { - const ENV_INSTALLER = '#!/usr/bin/env bash\necho "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan behind=${GSTACK_CAPY_BEHIND:-none} to=${GSTACK_CAPY_BEHIND_TO:-none} fix=${GSTACK_CAPY_BEHIND_FIX:-none}"\n'; + const ENV_INSTALLER = '#!/usr/bin/env bash\necho "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan behind=${GSTACK_CAPY_BEHIND:-none} to=${GSTACK_CAPY_BEHIND_TO:-none} fix=${GSTACK_CAPY_BEHIND_FIX:-none}${GSTACK_CAPY_BEHIND_BLOCKED:+ blocked=$GSTACK_CAPY_BEHIND_BLOCKED}"\n'; const rerun = `bash ${BOOT} --for autoplan`; test('--check behind origin: the commit count, origin\'s VERSION and the fast-forwarding command; a checkout the rerun would not move points at gstack-upgrade', () => { @@ -339,13 +339,39 @@ describe('capy skill: the session hands the installer GSTACK_CAPY_BEHIND', () => write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755); const skipped = f.run(['--for', 'autoplan']); expect(skipped.stdout).toMatch(/^SELF_UPGRADE: skipped \(tracked files changed: bin\/gstack-capy-install; fix: .+\)$/m); - expect(skipped.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade`); + expect(skipped.last).toMatch(new RegExp(`^gstack: ok 1\\.0\\.1 for=autoplan behind=1 to=1\\.0\\.2 fix=${f.root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/bin/gstack-upgrade blocked=tracked files changed: bin/gstack-capy-install; fix: .+ \\(UPGRADE_REFUSED\\)$`)); const disabled = f.run(['--for', 'autoplan'], { GSTACK_CAPY_SELF_UPGRADE: 'off' }); expect(disabled.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=unset GSTACK_CAPY_SELF_UPGRADE, then ${rerun}`); - for (const r of [skipped, disabled]) expect(r.last.split(' fix=')[1]).not.toContain('; '); + for (const r of [skipped, disabled]) expect(r.last.split(' fix=')[1].split(' blocked=')[0]).not.toContain('; '); expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); }); + // GBRA-105 / GBRA-106: a lease naming a pid that a machine wake handed to + // another process wedged every upgrade, and the skipped upgrade still ended ok. + test('install mode: a lease whose pid was reused is reclaimed and the checkout upgrades; a live holder blocks it and the session hands the installer the busy reason with the holder', () => { + 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 holder = spawn('sleep', ['300'], { stdio: 'ignore' }); + try { + const lease = (args: string[]) => spawnSync('bash', [path.join(f.root, 'bin/gstack-lease'), ...args, '--root', f.root], { encoding: 'utf8', env: { ...f.host.env, GSTACK_LEASE_DIR: leaseDir }, timeout: 30_000 }); + expect(lease(['touch', '--pid', String(holder.pid)]).status).toBe(0); + const busy = f.run(['--for', 'autoplan'], { GSTACK_LEASE_DIR: leaseDir }); + expect(busy.stdout).toMatch(new RegExp(`^SELF_UPGRADE: skipped \\(live gstack sessions hold this installation: pid ${holder.pid} \\(\\d+m sleep\\); fix: .+ \\(UPGRADE_BUSY\\)\\)$`, 'm')); + expect(busy.last).toMatch(new RegExp(`behind=1 to=1\\.0\\.2 fix=.+ blocked=live gstack sessions hold this installation: pid ${holder.pid} \\(\\d+m sleep\\); fix: .+GSTACK_UPGRADE_FORCE=1.+ \\(UPGRADE_BUSY\\)$`)); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + const file = path.join(leaseDir, String(holder.pid)); + fs.writeFileSync(file, fs.readFileSync(file, 'utf8').trim().replace(/\S+$/, 'x1') + '\n'); + const reclaimed = f.run(['--for', 'autoplan'], { GSTACK_LEASE_DIR: leaseDir }); + expect(reclaimed.stdout).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c); + expect(fs.existsSync(file)).toBe(false); + } finally { + holder.kill('SIGKILL'); + } + }); + test('current, or the fetch failed: nothing is exported, and values inherited from a parent session are dropped', () => { const f = fixture({ cloneAt: 'B' }); f.git(['fetch', '-q', 'origin', 'main']); diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index 729f3fb3e..a8db03fa4 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -279,6 +279,17 @@ describe('gstack-capy-install', () => { expect(lastLine(run(f, [], { ...behind, STUB_DOCTOR_FAIL: '1' }).stdout)).toBe('gstack: fail codex'); }); + test('a skipped session-start upgrade is never ok: the trailer is `fail behind` with the reason, the fix and the code, exit 1, and the verdict records the failure', () => { + const f = fixture(); + const busy = { GSTACK_CAPY_BEHIND: '3', GSTACK_CAPY_BEHIND_TO: '1.2.6', GSTACK_CAPY_BEHIND_FIX: `${f.root}/bin/gstack-upgrade`, GSTACK_CAPY_BEHIND_BLOCKED: 'live gstack sessions hold this installation: pid 379 (124m dbus-daemon); fix: wait for those sessions to finish, or re-run with GSTACK_UPGRADE_FORCE=1 when you know they are idle (UPGRADE_BUSY)' }; + const r = run(f, [], busy); + expect(r.status, r.stdout + r.stderr).toBe(1); + expect(lastLine(r.stdout)).toBe('gstack: fail behind — installed 1.2.3 is 3 commits behind origin (latest 1.2.6) and the session-start upgrade was skipped: live gstack sessions hold this installation: pid 379 (124m dbus-daemon); fix: wait for those sessions to finish, or re-run with GSTACK_UPGRADE_FORCE=1 when you know they are idle (UPGRADE_BUSY)'); + const bare = run(f, [], { ...busy, GSTACK_CAPY_BEHIND_BLOCKED: 'gstack-upgrade exit 9' }); + expect(lastLine(bare.stdout)).toBe(`gstack: fail behind — installed 1.2.3 is 3 commits behind origin (latest 1.2.6) and the session-start upgrade was skipped: gstack-upgrade exit 9; fix: ${f.root}/bin/gstack-upgrade (UPGRADE_REFUSED)`); + expect(lastLine(run(f, [], { ...busy, STUB_DOCTOR_FAIL: '1' }).stdout)).toBe('gstack: fail codex'); + }); + test('--root clones when the directory is absent and refuses a non-gstack directory', () => { const f = fixture(); const clone = path.join(f.base, 'clone'); diff --git a/test/gstack-lease.test.ts b/test/gstack-lease.test.ts index 0518136a9..4cfbd437e 100644 --- a/test/gstack-lease.test.ts +++ b/test/gstack-lease.test.ts @@ -86,7 +86,7 @@ describe('gstack-lease', () => { expect(run(['touch', '--root', dir, '--pid', String(live)]).status).toBe(0); const some = run(['live', '--root', dir]); expect(some.status, some.stdout + some.stderr).toBe(0); - expect(some.stdout.trim().split('\n')).toEqual([expect.stringMatching(new RegExp(`^${live} \\d+m$`))]); + expect(some.stdout.trim().split('\n')).toEqual([expect.stringMatching(new RegExp(`^${live} \\d+m sleep$`))]); const list = run(['list', '--root', dir]); expect(list.status).toBe(0); @@ -94,12 +94,42 @@ describe('gstack-lease', () => { expect(list.stdout).toMatch(new RegExp(`^${dead} \\d+m dead$`, 'm')); }); + // GBRA-105 / GBRA-106: after a machine wake the recorded pid was dbus-daemon + // and systemd-logind, and every upgrade refused with UPGRADE_BUSY. + test('a reused pid is not a holder: a lease whose recorded start identity differs is reclaimed by live and listed stale', () => { + const { dir, leases } = checkout(); + const reused = sleeper(); + expect(run(['touch', '--root', dir, '--pid', String(reused)]).status).toBe(0); + const recorded = fs.readFileSync(path.join(leases, String(reused)), 'utf8').trim(); + expect(recorded).toMatch(/^(linux [0-9a-f-]+ \d+|ps .+)$/); + fs.writeFileSync(path.join(leases, String(reused)), recorded.replace(/\S+$/, 'x1') + '\n'); + expect(run(['list', '--root', dir]).stdout).toMatch(new RegExp(`^${reused} \\d+m stale$`, 'm')); + const live = run(['live', '--root', dir]); + expect(live.status, live.stdout + live.stderr).toBe(1); + expect(live.stdout.trim()).toBe(''); + expect(live.stderr).toMatch(new RegExp(`^gstack-lease: reclaimed stale lease ${reused} \\(\\d+m; recorded '.+', pid ${reused} is now sleep '.+'\\)$`, 'm')); + expect(fs.existsSync(path.join(leases, String(reused)))).toBe(false); + }); + + test('a lease without a recorded identity (older gstack) counts while fresh and is stale past the 120-minute window', () => { + const { dir, leases } = checkout(); + const holder = sleeper(); + fs.mkdirSync(leases, { recursive: true }); + fs.writeFileSync(path.join(leases, String(holder)), ''); + expect(run(['live', '--root', dir]).status).toBe(0); + const old = new Date(Date.now() - 130 * 60_000); + fs.utimesSync(path.join(leases, String(holder)), old, old); + const r = run(['live', '--root', dir]); + expect(r.status, r.stdout + r.stderr).toBe(1); + expect(r.stderr).toContain(`reclaimed stale lease ${holder}`); + }); + test('live --exclude-ancestry-of drops a lease held by that pid or one of its ancestors (this test process)', () => { const { dir } = checkout(); expect(run(['touch', '--root', dir, '--pid', String(process.pid)]).status).toBe(0); const unfiltered = run(['live', '--root', dir]); expect(unfiltered.status, unfiltered.stdout + unfiltered.stderr).toBe(0); - expect(unfiltered.stdout).toMatch(new RegExp(`^${process.pid} \\d+m$`, 'm')); + expect(unfiltered.stdout).toMatch(new RegExp(`^${process.pid} \\d+m \\S+$`, 'm')); // $$ inside the bin is a child of this bun process, so our own lease is ancestry. const own = spawnSync('bash', ['-c', `"$LEASE" live --root "$ROOT" --exclude-ancestry-of $$`], { encoding: 'utf8', env: { ...baseEnv, LEASE, ROOT: dir }, timeout: 30_000, @@ -112,7 +142,7 @@ describe('gstack-lease', () => { encoding: 'utf8', env: { ...baseEnv, LEASE, ROOT: dir }, timeout: 30_000, }); expect(mixed.status).toBe(0); - expect(mixed.stdout.trim().split('\n')).toEqual([expect.stringMatching(new RegExp(`^${foreign} \\d+m$`))]); + expect(mixed.stdout.trim().split('\n')).toEqual([expect.stringMatching(new RegExp(`^${foreign} \\d+m sleep$`))]); }); test('touch without --pid uses the same harness pid gstack-skill-start derives (this process past the tool-call shell)', () => { diff --git a/test/gstack-upgrade-bin.test.ts b/test/gstack-upgrade-bin.test.ts index 98f4e3420..a9676603e 100644 --- a/test/gstack-upgrade-bin.test.ts +++ b/test/gstack-upgrade-bin.test.ts @@ -188,7 +188,7 @@ describe('gstack-upgrade', () => { expect(r.status, r.stdout + r.stderr).toBe(3); expect(r.stderr).toContain('(UPGRADE_BUSY)'); expect(r.stderr).toContain('nothing was changed'); - expect(r.stderr).toMatch(new RegExp(`pid ${live} \\(\\d+m\\)`)); + expect(r.stderr).toMatch(new RegExp(`pid ${live} \\(\\d+m sleep\\)`)); expect(r.stderr).not.toContain(`pid ${process.pid} `); expect(r.stderr).toContain('wait for those sessions to finish, or re-run with GSTACK_UPGRADE_FORCE=1 when you know they are idle'); expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);