gcf-harness-aif-ext 0.9.0 → 0.9.1
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/README.md +5 -0
- package/commands/activity.js +1 -1
- package/commands/harness.js +19 -18
- package/commands/run.js +253 -178
- package/extension.json +1 -1
- package/hooks/handoff-guard.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,6 +47,11 @@ Updating keeps the hook entry valid — it points into the extension's install d
|
|
|
47
47
|
|
|
48
48
|
0.5.1 is a fix release. During a run, `gcf/config.yml` is always readable, and a Grep inside `gcf/` reads only readset files. An import breaks a `depends:` cycle the pair checks closed and asks a D1 about it. It cuts a section longer than a segment at paragraphs, and lens and pair prompts carry a window around the quote. `/gcf-run` checks the plan against each pillar's invariant and asks each pillar's review question over the diff. Use it with core 0.4.1, which brings the parent feature into the readset.
|
|
49
49
|
|
|
50
|
+
0.9.1 is a fix release:
|
|
51
|
+
- A stopped session reads as `no result (stopped)`, and its kept record as `ended: stopped`: before, the kill's exit code (`agent exit 1` on Windows).
|
|
52
|
+
- `gcf-harness stop <id>` never kills the run: a stop before the item's session starts (or between two sessions of a handoff) leaves `.gcf-session/runs/<id>.stop`, and that session is stopped as it starts. Before, a stop then killed the whole run with the other items of its batch. A run hung before its first session (in its worktree setup) is not stopped. On main, a stop marks the item too once the run has reached it. Known limit: a stop in the moment between a worktree run's claim of the item and its clearing of an old mark is lost.
|
|
53
|
+
- A stop that comes while the session starts no longer misses it.
|
|
54
|
+
|
|
50
55
|
0.9.0, to update together with core 0.7.6:
|
|
51
56
|
- The harness's own agent runs with `--permission-mode bypassPermissions`: it may do everything but the project's deny rules, which hold in every mode. The rules come from the deny groups of `gcf/config.yml` (`ai-factory gcf permissions`, or the board): git history and uncommitted work, recursive delete, publishing, the human's GCF moves and the guards are on by default; secrets, changes outside the project and killing processes are off. `git push` is allowed. Deny rules match the command as written: they catch accidents, not a rewording, and `/gcf-run` makes a refused command a stop with `needs permission: <command>`.
|
|
52
57
|
- A run loads the project's settings and MCP servers only (`--setting-sources project,local --strict-mcp-config --mcp-config .mcp.json`): the operator's plugins, hooks and claude.ai connectors stay out. So does `env` or `apiKeyHelper` in `~/.claude/settings.json`: an operator who logs in through them, not `claude login`, names the agent with `--agent`. The deny rules and the handoff guard come in `.gcf-session/runs/agent-settings.json` (`--settings`), so a worktree gets them without a commit.
|
package/commands/activity.js
CHANGED
|
@@ -192,7 +192,7 @@ function ended(e, at) {
|
|
|
192
192
|
|
|
193
193
|
// The window of each model the session used: its subagents may run on another.
|
|
194
194
|
const windows = Object.entries(e.modelUsage ?? {})
|
|
195
|
-
.map(([m, u]) => [m, Number(u?.contextWindow)])
|
|
195
|
+
.map(([m, u]) => /** @type {[string, number]} */ ([m, Number(u?.contextWindow)]))
|
|
196
196
|
.filter(([, n]) => n > 0);
|
|
197
197
|
|
|
198
198
|
return {
|
package/commands/harness.js
CHANGED
|
@@ -329,7 +329,7 @@ export function register(program) {
|
|
|
329
329
|
});
|
|
330
330
|
harness
|
|
331
331
|
.command('stop <id>')
|
|
332
|
-
.description('Stop the
|
|
332
|
+
.description('Stop the session that works an item; the run goes on, the item stays ready')
|
|
333
333
|
.action((id) => console.log(stopRun(process.cwd(), id)));
|
|
334
334
|
}
|
|
335
335
|
|
|
@@ -360,12 +360,12 @@ function lockOf(file) {
|
|
|
360
360
|
}
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
-
/** Kill a
|
|
364
|
-
function killTree(pid
|
|
363
|
+
/** Kill a session and everything under it: a tree on Windows, its own group elsewhere. */
|
|
364
|
+
function killTree(pid) {
|
|
365
365
|
if (WIN) spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
366
366
|
else {
|
|
367
367
|
try {
|
|
368
|
-
process.kill(
|
|
368
|
+
process.kill(-pid, 'SIGKILL');
|
|
369
369
|
} catch {
|
|
370
370
|
// Gone already.
|
|
371
371
|
}
|
|
@@ -375,23 +375,24 @@ function killTree(pid, group) {
|
|
|
375
375
|
/**
|
|
376
376
|
* Stop the session that works `id`: the one its own lock names (worktree mode), or the main
|
|
377
377
|
* checkout's run when that run works this very item (main mode). Only that session goes: the
|
|
378
|
-
* run records it as
|
|
379
|
-
*
|
|
378
|
+
* run records it as stopped and goes on with the rest. The item stays ready and its work in
|
|
379
|
+
* progress is kept.
|
|
380
380
|
*/
|
|
381
381
|
export function stopRun(projectDir, id, core = cliCore(projectDir)) {
|
|
382
382
|
const runs = path.join(path.resolve(projectDir), '.gcf-session', 'runs');
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (lock
|
|
394
|
-
|
|
383
|
+
const lockFor = () => {
|
|
384
|
+
const main = lockOf(path.join(runs, `${MAIN_RUN}.lock`));
|
|
385
|
+
|
|
386
|
+
return lockOf(path.join(runs, `${id}.lock`)) ?? (main?.item === id ? main : null);
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// The mark says the session ended by a stop, and stops the next one as it starts: the lock
|
|
390
|
+
// names none before the item's first session, or between two of a handoff. Read again after
|
|
391
|
+
// the mark: a session that started meanwhile did not see it.
|
|
392
|
+
const lock = lockFor();
|
|
393
|
+
if (lock) writeFileSync(stopOf(runs, id), `${Date.now()}\n`);
|
|
394
|
+
const session = lock && lockFor()?.session;
|
|
395
|
+
if (session && alive(session)) killTree(session);
|
|
395
396
|
core(['release', id]);
|
|
396
397
|
|
|
397
398
|
return lock ? `stopped the run of ${id}; it stays ready` : `no run drives ${id}`;
|
package/commands/run.js
CHANGED
|
@@ -156,12 +156,12 @@ function killTree(child) {
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
// The sessions this run has going: a
|
|
159
|
+
// The sessions this run has going: a run that is killed (SIGTERM) takes them down with it.
|
|
160
160
|
const sessions = new Set();
|
|
161
161
|
|
|
162
162
|
/**
|
|
163
|
-
* `gcf-harness stop
|
|
164
|
-
* the run takes each down before it exits.
|
|
163
|
+
* A run that gets SIGTERM (`gcf-harness stop <id>` stops one session, never the run): its
|
|
164
|
+
* sessions lead their own process groups, so the run takes each down before it exits.
|
|
165
165
|
* Returns the unhook.
|
|
166
166
|
*/
|
|
167
167
|
function stopWithSessions() {
|
|
@@ -202,6 +202,91 @@ function byLine(stream, take) {
|
|
|
202
202
|
// What `claude --resume` prints on stderr for a session it does not have.
|
|
203
203
|
const NO_SESSION = /No conversation found with session ID/;
|
|
204
204
|
|
|
205
|
+
/** Keep what a session's activity told: the last of each, the model's window from the result. */
|
|
206
|
+
function absorb(seen, a) {
|
|
207
|
+
seen.cost = a.cost ?? seen.cost;
|
|
208
|
+
seen.session_id = a.session_id ?? seen.session_id;
|
|
209
|
+
seen.limit = a.limit ?? seen.limit;
|
|
210
|
+
seen.model = a.model ?? seen.model;
|
|
211
|
+
// The init model's window; the largest when the result does not name that model.
|
|
212
|
+
if (a.windows) seen.window = a.windows[seen.model] ?? Math.max(...Object.values(a.windows));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Write the flag the handoff guard reads into the session's checkout. */
|
|
216
|
+
function writeFlag(dir, flag) {
|
|
217
|
+
try {
|
|
218
|
+
mkdirSync(path.join(dir, '.gcf-session'), { recursive: true });
|
|
219
|
+
writeFileSync(path.join(dir, HANDOFF_FLAG), `${JSON.stringify(flag)}\n`);
|
|
220
|
+
} catch {
|
|
221
|
+
// No flag: the turns past it stop the session all the same.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* An agent session's shell: its own process group off Windows, so a stop takes all it started.
|
|
227
|
+
* The guards read the session's own checkout, not one an outer Claude session named.
|
|
228
|
+
*/
|
|
229
|
+
function spawnAgent(command, dir, env) {
|
|
230
|
+
return spawn(command, {
|
|
231
|
+
cwd: dir,
|
|
232
|
+
shell: true,
|
|
233
|
+
// GCF_RUN_ITEM: the session's own gate must not refuse the item this run marked as running.
|
|
234
|
+
env: { ...process.env, ...env, CLAUDE_PROJECT_DIR: dir },
|
|
235
|
+
detached: !WIN,
|
|
236
|
+
windowsHide: true,
|
|
237
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The handoff watch of one session (see `startAgent`): `see(a, model)` takes each activity, flags
|
|
243
|
+
* the session past its share or at a compaction, and calls `stop` once it works on past more.
|
|
244
|
+
*/
|
|
245
|
+
function handoffWatch(handoff, dir, write, stop) {
|
|
246
|
+
const w = { flagged: false, after: 0, context: 0, turn: undefined, unknown: false };
|
|
247
|
+
const unknownOnce = (model) => {
|
|
248
|
+
if (w.unknown) return;
|
|
249
|
+
w.unknown = true;
|
|
250
|
+
write([`[${clock()}] handoff: off, the window of ${model ?? 'the model'} is unknown`]);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const flag = (win, pct, compacted) => {
|
|
254
|
+
w.flagged = true;
|
|
255
|
+
writeFlag(dir, { pct, tokens: w.context, window: win, plan: handoff.plan ?? null });
|
|
256
|
+
const why = compacted
|
|
257
|
+
? 'a compaction'
|
|
258
|
+
: `context ${pct}% (${count(w.context)} of ${count(win)})`;
|
|
259
|
+
write([`[${clock()}] handoff: ${why}, the session hands over`]);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const check = (model, compacted) => {
|
|
263
|
+
const win = handoff.windowOf(model);
|
|
264
|
+
const pct = win ? Math.round((100 * w.context) / win) : null;
|
|
265
|
+
// No share is under every threshold: a session of an unknown window hands over at a compaction.
|
|
266
|
+
const share = pct ?? 0;
|
|
267
|
+
if (pct === null && !compacted) unknownOnce(model);
|
|
268
|
+
if (!w.flagged && (compacted || share >= handoff.pct)) flag(win, pct, compacted);
|
|
269
|
+
if (w.flagged && (w.after > HARD_TURNS || share >= handoff.pct + HARD_OVER)) {
|
|
270
|
+
write([`[${clock()}] handoff: the session went on past its flag, stopped`]);
|
|
271
|
+
stop();
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
state: w,
|
|
277
|
+
see(a, model) {
|
|
278
|
+
if (a.context !== undefined) {
|
|
279
|
+
w.context = a.context;
|
|
280
|
+
if (w.flagged && a.turn !== w.turn) w.after++;
|
|
281
|
+
w.turn = a.turn;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (handoff?.pct && (a.context !== undefined || a.compacted))
|
|
285
|
+
check(model, Boolean(a.compacted));
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
205
290
|
/**
|
|
206
291
|
* One agent session on `prompt` in `dir`; resolves with its exit code, or `timeout` when it ran
|
|
207
292
|
* past `timeoutMs`: a hung session would hold the item's run lock, and every move on it, for
|
|
@@ -212,40 +297,23 @@ const NO_SESSION = /No conversation found with session ID/;
|
|
|
212
297
|
* `handoff` ({pct, windowOf(model), plan}) watches the main agent's context: past `pct` of the
|
|
213
298
|
* window, or at a compaction, the session gets `.gcf-session/handoff.json`, and the handoff
|
|
214
299
|
* guard turns its tool calls into the instruction to hand over; one that keeps on working is
|
|
215
|
-
* stopped. `handoff: true` says the session was told to hand over.
|
|
300
|
+
* stopped. `handoff: true` says the session was told to hand over, `handedOver` that it did.
|
|
216
301
|
* @returns {Promise<{code: number | string, cost?: import('./activity.js').Cost,
|
|
217
302
|
* session_id?: string, limit?: {resets: string | null}, gone: boolean, handoff: boolean,
|
|
218
|
-
* model?: string, window?: number}>}
|
|
303
|
+
* handedOver: boolean, model?: string, window?: number}>}
|
|
219
304
|
*/
|
|
220
305
|
function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn, env = {}, handoff }) {
|
|
221
306
|
return new Promise((resolve) => {
|
|
222
307
|
const command = `${agent} "${prompt}"`;
|
|
223
308
|
// A line before the agent's first: the log's time says the session is alive from its start.
|
|
224
309
|
writeSync(fd, `[${clock()}] start: ${command}\n`);
|
|
225
|
-
const child =
|
|
226
|
-
cwd: dir,
|
|
227
|
-
shell: true,
|
|
228
|
-
// The session's own gate must not refuse the item this run marked as running.
|
|
229
|
-
// The guards read the session's own checkout, not one an outer Claude session named.
|
|
230
|
-
env: { ...process.env, ...env, GCF_RUN_ITEM: id, CLAUDE_PROJECT_DIR: dir },
|
|
231
|
-
detached: !WIN,
|
|
232
|
-
windowsHide: true,
|
|
233
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
234
|
-
});
|
|
310
|
+
const child = spawnAgent(command, dir, { ...env, GCF_RUN_ITEM: id });
|
|
235
311
|
sessions.add(child);
|
|
236
312
|
if (child.pid) onSpawn?.(child.pid);
|
|
237
313
|
let open = true;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
let limit;
|
|
314
|
+
/** @type {Record<string, any>} */
|
|
315
|
+
const seen = {};
|
|
241
316
|
let gone = false;
|
|
242
|
-
let model;
|
|
243
|
-
let window;
|
|
244
|
-
let context = 0;
|
|
245
|
-
let flagged = false;
|
|
246
|
-
let unknown = false;
|
|
247
|
-
let turn;
|
|
248
|
-
let after = 0;
|
|
249
317
|
const write = (lines) => {
|
|
250
318
|
try {
|
|
251
319
|
if (open && lines.length) writeSync(fd, lines.map((l) => `${l}\n`).join(''));
|
|
@@ -254,50 +322,15 @@ function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn, env = {},
|
|
|
254
322
|
}
|
|
255
323
|
};
|
|
256
324
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const pct = win ? Math.round((100 * context) / win) : null;
|
|
262
|
-
if (pct === null && !compacted && !unknown) {
|
|
263
|
-
unknown = true;
|
|
264
|
-
write([`[${clock()}] handoff: off, the window of ${model ?? 'the model'} is unknown`]);
|
|
265
|
-
}
|
|
266
|
-
if (!flagged && (compacted || (pct !== null && pct >= handoff.pct))) {
|
|
267
|
-
flagged = true;
|
|
268
|
-
const flag = { pct, tokens: context, window: win, plan: handoff.plan ?? null };
|
|
269
|
-
try {
|
|
270
|
-
mkdirSync(path.join(dir, '.gcf-session'), { recursive: true });
|
|
271
|
-
writeFileSync(path.join(dir, HANDOFF_FLAG), `${JSON.stringify(flag)}\n`);
|
|
272
|
-
} catch {
|
|
273
|
-
// No flag: the turns past it stop the session all the same.
|
|
274
|
-
}
|
|
275
|
-
const at = compacted ? 'a compaction' : `context ${pct}%`;
|
|
276
|
-
const of = win && !compacted ? ` (${count(context)} of ${count(win)})` : '';
|
|
277
|
-
write([`[${clock()}] handoff: ${at}${of}, the session hands over`]);
|
|
278
|
-
}
|
|
279
|
-
if (flagged && (after > HARD_TURNS || (pct !== null && pct >= handoff.pct + HARD_OVER))) {
|
|
280
|
-
write([`[${clock()}] handoff: the session went on past its flag, stopped`]);
|
|
281
|
-
killTree(child);
|
|
282
|
-
end('handoff');
|
|
283
|
-
}
|
|
284
|
-
};
|
|
285
|
-
|
|
325
|
+
const watch = handoffWatch(handoff, dir, write, () => {
|
|
326
|
+
killTree(child);
|
|
327
|
+
end('handoff');
|
|
328
|
+
});
|
|
286
329
|
const flushOut = byLine(child.stdout, (line) => {
|
|
287
330
|
const a = activity(line);
|
|
288
|
-
|
|
289
|
-
sessionId = a.session_id ?? sessionId;
|
|
290
|
-
limit = a.limit ?? limit;
|
|
291
|
-
model = a.model ?? model;
|
|
292
|
-
// The init model's window; the largest when the result does not name that model.
|
|
293
|
-
if (a.windows) window = a.windows[model] ?? Math.max(...Object.values(a.windows));
|
|
331
|
+
absorb(seen, a);
|
|
294
332
|
write(a.lines);
|
|
295
|
-
if (a.
|
|
296
|
-
context = a.context;
|
|
297
|
-
if (flagged && a.turn !== turn) after++;
|
|
298
|
-
turn = a.turn;
|
|
299
|
-
}
|
|
300
|
-
if (a.context !== undefined || a.compacted) watch(Boolean(a.compacted));
|
|
333
|
+
if (open) watch.see(a, seen.model);
|
|
301
334
|
});
|
|
302
335
|
const flushErr = byLine(child.stderr, (line) => {
|
|
303
336
|
gone ||= NO_SESSION.test(line);
|
|
@@ -314,20 +347,11 @@ function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn, env = {},
|
|
|
314
347
|
open = false;
|
|
315
348
|
sessions.delete(child);
|
|
316
349
|
clearTimeout(timer);
|
|
350
|
+
const { flagged, after } = watch.state;
|
|
317
351
|
// Told to hand over, and it went on past the flag: it read the guard's instruction, or the
|
|
318
352
|
// run stopped it. A flag on its last turn found a session already done.
|
|
319
353
|
const handedOver = flagged && (after > 0 || code === 'handoff');
|
|
320
|
-
resolve({
|
|
321
|
-
code,
|
|
322
|
-
cost,
|
|
323
|
-
session_id: sessionId,
|
|
324
|
-
limit,
|
|
325
|
-
gone,
|
|
326
|
-
handoff: flagged,
|
|
327
|
-
handedOver,
|
|
328
|
-
model,
|
|
329
|
-
window,
|
|
330
|
-
});
|
|
354
|
+
resolve({ ...seen, code, gone, handoff: flagged, handedOver });
|
|
331
355
|
}
|
|
332
356
|
|
|
333
357
|
child.on('error', () => end(-1));
|
|
@@ -491,8 +515,14 @@ async function claimAll(ids, logs) {
|
|
|
491
515
|
const failed = [];
|
|
492
516
|
for (const id of ids) {
|
|
493
517
|
const release = await claim(logs, id);
|
|
494
|
-
if (release)
|
|
495
|
-
|
|
518
|
+
if (!release) {
|
|
519
|
+
failed.push({ id, result: 'error', error: `${id} is already running in another run` });
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// A dead run's stop mark goes; one from now on stops the item's session as it starts.
|
|
524
|
+
await rm(stopOf(logs, id), { force: true });
|
|
525
|
+
claimed.push({ id, release });
|
|
496
526
|
}
|
|
497
527
|
|
|
498
528
|
return { claimed, failed };
|
|
@@ -519,7 +549,10 @@ async function prepare({ claimed, failed }, core, agentOf) {
|
|
|
519
549
|
return { ready, failed };
|
|
520
550
|
}
|
|
521
551
|
|
|
522
|
-
/**
|
|
552
|
+
/**
|
|
553
|
+
* `<id>.stop`: `gcf-harness stop` while the item has no session, before its first or between two
|
|
554
|
+
* of a handoff: the next one is stopped as it starts.
|
|
555
|
+
*/
|
|
523
556
|
export const stopOf = (logs, id) => path.join(logs, `${id}.stop`);
|
|
524
557
|
|
|
525
558
|
/** `<id>.agent.json`: the agent session an item's last run left unfinished, for a rerun. */
|
|
@@ -542,6 +575,19 @@ async function kept(logs, id) {
|
|
|
542
575
|
}
|
|
543
576
|
}
|
|
544
577
|
|
|
578
|
+
/**
|
|
579
|
+
* Why a kept session ended. A session that handed over is not resumed: the rerun starts fresh
|
|
580
|
+
* from the plan. So does one after a handoff that reported no id: the full session before it is
|
|
581
|
+
* never resumed.
|
|
582
|
+
*/
|
|
583
|
+
function endedOf(r) {
|
|
584
|
+
if (r.handoffPending || !r.session_id) return 'handoff';
|
|
585
|
+
if (r.limit) return 'limit';
|
|
586
|
+
if (r.stopped) return 'stopped';
|
|
587
|
+
|
|
588
|
+
return r.code === 'timeout' ? 'timeout' : `exit ${r.code}`;
|
|
589
|
+
}
|
|
590
|
+
|
|
545
591
|
/**
|
|
546
592
|
* The record of a session that ended with its item still ready: a rerun resumes it with all the
|
|
547
593
|
* agent read and decided. The item's move ends the session for good; an error leaves it as is.
|
|
@@ -550,19 +596,14 @@ async function remember(logs, r, item) {
|
|
|
550
596
|
if (item.result === 'error') return;
|
|
551
597
|
if (!STAYED.test(item.result) || r.lost) return rm(recordOf(logs, r.id), { force: true });
|
|
552
598
|
if (!r.session_id && !r.handoffPending && !r.handoffs) return;
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
? 'limit'
|
|
560
|
-
: r.code === 'timeout'
|
|
561
|
-
? 'timeout'
|
|
562
|
-
: `exit ${r.code}`;
|
|
563
|
-
const rec = {
|
|
599
|
+
await writeFile(recordOf(logs, r.id), `${JSON.stringify(recordFor(r), null, 2)}\n`);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** The `<id>.agent.json` of a session that ended with its item still ready. */
|
|
603
|
+
function recordFor(r) {
|
|
604
|
+
return {
|
|
564
605
|
...(r.session_id ? { session_id: r.session_id } : {}),
|
|
565
|
-
ended,
|
|
606
|
+
ended: endedOf(r),
|
|
566
607
|
...(r.limit?.resets ? { resets: r.limit.resets } : {}),
|
|
567
608
|
// The handoffs so far: the cap holds across runs.
|
|
568
609
|
...(r.handoffs ? { handoffs: r.handoffs } : {}),
|
|
@@ -571,7 +612,6 @@ async function remember(logs, r, item) {
|
|
|
571
612
|
...(!r.handoffPending && !r.session_id ? { counted: true } : {}),
|
|
572
613
|
at: new Date().toISOString(),
|
|
573
614
|
};
|
|
574
|
-
await writeFile(recordOf(logs, r.id), `${JSON.stringify(rec, null, 2)}\n`);
|
|
575
615
|
}
|
|
576
616
|
|
|
577
617
|
/**
|
|
@@ -593,10 +633,12 @@ function noResult(r, rerun) {
|
|
|
593
633
|
|
|
594
634
|
return {
|
|
595
635
|
id: r.id,
|
|
596
|
-
result:
|
|
636
|
+
result:
|
|
637
|
+
`paused (${limit}context handoff): ${r.id} stays ready, ` + 'a rerun goes on from the plan',
|
|
597
638
|
...(r.limit ? { limit_resets: r.limit.resets } : {}),
|
|
598
639
|
};
|
|
599
640
|
}
|
|
641
|
+
|
|
600
642
|
const again = r.session_id ? 'a rerun resumes its session' : rerun;
|
|
601
643
|
if (r.limit) {
|
|
602
644
|
const resets = r.limit.resets ? `, resets ${r.limit.resets}` : '';
|
|
@@ -608,7 +650,11 @@ function noResult(r, rerun) {
|
|
|
608
650
|
};
|
|
609
651
|
}
|
|
610
652
|
|
|
611
|
-
const why = r.
|
|
653
|
+
const why = r.stopped
|
|
654
|
+
? 'stopped'
|
|
655
|
+
: r.code === 'timeout'
|
|
656
|
+
? 'the session timed out'
|
|
657
|
+
: `agent exit ${r.code}`;
|
|
612
658
|
|
|
613
659
|
return { id: r.id, result: `no result (${why}): ${r.id} stays ready, ${again}` };
|
|
614
660
|
}
|
|
@@ -639,6 +685,110 @@ async function commandOf(r, logs, fresh) {
|
|
|
639
685
|
};
|
|
640
686
|
}
|
|
641
687
|
|
|
688
|
+
/**
|
|
689
|
+
* One agent session of the item `s.r`, its handoff flag cleared around it. `stopped` says a
|
|
690
|
+
* `gcf-harness stop` ended it.
|
|
691
|
+
*/
|
|
692
|
+
async function agentSession(s, agent, prompt) {
|
|
693
|
+
const { r, fd, deadline, launch } = s;
|
|
694
|
+
const flag = path.join(r.dir, HANDOFF_FLAG);
|
|
695
|
+
await rm(flag, { force: true });
|
|
696
|
+
const flags = launch?.flags(r.dir);
|
|
697
|
+
const handoff = launch?.handoff && { ...launch.handoff, plan: launch.planOf?.(r.id) };
|
|
698
|
+
const left = Math.max(deadline - Date.now(), 1000);
|
|
699
|
+
const opts = { fd, timeoutMs: left, id: r.id, onSpawn: s.onSpawn, env: launch?.env, handoff };
|
|
700
|
+
const ran = await startAgent(flags ? `${agent} ${flags}` : agent, prompt, r.dir, opts);
|
|
701
|
+
await rm(flag, { force: true });
|
|
702
|
+
// Between two sessions the lock names none: a stop then marks the item, not the run.
|
|
703
|
+
writeFileSync(s.lock, `${process.pid}\n${r.id}\n`);
|
|
704
|
+
if (launch?.learned) await learnWindow(s.logs, launch.learned, ran);
|
|
705
|
+
|
|
706
|
+
return { ...ran, stopped: existsSync(stopOf(s.logs, r.id)) };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Count the item's next handoff, or say the cap is reached: the item is then blocked. The cap
|
|
711
|
+
* counts each handoff once, the one a kept record still owes a session included.
|
|
712
|
+
*/
|
|
713
|
+
async function capped(count, r) {
|
|
714
|
+
if (count.handoffs < MAX_HANDOFFS) {
|
|
715
|
+
count.handoffs++;
|
|
716
|
+
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
count.limit = true;
|
|
721
|
+
// A worktree records no move: the main checkout reads the block from its outcome.
|
|
722
|
+
if (!r.main) {
|
|
723
|
+
await mkdir(path.join(r.dir, '.gcf-session'), { recursive: true });
|
|
724
|
+
const outcome = `${JSON.stringify({ block: HANDOFF_LIMIT })}\n`;
|
|
725
|
+
await writeFile(path.join(r.dir, '.gcf-session', 'outcome.json'), outcome);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Only a session that handed over as told goes on: one that saw its flag and ended its reply,
|
|
733
|
+
* or that the run stopped past it. A stop, a limit or the deadline leaves the handoff to the
|
|
734
|
+
* next run.
|
|
735
|
+
*/
|
|
736
|
+
async function goesOn(s, ran) {
|
|
737
|
+
const over = ran.handedOver && (ran.code === 0 || ran.code === 'handoff') && !ran.limit;
|
|
738
|
+
|
|
739
|
+
return over && Date.now() < s.deadline && !ran.stopped && !(await s.r.done?.());
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Whether the session `rec` kept is gone: a resume that claude answered with `No conversation
|
|
744
|
+
* found`, so the next run starts fresh rather than fail on it again. Any other early end (a stop,
|
|
745
|
+
* a crash, no network) keeps it for the next try, and the resumed session's id stands in for one
|
|
746
|
+
* its own run did not report. A fresh session after a handoff has none to borrow: the full one is
|
|
747
|
+
* never resumed.
|
|
748
|
+
*/
|
|
749
|
+
function resumeLost(rec, ran) {
|
|
750
|
+
if (!rec || rec.ended === 'handoff') return false;
|
|
751
|
+
if (ran.gone) return true;
|
|
752
|
+
ran.session_id ??= rec.session_id;
|
|
753
|
+
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* The sessions of one item: the first runs its command; while one hands over as told, a fresh
|
|
759
|
+
* one goes on from the plan, up to MAX_HANDOFFS.
|
|
760
|
+
*/
|
|
761
|
+
async function handOver(s, { agent, prompt, rec }) {
|
|
762
|
+
const { r } = s;
|
|
763
|
+
const count = { handoffs: Number(rec?.handoffs) || 0, limit: false };
|
|
764
|
+
const fromHandoff = rec?.ended === 'handoff';
|
|
765
|
+
// A record whose handoff its own run already counted (`counted`) owes none.
|
|
766
|
+
if (fromHandoff && !rec.counted && (await capped(count, r)))
|
|
767
|
+
return { code: 0, handoffs: count.handoffs, handoffLimit: true, handoffPending: false };
|
|
768
|
+
let ran = await agentSession(s, agent, prompt);
|
|
769
|
+
let cost = ran.cost;
|
|
770
|
+
const lost = resumeLost(rec, ran);
|
|
771
|
+
while ((await goesOn(s, ran)) && !(await capped(count, r))) {
|
|
772
|
+
writeSync(
|
|
773
|
+
s.fd,
|
|
774
|
+
`[${clock()}] handoff ${count.handoffs}: a fresh session goes on from the plan\n`,
|
|
775
|
+
);
|
|
776
|
+
ran = await agentSession(s, r.agent, `${taskOf(r)} --handoff`);
|
|
777
|
+
cost = addCost(cost, ran.cost);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
...ran,
|
|
782
|
+
cost,
|
|
783
|
+
handoffs: count.handoffs,
|
|
784
|
+
handoffLimit: count.limit,
|
|
785
|
+
// The session was told to hand over and no fresh one took it: the next run does.
|
|
786
|
+
// A limit there too: the rerun after the reset starts fresh, it does not resume the full one.
|
|
787
|
+
handoffPending: ran.handoff && !count.limit,
|
|
788
|
+
lost,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
642
792
|
/**
|
|
643
793
|
* One session per item, its output in `.gcf-session/runs/<id>.log`. In a worktree the skill runs
|
|
644
794
|
* its worktree mode; on main it records the item's move itself.
|
|
@@ -653,97 +803,19 @@ async function session(r, { logs, timeoutMs, fresh, launch }) {
|
|
|
653
803
|
// A stop that came while the lock named no session left only its mark: it ends this one.
|
|
654
804
|
if (existsSync(stopOf(logs, r.id))) killTree({ pid });
|
|
655
805
|
};
|
|
806
|
+
|
|
656
807
|
// On main the item carries a session mark; a stage with no gcf command must not expire it.
|
|
657
808
|
const mark = path.join(logs, `${r.id}.session`);
|
|
658
809
|
const keep = r.main ? setInterval(() => touch(mark), 5 * 60_000) : null;
|
|
659
|
-
const flag = path.join(r.dir, HANDOFF_FLAG);
|
|
660
|
-
await rm(stopOf(logs, r.id), { force: true });
|
|
661
810
|
try {
|
|
662
|
-
|
|
811
|
+
const cmd = await commandOf(r, logs, fresh);
|
|
663
812
|
log = await open(path.join(logs, `${r.id}.log`), 'a');
|
|
664
|
-
const fd = log.fd;
|
|
665
|
-
const flags = launch?.flags(r.dir);
|
|
666
|
-
const handoff = launch?.handoff && { ...launch.handoff, plan: launch.planOf?.(r.id) };
|
|
667
813
|
// One deadline for the item, however many sessions it takes.
|
|
668
814
|
const deadline = Date.now() + timeoutMs;
|
|
669
|
-
let handoffs = Number(rec?.handoffs) || 0;
|
|
670
|
-
let cost;
|
|
671
|
-
let ran = null;
|
|
672
|
-
let lost = false;
|
|
673
|
-
let handoffLimit = false;
|
|
674
|
-
const stopped = () => existsSync(stopOf(logs, r.id));
|
|
675
|
-
// The cap counts each handoff once, the one a kept record still owes a session included.
|
|
676
|
-
const capped = async () => {
|
|
677
|
-
if (handoffs < MAX_HANDOFFS) {
|
|
678
|
-
handoffs++;
|
|
679
|
-
|
|
680
|
-
return false;
|
|
681
|
-
}
|
|
682
|
-
handoffLimit = true;
|
|
683
|
-
// A worktree records no move: the main checkout reads the block from its outcome.
|
|
684
|
-
if (!r.main) {
|
|
685
|
-
await mkdir(path.join(r.dir, '.gcf-session'), { recursive: true });
|
|
686
|
-
await writeFile(
|
|
687
|
-
path.join(r.dir, '.gcf-session', 'outcome.json'),
|
|
688
|
-
`${JSON.stringify({ block: HANDOFF_LIMIT })}
|
|
689
|
-
`,
|
|
690
|
-
);
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
return true;
|
|
694
|
-
};
|
|
695
|
-
// A record whose handoff its own run already counted (`counted`) owes none.
|
|
696
|
-
const fromHandoff = rec?.ended === 'handoff';
|
|
697
|
-
const owed = fromHandoff && !rec.counted;
|
|
698
|
-
for (let first = true; !(first && owed && (await capped())); first = false) {
|
|
699
|
-
await rm(flag, { force: true });
|
|
700
|
-
const command = flags ? `${agent} ${flags}` : agent;
|
|
701
|
-
const left = Math.max(deadline - Date.now(), 1000);
|
|
702
|
-
const opts = { fd, timeoutMs: left, id: r.id, onSpawn, env: launch?.env, handoff };
|
|
703
|
-
ran = await startAgent(command, prompt, r.dir, opts);
|
|
704
|
-
await rm(flag, { force: true });
|
|
705
|
-
// Between two sessions the lock names none: a stop then marks the item, not the run.
|
|
706
|
-
writeFileSync(
|
|
707
|
-
lock,
|
|
708
|
-
`${process.pid}
|
|
709
|
-
${r.id}
|
|
710
|
-
`,
|
|
711
|
-
);
|
|
712
|
-
cost = addCost(cost, ran.cost);
|
|
713
|
-
if (launch?.learned) await learnWindow(logs, launch.learned, ran);
|
|
714
|
-
// A resume that claude answered with `No conversation found`: the session is gone, and
|
|
715
|
-
// the next run starts fresh rather than fail on it again. Any other early end (a stop, a
|
|
716
|
-
// crash, no network) keeps it for the next try.
|
|
717
|
-
if (first) lost = Boolean(rec) && !fromHandoff && ran.gone;
|
|
718
|
-
// The resumed session's id stands in for one its own run did not report; a fresh session
|
|
719
|
-
// after a handoff has none to borrow: the full one is never resumed.
|
|
720
|
-
if (first && !fromHandoff && !lost) ran.session_id ??= rec?.session_id;
|
|
721
|
-
|
|
722
|
-
// Only a session that handed over as told goes on: one that saw its flag and ended its
|
|
723
|
-
// reply, or that the run stopped past it. A stop, a limit or the deadline leaves the
|
|
724
|
-
// handoff to the next run.
|
|
725
|
-
const over = ran.handedOver && (ran.code === 0 || ran.code === 'handoff') && !ran.limit;
|
|
726
|
-
if (!over || Date.now() >= deadline || stopped() || (await r.done?.())) break;
|
|
727
|
-
if (await capped()) break;
|
|
728
|
-
writeSync(
|
|
729
|
-
fd,
|
|
730
|
-
`[${clock()}] handoff ${handoffs}: a fresh session goes on from the plan
|
|
731
|
-
`,
|
|
732
|
-
);
|
|
733
|
-
agent = r.agent;
|
|
734
|
-
prompt = `${taskOf(r)} --handoff`;
|
|
735
|
-
}
|
|
736
815
|
|
|
737
816
|
return {
|
|
738
817
|
...r,
|
|
739
|
-
...(
|
|
740
|
-
cost,
|
|
741
|
-
handoffs,
|
|
742
|
-
handoffLimit,
|
|
743
|
-
// The session was told to hand over and no fresh one took it: the next run does.
|
|
744
|
-
// A limit there too: the rerun after the reset starts fresh, it does not resume the full one.
|
|
745
|
-
handoffPending: Boolean(ran?.handoff) && !handoffLimit,
|
|
746
|
-
lost,
|
|
818
|
+
...(await handOver({ r, logs, lock, fd: log.fd, deadline, launch, onSpawn }, cmd)),
|
|
747
819
|
};
|
|
748
820
|
} catch (e) {
|
|
749
821
|
return { ...r, code: null, error: e.message };
|
|
@@ -855,7 +927,7 @@ async function launchOf(logs, managed, board) {
|
|
|
855
927
|
windowOf: (model) => windowFor(model, ctx.window, learned),
|
|
856
928
|
},
|
|
857
929
|
learned,
|
|
858
|
-
planOf: () => null,
|
|
930
|
+
planOf: (id) => board.items?.find((i) => i.id === id)?.plan_file ?? null,
|
|
859
931
|
};
|
|
860
932
|
}
|
|
861
933
|
|
|
@@ -912,10 +984,14 @@ async function runMain(projectDir, ids, ctx) {
|
|
|
912
984
|
async function mainItem(projectDir, id, ctx) {
|
|
913
985
|
const { core, logs, agentOf } = ctx;
|
|
914
986
|
let r;
|
|
987
|
+
await rm(stopOf(logs, id), { force: true });
|
|
988
|
+
// The checkout's lock names the item from now on: a stop before its session marks it.
|
|
989
|
+
writeFileSync(path.join(logs, `${MAIN_RUN}.lock`), `${process.pid}\n${id}\n`);
|
|
915
990
|
try {
|
|
916
991
|
const claimed = await core(['claim', id]);
|
|
917
992
|
if (!claimed.ok) throw new Error((claimed.err || claimed.out).trim());
|
|
918
993
|
const none = async () => {};
|
|
994
|
+
|
|
919
995
|
// The agent recorded the item's move: no fresh session takes it over.
|
|
920
996
|
// A board that cannot be read is no move: the run goes on, and the result read says why.
|
|
921
997
|
const done = async () => {
|
|
@@ -1023,7 +1099,6 @@ export async function run(
|
|
|
1023
1099
|
const itemOf = (id) => (board.items ?? []).find((i) => i.id === id);
|
|
1024
1100
|
const agentOf = (id) => agentFor(agent, itemOf(id));
|
|
1025
1101
|
const ctx = { core, logs, agentOf, timeoutMs, fresh, launch };
|
|
1026
|
-
launch.planOf = (id) => itemOf(id)?.plan_file ?? null;
|
|
1027
1102
|
const unhook = stopWithSessions();
|
|
1028
1103
|
try {
|
|
1029
1104
|
const items =
|
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.9.
|
|
4
|
+
"version": "0.9.1",
|
|
5
5
|
"description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
|
|
6
6
|
"skills": [
|
|
7
7
|
"skills/gcf-run",
|
package/hooks/handoff-guard.js
CHANGED
|
@@ -57,6 +57,7 @@ export function readFlag(projectDir) {
|
|
|
57
57
|
} catch {
|
|
58
58
|
return null;
|
|
59
59
|
}
|
|
60
|
+
|
|
60
61
|
try {
|
|
61
62
|
return JSON.parse(text) ?? {};
|
|
62
63
|
} catch {
|
|
@@ -67,12 +68,14 @@ export function readFlag(projectDir) {
|
|
|
67
68
|
// Hook entry: only runs when executed directly, so tests can import decide().
|
|
68
69
|
const entry = process.argv[1]?.replaceAll('\\', '/');
|
|
69
70
|
if (import.meta.url === `file://${entry}` || entry?.endsWith('handoff-guard.js')) {
|
|
71
|
+
/** @type {Record<string, any> | null} */
|
|
70
72
|
let input = null;
|
|
71
73
|
try {
|
|
72
74
|
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
73
75
|
} catch {
|
|
74
76
|
// No input: nothing to judge.
|
|
75
77
|
}
|
|
78
|
+
|
|
76
79
|
// The directory the session started in: `cwd` follows a `cd` of the agent.
|
|
77
80
|
const projectDir = process.env.CLAUDE_PROJECT_DIR ?? input?.cwd ?? process.cwd();
|
|
78
81
|
const verdict = decide(input, projectDir, readFlag(projectDir));
|