mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* feat(releases): weekly tagged releases and a stable update channel for team mode (GSTA-98)
Every Monday morning .github/workflows/weekly-release.yml tags the newest green
commit on main as v<VERSION>, publishes a GitHub Release from the CHANGELOG
headlines and fast-forwards refs/heads/stable to it (scripts/release-cut.ts;
workflow_dispatch with dry_run, and ref for a hotfix). Team-mode installs
follow stable through the new update_channel key (auto: stable when
team_mode, else main) in bin/gstack-session-update, bin/gstack-update-check,
bin/gstack-upgrade and /gstack-upgrade. Per-merge stamping and the merge
train are unchanged. docs/releases.md explains it.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ci(weekly-release): check out the dispatched ref so a dry run exercises that branch's script
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ci(weekly-release): a PR touching the release tooling runs the dry run against main (read-only); the cut job checks out the dispatched ref
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(gstack-upgrade): no $1 in the rendered skill (Claude Code rewrites $N), trim Step 4 under the size budget, pin the new floor-check literal
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore: leave setup untouched (its channel note moves to README and docs/releases.md); the release workflow and script are free-only PR files
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: record the pr-profile free-only classification as non-behavioral (HARNESS_VERSION 6)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(releases): gate the cut on a main-backstop check run, finish half-done cuts, keep team installs on their channel
/ship review fixes (GSTA-98, PR #3429):
- The weekly release read the Main backstop gate run's conclusion, but
scripts/train-backstop.ts exits 0 when main moved past the push before it
was gated (superseded), so a never-gated tree read as green. The backstop
workflow now publishes its verdict on the commit as the `main-backstop`
check run from a code-free checks:write job (success for covered|gated,
neutral for inert|superseded|deferred, failure for frozen|failed), fed by
the new `--outputs` flag; release-cut.ts requires that check run and
free-tests to succeed and holds a commit whose check runs are still going.
- A cut that pushed the tag and stable but failed to create the release can
be finished: stable itself stays a candidate until its release exists, the
notes compare against the release tag behind it, and a dry run with nothing
green exits 0 with the skips named (the real cut still fails).
- weekly-release.yml: the cut job runs only from refs/heads/main, `ref` must
be a full 40-hex SHA, and actions:read is dropped (no workflow-runs call).
- /gstack-upgrade: the destructive fallback requires the carried UPSTREAM_REF
instead of defaulting to origin/main in a fresh shell; a team install past
the release stops with AHEAD_OF_STABLE instead of FF_REFUSED and a reset
backwards. gstack-update-channel.sh --fetch-ref keeps origin/stable on any
fetch failure other than a missing release; dead gstack_update_channel_ref
removed. gstack-update-check follows main before the first release instead
of caching CHECK_FAILED; gstack-upgrade treats a failed ls-remote as a
fetch failure, not an empty answer.
- Tests: release-cut (check-run fixture, partial cut, neutral/pending),
weekly-release-workflow (new), evals-train-workflow and train-backstop
(backstop-check job, --outputs), update-channel (no-release fallback,
offline --fetch-ref), template pins.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(releases): the upgrade skill's ahead-of-stable exit works in depth-1 clones; update-check keeps its cache identity on the pre-release fallback
/ship second-pass review of 73e278d4 (GSTA-98, PR #3429):
- gstack-upgrade/SKILL.md.tmpl: README installs are depth-1 clones, where
origin/stable never connects to HEAD, so the AHEAD_OF_STABLE shortcut now
also fires when HEAD is not behind stable and stable's VERSION is not
higher than the installed one (the order bin/gstack-upgrade uses). The
fence prints the channel helper's warnings instead of discarding them, and
a 'fetching origin/stable failed' line is a STOP. Prose trimmed to stay
under the skill size budget. New behavioral test runs the Step 4 fence on
a depth-1 install past the release (AHEAD_OF_STABLE, nothing moved) and on
one behind it (goes on to the fast-forward).
- bin/gstack-update-check: the no-stable fallback keeps #stable in the
cache identity (so the outcome replays within its TTL instead of two
ls-remote calls per skill start), triggers only when ls-remote answered
with no such branch (a failed ls-remote stays CHECK_FAILED on stable), and
the branch-pinned URL derives from REMOTE_RAW_BASE so tests stay offline.
- scripts/release-cut.ts: a cancelled check run is red; the previous-release
lookups check .ok before using git output. docs/releases.md says which
pushes get no main-backstop check run at all.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
623 lines
33 KiB
Bash
Executable File
623 lines
33 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 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/<oid>, 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 <max> 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 <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> before the live
|
|
# checkout moves. <rev> 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 <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"
|
|
}
|
|
# rescue_note <ref> <unmerged> — 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 <release> <head> — 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
|