@agentswf/engine 0.1.0-beta.4 → 0.1.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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/package.json +13 -9
  4. package/src/accounting/format.ts +4 -3
  5. package/src/accounting/spent.ts +128 -0
  6. package/src/accounting/summary.ts +40 -32
  7. package/src/agent-cli/cli.ts +35 -22
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +5 -7
  11. package/src/attempt-output.ts +15 -32
  12. package/src/attempt-view.ts +34 -33
  13. package/src/author-typebox.ts +13 -0
  14. package/src/client/checks.ts +94 -0
  15. package/src/client/exported.ts +17 -0
  16. package/src/client/index.ts +122 -0
  17. package/src/client/locate.ts +34 -0
  18. package/src/client/published.ts +3 -0
  19. package/src/client/runs.ts +337 -0
  20. package/src/command-line.ts +64 -0
  21. package/src/commands.ts +7 -1
  22. package/src/control-plane.ts +41 -11
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +41 -33
  25. package/src/deadlines.ts +40 -7
  26. package/src/decisions/directory.ts +51 -40
  27. package/src/decisions/fake.ts +1 -1
  28. package/src/decisions/openrouter.ts +4 -2
  29. package/src/decisions/seam.ts +4 -4
  30. package/src/display-path.ts +3 -3
  31. package/src/duration.ts +43 -27
  32. package/src/emit-command.ts +88 -58
  33. package/src/emit.ts +254 -56
  34. package/src/event-ids.ts +31 -0
  35. package/src/files.ts +44 -4
  36. package/src/hand-over.ts +150 -0
  37. package/src/help/allowance.txt +4 -3
  38. package/src/help/emit.txt +18 -6
  39. package/src/help/inbox.txt +6 -4
  40. package/src/help/logs.txt +9 -6
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +30 -11
  44. package/src/help/runs.txt +4 -2
  45. package/src/help/tick.txt +9 -4
  46. package/src/herdr-placement.ts +3 -33
  47. package/src/herdr-run-session.ts +7 -15
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +56 -133
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +40 -57
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.test.ts.template +3 -3
  55. package/src/init-template/hello/workflow.ts.template +5 -4
  56. package/src/jsonl.ts +10 -6
  57. package/src/keep-alive.ts +15 -12
  58. package/src/logs-command.ts +128 -47
  59. package/src/message-operation.ts +6 -3
  60. package/src/messaging/limits.ts +4 -4
  61. package/src/messaging/messaging.ts +31 -28
  62. package/src/messaging/routes.ts +1 -1
  63. package/src/messaging/text.ts +9 -7
  64. package/src/open-command.ts +770 -0
  65. package/src/operation-events.ts +4 -6
  66. package/src/operation-liveness.ts +19 -4
  67. package/src/operator-cli.ts +224 -94
  68. package/src/operator-runtime.ts +29 -7
  69. package/src/ops.ts +270 -0
  70. package/src/pane-layout.ts +105 -59
  71. package/src/progress-view.ts +10 -10
  72. package/src/project-engine.ts +79 -0
  73. package/src/projects.ts +13 -2
  74. package/src/refusals.ts +119 -0
  75. package/src/result-slots.ts +11 -31
  76. package/src/result-validation.ts +2 -2
  77. package/src/run-command.ts +45 -28
  78. package/src/run-commands.ts +43 -115
  79. package/src/run-event-log.ts +1 -1
  80. package/src/run-lines.ts +159 -0
  81. package/src/run-lock.ts +57 -1
  82. package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
  83. package/src/run-policy/stages.ts +113 -0
  84. package/src/run-policy/waits.ts +452 -0
  85. package/src/run-prepare.ts +205 -43
  86. package/src/run-read.ts +109 -41
  87. package/src/run-state.ts +170 -50
  88. package/src/run-text.ts +54 -0
  89. package/src/run-unreachable.ts +18 -0
  90. package/src/run-usage.ts +18 -12
  91. package/src/run-view.ts +81 -17
  92. package/src/runs-command.ts +83 -63
  93. package/src/runs.ts +310 -212
  94. package/src/sandbox-homes.ts +65 -13
  95. package/src/sandboxes.ts +182 -28
  96. package/src/session-lines.ts +93 -0
  97. package/src/skills/run-skills.ts +8 -1
  98. package/src/sources.ts +28 -29
  99. package/src/spend.ts +333 -0
  100. package/src/stage-ledger.ts +119 -131
  101. package/src/stop-crashed.ts +6 -5
  102. package/src/stopped.ts +4 -4
  103. package/src/test-command.ts +36 -14
  104. package/src/testing/operation-events.ts +3 -23
  105. package/src/testing.ts +73 -26
  106. package/src/tick-command.ts +145 -15
  107. package/src/tools.ts +25 -16
  108. package/src/turn-ends.ts +37 -0
  109. package/src/waits.ts +651 -0
  110. package/src/wake.ts +84 -54
  111. package/src/workflow-loader.ts +54 -4
  112. package/src/workflow-runner.ts +805 -580
  113. package/src/workflow-test-preload.ts +4 -1
  114. package/src/workflow-testing/coordinators.ts +3 -3
  115. package/src/workflow-testing/fake-client.ts +387 -0
  116. package/src/workflow-testing/host.ts +121 -53
  117. package/src/workflow-testing/index.ts +33 -842
  118. package/src/workflow-testing/run-test.ts +1128 -0
  119. package/src/workflow-testing/script.ts +74 -45
  120. package/src/hold-run-lock.fixture.ts +0 -8
  121. package/src/mailbox.ts +0 -990
  122. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  123. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
package/src/runs.ts CHANGED
@@ -1,32 +1,39 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { mkdir, open, readdir, readFile, rename, rm, stat, unlink } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
+ import { isDeepStrictEqual } from "node:util";
4
5
  import {
5
- ATTEMPT_RECORD_VERSION,
6
6
  type AttemptRecord,
7
- type CallSpec,
8
- type Candidate,
9
- RUN_RECORD_VERSION,
7
+ type ClaimedAttempt,
8
+ type EventLine,
9
+ type FailedLaunch,
10
+ type RefusalCode,
11
+ RUN_FORMAT,
10
12
  type RunLogLine,
13
+ type RunPanes,
11
14
  type RunRecord,
15
+ type RunView,
12
16
  runLogLine,
13
- STAGE_RECORD_VERSION,
14
- type StageRecord,
17
+ type StageLine,
15
18
  type TurnRecord,
16
19
  } from "@agentswf/contract/records";
17
20
  import { type AttemptOutcome, nameProblem } from "@agentswf/contract/workflow";
18
- import { exists, isCode, linkNew, writeJson } from "./files";
19
- import { appendLine, endTornLine, readLines } from "./jsonl";
21
+ import { exists, isCode, linkNew, syncDir, writeJson, writeWhole } from "./files";
22
+ import { inFile } from "./in-lines";
23
+ import { appendLine, endTornLine } from "./jsonl";
20
24
  import { MESSAGE_LIMITS } from "./messaging/limits";
25
+ import type { EarlierOps } from "./ops";
26
+ import { type RunLines, readRunLines } from "./run-lines";
27
+ import { liveHolder, runOpenFile } from "./run-lock";
28
+ import { upcomingStages } from "./run-policy/stages";
21
29
 
22
30
  /**
23
31
  * Runs on disk: `{root}/{workflow}/{id}/`, holding `run.json`, `attempts/{attempt}.json`,
24
- * `stages/{stage}.json`, `replaced/{stage}.{attempt}.json`, `turns.jsonl`, each operation's
25
- * `calls/{id}/` with its `call.json`, `candidates.jsonl` and `result.json`, and the last ended
26
- * attempt's `output.json` and `report.md`. The only module that knows where a run's records are;
27
- * what its work keeps beside them, such as agents' bundles, skills and decisions, the module doing
28
- * that work places. Only the live attempt writes, and the two claims, a run's folder renamed into
29
- * place and an attempt's file linked into place, are the only locks.
32
+ * `run.jsonl` (`run-lines.ts`), `log.jsonl`, `ops/` (`ops.ts`), the last ended attempt's
33
+ * `out.json` and `report.md`, and `work/`. With those two modules, the only one that knows where a
34
+ * run's records are; what the harness keeps in `work/`, such as agents' bundles and skills, the
35
+ * module doing that work places. Only the live attempt writes, and the two claims, a run's folder
36
+ * renamed into place and an attempt's file linked into place, are the only locks.
30
37
  */
31
38
 
32
39
  /** Where a project's runs are kept, unless `--run-root` says otherwise. */
@@ -34,9 +41,20 @@ export function runRootOf(cwd: string): string {
34
41
  return join(cwd, ".awf", "runs");
35
42
  }
36
43
 
37
- /** Why awf will not start an attempt: the operator's to fix, so `awf run` exits 2. */
44
+ /**
45
+ * Why awf will not start an attempt: the operator's to fix, so `awf run` exits 2. `code` is its
46
+ * refusal's: `id`, an id the grammar refuses or one taken; `no-run`; `other-args`, a continue given
47
+ * other arguments or options; `prepare`, a recorded argv that no longer parses; `unreachable`, a
48
+ * record that can't be read; `not-started`, a run that can't take an attempt now.
49
+ */
38
50
  export class RunRefused extends Error {
39
- constructor(message: string) {
51
+ constructor(
52
+ message: string,
53
+ readonly code: Extract<
54
+ RefusalCode,
55
+ "id" | "no-run" | "other-args" | "prepare" | "unreachable" | "not-started"
56
+ >,
57
+ ) {
40
58
  super(message);
41
59
  this.name = "RunRefused";
42
60
  }
@@ -44,7 +62,14 @@ export class RunRefused extends Error {
44
62
 
45
63
  export type Run = { root: string; dir: string; record: RunRecord };
46
64
 
47
- export type Attempt = { run: Run; file: string; record: AttemptRecord };
65
+ /** An attempt's file, claimed. */
66
+ export type AttemptFile = { run: Run; file: string; record: ClaimedAttempt };
67
+
68
+ /**
69
+ * An attempt that holds its run: its file, the writer its claim on `run.jsonl` gave it, and what its
70
+ * claim's listing of `ops/` found.
71
+ */
72
+ export type Attempt = AttemptFile & { lines: RunLines; ops: EarlierOps };
48
73
 
49
74
  /** The start time of process `pid` as an ISO time to the second, or undefined when it is gone. */
50
75
  export type ProcessProbe = (pid: number) => string | undefined;
@@ -59,7 +84,7 @@ export function idProblem(id: string): string | undefined {
59
84
  : `${JSON.stringify(id)} is not a valid id: letters, digits, '.', '_', '@' and '-', starting with a letter or digit, up to 128`;
60
85
  }
61
86
 
62
- /** Why `name` can't name a stage, which is a file in the run's `stages/`; undefined when it can. */
87
+ /** Why `name` can't name a stage; undefined when it can. */
63
88
  export function stageNameProblem(name: string): string | undefined {
64
89
  const problem = typeof name === "string" ? nameProblem(name) : "a string";
65
90
  return problem && `${JSON.stringify(name)} is not a stage's name: ${problem}`;
@@ -68,7 +93,7 @@ export function stageNameProblem(name: string): string | undefined {
68
93
  function checkIds(...ids: string[]): void {
69
94
  for (const id of ids) {
70
95
  const problem = idProblem(id);
71
- if (problem) throw new RunRefused(problem);
96
+ if (problem) throw new RunRefused(problem, "id");
72
97
  }
73
98
  }
74
99
 
@@ -90,8 +115,10 @@ export function generateId(
90
115
  */
91
116
  export async function createRun(
92
117
  root: string,
93
- record: Omit<RunRecord, "version" | "created" | "id"> & { id?: string },
118
+ record: Omit<RunRecord, "format" | "created" | "id"> & { id?: string },
94
119
  now: Date = new Date(),
120
+ /** The event that starts the run, in its `in.jsonl` before the run is in place. */
121
+ first?: EventLine,
95
122
  ): Promise<Run> {
96
123
  checkIds(record.workflow);
97
124
  await mkdir(root, { recursive: true, mode: 0o700 });
@@ -102,36 +129,47 @@ export async function createRun(
102
129
  for (let tries = 1; ; tries += 1) {
103
130
  const id = record.id ?? generateId(now);
104
131
  try {
105
- return await claimRun(root, parent, { ...record, id }, now);
132
+ return await claimRun(root, parent, { ...record, id }, now, first);
106
133
  } catch (error) {
107
134
  if (record.id !== undefined || !(error instanceof RunTaken) || tries >= 5) throw error;
108
135
  }
109
136
  }
110
137
  }
111
138
 
112
- class RunTaken extends RunRefused {}
139
+ /** The id is a run's already, maybe in another case. */
140
+ export class RunTaken extends RunRefused {
141
+ constructor(message: string) {
142
+ super(message, "id");
143
+ }
144
+ }
113
145
 
114
146
  async function claimRun(
115
147
  root: string,
116
148
  parent: string,
117
- record: Omit<RunRecord, "version" | "created">,
149
+ record: Omit<RunRecord, "format" | "created">,
118
150
  now: Date,
151
+ first: EventLine | undefined,
119
152
  ): Promise<Run> {
120
153
  checkIds(record.id);
121
154
  const other = await takenInCase(parent, record.id);
122
155
  if (other !== undefined) throw new RunTaken(takenMessage(other));
123
- const full: RunRecord = { version: RUN_RECORD_VERSION, ...record, created: now.toISOString() };
156
+ const full: RunRecord = { format: RUN_FORMAT, ...record, created: now.toISOString() };
124
157
  const pending = join(parent, `.new-${randomBytes(6).toString("hex")}`);
125
158
  await mkdir(pending, { mode: 0o700 });
126
159
  const dir = join(parent, record.id);
127
160
  try {
128
161
  await writeJson(runFile(pending), full);
162
+ if (first) await writeWhole(inFile(pending), `${JSON.stringify(first)}\n`);
163
+ await syncDir(pending);
129
164
  await rename(pending, dir);
130
165
  } catch (error) {
131
166
  await rm(pending, { recursive: true, force: true });
132
167
  if (isCode(error, "ENOTEMPTY", "EEXIST", "EISDIR")) throw new RunTaken(takenMessage(record.id));
133
168
  throw error;
134
169
  }
170
+ // The run is in place and readable; a failed sync risks its entry only on a machine crash, so it
171
+ // must not refuse an id this call now holds.
172
+ await syncDir(parent).catch(() => undefined);
135
173
  return { root, dir, record: full };
136
174
  }
137
175
 
@@ -140,7 +178,7 @@ function takenMessage(id: string): string {
140
178
  }
141
179
 
142
180
  /** Another run whose id differs from `id` only in case; the same id is the rename's to refuse. */
143
- async function takenInCase(parent: string, id: string): Promise<string | undefined> {
181
+ export async function takenInCase(parent: string, id: string): Promise<string | undefined> {
144
182
  return (await entries(parent)).find(
145
183
  (name) => name !== id && name.toLowerCase() === id.toLowerCase(),
146
184
  );
@@ -151,7 +189,7 @@ export async function checkFree(root: string, workflow: string, id: string): Pro
151
189
  checkIds(workflow, id);
152
190
  const parent = join(root, workflow);
153
191
  const taken = (await exists(join(parent, id))) ? id : await takenInCase(parent, id);
154
- if (taken !== undefined) throw new RunRefused(takenMessage(taken));
192
+ if (taken !== undefined) throw new RunRefused(takenMessage(taken), "id");
155
193
  }
156
194
 
157
195
  /**
@@ -159,7 +197,7 @@ export async function checkFree(root: string, workflow: string, id: string): Pro
159
197
  * again: its first attempt was never claimed, or is `own`, which ended before it started. Whether
160
198
  * it did: not while another attempt is there.
161
199
  */
162
- export async function discardRun(run: Run, own?: Attempt): Promise<boolean> {
200
+ export async function discardRun(run: Run, own?: AttemptFile): Promise<boolean> {
163
201
  const dir = attemptsDir(run.dir);
164
202
  const others = (await entries(dir)).filter((name) => join(dir, name) !== own?.file);
165
203
  if (others.length > 0) return false;
@@ -177,56 +215,172 @@ export async function openRun(root: string, workflow: string, id: string): Promi
177
215
  if (other !== workflow && (await exists(runFile(join(root, other, id))))) {
178
216
  throw new RunRefused(
179
217
  `${id} is a run of ${other}; move ${join(root, other, id)} to ${dir} to continue it here`,
218
+ "no-run",
180
219
  );
181
220
  }
182
221
  }
183
222
  throw new RunRefused(
184
223
  `no run ${id} of ${workflow} in ${root}; a run is kept under the directory it works in, which --cwd names`,
224
+ "no-run",
185
225
  );
186
226
  }
187
- const record = await readRecord<RunRecord>(file, RUN_RECORD_VERSION);
227
+ const record = await readRecord<RunRecord>(file);
228
+ if (record.format !== RUN_FORMAT || missingField(record)) {
229
+ throw new RunUnreadable(`${workflow}/${id}`, record);
230
+ }
188
231
  return { root, dir, record };
189
232
  }
190
233
 
234
+ /**
235
+ * A run this awf does not read: its `run.json` has no `format`, made by an awf before the record
236
+ * had one, or one this awf doesn't know. Nothing else of it is read.
237
+ */
238
+ export class RunUnreadable extends RunRefused {
239
+ /** `run.json`'s `created`, when it has one: where `awf runs` lists it. */
240
+ readonly created?: string;
241
+ /** Whether it is a run with events, which `awf tick` would have woken. */
242
+ readonly events: boolean;
243
+ readonly why: string;
244
+ constructor(
245
+ readonly ref: string,
246
+ record: { format?: unknown; created?: unknown; events?: unknown },
247
+ ) {
248
+ const why = unreadableWhy(record);
249
+ super(`${ref} is unreadable: ${why}`, "unreachable");
250
+ this.why = why;
251
+ this.name = "RunUnreadable";
252
+ if (typeof record.created === "string") this.created = record.created;
253
+ this.events = record.events === true;
254
+ }
255
+ }
256
+
257
+ function unreadableWhy(record: { format?: unknown }): string {
258
+ const { format } = record;
259
+ if (format === undefined) {
260
+ return "an earlier awf made it, before run.json had a format; this awf does not read it";
261
+ }
262
+ if (format !== RUN_FORMAT) {
263
+ return `its run.json is format ${said(format)}; this awf reads format ${RUN_FORMAT} only`;
264
+ }
265
+ return `its run.json has no ${missingField(record)} of the right type`;
266
+ }
267
+
268
+ /** The first field format 1 requires of `run.json` that `record` lacks, or has of another type. */
269
+ function missingField(record: object): string | undefined {
270
+ const fields = record as Record<string, unknown>;
271
+ const strings = ["id", "workflow", "cwd", "created"] as const;
272
+ const string = strings.find((field) => typeof fields[field] !== "string");
273
+ if (string) return string;
274
+ const { argv } = fields;
275
+ if (!Array.isArray(argv) || !argv.every((each) => typeof each === "string")) return "argv";
276
+ if (!("sandbox" in fields)) return "sandbox";
277
+ return undefined;
278
+ }
279
+
280
+ const said = (value: unknown) => JSON.stringify(value)?.slice(0, 40) ?? String(value);
281
+
191
282
  /**
192
283
  * What a continue may be given: nothing that changes the run. argv is the run's, and its working
193
- * directory and sandbox are those it was started with.
284
+ * directory, sandbox and panes are those it was started with.
194
285
  */
195
286
  export function checkContinue(
196
287
  run: Run,
197
- given: { argv: readonly string[]; cwd?: string; sandbox?: unknown },
288
+ given: { argv: readonly string[]; cwd?: string; sandbox?: unknown; panes?: RunPanes },
198
289
  ): void {
199
290
  const { id, cwd, sandbox } = run.record;
291
+ const panes = panesChanged(run.record, given.panes);
292
+ if (panes) throw new RunRefused(panes, "other-args");
200
293
  if (given.argv.length > 0) {
201
294
  throw new RunRefused(
202
295
  `${id} keeps the arguments it was started with; a run with others is a new run (--id)`,
296
+ "other-args",
203
297
  );
204
298
  }
205
299
  if (given.cwd !== undefined && given.cwd !== cwd) {
206
- throw new RunRefused(`${id} works in ${cwd}, not ${given.cwd}`);
300
+ throw new RunRefused(`${id} works in ${cwd}, not ${given.cwd}`, "other-args");
207
301
  }
208
302
  if (given.sandbox !== undefined && JSON.stringify(given.sandbox) !== JSON.stringify(sandbox)) {
209
- throw new RunRefused(`${id} runs in the sandbox it was started with; leave --sandbox out`);
303
+ throw new RunRefused(
304
+ `${id} runs in the sandbox it was started with; leave --sandbox out`,
305
+ "other-args",
306
+ );
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Why `given` would change the panes `record`'s run was started with; a flag given again with its
312
+ * value, or with its default where the run was started without it, changes nothing.
313
+ */
314
+ export function panesChanged(record: RunRecord, given: RunPanes | undefined): string | undefined {
315
+ const flags = [
316
+ ["--workspace", "workspace", "run"],
317
+ ["--keep-panes", "keep", "never"],
318
+ ] as const;
319
+ for (const [flag, field, byDefault] of flags) {
320
+ const value = given?.[field];
321
+ const recorded = record.panes?.[field];
322
+ if (value === undefined || isDeepStrictEqual(value, recorded ?? byDefault)) continue;
323
+ // A run an event or an older awf started has none, and keeps the defaults.
324
+ return recorded === undefined
325
+ ? `${record.id} was started without ${flag}, so with ${flag} ${byDefault}; leave it out`
326
+ : `${record.id} keeps the ${flag} it was started with; leave ${flag} out`;
210
327
  }
328
+ return undefined;
329
+ }
330
+
331
+ /**
332
+ * The attempts that ran, in order: every attempt file but a background launch that never claimed
333
+ * its number. What a run does next, and how it shows, is decided from these.
334
+ */
335
+ export async function readAttempts(run: Pick<Run, "dir">): Promise<ClaimedAttempt[]> {
336
+ return claimedOf(await readAttemptFiles(run));
337
+ }
338
+
339
+ /** Where background launch `launch` of a run writes its output. */
340
+ export function launchLog(runDir: string, launch: string): string {
341
+ return join(runDir, "launches", `${launch}.log`);
342
+ }
343
+
344
+ /** Whether `attempt` claimed its number and ran, rather than being a launch that never did. */
345
+ export function isClaimed(attempt: AttemptRecord): attempt is ClaimedAttempt {
346
+ return attempt.kind !== "failed-launch";
347
+ }
348
+
349
+ /** The attempts of `attempts` that claimed their number, in order. */
350
+ export function claimedOf(attempts: readonly AttemptRecord[]): ClaimedAttempt[] {
351
+ return attempts.filter(isClaimed);
211
352
  }
212
353
 
213
354
  /**
214
- * The run's attempts, in order. One that doesn't parse, or is newer than this awf, refuses; one
355
+ * Every attempt file, in order, a failed launch's included: what `stuck` counts and the next
356
+ * number follows. One that doesn't parse refuses, and `run.json`'s `format` is their version; one
215
357
  * gone since the folder was listed was a refused attempt removing its own claim.
216
358
  */
217
- export async function readAttempts(run: Run): Promise<AttemptRecord[]> {
359
+ export async function readAttemptFiles(run: Pick<Run, "dir">): Promise<AttemptRecord[]> {
218
360
  const dir = attemptsDir(run.dir);
219
361
  const numbered = await attemptNames(run);
220
362
  const records = await Promise.all(
221
- numbered.map((name) =>
222
- readRecord<AttemptRecord>(join(dir, name), ATTEMPT_RECORD_VERSION, { missing: true }),
223
- ),
363
+ numbered.map(async (name) => {
364
+ const file = join(dir, name);
365
+ const record = await readRecord<AttemptRecord>(file, { missing: true });
366
+ const problem = record && attemptProblem(record);
367
+ if (problem) throw new RunRefused(`${file} could not be read: ${problem}`, "unreachable");
368
+ return record;
369
+ }),
224
370
  );
225
371
  return records.filter((record) => record !== undefined).sort((a, b) => a.attempt - b.attempt);
226
372
  }
227
373
 
374
+ /** Why `record` isn't an attempt file this awf reads: its number and kind are what it relies on. */
375
+ function attemptProblem(record: AttemptRecord): string | undefined {
376
+ const { attempt, kind } = record as { attempt?: unknown; kind?: unknown };
377
+ if (!Number.isSafeInteger(attempt) || (attempt as number) < 1) return "no positive attempt";
378
+ if (kind !== "attempt" && kind !== "failed-launch") return `kind ${said(kind)} is unknown`;
379
+ return undefined;
380
+ }
381
+
228
382
  /** No ending, and its process still the one that started it. */
229
- export function isLive(attempt: AttemptRecord, probe: ProcessProbe = processStart): boolean {
383
+ export function isLive(attempt: ClaimedAttempt, probe: ProcessProbe = processStart): boolean {
230
384
  return attempt.ended === undefined && sameProcess(attempt.pid, attempt.processStart, probe);
231
385
  }
232
386
 
@@ -252,26 +406,43 @@ export function sameStart(now: string, started: string): boolean {
252
406
  return Math.abs(Date.parse(now) - Date.parse(started)) <= 1_000;
253
407
  }
254
408
 
255
- /** Why a run takes no attempt now: one of its attempts is still running, or it completed. */
409
+ /**
410
+ * Why a run takes no attempt now: one of its attempts is still running, `awf open --no-fork`
411
+ * holds its session, or it completed.
412
+ */
256
413
  export type AttemptRefusal =
257
- | { kind: "running"; attempt: AttemptRecord }
258
- /** `stages` are the run's records, in the order they began, from which `--from-stage` redoes. */
259
- | { kind: "completed"; stages: StageRecord[] };
414
+ | { kind: "running"; attempt: ClaimedAttempt }
415
+ /** `events`: the run is woken once that exits; a plain run is continued by hand. */
416
+ | { kind: "opened"; pid: number; events: boolean }
417
+ /**
418
+ * `stages`, each recorded stage's latest line in the order they started, but those the last
419
+ * redo left behind, are what `--from-stage` redoes.
420
+ */
421
+ | { kind: "completed"; stages: StageLine[] };
260
422
 
261
423
  /**
262
- * Why the run whose attempts and stage records these are takes no attempt now, or undefined when
263
- * it takes one. A completed run takes one only to redo a stage, which `fromStage` says.
424
+ * Why the run whose attempts and `run.jsonl` these are takes no attempt now, or undefined when it
425
+ * takes one. A completed run takes one only to redo a stage, which `fromStage` says.
264
426
  */
265
427
  export function attemptRefusal(
266
- attempts: readonly AttemptRecord[],
267
- records: ReadonlyMap<string, StageRecord>,
428
+ attempts: readonly ClaimedAttempt[],
429
+ run: Pick<RunView, "stages" | "redo">,
268
430
  options: { fromStage: boolean; probe?: ProcessProbe },
269
431
  ): AttemptRefusal | undefined {
270
432
  const live = attempts.find((attempt) => isLive(attempt, options.probe));
271
433
  if (live) return { kind: "running", attempt: live };
272
- if (attempts.at(-1)?.outcome !== "completed" || options.fromStage) return undefined;
273
- const stages = [...records.values()].sort((a, b) => a.started.localeCompare(b.started));
274
- return { kind: "completed", stages };
434
+ const last = attempts.at(-1);
435
+ if (last?.outcome !== "completed" || options.fromStage) return undefined;
436
+ // A stage the code no longer has keeps its line, but the last redo left it behind.
437
+ return { kind: "completed", stages: upcomingStages(run) };
438
+ }
439
+
440
+ /** Why `awf open --no-fork` holding the run refuses an attempt, or undefined when none holds it. */
441
+ export async function openedRefusal(run: Run): Promise<AttemptRefusal | undefined> {
442
+ const holder = await liveHolder(runOpenFile(run.root, run.record.workflow, run.record.id));
443
+ return holder
444
+ ? { kind: "opened", pid: holder.pid, events: run.record.events === true }
445
+ : undefined;
275
446
  }
276
447
 
277
448
  /**
@@ -283,7 +454,7 @@ export function attemptRefusal(
283
454
  */
284
455
  export async function claimAttempt(
285
456
  run: Run,
286
- fields: Pick<AttemptRecord, "file" | "workflowVersion" | "flags">,
457
+ fields: Pick<ClaimedAttempt, "file" | "workflowVersion" | "flags" | "launch">,
287
458
  options: {
288
459
  now?: Date;
289
460
  pid?: number;
@@ -291,7 +462,7 @@ export async function claimAttempt(
291
462
  /** `--from-stage`: a run that completed is continued only to redo a stage of it. */
292
463
  fromStage?: boolean;
293
464
  } = {},
294
- ): Promise<{ attempt: Attempt; interrupted: AttemptRecord[] } | { refused: AttemptRefusal }> {
465
+ ): Promise<{ attempt: AttemptFile; interrupted: ClaimedAttempt[] } | { refused: AttemptRefusal }> {
295
466
  const probe = options.probe ?? processStart;
296
467
  const pid = options.pid ?? process.pid;
297
468
  const started = probe(pid);
@@ -301,15 +472,17 @@ export async function claimAttempt(
301
472
  const dir = attemptsDir(run.dir);
302
473
  await mkdir(dir, { recursive: true, mode: 0o700 });
303
474
  await sweep(dir);
304
- const earlier = await readAttempts(run);
475
+ // Numbered past every file, a failed launch's included.
476
+ const earlier = await readAttemptFiles(run);
305
477
  let n = (earlier.at(-1)?.attempt ?? 0) + 1;
306
478
  for (;;) {
307
- const record: AttemptRecord = {
308
- version: ATTEMPT_RECORD_VERSION,
479
+ const record: ClaimedAttempt = {
309
480
  attempt: n,
481
+ kind: "attempt",
310
482
  file: fields.file,
311
483
  ...(fields.workflowVersion === undefined ? {} : { workflowVersion: fields.workflowVersion }),
312
484
  flags: fields.flags,
485
+ ...(fields.launch === undefined ? {} : { launch: fields.launch }),
313
486
  pid,
314
487
  processStart: started,
315
488
  started: (options.now ?? new Date()).toISOString(),
@@ -320,11 +493,15 @@ export async function claimAttempt(
320
493
  // Unclaimed on any refusal or failure, so no attempt is left without an ending.
321
494
  const unclaim = () => unlink(file).catch(() => undefined);
322
495
  try {
496
+ await syncDir(dir);
323
497
  const before = (await readAttempts(run)).filter((other) => other.attempt < n);
324
- const refused = attemptRefusal(before, await readStageRecords(run.dir), {
325
- fromStage: options.fromStage ?? false,
326
- probe,
327
- });
498
+ // Linked first, then looked for: `awf open --no-fork` claims its file, then looks for a
499
+ // live attempt, so of the two one always sees the other.
500
+ const refused =
501
+ attemptRefusal(before, (await readRunLines(run.dir)).view, {
502
+ fromStage: options.fromStage ?? false,
503
+ probe,
504
+ }) ?? (await openedRefusal(run));
328
505
  if (refused) {
329
506
  await unclaim();
330
507
  return { refused };
@@ -340,93 +517,67 @@ export async function claimAttempt(
340
517
  }
341
518
  }
342
519
 
520
+ /**
521
+ * A background launch that ended without claiming attempt `attempt`, the number it would have
522
+ * claimed: linked as that attempt's file, failed, with no process, so `stuck` counts it. False when
523
+ * the launched attempt claimed the number after all, which then says what happened.
524
+ */
525
+ export async function recordFailedLaunch(
526
+ run: Run,
527
+ failed: {
528
+ attempt: number;
529
+ launch: string;
530
+ file: string;
531
+ started: string;
532
+ reason: string;
533
+ },
534
+ now: Date = new Date(),
535
+ ): Promise<boolean> {
536
+ const dir = attemptsDir(run.dir);
537
+ await mkdir(dir, { recursive: true, mode: 0o700 });
538
+ const record: FailedLaunch = {
539
+ attempt: failed.attempt,
540
+ kind: "failed-launch",
541
+ launch: failed.launch,
542
+ file: failed.file,
543
+ started: failed.started,
544
+ ended: now.toISOString(),
545
+ outcome: "failed",
546
+ reason: failed.reason,
547
+ };
548
+ const linked = await linkNew(join(dir, `${failed.attempt}.json`), record);
549
+ if (linked) await syncDir(dir);
550
+ return linked;
551
+ }
552
+
343
553
  /** Writes the attempt's ending into its file, whole. */
344
554
  export async function endAttempt(
345
- attempt: Attempt,
346
- ending: Pick<AttemptRecord, "stage" | "reason" | "stages" | "accounting" | "unacknowledged"> & {
347
- outcome: AttemptOutcome;
348
- },
555
+ attempt: AttemptFile,
556
+ ending: Pick<ClaimedAttempt, "stage" | "reason" | "value"> & { outcome: AttemptOutcome },
349
557
  now: Date = new Date(),
350
- ): Promise<AttemptRecord> {
351
- const { stage, reason, stages, accounting, unacknowledged } = ending;
352
- const record: AttemptRecord = {
558
+ ): Promise<ClaimedAttempt> {
559
+ const { stage, reason, value } = ending;
560
+ const record: ClaimedAttempt = {
353
561
  ...attempt.record,
354
562
  ended: now.toISOString(),
355
563
  outcome: ending.outcome,
356
564
  ...(stage === undefined ? {} : { stage }),
357
565
  ...(reason === undefined ? {} : { reason }),
358
- ...(stages === undefined ? {} : { stages }),
359
- ...(accounting === undefined ? {} : { accounting }),
360
- ...(unacknowledged === undefined ? {} : { unacknowledged }),
566
+ ...(ending.outcome === "completed" && value !== undefined ? { value } : {}),
361
567
  };
362
568
  await writeJson(attempt.file, record);
363
569
  attempt.record = record;
364
570
  return record;
365
571
  }
366
572
 
367
- /** Writes a stage's record into the run's `stages/`, whole, replacing the one before. */
368
- export async function writeStageRecord(runDir: string, record: StageRecord): Promise<void> {
369
- const dir = stagesDir(runDir);
370
- await mkdir(dir, { recursive: true, mode: 0o700 });
371
- await writeJson(join(dir, `${record.stage}.json`), record);
372
- }
373
-
374
- /** The run's current stage records, by stage. One that doesn't parse, or is newer, refuses. */
375
- export async function readStageRecords(runDir: string): Promise<Map<string, StageRecord>> {
376
- const dir = stagesDir(runDir);
377
- const names = (await entries(dir)).filter((name) => name.endsWith(".json"));
378
- const records = await Promise.all(
379
- names.map((name) => readRecord<StageRecord>(join(dir, name), STAGE_RECORD_VERSION)),
380
- );
381
- return new Map(records.map((record) => [record.stage, record]));
382
- }
383
-
384
- /**
385
- * At an attempt's start point: moves every record in `stages/` it hasn't reused to `replaced/`, as
386
- * `{stage}.{attempt}.json`, the start stage's own first. Each is one rename, so a crash part way
387
- * leaves the start stage without a record, and the next continue starts there and moves the rest.
388
- */
389
- export async function replaceStale(
390
- runDir: string,
391
- start: string,
392
- reused: ReadonlySet<string>,
393
- ): Promise<string[]> {
394
- const dir = stagesDir(runDir);
395
- const recorded = (await entries(dir))
396
- .filter((name) => name.endsWith(".json"))
397
- .map((name) => name.slice(0, -".json".length));
398
- const stale = [start, ...recorded.filter((name) => name !== start)].filter(
399
- (name) => recorded.includes(name) && !reused.has(name),
400
- );
401
- for (const name of stale) await replaceRecord(runDir, name);
402
- return stale;
403
- }
404
-
405
- /**
406
- * Moves a stage's record, unchanged, to `replaced/{stage}.{attempt}.json`, or, for a stage by
407
- * content replaced again in the same attempt, `{stage}.{attempt}+{n}.json` from 2: no name has a
408
- * `+`, so neither reads as another stage's.
409
- */
410
- export async function replaceRecord(runDir: string, stage: string): Promise<void> {
411
- const file = join(stagesDir(runDir), `${stage}.json`);
412
- const record = await readRecord<StageRecord>(file, STAGE_RECORD_VERSION);
413
- const replaced = join(runDir, "replaced");
414
- await mkdir(replaced, { recursive: true, mode: 0o700 });
415
- let target = join(replaced, `${stage}.${record.attempt}.json`);
416
- for (let n = 2; await exists(target); n++) {
417
- target = join(replaced, `${stage}.${record.attempt}+${n}.json`);
418
- }
419
- await rename(file, target);
420
- }
421
-
422
- /** Appends a line to the run's event log, `turns.jsonl`, which only the live attempt writes. */
573
+ /** Appends a line to the run's event log, `log.jsonl`, which only the live attempt writes. */
423
574
  export async function appendEvent(runDir: string, line: RunLogLine): Promise<void> {
424
- await appendLine(turnsFile(runDir), JSON.stringify(line));
575
+ await appendLine(logFile(runDir), JSON.stringify(line));
425
576
  }
426
577
 
427
578
  /** Ends a line a crash left torn, so the next line appended starts a line of its own. */
428
- export async function endTurnsLine(runDir: string): Promise<void> {
429
- await endTornLine(turnsFile(runDir));
579
+ export async function endLogLine(runDir: string): Promise<void> {
580
+ await endTornLine(logFile(runDir));
430
581
  }
431
582
 
432
583
  /** The run's event log in the order written, less a line a crash tore and the kinds this awf does not know. */
@@ -457,7 +608,7 @@ export async function readEventsFrom(
457
608
  ): Promise<{ lines: { number: number; line: RunLogLine }[]; mark: LogMark }> {
458
609
  let bytes: Buffer;
459
610
  try {
460
- const file = await open(turnsFile(runDir), "r");
611
+ const file = await open(logFile(runDir), "r");
461
612
  try {
462
613
  const { size } = await file.stat();
463
614
  bytes = Buffer.alloc(Math.max(0, size - from.offset));
@@ -498,10 +649,10 @@ export async function readEventsFrom(
498
649
 
499
650
  /** The event log's size in bytes, 0 before it is written. */
500
651
  export async function logSize(runDir: string): Promise<number> {
501
- return (await stat(turnsFile(runDir)).catch(() => undefined))?.size ?? 0;
652
+ return (await stat(logFile(runDir)).catch(() => undefined))?.size ?? 0;
502
653
  }
503
654
 
504
- async function attemptNames(run: Run): Promise<string[]> {
655
+ async function attemptNames(run: Pick<Run, "dir">): Promise<string[]> {
505
656
  return (await entries(attemptsDir(run.dir))).filter((name) => /^[1-9]\d*\.json$/.test(name));
506
657
  }
507
658
 
@@ -530,9 +681,14 @@ export async function readTurns(runDir: string): Promise<TurnRecord[]> {
530
681
  );
531
682
  }
532
683
 
533
- /** The last ended attempt's `output.json`. */
534
- export function outputFile(runDir: string): string {
535
- return join(runDir, "output.json");
684
+ /** The last ended attempt's `out.json`, a view: what `awf run --json` printed. */
685
+ export function outFile(runDir: string): string {
686
+ return join(runDir, "out.json");
687
+ }
688
+
689
+ /** Harness scratch: `skills/`, `agents/`, `staging/` and `forks/`. Only the attempt that made it uses it. */
690
+ export function workDir(runDir: string): string {
691
+ return join(runDir, "work");
536
692
  }
537
693
 
538
694
  /** The last ended attempt's `report.md`. */
@@ -540,55 +696,6 @@ export function reportFile(runDir: string): string {
540
696
  return join(runDir, "report.md");
541
697
  }
542
698
 
543
- /** Writes an operation's call, whole, before its result slot opens. */
544
- export async function writeCall(runDir: string, spec: CallSpec): Promise<void> {
545
- const dir = callDir(runDir, spec.callId);
546
- await mkdir(dir, { recursive: true });
547
- await writeJson(join(dir, "call.json"), spec);
548
- }
549
-
550
- export async function recordCandidate(
551
- runDir: string,
552
- callId: string,
553
- candidate: Candidate,
554
- ): Promise<void> {
555
- const dir = callDir(runDir, callId);
556
- await mkdir(dir, { recursive: true });
557
- await appendLine(join(dir, "candidates.jsonl"), JSON.stringify(candidate));
558
- }
559
-
560
- /** A call's candidates, as tests check them; a run never reads them back. */
561
- export async function readCandidates(runDir: string, callId: string): Promise<Candidate[]> {
562
- return readLines<Candidate>(join(callDir(runDir, callId), "candidates.jsonl"));
563
- }
564
-
565
- /** Claims an operation's one accepted result with `value`; false when another holds it. */
566
- export async function writeAcceptedExclusive(
567
- runDir: string,
568
- callId: string,
569
- value: unknown,
570
- ): Promise<boolean> {
571
- const dir = callDir(runDir, callId);
572
- await mkdir(dir, { recursive: true });
573
- return linkNew(join(dir, "result.json"), { value, at: new Date().toISOString() });
574
- }
575
-
576
- /** Null distinguishes "no value yet" from a call whose accepted value happens to be null. */
577
- export async function readAccepted(
578
- runDir: string,
579
- callId: string,
580
- ): Promise<{ value: unknown } | null> {
581
- let text: string;
582
- try {
583
- text = await readFile(join(callDir(runDir, callId), "result.json"), "utf8");
584
- } catch (error) {
585
- if (isCode(error, "ENOENT")) return null;
586
- throw error;
587
- }
588
- const { value } = JSON.parse(text) as { value: unknown };
589
- return { value };
590
- }
591
-
592
699
  function runFile(runDir: string): string {
593
700
  return join(runDir, "run.json");
594
701
  }
@@ -597,41 +704,28 @@ function attemptsDir(runDir: string): string {
597
704
  return join(runDir, "attempts");
598
705
  }
599
706
 
600
- function stagesDir(runDir: string): string {
601
- return join(runDir, "stages");
707
+ /** The run's event log, `log.jsonl`: read, never acted on. */
708
+ export function logFile(runDir: string): string {
709
+ return join(runDir, "log.jsonl");
602
710
  }
603
711
 
604
- function turnsFile(runDir: string): string {
605
- return join(runDir, "turns.jsonl");
606
- }
607
-
608
- function callDir(runDir: string, callId: string): string {
609
- return join(runDir, "calls", callId);
610
- }
611
-
612
- async function readRecord<T extends { version: number }>(file: string, known: number): Promise<T>;
613
- async function readRecord<T extends { version: number }>(
614
- file: string,
615
- known: number,
616
- options: { missing: true },
617
- ): Promise<T | undefined>;
618
- async function readRecord<T extends { version: number }>(
712
+ async function readRecord<T>(file: string): Promise<T>;
713
+ async function readRecord<T>(file: string, options: { missing: true }): Promise<T | undefined>;
714
+ async function readRecord<T>(
619
715
  file: string,
620
- known: number,
621
716
  options: { missing?: true } = {},
622
717
  ): Promise<T | undefined> {
623
- let record: T;
718
+ let record: unknown;
624
719
  try {
625
- record = JSON.parse(await readFile(file, "utf8")) as T;
720
+ record = JSON.parse(await readFile(file, "utf8"));
626
721
  } catch (error) {
627
722
  if (options.missing && isCode(error, "ENOENT")) return undefined;
628
- throw new RunRefused(`${file} could not be read: ${(error as Error).message}`);
723
+ throw new RunRefused(`${file} could not be read: ${(error as Error).message}`, "unreachable");
629
724
  }
630
- if (typeof record?.version !== "number") throw new RunRefused(`${file} has no version`);
631
- if (record.version > known) {
632
- throw new RunRefused(`${file} is version ${record.version}, newer than this awf reads`);
725
+ if (typeof record !== "object" || record === null || Array.isArray(record)) {
726
+ throw new RunRefused(`${file} could not be read: not a JSON object`, "unreachable");
633
727
  }
634
- return record;
728
+ return record as T;
635
729
  }
636
730
 
637
731
  /** So no run is committed by accident, as pytest and ruff keep their caches. */
@@ -719,7 +813,7 @@ const runPs: Ps = (pid) => {
719
813
  const PREVIOUS_GONE_MS = 5 * 60_000;
720
814
 
721
815
  /**
722
- * Waits until the attempt before `n`, a parked one closing its agents or one that crashed with
816
+ * Waits until the last attempt before `n` that ran, a parked one closing its agents or one that crashed with
723
817
  * its process left, is gone; past five minutes, cancels it, then kills it if it ignores that.
724
818
  * Its agents' sessions are then this attempt's.
725
819
  */
@@ -728,9 +822,13 @@ export async function previousGone(
728
822
  n: number,
729
823
  notice: (text: string) => void,
730
824
  ): Promise<void> {
731
- const previous = (await readAttempts(run)).find((each) => each.attempt === n - 1);
732
- if (!previous) return;
733
- const alive = () => sameProcess(previous.pid, previous.processStart, processStart);
825
+ // The last attempt before `n` that ran: a launch that never claimed its number had no process.
826
+ const previous = (await readAttempts(run)).filter((each) => each.attempt < n).at(-1);
827
+ // One that ran in this process, as a workflow's tests run attempts, has ended: waiting for it
828
+ // would be waiting for ourselves.
829
+ if (!previous || previous.pid === process.pid) return;
830
+ const { pid } = previous;
831
+ const alive = () => sameProcess(pid, previous.processStart, processStart);
734
832
  const started = Date.now();
735
833
  const giveUp = started + PREVIOUS_GONE_MS;
736
834
  let said = false;
@@ -748,7 +846,7 @@ export async function previousGone(
748
846
  // A cancel first, which closes its agents and sandboxes; only a process that ignores it is killed.
749
847
  const signal = (name: NodeJS.Signals) => {
750
848
  try {
751
- process.kill(previous.pid, name);
849
+ process.kill(pid, name);
752
850
  } catch {
753
851
  // Gone between the look and the signal.
754
852
  }