mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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:
1 parent
749a238275
commit
e3566ab141
6 files changed
+106
-6
No files matched your search
@@ -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,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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user