diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d2a0f31c..2218c79a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 for= (behind= (latest: ); fix: )`. The fix is the command that actually closes the gap: a rerun, `gstack-upgrade` for a dirty, detached or diverged checkout, or `unset ` 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

(m )`. +- **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. diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index 0b71d6577..a7a2205bc 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -521,11 +521,18 @@ EOF_SHIM # 4. Certify. The trailer is the contract, in one grammar: # gstack: ok for=[ (requested: …; pins: skipped (planning-only|no project); latest: available)] +# gstack: ok for= ([requested: …; ][pins: …; ]behind= (latest: ); fix: ) # 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 `) is unchanged. +# the doctor skipped the project pins, then either `behind= (latest: )` +# and `fix: ` 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 `) +# is unchanged. An install-mode session whose self-upgrade was skipped +# (the session exports GSTACK_CAPY_BEHIND_BLOCKED) is never ok: its ok trailer becomes +# gstack: fail behind — installed is commits behind origin (latest ) +# and the session-start upgrade was skipped: ; fix: () INSTALLED=$(head -1 "$ROOT/VERSION") TRAILER="$(printf '%s\n' "$OUT" | grep '^gstack: ' | tail -1)" NOTES="" @@ -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 /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: ' diff --git a/bin/gstack-capy-session b/bin/gstack-capy-session index ac04de7ac..017af4685 100755 --- a/bin/gstack-capy-session +++ b/bin/gstack-capy-session @@ -28,6 +28,15 @@ # GSTACK_CAPY_AUTO_UPGRADE=0>)`. # --check never moves the checkout; behind origin it prints # `gstack-capy-session: behind origin/: -> ( commits); fix: ...`. +# Whenever the session ends behind origin (--check, or a self-upgrade that was +# skipped or disabled) it exports GSTACK_CAPY_BEHIND=, GSTACK_CAPY_BEHIND_TO= and GSTACK_CAPY_BEHIND_FIX= for the installer's trailer notes +# `behind= (latest: ); fix: `. The command never contains +# "; " (the note separator): after a refused self-upgrade it is bin/gstack-upgrade, +# which prints its own refusal and fix. A skipped self-upgrade (refused, busy, +# or gstack-upgrade missing) also exports GSTACK_CAPY_BEHIND_BLOCKED=, and the installer turns an ok trailer into `gstack: fail behind`: a +# session that meant to upgrade and could not is never certified ok. # # Usage: gstack-capy-session [--check] --root [gstack-capy-install arguments] # --check certify only (bin/gstack-capy-install --check: the doctor with the @@ -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=` 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:-/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:-/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" "$@" diff --git a/bin/gstack-lease b/bin/gstack-lease index 431b3fba8..ded79313e 100755 --- a/bin/gstack-lease +++ b/bin/gstack-lease @@ -9,21 +9,29 @@ # # Usage: # gstack-lease touch [--root ] [--pid

| --from ] -# write /gstack-leases/; pid defaults to the harness pid -# behind this call (bin/gstack-harness-pid.sh, the same derivation the -# session marker uses), or behind --from (a caller whose own parent is -# short-lived passes its parent pid); leases older than 120 min are pruned +# write /gstack-leases/ holding the pid's start identity; +# pid defaults to the harness pid behind this call +# (bin/gstack-harness-pid.sh, the same derivation the session marker +# uses), or behind --from (a caller whose own parent is short-lived +# passes its parent pid); leases older than 120 min are pruned. Exits 3 +# and writes nothing when a live process holds the directory lock # gstack-lease live [--root ] [--exclude-ancestry-of ] -# print ` m` per lease whose pid is alive and is neither the -# given pid nor one of its ancestors (the caller's own session); exit 0 -# when any live lease exists, 1 when none +# print ` m ` per lease held by a live process that +# is neither the given pid nor one of its ancestors (the caller's own +# session); exit 0 when any live lease exists, 1 when none. A stale lease +# is reclaimed (deleted, one `gstack-lease: reclaimed …` line on stderr) # gstack-lease list [--root ] -# print ` m alive|dead` for every lease +# print ` m alive|dead|stale` for every lease +# A pid alone is not a holder: pids are reused after a reboot or a machine +# wake. A lease records its process's start identity (Linux: boot id and +# /proc start time; elsewhere `ps -o lstart`), and a lease whose pid now +# belongs to another process is stale. A lease without an identity (written by +# an older gstack) is stale once it is older than the 120-minute prune window. # defaults to the source checkout behind this bin (an installed # runtime root's .source-path, else ../). A root that is not a git checkout # exits 2 and writes nothing. GSTACK_LEASE_DIR overrides the lease directory # (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 (` `): 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 diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 6d2a3f352..da9a7b26f 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -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") diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 746664a77..457748c21 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -10,7 +10,7 @@ description: Install and use gstack in Capy and Claude Code. Load at session sta Resolve `scripts/install.sh` relative to this SKILL.md, not the working repository. It is a frozen bootstrapper: it prints `gstack-capy-skill: wrapper ()` first, learns the supported minimum from gstack's remote, and hands over to the checkout's own wrapper (`bin/gstack-capy-session`). 1. At session start run `bash scripts/install.sh --check`, adding `--for ` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP|WARN ` row per component (`WARN` is an owner action), a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok for=[ ()]` (exit 0) or `gstack: fail …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout. -2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or the output carries a `gstack-capy-session: behind origin/: …` 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 -> `; above it by the checkout, `SELF_UPGRADE: done -> `), reinstalled and rechecked in one locked step. A pinned (`--revision`, detached), dirty or diverged checkout, or one another live session holds a lease on (`UPGRADE_BUSY`), is never moved: below the floor the trailer names the cause and the exact commands; above it the run prints `SELF_UPGRADE: skipped ()` and continues on the installed revision. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one. +2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or an ok trailer carries `behind= (latest: ); fix: ` (the checkout is commits behind origin; the output also has a `gstack-capy-session: behind origin/: …` line), run the same command without `--check` (900-second timeout), or the `fix:` command when it names another. Report the trailer verbatim, `behind=` included: a lane that is behind says so. For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Any clean, unpinned checkout behind origin is fast-forwarded in install mode (below the `GSTACK_MIN_REVISION` floor by the bootstrapper, `gstack-capy-skill: upgraded -> `; above it by the checkout, `SELF_UPGRADE: done -> `), reinstalled and rechecked in one locked step. A pinned (`--revision`, detached), dirty or diverged checkout, or one another live session holds a lease on (`UPGRADE_BUSY`), is never moved: the trailer names the cause and the exact commands (above the floor it is a `fail behind` trailer after `SELF_UPGRADE: skipped ()`, with `UPGRADE_BUSY` or `UPGRADE_REFUSED`). A lease whose pid was reused after a wake is reclaimed on its own. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one. 3. If the trailer is `gstack: fail not-installed …` and this session runs no gstack workflow, record that line and continue; nothing is installed. Install right before the first workflow with `bash scripts/install.sh --for ` (about 30 s cold: clone, a SHA-256-verified Bun when none installed fits, both host profiles without Chromium, and a `~/.local/bin/bun` shim so fresh shells get the recorded Bun). 4. Any other `FAIL` row carries a `fix:` command and a reason code with a troubleshooting anchor; run the fix, then `--check` again. `SKIP codex-cli (optional; …)`, `SKIP browser (lazy; …)` and `SKIP pins (planning-only; …)` are not failures. diff --git a/capy/support.env b/capy/support.env index 01fd89653..29b7f8cdf 100644 --- a/capy/support.env +++ b/capy/support.env @@ -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). diff --git a/docs/capy-install.md b/docs/capy-install.md index 524d183fb..1e6f915e5 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -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 -> ` and re-execs the new checkout's wrapper, which installs from the new revision (`GSTACK_CAPY_UPGRADED_FROM` set, so setup is never skipped). - A refusal prints `SELF_UPGRADE: skipped ()` and the session - continues on the installed revision; the trailer stays the doctor's. + A refusal prints `SELF_UPGRADE: skipped ()`, and the session is + never certified ok: the trailer is `gstack: fail behind — installed is + commits behind origin (latest ) and the session-start upgrade was + skipped: ; fix: (UPGRADE_BUSY|UPGRADE_REFUSED)`, exit 1. `SELF_UPGRADE: none (current)` when nothing is ahead; `GSTACK_CAPY_SELF_UPGRADE=off` (or `GSTACK_CAPY_AUTO_UPGRADE=0`) prints `SELF_UPGRADE: disabled ()`. `--check` never moves the checkout; behind origin it prints `gstack-capy-session: behind origin/: -> ( commits); fix: bash /scripts/install.sh - [--for ] (fast-forwards)` and the agent runs that fix. + [--for ] (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= (latest: ); fix: ` (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 `/gstack-leases/` (`bin/gstack-lease touch`; pruned after -120 minutes). `bin/gstack-upgrade`, and so the session-start self-upgrade, -refuses with [UPGRADE_BUSY](troubleshooting.md#upgrade-busy) while a lease -whose pid is alive and outside its own process chain exists; -`bin/gstack-lease list` shows them and `GSTACK_UPGRADE_FORCE=1` overrides. +120 minutes). Each lease records its process's start identity (Linux: boot id +and `/proc` start time; elsewhere `ps -o lstart`), because a pid is reused after +a reboot or a machine wake. `bin/gstack-upgrade`, and so the session-start +self-upgrade, refuses with [UPGRADE_BUSY](troubleshooting.md#upgrade-busy) +while a lease held by a live process outside its own chain exists, naming each +holder as `pid

(m )`. A lease whose pid now belongs to another +process, or an identity-less lease from an older gstack past 120 minutes, is +stale: `bin/gstack-lease live` reclaims it (`gstack-lease: reclaimed stale +lease …`). 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 /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 /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: -available` (origin's default branch has a newer VERSION; the bootstrapper +`--revision`), `pins: skipped (planning-only|no project)`, then either +`behind= (latest: )` followed by `fix: ` (the session ended + 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: + 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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 6e2496b54..7f023d04c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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

(m )`), then re-run `bin/gstack-upgrade`. When you know they are idle, `GSTACK_UPGRADE_FORCE=1 bin/gstack-upgrade` proceeds. At a Capy session start the same refusal appears as -`SELF_UPGRADE: skipped (live gstack sessions hold this installation: ...)` -and the session continues on the installed revision. +`SELF_UPGRADE: skipped (live gstack sessions hold this installation: ...)`, +and the trailer is `gstack: fail behind — … (UPGRADE_BUSY)`: a session that +could not upgrade is never certified ok. ### `gstack-upgrade: could not reach origin (...) (UPGRADE_FETCH_FAILED)` diff --git a/test/capy-skill-wrapper.test.ts b/test/capy-skill-wrapper.test.ts index b7f8b6c10..711973ad0 100644 --- a/test/capy-skill-wrapper.test.ts +++ b/test/capy-skill-wrapper.test.ts @@ -14,7 +14,7 @@ * depth-1 file:// clone at A, on the simulated Capy host. */ import { afterEach, describe, expect, test } from 'bun:test'; -import { spawnSync } from 'child_process'; +import { spawn, spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { RESULT_CODES } from '../lib/result-codes'; @@ -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', () => { diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index 8f9c74c29..05ff1c71f 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -269,6 +269,27 @@ describe('gstack-capy-install', () => { expect(lastLine(failed.stdout)).toBe('gstack: fail codex'); }); + test('trailer grammar: behind= 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'); diff --git a/test/gstack-lease.test.ts b/test/gstack-lease.test.ts index 0518136a9..f2b6585d2 100644 --- a/test/gstack-lease.test.ts +++ b/test/gstack-lease.test.ts @@ -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 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); + }); }); diff --git a/test/gstack-upgrade-bin.test.ts b/test/gstack-upgrade-bin.test.ts index 98f4e3420..a9676603e 100644 --- a/test/gstack-upgrade-bin.test.ts +++ b/test/gstack-upgrade-bin.test.ts @@ -188,7 +188,7 @@ describe('gstack-upgrade', () => { expect(r.status, r.stdout + r.stderr).toBe(3); expect(r.stderr).toContain('(UPGRADE_BUSY)'); expect(r.stderr).toContain('nothing was changed'); - expect(r.stderr).toMatch(new RegExp(`pid ${live} \\(\\d+m\\)`)); + expect(r.stderr).toMatch(new RegExp(`pid ${live} \\(\\d+m sleep\\)`)); expect(r.stderr).not.toContain(`pid ${process.pid} `); expect(r.stderr).toContain('wait for those sessions to finish, or re-run with GSTACK_UPGRADE_FORCE=1 when you know they are idle'); expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a); diff --git a/test/helpers/third-party-actions.ts b/test/helpers/third-party-actions.ts index e2c7774b9..725b60610 100644 --- a/test/helpers/third-party-actions.ts +++ b/test/helpers/third-party-actions.ts @@ -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); }); } diff --git a/test/third-party-actions.test.ts b/test/third-party-actions.test.ts index 49a589ad3..23055df59 100644 --- a/test/third-party-actions.test.ts +++ b/test/third-party-actions.test.ts @@ -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); });