omp-conductor 0.15.12 → 0.16.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 (55) hide show
  1. package/REFERENCE.md +81 -6
  2. package/package.json +2 -1
  3. package/schema/config.schema.json +6 -0
  4. package/src/admission.ts +745 -0
  5. package/src/ask.ts +47 -0
  6. package/src/backups.ts +19 -7
  7. package/src/board.ts +1 -2
  8. package/src/briefs/orchestrator.md +62 -4
  9. package/src/cli.ts +26 -0
  10. package/src/commands/context.ts +3 -0
  11. package/src/commands/decision.ts +10 -1
  12. package/src/commands/doctor.ts +2 -0
  13. package/src/commands/message.ts +8 -1
  14. package/src/commands/restart.ts +93 -54
  15. package/src/commands/restore-db.ts +146 -0
  16. package/src/commands/stop.ts +66 -34
  17. package/src/commands/unfreeze.ts +56 -0
  18. package/src/commands/watch.ts +77 -0
  19. package/src/config-schema.ts +9 -0
  20. package/src/config.ts +24 -0
  21. package/src/daemon.ts +485 -577
  22. package/src/dashboard/server.ts +2 -1
  23. package/src/decisions.ts +32 -7
  24. package/src/depends-on.ts +73 -0
  25. package/src/doctor.ts +418 -8
  26. package/src/escalate.ts +122 -15
  27. package/src/failure-class.ts +47 -0
  28. package/src/fleet.ts +55 -377
  29. package/src/gitops.ts +86 -1
  30. package/src/lifecycle.ts +113 -2
  31. package/src/log.ts +40 -0
  32. package/src/model-fallback.ts +3 -2
  33. package/src/omp-settings.ts +114 -0
  34. package/src/omp.ts +63 -0
  35. package/src/orchestrator-down.ts +231 -0
  36. package/src/orchestrator-tick.ts +14 -1
  37. package/src/orchestrator.ts +14 -0
  38. package/src/release-policy.ts +163 -18
  39. package/src/reports.ts +124 -12
  40. package/src/session-host.ts +6 -0
  41. package/src/setup-host.ts +386 -17
  42. package/src/setup-install.ts +40 -2
  43. package/src/setup-wizard.ts +314 -113
  44. package/src/setup.ts +58 -1
  45. package/src/status-render.ts +445 -0
  46. package/src/stop-provenance.ts +119 -0
  47. package/src/store.ts +533 -11
  48. package/src/types.ts +298 -4
  49. package/src/unblock.ts +1 -1
  50. package/src/upgrade-verify.ts +1 -1
  51. package/src/upgrade.ts +27 -8
  52. package/src/verbs/protocol.ts +16 -3
  53. package/src/verbs/server.ts +52 -1
  54. package/src/wizard-ui.ts +261 -46
  55. package/src/worker.ts +183 -10
package/src/wizard-ui.ts CHANGED
@@ -36,8 +36,9 @@ export interface WizardUi {
36
36
  /** Single-line text prompt. `undefined` dismisses it; an empty submit accepts
37
37
  * the placeholder. */
38
38
  input(title: string, placeholder?: string): Promise<string | undefined>;
39
- /** Numbered single-choice list. Resolves the chosen option's label, or
40
- * `undefined` when dismissed. */
39
+ /** Single-choice list. On an interactive TTY the current option is rendered
40
+ * inline and moved with ↑/↓ or j/k; on a pipe it stays the numbered wall.
41
+ * Resolves the chosen option's label, or `undefined` when dismissed. */
41
42
  select(
42
43
  title: string,
43
44
  options: { label: string; description?: string }[],
@@ -62,6 +63,17 @@ import type { Readable, Writable } from "node:stream";
62
63
  * as long as the wizard, and {@link TerminalUi.close} releases it once, from the
63
64
  * caller's `finally`.
64
65
  *
66
+ * The one exception is {@link TerminalUi.select} on a real TTY, which takes the
67
+ * terminal over in raw mode. A live readline interface echoes every typed
68
+ * character back to the output and folds Enter into a `line` event, so it would
69
+ * smear the inline option row with pressed keys and drop stray lines into the
70
+ * buffer underneath it. That prompt closes the interface, reads the terminal
71
+ * byte by byte, and then re-creates the interface so the line protocol is still
72
+ * alive for the next prompt. The re-opening works on a TTY (the stream itself
73
+ * is untouched; closing only pauses it, and it is resumed), and the closed
74
+ * interface's key listeners are dropped, so nothing leaks across repeated
75
+ * passes of a menu.
76
+ *
65
77
  * Dismissal is the interface's own `close`: `rl.question()` on an exhausted
66
78
  * stream never settles on its own, so EOF (a script that ran out of answers) and
67
79
  * Ctrl-C both close the interface, and the line reader turns that into the
@@ -78,18 +90,18 @@ export interface TerminalUi extends WizardUi {
78
90
  export function terminalUi(io: { input?: Readable; output?: Writable } = {}): TerminalUi {
79
91
  const input = io.input ?? stdin;
80
92
  const output = io.output ?? stdout;
81
- const rl: Interface = createInterface({ input, output });
93
+ let rl: Interface = createInterface({ input, output });
82
94
  let closed = false;
83
- const close = (): void => {
84
- if (closed) return;
85
- closed = true;
86
- rl.close();
95
+ let buffered: string[] = [];
96
+ let waiting: ((value: string | undefined) => void) | undefined;
97
+ let ended = false;
98
+ const deliver = (value: string | undefined): boolean => {
99
+ const resolve = waiting;
100
+ if (resolve === undefined) return false;
101
+ waiting = undefined;
102
+ resolve(value);
103
+ return true;
87
104
  };
88
- // Ctrl-C is a cancel, not a signal to the whole process: closing the interface
89
- // makes every pending and subsequent prompt resolve `undefined`, which the
90
- // wizard already turns into `Cancelled`.
91
- rl.once("SIGINT", close);
92
-
93
105
  // Lines are buffered as they arrive rather than read on demand, because on a
94
106
  // non-TTY stdin readline drains the whole pipe at once and emits `line` for
95
107
  // every one of them immediately. `rl.question()` only captures the line that
@@ -97,23 +109,40 @@ export function terminalUi(io: { input?: Readable; output?: Writable } = {}): Te
97
109
  // already flown past and then hit EOF — three prompts resolving `undefined`
98
110
  // and a wizard that cancelled itself. Buffering makes a pipe and a terminal
99
111
  // behave the same: nothing is read before it is asked for, nothing is lost.
100
- const buffered: string[] = [];
101
- let waiting: ((value: string | undefined) => void) | undefined;
102
- let ended = false;
103
- const deliver = (value: string | undefined): boolean => {
104
- const resolve = waiting;
105
- if (resolve === undefined) return false;
112
+ const attach = (iface: Interface): void => {
113
+ // Ctrl-C on the line protocol is a cancel, not a signal to the whole
114
+ // process: closing the interface (on SIGINT or on EOF) makes every pending
115
+ // and subsequent prompt resolve `undefined`, which the wizard already turns
116
+ // into `Cancelled`. The raw-mode select handles Ctrl-C itself; every other
117
+ // prompt relies on this.
118
+ iface.once("SIGINT", close);
119
+ iface.on("line", (text: string) => {
120
+ if (!deliver(text)) buffered.push(text);
121
+ });
122
+ iface.once("close", () => {
123
+ ended = true;
124
+ deliver(undefined);
125
+ });
126
+ };
127
+ const close = (): void => {
128
+ if (closed) return;
129
+ closed = true;
130
+ rl.close();
131
+ };
132
+ // The line protocol runs on the *current* interface. An interactive select
133
+ // takes the terminal over in raw mode, so the interface that was echoing
134
+ // keystrokes and folding Enter into lines has to be closed out of the way and
135
+ // a fresh one opened for the prompts that follow (see the lifecycle comment
136
+ // at the top of the interface declaration).
137
+ const reopen = (): void => {
138
+ rl.close();
139
+ buffered = [];
106
140
  waiting = undefined;
107
- resolve(value);
108
- return true;
141
+ ended = false;
142
+ rl = createInterface({ input, output });
143
+ attach(rl);
109
144
  };
110
- rl.on("line", (text: string) => {
111
- if (!deliver(text)) buffered.push(text);
112
- });
113
- rl.once("close", () => {
114
- ended = true;
115
- deliver(undefined);
116
- });
145
+ attach(rl);
117
146
 
118
147
  /** One line, or `undefined` when the operator dismissed the prompt. */
119
148
  const line = async (query: string): Promise<string | undefined> => {
@@ -127,6 +156,37 @@ export function terminalUi(io: { input?: Readable; output?: Writable } = {}): Te
127
156
  });
128
157
  };
129
158
 
159
+ // -------- the interactive select: raw keys, one changing row --------
160
+ //
161
+ // On a real TTY the numbered wall is replaced by a single row that shows the
162
+ // option under the cursor and follows ↑/↓ or j/k. The keys are read from the
163
+ // raw stream byte by byte rather than through readline's keypress decode:
164
+ // readline only emits a lone Escape after its own escape-sequence timeout,
165
+ // and CSI sequences split across chunks have to be reassembled anyway. The
166
+ // terminal is taken over only for the duration of the prompt and restored on
167
+ // every exit path.
168
+ type TtyInput = Readable & {
169
+ isTTY: boolean;
170
+ isRaw?: boolean;
171
+ setRawMode(mode: boolean): void;
172
+ };
173
+ const asTty = (stream: Readable): TtyInput | undefined => {
174
+ const candidate = stream as Readable & Partial<TtyInput>;
175
+ return candidate.isTTY === true && typeof candidate.setRawMode === "function"
176
+ ? (candidate as TtyInput)
177
+ : undefined;
178
+ };
179
+
180
+ const ESC = 27;
181
+ const ENTERS = new Set([13, 10]);
182
+ const CTRL_C = 3;
183
+ const UP = 65; // CSI A after ESC [
184
+ const DOWN = 66; // CSI B after ESC [
185
+ const KEY_J = 106; // lower-case j: down
186
+ const KEY_K = 107; // lower-case k: up
187
+ const KEY_J_UP = 74; // J
188
+ const KEY_K_UP = 75; // K
189
+
130
190
  return {
131
191
  close,
132
192
  notify(message) {
@@ -156,25 +216,178 @@ export function terminalUi(io: { input?: Readable; output?: Writable } = {}): Te
156
216
  return answer.trim().length === 0 ? (placeholder ?? "") : answer;
157
217
  },
158
218
  async select(title, options, dialogOptions) {
159
- output.write(`${title}\n`);
160
- options.forEach((o, i) => {
161
- output.write(` ${i + 1}. ${o.label}${o.description !== undefined ? ` — ${o.description}` : ""}\n`);
162
- });
163
- const initial = dialogOptions?.initialIndex ?? 0;
164
- const defaultLabel = options[initial]?.label ?? "";
165
- for (;;) {
166
- const answer = await line(
167
- `Select 1-${options.length} [${initial + 1} = ${defaultLabel}] (Enter keeps it, Ctrl-C cancels): `,
168
- );
169
- if (answer === undefined) return undefined;
170
- const a = answer.trim();
171
- if (a === "") return defaultLabel; // a bare Enter re-affirms the current row
172
- const n = Number(a);
173
- if (Number.isInteger(n)) {
174
- const picked = options[n - 1];
175
- if (picked !== undefined) return picked.label;
219
+ const tty = asTty(input);
220
+ if (tty === undefined) {
221
+ // Non-TTY stdin (a pipe, a test harness, CI): the numbered wall, kept
222
+ // byte-identical so the scripted protocol cannot drift.
223
+ output.write(`${title}\n`);
224
+ options.forEach((o, i) => {
225
+ output.write(` ${i + 1}. ${o.label}${o.description !== undefined ? ` — ${o.description}` : ""}\n`);
226
+ });
227
+ const initial = dialogOptions?.initialIndex ?? 0;
228
+ const defaultLabel = options[initial]?.label ?? "";
229
+ for (;;) {
230
+ const answer = await line(
231
+ `Select 1-${options.length} [${initial + 1} = ${defaultLabel}] (Enter keeps it, Ctrl-C cancels): `,
232
+ );
233
+ if (answer === undefined) return undefined;
234
+ const a = answer.trim();
235
+ if (a === "") return defaultLabel; // a bare Enter re-affirms the current row
236
+ const n = Number(a);
237
+ if (Number.isInteger(n)) {
238
+ const picked = options[n - 1];
239
+ if (picked !== undefined) return picked.label;
240
+ }
241
+ output.write(`Please enter a number between 1 and ${options.length}.\n`);
242
+ }
243
+ }
244
+ if (options.length === 0) return undefined;
245
+ // Clamp rather than wrap: the row is the operator's place in the list and
246
+ // pressing past an edge should not fly from bottom to top (or the other
247
+ // way); a bare Enter is "keep the current row", so the position is a
248
+ // setting, not a spinner.
249
+ const bound = (index: number): number => Math.max(0, Math.min(index, options.length - 1));
250
+ let index = bound(dialogOptions?.initialIndex ?? 0);
251
+ const row = (at: number): string => {
252
+ const o = options[at];
253
+ return ` › ${o?.label ?? ""}${o?.description !== undefined ? ` — ${o.description}` : ""}`;
254
+ };
255
+ let prevLen = 0;
256
+ const paint = (): void => {
257
+ const text = row(index);
258
+ output.write(`\r${" ".repeat(prevLen)}\r${text}`);
259
+ prevLen = text.length;
260
+ };
261
+ let settled: ((value: string | undefined) => void) | undefined;
262
+ let failed: ((err: unknown) => void) | undefined;
263
+ let escapeState: "idle" | "esc" | "seq" = "idle";
264
+ let escapeTimer: ReturnType<typeof setTimeout> | undefined;
265
+ const clearEscape = (): void => {
266
+ if (escapeTimer !== undefined) {
267
+ clearTimeout(escapeTimer);
268
+ escapeTimer = undefined;
269
+ }
270
+ };
271
+ // Escape sequences arrive byte by byte and a lone ESC cannot be told
272
+ // apart from the start of `ESC [ A` until the next byte lands; reading
273
+ // the whole chunk at once handles the common atomic write, and a short
274
+ // timer turns an unfinished sequence into a plain Escape.
275
+ const armEscape = (): void => {
276
+ if (escapeTimer !== undefined) return;
277
+ escapeTimer = setTimeout(() => {
278
+ escapeTimer = undefined;
279
+ escapeState = "idle";
280
+ settled?.(undefined);
281
+ }, 30);
282
+ };
283
+ const onData = (chunk: Buffer): void => {
284
+ try {
285
+ for (const byte of chunk) {
286
+ switch (escapeState) {
287
+ case "esc": {
288
+ clearEscape();
289
+ if (byte === 91 || byte === 79) {
290
+ // ESC [ … or ESC O … — wait for the direction letter.
291
+ escapeState = "seq";
292
+ armEscape();
293
+ continue;
294
+ }
295
+ // ESC followed by anything else: a plain Escape press.
296
+ escapeState = "idle";
297
+ settled?.(undefined);
298
+ continue;
299
+ }
300
+ case "seq": {
301
+ clearEscape();
302
+ escapeState = "idle";
303
+ if (byte === 65) index = bound(index - 1);
304
+ else if (byte === 66) index = bound(index + 1);
305
+ else continue; // an unknown CSI sequence is not a key we know
306
+ paint();
307
+ continue;
308
+ }
309
+ case "idle": {
310
+ if (ENTERS.has(byte)) {
311
+ settled?.(options[index]?.label);
312
+ continue;
313
+ }
314
+ if (byte === CTRL_C) {
315
+ settled?.(undefined);
316
+ continue;
317
+ }
318
+ if (byte === ESC) {
319
+ escapeState = "esc";
320
+ armEscape();
321
+ continue;
322
+ }
323
+ if (byte === KEY_J || byte === KEY_J_UP) {
324
+ index = bound(index + 1);
325
+ paint();
326
+ continue;
327
+ }
328
+ if (byte === KEY_K || byte === KEY_K_UP) {
329
+ index = bound(index - 1);
330
+ paint();
331
+ continue;
332
+ }
333
+ // Any other byte (typos, mouse reports) is ignored.
334
+ continue;
335
+ }
336
+ }
337
+ }
338
+ } catch (err) {
339
+ failed?.(err);
176
340
  }
177
- output.write(`Please enter a number between 1 and ${options.length}.\n`);
341
+ };
342
+ const onSignal = (signal: NodeJS.Signals): void => {
343
+ // A real signal (not the Ctrl-C byte, which raw mode turns into the
344
+ // cancel above) still kills the process — but only after the terminal
345
+ // has been handed back, so the operator's shell is never left raw.
346
+ clearEscape();
347
+ input.removeListener("data", onData);
348
+ tty.setRawMode(wasRaw);
349
+ process.removeListener("SIGINT", onSignal);
350
+ process.removeListener("SIGTERM", onSignal);
351
+ process.removeListener("SIGHUP", onSignal);
352
+ process.kill(process.pid, signal);
353
+ };
354
+ let wasRaw = false;
355
+ try {
356
+ // Take the terminal over. The shared interface would echo every pressed
357
+ // key back into the row and fold Enter into a `line` the next prompt
358
+ // would swallow, so it is closed for the duration and re-opened below.
359
+ rl.close();
360
+ input.resume();
361
+ // Captured *after* the close: while the interface is alive it keeps the
362
+ // terminal raw (bun's readline does), and restoring to that state would
363
+ // leave the operator's shell raw after a signal.
364
+ wasRaw = tty.isRaw ?? false;
365
+ tty.setRawMode(true);
366
+ output.write(`${title}\n`);
367
+ output.write(" (↑/↓ or j/k to move · Enter accepts · Ctrl-C or Esc cancels)\n");
368
+ paint();
369
+ const done = new Promise<string | undefined>((resolve, reject) => {
370
+ settled = resolve;
371
+ failed = reject;
372
+ });
373
+ input.on("data", onData);
374
+ process.once("SIGINT", onSignal);
375
+ process.once("SIGTERM", onSignal);
376
+ process.once("SIGHUP", onSignal);
377
+ const picked = await done;
378
+ output.write("\n");
379
+ return picked;
380
+ } finally {
381
+ // Every exit path — accept, cancel, an exception in rendering, a signal
382
+ // that did not kill us — restores the terminal exactly as it was found
383
+ // and re-opens the line interface for the prompts that follow.
384
+ clearEscape();
385
+ input.removeListener("data", onData);
386
+ tty.setRawMode(wasRaw);
387
+ process.removeListener("SIGINT", onSignal);
388
+ process.removeListener("SIGTERM", onSignal);
389
+ process.removeListener("SIGHUP", onSignal);
390
+ reopen();
178
391
  }
179
392
  },
180
393
  };
@@ -209,7 +422,9 @@ export function terminalUi(io: { input?: Readable; output?: Writable } = {}): Te
209
422
  export interface ScriptedAnswers {
210
423
  input?: Record<string, string>;
211
424
  confirm?: Record<string, boolean | boolean[] | null>;
212
- select?: Record<string, string | number | null>;
425
+ /** The array variant is what lets one script drive a repeated menu (#417):
426
+ * the review loop's consent menu can be answered "decline, then apply". */
427
+ select?: Record<string, string | number | null | (string | number | null)[]>;
213
428
  }
214
429
 
215
430
  export function scriptedUi(script: ScriptedAnswers = {}): WizardUi {
package/src/worker.ts CHANGED
@@ -61,6 +61,17 @@ export const RESUME_PROMPT =
61
61
  "re-check the outcome of your last action before repeating it, then keep working your original " +
62
62
  "brief to the same report contract.";
63
63
 
64
+ /**
65
+ * What an orphan-resumed worker is told instead of re-sending its brief. One
66
+ * literal so tests can pin it (#536): the original brief is already in the
67
+ * resumed transcript, and re-sending it is how a resumed worker ends up
68
+ * re-doing the work it just did.
69
+ */
70
+ export const ORPHAN_RESUME_PROMPT =
71
+ "Your previous process was interrupted by a daemon restart. Review the transcript's final state before acting, " +
72
+ "then continue exactly where you left off: re-check the outcome of your last action before repeating it, and keep " +
73
+ "working your original brief to the same report contract.";
74
+
64
75
  export interface WorkerOpts {
65
76
  brief: string;
66
77
  cwd: string;
@@ -74,11 +85,29 @@ export interface WorkerOpts {
74
85
  * location; either way the real path comes back on {@link WorkerResult}.
75
86
  */
76
87
  sessionDir?: string;
88
+ /**
89
+ * Continue the most recent transcript in `sessionDir` instead of opening a
90
+ * blank session (#536). The daemon sets it only for an orphan-clean
91
+ * continuation, after verifying the prior transcript and worktree still
92
+ * exist — the harness's own `continueRecent` is the backstop, and a silent
93
+ * fallback to a fresh session is surfaced by the `sessionFile` lineage
94
+ * compare at the dispatch site, never left quiet.
95
+ */
96
+ resume?: boolean;
77
97
  /**
78
98
  * Model pattern for this session, in omp's model/role syntax. Omitted leaves
79
99
  * the harness to pick, which is what an unconfigured project wants.
80
100
  */
81
101
  model?: string;
102
+ /**
103
+ * Absolute path to the fleet-owned omp settings overlay (#537): the YAML the
104
+ * daemon materialised from the project's `ompSettings` map (plus the retry
105
+ * keys derived from `modelFallbacks`, which is where #539's staging lives)
106
+ * under the run's session directory. Forwarded to `createSession`, which
107
+ * loads it through `Settings.init({ configFiles: [<path>] })`. Absent, no
108
+ * settings are staged and dispatch is byte-for-byte what it is today.
109
+ */
110
+ ompSettingsFile?: string;
82
111
  /**
83
112
  * Effective per-shape release grants for this session. A worker is refused
84
113
  * every shape whatever they say — see {@link SessionRole} — so this is passed
@@ -149,6 +178,37 @@ export interface WorkerResult {
149
178
  turns: number;
150
179
  spendUsd: number;
151
180
  report: string;
181
+ /** In-session HTTP 429 responses the session recorded (stopReason "error",
182
+ * errorStatus 429), counted as the messages streamed in. A healthy run
183
+ * reports 0; a run the harness retried through a barrel of rate limits
184
+ * carries the number, which is what distinguishes provider-capacity (the
185
+ * provider was throttling all along) from an ordinary failure (#573). */
186
+ provider429Count: number;
187
+ /**
188
+ * The model that actually wrote this run's messages, read from
189
+ * `AssistantMessage.model` on the newest assistant `message_end`. Present
190
+ * even for a run that never failed over — it is the durable answer to "which
191
+ * model wrote this" (#535 slice 1, from the message field rather than the
192
+ * payload-free `model_changed` event). Absent only when no assistant message
193
+ * carried a model.
194
+ */
195
+ model?: string;
196
+ /** The provider that wrote them, read from the same `AssistantMessage.provider`. */
197
+ provider?: string;
198
+ /** Every within-run model fallback the harness applied
199
+ * (`retry_fallback_applied`), newest first. The `to` target is what a
200
+ * settlement report names when a run swapped providers mid-run. */
201
+ retryFallbacks: { from: string; to: string }[];
202
+ /** `retry_fallback_succeeded` events: within-run fallbacks the harness
203
+ * confirmed recovered on. */
204
+ retryFallbackSucceeded: number;
205
+ /** Assistant messages whose `retryRecovery.recovery === "model"` — the durable
206
+ * transcript record of a within-run model swap. */
207
+ modelRecoveries: number;
208
+ /** `auto_retry_start` events: in-session provider retries the harness ran. */
209
+ autoRetryCount: number;
210
+ /** `auto_compaction_start` events: in-session context compactions. */
211
+ autoCompactionCount: number;
152
212
  killedBy?: KilledBy;
153
213
  /** Present only when an operator terminally stopped this run. */
154
214
  stoppedReason?: string;
@@ -266,6 +326,11 @@ export async function runWorker(
266
326
  cwd: o.cwd,
267
327
  ...(o.sessionDir === undefined ? {} : { sessionDir: o.sessionDir }),
268
328
  ...(o.model === undefined ? {} : { model: o.model }),
329
+ ...(o.resume === undefined ? {} : { resume: o.resume }),
330
+ // The fleet-owned omp settings overlay (#537): carry the staged overlay
331
+ // path to the session so it loads the project's omp settings. Absent,
332
+ // nothing is staged and the harness discovers settings as it does today.
333
+ ...(o.ompSettingsFile === undefined ? {} : { ompSettingsFile: o.ompSettingsFile }),
269
334
  // Prevention half of #24: as a worker, structured file tools cannot leave
270
335
  // this worktree, and no release grant can ever reach this session (#122).
271
336
  role: "worker",
@@ -287,6 +352,12 @@ export async function runWorker(
287
352
  state: "stopped",
288
353
  turns: 0,
289
354
  spendUsd: 0,
355
+ provider429Count: 0,
356
+ retryFallbacks: [],
357
+ retryFallbackSucceeded: 0,
358
+ modelRecoveries: 0,
359
+ autoRetryCount: 0,
360
+ autoCompactionCount: 0,
290
361
  report: "",
291
362
  stoppedReason: "daemon shutdown began before the worker session started",
292
363
  };
@@ -303,7 +374,19 @@ export async function runWorker(
303
374
  // transcript it actually opened, and any model downgrade it announced. Read at
304
375
  // return time so a session that materialises either late is still reported
305
376
  // honestly.
306
- const withSessionFacts = (result: WorkerResult): WorkerResult => {
377
+ // The within-run reliability surface this worker now records (#539): the
378
+ // resolved model/provider plus the fallback/retry/compaction events. Layered
379
+ // last, at return time, so every exit path reports the same shape without
380
+ // each spelling the metrics out by hand.
381
+ type ReliabilityKeys =
382
+ | "retryFallbacks"
383
+ | "retryFallbackSucceeded"
384
+ | "modelRecoveries"
385
+ | "autoRetryCount"
386
+ | "autoCompactionCount";
387
+ const withSessionFacts = (
388
+ result: Omit<WorkerResult, ReliabilityKeys>,
389
+ ): Omit<WorkerResult, ReliabilityKeys> => {
307
390
  const { sessionFile, modelFallbackMessage } = session;
308
391
  return {
309
392
  ...result,
@@ -312,9 +395,38 @@ export async function runWorker(
312
395
  };
313
396
  };
314
397
 
398
+ // The count fields always travel (0 for a clean run, so an absent field can
399
+ // never be misread); the resolved model/provider only when some assistant
400
+ // message actually carried them.
401
+ const withMetrics = (result: Omit<WorkerResult, ReliabilityKeys>): WorkerResult => ({
402
+ ...result,
403
+ ...(resolvedModel === undefined ? {} : { model: resolvedModel }),
404
+ ...(resolvedProvider === undefined ? {} : { provider: resolvedProvider }),
405
+ retryFallbacks,
406
+ retryFallbackSucceeded,
407
+ modelRecoveries,
408
+ autoRetryCount,
409
+ autoCompactionCount,
410
+ });
411
+
315
412
  let turns = 0;
316
413
  let spendUsd = 0;
414
+ let provider429Count = 0;
317
415
  let report = "";
416
+ // Which model/provider actually wrote the newest assistant message. Last
417
+ // assistant message wins: that is the durable answer even for a run that
418
+ // never failed over (#535 slice 1, read off the message field which is where
419
+ // the resolved model actually lives).
420
+ let resolvedModel: string | undefined;
421
+ let resolvedProvider: string | undefined;
422
+ // Harness reliability surface (#539): within-run provider failover and the
423
+ // retry/compaction activity that surrounds it, all of it events this worker
424
+ // does not yet subscribe to but that the run row and settlement report want.
425
+ let retryFallbacks: { from: string; to: string }[] = [];
426
+ let retryFallbackSucceeded = 0;
427
+ let modelRecoveries = 0;
428
+ let autoRetryCount = 0;
429
+ let autoCompactionCount = 0;
318
430
  // The newest COMPLETE `pushed-green` verdict this session emitted. Tracked
319
431
  // apart from `report` because `report` is deliberately the newest non-empty
320
432
  // text — a run cut off mid-sentence must still report what it said last —
@@ -476,6 +588,48 @@ export async function runWorker(
476
588
  spendUsd += cost;
477
589
  o.onSpend?.(spendUsd);
478
590
  }
591
+
592
+ // Count the provider rate limits the harness retried in-session (#573). A
593
+ // run that drowns in 429s records `stopReason:"error", errorStatus:429`
594
+ // dozens of times and never surfaces one as `lastError` — omp swallowed
595
+ // every retry — so `unknown` and the provider failover chain (#286) never
596
+ // see them. Counted here, alongside spend, so the classifier can tell
597
+ // "the provider was throttling the whole run" from an ordinary failure.
598
+ if (provider429FromMessage(message)) provider429Count += 1;
599
+
600
+ // The resolved model and provider live on the message, not on any event
601
+ // (#539). Last assistant message wins, which is the run's durable answer
602
+ // even when it never failed over.
603
+ const model = field(message, "model");
604
+ if (typeof model === "string" && model !== "") resolvedModel = model;
605
+ const provider = field(message, "provider");
606
+ if (typeof provider === "string" && provider !== "") resolvedProvider = provider;
607
+ // A within-run model swap the harness persisted into the transcript rather
608
+ // than only emitting as a transient event. Recovery kind "model" is the
609
+ // durable spelling of the same thing `retry_fallback_applied` says.
610
+ if (field(field(message, "retryRecovery"), "recovery") === "model") modelRecoveries += 1;
611
+ });
612
+
613
+ // The harness reliability events the run row and settlement report now want
614
+ // (#539): within-run model fallback, and the retry/compaction activity that
615
+ // surrounds a throttled provider. Each is a session event (AgentSessionEvent)
616
+ // carrying the fields this worker reads — a fallback's from→to pair, the
617
+ // confirmed recoveries, and the auto-retry/compaction attempt counts.
618
+ session.on("retry_fallback_applied", (event) => {
619
+ const from = field(event, "from");
620
+ const to = field(event, "to");
621
+ if (typeof from === "string" && typeof to === "string") {
622
+ retryFallbacks = [{ from, to }, ...retryFallbacks];
623
+ }
624
+ });
625
+ session.on("retry_fallback_succeeded", () => {
626
+ retryFallbackSucceeded += 1;
627
+ });
628
+ session.on("auto_retry_start", () => {
629
+ autoRetryCount += 1;
630
+ });
631
+ session.on("auto_compaction_start", () => {
632
+ autoCompactionCount += 1;
479
633
  });
480
634
 
481
635
  session.on("agent_end", (event) => {
@@ -540,12 +694,13 @@ export async function runWorker(
540
694
  // Our own abort surfaces here on some paths; that is a kill, not a crash.
541
695
  if (killedBy === undefined && stoppedReason === undefined) {
542
696
  const detail = cause instanceof Error ? cause.message : String(cause);
543
- return withSessionFacts({
697
+ return withMetrics(withSessionFacts({
544
698
  state: "failed",
545
699
  turns,
546
700
  spendUsd,
701
+ provider429Count,
547
702
  report: report === "" ? detail : report,
548
- });
703
+ }));
549
704
  }
550
705
  } finally {
551
706
  done = true;
@@ -560,14 +715,15 @@ export async function runWorker(
560
715
  }
561
716
 
562
717
  if (stoppedReason !== undefined) {
563
- return withSessionFacts({
718
+ return withMetrics(withSessionFacts({
564
719
  state: "stopped",
565
720
  turns,
566
721
  spendUsd,
722
+ provider429Count,
567
723
  report,
568
724
  stoppedReason,
569
725
  ...(claim === undefined ? {} : { prUrl: claim.prUrl, headSha: claim.headSha }),
570
- });
726
+ }));
571
727
  }
572
728
 
573
729
  if (killedBy !== undefined) {
@@ -575,28 +731,30 @@ export async function runWorker(
575
731
  // survive the kill. Without them `shouldContinueAfterTurnsCap` sees no
576
732
  // artifacts and charges an implementation attempt for a cap kill that had
577
733
  // real work to continue from.
578
- return withSessionFacts({
734
+ return withMetrics(withSessionFacts({
579
735
  state: "killed",
580
736
  turns,
581
737
  spendUsd,
738
+ provider429Count,
582
739
  report,
583
740
  killedBy,
584
741
  ...(claim === undefined ? {} : { prUrl: claim.prUrl, headSha: claim.headSha }),
585
- });
742
+ }));
586
743
  }
587
744
 
588
745
  // An explicit later verdict always wins: a worker that pushed green and then
589
746
  // stopped to ask a question means the question. The earlier claim is only
590
747
  // restored when the last thing said was not a verdict at all.
591
748
  if (claim !== undefined && !hasVerdictLine(report)) {
592
- return withSessionFacts({ state: "pushed-green", ...claim, turns, spendUsd, report });
749
+ return withMetrics(withSessionFacts({ state: "pushed-green", ...claim, turns, spendUsd, provider429Count, report }));
593
750
  }
594
- return withSessionFacts({
751
+ return withMetrics(withSessionFacts({
595
752
  ...deriveResult(report, o.repoSlug),
596
753
  turns,
597
754
  spendUsd,
755
+ provider429Count,
598
756
  report,
599
- });
757
+ }));
600
758
  }
601
759
 
602
760
  /**
@@ -624,6 +782,21 @@ export function costUsdFromMessage(message: unknown): number | undefined {
624
782
  return any ? sum : undefined;
625
783
  }
626
784
 
785
+ /**
786
+ * Is this assistant message a provider HTTP 429 the harness recorded mid-run?
787
+ *
788
+ * Live transcripts mark a rate-limited turn with `stopReason:"error"`,
789
+ * `errorStatus:429` and a message like `429 Provider returned error`, and the
790
+ * harness's in-session auto-retry usually swallows it — the run carries on and
791
+ * the 429 never surfaces as `lastError` (#573). Exported so a unit test can pin
792
+ * the signature without standing up a session; the count itself distinguishes a
793
+ * run that drowned in them from a run that hit one and recovered.
794
+ */
795
+ export function provider429FromMessage(message: unknown): boolean {
796
+ if (field(message, "stopReason") !== "error") return false;
797
+ return field(message, "errorStatus") === 429;
798
+ }
799
+
627
800
  /**
628
801
  * Read one property off an unvalidated harness event. The event union lives in
629
802
  * the peer dependency, so the worker narrows the handful of fields it reads