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 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
- for (let attempt = 0; attempt < 2; attempt++) {
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
- if (text === null) continue; // released meanwhile
161
- const pid = Number(text.trim());
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))) return;
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
- * The worktree and the agent command of each item, claimed for this run; an item that cannot
220
- * start is an error. `agentOf(id)` gives the command.
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 prepare(ids, core, logs, agentOf) {
223
- const ready = [];
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 (!release) {
228
- failed.push({ id, result: 'error', error: `${id} is already running in another run` });
229
- continue;
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
- if (verified(r.dir, r.id))
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 own worktree at once; blocks recorded on main, verified branches merged. */
393
- async function runWorktrees(ids, { core, logs, agentOf, timeoutMs }) {
394
- const { ready, failed } = await prepare(ids, core, logs, agentOf);
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(ids, ctx);
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.3",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-harness-aif-ext",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- const body = ws(bodyOf.get(u.anchor) ?? '');
228
- if (u.quote && body.includes(ws(u.quote))) cited.push(u);
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