Files
gstack/bin/gstack-capy-session
T
Garry Tanandcapy-ai[bot] 0c41ced4fd Capy session: defer the upgrade while a /ship or review run is open; upgrade-path fixes (GST-1677, GST-1658) (#4095)
* capy session: defer the upgrade while a /ship or review run is open; upgrade-path fixes (GST-1677, GST-1658)

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

* fix: pre-landing review fixes

The pin override releases .setup-lock before an older gstack-upgrade on the local default branch runs, so that copy takes the lock itself instead of refusing against the session's own pid; the no-flock eval-lock test skips visibly instead of returning.

Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: p1/specialist-batched#1 reader=testing
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: troubleshooting names an open /ship or review run as an UPGRADE_BUSY cause

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

* changelog: note the older-gstack-upgrade lock release (GST-1658)

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>
2026-11-06 21:08:42 +00:00

340 lines
23 KiB
Bash
Executable File

#!/usr/bin/env bash
# gstack-capy-session — the full Capy skill wrapper: the checkout-owned half
# of a Capy session start. The frozen bootstrapper the Drive skill holds
# (capy/skill/gstack/scripts/install.sh) clones or fast-forwards this checkout
# and execs this script with its own arguments plus:
# GSTACK_CAPY_WRAPPER_REV / GSTACK_CAPY_WRAPPER_PATH the bootstrapper that ran
# GSTACK_CAPY_REMOTE_MIN the supported minimum from origin's capy/support.env, or `unknown`
# GSTACK_CAPY_REMOTE_VERSION origin's VERSION read in this run (wrapper 2+; the
# installer's `latest: <ver> available` trailer note)
# GSTACK_CAPY_UPGRADED_FROM the version it fast-forwarded from (setup is never skipped then)
# GSTACK_CAPY_CLONED the repo it just cloned (wrapper 2+; the installer's `cloned` line)
# GSTACK_CAPY_LOCK_HELD=1 install mode: .gstack-capy-install.lock is held on an
# inherited descriptor; nothing here takes it again
# Because this file moves with the checkout, session-start behavior can change
# without anyone recopying the Drive skill; that includes the stale-copy owner
# action, read from capy/support.env through gstack_support_env_get.
#
# Always current when online: the bootstrapper moves a checkout only when it
# is below GSTACK_MIN_REVISION (the floor). Above the floor, this half keeps
# the checkout at the tip of origin's default branch: in install mode, when
# the bootstrapper's FETCH_HEAD is ahead of HEAD, it runs
# bin/gstack-upgrade --ff-only-no-reinstall (every refusal of gstack-upgrade
# applies: dirty, diverged, pinned, Bun hold, a live workflow lease) and on
# success re-execs the NEW checkout's copy of this script with the same
# arguments (GSTACK_CAPY_SELF_UPGRADED=1 stops a second attempt) before the
# installer runs. A live lease under 120 minutes old (the lease prune window)
# held by this session's own process chain also skips it (#3571):
# gstack-upgrade never counts the caller's own session, but an install run from
# inside a workflow (/ship's docs step) must not change gstack under it. The age
# bound matters on Capy, where the leased harness pid is the machine's
# long-lived capy-server and a live lease alone would never end the deferral;
# each gstack-skill-start renews it (UPGRADE_BUSY; GSTACK_UPGRADE_FORCE=1 overrides).
# An install profile (`--for review-voice`, a dispatched reviewer on a machine
# whose checkout a lane shares) never self-upgrades, lease or not, and
# GSTACK_UPGRADE_FORCE does not override it (UPGRADE_SKIPPED, GST-290). Lines: `SELF_UPGRADE: done <old> -> <new>` | `skipped (<reason>)`
# | `none (current)` | `disabled (<the variable: GSTACK_CAPY_SELF_UPGRADE=off or
# GSTACK_CAPY_AUTO_UPGRADE=0>)`.
# Daily (R75): a checkout more than 24 hours behind origin's default branch
# (the oldest commit it lacks is over a day old) cannot hide behind a pin with
# no recorded reason. A pin is GSTACK_CAPY_SELF_UPGRADE=off,
# GSTACK_CAPY_AUTO_UPGRADE=0, a detached HEAD (--revision) or another branch;
# its reason is GSTACK_PIN_REASON or `git -C <root> config gstack.pinReason`.
# In install mode an unreasoned pin on a clean, undiverged checkout is
# overridden (`SELF_UPGRADE: pin overridden (...) (PIN_UNREASONED)`, the
# default branch checked out) and the checkout fast-forwards; --check points
# its fix at the install. Either way GSTACK_CAPY_STALE=<note> hands the
# installer the trailer's `stale=` note, which also covers a failed fetch
# with no successful one in 24 hours. Workflow boundary only: a live workflow
# lease (above), a live eval run (gstack-detach's ~/.gstack/locks/gstack-evals.lock),
# or a /ship or review run open on the state root (an unclosed ship-budget
# ledger written in the last 6 hours, an unconsumed review start under 120
# minutes old: open_run) skips every self-upgrade (UPGRADE_BUSY); an open run
# prints `SELF_UPGRADE: deferred (<run> is open ...)` and --check says the same.
# Below the supported minimum none of this applies: the bootstrapper's floor
# rules run before this script.
# --check never moves the checkout; behind origin it prints
# `gstack-capy-session: behind origin/<branch>: <installed> -> <remote> (<n> commits); fix: ...`.
# Whenever the session ends behind origin (--check, or a self-upgrade that was
# skipped or disabled) it exports GSTACK_CAPY_BEHIND=<n>, GSTACK_CAPY_BEHIND_TO=<remote
# VERSION> and GSTACK_CAPY_BEHIND_FIX=<command> for the installer's trailer notes
# `behind=<n> (latest: <remote>); fix: <command>`. The command never contains
# "; " (the note separator): after a refused self-upgrade it is bin/gstack-upgrade,
# which prints its own refusal and fix. A skipped self-upgrade (refused, busy,
# or gstack-upgrade missing) also exports GSTACK_CAPY_BEHIND_BLOCKED=<reason; fix: …
# (CODE)>, and the installer adds a `WARN behind` row with that reason and code:
# the trailer stays ok (the installed revision is above the supported minimum),
# and the note names the fix. A `--for none` session certifies nothing and moves
# nothing, so it never self-upgrades (it behaves like --check).
#
# Usage: gstack-capy-session [--check] --root <checkout> [gstack-capy-install arguments]
# --check certify only (bin/gstack-capy-install --check: the doctor with the
# launch PATH and the remote minimum, the verdict, the launch line)
# default install or repair (bin/gstack-capy-install)
# The last line is the `gstack:` trailer; the exit status is the installer's.
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
CHECK=0 SKILL="" REVISION_ARG=""
prev=""
for a in "$@"; do
[ "$prev" = --root ] && ROOT="$a"
case "$prev" in --for|--only) SKILL="$a" ;; --revision) REVISION_ARG="$a" ;; esac
[ "$a" = --check ] && CHECK=1
prev="$a"
done
[ "$SKILL" = none ] && CHECK=1
# Capy crews follow every merge (docs/releases.md#channels): main for all this
# session runs (the self-upgrade, ./setup's first-install record, the
# installer), whatever the machine's config says, unless GSTACK_UPDATE_CHANNEL
# names a channel.
case "${GSTACK_UPDATE_CHANNEL:-}" in stable|main) ;; *) export GSTACK_UPDATE_CHANNEL=main ;; esac
INSTALLER="$ROOT/bin/gstack-capy-install"
if [ ! -x "$INSTALLER" ]; then
echo "gstack: fail installer — $INSTALLER is missing; fix: bash ${GSTACK_CAPY_WRAPPER_PATH:-<skill>/scripts/install.sh} (re-clones or fast-forwards)"
exit 1
fi
[ "${GSTACK_CAPY_REMOTE_MIN:-}" = unknown ] && echo "gstack-capy-session: remote minimum unknown (fetch failed); the revision row is local only"
# shellcheck source=bin/gstack-install-verdict.sh
. "$ROOT/bin/gstack-install-verdict.sh"
# shellcheck source=bin/gstack-doctor-components.sh
. "$ROOT/bin/gstack-doctor-components.sh" 2>/dev/null || gstack_doctor_install_profile() { [ "$1" = review-voice ]; }
MIN_WRAPPER=$(gstack_support_env_get "$(gstack_source_root "$ROOT/bin")" GSTACK_CAPY_MIN_WRAPPER)
if [ -n "$MIN_WRAPPER" ] && [ "${GSTACK_CAPY_WRAPPER_REV:-0}" -lt "$MIN_WRAPPER" ] 2>/dev/null; then
echo "gstack-capy-skill: owner action — Drive copy is wrapper ${GSTACK_CAPY_WRAPPER_REV:-0}, minimum $MIN_WRAPPER; copy $ROOT/capy/skill/gstack/ (CAPY_WRAPPER_STALE)"
fi
# Behind origin? Only when the bootstrapper fetched (a known remote minimum
# means FETCH_HEAD is origin's default branch tip) and that tip is not already
# in HEAD.
behind() {
[ -n "${GSTACK_CAPY_REMOTE_MIN:-}" ] && [ "$GSTACK_CAPY_REMOTE_MIN" != unknown ] || return 1
git -C "$ROOT" rev-parse -q --verify FETCH_HEAD >/dev/null 2>&1 || return 1
! git -C "$ROOT" merge-base --is-ancestor FETCH_HEAD HEAD 2>/dev/null
}
disabled() { echo "SELF_UPGRADE: disabled ($1)"; behind && report_behind "unset $1, then $RERUN"; } # VAR — the switch that turned the fast-forward off
# report_behind FIX — hand the installer the gap and the command that closes
# it, for the trailer's `behind=<n>` note (lanes relay only the trailer).
report_behind() {
export GSTACK_CAPY_BEHIND="$(git -C "$ROOT" rev-list --count HEAD..FETCH_HEAD 2>/dev/null || echo '?')" \
GSTACK_CAPY_BEHIND_TO="$(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1)" GSTACK_CAPY_BEHIND_FIX="$1"
}
unset GSTACK_CAPY_BEHIND GSTACK_CAPY_BEHIND_TO GSTACK_CAPY_BEHIND_FIX GSTACK_CAPY_BEHIND_BLOCKED
# The owner's preferences.yaml sits beside the Drive skill's SKILL.md and is
# read where it lies (never copied), so --check never writes the skill folder
# and a newer file is seen by the next command. The installer writes the path
# into the launch file; gstack-models resolve --role agent reads it. A wrapper
# inside this checkout is no Drive skill, so it names no preferences file.
if [ -n "${GSTACK_CAPY_WRAPPER_PATH:-}" ]; then
_prefs_dir="$(dirname "$(dirname "$GSTACK_CAPY_WRAPPER_PATH")")"
case "$_prefs_dir/" in
"$ROOT"/*) ;;
*) export GSTACK_PREFERENCES_FILE="$_prefs_dir/preferences.yaml" ;;
esac
fi
RERUN="bash ${GSTACK_CAPY_WRAPPER_PATH:-<skill>/scripts/install.sh}${SKILL:+ --for $SKILL}"
unset GSTACK_CAPY_STALE
NOW=$(date +%s) STALE_H="" PIN_WHAT="" UNREASONED="" SWITCH=""
mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; } # GNU, then BSD/macOS
GIT_DIR_ABS=$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null)
PIN_REASON="${GSTACK_PIN_REASON:-$(git -C "$ROOT" config --local --get gstack.pinReason 2>/dev/null)}"
[ -n "$PIN_REASON" ] || [ -z "$REVISION_ARG" ] || PIN_REASON="--revision $REVISION_ARG"
[ -n "$PIN_REASON" ] || [ "$(git -C "$ROOT" config --local --bool --get gstack.contributor 2>/dev/null)" != true ] || PIN_REASON="contributor checkout, gstack.contributor"
# The reason rides the trailer inside `(pinned: ...)`: a note separator, a
# colon (`fix:`) or a parenthesis would let it end that note and add its own.
PIN_REASON=$(printf '%s' "$PIN_REASON" | tr -c 'A-Za-z0-9 ._/@#+=,-' ' ' | tr -s ' ' | sed 's/^ //; s/ $//' | cut -c1-80)
DEFAULT_BRANCH=$(sed -n "1s/.*branch '\([^']*\)' of .*/\1/p" "$GIT_DIR_ABS/FETCH_HEAD" 2>/dev/null)
# A failed fetch empties FETCH_HEAD and resets its mtime, so a successful one leaves its own stamp.
if [ -n "${GSTACK_CAPY_REMOTE_MIN:-}" ] && [ "$GSTACK_CAPY_REMOTE_MIN" != unknown ] && [ -n "$GIT_DIR_ABS" ]; then
: > "$GIT_DIR_ABS/gstack-last-fetch" 2>/dev/null || true
fi
if [ "${GSTACK_UPDATE_CHANNEL:-}" = stable ]; then :
elif behind; then
_t=$(git -C "$ROOT" log -1 --format=%ct "$(git -C "$ROOT" rev-list --first-parent --reverse HEAD..FETCH_HEAD 2>/dev/null | head -1)" 2>/dev/null)
case "$_t" in ''|*[!0-9]*) ;; *) [ $((NOW - _t)) -gt 86400 ] && STALE_H=$(((NOW - _t) / 3600)) ;; esac
elif [ "${GSTACK_CAPY_REMOTE_MIN:-}" = unknown ] && [ "$SKILL" != none ]; then
_ft=""
if [ -f "$GIT_DIR_ABS/gstack-last-fetch" ]; then _ft=$(mtime "$GIT_DIR_ABS/gstack-last-fetch")
elif [ -s "$GIT_DIR_ABS/FETCH_HEAD" ]; then _ft=$(mtime "$GIT_DIR_ABS/FETCH_HEAD")
fi
if [ -z "$_ft" ]; then export GSTACK_CAPY_STALE="no fetch from origin recorded"
elif [ $((NOW - _ft)) -gt 86400 ]; then export GSTACK_CAPY_STALE="no fetch from origin in $(((NOW - _ft) / 3600))h"
fi
fi
if [ -n "$STALE_H" ]; then
[ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ] && PIN_WHAT="GSTACK_CAPY_SELF_UPGRADE=off"
[ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ] && PIN_WHAT="${PIN_WHAT:+$PIN_WHAT, }GSTACK_CAPY_AUTO_UPGRADE=0"
_branch=$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null)
if [ -z "$_branch" ]; then PIN_WHAT="${PIN_WHAT:+$PIN_WHAT, }HEAD detached at $(git -C "$ROOT" rev-parse --short HEAD)"
elif [ -n "$DEFAULT_BRANCH" ] && [ "$_branch" != "$DEFAULT_BRANCH" ]; then PIN_WHAT="${PIN_WHAT:+$PIN_WHAT, }on branch $_branch"
fi
# A reason explains a pin; with nothing pinned there is nothing to explain.
[ -n "$PIN_WHAT" ] || PIN_REASON=""
if gstack_doctor_install_profile "$SKILL"; then export GSTACK_CAPY_STALE="${STALE_H}h (shared checkout: the lane's install moves it)"
elif [ -n "$PIN_REASON" ]; then export GSTACK_CAPY_STALE="${STALE_H}h (pinned: $PIN_REASON)"
else
export GSTACK_CAPY_STALE="${STALE_H}h"
if [ -n "$PIN_WHAT" ] && [ -n "$DEFAULT_BRANCH" ] \
&& [ -z "$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ] \
&& git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null; then
# Off the default branch, switch only to a local default that is absent or
# fast-forwardable, so the switch never lands on diverged work.
if [ "$_branch" = "$DEFAULT_BRANCH" ]; then UNREASONED=1
elif ! git -C "$ROOT" show-ref --verify -q "refs/heads/$DEFAULT_BRANCH" \
|| git -C "$ROOT" merge-base --is-ancestor "refs/heads/$DEFAULT_BRANCH" FETCH_HEAD 2>/dev/null; then UNREASONED=1 SWITCH=1
fi
fi
fi
fi
# foreign_busy — what bin/gstack-upgrade refuses on: a live lease outside this
# process chain (or a shared harness's under 120 minutes). Checked before the
# override switches branches, so nothing changes under another session.
foreign_busy() {
[ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$ROOT/bin/gstack-lease" ] \
&& "$ROOT/bin/gstack-lease" live --root "$ROOT" --exclude-ancestry-of $$ --count-shared-harness >/dev/null 2>&1
}
# eval_live — a live eval run holds gstack-detach's machine-wide eval lock
# (fcntl flock). Probed with flock(1), else python3 or perl (macOS has no
# flock); with no probe at all a lock file counts as held.
eval_live() {
local lock="$HOME/.gstack/locks/gstack-evals.lock"
[ -f "$lock" ] || return 1
if command -v flock >/dev/null 2>&1; then ! flock -n "$lock" true 2>/dev/null
elif command -v python3 >/dev/null 2>&1; then
! python3 -c 'import fcntl, sys; fcntl.flock(open(sys.argv[1], "a"), fcntl.LOCK_EX | fcntl.LOCK_NB)' "$lock" 2>/dev/null
elif command -v perl >/dev/null 2>&1; then
! perl -e 'use Fcntl ":flock"; open(my $f, ">>", $ARGV[0]) or exit 1; flock($f, LOCK_EX | LOCK_NB) or exit 1' "$lock" 2>/dev/null
fi
}
# open_run — a /ship or review run open on this state root, printed as
# "<kind> run <id> (<file>)": a ship-budget ledger with no closed_at that was
# written in the last 6 hours (every /ship program ends with a note that
# rewrites it, and Step 22's close stamps closed_at), or a review start capture
# under 120 minutes old that gstack-review-log --finish has not consumed. The
# age bounds keep an abandoned run from holding the upgrade back for good.
open_run() {
local sr f
sr=$("$ROOT/bin/gstack-paths" --get GSTACK_STATE_ROOT 2>/dev/null) && [ -n "$sr" ] || return 1
while IFS= read -r f; do
grep -q '"kind": *"gstack-ship-budget"' "$f" 2>/dev/null && ! grep -q '"closed_at"' "$f" 2>/dev/null || continue
echo "/ship run $(basename "$(dirname "$f")") ($f)"
return 0
done < <(find "$sr/ship-runs" -mindepth 2 -maxdepth 2 -name budget.json -mmin -360 2>/dev/null)
f=$(find "$sr/projects" -mindepth 3 -maxdepth 3 -path '*/.review-starts/*.json' -mmin -120 2>/dev/null | head -1)
[ -n "$f" ] || return 1
echo "review run $(basename "$f" .json) ($f)"
}
RUN_OPEN=""
behind && [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && RUN_OPEN=$(open_run)
DEFERRED="${RUN_OPEN:+$RUN_OPEN is open on this machine, so gstack stays at $(head -1 "$ROOT/VERSION" 2>/dev/null) until it ends (R75: upgrades wait for the workflow boundary); fix: $RERUN once the run ends (UPGRADE_BUSY)}"
if [ "$CHECK" -eq 1 ]; then
if behind; then
# The rerun fast-forwards only what gstack-upgrade would move; otherwise
# gstack-upgrade itself names the refusal and its fix.
if gstack_doctor_install_profile "$SKILL"; then report_behind "$ROOT/bin/gstack-upgrade"
elif [ -n "$UNREASONED" ]; then report_behind "$RERUN"
elif [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then report_behind "unset GSTACK_CAPY_SELF_UPGRADE, then $RERUN"
elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then report_behind "unset GSTACK_CAPY_AUTO_UPGRADE, then $RERUN"
elif [ "$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null)" != "$(sed -n "1s/.*branch '\([^']*\)' of .*/\1/p" "$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null)/FETCH_HEAD" 2>/dev/null)" ] \
|| [ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ] \
|| ! git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null; then report_behind "$ROOT/bin/gstack-upgrade"
else report_behind "$RERUN"
fi
BRANCH=$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null || echo HEAD)
echo "gstack-capy-session: behind origin/$BRANCH: $(head -1 "$ROOT/VERSION" 2>/dev/null) -> $GSTACK_CAPY_BEHIND_TO ($GSTACK_CAPY_BEHIND commits); fix: $GSTACK_CAPY_BEHIND_FIX$([ "$GSTACK_CAPY_BEHIND_FIX" = "$RERUN" ] && echo ' (fast-forwards)')"
[ -n "$STALE_H" ] && [ -z "$PIN_REASON" ] && ! gstack_doctor_install_profile "$SKILL" && echo "gstack-capy-session: stale: the oldest commit this checkout lacks is ${STALE_H}h old (over 24h); run the fix before the first workflow"
[ -n "$DEFERRED" ] && echo "gstack-capy-session: upgrade deferred ($DEFERRED)"
fi
elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then
if [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ] && [ -z "$UNREASONED" ]; then disabled GSTACK_CAPY_SELF_UPGRADE
elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ] && [ -z "$UNREASONED" ]; then disabled GSTACK_CAPY_AUTO_UPGRADE
elif [ -z "${GSTACK_CAPY_REMOTE_MIN:-}" ] || [ "$GSTACK_CAPY_REMOTE_MIN" = unknown ]; then echo "SELF_UPGRADE: none (remote minimum unknown)"
elif ! behind; then echo "SELF_UPGRADE: none (current)"
elif gstack_doctor_install_profile "$SKILL"; then
REASON="$SKILL never self-upgrades a shared checkout; fix: GSTACK_UPGRADE_FORCE=1 $ROOT/bin/gstack-upgrade when no other session uses this checkout (UPGRADE_SKIPPED)"
echo "SELF_UPGRADE: skipped ($REASON)"
report_behind "$ROOT/bin/gstack-upgrade"
export GSTACK_CAPY_BEHIND_BLOCKED="$REASON"
elif [ ! -x "$ROOT/bin/gstack-upgrade" ]; then echo "SELF_UPGRADE: skipped ($ROOT/bin/gstack-upgrade is missing)"; report_behind "$RERUN"
export GSTACK_CAPY_BEHIND_BLOCKED="$ROOT/bin/gstack-upgrade is missing; fix: $RERUN (UPGRADE_REFUSED)"
elif [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$ROOT/bin/gstack-lease" ] \
&& OWN=$("$ROOT/bin/gstack-lease" live --root "$ROOT" 2>/dev/null | awk '{ a = $2; sub(/m$/, "", a); if (a + 0 < 120) print }') \
&& [ -n "$OWN" ] \
&& ! "$ROOT/bin/gstack-lease" live --root "$ROOT" --exclude-ancestry-of $$ >/dev/null 2>&1; then
HOLDERS=""
while read -r _pid _age; do HOLDERS="$HOLDERS${HOLDERS:+, }pid $_pid ($_age)"; done <<EOF_OWN
$OWN
EOF_OWN
REASON="a gstack run in this session holds a workflow lease on this installation: $HOLDERS, so the upgrade waits until the lease is 120 minutes old; fix: $ROOT/bin/gstack-upgrade once the lease is 120 minutes old, or GSTACK_UPGRADE_FORCE=1 $ROOT/bin/gstack-upgrade when no other session uses this checkout (UPGRADE_BUSY)"
echo "SELF_UPGRADE: skipped ($REASON)"
report_behind "$ROOT/bin/gstack-upgrade"
export GSTACK_CAPY_BEHIND_BLOCKED="$REASON"
elif [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && eval_live; then
REASON="a live eval run holds $HOME/.gstack/locks/gstack-evals.lock, so the upgrade waits for it to finish; fix: $RERUN once the eval run ends (UPGRADE_BUSY)"
echo "SELF_UPGRADE: skipped ($REASON)"
report_behind "$RERUN"
export GSTACK_CAPY_BEHIND_BLOCKED="$REASON"
elif [ -n "$DEFERRED" ]; then
echo "SELF_UPGRADE: deferred ($DEFERRED)"
report_behind "$RERUN"
export GSTACK_CAPY_BEHIND_BLOCKED="$DEFERRED"
elif [ -n "$SWITCH" ] && foreign_busy; then
REASON="live gstack sessions hold this installation, so the unreasoned pin ($PIN_WHAT) is not switched under them; fix: $RERUN once they finish (UPGRADE_BUSY)"
echo "SELF_UPGRADE: skipped ($REASON)"
report_behind "$RERUN"
export GSTACK_CAPY_BEHIND_BLOCKED="$REASON"
elif [ -n "$SWITCH" ] && ! { SETUP_LOCK="$("$ROOT/bin/gstack-paths" --get GSTACK_STATE_ROOT 2>/dev/null)/.setup-lock"; [ "$SETUP_LOCK" != /.setup-lock ] \
&& mkdir -p "$(dirname "$SETUP_LOCK")" 2>/dev/null && mkdir "$SETUP_LOCK" 2>/dev/null && echo "$$" > "$SETUP_LOCK/pid"; }; then
# The switch and the fast-forward happen under gstack-upgrade's setup lock,
# taken here and handed over in GSTACK_SETUP_LOCK_OWNER, so a concurrent
# updater never sees the checkout between the two.
REASON="another gstack updater holds $SETUP_LOCK (pid $(cat "$SETUP_LOCK/pid" 2>/dev/null || echo unknown)), so the unreasoned pin ($PIN_WHAT) is not switched; fix: $RERUN once it finishes (UPGRADE_BUSY)"
echo "SELF_UPGRADE: skipped ($REASON)"
report_behind "$RERUN"
export GSTACK_CAPY_BEHIND_BLOCKED="$REASON"
else
PRE_HEAD="" PRE_BRANCH="" NEW_BRANCH="" RESTORE_FAILED=""
if [ -n "$SWITCH" ]; then
trap '[ "$(cat "$SETUP_LOCK/pid" 2>/dev/null)" = "$$" ] && rm -rf "$SETUP_LOCK"' EXIT
PRE_HEAD=$(git -C "$ROOT" rev-parse HEAD) PRE_BRANCH=$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null)
if git -C "$ROOT" show-ref --verify -q "refs/heads/$DEFAULT_BRANCH"; then git -C "$ROOT" checkout -q "$DEFAULT_BRANCH" 2>/dev/null || PRE_HEAD=""
elif git -C "$ROOT" checkout -q -b "$DEFAULT_BRANCH" 2>/dev/null; then
NEW_BRANCH=1
git -C "$ROOT" config "branch.$DEFAULT_BRANCH.remote" origin
git -C "$ROOT" config "branch.$DEFAULT_BRANCH.merge" "refs/heads/$DEFAULT_BRANCH"
else PRE_HEAD=""
fi
fi
OLD=$(head -1 "$ROOT/VERSION" 2>/dev/null)
LOCK_OWNER="${SWITCH:+$$}"
if [ -n "$LOCK_OWNER" ] && ! grep -q GSTACK_SETUP_LOCK_OWNER "$ROOT/bin/gstack-upgrade" 2>/dev/null; then rm -rf "$SETUP_LOCK"; trap - EXIT; LOCK_OWNER=""; fi
UP_OUT=$(GSTACK_SETUP_LOCK_OWNER="$LOCK_OWNER" "$ROOT/bin/gstack-upgrade" --ff-only-no-reinstall 2>&1); UP_RC=$?
[ -n "$UP_OUT" ] && printf '%s\n' "$UP_OUT"
if [ "$UP_RC" -ne 0 ] && [ -n "$PRE_HEAD" ]; then
if [ -n "$PRE_BRANCH" ]; then git -C "$ROOT" checkout -q "$PRE_BRANCH" 2>/dev/null; else git -C "$ROOT" checkout -q --detach "$PRE_HEAD" 2>/dev/null; fi \
&& [ "$(git -C "$ROOT" rev-parse HEAD 2>/dev/null)" = "$PRE_HEAD" ] \
&& [ "$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null)" = "$PRE_BRANCH" ] || RESTORE_FAILED=1
if [ -z "$RESTORE_FAILED" ]; then
[ -n "$NEW_BRANCH" ] && git -C "$ROOT" branch -q -D "$DEFAULT_BRANCH"
echo "SELF_UPGRADE: pin kept (the fast-forward was refused, so HEAD is back at ${PRE_BRANCH:-$(git -C "$ROOT" rev-parse --short HEAD)})"
else
RESTORE_FAILED="the fast-forward was refused and the pin could not be restored: HEAD is at $(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null) on $(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null || echo 'a detached HEAD'), not ${PRE_BRANCH:-$PRE_HEAD}; fix: git -C $ROOT checkout -q ${PRE_BRANCH:---detach $PRE_HEAD}, then $RERUN (UPGRADE_REFUSED)"
echo "SELF_UPGRADE: pin not restored ($RESTORE_FAILED)"
fi
fi
if [ -n "$LOCK_OWNER" ]; then rm -rf "$SETUP_LOCK"; trap - EXIT; fi
if [ "$UP_RC" -eq 0 ]; then
[ -n "$UNREASONED" ] && echo "SELF_UPGRADE: pin overridden ($PIN_WHAT, with no recorded reason, and the oldest commit this checkout lacks is ${STALE_H}h old; keep a pin with GSTACK_PIN_REASON=<why> or git -C $ROOT config gstack.pinReason <why>) (PIN_UNREASONED)"
echo "SELF_UPGRADE: done $OLD -> $(head -1 "$ROOT/VERSION" 2>/dev/null)"
export GSTACK_CAPY_UPGRADED_FROM="$OLD" GSTACK_CAPY_SELF_UPGRADED=1
exec "$ROOT/bin/gstack-capy-session" "$@"
fi
REASON=$(printf '%s\n' "$UP_OUT" | sed -n 's/^gstack-upgrade: \(.*\); nothing was changed; fix: /\1; fix: /p' | tail -1)
[ -n "$REASON" ] || REASON=$(printf '%s\n' "$UP_OUT" | sed -n 's/^gstack-upgrade: //p' | tail -1)
[ -z "$RESTORE_FAILED" ] || REASON="$RESTORE_FAILED"
echo "SELF_UPGRADE: skipped (${REASON:-gstack-upgrade exit $UP_RC})"
report_behind "$ROOT/bin/gstack-upgrade"
export GSTACK_CAPY_BEHIND_BLOCKED="${REASON:-gstack-upgrade exit $UP_RC; fix: $ROOT/bin/gstack-upgrade (UPGRADE_REFUSED)}"
fi
fi
exec "$INSTALLER" "$@"