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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-25 09:55:56 +00:00
1 parent 749a238275
commit e3566ab141
6 files changed
+106 -6

No files matched your search

+10
View File
@@ -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 <install> --exclude-ancestry-of <hook pid>`. Any live lease from another session logs `SKIP leased pid=<pid>/<age>` 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.
+1 -1
View File
@@ -1 +1 @@
1.91.201.0
1.91.202.0
+1 -1
View File
@@ -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,
+11
View File
@@ -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 "")
+1 -1
View File
@@ -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",
+82 -3
View File
@@ -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<string, string> } = {}) {
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<typeof spawn>[] = [];
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);
});