gcf-harness-aif-ext 0.8.3 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/run.js +103 -22
- package/extension.json +1 -1
- package/package.json +1 -1
- package/workflows/ingest.js +3 -2
package/commands/run.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// verified branches a dead run left; an agent that needs the human leaves `outcome.json` in its
|
|
4
4
|
// worktree, and the block is recorded on main — a worktree never writes gcf/ status.
|
|
5
5
|
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
-
import { existsSync, utimesSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { existsSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
|
|
@@ -154,11 +154,18 @@ function alive(pid) {
|
|
|
154
154
|
*/
|
|
155
155
|
async function claim(logs, id) {
|
|
156
156
|
const file = path.join(logs, `${id}.lock`);
|
|
157
|
-
|
|
157
|
+
// Three tries: a breaker a dead run left, then the dead lock itself, is cleared on the way.
|
|
158
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
158
159
|
if (await create(file)) return () => rm(file, { force: true });
|
|
159
160
|
const text = await readFile(file, 'utf8').catch(() => null);
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
// Released meanwhile: the next create may take it, and that try is not spent.
|
|
162
|
+
if (text === null) {
|
|
163
|
+
attempt--;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The run's pid is the first line; a started session adds the item and its own pid.
|
|
168
|
+
const pid = Number(text.split('\n')[0]);
|
|
162
169
|
if (pid === process.pid) return () => rm(file, { force: true });
|
|
163
170
|
if (!(await dead(file, pid))) return null;
|
|
164
171
|
await clearDead(file, text);
|
|
@@ -197,7 +204,15 @@ async function dead(file, pid) {
|
|
|
197
204
|
*/
|
|
198
205
|
async function clearDead(file, text) {
|
|
199
206
|
const breaker = `${file}.break`;
|
|
200
|
-
if (!(await create(breaker)))
|
|
207
|
+
if (!(await create(breaker))) {
|
|
208
|
+
// A breaker is held for a read and a remove: one whose run is gone was left by a crash there.
|
|
209
|
+
const held = await readFile(breaker, 'utf8').catch(() => null);
|
|
210
|
+
if (held !== null && (await dead(breaker, Number(held.split('\n')[0]))))
|
|
211
|
+
await rm(breaker, { force: true });
|
|
212
|
+
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
201
216
|
try {
|
|
202
217
|
if ((await readFile(file, 'utf8').catch(() => null)) === text) await rm(file, { force: true });
|
|
203
218
|
} finally {
|
|
@@ -215,20 +230,50 @@ function verified(dir, id) {
|
|
|
215
230
|
return (res.stdout ?? '').split('\n').some((v) => v.trim() === id);
|
|
216
231
|
}
|
|
217
232
|
|
|
233
|
+
// Not a slug, so no item: the lock a run gates its batch and claims its items under.
|
|
234
|
+
const ADMIT = '.admit';
|
|
235
|
+
|
|
218
236
|
/**
|
|
219
|
-
*
|
|
220
|
-
*
|
|
237
|
+
* `fn` under the admission lock: two runs started at once (two board clicks) would each pass a
|
|
238
|
+
* gate that sees no lock of the other yet, and run together what the gate keeps apart.
|
|
221
239
|
*/
|
|
222
|
-
async function
|
|
223
|
-
const
|
|
240
|
+
async function admitted(logs, fn) {
|
|
241
|
+
const until = Date.now() + 60_000;
|
|
242
|
+
let release = await claim(logs, ADMIT);
|
|
243
|
+
while (!release) {
|
|
244
|
+
if (Date.now() > until)
|
|
245
|
+
throw new Error(`another run is starting (${ADMIT}.lock in ${logs}): retry when it ends`);
|
|
246
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
247
|
+
release = await claim(logs, ADMIT);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
return await fn();
|
|
252
|
+
} finally {
|
|
253
|
+
await release();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Every item claimed for this run, or an error when another run drives it. */
|
|
258
|
+
async function claimAll(ids, logs) {
|
|
259
|
+
const claimed = [];
|
|
224
260
|
const failed = [];
|
|
225
261
|
for (const id of ids) {
|
|
226
262
|
const release = await claim(logs, id);
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
263
|
+
if (release) claimed.push({ id, release });
|
|
264
|
+
else failed.push({ id, result: 'error', error: `${id} is already running in another run` });
|
|
265
|
+
}
|
|
231
266
|
|
|
267
|
+
return { claimed, failed };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The worktree and the agent command of each claimed item; an item that cannot start is an
|
|
272
|
+
* error. `agentOf(id)` gives the command.
|
|
273
|
+
*/
|
|
274
|
+
async function prepare({ claimed, failed }, core, agentOf) {
|
|
275
|
+
const ready = [];
|
|
276
|
+
for (const { id, release } of claimed) {
|
|
232
277
|
try {
|
|
233
278
|
const agent = agentOf(id);
|
|
234
279
|
const wt = json(await core(['worktree', id, '--json']), `worktree ${id}`);
|
|
@@ -287,8 +332,7 @@ function touch(file) {
|
|
|
287
332
|
*/
|
|
288
333
|
async function recordBlock(r, outcome, core) {
|
|
289
334
|
const reason = String(outcome.block);
|
|
290
|
-
|
|
291
|
-
spawnSync('git', ['commit', '--allow-empty', '-m', `gcf(unverified): ${r.id}`], { cwd: r.dir });
|
|
335
|
+
unverify(r);
|
|
292
336
|
const b = await core(['block', r.id, '--reason', reason]);
|
|
293
337
|
|
|
294
338
|
return {
|
|
@@ -299,6 +343,28 @@ async function recordBlock(r, outcome, core) {
|
|
|
299
343
|
};
|
|
300
344
|
}
|
|
301
345
|
|
|
346
|
+
/**
|
|
347
|
+
* An empty commit takes the trailer off the branch tip: no integrate merges it. `--only` keeps
|
|
348
|
+
* what a killed agent staged out of it, and a killed git leaves an index.lock to clear first.
|
|
349
|
+
*/
|
|
350
|
+
function unverify(r) {
|
|
351
|
+
if (!verified(r.dir, r.id)) return null;
|
|
352
|
+
const args = ['commit', '-q', '--no-verify', '--allow-empty', '--only', '-m'];
|
|
353
|
+
const commit = () =>
|
|
354
|
+
spawnSync('git', [...args, `gcf(unverified): ${r.id}`], { cwd: r.dir, encoding: 'utf8' });
|
|
355
|
+
if (commit().status === 0) return null;
|
|
356
|
+
const gitDir = spawnSync('git', ['rev-parse', '--absolute-git-dir'], {
|
|
357
|
+
cwd: r.dir,
|
|
358
|
+
encoding: 'utf8',
|
|
359
|
+
}).stdout.trim();
|
|
360
|
+
if (gitDir) rmSync(path.join(gitDir, 'index.lock'), { force: true });
|
|
361
|
+
const again = commit();
|
|
362
|
+
|
|
363
|
+
// shortcut: the integrate after it still merges a branch whose trailer stayed; the report
|
|
364
|
+
// says so, upgrade to integrating only the named branches if it ever happens.
|
|
365
|
+
return again.status === 0 ? null : `could not take its trailer off: ${again.stderr.trim()}`;
|
|
366
|
+
}
|
|
367
|
+
|
|
302
368
|
/** The result line of an item the integrator handled, or why it has none. */
|
|
303
369
|
function resultOf(run, integrated) {
|
|
304
370
|
const done = integrated.find((x) => x.id === run.id);
|
|
@@ -389,14 +455,20 @@ async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }) {
|
|
|
389
455
|
return mainResult(core, r);
|
|
390
456
|
}
|
|
391
457
|
|
|
392
|
-
/** Every item in its
|
|
393
|
-
async function runWorktrees(
|
|
394
|
-
const { ready, failed } = await prepare(
|
|
458
|
+
/** Every claimed item in its worktree at once; blocks recorded on main, verified ones merged. */
|
|
459
|
+
async function runWorktrees(claims, { core, logs, agentOf, timeoutMs }) {
|
|
460
|
+
const { ready, failed } = await prepare(claims, core, agentOf);
|
|
395
461
|
const runs = await Promise.all(ready.map((r) => session(r, logs, timeoutMs)));
|
|
396
462
|
const items = [...failed];
|
|
397
463
|
for (const r of runs) {
|
|
398
464
|
const outcome = await outcomeOf(r.dir);
|
|
399
465
|
if (outcome?.block) items.push(await recordBlock(r, outcome, core));
|
|
466
|
+
// A session cut short (timeout, `stop`, a crash) may have meant to block after its trailer:
|
|
467
|
+
// its item stays ready, and the rerun verifies its branch again.
|
|
468
|
+
else if (r.code !== 0) {
|
|
469
|
+
const failed = unverify(r);
|
|
470
|
+
if (failed) items.push({ id: r.id, result: 'error', error: failed });
|
|
471
|
+
}
|
|
400
472
|
}
|
|
401
473
|
|
|
402
474
|
const integrated = json(await core(['integrate', '--json']), 'integrate');
|
|
@@ -425,13 +497,22 @@ export async function run(
|
|
|
425
497
|
const bad = ids.find((id) => !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(id));
|
|
426
498
|
if (bad) throw new Error(`${bad} is not an item id (a lowercase hyphen slug)`);
|
|
427
499
|
const orphans = json(await core(['integrate', '--json']), 'integrate');
|
|
428
|
-
const passed = gate(ids);
|
|
429
|
-
if (!passed.pass) return { orphans, gate: passed.reason, items: [] };
|
|
430
|
-
|
|
431
500
|
const logs = path.join(path.resolve(projectDir), '.gcf-session', 'runs');
|
|
432
501
|
await mkdir(logs, { recursive: true });
|
|
433
502
|
const board = json(await core(['board', '--json']), 'board');
|
|
434
503
|
const runMode = mode ?? board.run_mode ?? 'worktree';
|
|
504
|
+
// The gate counts the items other runs hold locks on, so this run's locks go in with its gate.
|
|
505
|
+
// shortcut: a main run marks each item only as its session starts, so the items it has yet
|
|
506
|
+
// to reach do not count for another run's gate; claim them all here if main and worktree
|
|
507
|
+
// runs come to share a project.
|
|
508
|
+
const { passed, claims } = await admitted(logs, async () => {
|
|
509
|
+
const verdict = gate(ids);
|
|
510
|
+
const go = verdict.pass && runMode !== 'main';
|
|
511
|
+
|
|
512
|
+
return { passed: verdict, claims: go ? await claimAll(ids, logs) : null };
|
|
513
|
+
});
|
|
514
|
+
if (!passed.pass) return { orphans, gate: passed.reason, items: [] };
|
|
515
|
+
|
|
435
516
|
const agentOf = (id) =>
|
|
436
517
|
agentFor(
|
|
437
518
|
agent,
|
|
@@ -441,7 +522,7 @@ export async function run(
|
|
|
441
522
|
const unhook = stopWithSessions();
|
|
442
523
|
try {
|
|
443
524
|
const items =
|
|
444
|
-
runMode === 'main' ? await runMain(projectDir, ids, ctx) : await runWorktrees(
|
|
525
|
+
runMode === 'main' ? await runMain(projectDir, ids, ctx) : await runWorktrees(claims, ctx);
|
|
445
526
|
|
|
446
527
|
return { orphans, mode: runMode, items };
|
|
447
528
|
} finally {
|
package/extension.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://raw.githubusercontent.com/lee-to/ai-factory/2.x/schemas/extension.schema.json",
|
|
3
3
|
"name": "gcf-harness-aif-ext",
|
|
4
|
-
"version": "0.8.
|
|
4
|
+
"version": "0.8.4",
|
|
5
5
|
"description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
|
|
6
6
|
"skills": [
|
|
7
7
|
"skills/gcf-run",
|
package/package.json
CHANGED
package/workflows/ingest.js
CHANGED
|
@@ -224,8 +224,9 @@ function splitByProvenance({ aspect, units }, bodyOf) {
|
|
|
224
224
|
const cited = [];
|
|
225
225
|
const inferred = [];
|
|
226
226
|
for (const u of units) {
|
|
227
|
-
|
|
228
|
-
|
|
227
|
+
// A blank quote is in every body, even the empty one of an anchor the GDD does not have.
|
|
228
|
+
const quote = ws(u.quote ?? '');
|
|
229
|
+
if (quote && ws(bodyOf.get(u.anchor) ?? '').includes(quote)) cited.push(u);
|
|
229
230
|
else inferred.push({ ...u, aspect });
|
|
230
231
|
}
|
|
231
232
|
|