Files
gstack/bin/gstack-session-update
T
e6edff0265 v1.91.89.0 fix(windows,setup): Git Bash hook false-positive recovery, incoming-checker auto-update, setup never deletes its own checkout (Wave 9 Lane A0) (#3140)
Git Bash hands native Bun C:/ paths while gstack-hook-check looked them up as
/c/, so every TypeScript hook was refused and team-mode auto-update held every
release since 1.91.31. The checker maps Bun's paths back to the shell spelling,
the auto-updater lets the incoming release's own checker decide (installed
checker only as a fallback, hook-check-unavailable when neither verifies), and
docs/troubleshooting.md gives stuck installs one recovery command.
/gstack-upgrade and /spec guards use the --show-prefix root check, gstack-wtree
treats drive-letter --git-path answers as absolute, and setup refuses to
replace a runtime root that is or contains its own checkout, Kiro's in-place
root included (#3084), with --status suggesting the move. New sourced helper
bin/gstack-path-spelling.sh and TS twin lib/path-spelling.ts.

Co-authored-by: yuriysan <63948747+yuriysan@users.noreply.github.com>
Co-authored-by: y$un_ <3579304+szsunyuan@users.noreply.github.com>
Co-authored-by: Frederik Reventlow <267160539+dmfrede@users.noreply.github.com>
Co-authored-by: skleinbe-ny <226563731+skleinbe-ny@users.noreply.github.com>
Co-authored-by: bkermen <184220642+bkermen@users.noreply.github.com>
Co-authored-by: Drew Jankowski <3200063+andrewkjankowski@users.noreply.github.com>
2026-10-11 16:48:11 +00:00

418 lines
22 KiB
Bash
Executable File

#!/usr/bin/env bash
# gstack-session-update — auto-update gstack on session start (team mode)
#
# Called by Claude Code SessionStart hook. Must be fast and non-fatal.
# The entire update runs in background (forked). The hook itself exits
# immediately so session startup is never delayed.
#
# Before the live checkout moves, the updater fetches, reads the incoming
# release's Bun floor (bin/gstack-bun-version.sh) and checks the `bun` setup
# will run; below it the update is held with reason bun-too-old and nothing
# changes (E1). Then it parse-checks every hook the incoming revision registers
# (bin/gstack-hook-check on a `git archive` of that revision): Claude Code runs
# hooks straight from this checkout, so an unparseable hook would block tool
# calls the moment the checkout moved. The incoming revision's own checker
# decides; the installed one decides only when that gives no verified report.
# A failure holds the update with reason hook-does-not-parse and its
# file:line; no verified report from either holds it as
# hook-check-unavailable. Updaters older than a check cannot protect their
# first pull.
#
# Stages (B11): pull, setup and migrations are tracked separately. The
# "just upgraded" marker is written only when every stage succeeded. A failed
# stage is persisted in $STATE_DIR/session-update-pending and retried even
# when HEAD did not move, with backoff (the update-check interval, then 6h,
# then 24h). While a stage is pending, each session start prints one line
# naming it and the repair command; nothing else is ever printed.
#
# Exit 0 always — errors must never block a Claude Code session.
set +e
GSTACK_DIR="${GSTACK_DIR:-$HOME/.claude/skills/gstack}"
# Hook: source the state-root twin (never spawn gstack-paths); a broken
# install exits 0 silently, like every other error here.
. "$(cd "$(dirname "$0")" && pwd)/gstack-state-root.sh" 2>/dev/null || exit 0 # best-effort: must never block a session start
STATE_DIR="$(gstack_state_root; printf x)"; STATE_DIR="${STATE_DIR%x}"
# Egress receipt helpers (_receipted_git): fail-open — an update pull must
# never block a session over a receipt hiccup.
. "$(cd "$(dirname "$0")" && pwd)/gstack-egress-lib.sh"
# Bun floor comparison (E1): the incoming release's floor is applied before
# the live checkout moves. Missing helper fails open, like every error here.
. "$(cd "$(dirname "$0")" && pwd)/gstack-bun-version.sh" 2>/dev/null
THROTTLE_FILE="$STATE_DIR/.last-session-update"
LOCK_DIR="$STATE_DIR/.setup-lock"
LOG_FILE="$STATE_DIR/analytics/session-update.log"
PENDING_FILE="$STATE_DIR/session-update-pending"
HOOK_CHECK="$(cd "$(dirname "$0")" && pwd)/gstack-hook-check"
# Path spelling (Git Bash /c/x vs native C:/x) for the held-reason prefix strip.
. "$(cd "$(dirname "$0")" && pwd)/gstack-path-spelling.sh" 2>/dev/null || { gstack_path_shell() { printf '%s\n' "$1"; }; gstack_path_native() { printf '%s\n' "$1"; }; }
THROTTLE_SECONDS=3600 # 1 hour
log_entry() {
mkdir -p "$(dirname "$LOG_FILE")"
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $1" >> "$LOG_FILE" 2>/dev/null || true # best-effort: logging never blocks a session
}
# hook_report <checker> <tree> — sets _hr_out to the checker's report on
# <tree> and returns 0 only when the report is verified and complete: exit 0
# with no fail line or exit 1 with one, every line `ok|missing <hook>` or
# `fail <hook> <where>: <why>`, and a line for each hook its own --list names. An empty list counts only from the
# installed checker (a tree without hooks). A crash, empty output or an
# unparseable report returns 1 with the reason in _hr_why. (No command
# substitution around it: the variables must reach the caller.)
hook_report() {
_hr_why="" _hr_rc=0
_hr_list=$(bash "$1" --list "$2" 2>/dev/null) || { _hr_why="--list exited $?"; return 1; }
if [ -z "$_hr_list" ] && [ "$1" != "$HOOK_CHECK" ]; then _hr_why="empty hook list"; return 1; fi
_hr_out=$(bash "$1" "$2" 2>"$_hc_dir.err") || _hr_rc=$?
case "$_hr_rc" in
0|1) ;;
*) _hr_why="exited $_hr_rc$(head -1 "$_hc_dir.err" 2>/dev/null | head -c 160 | sed 's/^/: /')"; rm -f "$_hc_dir.err"; return 1 ;;
esac
rm -f "$_hc_dir.err"
if ! printf '%s\n' "$_hr_out" | HR_LIST="$_hr_list" HR_RC="$_hr_rc" awk '
BEGIN { n = split(ENVIRON["HR_LIST"], h, "\n"); for (i = 1; i <= n; i++) if (h[i] != "") want[h[i]] = 1 }
$0 == "" { next }
/^fail [^ ]+ .+: / { if (!($2 in want)) bad = 1; failed[$2] = 1; any = 1; next }
/^(ok|missing) [^ ]+$/ { if (!($2 in want) || passed[$2]++) bad = 1; next }
{ bad = 1 }
END {
for (k in want) if ((k in passed) == (k in failed)) bad = 1
if (bad || (ENVIRON["HR_RC"] == 1) != (any == 1)) exit 1
}'; then
_hr_why="unparseable or incomplete report"
return 1
fi
}
# hook_incoming_hold <rev> — parse-check the hooks of <rev> (extracted with
# git archive) before the live checkout moves. The incoming revision's own
# bin/gstack-hook-check decides, so one checker bug in this install cannot hold
# every later release; when it gives no verified report (crash, empty output,
# unparseable), the installed checker decides. Prints the held reason and
# returns 0 when a hook does not parse or neither checker gave a verified
# report; otherwise prints nothing and returns 1. A pass from the incoming
# checker that the installed one disputes is logged.
hook_incoming_hold() {
_hc_dir=$(mktemp -d "${TMPDIR:-/tmp}/gstack-session-hooks-XXXXXX" 2>/dev/null) \
|| { echo "hook-check-unavailable: cannot create a temp directory under ${TMPDIR:-/tmp}"; return 0; }
_hc_out="" _hc_why=""
if ! git -C "$GSTACK_DIR" archive --format=tar "$1" 2>/dev/null | tar -xf - -C "$_hc_dir" 2>/dev/null; then
_hc_why="git archive of the incoming revision failed"
elif [ -f "$_hc_dir/bin/gstack-hook-check" ] && hook_report "$_hc_dir/bin/gstack-hook-check" "$_hc_dir"; then
_hc_out=$_hr_out
if ! printf '%s\n' "$_hc_out" | grep -q '^fail '; then
if hook_report "$HOOK_CHECK" "$_hc_dir"; then
_hc_inst=$(printf '%s\n' "$_hr_out" | sed -n 's/^fail [^ ]* //p' | head -1)
else
_hc_inst="no verified report ($_hr_why)"
fi
[ -z "$_hc_inst" ] || log_entry "HOOK_CHECK_DISAGREE incoming=pass installed=$(hook_hold_path "$_hc_inst" | head -c 200) rev=$1"
fi
else
[ -f "$_hc_dir/bin/gstack-hook-check" ] && log_entry "HOOK_CHECK_FALLBACK incoming checker gave no verified report ($_hr_why); the installed checker decides rev=$1"
if hook_report "$HOOK_CHECK" "$_hc_dir"; then
_hc_out=$_hr_out
else
_hc_why="the installed bin/gstack-hook-check gave no verified report either ($_hr_why)"
fi
fi
_hc_fail=$(hook_hold_path "$(printf '%s\n' "$_hc_out" | sed -n 's/^fail [^ ]* //p' | head -1)")
rm -rf "$_hc_dir" "$_hc_dir.err"
if [ -n "$_hc_why" ]; then
printf 'hook-check-unavailable: %s\n' "$_hc_why" | head -c 300
return 0
fi
[ -n "$_hc_fail" ] || return 1
printf 'hook-does-not-parse: %s\n' "$_hc_fail" | head -c 300
}
# hook_hold_path <text> — <text> without the archive directory's prefix, in
# each spelling a checker reports it (/tmp/x, /c/…/x or C:/…/x).
hook_hold_path() {
_hp=$1
for _hp_dir in "$_hc_dir" "$(gstack_path_shell "$_hc_dir")" "$(gstack_path_native "$_hc_dir")"; do
_hp=${_hp#"$_hp_dir"/}
done
printf '%s\n' "$_hp"
}
pending_get() { sed -n "s/^$1=//p" "$PENDING_FILE" 2>/dev/null | head -1; }
# pending_save — persist the P_* stage state; nothing pending removes the file.
pending_save() {
if [ -z "$P_STAGES" ] && [ -z "$P_PULL" ]; then rm -f "$PENDING_FILE"; return 0; fi
printf 'stages=%s\nfrom=%s\nfailures=%s\nnext=%s\nreason=%s\npull=%s\n' \
"$P_STAGES" "$P_FROM" "$P_FAILURES" "$P_NEXT" "$P_REASON" "$P_PULL" > "$PENDING_FILE.tmp.$$" \
&& mv -f "$PENDING_FILE.tmp.$$" "$PENDING_FILE"
}
# ── Guard: gstack must be a git repo ──
if [ ! -d "$GSTACK_DIR/.git" ]; then
exit 0
fi
# ── Guard: team mode must be enabled ──
AUTO=$("$GSTACK_DIR/bin/gstack-config" get auto_upgrade 2>/dev/null || true)
if [ "$AUTO" != "true" ]; then
exit 0
fi
# ── A stage that did not finish: one line per session start, with the fix ──
if [ -f "$PENDING_FILE" ]; then
_P_STAGES="$(pending_get stages)"; _P_REASON="$(pending_get reason)"; _P_PULL="$(pending_get pull)"
if [ -n "$_P_STAGES" ]; then
_P_FIX="cd $GSTACK_DIR && ./setup"
case "$_P_REASON" in bun*) _P_FIX="install Bun (https://bun.sh), then $_P_FIX" ;; esac
echo "gstack auto-update: ${_P_STAGES} did not finish (${_P_REASON:-unknown reason}); installed skills may be out of date. gstack retries automatically. Fix now: $_P_FIX"
elif [ -n "$_P_PULL" ]; then
case "$_P_PULL" in
bun-too-old*)
echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. Fix now: bun upgrade && cd $GSTACK_DIR && git pull --ff-only && ./setup" ;;
hook-does-not-parse*)
echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. This is a gstack bug; report ${_P_PULL#hook-does-not-parse: } at https://github.com/garrytan/gstack/issues. gstack retries at the next check. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-does-not-parse" ;;
hook-check-unavailable*)
echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. gstack retries at the next check. Fix now: cd $GSTACK_DIR && git pull --ff-only --autostash && ./setup (setup checks every hook it registers). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-check-unavailable" ;;
*)
echo "gstack auto-update: git pull did not finish ($_P_PULL); gstack is not updating. Fix now: cd $GSTACK_DIR && git pull --ff-only && ./setup" ;;
esac
fi
fi
# ── Throttle: skip if checked recently ──
if [ -f "$THROTTLE_FILE" ]; then
LAST=$(cat "$THROTTLE_FILE" 2>/dev/null || echo 0)
NOW=$(date +%s)
ELAPSED=$(( NOW - LAST ))
if [ "$ELAPSED" -lt "$THROTTLE_SECONDS" ]; then
exit 0
fi
fi
# ── Fork to background: zero latency on session start ──
(
# Prevent git from prompting for credentials (would hang the background process)
export GIT_TERMINAL_PROMPT=0
mkdir -p "$STATE_DIR"
# ── Acquire lockfile (skip if another session is running setup) ──
#
# Staleness has two independent detectors (#2613):
# 1. PID liveness — the pidfile records the HOLDER subshell's PID and a
# dead PID means reclaim. ($BASHPID, never $$: $$ expands to the PARENT
# hook's PID even inside this backgrounded subshell, and the parent
# exits immediately — so every later session judged the lock stale and
# rm -rf'd a LIVE holder's lock, letting concurrent updaters in.)
# 2. Hard TTL on the heartbeat mtime — reclaim regardless of kill -0, so a
# recycled PID or a hung holder can't wedge the lock forever. The
# holder touches the pidfile at step boundaries (after the pull, after
# setup), so a legitimately-slow run keeps itself alive. The TTL also
# bounds the missing/empty-pidfile states: inside the window they mean
# "just acquired, between mkdir and echo" and are respected.
LOCK_TTL_MINUTES=30
lock_is_expired() {
_hb="$LOCK_DIR/pid"
[ -f "$_hb" ] || _hb="$LOCK_DIR"
[ -n "$(find "$_hb" -maxdepth 0 -mmin +$LOCK_TTL_MINUTES 2>/dev/null)" ]
}
# Reclaim is TOCTOU-safe via atomic mv-aside: `rm -rf` then `mkdir` lets TWO
# contenders both judge the lock stale, both remove it, and both win the
# mkdir (one rm can land between the other's rm and mkdir). `mv` of the lock
# dir is atomic — exactly one contender's mv succeeds; the loser's mv fails
# (ENOENT) and it backs off. The winner reaps the moved-aside dir at leisure.
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
if lock_is_expired; then
mv "$LOCK_DIR" "$LOCK_DIR.reap.$$" 2>/dev/null || { log_entry "SKIP lock_contested"; exit 0; }
rm -rf "$LOCK_DIR.reap.$$" 2>/dev/null
mkdir "$LOCK_DIR" 2>/dev/null || { log_entry "SKIP lock_contested"; exit 0; }
log_entry "RECLAIMED lock_ttl_expired"
elif [ -f "$LOCK_DIR/pid" ]; then
LOCK_PID=$(cat "$LOCK_DIR/pid" 2>/dev/null || echo 0)
if [ "$LOCK_PID" -gt 0 ] 2>/dev/null && ! kill -0 "$LOCK_PID" 2>/dev/null; then
# Stale lock — mv aside atomically (see reclaim note above), re-acquire
mv "$LOCK_DIR" "$LOCK_DIR.reap.$$" 2>/dev/null || { log_entry "SKIP lock_contested"; exit 0; }
rm -rf "$LOCK_DIR.reap.$$" 2>/dev/null
mkdir "$LOCK_DIR" 2>/dev/null || { log_entry "SKIP lock_contested"; exit 0; }
else
# Live holder — or an empty/non-numeric pidfile inside the TTL
# window (the -gt test fails on garbage, landing here by design).
log_entry "SKIP locked_by=$LOCK_PID"
exit 0
fi
else
# Missing pidfile inside the TTL window: just-acquired (mkdir→echo race).
log_entry "SKIP locked_no_pid"
exit 0
fi
fi
# Write the HOLDER's PID for stale lock detection (see #2613 note above;
# macOS ships bash 3.2 with no BASHPID — the sh child's $PPID IS this
# subshell, so the fallback is exact there). MYPID is captured once at
# write time so the trap below can prove ownership before removing.
MYPID="${BASHPID:-$(sh -c 'echo $PPID')}"
echo "$MYPID" > "$LOCK_DIR/pid" 2>/dev/null
# In-flight heartbeat: the step-boundary touches below only fire AFTER the
# pull / setup return, so a legitimately-slow step (cold clone, huge setup)
# older than the TTL got reclaimed while ALIVE. This background loop
# freshens the pidfile mtime every 5 minutes for as long as we still own
# the lock (ownership re-checked each beat: if another updater reclaimed
# and wrote its own pid, the loop exits instead of touching THEIR file).
( while :; do sleep 300; [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] || exit 0; touch "$LOCK_DIR/pid" 2>/dev/null; done ) &
HB_PID=$!
# Clean up lock on exit — ownership-checked: after a TTL reclaim by another
# updater, $LOCK_DIR belongs to the NEW holder, and an unconditional rm -rf
# here would delete the live holder's lock (cascading reclaims). Remove the
# lock ONLY while $LOCK_DIR/pid still contains MYPID; always stop the
# heartbeat.
trap 'kill "$HB_PID" 2>/dev/null; [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null' EXIT
# ── Fetch, check the incoming release's Bun floor, then fast-forward (E1) ──
OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
UPDATE_URL=$(git -C "$GSTACK_DIR" remote get-url origin 2>/dev/null || echo "")
UPDATE_HOST="${UPDATE_URL#*://}"; UPDATE_HOST="${UPDATE_HOST#*@}"; UPDATE_HOST="${UPDATE_HOST%%[/:]*}"
# --autostash: locally-patched TRACKED files are the NORM on installs, not
# the exception — skill-prefix mode rewrites frontmatter names and
# `gstack-config gbrain-refresh` renders brain blocks into SKILL.md. A bare
# --ff-only refuses over those edits, so auto-upgrade wedged permanently
# (observed: 308 consecutive PULL_FAILED with the reason discarded, #2566).
# Capture stderr: the log must carry WHY a pull failed, never just the code.
PULL_ERR_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-session-pull-XXXXXX" 2>/dev/null || echo "")
GSTACK_HOME="$STATE_DIR" _receipted_git open session-update "${UPDATE_HOST:-unknown}" gstack-self-update-pull "auto_upgrade=true" \
bash -c 'git -C "$1" fetch -q 2>"${2:-/dev/null}"' _ "$GSTACK_DIR" "$PULL_ERR_FILE"
PULL_EXIT=$?
# The fast-forward targets the exact revision whose requirement was read.
# Setup runs `bun` from this PATH, so that is the binary checked; a
# malformed version passes here because setup only warns on it.
INCOMING="" HELD=""
if [ "$PULL_EXIT" -eq 0 ]; then
INCOMING=$(git -C "$GSTACK_DIR" rev-parse --verify '@{upstream}^{commit}' 2>"${PULL_ERR_FILE:-/dev/null}") || PULL_EXIT=1
fi
if [ "$PULL_EXIT" -eq 0 ] && [ "$INCOMING" != "$OLD_HEAD" ]; then
HELD=$(gstack_bun_incoming_hold "$GSTACK_DIR" "$INCOMING" 2>/dev/null) || HELD=""
fi
if [ "$PULL_EXIT" -eq 0 ] && [ -z "$HELD" ] && [ "$INCOMING" != "$OLD_HEAD" ]; then
HELD=$(hook_incoming_hold "$INCOMING") || HELD=""
fi
if [ "$PULL_EXIT" -eq 0 ] && [ -z "$HELD" ]; then
git -C "$GSTACK_DIR" merge --ff-only --autostash -q "$INCOMING" >/dev/null 2>"${PULL_ERR_FILE:-/dev/null}"
PULL_EXIT=$?
fi
NEW_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
# Heartbeat: pull done — keep the TTL clock fresh for the setup step.
touch "$LOCK_DIR/pid" 2>/dev/null
# Record check time regardless of outcome
date +%s > "$THROTTLE_FILE" 2>/dev/null
P_STAGES="$(pending_get stages)"; P_FROM="$(pending_get from)"; P_REASON="$(pending_get reason)"
P_FAILURES="$(pending_get failures)"; P_NEXT="$(pending_get next)"; P_PULL=""
case "$P_FAILURES" in ''|*[!0-9]*) P_FAILURES=0 ;; esac
case "$P_NEXT" in ''|*[!0-9]*) P_NEXT=0 ;; esac
if [ -n "$HELD" ]; then
log_entry "HELD $HELD incoming=$INCOMING"
P_PULL="$HELD"
pending_save
rm -f "$PULL_ERR_FILE" 2>/dev/null
exit 0
fi
if [ "$PULL_EXIT" -ne 0 ]; then
PULL_REASON=$(head -c 300 "$PULL_ERR_FILE" 2>/dev/null | tr '\n' ' ' | tr -s ' ')
log_entry "PULL_FAILED exit=$PULL_EXIT reason=${PULL_REASON:-unknown}"
P_PULL="exit $PULL_EXIT: $(printf '%s' "${PULL_REASON:-no error output}" | head -c 160)"
pending_save
# Autostash pop conflict leaves the stash behind and the tree half-merged.
# The local patches are REGENERABLE (prefix renames, gbrain blocks), so
# recover to a clean upstream tree and re-render them below rather than
# leaving conflict markers in a live install.
if grep -qi "autostash" "$PULL_ERR_FILE" 2>/dev/null; then
git -C "$GSTACK_DIR" checkout -q -- . 2>/dev/null
git -C "$GSTACK_DIR" stash drop -q 2>/dev/null
log_entry "AUTOSTASH_CONFLICT_RECOVERED tree_reset=1"
_PREFIX_CFG=$("$GSTACK_DIR/bin/gstack-config" get skill_prefix 2>/dev/null || echo false)
"$GSTACK_DIR/bin/gstack-patch-names" "$GSTACK_DIR" "$_PREFIX_CFG" >/dev/null 2>&1 || true # best-effort: regenerable prefix rewrite
"$GSTACK_DIR/bin/gstack-config" gbrain-refresh >/dev/null 2>&1 || true # best-effort: regenerable gbrain blocks
fi
rm -f "$PULL_ERR_FILE" 2>/dev/null
exit 0
fi
rm -f "$PULL_ERR_FILE" 2>/dev/null
# Re-render local patches over the fresh tree (both tools are idempotent
# no-ops when the feature is unconfigured); the autostash pop usually
# preserves them, but a clean re-render costs nothing and self-heals.
_PREFIX_CFG=$("$GSTACK_DIR/bin/gstack-config" get skill_prefix 2>/dev/null || echo false)
"$GSTACK_DIR/bin/gstack-patch-names" "$GSTACK_DIR" "$_PREFIX_CFG" >/dev/null 2>&1 || true # best-effort: regenerable prefix rewrite
"$GSTACK_DIR/bin/gstack-config" gbrain-refresh >/dev/null 2>&1 || true # best-effort: regenerable gbrain blocks
# ── Run setup when HEAD moved, or retry a pending stage after its backoff ──
NOW=$(date +%s)
RUN_SETUP=0
if [ "$OLD_HEAD" != "$NEW_HEAD" ]; then
log_entry "UPDATING old=$OLD_HEAD new=$NEW_HEAD"
RUN_SETUP=1
P_FAILURES=0
[ -n "$P_FROM" ] || P_FROM=$(git -C "$GSTACK_DIR" show "$OLD_HEAD:VERSION" 2>/dev/null || echo "unknown")
elif [ -n "$P_STAGES" ]; then
if [ "$NOW" -ge "$P_NEXT" ]; then
log_entry "RETRY_PENDING stages=$P_STAGES attempt=$((P_FAILURES + 1))"
RUN_SETUP=1
else
log_entry "PENDING_BACKOFF stages=$P_STAGES next=$P_NEXT"
fi
fi
if [ "$RUN_SETUP" -eq 1 ]; then
FAILED_STAGE="" FAIL_REASON=""
if command -v bun >/dev/null 2>&1; then
SETUP_OUT=$(mktemp "${TMPDIR:-/tmp}/gstack-session-setup-XXXXXX" 2>/dev/null || echo /dev/null)
( cd "$GSTACK_DIR" && ./setup -q --refresh-registered ) >"$SETUP_OUT" 2>&1
SETUP_EXIT=$?
# Heartbeat: setup done (either way) — refresh the TTL clock.
touch "$LOCK_DIR/pid" 2>/dev/null
if [ "$SETUP_EXIT" -ne 0 ]; then
_MIG="$(grep -o 'migration [^ ]* failed' "$SETUP_OUT" 2>/dev/null | head -1)"
if [ -n "$_MIG" ]; then
FAILED_STAGE="migrations" FAIL_REASON="$_MIG"
else
_LAST="$(grep -v '^[[:space:]]*$' "$SETUP_OUT" 2>/dev/null | tail -1 | head -c 160)"
FAILED_STAGE="setup" FAIL_REASON="./setup exited $SETUP_EXIT${_LAST:+: $_LAST}"
fi
fi
[ "$SETUP_OUT" = /dev/null ] || rm -f "$SETUP_OUT"
else
FAILED_STAGE="setup" FAIL_REASON="bun not found on PATH"
log_entry "SETUP_SKIPPED bun_missing"
fi
if [ -z "$FAILED_STAGE" ]; then
# Every stage succeeded: now the next skill preamble may say "just upgraded".
echo "${P_FROM:-unknown}" > "$STATE_DIR/just-upgraded-from" 2>/dev/null
rm -f "$STATE_DIR/last-update-check" 2>/dev/null
rm -f "$STATE_DIR/update-snoozed" 2>/dev/null
log_entry "UPDATED from=${P_FROM:-unknown} to=$(cat "$GSTACK_DIR/VERSION" 2>/dev/null || echo unknown)"
P_STAGES="" P_FROM="" P_FAILURES=0 P_NEXT=0 P_REASON=""
pending_save
else
P_FAILURES=$((P_FAILURES + 1))
case "$P_FAILURES" in 1) _WAIT=$THROTTLE_SECONDS ;; 2) _WAIT=21600 ;; *) _WAIT=86400 ;; esac
P_STAGES="$FAILED_STAGE" P_NEXT=$((NOW + _WAIT)) P_REASON="$FAIL_REASON"
pending_save
[ "$FAILED_STAGE" = setup ] && [ "$FAIL_REASON" != "bun not found on PATH" ] && log_entry "SETUP_FAILED reason=$FAIL_REASON"
[ "$FAILED_STAGE" = migrations ] && log_entry "MIGRATIONS_FAILED reason=$FAIL_REASON"
log_entry "PENDING stages=$FAILED_STAGE attempt=$P_FAILURES next_retry_in=${_WAIT}s"
fi
else
pending_save
[ -n "$P_STAGES" ] || log_entry "UP_TO_DATE head=$OLD_HEAD"
fi
# The detached subshell must own its stdio: it inherits the session hook's
# pipes, and once the hook exits and the caller closes them, any child that
# writes (git pull's autostash notice, setup output) dies of SIGPIPE —
# observed as PULL_FAILED exit=141 with an empty stderr capture. All
# observability goes through LOG_FILE.
) >/dev/null 2>&1 &
exit 0