@agentswf/engine 0.0.0-stage → 0.1.0-beta.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 (77) hide show
  1. package/package.json +39 -4
  2. package/src/accounting/format.ts +252 -0
  3. package/src/accounting/plans.ts +38 -0
  4. package/src/accounting/prices.ts +98 -0
  5. package/src/accounting/summary.ts +385 -0
  6. package/src/accounting/tokens.ts +34 -0
  7. package/src/agent-launcher.ts +127 -0
  8. package/src/allowance-command.ts +105 -0
  9. package/src/attempt-ending.ts +198 -0
  10. package/src/attempt-output.ts +132 -0
  11. package/src/attempt-view.ts +362 -0
  12. package/src/canonical-json.ts +12 -0
  13. package/src/control-plane.ts +424 -0
  14. package/src/deadlines.ts +106 -0
  15. package/src/decisions/directory.ts +432 -0
  16. package/src/decisions/fake.ts +60 -0
  17. package/src/decisions/openrouter.ts +161 -0
  18. package/src/decisions/seam.ts +70 -0
  19. package/src/display-path.ts +16 -0
  20. package/src/duration.ts +12 -0
  21. package/src/errors.ts +4 -0
  22. package/src/files.ts +78 -0
  23. package/src/herdr-placement.ts +182 -0
  24. package/src/herdr-run-session.ts +530 -0
  25. package/src/herdr-workspace-marks.ts +194 -0
  26. package/src/here.ts +289 -0
  27. package/src/index.ts +5 -0
  28. package/src/jsonl.ts +74 -0
  29. package/src/logs-command.ts +605 -0
  30. package/src/machine.ts +27 -0
  31. package/src/message-operation.ts +245 -0
  32. package/src/messaging/limits.ts +35 -0
  33. package/src/messaging/messaging.ts +1303 -0
  34. package/src/messaging/routes.ts +79 -0
  35. package/src/messaging/text.ts +251 -0
  36. package/src/operation-events.ts +182 -0
  37. package/src/operation-liveness.ts +674 -0
  38. package/src/operator-aliases.ts +10 -0
  39. package/src/operator-cli.ts +569 -0
  40. package/src/operator-runtime.ts +503 -0
  41. package/src/pane-layout.ts +125 -0
  42. package/src/progress-view.ts +568 -0
  43. package/src/result-slots.ts +325 -0
  44. package/src/result-validation.ts +47 -0
  45. package/src/run-command.ts +241 -0
  46. package/src/run-commands.ts +215 -0
  47. package/src/run-event-log.ts +72 -0
  48. package/src/run-prepare.ts +178 -0
  49. package/src/run-progress.ts +137 -0
  50. package/src/run-read.ts +425 -0
  51. package/src/run-state.ts +120 -0
  52. package/src/run-usage.ts +393 -0
  53. package/src/run-view.ts +225 -0
  54. package/src/runs-command.ts +356 -0
  55. package/src/runs.ts +645 -0
  56. package/src/sandbox-homes.ts +211 -0
  57. package/src/sandboxes.ts +516 -0
  58. package/src/skills/fetch.ts +305 -0
  59. package/src/skills/run-skills.ts +181 -0
  60. package/src/skills/sources.ts +87 -0
  61. package/src/skills/tree.ts +60 -0
  62. package/src/stage-ledger.ts +535 -0
  63. package/src/stage-plan.ts +127 -0
  64. package/src/stage-schema.ts +88 -0
  65. package/src/stopped.ts +36 -0
  66. package/src/test-command.ts +110 -0
  67. package/src/testing/operation-events.ts +89 -0
  68. package/src/testing.ts +164 -0
  69. package/src/workflow-loader.ts +113 -0
  70. package/src/workflow-runner.ts +3665 -0
  71. package/src/workflow-test-preload.ts +13 -0
  72. package/src/workflow-testing/decisions.ts +129 -0
  73. package/src/workflow-testing/host.ts +811 -0
  74. package/src/workflow-testing/index.ts +420 -0
  75. package/src/workflow-testing/script.ts +447 -0
  76. package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
  77. package/README.md +0 -3
package/src/runs.ts ADDED
@@ -0,0 +1,645 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, open, readdir, readFile, rename, rm, stat, unlink } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import {
5
+ ATTEMPT_RECORD_VERSION,
6
+ type AttemptRecord,
7
+ type CallSpec,
8
+ type Candidate,
9
+ RUN_RECORD_VERSION,
10
+ type RunLogLine,
11
+ type RunRecord,
12
+ runLogLine,
13
+ STAGE_RECORD_VERSION,
14
+ type StageRecord,
15
+ type TurnRecord,
16
+ } from "@agentswf/contract/records";
17
+ import type { AttemptOutcome } from "@agentswf/contract/workflow";
18
+ import { exists, isCode, linkNew, writeJson } from "./files";
19
+ import { appendLine, endTornLine, readLines } from "./jsonl";
20
+ import { MESSAGE_LIMITS } from "./messaging/limits";
21
+
22
+ /**
23
+ * 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.
30
+ */
31
+
32
+ /** Where a project's runs are kept, unless `--run-root` says otherwise. */
33
+ export function runRootOf(cwd: string): string {
34
+ return join(cwd, ".awf", "runs");
35
+ }
36
+
37
+ /** Why awf will not start an attempt: the operator's to fix, so `awf run` exits 2. */
38
+ export class RunRefused extends Error {
39
+ constructor(message: string) {
40
+ super(message);
41
+ this.name = "RunRefused";
42
+ }
43
+ }
44
+
45
+ export type Run = { root: string; dir: string; record: RunRecord };
46
+
47
+ export type Attempt = { run: Run; file: string; record: AttemptRecord };
48
+
49
+ /** The start time of process `pid` as an ISO time to the second, or undefined when it is gone. */
50
+ export type ProcessProbe = (pid: number) => string | undefined;
51
+
52
+ const ID = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$/;
53
+
54
+ /** Why `id` can't name a run or a workflow, or undefined when it can. */
55
+ export function idProblem(id: string): string | undefined {
56
+ return ID.test(id)
57
+ ? undefined
58
+ : `${JSON.stringify(id)} is not a valid id: letters, digits, '.', '_' and '-', up to 128, not starting with '.'`;
59
+ }
60
+
61
+ const STAGE_NAME = /^[a-z][a-z0-9-]*$/;
62
+
63
+ /** Why `name` can't name a stage, which is a file in the run's `stages/`; undefined when it can. */
64
+ export function stageNameProblem(name: string): string | undefined {
65
+ return typeof name === "string" && STAGE_NAME.test(name)
66
+ ? undefined
67
+ : `${JSON.stringify(name)} is not a stage's name: lowercase letters, digits and '-', starting with a letter`;
68
+ }
69
+
70
+ function checkIds(...ids: string[]): void {
71
+ for (const id of ids) {
72
+ const problem = idProblem(id);
73
+ if (problem) throw new RunRefused(problem);
74
+ }
75
+ }
76
+
77
+ /** Local time and four random hex digits: `20261004-1532-a7f3`, sortable and short. */
78
+ export function generateId(
79
+ now: Date = new Date(),
80
+ random = randomBytes(2).toString("hex"),
81
+ ): string {
82
+ const two = (value: number) => String(value).padStart(2, "0");
83
+ const date = `${now.getFullYear()}${two(now.getMonth() + 1)}${two(now.getDate())}`;
84
+ return `${date}-${two(now.getHours())}${two(now.getMinutes())}-${random}`;
85
+ }
86
+
87
+ /**
88
+ * Claims `{root}/{workflow}/{id}` with its `run.json`: written into a folder of its own, which is
89
+ * then renamed into place. A rename onto a folder that exists and isn't empty fails, so the first
90
+ * holds the id, and a claimed folder always has its `run.json`. An id differing only in case is
91
+ * refused first, as APFS would. Without an id, one is generated, and generated again if taken.
92
+ */
93
+ export async function createRun(
94
+ root: string,
95
+ record: Omit<RunRecord, "version" | "created" | "id"> & { id?: string },
96
+ now: Date = new Date(),
97
+ ): Promise<Run> {
98
+ checkIds(record.workflow);
99
+ await mkdir(root, { recursive: true, mode: 0o700 });
100
+ await writeIgnore(root);
101
+ const parent = join(root, record.workflow);
102
+ await mkdir(parent, { recursive: true, mode: 0o700 });
103
+ await sweep(parent);
104
+ for (let tries = 1; ; tries += 1) {
105
+ const id = record.id ?? generateId(now);
106
+ try {
107
+ return await claimRun(root, parent, { ...record, id }, now);
108
+ } catch (error) {
109
+ if (record.id !== undefined || !(error instanceof RunTaken) || tries >= 5) throw error;
110
+ }
111
+ }
112
+ }
113
+
114
+ class RunTaken extends RunRefused {}
115
+
116
+ async function claimRun(
117
+ root: string,
118
+ parent: string,
119
+ record: Omit<RunRecord, "version" | "created">,
120
+ now: Date,
121
+ ): Promise<Run> {
122
+ checkIds(record.id);
123
+ const other = await takenInCase(parent, record.id);
124
+ if (other !== undefined) throw new RunTaken(takenMessage(other));
125
+ const full: RunRecord = { version: RUN_RECORD_VERSION, ...record, created: now.toISOString() };
126
+ const pending = join(parent, `.new-${randomBytes(6).toString("hex")}`);
127
+ await mkdir(pending, { mode: 0o700 });
128
+ const dir = join(parent, record.id);
129
+ try {
130
+ await writeJson(runFile(pending), full);
131
+ await rename(pending, dir);
132
+ } catch (error) {
133
+ await rm(pending, { recursive: true, force: true });
134
+ if (isCode(error, "ENOTEMPTY", "EEXIST", "EISDIR")) throw new RunTaken(takenMessage(record.id));
135
+ throw error;
136
+ }
137
+ return { root, dir, record: full };
138
+ }
139
+
140
+ function takenMessage(id: string): string {
141
+ return `${id} exists; --continue it, or --id another to start over`;
142
+ }
143
+
144
+ /** Another run whose id differs from `id` only in case; the same id is the rename's to refuse. */
145
+ async function takenInCase(parent: string, id: string): Promise<string | undefined> {
146
+ return (await entries(parent)).find(
147
+ (name) => name !== id && name.toLowerCase() === id.toLowerCase(),
148
+ );
149
+ }
150
+
151
+ /** Refuses an id that is taken, as `createRun` would, without claiming it. */
152
+ export async function checkFree(root: string, workflow: string, id: string): Promise<void> {
153
+ checkIds(workflow, id);
154
+ const parent = join(root, workflow);
155
+ const taken = (await exists(join(parent, id))) ? id : await takenInCase(parent, id);
156
+ if (taken !== undefined) throw new RunRefused(takenMessage(taken));
157
+ }
158
+
159
+ /**
160
+ * Removes a run this process created and never ran, so it leaves nothing behind and the id is free
161
+ * again: its first attempt was never claimed, or is `own`, which ended before it started. Whether
162
+ * it did: not while another attempt is there.
163
+ */
164
+ export async function discardRun(run: Run, own?: Attempt): Promise<boolean> {
165
+ const dir = attemptsDir(run.dir);
166
+ const others = (await entries(dir)).filter((name) => join(dir, name) !== own?.file);
167
+ if (others.length > 0) return false;
168
+ await rm(run.dir, { recursive: true, force: true });
169
+ return true;
170
+ }
171
+
172
+ /** The run `id` of `workflow`, naming the workflow it is under when it is another's. */
173
+ export async function openRun(root: string, workflow: string, id: string): Promise<Run> {
174
+ checkIds(workflow, id);
175
+ const dir = join(root, workflow, id);
176
+ const file = runFile(dir);
177
+ if (!(await exists(file))) {
178
+ for (const other of await entries(root)) {
179
+ if (other !== workflow && (await exists(runFile(join(root, other, id))))) {
180
+ throw new RunRefused(
181
+ `${id} is a run of ${other}; move ${join(root, other, id)} to ${dir} to continue it here`,
182
+ );
183
+ }
184
+ }
185
+ throw new RunRefused(
186
+ `no run ${id} of ${workflow} in ${root}; a run is kept under the directory it works in, which --cwd names`,
187
+ );
188
+ }
189
+ const record = await readRecord<RunRecord>(file, RUN_RECORD_VERSION);
190
+ return { root, dir, record };
191
+ }
192
+
193
+ /**
194
+ * What a continue may be given: nothing that changes the run. argv is the run's, and its working
195
+ * directory and sandbox are those it was started with.
196
+ */
197
+ export function checkContinue(
198
+ run: Run,
199
+ given: { argv: readonly string[]; cwd?: string; sandbox?: unknown },
200
+ ): void {
201
+ const { id, cwd, sandbox } = run.record;
202
+ if (given.argv.length > 0) {
203
+ throw new RunRefused(
204
+ `${id} keeps the arguments it was started with; a run with others is a new run (--id)`,
205
+ );
206
+ }
207
+ if (given.cwd !== undefined && given.cwd !== cwd) {
208
+ throw new RunRefused(`${id} works in ${cwd}, not ${given.cwd}`);
209
+ }
210
+ if (given.sandbox !== undefined && JSON.stringify(given.sandbox) !== JSON.stringify(sandbox)) {
211
+ throw new RunRefused(`${id} runs in the sandbox it was started with; leave --sandbox out`);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * The run's attempts, in order. One that doesn't parse, or is newer than this awf, refuses; one
217
+ * gone since the folder was listed was a refused attempt removing its own claim.
218
+ */
219
+ export async function readAttempts(run: Run): Promise<AttemptRecord[]> {
220
+ const dir = attemptsDir(run.dir);
221
+ const numbered = (await entries(dir)).filter((name) => /^[1-9]\d*\.json$/.test(name));
222
+ const records = await Promise.all(
223
+ numbered.map((name) =>
224
+ readRecord<AttemptRecord>(join(dir, name), ATTEMPT_RECORD_VERSION, { missing: true }),
225
+ ),
226
+ );
227
+ return records.filter((record) => record !== undefined).sort((a, b) => a.attempt - b.attempt);
228
+ }
229
+
230
+ /** No ending, and its process still the one that started it. */
231
+ export function isLive(attempt: AttemptRecord, probe: ProcessProbe = processStart): boolean {
232
+ return attempt.ended === undefined && sameProcess(attempt.pid, attempt.processStart, probe);
233
+ }
234
+
235
+ /**
236
+ * Process `pid` is there and still the one that started at `started`, which tells a reused pid
237
+ * apart. A second apart is the same start: on Linux `ps` derives it from the boot time, which a
238
+ * clock step can move.
239
+ */
240
+ export function sameProcess(
241
+ pid: number,
242
+ started: string,
243
+ probe: ProcessProbe = processStart,
244
+ ): boolean {
245
+ const now = probe(pid);
246
+ return now !== undefined && sameStart(now, started);
247
+ }
248
+
249
+ /**
250
+ * Two `ps` start times of one process: a second apart is the same start, as on Linux `ps` derives
251
+ * it from the boot time, which a clock step can move.
252
+ */
253
+ export function sameStart(now: string, started: string): boolean {
254
+ return Math.abs(Date.parse(now) - Date.parse(started)) <= 1_000;
255
+ }
256
+
257
+ /** Why a run takes no attempt now: one of its attempts is still running, or it completed. */
258
+ export type AttemptRefusal =
259
+ | { kind: "running"; attempt: AttemptRecord }
260
+ /** `stages` are the run's records, in the order they began, from which `--from-stage` redoes. */
261
+ | { kind: "completed"; stages: StageRecord[] };
262
+
263
+ /**
264
+ * Why the run whose attempts and stage records these are takes no attempt now, or undefined when
265
+ * it takes one. A completed run takes one only to redo a stage, which `fromStage` says.
266
+ */
267
+ export function attemptRefusal(
268
+ attempts: readonly AttemptRecord[],
269
+ records: ReadonlyMap<string, StageRecord>,
270
+ options: { fromStage: boolean; probe?: ProcessProbe },
271
+ ): AttemptRefusal | undefined {
272
+ const live = attempts.find((attempt) => isLive(attempt, options.probe));
273
+ if (live) return { kind: "running", attempt: live };
274
+ if (attempts.at(-1)?.outcome !== "completed" || options.fromStage) return undefined;
275
+ const stages = [...records.values()].sort((a, b) => a.started.localeCompare(b.started));
276
+ return { kind: "completed", stages };
277
+ }
278
+
279
+ /**
280
+ * Claims the next attempt by linking its file into place, which fails when the name exists, so a
281
+ * failed link tries the next number. Having claimed `n`, it reads attempts `1 … n-1`: one still
282
+ * live refuses it, and its own file goes, as it does when the last of them completed the run. Of
283
+ * two racing attempts the later always sees the earlier, since it could only pick a higher number
284
+ * once the earlier file existed. A refusal is returned, for the caller to say.
285
+ */
286
+ export async function claimAttempt(
287
+ run: Run,
288
+ fields: Pick<AttemptRecord, "file" | "workflowVersion" | "flags">,
289
+ options: {
290
+ now?: Date;
291
+ pid?: number;
292
+ probe?: ProcessProbe;
293
+ /** `--from-stage`: a run that completed is continued only to redo a stage of it. */
294
+ fromStage?: boolean;
295
+ } = {},
296
+ ): Promise<{ attempt: Attempt; interrupted: AttemptRecord[] } | { refused: AttemptRefusal }> {
297
+ const probe = options.probe ?? processStart;
298
+ const pid = options.pid ?? process.pid;
299
+ const started = probe(pid);
300
+ if (started === undefined) {
301
+ throw new Error(`ps -o lstart= gave no start time for this process (${pid})`);
302
+ }
303
+ const dir = attemptsDir(run.dir);
304
+ await mkdir(dir, { recursive: true, mode: 0o700 });
305
+ await sweep(dir);
306
+ const earlier = await readAttempts(run);
307
+ let n = (earlier.at(-1)?.attempt ?? 0) + 1;
308
+ for (;;) {
309
+ const record: AttemptRecord = {
310
+ version: ATTEMPT_RECORD_VERSION,
311
+ attempt: n,
312
+ file: fields.file,
313
+ ...(fields.workflowVersion === undefined ? {} : { workflowVersion: fields.workflowVersion }),
314
+ flags: fields.flags,
315
+ pid,
316
+ processStart: started,
317
+ started: (options.now ?? new Date()).toISOString(),
318
+ messageLimits: MESSAGE_LIMITS,
319
+ };
320
+ const file = join(dir, `${n}.json`);
321
+ if (await linkNew(file, record)) {
322
+ // Unclaimed on any refusal or failure, so no attempt is left without an ending.
323
+ const unclaim = () => unlink(file).catch(() => undefined);
324
+ try {
325
+ const before = (await readAttempts(run)).filter((other) => other.attempt < n);
326
+ const refused = attemptRefusal(before, await readStageRecords(run.dir), {
327
+ fromStage: options.fromStage ?? false,
328
+ probe,
329
+ });
330
+ if (refused) {
331
+ await unclaim();
332
+ return { refused };
333
+ }
334
+ const interrupted = before.filter((other) => other.ended === undefined);
335
+ return { attempt: { run, file, record }, interrupted };
336
+ } catch (error) {
337
+ await unclaim();
338
+ throw error;
339
+ }
340
+ }
341
+ n += 1;
342
+ }
343
+ }
344
+
345
+ /** Writes the attempt's ending into its file, whole. */
346
+ export async function endAttempt(
347
+ attempt: Attempt,
348
+ ending: Pick<AttemptRecord, "stage" | "reason" | "stages" | "accounting"> & {
349
+ outcome: AttemptOutcome;
350
+ },
351
+ now: Date = new Date(),
352
+ ): Promise<AttemptRecord> {
353
+ const { stage, reason, stages, accounting } = ending;
354
+ const record: AttemptRecord = {
355
+ ...attempt.record,
356
+ ended: now.toISOString(),
357
+ outcome: ending.outcome,
358
+ ...(stage === undefined ? {} : { stage }),
359
+ ...(reason === undefined ? {} : { reason }),
360
+ ...(stages === undefined ? {} : { stages }),
361
+ ...(accounting === undefined ? {} : { accounting }),
362
+ };
363
+ await writeJson(attempt.file, record);
364
+ attempt.record = record;
365
+ return record;
366
+ }
367
+
368
+ /** Writes a stage's record into the run's `stages/`, whole, replacing the one before. */
369
+ export async function writeStageRecord(runDir: string, record: StageRecord): Promise<void> {
370
+ const dir = stagesDir(runDir);
371
+ await mkdir(dir, { recursive: true, mode: 0o700 });
372
+ await writeJson(join(dir, `${record.stage}.json`), record);
373
+ }
374
+
375
+ /** The run's current stage records, by stage. One that doesn't parse, or is newer, refuses. */
376
+ export async function readStageRecords(runDir: string): Promise<Map<string, StageRecord>> {
377
+ const dir = stagesDir(runDir);
378
+ const names = (await entries(dir)).filter((name) => name.endsWith(".json"));
379
+ const records = await Promise.all(
380
+ names.map((name) => readRecord<StageRecord>(join(dir, name), STAGE_RECORD_VERSION)),
381
+ );
382
+ return new Map(records.map((record) => [record.stage, record]));
383
+ }
384
+
385
+ /**
386
+ * At an attempt's start point: moves every record in `stages/` it hasn't reused to `replaced/`, as
387
+ * `{stage}.{attempt}.json`, the start stage's own first. Each is one rename, so a crash part way
388
+ * leaves the start stage without a record, and the next continue starts there and moves the rest.
389
+ */
390
+ export async function replaceStale(
391
+ runDir: string,
392
+ start: string,
393
+ reused: ReadonlySet<string>,
394
+ ): Promise<string[]> {
395
+ const dir = stagesDir(runDir);
396
+ const recorded = (await entries(dir))
397
+ .filter((name) => name.endsWith(".json"))
398
+ .map((name) => name.slice(0, -".json".length));
399
+ const stale = [start, ...recorded.filter((name) => name !== start)].filter(
400
+ (name) => recorded.includes(name) && !reused.has(name),
401
+ );
402
+ for (const name of stale) await replaceRecord(runDir, name);
403
+ return stale;
404
+ }
405
+
406
+ /** Moves a stage's record, unchanged, to `replaced/{stage}.{attempt}.json`. */
407
+ export async function replaceRecord(runDir: string, stage: string): Promise<void> {
408
+ const file = join(stagesDir(runDir), `${stage}.json`);
409
+ const record = await readRecord<StageRecord>(file, STAGE_RECORD_VERSION);
410
+ const replaced = join(runDir, "replaced");
411
+ await mkdir(replaced, { recursive: true, mode: 0o700 });
412
+ await rename(file, join(replaced, `${stage}.${record.attempt}.json`));
413
+ }
414
+
415
+ /** Appends a line to the run's event log, `turns.jsonl`, which only the live attempt writes. */
416
+ export async function appendEvent(runDir: string, line: RunLogLine): Promise<void> {
417
+ await appendLine(turnsFile(runDir), JSON.stringify(line));
418
+ }
419
+
420
+ /** Ends a line a crash left torn, so the next line appended starts a line of its own. */
421
+ export async function endTurnsLine(runDir: string): Promise<void> {
422
+ await endTornLine(turnsFile(runDir));
423
+ }
424
+
425
+ /** The run's event log in the order written, less a line a crash tore and the kinds this awf does not know. */
426
+ export async function readEvents(runDir: string): Promise<RunLogLine[]> {
427
+ return (await readNumberedEvents(runDir)).map(({ line }) => line);
428
+ }
429
+
430
+ /**
431
+ * The same, each with its line number in the file, from 1: its position, which never changes, as
432
+ * lines are only appended.
433
+ */
434
+ export async function readNumberedEvents(
435
+ runDir: string,
436
+ ): Promise<{ number: number; line: RunLogLine }[]> {
437
+ const text = await readFile(turnsFile(runDir), "utf8").catch((error) => {
438
+ if (isCode(error, "ENOENT")) return "";
439
+ throw error;
440
+ });
441
+ const lines = text.split("\n");
442
+ // A last line not yet ended is still being written.
443
+ lines.pop();
444
+ return lines.flatMap((json, index) => {
445
+ try {
446
+ const value: unknown = JSON.parse(json);
447
+ const line = typeof value === "object" && value !== null ? runLogLine(value) : undefined;
448
+ return line ? [{ number: index + 1, line }] : [];
449
+ } catch {
450
+ return [];
451
+ }
452
+ });
453
+ }
454
+
455
+ /** Every turn of the run, in the order they settled. */
456
+ export async function readTurns(runDir: string): Promise<TurnRecord[]> {
457
+ return (await readEvents(runDir)).filter(
458
+ (line): line is TurnRecord => line.kind === "turn" || line.kind === "compact",
459
+ );
460
+ }
461
+
462
+ /** The last ended attempt's `output.json`. */
463
+ export function outputFile(runDir: string): string {
464
+ return join(runDir, "output.json");
465
+ }
466
+
467
+ /** The last ended attempt's `report.md`. */
468
+ export function reportFile(runDir: string): string {
469
+ return join(runDir, "report.md");
470
+ }
471
+
472
+ /** Writes an operation's call, whole, before its result slot opens. */
473
+ export async function writeCall(runDir: string, spec: CallSpec): Promise<void> {
474
+ const dir = callDir(runDir, spec.callId);
475
+ await mkdir(dir, { recursive: true });
476
+ await writeJson(join(dir, "call.json"), spec);
477
+ }
478
+
479
+ export async function recordCandidate(
480
+ runDir: string,
481
+ callId: string,
482
+ candidate: Candidate,
483
+ ): Promise<void> {
484
+ const dir = callDir(runDir, callId);
485
+ await mkdir(dir, { recursive: true });
486
+ await appendLine(join(dir, "candidates.jsonl"), JSON.stringify(candidate));
487
+ }
488
+
489
+ /** A call's candidates, as tests check them; a run never reads them back. */
490
+ export async function readCandidates(runDir: string, callId: string): Promise<Candidate[]> {
491
+ return readLines<Candidate>(join(callDir(runDir, callId), "candidates.jsonl"));
492
+ }
493
+
494
+ /** Claims an operation's one accepted result with `value`; false when another holds it. */
495
+ export async function writeAcceptedExclusive(
496
+ runDir: string,
497
+ callId: string,
498
+ value: unknown,
499
+ ): Promise<boolean> {
500
+ const dir = callDir(runDir, callId);
501
+ await mkdir(dir, { recursive: true });
502
+ return linkNew(join(dir, "result.json"), { value, at: new Date().toISOString() });
503
+ }
504
+
505
+ /** Null distinguishes "no value yet" from a call whose accepted value happens to be null. */
506
+ export async function readAccepted(
507
+ runDir: string,
508
+ callId: string,
509
+ ): Promise<{ value: unknown } | null> {
510
+ let text: string;
511
+ try {
512
+ text = await readFile(join(callDir(runDir, callId), "result.json"), "utf8");
513
+ } catch (error) {
514
+ if (isCode(error, "ENOENT")) return null;
515
+ throw error;
516
+ }
517
+ const { value } = JSON.parse(text) as { value: unknown };
518
+ return { value };
519
+ }
520
+
521
+ function runFile(runDir: string): string {
522
+ return join(runDir, "run.json");
523
+ }
524
+
525
+ function attemptsDir(runDir: string): string {
526
+ return join(runDir, "attempts");
527
+ }
528
+
529
+ function stagesDir(runDir: string): string {
530
+ return join(runDir, "stages");
531
+ }
532
+
533
+ function turnsFile(runDir: string): string {
534
+ return join(runDir, "turns.jsonl");
535
+ }
536
+
537
+ function callDir(runDir: string, callId: string): string {
538
+ return join(runDir, "calls", callId);
539
+ }
540
+
541
+ async function readRecord<T extends { version: number }>(file: string, known: number): Promise<T>;
542
+ async function readRecord<T extends { version: number }>(
543
+ file: string,
544
+ known: number,
545
+ options: { missing: true },
546
+ ): Promise<T | undefined>;
547
+ async function readRecord<T extends { version: number }>(
548
+ file: string,
549
+ known: number,
550
+ options: { missing?: true } = {},
551
+ ): Promise<T | undefined> {
552
+ let record: T;
553
+ try {
554
+ record = JSON.parse(await readFile(file, "utf8")) as T;
555
+ } catch (error) {
556
+ if (options.missing && isCode(error, "ENOENT")) return undefined;
557
+ throw new RunRefused(`${file} could not be read: ${(error as Error).message}`);
558
+ }
559
+ if (typeof record?.version !== "number") throw new RunRefused(`${file} has no version`);
560
+ if (record.version > known) {
561
+ throw new RunRefused(`${file} is version ${record.version}, newer than this awf reads`);
562
+ }
563
+ return record;
564
+ }
565
+
566
+ /** So no run is committed by accident, as pytest and ruff keep their caches. */
567
+ async function writeIgnore(root: string): Promise<void> {
568
+ try {
569
+ const handle = await open(join(root, ".gitignore"), "wx", 0o644);
570
+ try {
571
+ await handle.writeFile("*\n");
572
+ } finally {
573
+ await handle.close();
574
+ }
575
+ } catch (error) {
576
+ if (!isCode(error, "EEXIST")) throw error;
577
+ }
578
+ }
579
+
580
+ const DAY_MS = 24 * 60 * 60_000;
581
+
582
+ /** Removes temp files and folders a crash left, once they are a day old: no writer is still on them. */
583
+ async function sweep(dir: string, now = Date.now()): Promise<void> {
584
+ const names = await readdir(dir).catch(() => [] as string[]);
585
+ for (const name of names) {
586
+ if (!name.startsWith(".tmp-") && !name.startsWith(".new-")) continue;
587
+ const path = join(dir, name);
588
+ const info = await stat(path).catch(() => undefined);
589
+ if (info && now - info.mtimeMs > DAY_MS) await rm(path, { recursive: true, force: true });
590
+ }
591
+ }
592
+
593
+ /** A folder's entries, sorted, without hidden ones (temp files, `.gitignore`); none when it is not there. */
594
+ export async function entries(dir: string): Promise<string[]> {
595
+ const names = await readdir(dir).catch((error) => {
596
+ if (isCode(error, "ENOENT")) return [] as string[];
597
+ throw error;
598
+ });
599
+ return names.filter((name) => !name.startsWith(".")).sort();
600
+ }
601
+
602
+ /**
603
+ * `ps -o lstart=` in UTC, as an ISO time to the second: the same on macOS and Linux, and whatever
604
+ * time zone the attempt that wrote it and the one checking it each run in.
605
+ */
606
+ export function processStart(pid: number): string | undefined {
607
+ const found = probeProcess(pid);
608
+ return found === "gone" || found === "unknown" ? undefined : found;
609
+ }
610
+
611
+ /**
612
+ * What `ps` says of process `pid`: when it started, as `processStart` gives it, `gone`, or
613
+ * `unknown` where `ps` could not be asked, as in a sandbox that forbids it.
614
+ */
615
+ export function probeProcess(pid: number, ps: Ps = runPs): string | "gone" | "unknown" {
616
+ if (!Number.isSafeInteger(pid) || pid <= 0) return "gone";
617
+ let done: ReturnType<Ps>;
618
+ try {
619
+ done = ps(pid);
620
+ } catch {
621
+ return "unknown";
622
+ }
623
+ const text = done.stdout.trim();
624
+ // `ps` exits 1 and prints nothing at all for a pid that is not there; one that cannot take these
625
+ // options, as BusyBox's, says so on stderr.
626
+ if (text === "") return done.exitCode === 1 && done.stderr.trim() === "" ? "gone" : "unknown";
627
+ const time = new Date(`${text} UTC`);
628
+ return Number.isNaN(time.getTime()) ? "unknown" : time.toISOString().replace(/\.\d{3}Z$/, "Z");
629
+ }
630
+
631
+ /** `ps -o lstart= -p {pid}`, as it answered. */
632
+ export type Ps = (pid: number) => { stdout: string; stderr: string; exitCode: number | null };
633
+
634
+ const runPs: Ps = (pid) => {
635
+ const done = Bun.spawnSync(["ps", "-o", "lstart=", "-p", String(pid)], {
636
+ stdout: "pipe",
637
+ stderr: "pipe",
638
+ env: { ...process.env, LC_ALL: "C", TZ: "UTC0" },
639
+ });
640
+ return {
641
+ stdout: done.stdout?.toString() ?? "",
642
+ stderr: done.stderr?.toString() ?? "",
643
+ exitCode: done.exitCode,
644
+ };
645
+ };