@agentswf/engine 0.1.0-beta.5 → 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 (122) 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 +28 -55
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +4 -4
  11. package/src/attempt-output.ts +13 -31
  12. package/src/attempt-view.ts +33 -32
  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 +4 -6
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +27 -28
  25. package/src/deadlines.ts +6 -40
  26. package/src/decisions/directory.ts +50 -37
  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 +19 -33
  32. package/src/emit-command.ts +38 -22
  33. package/src/emit.ts +148 -59
  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 +11 -7
  39. package/src/help/inbox.txt +5 -3
  40. package/src/help/logs.txt +7 -5
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +13 -8
  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 +5 -13
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +54 -136
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +42 -56
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.ts.template +3 -2
  55. package/src/jsonl.ts +10 -6
  56. package/src/keep-alive.ts +7 -19
  57. package/src/logs-command.ts +134 -53
  58. package/src/message-operation.ts +6 -3
  59. package/src/messaging/messaging.ts +8 -10
  60. package/src/messaging/text.ts +2 -2
  61. package/src/open-command.ts +770 -0
  62. package/src/operation-events.ts +4 -6
  63. package/src/operation-liveness.ts +19 -4
  64. package/src/operator-cli.ts +206 -90
  65. package/src/operator-runtime.ts +24 -2
  66. package/src/ops.ts +270 -0
  67. package/src/pane-layout.ts +49 -96
  68. package/src/progress-view.ts +2 -2
  69. package/src/project-engine.ts +79 -0
  70. package/src/projects.ts +13 -2
  71. package/src/refusals.ts +119 -0
  72. package/src/result-slots.ts +11 -31
  73. package/src/result-validation.ts +2 -2
  74. package/src/run-command.ts +27 -31
  75. package/src/run-commands.ts +42 -114
  76. package/src/run-event-log.ts +1 -1
  77. package/src/run-lines.ts +159 -0
  78. package/src/run-lock.ts +57 -1
  79. package/src/run-policy/stage-plan.ts +154 -0
  80. package/src/run-policy/stages.ts +113 -0
  81. package/src/run-policy/waits.ts +452 -0
  82. package/src/run-prepare.ts +165 -41
  83. package/src/run-read.ts +109 -41
  84. package/src/run-state.ts +170 -50
  85. package/src/run-text.ts +54 -0
  86. package/src/run-unreachable.ts +18 -0
  87. package/src/run-usage.ts +17 -11
  88. package/src/run-view.ts +73 -14
  89. package/src/runs-command.ts +81 -63
  90. package/src/runs.ts +284 -222
  91. package/src/sandbox-homes.ts +65 -13
  92. package/src/sandboxes.ts +182 -28
  93. package/src/session-lines.ts +93 -0
  94. package/src/skills/run-skills.ts +8 -1
  95. package/src/sources.ts +2 -39
  96. package/src/spend.ts +333 -0
  97. package/src/stage-ledger.ts +111 -153
  98. package/src/stop-crashed.ts +6 -5
  99. package/src/stopped.ts +4 -4
  100. package/src/test-command.ts +37 -19
  101. package/src/testing/operation-events.ts +3 -23
  102. package/src/testing.ts +66 -18
  103. package/src/tick-command.ts +145 -15
  104. package/src/tools.ts +11 -30
  105. package/src/turn-ends.ts +37 -0
  106. package/src/waits.ts +651 -0
  107. package/src/wake.ts +84 -54
  108. package/src/workflow-loader.ts +49 -2
  109. package/src/workflow-runner.ts +525 -618
  110. package/src/workflow-test-preload.ts +4 -1
  111. package/src/workflow-testing/coordinators.ts +1 -1
  112. package/src/workflow-testing/fake-client.ts +387 -0
  113. package/src/workflow-testing/host.ts +28 -29
  114. package/src/workflow-testing/index.ts +9 -27
  115. package/src/workflow-testing/run-test.ts +201 -286
  116. package/src/workflow-testing/script.ts +7 -39
  117. package/src/deprecations.ts +0 -177
  118. package/src/hold-run-lock.fixture.ts +0 -8
  119. package/src/mailbox.ts +0 -972
  120. package/src/stage-plan.ts +0 -186
  121. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  122. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -67
package/src/runs.ts CHANGED
@@ -1,44 +1,39 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import {
3
- copyFile,
4
- mkdir,
5
- open,
6
- readdir,
7
- readFile,
8
- rename,
9
- rm,
10
- stat,
11
- unlink,
12
- } from "node:fs/promises";
2
+ import { mkdir, open, readdir, readFile, rename, rm, stat, unlink } from "node:fs/promises";
13
3
  import { join } from "node:path";
14
4
  import { isDeepStrictEqual } from "node:util";
15
5
  import {
16
- ATTEMPT_RECORD_VERSION,
17
6
  type AttemptRecord,
18
- type CallSpec,
19
- type Candidate,
20
- RUN_RECORD_VERSION,
7
+ type ClaimedAttempt,
8
+ type EventLine,
9
+ type FailedLaunch,
10
+ type RefusalCode,
11
+ RUN_FORMAT,
21
12
  type RunLogLine,
22
13
  type RunPanes,
23
14
  type RunRecord,
15
+ type RunView,
24
16
  runLogLine,
25
- STAGE_RECORD_VERSION,
26
- type StageRecord,
17
+ type StageLine,
27
18
  type TurnRecord,
28
19
  } from "@agentswf/contract/records";
29
20
  import { type AttemptOutcome, nameProblem } from "@agentswf/contract/workflow";
30
- import { exists, isCode, linkNew, writeJson } from "./files";
31
- 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";
32
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";
33
29
 
34
30
  /**
35
31
  * Runs on disk: `{root}/{workflow}/{id}/`, holding `run.json`, `attempts/{attempt}.json`,
36
- * `stages/{stage}.json`, `replaced/{stage}.{attempt}.json`, `turns.jsonl`, each operation's
37
- * `calls/{id}/` with its `call.json`, `candidates.jsonl` and `result.json`, and the last ended
38
- * attempt's `output.json` and `report.md`. The only module that knows where a run's records are;
39
- * what its work keeps beside them, such as agents' bundles, skills and decisions, the module doing
40
- * that work places. Only the live attempt writes, and the two claims, a run's folder renamed into
41
- * 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.
42
37
  */
43
38
 
44
39
  /** Where a project's runs are kept, unless `--run-root` says otherwise. */
@@ -46,9 +41,20 @@ export function runRootOf(cwd: string): string {
46
41
  return join(cwd, ".awf", "runs");
47
42
  }
48
43
 
49
- /** 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
+ */
50
50
  export class RunRefused extends Error {
51
- 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
+ ) {
52
58
  super(message);
53
59
  this.name = "RunRefused";
54
60
  }
@@ -56,7 +62,14 @@ export class RunRefused extends Error {
56
62
 
57
63
  export type Run = { root: string; dir: string; record: RunRecord };
58
64
 
59
- 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 };
60
73
 
61
74
  /** The start time of process `pid` as an ISO time to the second, or undefined when it is gone. */
62
75
  export type ProcessProbe = (pid: number) => string | undefined;
@@ -71,7 +84,7 @@ export function idProblem(id: string): string | undefined {
71
84
  : `${JSON.stringify(id)} is not a valid id: letters, digits, '.', '_', '@' and '-', starting with a letter or digit, up to 128`;
72
85
  }
73
86
 
74
- /** 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. */
75
88
  export function stageNameProblem(name: string): string | undefined {
76
89
  const problem = typeof name === "string" ? nameProblem(name) : "a string";
77
90
  return problem && `${JSON.stringify(name)} is not a stage's name: ${problem}`;
@@ -80,7 +93,7 @@ export function stageNameProblem(name: string): string | undefined {
80
93
  function checkIds(...ids: string[]): void {
81
94
  for (const id of ids) {
82
95
  const problem = idProblem(id);
83
- if (problem) throw new RunRefused(problem);
96
+ if (problem) throw new RunRefused(problem, "id");
84
97
  }
85
98
  }
86
99
 
@@ -102,8 +115,10 @@ export function generateId(
102
115
  */
103
116
  export async function createRun(
104
117
  root: string,
105
- record: Omit<RunRecord, "version" | "created" | "id"> & { id?: string },
118
+ record: Omit<RunRecord, "format" | "created" | "id"> & { id?: string },
106
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,
107
122
  ): Promise<Run> {
108
123
  checkIds(record.workflow);
109
124
  await mkdir(root, { recursive: true, mode: 0o700 });
@@ -114,36 +129,47 @@ export async function createRun(
114
129
  for (let tries = 1; ; tries += 1) {
115
130
  const id = record.id ?? generateId(now);
116
131
  try {
117
- return await claimRun(root, parent, { ...record, id }, now);
132
+ return await claimRun(root, parent, { ...record, id }, now, first);
118
133
  } catch (error) {
119
134
  if (record.id !== undefined || !(error instanceof RunTaken) || tries >= 5) throw error;
120
135
  }
121
136
  }
122
137
  }
123
138
 
124
- 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
+ }
125
145
 
126
146
  async function claimRun(
127
147
  root: string,
128
148
  parent: string,
129
- record: Omit<RunRecord, "version" | "created">,
149
+ record: Omit<RunRecord, "format" | "created">,
130
150
  now: Date,
151
+ first: EventLine | undefined,
131
152
  ): Promise<Run> {
132
153
  checkIds(record.id);
133
154
  const other = await takenInCase(parent, record.id);
134
155
  if (other !== undefined) throw new RunTaken(takenMessage(other));
135
- const full: RunRecord = { version: RUN_RECORD_VERSION, ...record, created: now.toISOString() };
156
+ const full: RunRecord = { format: RUN_FORMAT, ...record, created: now.toISOString() };
136
157
  const pending = join(parent, `.new-${randomBytes(6).toString("hex")}`);
137
158
  await mkdir(pending, { mode: 0o700 });
138
159
  const dir = join(parent, record.id);
139
160
  try {
140
161
  await writeJson(runFile(pending), full);
162
+ if (first) await writeWhole(inFile(pending), `${JSON.stringify(first)}\n`);
163
+ await syncDir(pending);
141
164
  await rename(pending, dir);
142
165
  } catch (error) {
143
166
  await rm(pending, { recursive: true, force: true });
144
167
  if (isCode(error, "ENOTEMPTY", "EEXIST", "EISDIR")) throw new RunTaken(takenMessage(record.id));
145
168
  throw error;
146
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);
147
173
  return { root, dir, record: full };
148
174
  }
149
175
 
@@ -152,7 +178,7 @@ function takenMessage(id: string): string {
152
178
  }
153
179
 
154
180
  /** Another run whose id differs from `id` only in case; the same id is the rename's to refuse. */
155
- async function takenInCase(parent: string, id: string): Promise<string | undefined> {
181
+ export async function takenInCase(parent: string, id: string): Promise<string | undefined> {
156
182
  return (await entries(parent)).find(
157
183
  (name) => name !== id && name.toLowerCase() === id.toLowerCase(),
158
184
  );
@@ -163,7 +189,7 @@ export async function checkFree(root: string, workflow: string, id: string): Pro
163
189
  checkIds(workflow, id);
164
190
  const parent = join(root, workflow);
165
191
  const taken = (await exists(join(parent, id))) ? id : await takenInCase(parent, id);
166
- if (taken !== undefined) throw new RunRefused(takenMessage(taken));
192
+ if (taken !== undefined) throw new RunRefused(takenMessage(taken), "id");
167
193
  }
168
194
 
169
195
  /**
@@ -171,7 +197,7 @@ export async function checkFree(root: string, workflow: string, id: string): Pro
171
197
  * again: its first attempt was never claimed, or is `own`, which ended before it started. Whether
172
198
  * it did: not while another attempt is there.
173
199
  */
174
- export async function discardRun(run: Run, own?: Attempt): Promise<boolean> {
200
+ export async function discardRun(run: Run, own?: AttemptFile): Promise<boolean> {
175
201
  const dir = attemptsDir(run.dir);
176
202
  const others = (await entries(dir)).filter((name) => join(dir, name) !== own?.file);
177
203
  if (others.length > 0) return false;
@@ -189,17 +215,70 @@ export async function openRun(root: string, workflow: string, id: string): Promi
189
215
  if (other !== workflow && (await exists(runFile(join(root, other, id))))) {
190
216
  throw new RunRefused(
191
217
  `${id} is a run of ${other}; move ${join(root, other, id)} to ${dir} to continue it here`,
218
+ "no-run",
192
219
  );
193
220
  }
194
221
  }
195
222
  throw new RunRefused(
196
223
  `no run ${id} of ${workflow} in ${root}; a run is kept under the directory it works in, which --cwd names`,
224
+ "no-run",
197
225
  );
198
226
  }
199
- 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
+ }
200
231
  return { root, dir, record };
201
232
  }
202
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
+
203
282
  /**
204
283
  * What a continue may be given: nothing that changes the run. argv is the run's, and its working
205
284
  * directory, sandbox and panes are those it was started with.
@@ -210,17 +289,21 @@ export function checkContinue(
210
289
  ): void {
211
290
  const { id, cwd, sandbox } = run.record;
212
291
  const panes = panesChanged(run.record, given.panes);
213
- if (panes) throw new RunRefused(panes);
292
+ if (panes) throw new RunRefused(panes, "other-args");
214
293
  if (given.argv.length > 0) {
215
294
  throw new RunRefused(
216
295
  `${id} keeps the arguments it was started with; a run with others is a new run (--id)`,
296
+ "other-args",
217
297
  );
218
298
  }
219
299
  if (given.cwd !== undefined && given.cwd !== cwd) {
220
- throw new RunRefused(`${id} works in ${cwd}, not ${given.cwd}`);
300
+ throw new RunRefused(`${id} works in ${cwd}, not ${given.cwd}`, "other-args");
221
301
  }
222
302
  if (given.sandbox !== undefined && JSON.stringify(given.sandbox) !== JSON.stringify(sandbox)) {
223
- 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
+ );
224
307
  }
225
308
  }
226
309
 
@@ -246,22 +329,58 @@ export function panesChanged(record: RunRecord, given: RunPanes | undefined): st
246
329
  }
247
330
 
248
331
  /**
249
- * The run's attempts, in order. One that doesn't parse, or is newer than this awf, refuses; one
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);
352
+ }
353
+
354
+ /**
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
250
357
  * gone since the folder was listed was a refused attempt removing its own claim.
251
358
  */
252
- export async function readAttempts(run: Pick<Run, "dir">): Promise<AttemptRecord[]> {
359
+ export async function readAttemptFiles(run: Pick<Run, "dir">): Promise<AttemptRecord[]> {
253
360
  const dir = attemptsDir(run.dir);
254
361
  const numbered = await attemptNames(run);
255
362
  const records = await Promise.all(
256
- numbered.map((name) =>
257
- readRecord<AttemptRecord>(join(dir, name), ATTEMPT_RECORD_VERSION, { missing: true }),
258
- ),
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
+ }),
259
370
  );
260
371
  return records.filter((record) => record !== undefined).sort((a, b) => a.attempt - b.attempt);
261
372
  }
262
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
+
263
382
  /** No ending, and its process still the one that started it. */
264
- export function isLive(attempt: AttemptRecord, probe: ProcessProbe = processStart): boolean {
383
+ export function isLive(attempt: ClaimedAttempt, probe: ProcessProbe = processStart): boolean {
265
384
  return attempt.ended === undefined && sameProcess(attempt.pid, attempt.processStart, probe);
266
385
  }
267
386
 
@@ -287,26 +406,43 @@ export function sameStart(now: string, started: string): boolean {
287
406
  return Math.abs(Date.parse(now) - Date.parse(started)) <= 1_000;
288
407
  }
289
408
 
290
- /** 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
+ */
291
413
  export type AttemptRefusal =
292
- | { kind: "running"; attempt: AttemptRecord }
293
- /** `stages` are the run's records, in the order they began, from which `--from-stage` redoes. */
294
- | { 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[] };
295
422
 
296
423
  /**
297
- * Why the run whose attempts and stage records these are takes no attempt now, or undefined when
298
- * 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.
299
426
  */
300
427
  export function attemptRefusal(
301
- attempts: readonly AttemptRecord[],
302
- records: ReadonlyMap<string, StageRecord>,
428
+ attempts: readonly ClaimedAttempt[],
429
+ run: Pick<RunView, "stages" | "redo">,
303
430
  options: { fromStage: boolean; probe?: ProcessProbe },
304
431
  ): AttemptRefusal | undefined {
305
432
  const live = attempts.find((attempt) => isLive(attempt, options.probe));
306
433
  if (live) return { kind: "running", attempt: live };
307
- if (attempts.at(-1)?.outcome !== "completed" || options.fromStage) return undefined;
308
- const stages = [...records.values()].sort((a, b) => a.started.localeCompare(b.started));
309
- 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;
310
446
  }
311
447
 
312
448
  /**
@@ -318,7 +454,7 @@ export function attemptRefusal(
318
454
  */
319
455
  export async function claimAttempt(
320
456
  run: Run,
321
- fields: Pick<AttemptRecord, "file" | "workflowVersion" | "flags">,
457
+ fields: Pick<ClaimedAttempt, "file" | "workflowVersion" | "flags" | "launch">,
322
458
  options: {
323
459
  now?: Date;
324
460
  pid?: number;
@@ -326,7 +462,7 @@ export async function claimAttempt(
326
462
  /** `--from-stage`: a run that completed is continued only to redo a stage of it. */
327
463
  fromStage?: boolean;
328
464
  } = {},
329
- ): Promise<{ attempt: Attempt; interrupted: AttemptRecord[] } | { refused: AttemptRefusal }> {
465
+ ): Promise<{ attempt: AttemptFile; interrupted: ClaimedAttempt[] } | { refused: AttemptRefusal }> {
330
466
  const probe = options.probe ?? processStart;
331
467
  const pid = options.pid ?? process.pid;
332
468
  const started = probe(pid);
@@ -336,15 +472,17 @@ export async function claimAttempt(
336
472
  const dir = attemptsDir(run.dir);
337
473
  await mkdir(dir, { recursive: true, mode: 0o700 });
338
474
  await sweep(dir);
339
- const earlier = await readAttempts(run);
475
+ // Numbered past every file, a failed launch's included.
476
+ const earlier = await readAttemptFiles(run);
340
477
  let n = (earlier.at(-1)?.attempt ?? 0) + 1;
341
478
  for (;;) {
342
- const record: AttemptRecord = {
343
- version: ATTEMPT_RECORD_VERSION,
479
+ const record: ClaimedAttempt = {
344
480
  attempt: n,
481
+ kind: "attempt",
345
482
  file: fields.file,
346
483
  ...(fields.workflowVersion === undefined ? {} : { workflowVersion: fields.workflowVersion }),
347
484
  flags: fields.flags,
485
+ ...(fields.launch === undefined ? {} : { launch: fields.launch }),
348
486
  pid,
349
487
  processStart: started,
350
488
  started: (options.now ?? new Date()).toISOString(),
@@ -355,11 +493,15 @@ export async function claimAttempt(
355
493
  // Unclaimed on any refusal or failure, so no attempt is left without an ending.
356
494
  const unclaim = () => unlink(file).catch(() => undefined);
357
495
  try {
496
+ await syncDir(dir);
358
497
  const before = (await readAttempts(run)).filter((other) => other.attempt < n);
359
- const refused = attemptRefusal(before, await readStageRecords(run.dir), {
360
- fromStage: options.fromStage ?? false,
361
- probe,
362
- });
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));
363
505
  if (refused) {
364
506
  await unclaim();
365
507
  return { refused };
@@ -375,94 +517,67 @@ export async function claimAttempt(
375
517
  }
376
518
  }
377
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
+
378
553
  /** Writes the attempt's ending into its file, whole. */
379
554
  export async function endAttempt(
380
- attempt: Attempt,
381
- ending: Pick<AttemptRecord, "stage" | "reason" | "stages" | "accounting" | "unacknowledged"> & {
382
- outcome: AttemptOutcome;
383
- },
555
+ attempt: AttemptFile,
556
+ ending: Pick<ClaimedAttempt, "stage" | "reason" | "value"> & { outcome: AttemptOutcome },
384
557
  now: Date = new Date(),
385
- ): Promise<AttemptRecord> {
386
- const { stage, reason, stages, accounting, unacknowledged } = ending;
387
- const record: AttemptRecord = {
558
+ ): Promise<ClaimedAttempt> {
559
+ const { stage, reason, value } = ending;
560
+ const record: ClaimedAttempt = {
388
561
  ...attempt.record,
389
562
  ended: now.toISOString(),
390
563
  outcome: ending.outcome,
391
564
  ...(stage === undefined ? {} : { stage }),
392
565
  ...(reason === undefined ? {} : { reason }),
393
- ...(stages === undefined ? {} : { stages }),
394
- ...(accounting === undefined ? {} : { accounting }),
395
- ...(unacknowledged === undefined ? {} : { unacknowledged }),
566
+ ...(ending.outcome === "completed" && value !== undefined ? { value } : {}),
396
567
  };
397
568
  await writeJson(attempt.file, record);
398
569
  attempt.record = record;
399
570
  return record;
400
571
  }
401
572
 
402
- /** Writes a stage's record into the run's `stages/`, whole, replacing the one before. */
403
- export async function writeStageRecord(runDir: string, record: StageRecord): Promise<void> {
404
- const dir = stagesDir(runDir);
405
- await mkdir(dir, { recursive: true, mode: 0o700 });
406
- await writeJson(join(dir, `${record.stage}.json`), record);
407
- }
408
-
409
- /** The run's current stage records, by stage. One that doesn't parse, or is newer, refuses. */
410
- export async function readStageRecords(runDir: string): Promise<Map<string, StageRecord>> {
411
- const dir = stagesDir(runDir);
412
- const names = (await entries(dir)).filter((name) => name.endsWith(".json"));
413
- const records = await Promise.all(
414
- names.map((name) => readRecord<StageRecord>(join(dir, name), STAGE_RECORD_VERSION)),
415
- );
416
- return new Map(records.map((record) => [record.stage, record]));
417
- }
418
-
419
- /**
420
- * At an attempt's start point: moves every record in `stages/` it hasn't reused to `replaced/`, as
421
- * `{stage}.{attempt}.json`, the start stage's own first. Each is one rename, so a crash part way
422
- * leaves the start stage without a record, and the next continue starts there and moves the rest.
423
- */
424
- export async function replaceStale(
425
- runDir: string,
426
- start: string,
427
- reused: ReadonlySet<string>,
428
- ): Promise<string[]> {
429
- const dir = stagesDir(runDir);
430
- const recorded = (await entries(dir))
431
- .filter((name) => name.endsWith(".json"))
432
- .map((name) => name.slice(0, -".json".length));
433
- const stale = [start, ...recorded.filter((name) => name !== start)].filter(
434
- (name) => recorded.includes(name) && !reused.has(name),
435
- );
436
- for (const name of stale) await replaceRecord(runDir, name);
437
- return stale;
438
- }
439
-
440
- /**
441
- * Moves a stage's record, unchanged, to `replaced/{stage}.{attempt}.json`, or, for a stage by
442
- * content replaced again in the same attempt, `{stage}.{attempt}+{n}.json` from 2: no name has a
443
- * `+`, so neither reads as another stage's. `copy` leaves it in `stages/` for the new record to
444
- * overwrite, so an attempt that dies between the two leaves the old one, not none.
445
- */
446
- export async function replaceRecord(runDir: string, stage: string, copy = false): Promise<void> {
447
- const file = join(stagesDir(runDir), `${stage}.json`);
448
- const record = await readRecord<StageRecord>(file, STAGE_RECORD_VERSION);
449
- const replaced = join(runDir, "replaced");
450
- await mkdir(replaced, { recursive: true, mode: 0o700 });
451
- let target = join(replaced, `${stage}.${record.attempt}.json`);
452
- for (let n = 2; await exists(target); n++) {
453
- target = join(replaced, `${stage}.${record.attempt}+${n}.json`);
454
- }
455
- await (copy ? copyFile(file, target) : rename(file, target));
456
- }
457
-
458
- /** 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. */
459
574
  export async function appendEvent(runDir: string, line: RunLogLine): Promise<void> {
460
- await appendLine(turnsFile(runDir), JSON.stringify(line));
575
+ await appendLine(logFile(runDir), JSON.stringify(line));
461
576
  }
462
577
 
463
578
  /** Ends a line a crash left torn, so the next line appended starts a line of its own. */
464
- export async function endTurnsLine(runDir: string): Promise<void> {
465
- await endTornLine(turnsFile(runDir));
579
+ export async function endLogLine(runDir: string): Promise<void> {
580
+ await endTornLine(logFile(runDir));
466
581
  }
467
582
 
468
583
  /** The run's event log in the order written, less a line a crash tore and the kinds this awf does not know. */
@@ -493,7 +608,7 @@ export async function readEventsFrom(
493
608
  ): Promise<{ lines: { number: number; line: RunLogLine }[]; mark: LogMark }> {
494
609
  let bytes: Buffer;
495
610
  try {
496
- const file = await open(turnsFile(runDir), "r");
611
+ const file = await open(logFile(runDir), "r");
497
612
  try {
498
613
  const { size } = await file.stat();
499
614
  bytes = Buffer.alloc(Math.max(0, size - from.offset));
@@ -534,7 +649,7 @@ export async function readEventsFrom(
534
649
 
535
650
  /** The event log's size in bytes, 0 before it is written. */
536
651
  export async function logSize(runDir: string): Promise<number> {
537
- return (await stat(turnsFile(runDir)).catch(() => undefined))?.size ?? 0;
652
+ return (await stat(logFile(runDir)).catch(() => undefined))?.size ?? 0;
538
653
  }
539
654
 
540
655
  async function attemptNames(run: Pick<Run, "dir">): Promise<string[]> {
@@ -566,9 +681,14 @@ export async function readTurns(runDir: string): Promise<TurnRecord[]> {
566
681
  );
567
682
  }
568
683
 
569
- /** The last ended attempt's `output.json`. */
570
- export function outputFile(runDir: string): string {
571
- 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");
572
692
  }
573
693
 
574
694
  /** The last ended attempt's `report.md`. */
@@ -576,55 +696,6 @@ export function reportFile(runDir: string): string {
576
696
  return join(runDir, "report.md");
577
697
  }
578
698
 
579
- /** Writes an operation's call, whole, before its result slot opens. */
580
- export async function writeCall(runDir: string, spec: CallSpec): Promise<void> {
581
- const dir = callDir(runDir, spec.callId);
582
- await mkdir(dir, { recursive: true });
583
- await writeJson(join(dir, "call.json"), spec);
584
- }
585
-
586
- export async function recordCandidate(
587
- runDir: string,
588
- callId: string,
589
- candidate: Candidate,
590
- ): Promise<void> {
591
- const dir = callDir(runDir, callId);
592
- await mkdir(dir, { recursive: true });
593
- await appendLine(join(dir, "candidates.jsonl"), JSON.stringify(candidate));
594
- }
595
-
596
- /** A call's candidates, as tests check them; a run never reads them back. */
597
- export async function readCandidates(runDir: string, callId: string): Promise<Candidate[]> {
598
- return readLines<Candidate>(join(callDir(runDir, callId), "candidates.jsonl"));
599
- }
600
-
601
- /** Claims an operation's one accepted result with `value`; false when another holds it. */
602
- export async function writeAcceptedExclusive(
603
- runDir: string,
604
- callId: string,
605
- value: unknown,
606
- ): Promise<boolean> {
607
- const dir = callDir(runDir, callId);
608
- await mkdir(dir, { recursive: true });
609
- return linkNew(join(dir, "result.json"), { value, at: new Date().toISOString() });
610
- }
611
-
612
- /** Null distinguishes "no value yet" from a call whose accepted value happens to be null. */
613
- export async function readAccepted(
614
- runDir: string,
615
- callId: string,
616
- ): Promise<{ value: unknown } | null> {
617
- let text: string;
618
- try {
619
- text = await readFile(join(callDir(runDir, callId), "result.json"), "utf8");
620
- } catch (error) {
621
- if (isCode(error, "ENOENT")) return null;
622
- throw error;
623
- }
624
- const { value } = JSON.parse(text) as { value: unknown };
625
- return { value };
626
- }
627
-
628
699
  function runFile(runDir: string): string {
629
700
  return join(runDir, "run.json");
630
701
  }
@@ -633,41 +704,28 @@ function attemptsDir(runDir: string): string {
633
704
  return join(runDir, "attempts");
634
705
  }
635
706
 
636
- function stagesDir(runDir: string): string {
637
- 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");
638
710
  }
639
711
 
640
- function turnsFile(runDir: string): string {
641
- return join(runDir, "turns.jsonl");
642
- }
643
-
644
- function callDir(runDir: string, callId: string): string {
645
- return join(runDir, "calls", callId);
646
- }
647
-
648
- async function readRecord<T extends { version: number }>(file: string, known: number): Promise<T>;
649
- async function readRecord<T extends { version: number }>(
650
- file: string,
651
- known: number,
652
- options: { missing: true },
653
- ): Promise<T | undefined>;
654
- 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>(
655
715
  file: string,
656
- known: number,
657
716
  options: { missing?: true } = {},
658
717
  ): Promise<T | undefined> {
659
- let record: T;
718
+ let record: unknown;
660
719
  try {
661
- record = JSON.parse(await readFile(file, "utf8")) as T;
720
+ record = JSON.parse(await readFile(file, "utf8"));
662
721
  } catch (error) {
663
722
  if (options.missing && isCode(error, "ENOENT")) return undefined;
664
- 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");
665
724
  }
666
- if (typeof record?.version !== "number") throw new RunRefused(`${file} has no version`);
667
- if (record.version > known) {
668
- 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");
669
727
  }
670
- return record;
728
+ return record as T;
671
729
  }
672
730
 
673
731
  /** So no run is committed by accident, as pytest and ruff keep their caches. */
@@ -755,7 +813,7 @@ const runPs: Ps = (pid) => {
755
813
  const PREVIOUS_GONE_MS = 5 * 60_000;
756
814
 
757
815
  /**
758
- * 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
759
817
  * its process left, is gone; past five minutes, cancels it, then kills it if it ignores that.
760
818
  * Its agents' sessions are then this attempt's.
761
819
  */
@@ -764,9 +822,13 @@ export async function previousGone(
764
822
  n: number,
765
823
  notice: (text: string) => void,
766
824
  ): Promise<void> {
767
- const previous = (await readAttempts(run)).find((each) => each.attempt === n - 1);
768
- if (!previous) return;
769
- 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);
770
832
  const started = Date.now();
771
833
  const giveUp = started + PREVIOUS_GONE_MS;
772
834
  let said = false;
@@ -784,7 +846,7 @@ export async function previousGone(
784
846
  // A cancel first, which closes its agents and sandboxes; only a process that ignores it is killed.
785
847
  const signal = (name: NodeJS.Signals) => {
786
848
  try {
787
- process.kill(previous.pid, name);
849
+ process.kill(pid, name);
788
850
  } catch {
789
851
  // Gone between the look and the signal.
790
852
  }