capy: threads run gstack's latest main at least daily; review fixes (GST-1642, R75)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-06 16:13:08 +00:00
1 parent 26583061e4
commit 90588d7b7d
18 files changed
+405 -28

No files matched your search

+2 -2
View File
@@ -30,7 +30,7 @@ Open Claude Code and paste this. Claude does the rest.
> Install gstack: run **`git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup`** then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing and never use mcp\_\_claude-in-chrome\_\_\* tools. Claude Code lists the installed skills itself, so the section needs no skill list. Then ask the user if they also want to add gstack to the current project so teammates get it.
**Which updates you get:** a new install follows the `stable` channel, the tagged release cut twice a week (Monday and Thursday, at most once every 72 hours, plus verified security and regression fixes), so you get releases that passed the full release gate instead of every merge to main. `./setup` records this the first time it runs on a machine and prints it. A gstack contributor's checkout (marked by `bin/dev-setup`, on a branch other than `main`, or carrying local commits) follows `main` instead, and an install set up before stable became the default keeps following `main`. To switch, run `~/.claude/skills/gstack/bin/gstack-config set update_channel main` (every merge) or `~/.claude/skills/gstack/bin/gstack-config set update_channel stable` (releases only). Details: [docs/releases.md](docs/releases.md#channels).
**Which updates you get:** a new install follows the `stable` channel, the tagged release cut twice a week (Monday and Thursday, at most once every 72 hours, plus verified security and regression fixes), instead of every merge to main. `./setup` records this the first time it runs on a machine and prints it. The clone itself starts at main's latest commit, and the updater never moves a checkout backwards, so it moves onto releases at the next cut that passes it (within a few days). A gstack contributor's checkout (marked by `bin/dev-setup`, on a branch other than origin's default `main`, or carrying local commits) follows `main` instead, and an install set up before stable became the default keeps following `main`. To switch, run `~/.claude/skills/gstack/bin/gstack-config set update_channel main` (every merge) or `~/.claude/skills/gstack/bin/gstack-config set update_channel stable` (releases only). Details: [docs/releases.md](docs/releases.md#channels).
### Step 2: Team mode — auto-update for shared repos (recommended)
@@ -40,7 +40,7 @@ From inside your repo, paste this. Switches you to team mode, bootstraps the rep
(cd ~/.claude/skills/gstack && ./setup --team) && ~/.claude/skills/gstack/bin/gstack-team-init required && git add .claude/ CLAUDE.md && git commit -m "require gstack for AI-assisted work"
```
No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel, even on an install that otherwise follows `main`: the twice-weekly tagged release, not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [docs/releases.md](docs/releases.md#channels).
No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel while `update_channel` is `auto` (so also on an install from before stable became the default): the twice-weekly tagged release, not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [docs/releases.md](docs/releases.md#channels).
Swap `required` for `optional` if you'd rather nudge teammates than block them.
+10
View File
@@ -625,6 +625,11 @@ EOF_SHIM
# keeps its ok trailer, whose notes then read
# behind=<n> (latest: <ver>); upgrade skipped: <reason> (<CODE>); fix: <command>
# (below the minimum the bootstrapper fails revision before anything runs).
# A checkout more than 24 hours behind (bin/gstack-capy-session exports
# GSTACK_CAPY_STALE) adds `stale=<h>h` after `behind=`, or `stale=<h>h (pinned: <reason>)`
# for a pin with a recorded reason; a failed fetch with no successful one in 24
# hours adds `stale=no fetch from origin in <h>h` on its own. Unlike a bare
# `behind=`, a `stale=` with no pin means: run the fix before the first workflow.
# A project floor the doctor could not meet (`WARN floor`, PROJECT_FLOOR_UNMET)
# adds `floor=<ver> (<file>)` and a `fix:` note the same way. A passing run
# whose launch file cannot be written becomes
@@ -653,6 +658,7 @@ if [ -n "$BLOCKED" ]; then
fi
if [ -n "${GSTACK_CAPY_BEHIND:-}" ]; then
note "behind=$GSTACK_CAPY_BEHIND (latest: ${GSTACK_CAPY_BEHIND_TO:-${REMOTE_VERSION:-unknown}})"
[ -n "${GSTACK_CAPY_STALE:-}" ] && note "stale=$GSTACK_CAPY_STALE"
[ -n "$BLOCKED" ] && note "upgrade skipped: $BLOCKED_REASON ($BLOCKED_CODE)"
# The trailer's fix is always one runnable command (the session's: the rerun
# or bin/gstack-upgrade, which prints its own refusal); the refusal's own
@@ -662,6 +668,10 @@ elif [ -n "$REMOTE_VERSION" ] && [ "$REMOTE_VERSION" != "$INSTALLED" ] \
&& [ "$(printf '%s\n%s\n' "$INSTALLED" "$REMOTE_VERSION" | sort -V | head -1)" = "$INSTALLED" ]; then
note "latest: $REMOTE_VERSION available"
fi
if [ -n "${GSTACK_CAPY_STALE:-}" ] && [ -z "${GSTACK_CAPY_BEHIND:-}" ]; then
note "stale=$GSTACK_CAPY_STALE"
FIX_NOTE="check network access to origin, then ${GSTACK_CAPY_WRAPPER_PATH:+bash $GSTACK_CAPY_WRAPPER_PATH${FOR:+ --for $FOR}}"
fi
# The project floor (the doctor's WARN floor row): the floor and its file ride
# the trailer, and the fix is the install when it fast-forwards, else the row's.
FLOOR_ROW=$(printf '%s\n' "$OUT" | grep '^WARN floor — ' | head -1)
+103 -4
View File
@@ -35,6 +35,19 @@
# 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) or a live eval run (gstack-detach's ~/.gstack/locks/gstack-evals.lock)
# skips every self-upgrade (UPGRADE_BUSY).
# --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
@@ -56,11 +69,11 @@
# 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=""
CHECK=0 SKILL="" REVISION_ARG=""
prev=""
for a in "$@"; do
[ "$prev" = --root ] && ROOT="$a"
case "$prev" in --for|--only) SKILL="$a" ;; esac
case "$prev" in --for|--only) SKILL="$a" ;; --revision) REVISION_ARG="$a" ;; esac
[ "$a" = --check ] && CHECK=1
prev="$a"
done
@@ -114,11 +127,72 @@ if [ -n "${GSTACK_CAPY_WRAPPER_PATH:-}" ]; then
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=""
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)"
PIN_REASON=$(printf '%s' "$PIN_REASON" | tr ';\r\n' ' ' | tr -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=$(date -r "$GIT_DIR_ABS/gstack-last-fetch" +%s 2>/dev/null)
elif [ -s "$GIT_DIR_ABS/FETCH_HEAD" ]; then _ft=$(date -r "$GIT_DIR_ABS/FETCH_HEAD" +%s 2>/dev/null)
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
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.
eval_live() {
local lock="$HOME/.gstack/locks/gstack-evals.lock"
[ -f "$lock" ] && command -v flock >/dev/null 2>&1 && ! flock -n "$lock" true 2>/dev/null
}
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)" ] \
@@ -128,10 +202,11 @@ if [ "$CHECK" -eq 1 ]; then
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" ] && 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"
fi
elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then
if [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then disabled GSTACK_CAPY_SELF_UPGRADE
elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then disabled GSTACK_CAPY_AUTO_UPGRADE
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
@@ -153,11 +228,35 @@ EOF_OWN
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 "$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"
else
PRE_HEAD="" PRE_BRANCH="" NEW_BRANCH=""
if [ -n "$SWITCH" ]; then
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
else PRE_HEAD=""
fi
fi
OLD=$(head -1 "$ROOT/VERSION" 2>/dev/null)
UP_OUT=$("$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"; else git -C "$ROOT" checkout -q --detach "$PRE_HEAD"; fi
[ -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)})"
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" "$@"
+12 -4
View File
@@ -67,15 +67,20 @@ gstack_update_channel_missing() {
printf '%s' "${1:-}" | grep -qiE "couldn't find remote ref|no such ref was fetched|invalid refspec" 2>/dev/null
}
# A state root that holds a setup marker or a config is an existing install:
# it keeps auto, so nothing changes under it. A contributor checkout keeps
# A state root (or the legacy ~/.gstack) that holds a setup marker or a config
# is an existing install: it keeps auto, so nothing changes under it. A Capy
# machine (CAPY_MACHINE_CONFIG or ~/.capy/system, as bin/gstack-capy-install
# detects it) records main, even for a ./setup run outside a Capy session. A contributor checkout keeps
# auto too: gstack.contributor (bin/dev-setup) or GSTACK_CONTRIBUTOR=1, CI, a
# checkout that is not its own git repository, a detached HEAD, a branch other
# than origin's default, no upstream, or commits the upstream does not have.
gstack_update_channel_initial() {
_uc_dir=$1 _uc_state=$2
if [ -e "$_uc_state/.last-setup-version" ] || [ -e "$_uc_state/config.yaml" ] || [ -e "$HOME/.gstack/.last-setup-version" ]; then return 0; fi
for _uc_m in "$_uc_state/.last-setup-version" "$_uc_state/config.yaml" "$HOME/.gstack/.last-setup-version" "$HOME/.gstack/config.yaml"; do
[ -e "$_uc_m" ] && return 0
done
case "${GSTACK_UPDATE_CHANNEL:-}" in stable|main) printf '%s set by GSTACK_UPDATE_CHANNEL at install\n' "$GSTACK_UPDATE_CHANNEL"; return 0 ;; esac
if [ -n "${CAPY_MACHINE_CONFIG:-}" ] || [ -d "$HOME/.capy/system" ]; then printf 'main Capy machine: crews follow every merge\n'; return 0; fi
if [ "${GSTACK_CONTRIBUTOR:-}" = 1 ] || [ "$(git -C "$_uc_dir" config --bool --get gstack.contributor 2>/dev/null)" = true ]; then
printf 'auto contributor checkout: gstack.contributor is set\n'; return 0
fi
@@ -92,7 +97,10 @@ gstack_update_channel_initial() {
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
_uc_fetch=0 _uc_arg=""
[ "${1:-}" = --initial ] && { gstack_update_channel_initial "${3:-$(cd "$(dirname "$0")/.." && pwd)}" "${2:?--initial needs <state root>}"; exit 0; }
if [ "${1:-}" = --initial ]; then
[ -n "${2:-}" ] || { echo "usage: gstack-update-channel.sh --initial <state root> [<install dir>]" >&2; exit 2; }
gstack_update_channel_initial "${3:-$(cd "$(dirname "$0")/.." && pwd)}" "$2"; exit 0
fi
for _uc_a in "$@"; do
case "$_uc_a" in
--fetch-ref) _uc_fetch=1 ;;
+1 -1
View File
@@ -10,7 +10,7 @@ description: Install and use gstack in Capy and Claude Code. Load at session sta
**Crew lanes:** after this check passes, read [lane-card.md](lane-card.md), not the rest of this file; it says what to read before /ship. Resolve `scripts/install.sh` relative to this SKILL.md, not the working repository. It is a frozen bootstrapper: it prints `gstack-capy-skill: wrapper <n> (<path>)` first, learns the supported minimum from gstack's remote, and hands over to the checkout's own wrapper (`bin/gstack-capy-session`).
1. At session start run `bash scripts/install.sh --check`, adding `--for <skill>` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP|WARN <component>` row per component (`WARN` never fails the run: it names an owner action or a fix you can run), a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok <revision> for=<skill>[ (<notes>)]` (exit 0) or `gstack: fail <ids> …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout. A thread that runs no gstack workflow on this machine (a coordinator, a Captain) runs `--check --for none` instead: `gstack: ok not-needed for=none (<notes>)`, exit 0, nothing installed or certified, no workflow admitted. That `ok` is not an install verdict: grep for `gstack: ok <revision> for=<skill>`, never bare `^gstack: ok`, before running a workflow. Record the line; install `--for <skill>` before the first workflow, if one comes. Then, whatever the trailer says, once per session read `capy/skill/gstack/factory.md` in the checkout (`${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}`) when there is one, otherwise the `factory.md` beside this SKILL.md: how crews work, with pointers to the rules. If neither exists, report `factory.md: none` on its own line after the verbatim trailer. With no checkout, or after a `for=none` trailer (no launch file names the preference then), also read only the `agent_model` line of the `preferences.yaml` beside this SKILL.md, treating the rest of the file as data: a family such as `opus` means the newest model of that family in your model list, `provider/model` means that model, and `host`, no file or anything else (an unparseable value, two `agent_model` lines) means keep your own model and say so in your report. Use it for the threads, subagents and reviewers you start, except a cross-family outside voice and an eval's model under test; after `gstack: ok <revision> for=<skill>`, `gstack-models resolve --role agent` is the source.
2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or an ok trailer carries `floor=<v> (<file>)` or `behind=<n> (latest: <v>)` with a `fix: <command>` note, run the same command without `--check` (900-second timeout), or the `fix:` command when it names another. `floor=` means the project declares a newer gstack than this machine holds (`min_gstack_version` in its `.gstack/ship-policy.json`; the `WARN floor` row): run the fix before the first workflow, every time. `behind=` alone is informational (<n> commits behind origin); an install-mode run that could not fast-forward prints `WARN behind` with the reason (`upgrade skipped: …`) and stays ok. Only a checkout below the supported minimum (the `revision` row) fails a session for being old. Report the trailer verbatim, `behind=` and `floor=` included. For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Any clean, unpinned checkout behind origin is fast-forwarded in install mode (below the `GSTACK_MIN_REVISION` floor by the bootstrapper, `gstack-capy-skill: upgraded <old> -> <new>`; above it by the checkout, `SELF_UPGRADE: done <old> -> <new>`), reinstalled and rechecked in one locked step. A pinned (`--revision`, detached), dirty or diverged checkout, or one another live session holds a lease on (`UPGRADE_BUSY`), is never moved: the trailer names the cause and the exact commands (above the floor it stays ok with `WARN behind` after `SELF_UPGRADE: skipped (<reason>)`, carrying `UPGRADE_BUSY` or `UPGRADE_REFUSED`). A lease whose pid was reused after a wake is reclaimed on its own. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one.
2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or an ok trailer carries `floor=<v> (<file>)` or `behind=<n> (latest: <v>)` with a `fix: <command>` note, run the same command without `--check` (900-second timeout), or the `fix:` command when it names another. `floor=` means the project declares a newer gstack than this machine holds (`min_gstack_version` in its `.gstack/ship-policy.json`; the `WARN floor` row): run the fix before the first workflow, every time. `behind=` alone is informational (<n> commits behind origin), but `stale=<h>h` after it means the checkout has lacked an origin commit for over 24 hours: run the fix before the first workflow, every time (`stale=<h>h (pinned: <why>)` is a pin with a recorded reason and stays; `stale=no fetch from origin in <h>h` needs network first). A thread that lives past a day reruns the check before a workflow once its last check is over 24 hours old. An install-mode run that could not fast-forward prints `WARN behind` with the reason (`upgrade skipped: …`) and stays ok. Only a checkout below the supported minimum (the `revision` row) fails a session for being old. Report the trailer verbatim, `behind=` and `floor=` included. For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Any clean, unpinned checkout behind origin is fast-forwarded in install mode (below the `GSTACK_MIN_REVISION` floor by the bootstrapper, `gstack-capy-skill: upgraded <old> -> <new>`; above it by the checkout, `SELF_UPGRADE: done <old> -> <new>`), reinstalled and rechecked in one locked step. A dirty or diverged checkout, or one a live session's lease or a live eval run holds (`UPGRADE_BUSY`), is never moved; a pin (`--revision`, detached, another branch, `GSTACK_CAPY_SELF_UPGRADE=off`, `GSTACK_CAPY_AUTO_UPGRADE=0`) holds only while under 24 hours behind or with a recorded reason (`GSTACK_PIN_REASON=<why>` or `git -C <checkout> config gstack.pinReason <why>`), else the install overrides it (`PIN_UNREASONED`). A refused checkout: the trailer names the cause and the exact commands (above the floor it stays ok with `WARN behind` after `SELF_UPGRADE: skipped (<reason>)`, carrying `UPGRADE_BUSY` or `UPGRADE_REFUSED`). A lease whose pid was reused after a wake is reclaimed on its own. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one, each subject to the 24-hour rule above. A `--for review-voice` reader only reports `stale=`; the lane's install moves the shared checkout.
3. If the trailer is `gstack: fail not-installed …` and this session runs no gstack workflow, record that line and continue; nothing is installed. Install right before the first workflow with `bash scripts/install.sh --for <skill>` (about 30 s cold: clone, a SHA-256-verified Bun when none installed fits, both host profiles without Chromium, and a `~/.local/bin/bun` shim so fresh shells get the recorded Bun).
4. Any other `FAIL` row carries a `fix:` command and a reason code with a troubleshooting anchor; run the fix, then `--check` again. `SKIP codex-cli (optional; …)`, `SKIP browser (lazy; …)`, `SKIP pins (planning-only; …)`, `SKIP pins (helper runtime; …)` and `SKIP floor (…)` are not failures. `WARN pins … (node pin only; bun pins pass)` is not one either: the Node on PATH misses the project's pin while Bun is right; run its fix only when this machine will run the project's tests.
+1 -1
View File
@@ -4,7 +4,7 @@ For a crew lane (a thread working for a Captain). Read this once `gstack: ok <re
## After the check
- Report the trailer verbatim in your first report, `floor=` and `behind=` notes included. A `floor=` note or a `fix:` command runs before the first workflow (§ "Session start: check, then install only when needed", step 2).
- Report the trailer verbatim in your first report, `floor=` and `behind=` notes included. A `floor=` or `stale=` note or a `fix:` command runs before the first workflow, and a thread older than a day reruns the check before its next workflow (§ "Session start: check, then install only when needed", step 2).
- Read `capy/skill/gstack/factory.md` in the checkout once per session.
- Start every shell that runs a gstack command with `. <GSTACK_LAUNCH_FILE>`. Never re-type the assignments as `export $(...)`, and never run `env`, `printenv`, a bare `set` or a bare `export`: they print API keys into the transcript. Hand a child agent the file path, never the values.
- Every subagent runs its own `--check` and puts its trailer in its first report; a read-only reviewer uses `--for review-voice`. One machine's install never covers another.
+2 -1
View File
@@ -36,7 +36,8 @@
# bash install.sh --check --for none this machine runs no workflow: ok not-needed, nothing installed
# bash install.sh [--for <skill> | --require <id,id>] [--with-browser] [--revision <ref>] [--project <path>]
# Environment: GSTACK_CAPY_ROOT (default ~/.local/share/gstack), GSTACK_CAPY_REPO,
# GSTACK_CAPY_AUTO_UPGRADE=0 (never fast-forward), GSTACK_CAPY_FETCH_TIMEOUT (s),
# GSTACK_CAPY_AUTO_UPGRADE=0 (no fast-forward; above the floor the checkout's session
# overrides it past 24 hours behind unless GSTACK_PIN_REASON is set), GSTACK_CAPY_FETCH_TIMEOUT (s),
# GSTACK_UPDATE_CHANNEL=stable (follow the tagged release; default main, every merge).
set -u
GSTACK_CAPY_WRAPPER=5
+2 -1
View File
@@ -1 +1,2 @@
- **New installs follow the stable channel; Capy crews stay on main.** A fresh `git clone … && ./setup` now records `update_channel: stable` the first time it runs on a machine and prints `Update channel: stable (new install)` with the command that switches, so a new user gets the twice-weekly tested release instead of every merge to main. A gstack contributor's checkout (`bin/dev-setup`'s marker, another branch, local commits, CI) still follows main, and every install set up before this change keeps following main: nothing switches under anyone. Capy crew machines stay on main: `bin/gstack-capy-session` pins `GSTACK_UPDATE_CHANNEL=main` unless the variable names a channel, and the Capy skill bootstrapper (wrapper 5) no longer follows a machine's own channel. The README install section says which channel you get and how to switch. (GST-1642)
- **New installs follow the stable channel; Capy crews stay on main.** A fresh `git clone … && ./setup` now records `update_channel: stable` the first time it runs on a machine and prints `Update channel: stable (new install)` with the command that switches, so a new user gets the twice-weekly tested release instead of every merge to main (the clone starts at main's tip and joins releases at the next cut that passes it). A gstack contributor's checkout (`bin/dev-setup`'s marker, another branch, local commits, CI) still follows main, and every install set up before this change keeps following main: nothing switches under anyone. Capy crew machines stay on main: `bin/gstack-capy-session` pins `GSTACK_UPDATE_CHANNEL=main` unless the variable names a channel, a fresh Capy machine records main, and the Capy skill bootstrapper (wrapper 5) no longer follows a machine's own channel. The README install section says which channel you get and how to switch. (GST-1642)
- **Capy threads run gstack's latest main at least daily.** When the oldest origin commit a Capy checkout lacks is more than 24 hours old, the session check's trailer says `stale=<h>h` and the skill runs the install before the next workflow. A pin (`GSTACK_CAPY_SELF_UPGRADE=off`, `GSTACK_CAPY_AUTO_UPGRADE=0`, a detached HEAD or another branch) holds past that only with a recorded reason (`GSTACK_PIN_REASON`, `git config gstack.pinReason`, an explicit `--revision`); an unreasoned one is overridden at the workflow boundary and reported (`PIN_UNREASONED`). The upgrade never runs under a live workflow lease or eval run, never switches a checkout another session holds or onto diverged work, and a failed fetch with none successful in 24 hours is reported. (R75, GST-1642)
+22 -2
View File
@@ -136,9 +136,28 @@ there is refused with the usual `--for <skill>` fix.
disabled), the ok trailer also says so, because lanes relay only the
trailer: `behind=<n> (latest: <ver>); fix: <command>` (see the trailer
grammar below).
**Daily (R75).** When the oldest origin commit the checkout lacks is more
than 24 hours old, the trailer adds `stale=<h>h` after `behind=`, and
unlike a bare `behind=` the agent runs the fix before the first workflow.
A pin (`GSTACK_CAPY_SELF_UPGRADE=off`, `GSTACK_CAPY_AUTO_UPGRADE=0`, a
detached HEAD or another branch) then holds only with a recorded reason:
`GSTACK_PIN_REASON=<why>`, `git -C <root> config gstack.pinReason <why>`,
an explicit `--revision`, or `gstack.contributor=true`
(`stale=<h>h (pinned: <why>)`). Without one, an install-mode run on a clean,
undiverged checkout overrides it (`SELF_UPGRADE: pin overridden (...)
(PIN_UNREASONED)`), switching to the default branch only when that local
branch is absent or fast-forwardable and no other session holds a lease,
and switching back if `bin/gstack-upgrade` refuses. A workflow lease of the
current run or a live eval run (gstack-detach's
`~/.gstack/locks/gstack-evals.lock`) defers every self-upgrade
(`UPGRADE_BUSY`). A `--for review-voice` reader gets
`stale=<h>h (shared checkout: the lane's install moves it)` and only reports
it. With `GSTACK_UPDATE_CHANNEL=stable` nothing is measured against main.
4. When the fetch fails (offline), the bootstrapper says `remote minimum
unknown (fetch failed)` and checks the local install only; it never claims
the minimum, and nothing is fast-forwarded.
the minimum, and nothing is fast-forwarded. With no successful fetch in 24
hours (the session's `<git dir>/gstack-last-fetch` stamp) the trailer adds
`stale=no fetch from origin in <h>h` with a network fix.
5. The project's floor. A consuming repository names the oldest gstack it wants
its crew on as `min_gstack_version` in its `.gstack/ship-policy.json` (the
key `gstack-ship-queue` already validates; a file holding only that key is
@@ -419,7 +438,8 @@ with every bootstrapper revision), optionally followed by `upgrade skipped:
<reason> (<CODE>)` when an install-mode fast-forward was refused, or `latest:
<ver> available` (origin's default branch has a newer VERSION; the bootstrapper
exports it as `GSTACK_CAPY_REMOTE_VERSION` only after a fetch that succeeded
in this run); then `floor=<f> (<file>)` when the project's floor is unmet; and
in this run); `stale=<note>` right after `behind=` (or alone, for no fetch in 24
hours); then `floor=<f> (<file>)` when the project's floor is unmet; and
last exactly one `fix: <command>` whenever `behind=` or `floor=` is present.
A fail trailer never carries notes. `gstack: ok not-needed for=none (...)` is
the `--for none` outcome: no revision, nothing certified, no workflow admitted. Match `^gstack: ok ` or
+5 -2
View File
@@ -246,10 +246,13 @@ Which channel an install gets:
- **A new install follows `stable`.** The first `./setup` on a machine (no
`.last-setup-version` or `config.yaml` in the state root, and no legacy
`~/.gstack/.last-setup-version`) records `update_channel: stable` and prints
`~/.gstack/.last-setup-version` or `~/.gstack/config.yaml`) records `update_channel: stable` and prints
`Update channel: stable (new install)` with the command that switches. When
`GSTACK_UPDATE_CHANNEL` is set during that first setup, its value is
recorded instead.
recorded instead, and a Capy machine (`CAPY_MACHINE_CONFIG` or
`~/.capy/system`) records `main`. The clone starts at main's latest
commit; since nothing moves a checkout backwards, a new stable install runs
that build until the next cut passes it, then follows releases.
- **A contributor checkout follows `main`.** That first setup records nothing,
so the key stays `auto`, when the checkout is a contributor's:
`git config gstack.contributor true` (what `bin/dev-setup` writes) or
+19
View File
@@ -1747,6 +1747,25 @@ after the refusal is resolved, or the Capy skill installer again once the
lease holder finishes. Below the supported minimum the bootstrapper fails
`revision` instead (`REVISION_BELOW_MINIMUM`).
<a id="pin-unreasoned"></a>
### `SELF_UPGRADE: pin overridden (<pin>, with no recorded reason, and the oldest commit this checkout lacks is <h>h old; ...) (PIN_UNREASONED)` / trailer note `stale=<h>h`
**Meaning.** Capy threads run gstack's latest main at least daily (R75). A Capy
session start in install mode (`--for <skill>`, a workflow boundary) found the
checkout more than 24 hours behind origin's default branch, by the commit time
of the oldest commit it lacks, and pinned by `GSTACK_CAPY_SELF_UPGRADE=off`,
`GSTACK_CAPY_AUTO_UPGRADE=0`, a detached HEAD (`--revision`) or another branch,
with no recorded reason. It checked out the default branch where needed and
fast-forwarded. A dirty or diverged checkout is still never moved, and a live
workflow lease or eval run still defers the upgrade (`UPGRADE_BUSY`). On
`--check` the trailer carries `stale=<h>h` with the install as its fix; unlike a
bare `behind=`, run that fix before the first workflow.
**Fix.** Nothing, when the pin was not deliberate. To keep a pin, record why:
`GSTACK_PIN_REASON=<why>` in the session's environment, or
`git -C <checkout> config gstack.pinReason "<why>"`; the trailer then reads
`stale=<h>h (pinned: <why>)` and the checkout stays where it is.
<a id="upgrade-refused"></a>
### `gstack-upgrade: <cause>; nothing was changed; fix: <command> (UPGRADE_REFUSED)`
+5
View File
@@ -79,6 +79,11 @@ export const CAPY_RESULT_CODES = {
summary: "the Node on PATH does not satisfy the project's Node pin while every Bun pin is met; the pins row is a WARN and the trailer stays ok, because gstack runs on Bun and the project's own tests may select their Node (nvm, CI)",
fix: "put a Node that satisfies the pin first on PATH (the Capy skill installer installs an exact pin into gstack's runtime store and launches it through the GSTACK_LAUNCH PATH), or change the pin in the project",
},
PIN_UNREASONED: {
anchor: 'pin-unreasoned',
summary: 'a Capy session start found the checkout more than 24 hours behind origin\'s default branch and pinned (GSTACK_CAPY_SELF_UPGRADE=off, GSTACK_CAPY_AUTO_UPGRADE=0, a detached HEAD or another branch) with no recorded reason, so it overrode the pin and fast-forwarded (R75)',
fix: 'nothing when the pin was not deliberate; to keep a pin, record why: GSTACK_PIN_REASON=<why> for the session, or git -C <checkout> config gstack.pinReason "<why>"',
},
UPGRADE_SKIPPED: {
anchor: 'upgrade-skipped',
summary: 'the install-mode session was behind origin and its session-start fast-forward was skipped (dirty, diverged or pinned checkout, a live lease, or gstack-upgrade missing); the row is a WARN with the reason and the trailer stays ok with a behind= note, because a gap above the supported minimum is never a hard fail',
+1 -1
View File
@@ -603,7 +603,7 @@ export const RESULT_CODES = {
},
UPGRADE_BUSY: {
anchor: 'upgrade-busy',
summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), or, at a Capy install, the caller\'s own run does, or a shared harness (capy-server) whose lease may be a sibling session\'s is under 120 minutes old, so moving the checkout would change gstack under it',
summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), or, at a Capy install, the caller\'s own run does, or a shared harness (capy-server) whose lease may be a sibling session\'s is under 120 minutes old, or a live eval run holds gstack-detach\'s ~/.gstack/locks/gstack-evals.lock, so moving the checkout would change gstack under it',
fix: 'wait for those sessions to finish, or for a shared-harness lease to turn 120 minutes old (bin/gstack-lease list names each pid and age), then re-run bin/gstack-upgrade; GSTACK_UPGRADE_FORCE=1 overrides when you know no other session uses the checkout',
},
UPGRADE_FETCH_FAILED: {
+1
View File
@@ -1439,6 +1439,7 @@
"test/unattended-grant.test.ts": 9,
"test/uninstall-windows-copies.test.ts": 1131,
"test/uninstall.test.ts": 4140,
"test/update-channel-setup.test.ts": 24000,
"test/update-channel.test.ts": 5290,
"test/update-check-crash-sentinel.test.ts": 409,
"test/update-check-main-moved.test.ts": 2176,
+5 -1
View File
@@ -1385,7 +1385,11 @@ export GSTACK_SETUP_RUNNING=1 # Prevent gstack-config post-set hook from trigge
# install keep auto. Read before anything below writes config.yaml.
_UC_INITIAL="$(bash "$SOURCE_GSTACK_DIR/bin/gstack-update-channel.sh" --initial "$GSTACK_STATE_ROOT" "$SOURCE_GSTACK_DIR" 2>/dev/null || true)"
case "$_UC_INITIAL" in
stable\ *|main\ *) "$GSTACK_CONFIG" set update_channel "${_UC_INITIAL%% *}" >/dev/null 2>&1 || _UC_INITIAL="" ;;
stable\ *|main\ *)
"$GSTACK_CONFIG" set update_channel "${_UC_INITIAL%% *}" >/dev/null || {
echo "Update channel: could not record ${_UC_INITIAL%% *} (gstack-config failed); fix: $INSTALL_GSTACK_DIR/bin/gstack-config set update_channel ${_UC_INITIAL%% *}" >&2
_UC_INITIAL=""
} ;;
esac
if [ "$SKILL_PREFIX_FLAG" -eq 0 ]; then
_saved_prefix="$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || true)"
+195 -6
View File
@@ -65,13 +65,14 @@ const UPGRADE_DEPS = [
* next: the remote moves on to C for a clone at A too. stable: origin's stable
* branch (the release, docs/releases.md) at that commit. configStable: B and later
* carry a bin/gstack-config stub whose update_channel is stable (a machine whose
* own config, or team mode, would follow the release).
* own config, or team mode, would follow the release). nextAgeHours: C's commit
* date that many hours ago (a checkout at B is that stale).
*/
function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B'; next?: boolean; stable?: 'A' | 'B' | 'C'; configStable?: boolean } = {}) {
function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B'; next?: boolean; stable?: 'A' | 'B' | 'C'; configStable?: boolean; nextAgeHours?: number } = {}) {
const host = capyHost({ systemBun: '1.4.2' });
hosts.push(host);
const git = (args: string[], cwd: string) => {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: host.env, timeout: 30_000 });
const git = (args: string[], cwd: string, extra: Record<string, string> = {}) => {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: { ...host.env, ...extra }, timeout: 30_000 });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
@@ -105,7 +106,8 @@ function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B'; next?: boolea
if (opts.cloneAt === 'B' || opts.next) {
if (opts.cloneAt === 'B') git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
write(path.join(seed, 'VERSION'), '1.0.2\n');
git(['commit', '-qam', 'C'], seed);
const at = opts.nextAgeHours ? `@${Math.floor(Date.now() / 1000) - opts.nextAgeHours * 3600 - 60} +0000` : '';
git(['commit', '-qam', 'C'], seed, at ? { GIT_AUTHOR_DATE: at, GIT_COMMITTER_DATE: at } : {});
c = git(['rev-parse', 'HEAD'], seed);
git(['push', '-q', bare, 'main'], seed);
}
@@ -326,7 +328,7 @@ describe('capy skill: always current when online (the minimum is the floor, not
// bootstrapper read in this run; a FETCH_HEAD left by an earlier run never
// reaches the installer.
describe('capy skill bootstrapper: the update channel (wrapper 4, docs/releases.md#channels)', () => {
// Value: protects=an install that follows stable (GSTACK_UPDATE_CHANNEL=stable, or team mode through bin/gstack-update-channel.sh) is cloned at
// Value: protects=an install that follows stable (GSTACK_UPDATE_CHANNEL=stable; from wrapper 5 the machine's own channel no longer counts) is cloned at
// origin's stable branch, not main, and stays there through the checkout half's self-upgrade; GSTACK_UPDATE_CHANNEL=main and a remote with no stable
// branch (before the first release) clone main; fails_when=a fresh team install lands on unreleased main, the override cannot pick main, or a missing
// stable branch fails the install; why_new=the clone test covers the default branch only; seam=GSTACK_UPDATE_CHANNEL instead of gstack-config's
@@ -769,3 +771,190 @@ describe('capy skill: factory.md, the auto-loaded "how we work" page', () => {
});
});
});
// R75 (Garry, 2026-11-06): every Capy thread runs gstack's latest main at least daily.
describe('capy skill: a checkout more than 24 hours behind origin (R75)', () => {
const STALE_INSTALLER = '#!/usr/bin/env bash\necho "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan behind=${GSTACK_CAPY_BEHIND:-none} stale=${GSTACK_CAPY_STALE:-none} fix=${GSTACK_CAPY_BEHIND_FIX:-none}"\n';
const rerun = `bash ${BOOT} --for autoplan`;
/** Clone at B; origin's C is ageHours old. */
const behindBy = (ageHours: number) => {
const f = fixture({ cloneAt: 'B', nextAgeHours: ageHours });
write(path.join(f.root, 'bin/gstack-capy-install'), STALE_INSTALLER, 0o755);
f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']);
return f;
};
// Value: protects=a checkout whose oldest missing commit is over 24h old is flagged stale on --check with the fast-forwarding install as its fix (no longer
// a bare informational behind=), and the install at the workflow boundary fast-forwards it; an hour behind stays plain behind=; fails_when=--check
// leaves a day-old checkout informational, or the boundary install does not move it; why_new=R75; seam=C's commit date; no_claim=GitHub's main
test('25 hours behind: --check notes stale=25h with the install as its fix and moves nothing; the install fast-forwards; an hour behind is not stale', () => {
const f = behindBy(25);
const check = f.run(['--check', '--for', 'autoplan']);
expect(check.status, check.stdout + check.stderr).toBe(0);
expect(check.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 stale=25h fix=${rerun}`);
expect(check.stdout).toContain('gstack-capy-session: stale: the oldest commit this checkout lacks is 25h old (over 24h); run the fix before the first workflow');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
const install = f.run(['--for', 'autoplan']);
expect(install.stdout).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c);
const fresh = behindBy(1);
expect(fresh.run(['--check', '--for', 'autoplan']).last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 stale=none fix=${rerun}`);
});
// Value: protects=the daily upgrade happens only at a workflow boundary: a run holding a fresh workflow lease, or a live eval run (gstack-detach's eval
// lock), skips it even for a stale checkout with an unreasoned pin; fails_when=a /ship, review or eval run has gstack moved under it; why_new=R75 adds
// a forced path that must not bypass the in-run markers; seam=gstack-lease and flock on the eval lock; no_claim=leases of other machines
test('mid-run: a fresh workflow lease of this run, or a live eval run, defers the upgrade of a stale checkout', () => {
const f = behindBy(25);
const leaseDir = path.join(f.host.base, 'leases');
const script = ['bash "$1" touch --pid $$ --root "$2" || exit 9', 'bash "$3" --for autoplan; echo "exit=$?"'].join('\n');
const inRun = spawnSync('bash', ['-c', script, 'run', path.join(f.root, 'bin/gstack-lease'), f.root, BOOT], { cwd: f.host.home, encoding: 'utf8', env: { ...f.host.env, GSTACK_CAPY_ROOT: f.root, GSTACK_CAPY_REPO: `file://${f.bare}`, WRAP_LOG: path.join(f.host.base, 'wrap.log'), GSTACK_LEASE_DIR: leaseDir, GSTACK_CAPY_SELF_UPGRADE: 'off' }, timeout: 120_000 });
expect(inRun.stdout, inRun.stdout + inRun.stderr).toContain('exit=0');
expect(inRun.stdout).toMatch(/^SELF_UPGRADE: skipped \(a gstack run in this session holds a workflow lease .+ \(UPGRADE_BUSY\)\)$/m);
expect(inRun.stdout).not.toContain('pin overridden');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
if (spawnSync('sh', ['-c', 'command -v flock'], { timeout: 5_000 }).status !== 0) return;
const lock = path.join(f.host.home, '.gstack/locks/gstack-evals.lock');
fs.mkdirSync(path.dirname(lock), { recursive: true });
const holder = spawn('bash', ['-c', 'exec 9>"$1" && flock 9 && exec sleep 300', '_', lock], { stdio: 'ignore' });
try {
for (let i = 0; i < 50 && spawnSync('flock', ['-n', lock, 'true'], { timeout: 5_000 }).status === 0; i++) spawnSync('sleep', ['0.1']);
const evalRun = f.run(['--for', 'autoplan']);
expect(evalRun.status, evalRun.stdout + evalRun.stderr).toBe(0);
expect(evalRun.stdout).toContain(`SELF_UPGRADE: skipped (a live eval run holds ${lock}, so the upgrade waits for it to finish; fix: ${rerun} once the eval run ends (UPGRADE_BUSY))`);
expect(evalRun.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 stale=25h fix=${rerun}`);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
} finally {
holder.kill('SIGKILL');
}
const after = f.run(['--for', 'autoplan']);
expect(after.stdout).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m);
});
// Value: protects=a pin with a recorded reason (GSTACK_PIN_REASON, or gstack.pinReason in the checkout's git config) holds a stale checkout and the
// trailer names the reason; fails_when=a deliberate, explained pin is moved; why_new=R75; seam=both reason sources; no_claim=pins below the floor
test('a pin with a recorded reason holds, and the trailer carries the reason', () => {
const f = behindBy(25);
const off = f.run(['--for', 'autoplan'], { GSTACK_CAPY_SELF_UPGRADE: 'off', GSTACK_PIN_REASON: 'bisecting GST-1 at 1.0.1' });
expect(off.stdout).toMatch(/^SELF_UPGRADE: disabled \(GSTACK_CAPY_SELF_UPGRADE\)$/m);
expect(off.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 stale=25h (pinned: bisecting GST-1 at 1.0.1) fix=unset GSTACK_CAPY_SELF_UPGRADE, then ${rerun}`);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
f.git(['checkout', '-q', '--detach']);
f.git(['config', 'gstack.pinReason', 'repro for GST-2']);
const detached = f.run(['--for', 'autoplan']);
expect(detached.stdout).toMatch(/^SELF_UPGRADE: skipped \(HEAD is detached .+\)$/m);
expect(detached.last).toContain('stale=25h (pinned: repro for GST-2)');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
});
// Value: protects=a pin with no recorded reason does not hold a stale checkout: the install overrides it, says so with PIN_UNREASONED, puts a detached
// checkout back on the default branch and fast-forwards; --check points its fix at that install; fails_when=an unexplained pin keeps a thread on a
// day-old gstack; why_new=R75; seam=GSTACK_CAPY_SELF_UPGRADE=off and a detached HEAD; no_claim=dirty or diverged checkouts (still refused)
test('a pin with no recorded reason is overridden at the boundary and reported; --check names the install as the fix', () => {
const f = behindBy(25);
expect(f.run(['--check', '--for', 'autoplan'], { GSTACK_CAPY_SELF_UPGRADE: 'off' }).last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 stale=25h fix=${rerun}`);
const off = f.run(['--for', 'autoplan'], { GSTACK_CAPY_SELF_UPGRADE: 'off' });
expect(off.status, off.stdout + off.stderr).toBe(0);
expect(off.stdout).toContain(`SELF_UPGRADE: pin overridden (GSTACK_CAPY_SELF_UPGRADE=off, with no recorded reason, and the oldest commit this checkout lacks is 25h old; keep a pin with GSTACK_PIN_REASON=<why> or git -C ${f.root} config gstack.pinReason <why>) (PIN_UNREASONED)`);
expect(off.stdout).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c);
const d = behindBy(25);
d.git(['checkout', '-q', '--detach']);
const detached = d.run(['--for', 'autoplan']);
expect(detached.status, detached.stdout + detached.stderr).toBe(0);
expect(detached.stdout).toContain(`SELF_UPGRADE: pin overridden (HEAD detached at ${d.b.slice(0, 7)}, with no recorded reason`);
expect(detached.stdout).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m);
expect(d.git(['rev-parse', 'HEAD'])).toBe(d.c);
expect(d.git(['symbolic-ref', '--short', 'HEAD'])).toBe('main');
});
});
describe('capy skill: R75 edges (review)', () => {
const STALE_INSTALLER = '#!/usr/bin/env bash\necho "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan behind=${GSTACK_CAPY_BEHIND:-none} stale=${GSTACK_CAPY_STALE:-none} fix=${GSTACK_CAPY_BEHIND_FIX:-none}"\n';
const behindBy = (ageHours: number) => {
const f = fixture({ cloneAt: 'B', nextAgeHours: ageHours });
write(path.join(f.root, 'bin/gstack-capy-install'), STALE_INSTALLER, 0o755);
f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']);
return f;
};
// Value: protects=an unreasoned branch pin and GSTACK_CAPY_AUTO_UPGRADE=0 are overridden like SELF_UPGRADE=off; fails_when=either pin kind is missed;
// why_new=R75 review F7; seam=fixture; no_claim=none
test('another branch and GSTACK_CAPY_AUTO_UPGRADE=0 are overridden too', () => {
const b = behindBy(25);
b.git(['checkout', '-q', '-b', 'topic']);
const r = b.run(['--for', 'autoplan']);
expect(r.stdout).toContain('SELF_UPGRADE: pin overridden (on branch topic, with no recorded reason');
expect(b.git(['symbolic-ref', '--short', 'HEAD'])).toBe('main');
expect(b.git(['rev-parse', 'HEAD'])).toBe(b.c);
const a = behindBy(25);
const s = a.run(['--for', 'autoplan'], { GSTACK_CAPY_AUTO_UPGRADE: '0' });
expect(s.stdout).toContain('SELF_UPGRADE: pin overridden (GSTACK_CAPY_AUTO_UPGRADE=0, with no recorded reason');
expect(a.git(['rev-parse', 'HEAD'])).toBe(a.c);
});
// Value: protects=the override never switches a checkout under another live session and never onto a diverged local default branch, and HEAD stays
// where it was; fails_when=a refusal leaves gstack switched (R75 review F1, High); why_new=review F1; seam=gstack-lease, a local commit on main;
// no_claim=Bun-hold and setup-lock refusals (they restore through the same path)
test('a foreign lease or a diverged local main: the detached pin is not switched and HEAD stays', () => {
const f = behindBy(25);
f.git(['checkout', '-q', '--detach']);
const leaseDir = path.join(f.host.base, 'leases');
const holder = spawn('sleep', ['300'], { stdio: 'ignore' });
try {
expect(spawnSync('bash', [path.join(f.root, 'bin/gstack-lease'), 'touch', '--pid', String(holder.pid), '--root', f.root], { encoding: 'utf8', env: { ...f.host.env, GSTACK_LEASE_DIR: leaseDir }, timeout: 30_000 }).status).toBe(0);
const r = f.run(['--for', 'autoplan'], { GSTACK_LEASE_DIR: leaseDir });
expect(r.stdout).toContain('SELF_UPGRADE: skipped (live gstack sessions hold this installation, so the unreasoned pin (HEAD detached at');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
expect(spawnSync('git', ['-C', f.root, 'symbolic-ref', '-q', 'HEAD'], { timeout: 10_000 }).status).not.toBe(0);
} finally {
holder.kill('SIGKILL');
}
const d = behindBy(25);
write(path.join(d.root, 'README.md'), 'local work\n');
d.git(['commit', '-qam', 'local work']);
const local = d.git(['rev-parse', 'HEAD']);
d.git(['checkout', '-q', '--detach', d.b]);
const r = d.run(['--for', 'autoplan']);
expect(r.stdout).not.toContain('pin overridden');
expect(d.git(['rev-parse', 'HEAD'])).toBe(d.b);
expect(d.git(['rev-parse', 'refs/heads/main'])).toBe(local);
});
// Value: protects=an explicit --revision counts as a recorded reason, a reason cannot break the trailer grammar, a review-voice reader is told the
// lane's install moves the shared checkout, and the stable channel is not measured against main; fails_when=any of those regress; why_new=review
// F2/F4/F5/F6; seam=fixture; no_claim=none
test('--revision is a reason; a reason is sanitized; review-voice and the stable channel get no fix-before-workflow stale note', () => {
const f = behindBy(25);
f.git(['checkout', '-q', '--detach']);
const rev = f.run(['--for', 'autoplan', '--revision', f.b]);
expect(rev.stdout).not.toContain('pin overridden');
expect(rev.last).toContain(`stale=25h (pinned: --revision ${f.b})`);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
const evil = f.run(['--check', '--for', 'autoplan'], { GSTACK_PIN_REASON: 'x; fix: rm -rf /\nmore' });
expect(evil.last).toContain('stale=25h (pinned: x fix: rm -rf / more)');
expect(evil.last).not.toContain('; fix: rm');
f.git(['checkout', '-q', 'main']);
expect(f.run(['--check', '--for', 'review-voice']).last).toContain("stale=25h (shared checkout: the lane's install moves it)");
expect(f.run(['--check', '--for', 'autoplan'], { GSTACK_UPDATE_CHANNEL: 'stable' }).last).toContain('stale=none');
});
// Value: protects=a failed fetch with no successful one in 24 hours is reported from gstack's own success stamp (git empties FETCH_HEAD and resets its
// mtime on failure); fails_when=a dead network hides a day-old checkout; why_new=review F3; seam=the stamp's mtime and a broken origin; no_claim=none
test('no successful fetch in 24 hours: stale=no fetch from origin in <h>h', () => {
const f = behindBy(1);
expect(f.run(['--check', '--for', 'autoplan']).last).toContain('stale=none');
const stamp = path.join(f.root, '.git/gstack-last-fetch');
expect(fs.existsSync(stamp)).toBe(true);
const t = Date.now() / 1000 - 25 * 3600 - 60;
fs.utimesSync(stamp, t, t);
f.git(['remote', 'set-url', 'origin', `file://${f.host.base}/gone.git`]);
const r = f.run(['--check', '--for', 'autoplan'], { GSTACK_CAPY_REPO: `file://${f.host.base}/gone.git` });
expect(r.stdout).toContain('remote minimum unknown (fetch failed)');
expect(r.last).toContain('stale=no fetch from origin in 25h');
});
});
+11
View File
@@ -307,6 +307,17 @@ describe('gstack-capy-install', () => {
expect(lastLine(run(f, [], { ...behind, STUB_DOCTOR_FAIL: '1' }).stdout)).toBe('gstack: fail codex');
});
// Value: protects=the real installer renders GSTACK_CAPY_STALE as stale= right after behind=, and on its own (no fetch) with a network fix and no
// "; " inside the fix; fails_when=the note is dropped, misplaced, or breaks the trailer grammar; why_new=R75 (GST-1642) review F7; seam=env;
// no_claim=the session's computation (capy-skill-wrapper.test.ts)
test('trailer grammar: stale= follows behind=, or stands alone with a network fix when no fetch succeeded in 24 hours', () => {
const f = fixture();
const behind = { GSTACK_CAPY_BEHIND: '5', GSTACK_CAPY_BEHIND_TO: '1.2.8', GSTACK_CAPY_BEHIND_FIX: 'bash /s/install.sh --for autoplan', GSTACK_CAPY_STALE: '30h' };
expect(lastLine(run(f, [], behind).stdout)).toBe('gstack: ok 1.2.3 for=autoplan (behind=5 (latest: 1.2.8); stale=30h; fix: bash /s/install.sh --for autoplan)');
const nofetch = run(f, ['--for', 'autoplan'], { GSTACK_CAPY_STALE: 'no fetch from origin in 26h', GSTACK_CAPY_WRAPPER_PATH: '/s/install.sh' });
expect(lastLine(nofetch.stdout)).toBe('gstack: ok 1.2.3 for=autoplan (stale=no fetch from origin in 26h; fix: check network access to origin, then bash /s/install.sh --for autoplan)');
});
test('a skipped session-start upgrade above the minimum is a WARN, never a fail: the trailer stays ok with behind=, the skip reason and the fix, exit 0, and a WARN behind row names the code', () => {
const f = fixture();
const busy = { GSTACK_CAPY_BEHIND: '3', GSTACK_CAPY_BEHIND_TO: '1.2.6', GSTACK_CAPY_BEHIND_FIX: `${f.root}/bin/gstack-upgrade`, GSTACK_CAPY_BEHIND_BLOCKED: 'live gstack sessions hold this installation: pid 379 (124m dbus-daemon); fix: wait for those sessions to finish, or re-run with GSTACK_UPGRADE_FORCE=1 when you know they are idle (UPGRADE_BUSY)' };
+8 -2
View File
@@ -397,7 +397,7 @@ describe.skipIf(process.platform === 'win32')('gstack_update_channel_initial: th
};
const initial = (dir: string, extra: Record<string, string> = {}) => spawnSync('bash', [HELPER, '--initial', state, dir], {
encoding: 'utf8', timeout: 10_000,
env: { ...process.env, ...GIT_ENV, HOME: home, GSTACK_UPDATE_CHANNEL: '', GSTACK_CONTRIBUTOR: '', CI: '', ...extra },
env: { ...process.env, ...GIT_ENV, HOME: home, GSTACK_UPDATE_CHANNEL: '', GSTACK_CONTRIBUTOR: '', CI: '', CAPY_MACHINE_CONFIG: '', ...extra },
}).stdout.trim();
return { base, home, state, clone, initial };
}
@@ -410,12 +410,18 @@ describe.skipIf(process.platform === 'win32')('gstack_update_channel_initial: th
expect(c.initial(shallow, { GSTACK_UPDATE_CHANNEL: 'main' })).toBe('main set by GSTACK_UPDATE_CHANNEL at install');
expect(c.initial(shallow, { GSTACK_UPDATE_CHANNEL: 'stable', CI: 'true' })).toBe('stable set by GSTACK_UPDATE_CHANNEL at install');
expect(c.initial(shallow, { GSTACK_UPDATE_CHANNEL: 'weekly' })).toBe('stable new install');
expect(c.initial(shallow, { CAPY_MACHINE_CONFIG: '/etc/capy.json' })).toBe('main Capy machine: crews follow every merge');
fs.mkdirSync(path.join(c.home, '.capy', 'system'), { recursive: true });
expect(c.initial(shallow)).toBe('main Capy machine: crews follow every merge');
fs.rmSync(path.join(c.home, '.capy'), { recursive: true });
const usage = spawnSync('bash', [HELPER, '--initial'], { encoding: 'utf8', timeout: 10_000 });
expect([usage.status, usage.stderr.trim()]).toEqual([2, 'usage: gstack-update-channel.sh --initial <state root> [<install dir>]']);
});
test('an existing install records nothing: a setup marker or config.yaml in the state root, or the legacy ~/.gstack marker', () => {
const c = checkout();
const dir = c.clone('full');
for (const marker of [path.join(c.state, '.last-setup-version'), path.join(c.state, 'config.yaml'), path.join(c.home, '.gstack', '.last-setup-version')]) {
for (const marker of [path.join(c.state, '.last-setup-version'), path.join(c.state, 'config.yaml'), path.join(c.home, '.gstack', '.last-setup-version'), path.join(c.home, '.gstack', 'config.yaml')]) {
write(marker, '1.0.0\n');
expect(c.initial(dir), marker).toBe('');
expect(c.initial(dir, { GSTACK_UPDATE_CHANNEL: 'main' }), marker).toBe('');