fix: gstack-skill-end releases its run on the workflow lease, so a finished /ship stops blocking gstack-upgrade (GST-1205)

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-03 07:51:16 +00:00
1 parent c197a139b7
commit 03ada799e8
8 files changed
+100 -22

No files matched your search

+25 -1
View File
@@ -91,7 +91,7 @@ describe('gstack-lease', () => {
const r = spawnSync('bash', ['-n', LEASE], { encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
const help = spawnSync('bash', [LEASE, '--help'], { encoding: 'utf8', timeout: 30_000 });
expect(help.stdout).toContain('print `<pid> <age>m alive|dead|stale` for every lease');
expect(help.stdout).toContain('print `<pid> <age>m alive|idle|dead|stale` for every lease');
});
test('touch writes <git dir>/gstack-leases/<pid> for --pid and prunes leases older than 120 minutes', () => {
@@ -716,6 +716,30 @@ describe('gstack-lease', () => {
}
});
// GST-1205: gstack-skill-end releases the run gstack-skill-start opened, so a finished run stops holding the lease.
test('gstack-skill-start opens a run on the lease and gstack-skill-end with its SESSION_ID releases it; another holder still counts', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-home-'));
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-state-'));
dirs.push(home, state);
fs.writeFileSync(path.join(state, 'config.yaml'), 'update_check: false\n');
const leaseDir = path.join(state, 'leases');
const env = { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: state, GSTACK_LEASE_DIR: leaseDir };
const holder = sleeper();
const start = spawnSync(START, ['--skill', 'ship', '--parent-pid', String(holder)], { encoding: 'utf8', cwd: home, env, timeout: 30_000 });
expect(start.status, start.stdout + start.stderr).toBe(0);
const sid = start.stdout.match(/^SESSION_ID: (\S+)$/m)?.[1] ?? '';
expect(sid.startsWith(`${holder}-`), sid).toBe(true);
expect(run(['live'], env).status).toBe(0);
const end = spawnSync(path.join(REPO, 'bin/gstack-skill-end'), ['--skill', 'ship', '--outcome', 'success', '--session-id', sid, '--tel-start', '0'], { encoding: 'utf8', cwd: home, env, timeout: 30_000 });
expect(end.status, end.stdout + end.stderr).toBe(0);
const after = run(['live'], env);
expect(after.status, after.stdout + after.stderr).toBe(1);
expect(run(['list'], env).stdout).toMatch(new RegExp(`^${holder} \\d+m idle$`, 'm'));
const other = sleeper();
expect(run(['touch', '--pid', String(other)], env).status).toBe(0);
expect(run(['live'], env).stdout).toMatch(new RegExp(`^${other} \\d+m sleep$`, 'm'));
});
test('gstack-skill-start reports a lease the held lock kept from being written, and still starts', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-home-'));
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-state-'));
+17 -2
View File
@@ -216,14 +216,14 @@ describe('gstack-upgrade', () => {
// every session's lease names capy-server and the caller's own-ancestry
// exclusion used to drop a sibling's fresh lease: a hand-run upgrade moved the checkout.
describe('a lease held by a shared harness (capy-server) ancestor', () => {
const underHarness = (f: ReturnType<typeof fixture>, harness: string, ageMinutes: number, extra: Record<string, string> = {}) => {
const underHarness = (f: ReturnType<typeof fixture>, harness: string, ageMinutes: number, extra: Record<string, string> = {}, lease = 'bash "$1" touch --root "$2" --pid $$ || exit 9') => {
const bin = path.join(f.host.base, 'harness');
fs.mkdirSync(bin, { recursive: true });
const exe = path.join(bin, harness);
if (!fs.existsSync(exe)) fs.copyFileSync(fs.realpathSync(spawnSync('bash', ['-c', 'command -v bash'], { encoding: 'utf8', timeout: 30_000 }).stdout.trim()), exe);
fs.chmodSync(exe, 0o755);
const script = [
'bash "$1" touch --root "$2" --pid $$ || exit 9',
lease,
'[ "$4" -gt 0 ] && "$5" -e "const t = Date.now() / 1000 - Number(process.argv[2]) * 60; require(\'fs\').utimesSync(process.argv[1], t, t)" "${GSTACK_LEASE_DIR:-$(git -C "$2" rev-parse --absolute-git-dir)/gstack-leases}/$$" "$4"',
'bash "$3"',
'echo "exit=$?"',
@@ -242,6 +242,21 @@ describe('gstack-upgrade', () => {
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
});
// GST-1205: a finished /ship run's lease used to block the upgrade for 120 minutes.
test('a lease whose runs all finished (gstack-lease release) no longer blocks; a sibling run still open on the same harness does', () => {
const touch = (id: string) => `bash "$1" touch --root "$2" --pid $$ --run "$$-${id}"`;
const release = (id: string) => `bash "$1" release --root "$2" --pid $$ --run "$$-${id}"`;
const f = fixture();
const open = underHarness(f, 'capy-server', 0, {}, `${touch('1-a')} && ${touch('2-b')} && ${release('1-a')} || exit 9`);
expect(open.stdout, open.stdout + open.stderr).toContain('exit=3');
expect(open.stderr).toContain('(UPGRADE_BUSY)');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
const finished = underHarness(f, 'capy-server', 0, {}, `${touch('1-a')} && ${release('1-a')} || exit 9`);
expect(finished.stdout, finished.stdout + finished.stderr).toContain('exit=0');
expect(finished.stderr).not.toContain('UPGRADE_BUSY');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
});
test('once that lease is 120 minutes old the upgrade proceeds', () => {
const f = fixture();
const r = underHarness(f, 'capy-server', 121);