@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.
Files changed (37) hide show
  1. package/README.md +194 -114
  2. package/docs/orchestrator-instructions.md +25 -22
  3. package/package.json +2 -2
  4. package/src/agents/agy.mjs +2 -11
  5. package/src/agents/codex.mjs +3 -20
  6. package/src/agents/copilot.mjs +8 -16
  7. package/src/agents/opencode.mjs +2 -7
  8. package/src/agents/shared.mjs +29 -0
  9. package/src/cli.mjs +46 -25
  10. package/src/entrypoints/copilot.mjs +6 -1
  11. package/src/hook/antigravity-parent-guard.mjs +28 -0
  12. package/src/hook/copilot-parent-guard.mjs +5 -30
  13. package/src/hook/decision.mjs +59 -6
  14. package/src/hook/opencode-plugin.mjs +92 -0
  15. package/src/hook/parent-guard.mjs +8 -33
  16. package/src/install/commands.mjs +268 -0
  17. package/src/install/fsutil.mjs +159 -0
  18. package/src/install/harnesses.mjs +170 -0
  19. package/src/install/installer.mjs +688 -0
  20. package/src/install/manifest.mjs +222 -0
  21. package/src/install/settings.mjs +217 -0
  22. package/src/install/templates/antigravity/agent-loop-antigravity-parent-guard.mjs +14 -0
  23. package/src/install/templates/antigravity/hooks.json +16 -0
  24. package/src/install/templates/antigravity/skills/agent-loop/SKILL.md +24 -0
  25. package/src/install/templates/claude/skills/agent-loop/SKILL.md +33 -0
  26. package/src/install/templates/codex/skills/agent-loop/SKILL.md +30 -0
  27. package/src/install/templates/codex/skills/agent-loop/agents/openai.yaml +2 -0
  28. package/src/install/templates/copilot/hooks/parent-guard.json +15 -0
  29. package/src/install/templates/opencode/plugins/parent-guard.ts +11 -0
  30. package/src/lib/args.mjs +23 -0
  31. package/src/lib/hash.mjs +9 -0
  32. package/src/lib/log.mjs +18 -3
  33. package/src/lib/process-ancestry.mjs +104 -0
  34. package/src/lib/runstate.mjs +133 -38
  35. package/src/lib/snapshot.mjs +3 -7
  36. package/src/role.mjs +71 -87
  37. 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
+ }
@@ -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 { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
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 (!sessionId || /[\\/\0]/.test(sessionId) || sessionId === "." || sessionId === "..") {
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` with
85
- * O_EXCL, treats an existing lock with a live owner pid as busy and a dead one
86
- * as stale (removed with a warning, then retried). Returns a release function.
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
- async function acquireLock(lockFile, retry = true) {
99
- try {
100
- const handle = await open(lockFile, "wx");
101
- try {
102
- await handle.writeFile(
103
- JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }),
104
- "utf8",
105
- );
106
- } finally {
107
- await handle.close();
108
- }
109
- // The O_EXCL create above is the lock; the content write closes the
110
- // reader-visible window. Contenders finding an unparseable lock fail
111
- // closed below and never remove it while it is fresh.
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
- `State is locked by a live process (pid ${owner.pid}, started ${owner.startedAt ?? "unknown"}).`,
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: the creator may still be between create and
128
- // content write, so it is never treated as stale here.
129
- throw new Error("State is locked (the lock file is not readable yet; retry shortly).");
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("State lock could not be acquired after stale removal.");
172
+ throw new Error(`${label} lock could not be acquired after stale removal.`);
134
173
  }
135
174
 
136
- logWarn(`removing stale state lock (dead pid ${owner?.pid ?? "unknown"})`);
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 appendSessionIndex(sessionIndexFile, stateFile) {
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");
@@ -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>} before
145
- * @param {ReturnType<typeof snapshot>} after
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(["worker", "reviewer"]);
31
- const DEFAULT_MAX_STEPS = 20;
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
- throw new RoleError(`Unknown argument: ${arg}`);
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
- /** Loads state for a non-init call, rejecting a missing state file. */
179
- async function loadExistingState(paths, cwd) {
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"] : ["worker", "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 ["worker", "reviewer"]) {
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(`--${kebab(flag)} requires --${roleName}.`);
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
- * New-run rule: init over a terminal or absent state file archives any
255
- * existing file as `state.<timestamp>.json` and creates a new state; init over
256
- * a non-terminal lifecycle is rejected and the parent must abort it first.
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 initRun(args, paths, existing, agents) {
259
- validateInitFlags(args, agents);
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 state = initialState(args);
274
- await writeState(paths.stateFile, state);
275
- if (args.parentSession) {
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], INIT_COMPARATORS[flag](state)]);
269
+ provided.push([flag, args[flag], state[flag]]);
297
270
  }
298
271
  }
299
- for (const roleName of ["worker", "reviewer"]) {
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
- let state = await readState(paths.stateFile);
383
+ const existing = await readState(paths.stateFile);
411
384
  const init = isInitCall(args);
385
+ let state;
386
+
412
387
  if (init) {
413
- state = await initRun(args, paths, state, agents);
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 = await loadExistingState(paths, args.cwd);
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
- type: "result",
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 loadExistingState(paths, args.cwd);
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 loadExistingState(paths, args.cwd);
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}.`);