From e3566ab141ee3793ce1ec0a9c6a0109de212874a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 25 Oct 2026 02:55:56 -0700 Subject: [PATCH] fix(session-update): skip the team-mode auto-update while another session holds a workflow lease (#3363) (#3385) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 10 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-session-update | 11 ++++ package.json | 2 +- test/session-update-stages.test.ts | 85 ++++++++++++++++++++++++++++-- 6 files changed, 106 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdfb0ccdf..5eea3d0ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.91.202.0] - 2026-10-25 + +**Team-mode session auto-update waits for live gstack sessions instead of moving the checkout under them (#3363).** `bin/gstack-upgrade` already refuses with `UPGRADE_BUSY` while another live session holds a workflow lease on the installation (`bin/gstack-lease`), but the SessionStart hook `bin/gstack-session-update` never read the leases. A shared `./setup --team` install could fast-forward and re-run `./setup` while another session was mid-workflow. Reproduced on main: with a live lease held, the hook logged `UPDATING` and moved the install's HEAD. + +### Fixed +- **`bin/gstack-session-update`**: after taking its setup lock and before fetching, the background updater asks `gstack-lease live --root --exclude-ancestry-of `. Any live lease from another session logs `SKIP leased pid=/` and exits without fetching, fast-forwarding, retrying a pending setup stage or writing the hourly throttle stamp, so the next session start tries again. `GSTACK_UPGRADE_FORCE=1` overrides, as it does for `gstack-upgrade`. A dead or stale lease never blocks. + +### Tests +- `test/session-update-stages.test.ts` holds a lease from a live process: the install's HEAD, setup runs and the throttle stamp are unchanged, and the first session start after the holder exits updates. A second case shows a lease also holds a pending setup retry. Both fail on main. Controls: `GSTACK_UPGRADE_FORCE=1` updates under a live lease, and a lease whose process is dead does not block. + ## [1.91.201.0] - 2026-10-25 **/ship's review army and /plan-eng-review now ask for one severity scale, CRITICAL or INFORMATIONAL, the same scale /review, the specialist JSON and the review checklist already use (#3349).** The shared confidence rubric those two skills render told the model to label findings `[P1]` and `[P2]` and to report a 1-2 confidence finding "only if severity would be P0", while the rest of /ship's Step 9 (Pass 1 CRITICAL, Pass 2 INFORMATIONAL, `"severity":"CRITICAL|INFORMATIONAL"`) used the other scale. A finding from one skill couldn't be compared, deduplicated or gated against a finding from another. Garry picked CRITICAL/INFORMATIONAL for plan-eng-review on 2026-10-24. diff --git a/VERSION b/VERSION index 745abaa9b..0d217760d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.201.0 +1.91.202.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 23cc25c10..8e49ec766 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.201.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.202.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-session-update b/bin/gstack-session-update index 7166abc6d..2ae01297f 100755 --- a/bin/gstack-session-update +++ b/bin/gstack-session-update @@ -53,6 +53,7 @@ LOG_FILE="$STATE_DIR/analytics/session-update.log" PENDING_FILE="$STATE_DIR/session-update-pending" HOOK_CHECK="$(cd "$(dirname "$0")" && pwd)/gstack-hook-check" FF_UPDATE="$(cd "$(dirname "$0")" && pwd)/gstack-ff-update" +LEASE="$(cd "$(dirname "$0")" && pwd)/gstack-lease" RESCUE_FILE="$STATE_DIR/session-update-rescues" # Path spelling (Git Bash /c/x vs native C:/x) for the held-reason prefix strip. . "$(cd "$(dirname "$0")" && pwd)/gstack-path-spelling.sh" 2>/dev/null || { gstack_path_shell() { printf '%s\n' "$1"; }; gstack_path_native() { printf '%s\n' "$1"; }; } @@ -362,6 +363,16 @@ fi ) & HB_PID=$! + # ── Workflow leases (#3363): a live gstack session on this installation + # (bin/gstack-lease) means neither the fast-forward nor a pending setup + # retry runs under it, the rule bin/gstack-upgrade enforces as UPGRADE_BUSY. + # The throttle file is not written, so the next session start tries again. + if [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$LEASE" ] \ + && BUSY=$("$LEASE" live --root "$GSTACK_DIR" --exclude-ancestry-of $$ 2>/dev/null); then + log_entry "SKIP leased $(printf '%s\n' "$BUSY" | awk 'NF { printf "%spid=%s/%s", n++ ? " " : "", $1, $2 }')" + exit 0 + fi + # ── Fetch, check the incoming release's Bun floor, then fast-forward (E1) ── OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null) UPDATE_URL=$(git -C "$GSTACK_DIR" remote get-url origin 2>/dev/null || echo "") diff --git a/package.json b/package.json index 026ccc362..cc28c28da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.201", + "version": "1.91.202", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/session-update-stages.test.ts b/test/session-update-stages.test.ts index ef0c1d581..7bace11a9 100644 --- a/test/session-update-stages.test.ts +++ b/test/session-update-stages.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, test, expect } from 'bun:test'; -import { execFileSync, spawnSync } from 'child_process'; +import { execFileSync, spawn, spawnSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -97,13 +97,13 @@ function bunStub(fx: Fx, version: string): string { return dir; } -function runHook(fx: Fx, opts: { noBun?: boolean; bun?: string } = {}) { +function runHook(fx: Fx, opts: { noBun?: boolean; bun?: string; env?: Record } = {}) { const PATH = opts.noBun ? pathWithoutBun(fx.base) : opts.bun ? `${bunStub(fx, opts.bun)}:${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 }, + 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, ...opts.env }, timeout: 20_000, }); } @@ -287,3 +287,82 @@ describe.skipIf(process.platform === 'win32')('gstack-session-update Bun floor ( expect(banner).not.toContain('git pull did not finish'); }); }); + +describe.skipIf(process.platform === 'win32')('gstack-session-update honors workflow leases (#3363)', () => { + const head = (fx: Fx) => git(fx.install, 'rev-parse', 'HEAD'); + const sleepers: ReturnType[] = []; + afterEach(() => { for (const s of sleepers.splice(0)) s.kill('SIGKILL'); }); + // The test preload points GSTACK_LEASE_DIR at a per-run directory; each + // fixture gets its own, shared by the lease writer and the hook. + const leaseEnv = (fx: Fx) => ({ GSTACK_LEASE_DIR: path.join(fx.base, 'leases') }); + function touchLease(fx: Fx, pid: number) { + const touch = spawnSync('bash', [path.join(ROOT, 'bin', 'gstack-lease'), 'touch', '--root', fx.install, '--pid', String(pid)], + { encoding: 'utf8', env: { ...process.env, ...leaseEnv(fx) }, timeout: 10_000 }); + expect(touch.status, touch.stderr).toBe(0); + } + /** A live process holding a workflow lease on the fixture install, as a running skill would. */ + function holdLease(fx: Fx): number { + const sleeper = spawn('sleep', ['120'], { stdio: 'ignore' }); + sleepers.push(sleeper); + touchLease(fx, sleeper.pid!); + return sleeper.pid!; + } + const throttle = (fx: Fx) => path.join(fx.state, '.last-session-update'); + + test('a live lease: no fast-forward, no setup, no throttle stamp; the next start after it ends updates', async () => { + const fx = makeFixture(); + const before = head(fx); + upstreamRelease(fx, '1.1.0'); + const pid = holdLease(fx); + + expect(runHook(fx, { env: leaseEnv(fx) }).stdout).toBe(''); + const log = await waitFor(fx, /SKIP leased /); + expect(log).toMatch(new RegExp(`SKIP leased pid=${pid}/\\d+m`)); + expect(log).not.toContain('UPDATING'); + expect(head(fx)).toBe(before); + expect(setupRuns(fx)).toBe(0); + expect(fs.existsSync(throttle(fx))).toBe(false); + expect(fs.existsSync(marker(fx))).toBe(false); + + await Promise.all(sleepers.splice(0).map(s => new Promise(r => { s.once('exit', r); s.kill('SIGKILL'); }))); + runHook(fx, { env: leaseEnv(fx) }); + await waitFor(fx, /UPDATED from=1\.0\.0 to=1\.1\.0/); + expect(head(fx)).not.toBe(before); + expect(setupRuns(fx)).toBe(1); + }, 30_000); + + test('a live lease also holds a pending setup retry', async () => { + const fx = makeFixture(); + fs.writeFileSync(fx.control, 'fail'); + upstreamRelease(fx, '1.1.0'); + runHook(fx); + await waitFor(fx, /Z PENDING stages=setup attempt=1/); + expireThrottle(fx); + expireBackoff(fx); + fs.writeFileSync(fx.control, 'ok'); + holdLease(fx); + runHook(fx, { env: leaseEnv(fx) }); + const log = await waitFor(fx, /SKIP leased /); + expect(log).not.toContain('RETRY_PENDING'); + expect(setupRuns(fx)).toBe(1); + }, 30_000); + + test('controls: GSTACK_UPGRADE_FORCE=1 updates under a live lease, and a dead holder never blocks', async () => { + const forced = makeFixture(); + upstreamRelease(forced, '1.1.0'); + holdLease(forced); + runHook(forced, { env: { ...leaseEnv(forced), GSTACK_UPGRADE_FORCE: '1' } }); + await waitFor(forced, /UPDATED from=1\.0\.0 to=1\.1\.0/); + expect(setupRuns(forced)).toBe(1); + + const dead = makeFixture(); + upstreamRelease(dead, '1.1.0'); + const sleeper = spawn('sleep', ['120'], { stdio: 'ignore' }); + touchLease(dead, sleeper.pid!); + await new Promise(r => { sleeper.once('exit', r); sleeper.kill('SIGKILL'); }); + runHook(dead, { env: leaseEnv(dead) }); + const log = await waitFor(dead, /UPDATED from=1\.0\.0 to=1\.1\.0/); + expect(log).not.toContain('SKIP leased'); + }, 30_000); +}); +