@agentswf/engine 0.1.0-beta.2 → 0.1.0-beta.4

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 (82) hide show
  1. package/package.json +5 -6
  2. package/src/agent-cli/cli.ts +571 -0
  3. package/src/agent-cli/client.ts +115 -0
  4. package/src/agent-launcher.ts +15 -25
  5. package/src/allowance-command.ts +31 -21
  6. package/src/attempt-ending.ts +23 -5
  7. package/src/attempt-output.ts +48 -18
  8. package/src/attempt-view.ts +25 -15
  9. package/src/commands.ts +176 -0
  10. package/src/control-plane.ts +73 -22
  11. package/src/coordinator-port.ts +29 -0
  12. package/src/coordinator-prompt.ts +237 -0
  13. package/src/coordinator-socket.ts +236 -0
  14. package/src/deadlines.ts +47 -10
  15. package/src/decisions/directory.ts +8 -6
  16. package/src/duration.ts +29 -0
  17. package/src/emit-command.ts +119 -0
  18. package/src/emit.ts +246 -0
  19. package/src/files.ts +11 -1
  20. package/src/help/allowance.txt +13 -0
  21. package/src/help/emit.txt +13 -0
  22. package/src/help/inbox.txt +12 -0
  23. package/src/help/init.txt +12 -0
  24. package/src/help/logs.txt +13 -0
  25. package/src/help/reply.txt +18 -0
  26. package/src/help/run.txt +44 -0
  27. package/src/help/runs.txt +11 -0
  28. package/src/help/test.txt +9 -0
  29. package/src/help/tick.txt +12 -0
  30. package/src/here.ts +9 -3
  31. package/src/hold-run-lock.fixture.ts +8 -0
  32. package/src/holder-command.ts +279 -0
  33. package/src/inbox-command.ts +715 -0
  34. package/src/init-command.ts +29 -11
  35. package/src/init-template/AGENTS.md.template +4 -4
  36. package/src/init-template/hello/workflow.test.ts.template +2 -2
  37. package/src/init-template/hello/workflow.ts.template +16 -20
  38. package/src/keep-alive.ts +40 -0
  39. package/src/logs-command.ts +106 -35
  40. package/src/machine.ts +12 -1
  41. package/src/mailbox.ts +990 -0
  42. package/src/message-operation.ts +26 -3
  43. package/src/messaging/limits.ts +1 -1
  44. package/src/messaging/messaging.ts +478 -85
  45. package/src/messaging/routes.ts +11 -4
  46. package/src/messaging/text.ts +25 -22
  47. package/src/operation-liveness.ts +55 -9
  48. package/src/operator-cli.ts +290 -119
  49. package/src/operator-runtime.ts +8 -8
  50. package/src/progress-view.ts +49 -7
  51. package/src/projects.ts +57 -0
  52. package/src/result-slots.ts +43 -19
  53. package/src/result-validation.ts +18 -27
  54. package/src/run-command.ts +29 -66
  55. package/src/run-commands.ts +13 -2
  56. package/src/run-lock.ts +123 -0
  57. package/src/run-prepare.ts +94 -22
  58. package/src/run-state.ts +125 -6
  59. package/src/run-usage.ts +2 -2
  60. package/src/run-view.ts +0 -9
  61. package/src/runs-command.ts +43 -25
  62. package/src/runs.ts +142 -23
  63. package/src/sandboxes.ts +11 -4
  64. package/src/simple-input.ts +268 -0
  65. package/src/socket-exchange.ts +26 -0
  66. package/src/sources.ts +70 -0
  67. package/src/stage-ledger.ts +89 -14
  68. package/src/stage-plan.ts +23 -0
  69. package/src/stop-crashed.ts +39 -0
  70. package/src/test-command.ts +34 -11
  71. package/src/testing.ts +72 -4
  72. package/src/tick-command.ts +76 -0
  73. package/src/tools.ts +645 -0
  74. package/src/wake.ts +264 -0
  75. package/src/workflow-loader.ts +32 -22
  76. package/src/workflow-runner.ts +1277 -97
  77. package/src/workflow-testing/coordinator.typecheck.ts +54 -0
  78. package/src/workflow-testing/coordinators.ts +137 -0
  79. package/src/workflow-testing/host.ts +67 -7
  80. package/src/workflow-testing/index.ts +488 -34
  81. package/src/workflow-testing/script.ts +74 -4
  82. package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
@@ -0,0 +1,115 @@
1
+ import { createConnection, type Socket } from "node:net";
2
+ import {
3
+ type ControlRequest,
4
+ decodeResponseFor,
5
+ type ResponseFor,
6
+ type ResultSubmitRequest,
7
+ type ResultSubmitResponse,
8
+ type WaitingRequest,
9
+ type WaitingResponse,
10
+ } from "@agentswf/contract/wire";
11
+
12
+ const MAX_RESPONSE_BYTES = 256 * 1024;
13
+
14
+ /** The connection never opened: nothing was written, so nothing reached the engine. */
15
+ export class NotSentError extends Error {}
16
+
17
+ export function submitResult(
18
+ endpoint: string,
19
+ request: ResultSubmitRequest,
20
+ timeoutSeconds = 30,
21
+ connect: (endpoint: string) => Socket = createConnection,
22
+ ): Promise<ResultSubmitResponse> {
23
+ return submitControl(endpoint, request, timeoutSeconds, connect);
24
+ }
25
+
26
+ export function submitWaiting(
27
+ endpoint: string,
28
+ request: WaitingRequest,
29
+ timeoutSeconds = 30,
30
+ connect: (endpoint: string) => Socket = createConnection,
31
+ ): Promise<WaitingResponse> {
32
+ return submitControl(endpoint, request, timeoutSeconds, connect);
33
+ }
34
+
35
+ /** Sends one request and resolves with a response that answers its command. */
36
+ export function submitControl<R extends ControlRequest>(
37
+ endpoint: string,
38
+ request: R,
39
+ /** Null waits as long as the engine takes, as a tool call does: it answers once the call settles. */
40
+ timeoutSeconds: number | null = 30,
41
+ connect: (endpoint: string) => Socket = createConnection,
42
+ ): Promise<ResponseFor[R["command"]]> {
43
+ type Response = ResponseFor[R["command"]];
44
+ if (timeoutSeconds !== null && (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)) {
45
+ return Promise.reject(new Error("control-plane timeout must be positive"));
46
+ }
47
+ const outgoing = Buffer.from(`${JSON.stringify(request)}\n`);
48
+
49
+ return new Promise<Response>((resolve, reject) => {
50
+ const chunks: Buffer[] = [];
51
+ let bytes = 0;
52
+ let settled = false;
53
+ let connected = false;
54
+ const socket = connect(endpoint);
55
+ const lifetime =
56
+ timeoutSeconds === null
57
+ ? undefined
58
+ : setTimeout(
59
+ () => fail(new Error("control plane response timed out")),
60
+ timeoutSeconds * 1000,
61
+ );
62
+
63
+ const fail = (error: Error): void => {
64
+ if (settled) return;
65
+ settled = true;
66
+ clearTimeout(lifetime);
67
+ socket.destroy();
68
+ reject(error);
69
+ };
70
+
71
+ socket.once("connect", () => {
72
+ connected = true;
73
+ socket.end(outgoing);
74
+ });
75
+ socket.on("data", (data) => {
76
+ if (settled) return;
77
+ const chunk = typeof data === "string" ? Buffer.from(data) : data;
78
+ bytes += chunk.byteLength;
79
+ if (bytes > MAX_RESPONSE_BYTES) {
80
+ fail(new Error("control-plane response exceeds the size limit"));
81
+ return;
82
+ }
83
+ chunks.push(chunk);
84
+ });
85
+ socket.once("end", () => {
86
+ if (settled) return;
87
+ const frame = Buffer.concat(chunks).toString("utf8");
88
+ if (!frame.endsWith("\n") || frame.slice(0, -1).includes("\n")) {
89
+ fail(new Error("control plane returned an invalid response frame"));
90
+ return;
91
+ }
92
+ let parsed: unknown;
93
+ try {
94
+ parsed = JSON.parse(frame.slice(0, -1));
95
+ } catch {
96
+ fail(new Error("control plane returned invalid JSON"));
97
+ return;
98
+ }
99
+ const decoded = decodeResponseFor<R["command"]>(request.command, parsed);
100
+ if (!decoded.ok) {
101
+ fail(new Error(decoded.error));
102
+ return;
103
+ }
104
+ settled = true;
105
+ clearTimeout(lifetime);
106
+ resolve(decoded.value);
107
+ });
108
+ socket.once("error", (error) =>
109
+ fail(connected ? error : new NotSentError(error.message, { cause: error })),
110
+ );
111
+ socket.once("close", () => {
112
+ if (!settled) fail(new Error("control plane closed without a response"));
113
+ });
114
+ });
115
+ }
@@ -1,9 +1,11 @@
1
- import { chmod, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
2
- import { createRequire } from "node:module";
3
- import { basename, dirname, join, resolve } from "node:path";
1
+ import { chmod, mkdir, realpath, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
4
  import { type AgentDoor, shellQuote } from "@agentswf/sandbox";
5
5
 
6
- /** bun loads the `.env` of the directory it starts in: an agent's, which is none of `wf`'s. */
6
+ const AGENT_CLI = fileURLToPath(new URL("./agent-cli/cli.ts", import.meta.url));
7
+
8
+ /** bun loads the `.env` of the directory it starts in: an agent's, which is none of `wfa`'s. */
7
9
  const NO_ENV = "--no-env-file";
8
10
 
9
11
  /**
@@ -27,19 +29,18 @@ export async function installAgentLauncher(
27
29
  */
28
30
  sessionEnv?: string,
29
31
  ): Promise<string> {
30
- const command = await resolveAgentCommand();
31
- return writeLauncher(directory, [process.execPath, NO_ENV, command], endpoint, sessionEnv);
32
+ return writeLauncher(directory, [process.execPath, NO_ENV, AGENT_CLI], endpoint, sessionEnv);
32
33
  }
33
34
 
34
35
  /**
35
- * A sandboxed agent's door: the same launcher, running the bundled `wf` with the host's bun by
36
+ * A sandboxed agent's door: the same launcher, running the bundled `wfa` with the host's bun by
36
37
  * its real path, since a sandbox reads neither the workspace's sources nor bun's links under `~`.
37
38
  * A box copies `boxScript` to the launcher's path, which runs the bundle with the box's own bun.
38
39
  */
39
40
  export async function installSandboxedDoor(
40
41
  directory: string,
41
42
  endpoint: string,
42
- /** The bundled `wf`'s source, written beside the launcher. */
43
+ /** The bundled `wfa`'s source, written beside the launcher. */
43
44
  source: string,
44
45
  sessionEnv?: string,
45
46
  ): Promise<AgentDoor> {
@@ -50,7 +51,7 @@ export async function installSandboxedDoor(
50
51
  const socket = join(real, basename(endpoint));
51
52
  // In the agent's own directory: bun will not load a module from the control plane's, which is
52
53
  // not listable so that no agent can find another's socket.
53
- const bundle = join(real, "wf.js");
54
+ const bundle = join(real, "wfa.js");
54
55
  await writeFile(bundle, source, { mode: 0o500 });
55
56
  const launcher = await writeLauncher(real, [bun, NO_ENV, bundle], socket, sessionEnv);
56
57
  return {
@@ -64,12 +65,12 @@ export async function installSandboxedDoor(
64
65
  }
65
66
 
66
67
  /**
67
- * `wf` as one file's source, which a sandbox can read where it cannot read this workspace. Built by
68
+ * `wfa` as one file's source, which a sandbox can read where it cannot read this workspace. Built by
68
69
  * a `bun build` of its own: in process, the bundler resolved packages against the test runner's
69
70
  * state and missed the workspace's links.
70
71
  */
71
72
  export async function buildAgentBundle(): Promise<string> {
72
- const entry = await resolveAgentCommand();
73
+ const entry = AGENT_CLI;
73
74
  const built = Bun.spawn({
74
75
  cmd: [process.execPath, "build", entry, "--target=bun"],
75
76
  cwd: dirname(entry),
@@ -81,7 +82,7 @@ export async function buildAgentBundle(): Promise<string> {
81
82
  new Response(built.stdout).text(),
82
83
  new Response(built.stderr).text(),
83
84
  ]);
84
- if (code !== 0) throw new Error(`could not bundle wf: ${stderr.trim().slice(0, 400)}`);
85
+ if (code !== 0) throw new Error(`could not bundle wfa: ${stderr.trim().slice(0, 400)}`);
85
86
  return bundle;
86
87
  }
87
88
 
@@ -93,7 +94,7 @@ async function writeLauncher(
93
94
  ): Promise<string> {
94
95
  await mkdir(directory, { recursive: true });
95
96
  await chmod(directory, 0o700);
96
- const path = join(directory, "wf");
97
+ const path = join(directory, "wfa");
97
98
  // Not writable, so an agent that redirects into its own launcher fails rather than replacing its
98
99
  // only way to answer. A `chmod` still gets past it; a sandbox denies the write outright.
99
100
  await writeFile(path, launcherScript(command, endpoint, sessionEnv, path), { mode: 0o500 });
@@ -105,7 +106,7 @@ function launcherScript(
105
106
  command: readonly string[],
106
107
  endpoint: string,
107
108
  sessionEnv: string | undefined,
108
- /** Its own path, which `wf` names in what it prints, as an agent must type it. */
109
+ /** Its own path, which `wfa` names in what it prints, as an agent must type it. */
109
110
  path: string,
110
111
  ): string {
111
112
  if (sessionEnv !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sessionEnv)) {
@@ -114,14 +115,3 @@ function launcherScript(
114
115
  const session = sessionEnv ? ` --session "\${${sessionEnv}:-}"` : "";
115
116
  return `#!/bin/sh\nexec ${command.map(shellQuote).join(" ")} --at ${shellQuote(endpoint)} --as ${shellQuote(path)}${session} "$@"\n`;
116
117
  }
117
-
118
- async function resolveAgentCommand(): Promise<string> {
119
- const require = createRequire(import.meta.url);
120
- const manifestPath = require.resolve("@agentswf/wf/package.json");
121
- const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
122
- bin?: string | Record<string, string>;
123
- };
124
- const relative = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.wf;
125
- if (!relative) throw new Error("@agentswf/wf does not publish the wf command");
126
- return resolve(dirname(manifestPath), relative);
127
- }
@@ -1,26 +1,15 @@
1
- import {
2
- ALLOWANCE_VERSION,
3
- type AllowanceReport,
4
- type AllowanceWindow,
5
- type HarnessAllowance,
1
+ import type {
2
+ AllowanceReport,
3
+ AllowanceWindow,
4
+ HarnessAllowance,
6
5
  } from "@agentswf/contract/records";
7
6
  import { HARNESS_NAMES, type Harness } from "@agentswf/harness";
8
- import { PLAN_BASIS, planPrice } from "./accounting/plans";
7
+ import { planPrice } from "./accounting/plans";
8
+ import { messageOf } from "./errors";
9
+ import help from "./help/allowance.txt" with { type: "text" };
10
+ import type { Out } from "./run-commands";
9
11
 
10
- export const allowanceUsage = [
11
- "usage: awf allowance [harness...] [--json]",
12
- "",
13
- "What is left of each harness's plan, as the harness itself shows it: claude's /usage, codex's",
14
- "/status, and cursor's /usage, read in a Herdr pane. Nothing asks a model. Every harness when",
15
- `none is named: ${HARNESS_NAMES.join(", ")}.`,
16
- "",
17
- "A plan's monthly price is the vendor's list price as of the table's date, not what was",
18
- `charged: ${PLAN_BASIS}.`,
19
- "",
20
- `--json prints an ${ALLOWANCE_VERSION} record, the interface for scripts, with the plan as the`,
21
- "harness names it and no price; what it prints otherwise is for reading. A harness that could",
22
- "not be read says why. Exits 0 once every harness was asked, 2 on a usage error.",
23
- ].join("\n");
12
+ export const allowanceUsage = help.trimEnd();
24
13
 
25
14
  export type AllowanceCommand = { harnesses: Harness[]; json: boolean } | "help";
26
15
 
@@ -37,6 +26,27 @@ export function parseAllowanceCommand(argv: readonly string[]): AllowanceCommand
37
26
  return { harnesses: harnesses.length > 0 ? harnesses : [...HARNESS_NAMES], json };
38
27
  }
39
28
 
29
+ /** `awf allowance`: what is left of each harness's plan, read with `read`. */
30
+ export async function allowanceCommand(
31
+ argv: readonly string[],
32
+ io: Out & { read: (harness: Harness) => Promise<HarnessAllowance>; now: () => number },
33
+ ): Promise<number> {
34
+ let command: AllowanceCommand;
35
+ try {
36
+ command = parseAllowanceCommand(argv);
37
+ } catch (error) {
38
+ io.stderr(`awf: ${messageOf(error)}\n\n${allowanceUsage}`);
39
+ return 2;
40
+ }
41
+ if (command === "help") {
42
+ io.stdout(allowanceUsage);
43
+ return 0;
44
+ }
45
+ const report = await readReport(command.harnesses, io.read, io.now());
46
+ io.stdout(command.json ? JSON.stringify(report, null, 2) : describeReport(report));
47
+ return 0;
48
+ }
49
+
40
50
  /** Every harness asked at once: cursor's pane takes seconds the others need not wait for. */
41
51
  export async function readReport(
42
52
  harnesses: readonly Harness[],
@@ -44,7 +54,7 @@ export async function readReport(
44
54
  now: number,
45
55
  ): Promise<AllowanceReport> {
46
56
  return {
47
- version: ALLOWANCE_VERSION,
57
+ v: 1,
48
58
  readAt: new Date(now).toISOString(),
49
59
  harnesses: await Promise.all(harnesses.map(read)),
50
60
  };
@@ -12,6 +12,7 @@ import {
12
12
  import { PUBLISHED_PRICES } from "./accounting/prices";
13
13
  import { summarizeRun } from "./accounting/summary";
14
14
  import { messageOf } from "./errors";
15
+ import { RunParked } from "./mailbox";
15
16
  import type { Paint } from "./progress-view";
16
17
  import { FromStageUnreached, primaryFailure, WorkflowStopped } from "./stopped";
17
18
  import {
@@ -53,6 +54,7 @@ export const OUTCOMES: Record<
53
54
  { word: string; told: string; mark: (paint: Paint) => string; exitCode?: number }
54
55
  > = {
55
56
  completed: { word: "completed", told: "completed", mark: (p) => p.ok("✓"), exitCode: 0 },
57
+ parked: { word: "parked", told: "parked", mark: (p) => p.ok("◇"), exitCode: 0 },
56
58
  stopped: { word: "stopped", told: "stopped", mark: (p) => p.busy("■"), exitCode: 3 },
57
59
  cancelled: { word: "cancelled", told: "was cancelled", mark: (p) => p.busy("■") },
58
60
  "timed-out": { word: "timed out", told: "timed out", mark: (p) => p.bad("✗"), exitCode: 1 },
@@ -63,7 +65,11 @@ export const OUTCOMES: Record<
63
65
  export type GoOn = (stop: WorkflowStopped | undefined, entered: readonly StageSummary[]) => string;
64
66
 
65
67
  /** The ending of a run that returned or threw. */
66
- export function decideEnding(run: Finished, deadline: AbsoluteDeadline, goOn: GoOn): AttemptEnd {
68
+ export function decideEnding(
69
+ run: Finished,
70
+ deadline: AbsoluteDeadline | undefined,
71
+ goOn: GoOn,
72
+ ): AttemptEnd {
67
73
  if ("result" in run) {
68
74
  const { result } = run;
69
75
  return {
@@ -74,6 +80,18 @@ export function decideEnding(run: Finished, deadline: AbsoluteDeadline, goOn: Go
74
80
  }
75
81
  const { error } = run;
76
82
  const settled = error instanceof WorkflowRunError ? error : undefined;
83
+ const parked = primaryFailure(unwrapped(error));
84
+ if (parked instanceof RunParked) {
85
+ return {
86
+ ending: {
87
+ kind: "parked",
88
+ stages: settled?.stages ?? [],
89
+ ...parked.wait,
90
+ },
91
+ ...(settled ? { settled } : {}),
92
+ exitCode: OUTCOMES.parked.exitCode!,
93
+ };
94
+ }
77
95
  const { kind, stop, cancellation } = runOutcome(error, deadline);
78
96
  const cause = unwrapped(error);
79
97
  const beside =
@@ -104,10 +122,10 @@ export function decideEnding(run: Finished, deadline: AbsoluteDeadline, goOn: Go
104
122
  */
105
123
  export function endedBeforeStart(
106
124
  error: unknown,
107
- deadline: AbsoluteDeadline,
125
+ deadline: AbsoluteDeadline | undefined,
108
126
  run: { runId: string; startedAt: number; endedAt: number },
109
127
  goOn: GoOn,
110
- ): AttemptEnd & { ending: Exclude<Ending<JsonValue>, { kind: "completed" }> } {
128
+ ): AttemptEnd & { ending: Exclude<Ending<JsonValue>, { kind: "completed" | "parked" }> } {
111
129
  const { kind, cancellation } = runOutcome(error, deadline);
112
130
  const times = {
113
131
  startedAt: new Date(run.startedAt).toISOString(),
@@ -156,14 +174,14 @@ export function cleanupFailed(end: AttemptEnd, error: unknown, goOn: string): At
156
174
  */
157
175
  export function runOutcome(
158
176
  error: unknown,
159
- deadline: AbsoluteDeadline,
177
+ deadline: AbsoluteDeadline | undefined,
160
178
  ): { kind: UnfinishedOutcome; stop?: WorkflowStopped; cancellation?: WorkflowCancelledError } {
161
179
  const cancellation = findCancellation(error);
162
180
  const failure = primaryFailure(unwrapped(error));
163
181
  const stop = failure instanceof WorkflowStopped ? failure : undefined;
164
182
  const timedOut =
165
183
  failure instanceof DeadlineExceededError &&
166
- failure.deadline.unixMilliseconds === deadline.unixMilliseconds;
184
+ failure.deadline.unixMilliseconds === deadline?.unixMilliseconds;
167
185
  const kind = cancellation ? "cancelled" : stop ? "stopped" : timedOut ? "timed-out" : "failed";
168
186
  return { kind, ...(stop ? { stop } : {}), ...(cancellation ? { cancellation } : {}) };
169
187
  }
@@ -4,13 +4,15 @@ import {
4
4
  type OutputRecord,
5
5
  RUN_EVENT_VERSIONS,
6
6
  } from "@agentswf/contract/records";
7
- import type { Ending, ExecutableWorkflow, JsonValue } from "@agentswf/contract/workflow";
7
+ import type { Ending, JsonValue } from "@agentswf/contract/workflow";
8
8
  import { attemptAccounting } from "./accounting/summary";
9
9
  import type { AttemptEnd } from "./attempt-ending";
10
10
  import { messageOf } from "./errors";
11
11
  import { writeJson, writeWhole } from "./files";
12
+ import { foldMailbox, readMailbox } from "./mailbox";
12
13
  import { type Attempt, appendEvent, endAttempt, outputFile, reportFile } from "./runs";
13
14
  import { withoutValue } from "./stage-ledger";
15
+ import type { AnyWorkflow, LoadedWorkflow } from "./workflow-loader";
14
16
 
15
17
  /** Where an attempt's records went, and its output record as `--json` prints it. */
16
18
  export type Kept = {
@@ -30,27 +32,34 @@ export async function keepRecords(
30
32
  end: AttemptEnd,
31
33
  context: {
32
34
  attempt: Attempt;
33
- executable: ExecutableWorkflow<JsonValue, JsonValue>;
34
- workflow: { name: string; file: string };
35
+ loaded: LoadedWorkflow;
35
36
  stderr: (text: string) => void;
37
+ /** An event run's: runs `work` holding its run's lock. */
38
+ locked?: (work: () => Promise<void>) => Promise<void>;
36
39
  },
37
40
  ): Promise<Kept> {
38
41
  const { attempt, stderr } = context;
39
42
  const { ending, settled } = end;
40
43
  const dir = attempt.run.dir;
41
- const report = await writeReport(context.executable, ending, dir, stderr);
44
+ const report = await writeReport(context.loaded.workflow, ending, dir, stderr);
42
45
  const stages = settled?.stages?.map(withoutValue);
43
- const at =
44
- ending.kind !== "completed" && ending.stage !== undefined ? { stage: ending.stage } : {};
46
+ const unfinished = ending.kind !== "completed" && ending.kind !== "parked" ? ending : undefined;
47
+ const at = unfinished?.stage !== undefined ? { stage: unfinished.stage } : {};
45
48
  const ended =
46
49
  ending.kind === "completed"
47
50
  ? { outcome: "completed" as const, value: ending.value }
48
- : {
49
- outcome: ending.kind,
50
- reason: ending.reason,
51
- ...at,
52
- ...(end.needs ? { needs: [...end.needs] } : {}),
53
- };
51
+ : ending.kind === "parked"
52
+ ? {
53
+ outcome: "parked" as const,
54
+ ...(ending.label === undefined ? {} : { label: ending.label }),
55
+ ...(ending.until === undefined ? {} : { until: ending.until }),
56
+ }
57
+ : {
58
+ outcome: ending.kind,
59
+ reason: ending.reason,
60
+ ...at,
61
+ ...(end.needs ? { needs: [...end.needs] } : {}),
62
+ };
54
63
  const kept: Kept = report ? { report } : {};
55
64
  const output = outputFile(dir);
56
65
  if (!settled) {
@@ -63,7 +72,7 @@ export async function keepRecords(
63
72
  version: OUTPUT_RECORD_VERSION,
64
73
  runId: settled.runId,
65
74
  attempt: attempt.record.attempt,
66
- workflow: context.workflow,
75
+ workflow: { name: context.loaded.workflow.meta.name, file: context.loaded.file },
67
76
  accounting: settled.accounting,
68
77
  usage: settled.usage,
69
78
  artifacts: dir,
@@ -91,14 +100,35 @@ export async function keepRecords(
91
100
  kind: "attempt-ended",
92
101
  outcome: ending.kind,
93
102
  }).catch((error) => stderr(`awf: the run's event log was not written: ${messageOf(error)}`));
94
- try {
103
+ // An event run's ending is written holding its run's lock, as parking's is: a sender after it sees
104
+ // the run returned and is refused, so what no wait took is counted whole. Its last batch was
105
+ // handled, as the code returned after it.
106
+ const writeEnding = async () => {
107
+ const unacknowledged =
108
+ ending.kind === "completed" && attempt.run.record.events
109
+ ? await readMailbox(dir).then(
110
+ // The event that started the run is `wf.started`'s, taken or not.
111
+ (records) => {
112
+ const state = foldMailbox(records);
113
+ return state.unacknowledged.filter((id) => id !== state.started?.id).length;
114
+ },
115
+ (error: unknown) => {
116
+ stderr(`awf: what no wait took was not counted: ${messageOf(error)}`);
117
+ return undefined;
118
+ },
119
+ )
120
+ : undefined;
95
121
  await endAttempt(attempt, {
96
122
  outcome: ending.kind,
97
- ...(ending.kind === "completed" ? {} : { reason: ending.reason }),
123
+ ...(unacknowledged === undefined ? {} : { unacknowledged }),
124
+ ...(unfinished ? { reason: unfinished.reason } : {}),
98
125
  ...at,
99
126
  ...(stages ? { stages } : {}),
100
127
  ...(settled ? { accounting: attemptAccounting(settled.accounting) } : {}),
101
128
  });
129
+ };
130
+ try {
131
+ await (context.locked ?? ((work: () => Promise<void>) => work()))(writeEnding);
102
132
  } catch (error) {
103
133
  stderr(`awf: the attempt's ending was not written to ${attempt.file}: ${messageOf(error)}`);
104
134
  }
@@ -106,7 +136,7 @@ export async function keepRecords(
106
136
  }
107
137
 
108
138
  async function writeReport(
109
- executable: ExecutableWorkflow<JsonValue, JsonValue>,
139
+ workflow: AnyWorkflow,
110
140
  ending: Ending<JsonValue>,
111
141
  dir: string,
112
142
  stderr: (text: string) => void,
@@ -114,11 +144,11 @@ async function writeReport(
114
144
  const file = reportFile(dir);
115
145
  // An earlier attempt's report is gone with it: report.md is the last ended attempt's.
116
146
  const none = () => rm(file, { force: true }).then(() => undefined);
117
- if (!executable.report) return none();
147
+ if (!workflow.report) return none();
118
148
  // The result is still in output.json; a report that cannot be rendered or saved should not fail
119
149
  // the run, nor cost it its record.
120
150
  try {
121
- const markdown = executable.report(
151
+ const markdown = workflow.report(
122
152
  ending.kind === "completed" ? ending.value : undefined,
123
153
  ending,
124
154
  );
@@ -5,12 +5,7 @@ import type {
5
5
  StageNeed,
6
6
  StageRecord,
7
7
  } from "@agentswf/contract/records";
8
- import type {
9
- Ending,
10
- ExecutableWorkflow,
11
- JsonValue,
12
- StageSummary,
13
- } from "@agentswf/contract/workflow";
8
+ import type { Ending, JsonValue, StageSummary } from "@agentswf/contract/workflow";
14
9
  import { ago, describeAccounting, describeAttempts } from "./accounting/format";
15
10
  import { sumAttempts } from "./accounting/summary";
16
11
  import { type AttemptEnd, OUTCOMES } from "./attempt-ending";
@@ -22,6 +17,7 @@ import type { RunCommand } from "./run-command";
22
17
  import type { AttemptRefusal } from "./runs";
23
18
  import { schemaLines } from "./stage-schema";
24
19
  import { FromStageUnreached, type WorkflowStopped } from "./stopped";
20
+ import type { AnyWorkflow } from "./workflow-loader";
25
21
 
26
22
  /**
27
23
  * Prints how the attempt ended, and returns its exit code. A completed one's result goes to stdout;
@@ -32,7 +28,7 @@ export function printEnding(
32
28
  end: AttemptEnd,
33
29
  kept: Kept,
34
30
  context: {
35
- executable: ExecutableWorkflow<JsonValue, JsonValue>;
31
+ workflow: AnyWorkflow;
36
32
  /** The run's id and folder. */
37
33
  run: { id: string; dir: string };
38
34
  n: number;
@@ -56,7 +52,7 @@ export function printEnding(
56
52
  const { ending, settled } = end;
57
53
  const presented =
58
54
  ending.kind === "completed" && !context.json
59
- ? present(context.executable, ending, stderr)
55
+ ? present(context.workflow, ending, stderr)
60
56
  : undefined;
61
57
  if (end.alsoFailed) stderr(`awf: also failed: ${end.alsoFailed}`);
62
58
  const again =
@@ -67,7 +63,7 @@ export function printEnding(
67
63
  if (context.cleanupAlso) stderr(`awf: runtime cleanup also failed: ${context.cleanupAlso}`);
68
64
  // Beside the result rather than in it: stdout stays the workflow's report or the JSON.
69
65
  const lines = describeEnding(ending, {
70
- named: `${context.executable.definition.meta.name} ${context.run.id}`,
66
+ named: `${context.workflow.meta.name} ${context.run.id}`,
71
67
  n: context.n,
72
68
  ...(settled ? { accounting: settled.accounting } : {}),
73
69
  earlier: context.earlier,
@@ -78,7 +74,7 @@ export function printEnding(
78
74
  ...(end.needs ? { needs: end.needs } : {}),
79
75
  ...(settled?.panes ? { panes: settled.panes } : {}),
80
76
  // As the run's Herdr workspace is labelled.
81
- runWorkspace: `awf ${context.executable.definition.meta.name} ${context.run.id} #${context.n}`,
77
+ runWorkspace: `awf ${context.workflow.meta.name} ${context.run.id} #${context.n}`,
82
78
  paint: context.terminal?.color ? ANSI : PLAIN,
83
79
  });
84
80
  for (const line of ["", ...lines]) stderr(line);
@@ -91,7 +87,9 @@ export function printEnding(
91
87
  export function toldOf(end: AttemptEnd, kept: Kept): string {
92
88
  const { ending } = end;
93
89
  const told = `${OUTCOMES[ending.kind].told}${kept.output ? `; its record is ${kept.output}` : ""}`;
94
- return ending.kind === "completed" ? told : `${told}: ${ending.reason}`;
90
+ if (ending.kind === "completed") return told;
91
+ if (ending.kind === "parked") return ending.label ? `${told}: ${ending.label}` : told;
92
+ return `${told}: ${ending.reason}`;
95
93
  }
96
94
 
97
95
  /**
@@ -135,6 +133,17 @@ function describeEnding(
135
133
  if (ending.kind === "completed") {
136
134
  return [`${mark} ${word} · ${run}`, ...figures, ...rows([], context)];
137
135
  }
136
+ if (ending.kind === "parked") {
137
+ const waits = [ending.label, ending.until && `until ${ending.until}`]
138
+ .filter(Boolean)
139
+ .join(" · ");
140
+ return [
141
+ `${mark} ${word}${waits ? `: ${waits}` : ""}`,
142
+ ` ${run}`,
143
+ ...figures,
144
+ ...rows([], context),
145
+ ];
146
+ }
138
147
  // A reason's later lines, such as a schema's problems, sit under it, apart from the rows.
139
148
  const [first, ...more] = ending.reason.split("\n");
140
149
  const goOn = context.choose
@@ -233,13 +242,13 @@ function runTotal(n: number, current: RunAccounting, earlier: readonly AttemptRe
233
242
 
234
243
  /** A completed attempt's value as the workflow presents it; undefined to print the JSON. */
235
244
  function present(
236
- executable: ExecutableWorkflow<JsonValue, JsonValue>,
245
+ workflow: AnyWorkflow,
237
246
  ending: Extract<Ending<JsonValue>, { kind: "completed" }>,
238
247
  stderr: (text: string) => void,
239
248
  ): string | undefined {
240
- if (!executable.present) return undefined;
249
+ if (!workflow.present) return undefined;
241
250
  try {
242
- return executable.present(ending.value, ending)?.trimEnd();
251
+ return workflow.present(ending.value, ending)?.trimEnd();
243
252
  } catch (error) {
244
253
  stderr(`awf: present: ${messageOf(error)}; printing the full result instead`);
245
254
  return undefined;
@@ -343,8 +352,9 @@ export function continueCommand(
343
352
  ...(command.runRootGiven ? ["--run-root", command.runRoot] : []),
344
353
  ...(command.cwdGiven ? ["--cwd", command.cwd] : []),
345
354
  pathFrom(command.shellCwd, command.workflowFile),
346
- "--continue",
355
+ "--id",
347
356
  id,
357
+ "--continue",
348
358
  ...(redo === undefined ? [] : ["--from-stage", redo]),
349
359
  ...(values === undefined ? [] : ["--values", values]),
350
360
  ]