gcf-harness-aif-ext 0.8.4 → 0.8.8
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 +1 -1
- package/commands/activity.js +170 -0
- package/commands/harness.js +3 -1
- package/commands/run.js +249 -57
- package/extension.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ Removal does not touch `.claude/settings.json`: delete the `PreToolUse` entry wh
|
|
|
77
77
|
|
|
78
78
|
**`ai-factory gcf-harness gate <item-id…>`** — the `/gcf-run` gate as code. It reads one `gcf audit --json` report and exits 1 with a one-line reason when the items may not go: a core older than 0.4.0, a `schema:` other than 1 in `gcf/config.yml`, an id with no work item, a feature (run its children; the feature closes when they are finished), an item that is not `ready`, an unplayed dependency, no WIP room for the batch (items awaiting a verdict + the batch's `gate: verdict` items over `wip_built_limit`), or two items of the batch touching the same system unless both say `parallel: ok`. The tune limit is not a gate rule: `verdict tune` itself refuses at the limit, and an item tuned up to it still gets its last build. The skill runs it and stops on a non-zero exit — no model compares the numbers.
|
|
79
79
|
|
|
80
|
-
**`ai-factory gcf-harness run <item-id…> [--agent "<cmd>"]`** — runs a batch at once, each item in its own worktree (`gcf worktree <id>`) with its own agent session: `claude -p "/gcf-run <id> --worktree"` by default, or the command in `--agent` / `GCF_RUN_AGENT`. The sessions run unattended, so set the agent's permissions for print mode first. A session ends with a commit and `gcf verify <id> --mark` (the `GCF-Verified` trailer); a question for the human goes into `.gcf-session/outcome.json` in its worktree, and `run` records the block on main. Then `gcf integrate` merges the verified branches and records each move, and `run` prints the playtest session. Its first step, like `/gcf-run`'s, integrates the verified branches a dead run left. A session that ended with no result leaves its item `ready`; the next `run` resumes its worktree. Logs: `.gcf-session/runs/<id>.log
|
|
80
|
+
**`ai-factory gcf-harness run <item-id…> [--agent "<cmd>"]`** — runs a batch at once, each item in its own worktree (`gcf worktree <id>`) with its own agent session: `claude -p --output-format stream-json --verbose "/gcf-run <id> --worktree"` by default, or the command in `--agent` / `GCF_RUN_AGENT`. The sessions run unattended, so set the agent's permissions for print mode first. A session ends with a commit and `gcf verify <id> --mark` (the `GCF-Verified` trailer); a question for the human goes into `.gcf-session/outcome.json` in its worktree, and `run` records the block on main. Then `gcf integrate` merges the verified branches and records each move, and `run` prints the playtest session. Its first step, like `/gcf-run`'s, integrates the verified branches a dead run left. A session that ended with no result leaves its item `ready`; the next `run` resumes its worktree and the agent's own session (`--resume <session_id>`, kept in `.gcf-session/runs/<id>.agent.json`), in either run mode; `--fresh` starts a new session instead. A session cut off by Claude's usage limit reports `paused (usage limit, resets 3am (…))` and `limit_resets`. Logs: `.gcf-session/runs/<id>.log`, written as the session works: a `start:` line, then one stamped line per agent message, tool call (`tool: Edit <file>`, `tool: Bash <command>`) and failed tool result, and at the end the agent's final text and `done in 34m, 212 turns, $3.41 (…tokens)`; stderr goes in as it is, and a non-Claude `--agent` keeps its own lines. With `--json`, each item carries the session's `cost_usd`, `tokens`, `turns` and `duration_ms` when the agent reported them. One run drives an item at a time (`.gcf-session/runs/<id>.lock`), and a resumed worktree first gets main merged in, so the answer from an `unblock` reaches the session. A worktree sees only what is committed: keep `.ai-factory.json`, the installed extensions and `.claude/` (skills, the readset-guard hook) in git, or the session has no `/gcf-run` and no `ai-factory gcf`.
|
|
81
81
|
|
|
82
82
|
**`/gcf-run-ingest <gdd.md>`** — imports a design document as a fan-out workflow: parallel aspect extraction, provenance verified by code rather than by an agent (a quote either is a verbatim substring of its section or it is not), completeness lenses per candidate, and an interaction matrix computed from the candidates that write the same state.
|
|
83
83
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The live log of an agent session: `claude -p --output-format stream-json` prints one JSON event
|
|
2
|
+
// per line, and the run log gets one readable line per event worth seeing, so an operator can tell
|
|
3
|
+
// a working session from a stuck one. A line that is not JSON is another agent's own output.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} Cost What a session spent, from its `result` event.
|
|
7
|
+
* @property {number} cost_usd
|
|
8
|
+
* @property {{input: number, output: number, cache_read: number, cache_creation: number}} tokens
|
|
9
|
+
* @property {number} turns
|
|
10
|
+
* @property {number} duration_ms
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** `12:03:10`, the local time of day. */
|
|
14
|
+
export const clock = (now = new Date()) => now.toTimeString().slice(0, 8);
|
|
15
|
+
|
|
16
|
+
const cut = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
17
|
+
|
|
18
|
+
/** The first `n` non-empty lines of a text, trimmed, on one line. */
|
|
19
|
+
const firstLines = (text, n) =>
|
|
20
|
+
String(text)
|
|
21
|
+
.split('\n')
|
|
22
|
+
.map((l) => l.trim())
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.slice(0, n)
|
|
25
|
+
.join(' ');
|
|
26
|
+
|
|
27
|
+
// The input field that tells most about a tool call, in this order; with none, the name alone.
|
|
28
|
+
const TELLING = ['file_path', 'command', 'pattern', 'url', 'description'];
|
|
29
|
+
|
|
30
|
+
function toolLine(use) {
|
|
31
|
+
const key = TELLING.find((k) => typeof use.input?.[k] === 'string');
|
|
32
|
+
|
|
33
|
+
return key ? `tool: ${use.name} ${cut(firstLines(use.input[key], 1), 120)}` : `tool: ${use.name}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A tool result's text: a string, or a list of text blocks. */
|
|
37
|
+
const resultText = (content) =>
|
|
38
|
+
Array.isArray(content) ? content.map((c) => c?.text ?? '').join('\n') : String(content ?? '');
|
|
39
|
+
|
|
40
|
+
/** `1.2M`, `85k`, `950`: one decimal below ten of its unit. */
|
|
41
|
+
const count = (n) => (n >= 1e6 ? scaled(n / 1e6, 'M') : n >= 1e3 ? scaled(n / 1e3, 'k') : `${n}`);
|
|
42
|
+
const scaled = (v, unit) => `${v < 10 ? v.toFixed(1) : Math.round(v)}${unit}`;
|
|
43
|
+
|
|
44
|
+
/** `8s`, `34m`, `1h 12m`. */
|
|
45
|
+
function span(ms) {
|
|
46
|
+
const s = Math.round(ms / 1000);
|
|
47
|
+
if (s < 60) return `${s}s`;
|
|
48
|
+
const m = Math.round(s / 60);
|
|
49
|
+
|
|
50
|
+
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @returns {Cost} */
|
|
54
|
+
function costOf(e) {
|
|
55
|
+
const u = e.usage ?? {};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
cost_usd: Number(e.total_cost_usd ?? 0),
|
|
59
|
+
tokens: {
|
|
60
|
+
input: Number(u.input_tokens ?? 0),
|
|
61
|
+
output: Number(u.output_tokens ?? 0),
|
|
62
|
+
cache_read: Number(u.cache_read_input_tokens ?? 0),
|
|
63
|
+
cache_creation: Number(u.cache_creation_input_tokens ?? 0),
|
|
64
|
+
},
|
|
65
|
+
turns: Number(e.num_turns ?? 0),
|
|
66
|
+
duration_ms: Number(e.duration_ms ?? 0),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function summary(e, c, limited) {
|
|
71
|
+
// A limit ends with subtype `success`: name the limit, not that.
|
|
72
|
+
const end = limited
|
|
73
|
+
? 'ended (usage limit)'
|
|
74
|
+
: e.is_error
|
|
75
|
+
? `ended (${e.subtype ?? 'error'})`
|
|
76
|
+
: 'done';
|
|
77
|
+
const t = c.tokens;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
`${end} in ${span(c.duration_ms)}, ${c.turns} turns, $${c.cost_usd.toFixed(2)} ` +
|
|
81
|
+
`(in ${count(t.input)} / out ${count(t.output)} tokens, cache read ${count(t.cache_read)}, ` +
|
|
82
|
+
`cache write ${count(t.cache_creation)})`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Claude's usage-limit ending, `You've hit your session limit · resets 3am (Europe/Berlin)`:
|
|
88
|
+
* `{limit: {resets}}`, the time it names (null without one), or nothing for any other text. An
|
|
89
|
+
* error result is the session's end, so the message anywhere in it counts; an agent's text or a
|
|
90
|
+
* plain line counts only when it is that message whole: prose about some other limit is not.
|
|
91
|
+
* @returns {{limit?: {resets: string | null}}}
|
|
92
|
+
*/
|
|
93
|
+
function limitIn(text, whole = true) {
|
|
94
|
+
const s = String(text ?? '').trim();
|
|
95
|
+
// shortcut: any "You've hit your … limit" counts, so an agent's whole-line "You've hit your WIP
|
|
96
|
+
// limit" would pause its item; narrow it once Claude's wordings (Opus, weekly…) are all known.
|
|
97
|
+
const hit = whole ? /^You['’]ve hit your [^\n]*limit[^\n]*$/i : /hit your .*limit/i;
|
|
98
|
+
if (!hit.test(s)) return {};
|
|
99
|
+
|
|
100
|
+
return { limit: { resets: s.match(/resets (.+)$/im)?.[1].trim() || null } };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One stdout line of a session as log lines: an event worth seeing becomes one stamped line, the
|
|
105
|
+
* rest of the stream (hooks, init, thinking, rate limits) none; the `result` event gives the
|
|
106
|
+
* final text, a summary line and the cost. `session_id` comes with the init event, `limit` with
|
|
107
|
+
* the usage-limit message, in whatever form it arrives: the session can be resumed after either.
|
|
108
|
+
* @param {string} line
|
|
109
|
+
* @returns {{lines: string[], cost?: Cost, session_id?: string, limit?: {resets: string | null}}}
|
|
110
|
+
*/
|
|
111
|
+
export function activity(line, now = new Date()) {
|
|
112
|
+
let e;
|
|
113
|
+
try {
|
|
114
|
+
e = JSON.parse(line);
|
|
115
|
+
} catch {
|
|
116
|
+
return { lines: [line], ...limitIn(line) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (typeof e?.type !== 'string') return { lines: [line] };
|
|
120
|
+
|
|
121
|
+
const at = `[${clock(now)}]`;
|
|
122
|
+
const content = Array.isArray(e.message?.content) ? e.message.content : [];
|
|
123
|
+
if (e.type === 'system') return system(e);
|
|
124
|
+
if (e.type === 'assistant') return said(content, at);
|
|
125
|
+
if (e.type === 'user')
|
|
126
|
+
return {
|
|
127
|
+
lines: content
|
|
128
|
+
.filter((b) => b.type === 'tool_result' && b.is_error)
|
|
129
|
+
.map((b) => `${at} tool error: ${cut(firstLines(resultText(b.content), 1), 300)}`),
|
|
130
|
+
};
|
|
131
|
+
if (e.type === 'result') return ended(e, at);
|
|
132
|
+
|
|
133
|
+
return { lines: [] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A system event: the init names the session; the rest (hooks, thinking) give nothing. */
|
|
137
|
+
const system = (e) =>
|
|
138
|
+
e.subtype === 'init' && typeof e.session_id === 'string'
|
|
139
|
+
? { lines: [], session_id: e.session_id }
|
|
140
|
+
: { lines: [] };
|
|
141
|
+
|
|
142
|
+
/** An assistant turn: its tool calls and its text, which may be the usage-limit message. */
|
|
143
|
+
function said(content, at) {
|
|
144
|
+
const texts = content.filter((b) => b.type === 'text').map((b) => b.text);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
...(texts.map((t) => limitIn(t)).find((l) => l.limit) ?? {}),
|
|
148
|
+
lines: content.flatMap((b) => {
|
|
149
|
+
if (b.type === 'tool_use') return [`${at} ${toolLine(b)}`];
|
|
150
|
+
const text = b.type === 'text' ? cut(firstLines(b.text, 2), 300) : '';
|
|
151
|
+
|
|
152
|
+
return text ? [`${at} agent: ${text}`] : [];
|
|
153
|
+
}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The `result` event: the final text, a summary line, the cost, and a limit that ended it. */
|
|
158
|
+
function ended(e, at) {
|
|
159
|
+
const cost = costOf(e);
|
|
160
|
+
const text =
|
|
161
|
+
typeof e.result === 'string' && e.result.trim() ? [`${at} result:\n${e.result}`] : [];
|
|
162
|
+
// An error result counts the message anywhere in it; any other only as the whole text.
|
|
163
|
+
const limit = limitIn(e.result, !e.is_error);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
lines: [...text, `${at} ${summary(e, cost, Boolean(limit.limit))}`],
|
|
167
|
+
cost,
|
|
168
|
+
...limit,
|
|
169
|
+
};
|
|
170
|
+
}
|
package/commands/harness.js
CHANGED
|
@@ -303,7 +303,7 @@ export function register(program) {
|
|
|
303
303
|
.description('Run items at once, each in its worktree with its own agent, then integrate')
|
|
304
304
|
.option(
|
|
305
305
|
'--agent <command>',
|
|
306
|
-
'the agent command; it gets "/gcf-run <id> --worktree"',
|
|
306
|
+
'the agent command; it gets "/gcf-run <id> --worktree", or --resume <session> to go on',
|
|
307
307
|
process.env.GCF_RUN_AGENT ?? DEFAULT_AGENT,
|
|
308
308
|
)
|
|
309
309
|
.option(
|
|
@@ -312,6 +312,7 @@ export function register(program) {
|
|
|
312
312
|
String(DEFAULT_TIMEOUT_MIN),
|
|
313
313
|
)
|
|
314
314
|
.option('--mode <mode>', 'worktree | main (default: run_mode of gcf/config.yml)')
|
|
315
|
+
.option('--fresh', 'start new agent sessions: an interrupted one is not resumed')
|
|
315
316
|
.option('--json', 'machine output')
|
|
316
317
|
.action(runItems);
|
|
317
318
|
|
|
@@ -416,6 +417,7 @@ async function runItems(ids, opts) {
|
|
|
416
417
|
gate: (batch) => gate(auditReport(), batch),
|
|
417
418
|
timeoutMs: minutes * 60_000,
|
|
418
419
|
mode: opts.mode,
|
|
420
|
+
fresh: Boolean(opts.fresh),
|
|
419
421
|
});
|
|
420
422
|
if (opts.json) console.log(JSON.stringify(r, null, 2));
|
|
421
423
|
else printRun(r);
|
package/commands/run.js
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
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,
|
|
6
|
+
import { existsSync, utimesSync, writeFileSync, writeSync } from 'node:fs';
|
|
7
7
|
import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
+
import { activity, clock } from './activity.js';
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
// The event stream, not the final answer alone: the log shows the session at work as it goes.
|
|
12
|
+
export const DEFAULT_AGENT = 'claude -p --output-format stream-json --verbose';
|
|
11
13
|
|
|
12
14
|
/** The core CLI: the same ai-factory that runs this command, in the project. */
|
|
13
15
|
export function cliCore(projectDir) {
|
|
@@ -90,36 +92,104 @@ function stopWithSessions() {
|
|
|
90
92
|
return () => process.removeListener('SIGTERM', stop);
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Hand a stream to `take` line by line. Returns the flush of a last line with no newline: a
|
|
97
|
+
* stream destroyed under a tool that holds it open never ends.
|
|
98
|
+
*/
|
|
99
|
+
function byLine(stream, take) {
|
|
100
|
+
let rest = '';
|
|
101
|
+
stream.setEncoding('utf8');
|
|
102
|
+
stream.on('data', (chunk) => {
|
|
103
|
+
const lines = (rest + chunk).split('\n');
|
|
104
|
+
rest = lines.pop() ?? '';
|
|
105
|
+
for (const l of lines) take(l);
|
|
106
|
+
});
|
|
107
|
+
const flush = () => {
|
|
108
|
+
if (rest) take(rest);
|
|
109
|
+
rest = '';
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
stream.on('end', flush);
|
|
113
|
+
|
|
114
|
+
return flush;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// What `claude --resume` prints on stderr for a session it does not have.
|
|
118
|
+
const NO_SESSION = /No conversation found with session ID/;
|
|
119
|
+
|
|
93
120
|
/**
|
|
94
121
|
* One agent session on `prompt` in `dir`; resolves with its exit code, or `timeout` when it ran
|
|
95
122
|
* past `timeoutMs`: a hung session would hold the item's run lock, and every move on it, for
|
|
96
|
-
* good.
|
|
123
|
+
* good. Its stdout reaches the log through `activity`, its stderr as it is; `cost` is what a
|
|
124
|
+
* Claude session reported at its end, `session_id` the id a rerun resumes it by, `limit` the
|
|
125
|
+
* usage limit that ended it, `gone` that the session it was to resume does not exist.
|
|
126
|
+
* @returns {Promise<{code: number | string, cost?: import('./activity.js').Cost,
|
|
127
|
+
* session_id?: string, limit?: {resets: string | null}, gone: boolean}>}
|
|
97
128
|
*/
|
|
98
129
|
function startAgent(agent, prompt, dir, { fd, timeoutMs, id, onSpawn }) {
|
|
99
130
|
return new Promise((resolve) => {
|
|
100
|
-
const
|
|
131
|
+
const command = `${agent} "${prompt}"`;
|
|
132
|
+
// A line before the agent's first: the log's time says the session is alive from its start.
|
|
133
|
+
writeSync(fd, `[${clock()}] start: ${command}\n`);
|
|
134
|
+
const child = spawn(command, {
|
|
101
135
|
cwd: dir,
|
|
102
136
|
shell: true,
|
|
103
137
|
// The session's own gate must not refuse the item this run marked as running.
|
|
104
138
|
env: { ...process.env, GCF_RUN_ITEM: id },
|
|
105
139
|
detached: !WIN,
|
|
106
140
|
windowsHide: true,
|
|
107
|
-
stdio: ['ignore',
|
|
141
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
108
142
|
});
|
|
109
143
|
sessions.add(child);
|
|
110
144
|
if (child.pid) onSpawn?.(child.pid);
|
|
145
|
+
let open = true;
|
|
146
|
+
let cost;
|
|
147
|
+
let sessionId;
|
|
148
|
+
let limit;
|
|
149
|
+
let gone = false;
|
|
150
|
+
const write = (lines) => {
|
|
151
|
+
try {
|
|
152
|
+
if (open && lines.length) writeSync(fd, lines.map((l) => `${l}\n`).join(''));
|
|
153
|
+
} catch {
|
|
154
|
+
// A log that cannot be written loses its lines, not the run.
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const flushOut = byLine(child.stdout, (line) => {
|
|
159
|
+
const a = activity(line);
|
|
160
|
+
cost = a.cost ?? cost;
|
|
161
|
+
sessionId = a.session_id ?? sessionId;
|
|
162
|
+
limit = a.limit ?? limit;
|
|
163
|
+
write(a.lines);
|
|
164
|
+
});
|
|
165
|
+
const flushErr = byLine(child.stderr, (line) => {
|
|
166
|
+
gone ||= NO_SESSION.test(line);
|
|
167
|
+
write([line]);
|
|
168
|
+
});
|
|
111
169
|
const timer = setTimeout(() => {
|
|
112
170
|
killTree(child);
|
|
113
|
-
|
|
171
|
+
end('timeout');
|
|
114
172
|
}, timeoutMs);
|
|
115
|
-
|
|
173
|
+
function end(code) {
|
|
174
|
+
if (!open) return;
|
|
175
|
+
flushOut();
|
|
176
|
+
flushErr();
|
|
177
|
+
open = false;
|
|
116
178
|
sessions.delete(child);
|
|
117
179
|
clearTimeout(timer);
|
|
118
|
-
resolve(code);
|
|
119
|
-
}
|
|
180
|
+
resolve({ code, cost, session_id: sessionId, limit, gone });
|
|
181
|
+
}
|
|
120
182
|
|
|
121
183
|
child.on('error', () => end(-1));
|
|
122
|
-
|
|
184
|
+
// `close` waits for the agent's output as well: its last lines, the result, may come after
|
|
185
|
+
// its exit. A tool it left running may hold that output open: the session ends with it.
|
|
186
|
+
child.on('close', (code) => end(code ?? -1));
|
|
187
|
+
child.on('exit', () =>
|
|
188
|
+
setTimeout(() => {
|
|
189
|
+
child.stdout.destroy();
|
|
190
|
+
child.stderr.destroy();
|
|
191
|
+
}, 2000).unref(),
|
|
192
|
+
);
|
|
123
193
|
});
|
|
124
194
|
}
|
|
125
195
|
|
|
@@ -154,16 +224,22 @@ function alive(pid) {
|
|
|
154
224
|
*/
|
|
155
225
|
async function claim(logs, id) {
|
|
156
226
|
const file = path.join(logs, `${id}.lock`);
|
|
157
|
-
// Three tries: a breaker a dead run left, then the dead lock itself, is cleared on the way.
|
|
158
|
-
|
|
227
|
+
// Three tries: a breaker a dead run left, then the dead lock itself, is cleared on the way. A
|
|
228
|
+
// lock released between the create and the read spends no try, up to six rounds in all.
|
|
229
|
+
for (let attempt = 0, round = 0; attempt < 3 && round < 6; round++) {
|
|
159
230
|
if (await create(file)) return () => rm(file, { force: true });
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
231
|
+
let text;
|
|
232
|
+
try {
|
|
233
|
+
text = await readFile(file, 'utf8');
|
|
234
|
+
} catch (e) {
|
|
235
|
+
// Released meanwhile: the next create may take it. A lock that cannot be read is busy.
|
|
236
|
+
if (e.code === 'ENOENT') continue;
|
|
237
|
+
|
|
238
|
+
return null;
|
|
165
239
|
}
|
|
166
240
|
|
|
241
|
+
attempt++;
|
|
242
|
+
|
|
167
243
|
// The run's pid is the first line; a started session adds the item and its own pid.
|
|
168
244
|
const pid = Number(text.split('\n')[0]);
|
|
169
245
|
if (pid === process.pid) return () => rm(file, { force: true });
|
|
@@ -220,9 +296,14 @@ async function clearDead(file, text) {
|
|
|
220
296
|
}
|
|
221
297
|
}
|
|
222
298
|
|
|
223
|
-
|
|
299
|
+
// The item's branch, as the integrator reads it: not HEAD, which a session cut off mid-rebase
|
|
300
|
+
// leaves detached.
|
|
301
|
+
const branchRef = (id) => `refs/heads/work/${id}`;
|
|
302
|
+
|
|
303
|
+
/** Whether the tip of the item's branch carries `GCF-Verified: <id>`. */
|
|
224
304
|
function verified(dir, id) {
|
|
225
|
-
const
|
|
305
|
+
const format = '--format=%(trailers:key=GCF-Verified,valueonly)';
|
|
306
|
+
const res = spawnSync('git', ['log', '-1', format, branchRef(id)], {
|
|
226
307
|
cwd: dir,
|
|
227
308
|
encoding: 'utf8',
|
|
228
309
|
});
|
|
@@ -288,14 +369,98 @@ async function prepare({ claimed, failed }, core, agentOf) {
|
|
|
288
369
|
return { ready, failed };
|
|
289
370
|
}
|
|
290
371
|
|
|
372
|
+
/** `<id>.agent.json`: the agent session an item's last run left unfinished, for a rerun. */
|
|
373
|
+
const recordOf = (logs, id) => path.join(logs, `${id}.agent.json`);
|
|
374
|
+
|
|
375
|
+
// A session id goes into the agent's shell command: a UUID, nothing else.
|
|
376
|
+
const SESSION_ID = /^[0-9a-f-]{36}$/i;
|
|
377
|
+
|
|
378
|
+
/** The kept session of an item, or null: a file that is not a record is none. */
|
|
379
|
+
async function kept(logs, id) {
|
|
380
|
+
try {
|
|
381
|
+
const rec = JSON.parse(await readFile(recordOf(logs, id), 'utf8'));
|
|
382
|
+
|
|
383
|
+
return SESSION_ID.test(String(rec?.session_id)) ? rec : null;
|
|
384
|
+
} catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The record of a session that ended with its item still ready: a rerun resumes it with all the
|
|
391
|
+
* agent read and decided. The item's move ends the session for good; an error leaves it as is.
|
|
392
|
+
*/
|
|
393
|
+
async function remember(logs, r, item) {
|
|
394
|
+
if (item.result === 'error') return;
|
|
395
|
+
if (!STAYED.test(item.result) || r.lost) return rm(recordOf(logs, r.id), { force: true });
|
|
396
|
+
if (!r.session_id) return;
|
|
397
|
+
const ended = r.limit ? 'limit' : r.code === 'timeout' ? 'timeout' : `exit ${r.code}`;
|
|
398
|
+
const rec = {
|
|
399
|
+
session_id: r.session_id,
|
|
400
|
+
ended,
|
|
401
|
+
...(r.limit?.resets ? { resets: r.limit.resets } : {}),
|
|
402
|
+
at: new Date().toISOString(),
|
|
403
|
+
};
|
|
404
|
+
await writeFile(recordOf(logs, r.id), `${JSON.stringify(rec, null, 2)}\n`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The line of a session that ended with its item still ready: why, and what a rerun does. A
|
|
409
|
+
* usage limit pauses the item; `limit_resets` tells a board when it may go on.
|
|
410
|
+
*/
|
|
411
|
+
function noResult(r, rerun) {
|
|
412
|
+
if (r.lost)
|
|
413
|
+
return {
|
|
414
|
+
id: r.id,
|
|
415
|
+
result:
|
|
416
|
+
`no result (its session could not be resumed, agent exit ${r.code}): ` +
|
|
417
|
+
`${r.id} stays ready, a rerun starts fresh`,
|
|
418
|
+
};
|
|
419
|
+
const again = r.session_id ? 'a rerun resumes its session' : rerun;
|
|
420
|
+
if (r.limit) {
|
|
421
|
+
const resets = r.limit.resets ? `, resets ${r.limit.resets}` : '';
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
id: r.id,
|
|
425
|
+
result: `paused (usage limit${resets}): ${r.id} stays ready, ${again}`,
|
|
426
|
+
limit_resets: r.limit.resets,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const why = r.code === 'timeout' ? 'the session timed out' : `agent exit ${r.code}`;
|
|
431
|
+
|
|
432
|
+
return { id: r.id, result: `no result (${why}): ${r.id} stays ready, ${again}` };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const STAYED = / stays ready, a rerun /;
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The agent command and prompt of an item's session: `/gcf-run`, or the resume of the session
|
|
439
|
+
* its last run kept (`--resume`, in the same directory, so the agent finds it) unless `fresh`.
|
|
440
|
+
*/
|
|
441
|
+
async function commandOf(r, logs, fresh) {
|
|
442
|
+
const task = r.main ? `/gcf-run ${r.id}` : `/gcf-run ${r.id} --worktree`;
|
|
443
|
+
if (fresh) await rm(recordOf(logs, r.id), { force: true });
|
|
444
|
+
// `--resume` is a flag of `claude`: another agent gets the task, and the record stays.
|
|
445
|
+
const rec = fresh || !/^claude(\s|$)/.test(r.agent) ? null : await kept(logs, r.id);
|
|
446
|
+
if (!rec) return { agent: r.agent, prompt: task, rec };
|
|
447
|
+
// One plain line: the prompt goes into the shell command in double quotes.
|
|
448
|
+
const ended = String(rec.ended).replace(/[^\w -]/g, '');
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
agent: `${r.agent} --resume ${rec.session_id}`,
|
|
452
|
+
prompt: `Continue ${task}: the session was cut off (${ended}). Pick up where you stopped.`,
|
|
453
|
+
rec,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
291
457
|
/**
|
|
292
458
|
* One session per item, its output in `.gcf-session/runs/<id>.log`. In a worktree the skill runs
|
|
293
459
|
* its worktree mode; on main it records the item's move itself.
|
|
294
460
|
*/
|
|
295
|
-
async function session(r, logs, timeoutMs) {
|
|
461
|
+
async function session(r, logs, timeoutMs, fresh) {
|
|
296
462
|
/** @type {import('node:fs/promises').FileHandle | null} */
|
|
297
463
|
let log = null;
|
|
298
|
-
const prompt = r.main ? `/gcf-run ${r.id}` : `/gcf-run ${r.id} --worktree`;
|
|
299
464
|
// The lock names the item and its session: `stop <id>` ends that session alone, not the run.
|
|
300
465
|
const lock = path.join(logs, `${r.main ? MAIN_RUN : r.id}.lock`);
|
|
301
466
|
const onSpawn = (pid) => writeFileSync(lock, `${process.pid}\n${r.id}\n${pid}\n`);
|
|
@@ -303,10 +468,22 @@ async function session(r, logs, timeoutMs) {
|
|
|
303
468
|
const mark = path.join(logs, `${r.id}.session`);
|
|
304
469
|
const keep = r.main ? setInterval(() => touch(mark), 5 * 60_000) : null;
|
|
305
470
|
try {
|
|
471
|
+
const { agent, prompt, rec } = await commandOf(r, logs, fresh);
|
|
306
472
|
log = await open(path.join(logs, `${r.id}.log`), 'a');
|
|
307
473
|
const opts = { fd: log.fd, timeoutMs, id: r.id, onSpawn };
|
|
474
|
+
const ran = await startAgent(agent, prompt, r.dir, opts);
|
|
475
|
+
|
|
476
|
+
// A resume that claude answered with `No conversation found`: the session is gone, and the
|
|
477
|
+
// next run starts fresh rather than fail on it again. Any other early end (a stop, a crash,
|
|
478
|
+
// no network) keeps it for the next try.
|
|
479
|
+
const lost = Boolean(rec) && ran.gone;
|
|
308
480
|
|
|
309
|
-
return {
|
|
481
|
+
return {
|
|
482
|
+
...r,
|
|
483
|
+
...ran,
|
|
484
|
+
session_id: ran.session_id ?? (lost ? undefined : rec?.session_id),
|
|
485
|
+
lost,
|
|
486
|
+
};
|
|
310
487
|
} catch (e) {
|
|
311
488
|
return { ...r, code: null, error: e.message };
|
|
312
489
|
} finally {
|
|
@@ -332,7 +509,11 @@ function touch(file) {
|
|
|
332
509
|
*/
|
|
333
510
|
async function recordBlock(r, outcome, core) {
|
|
334
511
|
const reason = String(outcome.block);
|
|
335
|
-
|
|
512
|
+
// A block recorded over a trailer that stayed would see the branch merged after its unblock,
|
|
513
|
+
// without the answer: the operator gets the error instead.
|
|
514
|
+
const failed = unverify(r);
|
|
515
|
+
if (failed)
|
|
516
|
+
return { id: r.id, result: 'error', reason, error: `${failed}; the block is not recorded` };
|
|
336
517
|
const b = await core(['block', r.id, '--reason', reason]);
|
|
337
518
|
|
|
338
519
|
return {
|
|
@@ -343,40 +524,42 @@ async function recordBlock(r, outcome, core) {
|
|
|
343
524
|
};
|
|
344
525
|
}
|
|
345
526
|
|
|
527
|
+
// The bookkeeping commit's author when the worktree has none: the trailer must come off anyway.
|
|
528
|
+
const GCF_IDENT = {
|
|
529
|
+
GIT_AUTHOR_NAME: 'gcf',
|
|
530
|
+
GIT_AUTHOR_EMAIL: 'gcf@localhost',
|
|
531
|
+
GIT_COMMITTER_NAME: 'gcf',
|
|
532
|
+
GIT_COMMITTER_EMAIL: 'gcf@localhost',
|
|
533
|
+
};
|
|
534
|
+
|
|
346
535
|
/**
|
|
347
|
-
* An empty commit takes the trailer off the branch tip: no integrate merges it.
|
|
348
|
-
*
|
|
536
|
+
* An empty commit takes the trailer off the branch tip: no integrate merges it. Plumbing, not
|
|
537
|
+
* `git commit`: it never touches the index, so what a killed agent staged, its index.lock or a
|
|
538
|
+
* merge it left half done cannot stop it.
|
|
349
539
|
*/
|
|
350
540
|
function unverify(r) {
|
|
351
541
|
if (!verified(r.dir, r.id)) return null;
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (gitDir) rmSync(path.join(gitDir, 'index.lock'), { force: true });
|
|
361
|
-
const again = commit();
|
|
542
|
+
const git = (args, env = {}) =>
|
|
543
|
+
spawnSync('git', args, { cwd: r.dir, encoding: 'utf8', env: { ...process.env, ...env } });
|
|
544
|
+
const ref = branchRef(r.id);
|
|
545
|
+
const tip = git(['rev-parse', ref]).stdout.trim();
|
|
546
|
+
const tree = ['commit-tree', `${ref}^{tree}`, '-p', ref, '-m', `gcf(unverified): ${r.id}`];
|
|
547
|
+
let made = git(tree);
|
|
548
|
+
if (made.status !== 0) made = git(tree, GCF_IDENT);
|
|
549
|
+
const moved = made.status === 0 && git(['update-ref', ref, made.stdout.trim(), tip]);
|
|
362
550
|
|
|
363
551
|
// shortcut: the integrate after it still merges a branch whose trailer stayed; the report
|
|
364
552
|
// says so, upgrade to integrating only the named branches if it ever happens.
|
|
365
|
-
return
|
|
553
|
+
return moved && moved.status === 0
|
|
554
|
+
? null
|
|
555
|
+
: `could not take its trailer off: ${(moved || made).stderr.trim()}`;
|
|
366
556
|
}
|
|
367
557
|
|
|
368
558
|
/** The result line of an item the integrator handled, or why it has none. */
|
|
369
559
|
function resultOf(run, integrated) {
|
|
370
560
|
const done = integrated.find((x) => x.id === run.id);
|
|
371
561
|
if (!done && run.error) return { id: run.id, result: 'error', error: run.error };
|
|
372
|
-
if (!done)
|
|
373
|
-
const why = run.code === 'timeout' ? 'the session timed out' : `agent exit ${run.code}`;
|
|
374
|
-
|
|
375
|
-
return {
|
|
376
|
-
id: run.id,
|
|
377
|
-
result: `no result (${why}): ${run.id} stays ready, a rerun resumes its worktree`,
|
|
378
|
-
};
|
|
379
|
-
}
|
|
562
|
+
if (!done) return noResult(run, 'a rerun resumes its worktree');
|
|
380
563
|
|
|
381
564
|
return {
|
|
382
565
|
...done,
|
|
@@ -402,9 +585,8 @@ async function mainResult(core, r) {
|
|
|
402
585
|
if (!after) return { id: r.id, result: 'close' };
|
|
403
586
|
if (after.status !== 'ready')
|
|
404
587
|
return { id: r.id, result: MOVE_OF[after.status] ?? after.status, reason: after.blocked };
|
|
405
|
-
const why = r.code === 'timeout' ? 'the session timed out' : `agent exit ${r.code}`;
|
|
406
588
|
|
|
407
|
-
return
|
|
589
|
+
return noResult(r, 'a rerun picks it up');
|
|
408
590
|
}
|
|
409
591
|
|
|
410
592
|
// The lock of the main checkout itself: one run works there at a time. Not a slug, so no item.
|
|
@@ -416,7 +598,8 @@ export const MAIN_RUN = '.main-run';
|
|
|
416
598
|
* carries a session mark (`gcf claim`), not a run lock: a lock would refuse the agent's own
|
|
417
599
|
* moves. A session that stopped without a move leaves no mark behind.
|
|
418
600
|
*/
|
|
419
|
-
async function runMain(projectDir, ids,
|
|
601
|
+
async function runMain(projectDir, ids, ctx) {
|
|
602
|
+
const { logs } = ctx;
|
|
420
603
|
const releaseMain = await claim(logs, MAIN_RUN);
|
|
421
604
|
if (!releaseMain)
|
|
422
605
|
return ids.map((id) => ({
|
|
@@ -427,8 +610,7 @@ async function runMain(projectDir, ids, { core, logs, agentOf, timeoutMs }) {
|
|
|
427
610
|
|
|
428
611
|
const items = [];
|
|
429
612
|
try {
|
|
430
|
-
for (const id of ids)
|
|
431
|
-
items.push(await mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }));
|
|
613
|
+
for (const id of ids) items.push(await mainItem(projectDir, id, ctx));
|
|
432
614
|
} finally {
|
|
433
615
|
await releaseMain();
|
|
434
616
|
}
|
|
@@ -437,7 +619,7 @@ async function runMain(projectDir, ids, { core, logs, agentOf, timeoutMs }) {
|
|
|
437
619
|
}
|
|
438
620
|
|
|
439
621
|
/** One item of a main run: marked, its session, its mark cleared, its result read. */
|
|
440
|
-
async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }) {
|
|
622
|
+
async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs, fresh }) {
|
|
441
623
|
let r;
|
|
442
624
|
try {
|
|
443
625
|
const claimed = await core(['claim', id]);
|
|
@@ -445,20 +627,22 @@ async function mainItem(projectDir, id, { core, logs, agentOf, timeoutMs }) {
|
|
|
445
627
|
const none = async () => {};
|
|
446
628
|
|
|
447
629
|
const s = { id, dir: projectDir, agent: agentOf(id), release: none, main: true };
|
|
448
|
-
r = await session(s, logs, timeoutMs);
|
|
630
|
+
r = await session(s, logs, timeoutMs, fresh);
|
|
449
631
|
} catch (e) {
|
|
450
632
|
r = { id, error: e.message };
|
|
451
633
|
}
|
|
452
634
|
|
|
453
635
|
await core(['release', id]);
|
|
636
|
+
const item = await mainResult(core, r);
|
|
637
|
+
await remember(logs, r, item);
|
|
454
638
|
|
|
455
|
-
return
|
|
639
|
+
return { ...item, ...r.cost };
|
|
456
640
|
}
|
|
457
641
|
|
|
458
642
|
/** Every claimed item in its worktree at once; blocks recorded on main, verified ones merged. */
|
|
459
|
-
async function runWorktrees(claims, { core, logs, agentOf, timeoutMs }) {
|
|
643
|
+
async function runWorktrees(claims, { core, logs, agentOf, timeoutMs, fresh }) {
|
|
460
644
|
const { ready, failed } = await prepare(claims, core, agentOf);
|
|
461
|
-
const runs = await Promise.all(ready.map((r) => session(r, logs, timeoutMs)));
|
|
645
|
+
const runs = await Promise.all(ready.map((r) => session(r, logs, timeoutMs, fresh)));
|
|
462
646
|
const items = [...failed];
|
|
463
647
|
for (const r of runs) {
|
|
464
648
|
const outcome = await outcomeOf(r.dir);
|
|
@@ -473,14 +657,21 @@ async function runWorktrees(claims, { core, logs, agentOf, timeoutMs }) {
|
|
|
473
657
|
|
|
474
658
|
const integrated = json(await core(['integrate', '--json']), 'integrate');
|
|
475
659
|
for (const r of runs) if (!items.some((i) => i.id === r.id)) items.push(resultOf(r, integrated));
|
|
660
|
+
for (const r of runs)
|
|
661
|
+
await remember(
|
|
662
|
+
logs,
|
|
663
|
+
r,
|
|
664
|
+
items.find((i) => i.id === r.id),
|
|
665
|
+
);
|
|
476
666
|
|
|
477
|
-
|
|
667
|
+
// What each session spent rides on its item's line, when the agent reported it.
|
|
668
|
+
return items.map((i) => ({ ...i, ...runs.find((r) => r.id === i.id)?.cost }));
|
|
478
669
|
}
|
|
479
670
|
|
|
480
671
|
/**
|
|
481
672
|
* Pick up orphans, gate the batch, run the items in the project's run mode (`mode` overrides
|
|
482
673
|
* it). `gate(ids)` answers `{pass, reason}`; a session past `timeoutMs` is stopped with all it
|
|
483
|
-
* started, and its item stays ready.
|
|
674
|
+
* started, and its item stays ready. `fresh` starts new sessions where kept ones would resume.
|
|
484
675
|
*/
|
|
485
676
|
export async function run(
|
|
486
677
|
projectDir,
|
|
@@ -491,6 +682,7 @@ export async function run(
|
|
|
491
682
|
gate,
|
|
492
683
|
timeoutMs = DEFAULT_TIMEOUT_MIN * 60_000,
|
|
493
684
|
mode = undefined,
|
|
685
|
+
fresh = false,
|
|
494
686
|
},
|
|
495
687
|
) {
|
|
496
688
|
// An id names a lock file and goes into the agent's shell command: a slug, nothing else.
|
|
@@ -518,7 +710,7 @@ export async function run(
|
|
|
518
710
|
agent,
|
|
519
711
|
(board.items ?? []).find((i) => i.id === id),
|
|
520
712
|
);
|
|
521
|
-
const ctx = { core, logs, agentOf, timeoutMs };
|
|
713
|
+
const ctx = { core, logs, agentOf, timeoutMs, fresh };
|
|
522
714
|
const unhook = stopWithSessions();
|
|
523
715
|
try {
|
|
524
716
|
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.8.
|
|
4
|
+
"version": "0.8.8",
|
|
5
5
|
"description": "GCF orchestration harness for Claude Code: one-command card pipeline, readset guard",
|
|
6
6
|
"skills": [
|
|
7
7
|
"skills/gcf-run",
|