@agentswf/engine 0.1.0-beta.4 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) 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 +35 -22
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +5 -7
  11. package/src/attempt-output.ts +15 -32
  12. package/src/attempt-view.ts +34 -33
  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 +41 -11
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +41 -33
  25. package/src/deadlines.ts +40 -7
  26. package/src/decisions/directory.ts +51 -40
  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 +43 -27
  32. package/src/emit-command.ts +88 -58
  33. package/src/emit.ts +254 -56
  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 +18 -6
  39. package/src/help/inbox.txt +6 -4
  40. package/src/help/logs.txt +9 -6
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +30 -11
  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 +7 -15
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +56 -133
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +40 -57
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.test.ts.template +3 -3
  55. package/src/init-template/hello/workflow.ts.template +5 -4
  56. package/src/jsonl.ts +10 -6
  57. package/src/keep-alive.ts +15 -12
  58. package/src/logs-command.ts +128 -47
  59. package/src/message-operation.ts +6 -3
  60. package/src/messaging/limits.ts +4 -4
  61. package/src/messaging/messaging.ts +31 -28
  62. package/src/messaging/routes.ts +1 -1
  63. package/src/messaging/text.ts +9 -7
  64. package/src/open-command.ts +770 -0
  65. package/src/operation-events.ts +4 -6
  66. package/src/operation-liveness.ts +19 -4
  67. package/src/operator-cli.ts +224 -94
  68. package/src/operator-runtime.ts +29 -7
  69. package/src/ops.ts +270 -0
  70. package/src/pane-layout.ts +105 -59
  71. package/src/progress-view.ts +10 -10
  72. package/src/project-engine.ts +79 -0
  73. package/src/projects.ts +13 -2
  74. package/src/refusals.ts +119 -0
  75. package/src/result-slots.ts +11 -31
  76. package/src/result-validation.ts +2 -2
  77. package/src/run-command.ts +45 -28
  78. package/src/run-commands.ts +43 -115
  79. package/src/run-event-log.ts +1 -1
  80. package/src/run-lines.ts +159 -0
  81. package/src/run-lock.ts +57 -1
  82. package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
  83. package/src/run-policy/stages.ts +113 -0
  84. package/src/run-policy/waits.ts +452 -0
  85. package/src/run-prepare.ts +205 -43
  86. package/src/run-read.ts +109 -41
  87. package/src/run-state.ts +170 -50
  88. package/src/run-text.ts +54 -0
  89. package/src/run-unreachable.ts +18 -0
  90. package/src/run-usage.ts +18 -12
  91. package/src/run-view.ts +81 -17
  92. package/src/runs-command.ts +83 -63
  93. package/src/runs.ts +310 -212
  94. package/src/sandbox-homes.ts +65 -13
  95. package/src/sandboxes.ts +182 -28
  96. package/src/session-lines.ts +93 -0
  97. package/src/skills/run-skills.ts +8 -1
  98. package/src/sources.ts +28 -29
  99. package/src/spend.ts +333 -0
  100. package/src/stage-ledger.ts +119 -131
  101. package/src/stop-crashed.ts +6 -5
  102. package/src/stopped.ts +4 -4
  103. package/src/test-command.ts +36 -14
  104. package/src/testing/operation-events.ts +3 -23
  105. package/src/testing.ts +73 -26
  106. package/src/tick-command.ts +145 -15
  107. package/src/tools.ts +25 -16
  108. package/src/turn-ends.ts +37 -0
  109. package/src/waits.ts +651 -0
  110. package/src/wake.ts +84 -54
  111. package/src/workflow-loader.ts +54 -4
  112. package/src/workflow-runner.ts +805 -580
  113. package/src/workflow-test-preload.ts +4 -1
  114. package/src/workflow-testing/coordinators.ts +3 -3
  115. package/src/workflow-testing/fake-client.ts +387 -0
  116. package/src/workflow-testing/host.ts +121 -53
  117. package/src/workflow-testing/index.ts +33 -842
  118. package/src/workflow-testing/run-test.ts +1128 -0
  119. package/src/workflow-testing/script.ts +74 -45
  120. package/src/hold-run-lock.fixture.ts +0 -8
  121. package/src/mailbox.ts +0 -990
  122. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  123. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
@@ -10,9 +10,12 @@ import {
10
10
  RESERVED_PARTICIPANT_KEYS,
11
11
  } from "@agentswf/contract/participant";
12
12
  import type {
13
+ AgentOp,
13
14
  AgentPaneRecord,
14
15
  AgentSkillsRecord,
15
16
  RunAccounting,
17
+ RunPanes,
18
+ RunView,
16
19
  SandboxRecord,
17
20
  SettledDecision,
18
21
  SettledOperation,
@@ -31,7 +34,8 @@ import {
31
34
  } from "@agentswf/contract/schema";
32
35
  import {
33
36
  type AbsoluteDeadline,
34
- type AgentEndedEvent,
37
+ type AgentEnded,
38
+ type AgentEndOf,
35
39
  type AgentExecution,
36
40
  type AgentForkSpec,
37
41
  type AgentKey,
@@ -40,42 +44,49 @@ import {
40
44
  type AgentRunStructuredSpec,
41
45
  type AgentRunTextSpec,
42
46
  type AgentStartSpec,
47
+ awfEventOf,
43
48
  type Branches,
44
- type CodeParticipantRef,
49
+ type CallerForkSpec,
45
50
  type CompactionId,
46
51
  type CompactSpec,
47
52
  DeadlineExceededError,
48
53
  type Duration,
54
+ durationMilliseconds,
49
55
  type EmitOutcome,
50
- type EventAgentOpenSpec,
51
- type EventAgentRef,
56
+ type EventLike,
57
+ type ForkRuntime,
52
58
  type HarnessAllowance,
59
+ type HarnessKind,
53
60
  isEventWorkflow,
54
61
  isJsonValue,
55
62
  type JsonObject,
56
63
  type JsonValue,
57
64
  type KeepPane,
58
- type Matched,
59
65
  type MessageAccess,
66
+ type NativeSessionRef,
60
67
  type Next,
61
68
  nameProblem,
62
69
  type OperationRecord,
63
70
  type OutputSchema,
64
71
  type PaneLayout,
72
+ type PaneSpec,
73
+ type ParticipantDirectory,
65
74
  type ParticipantRef,
66
75
  type ParticipantSpec,
67
76
  type PlacementChoice,
68
77
  placementOf,
78
+ positiveDurationMilliseconds,
69
79
  type RaceBranches,
70
- type ReceiveOptions,
71
80
  type RunResult,
72
81
  type RuntimeSelection,
73
- type SelectOptions,
74
82
  type SettingsSpec,
75
83
  type SkillSource,
84
+ type Source,
76
85
  type StageOptions,
77
86
  type StageSummary,
78
87
  type StartedTurn,
88
+ type Taken,
89
+ type Timeout,
79
90
  type TimeRead,
80
91
  type Timer,
81
92
  type TurnId,
@@ -83,8 +94,8 @@ import {
83
94
  type WaitOptions,
84
95
  type When,
85
96
  type WorkflowContext,
86
- type WorkflowDefinition,
87
97
  type WorkflowEvent,
98
+ type WorkflowMeta,
88
99
  } from "@agentswf/contract/workflow";
89
100
  import {
90
101
  type AgentSkills,
@@ -92,7 +103,8 @@ import {
92
103
  findHarness,
93
104
  HARNESS_NAMES,
94
105
  type Harness,
95
- hostHome,
106
+ type HarnessHomes,
107
+ OPERATOR_HOMES,
96
108
  settingsRefusal,
97
109
  skillsLayout,
98
110
  } from "@agentswf/harness";
@@ -121,7 +133,7 @@ import {
121
133
  import type { CoordinatorPort } from "./coordinator-port";
122
134
  import {
123
135
  assertDeadline,
124
- assertDeadlineValue,
136
+ boundedDeadline,
125
137
  deadlineWithin,
126
138
  earlierDeadline,
127
139
  harnessDeadline,
@@ -142,15 +154,6 @@ import {
142
154
  inTimeoutHandler,
143
155
  type KeepAlive,
144
156
  } from "./keep-alive";
145
- import {
146
- AT_DUE,
147
- type RestoredTurn,
148
- RunMailbox,
149
- RunParked,
150
- recoverTurns,
151
- type TurnEnd,
152
- unrunUsage,
153
- } from "./mailbox";
154
157
  import { superviseMessageOperation } from "./message-operation";
155
158
  import { canAwaitReply } from "./messaging/limits";
156
159
  import type { HolderRequest } from "./messaging/messaging";
@@ -165,9 +168,19 @@ import {
165
168
  type SupervisedOutcome,
166
169
  superviseOperation,
167
170
  } from "./operation-liveness";
168
- import { besideOf, checkPaneOptions, keepsPane, storedLayout } from "./pane-layout";
171
+ import { type EarlierOps, OpIds, writeScreen } from "./ops";
172
+ import {
173
+ besideOf,
174
+ checkPaneOptions,
175
+ keepsPane,
176
+ type PaneAsked,
177
+ paneAsked,
178
+ storedPane,
179
+ } from "./pane-layout";
169
180
  import { createResultSlotRegistry, type ResultSlotRegistry } from "./result-slots";
170
181
  import { RunEventLog } from "./run-event-log";
182
+ import { type RunLines, readRunLines } from "./run-lines";
183
+ import { branchOf, matches, type RestoredTurn } from "./run-policy/waits";
171
184
  import {
172
185
  type AgentProgress,
173
186
  type GroupProgress,
@@ -181,7 +194,7 @@ import {
181
194
  type OperationTags,
182
195
  type RunLedger,
183
196
  } from "./run-usage";
184
- import { endTurnsLine, readStageRecords, readTurns } from "./runs";
197
+ import { endLogLine, readAttempts, workDir } from "./runs";
185
198
  import { type CredentialLocks, placeCarried, seedHome } from "./sandbox-homes";
186
199
  import {
187
200
  RUN_SANDBOX_ONLY,
@@ -189,45 +202,62 @@ import {
189
202
  type RunSandboxOptions,
190
203
  type SeatedAgent,
191
204
  } from "./sandboxes";
205
+ import { SessionLines, type SessionPlace } from "./session-lines";
192
206
  import { QUOTING_RULE, simpleInput, takesText, usageLine } from "./simple-input";
193
207
  import { placeSkills, RunSkills, readSkillSources } from "./skills/run-skills";
194
- import { asSource, branchOf, branchValue, source, takeOf } from "./sources";
208
+ import { asSource, branchValue, isSignal, presented, signalOf, source, takeOf } from "./sources";
209
+ import { keepSpend, settleEarlier } from "./spend";
195
210
  import { StageLedger, type StageProgress } from "./stage-ledger";
196
211
  import { FromStageUnreached } from "./stopped";
197
212
  import { inToolHandler, outsideTools, refusedInTool, ToolCalls, toolsLesson } from "./tools";
213
+ import { unrunUsage } from "./turn-ends";
214
+ import { RunParked, RunWaits, type TurnEnd } from "./waits";
198
215
  import type { EventWorkflow } from "./workflow-loader";
199
216
 
200
217
  export { WorkflowCancelledError } from "./deadlines";
201
218
 
219
+ /** What the runner needs of a workflow: its meta and its run. */
220
+ export type RunnableWorkflow<Args extends JsonValue, Result extends JsonValue> = {
221
+ readonly meta: WorkflowMeta;
222
+ run(context: WorkflowContext, args: Args): Promise<Result>;
223
+ };
224
+
202
225
  export type RunWorkflowOptions = {
203
226
  /** Internal timing policy; workflow authors use the operation deadline. */
204
227
  livenessPolicy?: LivenessPolicy;
205
- /** A `keepAlive` window that gives no `timeoutMs`; absent, an hour. A test's is short. */
228
+ /** A `keepAlive` that gives no `window`; absent, an hour. A test's is short. */
206
229
  defaultWindowMs?: number;
207
230
  /** The run's time, which its timers and `wf.now()` read; absent, the clock's. A test's is virtual. */
208
231
  now?: () => number;
232
+ /** Where the control plane's socket directory is made; `/tmp` unless a test watches its own. */
233
+ socketRoot?: string;
209
234
  runRoot: string;
210
235
  /** The run this is an attempt of, whose folder the operator claimed under `runRoot`. */
211
236
  run: {
212
237
  dir: string;
213
238
  id: string;
214
- attempt: number;
239
+ /** The attempt's writer on `run.jsonl`, which its claim made; its `attempt` is this attempt. */
240
+ lines: RunLines;
241
+ /** What the claim's listing of `ops/` found: each key's last `n`, numbered on from. */
242
+ ops: EarlierOps;
215
243
  /** How the attempt is named to a person: its Herdr workspace's label. */
216
244
  label?: string;
217
245
  /** `--from-stage`: the stage this attempt starts at, reusing those before it. */
218
246
  fromStage?: string;
219
247
  /** `--values`: the values of stages before `fromStage` that have no record to reuse. */
220
248
  values?: ReadonlyMap<string, JsonValue>;
249
+ /** `--workspace` and `--keep-panes`, as the run's record keeps them for every attempt. */
250
+ panes?: RunPanes;
221
251
  /** A run of a workflow with `events`. */
222
252
  events?: true;
223
- /** With `events`: runs `work` holding the run's lock, as every append to the mailbox is made. */
253
+ /** With `events`: runs `work` holding the run's lock, as every sender appends to `in.jsonl`. */
224
254
  locked?<T>(work: () => Promise<T>): Promise<T>;
225
255
  /**
226
256
  * With `events`: false when the attempt before this one has no ending, its process gone with its
227
257
  * agents perhaps still running: its sessions aren't reopened until they are stopped (Q17).
228
258
  */
229
259
  resumeSessions?: boolean;
230
- /** With `events`: calls `nudge` whenever awf tells the run its mailbox has a new event. */
260
+ /** With `events`: calls `nudge` whenever awf tells the run it has a new event. */
231
261
  listenNudges?(nudge: () => void): void;
232
262
  /**
233
263
  * Writes the attempt's `parked` ending, which the run's wait calls holding the run's lock: an
@@ -244,7 +274,7 @@ export type RunWorkflowOptions = {
244
274
  deadline?: AbsoluteDeadline;
245
275
  cwd?: string;
246
276
  onLog?: (message: string, fields?: JsonObject) => void;
247
- /** The operator hands the session back once agents.stop has released it. */
277
+ /** The operator hands the session back once close() has released it. */
248
278
  onCallerStopped?: (reason: string) => Promise<void>;
249
279
  signal?: AbortSignal;
250
280
  /** The sandbox providers agents may run in; without them, a sandboxed agent is refused. */
@@ -304,7 +334,7 @@ export type SettledRun = {
304
334
  sandboxes?: SandboxRecord[];
305
335
  /** Each agent's skills; absent when no agent opened. */
306
336
  skills?: AgentSkillsRecord[];
307
- /** Where each pane agent's pane went; absent when the run placed none. */
337
+ /** Where each TUI agent's pane went; absent when the run placed none. */
308
338
  panes?: AgentPaneRecord[];
309
339
  /** Every decision the run asked, in the order asked; absent when it asked none. */
310
340
  decisions?: SettledDecision[];
@@ -392,7 +422,7 @@ type AgentEntry = {
392
422
  * `undefined` when the channel never opened; the agent state carries the reason.
393
423
  */
394
424
  channel: Promise<ResultChannel | undefined>;
395
- /** Why `agents.stop` ended it; its key is not opened again. */
425
+ /** Why `close()` ended it; its key is not opened again. */
396
426
  stopped?: string;
397
427
  stopping?: Promise<boolean>;
398
428
  stopComplete?: boolean;
@@ -416,12 +446,12 @@ type AgentIdentity = {
416
446
  /** Its skills' sources, checked; absent when it has the operator's. */
417
447
  skills?: SkillSource[];
418
448
  instructions?: string;
419
- labels?: AgentOpenSpec["labels"];
420
449
  /** The agent it was forked from, and the spec it was forked with. */
421
- forkedFrom?: { parent: AgentKey; spec: AgentForkSpec };
422
- /** As written; a reopen compares them so, never where the pane ended up. */
423
- layout?: PaneLayout;
424
- keepPane?: KeepPane;
450
+ forkedFrom?: { parent: AgentKey; spec: CallerForkSpec };
451
+ /** As written; a reopen compares it so, never where the pane ended up. */
452
+ pane?: PaneSpec;
453
+ /** What its host is asked for its pane: it, with the operator's flags applied. */
454
+ asked?: PaneAsked;
425
455
  };
426
456
 
427
457
  const CLEANUP_GRACE_MILLISECONDS = 5_000;
@@ -434,7 +464,7 @@ const FORK_TIMEOUT_MS = 60_000;
434
464
  const scopes = new AsyncLocalStorage<ExecutionScope>();
435
465
 
436
466
  export async function runWorkflow<Args extends JsonValue, Result extends JsonValue>(
437
- definition: WorkflowDefinition<Args, Result>,
467
+ definition: RunnableWorkflow<Args, Result>,
438
468
  args: Args,
439
469
  options: RunWorkflowOptions,
440
470
  ): Promise<WorkflowRunResult<Result>> {
@@ -447,25 +477,50 @@ export async function runWorkflow<Args extends JsonValue, Result extends JsonVal
447
477
  * handle's `result`'s.
448
478
  */
449
479
  export async function startWorkflow<Args extends JsonValue, Result extends JsonValue>(
450
- definition: WorkflowDefinition<Args, Result>,
480
+ definition: RunnableWorkflow<Args, Result>,
451
481
  args: Args,
452
482
  options: RunWorkflowOptions,
453
483
  ): Promise<WorkflowRunHandle<Result>> {
454
- assertDeadline(options.deadline);
484
+ // Cancellation wins when the deadline has passed too, as runUntilStopped has it.
455
485
  if (options.signal?.aborted) throw new WorkflowCancelledError(options.signal.reason);
486
+ assertDeadline(options.deadline);
456
487
  // Before anything opens, so a refusal leaves nothing to close.
457
488
  const withEvents = isEventWorkflow(definition);
458
489
  const locked = options.run.locked ?? (<T>(work: () => Promise<T>) => work());
459
- if (withEvents && (options.run.fromStage !== undefined || options.run.values))
460
- throw new Error("a run of a workflow with events takes no --from-stage or --values");
461
- const { dir: runDir, id: runId, attempt } = options.run;
462
- const startedAt = Date.now();
490
+ if (withEvents && options.run.values)
491
+ throw new Error(
492
+ "a run of a workflow with events takes no --values: before --from-stage, a stage with no record runs",
493
+ );
494
+ const { dir: runDir, id: runId } = options.run;
495
+ const { attempt } = options.run.lines;
463
496
  const cwd = options.cwd ?? process.cwd();
464
- // Before anything opens: a record this awf can't read refuses the attempt, and a crash may have
465
- // torn the last line an earlier attempt appended.
466
- const recorded = await readStageRecords(runDir);
497
+ // Before anything opens: a line this awf can't read refuses the attempt.
498
+ const { lines: written, view: recorded } = await readRunLines(runDir);
499
+ // The version each earlier attempt ran, which a line it wrote is reused under.
500
+ const earlier = await readAttempts({ dir: runDir });
501
+ // While no agent of this attempt can resume a session an earlier one's spend is still in.
502
+ await settleEarlier(runDir, {
503
+ attempt,
504
+ attempts: earlier,
505
+ lines: written,
506
+ accounting: options.runtime.host.accounting,
507
+ now: Date.now(),
508
+ ...(options.signal ? { signal: options.signal } : {}),
509
+ }).then(
510
+ ({ failed }) => {
511
+ for (const each of failed) options.onLog?.(`awf: spend.json not written for ${each}`);
512
+ },
513
+ (error: unknown) =>
514
+ options.onLog?.(`awf: earlier attempts' spend was not settled: ${messageOf(error)}`),
515
+ );
516
+ const startedAt = Date.now();
517
+ const versions = new Map(
518
+ earlier.flatMap(({ attempt: n, workflowVersion }) =>
519
+ workflowVersion === undefined ? [] : [[n, workflowVersion] as const],
520
+ ),
521
+ );
467
522
  // The log is observation: one that cannot be written says so as it is, and fails nothing.
468
- await endTurnsLine(runDir).catch(() => undefined);
523
+ await endLogLine(runDir).catch(() => undefined);
469
524
  const slots = createResultSlotRegistry({
470
525
  runDir,
471
526
  ...(options.livenessPolicy
@@ -475,7 +530,10 @@ export async function startWorkflow<Args extends JsonValue, Result extends JsonV
475
530
  }
476
531
  : {}),
477
532
  });
478
- const control = await startResultControlPlane({ slots });
533
+ const control = await startResultControlPlane({
534
+ slots,
535
+ ...(options.socketRoot ? { socketRoot: options.socketRoot } : {}),
536
+ });
479
537
  let host: AgentRunHost;
480
538
  const openingHost = Promise.resolve().then(() =>
481
539
  options.runtime.host.openRun({
@@ -512,8 +570,7 @@ export async function startWorkflow<Args extends JsonValue, Result extends JsonV
512
570
  });
513
571
  };
514
572
  const warn = say("warning");
515
- // Each turn as it settles, so an attempt that dies keeps the sessions and times of its turns.
516
- const turns: TurnRecord[] = [];
573
+ const sessionLines = new SessionLines({ lines: options.run.lines, failed: (text) => warn(text) });
517
574
  const ledger = createRunLedger({ accounting: options.runtime.host.accounting, startedAt });
518
575
  const recordTurn: RecordTurn = (outcome, kind) => {
519
576
  const turn: TurnRecord = {
@@ -524,18 +581,16 @@ export async function startWorkflow<Args extends JsonValue, Result extends JsonV
524
581
  callPath: outcome.usage.callPath,
525
582
  ...pick(outcome.usage, TURN_OPERATION_FIELDS),
526
583
  };
527
- turns.push(turn);
528
584
  events.turn(turn);
529
585
  };
530
586
  const stages = new StageLedger({
531
- runDir,
532
- attempt,
533
- records: recorded,
587
+ lines: options.run.lines,
588
+ run: recorded,
589
+ versions,
534
590
  ...(options.run.fromStage === undefined ? {} : { fromStage: options.run.fromStage }),
535
591
  ...(options.run.values === undefined ? {} : { values: options.run.values }),
536
592
  ...(definition.meta.version === undefined ? {} : { workflowVersion: definition.meta.version }),
537
593
  ...(withEvents ? { byContent: true } : {}),
538
- turns: () => turns,
539
594
  event: (body, at) => events.event(body, at),
540
595
  });
541
596
  const progress = new RunProgress();
@@ -549,6 +604,7 @@ export async function startWorkflow<Args extends JsonValue, Result extends JsonV
549
604
  cwd,
550
605
  deadline: options.deadline,
551
606
  log: (message) => warn(message),
607
+ opened: (record) => events.event({ kind: "sandbox-opened", ...record }),
552
608
  });
553
609
  if (sandboxes.hasRunSandbox) {
554
610
  try {
@@ -563,9 +619,11 @@ export async function startWorkflow<Args extends JsonValue, Result extends JsonV
563
619
  throw new Error(`the run's sandbox did not open: ${messageOf(error)}`);
564
620
  }
565
621
  }
622
+ const ops = new OpIds(runDir, options.run.lines.attempt, options.run.ops);
566
623
  const decisions = new RunDecisions({
567
624
  ...(options.decisions ? { installation: options.decisions } : {}),
568
625
  runDir,
626
+ ops,
569
627
  event: (body) => events.event(body),
570
628
  });
571
629
  const skills = new RunSkills({
@@ -576,28 +634,33 @@ export async function startWorkflow<Args extends JsonValue, Result extends JsonV
576
634
  const fatal = Promise.withResolvers<never>();
577
635
  void fatal.promise.catch(() => undefined);
578
636
  const now = options.now ?? Date.now;
579
- // A turn an earlier attempt started and never ended, its process gone, ends failed before the code
580
- // runs again (Q6); the turns whose ends no wait took are their agents' turns again.
581
- const restoredTurns = withEvents ? await recoverTurns(runDir, attempt, locked, now) : undefined;
582
- // An event run's agents reopen on the sessions its earlier attempts' turns ran on.
637
+ // An event run's agents reopen on the sessions its earlier attempts recorded.
583
638
  const keptSessions =
584
639
  withEvents && options.run.resumeSessions !== false
585
- ? keptSessionsOf(await readTurns(runDir))
640
+ ? keptSessionsOf(recorded.sessions)
586
641
  : undefined;
587
- // Every run's timers live in a mailbox; a run of a workflow with events takes its events there too.
642
+ // Every run's timers are its own lines; a run of a workflow with events takes its events with them.
588
643
  // A plain run without `park` waits for its timers in its process.
589
- const waits = new RunMailbox({
644
+ const { park } = options.run;
645
+ const waits = new RunWaits({
590
646
  runDir,
591
- attempt,
647
+ lines: options.run.lines,
592
648
  locked,
593
649
  now,
594
- ...(options.run.park
650
+ ...(park
595
651
  ? {
596
652
  parking: {
597
- idle: (stage) => owner.idle(stage),
653
+ idle: () => owner.idle(),
598
654
  timeout: (signal) => owner.timeout(signal),
599
655
  liveUntil: () => owner.liveUntil(),
600
- park: options.run.park,
656
+ park: async (wait) => {
657
+ // No wake carries `--from-stage`: parked short of it, the redo would be lost unsaid.
658
+ const unreached = stages.fromStageUnreached;
659
+ if (unreached === undefined) return park(wait);
660
+ const stop = new FromStageUnreached(unreached, stages.recorded);
661
+ fatal.reject(stop);
662
+ throw stop;
663
+ },
601
664
  ...(options.run.live ? { live: options.run.live } : {}),
602
665
  ...(options.run.skip ? { skip: options.run.skip } : {}),
603
666
  },
@@ -605,22 +668,28 @@ export async function startWorkflow<Args extends JsonValue, Result extends JsonV
605
668
  : {}),
606
669
  });
607
670
  await waits.open();
608
- const mailbox = withEvents ? waits : undefined;
609
- if (mailbox) options.run.listenNudges?.(() => mailbox.nudge());
610
- // The mailbox's parking asks the owner, made after it: by then it is.
671
+ const eventWaits = withEvents ? waits : undefined;
672
+ if (eventWaits) options.run.listenNudges?.(() => eventWaits.nudge());
673
+ // A turn an earlier attempt started whose end no wait took is its agent's turn again (the claim
674
+ // ended any a crash left running).
675
+ const restoredTurns = eventWaits?.restored;
676
+ // The waits' parking asks the owner, made after it: by then it is.
611
677
  const owner: WorkflowOwner = new WorkflowOwner({
612
- ...(mailbox ? { mailbox } : {}),
678
+ ...(eventWaits ? { eventWaits } : {}),
613
679
  waits,
614
680
  ...(keptSessions ? { keptSessions } : {}),
681
+ sessionLines,
615
682
  ...(restoredTurns ? { restoredTurns } : {}),
616
683
  failRun: (error) => fatal.reject(error),
617
684
  decisions,
685
+ ops,
618
686
  ...(options.allowance ? { allowance: options.allowance } : {}),
619
687
  sandboxes,
620
688
  skills,
621
689
  runDir,
622
690
  locks,
623
691
  environment,
692
+ ...(options.sandboxes?.homes ? { homes: options.sandboxes.homes } : {}),
624
693
  progress,
625
694
  recordTurn,
626
695
  stages,
@@ -644,6 +713,8 @@ export async function startWorkflow<Args extends JsonValue, Result extends JsonV
644
713
  coordinators: options.coordinators ?? [],
645
714
  emit: options.emit ?? machineEmitter,
646
715
  workflow: definition.meta.name,
716
+ declared: new Set(withEvents ? Object.keys(definition.events) : []),
717
+ ...(options.run.panes ? { panes: options.run.panes } : {}),
647
718
  });
648
719
  const stopped = new AbortController();
649
720
  // Aborted by a stop that comes after the body ended; the one that ended it does not cut short the
@@ -683,7 +754,7 @@ export async function startWorkflow<Args extends JsonValue, Result extends JsonV
683
754
  failed = true;
684
755
  failure = error;
685
756
  }
686
- if (mailbox && !mailbox.parked)
757
+ if (eventWaits && !eventWaits.parked)
687
758
  await owner.endStartedTurns(failed ? "the workflow failed" : "the workflow returned");
688
759
  // Once `parked` is written, the attempt is parked, whatever the code did after.
689
760
  if (waits.parked) {
@@ -731,12 +802,21 @@ export async function startWorkflow<Args extends JsonValue, Result extends JsonV
731
802
 
732
803
  // Before the wait for session files, which is bookkeeping rather than the run's own time.
733
804
  const finishedAt = new Date().toISOString();
805
+ // A session that moved as its agent closed is recorded before the attempt ends.
806
+ owner.recordSessions();
807
+ await sessionLines.flushed();
734
808
  await stages
735
809
  .close()
736
- .catch((error) => warn(`awf: a stage record was not written: ${messageOf(error)}`));
810
+ .catch((error) => warn(`awf: a stage's line was not written: ${messageOf(error)}`));
737
811
  await events.close();
738
812
  const usage = await ledger.settle(reading.signal);
739
813
  signal.removeEventListener("abort", cutReading);
814
+ // What a stop or the read's deadline left unread is the next attempt's to read again.
815
+ const reader = options.runtime.host.accounting;
816
+ const failedSpend = await keepSpend(runDir, attempt, usage, {
817
+ reads: (execution) => reader !== undefined && (reader.canRead?.(execution) ?? true),
818
+ }).catch((error: unknown) => [messageOf(error)]);
819
+ for (const each of failedSpend) options.onLog?.(`awf: spend.json not written for ${each}`);
740
820
  const times = { startedAt: new Date(startedAt).toISOString(), finishedAt };
741
821
  const opened = sandboxes.records();
742
822
  const given = skills.records();
@@ -878,6 +958,8 @@ class WorkflowOwner {
878
958
  readonly #cleanupFailures: Error[] = [];
879
959
  readonly context: WorkflowContext;
880
960
  readonly #agents = new Map<string, AgentEntry>();
961
+ /** The skills each agent's harness was given, as placed: what its session lines record. */
962
+ readonly #givenSkills = new Map<string, AgentSkills>();
881
963
  /** Each agent once it has opened, by key: what messaging asks about, and what a ref must be. */
882
964
  readonly #logical = new Map<string, LogicalAgent>();
883
965
  readonly #messaging: Messaging;
@@ -910,7 +992,9 @@ class WorkflowOwner {
910
992
  defaultWindowMs?: number;
911
993
  locks: CredentialLocks;
912
994
  environment: Readonly<Record<string, string | undefined>>;
995
+ homes?: HarnessHomes;
913
996
  decisions: RunDecisions;
997
+ ops: OpIds;
914
998
  allowance?: (harness: Harness) => Promise<HarnessAllowance>;
915
999
  stages: StageLedger;
916
1000
  runId: string;
@@ -932,19 +1016,25 @@ class WorkflowOwner {
932
1016
  onCallerStopped?: RunWorkflowOptions["onCallerStopped"];
933
1017
  awaitReply: NonNullable<RunWorkflowOptions["awaitReply"]>;
934
1018
  coordinators: readonly CoordinatorPort[];
935
- /** A run of a workflow with `events`: its mailbox, which its waits take from. */
936
- mailbox?: RunMailbox;
1019
+ /** A run of a workflow with `events`: its waits, which take its events. */
1020
+ eventWaits?: RunWaits;
937
1021
  /** The run's time. */
938
1022
  now: () => number;
939
- /** Its timers: the mailbox for a run with `events`, one of its own for a plain run. */
940
- waits: RunMailbox;
941
- /** With `mailbox`: each root agent's last session in earlier attempts, which it reopens on. */
1023
+ /** Its timers and waits, every run's. */
1024
+ waits: RunWaits;
1025
+ /** With `eventWaits`: each agent's latest session in earlier attempts, which it reopens on. */
942
1026
  keptSessions?: ReadonlyMap<string, NativeFork>;
943
- /** With `mailbox`: each agent's turn an earlier attempt started whose end no wait took. */
1027
+ /** Where each session an agent is seen with is recorded, as it is. */
1028
+ sessionLines: SessionLines;
1029
+ /** With `eventWaits`: each agent's turn an earlier attempt started whose end no wait took. */
944
1030
  restoredTurns?: ReadonlyMap<string, RestoredTurn>;
945
1031
  emit: Emitter;
946
1032
  /** The workflow's name, which with the run's id is the ref its events are `from`. */
947
1033
  workflow: string;
1034
+ /** The names its `events` declares, which `signal` takes. */
1035
+ declared: ReadonlySet<string>;
1036
+ /** The operator's choices for every TUI agent's pane on the host. */
1037
+ panes?: RunPanes;
948
1038
  },
949
1039
  ) {
950
1040
  this.#restored = new Map(options.restoredTurns ?? []);
@@ -964,6 +1054,7 @@ class WorkflowOwner {
964
1054
  },
965
1055
  schedule: (at, action) => scheduleAt({ unixMilliseconds: at }, action),
966
1056
  now: Date.now,
1057
+ question: (n) => `${options.attempt}.${n}`,
967
1058
  record: async (event) => {
968
1059
  this.#messagesChanged();
969
1060
  if (event.kind === "message-sent") {
@@ -1004,7 +1095,10 @@ class WorkflowOwner {
1004
1095
  runId: options.runId,
1005
1096
  attempt: options.attempt,
1006
1097
  cwd: options.cwd,
1007
- deadline: options.deadline,
1098
+ // A new `Date` each time, so one a workflow changes isn't the run's.
1099
+ get deadline() {
1100
+ return options.deadline && new Date(options.deadline.unixMilliseconds);
1101
+ },
1008
1102
  agents: {
1009
1103
  open: (spec) => {
1010
1104
  try {
@@ -1013,24 +1107,6 @@ class WorkflowOwner {
1013
1107
  return Promise.reject(error);
1014
1108
  }
1015
1109
  },
1016
- attach: (key, runtime) => {
1017
- if (this.#caller?.key !== key) return unavailable("agents.attach");
1018
- const stopped = this.#agents.get(key)?.stopped;
1019
- if (stopped !== undefined) return Promise.reject(stoppedError(key, stopped));
1020
- if (runtime !== undefined) {
1021
- return Promise.reject(
1022
- new Error(`agent ${key} is the calling session, whose runtime is the operator's`),
1023
- );
1024
- }
1025
- return this.#caller.state;
1026
- },
1027
- stop: (key, reason) => {
1028
- try {
1029
- return this.stopAgent(key, reason);
1030
- } catch (error) {
1031
- return Promise.reject(error);
1032
- }
1033
- },
1034
1110
  caller: (spec) => {
1035
1111
  try {
1036
1112
  return this.caller(spec.key);
@@ -1083,17 +1159,10 @@ class WorkflowOwner {
1083
1159
  },
1084
1160
  },
1085
1161
  participants: {
1086
- connect: async (spec) => this.connectParticipant(spec),
1087
- get: async (key) =>
1088
- (this.#messaging.connected().includes(key) ? this.#outsideRefs.get(key) : undefined) ??
1089
- (this.#logical.get(key) as AgentRef | undefined) ??
1090
- null,
1162
+ connect: (async (spec: ParticipantSpec) =>
1163
+ this.connectParticipant(spec)) as ParticipantDirectory["connect"],
1091
1164
  },
1092
1165
  messages: { allow: async (access) => this.allowRoute(access) },
1093
- steps: {
1094
- run: () => unavailable("steps.run"),
1095
- sleep: () => unavailable("steps.sleep"),
1096
- },
1097
1166
  emit: async (target, run, name, data, emitOptions) => {
1098
1167
  if (this.#closed || this.#cleanupFailures.length)
1099
1168
  throw new Error("workflow context is closed");
@@ -1115,9 +1184,12 @@ class WorkflowOwner {
1115
1184
  },
1116
1185
  parallel: (items, operation, parallelOptions) => {
1117
1186
  const inherited = scopes.getStore()?.deadline ?? options.deadline;
1118
- const deadline = parallelOptions?.deadline
1119
- ? earlierDeadline(parallelOptions.deadline, inherited)
1120
- : inherited;
1187
+ let deadline: AbsoluteDeadline | undefined;
1188
+ try {
1189
+ deadline = boundedDeadline(parallelOptions?.timeout, inherited);
1190
+ } catch (error) {
1191
+ return Promise.reject(error);
1192
+ }
1121
1193
  const label = parallelOptions?.label;
1122
1194
  return runParallel(items, operation, deadline, parallelOptions?.concurrency, () =>
1123
1195
  label === undefined ? undefined : options.progress.group(label, items.length),
@@ -1129,7 +1201,6 @@ class WorkflowOwner {
1129
1201
  },
1130
1202
  stage: ((name: string, ...rest: unknown[]) =>
1131
1203
  this.runStage(name, rest)) as WorkflowContext["stage"],
1132
- call: () => unavailable("call"),
1133
1204
  usage: () => options.ledger.records(),
1134
1205
  allowance: async (harness) => {
1135
1206
  if (this.#closed || this.#cleanupFailures.length)
@@ -1153,33 +1224,29 @@ class WorkflowOwner {
1153
1224
  undefined,
1154
1225
  );
1155
1226
  },
1227
+ // Every run waits on its own lines; one without events never parks, so it waits here.
1228
+ receive: (spec?: WaitOptions) => this.receive(options.waits, spec),
1229
+ race: ((name: string, branches: RaceBranches, raceOptions?: WaitOptions) =>
1230
+ this.race(options.waits, name, branches, raceOptions)) as WorkflowContext["race"],
1231
+ select: ((branches: Branches, selectOptions?: WaitOptions) =>
1232
+ this.select(options.waits, branches, selectOptions)) as WorkflowContext["select"],
1233
+ timer: (name: string, when: When) => this.timer(options.waits, name, when),
1234
+ at: (read: () => Date | null) => timeRead(read),
1235
+ after: (duration: Duration) => {
1236
+ this.#notReading("wf.after()");
1237
+ let milliseconds: number;
1238
+ try {
1239
+ milliseconds = durationMilliseconds(duration);
1240
+ } catch (error) {
1241
+ throw new Error(`wf.after takes a duration: ${messageOf(error)}`);
1242
+ }
1243
+ return new Date(options.now() + milliseconds);
1244
+ },
1245
+ signal: ((name: string) => signalSource(name, options.declared)) as WorkflowContext["signal"],
1246
+ ...(options.eventWaits?.started
1247
+ ? { started: options.eventWaits.started as WorkflowContext["started"] }
1248
+ : {}),
1156
1249
  };
1157
- const { mailbox } = options;
1158
- Object.assign(
1159
- this.context,
1160
- mailbox
1161
- ? {
1162
- receive: (spec?: ReceiveOptions) => this.receive(mailbox, spec),
1163
- race: (name: string, branches: RaceBranches, raceOptions?: WaitOptions) =>
1164
- this.race(mailbox, name, branches, raceOptions),
1165
- select: (branches: Branches, selectOptions?: SelectOptions) =>
1166
- this.select(mailbox, branches, selectOptions),
1167
- timer: (name: string, when: When) => this.timer(mailbox, name, when),
1168
- timers: () => source({}, { name: "timer:due" }),
1169
- at: (read: () => Date | null) => timeRead(read),
1170
- after: (duration: Duration) => {
1171
- this.#notReading("wf.after()");
1172
- if (typeof duration !== "string" || !/^\d+(?:\.\d+)?[dhm]$/.test(duration))
1173
- throw new Error(
1174
- `wf.after takes days, hours or minutes, "1d", "3h", "1.5h"; not ${String(duration)}`,
1175
- );
1176
- return new Date(parseWhen(duration, options.now()).due);
1177
- },
1178
- signal: (name: string) => signalSource(name),
1179
- ...(mailbox.started ? { started: mailbox.started } : {}),
1180
- }
1181
- : { receive: () => Promise.reject(new Error("receive needs a workflow with events")) },
1182
- );
1183
1250
  }
1184
1251
 
1185
1252
  /** Set while a select reads a time from state. */
@@ -1243,7 +1310,8 @@ class WorkflowOwner {
1243
1310
  if (this.#restored.get(event.from)?.turn === turn) this.#restored.delete(event.from);
1244
1311
  this.#logical.get(event.from)?.endTaken(turn);
1245
1312
  }
1246
- return taken;
1313
+ // `in.jsonl` and the turns' ends keep awf's record names; the code is handed the names it declares.
1314
+ return taken.map((event) => presented(event, this.options.declared));
1247
1315
  } finally {
1248
1316
  removeFromScope?.();
1249
1317
  this.#waiting = false;
@@ -1251,7 +1319,7 @@ class WorkflowOwner {
1251
1319
  }
1252
1320
 
1253
1321
  /** A timer, by name: the recorded one, or set now. A race's own are named `{race}@{branch}`. */
1254
- private timer(mailbox: RunMailbox, name: string, when: When, own = false): Timer {
1322
+ private timer(waits: RunWaits, name: string, when: When, own = false): Timer {
1255
1323
  if (this.#closed || this.#cleanupFailures.length) throw new Error("workflow context is closed");
1256
1324
  if (typeof name !== "string") throw new Error("a timer's name is a string");
1257
1325
  if (!own) {
@@ -1261,7 +1329,7 @@ class WorkflowOwner {
1261
1329
  if (problem) throw new Error(`timer ${name}: its name is ${problem}`);
1262
1330
  }
1263
1331
  const given = parseWhen(when, this.options.now());
1264
- const { timer, asked } = mailbox.setTimer(name, given.due, given.when);
1332
+ const { timer, asked } = waits.setTimer(name, given.due, given.when);
1265
1333
  if (timer.cancelled)
1266
1334
  this.options.onLog?.(
1267
1335
  `awf: timer ${name} was cancelled; a name stays cancelled, so it won't fire`,
@@ -1270,12 +1338,12 @@ class WorkflowOwner {
1270
1338
  this.options.onLog?.(
1271
1339
  `awf: timer ${name} is due ${timer.due}, as first set (${timer.when}); ${asked} is ignored`,
1272
1340
  );
1273
- return timerSource(mailbox, name, timer.due);
1341
+ return timerSource(waits, name, timer.due);
1274
1342
  }
1275
1343
 
1276
1344
  /** Waits for the first of `branches`, recorded under `name`. */
1277
1345
  private async race(
1278
- mailbox: RunMailbox,
1346
+ waits: RunWaits,
1279
1347
  name: string,
1280
1348
  branches: RaceBranches,
1281
1349
  raceOptions: WaitOptions | undefined,
@@ -1291,8 +1359,7 @@ class WorkflowOwner {
1291
1359
  throw new Error(
1292
1360
  `race ${name} has answered in this attempt; a wait in a loop is named per round, as approval-\${round}`,
1293
1361
  );
1294
- this.#raced.add(name);
1295
- const label = checkedLabel(raceOptions?.label);
1362
+ const label = labelOf(raceOptions?.label);
1296
1363
  const given = branchesOf(`race ${name}`, branches, true);
1297
1364
  // A sleep's time may be a Date, its own timer as a duration is.
1298
1365
  const own = (source: unknown) => typeof source === "string" || source instanceof Date;
@@ -1302,36 +1369,43 @@ class WorkflowOwner {
1302
1369
  own(source) ? [] : [[branch, sourceTake(`race ${name}`, branch, source)] as const],
1303
1370
  ),
1304
1371
  );
1372
+ this.#checkSources(`race ${name}`, takes.entries());
1373
+ // Only once its label and branches are checked: a refused race leaves its name to a retry.
1374
+ this.#raced.add(name);
1305
1375
  const owned = new Map<string, string>();
1306
1376
  const pairs: (readonly [string, WaitTake])[] = given.map(([branch, source]) => {
1307
1377
  if (!own(source)) return [branch, takes.get(branch) as WaitTake] as const;
1308
- const timer = this.timer(mailbox, `${name}@${branch}`, source as When, true);
1378
+ const timer = this.timer(waits, `${name}@${branch}`, source as When, true);
1309
1379
  owned.set(branch, timer.name);
1310
1380
  return [branch, { name: "timer:due", from: timer.name }] as const;
1311
1381
  });
1312
- let won: { branch: string; event: WorkflowEvent<string, JsonValue> } | undefined;
1382
+ let branch: string | undefined;
1383
+ let event: WorkflowEvent<string, JsonValue>;
1313
1384
  try {
1314
- await this.#holdingWait(scope, async (signal) => {
1315
- won = await mailbox.race(name, pairs, owned, label, signal);
1385
+ [event] = (await this.#holdingWait(scope, async (signal) => {
1386
+ const won = await waits.race(name, pairs, owned, label, signal);
1387
+ branch = won.branch;
1316
1388
  return [won.event];
1317
- });
1389
+ })) as [WorkflowEvent<string, JsonValue>];
1318
1390
  } catch (error) {
1319
- // Cut short, not parked: its own timers would fire into a later wait. A park's rerun races again.
1320
- if (!(error instanceof RunParked))
1321
- await Promise.all([...owned.values()].map((timer) => mailbox.cancelTimer(timer))).catch(
1391
+ // Cut short, not parked: its own timers would fire into a later wait. A park's rerun races
1392
+ // again, and so does a continue's after a park refused short of its --from-stage.
1393
+ if (!(error instanceof RunParked || error instanceof FromStageUnreached))
1394
+ await Promise.all([...owned.values()].map((timer) => waits.cancelTimer(timer))).catch(
1322
1395
  () => undefined,
1323
1396
  );
1324
1397
  throw error;
1325
1398
  }
1326
- const { branch, event } = won as { branch: string; event: WorkflowEvent<string, JsonValue> };
1327
- return { key: branch, [branch]: branchValue(event) } as Next<RaceBranches>;
1399
+ const key = branch as string;
1400
+ const whole = isSignal(given.find(([each]) => each === key)?.[1]);
1401
+ return { key, [key]: branchValue(event, whole) } as Next<RaceBranches>;
1328
1402
  }
1329
1403
 
1330
1404
  /** A loop over the events `branches` take, one at a time. */
1331
1405
  private select(
1332
- mailbox: RunMailbox,
1406
+ waits: RunWaits,
1333
1407
  branches: Branches,
1334
- selectOptions: SelectOptions | undefined,
1408
+ selectOptions: WaitOptions | undefined,
1335
1409
  ): AsyncIterable<Next<Branches>> {
1336
1410
  const given = branchesOf("select", branches, false);
1337
1411
  const reads = new Map(
@@ -1344,9 +1418,12 @@ class WorkflowOwner {
1344
1418
  ([branch, source]) =>
1345
1419
  [
1346
1420
  branch,
1347
- reads.has(branch) ? { name: AT_DUE, from: branch } : sourceTake("select", branch, source),
1421
+ reads.has(branch)
1422
+ ? { name: "at:due", from: branch }
1423
+ : sourceTake("select", branch, source),
1348
1424
  ] as const,
1349
1425
  );
1426
+ this.#checkSources("select", pairs);
1350
1427
  // Read at each pass of the wait: a time follows the state as it moves.
1351
1428
  const takes = (): WaitTake[] =>
1352
1429
  pairs.map(([branch, take]) => {
@@ -1354,20 +1431,17 @@ class WorkflowOwner {
1354
1431
  const due = read && this.#readTime(branch, read);
1355
1432
  return due === undefined ? take : { ...take, due };
1356
1433
  });
1357
- const labelOf = () => {
1358
- const given = selectOptions?.label;
1359
- return checkedLabel(typeof given === "function" ? given() : given);
1360
- };
1361
1434
  const next = async (): Promise<Next<Branches>> => {
1362
1435
  const scope = this.#beginWait("select");
1363
1436
  if (scope?.stage !== undefined)
1364
1437
  throw new Error(`select is in stage ${scope.stage}: a loop's wait is not a stage's`);
1365
1438
  const [event] = await this.#holdingWait(scope, async (signal) => [
1366
- await mailbox.select(takes, labelOf(), signal),
1439
+ await waits.select(takes, labelOf(selectOptions?.label), signal),
1367
1440
  ]);
1368
1441
  const taken = event as WorkflowEvent<string, JsonValue>;
1369
1442
  const branch = branchOf(pairs, taken) as string;
1370
- return { key: branch, [branch]: branchValue(taken) } as Next<Branches>;
1443
+ const whole = isSignal(given.find(([each]) => each === branch)?.[1]);
1444
+ return { key: branch, [branch]: branchValue(taken, whole) } as Next<Branches>;
1371
1445
  };
1372
1446
  return {
1373
1447
  [Symbol.asyncIterator]: () => ({
@@ -1377,17 +1451,38 @@ class WorkflowOwner {
1377
1451
  };
1378
1452
  }
1379
1453
 
1454
+ /**
1455
+ * A wait's branches as a run can take them: a run without events, which hears no agent's end or
1456
+ * reply to the code, refuses a branch waiting on one, which would never come.
1457
+ */
1458
+ #checkSources(what: string, takes: Iterable<readonly [string, WaitTake]>): void {
1459
+ const given = [...takes];
1460
+ if (!this.options.eventWaits) {
1461
+ const unheard = given.find(([, take]) => HEARD_WITH_EVENTS.has(take.name));
1462
+ if (unheard)
1463
+ throw new Error(
1464
+ `${what}'s ${unheard[0]} waits on ${unheard[1].name === "agent:ended" ? "an agent's end" : "a participant's reply to the code"}, which a run without events never hears; declare events in the workflow`,
1465
+ );
1466
+ }
1467
+ }
1468
+
1380
1469
  #waiting = false;
1381
1470
  /** The races and sleeps this attempt has run, each by its name once. */
1382
1471
  readonly #raced = new Set<string>();
1383
1472
  /** Each agent's turn an earlier attempt started whose end no wait has taken yet. */
1384
1473
  readonly #restored: Map<string, RestoredTurn>;
1385
1474
 
1386
- /** How an agent of an event run records its started turns in the mailbox. */
1387
- #turnsOf(key: string, mailbox: RunMailbox): LogicalAgentTurns {
1475
+ /** How an agent of an event run records its started turns in `run.jsonl`. */
1476
+ #turnsOf(key: string, waits: RunWaits): LogicalAgentTurns {
1388
1477
  return {
1389
- begin: (turn, meta, execution) => mailbox.beginTurn(key, turn, meta, execution),
1390
- end: (turn, meta, outcome) => mailbox.endTurn(key, { turn, meta, outcome }),
1478
+ begin: (turn, meta, execution) => waits.beginTurn(key, turn, meta, execution),
1479
+ end: (turn, meta, outcome) => waits.endTurn(key, { turn, meta, outcome }),
1480
+ checkDeclared: () => {
1481
+ if (!this.options.declared.has(`${key}:ended`))
1482
+ throw new Error(
1483
+ `agent ${key}: start()'s end arrives as ${key}:ended, which the workflow declares in events, "${key}:ended": AgentEnded`,
1484
+ );
1485
+ },
1391
1486
  // Read when the agent opens: a wait before then may have taken the end.
1392
1487
  restored: () => this.#restored.get(key),
1393
1488
  };
@@ -1411,10 +1506,9 @@ class WorkflowOwner {
1411
1506
  * Nothing of the run is working but its wait: no agent's turn, message or compaction, and no
1412
1507
  * decision, sandbox or agent opening in flight.
1413
1508
  */
1414
- idle(waitStage?: string): boolean {
1415
- const open = this.options.stages.openStage;
1509
+ idle(): boolean {
1416
1510
  return (
1417
- (open === undefined || open === waitStage) &&
1511
+ this.options.stages.openStage === undefined &&
1418
1512
  this.#inFlight.size === 0 &&
1419
1513
  [...this.#logical.values()].every((agent) => !agent.busy)
1420
1514
  );
@@ -1442,7 +1536,7 @@ class WorkflowOwner {
1442
1536
  if (!onTimeout) continue;
1443
1537
  try {
1444
1538
  await runUntilStopped(
1445
- async () => asTimeoutHandler(() => onTimeout(agent as unknown as EventAgentRef)),
1539
+ async () => asTimeoutHandler(() => onTimeout(agent)),
1446
1540
  signal,
1447
1541
  undefined,
1448
1542
  );
@@ -1454,34 +1548,17 @@ class WorkflowOwner {
1454
1548
  }
1455
1549
  return false;
1456
1550
  }
1457
- /** The batch a wait in a stage took, by stage, until the stage ends: what it must return. */
1458
- readonly #stageBatches = new Map<string, WorkflowEvent<string, JsonValue>[]>();
1459
- /** Stages around a wait this attempt entered: each is entered once, as a loop over one spins. */
1460
- readonly #waitedStages = new Set<string>();
1461
-
1462
- /** A wait: one at a time, and in a stage, the stage's batch. */
1551
+ /** A wait, outside any stage: a wait a rerun gets the same answer from is a `race`. */
1463
1552
  private async receive(
1464
- mailbox: RunMailbox,
1465
- spec: ReceiveOptions | undefined,
1553
+ waits: RunWaits,
1554
+ spec: WaitOptions | undefined,
1466
1555
  ): Promise<WorkflowEvent<string, JsonValue>[]> {
1467
1556
  const scope = this.#beginWait("receive()");
1468
- if (scope?.stage !== undefined && this.#stageBatches.has(scope.stage))
1469
- throw new Error(`stage ${scope.stage} has waited once; a stage holds one wait`);
1470
- try {
1471
- const taken = await this.#holdingWait(scope, (signal) =>
1472
- mailbox.receive(spec, scope?.stage, signal),
1557
+ if (scope?.stage !== undefined)
1558
+ throw new Error(
1559
+ `receive() is refused in stage ${scope.stage}: wait outside it with wf.race(name, …), which is recorded under its name`,
1473
1560
  );
1474
- if (scope?.stage !== undefined) {
1475
- this.#stageBatches.set(scope.stage, taken);
1476
- this.#waitedStages.add(scope.stage);
1477
- }
1478
- return taken;
1479
- } catch (error) {
1480
- // Parked in a stage: left unended now, before anything closes it, for the next attempt.
1481
- if (error instanceof RunParked && scope?.stage !== undefined)
1482
- this.options.stages.abandon(scope.stage);
1483
- throw error;
1484
- }
1561
+ return this.#holdingWait(scope, (signal) => waits.receive(receiveLabel(spec), signal));
1485
1562
  }
1486
1563
 
1487
1564
  /** An agent as messaging sees it, from its entry and its operations. */
@@ -1512,47 +1589,38 @@ class WorkflowOwner {
1512
1589
  !isJsonValue(labels))
1513
1590
  )
1514
1591
  throw new Error("a participant's labels are a JSON object");
1515
- const to = (spec as { to?: unknown }).to;
1516
- if (to !== undefined && to !== "code")
1517
- throw new Error(`a participant's messages go to agents, or to: "code"; not ${String(to)}`);
1518
- if (to === "code" && !this.options.mailbox)
1592
+ if ((spec as { to?: unknown }).to !== undefined)
1519
1593
  throw new Error(
1520
- "a participant routed to the code needs a workflow with events, whose waits take its replies",
1594
+ `participant ${key} is connected without to: its messages reach the code when the workflow declares "${key}:reply": ParticipantReply in events`,
1521
1595
  );
1522
1596
  this.#messaging.connect(
1523
1597
  key,
1524
1598
  labels === undefined ? undefined : structuredClone(labels),
1525
- to === "code" ? "code" : undefined,
1599
+ this.options.declared.has(`${key}:reply`) ? "code" : undefined,
1526
1600
  );
1527
1601
  const ref: ParticipantRef = {
1528
1602
  ...participantRef(key),
1603
+ post: async (text: string) => {
1604
+ if (typeof text !== "string" || text.trim() === "")
1605
+ throw new Error("post takes the text to write");
1606
+ await this.#messaging.post(key, text);
1607
+ },
1529
1608
  release: async (reason) => {
1530
1609
  if (reason !== undefined && typeof reason !== "string")
1531
1610
  throw new Error("a release's reason is a string");
1532
1611
  return this.#messaging.release(key, reason);
1533
1612
  },
1534
1613
  };
1535
- if (to === "code") {
1536
- const routed = source(
1537
- {
1538
- ...ref,
1539
- post: async (text: string) => {
1540
- if (typeof text !== "string" || text.trim() === "")
1541
- throw new Error("post takes the text to write");
1542
- await this.#messaging.post(key, text);
1543
- },
1544
- },
1545
- { name: "participant:reply", from: key },
1546
- ) as unknown as CodeParticipantRef;
1547
- this.#outsideRefs.set(key, routed);
1548
- return routed;
1549
- }
1550
1614
  this.#outsideRefs.set(key, ref);
1551
1615
  return ref;
1552
1616
  }
1553
1617
 
1554
1618
  /** A key an agent may not take: a reserved one, or an outside participant's. */
1555
1619
  #assertFreeKey(key: string, who: string): void {
1620
+ if (typeof key === "string" && key.includes(":"))
1621
+ throw new Error(
1622
+ `${key} has a colon, kept for awf's own event names, {key}:ended and {key}:reply; ${who} takes another key, such as ${key.replaceAll(":", "-")}`,
1623
+ );
1556
1624
  if ((RESERVED_PARTICIPANT_KEYS as readonly string[]).includes(key.toLowerCase()))
1557
1625
  throw new Error(
1558
1626
  `${key} is reserved: an agent could read a message from it as authority; ${who} takes another key`,
@@ -1600,10 +1668,14 @@ class WorkflowOwner {
1600
1668
  throw new Error("a route between agents names two of them");
1601
1669
  ends = { between: [end(shape.between[0]), end(shape.between[1])] };
1602
1670
  } else ends = { from: end(shape.from), to: end(shape.to) };
1671
+ const timeoutMs =
1672
+ access.timeout == null
1673
+ ? undefined
1674
+ : positiveDurationMilliseconds(access.timeout, "a route's timeout");
1603
1675
  this.#messaging.grant(ends, {
1604
1676
  purpose: access.purpose,
1605
1677
  maxMessages: access.maxMessages,
1606
- timeoutMs: access.timeoutMs,
1678
+ timeoutMs,
1607
1679
  scope: scope?.messages ?? this.#messaging.attempt,
1608
1680
  });
1609
1681
  }
@@ -1694,7 +1766,7 @@ class WorkflowOwner {
1694
1766
  : "cancelled",
1695
1767
  reason: "workflow closing",
1696
1768
  })
1697
- .then(() => state.close("workflow complete")),
1769
+ .then(() => state.terminate("workflow complete")),
1698
1770
  () => undefined,
1699
1771
  ),
1700
1772
  );
@@ -1777,19 +1849,7 @@ class WorkflowOwner {
1777
1849
  });
1778
1850
  if (entered.kind === "reuse") {
1779
1851
  entered.release();
1780
- // A stage around a wait returns the batch it took, which its record doesn't hold.
1781
- const batch =
1782
- entered.value === undefined && !options
1783
- ? await this.options.mailbox?.batchOf(name)
1784
- : undefined;
1785
- if (batch) {
1786
- if (this.#waitedStages.has(name))
1787
- throw new Error(
1788
- `stage ${name} around a wait was entered again in this attempt, and would return the same batch forever; name it by what it waits for`,
1789
- );
1790
- this.#waitedStages.add(name);
1791
- }
1792
- return (batch as JsonValue | undefined) ?? entered.value;
1852
+ return entered.value;
1793
1853
  }
1794
1854
  const open = entered.stage;
1795
1855
  const messages = this.#messaging.scope(name);
@@ -1807,22 +1867,9 @@ class WorkflowOwner {
1807
1867
  this.#messaging.finishing(messages);
1808
1868
  scope.seal();
1809
1869
  await this.drainMessages(messages, () => scope.settleOwned());
1810
- // A timer it set is written before its record, which a rerun reuses without its work.
1870
+ // A timer it set is written before its line, which a rerun reuses without its work.
1811
1871
  await this.options.waits.flush();
1812
1872
  if (this.options.stages.stopped?.stage === name) throw this.options.stages.caught();
1813
- const batch = this.#stageBatches.get(name);
1814
- if (batch && !options) {
1815
- const same =
1816
- Array.isArray(returned) &&
1817
- returned.length === batch.length &&
1818
- returned.every((event, i) => (event as { id?: unknown })?.id === batch[i]?.id);
1819
- if (!same)
1820
- throw new Error(
1821
- `stage ${name} returned other events than its wait took; its work is the wait alone`,
1822
- );
1823
- await open.succeed(undefined);
1824
- return batch as JsonValue;
1825
- }
1826
1873
  const value = stageValue(name, options, returned);
1827
1874
  await open.succeed(value, summaryOf(name, options, value, this.options.onLog));
1828
1875
  return value;
@@ -1842,7 +1889,6 @@ class WorkflowOwner {
1842
1889
  }
1843
1890
  throw error;
1844
1891
  } finally {
1845
- this.#stageBatches.delete(name);
1846
1892
  this.#messaging.closeScope(messages);
1847
1893
  this.#stageScopes.delete(messages);
1848
1894
  removeFromParent?.();
@@ -1856,13 +1902,14 @@ class WorkflowOwner {
1856
1902
  if (this.#caller?.key === spec.key) {
1857
1903
  throw new Error(`agent ${spec.key} is the calling session; it is not opened`);
1858
1904
  }
1905
+ this.options.ops.claim(spec.key, "agent");
1859
1906
  const inRunSandbox = this.options.sandboxes.hasRunSandbox;
1860
1907
  if (inRunSandbox && spec.sandbox !== undefined)
1861
1908
  throw new Error(`agent ${spec.key}: ${RUN_SANDBOX_ONLY}`);
1862
1909
  const existing = this.#agents.get(spec.key);
1863
1910
  if (existing?.stopped !== undefined) throw stoppedError(spec.key, existing.stopped);
1864
- const given = (spec as EventAgentOpenSpec).keepAlive;
1865
- if (given !== undefined && !this.options.mailbox)
1911
+ const given = spec.keepAlive;
1912
+ if (given !== undefined && !this.options.eventWaits)
1866
1913
  throw new Error(
1867
1914
  `agent ${spec.key}: keepAlive is for a workflow with events, whose runs park; this one's never do`,
1868
1915
  );
@@ -1870,26 +1917,25 @@ class WorkflowOwner {
1870
1917
  const execution = existing
1871
1918
  ? constrainExistingExecution(spec.runtime, existing.identity.execution)
1872
1919
  : withKnownEffort(resolveExecution(spec.runtime, this.options.runtime));
1873
- checkPaneOptions(spec.key, spec, {
1920
+ const paneless = {
1874
1921
  headless: placementOf(execution) === "headless",
1875
1922
  sandboxed:
1876
1923
  spec.sandbox !== undefined || existing?.identity.sandbox !== undefined || inRunSandbox,
1877
- });
1924
+ };
1925
+ checkPaneOptions(spec.key, spec, paneless);
1926
+ const pane = spec.pane === undefined ? undefined : storedPane(spec.pane);
1878
1927
  const identity: AgentIdentity = {
1879
1928
  execution,
1880
1929
  cwd: spec.cwd ?? this.options.cwd,
1881
1930
  ...(spec.instructions === undefined ? {} : { instructions: spec.instructions }),
1882
- ...(spec.labels === undefined ? {} : { labels: structuredClone(spec.labels) }),
1883
1931
  ...(spec.sandbox === undefined ? {} : { sandbox: spec.sandbox }),
1884
1932
  ...(spec.skills === undefined ? {} : { skills: readSkillSources(spec.skills) }),
1885
- ...(spec.layout === undefined ? {} : { layout: storedLayout(spec.layout) }),
1886
- ...(spec.keepPane === undefined ? {} : { keepPane: spec.keepPane }),
1933
+ ...(pane === undefined ? {} : { pane }),
1934
+ asked: paneAsked(pane, this.options.panes, !paneless.headless && !paneless.sandboxed),
1887
1935
  };
1888
1936
  const scope = scopes.getStore();
1889
1937
  const inheritedDeadline = scope?.deadline ?? this.options.deadline;
1890
- const effectiveDeadline = spec.deadline
1891
- ? earlierDeadline(spec.deadline, inheritedDeadline)
1892
- : inheritedDeadline;
1938
+ const effectiveDeadline = boundedDeadline(spec.timeout, inheritedDeadline);
1893
1939
  assertDeadline(effectiveDeadline);
1894
1940
  if (existing) {
1895
1941
  assertCompatibleAgent(spec.key, existing.identity, identity, spec);
@@ -1944,15 +1990,14 @@ class WorkflowOwner {
1944
1990
  // A kept session already holds the instructions; they are not sent again.
1945
1991
  ...(kept ? { continues: kept } : {}),
1946
1992
  ...(spec.instructions === undefined || kept ? {} : { instructions: spec.instructions }),
1947
- ...(this.options.mailbox ? { paneAtFirstTurn: true as const } : {}),
1948
- ...(spec.labels === undefined ? {} : { labels: spec.labels }),
1993
+ ...(this.options.eventWaits ? { paneAtFirstTurn: true as const } : {}),
1949
1994
  ...(seated ? { occupant: seated.occupant } : {}),
1950
1995
  ...(seated?.webSearch ? { webSearch: true } : {}),
1951
1996
  ...(skills ? { skills } : {}),
1952
1997
  ...(home ? { home } : {}),
1953
- ...paneOptions(identity),
1998
+ ...identity.asked,
1954
1999
  }),
1955
- layoutFallback: this.besideFallback(identity.layout, effectiveDeadline)(),
2000
+ layoutFallback: this.besideFallback(identity.asked?.layout, effectiveDeadline)(),
1956
2001
  };
1957
2002
  let skills: Promise<PlacedSkills | undefined> = Promise.resolve(undefined);
1958
2003
  let opened: { state: Promise<LogicalAgent>; channel: Promise<ResultChannel> };
@@ -1985,11 +2030,11 @@ class WorkflowOwner {
1985
2030
 
1986
2031
  /**
1987
2032
  * Ends an agent the workflow has no more use for, as an operation that fails ends it: what it
1988
- * is running is cancelled, and its pane closed or, where `keepPane` says, kept and released.
2033
+ * is running is cancelled, and its pane closed or, where `--keep-panes` says, kept and released.
1989
2034
  */
1990
- private stopAgent(key: string, reason?: string): Promise<boolean> {
2035
+ private stopAgent(key: string, reason: string | undefined, what: string): Promise<boolean> {
1991
2036
  if (this.#closed || this.#cleanupFailures.length) throw new Error("workflow context is closed");
1992
- const inTool = refusedInTool(key, "agents.stop");
2037
+ const inTool = refusedInTool(key, what);
1993
2038
  if (inTool) throw inTool;
1994
2039
  const scope = scopes.getStore();
1995
2040
  scope?.assertAccepting();
@@ -2014,7 +2059,7 @@ class WorkflowOwner {
2014
2059
  }
2015
2060
  this.options.progress.agentStopped(key, entry.stopped!);
2016
2061
  try {
2017
- await state.close(entry.stopped);
2062
+ await state.terminate(entry.stopped);
2018
2063
  } finally {
2019
2064
  await entry.channel.then((channel) => channel?.close());
2020
2065
  }
@@ -2044,13 +2089,14 @@ class WorkflowOwner {
2044
2089
  /**
2045
2090
  * The session the run was started from, under the key the workflow names: `null` when there is
2046
2091
  * none. It is found, not opened: its harness, directory and skills are the operator's, and it
2047
- * answers through a launcher by path as any pane agent does (ADR 0010).
2092
+ * answers through a launcher by path as any TUI agent does (ADR 0010).
2048
2093
  */
2049
2094
  private caller(key: string): Promise<AgentRef | null> {
2050
2095
  if (this.#closed || this.#cleanupFailures.length) throw new Error("workflow context is closed");
2051
2096
  const scope = scopes.getStore();
2052
2097
  scope?.assertAccepting();
2053
2098
  this.#assertFreeKey(key, "an agent");
2099
+ this.options.ops.claim(key, "agent");
2054
2100
  const found = this.options.runtime.host.caller;
2055
2101
  if (!found) return Promise.resolve(null);
2056
2102
  if (this.#caller) {
@@ -2078,7 +2124,9 @@ class WorkflowOwner {
2078
2124
  };
2079
2125
  const ledger = this.options.ledger.agent(accounted);
2080
2126
  this.options.skills.record(key, "operator");
2081
- const opened = this.options.control.openChannel(key, (id) => sessions.launcher.add(id));
2127
+ const opened = this.options.control.openChannel(key, (id) =>
2128
+ this.launcherReported(key, execution.harness, sessions, id),
2129
+ );
2082
2130
  const state = opened.then(async (channel) => {
2083
2131
  try {
2084
2132
  const launcher = await installAgentLauncher(
@@ -2121,7 +2169,7 @@ class WorkflowOwner {
2121
2169
  * it is that agent's own fork, taken in its queue; otherwise it is taken now, from a parent the
2122
2170
  * run never opened. `null`, and the reason logged, where there is none to fork.
2123
2171
  */
2124
- private forkCaller(spec: AgentForkSpec): Promise<AgentRef | null> {
2172
+ private forkCaller(spec: CallerForkSpec): Promise<AgentRef | null> {
2125
2173
  if (this.#closed || this.#cleanupFailures.length) throw new Error("workflow context is closed");
2126
2174
  const scope = scopes.getStore();
2127
2175
  scope?.assertAccepting();
@@ -2150,11 +2198,10 @@ class WorkflowOwner {
2150
2198
  channel: Promise.resolve(undefined),
2151
2199
  };
2152
2200
  parent.state.catch(() => undefined);
2153
- const deadline = scope?.deadline ?? this.options.deadline;
2154
2201
  return this.forkAgent(
2155
2202
  CALLING,
2156
2203
  spec,
2157
- (into) =>
2204
+ (deadline, into) =>
2158
2205
  calling
2159
2206
  .fork(deadlineWithin(FORK_TIMEOUT_MS, deadline), into, this.#closingRun.signal)
2160
2207
  .then((native) => ({ native, after: null })),
@@ -2175,7 +2222,10 @@ class WorkflowOwner {
2175
2222
  ): { state: Promise<LogicalAgent>; channel: Promise<ResultChannel> } {
2176
2223
  const opened = placed.then((skills) => {
2177
2224
  if (skills?.given.ownHome) accounted.home = skills.given.ownHome;
2178
- return this.options.control.openChannel(spec.key, (id) => sessions.launcher.add(id));
2225
+ if (skills) this.#givenSkills.set(spec.key, skills.given);
2226
+ return this.options.control.openChannel(spec.key, (id) =>
2227
+ this.launcherReported(spec.key, execution.harness, sessions, id),
2228
+ );
2179
2229
  });
2180
2230
  const reachable = opened.then(async (channel) => ({
2181
2231
  channel,
@@ -2253,7 +2303,7 @@ class WorkflowOwner {
2253
2303
  const given = skillsLayout(
2254
2304
  execution.harness,
2255
2305
  resolved.map((skill) => skill.name),
2256
- { bundle: join(runDir, "agents", id), cwd: realCwd },
2306
+ { bundle: join(workDir(runDir), "agents", id), cwd: realCwd },
2257
2307
  );
2258
2308
  this.options.skills.record(key, resolved, given.ownHome);
2259
2309
  const { ownHome } = given;
@@ -2264,8 +2314,12 @@ class WorkflowOwner {
2264
2314
  await mkdir(dirname(ownHome), { recursive: true });
2265
2315
  const seeded = await seedHome(
2266
2316
  ownHome,
2267
- join(runDir, "staging", id),
2268
- hostHome(execution.harness, ownHome, this.options.environment),
2317
+ join(workDir(runDir), "staging", id),
2318
+ (this.options.homes ?? OPERATOR_HOMES).host(
2319
+ execution.harness,
2320
+ ownHome,
2321
+ this.options.environment,
2322
+ ),
2269
2323
  realCwd,
2270
2324
  this.options.locks,
2271
2325
  async (staged) => {
@@ -2314,10 +2368,11 @@ class WorkflowOwner {
2314
2368
  // What the agent really ran in, which is where accounting finds its sessions.
2315
2369
  accounted.cwd = seat.cwd;
2316
2370
  accounted.home = seat.home;
2371
+ if (seat.skills) this.#givenSkills.set(spec.key, seat.skills);
2317
2372
  let channel: ResultChannel | undefined;
2318
2373
  try {
2319
2374
  channel = await this.options.control.openChannel(spec.key, (id) =>
2320
- sessions.launcher.add(id),
2375
+ this.launcherReported(spec.key, execution.harness, sessions, id),
2321
2376
  );
2322
2377
  const door = await installSandboxedDoor(
2323
2378
  dirname(channel.endpoint),
@@ -2381,6 +2436,7 @@ class WorkflowOwner {
2381
2436
  execution,
2382
2437
  session,
2383
2438
  slots: this.options.slots,
2439
+ ops: this.options.ops,
2384
2440
  endpoint,
2385
2441
  launcher,
2386
2442
  deadline: this.options.deadline,
@@ -2388,6 +2444,11 @@ class WorkflowOwner {
2388
2444
  progress: this.options.progress,
2389
2445
  recordTurn: this.options.recordTurn,
2390
2446
  events: this.options.events,
2447
+ sessionsKnown: (sessions, operation) =>
2448
+ this.options.sessionLines.known(key, sessions, {
2449
+ ...(operation === undefined ? {} : { operation }),
2450
+ ...this.placeOf(key),
2451
+ }),
2391
2452
  track: (promise) => this.track(promise),
2392
2453
  isRunClosing: () => this.#closed || this.#cleanupFailures.length > 0,
2393
2454
  checkOperation: () => this.options.stages.checkOperation(),
@@ -2397,9 +2458,16 @@ class WorkflowOwner {
2397
2458
  this.options.failRun(error);
2398
2459
  },
2399
2460
  livenessPolicy: this.options.livenessPolicy,
2400
- ...(this.options.mailbox ? { turns: this.#turnsOf(key, this.options.mailbox) } : {}),
2461
+ ...(this.options.eventWaits ? { turns: this.#turnsOf(key, this.options.eventWaits) } : {}),
2401
2462
  fork: (spec, take, settings) => this.forkAgent(key, spec, take, settings),
2402
- keepPane: () => this.#agents.get(key)?.identity.keepPane,
2463
+ close: (reason) => {
2464
+ try {
2465
+ return this.stopAgent(key, reason, "close()");
2466
+ } catch (error) {
2467
+ return Promise.reject(error);
2468
+ }
2469
+ },
2470
+ keepPane: () => this.#agents.get(key)?.identity.asked?.keepPane,
2403
2471
  keepAlive: () => this.#agents.get(key)?.keepAlive,
2404
2472
  defaultWindowMs: this.options.defaultWindowMs ?? DEFAULT_WINDOW_MS,
2405
2473
  now: this.options.now,
@@ -2431,8 +2499,11 @@ class WorkflowOwner {
2431
2499
  private forkAgent(
2432
2500
  parentKey: string,
2433
2501
  spec: AgentForkSpec,
2434
- /** Queues the native fork, or its copy `into` another home; or why its host cannot fork. */
2435
- take: ((into?: SessionCopy) => Promise<TakenFork>) | string,
2502
+ /**
2503
+ * Queues the native fork, or its copy `into` another home, within the fork's `deadline`; or why
2504
+ * its host cannot fork.
2505
+ */
2506
+ take: TakeFork | string,
2436
2507
  /** The parent's settings once every `set` queued before the fork has run. */
2437
2508
  parentSettings: AgentExecution,
2438
2509
  /** A parent the run never opened: the session it was started from. */
@@ -2446,11 +2517,13 @@ class WorkflowOwner {
2446
2517
  throw new Error(`agent ${spec.key} is the calling session; it is not opened`);
2447
2518
  }
2448
2519
  this.#assertFreeKey(spec.key, "an agent");
2520
+ this.options.ops.claim(spec.key, "agent");
2449
2521
  // Out before the clone, which throws on a handler, and out of the spec a repeat is compared by.
2450
- const { tools, ...cloned } = spec;
2451
- const forkSpec = structuredClone(cloned);
2452
- if (forkSpec.layout !== undefined) forkSpec.layout = storedLayout(forkSpec.layout);
2453
- const deadline = scope?.deadline ?? this.options.deadline;
2522
+ // A bound is the call's, not the fork's: a repeat with another is the same fork.
2523
+ const { tools, timeout, ...given } = spec;
2524
+ const forkSpec = structuredClone(forkSpecOf(given));
2525
+ if (forkSpec.pane !== undefined) forkSpec.pane = storedPane(forkSpec.pane);
2526
+ const deadline = boundedDeadline(timeout, scope?.deadline ?? this.options.deadline);
2454
2527
  assertDeadline(deadline);
2455
2528
  // A fork of the calling session is one whichever way it was asked for.
2456
2529
  const from = unopened || this.#caller?.key === parentKey ? CALLING : parentKey;
@@ -2478,27 +2551,24 @@ class WorkflowOwner {
2478
2551
  // child's home: anything added to the parent's would be read as the parent's.
2479
2552
  const copy =
2480
2553
  sandboxed || opening.accounted.home !== undefined
2481
- ? join(this.options.runDir, "forks", randomUUID())
2554
+ ? join(workDir(this.options.runDir), "forks", randomUUID())
2482
2555
  : undefined;
2483
- const execution = withKnownEffort(forkExecution(parentSettings, forkSpec));
2556
+ const execution = withKnownEffort(forkExecution(parentSettings, forkSpec.runtime));
2484
2557
  // A fork of the calling session is an agent the run opened, on the model the session's files
2485
2558
  // show, which the fork reads: `""` until then (ADR 0010).
2486
2559
  const ofCaller = execution.caller === true;
2487
2560
  delete execution.caller;
2488
- checkPaneOptions(spec.key, forkSpec, {
2489
- headless: placementOf(execution) === "headless",
2490
- sandboxed,
2491
- });
2561
+ const headless = placementOf(execution) === "headless";
2562
+ checkPaneOptions(spec.key, forkSpec, { headless, sandboxed });
2492
2563
  const identity: AgentIdentity = {
2493
2564
  execution,
2494
2565
  cwd: parent.identity.cwd,
2495
2566
  ...(forkSpec.instructions === undefined ? {} : { instructions: forkSpec.instructions }),
2496
- ...(forkSpec.labels === undefined ? {} : { labels: forkSpec.labels }),
2497
2567
  ...(parent.identity.skills ? { skills: parent.identity.skills } : {}),
2498
2568
  ...(parent.identity.sandbox === undefined ? {} : { sandbox: parent.identity.sandbox }),
2499
2569
  forkedFrom: { parent: from, spec: forkSpec },
2500
- ...(forkSpec.layout === undefined ? {} : { layout: forkSpec.layout }),
2501
- ...(forkSpec.keepPane === undefined ? {} : { keepPane: forkSpec.keepPane }),
2570
+ ...(forkSpec.pane === undefined ? {} : { pane: forkSpec.pane }),
2571
+ asked: paneAsked(forkSpec.pane, this.options.panes, !headless && !sandboxed),
2502
2572
  };
2503
2573
 
2504
2574
  this.options.progress.agentOpened(spec.key, scope?.group);
@@ -2512,11 +2582,11 @@ class WorkflowOwner {
2512
2582
  sessions: () => reportedSessions(sessions),
2513
2583
  forkedAt: () => forkedAt,
2514
2584
  };
2515
- const besideAtFork = this.besideFallback(identity.layout, deadline);
2585
+ const besideAtFork = this.besideFallback(identity.asked?.layout, deadline);
2516
2586
  let continues: NativeFork | undefined;
2517
2587
  // The fork is asked for now, in the parent's queue; the child opens once it is made. One that
2518
2588
  // was not made leaves nothing behind: its key may be forked again, after the parent's turn.
2519
- const forked = take(copy === undefined ? undefined : { directory: copy })
2589
+ const forked = take(deadline, copy === undefined ? undefined : { directory: copy })
2520
2590
  .then(({ native: fork, after }) => {
2521
2591
  if (ofCaller) {
2522
2592
  if (!fork.model) throw new Error(`agent ${parentKey}'s fork named no model to run on`);
@@ -2563,13 +2633,12 @@ class WorkflowOwner {
2563
2633
  cwd,
2564
2634
  execution,
2565
2635
  ...(forkSpec.instructions === undefined ? {} : { instructions: forkSpec.instructions }),
2566
- ...(forkSpec.labels === undefined ? {} : { labels: forkSpec.labels }),
2567
2636
  ...(seated ? { occupant: seated.occupant } : {}),
2568
2637
  ...(seated?.webSearch ? { webSearch: true } : {}),
2569
2638
  ...(given ? { skills: given } : {}),
2570
2639
  ...(home ? { home } : {}),
2571
2640
  ...(continues ? { continues } : {}),
2572
- ...paneOptions(identity),
2641
+ ...identity.asked,
2573
2642
  }),
2574
2643
  // The target as it stands when `fork` is called, waited for once the fork is made.
2575
2644
  layoutFallback: forked.then(besideAtFork, () => undefined),
@@ -2627,6 +2696,10 @@ class WorkflowOwner {
2627
2696
  ): void {
2628
2697
  state.then(
2629
2698
  () => {
2699
+ const entry = this.#agents.get(accounted.key);
2700
+ const skills = this.options.skills.of(accounted.key);
2701
+ const pane = entry && paneOf(entry);
2702
+ const seat = this.options.sandboxes.admitted(accounted.key);
2630
2703
  this.options.events.event({
2631
2704
  kind: "agent-opened",
2632
2705
  agent: accounted.key,
@@ -2634,17 +2707,26 @@ class WorkflowOwner {
2634
2707
  harness: accounted.execution.harness,
2635
2708
  cwd: accounted.cwd,
2636
2709
  ...(accounted.home === undefined ? {} : { home: accounted.home }),
2710
+ ...(seat ? { sandbox: seat.sandbox, domains: seat.domains } : {}),
2637
2711
  ...(as && "caller" in as ? { caller: true } : {}),
2712
+ ...(skills
2713
+ ? {
2714
+ skills: {
2715
+ skills: skills.skills,
2716
+ ...(skills.home === undefined ? {} : { home: skills.home }),
2717
+ },
2718
+ }
2719
+ : {}),
2720
+ ...(pane ? { pane } : {}),
2638
2721
  });
2639
2722
  // A fork's session is made before its first operation names it.
2640
2723
  if (!as || !("fork" in as)) return;
2641
- for (const id of accounted.sessions()) {
2642
- this.options.events.event({
2643
- kind: "session-known",
2644
- session: { harness: accounted.execution.harness, id },
2645
- agent: accounted.key,
2646
- });
2647
- }
2724
+ const { harness } = accounted.execution;
2725
+ this.options.sessionLines.known(
2726
+ accounted.key,
2727
+ accounted.sessions().map((id) => ({ harness, id })),
2728
+ this.placeOf(accounted.key),
2729
+ );
2648
2730
  },
2649
2731
  () => undefined,
2650
2732
  );
@@ -2690,24 +2772,60 @@ class WorkflowOwner {
2690
2772
  );
2691
2773
  }
2692
2774
 
2693
- /** Where each pane agent's pane went, as its host reported it, in the order opened. */
2775
+ /** Where each TUI agent's pane went, as its host reported it, in the order opened. */
2694
2776
  panes(): AgentPaneRecord[] {
2695
2777
  return [...this.#agents].flatMap(([agent, entry]) => {
2696
- const placed = entry.opening?.sessions.harness?.pane?.();
2697
- if (!placed) return [];
2698
- const { layout, keepPane } = entry.identity;
2699
- return [
2700
- {
2701
- callPath: [],
2702
- agent,
2703
- ...(layout === undefined ? {} : { layout }),
2704
- ...(keepPane === undefined ? {} : { keepPane }),
2705
- placed,
2706
- },
2707
- ];
2778
+ const pane = paneOf(entry);
2779
+ return pane ? [{ callPath: [], agent, ...pane }] : [];
2708
2780
  });
2709
2781
  }
2710
2782
 
2783
+ /**
2784
+ * Where an agent's sessions live, once it is known: its own harness home, the sandbox that home is
2785
+ * in, and the skills its harness was given, which `awf open` reopens (story 042).
2786
+ */
2787
+ private placeOf(key: string): SessionPlace {
2788
+ const home = this.#agents.get(key)?.opening?.accounted.home;
2789
+ const sandbox = this.options.sandboxes.sessionSandbox(key);
2790
+ const skills = this.#givenSkills.get(key);
2791
+ return {
2792
+ ...(home === undefined ? {} : { home }),
2793
+ ...(sandbox ? { sandbox } : {}),
2794
+ ...(skills ? { skills: { names: [...skills.names], directory: skills.directory } } : {}),
2795
+ };
2796
+ }
2797
+
2798
+ /** A session an agent's `wfa` named: recorded at once, less one a fork or parent holds. */
2799
+ private launcherReported(
2800
+ key: string,
2801
+ harness: HarnessKind,
2802
+ sessions: AgentSessions,
2803
+ id: string,
2804
+ ): void {
2805
+ sessions.launcher.add(id);
2806
+ this.options.sessionLines.known(
2807
+ key,
2808
+ reportedSessions(sessions).map((session) => ({ harness, id: session })),
2809
+ this.placeOf(key),
2810
+ );
2811
+ }
2812
+
2813
+ /**
2814
+ * Every agent's sessions as its close left them: one that moved in a compaction or fork at close,
2815
+ * or that its harness saw after its last operation, is what the next continue resumes.
2816
+ */
2817
+ recordSessions(): void {
2818
+ for (const [key, entry] of this.#agents) {
2819
+ if (!entry.opening) continue;
2820
+ const { harness } = entry.opening.accounted.execution;
2821
+ this.options.sessionLines.known(
2822
+ key,
2823
+ reportedSessions(entry.opening.sessions).map((id) => ({ harness, id })),
2824
+ this.placeOf(key),
2825
+ );
2826
+ }
2827
+ }
2828
+
2711
2829
  /** A reopened agent names the sandbox it runs in, or none: a different one is a conflict. */
2712
2830
  private async assertSameSandbox(
2713
2831
  key: string,
@@ -2726,21 +2844,22 @@ class WorkflowOwner {
2726
2844
  }
2727
2845
  }
2728
2846
 
2847
+ /** An agent's pane as its workflow wrote it and as its host placed it; undefined for no pane. */
2848
+ function paneOf(entry: AgentEntry): Omit<AgentPaneRecord, "callPath" | "agent"> | undefined {
2849
+ const placed = entry.opening?.sessions.harness?.pane?.();
2850
+ if (!placed) return undefined;
2851
+ const { pane } = entry.identity;
2852
+ return { ...(pane === undefined ? {} : { pane }), placed };
2853
+ }
2854
+
2855
+ // What a wait takes of it is type-only, and `asSource` marks it at run time.
2856
+ // biome-ignore lint/correctness/noUnusedVariables: merged into the class below.
2857
+ interface LogicalAgent extends Source<Taken<AgentEnded>> {}
2858
+
2859
+ // biome-ignore lint/suspicious/noUnsafeDeclarationMerging: the interface adds a type-only member.
2729
2860
  class LogicalAgent implements AgentRef {
2730
2861
  readonly [PARTICIPANT] = true as const;
2731
2862
  readonly #operationStops = new Set<(stop: OperationStop) => Promise<void>>();
2732
- readonly #operations = new Map<
2733
- string,
2734
- {
2735
- spec: AgentRunTextSpec | AgentRunStructuredSpec<JsonValue>;
2736
- result: Promise<RunResult<JsonValue>>;
2737
- }
2738
- >();
2739
- readonly #compactions = new Map<
2740
- string,
2741
- { spec: CompactSpec; result: Promise<TurnOutcome<string>> }
2742
- >();
2743
- readonly #sets = new Map<string, { spec: SettingsSpec; result: Promise<TurnOutcome<null>> }>();
2744
2863
  /** Its settings now; see `AgentRef.execution`. */
2745
2864
  #execution: AgentExecution;
2746
2865
  /** What each `set` queued and not yet settled changes, in queue order. */
@@ -2781,6 +2900,8 @@ class LogicalAgent implements AgentRef {
2781
2900
  execution: AgentExecution;
2782
2901
  session: HarnessSession;
2783
2902
  slots: ResultSlotRegistry;
2903
+ /** Numbers its operations, `{key}.{n}`. */
2904
+ ops: OpIds;
2784
2905
  endpoint: string;
2785
2906
  /** The path this agent is told to run; see `installAgentLauncher`. */
2786
2907
  launcher: string;
@@ -2789,6 +2910,8 @@ class LogicalAgent implements AgentRef {
2789
2910
  progress: RunProgress;
2790
2911
  recordTurn: RecordTurn;
2791
2912
  events: RunEventLog;
2913
+ /** Records the sessions it is seen with, in `operation` when one is named. */
2914
+ sessionsKnown(sessions: readonly NativeSessionRef[], operation?: string): void;
2792
2915
  track<T>(promise: Promise<T>): Promise<T>;
2793
2916
  isRunClosing(): boolean;
2794
2917
  /** Throws when no turn may start: the attempt is looking on for values it needs. */
@@ -2804,14 +2927,16 @@ class LogicalAgent implements AgentRef {
2804
2927
  */
2805
2928
  fork(
2806
2929
  spec: AgentForkSpec,
2807
- take: ((into?: SessionCopy) => Promise<TakenFork>) | string,
2930
+ take: TakeFork | string,
2808
2931
  settings: AgentExecution,
2809
2932
  ): Promise<AgentRef>;
2810
- /** As the workflow wrote it; decides, at close, whether its pane stays. */
2933
+ /** The workflow ends it: the run tears it down and its key is not opened again. */
2934
+ close(reason: string | undefined): Promise<boolean>;
2935
+ /** As the operator gave it; decides, at close, whether its pane stays. */
2811
2936
  keepPane(): KeepPane | undefined;
2812
2937
  /** As the workflow's last open of it gave it. */
2813
2938
  keepAlive(): KeepAlive | undefined;
2814
- /** Its window when `keepAlive` gives no `timeoutMs`. */
2939
+ /** Its window when `keepAlive` gives no `window`. */
2815
2940
  defaultWindowMs: number;
2816
2941
  /** The run's time, which its window is measured on. */
2817
2942
  now: () => number;
@@ -2825,7 +2950,7 @@ class LogicalAgent implements AgentRef {
2825
2950
  const restored = options.turns?.restored();
2826
2951
  if (restored)
2827
2952
  this.#started = {
2828
- ref: turnRef(options.key, restored.turn, async () => false),
2953
+ ref: turnRef(restored.turn, async () => false),
2829
2954
  meta: restored.meta,
2830
2955
  ended: true,
2831
2956
  done: Promise.resolve(),
@@ -2865,7 +2990,7 @@ class LogicalAgent implements AgentRef {
2865
2990
  liveUntil(now = this.options.now()): number | undefined {
2866
2991
  const keepAlive = this.options.keepAlive();
2867
2992
  if (this.#closed || !keepAlive || this.#lastEnd === undefined) return undefined;
2868
- const until = this.#lastEnd + (keepAlive.timeoutMs ?? this.options.defaultWindowMs);
2993
+ const until = this.#lastEnd + (keepAlive.windowMs ?? this.options.defaultWindowMs);
2869
2994
  return until > now ? until : undefined;
2870
2995
  }
2871
2996
 
@@ -2928,8 +3053,12 @@ class LogicalAgent implements AgentRef {
2928
3053
 
2929
3054
  async start(spec: AgentStartSpec): Promise<StartedTurn> {
2930
3055
  const { turns } = this.options;
2931
- if (!turns) return unavailable("AgentRef.start");
3056
+ if (!turns)
3057
+ throw new Error(
3058
+ `agent ${this.key}: start() is for a workflow with events, at whose waits its end arrives; run() waits for the end here`,
3059
+ );
2932
3060
  if (this.#closed) throw new Error("logical agent is closed");
3061
+ turns.checkDeclared();
2933
3062
  const inTool = refusedInTool(this.key, "start()");
2934
3063
  if (inTool) throw inTool;
2935
3064
  const scope = scopes.getStore();
@@ -2940,12 +3069,12 @@ class LogicalAgent implements AgentRef {
2940
3069
  const refused = this.#refusedWhileStarted("start");
2941
3070
  if (refused) throw refused;
2942
3071
  const { meta = null, ...run } = spec;
2943
- const id = run.id ?? randomUUID();
3072
+ const id = randomUUID();
2944
3073
  if (!isJsonValue(meta)) throw new Error("a turn's meta is a JSON value");
2945
3074
  const ended = Promise.withResolvers<void>();
2946
3075
  let written: TurnOutcome<JsonValue>["kind"] | undefined;
2947
3076
  const started = {
2948
- ref: turnRef(this.key, id, async (reason?: string) => {
3077
+ ref: turnRef(id, async (reason?: string) => {
2949
3078
  if (started.ended) return false;
2950
3079
  // Its turn waits for the handler, which would wait for the turn's end.
2951
3080
  const inTool = refusedInTool(this.key, "cancel()");
@@ -2976,13 +3105,13 @@ class LogicalAgent implements AgentRef {
2976
3105
  this.#launching = true;
2977
3106
  let launched: Promise<RunResult<JsonValue>>;
2978
3107
  try {
2979
- launched = outsideTools(() => this.run({ ...run, id } as AgentRunStructuredSpec<JsonValue>));
3108
+ launched = outsideTools(() => this.#runTurn(run, id));
2980
3109
  } catch (error) {
2981
3110
  launched = Promise.reject(error);
2982
3111
  } finally {
2983
3112
  this.#launching = false;
2984
3113
  }
2985
- // Tracked until the end is in the mailbox, so the run doesn't park with a turn's end on its way.
3114
+ // Tracked until the end is written, so the run doesn't park with a turn's end on its way.
2986
3115
  this.options.track(
2987
3116
  launched
2988
3117
  .then(
@@ -3023,11 +3152,12 @@ class LogicalAgent implements AgentRef {
3023
3152
  );
3024
3153
  }
3025
3154
 
3026
- is<Meta extends JsonValue = JsonValue, T extends JsonValue = JsonValue>(event: {
3027
- readonly name: string;
3028
- readonly from: string;
3029
- }): event is AgentEndedEvent<Meta, T> & Matched {
3030
- return event.name === "agent:ended" && event.from === this.key;
3155
+ is<
3156
+ Meta extends JsonValue = JsonValue,
3157
+ T extends JsonValue = JsonValue,
3158
+ E extends EventLike = EventLike,
3159
+ >(event: E): event is E & AgentEndOf<Meta, T> {
3160
+ return matches(event, { name: "agent:ended", from: this.key });
3031
3161
  }
3032
3162
 
3033
3163
  run(spec: AgentRunTextSpec): Promise<RunResult<string>>;
@@ -3035,51 +3165,24 @@ class LogicalAgent implements AgentRef {
3035
3165
  run<T extends JsonValue>(
3036
3166
  spec: AgentRunTextSpec | AgentRunStructuredSpec<T>,
3037
3167
  ): Promise<RunResult<string | T>> {
3038
- if (this.#closed) return Promise.reject(new Error("logical agent is closed"));
3039
- const refused = this.#refusedWhileStarted("run");
3040
- if (refused) return Promise.reject(refused);
3041
- const scope = scopes.getStore();
3042
- try {
3043
- scope?.assertAccepting();
3044
- this.options.checkOperation();
3045
- } catch (error) {
3046
- return Promise.reject(error);
3047
- }
3168
+ return this.#runTurn(spec, randomUUID()) as Promise<RunResult<string | T>>;
3169
+ }
3170
+
3171
+ /** A turn under `id`: `start()`'s, which its end is recorded by, or a new one. */
3172
+ #runTurn(
3173
+ spec: AgentRunTextSpec | AgentRunStructuredSpec<JsonValue>,
3174
+ id: TurnId,
3175
+ ): Promise<RunResult<JsonValue>> {
3176
+ const admitted = this.#admit<RunResult<JsonValue>, AbsoluteDeadline | undefined>(
3177
+ "run",
3178
+ { checkOperation: true },
3179
+ (scope) => this.operationDeadline(spec, scope),
3180
+ );
3181
+ if ("rejected" in admitted) return admitted.rejected;
3182
+ const { scope, prepared: deadline } = admitted;
3048
3183
  const opensWindow = !inTimeoutHandler();
3049
- const id = spec.id ?? randomUUID();
3050
- const completeSpec = structuredClone({ ...spec, id }) as
3051
- | AgentRunTextSpec
3052
- | AgentRunStructuredSpec<JsonValue>;
3053
- const existing = this.#operations.get(id);
3054
- if (existing) {
3055
- if (!isDeepStrictEqual(existing.spec, completeSpec)) {
3056
- const rejected = Promise.reject<RunResult<string | T>>(
3057
- new Error(`turn id ${id} was reused with a different specification`),
3058
- );
3059
- scope?.track(rejected);
3060
- return rejected;
3061
- }
3062
- scope?.track(existing.result);
3063
- return existing.result as Promise<RunResult<string | T>>;
3064
- }
3065
- let deadline: AbsoluteDeadline | undefined;
3066
- try {
3067
- deadline = this.operationDeadline(spec, scope);
3068
- const recoveryDeadline = spec.nudge ? spec.nudge.deadline : undefined;
3069
- if (recoveryDeadline) {
3070
- assertDeadlineValue(recoveryDeadline);
3071
- deadline = earlierDeadline(deadline, recoveryDeadline);
3072
- }
3073
- } catch (error) {
3074
- const rejected = Promise.reject<RunResult<string | T>>(error);
3075
- scope?.track(rejected);
3076
- return rejected;
3077
- }
3078
- const result = this.queue(async () => {
3079
- if (this.#closed || this.options.isRunClosing()) {
3080
- throw new Error("logical agent is closed");
3081
- }
3082
- scope?.assertActive();
3184
+ const completeSpec: TurnSpec = structuredClone({ ...spec, id });
3185
+ const result = this.#queueActive(scope, async () => {
3083
3186
  const cancelled = this.#cancelledTurns.get(id);
3084
3187
  if (cancelled !== undefined) {
3085
3188
  this.#cancelledTurns.delete(id);
@@ -3093,7 +3196,19 @@ class LogicalAgent implements AgentRef {
3093
3196
  }
3094
3197
  const tags = tagsOf(scope, completeSpec.label);
3095
3198
  // Drawn here, so a turn that throws before it is delivered is still written as failed.
3096
- const operation = this.operationLog(randomUUID(), "turn", tags);
3199
+ let schema: JsonSchema | undefined;
3200
+ try {
3201
+ schema = resultSchema(completeSpec.schema);
3202
+ } catch {
3203
+ // Thrown again as the turn runs, under its id, so it is written as failed.
3204
+ }
3205
+ const { id: drawn, lost } = this.options.ops.draw(this.key, {
3206
+ kind: "turn",
3207
+ ...this.opWhere(tags),
3208
+ question: completeSpec.prompt,
3209
+ ...(schema === undefined ? {} : { schema }),
3210
+ });
3211
+ const operation = this.operationLog(drawn, "turn", tags, lost);
3097
3212
  this.options.progress.turnStarted(this.key, tags);
3098
3213
  try {
3099
3214
  const settled = await this.executeOperation(completeSpec, scope, deadline, operation);
@@ -3107,54 +3222,25 @@ class LogicalAgent implements AgentRef {
3107
3222
  await this.options.afterOperation?.();
3108
3223
  }
3109
3224
  });
3110
- const tracked = this.options.track(result);
3111
- scope?.track(tracked);
3112
- this.#operations.set(id, { spec: completeSpec, result: tracked });
3113
- return tracked as Promise<RunResult<string | T>>;
3225
+ return this.#tracked(scope, result);
3114
3226
  }
3115
3227
 
3116
3228
  compact(spec: CompactSpec): Promise<TurnOutcome<string>> {
3117
- if (this.#closed) return Promise.reject(new Error("logical agent is closed"));
3118
- const refused = this.#refusedWhileStarted("compact");
3119
- if (refused) return Promise.reject(refused);
3120
- const scope = scopes.getStore();
3121
- try {
3122
- scope?.assertAccepting();
3123
- this.options.checkOperation();
3124
- } catch (error) {
3125
- return Promise.reject(error);
3126
- }
3127
- const id = spec.id ?? randomUUID();
3128
- const completeSpec = structuredClone({ ...spec, id });
3129
- const existing = this.#compactions.get(id);
3130
- if (existing) {
3131
- if (!isDeepStrictEqual(existing.spec, completeSpec)) {
3132
- const rejected = Promise.reject<TurnOutcome<string>>(
3133
- new Error(`compaction id ${id} was reused with a different specification`),
3134
- );
3135
- scope?.track(rejected);
3136
- return rejected;
3137
- }
3138
- scope?.track(existing.result);
3139
- return existing.result;
3140
- }
3141
- let deadline: AbsoluteDeadline;
3142
- try {
3143
- deadline = harnessDeadline(this.operationDeadline(spec, scope));
3144
- } catch (error) {
3145
- const rejected = Promise.reject<TurnOutcome<string>>(error);
3146
- scope?.track(rejected);
3147
- return rejected;
3148
- }
3149
- const result = this.queue(async () => {
3150
- if (this.#closed || this.options.isRunClosing()) {
3151
- throw new Error("logical agent is closed");
3152
- }
3153
- scope?.assertActive();
3229
+ const admitted = this.#admit<TurnOutcome<string>, AbsoluteDeadline>(
3230
+ "compact",
3231
+ { checkOperation: true },
3232
+ (scope) => harnessDeadline(this.operationDeadline(spec, scope)),
3233
+ );
3234
+ if ("rejected" in admitted) return admitted.rejected;
3235
+ const { scope, prepared: deadline } = admitted;
3236
+ const id = randomUUID();
3237
+ const result = this.#queueActive(scope, async () => {
3154
3238
  const tags = tagsOf(scope);
3155
- const operation = this.operationLog(randomUUID(), "compact", tags);
3239
+ const drawn = this.options.ops.draw(this.key, { kind: "compact", ...this.opWhere(tags) });
3240
+ const operation = this.operationLog(drawn.id, "compact", tags);
3156
3241
  this.options.progress.turnStarted(this.key, tags, "compact");
3157
3242
  try {
3243
+ if (drawn.lost !== undefined) throw new Error(drawn.lost);
3158
3244
  const outcome = await this.executeCompaction(id, spec.prompt, deadline, scope, operation);
3159
3245
  this.turnEnded(outcome, "compact");
3160
3246
  return outcome;
@@ -3166,47 +3252,23 @@ class LogicalAgent implements AgentRef {
3166
3252
  await this.options.afterOperation?.();
3167
3253
  }
3168
3254
  });
3169
- const tracked = this.options.track(result);
3170
- scope?.track(tracked);
3171
- this.#compactions.set(id, { spec: completeSpec, result: tracked });
3172
- return tracked;
3255
+ return this.#tracked(scope, result);
3173
3256
  }
3174
3257
 
3175
3258
  /** Not a turn: progress counts none for it, and it asks the model nothing. */
3176
3259
  set(spec: SettingsSpec): Promise<TurnOutcome<null>> {
3177
- if (this.#closed) return Promise.reject(new Error("logical agent is closed"));
3178
- const refused = this.#refusedWhileStarted("set");
3179
- if (refused) return Promise.reject(refused);
3180
- const scope = scopes.getStore();
3181
- try {
3182
- scope?.assertAccepting();
3183
- } catch (error) {
3184
- return Promise.reject(error);
3185
- }
3186
- const id = spec.id ?? randomUUID();
3187
- const completeSpec = structuredClone({ ...spec, id });
3188
- const existing = this.#sets.get(id);
3189
- if (existing) {
3190
- if (!isDeepStrictEqual(existing.spec, completeSpec)) {
3191
- const rejected = Promise.reject<TurnOutcome<null>>(
3192
- new Error(`settings id ${id} was reused with a different specification`),
3193
- );
3194
- scope?.track(rejected);
3195
- return rejected;
3196
- }
3197
- scope?.track(existing.result);
3198
- return existing.result;
3199
- }
3200
- let change: SettingsChange;
3201
- let deadline: AbsoluteDeadline;
3202
- try {
3203
- change = this.settingsChange(spec);
3204
- deadline = harnessDeadline(this.operationDeadline(spec, scope));
3205
- } catch (error) {
3206
- const rejected = Promise.reject<TurnOutcome<null>>(error);
3207
- scope?.track(rejected);
3208
- return rejected;
3209
- }
3260
+ const admitted = this.#admit<
3261
+ TurnOutcome<null>,
3262
+ { change: SettingsChange; deadline: AbsoluteDeadline }
3263
+ >("set", { checkOperation: false }, (scope) => ({
3264
+ change: this.settingsChange(spec),
3265
+ deadline: harnessDeadline(this.operationDeadline(spec, scope)),
3266
+ }));
3267
+ if ("rejected" in admitted) return admitted.rejected;
3268
+ const {
3269
+ scope,
3270
+ prepared: { change, deadline },
3271
+ } = admitted;
3210
3272
  this.#pendingSets.push(change);
3211
3273
  const result = this.queue(async () => {
3212
3274
  try {
@@ -3221,12 +3283,68 @@ class LogicalAgent implements AgentRef {
3221
3283
  this.#pendingSets.splice(this.#pendingSets.indexOf(change), 1);
3222
3284
  }
3223
3285
  });
3286
+ return this.#tracked(scope, result);
3287
+ }
3288
+
3289
+ /**
3290
+ * The checks before an operation is queued, in order: the agent open, no started turn in its
3291
+ * way, the scope accepting and, with `checkOperation`, the stage; then `prepare`, whose refusal
3292
+ * the scope tracks. A refusal is a rejection, never a throw.
3293
+ */
3294
+ #admit<T, P>(
3295
+ what: string,
3296
+ options: { checkOperation: boolean },
3297
+ prepare: (scope: ExecutionScope | undefined) => P,
3298
+ ): { scope: ExecutionScope | undefined; prepared: P } | { rejected: Promise<T> } {
3299
+ if (this.#closed) return { rejected: Promise.reject(new Error("logical agent is closed")) };
3300
+ const refused = this.#refusedWhileStarted(what);
3301
+ if (refused) return { rejected: Promise.reject(refused) };
3302
+ const scope = scopes.getStore();
3303
+ try {
3304
+ scope?.assertAccepting();
3305
+ if (options.checkOperation) this.options.checkOperation();
3306
+ } catch (error) {
3307
+ return { rejected: Promise.reject(error) };
3308
+ }
3309
+ try {
3310
+ return { scope, prepared: prepare(scope) };
3311
+ } catch (error) {
3312
+ const rejected = Promise.reject<T>(error);
3313
+ scope?.track(rejected);
3314
+ return { rejected };
3315
+ }
3316
+ }
3317
+
3318
+ /** `work` once the queue reaches it, the agent and run still open and `scope` still active. */
3319
+ #queueActive<T>(scope: ExecutionScope | undefined, work: () => Promise<T>): Promise<T> {
3320
+ return this.queue(async () => {
3321
+ if (this.#closed || this.options.isRunClosing()) {
3322
+ throw new Error("logical agent is closed");
3323
+ }
3324
+ scope?.assertActive();
3325
+ return work();
3326
+ });
3327
+ }
3328
+
3329
+ #tracked<T>(scope: ExecutionScope | undefined, result: Promise<T>): Promise<T> {
3224
3330
  const tracked = this.options.track(result);
3225
3331
  scope?.track(tracked);
3226
- this.#sets.set(id, { spec: completeSpec, result: tracked });
3227
3332
  return tracked;
3228
3333
  }
3229
3334
 
3335
+ /**
3336
+ * Whose an operation is, the settings it runs at and where, as its `op.json` says: what its spend
3337
+ * is read with, by the next attempt when this one never settles it.
3338
+ */
3339
+ private opWhere(tags: { stage?: string }): AgentOp & { stage?: string } {
3340
+ return {
3341
+ agent: this.key,
3342
+ execution: this.#execution,
3343
+ cwd: this.options.ledger.cwd(),
3344
+ ...(tags.stage === undefined ? {} : { stage: tags.stage }),
3345
+ };
3346
+ }
3347
+
3230
3348
  /**
3231
3349
  * An operation's start and failure in the run's event log, under the id its record keeps. Its
3232
3350
  * settling is the turn line `recordTurn` writes.
@@ -3235,10 +3353,12 @@ class LogicalAgent implements AgentRef {
3235
3353
  operationId: string,
3236
3354
  of: TurnRecord["kind"],
3237
3355
  tags: OperationTags,
3356
+ unrecorded?: string,
3238
3357
  ): OperationLog {
3239
3358
  let deliveredAt: number | undefined;
3240
3359
  /** Named by its host before delivery, for its start to carry. */
3241
3360
  const named: string[] = [];
3361
+ // Its sessions as of now, recorded before the line that names it, as a turn's are.
3242
3362
  const fields = () => {
3243
3363
  const { callPath, sessions, ...record } = this.options.ledger.unsettled(
3244
3364
  operationId,
@@ -3248,16 +3368,16 @@ class LogicalAgent implements AgentRef {
3248
3368
  );
3249
3369
  const harness = this.#execution.harness;
3250
3370
  const fresh = named.filter((id) => !sessions.some((known) => known.id === id));
3251
- return {
3252
- of,
3253
- callPath,
3254
- ...record,
3255
- sessions: [...sessions, ...fresh.map((id) => ({ harness, id }))],
3256
- };
3371
+ this.options.sessionsKnown(
3372
+ [...sessions, ...fresh.map((id) => ({ harness, id }))],
3373
+ operationId,
3374
+ );
3375
+ return { of, callPath, ...record };
3257
3376
  };
3258
3377
  return {
3259
3378
  operationId,
3260
3379
  tags,
3380
+ ...(unrecorded === undefined ? {} : { unrecorded }),
3261
3381
  delivered: (at: number) => {
3262
3382
  deliveredAt = at;
3263
3383
  this.#hasRun = true;
@@ -3269,11 +3389,7 @@ class LogicalAgent implements AgentRef {
3269
3389
  named.push(id);
3270
3390
  return;
3271
3391
  }
3272
- this.options.events.event({
3273
- kind: "session-known",
3274
- session: { harness: this.#execution.harness, id },
3275
- operationId,
3276
- });
3392
+ this.options.sessionsKnown([{ harness: this.#execution.harness, id }], operationId);
3277
3393
  },
3278
3394
  failed: (error: unknown) =>
3279
3395
  this.options.events.event({
@@ -3284,7 +3400,7 @@ class LogicalAgent implements AgentRef {
3284
3400
  };
3285
3401
  }
3286
3402
 
3287
- /** A turn or compaction that settled, as the view shows it and `turns.jsonl` keeps it. */
3403
+ /** A turn or compaction that settled, as the view shows it and `log.jsonl` keeps it. */
3288
3404
  private turnEnded(
3289
3405
  outcome: TurnOutcome<JsonValue>,
3290
3406
  kind: TurnRecord["kind"],
@@ -3298,6 +3414,7 @@ class LogicalAgent implements AgentRef {
3298
3414
  this.#last = outcome.kind;
3299
3415
  const reason = "reason" in outcome ? outcome.reason : undefined;
3300
3416
  this.options.progress.turnSettled(this.key, outcome.kind, reason);
3417
+ this.options.sessionsKnown(outcome.usage.sessions, outcome.usage.operationId);
3301
3418
  this.options.recordTurn(outcome, kind);
3302
3419
  }
3303
3420
 
@@ -3306,7 +3423,6 @@ class LogicalAgent implements AgentRef {
3306
3423
  const refused = this.#refusedWhileStarted("fork");
3307
3424
  if (refused) return Promise.reject(refused);
3308
3425
  const scope = scopes.getStore();
3309
- const deadline = this.deadlineCeiling(scope);
3310
3426
  const { session, execution } = this.options;
3311
3427
  const settings = this.queuedExecution();
3312
3428
  const fork = session.fork?.bind(session);
@@ -3325,12 +3441,8 @@ class LogicalAgent implements AgentRef {
3325
3441
  }
3326
3442
  // The harness bounds its fork by this deadline and stops it there, so the session is idle again
3327
3443
  // when the fork rejects.
3328
- const take = (into?: SessionCopy) =>
3329
- this.queue(async () => {
3330
- if (this.#closed || this.options.isRunClosing()) {
3331
- throw new Error("logical agent is closed");
3332
- }
3333
- scope?.assertActive();
3444
+ const take: TakeFork = (deadline, into) =>
3445
+ this.#queueActive(scope, async () => {
3334
3446
  // The fork was opened at the settings it would copy; a `set` before it that did not take
3335
3447
  // would leave it recording settings it never ran at.
3336
3448
  if (!isDeepStrictEqual(sessionSettings(this.#execution), sessionSettings(settings))) {
@@ -3348,19 +3460,12 @@ class LogicalAgent implements AgentRef {
3348
3460
  }
3349
3461
  }
3350
3462
 
3351
- /** `deadline` or `timeoutMs`, never later than the scope's or the run's; else the earlier of those. */
3463
+ /** Its `timeout`, never later than the scope's or the run's; else the earlier of those. */
3352
3464
  private operationDeadline(
3353
- spec: { deadline?: AbsoluteDeadline; timeoutMs?: number },
3465
+ spec: { timeout?: Timeout },
3354
3466
  scope: ExecutionScope | undefined,
3355
3467
  ): AbsoluteDeadline | undefined {
3356
- if (spec.deadline && spec.timeoutMs !== undefined) {
3357
- throw new Error("an operation cannot specify both deadline and timeoutMs");
3358
- }
3359
- const ceiling = this.deadlineCeiling(scope);
3360
- if (spec.timeoutMs !== undefined) return deadlineWithin(spec.timeoutMs, ceiling);
3361
- if (!spec.deadline) return ceiling;
3362
- assertDeadlineValue(spec.deadline);
3363
- return earlierDeadline(spec.deadline, ceiling);
3468
+ return boundedDeadline(spec.timeout, this.deadlineCeiling(scope));
3364
3469
  }
3365
3470
 
3366
3471
  /** The deadlines around an operation: its scope's and the run's, if either has one. */
@@ -3381,9 +3486,19 @@ class LogicalAgent implements AgentRef {
3381
3486
  `agent ${this.key} is the calling session, whose model and effort are the operator's`,
3382
3487
  );
3383
3488
  }
3489
+ const removed = Object.keys(spec).filter(
3490
+ (field) =>
3491
+ REMOVED_SETTINGS_FIELDS.has(field) && (spec as Record<string, unknown>)[field] != null,
3492
+ );
3493
+ if (removed.length > 0) {
3494
+ const bound = removed.some((field) => field !== "id") ? ": bound it with timeout" : "";
3495
+ throw new Error(`set takes no ${removed.join(", ")}${bound}`);
3496
+ }
3384
3497
  // A workflow is untyped JavaScript at run time: a harness or placement asked for must not be
3385
3498
  // dropped in silence.
3386
- const others = Object.keys(spec).filter((field) => !SETTINGS_FIELDS.has(field));
3499
+ const others = Object.keys(spec).filter(
3500
+ (field) => !SETTINGS_FIELDS.has(field) && !REMOVED_SETTINGS_FIELDS.has(field),
3501
+ );
3387
3502
  if (others.length > 0) {
3388
3503
  throw new Error(
3389
3504
  `set changes an agent's model and effort, not its ${others.join(", ")}: another harness or placement is another agent`,
@@ -3432,7 +3547,7 @@ class LogicalAgent implements AgentRef {
3432
3547
  };
3433
3548
  }
3434
3549
  const next: AgentExecution = { ...this.#execution, ...change };
3435
- const pane = placementOf(next) === "pane";
3550
+ const pane = placementOf(next) === "tui";
3436
3551
  let cancelled: string | undefined;
3437
3552
  // A pane's switch waits on its screen; a scope cancelled meanwhile closes the agent, as a
3438
3553
  // half-made switch leaves it at settings nobody knows. A headless one takes no time.
@@ -3440,7 +3555,7 @@ class LogicalAgent implements AgentRef {
3440
3555
  ? scope?.add(async (stop) => {
3441
3556
  cancelled = stop.reason;
3442
3557
  this.#last = "cancelled";
3443
- await this.close(stop.reason);
3558
+ await this.terminate(stop.reason);
3444
3559
  })
3445
3560
  : undefined;
3446
3561
  try {
@@ -3477,10 +3592,15 @@ class LogicalAgent implements AgentRef {
3477
3592
  if (this.options.execution.caller) return;
3478
3593
  // Before its outcome is recorded: whether its pane stays turns on how this one ended.
3479
3594
  this.#last = ended;
3480
- void this.close(reason).catch(() => undefined);
3595
+ void this.terminate(reason).catch(() => undefined);
3481
3596
  }
3482
3597
 
3483
- close(reason?: string): Promise<void> {
3598
+ close(reason?: string): Promise<boolean> {
3599
+ return this.options.close(reason);
3600
+ }
3601
+
3602
+ /** What `close()`, a failed operation and the run's end each come to. */
3603
+ terminate(reason?: string): Promise<void> {
3484
3604
  const first = !this.#closed;
3485
3605
  this.#closed = true;
3486
3606
  if (first) {
@@ -3513,13 +3633,17 @@ class LogicalAgent implements AgentRef {
3513
3633
  // Once what it was running has ended, so its last operation's line comes first.
3514
3634
  if (first) {
3515
3635
  const { key, execution } = this.options;
3516
- const stoppedLine = () =>
3636
+ const stoppedLine = () => {
3637
+ const placed = this.options.session.pane?.();
3517
3638
  this.options.events.event({
3518
3639
  kind: "agent-stopped",
3519
3640
  agent: key,
3520
3641
  callPath: [],
3521
3642
  harness: execution.harness,
3643
+ ...(placed?.kept ? { kept: true } : {}),
3644
+ ...(placed?.notKept === undefined ? {} : { notKept: placed.notKept }),
3522
3645
  });
3646
+ };
3523
3647
  closed.then(stoppedLine, stoppedLine);
3524
3648
  }
3525
3649
  return closed;
@@ -3557,22 +3681,21 @@ class LogicalAgent implements AgentRef {
3557
3681
  }
3558
3682
 
3559
3683
  private async executeOperation(
3560
- spec: AgentRunTextSpec | AgentRunStructuredSpec<JsonValue>,
3684
+ spec: TurnSpec,
3561
3685
  scope: ExecutionScope | undefined,
3562
3686
  operationDeadline: AbsoluteDeadline | undefined,
3563
3687
  operation: OperationLog,
3564
3688
  ): Promise<RunResult<JsonValue>> {
3565
3689
  const nudge = spec.nudge === false ? undefined : (spec.nudge ?? this.defaultNudge());
3566
3690
  scope?.assertActive();
3567
- if (nudge?.deadline) assertDeadlineValue(nudge.deadline);
3568
3691
  return this.executeWaitingOperation(spec, scope, operationDeadline, operation, nudge);
3569
3692
  }
3570
3693
 
3571
3694
  private async executeWaitingOperation(
3572
- spec: AgentRunTextSpec | AgentRunStructuredSpec<JsonValue>,
3695
+ spec: TurnSpec,
3573
3696
  scope: ExecutionScope | undefined,
3574
3697
  initial: AbsoluteDeadline | undefined,
3575
- { operationId, tags, delivered, session }: OperationLog,
3698
+ { operationId, tags, delivered, session, unrecorded }: OperationLog,
3576
3699
  nudge: Exclude<AgentRunTextSpec["nudge"], false>,
3577
3700
  ): Promise<RunResult<JsonValue>> {
3578
3701
  const schema = resultSchema(spec.schema);
@@ -3634,7 +3757,7 @@ class LogicalAgent implements AgentRef {
3634
3757
  ].join("\n")
3635
3758
  : "");
3636
3759
  const events = createOperationEvents(this.options.runDir, operationId);
3637
- const messages = this.operationMessages(operationId, `${spec.id!}:message`, {
3760
+ const messages = this.operationMessages(operationId, `${spec.id}:message`, {
3638
3761
  deadline: () => harnessDeadline(deadline),
3639
3762
  authored,
3640
3763
  session,
@@ -3645,6 +3768,7 @@ class LogicalAgent implements AgentRef {
3645
3768
  event: events.record,
3646
3769
  operationId,
3647
3770
  agentId: this.key,
3771
+ ...(unrecorded === undefined ? {} : { unrecorded }),
3648
3772
  question: spec.prompt,
3649
3773
  schema,
3650
3774
  ...(deadline ? { deadline } : {}),
@@ -3666,7 +3790,7 @@ class LogicalAgent implements AgentRef {
3666
3790
  this.options.session
3667
3791
  .start(
3668
3792
  {
3669
- id: spec.id!,
3793
+ id: spec.id,
3670
3794
  prompt: [
3671
3795
  operationPrompt(prompt, schema, this.options.launcher, operationId),
3672
3796
  toolsLesson(
@@ -3686,7 +3810,7 @@ class LogicalAgent implements AgentRef {
3686
3810
  .then((turn) => this.confirmed(operationId, joined?.numbers ?? [], turn)),
3687
3811
  successor: (turn, sequence, deliverySignal) =>
3688
3812
  turn.nudge({
3689
- id: `${spec.id!}:check-in:${sequence}`,
3813
+ id: `${spec.id}:check-in:${sequence}`,
3690
3814
  deadline: harnessDeadline(deadline),
3691
3815
  deliverySignal,
3692
3816
  prompt: checkInPrompt(recoveryPrompt),
@@ -3700,24 +3824,25 @@ class LogicalAgent implements AgentRef {
3700
3824
  return settle(request);
3701
3825
  };
3702
3826
  this.#operationStops.add(stop);
3703
- this.#stopsByTurn.set(spec.id!, stop);
3827
+ this.#stopsByTurn.set(spec.id, stop);
3704
3828
  const remove = scope?.add(stop);
3705
3829
  return () => {
3706
3830
  this.#operationStops.delete(stop);
3707
- if (this.#stopsByTurn.get(spec.id!) === stop) this.#stopsByTurn.delete(spec.id!);
3831
+ if (this.#stopsByTurn.get(spec.id) === stop) this.#stopsByTurn.delete(spec.id);
3708
3832
  remove?.();
3709
3833
  };
3710
3834
  },
3711
3835
  phase: (phase, reason, until) =>
3712
3836
  this.options.progress.turnPhase(this.key, phase, reason, until),
3713
3837
  delivered,
3838
+ keepScreen: (turn) => keepScreen(this.options.runDir, operationId, turn),
3714
3839
  })
3715
3840
  .catch(
3716
3841
  (error: unknown): SupervisedOutcome => ({
3717
3842
  kind: "failed",
3718
3843
  reason: messageOf(error),
3719
3844
  cleanupUnresolved: true,
3720
- charges: [],
3845
+ reportedCosts: [],
3721
3846
  settledAt: Date.now(),
3722
3847
  }),
3723
3848
  )
@@ -3737,7 +3862,7 @@ class LogicalAgent implements AgentRef {
3737
3862
  ...(outcome.deliveredAt === undefined ? {} : { deliveredAt: outcome.deliveredAt }),
3738
3863
  settledAt: outcome.settledAt,
3739
3864
  },
3740
- outcome.charges,
3865
+ outcome.reportedCosts,
3741
3866
  );
3742
3867
  if (outcome.kind === "answered")
3743
3868
  return {
@@ -3883,7 +4008,10 @@ class LogicalAgent implements AgentRef {
3883
4008
  stage: ExecutionScope | undefined,
3884
4009
  ): Promise<void> {
3885
4010
  const { messaging } = this.options;
3886
- const operationId = randomUUID();
4011
+ const { id: operationId, lost } = this.options.ops.draw(this.key, {
4012
+ kind: "message",
4013
+ ...this.opWhere(stage?.stage === undefined ? {} : { stage: stage.stage }),
4014
+ });
3887
4015
  const ceiling = this.deadlineCeiling(stage)?.unixMilliseconds;
3888
4016
  let deadline = Math.min(Date.now() + messaging.limits.operationMs, ceiling ?? Infinity);
3889
4017
  messaging.opened({
@@ -3906,7 +4034,7 @@ class LogicalAgent implements AgentRef {
3906
4034
  let end: SupervisedOutcome["kind"] | undefined;
3907
4035
  try {
3908
4036
  const opening = messaging.take(operationId, "opens");
3909
- // Withdrawn before it could be shown: nothing to wake it for.
4037
+ // Withdrawn before it could be shown: nothing to wake it for. Its folder stays, its number used.
3910
4038
  if (!opening) return;
3911
4039
  if (opening.deadline !== undefined) deadline = opening.deadline;
3912
4040
  const cause = { message: { from } };
@@ -3917,6 +4045,7 @@ class LogicalAgent implements AgentRef {
3917
4045
  const operation = this.operationLog(operationId, "turn", tags);
3918
4046
  this.options.progress.turnStarted(this.key, tags);
3919
4047
  try {
4048
+ if (lost !== undefined) throw new Error(lost);
3920
4049
  const outcome = await this.superviseMessage(operationId, deadline, opening, {
3921
4050
  cause,
3922
4051
  tags,
@@ -4031,6 +4160,7 @@ class LogicalAgent implements AgentRef {
4031
4160
  phase: (phase, reason, until) =>
4032
4161
  this.options.progress.turnPhase(this.key, phase, reason, until),
4033
4162
  delivered: operation.delivered,
4163
+ keepScreen: (turn) => keepScreen(this.options.runDir, operationId, turn),
4034
4164
  ...(this.options.livenessPolicy ? { policy: this.options.livenessPolicy } : {}),
4035
4165
  });
4036
4166
  } finally {
@@ -4047,7 +4177,7 @@ class LogicalAgent implements AgentRef {
4047
4177
  ...(outcome.deliveredAt === undefined ? {} : { deliveredAt: outcome.deliveredAt }),
4048
4178
  settledAt: outcome.settledAt,
4049
4179
  },
4050
- outcome.charges,
4180
+ outcome.reportedCosts,
4051
4181
  );
4052
4182
  // Recorded `answered` with no value: it did what it was woken for, and takes no result.
4053
4183
  this.turnEnded(
@@ -4078,7 +4208,7 @@ class LogicalAgent implements AgentRef {
4078
4208
  const times: { deliveredAt?: number } = {};
4079
4209
  const settle = (
4080
4210
  native: HarnessTurnOutcome | "expired",
4081
- /** A compaction left to end on its own, whose charges come when it does. */
4211
+ /** A compaction left to end on its own, whose reported costs come when it does. */
4082
4212
  later?: Promise<HarnessTurnOutcome>,
4083
4213
  ): TurnOutcome<string> => {
4084
4214
  const usage = entry.settle(
@@ -4086,7 +4216,7 @@ class LogicalAgent implements AgentRef {
4086
4216
  ...times,
4087
4217
  settledAt: Math.min(Date.now(), deadline.unixMilliseconds),
4088
4218
  },
4089
- native === "expired" ? [] : native.chargesUsd,
4219
+ native === "expired" ? [] : native.reportedCostsUsd,
4090
4220
  later,
4091
4221
  );
4092
4222
  if (native === "expired") {
@@ -4134,7 +4264,7 @@ class LogicalAgent implements AgentRef {
4134
4264
  state: "failed",
4135
4265
  detail: messageOf(error),
4136
4266
  resultEvidence: { kind: "unavailable" },
4137
- chargesUsd: [],
4267
+ reportedCostsUsd: [],
4138
4268
  });
4139
4269
  }
4140
4270
  times.deliveredAt = Date.now();
@@ -4148,8 +4278,14 @@ class LogicalAgent implements AgentRef {
4148
4278
  cancelTimer = scheduleAt(deadline, () => resolve("expired"));
4149
4279
  }),
4150
4280
  ]).finally(() => cancelTimer?.());
4151
- if (native === "expired") return settle(native, (await this.leaveFinishing(turn)).later);
4152
- return settle(native);
4281
+ if (native === "expired") {
4282
+ await keepScreen(this.options.runDir, operationId, turn);
4283
+ return settle(native, (await this.leaveFinishing(turn)).later);
4284
+ }
4285
+ const outcome = settle(native);
4286
+ if (!outcome.answered && outcome.kind !== "cancelled")
4287
+ await keepScreen(this.options.runDir, operationId, turn);
4288
+ return outcome;
4153
4289
  } finally {
4154
4290
  removeCanceller?.();
4155
4291
  }
@@ -4183,10 +4319,27 @@ function checkedWhen(when: unknown): string | Date {
4183
4319
  throw new Error('sleep is for a duration, "2h", or until a Date');
4184
4320
  }
4185
4321
 
4186
- function checkedLabel(label: unknown): string | undefined {
4187
- if (label !== undefined && typeof label !== "string")
4188
- throw new Error("a wait's label is a string");
4189
- return label;
4322
+ /** Receive's options as given, checked: what a typed caller can't get wrong, a plain one can. */
4323
+ function receiveLabel(options: unknown): string | undefined {
4324
+ if (options === undefined) return undefined;
4325
+ if (typeof options !== "object" || options === null) throw new Error("receive takes { label? }");
4326
+ // `take: "all"` was the default, so only "oldest" asked for what receive no longer does.
4327
+ const { take } = options as { take?: unknown };
4328
+ if (take !== undefined && take !== "all")
4329
+ throw new Error(
4330
+ "receive takes every event not yet acknowledged; wf.select takes one at a time",
4331
+ );
4332
+ if ("until" in options)
4333
+ throw new Error("receive has no until: a timer, wf.timer(name, when), wakes it");
4334
+ return labelOf((options as WaitOptions).label);
4335
+ }
4336
+
4337
+ /** A wait's label, a function's asked now. */
4338
+ function labelOf(label: unknown): string | undefined {
4339
+ const given = typeof label === "function" ? label() : label;
4340
+ if (given !== undefined && typeof given !== "string")
4341
+ throw new Error("a wait's label is a string, or a function that returns one");
4342
+ return given;
4190
4343
  }
4191
4344
 
4192
4345
  /** A wait's branches, by name, checked: a race may give a duration where a select may not. */
@@ -4229,36 +4382,54 @@ function sourceTake(what: string, branch: string, source: unknown): WaitTake {
4229
4382
  throw new Error(
4230
4383
  `${what}'s ${branch} is wf.at(), a select's: a race answers once, so it takes a timer, wf.timer(name, date)`,
4231
4384
  );
4232
- throw new Error(
4233
- `${what}'s ${branch} is not a source: a timer, a signal, a participant routed to the code, an agent, or a started turn`,
4234
- );
4385
+ if (typeof source === "object" && source !== null && startedTurns.has(source))
4386
+ throw new Error(
4387
+ `${what}'s ${branch} is a started turn: name its agent, { ${branch}: agent }, which has at most one`,
4388
+ );
4389
+ throw new Error(`${what}'s ${branch} is not a source: a timer, a signal, or an agent`);
4235
4390
  }
4236
4391
 
4392
+ /** The sources only a run of a workflow with events hears. */
4393
+ const HEARD_WITH_EVENTS: ReadonlySet<string> = new Set(["agent:ended", "participant:reply"]);
4394
+
4237
4395
  /** One of the workflow's own events, by name, as a source. */
4238
- function signalSource(name: string): unknown {
4239
- if (typeof name !== "string" || name.includes(":"))
4240
- throw new Error(`a signal is one of the workflow's own events, by name; not ${String(name)}`);
4241
- return source({ name }, { name });
4396
+ function signalSource(name: string, declared: ReadonlySet<string>): unknown {
4397
+ if (typeof name !== "string")
4398
+ throw new Error(
4399
+ `a signal is one of the workflow's declared events, by name; not ${String(name)}`,
4400
+ );
4401
+ if (!declared.has(name))
4402
+ throw new Error(
4403
+ declared.size === 0
4404
+ ? `signal ${name}: the workflow declares no events`
4405
+ : `signal ${name}: the workflow declares no such event; its events are ${[...declared].join(", ")}`,
4406
+ );
4407
+ const awf = awfEventOf(name);
4408
+ if (awf?.kind === "ended")
4409
+ throw new Error(
4410
+ `signal ${name}: an agent's end is waited on by naming the agent, { done: ${awf.key} }, not a signal`,
4411
+ );
4412
+ // `in.jsonl` keeps a reply under its record name, from the participant's key.
4413
+ const take = awf ? { name: "participant:reply", from: awf.key } : { name };
4414
+ return signalOf({ name }, take);
4242
4415
  }
4243
4416
 
4244
4417
  /** Timer `name`'s source. */
4245
- function timerSource(mailbox: RunMailbox, name: string, due: string): Timer {
4418
+ function timerSource(waits: RunWaits, name: string, due: string): Timer {
4246
4419
  return source(
4247
- { name, due, cancel: () => mailbox.cancelTimer(name) },
4420
+ { name, due, cancel: () => waits.cancelTimer(name) },
4248
4421
  { name: "timer:due", from: name },
4249
4422
  ) as unknown as Timer;
4250
4423
  }
4251
4424
 
4252
- /** A started turn, as the code holds it: its id, its cancel, and a source of its end. */
4253
- function turnRef(
4254
- agent: string,
4255
- id: string,
4256
- cancel: (reason?: string) => Promise<boolean>,
4257
- ): StartedTurn {
4258
- return source(
4259
- { id, cancel },
4260
- { name: "agent:ended", from: agent, turn: id },
4261
- ) as unknown as StartedTurn;
4425
+ /** The turns `start()` handed out, which a wait refuses by name. */
4426
+ const startedTurns = new WeakSet<object>();
4427
+
4428
+ /** A started turn, as the code holds it: its id and its cancel. A wait names its agent. */
4429
+ function turnRef(id: string, cancel: (reason?: string) => Promise<boolean>): StartedTurn {
4430
+ const turn = Object.freeze({ id, cancel });
4431
+ startedTurns.add(turn);
4432
+ return turn;
4262
4433
  }
4263
4434
 
4264
4435
  class ExecutionScope {
@@ -4435,6 +4606,17 @@ async function executeParallel<Item, Result>(
4435
4606
  }
4436
4607
  }
4437
4608
 
4609
+ /** How long reading a pane for an operation's evidence may hold up its release. */
4610
+ const SCREEN_READ_MS = 5_000;
4611
+
4612
+ /** Keeps what `turn`'s pane shows, the evidence of an operation that ended without an answer. */
4613
+ async function keepScreen(runDir: string, operationId: string, turn: HarnessTurn): Promise<void> {
4614
+ if (!turn.screen) return;
4615
+ const deadline = { unixMilliseconds: Date.now() + SCREEN_READ_MS };
4616
+ const screen = await waitForDeadline(turn.screen(), deadline).catch(() => undefined);
4617
+ if (screen?.trim()) await writeScreen(runDir, operationId, screen).catch(() => undefined);
4618
+ }
4619
+
4438
4620
  /**
4439
4621
  * Compaction keeps its existing bounded release behavior. Structured operations use the
4440
4622
  * supervisor, which requires confirmed native completion before returning an answer.
@@ -4521,16 +4703,21 @@ type Warn = (message: string, fields?: JsonObject, stage?: string) => void;
4521
4703
  type OperationLog = {
4522
4704
  operationId: string;
4523
4705
  tags: OperationTags;
4706
+ /** Why its `op.json` wasn't written as it was drawn: a turn fails as its result slot would. */
4707
+ unrecorded?: string;
4524
4708
  delivered(at: number): void;
4525
4709
  /** A session its host named while it ran: on its start if that is still to come. */
4526
4710
  session(id: string): void;
4527
4711
  failed(error: unknown): void;
4528
4712
  };
4529
4713
 
4714
+ /** A turn as the agent runs it: its spec, under the id it is recorded by. */
4715
+ type TurnSpec = (AgentRunTextSpec | AgentRunStructuredSpec<JsonValue>) & { id: TurnId };
4530
4716
  /** A native fork, and the parent's operation it was copied after: null when it ran none. */
4531
4717
  type TakenFork = { native: NativeFork; after: string | null };
4718
+ type TakeFork = (deadline: AbsoluteDeadline | undefined, into?: SessionCopy) => Promise<TakenFork>;
4532
4719
 
4533
- /** A settled turn or compaction, kept in the run's `turns.jsonl`. */
4720
+ /** A settled turn or compaction, kept in the run's `log.jsonl`. */
4534
4721
  type RecordTurn = (outcome: TurnOutcome<JsonValue>, kind: TurnRecord["kind"]) => void;
4535
4722
 
4536
4723
  function pick<T extends object, K extends keyof T>(value: T, keys: readonly K[]): Pick<T, K> {
@@ -4693,46 +4880,99 @@ function withKnownEffort(execution: AgentExecution): AgentExecution {
4693
4880
  /** What `set` changes. */
4694
4881
  type SettingsChange = Partial<Pick<AgentExecution, "model" | "effort">>;
4695
4882
 
4696
- const SETTINGS_FIELDS = new Set(["id", "model", "effort", "deadline", "timeoutMs"]);
4883
+ const SETTINGS_FIELDS = new Set(["model", "effort", "timeout"]);
4884
+
4885
+ /** Removed in 0.1.0-beta.6; refused, as the switch would run otherwise than written. */
4886
+ const REMOVED_SETTINGS_FIELDS = new Set(["id", "deadline", "timeoutMs"]);
4697
4887
 
4698
4888
  function sessionSettings({ model, effort }: AgentExecution): SessionSettings {
4699
4889
  return { model, ...(effort === undefined ? {} : { effort }) };
4700
4890
  }
4701
4891
 
4702
4892
  /**
4703
- * A pane is the default and is left unsaid, and `metered` is kept only where it means something,
4893
+ * A TUI is the default and is left unsaid, and `metered` is kept only where it means something,
4704
4894
  * so the same agent always has one identity.
4705
4895
  */
4706
4896
  function storedPlacement(choice: PlacementChoice): PlacementChoice {
4707
- // A workflow is untyped JavaScript at run time; a misspelt placement must not become a pane.
4897
+ knownPlacement(choice);
4898
+ return placementOf(choice) === "headless"
4899
+ ? { placement: "headless", ...(choice.metered === true ? { metered: true as const } : {}) }
4900
+ : {};
4901
+ }
4902
+
4903
+ /** A workflow is untyped JavaScript at run time; a misspelt placement must not become a TUI. */
4904
+ function knownPlacement(choice: PlacementChoice): void {
4708
4905
  if (
4709
4906
  choice.placement !== undefined &&
4710
- choice.placement !== "pane" &&
4907
+ choice.placement !== "tui" &&
4711
4908
  choice.placement !== "headless"
4712
4909
  ) {
4910
+ const renamed = (choice.placement as string) === "pane" ? ` ("pane" is now "tui")` : "";
4713
4911
  throw new Error(
4714
- `unknown placement ${JSON.stringify(choice.placement)}; expected pane or headless`,
4912
+ `unknown placement ${JSON.stringify(choice.placement)}; expected tui or headless${renamed}`,
4715
4913
  );
4716
4914
  }
4717
- return placementOf(choice) === "headless"
4718
- ? { placement: "headless", ...(choice.metered === true ? { metered: true as const } : {}) }
4719
- : {};
4720
4915
  }
4721
4916
 
4722
4917
  /**
4723
4918
  * The parent's harness, model and effort, in the placement and at the effort the fork names, else
4724
4919
  * its parent's.
4725
4920
  */
4726
- function forkExecution(parent: AgentExecution, spec: AgentForkSpec): AgentExecution {
4921
+ function forkExecution(parent: AgentExecution, runtime: ForkRuntime = {}): AgentExecution {
4727
4922
  const { placement: _placement, metered: _metered, ...target } = parent;
4728
- const choice = spec.placement === undefined && spec.metered === undefined ? parent : spec;
4923
+ const choice =
4924
+ runtime.placement === undefined && runtime.metered === undefined ? parent : runtime;
4729
4925
  return {
4730
4926
  ...target,
4731
- ...(spec.effort === undefined ? {} : { effort: spec.effort }),
4927
+ ...(runtime.effort === undefined ? {} : { effort: runtime.effort }),
4732
4928
  ...storedPlacement(choice),
4733
4929
  };
4734
4930
  }
4735
4931
 
4932
+ const FORK_RUNTIME_FIELDS = ["placement", "metered", "effort"] as const;
4933
+
4934
+ /** A fork's spec, its `runtime` checked: what a typed caller can't get wrong, a plain one can. */
4935
+ function forkSpecOf(spec: CallerForkSpec): CallerForkSpec {
4936
+ const { runtime, ...rest } = spec;
4937
+ // Removed in 0.1.0-beta.6; refused, as the fork would run otherwise than written.
4938
+ const flat = FORK_RUNTIME_FIELDS.filter(
4939
+ (field) => (rest as Record<string, unknown>)[field] !== undefined,
4940
+ );
4941
+ if (flat.length > 0)
4942
+ throw new Error(
4943
+ `agent ${spec.key}: a fork's ${flat.join(", ")} go in its runtime: fork({ key, runtime: { ${flat.join(", ")} } })`,
4944
+ );
4945
+ if (runtime === undefined) return rest;
4946
+ if (typeof runtime !== "object" || runtime === null || Array.isArray(runtime)) {
4947
+ throw new Error(
4948
+ `agent ${spec.key}: a fork's runtime is { placement?, metered?, effort? }, not ${JSON.stringify(runtime)}`,
4949
+ );
4950
+ }
4951
+ // A workflow is untyped JavaScript at run time; a harness or model asked for must not be dropped.
4952
+ const others = Object.keys(runtime).filter(
4953
+ (field) =>
4954
+ !(FORK_RUNTIME_FIELDS as readonly string[]).includes(field) &&
4955
+ (runtime as Record<string, unknown>)[field] !== undefined,
4956
+ );
4957
+ if (others.length > 0) {
4958
+ throw new Error(
4959
+ `agent ${spec.key}: a fork continues its parent's session on its harness and model; its runtime takes placement, metered and effort, not ${others.join(", ")}`,
4960
+ );
4961
+ }
4962
+ const given = definedFields({
4963
+ placement: runtime.placement,
4964
+ metered: runtime.metered,
4965
+ effort: runtime.effort,
4966
+ });
4967
+ return Object.keys(given).length === 0 ? rest : { ...rest, runtime: given };
4968
+ }
4969
+
4970
+ function definedFields<T extends object>(fields: T): Partial<T> {
4971
+ return Object.fromEntries(
4972
+ Object.entries(fields).filter(([, value]) => value !== undefined),
4973
+ ) as Partial<T>;
4974
+ }
4975
+
4736
4976
  function constrainExistingExecution(
4737
4977
  selection: RuntimeSelection,
4738
4978
  existing: AgentExecution,
@@ -4757,6 +4997,7 @@ function constrainExistingExecution(
4757
4997
  if (selection.effort !== undefined && selection.effort !== existing.effort) {
4758
4998
  throw new Error("existing agent was opened at a different effort");
4759
4999
  }
5000
+ knownPlacement(selection);
4760
5001
  const placed = placementOf(existing);
4761
5002
  if (
4762
5003
  (selection.placement !== undefined && placementOf(selection) !== placed) ||
@@ -4792,23 +5033,13 @@ function assertCompatibleAgent(
4792
5033
  if (spec.skills !== undefined && !isDeepStrictEqual(existing.skills, requested.skills)) {
4793
5034
  conflict(key, "skills");
4794
5035
  }
4795
- for (const field of ["instructions", "labels", "layout", "keepPane"] as const) {
5036
+ for (const field of ["instructions", "pane"] as const) {
4796
5037
  if (spec[field] !== undefined && !isDeepStrictEqual(existing[field], requested[field])) {
4797
5038
  conflict(key, field);
4798
5039
  }
4799
5040
  }
4800
5041
  }
4801
5042
 
4802
- function paneOptions({
4803
- layout,
4804
- keepPane,
4805
- }: AgentIdentity): Pick<HarnessActivation, "layout" | "keepPane"> {
4806
- return {
4807
- ...(layout === undefined ? {} : { layout }),
4808
- ...(keepPane === undefined ? {} : { keepPane }),
4809
- };
4810
- }
4811
-
4812
5043
  function conflict(key: string, field: string): never {
4813
5044
  throw new Error(`agent ${key} is already open with different ${field}`);
4814
5045
  }
@@ -4817,22 +5048,14 @@ function stoppedError(key: string, reason: string): Error {
4817
5048
  return new Error(`agent ${key} was stopped (${reason}); its key is not opened again`);
4818
5049
  }
4819
5050
 
4820
- function unavailable(name: string): Promise<never> {
4821
- return Promise.reject(new Error(`${name} is not implemented by the minimum workflow runner`));
4822
- }
4823
-
4824
- /**
4825
- * Each root agent's last native session in a run's turns, where its harness can resume it: what a
4826
- * event run's continue reopens it on.
4827
- */
4828
- export function keptSessionsOf(turns: readonly TurnRecord[]): Map<string, NativeFork> {
4829
- const kept = new Map<string, NativeFork>();
4830
- for (const turn of turns) {
4831
- if ((turn.callPath?.length ?? 0) > 0) continue;
4832
- const session = turn.sessions.at(-1);
4833
- if (session) kept.set(turn.agent, { harness: session.harness, sessionRef: session.id });
4834
- }
4835
- return kept;
5051
+ /** Each agent's latest `session` line: what an event run's continue reopens it on. */
5052
+ export function keptSessionsOf(sessions: RunView["sessions"]): Map<string, NativeFork> {
5053
+ return new Map(
5054
+ Object.values(sessions.latest).map((line) => [
5055
+ line.agent,
5056
+ { harness: line.harness, sessionRef: line.session },
5057
+ ]),
5058
+ );
4836
5059
  }
4837
5060
 
4838
5061
  /**
@@ -4843,4 +5066,6 @@ type LogicalAgentTurns = {
4843
5066
  begin(turn: TurnId, meta: JsonValue, execution: AgentExecution): Promise<void>;
4844
5067
  end(turn: TurnId, meta: JsonValue, outcome: TurnOutcome<JsonValue>): Promise<void>;
4845
5068
  restored(): RestoredTurn | undefined;
5069
+ /** Throws unless the workflow declares this agent's `{key}:ended`. */
5070
+ checkDeclared(): void;
4846
5071
  };