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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/package.json +5 -6
  2. package/src/agent-cli/cli.ts +571 -0
  3. package/src/agent-cli/client.ts +115 -0
  4. package/src/agent-launcher.ts +15 -25
  5. package/src/allowance-command.ts +31 -21
  6. package/src/attempt-ending.ts +23 -5
  7. package/src/attempt-output.ts +48 -18
  8. package/src/attempt-view.ts +25 -15
  9. package/src/commands.ts +176 -0
  10. package/src/control-plane.ts +73 -22
  11. package/src/coordinator-port.ts +29 -0
  12. package/src/coordinator-prompt.ts +237 -0
  13. package/src/coordinator-socket.ts +236 -0
  14. package/src/deadlines.ts +47 -10
  15. package/src/decisions/directory.ts +8 -6
  16. package/src/duration.ts +29 -0
  17. package/src/emit-command.ts +119 -0
  18. package/src/emit.ts +246 -0
  19. package/src/files.ts +11 -1
  20. package/src/help/allowance.txt +13 -0
  21. package/src/help/emit.txt +13 -0
  22. package/src/help/inbox.txt +12 -0
  23. package/src/help/init.txt +12 -0
  24. package/src/help/logs.txt +13 -0
  25. package/src/help/reply.txt +18 -0
  26. package/src/help/run.txt +44 -0
  27. package/src/help/runs.txt +11 -0
  28. package/src/help/test.txt +9 -0
  29. package/src/help/tick.txt +12 -0
  30. package/src/here.ts +9 -3
  31. package/src/hold-run-lock.fixture.ts +8 -0
  32. package/src/holder-command.ts +279 -0
  33. package/src/inbox-command.ts +715 -0
  34. package/src/init-command.ts +29 -11
  35. package/src/init-template/AGENTS.md.template +4 -4
  36. package/src/init-template/hello/workflow.test.ts.template +2 -2
  37. package/src/init-template/hello/workflow.ts.template +16 -20
  38. package/src/keep-alive.ts +40 -0
  39. package/src/logs-command.ts +106 -35
  40. package/src/machine.ts +12 -1
  41. package/src/mailbox.ts +990 -0
  42. package/src/message-operation.ts +26 -3
  43. package/src/messaging/limits.ts +1 -1
  44. package/src/messaging/messaging.ts +478 -85
  45. package/src/messaging/routes.ts +11 -4
  46. package/src/messaging/text.ts +25 -22
  47. package/src/operation-liveness.ts +55 -9
  48. package/src/operator-cli.ts +290 -119
  49. package/src/operator-runtime.ts +8 -8
  50. package/src/progress-view.ts +49 -7
  51. package/src/projects.ts +57 -0
  52. package/src/result-slots.ts +43 -19
  53. package/src/result-validation.ts +18 -27
  54. package/src/run-command.ts +29 -66
  55. package/src/run-commands.ts +13 -2
  56. package/src/run-lock.ts +123 -0
  57. package/src/run-prepare.ts +94 -22
  58. package/src/run-state.ts +125 -6
  59. package/src/run-usage.ts +2 -2
  60. package/src/run-view.ts +0 -9
  61. package/src/runs-command.ts +43 -25
  62. package/src/runs.ts +142 -23
  63. package/src/sandboxes.ts +11 -4
  64. package/src/simple-input.ts +268 -0
  65. package/src/socket-exchange.ts +26 -0
  66. package/src/sources.ts +70 -0
  67. package/src/stage-ledger.ts +89 -14
  68. package/src/stage-plan.ts +23 -0
  69. package/src/stop-crashed.ts +39 -0
  70. package/src/test-command.ts +34 -11
  71. package/src/testing.ts +72 -4
  72. package/src/tick-command.ts +76 -0
  73. package/src/tools.ts +645 -0
  74. package/src/wake.ts +264 -0
  75. package/src/workflow-loader.ts +32 -22
  76. package/src/workflow-runner.ts +1277 -97
  77. package/src/workflow-testing/coordinator.typecheck.ts +54 -0
  78. package/src/workflow-testing/coordinators.ts +137 -0
  79. package/src/workflow-testing/host.ts +67 -7
  80. package/src/workflow-testing/index.ts +488 -34
  81. package/src/workflow-testing/script.ts +74 -4
  82. package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
@@ -0,0 +1,123 @@
1
+ import { mkdir, readFile, unlink } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { isCode, linkNew } from "./files";
4
+ import { probeProcess, processStart, sameStart } from "./runs";
5
+
6
+ /**
7
+ * `{root}/{workflow}/{run id}.lock`: the lock on a run, beside its run's folder so it exists before
8
+ * the run does. Every append to the run's mailbox, and each decision made from it, holds it.
9
+ */
10
+ export function runLockFile(runRoot: string, workflow: string, runId: string): string {
11
+ return join(runRoot, workflow, `${runId}.lock`);
12
+ }
13
+
14
+ type Holder = { pid: number; processStart: string; at: string };
15
+
16
+ let own: string | undefined;
17
+ /** This process's start, asked of `ps` once. */
18
+ function ownStart(): string | undefined {
19
+ own ??= processStart(process.pid);
20
+ return own;
21
+ }
22
+
23
+ /** Callers in this process queue for a lock here before any of them links the file. */
24
+ const queues = new Map<string, Promise<void>>();
25
+
26
+ /** How long a caller tries before it gives up: a holder that never lets go is gone or wedged. */
27
+ const GIVE_UP_MILLISECONDS = 120_000;
28
+
29
+ /**
30
+ * Runs `work` holding the run's lock: claimed by linking its file into place, which fails while
31
+ * another holds it, and taken over when the process that holds it is gone. Callers in one process
32
+ * take it in turn. A lock is not reentrant: `work` that takes the same lock again waits on itself.
33
+ */
34
+ export async function withRunLock<T>(file: string, work: () => Promise<T>): Promise<T> {
35
+ const previous = queues.get(file) ?? Promise.resolve();
36
+ const turn = Promise.withResolvers<void>();
37
+ const mine = previous.then(() => turn.promise);
38
+ queues.set(file, mine);
39
+ try {
40
+ await previous;
41
+ const held = await claim(file);
42
+ try {
43
+ return await work();
44
+ } finally {
45
+ // Ours to remove only while it is still ours: one taken over meanwhile is its taker's.
46
+ if (JSON.stringify(await readHolder(file)) === JSON.stringify(held))
47
+ await unlink(file).catch(() => undefined);
48
+ }
49
+ } finally {
50
+ turn.resolve();
51
+ if (queues.get(file) === mine) queues.delete(file);
52
+ }
53
+ }
54
+
55
+ async function claim(file: string): Promise<Holder> {
56
+ const started = ownStart();
57
+ if (started === undefined) throw new Error(`no start time for this process (${process.pid})`);
58
+ const mine: Holder = { pid: process.pid, processStart: started, at: new Date().toISOString() };
59
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
60
+ const giveUp = Date.now() + GIVE_UP_MILLISECONDS;
61
+ let wait = 5;
62
+ let lastLook = 0;
63
+ for (;;) {
64
+ if (await linkNew(file, mine)) return mine;
65
+ // Asking `ps` costs a process: whether the holder is gone is asked now and then, not each try.
66
+ if (Date.now() - lastLook > 250) {
67
+ lastLook = Date.now();
68
+ const holder = await readHolder(file);
69
+ if (holder === "gone") continue;
70
+ if (!holder || gone(holder)) {
71
+ await takeOver(file, holder);
72
+ continue;
73
+ }
74
+ }
75
+ if (Date.now() > giveUp)
76
+ throw new Error(`${file} is still held after ${GIVE_UP_MILLISECONDS / 1000} s`);
77
+ await Bun.sleep(wait);
78
+ wait = Math.min(wait * 2, 100);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Whether a holder's process is gone. Where `ps` can't be asked, it is taken as alive: taking a
84
+ * live holder's lock over would let two writers in.
85
+ */
86
+ function gone(holder: Holder): boolean {
87
+ const now = probeProcess(holder.pid);
88
+ if (now === "unknown") return false;
89
+ return now === "gone" || !sameStart(now, holder.processStart);
90
+ }
91
+
92
+ /** The holder as its file says, `gone` when the file went meanwhile, undefined when it is torn. */
93
+ async function readHolder(file: string): Promise<Holder | "gone" | undefined> {
94
+ let text: string;
95
+ try {
96
+ text = await readFile(file, "utf8");
97
+ } catch (error) {
98
+ if (isCode(error, "ENOENT")) return "gone";
99
+ throw error;
100
+ }
101
+ try {
102
+ const value = JSON.parse(text) as Partial<Holder>;
103
+ return typeof value.pid === "number" && typeof value.processStart === "string"
104
+ ? (value as Holder)
105
+ : undefined;
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Removes a lock its holder left behind, if it is still the one read: another taker may have
113
+ * replaced it since. The window between the second read and the unlink is the one race left; a
114
+ * holder that died is rare, and two takers at that instant rarer.
115
+ */
116
+ async function takeOver(file: string, stale: Holder | undefined): Promise<void> {
117
+ const now = await readHolder(file);
118
+ if (now === "gone") return;
119
+ if (JSON.stringify(now) !== JSON.stringify(stale)) return;
120
+ await unlink(file).catch((error: unknown) => {
121
+ if (!isCode(error, "ENOENT")) throw error;
122
+ });
123
+ }
@@ -1,8 +1,10 @@
1
1
  import { statSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { type AttemptRecord, RUN_EVENT_VERSIONS } from "@agentswf/contract/records";
3
- import type { ExecutableWorkflow, JsonValue } from "@agentswf/contract/workflow";
4
+ import { isEventWorkflow, type JsonValue } from "@agentswf/contract/workflow";
4
5
  import { refusalMessage } from "./attempt-view";
5
6
  import { messageOf } from "./errors";
7
+ import { projectOf, projectRunRoot } from "./projects";
6
8
  import type { RunCommand } from "./run-command";
7
9
  import {
8
10
  type Attempt,
@@ -14,13 +16,19 @@ import {
14
16
  endTurnsLine,
15
17
  idProblem,
16
18
  openRun,
19
+ previousGone,
17
20
  type Run,
18
21
  RunRefused,
19
22
  readAttempts,
20
23
  readStageRecords,
21
24
  readTurns,
22
25
  } from "./runs";
23
- import { assertJsonValue, type LoadedWorkflow, loadWorkflowFile } from "./workflow-loader";
26
+ import {
27
+ type AnyWorkflow,
28
+ assertJsonValue,
29
+ type LoadedWorkflow,
30
+ loadWorkflowFile,
31
+ } from "./workflow-loader";
24
32
 
25
33
  export type PreparedRun = {
26
34
  args: JsonValue;
@@ -35,17 +43,50 @@ export type PreparedRun = {
35
43
  * typed the command, before anything starts.
36
44
  */
37
45
  export async function loadAndPrepare(
38
- command: RunCommand,
46
+ given: RunCommand,
39
47
  now: number,
40
- ): Promise<{ loaded: LoadedWorkflow; prepared: PreparedRun } | { refused: string }> {
48
+ home: string = homedir(),
49
+ ): Promise<
50
+ | { loaded: LoadedWorkflow; prepared: PreparedRun; command: RunCommand }
51
+ | { refused: string }
52
+ | { opens: { loaded: LoadedWorkflow; id: string; argv: readonly string[] } }
53
+ > {
41
54
  let loaded: LoadedWorkflow;
42
55
  try {
43
- loaded = await loadWorkflowFile(command.workflowFile, command.shellCwd);
56
+ loaded = await loadWorkflowFile(given.workflowFile, given.shellCwd);
44
57
  } catch (error) {
45
58
  return { refused: `load: ${messageOf(error)}` };
46
59
  }
60
+ let command = given;
61
+ if (isEventWorkflow(loaded.workflow)) {
62
+ const refused = eventRunRefusal(command);
63
+ if (refused) return { refused };
64
+ // An event run lives and works in its file's project, whoever starts or continues it (Q15).
65
+ let project: string;
66
+ try {
67
+ project = await projectOf(loaded.file, home);
68
+ } catch (error) {
69
+ return { refused: messageOf(error) };
70
+ }
71
+ if (!command.runRootGiven) command = { ...command, runRoot: projectRunRoot(project) };
72
+ if (command.continueId === undefined) {
73
+ // A run --id names is prepared only if it is made: one already there is woken with its own args.
74
+ try {
75
+ const id =
76
+ command.id ??
77
+ derivedId(loaded.workflow, prepareArgs(loaded.workflow, command.workflowArgs, project));
78
+ if (id === undefined)
79
+ 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`,
81
+ };
82
+ return { opens: { loaded, id, argv: command.workflowArgs } };
83
+ } catch (error) {
84
+ return { refused: `${error instanceof RunRefused ? "" : "prepare: "}${messageOf(error)}` };
85
+ }
86
+ }
87
+ }
47
88
  try {
48
- return { loaded, prepared: await prepareRun(command, loaded, now) };
89
+ return { loaded, prepared: await prepareRun(command, loaded, now), command };
49
90
  } catch (error) {
50
91
  return { refused: `${error instanceof RunRefused ? "" : "prepare: "}${messageOf(error)}` };
51
92
  }
@@ -60,16 +101,12 @@ async function prepareRun(
60
101
  loaded: LoadedWorkflow,
61
102
  now: number,
62
103
  ): Promise<PreparedRun> {
63
- const { executable, file } = loaded;
64
- const { meta } = executable.definition;
65
- const prepare = (argv: readonly string[], cwd: string) => {
66
- const args = executable.prepare({ argv, cwd });
67
- assertJsonValue(args, `${meta.name} arguments`);
68
- return args;
69
- };
104
+ const { workflow, file } = loaded;
105
+ const { meta } = workflow;
106
+ const prepare = (argv: readonly string[], cwd: string) => prepareArgs(workflow, argv, cwd);
70
107
  if (command.continueId === undefined) {
71
108
  const args = prepare(command.workflowArgs, command.cwd);
72
- const id = command.id ?? derivedId(executable, args);
109
+ const id = command.id ?? derivedId(workflow, args);
73
110
  if (id !== undefined) await checkFree(command.runRoot, meta.name, id);
74
111
  return { args, ...(id === undefined ? {} : { id }) };
75
112
  }
@@ -107,15 +144,46 @@ async function prepareRun(
107
144
  return { args, continued: { run, attempts, warnings: unrecorded } };
108
145
  }
109
146
 
147
+ /** The workflow's args from `argv`, as its `prepare` makes them, checked to be JSON. */
148
+ export function prepareArgs(
149
+ workflow: AnyWorkflow,
150
+ argv: readonly string[],
151
+ cwd: string,
152
+ ): JsonValue {
153
+ const args = workflow.prepare({ argv, cwd });
154
+ assertJsonValue(args, `${workflow.meta.name} arguments`);
155
+ return args;
156
+ }
157
+
158
+ /**
159
+ * 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.
161
+ */
162
+ 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";
165
+ if (command.continueId !== undefined) {
166
+ if (command.workflowArgs.length > 0)
167
+ return "a continue takes no arguments: it goes on with those its run was started with";
168
+ return undefined;
169
+ }
170
+ const unkept = [
171
+ command.timeout === undefined ? undefined : "--timeout",
172
+ command.sandbox === undefined ? undefined : "--sandbox",
173
+ command.cwdGiven ? "--cwd" : undefined,
174
+ command.runRootGiven ? "--run-root" : undefined,
175
+ ].filter((flag) => flag !== undefined);
176
+ if (unkept.length > 0)
177
+ return `a run of a workflow with events runs in its file's project, as every wake starts it: it takes no ${unkept.join(", ")}`;
178
+ return undefined;
179
+ }
180
+
110
181
  /** The id the workflow's `id(args)` gives a new run, checked; undefined when it has none. */
111
- function derivedId(
112
- executable: ExecutableWorkflow<JsonValue, JsonValue>,
113
- args: JsonValue,
114
- ): string | undefined {
115
- if (!executable.id) return undefined;
182
+ function derivedId(workflow: AnyWorkflow, args: JsonValue): string | undefined {
183
+ if (!workflow.id) return undefined;
116
184
  let id: unknown;
117
185
  try {
118
- id = executable.id(args);
186
+ id = workflow.id(args);
119
187
  } catch (error) {
120
188
  throw new RunRefused(`the workflow's id(args) failed: ${messageOf(error)}; --id gives one`);
121
189
  }
@@ -133,14 +201,14 @@ export async function claimNext(
133
201
  notice: (text: string) => void,
134
202
  now: number,
135
203
  ): Promise<Attempt> {
136
- const { meta } = loaded.executable.definition;
204
+ const { meta } = loaded.workflow;
137
205
  const claimed = await claimAttempt(
138
206
  run,
139
207
  {
140
208
  file: loaded.file,
141
209
  ...(meta.version === undefined ? {} : { workflowVersion: meta.version }),
142
210
  flags: {
143
- timeout: command.timeout,
211
+ ...(command.timeout === undefined ? {} : { timeout: command.timeout }),
144
212
  ...(command.fromStage === undefined ? {} : { fromStage: command.fromStage }),
145
213
  },
146
214
  },
@@ -149,6 +217,10 @@ export async function claimNext(
149
217
  if ("refused" in claimed) {
150
218
  throw new RunRefused(refusalMessage(run.record.id, claimed.refused, now));
151
219
  }
220
+ // An event run's: claimed first, which `wake` waits for holding the run's lock, then waited on
221
+ // before this attempt writes anything, so the attempt before it has finished its own records and
222
+ // two never share an agent's session.
223
+ if (run.record.events) await previousGone(run, claimed.attempt.record.attempt, notice);
152
224
  // A crash may have torn the last line an earlier attempt appended; this attempt's first line
153
225
  // must not be glued to it.
154
226
  await endTurnsLine(run.dir)
package/src/run-state.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { join } from "node:path";
2
- import type { AttemptRecord } from "@agentswf/contract/records";
2
+ import type { AttemptRecord, MailboxRecord, WaitingJson } from "@agentswf/contract/records";
3
3
  import type { AttemptOutcome } from "@agentswf/contract/workflow";
4
4
  import { exists } from "./files";
5
+ import { foldMailbox, nextTimer, readMailbox } from "./mailbox";
6
+ import { listProjects, projectRunRoot } from "./projects";
5
7
  import {
6
8
  entries,
7
9
  idProblem,
@@ -20,7 +22,7 @@ export type ProcessStatus = (pid: number) => string | "gone" | "unknown";
20
22
  * `running`; open and its process gone, `interrupted`; or how its last attempt ended. Read from
21
23
  * `run.json` and the attempt files, never the event log, so a run an older awf wrote reads too.
22
24
  */
23
- export type RunState = "running" | "interrupted" | AttemptOutcome;
25
+ export type RunState = "running" | "interrupted" | "stuck" | AttemptOutcome;
24
26
 
25
27
  export type RunStatus = {
26
28
  run: Run;
@@ -30,6 +32,8 @@ export type RunStatus = {
30
32
  state: RunState;
31
33
  /** False for a `running` whose process could not be asked about. */
32
34
  checked: boolean;
35
+ /** A run of a workflow with `events` at its wait, live or parked: what its mailbox says. */
36
+ waiting?: WaitingJson;
33
37
  };
34
38
 
35
39
  /** How long a run folder with no attempt yet may be an `awf run` still starting. */
@@ -40,7 +44,35 @@ export async function runStatus(
40
44
  probe: ProcessStatus = probeProcess,
41
45
  at = Date.now(),
42
46
  ): Promise<RunStatus> {
43
- const attempts = await readAttempts(run);
47
+ const status = statusOf(run, await readAttempts(run), probe, at);
48
+ if (!run.record.events) return status;
49
+ const records = await readMailbox(run.dir);
50
+ const waiting = waitingOf(status, records);
51
+ const stuck = status.state !== "running" && stuckOf(status.attempts, records);
52
+ return { ...status, ...(waiting ? { waiting } : {}), ...(stuck ? { state: "stuck" } : {}) };
53
+ }
54
+
55
+ /** An event run's wait, while it is running or parked there: its label and `until`. */
56
+ function waitingOf(status: RunStatus, records: readonly MailboxRecord[]): WaitingJson | undefined {
57
+ if (status.state !== "running" && status.state !== "parked") return undefined;
58
+ const state = foldMailbox(records);
59
+ const wait = state.lastWait;
60
+ // A wait an earlier attempt died in is not this one's.
61
+ if (!wait?.waiting || wait.attempt !== status.attempts.at(-1)?.attempt) return undefined;
62
+ const until = nextTimer(state, wait.takes);
63
+ return {
64
+ ...(wait.label === undefined ? {} : { label: wait.label }),
65
+ ...(until === undefined ? {} : { until: new Date(until).toISOString() }),
66
+ };
67
+ }
68
+
69
+ /** As `runStatus`, from its attempts as already read. */
70
+ export function statusOf(
71
+ run: Run,
72
+ attempts: AttemptRecord[],
73
+ probe: ProcessStatus = probeProcess,
74
+ at = Date.now(),
75
+ ): RunStatus {
44
76
  const last = attempts.at(-1);
45
77
  const ref = `${run.record.workflow}/${run.record.id}`;
46
78
  // Between its folder and its first attempt's claim: an `awf run` starting, or, past a minute,
@@ -63,13 +95,19 @@ export class RunNotFound extends Error {}
63
95
 
64
96
  /**
65
97
  * Every run under `runRoot`, newest `created` first. A folder whose `run.json` cannot be read is
66
- * not a run this awf can show, and is passed over.
98
+ * not a run this awf can show, and is passed over. One `known` has, by its folder, is not read
99
+ * again: a run's record never changes.
67
100
  */
68
- export async function listRuns(runRoot: string): Promise<Run[]> {
101
+ export async function listRuns(
102
+ runRoot: string,
103
+ known?: (dir: string) => Run | undefined,
104
+ ): Promise<Run[]> {
69
105
  const runs: Run[] = [];
70
106
  for (const workflow of await entries(runRoot).catch(() => [])) {
71
107
  for (const id of await entries(join(runRoot, workflow)).catch(() => [])) {
72
- const run = await openRun(runRoot, workflow, id).catch(() => undefined);
108
+ const run =
109
+ known?.(join(runRoot, workflow, id)) ??
110
+ (await openRun(runRoot, workflow, id).catch(() => undefined));
73
111
  if (run) runs.push(run);
74
112
  }
75
113
  }
@@ -118,3 +156,84 @@ export async function findRun(
118
156
  }
119
157
  return { status: await runStatus(found[0]!, probe), alsoRunning: [] };
120
158
  }
159
+
160
+ /** Failed or crashed attempts in a row, counted together, after which a run is `stuck` (Q13). */
161
+ export const STUCK_AFTER = 3;
162
+
163
+ /**
164
+ * Failed, timed-out or crashed attempts at the end of the run, and launches since the last attempt
165
+ * that never claimed one, counted together; any other ending resets them.
166
+ */
167
+ function failuresInARow(
168
+ attempts: readonly AttemptRecord[],
169
+ records: readonly MailboxRecord[],
170
+ ): number {
171
+ const since = attempts.at(-1)?.started ?? "";
172
+ let n = records.filter((record) => record.kind === "launch-failed" && record.at > since).length;
173
+ for (const attempt of [...attempts].reverse()) {
174
+ const failed =
175
+ attempt.outcome === undefined ||
176
+ attempt.outcome === "failed" ||
177
+ attempt.outcome === "timed-out";
178
+ if (!failed) break;
179
+ n++;
180
+ }
181
+ return n;
182
+ }
183
+
184
+ /**
185
+ * Whether an event run is `stuck` (Q13): failed, crashed or failed to launch three times in a row,
186
+ * whatever its last ending says, short of one the operator or the code chose.
187
+ */
188
+ export function stuckOf(
189
+ attempts: readonly AttemptRecord[],
190
+ records: readonly MailboxRecord[],
191
+ ): boolean {
192
+ const last = attempts.at(-1)?.outcome;
193
+ if (last === "completed" || last === "stopped" || last === "cancelled") return false;
194
+ return failuresInARow(attempts, records) >= STUCK_AFTER;
195
+ }
196
+
197
+ /**
198
+ * The run `ref` names, `{workflow}/{id}` or an `{id}` only one workflow has: under `--cwd`'s runs
199
+ * first, then in each project awf started an event run in (Q15). Every command that takes a run
200
+ * finds it here.
201
+ */
202
+ export async function findRunByRef(
203
+ ref: string,
204
+ place: { runRoot: string },
205
+ home: string,
206
+ ): Promise<RunStatus> {
207
+ // Here first: what `--cwd` names wins.
208
+ let here: RunNotFound;
209
+ try {
210
+ return (await findRun(place.runRoot, ref)).status;
211
+ } catch (error) {
212
+ if (!(error instanceof RunNotFound)) throw error;
213
+ here = error;
214
+ }
215
+ const found: RunStatus[] = [];
216
+ for (const project of await listProjects(home)) {
217
+ try {
218
+ found.push((await findRun(projectRunRoot(project), ref)).status);
219
+ } catch (error) {
220
+ if (!(error instanceof RunNotFound)) throw error;
221
+ }
222
+ }
223
+ // Workflows of one name in two projects make the same ref: naming the project is the caller's.
224
+ if (found.length > 1)
225
+ throw new RunNotFound(
226
+ `${ref} is a run in ${found.length} projects, ${found.map((each) => each.run.record.cwd).join(" and ")}; --cwd names one`,
227
+ );
228
+ if (found[0]) return found[0];
229
+ // Nowhere: a ref that can't name a run, malformed or naming two workflows, says why.
230
+ if (malformedRef(ref) || here.message.includes("more than one workflow")) throw here;
231
+ throw new RunNotFound(`no run ${ref} under ${place.runRoot}, or in a project awf knows`);
232
+ }
233
+
234
+ /** Why `ref` can't name a run at all, whatever is on disk; undefined when it can. */
235
+ function malformedRef(ref: string): string | undefined {
236
+ const slash = ref.indexOf("/");
237
+ if (slash < 0) return idProblem(ref);
238
+ return idProblem(ref.slice(0, slash)) ?? idProblem(ref.slice(slash + 1));
239
+ }
package/src/run-usage.ts CHANGED
@@ -31,7 +31,7 @@ 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;
@@ -83,7 +83,7 @@ export type RunLedger = {
83
83
  /** Every settled operation, with the sessions its agent has been seen with so far. */
84
84
  records(): OperationRecord[];
85
85
  /** Best effort: a turn still going at the deadline, or when the run is stopped, is killed. */
86
- letFinish(runDeadline: AbsoluteDeadline, signal: AbortSignal): Promise<void>;
86
+ letFinish(runDeadline: AbsoluteDeadline | undefined, signal: AbortSignal): Promise<void>;
87
87
  /**
88
88
  * Every record, completed with what its agent spent. Called once the host has closed, because a
89
89
  * released turn still writes its last request after the engine has moved on (story 002,
package/src/run-view.ts CHANGED
@@ -195,15 +195,6 @@ async function latestPhase(
195
195
  return (await readLines<OperationLivenessRecord>(file).catch(() => [])).at(-1);
196
196
  }
197
197
 
198
- /** `45s`, `6m40s`, `1h02m`. */
199
- export function duration(milliseconds: number): string {
200
- const seconds = Math.max(0, Math.round(milliseconds / 1000));
201
- if (seconds < 60) return `${seconds}s`;
202
- const minutes = Math.floor(seconds / 60);
203
- if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
204
- return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
205
- }
206
-
207
198
  /** An activity in a few words: `tool Bash bun test → exit 0`, `message 412 chars`. */
208
199
  export function describeActivity(activity: Activity): string {
209
200
  switch (activity.type) {
@@ -1,5 +1,8 @@
1
+ import { homedir } from "node:os";
1
2
  import type { RunJson, RunsJson } from "@agentswf/contract/records";
3
+ import { duration } from "./duration";
2
4
  import { messageOf } from "./errors";
5
+ import help from "./help/runs.txt" with { type: "text" };
3
6
  import {
4
7
  clockTime,
5
8
  command,
@@ -18,20 +21,10 @@ import {
18
21
  stateWord,
19
22
  } from "./run-commands";
20
23
  import { encodeCursor } from "./run-read";
21
- import { findRun, listRuns, type RunStatus, runStatus } from "./run-state";
22
- import { type AgentView, describeActivity, duration, type RunView, viewRun } from "./run-view";
24
+ import { findRunByRef, listRuns, type RunStatus, runStatus } from "./run-state";
25
+ import { type AgentView, describeActivity, type RunView, viewRun } from "./run-view";
23
26
 
24
- export const runsUsage = [
25
- "usage: awf runs [--all] [--cwd <directory>] [--run-root <directory>] [--json]",
26
- " awf runs <run> [--cwd <directory>] [--run-root <directory>] [--json]",
27
- "",
28
- "The runs here, newest first, or one run in depth: its attempts, the stages of the last, what",
29
- "each agent is doing, and what to run next. A run is <workflow>/<id>, or an <id> only one",
30
- "workflow here has. Runs are read under --cwd's .awf/runs, or --run-root, as awf run keeps them.",
31
- "",
32
- "--json prints one document, the interface for scripts. Exits 0 on any read that worked, 1 when",
33
- "there is no such run, 2 on a usage error, 3 when the run's files could not be read.",
34
- ].join("\n");
27
+ export const runsUsage = help.trimEnd();
35
28
 
36
29
  const LISTED = 20;
37
30
 
@@ -40,31 +33,37 @@ export async function runsCommand(
40
33
  shellCwd: string,
41
34
  out: Out,
42
35
  now: number = Date.now(),
36
+ home: string = homedir(),
43
37
  ): Promise<number> {
44
38
  if (argv.includes("-h") || argv.includes("--help")) {
45
39
  out.stdout(runsUsage);
46
40
  return 0;
47
41
  }
48
42
  let parsed: ReturnType<typeof parsePlace>;
49
- let all = false;
43
+ let limit: number | undefined;
50
44
  let ref: string | undefined;
51
45
  try {
52
46
  parsed = parsePlace(argv, shellCwd);
53
- for (const arg of parsed.rest) {
54
- if (arg === "--all") all = true;
55
- else if (arg.startsWith("-")) throw new Error(`unknown option: ${arg}`);
47
+ for (let index = 0; index < parsed.rest.length; index++) {
48
+ const arg = parsed.rest[index]!;
49
+ if (arg === "--limit") {
50
+ limit = Number(parsed.rest[++index]);
51
+ if (!Number.isSafeInteger(limit) || limit < 1)
52
+ throw new Error("--limit needs a whole number");
53
+ } else if (arg.startsWith("-")) throw new Error(`unknown option: ${arg}`);
56
54
  else if (ref === undefined) ref = arg;
57
55
  else throw new Error(`one run at a time: ${ref} and ${arg}`);
58
56
  }
59
- if (ref !== undefined && all) throw new Error("--all lists runs; it takes no run");
57
+ if (ref !== undefined && limit !== undefined)
58
+ throw new Error("--limit lists runs; it takes no run");
60
59
  } catch (error) {
61
60
  out.stderr(`awf: ${messageOf(error)}\n\n${runsUsage}`);
62
61
  return 2;
63
62
  }
64
63
  const { place, json } = parsed;
65
64
  try {
66
- if (ref === undefined) return await list(place, all, json, out, now);
67
- const view = await viewRun((await findRun(place.runRoot, ref)).status);
65
+ if (ref === undefined) return await list(place, limit ?? LISTED, json, out, now);
66
+ const view = await viewRun(await findRunByRef(ref, place, home));
68
67
  out.stdout(
69
68
  json ? JSON.stringify(runJson(view, place, now), null, 2) : describe(view, place, now),
70
69
  );
@@ -77,9 +76,9 @@ export async function runsCommand(
77
76
  /** A run listed, or why it could not be read: one bad run does not hide the others. */
78
77
  type Listed = { status: RunStatus } | { ref: string; unreadable: string };
79
78
 
80
- async function list(place: ReadPlace, all: boolean, json: boolean, out: Out, now: number) {
79
+ async function list(place: ReadPlace, limit: number, json: boolean, out: Out, now: number) {
81
80
  const runs = await listRuns(place.runRoot);
82
- const shown = all ? runs : runs.slice(0, LISTED);
81
+ const shown = runs.slice(0, limit);
83
82
  const listed: Listed[] = await Promise.all(
84
83
  shown.map((run) =>
85
84
  runStatus(run, undefined, now).then(
@@ -105,9 +104,14 @@ async function list(place: ReadPlace, all: boolean, json: boolean, out: Out, now
105
104
  started: new Date(started).toISOString(),
106
105
  ...(ended === undefined ? {} : { duration: ended - started }),
107
106
  ...(estimate === undefined ? {} : { estimate }),
107
+ ...(status.waiting ? { waiting: status.waiting } : {}),
108
108
  };
109
109
  });
110
- const document: RunsJson = { v: 1, runs: runsJson, more: { older: older > 0 } };
110
+ const document: RunsJson = {
111
+ v: 1,
112
+ runs: runsJson,
113
+ ...(older > 0 ? { more: { older: true } } : {}),
114
+ };
111
115
  out.stdout(JSON.stringify(document, null, 2));
112
116
  return 0;
113
117
  }
@@ -124,21 +128,34 @@ async function list(place: ReadPlace, all: boolean, json: boolean, out: Out, now
124
128
  const { status } = each;
125
129
  const { started, ms } = span(status, now);
126
130
  const estimate = estimateOf(status.attempts);
131
+ const waiting = waitingText(status);
127
132
  return [
128
133
  status.ref.padEnd(width),
129
134
  states[index]!.padEnd(stateWidth),
130
135
  dayTime(started).padEnd(13),
131
136
  duration(ms).padEnd(8),
132
- money(estimate).join(""),
137
+ money(estimate)
138
+ .join("")
139
+ .concat(waiting ? " " : ""),
140
+ waiting,
133
141
  ]
134
142
  .join("")
135
143
  .trimEnd();
136
144
  });
137
- if (older > 0) lines.push(`… ${older} older (--all)`);
145
+ if (older > 0) lines.push(`… ${older} older (--limit ${runs.length})`);
138
146
  out.stdout(lines.join("\n"));
139
147
  return 0;
140
148
  }
141
149
 
150
+ /** A run at its wait: its label, and until when; a returned one, the events it never took. */
151
+ function waitingText(status: RunStatus): string {
152
+ const { waiting } = status;
153
+ const never = status.attempts.at(-1)?.unacknowledged;
154
+ if (!waiting) return never ? `${never} event${never === 1 ? "" : "s"} never taken` : "";
155
+ const until = waiting.until ? `until ${dayTime(Date.parse(waiting.until))}` : "";
156
+ return [waiting.label, until].filter(Boolean).join(" · ") || "waiting";
157
+ }
158
+
142
159
  function dayTime(at: number): string {
143
160
  const date = new Date(at);
144
161
  const two = (n: number) => String(n).padStart(2, "0");
@@ -300,6 +317,7 @@ function runJson(view: RunView, place: ReadPlace, now: number): RunJson {
300
317
  ...(view.stage ? { stage: view.stage } : {}),
301
318
  ...(status.state !== "running" && ended !== undefined ? { duration: ended - started } : {}),
302
319
  ...(view.estimate === undefined ? {} : { estimate: view.estimate }),
320
+ ...(status.waiting ? { waiting: status.waiting } : {}),
303
321
  dir: status.run.dir,
304
322
  },
305
323
  attempts: status.attempts.map(({ attempt, started, ended, outcome, stage, reason }) => ({