mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
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>
This commit is contained in:
1 parent
d7a650fcea
commit
7fa9a1bdcf
10 files changed
+162
-35
No files matched your search
+11
-2
@@ -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 <ids>`)
|
||||
# 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 <rev> is <n> commits behind origin (latest <ver>)
|
||||
# and the session-start upgrade was skipped: <reason>; fix: <command> (<CODE>)
|
||||
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: '
|
||||
|
||||
@@ -33,7 +33,10 @@
|
||||
# VERSION> and GSTACK_CAPY_BEHIND_FIX=<command> for the installer's trailer notes
|
||||
# `behind=<n> (latest: <remote>); fix: <command>`. 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=<reason; fix: …
|
||||
# (CODE)>, 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 <checkout> [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:-<skill>/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" "$@"
|
||||
+45
-12
@@ -9,16 +9,23 @@
|
||||
#
|
||||
# Usage:
|
||||
# gstack-lease touch [--root <checkout>] [--pid <p> | --from <pid>]
|
||||
# write <git dir>/gstack-leases/<pid>; 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 <git dir>/gstack-leases/<pid> 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 <checkout>] [--exclude-ancestry-of <pid>]
|
||||
# print `<pid> <age>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 `<pid> <age>m <command>` per lease held by a live process that
|
||||
# is neither the given pid nor one of its ancestors (the caller's own
|
||||
# session); exit 0 when any live lease exists, 1 when none. A stale lease
|
||||
# is reclaimed (deleted, one `gstack-lease: reclaimed …` line on stderr)
|
||||
# gstack-lease list [--root <checkout>]
|
||||
# print `<pid> <age>m alive|dead` for every lease
|
||||
# print `<pid> <age>m alive|dead|stale` for every lease
|
||||
# A pid alone is not a holder: pids are reused after a reboot or a machine
|
||||
# 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.
|
||||
# <checkout> 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
|
||||
@@ -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 <n> (<path>)` 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 <skill>` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP|WARN <component>` row per component (`WARN` is an owner action), a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok <revision> for=<skill>[ (<notes>)]` (exit 0) or `gstack: fail <ids> …` (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=<n> (latest: <v>); fix: <command>` (the checkout is <n> commits behind origin; the output also has a `gstack-capy-session: behind origin/<branch>: …` 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 <old> -> <new>`; above it by the checkout, `SELF_UPGRADE: done <old> -> <new>`), 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 (<reason>)` 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=<n> (latest: <v>); fix: <command>` (the checkout is <n> commits behind origin; the output also has a `gstack-capy-session: behind origin/<branch>: …` 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 <old> -> <new>`; above it by the checkout, `SELF_UPGRADE: done <old> -> <new>`), 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 (<reason>)`, 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 <skill>` (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.
|
||||
|
||||
|
||||
+14
-6
@@ -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 <old> ->
|
||||
<new>` 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 (<reason>)` and the session
|
||||
continues on the installed revision; the trailer stays the doctor's.
|
||||
A refusal prints `SELF_UPGRADE: skipped (<reason>)`, and the session is
|
||||
never certified ok: the trailer is `gstack: fail behind — installed <rev> is
|
||||
<n> commits behind origin (latest <ver>) and the session-start upgrade was
|
||||
skipped: <reason>; fix: <command> (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 (<variable>)`. `--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
|
||||
`<git dir>/gstack-leases/<harness pid>` (`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 <p> (<age>m <command>)`. 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
|
||||
|
||||
|
||||
@@ -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 <p> (<age>m <command>)`), 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.
|
||||
|
||||
<a id="upgrade-fetch-failed"></a>
|
||||
### `gstack-upgrade: could not reach origin (...) (UPGRADE_FETCH_FAILED)`
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 <pid> 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)', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user