From 2f9d11a59d68822c36b3e03fb3e535f48a8ace96 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 06:30:58 -0800 Subject: [PATCH] v1.94.19.0 fix: a finished /ship's workflow lease no longer blocks gstack-upgrade (GST-1205) (#3932) * fix: gstack-skill-end releases its run on the workflow lease, so a finished /ship stops blocking gstack-upgrade (GST-1205) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a hold without --run survives a sibling's release, and an orphaned run ages out on its own (GST-1205 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a touch whose identity read comes back empty keeps the recorded identity and its runs (GST-1205 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: keep an unreadable identity unknown, keep a converted hold's age, and document each run's 120-minute bound (GST-1205 review round 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a SESSION_ID without urandom falls back to the start's pid, so two same-second runs keep separate lease entries (GST-1205 review round 2) 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 | 6 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-lease | 63 ++++++++++++++++++++++++++++----- bin/gstack-skill-end | 3 ++ bin/gstack-skill-start | 20 ++++++----- docs/capy-install.md | 4 +-- docs/troubleshooting.md | 6 ++-- package.json | 2 +- test/gstack-lease.test.ts | 54 +++++++++++++++++++++++++++- test/gstack-upgrade-bin.test.ts | 19 ++++++++-- 11 files changed, 154 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c190e5a7..6bd1587d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.94.19.0] - 2026-11-03 + +### Fixed + +- **A finished /ship no longer blocks `gstack-upgrade` for two hours (GST-1205).** Each `gstack-skill-start` writes a workflow lease that `gstack-upgrade` checks before moving a shared checkout. Nothing released it when the workflow ended, and on Capy every session's lease names the machine's one `capy-server`. So after a /ship finished, its own lease kept refusing the upgrade with UPGRADE_BUSY until it was 120 minutes old, unless you set `GSTACK_UPGRADE_FORCE=1`. Now `gstack-skill-start` records its `SESSION_ID` as an open run on the lease, and `gstack-skill-end` closes it with the new `gstack-lease release`. Once every run on a lease is closed, the lease is idle and blocks nothing (`gstack-lease list` shows `idle`). A run another session still has open keeps blocking. So does a holder that records no run (an older gstack, or `gstack-lease touch` without `--run`, which now leaves a `hold` entry that no release removes). Each run counts for 120 minutes from its own start, so one that never reaches `gstack-skill-end` stops blocking on time even while other sessions keep starting. A nested skill's own run covers it while it runs, but other sessions' starts no longer extend a run past its 120 minutes. + ## [1.94.18.0] - 2026-11-03 **The Windows-safe test subset is green again on current main.** `test/qa-report-dir.test.ts` expected `ENOTDIR` when `.gstack` is a file, but Windows reports `ENOENT` for the same mistake, so the test failed on every Windows run (windows-free-tests run 41607809007 at f6d6215). The test now accepts either code on Windows and still requires `ENOTDIR` everywhere else. With Windows checks now running by path, a red subset test would block any pull request that touches it. diff --git a/VERSION b/VERSION index ce198dd4a..807c34fb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.94.18.0 +1.94.19.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index c591dfe8a..9468d6bf6 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.94.18.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.94.19.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-lease b/bin/gstack-lease index 7a0aa3348..8fe9a0139 100755 --- a/bin/gstack-lease +++ b/bin/gstack-lease @@ -8,8 +8,11 @@ # shares them. # # Usage: -# gstack-lease touch [--root ] [--pid

| --from ] -# write /gstack-leases/ holding the pid's start identity; +# gstack-lease touch [--root ] [--pid

| --from ] [--run ] +# write /gstack-leases/ holding the pid's start identity +# and the open run (gstack-skill-start's SESSION_ID); without --run +# it records a hold no release closes; a run counts 120 min from its +# start and a hold 120 min from its last renewal; # 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 @@ -24,8 +27,11 @@ # --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 release --pid

--run [--root ] +# close run on 's lease (gstack-skill-end); once every run it +# recorded is closed the lease is idle and live skips it (GST-1205) # gstack-lease list [--root ] -# print ` m alive|dead|stale` for every lease +# print ` m alive|idle|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 @@ -47,12 +53,13 @@ BIN="$(cd "$(dirname "$0")" && pwd -P)" } VERB="${1:-}"; [ $# -gt 0 ] && shift -ROOT="" PID="" EXCLUDE="" FROM="" SHARED=0 +ROOT="" PID="" EXCLUDE="" FROM="" SHARED=0 RUN="" while [ $# -gt 0 ]; do case "$1" in --root) ROOT="${2:-}"; shift ;; --pid) PID="${2:-}"; shift ;; --from) FROM="${2:-}"; shift ;; + --run) RUN="${2:-}"; shift ;; --exclude-ancestry-of) EXCLUDE="${2:-}"; shift ;; --count-shared-harness) SHARED=1 ;; *) echo "gstack-lease: unknown argument '$1'" >&2; exit 2 ;; @@ -60,8 +67,8 @@ while [ $# -gt 0 ]; do shift done case "$VERB" in - touch|live|list) ;; - -h|--help|'') sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + touch|live|list|release) ;; + -h|--help|'') sed -n '2,34p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "gstack-lease: unknown verb '$VERB'" >&2; exit 2 ;; esac [ -n "$ROOT" ] || ROOT=$(gstack_source_root "$BIN") @@ -248,6 +255,15 @@ reclaim() { unlock return 1 } +# Run entries (GST-1205), lines after the identity: `run ` per +# open run, `hold ` for a holder that opened none, `idle` once every +# run closed. An entry older than 120 minutes no longer counts. +# fresh FILE [DROP] — the entries under 120 minutes old, minus the line whose +# first two fields are DROP ("run " or "hold"). +fresh() { sed -n '2,$p' "$1" 2>/dev/null | awk -v now="$(date +%s)" -v drop="${2:-}" '($1 == "run" && NF == 3 && now - $3 < 7200 && $1 " " $2 != drop) || ($1 == "hold" && NF == 2 && now - $2 < 7200 && $1 != drop)'; } +# idle FILE — the lease records entries and none of them still counts. A lease +# with no entries (written without --run, or by an older gstack) is never idle. +idle() { [ -n "$(sed -n '2,$p' "$1" 2>/dev/null | grep -v '^$')" ] && [ -z "$(fresh "$1")" ]; } 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 )) @@ -264,7 +280,20 @@ case "$VERB" in exit 3 } _tmp="$DIR/.$PID.$$" - identity "$PID" > "$_tmp" && mv -f "$_tmp" "$DIR/$PID" || { rm -f "$_tmp"; unlock; exit 2; } + _id=$(identity "$PID") _keep="" _held=0 _now=$(date +%s) + if [ -f "$DIR/$PID" ] && [ "$(head -1 "$DIR/$PID" 2>/dev/null)" = "$_id" ]; then + if [ -n "$RUN" ]; then _keep=$(fresh "$DIR/$PID" "run $RUN"); else _keep=$(fresh "$DIR/$PID" hold); fi + [ -z "$(sed -n '2,$p' "$DIR/$PID" 2>/dev/null | grep -v '^$')" ] && _held=1 + fi + { + printf '%s\n' "$_id" + [ -z "$_keep" ] || printf '%s\n' "$_keep" + if [ -n "$RUN" ]; then + [ "$_held" -eq 1 ] && echo "hold $(stat -c %Y "$DIR/$PID" 2>/dev/null || stat -f %m "$DIR/$PID" 2>/dev/null || echo "$_now")" + echo "run $RUN $_now" + elif [ -n "$_keep" ]; then echo "hold $_now" + fi + } > "$_tmp" && mv -f "$_tmp" "$DIR/$PID" || { rm -f "$_tmp"; unlock; exit 2; } find "$DIR" -maxdepth 1 -mmin +120 -type f ! -name '.lock*' -exec rm -f {} + 2>/dev/null for _c in "$DIR"/.lock*.claim.*; do [ -f "$_c" ] || continue @@ -274,12 +303,29 @@ case "$VERB" in rm -rf "$DIR"/.lock*.gone.* 2>/dev/null unlock exit 0 ;; + release) + case "$PID" in ''|*[!0-9]*) echo "gstack-lease: release needs --pid and --run " >&2; exit 2 ;; esac + [ -n "$RUN" ] && [ "$RUN" != - ] || { echo "gstack-lease: release needs --pid and --run " >&2; exit 2; } + [ -f "$DIR/$PID" ] || exit 0 + lock || exit 3 + f="$DIR/$PID" _tmp="$DIR/.$PID.$$" + if sed -n '2,$p' "$f" | awk -v id="$RUN" '$1 == "run" && $2 == id { found = 1 } END { exit !found }'; then + _keep=$(fresh "$f" "run $RUN") + { + head -1 "$f" + if [ -n "$_keep" ]; then printf '%s\n' "$_keep"; else echo idle; fi + } > "$_tmp" && touch -r "$f" "$_tmp" && mv -f "$_tmp" "$f" || { rm -f "$_tmp"; unlock; exit 2; } + fi + unlock + exit 0 ;; list) for f in "$DIR"/*; do [ -f "$f" ] || continue p=${f##*/} _age=$(age_min "$f") - echo "$p ${_age}m $(state "$p" "$(head -1 "$f" 2>/dev/null)" "$_age")" + _st=$(state "$p" "$(head -1 "$f" 2>/dev/null)" "$_age") + [ "$_st" = alive ] && idle "$f" && _st=idle + echo "$p ${_age}m $_st" done exit 0 ;; esac @@ -296,6 +342,7 @@ done FOUND=1 for f in "$DIR"/*; do [ -f "$f" ] || continue + idle "$f" && continue p=${f##*/} _age=$(age_min "$f") case "$ANCESTRY" in *" $p "*) [ "$SHARED" -eq 1 ] && [ "$_age" -lt 120 ] && gstack_shared_harness "$p" || continue ;; esac diff --git a/bin/gstack-skill-end b/bin/gstack-skill-end index 865255a46..441495814 100755 --- a/bin/gstack-skill-end +++ b/bin/gstack-skill-end @@ -57,6 +57,9 @@ _TEL_END=$(date +%s) case "$TEL_START" in ''|*[!0-9]*) TEL_START="$_TEL_END" ;; esac _TEL_DUR=$(( _TEL_END - TEL_START )) [ -n "$SESSION_ID" ] && rm -f "$_GH/analytics/.pending-$SESSION_ID" 2>/dev/null || true +# Release the workflow lease run gstack-skill-start opened (SESSION_ID starts +# with its harness pid), so a finished run stops blocking gstack-upgrade (GST-1205). +[ -n "$SESSION_ID" ] && "$_BIN/gstack-lease" release --pid "${SESSION_ID%%-*}" --run "$SESSION_ID" >/dev/null 2>&1 || true # Record completion before the final drain, including its durable enqueue. # The normal sync gates still decide publication: full may sync timelines, diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 16b1c9133..aca52a879 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -127,13 +127,6 @@ fi mkdir -p "$_GH/sessions" 2>/dev/null || true touch "$_GH/sessions/$PARENT_PID" 2>/dev/null || true find "$_GH/sessions" -mmin +120 -type f -exec rm {} + 2>/dev/null || true -# The install-scoped twin (bin/gstack-lease): what bin/gstack-upgrade reads -# before moving a checkout other sessions share. Fail-open, but a lease the -# held lock kept from being written is reported. -"$_BIN/gstack-lease" touch --pid "$PARENT_PID" >/dev/null 2>&1 || { - [ $? -eq 3 ] && echo "gstack-lease: the lease directory stayed locked by a live process; this session holds no workflow lease, so bin/gstack-upgrade cannot see it" >&2 - true -} _SESSIONS=$(find "$_GH/sessions" -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ') _PROACTIVE=$("$_BIN/gstack-config" get proactive 2>/dev/null || echo "core") @@ -241,9 +234,18 @@ _TEL_PROMPTED=$([ -f "$_GH/.telemetry-prompted" ] && echo "yes" || echo "no") _TEL_START=$(date +%s) # PID+epoch alone is guessable; a random suffix makes the block-binding token # unforgeable by content reflected into the same tool result. Falls back to -# the plain form when urandom is unavailable. +# this process's pid when urandom is unavailable, so two same-second starts +# never share a SESSION_ID (it also keys their lease runs, GST-1205). _SID_RAND=$(od -An -N4 -tx4 /dev/urandom 2>/dev/null | tr -d ' \n' || true) -_SESSION_ID="$PARENT_PID-$_TEL_START${_SID_RAND:+-$_SID_RAND}" +_SESSION_ID="$PARENT_PID-$_TEL_START-${_SID_RAND:-$$}" +# The install-scoped twin (bin/gstack-lease): what bin/gstack-upgrade reads +# before moving a checkout other sessions share. This run holds it until +# gstack-skill-end releases its SESSION_ID (GST-1205). Fail-open, but a lease +# the held lock kept from being written is reported. +"$_BIN/gstack-lease" touch --pid "$PARENT_PID" --run "$_SESSION_ID" >/dev/null 2>&1 || { + [ $? -eq 3 ] && echo "gstack-lease: the lease directory stayed locked by a live process; this session holds no workflow lease, so bin/gstack-upgrade cannot see it" >&2 + true +} echo "TELEMETRY: ${_TEL:-off}" echo "TEL_PROMPTED: $_TEL_PROMPTED" echo "SESSION_ID: $_SESSION_ID" diff --git a/docs/capy-install.md b/docs/capy-install.md index 53326df6d..6f0feef46 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -187,7 +187,7 @@ run from inside a workflow (/ship's docs step running `install.sh --for document-release`, #3571) prints `SELF_UPGRADE: skipped (a gstack run in this 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 +`gstack-skill-start` renews the lease and records its `SESSION_ID` as an open run; `gstack-skill-end` closes that run, and a lease whose runs are all closed is idle and blocks nothing, so a finished /ship no longer holds the upgrade back (GST-1205). Each run counts for 120 minutes from its own start, whatever other sessions renew. A lease with no recorded run (written without `--run`, or by an older gstack) counts as before. 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. Every session on the machine runs under that same `capy-server`, so `bin/gstack-upgrade` run by hand also @@ -231,7 +231,7 @@ identities, a taker stalled over 2 minutes between its `mkdir` and its owner rename can be broken and then publish into its successor's lock. While a live process holds the lock, or it cannot be taken at all, `touch` exits 3 without writing (`gstack-skill-start` reports that the session holds no lease) and a reclaim -keeps the lease. `bin/gstack-lease list` shows `alive|dead|stale`, and +keeps the lease. `bin/gstack-lease list` shows `alive|idle|dead|stale`, and `GSTACK_UPGRADE_FORCE=1` overrides. ## What the installer does diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 95e20574f..f17f55959 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1705,10 +1705,12 @@ session holds a workflow lease on this installation: pid

(m /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. +checkout where the run started. Each `gstack-skill-start` renews the lease +and opens a run that its `gstack-skill-end` closes; a lease whose runs are all +closed is idle and never counts (GST-1205). **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 +every lease with its age and `alive|idle|dead|stale`; a holder is named as `pid

(m )`), then re-run `bin/gstack-upgrade`. For the 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` diff --git a/package.json b/package.json index 1ca02c0a8..d6c71b54c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.94.18", + "version": "1.94.19", "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/gstack-lease.test.ts b/test/gstack-lease.test.ts index 1736530f5..7850633d2 100644 --- a/test/gstack-lease.test.ts +++ b/test/gstack-lease.test.ts @@ -91,7 +91,7 @@ describe('gstack-lease', () => { const r = spawnSync('bash', ['-n', LEASE], { encoding: 'utf8', timeout: 30_000 }); expect(r.status, r.stderr).toBe(0); const help = spawnSync('bash', [LEASE, '--help'], { encoding: 'utf8', timeout: 30_000 }); - expect(help.stdout).toContain('print ` m alive|dead|stale` for every lease'); + expect(help.stdout).toContain('print ` m alive|idle|dead|stale` for every lease'); }); test('touch writes /gstack-leases/ for --pid and prunes leases older than 120 minutes', () => { @@ -716,6 +716,58 @@ describe('gstack-lease', () => { } }); + // GST-1205: gstack-skill-end releases the run gstack-skill-start opened, so a finished run stops holding the lease. + test('gstack-skill-start opens a run on the lease and gstack-skill-end with its SESSION_ID releases it; another holder still counts', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-home-')); + const state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-state-')); + dirs.push(home, state); + fs.writeFileSync(path.join(state, 'config.yaml'), 'update_check: false\n'); + const leaseDir = path.join(state, 'leases'); + const env = { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: state, GSTACK_LEASE_DIR: leaseDir }; + const holder = sleeper(); + const start = spawnSync(START, ['--skill', 'ship', '--parent-pid', String(holder)], { encoding: 'utf8', cwd: home, env, timeout: 30_000 }); + expect(start.status, start.stdout + start.stderr).toBe(0); + const sid = start.stdout.match(/^SESSION_ID: (\S+)$/m)?.[1] ?? ''; + expect(sid.startsWith(`${holder}-`), sid).toBe(true); + expect(run(['live'], env).status).toBe(0); + const end = spawnSync(path.join(REPO, 'bin/gstack-skill-end'), ['--skill', 'ship', '--outcome', 'success', '--session-id', sid, '--tel-start', '0'], { encoding: 'utf8', cwd: home, env, timeout: 30_000 }); + expect(end.status, end.stdout + end.stderr).toBe(0); + const after = run(['live'], env); + expect(after.status, after.stdout + after.stderr).toBe(1); + expect(run(['list'], env).stdout).toMatch(new RegExp(`^${holder} \\d+m idle$`, 'm')); + const other = sleeper(); + expect(run(['touch', '--pid', String(other)], env).status).toBe(0); + expect(run(['live'], env).stdout).toMatch(new RegExp(`^${other} \\d+m sleep$`, 'm')); + }); + + test('GST-1205 review: a hold without --run survives a sibling run\'s release in either order; an unrecorded release changes nothing; an orphaned run ages out by itself', () => { + const { dir, leases } = checkout(); + const live = (pid: number) => run(['live', '--root', dir]).stdout.includes(`${pid} `); + for (const order of [['hold', 'run'], ['run', 'hold']]) { + const p = sleeper(); + for (const step of order) expect(run(['touch', '--root', dir, '--pid', String(p), ...(step === 'run' ? ['--run', `${p}-1-a`] : [])]).status).toBe(0); + expect(run(['release', '--root', dir, '--pid', String(p), '--run', `${p}-1-a`]).status).toBe(0); + expect(live(p), order.join('>')).toBe(true); + } + const legacy = sleeper(); + expect(run(['touch', '--root', dir, '--pid', String(legacy)]).status).toBe(0); + expect(run(['release', '--root', dir, '--pid', String(legacy), '--run', `${legacy}-9-z`]).status).toBe(0); + expect(live(legacy)).toBe(true); + expect(run(['release', '--root', dir, '--pid', String(legacy)]).status).toBe(2); + expect(run(['release', '--root', dir, '--pid', 'x', '--run', 'y']).status).toBe(2); + const shared = sleeper(); + expect(run(['touch', '--root', dir, '--pid', String(shared), '--run', `${shared}-1-old`]).status).toBe(0); + const file = path.join(leases, String(shared)); + const old = Math.floor(Date.now() / 1000) - 121 * 60; + fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace(/^run (\S+) \d+$/m, `run $1 ${old}`)); + expect(live(shared)).toBe(false); + expect(run(['touch', '--root', dir, '--pid', String(shared), '--run', `${shared}-2-new`]).status).toBe(0); + expect(live(shared)).toBe(true); + expect(run(['release', '--root', dir, '--pid', String(shared), '--run', `${shared}-2-new`]).status).toBe(0); + expect(live(shared)).toBe(false); + expect(fs.readFileSync(file, 'utf8')).not.toContain('-1-old'); + }); + test('gstack-skill-start reports a lease the held lock kept from being written, and still starts', () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-home-')); const state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-state-')); diff --git a/test/gstack-upgrade-bin.test.ts b/test/gstack-upgrade-bin.test.ts index b64037b4e..6cd24a154 100644 --- a/test/gstack-upgrade-bin.test.ts +++ b/test/gstack-upgrade-bin.test.ts @@ -216,14 +216,14 @@ describe('gstack-upgrade', () => { // 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 underHarness = (f: ReturnType, harness: string, ageMinutes: number, extra: Record = {}, lease = 'bash "$1" touch --root "$2" --pid $$ || exit 9') => { 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', + lease, '[ "$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=$?"', @@ -242,6 +242,21 @@ describe('gstack-upgrade', () => { expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); }); + // GST-1205: a finished /ship run's lease used to block the upgrade for 120 minutes. + test('a lease whose runs all finished (gstack-lease release) no longer blocks; a sibling run still open on the same harness does', () => { + const touch = (id: string) => `bash "$1" touch --root "$2" --pid $$ --run "$$-${id}"`; + const release = (id: string) => `bash "$1" release --root "$2" --pid $$ --run "$$-${id}"`; + const f = fixture(); + const open = underHarness(f, 'capy-server', 0, {}, `${touch('1-a')} && ${touch('2-b')} && ${release('1-a')} || exit 9`); + expect(open.stdout, open.stdout + open.stderr).toContain('exit=3'); + expect(open.stderr).toContain('(UPGRADE_BUSY)'); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a); + const finished = underHarness(f, 'capy-server', 0, {}, `${touch('1-a')} && ${release('1-a')} || exit 9`); + expect(finished.stdout, finished.stdout + finished.stderr).toContain('exit=0'); + expect(finished.stderr).not.toContain('UPGRADE_BUSY'); + 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);