mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
B11: auto-update tracks pull, setup and migrations; announces only on success; retries with backoff
gstack-session-update wrote the 'just upgraded' marker after SETUP_FAILED or SETUP_SKIPPED bun_missing, and re-ran setup only when HEAD moved, so a failed install was announced as an upgrade and never retried. Stages are now persisted in <state root>/session-update-pending; the marker is written only when every stage succeeded; a pending setup or migrations stage is retried even with HEAD unchanged after the update-check interval, then 6h, then 24h; and each session start prints one line naming the stage, the reason and the repair command while it is pending. Best-effort tails carry a same-line reason.
This commit is contained in:
1 parent
f89c5bcefc
commit
2482825b90
2 files changed
+290
-19
No files matched your search
+90
-19
@@ -1,10 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-session-update — auto-update gstack on session start (team mode)
|
||||
#
|
||||
# Called by Claude Code SessionStart hook. Must be fast, silent, non-fatal.
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
@@ -21,11 +28,20 @@ STATE_DIR="$(gstack_state_root; printf x)"; STATE_DIR="${STATE_DIR%x}"
|
||||
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"
|
||||
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
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $1" >> "$LOG_FILE" 2>/dev/null || true # best-effort: logging never blocks a session
|
||||
}
|
||||
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 ──
|
||||
@@ -39,6 +55,18 @@ if [ "$AUTO" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── A stage that did not finish: one line per session start, with the fix ──
|
||||
if [ -f "$PENDING_FILE" ]; then
|
||||
_P_STAGES="$(pending_get stages)"; _P_REASON="$(pending_get reason)"; _P_PULL="$(pending_get pull)"
|
||||
if [ -n "$_P_STAGES" ]; then
|
||||
_P_FIX="cd $GSTACK_DIR && ./setup"
|
||||
case "$_P_REASON" in bun*) _P_FIX="install Bun (https://bun.sh), then $_P_FIX" ;; esac
|
||||
echo "gstack auto-update: ${_P_STAGES} did not finish (${_P_REASON:-unknown reason}); installed skills may be out of date. gstack retries automatically. Fix now: $_P_FIX"
|
||||
elif [ -n "$_P_PULL" ]; then
|
||||
echo "gstack auto-update: git pull did not finish ($_P_PULL); gstack is not updating. Fix now: cd $GSTACK_DIR && git pull --ff-only && ./setup"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Throttle: skip if checked recently ──
|
||||
if [ -f "$THROTTLE_FILE" ]; then
|
||||
LAST=$(cat "$THROTTLE_FILE" 2>/dev/null || echo 0)
|
||||
@@ -152,9 +180,16 @@ fi
|
||||
# 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 [ "$PULL_EXIT" -ne 0 ]; then
|
||||
PULL_REASON=$(head -c 300 "$PULL_ERR_FILE" 2>/dev/null | tr '\n' ' ' | tr -s ' ')
|
||||
log_entry "PULL_FAILED exit=$PULL_EXIT reason=${PULL_REASON:-unknown}"
|
||||
P_PULL="exit $PULL_EXIT: $(printf '%s' "${PULL_REASON:-no error output}" | head -c 160)"
|
||||
pending_save
|
||||
# Autostash pop conflict leaves the stash behind and the tree half-merged.
|
||||
# The local patches are REGENERABLE (prefix renames, gbrain blocks), so
|
||||
# recover to a clean upstream tree and re-render them below rather than
|
||||
@@ -164,8 +199,8 @@ fi
|
||||
git -C "$GSTACK_DIR" stash drop -q 2>/dev/null
|
||||
log_entry "AUTOSTASH_CONFLICT_RECOVERED tree_reset=1"
|
||||
_PREFIX_CFG=$("$GSTACK_DIR/bin/gstack-config" get skill_prefix 2>/dev/null || echo false)
|
||||
"$GSTACK_DIR/bin/gstack-patch-names" "$GSTACK_DIR" "$_PREFIX_CFG" >/dev/null 2>&1 || true
|
||||
"$GSTACK_DIR/bin/gstack-config" gbrain-refresh >/dev/null 2>&1 || true
|
||||
"$GSTACK_DIR/bin/gstack-patch-names" "$GSTACK_DIR" "$_PREFIX_CFG" >/dev/null 2>&1 || true # best-effort: regenerable prefix rewrite
|
||||
"$GSTACK_DIR/bin/gstack-config" gbrain-refresh >/dev/null 2>&1 || true # best-effort: regenerable gbrain blocks
|
||||
fi
|
||||
rm -f "$PULL_ERR_FILE" 2>/dev/null
|
||||
exit 0
|
||||
@@ -175,33 +210,69 @@ fi
|
||||
# no-ops when the feature is unconfigured); the autostash pop usually
|
||||
# preserves them, but a clean re-render costs nothing and self-heals.
|
||||
_PREFIX_CFG=$("$GSTACK_DIR/bin/gstack-config" get skill_prefix 2>/dev/null || echo false)
|
||||
"$GSTACK_DIR/bin/gstack-patch-names" "$GSTACK_DIR" "$_PREFIX_CFG" >/dev/null 2>&1 || true
|
||||
"$GSTACK_DIR/bin/gstack-config" gbrain-refresh >/dev/null 2>&1 || true
|
||||
"$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
|
||||
|
||||
# ── If HEAD moved, run setup -q ──
|
||||
# ── 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
|
||||
|
||||
# bun must be available for setup
|
||||
if [ "$RUN_SETUP" -eq 1 ]; then
|
||||
FAILED_STAGE="" FAIL_REASON=""
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
( cd "$GSTACK_DIR" && ./setup -q --refresh-registered ) >/dev/null 2>&1 || {
|
||||
log_entry "SETUP_FAILED"
|
||||
}
|
||||
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
|
||||
|
||||
# Write marker so next skill preamble shows "just upgraded"
|
||||
OLD_VER=$(git -C "$GSTACK_DIR" show "$OLD_HEAD:VERSION" 2>/dev/null || echo "unknown")
|
||||
echo "$OLD_VER" > "$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=$OLD_VER to=$(cat "$GSTACK_DIR/VERSION" 2>/dev/null || echo unknown)"
|
||||
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
|
||||
log_entry "UP_TO_DATE head=$OLD_HEAD"
|
||||
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
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { afterEach, describe, test, expect } from 'bun:test';
|
||||
import { execFileSync, spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
// B11: the auto-updater wrote the "just upgraded" marker after setup failed
|
||||
// (or was skipped because bun was missing), and only re-ran setup when HEAD
|
||||
// moved, so a failed install was never retried. Pull, setup and migrations
|
||||
// are now separate stages: the marker is written only when all succeed, a
|
||||
// failed stage is persisted and retried with backoff even when HEAD did not
|
||||
// move, and every session start prints one failure line with the fix.
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SCRIPT = path.join(ROOT, 'bin', 'gstack-session-update');
|
||||
const bases: string[] = [];
|
||||
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }).trim();
|
||||
}
|
||||
|
||||
/** PATH without any bun: directories holding bun are replaced by shims of everything else. */
|
||||
function pathWithoutBun(base: string): string {
|
||||
return (process.env.PATH ?? '/usr/bin:/bin').split(':').filter(Boolean).map((dir, i) => {
|
||||
if (!fs.existsSync(path.join(dir, 'bun'))) return dir;
|
||||
const shim = path.join(base, `nobun-${i}`);
|
||||
if (fs.existsSync(shim)) return shim;
|
||||
fs.mkdirSync(shim, { recursive: true });
|
||||
for (const name of fs.readdirSync(dir)) if (name !== 'bun') fs.symlinkSync(path.join(dir, name), path.join(shim, name));
|
||||
return shim;
|
||||
}).join(':');
|
||||
}
|
||||
|
||||
function makeFixture() {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-supd-stages-'));
|
||||
bases.push(base);
|
||||
const origin = path.join(base, 'origin.git');
|
||||
const seed = path.join(base, 'seed');
|
||||
const install = path.join(base, 'install');
|
||||
const state = path.join(base, 'state');
|
||||
const home = path.join(base, 'home');
|
||||
fs.mkdirSync(state, { recursive: true });
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 });
|
||||
fs.mkdirSync(path.join(seed, 'bin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(seed, 'VERSION'), '1.0.0\n');
|
||||
fs.writeFileSync(path.join(seed, 'bin', 'gstack-config'),
|
||||
'#!/usr/bin/env bash\nif [ "$1" = "get" ]; then case "$2" in auto_upgrade) echo true;; skill_prefix) echo false;; *) echo "";; esac; fi\nexit 0\n', { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(seed, 'bin', 'gstack-patch-names'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
|
||||
// Stub setup: records each run; $SETUP_CONTROL selects the outcome.
|
||||
fs.writeFileSync(path.join(seed, 'setup'), [
|
||||
'#!/usr/bin/env bash',
|
||||
'echo "$*" >> "$SETUP_CALLS"',
|
||||
'case "$(cat "$SETUP_CONTROL" 2>/dev/null)" in',
|
||||
' fail) echo "error: could not build browse" >&2; exit 1 ;;',
|
||||
' migfail) echo " warning: migration 1.1.0 failed; the state root stays marked at 1.0.0, so the next ./setup retries it" >&2; exit 1 ;;',
|
||||
'esac',
|
||||
'exit 0',
|
||||
].join('\n') + '\n', { mode: 0o755 });
|
||||
git(seed, 'init', '-q');
|
||||
git(seed, 'add', '-A');
|
||||
git(seed, 'commit', '-q', '-m', 'seed');
|
||||
git(seed, 'branch', '-M', 'main');
|
||||
git(seed, 'remote', 'add', 'origin', origin);
|
||||
git(seed, 'push', '-q', 'origin', 'main');
|
||||
execFileSync('git', ['clone', '-q', origin, install], { timeout: 30_000 });
|
||||
const control = path.join(base, 'control');
|
||||
const calls = path.join(base, 'calls');
|
||||
return { base, seed, install, state, home, control, calls };
|
||||
}
|
||||
|
||||
type Fx = ReturnType<typeof makeFixture>;
|
||||
|
||||
function upstreamRelease(fx: Fx, version: string) {
|
||||
fs.writeFileSync(path.join(fx.seed, 'VERSION'), `${version}\n`);
|
||||
git(fx.seed, 'commit', '-aqm', `release ${version}`);
|
||||
git(fx.seed, 'push', '-q', 'origin', 'main');
|
||||
}
|
||||
|
||||
function runHook(fx: Fx, opts: { noBun?: boolean } = {}) {
|
||||
const PATH = opts.noBun ? pathWithoutBun(fx.base) : `${path.dirname(process.execPath)}:${process.env.PATH}`;
|
||||
return spawnSync('bash', [SCRIPT], {
|
||||
encoding: 'utf8',
|
||||
env: { PATH, HOME: fx.home, GSTACK_DIR: fx.install, GSTACK_STATE_ROOT: fx.state, SETUP_CONTROL: fx.control, SETUP_CALLS: fx.calls, TMPDIR: fx.base },
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait for the forked updater to log `pattern` `count` times and release its lock. */
|
||||
async function waitFor(fx: Fx, pattern: RegExp, count = 1, ms = 15_000): Promise<string> {
|
||||
const logFile = path.join(fx.state, 'analytics', 'session-update.log');
|
||||
const deadline = Date.now() + ms;
|
||||
let content = '';
|
||||
while (Date.now() < deadline) {
|
||||
content = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : '';
|
||||
const hits = content.split('\n').filter(l => pattern.test(l)).length;
|
||||
if (hits >= count && !fs.existsSync(path.join(fx.state, '.setup-lock'))) return content;
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
throw new Error(`timed out waiting for ${pattern} x${count}; log:\n${content}`);
|
||||
}
|
||||
|
||||
const marker = (fx: Fx) => path.join(fx.state, 'just-upgraded-from');
|
||||
const pending = (fx: Fx) => path.join(fx.state, 'session-update-pending');
|
||||
const setupRuns = (fx: Fx) => (fs.existsSync(fx.calls) ? fs.readFileSync(fx.calls, 'utf8').trim().split('\n').length : 0);
|
||||
/** Let the next hook run start a check: the hourly throttle has elapsed. */
|
||||
const expireThrottle = (fx: Fx) => fs.rmSync(path.join(fx.state, '.last-session-update'), { force: true });
|
||||
const expireBackoff = (fx: Fx) => fs.writeFileSync(pending(fx), fs.readFileSync(pending(fx), 'utf8').replace(/^next=\d+$/m, 'next=0'));
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('gstack-session-update stages (B11)', () => {
|
||||
test('setup failure: no upgrade announcement, one failure line, retried with HEAD unchanged after backoff', async () => {
|
||||
const fx = makeFixture();
|
||||
fs.writeFileSync(fx.control, 'fail');
|
||||
upstreamRelease(fx, '1.1.0');
|
||||
|
||||
expect(runHook(fx).stdout).toBe('');
|
||||
await waitFor(fx, /Z PENDING stages=setup attempt=1/);
|
||||
expect(fs.existsSync(marker(fx))).toBe(false);
|
||||
expect(setupRuns(fx)).toBe(1);
|
||||
const state = fs.readFileSync(pending(fx), 'utf8');
|
||||
expect(state).toContain('stages=setup\n');
|
||||
expect(state).toContain('from=1.0.0\n');
|
||||
expect(state).toMatch(/reason=\.\/setup exited 1: error: could not build browse\n/);
|
||||
|
||||
// Next session start inside the backoff window: the line, but no setup run.
|
||||
expireThrottle(fx);
|
||||
const second = runHook(fx);
|
||||
expect(second.stdout.trim().split('\n')).toEqual([
|
||||
`gstack auto-update: setup did not finish (./setup exited 1: error: could not build browse); installed skills may be out of date. gstack retries automatically. Fix now: cd ${fx.install} && ./setup`,
|
||||
]);
|
||||
await waitFor(fx, /PENDING_BACKOFF stages=setup/);
|
||||
expect(setupRuns(fx)).toBe(1);
|
||||
|
||||
// After the backoff, the pending stage is retried although HEAD is unchanged.
|
||||
fs.writeFileSync(fx.control, 'ok');
|
||||
expireThrottle(fx);
|
||||
expireBackoff(fx);
|
||||
runHook(fx);
|
||||
await waitFor(fx, /UPDATED from=1\.0\.0 to=1\.1\.0/);
|
||||
expect(setupRuns(fx)).toBe(2);
|
||||
expect(fs.readFileSync(marker(fx), 'utf8').trim()).toBe('1.0.0');
|
||||
expect(fs.existsSync(pending(fx))).toBe(false);
|
||||
expireThrottle(fx);
|
||||
expect(runHook(fx).stdout).toBe('');
|
||||
await waitFor(fx, /UP_TO_DATE/);
|
||||
});
|
||||
|
||||
test('backoff grows: update-check interval, then 6h, then 24h', async () => {
|
||||
const fx = makeFixture();
|
||||
fs.writeFileSync(fx.control, 'fail');
|
||||
upstreamRelease(fx, '1.1.0');
|
||||
const waits: number[] = [];
|
||||
for (let attempt = 1; attempt <= 4; attempt++) {
|
||||
if (attempt > 1) { expireThrottle(fx); expireBackoff(fx); }
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
runHook(fx);
|
||||
await waitFor(fx, /Z PENDING stages=setup attempt=/, attempt);
|
||||
const next = Number(fs.readFileSync(pending(fx), 'utf8').match(/^next=(\d+)$/m)![1]);
|
||||
waits.push(Math.round((next - before) / 3600));
|
||||
}
|
||||
expect(waits).toEqual([1, 6, 24, 24]);
|
||||
expect(setupRuns(fx)).toBe(4);
|
||||
expect(fs.existsSync(marker(fx))).toBe(false);
|
||||
});
|
||||
|
||||
test('bun missing: setup skipped is a pending stage with the install-Bun fix, never an announced upgrade', async () => {
|
||||
const fx = makeFixture();
|
||||
upstreamRelease(fx, '1.1.0');
|
||||
runHook(fx, { noBun: true });
|
||||
await waitFor(fx, /Z PENDING stages=setup attempt=1/);
|
||||
expect(fs.readFileSync(path.join(fx.state, 'analytics', 'session-update.log'), 'utf8')).toContain('SETUP_SKIPPED bun_missing');
|
||||
expect(fs.existsSync(marker(fx))).toBe(false);
|
||||
expect(setupRuns(fx)).toBe(0);
|
||||
expireThrottle(fx);
|
||||
expect(runHook(fx, { noBun: true }).stdout).toContain(`(bun not found on PATH); installed skills may be out of date. gstack retries automatically. Fix now: install Bun (https://bun.sh), then cd ${fx.install} && ./setup`);
|
||||
});
|
||||
|
||||
test('a failed migration is its own stage', async () => {
|
||||
const fx = makeFixture();
|
||||
fs.writeFileSync(fx.control, 'migfail');
|
||||
upstreamRelease(fx, '1.1.0');
|
||||
runHook(fx);
|
||||
await waitFor(fx, /MIGRATIONS_FAILED reason=migration 1\.1\.0 failed/);
|
||||
expect(fs.readFileSync(pending(fx), 'utf8')).toContain('stages=migrations\n');
|
||||
expect(fs.existsSync(marker(fx))).toBe(false);
|
||||
expireThrottle(fx);
|
||||
expect(runHook(fx).stdout).toContain('gstack auto-update: migrations did not finish (migration 1.1.0 failed)');
|
||||
});
|
||||
|
||||
test('all stages pass: the marker is written once and nothing is printed', async () => {
|
||||
const fx = makeFixture();
|
||||
upstreamRelease(fx, '1.1.0');
|
||||
expect(runHook(fx).stdout).toBe('');
|
||||
await waitFor(fx, /UPDATED from=1\.0\.0 to=1\.1\.0/);
|
||||
expect(fs.readFileSync(marker(fx), 'utf8').trim()).toBe('1.0.0');
|
||||
expect(fs.existsSync(pending(fx))).toBe(false);
|
||||
expect(setupRuns(fx)).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user