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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-03 14:30:58 +00:00
1 parent 44471cff1f
commit 2f9d11a59d
11 files changed
+154 -27

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Changelog # 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 ## [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. **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.
+1 -1
View File
@@ -1 +1 @@
1.94.18.0 1.94.19.0
+1 -1
View File
@@ -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 Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows, for agent hosts without a full skill install. The full skills add workflows,
+55 -8
View File
@@ -8,8 +8,11 @@
# shares them. # shares them.
# #
# Usage: # Usage:
# gstack-lease touch [--root <checkout>] [--pid <p> | --from <pid>] # gstack-lease touch [--root <checkout>] [--pid <p> | --from <pid>] [--run <id>]
# write <git dir>/gstack-leases/<pid> holding the pid's start identity; # write <git dir>/gstack-leases/<pid> holding the pid's start identity
# and the open run <id> (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 # pid defaults to the harness pid behind this call
# (bin/gstack-harness-pid.sh, the same derivation the session marker # (bin/gstack-harness-pid.sh, the same derivation the session marker
# uses), or behind --from (a caller whose own parent is short-lived # 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 # --count-shared-harness (bin/gstack-upgrade only) still prints an
# ancestor that is a shared harness (capy-server: every session on the # ancestor that is a shared harness (capy-server: every session on the
# machine runs under it) while its lease is under 120 minutes old # machine runs under it) while its lease is under 120 minutes old
# gstack-lease release --pid <p> --run <id> [--root <checkout>]
# close run <id> on <pid>'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 <checkout>] # gstack-lease list [--root <checkout>]
# print `<pid> <age>m alive|dead|stale` for every lease # print `<pid> <age>m alive|idle|dead|stale` for every lease
# A pid alone is not a holder: pids are reused after a reboot or a machine # 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 # 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 # /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 VERB="${1:-}"; [ $# -gt 0 ] && shift
ROOT="" PID="" EXCLUDE="" FROM="" SHARED=0 ROOT="" PID="" EXCLUDE="" FROM="" SHARED=0 RUN=""
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--root) ROOT="${2:-}"; shift ;; --root) ROOT="${2:-}"; shift ;;
--pid) PID="${2:-}"; shift ;; --pid) PID="${2:-}"; shift ;;
--from) FROM="${2:-}"; shift ;; --from) FROM="${2:-}"; shift ;;
--run) RUN="${2:-}"; shift ;;
--exclude-ancestry-of) EXCLUDE="${2:-}"; shift ;; --exclude-ancestry-of) EXCLUDE="${2:-}"; shift ;;
--count-shared-harness) SHARED=1 ;; --count-shared-harness) SHARED=1 ;;
*) echo "gstack-lease: unknown argument '$1'" >&2; exit 2 ;; *) echo "gstack-lease: unknown argument '$1'" >&2; exit 2 ;;
@@ -60,8 +67,8 @@ while [ $# -gt 0 ]; do
shift shift
done done
case "$VERB" in case "$VERB" in
touch|live|list) ;; touch|live|list|release) ;;
-h|--help|'') sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; -h|--help|'') sed -n '2,34p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "gstack-lease: unknown verb '$VERB'" >&2; exit 2 ;; *) echo "gstack-lease: unknown verb '$VERB'" >&2; exit 2 ;;
esac esac
[ -n "$ROOT" ] || ROOT=$(gstack_source_root "$BIN") [ -n "$ROOT" ] || ROOT=$(gstack_source_root "$BIN")
@@ -248,6 +255,15 @@ reclaim() {
unlock unlock
return 1 return 1
} }
# Run entries (GST-1205), lines after the identity: `run <id> <epoch>` per
# open run, `hold <epoch>` 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 <id>" 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 age_min() { # FILE — minutes since its mtime
_m=$(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0) _m=$(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0)
echo $(( ( $(date +%s) - _m ) / 60 )) echo $(( ( $(date +%s) - _m ) / 60 ))
@@ -264,7 +280,20 @@ case "$VERB" in
exit 3 exit 3
} }
_tmp="$DIR/.$PID.$$" _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 find "$DIR" -maxdepth 1 -mmin +120 -type f ! -name '.lock*' -exec rm -f {} + 2>/dev/null
for _c in "$DIR"/.lock*.claim.*; do for _c in "$DIR"/.lock*.claim.*; do
[ -f "$_c" ] || continue [ -f "$_c" ] || continue
@@ -274,12 +303,29 @@ case "$VERB" in
rm -rf "$DIR"/.lock*.gone.* 2>/dev/null rm -rf "$DIR"/.lock*.gone.* 2>/dev/null
unlock unlock
exit 0 ;; exit 0 ;;
release)
case "$PID" in ''|*[!0-9]*) echo "gstack-lease: release needs --pid <number> and --run <id>" >&2; exit 2 ;; esac
[ -n "$RUN" ] && [ "$RUN" != - ] || { echo "gstack-lease: release needs --pid <number> and --run <id>" >&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) list)
for f in "$DIR"/*; do for f in "$DIR"/*; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
p=${f##*/} p=${f##*/}
_age=$(age_min "$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 done
exit 0 ;; exit 0 ;;
esac esac
@@ -296,6 +342,7 @@ done
FOUND=1 FOUND=1
for f in "$DIR"/*; do for f in "$DIR"/*; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
idle "$f" && continue
p=${f##*/} p=${f##*/}
_age=$(age_min "$f") _age=$(age_min "$f")
case "$ANCESTRY" in *" $p "*) [ "$SHARED" -eq 1 ] && [ "$_age" -lt 120 ] && gstack_shared_harness "$p" || continue ;; esac case "$ANCESTRY" in *" $p "*) [ "$SHARED" -eq 1 ] && [ "$_age" -lt 120 ] && gstack_shared_harness "$p" || continue ;; esac
+3
View File
@@ -57,6 +57,9 @@ _TEL_END=$(date +%s)
case "$TEL_START" in ''|*[!0-9]*) TEL_START="$_TEL_END" ;; esac case "$TEL_START" in ''|*[!0-9]*) TEL_START="$_TEL_END" ;; esac
_TEL_DUR=$(( _TEL_END - TEL_START )) _TEL_DUR=$(( _TEL_END - TEL_START ))
[ -n "$SESSION_ID" ] && rm -f "$_GH/analytics/.pending-$SESSION_ID" 2>/dev/null || true [ -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. # Record completion before the final drain, including its durable enqueue.
# The normal sync gates still decide publication: full may sync timelines, # The normal sync gates still decide publication: full may sync timelines,
+11 -9
View File
@@ -127,13 +127,6 @@ fi
mkdir -p "$_GH/sessions" 2>/dev/null || true mkdir -p "$_GH/sessions" 2>/dev/null || true
touch "$_GH/sessions/$PARENT_PID" 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 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 ' ') _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") _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) _TEL_START=$(date +%s)
# PID+epoch alone is guessable; a random suffix makes the block-binding token # 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 # 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) _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 "TELEMETRY: ${_TEL:-off}"
echo "TEL_PROMPTED: $_TEL_PROMPTED" echo "TEL_PROMPTED: $_TEL_PROMPTED"
echo "SESSION_ID: $_SESSION_ID" echo "SESSION_ID: $_SESSION_ID"
+2 -2
View File
@@ -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 document-release`, #3571) prints `SELF_UPGRADE: skipped (a gstack run in this
session holds a workflow lease on this installation: ... (UPGRADE_BUSY))`, session holds a workflow lease on this installation: ... (UPGRADE_BUSY))`,
leaves the checkout where the run started and stays ok with `behind=`. Each 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 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 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 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 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 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 (`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. `GSTACK_UPGRADE_FORCE=1` overrides.
## What the installer does ## What the installer does
+4 -2
View File
@@ -1705,10 +1705,12 @@ session holds a workflow lease on this installation: pid <p> (<age>m
<checkout>/bin/gstack-upgrade once the lease is 120 minutes old, or <checkout>/bin/gstack-upgrade once the lease is 120 minutes old, or
GSTACK_UPGRADE_FORCE=1 <checkout>/bin/gstack-upgrade when no other session uses GSTACK_UPGRADE_FORCE=1 <checkout>/bin/gstack-upgrade when no other session uses
this checkout (UPGRADE_BUSY))` and leaves the 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 **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 <p> (<age>m <command>)`), then re-run `bin/gstack-upgrade`. For the `pid <p> (<age>m <command>)`), then re-run `bin/gstack-upgrade`. For the
session's own run, or a shared-harness lease, re-run once the lease is 120 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` minutes old. When you know no other session uses the checkout, `GSTACK_UPGRADE_FORCE=1 bin/gstack-upgrade`
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gstack", "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.", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+53 -1
View File
@@ -91,7 +91,7 @@ describe('gstack-lease', () => {
const r = spawnSync('bash', ['-n', LEASE], { encoding: 'utf8', timeout: 30_000 }); const r = spawnSync('bash', ['-n', LEASE], { encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0); expect(r.status, r.stderr).toBe(0);
const help = spawnSync('bash', [LEASE, '--help'], { encoding: 'utf8', timeout: 30_000 }); const help = spawnSync('bash', [LEASE, '--help'], { encoding: 'utf8', timeout: 30_000 });
expect(help.stdout).toContain('print `<pid> <age>m alive|dead|stale` for every lease'); expect(help.stdout).toContain('print `<pid> <age>m alive|idle|dead|stale` for every lease');
}); });
test('touch writes <git dir>/gstack-leases/<pid> for --pid and prunes leases older than 120 minutes', () => { test('touch writes <git dir>/gstack-leases/<pid> 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', () => { 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 home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-home-'));
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-state-')); const state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-state-'));
+17 -2
View File
@@ -216,14 +216,14 @@ describe('gstack-upgrade', () => {
// every session's lease names capy-server and the caller's own-ancestry // 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. // 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', () => { describe('a lease held by a shared harness (capy-server) ancestor', () => {
const underHarness = (f: ReturnType<typeof fixture>, harness: string, ageMinutes: number, extra: Record<string, string> = {}) => { const underHarness = (f: ReturnType<typeof fixture>, harness: string, ageMinutes: number, extra: Record<string, string> = {}, lease = 'bash "$1" touch --root "$2" --pid $$ || exit 9') => {
const bin = path.join(f.host.base, 'harness'); const bin = path.join(f.host.base, 'harness');
fs.mkdirSync(bin, { recursive: true }); fs.mkdirSync(bin, { recursive: true });
const exe = path.join(bin, harness); 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); 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); fs.chmodSync(exe, 0o755);
const script = [ 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"', '[ "$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"', 'bash "$3"',
'echo "exit=$?"', 'echo "exit=$?"',
@@ -242,6 +242,21 @@ describe('gstack-upgrade', () => {
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); 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', () => { test('once that lease is 120 minutes old the upgrade proceeds', () => {
const f = fixture(); const f = fixture();
const r = underHarness(f, 'capy-server', 121); const r = underHarness(f, 'capy-server', 121);