Merge remote-tracking branch 'origin/main' into capy/agent-ready-errors

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

# Conflicts:
#	CHANGELOG.md
#	VERSION
#	agents-digest/gstack-AGENTS.md
#	package.json
This commit is contained in:
garrytan committed 2026-10-17 01:54:08 +00:00
commit af984de28c
15 files changed
+540 -44

No files matched your search

+16
View File
@@ -18,6 +18,22 @@
- New paid behavior case `dx-error-contract-review` (periodic, 3 trials). The native DX reviewer must cap a hostile CLI path and an unspecified path, and must not cap three decoys. Its grader is replayed free in `test/dx-error-contract-grader.test.ts`.
- Known limit: containment lasts only while the helper runs. A SIGKILLed helper, or a descendant that leaves the process group, outlives it, which is why captures are limited to read-only invocations. The helper's Windows and macOS paths are covered only by CI's free shards.
## [1.91.113.0] - 2026-10-17
**A Capy install that has fallen behind now says so in the one line lanes relay, a skipped upgrade is never reported ok, and a reused pid no longer blocks every upgrade.** Lanes pass along only the `gstack:` trailer, so a session that was 12 commits behind origin looked current. Two field reports (GBRA-105, GBRA-106) showed session-start upgrades wedged behind a lease whose pid now belonged to dbus-daemon or systemd-logind, while the trailer still said ok.
### What this means for you
- **The ok trailer carries the gap and the fix:** `gstack: ok <v> for=<skill> (behind=<n> (latest: <v>); fix: <command>)`. The fix is the command that actually closes the gap: a rerun, `gstack-upgrade` for a dirty, detached or diverged checkout, or `unset <VAR>` when upgrades are switched off. Run it.
- **A skipped session-start upgrade fails.** When the upgrade is busy, refused or missing, install mode ends `gstack: fail behind — … (UPGRADE_BUSY|UPGRADE_REFUSED)` with exit 1, and workflows refuse until the fix runs. An explicit opt-out stays ok with the `behind=` note.
- **A lease records who holds it.** `gstack-lease` stores the holder's start identity (boot id plus start time, or `ps` lstart). A lease whose pid now belongs to another process is reclaimed, with a `gstack-lease: reclaimed stale lease …` line. `live`, UPGRADE_BUSY and `fail behind` name each holder as `pid <p> (<age>m <command>)`.
- **The supported minimum rises to this release**, so older Capy checkouts are fast-forwarded at install-mode session start.
### For contributors
- The lease directory has a lock: a symlink naming its holder, broken only when that holder is dead or its pid was reused. Breakers serialize on `.lock.break` and reread before removing. While a live process holds the lock, `touch` exits 3, and `gstack-skill-start` reports that the session holds no workflow lease. Known limit: two gstack-lease processes killed in a row can still let a reclaim delete a renewal.
- The paid-gate Aside drive-offer detector (`test/helpers/third-party-actions.ts`) now matches the product name only. The English "set this aside" was a false positive in paid run 38988927989.
## [1.91.112.0] - 2026-10-17
**`/cso --paths` audits part of a repository and says exactly what it skipped.** A repository over /cso's 64 MiB source cap used to get one piece of advice, "run /cso on a smaller checkout". The trimmed copy then read as the whole repository, and the verdict never said what was left out. `gstack-cso start --paths src/core,src/mcp` now audits only those prefixes and reports every other path as skipped, with snapshot and history coverage marked partial.
+21 -5
View File
@@ -521,11 +521,18 @@ EOF_SHIM
# 4. Certify. The trailer is the contract, in one grammar:
# gstack: ok <rev> for=<skill>[ (requested: …; pins: skipped (planning-only|no project); latest: <ver> available)]
# gstack: ok <rev> for=<skill> ([requested: …; ][pins: …; ]behind=<n> (latest: <ver>); fix: <command>)
# Notes, in that fixed order, ride only on an ok trailer: `requested` when the
# existing checkout is not at --revision (it is never moved here), `pins` when
# the doctor skipped the project pins, `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.
# the doctor skipped the project pins, then either `behind=<n> (latest: <ver>)`
# and `fix: <command>` when the session ends behind origin's default branch
# (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. 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=""
@@ -538,11 +545,20 @@ if printf '%s\n' "$OUT" | grep -q '^SKIP pins (planning-only'; then note "pins:
elif printf '%s\n' "$OUT" | grep -Eq '^SKIP pins \((no project|project directory )'; then note "pins: skipped (no project)"
fi
REMOTE_VERSION="${GSTACK_CAPY_REMOTE_VERSION:-}"
if [ -n "$REMOTE_VERSION" ] && [ "$REMOTE_VERSION" != "$INSTALLED" ] \
if [ -n "${GSTACK_CAPY_BEHIND:-}" ]; then
note "behind=$GSTACK_CAPY_BEHIND (latest: ${GSTACK_CAPY_BEHIND_TO:-${REMOTE_VERSION:-unknown}})"
note "fix: ${GSTACK_CAPY_BEHIND_FIX:-bash <skill>/scripts/install.sh}"
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: '
+32 -3
View File
@@ -28,6 +28,15 @@
# GSTACK_CAPY_AUTO_UPGRADE=0>)`.
# --check never moves the checkout; behind origin it prints
# `gstack-capy-session: behind origin/<branch>: <installed> -> <remote> (<n> commits); fix: ...`.
# Whenever the session ends behind origin (--check, or a self-upgrade that was
# skipped or disabled) it exports GSTACK_CAPY_BEHIND=<n>, GSTACK_CAPY_BEHIND_TO=<remote
# 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. 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
@@ -65,18 +74,36 @@ behind() {
git -C "$ROOT" rev-parse -q --verify FETCH_HEAD >/dev/null 2>&1 || return 1
! git -C "$ROOT" merge-base --is-ancestor FETCH_HEAD HEAD 2>/dev/null
}
disabled() { echo "SELF_UPGRADE: disabled ($1)"; } # VAR — the switch that turned the fast-forward off
disabled() { echo "SELF_UPGRADE: disabled ($1)"; behind && report_behind "unset $1, then $RERUN"; } # VAR — the switch that turned the fast-forward off
# report_behind FIX — hand the installer the gap and the command that closes
# it, for the trailer's `behind=<n>` note (lanes relay only the trailer).
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 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
# The rerun fast-forwards only what gstack-upgrade would move; otherwise
# gstack-upgrade itself names the refusal and its fix.
if [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then report_behind "unset GSTACK_CAPY_SELF_UPGRADE, then $RERUN"
elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then report_behind "unset GSTACK_CAPY_AUTO_UPGRADE, then $RERUN"
elif [ "$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null)" != "$(sed -n "1s/.*branch '\([^']*\)' of .*/\1/p" "$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null)/FETCH_HEAD" 2>/dev/null)" ] \
|| [ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ] \
|| ! git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null; then report_behind "$ROOT/bin/gstack-upgrade"
else report_behind "$RERUN"
fi
BRANCH=$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null || echo HEAD)
echo "gstack-capy-session: behind origin/$BRANCH: $(head -1 "$ROOT/VERSION" 2>/dev/null) -> $(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1) ($(git -C "$ROOT" rev-list --count HEAD..FETCH_HEAD 2>/dev/null || echo '?') commits); fix: bash ${GSTACK_CAPY_WRAPPER_PATH:-<skill>/scripts/install.sh}${SKILL:+ --for $SKILL} (fast-forwards)"
echo "gstack-capy-session: behind origin/$BRANCH: $(head -1 "$ROOT/VERSION" 2>/dev/null) -> $GSTACK_CAPY_BEHIND_TO ($GSTACK_CAPY_BEHIND commits); fix: $GSTACK_CAPY_BEHIND_FIX$([ "$GSTACK_CAPY_BEHIND_FIX" = "$RERUN" ] && echo ' (fast-forwards)')"
fi
elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then
if [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then disabled GSTACK_CAPY_SELF_UPGRADE
elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then disabled GSTACK_CAPY_AUTO_UPGRADE
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)"
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=$?
@@ -89,6 +116,8 @@ elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then
REASON=$(printf '%s\n' "$UP_OUT" | sed -n 's/^gstack-upgrade: \(.*\); nothing was changed; fix: /\1; fix: /p' | tail -1)
[ -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" "$@"
+115 -13
View File
@@ -9,21 +9,29 @@
#
# 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. Exits 3
# and writes nothing when a live process holds the directory lock
# 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
# (hermetic tests; a dev checkout's own tests must not lease it). Bash 3.2
# plus git, ps, find.
# plus git, ps, find, ln, readlink.
set -u
BIN="$(cd "$(dirname "$0")" && pwd -P)"
# shellcheck source=bin/gstack-install-verdict.sh
@@ -54,6 +62,88 @@ 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##*) }
_id="linux $(cat /proc/sys/kernel/random/boot_id 2>/dev/null) $(printf '%s\n' "$_st" | awk '{print $20}')"
else
_id="ps $(LC_ALL=C TZ=UTC0 ps -o lstart= -p "$1" 2>/dev/null | sed 's/^ *//; s/ *$//')"
fi
valid_identity "$_id" && printf '%s\n' "$_id"
return 0
}
# valid_identity ID — a well-formed identity from either backend
valid_identity() {
case "$1" in
"linux "*) printf '%s\n' "$1" | grep -Eq '^linux [0-9a-f-]{36} [0-9]+$' ;;
"ps "?*) case "$1" in "ps "*[!\ ]*) return 0 ;; esac; return 1 ;;
*) return 1 ;;
esac
}
# state PID RECORD AGE — alive | dead | stale for a lease whose record (first
# line) and age were read once by the caller. Stale needs proof: two
# well-formed identities from the same backend that differ. An identity that
# cannot be read now, a malformed record or a backend change keeps the lease
# (never reclaim a live session on missing evidence).
state() {
alive "$1" || { echo dead; return; }
if [ -z "$2" ]; then
[ "$3" -ge 120 ] && echo stale || echo alive
return
fi
_now=$(identity "$1")
if valid_identity "$2" && [ -n "$_now" ] && [ "${2%% *}" = "${_now%% *}" ] && [ "$2" != "$_now" ]; then echo stale
else echo alive
fi
}
# The lease directory's lock: touch's publish and prune, and reclaim's
# reread-and-delete, run under it, so a reclaim never deletes a renewal and a
# lease never disappears while it is live. The lock is a symlink whose target
# names its holder (`<pid> <start identity>`): ln -s creates it and its owner
# in one atomic step. It is broken only when that holder is dead or its pid
# now belongs to another process, never by age, and unlock removes only the
# caller's own lock. A live holder makes lock() give up after
# GSTACK_LEASE_LOCK_TRIES tries 0.1 s apart (default 300).
LOCK="$DIR/.lock" TOKEN=""
lock() {
[ -n "$TOKEN" ] || TOKEN="$$ $(identity $$)"
_tries=0
until ln -s "$TOKEN" "$LOCK" 2>/dev/null; do
_tries=$((_tries + 1))
LOCK_OWNER=$(readlink "$LOCK" 2>/dev/null)
[ -n "$LOCK_OWNER" ] && holder_gone "$LOCK_OWNER" && break_lock "$LOCK_OWNER" && continue
[ "$_tries" -ge "${GSTACK_LEASE_LOCK_TRIES:-300}" ] && return 1
sleep 0.1
done
}
holder_gone() { case "$(state "${1%% *}" "${1#* }" 0)" in dead|stale) return 0 ;; esac; return 1; }
# break_lock OWNER — remove the lock only if it still names OWNER. Breakers
# take a second symlink, .lock.break, and reread the lock under it, so a lock
# taken since the verdict is never removed; a .lock.break whose holder is gone
# is cleared. Returns 1 when another breaker holds .lock.break. Known limit:
# that clear is not atomic, so two gstack-lease processes killed in a row (one
# holding .lock, the next inside this function) can let a reclaim delete a
# renewal; one crash is always safe.
break_lock() {
if ! ln -s "$TOKEN" "$LOCK.break" 2>/dev/null; then
_b=$(readlink "$LOCK.break" 2>/dev/null)
[ -n "$_b" ] && holder_gone "$_b" && [ "$(readlink "$LOCK.break" 2>/dev/null)" = "$_b" ] && rm -f "$LOCK.break"
return 1
fi
[ "$(readlink "$LOCK" 2>/dev/null)" = "$1" ] && rm -f "$LOCK"
rm -f "$LOCK.break"
return 0
}
unlock() { [ "$(readlink "$LOCK" 2>/dev/null)" = "$TOKEN" ] && rm -f "$LOCK"; return 0; }
# reclaim FILE RECORD — delete a stale lease only if, under the lock, it still
# holds the record judged stale; a renewal since then keeps it.
reclaim() {
lock || return 1
if [ "$(head -1 "$1" 2>/dev/null)" = "$2" ]; then rm -f "$1"; unlock; return 0; fi
unlock
return 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 ))
@@ -63,14 +153,18 @@ case "$VERB" in
touch)
[ -n "$PID" ] || PID=$(gstack_harness_pid "${FROM:-$PPID}")
mkdir -p "$DIR" || exit 2
: > "$DIR/$PID" || exit 2
lock || { echo "gstack-lease: the lease directory is locked by live process ${LOCK_OWNER%% *}; lease $PID not renewed" >&2; exit 3; }
_tmp="$DIR/.$PID.$$"
identity "$PID" > "$_tmp" && mv -f "$_tmp" "$DIR/$PID" || { rm -f "$_tmp"; unlock; exit 2; }
find "$DIR" -mmin +120 -type f -exec rm -f {} + 2>/dev/null
unlock
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
_age=$(age_min "$f")
echo "$p ${_age}m $(state "$p" "$(head -1 "$f" 2>/dev/null)" "$_age")"
done
exit 0 ;;
esac
@@ -89,8 +183,16 @@ 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"
_rec=$(head -1 "$f" 2>/dev/null) _age=$(age_min "$f")
case "$(state "$p" "$_rec" "$_age")" in
dead) continue ;;
stale)
if reclaim "$f" "$_rec"; then
echo "gstack-lease: reclaimed stale lease $p (${_age}m; recorded '$_rec', pid $p is now $(ps -o comm= -p "$p" 2>/dev/null | head -1) '$(identity "$p")')" >&2
continue
fi ;;
esac
echo "$p $(age_min "$f")m $(ps -o comm= -p "$p" 2>/dev/null | head -1 | sed 's|.*/||')"
FOUND=0
done
exit $FOUND
+6 -2
View File
@@ -124,8 +124,12 @@ 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.
"$_BIN/gstack-lease" touch --pid "$PARENT_PID" >/dev/null 2>&1 || true
# 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 "true")
+1 -1
View File
@@ -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 the output carries a `gstack-capy-session: behind origin/<branch>: …` line, run the same command without `--check` (900-second timeout). 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.
+1 -1
View File
@@ -12,7 +12,7 @@
# lines; refusals skip and continue). Raise it only for install-honesty or
# security fixes, at stamp time, to the VERSION being released
# (docs/capy-install.md "Raising the supported minimum"); it never exceeds VERSION.
GSTACK_MIN_REVISION=1.91.96.0
GSTACK_MIN_REVISION=1.91.113.0
# GSTACK_CAPY_MIN_WRAPPER: the oldest bootstrapper revision (GSTACK_CAPY_WRAPPER in
# capy/skill/gstack/scripts/install.sh) the owner should run. An older copy still
# works and prints an owner action (CAPY_WRAPPER_STALE).
+38 -9
View File
@@ -42,14 +42,20 @@ 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;
behind origin it prints `gstack-capy-session: behind origin/<branch>:
<installed> -> <remote> (<n> commits); fix: bash <skill>/scripts/install.sh
[--for <skill>] (fast-forwards)` and the agent runs that fix.
[--for <skill>] (fast-forwards)` and the agent runs that fix. Whenever the
session ends behind origin (`--check`, or a self-upgrade that was skipped or
disabled), the ok trailer also says so, because lanes relay only the
trailer: `behind=<n> (latest: <ver>); fix: <command>` (see the trailer
grammar below).
4. When the fetch fails (offline), the bootstrapper says `remote minimum
unknown (fetch failed)` and checks the local install only; it never claims
the minimum, and nothing is fast-forwarded.
@@ -72,10 +78,28 @@ 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 …`). Stale needs proof: two well-formed identities from the same backend
that differ (`ps` is read under `LC_ALL=C TZ=UTC0`). An identity that cannot be
read, a malformed record or a backend change keeps the lease. `touch` (publish
by rename, prune) and reclaim (reread, then delete only the record judged
stale) run under one lock in the lease directory, a symlink naming its holder's
pid and start identity, so a renewal is never deleted and a live lease never
disappears from a concurrent scan. The lock is broken only when its holder is
dead or its pid was reused, never by age, and breakers serialize on a second
symlink and reread the lock before removing it. One killed gstack-lease process
is always safe; two killed in a row, one holding the lock and the next while
breaking it, can still let a reclaim delete a renewal. While a live process holds it,
`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
`GSTACK_UPGRADE_FORCE=1` overrides.
## What the installer does
@@ -213,13 +237,18 @@ The last line is the trailer:
gstack: ok 1.91.96.0 for=autoplan
gstack: ok 1.91.96.0 for=review (requested: v1.91.90.0 (run <checkout>/bin/gstack-upgrade to move); latest: 1.91.101.0 available)
gstack: ok 1.91.96.0 for=autoplan (pins: skipped (planning-only))
gstack: ok 1.91.101.0 for=autoplan (behind=12 (latest: 1.91.107.0); fix: bash <skill>/scripts/install.sh --for autoplan)
gstack: fail runtime,pins
```
An ok trailer carries at most one parenthesised note group, `; `-separated,
in this order: `requested: …` (an existing checkout at another revision than
`--revision`), `pins: skipped (planning-only|no project)`, and `latest: <ver>
available` (origin's default branch has a newer VERSION; the bootstrapper
`--revision`), `pins: skipped (planning-only|no project)`, then either
`behind=<n> (latest: <ver>)` followed by `fix: <command>` (the session ended
<n> commits behind origin's default branch after a fetch that succeeded in this
run; `bin/gstack-capy-session` exports `GSTACK_CAPY_BEHIND`, `_BEHIND_TO` and
`_BEHIND_FIX`, so this works with every bootstrapper revision), or `latest:
<ver> available` (origin's default branch has a newer VERSION; the bootstrapper
exports it as `GSTACK_CAPY_REMOTE_VERSION` only after a fetch that succeeded
in this run). A fail trailer never carries notes. Match `^gstack: ok ` or
`^gstack: fail `; never parse past the first space after the version.
+9 -4
View File
@@ -1413,15 +1413,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)`
+82 -1
View File
@@ -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';
@@ -308,6 +308,87 @@ 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}${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', () => {
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 r = f.run(['--check', '--for', 'autoplan']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${rerun}`);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
fs.writeFileSync(path.join(f.root, 'README.md'), 'local edit\n');
expect(f.run(['--check', '--for', 'autoplan']).last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade`);
f.git(['checkout', '-q', '--', 'README.md']);
f.git(['checkout', '-q', '--detach']);
expect(f.run(['--check', '--for', 'autoplan']).last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade`);
f.git(['checkout', '-q', '-b', 'topic']);
expect(f.run(['--check', '--for', 'autoplan']).last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade`);
f.git(['checkout', '-q', 'main']);
expect(f.run(['--check', '--for', 'autoplan']).stdout).toMatch(/^gstack-capy-session: behind origin\/main: 1\.0\.1 -> 1\.0\.2 \(1 commits\); fix: bash .+ --for autoplan \(fast-forwards\)$/m);
expect(f.run(['--check', '--for', 'autoplan'], { GSTACK_CAPY_AUTO_UPGRADE: '0' }).last).toContain('fix=unset GSTACK_CAPY_AUTO_UPGRADE, then ');
});
test('install mode: a skipped self-upgrade points at gstack-upgrade (which names its refusal); a disabled one names the variable to unset; no fix carries the "; " note separator', () => {
const f = fixture({ cloneAt: 'B' });
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).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].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));
const recorded = fs.readFileSync(file, 'utf8').trim();
fs.writeFileSync(file, (recorded.startsWith('linux ') ? recorded.replace(/\d+$/, (n) => String(Number(n) + 1)) : 'ps Thu Jan 1 00:00:00 1970') + '\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']);
f.git(['merge', '--ff-only', '-q', 'FETCH_HEAD']);
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
const inherited = { GSTACK_CAPY_BEHIND: '9', GSTACK_CAPY_BEHIND_TO: '9.9.9', GSTACK_CAPY_BEHIND_FIX: 'stale' };
expect(f.run(['--check', '--for', 'autoplan'], inherited).last).toBe('gstack: ok 1.0.2 for=autoplan behind=none to=none fix=none');
f.git(['reset', '-q', '--hard', f.b]);
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
f.git(['remote', 'set-url', 'origin', `file://${f.host.base}/missing.git`]);
const offline = f.run(['--check', '--for', 'autoplan'], inherited);
expect(offline.stdout).toContain('remote minimum unknown (fetch failed)');
expect(offline.last).toBe('gstack: ok 1.0.1 for=autoplan behind=none to=none fix=none');
});
});
describe('capy skill: vendored text and support file', () => {
const support = fs.existsSync(path.join(REPO, 'capy/support.env')) ? fs.readFileSync(path.join(REPO, 'capy/support.env'), 'utf8') : '';
test('the wrapper revision meets the checkout minimum; GSTACK_MIN_REVISION <= VERSION', () => {
+21
View File
@@ -269,6 +269,27 @@ describe('gstack-capy-install', () => {
expect(lastLine(failed.stdout)).toBe('gstack: fail codex');
});
test('trailer grammar: behind=<n> and its fix replace latest, after requested and pins; never on a fail trailer', () => {
const f = fixture();
const behind = { GSTACK_CAPY_BEHIND: '5', GSTACK_CAPY_BEHIND_TO: '1.2.8', GSTACK_CAPY_BEHIND_FIX: 'bash /s/install.sh --for autoplan', GSTACK_CAPY_REMOTE_VERSION: '1.2.8' };
expect(lastLine(run(f, [], behind).stdout)).toBe('gstack: ok 1.2.3 for=autoplan (behind=5 (latest: 1.2.8); fix: bash /s/install.sh --for autoplan)');
const all = run(f, ['--revision', 'v9.9.9.9'], { ...behind, STUB_PINS_SKIP: 'planning-only; repo pins differ: x' });
expect(all.status, all.stdout + all.stderr).toBe(0);
expect(lastLine(all.stdout)).toBe(`gstack: ok 1.2.3 for=autoplan (requested: v9.9.9.9 (run ${f.root}/bin/gstack-upgrade to move); pins: skipped (planning-only); behind=5 (latest: 1.2.8); fix: bash /s/install.sh --for autoplan)`);
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');
+193 -3
View File
@@ -54,6 +54,18 @@ function deadPid(): number {
return r.pid!;
}
/** The same backend's identity for a different process start (what a reused pid shows). */
function otherStart(recorded: string): string {
return recorded.startsWith('linux ') ? recorded.replace(/\d+$/, (n) => String(Number(n) + 1)) : 'ps Thu Jan 1 00:00:00 1970';
}
/** A BASH_ENV file whose functions shadow commands inside the lease script (fault injection). */
function bashEnv(dir: string, body: string): string {
const file = path.join(dir, 'bash-env.sh');
fs.writeFileSync(file, body);
return file;
}
describe('gstack-lease', () => {
test('bash -n passes and the bin is executable', () => {
expect(fs.existsSync(LEASE), LEASE).toBe(true);
@@ -86,7 +98,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 +106,174 @@ 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)), otherStart(recorded) + '\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('missing evidence never reclaims a live lease: an unreadable current identity, a malformed record or another backend keeps it', () => {
const { dir, leases } = checkout();
const holder = sleeper();
const file = path.join(leases, String(holder));
expect(run(['touch', '--root', dir, '--pid', String(holder)]).status).toBe(0);
const recorded = fs.readFileSync(file, 'utf8').trim();
const blind = bashEnv(dir, 'cat() { case "$1" in /proc/*/stat) return 1 ;; esac; command cat "$@"; }\nps() { case "$*" in *lstart*) return 1 ;; esac; command ps "$@"; }\n');
const unreadable = run(['live', '--root', dir], { BASH_ENV: blind });
expect(unreadable.status, unreadable.stdout + unreadable.stderr).toBe(0);
expect(unreadable.stderr).not.toContain('reclaimed');
for (const record of ['linux not-a-boot-id 12', 'linux ' + '0'.repeat(36) + ' x1', recorded.startsWith('linux ') ? 'ps Thu Jan 1 00:00:00 1970' : 'linux ' + '0'.repeat(8) + '-0000-0000-0000-' + '0'.repeat(12) + ' 1']) {
fs.writeFileSync(file, record + '\n');
const r = run(['live', '--root', dir]);
expect(r.status, record + ': ' + r.stderr).toBe(0);
expect(fs.existsSync(file), record).toBe(true);
}
});
test('the recorded identity does not depend on the caller\'s timezone or locale', () => {
const { dir } = checkout();
const holder = sleeper();
expect(run(['touch', '--root', dir, '--pid', String(holder)], { TZ: 'UTC', LC_ALL: 'C' }).status).toBe(0);
const r = run(['live', '--root', dir], { TZ: 'Pacific/Kiritimati', LC_ALL: 'C.UTF-8' });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stderr).not.toContain('reclaimed');
});
test('a renewal that lands between the stale verdict and the reclaim survives: reclaim rereads under the lock', () => {
const { dir, leases } = checkout();
const holder = sleeper();
const file = path.join(leases, String(holder));
expect(run(['touch', '--root', dir, '--pid', String(holder)]).status).toBe(0);
const fresh = fs.readFileSync(file, 'utf8');
fs.writeFileSync(file, otherStart(fresh.trim()) + '\n');
const renewed = path.join(dir, 'renewed');
fs.writeFileSync(renewed, fresh);
// The holder renews just before reclaim takes the lock, as if touch ran after the stale verdict.
const racing = bashEnv(dir, `ln() { case "$*" in *"/.lock") command cp "${renewed}" "${file}" ;; esac; command ln "$@"; }\n`);
const r = run(['live', '--root', dir], { BASH_ENV: racing });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stderr).not.toContain('reclaimed');
expect(fs.readFileSync(file, 'utf8')).toBe(fresh);
expect(fs.readdirSync(leases).filter((n) => n.startsWith('.'))).toEqual([]);
});
test('a lock whose holder died, or whose pid now belongs to another process, is broken at once', () => {
const { dir, leases } = checkout();
const holder = sleeper();
fs.mkdirSync(leases, { recursive: true });
fs.symlinkSync(`${deadPid()} `, path.join(leases, '.lock'));
const r = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(fs.existsSync(path.join(leases, '.lock'))).toBe(false);
const identity = fs.readFileSync(path.join(leases, String(holder)), 'utf8').trim();
expect(identity).toMatch(/^(linux|ps) /);
fs.symlinkSync(`${holder} ${otherStart(identity)}`, path.join(leases, '.lock'));
const again = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' });
expect(again.status, again.stdout + again.stderr).toBe(0);
expect(fs.existsSync(path.join(leases, '.lock'))).toBe(false);
});
test('a live holder\'s lock is never broken, however old: touch exits 3 without writing and a reclaim keeps the lease', () => {
const { dir, leases } = checkout();
const owner = sleeper();
const holder = sleeper();
expect(run(['touch', '--root', dir, '--pid', String(owner)]).status).toBe(0);
expect(run(['touch', '--root', dir, '--pid', String(holder)]).status).toBe(0);
const ownerToken = `${owner} ${fs.readFileSync(path.join(leases, String(owner)), 'utf8').trim()}`;
const lockPath = path.join(leases, '.lock');
fs.symlinkSync(ownerToken, lockPath);
const old = new Date(Date.now() - 5 * 60_000);
fs.lutimesSync(lockPath, old, old);
const holderFile = path.join(leases, String(holder));
const stale = otherStart(fs.readFileSync(holderFile, 'utf8').trim()) + '\n';
fs.writeFileSync(holderFile, stale);
const t = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' });
expect(t.status, t.stdout + t.stderr).toBe(3);
expect(t.stderr).toContain(`locked by live process ${owner}`);
expect(fs.readFileSync(holderFile, 'utf8')).toBe(stale);
const l = run(['live', '--root', dir], { GSTACK_LEASE_LOCK_TRIES: '3' });
expect(l.status, l.stdout + l.stderr).toBe(0);
expect(l.stderr).not.toContain('reclaimed');
expect(l.stdout).toContain(`${holder} `);
expect(fs.existsSync(holderFile)).toBe(true);
expect(fs.readlinkSync(lockPath)).toBe(ownerToken);
});
test('two breakers of one dead lock: the second rereads under .lock.break and leaves the lock the first one took', () => {
const { dir, leases } = checkout();
const owner = sleeper();
const holder = sleeper();
expect(run(['touch', '--root', dir, '--pid', String(owner)]).status).toBe(0);
const ownerToken = `${owner} ${fs.readFileSync(path.join(leases, String(owner)), 'utf8').trim()}`;
const lockPath = path.join(leases, '.lock');
fs.symlinkSync(`${deadPid()} `, lockPath);
// Between this breaker's dead verdict and its .lock.break, another breaker removes the dead lock and takes it.
const first = bashEnv(dir, `ln() { case "$*" in *"/.lock.break") command rm -f "${lockPath}"; command ln -s "${ownerToken}" "${lockPath}" ;; esac; command ln "$@"; }\n`);
const t = run(['touch', '--root', dir, '--pid', String(holder)], { BASH_ENV: first, GSTACK_LEASE_LOCK_TRIES: '3' });
expect(t.status, t.stdout + t.stderr).toBe(3);
expect(fs.readlinkSync(lockPath)).toBe(ownerToken);
expect(fs.existsSync(path.join(leases, '.lock.break'))).toBe(false);
});
test('a .lock.break left by a dead breaker is cleared and the dead lock is still broken', () => {
const { dir, leases } = checkout();
const holder = sleeper();
fs.mkdirSync(leases, { recursive: true });
fs.symlinkSync(`${deadPid()} `, path.join(leases, '.lock'));
fs.symlinkSync(`${deadPid()} `, path.join(leases, '.lock.break'));
const r = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '5' });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(fs.existsSync(path.join(leases, String(holder)))).toBe(true);
expect(fs.readdirSync(leases).filter((n) => n.startsWith('.'))).toEqual([]);
});
test('unlock removes only its own lock: a lock that changed hands during a reclaim stays', () => {
const { dir, leases } = checkout();
const owner = sleeper();
const holder = sleeper();
expect(run(['touch', '--root', dir, '--pid', String(owner)]).status).toBe(0);
expect(run(['touch', '--root', dir, '--pid', String(holder)]).status).toBe(0);
const ownerToken = `${owner} ${fs.readFileSync(path.join(leases, String(owner)), 'utf8').trim()}`;
const holderFile = path.join(leases, String(holder));
fs.writeFileSync(holderFile, otherStart(fs.readFileSync(holderFile, 'utf8').trim()) + '\n');
const lockPath = path.join(leases, '.lock');
// While reclaim deletes the stale lease under its lock, the lock changes hands.
const swap = bashEnv(dir, `rm() { if [ "$*" = "-f ${holderFile}" ]; then command rm -f "${lockPath}"; command ln -s "${ownerToken}" "${lockPath}"; fi; command rm "$@"; }\n`);
const r = run(['live', '--root', dir, '--exclude-ancestry-of', String(owner)], { BASH_ENV: swap });
expect(r.stderr).toContain(`reclaimed stale lease ${holder}`);
expect(fs.readlinkSync(lockPath)).toBe(ownerToken);
});
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 +286,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)', () => {
@@ -151,4 +325,20 @@ describe('gstack-lease', () => {
fs.rmSync(lease, { force: true });
}
});
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-'));
dirs.push(home, state);
fs.writeFileSync(path.join(state, 'config.yaml'), 'update_check: false\n');
const leaseDir = path.join(state, 'leases');
fs.mkdirSync(leaseDir);
fs.symlinkSync(`${sleeper()} `, path.join(leaseDir, '.lock'));
const r = spawnSync(START, ['--skill', 'testskill', '--parent-pid', '424244'], {
encoding: 'utf8', cwd: home, env: { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: state, GSTACK_LEASE_DIR: leaseDir, GSTACK_LEASE_LOCK_TRIES: '2' }, timeout: 30_000,
});
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stderr).toContain('this session holds no workflow lease');
expect(fs.existsSync(path.join(leaseDir, '424244'))).toBe(false);
});
});
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -20,6 +20,6 @@ export function asideDriveOptions(text: string): string[] {
/\b(?:I|we)(?:['’]ll| will) (?:re[- ]?ask|ask again)(?: (?:you|this question))? with (?:the )?(?:"[^"]+"|“[^”]+”|[\w -]+?) option(?: included)?\b/gi,
'',
);
return /\bAside\b/i.test(option) && /\b(?:drive|driving|browse|browsing|navigate|click)\b/i.test(currentOffer);
return /\bAside\b/.test(option) && /\b(?:drive|driving|browse|browsing|navigate|click)\b/i.test(currentOffer);
});
}
+3
View File
@@ -63,6 +63,9 @@ D) Defer
test('allows recovery questions but rejects conditional drive consent', () => {
expect(asideDriveOptions('A) Open the Aside app so I can re-run the probe.')).toEqual([]);
// Captured tpa-absent-linux answer (paid run 38988927989): the English idiom is not the Aside browser.
expect(asideDriveOptions('D) Defer. I set this aside and mark it blocked on you, with no browsing now.')).toEqual([]);
expect(asideDriveOptions('D) Defer, or I drive it in Aside later.')).toHaveLength(1);
expect(asideDriveOptions('A) Open the Aside app; if READY, I drive the dashboard.')).toHaveLength(1);
});