mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
fix(autoplan): run.lock is linked into place with its owner written; a fresh unreadable lock is held, not stolen
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
98d0b70d8d
commit
4d7d3509ab
3 files changed
+34
-8
No files matched your search
@@ -18,6 +18,8 @@
|
||||
- **PLAN_REALITY wants a receipt per measured number and the rule itself.** Each clause stating a measured quantity (seconds, %, $, tokens, MB) needs its own file:line, script or command receipt (`unreceipted_numbers=<n>` plus one `NUMBER_UNRECEIPTED:` line each), and a line claiming a decision rule must quote or link it (`unquoted=<n>`). Receipts on dot paths such as `.gstack/ship-policy.json:1` count. `submit` records the check for each plan revision; it never refuses a submit.
|
||||
- **`autoplan/DRIVER.md` is the compact driver.** About a hundred lines generated from the code: the phase order, the artifact contract, the gate protocol and the coordinator contract, with no hook prose. `gstack-autoplan --help` and the first `next` print its path, and any host can read it instead of the full autoplan skill.
|
||||
|
||||
- **Six submits at once bind once.** `run.lock` is now created with its owner already written (a staged file linked into place), and an unreadable lock younger than 30 seconds counts as held, so a command that reads another's lock between create and write no longer deletes it and runs alongside it.
|
||||
|
||||
### For contributors
|
||||
|
||||
- New modules: `lib/autoplan-revise.ts`, `lib/autoplan-migrate.ts`, `lib/autoplan-plan-text.ts`, `lib/autoplan-admission.ts`, `lib/autoplan-scope.ts`, `lib/source-root.ts`, `scripts/resolvers/autoplan-driver.ts`.
|
||||
|
||||
+16
-8
@@ -14,6 +14,7 @@
|
||||
* EXECUTION_UNKNOWN, never redispatching them on its own. Thin bin:
|
||||
* bin/gstack-autoplan.
|
||||
*/
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
@@ -31,28 +32,35 @@ import { outsideConsent } from './outside-consent';
|
||||
import { familyConflict, modelFamily } from './outside-voice-runner';
|
||||
import { renderSpend, reserve, settle, summarize, readLedger } from './spend-ledger';
|
||||
|
||||
/** An unreadable run.lock younger than this is a lock some other gstack is still writing, never a stale one. */
|
||||
const LOCK_WRITE_GRACE_MS = 30_000;
|
||||
|
||||
/** Exclusive ownership for one command: a lock whose pid is alive is never stolen; a dead pid's lock is reclaimed. */
|
||||
export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean } = {}): T {
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const lock = path.join(out, FILES.lock);
|
||||
const mine = JSON.stringify({ pid: process.pid, host: os.hostname(), at: now() });
|
||||
const staged = `${lock}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
|
||||
fs.writeFileSync(staged, mine);
|
||||
for (let i = 0; ; i++) {
|
||||
try {
|
||||
const fd = fs.openSync(lock, 'wx');
|
||||
fs.writeSync(fd, mine);
|
||||
fs.closeSync(fd);
|
||||
fs.linkSync(staged, lock);
|
||||
fs.unlinkSync(staged);
|
||||
break;
|
||||
} catch (e: any) {
|
||||
if (e.code !== 'EEXIST') throw e;
|
||||
if (e.code !== 'EEXIST') { fs.rmSync(staged, { force: true }); throw e; }
|
||||
let owner: { pid?: number; host?: string; at?: string } = {};
|
||||
try { owner = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch { /* unreadable: treat as stale below */ }
|
||||
let unreadable = false;
|
||||
try { owner = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch { unreadable = true; }
|
||||
let alive = false;
|
||||
if (owner.host === os.hostname() && typeof owner.pid === 'number') {
|
||||
if (unreadable) {
|
||||
try { alive = Date.now() - fs.statSync(lock).mtimeMs < LOCK_WRITE_GRACE_MS; } catch { /* gone: retry the link */ }
|
||||
} else if (owner.host === os.hostname() && typeof owner.pid === 'number') {
|
||||
try { process.kill(owner.pid, 0); alive = true; } catch (k: any) { alive = k.code === 'EPERM'; }
|
||||
} else if (owner.host && owner.host !== os.hostname()) alive = !opts.steal;
|
||||
if (alive) throw new RunError('RUN_LOCKED', `pid ${owner.pid} on ${owner.host} since ${owner.at}`, 3);
|
||||
if (alive) { fs.rmSync(staged, { force: true }); throw new RunError('RUN_LOCKED', unreadable ? `${lock} is being written by another command` : `pid ${owner.pid} on ${owner.host} since ${owner.at}`, 3); }
|
||||
try { fs.unlinkSync(lock); } catch { /* raced with its owner's exit */ }
|
||||
if (i > 50) throw new RunError('RUN_LOCKED', `could not reclaim ${lock}`, 3);
|
||||
if (i > 50) { fs.rmSync(staged, { force: true }); throw new RunError('RUN_LOCKED', `could not reclaim ${lock}`, 3); }
|
||||
}
|
||||
}
|
||||
try { return fn(); } finally { try { if (fs.readFileSync(lock, 'utf8') === mine) fs.unlinkSync(lock); } catch { /* already gone */ } }
|
||||
|
||||
@@ -24,6 +24,22 @@ async function parallel(argvs: string[][]): Promise<Array<{ status: number; stdo
|
||||
}
|
||||
|
||||
describe('two writers', () => {
|
||||
test('a lock caught between create and write is held, not stolen: a fresh empty run.lock refuses RUN_LOCKED; an old one is reclaimed', () => {
|
||||
const out = startSmallRun(path.join(TMP, 'half-written'));
|
||||
const lock = path.join(out, 'run.lock');
|
||||
const next = () => Bun.spawnSync(['bun', AUTOPLAN, 'next', '--out', out], { cwd: ROOT, env: env(), timeout: 60_000 });
|
||||
fs.writeFileSync(lock, '');
|
||||
const held = next();
|
||||
expect(held.exitCode).toBe(3);
|
||||
expect(held.stderr.toString()).toMatch(/\(RUN_LOCKED\)$/m);
|
||||
expect(fs.existsSync(lock)).toBe(true);
|
||||
const old = new Date(Date.now() - 60_000);
|
||||
fs.utimesSync(lock, old, old);
|
||||
const reclaimed = next();
|
||||
expect(reclaimed.exitCode, reclaimed.stderr.toString()).toBe(0);
|
||||
expect(fs.existsSync(lock)).toBe(false);
|
||||
});
|
||||
|
||||
test('six concurrent submits of the same attempt: exactly one binds, the rest are refused, one journal row, one settlement', async () => {
|
||||
const out = startSmallRun(path.join(TMP, 'submit'), ['--estimate-usd', '1']);
|
||||
const ids = attemptsOf(out, 'ceo');
|
||||
|
||||
Reference in new issue
Block a user