#!/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 that revision, extracted through a private index # with symlinks as plain files, never tar: MSYS tar cannot create a link # before its target, #3184): 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. # # The fast-forward itself is bin/gstack-ff-update: edits that conflict with # the release are shelved in refs/gstack/rescue/, never left as conflict # markers, and the next session start names them (at most once a day). # # Channel (bin/gstack-update-channel.sh, docs/releases.md): a team install # follows refs/heads/stable, the weekly tagged release, so it moves once a # week (plus hotfixes) instead of at every merge; update_channel=main follows # every merge as before. Until the first release is cut, origin has no stable # and the updater follows main (CHANNEL_FALLBACK in the log). An install # already past the release waits for stable to catch up; nothing moves back. # # 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 # Update channel (docs/releases.md): a team install follows refs/heads/stable, # the weekly tagged release, unless update_channel says main. A missing # helper means main, the behaviour before channels existed. . "$(cd "$(dirname "$0")" && pwd)/gstack-update-channel.sh" 2>/dev/null || { gstack_update_channel() { echo main; } gstack_update_channel_refspec() { :; } gstack_update_channel_missing() { return 1; } } 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" FF_UPDATE="$(cd "$(dirname "$0")" && pwd)/gstack-ff-update" LEASE="$(cd "$(dirname "$0")" && pwd)/gstack-lease" RESCUE_FILE="$STATE_DIR/session-update-rescues" # 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 } # Twin: ff_flatten in bin/gstack-ff-update (test-pinned). One line, no # control characters or escape sequences, at most bytes. ff_flatten() { _ff_esc=$(printf '\033') printf '%s' "$1" | tr '\n\r\t' ' ' | sed "s/${_ff_esc}\[[0-9;?]*[ -\/]*[@-~]//g" | tr -d '\000-\037\177' | head -c "${2:-200}" } sq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } # hook_report — sets _hr_out to the checker's report on # 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 ` or # `fail : `, 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 — parse-check the hooks of before the live # checkout moves. is extracted without tar and without symlinks: a # private index (the real one is never touched) and checkout-index with # core.symlinks=false, so a link becomes a plain file holding its target # path, which the hook check never reads (test/session-update-hook-hold.test.ts # keeps hooked paths and their imports free of symlinks). 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 ! ( export GIT_INDEX_FILE="$_hc_dir.index" git -C "$GSTACK_DIR" read-tree "$1" && git -C "$GSTACK_DIR" -c core.symlinks=false checkout-index -a --prefix="$_hc_dir/" ) 2>"$_hc_dir.err"; then _hc_why="cannot extract the incoming revision: $(ff_flatten "$(head -c 200 "$_hc_dir.err" 2>/dev/null)" 200)" 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" "$_hc_dir.index" 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 — 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" } # rescue_note — remember a rescue for the session-start # banner (shown at most once a day until the ref is deleted). rescue_note() { grep -q "^$1 " "$RESCUE_FILE" 2>/dev/null && return 0 printf '%s %s -\n' "$1" "$2" >> "$RESCUE_FILE" 2>/dev/null } # channel_behind_release — true when the release commit is at # or behind this install's head: an ancestor of it, or, when the shallow # history cannot relate the two, a VERSION that is not higher. channel_behind_release() { git -C "$GSTACK_DIR" merge-base --is-ancestor "$1" "$2" 2>/dev/null && return 0 git -C "$GSTACK_DIR" merge-base --is-ancestor "$2" "$1" 2>/dev/null && return 1 _cb_rel=$(git -C "$GSTACK_DIR" show "$1:VERSION" 2>/dev/null | head -1) _cb_head=$(git -C "$GSTACK_DIR" show "$2:VERSION" 2>/dev/null | head -1) [ -n "$_cb_rel" ] && [ -n "$_cb_head" ] || return 1 _cb_i=1 while :; do _cb_a=$(printf '%s' "$_cb_rel" | cut -d. -f"$_cb_i"); _cb_b=$(printf '%s' "$_cb_head" | cut -d. -f"$_cb_i") [ -z "$_cb_a" ] && [ -z "$_cb_b" ] && return 0 case "${_cb_a:-0}${_cb_b:-0}" in *[!0-9]*) return 1 ;; esac [ "${_cb_a:-0}" -lt "${_cb_b:-0}" ] && return 0 [ "${_cb_a:-0}" -gt "${_cb_b:-0}" ] && return 1 _cb_i=$((_cb_i + 1)) done } 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 # ── Session-start lines: a held update first, then a stage that did not # finish, then edits a recovery shelved (daily). Each names its fix; every # path in a printed command is shell-quoted. ── _Q=$(sq "$GSTACK_DIR") _WHERE="in a terminal; Git Bash on Windows" # A printed instruction (the egress tripwire skips printf lines): nothing runs it here. _FF_FIX=$( printf "cd %s && git fetch origin && bash bin/gstack-ff-update . '@{upstream}' && ./setup" "$_Q") _P_PULL="" _P_STAGES="" if [ -f "$PENDING_FILE" ]; then _P_STAGES="$(pending_get stages)"; _P_REASON="$(pending_get reason)"; _P_PULL="$(pending_get pull)" fi # Unmerged files in the install: no stage fix, only the rescue-first recovery. _UNM=$(git -C "$GSTACK_DIR" ls-files -u 2>/dev/null | head -1 | cut -f2-) if [ -n "$_UNM" ]; then _P_STAGES="" case "$_P_PULL" in unmerged-files*) ;; *) _P_PULL="unmerged-files: the install has unmerged files (first: $_UNM)" ;; esac fi case "$_P_PULL" in "") ;; bun-too-old*) echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. Fix now ($_WHERE): bun upgrade && $_FF_FIX" ;; bun-version-unreadable*) echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. Fix now: reinstall Bun (curl -fsSL https://bun.sh/install | bash), check that bun --version prints a version, then cd $GSTACK_DIR && git pull --ff-only && ./setup. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#bun-version-unreadable" ;; 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 ($_WHERE): $_FF_FIX (setup checks every hook it registers). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-check-unavailable" ;; unmerged-files*) echo "gstack auto-update: update held ($_P_PULL); an earlier merge, rebase or stash pop left conflicts in the install, and an update would bury them. Fix now ($_WHERE): $_FF_FIX (saves every edit, the unmerged files included, in a rescue ref before it updates). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-unmerged-files" ;; autostash-unrecoverable*) echo "gstack auto-update: update held ($_P_PULL); your local edits are where they were and nothing was installed. Look first: git -C $_Q status --short. Fix now ($_WHERE): $_FF_FIX (once the cause above is gone). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-autostash-unrecoverable" ;; *) echo "gstack auto-update: git pull did not finish ($_P_PULL); gstack is not updating. Fix now ($_WHERE): $_FF_FIX" ;; esac if [ -n "$_P_STAGES" ]; then _P_FIX="cd $_Q && ./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" fi if [ -s "$RESCUE_FILE" ]; then _TODAY=$(date -u +%Y-%m-%d) : > "$RESCUE_FILE.tmp.$$" while read -r _R_REF _R_UNM _R_SHOWN; do [ -n "$_R_REF" ] && git -C "$GSTACK_DIR" rev-parse -q --verify "$_R_REF^{commit}" >/dev/null 2>&1 || continue if [ "$_R_SHOWN" != "$_TODAY" ]; then _R_FILES=$(git -C "$GSTACK_DIR" diff --name-only "$_R_REF^" "$_R_REF" 2>/dev/null) _R_N=$(printf '%s\n' "$_R_FILES" | grep -c .) _R_LIST=$(printf '%s\n' "$_R_FILES" | head -3 | paste -sd, - | sed 's/,/, /g') [ "$_R_N" -gt 3 ] && _R_LIST="$_R_LIST and $((_R_N - 3)) more" _R_NOTE="" [ "$_R_UNM" = 1 ] && _R_NOTE=" It includes files that were already unmerged: it needs manual resolution." echo "gstack auto-update: your local edits in the install ($(ff_flatten "$_R_LIST" 300)) conflicted with an update, so they were saved in $_R_REF and the install runs the update.$_R_NOTE Inspect ($_WHERE): git -C $_Q show --stat '$_R_REF'. To keep them, export only your changes and apply them in a scratch checkout, not in the install: git -C $_Q diff '$_R_REF^' '$_R_REF' > ~/gstack-local-edits.patch, then git apply -3 ~/gstack-local-edits.patch. Delete when done: git -C $_Q update-ref -d '$_R_REF'. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-rescued-edits" _R_SHOWN=$_TODAY fi printf '%s %s %s\n' "$_R_REF" "$_R_UNM" "$_R_SHOWN" >> "$RESCUE_FILE.tmp.$$" done < "$RESCUE_FILE" if [ -s "$RESCUE_FILE.tmp.$$" ]; then mv -f "$RESCUE_FILE.tmp.$$" "$RESCUE_FILE"; else rm -f "$RESCUE_FILE.tmp.$$" "$RESCUE_FILE"; 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 # The caller's own session never counts as a lease holder (#3363). Resolve its # harness pid now, the same way bin/gstack-skill-start names the pid it leases: # once this hook exits, the background updater's $$ names a dead process and # an ancestry walk from it excludes nothing. . "$(cd "$(dirname "$0")" && pwd)/gstack-harness-pid.sh" 2>/dev/null || gstack_harness_pid() { printf '%s\n' "$1"; } CALLER_PID=$(gstack_harness_pid "$PPID") # ── 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). # 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, including its sleeping child, and reap it before returning. HB_PID="" cleanup_session_update() { # The job-table fallback covers a signal between fork and HB_PID=$!. # This holder has no other background jobs at that point. for heartbeat in ${HB_PID:-$(jobs -p)}; do kill "$heartbeat" 2>/dev/null wait "$heartbeat" 2>/dev/null done [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null } trap cleanup_session_update EXIT trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM ( cleanup_heartbeat() { # Only this heartbeat's own background sleep belongs to this job table. # Listing jobs also covers interruption before $! could be recorded. for sleeper in $(jobs -p); do kill "$sleeper" 2>/dev/null wait "$sleeper" 2>/dev/null done } trap cleanup_heartbeat EXIT trap 'exit 0' HUP INT TERM while :; do sleep 300 & wait "$!" || exit 0 [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] || exit 0 touch "$LOCK_DIR/pid" 2>/dev/null done ) & HB_PID=$! # ── Workflow leases (#3363): a live gstack session on this installation # (bin/gstack-lease) means neither the fast-forward nor a pending setup # retry runs under it, the rule bin/gstack-upgrade enforces as UPGRADE_BUSY. # The throttle file is not written, so the next session start tries again. if [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$LEASE" ] \ && BUSY=$("$LEASE" live --root "$GSTACK_DIR" --exclude-ancestry-of "$CALLER_PID" 2>/dev/null); then log_entry "SKIP leased $(printf '%s\n' "$BUSY" | awk 'NF { printf "%spid=%s/%s", n++ ? " " : "", $1, $2 }')" exit 0 fi # ── 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%%[/:]*}" # 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); # bin/gstack-ff-update carries them over the release instead. # 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 "") # The channel decides which ref is fetched and followed. stable is fetched # by refspec: team installs are single-branch clones, whose plain fetch # brings main only. Before the first release is cut origin has no stable, # and the updater follows main instead of standing still. CHANNEL=$(gstack_update_channel "$GSTACK_DIR" 2>/dev/null) || CHANNEL=main PULL_EXIT=0 if [ "$CHANNEL" = stable ]; then 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 origin "$3" 2>"${2:-/dev/null}"' _ "$GSTACK_DIR" "$PULL_ERR_FILE" "$(gstack_update_channel_refspec stable)" PULL_EXIT=$? if [ "$PULL_EXIT" -ne 0 ] && gstack_update_channel_missing "$(cat "${PULL_ERR_FILE:-/dev/null}" 2>/dev/null)"; then log_entry "CHANNEL_FALLBACK stable->main reason=no-stable-on-origin" CHANNEL=main fi fi if [ "$CHANNEL" = main ]; then 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=$? fi # The fast-forward targets the exact revision whose requirement was read. # Setup runs `bun` from this PATH, so that is the binary checked; a # version it cannot read holds too (bun-version-unreadable, #3195). INCOMING="" HELD="" if [ "$PULL_EXIT" -eq 0 ]; then if [ "$CHANNEL" = stable ]; then INCOMING=$(git -C "$GSTACK_DIR" rev-parse --verify 'refs/remotes/origin/stable^{commit}' 2>"${PULL_ERR_FILE:-/dev/null}") || PULL_EXIT=1 # An install ahead of the release (it followed main until now) waits # for stable to pass it; the updater never moves a checkout backwards. # A depth-1 clone cannot see that an older release is its ancestor, so # when the histories look unrelated the VERSIONs decide: main stamps a # higher version at every merge, and stable is always a main commit. if [ "$PULL_EXIT" -eq 0 ] && [ "$INCOMING" != "$OLD_HEAD" ] && channel_behind_release "$INCOMING" "$OLD_HEAD"; then log_entry "CHANNEL stable ahead_of_release head=$OLD_HEAD stable=$INCOMING" INCOMING=$OLD_HEAD elif [ "$PULL_EXIT" -eq 0 ]; then log_entry "CHANNEL stable target=$INCOMING" fi else INCOMING=$(git -C "$GSTACK_DIR" rev-parse --verify '@{upstream}^{commit}' 2>"${PULL_ERR_FILE:-/dev/null}") || PULL_EXIT=1 fi 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" ] && [ -z "$PULL_ERR_FILE" ] && [ "$INCOMING" != "$OLD_HEAD" ]; then # Recovering a conflicting autostash needs git's own messages; without a # file to keep them in, hold rather than merge blind. HELD="temp-file-unavailable: cannot create a temp file under ${TMPDIR:-/tmp}" fi if [ "$PULL_EXIT" -eq 0 ] && [ -z "$HELD" ]; then # An index that cannot be read is never "no conflicts". if ! _UNMERGED=$(git -C "$GSTACK_DIR" ls-files -u 2>/dev/null); then echo "cannot read the index of $GSTACK_DIR; nothing was moved" > "${PULL_ERR_FILE:-/dev/null}" PULL_EXIT=1 elif [ -n "$_UNMERGED" ]; then # Someone's own merge, rebase or stash pop (or an older updater's # conflicting autostash) left conflicts here. A background update never # buries them; the banner gives the command that rescues them first. HELD="unmerged-files: $(printf '%s\n' "$_UNMERGED" | cut -f2- | sort -u | wc -l | tr -d ' ') unmerged file(s) in the install (first: $(printf '%s\n' "$_UNMERGED" | head -1 | cut -f2-))" else # gstack-ff-update moves the tree to the revision the checks passed: # conflicting edits are shelved in a rescue ref, never left as markers. FF_OUT=$(bash "$FF_UPDATE" "$GSTACK_DIR" "$INCOMING" 2>/dev/null) PULL_EXIT=$? case "$PULL_EXIT:$FF_OUT" in 0:rescued\ *) set -- $FF_OUT log_entry "AUTOSTASH_CONFLICT_RECOVERED tree_reset=1 kept_stash=${4##*/} kept_ref=$4 $5" rescue_note "$4" "${5#unmerged=}" ;; 0:*) ;; 3:*) HELD=$FF_OUT PULL_EXIT=0 ;; 75:*) log_entry "SKIP ff_busy $(ff_flatten "$FF_OUT" 200)"; rm -f "$PULL_ERR_FILE" 2>/dev/null; exit 0 ;; *) printf '%s\n' "${FF_OUT:-bin/gstack-ff-update exited $PULL_EXIT}" > "${PULL_ERR_FILE:-/dev/null}" ;; esac fi 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 HELD=$(ff_flatten "$HELD" 300) 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=$(ff_flatten "$(head -c 300 "$PULL_ERR_FILE" 2>/dev/null)" 300 | 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)" if [ -n "$NEW_HEAD" ] && [ "$NEW_HEAD" != "$OLD_HEAD" ]; then # The checkout moved but setup did not run. Owe it, or once the person # resolves the conflict the next check sees nothing new and never sets # up this revision. The unmerged-files check holds it until then. [ -n "$P_FROM" ] || P_FROM=$(git -C "$GSTACK_DIR" show "$OLD_HEAD:VERSION" 2>/dev/null || echo "unknown") P_STAGES="${P_STAGES:-setup}" P_NEXT=0 P_REASON="$P_PULL" log_entry "PENDING stages=$P_STAGES after a moved checkout" fi pending_save 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); gstack-ff-update usually # carries 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