@andromarces/agent-loops 0.2.1 → 0.3.0
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 +194 -114
- package/docs/orchestrator-instructions.md +25 -22
- package/package.json +2 -2
- package/src/agents/agy.mjs +2 -11
- package/src/agents/codex.mjs +3 -20
- package/src/agents/copilot.mjs +8 -16
- package/src/agents/opencode.mjs +2 -7
- package/src/agents/shared.mjs +29 -0
- package/src/cli.mjs +46 -25
- package/src/entrypoints/copilot.mjs +6 -1
- package/src/hook/antigravity-parent-guard.mjs +28 -0
- package/src/hook/copilot-parent-guard.mjs +5 -30
- package/src/hook/decision.mjs +59 -6
- package/src/hook/opencode-plugin.mjs +92 -0
- package/src/hook/parent-guard.mjs +8 -33
- package/src/install/commands.mjs +268 -0
- package/src/install/fsutil.mjs +159 -0
- package/src/install/harnesses.mjs +170 -0
- package/src/install/installer.mjs +688 -0
- package/src/install/manifest.mjs +222 -0
- package/src/install/settings.mjs +217 -0
- package/src/install/templates/antigravity/agent-loop-antigravity-parent-guard.mjs +14 -0
- package/src/install/templates/antigravity/hooks.json +16 -0
- package/src/install/templates/antigravity/skills/agent-loop/SKILL.md +24 -0
- package/src/install/templates/claude/skills/agent-loop/SKILL.md +33 -0
- package/src/install/templates/codex/skills/agent-loop/SKILL.md +30 -0
- package/src/install/templates/codex/skills/agent-loop/agents/openai.yaml +2 -0
- package/src/install/templates/copilot/hooks/parent-guard.json +15 -0
- package/src/install/templates/opencode/plugins/parent-guard.ts +11 -0
- package/src/lib/args.mjs +23 -0
- package/src/lib/hash.mjs +9 -0
- package/src/lib/log.mjs +18 -3
- package/src/lib/process-ancestry.mjs +104 -0
- package/src/lib/runstate.mjs +133 -38
- package/src/lib/snapshot.mjs +3 -7
- package/src/role.mjs +71 -87
- package/src/runtime.mjs +9 -9
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Process-ancestry harness detection (#139). A nested harness inherits the
|
|
2
|
+
// parent's session variables, so no environment variable can identify the
|
|
3
|
+
// running harness. The nearest harness process above the shell command can.
|
|
4
|
+
// Returns null when the platform, the process table, or the ancestry is absent,
|
|
5
|
+
// so the Claude, Codex, and Antigravity skills refuse to start instead of
|
|
6
|
+
// registering the wrong parent.
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
// Process basename (lowercased, extension stripped) to harness id.
|
|
13
|
+
const HARNESS_BY_PROCESS = new Map([
|
|
14
|
+
["claude", "claude"],
|
|
15
|
+
["codex", "codex"],
|
|
16
|
+
["copilot", "copilot"],
|
|
17
|
+
["opencode", "opencode"],
|
|
18
|
+
["agy", "antigravity"],
|
|
19
|
+
["antigravity", "antigravity"],
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export function harnessForProcessName(name) {
|
|
23
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const normalized = name
|
|
27
|
+
.trim()
|
|
28
|
+
.split(/[\\/]/)
|
|
29
|
+
.pop()
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/\.(exe|cmd|bat|com)$/, "");
|
|
32
|
+
return HARNESS_BY_PROCESS.get(normalized) ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readWindowsProcesses() {
|
|
36
|
+
const script =
|
|
37
|
+
"Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Json -Compress";
|
|
38
|
+
const { stdout } = await execFileAsync(
|
|
39
|
+
"powershell.exe",
|
|
40
|
+
["-NoProfile", "-NonInteractive", "-Command", script],
|
|
41
|
+
{ windowsHide: true, maxBuffer: 32 * 1024 * 1024 },
|
|
42
|
+
);
|
|
43
|
+
const parsed = JSON.parse(stdout.trim() || "[]");
|
|
44
|
+
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
|
45
|
+
return rows.map((row) => ({
|
|
46
|
+
pid: row.ProcessId,
|
|
47
|
+
ppid: row.ParentProcessId,
|
|
48
|
+
name: row.Name,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readPosixProcesses() {
|
|
53
|
+
const { stdout } = await execFileAsync("ps", ["-eo", "pid=,ppid=,comm="], {
|
|
54
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
55
|
+
});
|
|
56
|
+
return stdout
|
|
57
|
+
.split("\n")
|
|
58
|
+
.map((line) => line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/))
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.map((match) => ({ pid: Number(match[1]), ppid: Number(match[2]), name: match[3] }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function readProcessTable() {
|
|
64
|
+
return process.platform === "win32" ? readWindowsProcesses() : readPosixProcesses();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Walks from `startPid` to the first process that is a known harness.
|
|
69
|
+
* @returns {Promise<string|null>} the harness id, or null when ancestry is absent
|
|
70
|
+
*/
|
|
71
|
+
export async function nearestHarness({
|
|
72
|
+
startPid = process.ppid,
|
|
73
|
+
readProcesses = readProcessTable,
|
|
74
|
+
} = {}) {
|
|
75
|
+
if (!Number.isInteger(Number(startPid)) || Number(startPid) <= 0) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const byPid = new Map();
|
|
79
|
+
for (const entry of await readProcesses()) {
|
|
80
|
+
byPid.set(String(entry.pid), entry);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
let pid = String(startPid);
|
|
85
|
+
while (pid && !seen.has(pid)) {
|
|
86
|
+
seen.add(pid);
|
|
87
|
+
const entry = byPid.get(pid);
|
|
88
|
+
if (!entry) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const harness = harnessForProcessName(entry.name);
|
|
92
|
+
if (harness) {
|
|
93
|
+
return harness;
|
|
94
|
+
}
|
|
95
|
+
if (entry.ppid === undefined || entry.ppid === null) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
pid = String(entry.ppid);
|
|
99
|
+
if (pid === "0" || pid === "1") {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
package/src/lib/runstate.mjs
CHANGED
|
@@ -3,13 +3,18 @@
|
|
|
3
3
|
// resolved work tree cwd, never passed as a flag; tests override the runs
|
|
4
4
|
// root with AGENT_LOOP_RUNS_ROOT.
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
-
import {
|
|
6
|
+
import { link, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
|
-
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
9
9
|
import { logWarn } from "./log.mjs";
|
|
10
10
|
|
|
11
11
|
export const TERMINAL_LIFECYCLES = new Set(["halted", "finished", "aborted"]);
|
|
12
12
|
|
|
13
|
+
// An unparseable lock younger than this is never treated as stale: a fresh
|
|
14
|
+
// unreadable lock is a contender racing a removal, a foreign file, or (on the
|
|
15
|
+
// exclusive-create fallback path) a half-written owner.
|
|
16
|
+
export const STALE_LOCK_GRACE_MS = 60_000;
|
|
17
|
+
|
|
13
18
|
/**
|
|
14
19
|
* Resolve the state paths for one run. `cwd` derives the per-work-tree state
|
|
15
20
|
* directory; `parentSession` derives the session index entry that the #57 hook
|
|
@@ -54,8 +59,24 @@ function canonicalCwd(cwd) {
|
|
|
54
59
|
return resolved.replace(/^[A-Za-z]:/, (drive) => drive.toLowerCase());
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
// A parent session id is one path segment under <root>/sessions and is matched
|
|
63
|
+
// verbatim against the harness session id by the #57 guard. Real harness ids
|
|
64
|
+
// are opaque tokens, but an unexpanded template (`${CLAUDE_SESSION_ID}`,
|
|
65
|
+
// `%CODEX_THREAD_ID%`, `<parent-session-id>`), a path separator, or whitespace
|
|
66
|
+
// can only come from a caller that failed to expand its placeholder. Any of
|
|
67
|
+
// them would register a run whose parent never matches, so refuse all of them
|
|
68
|
+
// before a state file exists. A bare placeholder name with no punctuation
|
|
69
|
+
// (`CLAUDE_SESSION_ID`) is indistinguishable from a real token here; only a
|
|
70
|
+
// harness-id allowlist would catch it, which ADR 0006 rejects.
|
|
71
|
+
const SESSION_ID_FORBIDDEN = /[\\/\0\s$`{}%<>]/;
|
|
72
|
+
|
|
57
73
|
function assertSessionId(sessionId) {
|
|
58
|
-
if (
|
|
74
|
+
if (
|
|
75
|
+
!sessionId ||
|
|
76
|
+
sessionId === "." ||
|
|
77
|
+
sessionId === ".." ||
|
|
78
|
+
SESSION_ID_FORBIDDEN.test(sessionId)
|
|
79
|
+
) {
|
|
59
80
|
throw new Error(`Invalid session id: ${JSON.stringify(sessionId ?? null)}`);
|
|
60
81
|
}
|
|
61
82
|
}
|
|
@@ -81,13 +102,17 @@ export async function readStateForSession(parentSession) {
|
|
|
81
102
|
}
|
|
82
103
|
|
|
83
104
|
/**
|
|
84
|
-
* Exclusive access around one state-file operation. Creates `state.lock`
|
|
85
|
-
*
|
|
86
|
-
*
|
|
105
|
+
* Exclusive access around one state-file operation. Creates `state.lock` (an
|
|
106
|
+
* atomic hard link where supported, otherwise an exclusive create), treats an
|
|
107
|
+
* existing lock with a live owner pid as busy and a dead one as stale
|
|
108
|
+
* (removed with a warning, then retried). Returns the result of `fn`. `label`
|
|
109
|
+
* names the guarded resource in a refusal, so an installer refusal can say what
|
|
110
|
+
* is locked instead of the generic default; `noun` is the same resource as a
|
|
111
|
+
* lowercase phrase for the stale-removal warning.
|
|
87
112
|
*/
|
|
88
|
-
export async function withStateLock(lockFile, fn) {
|
|
113
|
+
export async function withStateLock(lockFile, fn, { label = "State", noun = "state" } = {}) {
|
|
89
114
|
await mkdir(dirname(lockFile), { recursive: true });
|
|
90
|
-
await acquireLock(lockFile);
|
|
115
|
+
await acquireLock(lockFile, { label, noun });
|
|
91
116
|
try {
|
|
92
117
|
return await fn();
|
|
93
118
|
} finally {
|
|
@@ -95,47 +120,121 @@ export async function withStateLock(lockFile, fn) {
|
|
|
95
120
|
}
|
|
96
121
|
}
|
|
97
122
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
123
|
+
// Monotonic suffix for lock temp files; keeps same-process contenders that
|
|
124
|
+
// share a pid on distinct paths so one's cleanup never removes another's.
|
|
125
|
+
let lockTempCounter = 0;
|
|
126
|
+
|
|
127
|
+
// `link` is the atomic create primitive, but FAT/exFAT and some network mounts
|
|
128
|
+
// have no hard links and report one of these codes. They fall back to an
|
|
129
|
+
// exclusive create, where the pre-#176 create/write window returns.
|
|
130
|
+
// EISDIR is the Windows mapping: libuv translates the ERROR_INVALID_FUNCTION
|
|
131
|
+
// from CreateHardLinkW on FAT/exFAT to EISDIR (nodejs/node#65817).
|
|
132
|
+
const LINK_UNSUPPORTED = new Set([
|
|
133
|
+
"EPERM",
|
|
134
|
+
"EACCES",
|
|
135
|
+
"ENOTSUP",
|
|
136
|
+
"EOPNOTSUPP",
|
|
137
|
+
"EINVAL",
|
|
138
|
+
"ENOSYS",
|
|
139
|
+
"EMLINK",
|
|
140
|
+
"EXDEV",
|
|
141
|
+
"EISDIR",
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
// Matches the `<pid>.<counter>.tmp` suffix of a lock temp file name.
|
|
145
|
+
const LOCK_TEMP_SUFFIX = /^(\d+)\.\d+\.tmp$/;
|
|
146
|
+
|
|
147
|
+
async function acquireLock(lockFile, { retry = true, label = "State", noun = "state" } = {}) {
|
|
148
|
+
if (await createLock(lockFile)) {
|
|
149
|
+
// Best-effort removal of temp files left by a crash between the temp write
|
|
150
|
+
// and the link (#178). Runs while the lock is held and never touches a live
|
|
151
|
+
// contender's temp, so it cannot break a racing acquisition.
|
|
152
|
+
await pruneStaleLockTemps(lockFile);
|
|
112
153
|
return;
|
|
113
|
-
} catch (err) {
|
|
114
|
-
if (err.code !== "EEXIST") {
|
|
115
|
-
throw err;
|
|
116
|
-
}
|
|
117
154
|
}
|
|
118
155
|
|
|
119
156
|
const owner = await readLockOwner(lockFile);
|
|
120
157
|
if (owner && pidAlive(owner.pid)) {
|
|
121
158
|
throw new Error(
|
|
122
|
-
|
|
159
|
+
`${label} is locked by a live process (pid ${owner.pid}, started ${owner.startedAt ?? "unknown"}).`,
|
|
123
160
|
);
|
|
124
161
|
}
|
|
125
162
|
|
|
126
163
|
if (!owner && (await lockAgeMs(lockFile)) < STALE_LOCK_GRACE_MS) {
|
|
127
|
-
// Unparseable and fresh:
|
|
128
|
-
//
|
|
129
|
-
|
|
164
|
+
// Unparseable and fresh: fail closed. On a link-capable filesystem creation
|
|
165
|
+
// is atomic, so this is a removal race (lockAgeMs reads 0 on ENOENT) or a
|
|
166
|
+
// foreign file; the exclusive-create fallback can leave a half-written
|
|
167
|
+
// owner, which this refusal also covers.
|
|
168
|
+
throw new Error(`${label} is locked (the lock file is not readable yet; retry shortly).`);
|
|
130
169
|
}
|
|
131
170
|
|
|
132
171
|
if (!retry) {
|
|
133
|
-
throw new Error(
|
|
172
|
+
throw new Error(`${label} lock could not be acquired after stale removal.`);
|
|
134
173
|
}
|
|
135
174
|
|
|
136
|
-
logWarn(`removing stale
|
|
175
|
+
logWarn(`removing stale ${noun} lock (dead pid ${owner?.pid ?? "unknown"})`);
|
|
137
176
|
await rm(lockFile, { force: true });
|
|
138
|
-
return acquireLock(lockFile, false);
|
|
177
|
+
return acquireLock(lockFile, { retry: false, label, noun });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Create the lock and its owner content. The owner JSON goes to a private temp
|
|
181
|
+
// file that is hard linked to the lock path; the link is atomic, so EEXIST
|
|
182
|
+
// means a contender won and the owner is readable the instant the lock exists
|
|
183
|
+
// (fixes #176 path 1). On a filesystem with no hard links, an exclusive create
|
|
184
|
+
// and write keeps the lock usable at the cost of that window. Returns false
|
|
185
|
+
// only when another owner already holds the lock.
|
|
186
|
+
async function createLock(lockFile) {
|
|
187
|
+
const owner = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
188
|
+
const tempFile = `${lockFile}.${process.pid}.${lockTempCounter++}.tmp`;
|
|
189
|
+
await writeFile(tempFile, owner, "utf8");
|
|
190
|
+
try {
|
|
191
|
+
await link(tempFile, lockFile);
|
|
192
|
+
return true;
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (err.code === "EEXIST") {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
if (!LINK_UNSUPPORTED.has(err.code)) {
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
await writeFile(lockFile, owner, { encoding: "utf8", flag: "wx" });
|
|
202
|
+
return true;
|
|
203
|
+
} catch (openErr) {
|
|
204
|
+
if (openErr.code === "EEXIST") {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
throw openErr;
|
|
208
|
+
}
|
|
209
|
+
} finally {
|
|
210
|
+
await rm(tempFile, { force: true });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Removes lock temp files whose creating pid is gone. A live contender's temp
|
|
215
|
+
// is never touched, so a concurrent acquisition is unaffected; failures are
|
|
216
|
+
// ignored because cleanup is best-effort and must not fail the lock holder.
|
|
217
|
+
async function pruneStaleLockTemps(lockFile) {
|
|
218
|
+
try {
|
|
219
|
+
const dir = dirname(lockFile);
|
|
220
|
+
const prefix = `${basename(lockFile)}.`;
|
|
221
|
+
for (const entry of await readdir(dir)) {
|
|
222
|
+
if (!entry.startsWith(prefix)) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const match = LOCK_TEMP_SUFFIX.exec(entry.slice(prefix.length));
|
|
226
|
+
if (match === null) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const pid = Number(match[1]);
|
|
230
|
+
if (pid === process.pid || pidAlive(pid)) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
await rm(join(dir, entry), { force: true });
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// The lock is already held; a failed scan or unlink leaves only a temp file.
|
|
237
|
+
}
|
|
139
238
|
}
|
|
140
239
|
|
|
141
240
|
export function pidAlive(pid) {
|
|
@@ -159,10 +258,6 @@ async function readLockOwner(lockFile) {
|
|
|
159
258
|
}
|
|
160
259
|
}
|
|
161
260
|
|
|
162
|
-
// An unparseable lock younger than this is assumed to be a contender still
|
|
163
|
-
// between create and content write; only an older one is stale.
|
|
164
|
-
export const STALE_LOCK_GRACE_MS = 60_000;
|
|
165
|
-
|
|
166
261
|
async function lockAgeMs(lockFile) {
|
|
167
262
|
try {
|
|
168
263
|
const stats = await stat(lockFile);
|
|
@@ -200,7 +295,7 @@ export async function writeState(stateFile, state) {
|
|
|
200
295
|
await rename(temp, stateFile);
|
|
201
296
|
}
|
|
202
297
|
|
|
203
|
-
export async function
|
|
298
|
+
export async function writeSessionIndex(sessionIndexFile, stateFile) {
|
|
204
299
|
// The index entry is overwritten by the next init call from the same session.
|
|
205
300
|
await mkdir(dirname(sessionIndexFile), { recursive: true });
|
|
206
301
|
return writeFile(sessionIndexFile, `${stateFile}\n`, "utf8");
|
package/src/lib/snapshot.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { readFile, stat } from "node:fs/promises";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { execa } from "execa";
|
|
4
|
+
import { sha256 } from "./hash.mjs";
|
|
5
5
|
import { logDebug, logError } from "./log.mjs";
|
|
6
6
|
|
|
7
7
|
export class MutationError extends Error {
|
|
@@ -34,10 +34,6 @@ export async function assertGitWorkTree(cwd) {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function sha256(data) {
|
|
38
|
-
return createHash("sha256").update(data).digest("hex");
|
|
39
|
-
}
|
|
40
|
-
|
|
41
37
|
// Returns null only for an absent or non-regular file; read errors other than ENOENT propagate.
|
|
42
38
|
export async function sha256File(path) {
|
|
43
39
|
let s;
|
|
@@ -141,8 +137,8 @@ export async function snapshot(cwd) {
|
|
|
141
137
|
/**
|
|
142
138
|
* Diff two snapshots. Returns the sorted changed work-tree paths, plus the
|
|
143
139
|
* sentinel entries `<index>` and `<HEAD>` when the index or HEAD changed.
|
|
144
|
-
* @param {ReturnType<typeof snapshot
|
|
145
|
-
* @param {ReturnType<typeof snapshot
|
|
140
|
+
* @param {Awaited<ReturnType<typeof snapshot>>} before
|
|
141
|
+
* @param {Awaited<ReturnType<typeof snapshot>>} after
|
|
146
142
|
* @returns {string[]}
|
|
147
143
|
*/
|
|
148
144
|
export function diffSnapshots(before, after) {
|
package/src/role.mjs
CHANGED
|
@@ -6,19 +6,24 @@ import { readFile, appendFile, rename } from "node:fs/promises";
|
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
7
7
|
import { defaultAgents, normalizeAgent, supportedAgents } from "./agents/index.mjs";
|
|
8
8
|
import {
|
|
9
|
+
CHILD_ROLE_KINDS,
|
|
10
|
+
DEFAULT_MAX_STEPS,
|
|
11
|
+
DEFAULT_TIMEOUT,
|
|
12
|
+
ROLE_FLAG_BY_OPTION,
|
|
9
13
|
assertOpenCodeOptions,
|
|
10
14
|
readArgValue,
|
|
11
15
|
readNonNegativeInt,
|
|
12
16
|
readPositiveInt,
|
|
17
|
+
roleFlags,
|
|
13
18
|
} from "./lib/args.mjs";
|
|
14
19
|
import { logInfo, setVerbose, setLogsToStderr } from "./lib/log.mjs";
|
|
15
20
|
import { parseReportBlock, parseVerdict } from "./lib/report.mjs";
|
|
16
21
|
import {
|
|
17
22
|
TERMINAL_LIFECYCLES,
|
|
18
|
-
appendSessionIndex,
|
|
19
23
|
readState,
|
|
20
24
|
statePaths,
|
|
21
25
|
withStateLock,
|
|
26
|
+
writeSessionIndex,
|
|
22
27
|
writeState,
|
|
23
28
|
} from "./lib/runstate.mjs";
|
|
24
29
|
import { assertGitWorkTree } from "./lib/snapshot.mjs";
|
|
@@ -27,9 +32,8 @@ import { validateAction } from "./contracts/orchestrator-action.mjs";
|
|
|
27
32
|
|
|
28
33
|
const OPERATIONS = new Set(["dispatch", "finish", "abort"]);
|
|
29
34
|
const MODES = new Set(["work-first", "review-first", "review-only"]);
|
|
30
|
-
const ROLE_NAMES = new Set(
|
|
31
|
-
const
|
|
32
|
-
const DEFAULT_TIMEOUT = 3600;
|
|
35
|
+
const ROLE_NAMES = new Set(CHILD_ROLE_KINDS);
|
|
36
|
+
const ROLE_FLAGS = roleFlags(CHILD_ROLE_KINDS);
|
|
33
37
|
// Bound for `raw` in the envelope when the closing block could not be parsed.
|
|
34
38
|
const RAW_TAIL_LIMIT = 2000;
|
|
35
39
|
|
|
@@ -107,26 +111,6 @@ export function parseRoleArgs(argv) {
|
|
|
107
111
|
args.parentSession = readValue(arg, ++index);
|
|
108
112
|
break;
|
|
109
113
|
|
|
110
|
-
case "--worker":
|
|
111
|
-
args.worker = readValue(arg, ++index);
|
|
112
|
-
break;
|
|
113
|
-
case "--worker-model":
|
|
114
|
-
args.workerModel = readValue(arg, ++index);
|
|
115
|
-
break;
|
|
116
|
-
case "--worker-effort":
|
|
117
|
-
args.workerEffort = readValue(arg, ++index);
|
|
118
|
-
break;
|
|
119
|
-
|
|
120
|
-
case "--reviewer":
|
|
121
|
-
args.reviewer = readValue(arg, ++index);
|
|
122
|
-
break;
|
|
123
|
-
case "--reviewer-model":
|
|
124
|
-
args.reviewerModel = readValue(arg, ++index);
|
|
125
|
-
break;
|
|
126
|
-
case "--reviewer-effort":
|
|
127
|
-
args.reviewerEffort = readValue(arg, ++index);
|
|
128
|
-
break;
|
|
129
|
-
|
|
130
114
|
case "--max-steps":
|
|
131
115
|
args.maxSteps = readPositiveInt(arg, readValue(arg, ++index));
|
|
132
116
|
break;
|
|
@@ -159,7 +143,11 @@ export function parseRoleArgs(argv) {
|
|
|
159
143
|
break;
|
|
160
144
|
|
|
161
145
|
default:
|
|
162
|
-
|
|
146
|
+
if (!Object.hasOwn(ROLE_FLAGS, arg)) {
|
|
147
|
+
throw new RoleError(`Unknown argument: ${arg}`);
|
|
148
|
+
}
|
|
149
|
+
args[ROLE_FLAGS[arg]] = readValue(arg, ++index);
|
|
150
|
+
break;
|
|
163
151
|
}
|
|
164
152
|
}
|
|
165
153
|
|
|
@@ -175,9 +163,8 @@ function isInitCall(args) {
|
|
|
175
163
|
return args.task !== null;
|
|
176
164
|
}
|
|
177
165
|
|
|
178
|
-
/**
|
|
179
|
-
|
|
180
|
-
const state = await readState(paths.stateFile);
|
|
166
|
+
/** Rejects a non-init call when no state file exists for the work tree. */
|
|
167
|
+
function requireState(state, cwd) {
|
|
181
168
|
if (!state) {
|
|
182
169
|
throw new RoleError(
|
|
183
170
|
`No run state for ${cwd}. Start one with: agent-loop role --task "..." --worker ... --reviewer ...`,
|
|
@@ -190,9 +177,17 @@ function validateInitFlags(args, agents = {}) {
|
|
|
190
177
|
if (args.task === null || String(args.task).trim() === "") {
|
|
191
178
|
throw new RoleError('Init requires --task, for example --task "Implement the change."');
|
|
192
179
|
}
|
|
180
|
+
// The parent guard is the hard backstop for the prompt-only parent rule, so an
|
|
181
|
+
// interactive run is never left unguarded by default. The headless `agent-loop`
|
|
182
|
+
// command (no subcommand) is the explicit unguarded path.
|
|
183
|
+
if (args.parentSession === null) {
|
|
184
|
+
throw new RoleError(
|
|
185
|
+
"Init requires --parent-session (the harness session id the parent-edit guard matches).",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
193
188
|
// review-only never dispatches the worker, so --worker is optional there.
|
|
194
189
|
const requiredRoles =
|
|
195
|
-
(args.mode ?? "work-first") === "review-only" ? ["reviewer"] :
|
|
190
|
+
(args.mode ?? "work-first") === "review-only" ? ["reviewer"] : CHILD_ROLE_KINDS;
|
|
196
191
|
for (const roleName of requiredRoles) {
|
|
197
192
|
if (args[roleName] === null) {
|
|
198
193
|
throw new RoleError(`Missing required --${roleName}.`);
|
|
@@ -200,12 +195,12 @@ function validateInitFlags(args, agents = {}) {
|
|
|
200
195
|
}
|
|
201
196
|
// Every supplied role is still validated; review-only may omit the worker.
|
|
202
197
|
// A model or effort without its role kind is an orphan option, not a silent drop.
|
|
203
|
-
for (const roleName of
|
|
198
|
+
for (const roleName of CHILD_ROLE_KINDS) {
|
|
204
199
|
const kind = args[roleName];
|
|
205
200
|
if (kind === null) {
|
|
206
201
|
for (const flag of [`${roleName}Model`, `${roleName}Effort`]) {
|
|
207
202
|
if (args[flag] !== null) {
|
|
208
|
-
throw new RoleError(
|
|
203
|
+
throw new RoleError(`${ROLE_FLAG_BY_OPTION[flag]} requires --${roleName}.`);
|
|
209
204
|
}
|
|
210
205
|
}
|
|
211
206
|
continue;
|
|
@@ -251,52 +246,30 @@ function roleState(source, roleName) {
|
|
|
251
246
|
}
|
|
252
247
|
|
|
253
248
|
/**
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
249
|
+
* Archives a terminal state file as `state.<timestamp>.json` so a new run can
|
|
250
|
+
* take its place. Absence is a no-op. Called only after every init check and
|
|
251
|
+
* the prompt read pass, so a rejected init archives nothing (#123).
|
|
257
252
|
*/
|
|
258
|
-
async function
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (existing) {
|
|
262
|
-
if (!TERMINAL_LIFECYCLES.has(existing.lifecycle)) {
|
|
263
|
-
throw new RoleError(
|
|
264
|
-
`Existing run is ${existing.lifecycle}; abort it before starting a new run.`,
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
268
|
-
const archived = join(dirname(paths.stateFile), `state.${stamp}.json`);
|
|
269
|
-
await rename(paths.stateFile, archived);
|
|
270
|
-
logInfo(`archived terminal state file to ${archived}`);
|
|
253
|
+
async function archiveState(paths, existing) {
|
|
254
|
+
if (!existing) {
|
|
255
|
+
return;
|
|
271
256
|
}
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
await
|
|
275
|
-
|
|
276
|
-
await appendSessionIndex(paths.sessionIndexFile, paths.stateFile);
|
|
277
|
-
}
|
|
278
|
-
logInfo(`initialized run state (mode: ${state.mode}, maxSteps: ${state.maxSteps})`);
|
|
279
|
-
return state;
|
|
257
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
258
|
+
const archived = join(dirname(paths.stateFile), `state.${stamp}.json`);
|
|
259
|
+
await rename(paths.stateFile, archived);
|
|
260
|
+
logInfo(`archived terminal state file to ${archived}`);
|
|
280
261
|
}
|
|
281
262
|
|
|
282
|
-
const INIT_COMPARATORS = {
|
|
283
|
-
task: (state) => state.task,
|
|
284
|
-
mode: (state) => state.mode,
|
|
285
|
-
parentSession: (state) => state.parentSession,
|
|
286
|
-
maxSteps: (state) => state.maxSteps,
|
|
287
|
-
timeout: (state) => state.timeout,
|
|
288
|
-
};
|
|
289
|
-
|
|
290
263
|
/** Later calls read configuration from the state file and reject any change. */
|
|
291
264
|
function rejectInitFlagChanges(args, state) {
|
|
292
265
|
const provided = [];
|
|
293
266
|
for (const flag of INIT_FIELDS) {
|
|
294
267
|
const isGiven = flag === "timeout" ? args.timeoutProvided : args[flag] !== null;
|
|
295
268
|
if (isGiven) {
|
|
296
|
-
provided.push([flag, args[flag],
|
|
269
|
+
provided.push([flag, args[flag], state[flag]]);
|
|
297
270
|
}
|
|
298
271
|
}
|
|
299
|
-
for (const roleName of
|
|
272
|
+
for (const roleName of CHILD_ROLE_KINDS) {
|
|
300
273
|
for (const [flag, path] of [
|
|
301
274
|
[roleName, "kind"],
|
|
302
275
|
[`${roleName}Model`, "model"],
|
|
@@ -316,7 +289,7 @@ function rejectInitFlagChanges(args, state) {
|
|
|
316
289
|
for (const [flag, value, existing] of provided) {
|
|
317
290
|
if (value !== existing) {
|
|
318
291
|
throw new RoleError(
|
|
319
|
-
`--${kebab(flag)} cannot be changed after init (state holds: ${JSON.stringify(existing ?? null)}).`,
|
|
292
|
+
`${ROLE_FLAG_BY_OPTION[flag] ?? `--${kebab(flag)}`} cannot be changed after init (state holds: ${JSON.stringify(existing ?? null)}).`,
|
|
320
293
|
);
|
|
321
294
|
}
|
|
322
295
|
}
|
|
@@ -407,12 +380,24 @@ async function dispatch(args, { agents, stdin = readStdin, signal }) {
|
|
|
407
380
|
}
|
|
408
381
|
|
|
409
382
|
async function dispatchLocked(args, { agents, stdin, signal, paths, onEvent }) {
|
|
410
|
-
|
|
383
|
+
const existing = await readState(paths.stateFile);
|
|
411
384
|
const init = isInitCall(args);
|
|
385
|
+
let state;
|
|
386
|
+
|
|
412
387
|
if (init) {
|
|
413
|
-
|
|
388
|
+
// New-run rule: init over a terminal or absent state file is allowed; init
|
|
389
|
+
// over a non-terminal lifecycle is rejected and the parent must abort it
|
|
390
|
+
// first. Nothing is archived or written until every init check and the
|
|
391
|
+
// prompt read pass, so a rejected init leaves the previous state untouched.
|
|
392
|
+
validateInitFlags(args, agents);
|
|
393
|
+
if (existing && !TERMINAL_LIFECYCLES.has(existing.lifecycle)) {
|
|
394
|
+
throw new RoleError(
|
|
395
|
+
`Existing run is ${existing.lifecycle}; abort it before starting a new run.`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
state = initialState(args);
|
|
414
399
|
} else {
|
|
415
|
-
state =
|
|
400
|
+
state = requireState(existing, args.cwd);
|
|
416
401
|
rejectInitFlagChanges(args, state);
|
|
417
402
|
}
|
|
418
403
|
|
|
@@ -452,6 +437,17 @@ async function dispatchLocked(args, { agents, stdin, signal, paths, onEvent }) {
|
|
|
452
437
|
|
|
453
438
|
const prompt = await readPrompt(args, stdin);
|
|
454
439
|
|
|
440
|
+
if (init) {
|
|
441
|
+
// Every check and the prompt read passed; now archive the old terminal
|
|
442
|
+
// state file, if any, and write the new one.
|
|
443
|
+
await archiveState(paths, existing);
|
|
444
|
+
await writeState(paths.stateFile, state);
|
|
445
|
+
if (args.parentSession) {
|
|
446
|
+
await writeSessionIndex(paths.sessionIndexFile, paths.stateFile);
|
|
447
|
+
}
|
|
448
|
+
logInfo(`initialized run state (mode: ${state.mode}, maxSteps: ${state.maxSteps})`);
|
|
449
|
+
}
|
|
450
|
+
|
|
455
451
|
// Charge the step before execution, matching the headless runtime.
|
|
456
452
|
state.stepsUsed += 1;
|
|
457
453
|
state.lifecycle = "dispatched";
|
|
@@ -474,24 +470,12 @@ async function dispatchLocked(args, { agents, stdin, signal, paths, onEvent }) {
|
|
|
474
470
|
});
|
|
475
471
|
} catch (err) {
|
|
476
472
|
const canceled = Boolean(err?.isCanceled);
|
|
473
|
+
const payload = { role: roleName, status: "error", error: errorMessage(err) };
|
|
477
474
|
state.lifecycle = canceled ? "interrupted" : "halted";
|
|
478
|
-
state.lastResult = {
|
|
479
|
-
role: roleName,
|
|
480
|
-
status: "error",
|
|
481
|
-
error: errorMessage(err),
|
|
482
|
-
at: new Date().toISOString(),
|
|
483
|
-
};
|
|
475
|
+
state.lastResult = { ...payload, at: new Date().toISOString() };
|
|
484
476
|
await writeState(paths.stateFile, state);
|
|
485
|
-
onEvent({
|
|
486
|
-
|
|
487
|
-
role: roleName,
|
|
488
|
-
result: { role: roleName, status: "error", error: errorMessage(err) },
|
|
489
|
-
stepsUsed: state.stepsUsed,
|
|
490
|
-
});
|
|
491
|
-
return {
|
|
492
|
-
exitCode: canceled ? 130 : 1,
|
|
493
|
-
payload: { role: roleName, status: "error", error: errorMessage(err) },
|
|
494
|
-
};
|
|
477
|
+
onEvent({ type: "result", role: roleName, result: payload, stepsUsed: state.stepsUsed });
|
|
478
|
+
return { exitCode: canceled ? 130 : 1, payload };
|
|
495
479
|
}
|
|
496
480
|
|
|
497
481
|
state.lifecycle = "active";
|
|
@@ -537,7 +521,7 @@ async function finish(args, { stdin = readStdin }) {
|
|
|
537
521
|
|
|
538
522
|
const paths = statePaths({ cwd: args.cwd });
|
|
539
523
|
return withStateLock(paths.lockFile, async () => {
|
|
540
|
-
const state = await
|
|
524
|
+
const state = requireState(await readState(paths.stateFile), args.cwd);
|
|
541
525
|
rejectInitFlagChanges(args, state);
|
|
542
526
|
if (TERMINAL_LIFECYCLES.has(state.lifecycle)) {
|
|
543
527
|
throw new RoleError(`Run is already ${state.lifecycle}.`);
|
|
@@ -577,7 +561,7 @@ async function abort(args) {
|
|
|
577
561
|
|
|
578
562
|
const paths = statePaths({ cwd: args.cwd });
|
|
579
563
|
return withStateLock(paths.lockFile, async () => {
|
|
580
|
-
const state = await
|
|
564
|
+
const state = requireState(await readState(paths.stateFile), args.cwd);
|
|
581
565
|
rejectInitFlagChanges(args, state);
|
|
582
566
|
if (TERMINAL_LIFECYCLES.has(state.lifecycle)) {
|
|
583
567
|
throw new RoleError(`Run is already ${state.lifecycle}.`);
|