@agentswf/engine 0.1.0-beta.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/package.json +13 -9
  4. package/src/accounting/format.ts +4 -3
  5. package/src/accounting/spent.ts +128 -0
  6. package/src/accounting/summary.ts +40 -32
  7. package/src/agent-cli/cli.ts +28 -55
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +4 -4
  11. package/src/attempt-output.ts +13 -31
  12. package/src/attempt-view.ts +33 -32
  13. package/src/author-typebox.ts +13 -0
  14. package/src/client/checks.ts +94 -0
  15. package/src/client/exported.ts +17 -0
  16. package/src/client/index.ts +122 -0
  17. package/src/client/locate.ts +34 -0
  18. package/src/client/published.ts +3 -0
  19. package/src/client/runs.ts +337 -0
  20. package/src/command-line.ts +64 -0
  21. package/src/commands.ts +7 -1
  22. package/src/control-plane.ts +4 -6
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +27 -28
  25. package/src/deadlines.ts +6 -40
  26. package/src/decisions/directory.ts +50 -37
  27. package/src/decisions/fake.ts +1 -1
  28. package/src/decisions/openrouter.ts +4 -2
  29. package/src/decisions/seam.ts +4 -4
  30. package/src/display-path.ts +3 -3
  31. package/src/duration.ts +19 -33
  32. package/src/emit-command.ts +38 -22
  33. package/src/emit.ts +148 -59
  34. package/src/event-ids.ts +31 -0
  35. package/src/files.ts +44 -4
  36. package/src/hand-over.ts +150 -0
  37. package/src/help/allowance.txt +4 -3
  38. package/src/help/emit.txt +11 -7
  39. package/src/help/inbox.txt +5 -3
  40. package/src/help/logs.txt +7 -5
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +13 -8
  44. package/src/help/runs.txt +4 -2
  45. package/src/help/tick.txt +9 -4
  46. package/src/herdr-placement.ts +3 -33
  47. package/src/herdr-run-session.ts +5 -13
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +54 -136
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +42 -56
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.ts.template +3 -2
  55. package/src/jsonl.ts +10 -6
  56. package/src/keep-alive.ts +7 -19
  57. package/src/logs-command.ts +134 -53
  58. package/src/message-operation.ts +6 -3
  59. package/src/messaging/messaging.ts +8 -10
  60. package/src/messaging/text.ts +2 -2
  61. package/src/open-command.ts +770 -0
  62. package/src/operation-events.ts +4 -6
  63. package/src/operation-liveness.ts +19 -4
  64. package/src/operator-cli.ts +206 -90
  65. package/src/operator-runtime.ts +24 -2
  66. package/src/ops.ts +270 -0
  67. package/src/pane-layout.ts +49 -96
  68. package/src/progress-view.ts +2 -2
  69. package/src/project-engine.ts +79 -0
  70. package/src/projects.ts +13 -2
  71. package/src/refusals.ts +119 -0
  72. package/src/result-slots.ts +11 -31
  73. package/src/result-validation.ts +2 -2
  74. package/src/run-command.ts +27 -31
  75. package/src/run-commands.ts +42 -114
  76. package/src/run-event-log.ts +1 -1
  77. package/src/run-lines.ts +159 -0
  78. package/src/run-lock.ts +57 -1
  79. package/src/run-policy/stage-plan.ts +154 -0
  80. package/src/run-policy/stages.ts +113 -0
  81. package/src/run-policy/waits.ts +452 -0
  82. package/src/run-prepare.ts +165 -41
  83. package/src/run-read.ts +109 -41
  84. package/src/run-state.ts +170 -50
  85. package/src/run-text.ts +54 -0
  86. package/src/run-unreachable.ts +18 -0
  87. package/src/run-usage.ts +17 -11
  88. package/src/run-view.ts +73 -14
  89. package/src/runs-command.ts +81 -63
  90. package/src/runs.ts +284 -222
  91. package/src/sandbox-homes.ts +65 -13
  92. package/src/sandboxes.ts +182 -28
  93. package/src/session-lines.ts +93 -0
  94. package/src/skills/run-skills.ts +8 -1
  95. package/src/sources.ts +2 -39
  96. package/src/spend.ts +333 -0
  97. package/src/stage-ledger.ts +111 -153
  98. package/src/stop-crashed.ts +6 -5
  99. package/src/stopped.ts +4 -4
  100. package/src/test-command.ts +37 -19
  101. package/src/testing/operation-events.ts +3 -23
  102. package/src/testing.ts +66 -18
  103. package/src/tick-command.ts +145 -15
  104. package/src/tools.ts +11 -30
  105. package/src/turn-ends.ts +37 -0
  106. package/src/waits.ts +651 -0
  107. package/src/wake.ts +84 -54
  108. package/src/workflow-loader.ts +49 -2
  109. package/src/workflow-runner.ts +525 -618
  110. package/src/workflow-test-preload.ts +4 -1
  111. package/src/workflow-testing/coordinators.ts +1 -1
  112. package/src/workflow-testing/fake-client.ts +387 -0
  113. package/src/workflow-testing/host.ts +28 -29
  114. package/src/workflow-testing/index.ts +9 -27
  115. package/src/workflow-testing/run-test.ts +201 -286
  116. package/src/workflow-testing/script.ts +7 -39
  117. package/src/deprecations.ts +0 -177
  118. package/src/hold-run-lock.fixture.ts +0 -8
  119. package/src/mailbox.ts +0 -972
  120. package/src/stage-plan.ts +0 -186
  121. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  122. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -67
@@ -1,13 +1,24 @@
1
1
  import { statSync } from "node:fs";
2
+ import { unlink } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
- import { type AttemptRecord, RUN_EVENT_VERSIONS, type RunPanes } from "@agentswf/contract/records";
5
+ import {
6
+ type ClaimedAttempt,
7
+ type RefusalCode,
8
+ RUN_EVENT_VERSIONS,
9
+ type RunPanes,
10
+ } from "@agentswf/contract/records";
5
11
  import { isEventWorkflow, type JsonValue } from "@agentswf/contract/workflow";
6
12
  import { refusalMessage } from "./attempt-view";
7
13
  import { messageOf } from "./errors";
8
14
  import { exists } from "./files";
9
- import { projectOf, projectRunRoot } from "./projects";
15
+ import { type EarlierOps, listOps } from "./ops";
16
+ import { EngineRefused } from "./project-engine";
17
+ import { projectOf, projectRunRoot, recordProject } from "./projects";
10
18
  import type { RunCommand } from "./run-command";
19
+ import { claimRunLines, type RunLines, readRunLines } from "./run-lines";
20
+ import { crashedRedo } from "./run-policy/stages";
21
+ import { isSystemError, RunUnreachable } from "./run-unreachable";
11
22
  import {
12
23
  type Attempt,
13
24
  appendEvent,
@@ -15,15 +26,16 @@ import {
15
26
  checkContinue,
16
27
  checkFree,
17
28
  claimAttempt,
18
- endTurnsLine,
29
+ endLogLine,
19
30
  generateId,
20
31
  idProblem,
32
+ launchLog,
33
+ openedRefusal,
21
34
  openRun,
22
35
  previousGone,
23
36
  type Run,
24
37
  RunRefused,
25
38
  readAttempts,
26
- readStageRecords,
27
39
  readTurns,
28
40
  } from "./runs";
29
41
  import {
@@ -38,7 +50,12 @@ export type PreparedRun = {
38
50
  /** A new run's id, from `--id` or the workflow's `id(args)`; absent for one to generate. */
39
51
  id?: string;
40
52
  /** The run `--continue` names, its earlier attempts, and what to warn of before it goes on. */
41
- continued?: { run: Run; attempts: AttemptRecord[]; warnings: string[] };
53
+ continued?: { run: Run; attempts: ClaimedAttempt[]; warnings: string[] };
54
+ /**
55
+ * The `--from-stage` a continue goes on with though none was given: that of the attempt before
56
+ * it, which crashed inside its start stage.
57
+ */
58
+ resumes?: string;
42
59
  };
43
60
 
44
61
  /** `awf run --id` of a workflow with events, which starts the run detached, or wakes it. */
@@ -59,25 +76,26 @@ export async function loadAndPrepare(
59
76
  home: string = homedir(),
60
77
  ): Promise<
61
78
  | { loaded: LoadedWorkflow; prepared: PreparedRun; command: RunCommand }
62
- | { refused: string }
79
+ | { refused: string; code: RefusalCode }
63
80
  | { opens: OpensEventRun }
64
81
  > {
65
82
  let loaded: LoadedWorkflow;
66
83
  try {
67
- loaded = await loadWorkflowFile(given.workflowFile, given.shellCwd);
84
+ loaded = await loadWorkflowFile(given.workflowFile, given.shellCwd, home);
68
85
  } catch (error) {
69
- return { refused: `load: ${messageOf(error)}` };
86
+ const code = error instanceof EngineRefused ? error.code : "file";
87
+ return { refused: `load: ${messageOf(error)}`, code };
70
88
  }
71
89
  let command = given;
72
90
  if (isEventWorkflow(loaded.workflow)) {
73
91
  const refused = eventRunRefusal(command);
74
- if (refused) return { refused };
92
+ if (refused) return refused;
75
93
  // An event run lives and works in its file's project, whoever starts or continues it (Q15).
76
94
  let project: string;
77
95
  try {
78
96
  project = await projectOf(loaded.file, home);
79
97
  } catch (error) {
80
- return { refused: messageOf(error) };
98
+ return { refused: messageOf(error), code: "project" };
81
99
  }
82
100
  if (!command.runRootGiven) command = { ...command, runRoot: projectRunRoot(project) };
83
101
  if (command.continueId === undefined) {
@@ -85,7 +103,7 @@ export async function loadAndPrepare(
85
103
  try {
86
104
  const id =
87
105
  command.id ??
88
- derivedId(loaded.workflow, prepareArgs(loaded.workflow, command.workflowArgs, project)) ??
106
+ derivedId(loaded.workflow, refusedArgs(loaded.workflow, command.workflowArgs, project)) ??
89
107
  // Without starts no sender names its runs, so one may be generated, as a plain run's is.
90
108
  (loaded.workflow.starts === undefined
91
109
  ? await unusedId(command.runRoot, loaded.workflow.meta.name, now)
@@ -93,6 +111,12 @@ export async function loadAndPrepare(
93
111
  if (id === undefined)
94
112
  return {
95
113
  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`,
114
+ code: "no-id",
115
+ };
116
+ if (command.json)
117
+ return {
118
+ refused: `a run of ${loaded.file}, which starts on events, opens detached and prints no document: leave out --json, or start it with an event with awf emit --json`,
119
+ code: "usage",
96
120
  };
97
121
  return {
98
122
  opens: {
@@ -103,14 +127,57 @@ export async function loadAndPrepare(
103
127
  },
104
128
  };
105
129
  } catch (error) {
106
- return { refused: `${error instanceof RunRefused ? "" : "prepare: "}${messageOf(error)}` };
130
+ return prepareRefused(error);
107
131
  }
108
132
  }
109
133
  }
134
+ if (!isEventWorkflow(loaded.workflow) && loaded.workflow.file !== undefined) {
135
+ // A plain workflow that gives `file` keeps its runs in its project, registered, so a run a
136
+ // program started is continued from anywhere (ADR 0016); only the run root moves.
137
+ let project: string;
138
+ try {
139
+ project = await projectOf(loaded.file, home);
140
+ } catch (error) {
141
+ return { refused: messageOf(error), code: "project" };
142
+ }
143
+ if (!command.runRootGiven) command = { ...command, runRoot: projectRunRoot(project) };
144
+ try {
145
+ if (command.continueId === undefined && !command.runRootGiven)
146
+ await recordProject(home, project);
147
+ } catch (error) {
148
+ return { refused: messageOf(error), code: "internal-error" };
149
+ }
150
+ }
151
+ try {
152
+ const prepared = await prepareRun(command, loaded, now);
153
+ const { resumes } = prepared;
154
+ return {
155
+ loaded,
156
+ prepared,
157
+ command: resumes === undefined ? command : { ...command, fromStage: resumes },
158
+ };
159
+ } catch (error) {
160
+ return prepareRefused(error);
161
+ }
162
+ }
163
+
164
+ /** A refusal while preparing: the run's own, or the workflow's `prepare` refusing its argv. */
165
+ function prepareRefused(error: unknown): { refused: string; code: RefusalCode } {
166
+ // Preparing reads the run, when it is continued, and writes nothing: `readFailureCode`'s reading.
167
+ if (error instanceof RunRefused || error instanceof RunUnreachable)
168
+ return { refused: messageOf(error), code: error.code };
169
+ return {
170
+ refused: messageOf(error),
171
+ code: isSystemError(error) ? "unreachable" : "internal-error",
172
+ };
173
+ }
174
+
175
+ /** `prepareArgs`, its refusal of the argv a `RunRefused`, so a failure of awf's isn't one. */
176
+ function refusedArgs(workflow: AnyWorkflow, argv: readonly string[], cwd: string): JsonValue {
110
177
  try {
111
- return { loaded, prepared: await prepareRun(command, loaded, now), command };
178
+ return prepareArgs(workflow, argv, cwd);
112
179
  } catch (error) {
113
- return { refused: `${error instanceof RunRefused ? "" : "prepare: "}${messageOf(error)}` };
180
+ throw new RunRefused(`prepare: ${messageOf(error)}`, "prepare");
114
181
  }
115
182
  }
116
183
 
@@ -125,7 +192,7 @@ async function prepareRun(
125
192
  ): Promise<PreparedRun> {
126
193
  const { workflow, file } = loaded;
127
194
  const { meta } = workflow;
128
- const prepare = (argv: readonly string[], cwd: string) => prepareArgs(workflow, argv, cwd);
195
+ const prepare = (argv: readonly string[], cwd: string) => refusedArgs(workflow, argv, cwd);
129
196
  if (command.continueId === undefined) {
130
197
  const args = prepare(command.workflowArgs, command.cwd);
131
198
  const id = command.id ?? derivedId(workflow, args);
@@ -141,30 +208,51 @@ async function prepareRun(
141
208
  });
142
209
  const { id, argv, cwd } = run.record;
143
210
  if (!statSync(cwd, { throwIfNoEntry: false })?.isDirectory()) {
144
- throw new RunRefused(`${id} works in ${cwd}, which is no longer a directory`);
211
+ throw new RunRefused(`${id} works in ${cwd}, which is no longer a directory`, "not-started");
145
212
  }
146
213
  let args: JsonValue;
147
214
  try {
148
- args = prepare(argv, cwd);
215
+ // A run a program started runs with the args it was given, never prepared (ADR 0016).
216
+ args = run.record.args !== undefined ? run.record.args : prepareArgs(workflow, argv, cwd);
149
217
  } catch (error) {
150
218
  throw new RunRefused(
151
219
  `the recorded argv of ${id} no longer parses with ${file}: ${messageOf(error)}; start a new run`,
220
+ "prepare",
152
221
  );
153
222
  }
154
223
  const attempts = await readAttempts(run);
155
- const records = await readStageRecords(run.dir);
224
+ const { view } = await readRunLines(run.dir);
156
225
  // The claim refuses it too; this says so before `--here` opens a tab, or `--session` waits.
157
- const refused = attemptRefusal(attempts, records, { fromStage: command.fromStage !== undefined });
158
- if (refused) throw new RunRefused(refusalMessage(id, refused, now));
159
- const { fromStage } = command;
226
+ const refused =
227
+ attemptRefusal(attempts, view, { fromStage: command.fromStage !== undefined }) ??
228
+ (await openedRefusal(run));
229
+ if (refused) throw new RunRefused(refusalMessage(id, refused, now), "not-started");
230
+ // A plain run's redo that crashed inside its stage wrote no line, so nothing says it: it goes on
231
+ // from there, as 018 has a crash mid-stage do. A wake carries no flag, so an event run's is lost.
232
+ const resumes =
233
+ command.fromStage === undefined && !isEventWorkflow(workflow)
234
+ ? crashedRedo(attempts, view)
235
+ : undefined;
236
+ const fromStage = command.fromStage ?? resumes;
160
237
  // Once per start point: a run started at a stage goes on to it attempt after attempt.
161
238
  const unrecorded =
162
239
  fromStage !== undefined &&
163
- !records.has(fromStage) &&
240
+ view.stages[fromStage] === undefined &&
164
241
  attempts.at(-1)?.flags.fromStage !== fromStage
165
242
  ? [`awf: nothing is recorded for ${fromStage}; the attempt stops if it never reaches it`]
166
243
  : [];
167
- return { args, continued: { run, attempts, warnings: unrecorded } };
244
+ const warnings =
245
+ resumes === undefined
246
+ ? unrecorded
247
+ : [
248
+ `awf: attempt ${attempts.at(-1)?.attempt} of ${id} ended without an ending inside --from-stage ${resumes}; this attempt starts there again`,
249
+ ...unrecorded,
250
+ ];
251
+ return {
252
+ args,
253
+ continued: { run, attempts, warnings },
254
+ ...(resumes === undefined ? {} : { resumes }),
255
+ };
168
256
  }
169
257
 
170
258
  /** The workflow's args from `argv`, as its `prepare` makes them, checked to be JSON. */
@@ -186,23 +274,30 @@ async function unusedId(runRoot: string, workflow: string, now: number): Promise
186
274
  for (let tries = 1; ; tries += 1) {
187
275
  const id = generateId(new Date(now));
188
276
  if (!(await exists(join(runRoot, workflow, id)))) return id;
189
- if (tries >= 5) throw new RunRefused("every id generated was taken; --id gives one");
277
+ if (tries >= 5) throw new RunRefused("every id generated was taken; --id gives one", "id");
190
278
  }
191
279
  }
192
280
 
193
281
  /**
194
282
  * Why `awf run` doesn't take a workflow with `events` as asked: every attempt after the first is
195
283
  * 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.
284
+ * is the one attempt's, and its stage line keeps what it redid.
197
285
  */
198
- function eventRunRefusal(command: RunCommand): string | undefined {
286
+ function eventRunRefusal(command: RunCommand): { refused: string; code: RefusalCode } | undefined {
287
+ const usage = (refused: string) => ({ refused, code: "usage" as const });
199
288
  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";
289
+ return usage(
290
+ "a run of a workflow with events takes no --values: before --from-stage, a stage with no record runs",
291
+ );
201
292
  if (command.fromStage !== undefined && command.continueId === undefined)
202
- return "a run of a workflow with events takes --from-stage only with --continue";
293
+ return usage("a run of a workflow with events takes --from-stage only with --continue");
203
294
  if (command.continueId !== undefined) {
295
+ // Refused as a plain run's continue with arguments is.
204
296
  if (command.workflowArgs.length > 0)
205
- return "a continue takes no arguments: it goes on with those its run was started with";
297
+ return {
298
+ refused: "a continue takes no arguments: it goes on with those its run was started with",
299
+ code: "other-args",
300
+ };
206
301
  return undefined;
207
302
  }
208
303
  const unkept = [
@@ -212,7 +307,9 @@ function eventRunRefusal(command: RunCommand): string | undefined {
212
307
  command.runRootGiven ? "--run-root" : undefined,
213
308
  ].filter((flag) => flag !== undefined);
214
309
  if (unkept.length > 0)
215
- return `a run of a workflow with events runs in its file's project, as every wake starts it: it takes no ${unkept.join(", ")}`;
310
+ return usage(
311
+ `a run of a workflow with events runs in its file's project, as every wake starts it: it takes no ${unkept.join(", ")}`,
312
+ );
216
313
  return undefined;
217
314
  }
218
315
 
@@ -223,23 +320,32 @@ function derivedId(workflow: AnyWorkflow, args: JsonValue): string | undefined {
223
320
  try {
224
321
  id = workflow.id(args);
225
322
  } catch (error) {
226
- throw new RunRefused(`the workflow's id(args) failed: ${messageOf(error)}; --id gives one`);
323
+ throw new RunRefused(
324
+ `the workflow's id(args) failed: ${messageOf(error)}; --id gives one`,
325
+ "prepare",
326
+ );
227
327
  }
228
328
  const problem =
229
329
  typeof id === "string" ? idProblem(id) : `it returned a ${typeof id}, not a string`;
230
- if (problem) throw new RunRefused(`the workflow's id(args): ${problem}; --id gives one`);
330
+ if (problem) throw new RunRefused(`the workflow's id(args): ${problem}; --id gives one`, "id");
231
331
  return id as string;
232
332
  }
233
333
 
234
- /** Claims the run's next attempt, naming each earlier one found interrupted. */
334
+ /**
335
+ * Claims the run's next attempt, naming each earlier one found interrupted. `launch`: the
336
+ * background launch that started this process, recorded only when it is this run's, its
337
+ * `launches/{launch}.log` there.
338
+ */
235
339
  export async function claimNext(
236
340
  run: Run,
237
341
  loaded: LoadedWorkflow,
238
342
  command: RunCommand,
239
343
  notice: (text: string) => void,
240
344
  now: number,
345
+ launch?: string,
241
346
  ): Promise<Attempt> {
242
347
  const { meta } = loaded.workflow;
348
+ const ours = launch !== undefined && (await exists(launchLog(run.dir, launch)));
243
349
  const claimed = await claimAttempt(
244
350
  run,
245
351
  {
@@ -249,23 +355,41 @@ export async function claimNext(
249
355
  ...(command.timeout === undefined ? {} : { timeout: command.timeout }),
250
356
  ...(command.fromStage === undefined ? {} : { fromStage: command.fromStage }),
251
357
  },
358
+ ...(ours ? { launch } : {}),
252
359
  },
253
360
  { fromStage: command.fromStage !== undefined },
254
361
  );
255
362
  if ("refused" in claimed) {
256
- throw new RunRefused(refusalMessage(run.record.id, claimed.refused, now));
363
+ throw new RunRefused(refusalMessage(run.record.id, claimed.refused, now), "not-started");
364
+ }
365
+ // Claimed first, which `wake` waits for holding the run's lock, then waited on before this
366
+ // attempt writes anything, so the attempt before it has finished its own records and two never
367
+ // share an agent's session.
368
+ const n = claimed.attempt.record.attempt;
369
+ await previousGone(run, n, notice);
370
+ // Numbered on from what earlier attempts left, now that none still writes: an id is never reused.
371
+ let ops: EarlierOps;
372
+ try {
373
+ ops = await listOps(run.dir);
374
+ } catch (error) {
375
+ await unlink(claimed.attempt.file).catch(() => undefined);
376
+ throw new Error(`ops/ was not listed: ${messageOf(error)}`, { cause: error });
377
+ }
378
+ let lines: RunLines;
379
+ try {
380
+ lines = await claimRunLines(run.dir, n, claimed.attempt.record.started);
381
+ } catch (error) {
382
+ // Without its fence the attempt may not write: it goes, as a refused claim's does.
383
+ await unlink(claimed.attempt.file).catch(() => undefined);
384
+ throw new Error(`run.jsonl was not claimed: ${messageOf(error)}`, { cause: error });
257
385
  }
258
- // An event run's: claimed first, which `wake` waits for holding the run's lock, then waited on
259
- // before this attempt writes anything, so the attempt before it has finished its own records and
260
- // two never share an agent's session.
261
- if (run.record.events) await previousGone(run, claimed.attempt.record.attempt, notice);
262
386
  // A crash may have torn the last line an earlier attempt appended; this attempt's first line
263
387
  // must not be glued to it.
264
- await endTurnsLine(run.dir)
388
+ await endLogLine(run.dir)
265
389
  .then(() =>
266
390
  appendEvent(run.dir, {
267
391
  version: RUN_EVENT_VERSIONS["attempt-started"],
268
- attempt: claimed.attempt.record.attempt,
392
+ attempt: n,
269
393
  at: claimed.attempt.record.started,
270
394
  kind: "attempt-started",
271
395
  }),
@@ -279,7 +403,7 @@ export async function claimNext(
279
403
  `awf: attempt ${earlier.attempt} of ${run.record.id} was interrupted${stage ? ` in ${stage}` : ""}; its panes may still be open in Herdr workspace "${workspaceLabel(meta.name, run.record.id, earlier.attempt)}"`,
280
404
  );
281
405
  }
282
- return claimed.attempt;
406
+ return { ...claimed.attempt, lines, ops };
283
407
  }
284
408
 
285
409
  /** A run's Herdr workspace, which names the attempt: a dead one's may still be open. */