@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.
- 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 +28 -55
- package/src/agent-cli/client.ts +1 -27
- package/src/allowance-command.ts +8 -10
- package/src/attempt-ending.ts +4 -4
- package/src/attempt-output.ts +13 -31
- package/src/attempt-view.ts +33 -32
- 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 +4 -6
- package/src/coordinator-prompt.ts +4 -3
- package/src/coordinator-socket.ts +27 -28
- package/src/deadlines.ts +6 -40
- package/src/decisions/directory.ts +50 -37
- 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 +19 -33
- package/src/emit-command.ts +38 -22
- package/src/emit.ts +148 -59
- 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 +11 -7
- package/src/help/inbox.txt +5 -3
- package/src/help/logs.txt +7 -5
- package/src/help/open.txt +29 -0
- package/src/help/reply.txt +6 -8
- package/src/help/run.txt +13 -8
- 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 +5 -13
- package/src/herdr-workspace-marks.ts +7 -17
- package/src/holder-command.ts +54 -136
- package/src/in-lines.ts +28 -0
- package/src/inbox-command.ts +42 -56
- package/src/index.ts +1 -1
- package/src/init-command.ts +8 -17
- package/src/init-template/hello/workflow.ts.template +3 -2
- package/src/jsonl.ts +10 -6
- package/src/keep-alive.ts +7 -19
- package/src/logs-command.ts +134 -53
- package/src/message-operation.ts +6 -3
- package/src/messaging/messaging.ts +8 -10
- package/src/messaging/text.ts +2 -2
- 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 +206 -90
- package/src/operator-runtime.ts +24 -2
- package/src/ops.ts +270 -0
- package/src/pane-layout.ts +49 -96
- package/src/progress-view.ts +2 -2
- 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 +27 -31
- package/src/run-commands.ts +42 -114
- package/src/run-event-log.ts +1 -1
- package/src/run-lines.ts +159 -0
- package/src/run-lock.ts +57 -1
- package/src/run-policy/stage-plan.ts +154 -0
- package/src/run-policy/stages.ts +113 -0
- package/src/run-policy/waits.ts +452 -0
- package/src/run-prepare.ts +165 -41
- 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 +17 -11
- package/src/run-view.ts +73 -14
- package/src/runs-command.ts +81 -63
- package/src/runs.ts +284 -222
- 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 +2 -39
- package/src/spend.ts +333 -0
- package/src/stage-ledger.ts +111 -153
- package/src/stop-crashed.ts +6 -5
- package/src/stopped.ts +4 -4
- package/src/test-command.ts +37 -19
- package/src/testing/operation-events.ts +3 -23
- package/src/testing.ts +66 -18
- package/src/tick-command.ts +145 -15
- package/src/tools.ts +11 -30
- package/src/turn-ends.ts +37 -0
- package/src/waits.ts +651 -0
- package/src/wake.ts +84 -54
- package/src/workflow-loader.ts +49 -2
- package/src/workflow-runner.ts +525 -618
- package/src/workflow-test-preload.ts +4 -1
- package/src/workflow-testing/coordinators.ts +1 -1
- package/src/workflow-testing/fake-client.ts +387 -0
- package/src/workflow-testing/host.ts +28 -29
- package/src/workflow-testing/index.ts +9 -27
- package/src/workflow-testing/run-test.ts +201 -286
- package/src/workflow-testing/script.ts +7 -39
- package/src/deprecations.ts +0 -177
- package/src/hold-run-lock.fixture.ts +0 -8
- package/src/mailbox.ts +0 -972
- package/src/stage-plan.ts +0 -186
- package/src/workflow-testing/coordinator.typecheck.ts +0 -54
- 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
|
|
19
|
-
type
|
|
20
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
* `
|
|
37
|
-
* `
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
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
|
|
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, "
|
|
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
|
-
|
|
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, "
|
|
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 = {
|
|
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?:
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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:
|
|
293
|
-
/** `
|
|
294
|
-
| { 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[] };
|
|
295
422
|
|
|
296
423
|
/**
|
|
297
|
-
* Why the run whose attempts and
|
|
298
|
-
*
|
|
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
|
|
302
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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<
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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:
|
|
381
|
-
ending: Pick<
|
|
382
|
-
outcome: AttemptOutcome;
|
|
383
|
-
},
|
|
555
|
+
attempt: AttemptFile,
|
|
556
|
+
ending: Pick<ClaimedAttempt, "stage" | "reason" | "value"> & { outcome: AttemptOutcome },
|
|
384
557
|
now: Date = new Date(),
|
|
385
|
-
): Promise<
|
|
386
|
-
const { stage, reason,
|
|
387
|
-
const record:
|
|
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
|
-
...(
|
|
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
|
-
/**
|
|
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(
|
|
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
|
|
465
|
-
await endTornLine(
|
|
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(
|
|
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(
|
|
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 `
|
|
570
|
-
export function
|
|
571
|
-
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");
|
|
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
|
-
|
|
637
|
-
|
|
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
|
|
641
|
-
|
|
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:
|
|
718
|
+
let record: unknown;
|
|
660
719
|
try {
|
|
661
|
-
record = JSON.parse(await readFile(file, "utf8"))
|
|
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
|
|
667
|
-
|
|
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
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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(
|
|
849
|
+
process.kill(pid, name);
|
|
788
850
|
} catch {
|
|
789
851
|
// Gone between the look and the signal.
|
|
790
852
|
}
|