@agentswf/engine 0.1.0-beta.3 → 0.1.0-beta.5

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 (72) hide show
  1. package/package.json +5 -6
  2. package/src/agent-cli/cli.ts +611 -0
  3. package/src/agent-cli/client.ts +115 -0
  4. package/src/agent-launcher.ts +15 -25
  5. package/src/allowance-command.ts +3 -15
  6. package/src/attempt-ending.ts +2 -4
  7. package/src/attempt-output.ts +3 -2
  8. package/src/attempt-view.ts +1 -1
  9. package/src/control-plane.ts +38 -6
  10. package/src/coordinator-socket.ts +15 -6
  11. package/src/deadlines.ts +69 -2
  12. package/src/decisions/directory.ts +9 -11
  13. package/src/deprecations.ts +177 -0
  14. package/src/duration.ts +50 -20
  15. package/src/emit-command.ts +57 -54
  16. package/src/emit.ts +124 -15
  17. package/src/help/allowance.txt +13 -0
  18. package/src/help/emit.txt +21 -0
  19. package/src/help/inbox.txt +12 -0
  20. package/src/help/init.txt +12 -0
  21. package/src/help/logs.txt +14 -0
  22. package/src/help/reply.txt +18 -0
  23. package/src/help/run.txt +58 -0
  24. package/src/help/runs.txt +11 -0
  25. package/src/help/test.txt +9 -0
  26. package/src/help/tick.txt +12 -0
  27. package/src/herdr-run-session.ts +2 -2
  28. package/src/here.ts +1 -1
  29. package/src/holder-command.ts +38 -51
  30. package/src/inbox-command.ts +9 -24
  31. package/src/init-command.ts +2 -11
  32. package/src/init-template/AGENTS.md.template +3 -3
  33. package/src/init-template/hello/workflow.test.ts.template +3 -3
  34. package/src/init-template/hello/workflow.ts.template +6 -6
  35. package/src/keep-alive.ts +28 -13
  36. package/src/logs-command.ts +14 -27
  37. package/src/mailbox.ts +16 -21
  38. package/src/messaging/limits.ts +5 -5
  39. package/src/messaging/messaging.ts +48 -43
  40. package/src/messaging/routes.ts +2 -2
  41. package/src/messaging/text.ts +24 -22
  42. package/src/operation-liveness.ts +1 -1
  43. package/src/operator-cli.ts +23 -9
  44. package/src/operator-runtime.ts +9 -12
  45. package/src/pane-layout.ts +125 -32
  46. package/src/progress-view.ts +8 -8
  47. package/src/result-validation.ts +1 -1
  48. package/src/run-command.ts +27 -47
  49. package/src/run-commands.ts +1 -1
  50. package/src/run-prepare.ts +46 -8
  51. package/src/run-usage.ts +2 -2
  52. package/src/run-view.ts +9 -4
  53. package/src/runs-command.ts +14 -23
  54. package/src/runs.ts +44 -8
  55. package/src/sandboxes.ts +3 -2
  56. package/src/simple-input.ts +13 -3
  57. package/src/sources.ts +42 -6
  58. package/src/stage-ledger.ts +43 -13
  59. package/src/stage-plan.ts +37 -1
  60. package/src/test-command.ts +9 -14
  61. package/src/testing.ts +8 -9
  62. package/src/tick-command.ts +2 -13
  63. package/src/tools.ts +62 -25
  64. package/src/workflow-loader.ts +5 -2
  65. package/src/workflow-runner.ts +636 -271
  66. package/src/workflow-testing/coordinator.typecheck.ts +12 -13
  67. package/src/workflow-testing/coordinators.ts +2 -2
  68. package/src/workflow-testing/host.ts +115 -46
  69. package/src/workflow-testing/index.ts +45 -837
  70. package/src/workflow-testing/run-test.ts +1213 -0
  71. package/src/workflow-testing/script.ts +106 -45
  72. package/src/workflow-testing/workflow-testing.typecheck.ts +13 -10
@@ -1,9 +1,11 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { type AttemptRecord, RUN_EVENT_VERSIONS } from "@agentswf/contract/records";
3
+ import { join } from "node:path";
4
+ import { type AttemptRecord, RUN_EVENT_VERSIONS, type RunPanes } from "@agentswf/contract/records";
4
5
  import { isEventWorkflow, type JsonValue } from "@agentswf/contract/workflow";
5
6
  import { refusalMessage } from "./attempt-view";
6
7
  import { messageOf } from "./errors";
8
+ import { exists } from "./files";
7
9
  import { projectOf, projectRunRoot } from "./projects";
8
10
  import type { RunCommand } from "./run-command";
9
11
  import {
@@ -14,6 +16,7 @@ import {
14
16
  checkFree,
15
17
  claimAttempt,
16
18
  endTurnsLine,
19
+ generateId,
17
20
  idProblem,
18
21
  openRun,
19
22
  previousGone,
@@ -38,6 +41,14 @@ export type PreparedRun = {
38
41
  continued?: { run: Run; attempts: AttemptRecord[]; warnings: string[] };
39
42
  };
40
43
 
44
+ /** `awf run --id` of a workflow with events, which starts the run detached, or wakes it. */
45
+ export type OpensEventRun = {
46
+ loaded: LoadedWorkflow;
47
+ id: string;
48
+ argv: readonly string[];
49
+ panes?: RunPanes;
50
+ };
51
+
41
52
  /**
42
53
  * The command's workflow loaded and its run prepared, or why not, as awf says it: in the shell that
43
54
  * typed the command, before anything starts.
@@ -49,7 +60,7 @@ export async function loadAndPrepare(
49
60
  ): Promise<
50
61
  | { loaded: LoadedWorkflow; prepared: PreparedRun; command: RunCommand }
51
62
  | { refused: string }
52
- | { opens: { loaded: LoadedWorkflow; id: string; argv: readonly string[] } }
63
+ | { opens: OpensEventRun }
53
64
  > {
54
65
  let loaded: LoadedWorkflow;
55
66
  try {
@@ -74,12 +85,23 @@ export async function loadAndPrepare(
74
85
  try {
75
86
  const id =
76
87
  command.id ??
77
- derivedId(loaded.workflow, prepareArgs(loaded.workflow, command.workflowArgs, project));
88
+ derivedId(loaded.workflow, prepareArgs(loaded.workflow, command.workflowArgs, project)) ??
89
+ // Without starts no sender names its runs, so one may be generated, as a plain run's is.
90
+ (loaded.workflow.starts === undefined
91
+ ? await unusedId(command.runRoot, loaded.workflow.meta.name, now)
92
+ : undefined);
78
93
  if (id === undefined)
79
94
  return {
80
- refused: `a run of ${loaded.file} is named by its id, which --id or the workflow's id(args) gives: awf run --id {id} starts one, awf emit --target ${loaded.file} --id {id} {event} {json} starts one with an event`,
95
+ refused: `a run of ${loaded.file}, which starts on events, is named by its id, which --id or the workflow's id(args) gives: awf run --id {id} starts one, awf emit --target ${loaded.file} --id {id} {event} {json} starts one with an event`,
81
96
  };
82
- return { opens: { loaded, id, argv: command.workflowArgs } };
97
+ return {
98
+ opens: {
99
+ loaded,
100
+ id,
101
+ argv: command.workflowArgs,
102
+ ...(command.panes ? { panes: command.panes } : {}),
103
+ },
104
+ };
83
105
  } catch (error) {
84
106
  return { refused: `${error instanceof RunRefused ? "" : "prepare: "}${messageOf(error)}` };
85
107
  }
@@ -115,6 +137,7 @@ async function prepareRun(
115
137
  argv: command.workflowArgs,
116
138
  ...(command.cwdGiven ? { cwd: command.cwd } : {}),
117
139
  ...(command.sandbox === undefined ? {} : { sandbox: command.sandbox }),
140
+ ...(command.panes === undefined ? {} : { panes: command.panes }),
118
141
  });
119
142
  const { id, argv, cwd } = run.record;
120
143
  if (!statSync(cwd, { throwIfNoEntry: false })?.isDirectory()) {
@@ -155,13 +178,28 @@ export function prepareArgs(
155
178
  return args;
156
179
  }
157
180
 
181
+ /**
182
+ * A generated id no run of `workflow` holds: `awf run` of one already there would wake it. Taken
183
+ * between this and the open, by a run started the same minute with the same four digits, it is.
184
+ */
185
+ async function unusedId(runRoot: string, workflow: string, now: number): Promise<string> {
186
+ for (let tries = 1; ; tries += 1) {
187
+ const id = generateId(new Date(now));
188
+ if (!(await exists(join(runRoot, workflow, id)))) return id;
189
+ if (tries >= 5) throw new RunRefused("every id generated was taken; --id gives one");
190
+ }
191
+ }
192
+
158
193
  /**
159
194
  * Why `awf run` doesn't take a workflow with `events` as asked: every attempt after the first is
160
- * started by a wake, which passes on none of the flags a run would have to keep.
195
+ * started by a wake, which passes on none of the flags a run would have to keep. A `--from-stage`
196
+ * is the one attempt's, and its records keep what it redid.
161
197
  */
162
198
  function eventRunRefusal(command: RunCommand): string | undefined {
163
- if (command.fromStage !== undefined || command.values !== undefined)
164
- return "a run of a workflow with events takes no --from-stage or --values";
199
+ if (command.values !== undefined)
200
+ return "a run of a workflow with events takes no --values: before --from-stage, a stage with no record runs";
201
+ if (command.fromStage !== undefined && command.continueId === undefined)
202
+ return "a run of a workflow with events takes --from-stage only with --continue";
165
203
  if (command.continueId !== undefined) {
166
204
  if (command.workflowArgs.length > 0)
167
205
  return "a continue takes no arguments: it goes on with those its run was started with";
package/src/run-usage.ts CHANGED
@@ -31,11 +31,11 @@ export type AccountedAgent = {
31
31
  cwd: string;
32
32
  /** A sandboxed agent's own harness home, where its sessions are. */
33
33
  home?: string;
34
- /** Every native session id seen for the agent so far, from its `wf` calls and its adapter. */
34
+ /** Every native session id seen for the agent so far, from its `wfa` calls and its adapter. */
35
35
  sessions(): readonly string[];
36
36
  /** When its host first prompted it, where that waits on the agent first; see `window`. */
37
37
  promptedAt?(): number | undefined;
38
- /** The caller handed back early by agents.stop. */
38
+ /** The caller handed back early by its close(). */
39
39
  releasedAt?(): number | undefined;
40
40
  /** When a fork's session was made: what it holds from before is its parent's (ADR 0009). */
41
41
  forkedAt?(): number | undefined;
package/src/run-view.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { join } from "node:path";
2
2
  import type {
3
3
  Activity,
4
+ AgentStateJson,
4
5
  AttemptRecord,
5
6
  OperationLivenessRecord,
6
7
  RunLogLine,
8
+ StageStateJson,
7
9
  } from "@agentswf/contract/records";
8
- import type { AgentKey, HarnessKind, StageOutcome } from "@agentswf/contract/workflow";
10
+ import type { AgentKey, HarnessKind } from "@agentswf/contract/workflow";
9
11
  import { readLines } from "./jsonl";
10
12
  import { currentStage, estimateOf } from "./run-commands";
11
13
  import { type Cursor, type FileCache, readRun, timeOf } from "./run-read";
@@ -14,7 +16,7 @@ import type { RunStatus } from "./run-state";
14
16
  /** A stage of the attempt shown: running, reused or given from an earlier one, or how it ended. */
15
17
  export type StageView = {
16
18
  stage: string;
17
- state: "running" | "interrupted" | "reused" | "provided" | StageOutcome;
19
+ state: StageStateJson;
18
20
  startedAt?: number;
19
21
  endedAt?: number;
20
22
  };
@@ -29,7 +31,7 @@ export type AgentView = {
29
31
  caller: boolean;
30
32
  /** The stage its last operation ran in. */
31
33
  stage?: string;
32
- state: string;
34
+ state: AgentStateJson;
33
35
  turns: number;
34
36
  /** When its current operation, or its last, started. */
35
37
  since?: number;
@@ -137,7 +139,7 @@ export async function viewRun(status: RunStatus): Promise<RunView> {
137
139
  const stage = currentStage(status, read.log.lines);
138
140
  // A run whose process is gone left what it was doing undone.
139
141
  if (status.state === "interrupted") {
140
- for (const each of agents.values()) if (each.state === "working") each.state = "was working";
142
+ for (const each of agents.values()) if (each.state === "working") each.state = "interrupted";
141
143
  for (const each of stages) if (each.state === "running") each.state = "interrupted";
142
144
  }
143
145
  if (status.state === "running") {
@@ -153,6 +155,9 @@ export async function viewRun(status: RunStatus): Promise<RunView> {
153
155
  } else if (phase?.kind === "awaiting-reply" && phase.peer !== undefined) {
154
156
  agent(key).live = { phase: "awaiting-reply", peer: phase.peer, ...until };
155
157
  }
158
+ // One word with `wfa peers`: the phase is the state; `live` keeps its detail.
159
+ const { live } = agent(key);
160
+ if (live && agent(key).state === "working") agent(key).state = live.phase;
156
161
  }
157
162
  }
158
163
  const since = attempt ? Date.parse(attempt.started) : Number.NEGATIVE_INFINITY;
@@ -1,7 +1,8 @@
1
1
  import { homedir } from "node:os";
2
2
  import type { RunJson, RunsJson } from "@agentswf/contract/records";
3
- import { duration } from "./duration";
3
+ import { formatDuration } from "./duration";
4
4
  import { messageOf } from "./errors";
5
+ import help from "./help/runs.txt" with { type: "text" };
5
6
  import {
6
7
  clockTime,
7
8
  command,
@@ -23,19 +24,7 @@ import { encodeCursor } from "./run-read";
23
24
  import { findRunByRef, listRuns, type RunStatus, runStatus } from "./run-state";
24
25
  import { type AgentView, describeActivity, type RunView, viewRun } from "./run-view";
25
26
 
26
- export const runsUsage = [
27
- "usage: awf runs [--limit <n>] [--cwd <directory>] [--run-root <directory>] [--json]",
28
- " awf runs <run> [--cwd <directory>] [--run-root <directory>] [--json]",
29
- "",
30
- "The runs here, newest first, or one run in depth: its attempts, the stages of the last, what",
31
- "each agent is doing, and what to run next. The list shows 20 unless --limit says another",
32
- "number. A run is <workflow>/<id>, or an <id> only one",
33
- "workflow has. Runs are listed under --cwd's .awf/runs, or --run-root, as awf run keeps them; a",
34
- "run named is found there first, then in each project awf has started a run with events in.",
35
- "",
36
- "--json prints one document, the interface for scripts. Exits 0 on any read that worked, 1 when",
37
- "there is no such run, 2 on a usage error, 3 when the run's files could not be read.",
38
- ].join("\n");
27
+ export const runsUsage = help.trimEnd();
39
28
 
40
29
  const LISTED = 20;
41
30
 
@@ -144,7 +133,7 @@ async function list(place: ReadPlace, limit: number, json: boolean, out: Out, no
144
133
  status.ref.padEnd(width),
145
134
  states[index]!.padEnd(stateWidth),
146
135
  dayTime(started).padEnd(13),
147
- duration(ms).padEnd(8),
136
+ formatDuration(ms).padEnd(8),
148
137
  money(estimate)
149
138
  .join("")
150
139
  .concat(waiting ? " " : ""),
@@ -173,13 +162,15 @@ function dayTime(at: number): string {
173
162
  return `${two(date.getMonth() + 1)}-${two(date.getDate())} ${clockTime(at).slice(0, 5)}`;
174
163
  }
175
164
 
176
- const busy = (agent: AgentView) => agent.state === "working" || agent.state === "was working";
165
+ const inTurn = (agent: AgentView) =>
166
+ agent.state === "working" || agent.state === "waiting" || agent.state === "awaiting-reply";
167
+ const busy = (agent: AgentView) => inTurn(agent) || agent.state === "interrupted";
177
168
 
178
169
  function agentLine(agent: AgentView, indent: string, now: number, width: number): string {
179
170
  const { live } = agent;
180
171
  const parts = [
181
172
  said(agent.key, 60).padEnd(width),
182
- (live?.phase === "waiting" ? "waiting" : live ? "awaiting" : agent.state).padEnd(11),
173
+ agent.state.padEnd(14),
183
174
  busy(agent) ? `turn ${agent.turns + 1}` : `${agent.turns} turn${agent.turns === 1 ? "" : "s"}`,
184
175
  ...(live?.phase === "waiting"
185
176
  ? [
@@ -192,10 +183,10 @@ function agentLine(agent: AgentView, indent: string, now: number, width: number)
192
183
  ]
193
184
  : live
194
185
  ? [
195
- `${said(live.peer, 60)}'s reply${live.until ? ` (until ${clockTime(live.until).slice(0, 5)})` : ""}`,
186
+ `from ${said(live.peer, 60)}${live.until ? ` (until ${clockTime(live.until).slice(0, 5)})` : ""}`,
196
187
  ]
197
188
  : []),
198
- ...(agent.since && agent.state === "working" ? [duration(now - agent.since)] : []),
189
+ ...(agent.since && inTurn(agent) ? [formatDuration(now - agent.since)] : []),
199
190
  ...(agent.last
200
191
  ? [`last: ${describeActivity(agent.last.activity)} (${clockTime(agent.last.at)})`]
201
192
  : []),
@@ -247,13 +238,13 @@ function describe(view: RunView, place: ReadPlace, now: number): string {
247
238
  ...(view.stage ? [`stage ${view.stage}`] : []),
248
239
  status.state === "interrupted" && view.lastEventAt
249
240
  ? `last event ${clockTime(view.lastEventAt)}`
250
- : duration(span(status, now).ms),
241
+ : formatDuration(span(status, now).ms),
251
242
  ...money(view.estimate),
252
243
  ].join(" "),
253
244
  ];
254
245
  for (const earlier of status.attempts.slice(0, -1)) {
255
246
  const took = earlier.ended
256
- ? ` ${duration(Date.parse(earlier.ended) - Date.parse(earlier.started))}`
247
+ ? ` ${formatDuration(Date.parse(earlier.ended) - Date.parse(earlier.started))}`
257
248
  : "";
258
249
  const how = `${earlier.outcome ?? "interrupted"}${earlier.stage ? ` in ${earlier.stage}` : ""}`;
259
250
  lines.push(` attempt ${earlier.attempt} ${how}${took}`);
@@ -265,7 +256,7 @@ function describe(view: RunView, place: ReadPlace, now: number): string {
265
256
  const took =
266
257
  stage.state === "reused" || stage.state === "provided" || stage.startedAt === undefined
267
258
  ? ""
268
- : duration((stage.endedAt ?? now) - stage.startedAt);
259
+ : formatDuration((stage.endedAt ?? now) - stage.startedAt);
269
260
  lines.push(
270
261
  `${indent} stage ${stage.stage.padEnd(8)} ${stage.state.padEnd(10)} ${took}`.trimEnd(),
271
262
  );
@@ -320,7 +311,7 @@ function runJson(view: RunView, place: ReadPlace, now: number): RunJson {
320
311
  ? undefined
321
312
  : continueOf(status, view.lines, place);
322
313
  return {
323
- v: 1,
314
+ v: 2,
324
315
  run: {
325
316
  ref: status.ref,
326
317
  state: status.state,
package/src/runs.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { mkdir, open, readdir, readFile, rename, rm, stat, unlink } from "node:fs/promises";
2
+ import {
3
+ copyFile,
4
+ mkdir,
5
+ open,
6
+ readdir,
7
+ readFile,
8
+ rename,
9
+ rm,
10
+ stat,
11
+ unlink,
12
+ } from "node:fs/promises";
3
13
  import { join } from "node:path";
14
+ import { isDeepStrictEqual } from "node:util";
4
15
  import {
5
16
  ATTEMPT_RECORD_VERSION,
6
17
  type AttemptRecord,
@@ -8,6 +19,7 @@ import {
8
19
  type Candidate,
9
20
  RUN_RECORD_VERSION,
10
21
  type RunLogLine,
22
+ type RunPanes,
11
23
  type RunRecord,
12
24
  runLogLine,
13
25
  STAGE_RECORD_VERSION,
@@ -190,13 +202,15 @@ export async function openRun(root: string, workflow: string, id: string): Promi
190
202
 
191
203
  /**
192
204
  * What a continue may be given: nothing that changes the run. argv is the run's, and its working
193
- * directory and sandbox are those it was started with.
205
+ * directory, sandbox and panes are those it was started with.
194
206
  */
195
207
  export function checkContinue(
196
208
  run: Run,
197
- given: { argv: readonly string[]; cwd?: string; sandbox?: unknown },
209
+ given: { argv: readonly string[]; cwd?: string; sandbox?: unknown; panes?: RunPanes },
198
210
  ): void {
199
211
  const { id, cwd, sandbox } = run.record;
212
+ const panes = panesChanged(run.record, given.panes);
213
+ if (panes) throw new RunRefused(panes);
200
214
  if (given.argv.length > 0) {
201
215
  throw new RunRefused(
202
216
  `${id} keeps the arguments it was started with; a run with others is a new run (--id)`,
@@ -210,11 +224,32 @@ export function checkContinue(
210
224
  }
211
225
  }
212
226
 
227
+ /**
228
+ * Why `given` would change the panes `record`'s run was started with; a flag given again with its
229
+ * value, or with its default where the run was started without it, changes nothing.
230
+ */
231
+ export function panesChanged(record: RunRecord, given: RunPanes | undefined): string | undefined {
232
+ const flags = [
233
+ ["--workspace", "workspace", "run"],
234
+ ["--keep-panes", "keep", "never"],
235
+ ] as const;
236
+ for (const [flag, field, byDefault] of flags) {
237
+ const value = given?.[field];
238
+ const recorded = record.panes?.[field];
239
+ if (value === undefined || isDeepStrictEqual(value, recorded ?? byDefault)) continue;
240
+ // A run an event or an older awf started has none, and keeps the defaults.
241
+ return recorded === undefined
242
+ ? `${record.id} was started without ${flag}, so with ${flag} ${byDefault}; leave it out`
243
+ : `${record.id} keeps the ${flag} it was started with; leave ${flag} out`;
244
+ }
245
+ return undefined;
246
+ }
247
+
213
248
  /**
214
249
  * The run's attempts, in order. One that doesn't parse, or is newer than this awf, refuses; one
215
250
  * gone since the folder was listed was a refused attempt removing its own claim.
216
251
  */
217
- export async function readAttempts(run: Run): Promise<AttemptRecord[]> {
252
+ export async function readAttempts(run: Pick<Run, "dir">): Promise<AttemptRecord[]> {
218
253
  const dir = attemptsDir(run.dir);
219
254
  const numbered = await attemptNames(run);
220
255
  const records = await Promise.all(
@@ -405,9 +440,10 @@ export async function replaceStale(
405
440
  /**
406
441
  * Moves a stage's record, unchanged, to `replaced/{stage}.{attempt}.json`, or, for a stage by
407
442
  * content replaced again in the same attempt, `{stage}.{attempt}+{n}.json` from 2: no name has a
408
- * `+`, so neither reads as another stage's.
443
+ * `+`, so neither reads as another stage's. `copy` leaves it in `stages/` for the new record to
444
+ * overwrite, so an attempt that dies between the two leaves the old one, not none.
409
445
  */
410
- export async function replaceRecord(runDir: string, stage: string): Promise<void> {
446
+ export async function replaceRecord(runDir: string, stage: string, copy = false): Promise<void> {
411
447
  const file = join(stagesDir(runDir), `${stage}.json`);
412
448
  const record = await readRecord<StageRecord>(file, STAGE_RECORD_VERSION);
413
449
  const replaced = join(runDir, "replaced");
@@ -416,7 +452,7 @@ export async function replaceRecord(runDir: string, stage: string): Promise<void
416
452
  for (let n = 2; await exists(target); n++) {
417
453
  target = join(replaced, `${stage}.${record.attempt}+${n}.json`);
418
454
  }
419
- await rename(file, target);
455
+ await (copy ? copyFile(file, target) : rename(file, target));
420
456
  }
421
457
 
422
458
  /** Appends a line to the run's event log, `turns.jsonl`, which only the live attempt writes. */
@@ -501,7 +537,7 @@ export async function logSize(runDir: string): Promise<number> {
501
537
  return (await stat(turnsFile(runDir)).catch(() => undefined))?.size ?? 0;
502
538
  }
503
539
 
504
- async function attemptNames(run: Run): Promise<string[]> {
540
+ async function attemptNames(run: Pick<Run, "dir">): Promise<string[]> {
505
541
  return (await entries(attemptsDir(run.dir))).filter((name) => /^[1-9]\d*\.json$/.test(name));
506
542
  }
507
543
 
package/src/sandboxes.ts CHANGED
@@ -7,6 +7,7 @@ import type { SandboxRecord } from "@agentswf/contract/records";
7
7
  import type {
8
8
  AbsoluteDeadline,
9
9
  AgentExecution,
10
+ SandboxEnvironment,
10
11
  SandboxEnvironmentKey,
11
12
  SandboxRef,
12
13
  } from "@agentswf/contract/workflow";
@@ -141,7 +142,7 @@ export class RunSandboxes {
141
142
  return this.options.sandboxes?.run !== undefined;
142
143
  }
143
144
 
144
- /** `workflow.sandboxes.open`. */
145
+ /** `wf.sandboxes.open`. */
145
146
  async open(spec: unknown): Promise<SandboxRef> {
146
147
  if (this.hasRunSandbox) throw new Error(RUN_SANDBOX_ONLY);
147
148
  const key = (spec as { key?: unknown } | null)?.key;
@@ -306,7 +307,7 @@ export class RunSandboxes {
306
307
  network: [...resolved.network],
307
308
  ...(resolved.sockets.length > 0 ? { sockets: [...resolved.sockets] } : {}),
308
309
  ...(resolved.webSearch ? { webSearch: true } : {}),
309
- [provider]: resolved.environment,
310
+ ...({ [provider]: resolved.environment } as SandboxEnvironment),
310
311
  },
311
312
  directory,
312
313
  gitdirs: resolved.gitdirs.map((gitdir) => ({ ...gitdir })),
@@ -19,10 +19,18 @@ type Kind =
19
19
  | { type: "boolean" }
20
20
  | { type: "enum"; values: string[] };
21
21
 
22
- /** Said once in a prompt, under the first block taught with flags, and with every refusal of them. */
22
+ /**
23
+ * Said once in a prompt, under the first block with a free-text flag, and with every refusal of
24
+ * flags that take text. Enum, number and boolean values are shell words as they stand.
25
+ */
23
26
  export const QUOTING_RULE =
24
27
  "Put every value in single quotes; a ' inside a value is written '\\''.";
25
28
 
29
+ /** Whether a flag takes free text, the only value the shell can break. */
30
+ export function takesText(simple: Simple): boolean {
31
+ return simple.some(({ kind }) => kind.type === "string");
32
+ }
33
+
26
34
  const MAX_PROPERTIES = 6;
27
35
  const NAME = /^[a-z][a-z0-9_]*$/;
28
36
  /** An enum value the usage line can show between `|`s and the agent can type as it stands. */
@@ -137,7 +145,7 @@ export type ReadFlags = { ok: true; value: JsonObject } | { ok: false; error: st
137
145
  export type ReadGiven = { ok: true; value: unknown } | { ok: false; error: string };
138
146
 
139
147
  /**
140
- * An input as `wf` sent it, as a value: JSON parsed, flags read by the schema, nothing as `{}`.
148
+ * An input as `wfa` sent it, as a value: JSON parsed, flags read by the schema, nothing as `{}`.
141
149
  * The schema's own check comes after, in `checkGiven`.
142
150
  */
143
151
  export function readGiven(schema: JsonSchema | undefined, given: GivenInput): ReadGiven {
@@ -181,7 +189,9 @@ export function checkGiven(
181
189
  }
182
190
 
183
191
  function expected(simple: Simple): string {
184
- return `expected: ${usageLine(simple)}\n${QUOTING_RULE}`;
192
+ return [`expected: ${usageLine(simple)}`, ...(takesText(simple) ? [QUOTING_RULE] : [])].join(
193
+ "\n",
194
+ );
185
195
  }
186
196
 
187
197
  /**
package/src/sources.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import type { WaitTake } from "@agentswf/contract/records";
2
- import type { JsonValue, WorkflowEvent } from "@agentswf/contract/workflow";
2
+ import { awfEventOf, type JsonValue, type WorkflowEvent } from "@agentswf/contract/workflow";
3
3
 
4
4
  /** What each source the run issued takes: only awf makes a source, so only these are. */
5
5
  const takes = new WeakMap<object, WaitTake>();
6
6
 
7
+ /** Signals: a branch on one hands back the whole event, not its data. */
8
+ const signals = new WeakSet<object>();
9
+
7
10
  /** Marks `value` as a source that takes `take`, and returns it. */
8
11
  export function asSource<T extends object>(value: T, take: WaitTake): T {
9
12
  takes.set(value, take);
@@ -18,8 +21,40 @@ export function source<T extends object>(
18
21
  return asSource(Object.freeze({ ...fields, is: (event: Looked) => matches(event, take) }), take);
19
22
  }
20
23
 
24
+ /** As `source`, for a signal. */
25
+ export function signalOf<T extends object>(fields: T, take: WaitTake): T {
26
+ const made = source(fields, take);
27
+ signals.add(made);
28
+ return made;
29
+ }
30
+
31
+ /** Whether `value` is a signal, whose branch hands back the whole event. */
32
+ export function isSignal(value: unknown): boolean {
33
+ return typeof value === "object" && value !== null && signals.has(value);
34
+ }
35
+
21
36
  type Looked = { name: string; from: string; data?: unknown };
22
37
 
38
+ /**
39
+ * The name the mailbox records an event under: awf's keep `agent:ended` and `participant:reply`,
40
+ * from their key, whatever name the workflow declares and is handed, `{key}:ended` or `{key}:reply`.
41
+ */
42
+ export function recordName(event: { name: string; from: string }): string {
43
+ const awf = awfEventOf(event.name);
44
+ if (!awf || awf.key !== event.from) return event.name;
45
+ return awf.kind === "ended" ? "agent:ended" : "participant:reply";
46
+ }
47
+
48
+ /** `event` under the name the workflow declares for it, where it declares one. */
49
+ export function presented<E extends WorkflowEvent<string, JsonValue>>(
50
+ event: E,
51
+ declared: ReadonlySet<string>,
52
+ ): E {
53
+ const kind = { "agent:ended": "ended", "participant:reply": "reply" }[event.name as string];
54
+ if (kind === undefined || !declared.has(`${event.from}:${kind}`)) return event;
55
+ return Object.freeze({ ...event, name: `${event.from}:${kind}` });
56
+ }
57
+
23
58
  /** The first of `branches` that takes `event`. */
24
59
  export function branchOf(
25
60
  branches: readonly (readonly [string, WaitTake])[],
@@ -33,12 +68,12 @@ export function takeOf(value: unknown): WaitTake | undefined {
33
68
  return typeof value === "object" && value !== null ? takes.get(value) : undefined;
34
69
  }
35
70
 
36
- /** Whether a wait that takes `take` takes `event`. */
71
+ /** Whether a wait that takes `take` takes `event`, under its recorded name or its declared one. */
37
72
  export function matches(
38
73
  event: { name: string; from: string; data?: unknown },
39
74
  take: WaitTake,
40
75
  ): boolean {
41
- if (event.name !== take.name) return false;
76
+ if (recordName(event) !== take.name) return false;
42
77
  if (take.from !== undefined && event.from !== take.from) return false;
43
78
  if (take.turn === undefined) return true;
44
79
  const data = event.data as { turn?: unknown } | null | undefined;
@@ -55,10 +90,11 @@ export function wouldTake(
55
90
 
56
91
  /**
57
92
  * What a branch hands back of the event it took: awf's own events as their data with the event's id
58
- * and arrival, and a workflow's own event whole.
93
+ * and arrival, and a workflow's own event, or any a signal takes, whole.
59
94
  */
60
- export function branchValue(event: WorkflowEvent<string, JsonValue>): unknown {
61
- switch (event.name) {
95
+ export function branchValue(event: WorkflowEvent<string, JsonValue>, whole = false): unknown {
96
+ if (whole) return event;
97
+ switch (recordName(event)) {
62
98
  case "timer:due":
63
99
  case "at:due":
64
100
  case "participant:reply":
@@ -8,7 +8,7 @@ import type { JsonSchema } from "@agentswf/contract/schema";
8
8
  import type { JsonValue, StageOutcome, StageSummary } from "@agentswf/contract/workflow";
9
9
  import type { RunEventBody } from "./run-event-log";
10
10
  import { replaceRecord, replaceStale, stageNameProblem, writeStageRecord } from "./runs";
11
- import { planStage, planStageByContent } from "./stage-plan";
11
+ import { planStage, planStageByContent, type StageRedo } from "./stage-plan";
12
12
  import { placeholderOf } from "./stage-schema";
13
13
  import { primaryFailure, WorkflowStopped } from "./stopped";
14
14
 
@@ -89,6 +89,11 @@ export class StageLedger {
89
89
  /** The run's stage records as they stand, this attempt's included: what a stage by content reuses. */
90
90
  readonly #records: Map<string, StageRecord>;
91
91
 
92
+ /** The redo stale records are judged by: the options', then this attempt's own. */
93
+ #redo: StageRedo | undefined;
94
+ /** Whether this attempt has entered the redo's stage, after which a stale record reruns. */
95
+ #afterRedo = false;
96
+
92
97
  constructor(
93
98
  private readonly options: {
94
99
  runDir: string;
@@ -102,9 +107,11 @@ export class StageLedger {
102
107
  fromStage?: string;
103
108
  /** `--values`: the values of stages before `fromStage` that have no record to reuse. */
104
109
  values?: ReadonlyMap<string, JsonValue>;
110
+ /** A run of a workflow with `events`: the last `--from-stage` an attempt redid. */
111
+ redo?: StageRedo;
105
112
  /**
106
113
  * A run of a workflow with `events`: a stage runs once ever, reused wherever it is entered,
107
- * as `planStageByContent` says; no start point, and no `fromStage` or `values`.
114
+ * as `planStageByContent` says, and no `values`. Its start point is `fromStage` alone.
108
115
  */
109
116
  byContent?: boolean;
110
117
  /** Writes the run's event log. */
@@ -113,6 +120,7 @@ export class StageLedger {
113
120
  },
114
121
  ) {
115
122
  this.#records = new Map(options.records);
123
+ this.#redo = options.redo;
116
124
  }
117
125
 
118
126
  /** The stage open now, if one is. */
@@ -174,7 +182,7 @@ export class StageLedger {
174
182
  }
175
183
 
176
184
  /**
177
- * `workflow.stop`, called in `stage` or between stages: what to throw. The first stop is kept,
185
+ * `wf.stop`, called in `stage` or between stages: what to throw. The first stop is kept,
178
186
  * and any later stop throws it again; once it was found caught, that instead. After the
179
187
  * workflow's body ended, a stop ends nothing.
180
188
  */
@@ -313,10 +321,23 @@ export class StageLedger {
313
321
 
314
322
  /**
315
323
  * Enters a stage by content: reused from its succeeded record, this attempt's included, run when
316
- * it has none or a failed one, which its own record then replaces.
324
+ * it has none or a failed one, which its own record then replaces. After the redo's stage in this
325
+ * attempt, a record older than the redo is stale and runs again: see `StageRedo`.
317
326
  */
318
327
  async #enterByContent(name: string, entry: StageEntry, at: number): Promise<EnteredStage> {
319
- const decision = planStageByContent(this.#records.get(name), entry.result);
328
+ if (!this.#started && name === this.options.fromStage) return this.#startByContent(name, at);
329
+ const record = this.#records.get(name);
330
+ const redo = this.#redo;
331
+ // Entered already in this attempt, it has its value here: one stage, one value.
332
+ const stale =
333
+ redo !== undefined &&
334
+ this.#afterRedo &&
335
+ record !== undefined &&
336
+ record.attempt < redo.attempt &&
337
+ !this.#stages.has(name);
338
+ if (name === redo?.stage) this.#afterRedo = true;
339
+ if (stale) return { kind: "run", stage: this.#opened(name, at) };
340
+ const decision = planStageByContent(record, entry.result);
320
341
  if (decision.kind === "stop") {
321
342
  this.#stopped = new WorkflowStopped(decision.reason, name, true);
322
343
  this.#stoppedAt(name, at);
@@ -347,6 +368,20 @@ export class StageLedger {
347
368
  return { kind: "run", stage: this.#opened(name, at) };
348
369
  }
349
370
 
371
+ /**
372
+ * `--from-stage` reached by content: it runs, and this attempt is the redo. Its old record moves to
373
+ * `replaced/` only as its new one is written, as a failed record's does, so an attempt that dies
374
+ * while it runs leaves the old one, and the redo, which nothing then shows, is issued again. The
375
+ * stages after it rerun as they are entered, not all at once: one never entered keeps its record.
376
+ */
377
+ #startByContent(name: string, at: number): EnteredStage {
378
+ const stage = this.#opened(name, at);
379
+ this.#started = true;
380
+ this.#redo = { stage: name, attempt: this.options.attempt };
381
+ this.#afterRedo = true;
382
+ return { kind: "run", stage };
383
+ }
384
+
350
385
  /** A stage the plan stopped as it was entered: shown stopped, though it ran nothing. */
351
386
  #stoppedAt(name: string, at: number): void {
352
387
  this.options.event?.({ kind: "stage-ended", stage: name, outcome: "stopped" }, at);
@@ -506,14 +541,9 @@ export class StageLedger {
506
541
  });
507
542
  // By content, a failed record this one replaces is kept, as a redone stage's is.
508
543
  const replacing = this.options.byContent && this.#records.has(name);
509
- ended = (
510
- replacing
511
- ? replaceRecord(this.options.runDir, name).then(() => {
512
- // Moved: a write that fails now leaves nothing in `stages/` to move again.
513
- this.#records.delete(name);
514
- })
515
- : Promise.resolve()
516
- )
544
+ // Copied, then overwritten: dying between the two leaves the old record, as dying while the
545
+ // stage runs does, never a redo's stage without one and stale records after it.
546
+ ended = (replacing ? replaceRecord(this.options.runDir, name, true) : Promise.resolve())
517
547
  .then(() => writeStageRecord(this.options.runDir, record))
518
548
  .then(
519
549
  () => {