@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/run-read.ts
CHANGED
|
@@ -1,27 +1,35 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import type { Activity, RunLogLine } from "@agentswf/contract/records";
|
|
3
|
+
import type { Activity, RunLine, RunLogLine } from "@agentswf/contract/records";
|
|
4
4
|
import type { AgentKey, HarnessKind } from "@agentswf/contract/workflow";
|
|
5
5
|
import { findHarness, harnessState, readActivity } from "@agentswf/harness";
|
|
6
|
+
import { messageOf } from "./errors";
|
|
7
|
+
import { readRunLines } from "./run-lines";
|
|
8
|
+
import { unfenced } from "./run-policy/waits";
|
|
6
9
|
import { readNumberedEvents } from "./runs";
|
|
7
10
|
|
|
11
|
+
/** An agent's native session, as `run.jsonl` records it. */
|
|
12
|
+
export type SessionLine = Extract<RunLine, { kind: "session" }>;
|
|
13
|
+
|
|
8
14
|
/**
|
|
9
|
-
* A native session
|
|
10
|
-
*
|
|
15
|
+
* A native session `run.jsonl` names, numbered by the line that first named it, so a session named
|
|
16
|
+
* later never renumbers the others. Its files are found under its agent's home and working
|
|
11
17
|
* directory: a sandbox's, for an agent in one.
|
|
12
18
|
*/
|
|
13
|
-
|
|
19
|
+
type NativeSession = {
|
|
14
20
|
n: number;
|
|
15
21
|
harness: HarnessKind;
|
|
16
22
|
id: string;
|
|
17
23
|
agent?: AgentKey;
|
|
24
|
+
/** The home its line names, for an agent whose opening the log lost. */
|
|
25
|
+
home?: string;
|
|
18
26
|
};
|
|
19
27
|
|
|
20
28
|
/** A span of time, open while it has no end. */
|
|
21
29
|
type Span = { from: number; to?: number };
|
|
22
30
|
|
|
23
31
|
/** What the log says of an agent: where its files are, and the spans its activity is the run's. */
|
|
24
|
-
|
|
32
|
+
type RunAgent = {
|
|
25
33
|
key: AgentKey;
|
|
26
34
|
cwd?: string;
|
|
27
35
|
home?: string;
|
|
@@ -36,8 +44,14 @@ export type RunAgent = {
|
|
|
36
44
|
|
|
37
45
|
export type RunLog = {
|
|
38
46
|
lines: { number: number; line: RunLogLine }[];
|
|
47
|
+
/** `run.jsonl`'s lines that first name a session, each with its line number there, from 1. */
|
|
48
|
+
named: { number: number; line: SessionLine }[];
|
|
49
|
+
/** How many complete lines `run.jsonl` has: where a read of it stopped. */
|
|
50
|
+
runLines: number;
|
|
39
51
|
sessions: NativeSession[];
|
|
40
52
|
agents: Map<AgentKey, RunAgent>;
|
|
53
|
+
/** Why `run.jsonl` could not be read, so no session is known. */
|
|
54
|
+
unreadSessions?: string;
|
|
41
55
|
};
|
|
42
56
|
|
|
43
57
|
/** `ended`: no attempt is running, so a span still open on the calling session ended with it. */
|
|
@@ -45,10 +59,26 @@ export async function readRunLog(
|
|
|
45
59
|
runDir: string,
|
|
46
60
|
options: { ended?: boolean } = {},
|
|
47
61
|
): Promise<RunLog> {
|
|
48
|
-
const lines = await
|
|
62
|
+
const [lines, recorded] = await Promise.all([
|
|
63
|
+
readNumberedEvents(runDir),
|
|
64
|
+
readRunLines(runDir).then(
|
|
65
|
+
({ lines }) => {
|
|
66
|
+
const kept = new Set(unfenced(lines));
|
|
67
|
+
const sessions = lines.flatMap((line, index) =>
|
|
68
|
+
line.kind === "session" && kept.has(line) ? [{ number: index + 1, line }] : [],
|
|
69
|
+
);
|
|
70
|
+
return { sessions, count: lines.length };
|
|
71
|
+
},
|
|
72
|
+
(error: unknown) => ({
|
|
73
|
+
sessions: [] as { number: number; line: SessionLine }[],
|
|
74
|
+
count: 0,
|
|
75
|
+
unread: messageOf(error),
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
]);
|
|
79
|
+
const named: RunLog["named"] = [];
|
|
49
80
|
const sessions: NativeSession[] = [];
|
|
50
81
|
const agents = new Map<AgentKey, RunAgent>();
|
|
51
|
-
const agentOf = new Map<string, AgentKey>();
|
|
52
82
|
const agent = (key: AgentKey) => {
|
|
53
83
|
const found = agents.get(key) ?? { key, caller: false, operations: [], windows: [] };
|
|
54
84
|
agents.set(key, found);
|
|
@@ -58,14 +88,21 @@ export async function readRunLog(
|
|
|
58
88
|
if (held.windows.at(-1)?.to === undefined && held.windows.length > 0) return;
|
|
59
89
|
held.windows.push({ from });
|
|
60
90
|
};
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
91
|
+
// Each session's agent is on its line; which of its activity is the run's, its agent's operations
|
|
92
|
+
// and windows below say.
|
|
93
|
+
for (const { number, line } of recorded.sessions) {
|
|
94
|
+
if (sessions.some((each) => each.harness === line.harness && each.id === line.session)) {
|
|
95
|
+
continue;
|
|
66
96
|
}
|
|
67
|
-
|
|
68
|
-
|
|
97
|
+
named.push({ number, line });
|
|
98
|
+
sessions.push({
|
|
99
|
+
n: sessions.length + 1,
|
|
100
|
+
harness: line.harness,
|
|
101
|
+
id: line.session,
|
|
102
|
+
agent: line.agent,
|
|
103
|
+
...(line.home === undefined ? {} : { home: line.home }),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
69
106
|
// An attempt that died left its spans open. An agent's end where the next attempt begins; the
|
|
70
107
|
// calling session's at the dead attempt's last line, past which its file is the operator's.
|
|
71
108
|
let attempt: number | undefined;
|
|
@@ -107,13 +144,6 @@ export async function readRunLog(
|
|
|
107
144
|
const from = Date.parse(line.deliveredAt ?? line.at);
|
|
108
145
|
if (!started.caller) open(started, from);
|
|
109
146
|
started.operations.push({ id: line.operationId, from });
|
|
110
|
-
agentOf.set(line.operationId, line.agent);
|
|
111
|
-
for (const session of line.sessions) name(session.harness, session.id, line.agent);
|
|
112
|
-
break;
|
|
113
|
-
}
|
|
114
|
-
case "session-known": {
|
|
115
|
-
const key = line.agent ?? (line.operationId ? agentOf.get(line.operationId) : undefined);
|
|
116
|
-
name(line.session.harness, line.session.id, key);
|
|
117
147
|
break;
|
|
118
148
|
}
|
|
119
149
|
case "turn":
|
|
@@ -129,7 +159,6 @@ export async function readRunLog(
|
|
|
129
159
|
if (!settled.caller) open(settled, from);
|
|
130
160
|
settled.operations.push({ id: line.operationId, from, to: to ?? at });
|
|
131
161
|
}
|
|
132
|
-
for (const session of line.sessions) name(session.harness, session.id, line.agent);
|
|
133
162
|
break;
|
|
134
163
|
}
|
|
135
164
|
case "operation-failed": {
|
|
@@ -145,7 +174,14 @@ export async function readRunLog(
|
|
|
145
174
|
}
|
|
146
175
|
}
|
|
147
176
|
if (options.ended && lastAt !== undefined) closeAll(Number.POSITIVE_INFINITY, lastAt);
|
|
148
|
-
return {
|
|
177
|
+
return {
|
|
178
|
+
lines,
|
|
179
|
+
named,
|
|
180
|
+
runLines: recorded.count,
|
|
181
|
+
sessions,
|
|
182
|
+
agents,
|
|
183
|
+
...("unread" in recorded ? { unreadSessions: recorded.unread } : {}),
|
|
184
|
+
};
|
|
149
185
|
}
|
|
150
186
|
|
|
151
187
|
const within = (span: Span, at: number) =>
|
|
@@ -177,7 +213,7 @@ const TAIL_BYTES = 4 * 1024 * 1024;
|
|
|
177
213
|
const READ_BYTES = 4 * 1024 * 1024;
|
|
178
214
|
|
|
179
215
|
/** One native file of a session, by a key its name gives, so a late file renumbers nothing. */
|
|
180
|
-
|
|
216
|
+
type SessionFile = { session: NativeSession; key: string; path: string };
|
|
181
217
|
|
|
182
218
|
/** A session's files, the first its own and keyed `""`; none where they cannot be found. */
|
|
183
219
|
async function sessionFiles(
|
|
@@ -188,7 +224,9 @@ async function sessionFiles(
|
|
|
188
224
|
const spec = findHarness(session.harness);
|
|
189
225
|
if (!spec?.sessionFiles || !spec.activityRow) return [];
|
|
190
226
|
const home =
|
|
191
|
-
agent?.home ??
|
|
227
|
+
agent?.home ??
|
|
228
|
+
session.home ??
|
|
229
|
+
harnessState()[session.harness as keyof ReturnType<typeof harnessState>];
|
|
192
230
|
if (!home) return [];
|
|
193
231
|
const found = await spec
|
|
194
232
|
.sessionFiles(home, session.id, agent?.cwd ?? runCwd)
|
|
@@ -206,27 +244,33 @@ async function sessionFiles(
|
|
|
206
244
|
}
|
|
207
245
|
|
|
208
246
|
/**
|
|
209
|
-
* Where a read stopped: the event log's last line number, and each native file's
|
|
210
|
-
* Opaque to its holder, who copies it back; versioned, so a later awf can refuse an
|
|
247
|
+
* Where a read stopped: the event log's last line number, `run.jsonl`'s, and each native file's
|
|
248
|
+
* byte offset. Opaque to its holder, who copies it back; versioned, so a later awf can refuse an
|
|
249
|
+
* old one.
|
|
211
250
|
*/
|
|
212
|
-
export type Cursor = { log: number; files: Record<string, number> };
|
|
251
|
+
export type Cursor = { log: number; run: number; files: Record<string, number> };
|
|
252
|
+
|
|
253
|
+
/** A read from the start. */
|
|
254
|
+
export const START: Cursor = Object.freeze({ log: 0, run: 0, files: Object.freeze({}) });
|
|
213
255
|
|
|
214
256
|
export function encodeCursor(cursor: Cursor): string {
|
|
215
|
-
|
|
257
|
+
const fields = [cursor.log, cursor.run, cursor.files];
|
|
258
|
+
return `c2.${Buffer.from(JSON.stringify(fields)).toString("base64url")}`;
|
|
216
259
|
}
|
|
217
260
|
|
|
218
261
|
export function decodeCursor(text: string): Cursor | undefined {
|
|
219
|
-
if (!text.startsWith("
|
|
262
|
+
if (!text.startsWith("c2.")) return undefined;
|
|
220
263
|
try {
|
|
221
|
-
const [log, files] = JSON.parse(Buffer.from(text.slice(3), "base64url").toString("utf8"));
|
|
222
|
-
|
|
264
|
+
const [log, run, files] = JSON.parse(Buffer.from(text.slice(3), "base64url").toString("utf8"));
|
|
265
|
+
const line = (n: unknown) => Number.isSafeInteger(n) && (n as number) >= 0;
|
|
266
|
+
if (!line(log) || !line(run) || typeof files !== "object" || files === null) {
|
|
223
267
|
return undefined;
|
|
224
268
|
}
|
|
225
269
|
const offsets = Object.entries(files as Record<string, unknown>);
|
|
226
270
|
if (!offsets.every(([, offset]) => Number.isSafeInteger(offset) && (offset as number) >= 0)) {
|
|
227
271
|
return undefined;
|
|
228
272
|
}
|
|
229
|
-
return { log, files: Object.fromEntries(offsets) as Record<string, number> };
|
|
273
|
+
return { log, run, files: Object.fromEntries(offsets) as Record<string, number> };
|
|
230
274
|
} catch {
|
|
231
275
|
return undefined;
|
|
232
276
|
}
|
|
@@ -237,13 +281,15 @@ function fileName(file: Pick<SessionFile, "session" | "key">): string {
|
|
|
237
281
|
return `a${file.session.n}${file.key ? `-${file.key}` : ""}`;
|
|
238
282
|
}
|
|
239
283
|
|
|
240
|
-
/** An event's handle: its line in the log
|
|
284
|
+
/** An event's handle: its line in the log or in `run.jsonl`, or its file and its place in that file. */
|
|
241
285
|
export type Handle =
|
|
242
286
|
| { kind: "event"; line: number }
|
|
287
|
+
| { kind: "run"; line: number }
|
|
243
288
|
| { kind: "activity"; file: string; offset: number; index: number };
|
|
244
289
|
|
|
245
290
|
export function encodeHandle(handle: Handle): string {
|
|
246
291
|
if (handle.kind === "event") return `e${handle.line}`;
|
|
292
|
+
if (handle.kind === "run") return `r${handle.line}`;
|
|
247
293
|
const index = handle.index > 0 ? `.${handle.index}` : "";
|
|
248
294
|
return `${handle.file}.${handle.offset.toString(16)}${index}`;
|
|
249
295
|
}
|
|
@@ -252,6 +298,8 @@ export function decodeHandle(text: string): Handle | undefined {
|
|
|
252
298
|
const plain = text.startsWith("@") ? text.slice(1) : text;
|
|
253
299
|
const event = /^e([1-9]\d*)$/.exec(plain);
|
|
254
300
|
if (event) return { kind: "event", line: Number(event[1]) };
|
|
301
|
+
const run = /^r([1-9]\d*)$/.exec(plain);
|
|
302
|
+
if (run) return { kind: "run", line: Number(run[1]) };
|
|
255
303
|
const activity = /^(a[1-9]\d*(?:-[0-9a-f]{12})?)\.([0-9a-f]+)(?:\.([1-9]\d*))?$/.exec(plain);
|
|
256
304
|
if (!activity) return undefined;
|
|
257
305
|
return {
|
|
@@ -262,7 +310,10 @@ export function decodeHandle(text: string): Handle | undefined {
|
|
|
262
310
|
};
|
|
263
311
|
}
|
|
264
312
|
|
|
265
|
-
/**
|
|
313
|
+
/**
|
|
314
|
+
* One event of a read: a line of the log, a line of `run.jsonl` that first names a session, or an
|
|
315
|
+
* agent's activity, with its handle.
|
|
316
|
+
*/
|
|
266
317
|
export type ReadEvent = {
|
|
267
318
|
handle: string;
|
|
268
319
|
at: string;
|
|
@@ -270,6 +321,7 @@ export type ReadEvent = {
|
|
|
270
321
|
time: number;
|
|
271
322
|
} & (
|
|
272
323
|
| { source: "log"; line: RunLogLine }
|
|
324
|
+
| { source: "run"; line: SessionLine }
|
|
273
325
|
| {
|
|
274
326
|
source: "activity";
|
|
275
327
|
agent?: AgentKey;
|
|
@@ -280,13 +332,14 @@ export type ReadEvent = {
|
|
|
280
332
|
);
|
|
281
333
|
|
|
282
334
|
/**
|
|
283
|
-
* Everything after `cursor`: the log's lines
|
|
284
|
-
* by time. The cursor returned is where this read stopped, past
|
|
335
|
+
* Everything after `cursor`: the log's lines, the sessions `run.jsonl` names, and each session's
|
|
336
|
+
* activity that is the run's, merged by time. The cursor returned is where this read stopped, past
|
|
337
|
+
* nothing torn.
|
|
285
338
|
*/
|
|
286
339
|
export async function readRun(
|
|
287
340
|
runDir: string,
|
|
288
341
|
runCwd: string,
|
|
289
|
-
cursor: Cursor =
|
|
342
|
+
cursor: Cursor = START,
|
|
290
343
|
options: {
|
|
291
344
|
full?: boolean;
|
|
292
345
|
ended?: boolean;
|
|
@@ -325,6 +378,20 @@ export async function readRun(
|
|
|
325
378
|
line,
|
|
326
379
|
});
|
|
327
380
|
}
|
|
381
|
+
// As the log's, `run.jsonl`'s lines keep their order.
|
|
382
|
+
let since = Number.NEGATIVE_INFINITY;
|
|
383
|
+
for (const { number, line } of log.named) {
|
|
384
|
+
if (number <= cursor.run) continue;
|
|
385
|
+
since = Math.max(since, Date.parse(line.at) || since);
|
|
386
|
+
events.push({
|
|
387
|
+
handle: encodeHandle({ kind: "run", line: number }),
|
|
388
|
+
at: line.at,
|
|
389
|
+
time: since,
|
|
390
|
+
source: "run",
|
|
391
|
+
line,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
const run = Math.max(cursor.run, log.runLines);
|
|
328
395
|
const files = { ...cursor.files };
|
|
329
396
|
// A pane's pi names its session twice, by id and by path: one file, read once.
|
|
330
397
|
const seen = new Set<string>();
|
|
@@ -396,8 +463,8 @@ export async function readRun(
|
|
|
396
463
|
events.sort((a, b) => a.time - b.time);
|
|
397
464
|
const more = horizon < Number.POSITIVE_INFINITY;
|
|
398
465
|
const beyond = events.findIndex((event) => event.time > horizon);
|
|
399
|
-
if (beyond < 0) return { events, cursor: { log: last, files }, log, cut, more };
|
|
400
|
-
const next = cutCursor(events, beyond, { log: last, files });
|
|
466
|
+
if (beyond < 0) return { events, cursor: { log: last, run, files }, log, cut, more };
|
|
467
|
+
const next = cutCursor(events, beyond, { log: last, run, files });
|
|
401
468
|
return { events: events.slice(0, beyond), cursor: next, log, cut, more };
|
|
402
469
|
}
|
|
403
470
|
|
|
@@ -406,10 +473,11 @@ export async function readRun(
|
|
|
406
473
|
* time order and the log's lines in line order among them, so nothing shown is read again.
|
|
407
474
|
*/
|
|
408
475
|
export function cutCursor(all: readonly ReadEvent[], cut: number, end: Cursor): Cursor {
|
|
409
|
-
const next: Cursor = { log: end.log, files: { ...end.files } };
|
|
476
|
+
const next: Cursor = { log: end.log, run: end.run, files: { ...end.files } };
|
|
410
477
|
for (const event of all.slice(cut)) {
|
|
411
478
|
const handle = decodeHandle(event.handle)!;
|
|
412
479
|
if (handle.kind === "event") next.log = Math.min(next.log, handle.line - 1);
|
|
480
|
+
else if (handle.kind === "run") next.run = Math.min(next.run, handle.line - 1);
|
|
413
481
|
else
|
|
414
482
|
next.files[handle.file] = Math.min(next.files[handle.file] ?? handle.offset, handle.offset);
|
|
415
483
|
}
|
package/src/run-state.ts
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
|
-
import type { AttemptRecord,
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import type { AttemptRecord, ClaimedAttempt, WaitingJson } from "@agentswf/contract/records";
|
|
3
3
|
import type { AttemptOutcome } from "@agentswf/contract/workflow";
|
|
4
4
|
import { exists } from "./files";
|
|
5
|
-
import {
|
|
6
|
-
import { listProjects, projectRunRoot } from "./projects";
|
|
5
|
+
import { checkProjectEngine } from "./project-engine";
|
|
6
|
+
import { listProjects, projectOf, projectRunRoot, rootProject } from "./projects";
|
|
7
|
+
import { lockHeld, runLockFile } from "./run-lock";
|
|
8
|
+
import { nextTimer, untakenAtReturn, type WaitState } from "./run-policy/waits";
|
|
7
9
|
import {
|
|
10
|
+
claimedOf,
|
|
8
11
|
entries,
|
|
9
12
|
idProblem,
|
|
10
13
|
openRun,
|
|
11
14
|
probeProcess,
|
|
12
15
|
type Run,
|
|
13
|
-
|
|
16
|
+
RunUnreadable,
|
|
17
|
+
readAttemptFiles,
|
|
14
18
|
sameStart,
|
|
15
19
|
} from "./runs";
|
|
20
|
+
import { readWaits } from "./waits";
|
|
16
21
|
|
|
17
22
|
/** What `ps` says of a process: its start, `gone`, or `unknown`. */
|
|
18
23
|
export type ProcessStatus = (pid: number) => string | "gone" | "unknown";
|
|
@@ -28,12 +33,20 @@ export type RunStatus = {
|
|
|
28
33
|
run: Run;
|
|
29
34
|
/** `{workflow}/{id}`. */
|
|
30
35
|
ref: string;
|
|
31
|
-
attempts:
|
|
36
|
+
/** The attempts that ran, in order: what its state is decided from. */
|
|
37
|
+
attempts: ClaimedAttempt[];
|
|
38
|
+
/** Every attempt file, in order, a background launch that never claimed its number included. */
|
|
39
|
+
files: AttemptRecord[];
|
|
32
40
|
state: RunState;
|
|
33
41
|
/** False for a `running` whose process could not be asked about. */
|
|
34
42
|
checked: boolean;
|
|
35
|
-
/** A run of a workflow with `events` at its wait, live or parked: what its
|
|
43
|
+
/** A run of a workflow with `events` at its wait, live or parked: what its last `wait` line says. */
|
|
36
44
|
waiting?: WaitingJson;
|
|
45
|
+
/**
|
|
46
|
+
* A completed run of a workflow with `events`: how many events no wait took by when it returned,
|
|
47
|
+
* from its lines. Its last batch taken was handled, as the code returned after it.
|
|
48
|
+
*/
|
|
49
|
+
unacknowledged?: number;
|
|
37
50
|
};
|
|
38
51
|
|
|
39
52
|
/** How long a run folder with no attempt yet may be an `awf run` still starting. */
|
|
@@ -44,18 +57,43 @@ export async function runStatus(
|
|
|
44
57
|
probe: ProcessStatus = probeProcess,
|
|
45
58
|
at = Date.now(),
|
|
46
59
|
): Promise<RunStatus> {
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
let status = statusOf(run, await readAttemptFiles(run), probe, at);
|
|
61
|
+
if (status.attempts.length === 0 && (status.files.length > 0 || status.state === "interrupted")) {
|
|
62
|
+
// Its launch failed, or is past the minute an attempt has to claim it: unless a live sender
|
|
63
|
+
// holds its lock, launching it, nothing runs it.
|
|
64
|
+
const { workflow, id } = run.record;
|
|
65
|
+
const launching = await lockHeld(runLockFile(run.root, workflow, id));
|
|
66
|
+
status = { ...status, state: launching ? "running" : "interrupted", checked: true };
|
|
67
|
+
}
|
|
68
|
+
if (!run.record.events) {
|
|
69
|
+
// A plain run's failed attempt is final; only a program's start, whose launches failed, sticks.
|
|
70
|
+
const stuck = status.state !== "running" && launchesFailed(status.files) >= STUCK_AFTER;
|
|
71
|
+
return stuck ? { ...status, state: "stuck" } : status;
|
|
72
|
+
}
|
|
73
|
+
// A `run.jsonl` that can't be read shows no wait; `awf runs` says why it can't be read.
|
|
74
|
+
const waiting =
|
|
75
|
+
status.state === "running" || status.state === "parked"
|
|
76
|
+
? await readWaits(run.dir, at).then(
|
|
77
|
+
(state) => waitingOf(status, state),
|
|
78
|
+
() => undefined,
|
|
79
|
+
)
|
|
80
|
+
: undefined;
|
|
81
|
+
const returned = status.state === "completed" ? status.attempts.at(-1)?.ended : undefined;
|
|
82
|
+
const unacknowledged =
|
|
83
|
+
returned === undefined
|
|
84
|
+
? undefined
|
|
85
|
+
: await readWaits(run.dir, Date.parse(returned)).then(untakenAtReturn, () => undefined);
|
|
86
|
+
const stuck = status.state !== "running" && stuckOf(status.files);
|
|
87
|
+
return {
|
|
88
|
+
...status,
|
|
89
|
+
...(waiting ? { waiting } : {}),
|
|
90
|
+
...(unacknowledged === undefined ? {} : { unacknowledged }),
|
|
91
|
+
...(stuck ? { state: "stuck" } : {}),
|
|
92
|
+
};
|
|
53
93
|
}
|
|
54
94
|
|
|
55
95
|
/** An event run's wait, while it is running or parked there: its label and `until`. */
|
|
56
|
-
function waitingOf(status: RunStatus,
|
|
57
|
-
if (status.state !== "running" && status.state !== "parked") return undefined;
|
|
58
|
-
const state = foldMailbox(records);
|
|
96
|
+
function waitingOf(status: RunStatus, state: WaitState): WaitingJson | undefined {
|
|
59
97
|
const wait = state.lastWait;
|
|
60
98
|
// A wait an earlier attempt died in is not this one's.
|
|
61
99
|
if (!wait?.waiting || wait.attempt !== status.attempts.at(-1)?.attempt) return undefined;
|
|
@@ -66,32 +104,46 @@ function waitingOf(status: RunStatus, records: readonly MailboxRecord[]): Waitin
|
|
|
66
104
|
};
|
|
67
105
|
}
|
|
68
106
|
|
|
69
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* As `runStatus`, from its attempt files as already read: decided from the attempts that ran, so a
|
|
109
|
+
* launch that never claimed its number leaves a parked run parked.
|
|
110
|
+
*/
|
|
70
111
|
export function statusOf(
|
|
71
112
|
run: Run,
|
|
72
|
-
|
|
113
|
+
files: readonly AttemptRecord[],
|
|
73
114
|
probe: ProcessStatus = probeProcess,
|
|
74
115
|
at = Date.now(),
|
|
75
116
|
): RunStatus {
|
|
117
|
+
const attempts = claimedOf(files);
|
|
118
|
+
const head = { run, ref: `${run.record.workflow}/${run.record.id}`, attempts, files: [...files] };
|
|
76
119
|
const last = attempts.at(-1);
|
|
77
|
-
const ref = `${run.record.workflow}/${run.record.id}`;
|
|
78
120
|
// Between its folder and its first attempt's claim: an `awf run` starting, or, past a minute,
|
|
79
121
|
// one refused or killed before it claimed one.
|
|
80
122
|
if (!last) {
|
|
81
123
|
const starting = at - Date.parse(run.record.created) < STARTING_MS;
|
|
82
|
-
return {
|
|
124
|
+
return { ...head, state: starting ? "running" : "interrupted", checked: !starting };
|
|
83
125
|
}
|
|
84
|
-
if (last.outcome !== undefined) return {
|
|
126
|
+
if (last.outcome !== undefined) return { ...head, state: last.outcome, checked: true };
|
|
85
127
|
const now = probe(last.pid);
|
|
86
|
-
if (now === "unknown") return {
|
|
128
|
+
if (now === "unknown") return { ...head, state: "running", checked: false };
|
|
87
129
|
const same = now !== "gone" && sameStart(now, last.processStart);
|
|
88
|
-
return {
|
|
130
|
+
return { ...head, state: same ? "running" : "interrupted", checked: true };
|
|
89
131
|
}
|
|
90
132
|
|
|
91
133
|
const ALSO_RUNNING_CHECKED = 20;
|
|
92
134
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
135
|
+
/**
|
|
136
|
+
* A reference the commands take, or why there is none to read: `no-run`, none by that ref; `id`, a
|
|
137
|
+
* ref no run could have; `ambiguous`, one naming runs of two workflows or projects.
|
|
138
|
+
*/
|
|
139
|
+
export class RunNotFound extends Error {
|
|
140
|
+
constructor(
|
|
141
|
+
message: string,
|
|
142
|
+
readonly code: "no-run" | "id" | "ambiguous" = "no-run",
|
|
143
|
+
) {
|
|
144
|
+
super(message);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
95
147
|
|
|
96
148
|
/**
|
|
97
149
|
* Every run under `runRoot`, newest `created` first. A folder whose `run.json` cannot be read is
|
|
@@ -102,16 +154,33 @@ export async function listRuns(
|
|
|
102
154
|
runRoot: string,
|
|
103
155
|
known?: (dir: string) => Run | undefined,
|
|
104
156
|
): Promise<Run[]> {
|
|
105
|
-
|
|
157
|
+
return (await listRunEntries(runRoot, known)).filter(
|
|
158
|
+
(each): each is Run => !(each instanceof RunUnreadable),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The same, with each run of a `format` this awf does not read in its place by `created`, the
|
|
164
|
+
* last when it has none: `awf runs` lists them `unreadable`.
|
|
165
|
+
*/
|
|
166
|
+
export async function listRunEntries(
|
|
167
|
+
runRoot: string,
|
|
168
|
+
known?: (dir: string) => Run | undefined,
|
|
169
|
+
): Promise<(Run | RunUnreadable)[]> {
|
|
170
|
+
const runs: (Run | RunUnreadable)[] = [];
|
|
106
171
|
for (const workflow of await entries(runRoot).catch(() => [])) {
|
|
107
172
|
for (const id of await entries(join(runRoot, workflow)).catch(() => [])) {
|
|
108
173
|
const run =
|
|
109
174
|
known?.(join(runRoot, workflow, id)) ??
|
|
110
|
-
(await openRun(runRoot, workflow, id).catch(() =>
|
|
175
|
+
(await openRun(runRoot, workflow, id).catch((error: unknown) =>
|
|
176
|
+
error instanceof RunUnreadable ? error : undefined,
|
|
177
|
+
));
|
|
111
178
|
if (run) runs.push(run);
|
|
112
179
|
}
|
|
113
180
|
}
|
|
114
|
-
|
|
181
|
+
const created = (each: Run | RunUnreadable) =>
|
|
182
|
+
each instanceof RunUnreadable ? (each.created ?? "") : each.record.created;
|
|
183
|
+
return runs.sort((a, b) => created(b).localeCompare(created(a)));
|
|
115
184
|
}
|
|
116
185
|
|
|
117
186
|
/**
|
|
@@ -139,7 +208,7 @@ export async function findRun(
|
|
|
139
208
|
if (slash >= 0) {
|
|
140
209
|
const [workflow, id] = [ref.slice(0, slash), ref.slice(slash + 1)];
|
|
141
210
|
const problem = idProblem(workflow) ?? idProblem(id);
|
|
142
|
-
if (problem) throw new RunNotFound(`no run ${ref}: ${problem}
|
|
211
|
+
if (problem) throw new RunNotFound(`no run ${ref}: ${problem}`, "id");
|
|
143
212
|
// A record that is there but cannot be read is not a missing run, and says why itself.
|
|
144
213
|
if (!(await exists(join(runRoot, workflow, id, "run.json")))) {
|
|
145
214
|
const missing = await openRun(runRoot, workflow, id).catch((error: unknown) => error);
|
|
@@ -148,11 +217,19 @@ export async function findRun(
|
|
|
148
217
|
const run = await openRun(runRoot, workflow, id);
|
|
149
218
|
return { status: await runStatus(run, probe), alsoRunning: [] };
|
|
150
219
|
}
|
|
151
|
-
const
|
|
152
|
-
|
|
220
|
+
const listed = await listRunEntries(runRoot);
|
|
221
|
+
const found = listed.filter(
|
|
222
|
+
(run): run is Run => !(run instanceof RunUnreadable) && run.record.id === ref,
|
|
223
|
+
);
|
|
224
|
+
if (found.length === 0) {
|
|
225
|
+
// A run of an earlier format says so, as it does named by its workflow.
|
|
226
|
+
const old = listed.find((run) => run instanceof RunUnreadable && run.ref.endsWith(`/${ref}`));
|
|
227
|
+
if (old) throw old;
|
|
228
|
+
throw new RunNotFound(`no run ${ref} under ${runRoot}`);
|
|
229
|
+
}
|
|
153
230
|
if (found.length > 1) {
|
|
154
231
|
const refs = found.map((run) => `${run.record.workflow}/${ref}`).join(", ");
|
|
155
|
-
throw new RunNotFound(`${ref} names a run of more than one workflow: ${refs}
|
|
232
|
+
throw new RunNotFound(`${ref} names a run of more than one workflow: ${refs}`, "ambiguous");
|
|
156
233
|
}
|
|
157
234
|
return { status: await runStatus(found[0]!, probe), alsoRunning: [] };
|
|
158
235
|
}
|
|
@@ -160,16 +237,22 @@ export async function findRun(
|
|
|
160
237
|
/** Failed or crashed attempts in a row, counted together, after which a run is `stuck` (Q13). */
|
|
161
238
|
export const STUCK_AFTER = 3;
|
|
162
239
|
|
|
240
|
+
/** Launches that never claimed their number since the last attempt that did. */
|
|
241
|
+
function launchesFailed(files: readonly AttemptRecord[]): number {
|
|
242
|
+
let n = 0;
|
|
243
|
+
for (const file of [...files].reverse()) {
|
|
244
|
+
if (file.kind !== "failed-launch") break;
|
|
245
|
+
n++;
|
|
246
|
+
}
|
|
247
|
+
return n;
|
|
248
|
+
}
|
|
249
|
+
|
|
163
250
|
/**
|
|
164
|
-
* Failed, timed-out or crashed attempts at the end of the run,
|
|
165
|
-
*
|
|
251
|
+
* Failed, timed-out or crashed attempts at the end of the run, a background launch that never
|
|
252
|
+
* claimed its number among them, as its launcher wrote it failed; any other ending resets them.
|
|
166
253
|
*/
|
|
167
|
-
function failuresInARow(
|
|
168
|
-
|
|
169
|
-
records: readonly MailboxRecord[],
|
|
170
|
-
): number {
|
|
171
|
-
const since = attempts.at(-1)?.started ?? "";
|
|
172
|
-
let n = records.filter((record) => record.kind === "launch-failed" && record.at > since).length;
|
|
254
|
+
function failuresInARow(attempts: readonly AttemptRecord[]): number {
|
|
255
|
+
let n = 0;
|
|
173
256
|
for (const attempt of [...attempts].reverse()) {
|
|
174
257
|
const failed =
|
|
175
258
|
attempt.outcome === undefined ||
|
|
@@ -185,13 +268,10 @@ function failuresInARow(
|
|
|
185
268
|
* Whether an event run is `stuck` (Q13): failed, crashed or failed to launch three times in a row,
|
|
186
269
|
* whatever its last ending says, short of one the operator or the code chose.
|
|
187
270
|
*/
|
|
188
|
-
export function stuckOf(
|
|
189
|
-
attempts: readonly AttemptRecord[],
|
|
190
|
-
records: readonly MailboxRecord[],
|
|
191
|
-
): boolean {
|
|
271
|
+
export function stuckOf(attempts: readonly AttemptRecord[]): boolean {
|
|
192
272
|
const last = attempts.at(-1)?.outcome;
|
|
193
273
|
if (last === "completed" || last === "stopped" || last === "cancelled") return false;
|
|
194
|
-
return failuresInARow(attempts
|
|
274
|
+
return failuresInARow(attempts) >= STUCK_AFTER;
|
|
195
275
|
}
|
|
196
276
|
|
|
197
277
|
/**
|
|
@@ -204,12 +284,47 @@ export async function findRunByRef(
|
|
|
204
284
|
place: { runRoot: string },
|
|
205
285
|
home: string,
|
|
206
286
|
): Promise<RunStatus> {
|
|
207
|
-
|
|
208
|
-
|
|
287
|
+
const status = await findAnywhere(ref, place, home);
|
|
288
|
+
await checkRunEngine(status, home);
|
|
289
|
+
return status;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Refuses a run this engine doesn't run: as `awf run` checked it, the project of the file it last
|
|
294
|
+
* ran, not the folder a `--cwd` put it to work in; without a file, the project it is kept in.
|
|
295
|
+
*/
|
|
296
|
+
export async function checkRunEngine(
|
|
297
|
+
status: Pick<RunStatus, "run" | "attempts">,
|
|
298
|
+
home: string,
|
|
299
|
+
): Promise<void> {
|
|
300
|
+
const file = status.attempts.at(-1)?.file ?? status.run.record.file;
|
|
301
|
+
if (file === undefined) return checkProjectEngine(runProject(status.run));
|
|
302
|
+
const project = await projectOf(file, home).catch(() => dirname(file));
|
|
303
|
+
checkProjectEngine(project, { file });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The project a run belongs to: the folder holding the `.awf/runs` it is kept in, else, under a
|
|
308
|
+
* `--run-root` of the operator's, the directory it works in.
|
|
309
|
+
*/
|
|
310
|
+
function runProject(run: Pick<Run, "root" | "record">): string {
|
|
311
|
+
return rootProject(run.root, run.record.cwd);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function findAnywhere(
|
|
315
|
+
ref: string,
|
|
316
|
+
place: { runRoot: string },
|
|
317
|
+
home: string,
|
|
318
|
+
): Promise<RunStatus> {
|
|
319
|
+
// Here first: what `--cwd` names wins. A run an earlier awf made is said only where no run
|
|
320
|
+
// that reads has the ref.
|
|
321
|
+
let here: RunNotFound | RunUnreadable;
|
|
322
|
+
let old: RunUnreadable | undefined;
|
|
209
323
|
try {
|
|
210
324
|
return (await findRun(place.runRoot, ref)).status;
|
|
211
325
|
} catch (error) {
|
|
212
|
-
if (
|
|
326
|
+
if (error instanceof RunUnreadable) old = error;
|
|
327
|
+
else if (!(error instanceof RunNotFound)) throw error;
|
|
213
328
|
here = error;
|
|
214
329
|
}
|
|
215
330
|
const found: RunStatus[] = [];
|
|
@@ -217,17 +332,22 @@ export async function findRunByRef(
|
|
|
217
332
|
try {
|
|
218
333
|
found.push((await findRun(projectRunRoot(project), ref)).status);
|
|
219
334
|
} catch (error) {
|
|
220
|
-
if (
|
|
335
|
+
if (error instanceof RunUnreadable) old ??= error;
|
|
336
|
+
else if (!(error instanceof RunNotFound)) throw error;
|
|
221
337
|
}
|
|
222
338
|
}
|
|
223
339
|
// Workflows of one name in two projects make the same ref: naming the project is the caller's.
|
|
224
340
|
if (found.length > 1)
|
|
225
341
|
throw new RunNotFound(
|
|
226
342
|
`${ref} is a run in ${found.length} projects, ${found.map((each) => each.run.record.cwd).join(" and ")}; --cwd names one`,
|
|
343
|
+
"ambiguous",
|
|
227
344
|
);
|
|
228
345
|
if (found[0]) return found[0];
|
|
346
|
+
if (old) throw old;
|
|
229
347
|
// Nowhere: a ref that can't name a run, malformed or naming two workflows, says why.
|
|
230
|
-
|
|
348
|
+
const problem = malformedRef(ref);
|
|
349
|
+
if (problem) throw new RunNotFound(`no run ${ref}: ${problem}`, "id");
|
|
350
|
+
if (here.message.includes("more than one workflow")) throw here;
|
|
231
351
|
throw new RunNotFound(`no run ${ref} under ${place.runRoot}, or in a project awf knows`);
|
|
232
352
|
}
|
|
233
353
|
|