@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
@@ -7,13 +7,7 @@ import type { JsonObject, JsonValue } from "@agentswf/contract/workflow";
7
7
  import { type Harness, handBack, type RunProcess } from "@agentswf/harness";
8
8
  import manifest from "../package.json" with { type: "json" };
9
9
  import { stageFigures } from "./accounting/format";
10
- import {
11
- type AllowanceCommand,
12
- allowanceUsage,
13
- describeReport,
14
- parseAllowanceCommand,
15
- readReport,
16
- } from "./allowance-command";
10
+ import { allowanceCommand } from "./allowance-command";
17
11
  import {
18
12
  type AttemptEnd,
19
13
  cleanupFailed,
@@ -25,11 +19,23 @@ import {
25
19
  } from "./attempt-ending";
26
20
  import { type Kept, keepRecords } from "./attempt-output";
27
21
  import { continueCommand, printEnding, toldOf } from "./attempt-view";
22
+ import { type CommandName, helpOf, index, isCommand, reference, unknownCommand } from "./commands";
23
+ import {
24
+ type LineReader,
25
+ promptCoordinator,
26
+ replyHints,
27
+ terminalReader,
28
+ } from "./coordinator-prompt";
29
+ import { coordinatorSocket, prepareCoordinatorDir } from "./coordinator-socket";
30
+ import { EmitError, emitEvent, openEventRun } from "./emit";
31
+ import { emitCommand } from "./emit-command";
28
32
  import { messageOf } from "./errors";
29
33
  import { type Caller, showOwnTab, startHere, takeCaller } from "./here";
30
- import { type InitCommand, initUsage, parseInitCommand, runInit } from "./init-command";
34
+ import { holderCommand } from "./holder-command";
35
+ import { inboxCommand } from "./inbox-command";
36
+ import { initCommand } from "./init-command";
31
37
  import { logsCommand } from "./logs-command";
32
- import { machinePaths, sandboxesDirOf } from "./machine";
38
+ import { coordinatorDir, machinePaths, sandboxesDirOf } from "./machine";
33
39
  import {
34
40
  allowanceReader,
35
41
  herdrConfig,
@@ -39,13 +45,17 @@ import {
39
45
  } from "./operator-runtime";
40
46
  import { ANSI, describeRunSession, type Terminal, watchProgress } from "./progress-view";
41
47
  import { parseCommand, type RunCommand, usage } from "./run-command";
42
- import { shellLine, startedLine } from "./run-commands";
48
+ import { placeFlags, shellLine, startedLine } from "./run-commands";
49
+ import { runLockFile, withRunLock } from "./run-lock";
43
50
  import { claimNext, loadAndPrepare, type PreparedRun, workspaceLabel } from "./run-prepare";
44
51
  import { findRun } from "./run-state";
45
- import { type Attempt, createRun, discardRun, type Run, RunRefused } from "./runs";
52
+ import { type Attempt, createRun, discardRun, endAttempt, type Run, RunRefused } from "./runs";
46
53
  import { runsCommand } from "./runs-command";
47
- import { parseTestCommand, runWorkflowTests, type TestCommand, testUsage } from "./test-command";
48
- import type { LoadedWorkflow } from "./workflow-loader";
54
+ import { stopCrashed } from "./stop-crashed";
55
+ import { testCommand } from "./test-command";
56
+ import { tickCommand } from "./tick-command";
57
+ import type { Launch } from "./wake";
58
+ import type { EventWorkflow, LoadedWorkflow } from "./workflow-loader";
49
59
  import {
50
60
  type RunWorkflowOptions,
51
61
  startWorkflow,
@@ -60,7 +70,7 @@ export type OperatorEnvironment = {
60
70
  stdout?: (text: string) => void;
61
71
  stderr?: (text: string) => void;
62
72
  installRuntime?: (
63
- timeoutMilliseconds: number,
73
+ timeoutMilliseconds: number | undefined,
64
74
  options: {
65
75
  watchSandboxes: boolean;
66
76
  caller?: Caller;
@@ -84,11 +94,22 @@ export type OperatorEnvironment = {
84
94
  install?: (dir: string) => Promise<number>;
85
95
  /** How `awf allowance` reads a harness's plan. */
86
96
  readAllowance?: (harness: Harness) => Promise<HarnessAllowance>;
97
+ /** Starts or continues an event run's attempt for `awf emit`; absent, a detached `awf run`. */
98
+ launch?: Launch;
87
99
  /**
88
100
  * Where agents may `--await-reply`, past the engine's list: for the live checks that decide
89
101
  * what goes on it (story 029's M2 gate).
90
102
  */
91
103
  awaitReply?: RunWorkflowOptions["awaitReply"];
104
+ /** Answers for the run's coordinators in place of a person: for the live checks of story 032. */
105
+ coordinators?: RunWorkflowOptions["coordinators"];
106
+ /**
107
+ * Given, stdin is a terminal and these are the lines typed at it: `awf run` asks a coordinator's
108
+ * questions there. Absent, it is so when stdin and stderr are both terminals.
109
+ */
110
+ lines?: LineReader;
111
+ /** What `awf reply` and `awf send` read their text from without an argument; stdin otherwise. */
112
+ stdin?: () => Promise<string>;
92
113
  };
93
114
 
94
115
  type Output = {
@@ -124,28 +145,156 @@ export async function runOperatorCli(
124
145
  stderr(`awf: ${tooOld}`);
125
146
  return 1;
126
147
  }
127
- if (argv.length === 1 && argv[0] === "--version") {
148
+ const cwd = environment.cwd ?? process.cwd();
149
+ const holder = () =>
150
+ holderCommand(argv, cwd, {
151
+ stdout,
152
+ stderr,
153
+ stdin: environment.stdin ?? (() => new Response(Bun.stdin.stream()).text()),
154
+ ...(environment.stdin ? {} : { stdinIsTerminal: process.stdin.isTTY === true }),
155
+ ...(environment.environment ? { environment: environment.environment } : {}),
156
+ ...(environment.home ? { home: environment.home } : {}),
157
+ });
158
+ const wakes = {
159
+ home: environment.home ?? homedir(),
160
+ environment: environment.environment ?? process.env,
161
+ ...(environment.launch ? { launch: environment.launch } : {}),
162
+ };
163
+ const handlers = {
164
+ run: () => runCommand(argv, environment, { stdout, stderr, notice, terminal, now }),
165
+ test: () => {
166
+ const captured = environment.stdout || environment.stderr ? { stdout, stderr } : undefined;
167
+ return testCommand(argv.slice(1), cwd, {
168
+ stdout,
169
+ stderr,
170
+ ...(captured ? { output: captured } : {}),
171
+ ...(environment.signal ? { signal: environment.signal } : {}),
172
+ });
173
+ },
174
+ init: () =>
175
+ initCommand(argv.slice(1), cwd, {
176
+ stdout,
177
+ stderr,
178
+ ...(environment.install ? { install: environment.install } : {}),
179
+ }),
180
+ emit: () => emitCommand(argv.slice(1), cwd, { stdout, stderr }, wakes),
181
+ tick: () => tickCommand(argv.slice(1), cwd, { stdout, stderr }, wakes),
182
+ runs: () => runsCommand(argv.slice(1), cwd, { stdout, stderr }, Date.now(), wakes.home),
183
+ logs: () => logsCommand(argv.slice(1), cwd, { stdout, stderr }, undefined, wakes.home),
184
+ inbox: () =>
185
+ inboxCommand(
186
+ argv.slice(1),
187
+ cwd,
188
+ { stdout, stderr },
189
+ { home: wakes.home, ...(environment.signal ? { signal: environment.signal } : {}) },
190
+ ),
191
+ reply: () => holder(),
192
+ send: () => holder(),
193
+ allowance: () =>
194
+ allowanceCommand(argv.slice(1), {
195
+ stdout,
196
+ stderr,
197
+ read:
198
+ environment.readAllowance ??
199
+ allowanceReader(
200
+ environment.environment ?? process.env,
201
+ undefined,
202
+ environment.signal,
203
+ environment.home ?? homedir(),
204
+ ),
205
+ now,
206
+ }),
207
+ } satisfies Record<CommandName, () => Promise<number>>;
208
+ const [word, ...rest] = argv;
209
+ if (word === undefined) {
210
+ stdout(index());
211
+ return 0;
212
+ }
213
+ if (word === "--version") {
214
+ if (rest.length > 0) {
215
+ stderr("awf: --version takes nothing after it; awf -h lists the commands");
216
+ return 2;
217
+ }
128
218
  stdout(describeVersion());
129
219
  return 0;
130
220
  }
131
- if (argv[0] === "test") return testCommand(argv.slice(1), environment, stdout, stderr);
132
- if (argv[0] === "init") return initCommand(argv.slice(1), environment, stdout, stderr);
133
- if (argv[0] === "logs") {
134
- return logsCommand(argv.slice(1), environment.cwd ?? process.cwd(), { stdout, stderr });
221
+ if (word === "help" || word === "-h" || word === "--help") return help(rest, stdout, stderr);
222
+ if (isCommand(word)) return handlers[word]();
223
+ stderr(unknownCommand(word));
224
+ return 2;
225
+ }
226
+
227
+ /** `awf help`, `awf help {command}` and `awf help reference`; `-h` and `--help` are `help`. */
228
+ function help(
229
+ words: readonly string[],
230
+ stdout: (text: string) => void,
231
+ stderr: (text: string) => void,
232
+ ): number {
233
+ const [word] = words;
234
+ if (words.length > 1) {
235
+ stderr("awf: help takes one command; awf -h lists the commands");
236
+ return 2;
135
237
  }
136
- if (argv[0] === "runs") {
137
- return runsCommand(argv.slice(1), environment.cwd ?? process.cwd(), { stdout, stderr });
238
+ if (word === undefined || word === "help" || word === "-h" || word === "--help") {
239
+ stdout(index());
240
+ } else if (word === "reference") {
241
+ stdout(reference());
242
+ } else if (isCommand(word)) {
243
+ stdout(helpOf(word));
244
+ } else {
245
+ stderr(unknownCommand(word));
246
+ return 2;
138
247
  }
139
- if (argv[0] === "allowance") {
140
- return allowanceCommand(argv.slice(1), environment, stdout, stderr);
248
+ return 0;
249
+ }
250
+
251
+ /** `awf run`: parses it, then starts or continues the run, here or in a tab. */
252
+ /**
253
+ * `awf run --id` for a workflow with events: the run is made with no event and started detached, as
254
+ * an event starts one, so it runs to its first wait and parks; one already there is only woken.
255
+ */
256
+ async function openRun(
257
+ { loaded, id, argv }: { loaded: LoadedWorkflow; id: string; argv: readonly string[] },
258
+ environment: OperatorEnvironment,
259
+ { stdout, stderr }: Output,
260
+ ): Promise<number> {
261
+ try {
262
+ const opened = await openEventRun(loaded.workflow as EventWorkflow, id, argv, {
263
+ home: environment.home ?? homedir(),
264
+ environment: environment.environment ?? process.env,
265
+ ...(environment.launch ? { launch: environment.launch } : {}),
266
+ });
267
+ const did = opened.did === "none" ? opened.why : opened.did;
268
+ stdout(
269
+ `${opened.run}${opened.created ? "" : " was there"}${did === undefined ? "" : `: ${did}`}`,
270
+ );
271
+ if (opened.why !== "launch-failed") return 0;
272
+ stderr(`awf: the run's attempt did not start: ${opened.error}`);
273
+ return 1;
274
+ } catch (error) {
275
+ stderr(`awf: ${messageOf(error)}`);
276
+ return error instanceof EmitError ? 2 : 1;
141
277
  }
142
- let command: RunCommand;
278
+ }
279
+
280
+ async function runCommand(
281
+ argv: readonly string[],
282
+ environment: OperatorEnvironment,
283
+ output: Output,
284
+ ): Promise<number> {
285
+ const { stdout, stderr, notice, terminal, now } = output;
286
+ let parsed: RunCommand | "help";
143
287
  try {
144
- command = parseCommand(argv, environment.cwd ?? process.cwd());
288
+ parsed = parseCommand(argv, environment.cwd ?? process.cwd());
145
289
  } catch (error) {
146
290
  stderr(`awf: ${messageOf(error)}\n\n${usage}`);
147
291
  return 2;
148
292
  }
293
+ if (parsed === "help") {
294
+ stdout(usage);
295
+ return 0;
296
+ }
297
+ let command: RunCommand = parsed;
149
298
  const meant = await runNamed(command);
150
299
  if (meant) {
151
300
  const look = shellLine([
@@ -167,17 +316,20 @@ export async function runOperatorCli(
167
316
  };
168
317
  const home = environment.home ?? homedir();
169
318
  const { sandboxes, callers } = machinePaths(home);
319
+ const ready = await loadAndPrepare(command, now(), home);
320
+ if ("refused" in ready) {
321
+ stderr(`awf: ${ready.refused}`);
322
+ return unstarted(2);
323
+ }
324
+ if ("opens" in ready) return openRun(ready.opens, environment, output);
325
+ // After loading: an event run's root is its project's, which this checks too.
326
+ command = ready.command;
170
327
  if (!relative(command.runRoot, sandboxes).startsWith("..")) {
171
328
  stderr(
172
329
  `awf: --run-root ${command.runRoot} holds ${sandboxes}, which every sandbox must reach and none may reach the run root`,
173
330
  );
174
331
  return unstarted(2);
175
332
  }
176
- const ready = await loadAndPrepare(command, now());
177
- if ("refused" in ready) {
178
- stderr(`awf: ${ready.refused}`);
179
- return unstarted(2);
180
- }
181
333
  for (const line of ready.prepared.continued?.warnings ?? []) notice(line);
182
334
 
183
335
  // Loading the workflow imports operator-supplied code before anything listens to the signal, so
@@ -227,8 +379,8 @@ async function runAttempt(
227
379
  output: Output,
228
380
  ): Promise<number> {
229
381
  const { stdout, stderr, notice, terminal, now } = output;
230
- const { executable } = loaded;
231
- const { meta } = executable.definition;
382
+ const { workflow } = loaded;
383
+ const { meta } = workflow;
232
384
  /** The run, once it is claimed. */
233
385
  let run: Run | undefined;
234
386
  // Once the run has taken the session over, however it ends, the session gets it back.
@@ -251,6 +403,17 @@ async function runAttempt(
251
403
  await handOver(`did not start: ${why}`);
252
404
  return 2;
253
405
  };
406
+ // The operator's, never where the workflow's agents work.
407
+ const coordinatorRoot = coordinatorDir(home, environment.environment ?? process.env);
408
+ try {
409
+ await prepareCoordinatorDir(
410
+ coordinatorRoot,
411
+ command.cwd,
412
+ Boolean((environment.environment ?? process.env).AWF_COORDINATOR_DIR),
413
+ );
414
+ } catch (error) {
415
+ return refuse(messageOf(error));
416
+ }
254
417
  try {
255
418
  run =
256
419
  prepared.continued?.run ??
@@ -275,13 +438,33 @@ async function runAttempt(
275
438
  return refuse(messageOf(error));
276
439
  }
277
440
  const { id, cwd } = run.record;
441
+ const locked = <T>(work: () => Promise<T>) =>
442
+ withRunLock(runLockFile(run.root, meta.name, id), work);
278
443
  const sandbox = run.record.sandbox ?? undefined;
279
444
  const n = attempt.record.attempt;
445
+ // Claimed first, which `wake` waits for holding the run's lock; only then waited on: two
446
+ // attempts never share an agent's session.
447
+ const previous = prepared.continued?.attempts.at(-1);
448
+ // An event run continued after a crash: what that attempt left working is stopped first (Q17).
449
+ if (run.record.events && previous && previous.ended === undefined)
450
+ await stopCrashed(previous, workspaceLabel(meta.name, run.record.id, previous.attempt), {
451
+ home,
452
+ environment: environment.environment ?? process.env,
453
+ }).catch((error: unknown) =>
454
+ notice(
455
+ `awf: what attempt ${previous.attempt} left running was not all stopped: ${messageOf(error)}`,
456
+ ),
457
+ );
280
458
  const goOn: GoOn = (stop, entered) => continueCommand(command, id, stop, entered);
281
- const records = { attempt, executable, workflow: { name: meta.name, file: loaded.file }, stderr };
459
+ const records = {
460
+ attempt,
461
+ loaded,
462
+ stderr,
463
+ ...(!run.record.events ? {} : { locked }),
464
+ };
282
465
  const close = (end: AttemptEnd, kept: Kept, cleanupAlso?: string) =>
283
466
  printEnding(end, kept, {
284
- executable,
467
+ workflow,
285
468
  run: { id, dir: run.dir },
286
469
  n,
287
470
  earlier: prepared.continued?.attempts ?? [],
@@ -302,7 +485,10 @@ async function runAttempt(
302
485
  // run is over may continue it at once. One that never does is interrupted. A new run that never
303
486
  // started is not kept at all, so the same command starts it again, unless another attempt has
304
487
  // claimed it since: then its records are kept as a continued run's are.
305
- const deadline = { unixMilliseconds: startedAt + command.timeoutMilliseconds };
488
+ const deadline =
489
+ command.timeoutMilliseconds === undefined
490
+ ? undefined
491
+ : { unixMilliseconds: startedAt + command.timeoutMilliseconds };
306
492
  const endUnstarted = async (error: unknown, cleanupAlso?: string) => {
307
493
  const end = endedBeforeStart(error, deadline, { runId: id, startedAt, endedAt: now() }, goOn);
308
494
  const cancelled = end.ending.kind === "cancelled";
@@ -360,9 +546,33 @@ async function runAttempt(
360
546
  const insideHerdr = (environment.environment ?? process.env).HERDR_ENV === "1";
361
547
  for (const line of describeRunSession(session, label, insideHerdr)) progress.log(line);
362
548
  };
549
+ // Interactive: a coordinator's questions are asked here, unless the output is for a program.
550
+ const reader =
551
+ terminal && !command.json
552
+ ? (environment.lines ?? (process.stdin.isTTY ? terminalReader() : undefined))
553
+ : undefined;
554
+ const prompt = reader && promptCoordinator(reader, progress);
555
+ // Without a prompt, each question says how to answer it, so the run does not look stuck.
556
+ const asked =
557
+ prompt ??
558
+ replyHints(`${meta.name}/${id}`, placeFlags(command), n, (line) => progress.log(line));
559
+ progress.showsMessages((message) => asked.shows(message));
560
+ const { events } = run.record;
561
+ let nudge: (() => void) | undefined;
562
+ const socket = coordinatorSocket({
563
+ dir: coordinatorRoot,
564
+ runDir: run.dir,
565
+ attempt: n,
566
+ ...(events ? { nudged: () => nudge?.() } : {}),
567
+ failed: (reason) =>
568
+ progress.log(
569
+ `awf: the participants' socket did not open, so awf reply and awf send cannot reach this run: ${reason}`,
570
+ ),
571
+ });
572
+ const coordinators = [...(environment.coordinators ?? []), socket, asked];
363
573
  let handle: WorkflowRunHandle<JsonValue>;
364
574
  try {
365
- handle = await startWorkflow(executable.definition, prepared.args, {
575
+ handle = await startWorkflow(workflow, prepared.args, {
366
576
  runRoot: run.root,
367
577
  run: {
368
578
  dir: run.dir,
@@ -371,24 +581,54 @@ async function runAttempt(
371
581
  label: workspaceLabel(meta.name, id, n),
372
582
  ...(command.fromStage === undefined ? {} : { fromStage: command.fromStage }),
373
583
  ...(command.values === undefined ? {} : { values: command.values.stages }),
584
+ ...(!events
585
+ ? {}
586
+ : {
587
+ events,
588
+ locked,
589
+ listenNudges: (given) => {
590
+ nudge = given;
591
+ },
592
+
593
+ // After a crash its agents start new sessions: a harness may still count the killed
594
+ // writer as its session's, as codex's thread store did (story 034, task 6).
595
+ resumeSessions: previous === undefined || previous.ended !== undefined,
596
+ park: async () => {
597
+ await endAttempt(attempt, { outcome: "parked" });
598
+ },
599
+ }),
374
600
  },
601
+ emit: async (target, event, from) =>
602
+ (
603
+ await emitEvent(target, event, {
604
+ from,
605
+ home,
606
+ environment: environment.environment ?? process.env,
607
+ ...(environment.launch ? { launch: environment.launch } : {}),
608
+ })
609
+ ).outcome,
375
610
  runtime: installed.config,
376
611
  sandboxes: {
377
612
  providers: installed.sandboxes ?? { installed: {} },
378
613
  sandboxesDir: sandboxesDirOf(home, run.record),
379
614
  machineRoot: machinePaths(home).root,
615
+ coordinatorRoot,
380
616
  ...(sandbox === undefined ? {} : { run: sandbox }),
381
617
  },
382
618
  ...(installed.decisions ? { decisions: installed.decisions } : {}),
619
+ ...(installed.allowance ? { allowance: installed.allowance } : {}),
383
620
  deadline,
384
621
  cwd,
385
622
  ...(environment.signal ? { signal: environment.signal } : {}),
386
623
  ...(environment.awaitReply ? { awaitReply: environment.awaitReply } : {}),
624
+ ...(coordinators.length > 0 ? { coordinators } : {}),
387
625
  onCallerStopped: () => handOver("stopped using this session", true),
388
626
  onLog: (logMessage, fields?: JsonObject) =>
389
627
  progress.log(fields ? `${logMessage} ${JSON.stringify(fields)}` : logMessage),
390
628
  });
391
629
  } catch (error) {
630
+ prompt?.close();
631
+ await socket.close();
392
632
  progress.stop();
393
633
  return endUnstarted(error, await cleanUp());
394
634
  }
@@ -399,6 +639,9 @@ async function runAttempt(
399
639
  } catch (error) {
400
640
  finished = { error };
401
641
  }
642
+ prompt?.close();
643
+ // Gone with the run: a holder now finds it ended.
644
+ await socket.close();
402
645
 
403
646
  let end = decideEnding(finished, deadline, goOn);
404
647
  progress.stop(end.settled ? stageFigures(end.settled.accounting) : undefined);
@@ -426,89 +669,6 @@ async function runAttempt(
426
669
  return close(end, kept, cleanupAlso);
427
670
  }
428
671
 
429
- /** `awf allowance`: what is left of each harness's plan. */
430
- async function allowanceCommand(
431
- argv: readonly string[],
432
- environment: OperatorEnvironment,
433
- stdout: (text: string) => void,
434
- stderr: (text: string) => void,
435
- ): Promise<number> {
436
- let command: AllowanceCommand;
437
- try {
438
- command = parseAllowanceCommand(argv);
439
- } catch (error) {
440
- stderr(`awf: ${messageOf(error)}\n\n${allowanceUsage}`);
441
- return 2;
442
- }
443
- if (command === "help") {
444
- stdout(allowanceUsage);
445
- return 0;
446
- }
447
- const read =
448
- environment.readAllowance ??
449
- allowanceReader(
450
- environment.environment ?? process.env,
451
- undefined,
452
- environment.signal,
453
- environment.home ?? homedir(),
454
- );
455
- const report = await readReport(command.harnesses, read, (environment.now ?? Date.now)());
456
- stdout(command.json ? JSON.stringify(report, null, 2) : describeReport(report));
457
- return 0;
458
- }
459
-
460
- /** `awf init`: a folder set up for writing workflows. */
461
- async function initCommand(
462
- argv: readonly string[],
463
- environment: OperatorEnvironment,
464
- stdout: (text: string) => void,
465
- stderr: (text: string) => void,
466
- ): Promise<number> {
467
- const cwd = environment.cwd ?? process.cwd();
468
- let command: InitCommand | "help";
469
- try {
470
- command = parseInitCommand(argv, cwd);
471
- } catch (error) {
472
- stderr(`awf: ${messageOf(error)}\n\n${initUsage}`);
473
- return 2;
474
- }
475
- if (command === "help") {
476
- stdout(initUsage);
477
- return 0;
478
- }
479
- try {
480
- return await runInit(command, cwd, { stdout, stderr }, environment.install);
481
- } catch (error) {
482
- stderr(`awf: ${messageOf(error)}`);
483
- return 1;
484
- }
485
- }
486
-
487
- /** `awf test`: the workflow tests under the given paths, with Bun's test runner. */
488
- async function testCommand(
489
- argv: readonly string[],
490
- environment: OperatorEnvironment,
491
- stdout: (text: string) => void,
492
- stderr: (text: string) => void,
493
- ): Promise<number> {
494
- let tests: TestCommand | "help";
495
- try {
496
- tests = parseTestCommand(argv, environment.cwd ?? process.cwd());
497
- } catch (error) {
498
- stderr(`awf: ${messageOf(error)}\n\n${testUsage}`);
499
- return 2;
500
- }
501
- if (tests === "help") {
502
- stdout(testUsage);
503
- return 0;
504
- }
505
- const captured = environment.stdout || environment.stderr ? { stdout, stderr } : undefined;
506
- return runWorkflowTests(tests, environment.cwd ?? process.cwd(), {
507
- ...(environment.signal ? { signal: environment.signal } : {}),
508
- ...(captured ? { output: captured } : {}),
509
- });
510
- }
511
-
512
672
  /**
513
673
  * Not `console.error`, which Bun paints red on a terminal: a workflow's log and the accounting
514
674
  * are ordinary lines. Only awf's own problems, which it prefixes `awf: `, are red: their first line.
@@ -557,6 +717,8 @@ function describeVersion(): string {
557
717
 
558
718
  /** How soon a repeated signal is the copy `bun awf` forwards, not the operator pressing again. */
559
719
  const REPEAT_MS = 1_000;
720
+ /** How long a command that has returned and written its output may keep the process. */
721
+ const EXIT_GRACE_MS = 2_000;
560
722
 
561
723
  /**
562
724
  * Cancels `controller` at the first signal that asks the run to stop, and stops the process at
@@ -595,6 +757,15 @@ export async function main(): Promise<void> {
595
757
  } finally {
596
758
  stop();
597
759
  }
760
+ // A workflow's body can leave a timer or a pending call behind once its run has ended and its
761
+ // records are written; a woken run, detached, would otherwise live on with nothing to reap it.
762
+ // Output still queued for a slow reader is written first.
763
+ await Promise.all(
764
+ [process.stdout, process.stderr].map(
765
+ (stream) => new Promise<void>((resolve) => stream.write("", () => resolve())),
766
+ ),
767
+ );
768
+ setTimeout(() => process.exit(), EXIT_GRACE_MS).unref();
598
769
  }
599
770
 
600
771
  if (import.meta.main) await main();
@@ -56,6 +56,8 @@ export type OperatorRuntimeInstallation = {
56
56
  sandboxes?: SandboxProviders;
57
57
  /** The decision models workflows may ask, and their aliases. */
58
58
  decisions?: DecisionInstallation;
59
+ /** How `wf.allowance` reads a harness's plan, as `awf allowance` does. */
60
+ allowance?: (harness: Harness) => Promise<HarnessAllowance>;
59
61
  cleanup(): Promise<void>;
60
62
  };
61
63
 
@@ -82,7 +84,7 @@ export type OperatorRuntimeOptions = {
82
84
  * headless one. `WITHHELD_ENVIRONMENT` is cleared from both.
83
85
  */
84
86
  export async function installOperatorRuntime(
85
- timeoutMilliseconds: number,
87
+ timeoutMilliseconds: number | undefined,
86
88
  options: OperatorRuntimeOptions = {},
87
89
  ): Promise<OperatorRuntimeInstallation> {
88
90
  const {
@@ -101,7 +103,7 @@ export async function installOperatorRuntime(
101
103
  const session = runSessionOnce(() => name, { run, environment, home }, onRunSession);
102
104
  const runConfig = (session: string): HerdrConfig => ({
103
105
  ...herdrConfig(session),
104
- commandTimeoutMs: Math.min(timeoutMilliseconds, 150_000),
106
+ commandTimeoutMs: Math.min(timeoutMilliseconds ?? Number.POSITIVE_INFINITY, 150_000),
105
107
  emptyEnvironment: PANE_WITHHELD_ENVIRONMENT,
106
108
  acceptWorkspaceTrust: true,
107
109
  watchSandboxes,
@@ -178,7 +180,8 @@ export async function installOperatorRuntime(
178
180
  },
179
181
  sandboxes: await installSandboxes(environment),
180
182
  decisions: installDecisions({ OPENROUTER_API_KEY: await openRouterKey(environment) }),
181
- // Nothing to undo: the agent's `wf` is a launcher the control plane installs beside its own
183
+ allowance: allowanceReader(environment, run, undefined, home),
184
+ // Nothing to undo: the agent's `wfa` is a launcher the control plane installs beside its own
182
185
  // socket, and the control plane removes both when the run closes.
183
186
  cleanup: async () => undefined,
184
187
  };
@@ -279,9 +282,8 @@ export function herdrConfig(session: string): HerdrConfig {
279
282
 
280
283
  /**
281
284
  * The sandbox providers this machine has: srt when its CLI is on `PATH`, docker when its CLI is;
282
- * its daemon is first asked when a sandbox opens. srt is the default when installed, as it runs
283
- * the project's own toolchain, which an image lacks (story 004), and when it is there but cannot
284
- * be set up: a spec naming none then fails saying why, rather than moving to docker unsaid.
285
+ * its daemon is first asked when a sandbox opens. There is no default: a spec names its provider,
286
+ * since the two give agents different machines, the host's toolchain or an image.
285
287
  */
286
288
  export async function installSandboxes(
287
289
  environment: Readonly<Record<string, string | undefined>>,
@@ -301,10 +303,8 @@ export async function installSandboxes(
301
303
  ...(srt ? { srt: createSrtProvider(srt) } : {}),
302
304
  ...(docker ? { docker: createDockerProvider(docker) } : {}),
303
305
  };
304
- const fallback = srt || unavailable.srt ? "srt" : docker ? "docker" : undefined;
305
306
  return {
306
307
  installed,
307
- ...(fallback ? { default: fallback } : {}),
308
308
  ...(Object.keys(unavailable).length > 0 ? { unavailable } : {}),
309
309
  };
310
310
  }