@agentswf/engine 0.1.0-beta.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/package.json +13 -9
  4. package/src/accounting/format.ts +4 -3
  5. package/src/accounting/spent.ts +128 -0
  6. package/src/accounting/summary.ts +40 -32
  7. package/src/agent-cli/cli.ts +28 -55
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +4 -4
  11. package/src/attempt-output.ts +13 -31
  12. package/src/attempt-view.ts +33 -32
  13. package/src/author-typebox.ts +13 -0
  14. package/src/client/checks.ts +94 -0
  15. package/src/client/exported.ts +17 -0
  16. package/src/client/index.ts +122 -0
  17. package/src/client/locate.ts +34 -0
  18. package/src/client/published.ts +3 -0
  19. package/src/client/runs.ts +337 -0
  20. package/src/command-line.ts +64 -0
  21. package/src/commands.ts +7 -1
  22. package/src/control-plane.ts +4 -6
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +27 -28
  25. package/src/deadlines.ts +6 -40
  26. package/src/decisions/directory.ts +50 -37
  27. package/src/decisions/fake.ts +1 -1
  28. package/src/decisions/openrouter.ts +4 -2
  29. package/src/decisions/seam.ts +4 -4
  30. package/src/display-path.ts +3 -3
  31. package/src/duration.ts +19 -33
  32. package/src/emit-command.ts +38 -22
  33. package/src/emit.ts +148 -59
  34. package/src/event-ids.ts +31 -0
  35. package/src/files.ts +44 -4
  36. package/src/hand-over.ts +150 -0
  37. package/src/help/allowance.txt +4 -3
  38. package/src/help/emit.txt +11 -7
  39. package/src/help/inbox.txt +5 -3
  40. package/src/help/logs.txt +7 -5
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +13 -8
  44. package/src/help/runs.txt +4 -2
  45. package/src/help/tick.txt +9 -4
  46. package/src/herdr-placement.ts +3 -33
  47. package/src/herdr-run-session.ts +5 -13
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +54 -136
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +42 -56
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.ts.template +3 -2
  55. package/src/jsonl.ts +10 -6
  56. package/src/keep-alive.ts +7 -19
  57. package/src/logs-command.ts +134 -53
  58. package/src/message-operation.ts +6 -3
  59. package/src/messaging/messaging.ts +8 -10
  60. package/src/messaging/text.ts +2 -2
  61. package/src/open-command.ts +770 -0
  62. package/src/operation-events.ts +4 -6
  63. package/src/operation-liveness.ts +19 -4
  64. package/src/operator-cli.ts +206 -90
  65. package/src/operator-runtime.ts +24 -2
  66. package/src/ops.ts +270 -0
  67. package/src/pane-layout.ts +49 -96
  68. package/src/progress-view.ts +2 -2
  69. package/src/project-engine.ts +79 -0
  70. package/src/projects.ts +13 -2
  71. package/src/refusals.ts +119 -0
  72. package/src/result-slots.ts +11 -31
  73. package/src/result-validation.ts +2 -2
  74. package/src/run-command.ts +27 -31
  75. package/src/run-commands.ts +42 -114
  76. package/src/run-event-log.ts +1 -1
  77. package/src/run-lines.ts +159 -0
  78. package/src/run-lock.ts +57 -1
  79. package/src/run-policy/stage-plan.ts +154 -0
  80. package/src/run-policy/stages.ts +113 -0
  81. package/src/run-policy/waits.ts +452 -0
  82. package/src/run-prepare.ts +165 -41
  83. package/src/run-read.ts +109 -41
  84. package/src/run-state.ts +170 -50
  85. package/src/run-text.ts +54 -0
  86. package/src/run-unreachable.ts +18 -0
  87. package/src/run-usage.ts +17 -11
  88. package/src/run-view.ts +73 -14
  89. package/src/runs-command.ts +81 -63
  90. package/src/runs.ts +284 -222
  91. package/src/sandbox-homes.ts +65 -13
  92. package/src/sandboxes.ts +182 -28
  93. package/src/session-lines.ts +93 -0
  94. package/src/skills/run-skills.ts +8 -1
  95. package/src/sources.ts +2 -39
  96. package/src/spend.ts +333 -0
  97. package/src/stage-ledger.ts +111 -153
  98. package/src/stop-crashed.ts +6 -5
  99. package/src/stopped.ts +4 -4
  100. package/src/test-command.ts +37 -19
  101. package/src/testing/operation-events.ts +3 -23
  102. package/src/testing.ts +66 -18
  103. package/src/tick-command.ts +145 -15
  104. package/src/tools.ts +11 -30
  105. package/src/turn-ends.ts +37 -0
  106. package/src/waits.ts +651 -0
  107. package/src/wake.ts +84 -54
  108. package/src/workflow-loader.ts +49 -2
  109. package/src/workflow-runner.ts +525 -618
  110. package/src/workflow-test-preload.ts +4 -1
  111. package/src/workflow-testing/coordinators.ts +1 -1
  112. package/src/workflow-testing/fake-client.ts +387 -0
  113. package/src/workflow-testing/host.ts +28 -29
  114. package/src/workflow-testing/index.ts +9 -27
  115. package/src/workflow-testing/run-test.ts +201 -286
  116. package/src/workflow-testing/script.ts +7 -39
  117. package/src/deprecations.ts +0 -177
  118. package/src/hold-run-lock.fixture.ts +0 -8
  119. package/src/mailbox.ts +0 -972
  120. package/src/stage-plan.ts +0 -186
  121. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  122. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -67
package/src/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 the run's log names, numbered by the line that first named it, so a session
10
- * named later never renumbers the others. Its files are found under its agent's home and working
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
- export type NativeSession = {
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
- export type RunAgent = {
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 readNumberedEvents(runDir);
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
- const name = (harness: HarnessKind, id: string, key?: AgentKey) => {
62
- const known = sessions.find((each) => each.harness === harness && each.id === id);
63
- if (known) {
64
- if (key && !known.agent) known.agent = key;
65
- return;
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
- sessions.push({ n: sessions.length + 1, harness, id, ...(key ? { agent: key } : {}) });
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 { lines, sessions, agents };
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
- export type SessionFile = { session: NativeSession; key: string; path: string };
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 ?? harnessState()[session.harness as keyof ReturnType<typeof harnessState>];
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 byte offset.
210
- * Opaque to its holder, who copies it back; versioned, so a later awf can refuse an old one.
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
- return `c1.${Buffer.from(JSON.stringify([cursor.log, cursor.files])).toString("base64url")}`;
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("c1.")) return undefined;
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
- if (!Number.isSafeInteger(log) || log < 0 || typeof files !== "object" || files === null) {
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, or its file and its place in that file. */
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
- /** One event of a read: a line of the log, or an agent's activity, with its handle. */
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 and each session's activity that is the run's, merged
284
- * by time. The cursor returned is where this read stopped, past nothing torn.
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 = { log: 0, files: {} },
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, MailboxRecord, WaitingJson } from "@agentswf/contract/records";
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 { foldMailbox, nextTimer, readMailbox } from "./mailbox";
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
- readAttempts,
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: AttemptRecord[];
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 mailbox says. */
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
- const status = statusOf(run, await readAttempts(run), probe, at);
48
- if (!run.record.events) return status;
49
- const records = await readMailbox(run.dir);
50
- const waiting = waitingOf(status, records);
51
- const stuck = status.state !== "running" && stuckOf(status.attempts, records);
52
- return { ...status, ...(waiting ? { waiting } : {}), ...(stuck ? { state: "stuck" } : {}) };
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, records: readonly MailboxRecord[]): WaitingJson | undefined {
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
- /** As `runStatus`, from its attempts as already read. */
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
- attempts: AttemptRecord[],
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 { run, ref, attempts, state: starting ? "running" : "interrupted", checked: !starting };
124
+ return { ...head, state: starting ? "running" : "interrupted", checked: !starting };
83
125
  }
84
- if (last.outcome !== undefined) return { run, ref, attempts, state: last.outcome, checked: true };
126
+ if (last.outcome !== undefined) return { ...head, state: last.outcome, checked: true };
85
127
  const now = probe(last.pid);
86
- if (now === "unknown") return { run, ref, attempts, state: "running", checked: false };
128
+ if (now === "unknown") return { ...head, state: "running", checked: false };
87
129
  const same = now !== "gone" && sameStart(now, last.processStart);
88
- return { run, ref, attempts, state: same ? "running" : "interrupted", checked: true };
130
+ return { ...head, state: same ? "running" : "interrupted", checked: true };
89
131
  }
90
132
 
91
133
  const ALSO_RUNNING_CHECKED = 20;
92
134
 
93
- /** A reference the commands take, or why there is none to read. */
94
- export class RunNotFound extends Error {}
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
- const runs: Run[] = [];
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(() => undefined));
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
- return runs.sort((a, b) => b.record.created.localeCompare(a.record.created));
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 found = (await listRuns(runRoot)).filter((run) => run.record.id === ref);
152
- if (found.length === 0) throw new RunNotFound(`no run ${ref} under ${runRoot}`);
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, and launches since the last attempt
165
- * that never claimed one, counted together; any other ending resets them.
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
- attempts: readonly AttemptRecord[],
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, records) >= STUCK_AFTER;
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
- // Here first: what `--cwd` names wins.
208
- let here: RunNotFound;
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 (!(error instanceof RunNotFound)) throw error;
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 (!(error instanceof RunNotFound)) throw error;
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
- if (malformedRef(ref) || here.message.includes("more than one workflow")) throw here;
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