@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.
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +13 -9
- package/src/accounting/format.ts +4 -3
- package/src/accounting/spent.ts +128 -0
- package/src/accounting/summary.ts +40 -32
- package/src/agent-cli/cli.ts +35 -22
- package/src/agent-cli/client.ts +1 -27
- package/src/allowance-command.ts +8 -10
- package/src/attempt-ending.ts +5 -7
- package/src/attempt-output.ts +15 -32
- package/src/attempt-view.ts +34 -33
- package/src/author-typebox.ts +13 -0
- package/src/client/checks.ts +94 -0
- package/src/client/exported.ts +17 -0
- package/src/client/index.ts +122 -0
- package/src/client/locate.ts +34 -0
- package/src/client/published.ts +3 -0
- package/src/client/runs.ts +337 -0
- package/src/command-line.ts +64 -0
- package/src/commands.ts +7 -1
- package/src/control-plane.ts +41 -11
- package/src/coordinator-prompt.ts +4 -3
- package/src/coordinator-socket.ts +41 -33
- package/src/deadlines.ts +40 -7
- package/src/decisions/directory.ts +51 -40
- package/src/decisions/fake.ts +1 -1
- package/src/decisions/openrouter.ts +4 -2
- package/src/decisions/seam.ts +4 -4
- package/src/display-path.ts +3 -3
- package/src/duration.ts +43 -27
- package/src/emit-command.ts +88 -58
- package/src/emit.ts +254 -56
- package/src/event-ids.ts +31 -0
- package/src/files.ts +44 -4
- package/src/hand-over.ts +150 -0
- package/src/help/allowance.txt +4 -3
- package/src/help/emit.txt +18 -6
- package/src/help/inbox.txt +6 -4
- package/src/help/logs.txt +9 -6
- package/src/help/open.txt +29 -0
- package/src/help/reply.txt +6 -8
- package/src/help/run.txt +30 -11
- package/src/help/runs.txt +4 -2
- package/src/help/tick.txt +9 -4
- package/src/herdr-placement.ts +3 -33
- package/src/herdr-run-session.ts +7 -15
- package/src/herdr-workspace-marks.ts +7 -17
- package/src/holder-command.ts +56 -133
- package/src/in-lines.ts +28 -0
- package/src/inbox-command.ts +40 -57
- package/src/index.ts +1 -1
- package/src/init-command.ts +8 -17
- package/src/init-template/hello/workflow.test.ts.template +3 -3
- package/src/init-template/hello/workflow.ts.template +5 -4
- package/src/jsonl.ts +10 -6
- package/src/keep-alive.ts +15 -12
- package/src/logs-command.ts +128 -47
- package/src/message-operation.ts +6 -3
- package/src/messaging/limits.ts +4 -4
- package/src/messaging/messaging.ts +31 -28
- package/src/messaging/routes.ts +1 -1
- package/src/messaging/text.ts +9 -7
- package/src/open-command.ts +770 -0
- package/src/operation-events.ts +4 -6
- package/src/operation-liveness.ts +19 -4
- package/src/operator-cli.ts +224 -94
- package/src/operator-runtime.ts +29 -7
- package/src/ops.ts +270 -0
- package/src/pane-layout.ts +105 -59
- package/src/progress-view.ts +10 -10
- package/src/project-engine.ts +79 -0
- package/src/projects.ts +13 -2
- package/src/refusals.ts +119 -0
- package/src/result-slots.ts +11 -31
- package/src/result-validation.ts +2 -2
- package/src/run-command.ts +45 -28
- package/src/run-commands.ts +43 -115
- package/src/run-event-log.ts +1 -1
- package/src/run-lines.ts +159 -0
- package/src/run-lock.ts +57 -1
- package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
- package/src/run-policy/stages.ts +113 -0
- package/src/run-policy/waits.ts +452 -0
- package/src/run-prepare.ts +205 -43
- package/src/run-read.ts +109 -41
- package/src/run-state.ts +170 -50
- package/src/run-text.ts +54 -0
- package/src/run-unreachable.ts +18 -0
- package/src/run-usage.ts +18 -12
- package/src/run-view.ts +81 -17
- package/src/runs-command.ts +83 -63
- package/src/runs.ts +310 -212
- package/src/sandbox-homes.ts +65 -13
- package/src/sandboxes.ts +182 -28
- package/src/session-lines.ts +93 -0
- package/src/skills/run-skills.ts +8 -1
- package/src/sources.ts +28 -29
- package/src/spend.ts +333 -0
- package/src/stage-ledger.ts +119 -131
- package/src/stop-crashed.ts +6 -5
- package/src/stopped.ts +4 -4
- package/src/test-command.ts +36 -14
- package/src/testing/operation-events.ts +3 -23
- package/src/testing.ts +73 -26
- package/src/tick-command.ts +145 -15
- package/src/tools.ts +25 -16
- package/src/turn-ends.ts +37 -0
- package/src/waits.ts +651 -0
- package/src/wake.ts +84 -54
- package/src/workflow-loader.ts +54 -4
- package/src/workflow-runner.ts +805 -580
- package/src/workflow-test-preload.ts +4 -1
- package/src/workflow-testing/coordinators.ts +3 -3
- package/src/workflow-testing/fake-client.ts +387 -0
- package/src/workflow-testing/host.ts +121 -53
- package/src/workflow-testing/index.ts +33 -842
- package/src/workflow-testing/run-test.ts +1128 -0
- package/src/workflow-testing/script.ts +74 -45
- package/src/hold-run-lock.fixture.ts +0 -8
- package/src/mailbox.ts +0 -990
- package/src/workflow-testing/coordinator.typecheck.ts +0 -54
- 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
|
|
8
|
-
type
|
|
9
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
* `
|
|
25
|
-
* `
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
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
|
|
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, "
|
|
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
|
-
|
|
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, "
|
|
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 = {
|
|
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?:
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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:
|
|
258
|
-
/** `
|
|
259
|
-
| { kind: "
|
|
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
|
|
263
|
-
*
|
|
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
|
|
267
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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<
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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:
|
|
346
|
-
ending: Pick<
|
|
347
|
-
outcome: AttemptOutcome;
|
|
348
|
-
},
|
|
555
|
+
attempt: AttemptFile,
|
|
556
|
+
ending: Pick<ClaimedAttempt, "stage" | "reason" | "value"> & { outcome: AttemptOutcome },
|
|
349
557
|
now: Date = new Date(),
|
|
350
|
-
): Promise<
|
|
351
|
-
const { stage, reason,
|
|
352
|
-
const record:
|
|
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
|
-
...(
|
|
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
|
-
/**
|
|
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(
|
|
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
|
|
429
|
-
await endTornLine(
|
|
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(
|
|
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(
|
|
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 `
|
|
534
|
-
export function
|
|
535
|
-
return join(runDir, "
|
|
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
|
-
|
|
601
|
-
|
|
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
|
|
605
|
-
|
|
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:
|
|
718
|
+
let record: unknown;
|
|
624
719
|
try {
|
|
625
|
-
record = JSON.parse(await readFile(file, "utf8"))
|
|
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
|
|
631
|
-
|
|
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
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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(
|
|
849
|
+
process.kill(pid, name);
|
|
752
850
|
} catch {
|
|
753
851
|
// Gone between the look and the signal.
|
|
754
852
|
}
|