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.
- package/REFERENCE.md +81 -6
- package/package.json +2 -1
- package/schema/config.schema.json +6 -0
- package/src/admission.ts +745 -0
- package/src/ask.ts +47 -0
- package/src/backups.ts +19 -7
- package/src/board.ts +1 -2
- package/src/briefs/orchestrator.md +62 -4
- package/src/cli.ts +26 -0
- package/src/commands/context.ts +3 -0
- package/src/commands/decision.ts +10 -1
- package/src/commands/doctor.ts +2 -0
- package/src/commands/message.ts +8 -1
- package/src/commands/restart.ts +93 -54
- package/src/commands/restore-db.ts +146 -0
- package/src/commands/stop.ts +66 -34
- package/src/commands/unfreeze.ts +56 -0
- package/src/commands/watch.ts +77 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +24 -0
- package/src/daemon.ts +485 -577
- package/src/dashboard/server.ts +2 -1
- package/src/decisions.ts +32 -7
- package/src/depends-on.ts +73 -0
- package/src/doctor.ts +418 -8
- package/src/escalate.ts +122 -15
- package/src/failure-class.ts +47 -0
- package/src/fleet.ts +55 -377
- package/src/gitops.ts +86 -1
- package/src/lifecycle.ts +113 -2
- package/src/log.ts +40 -0
- package/src/model-fallback.ts +3 -2
- package/src/omp-settings.ts +114 -0
- package/src/omp.ts +63 -0
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +14 -1
- package/src/orchestrator.ts +14 -0
- package/src/release-policy.ts +163 -18
- package/src/reports.ts +124 -12
- package/src/session-host.ts +6 -0
- package/src/setup-host.ts +386 -17
- package/src/setup-install.ts +40 -2
- package/src/setup-wizard.ts +314 -113
- package/src/setup.ts +58 -1
- package/src/status-render.ts +445 -0
- package/src/stop-provenance.ts +119 -0
- package/src/store.ts +533 -11
- package/src/types.ts +298 -4
- package/src/unblock.ts +1 -1
- package/src/upgrade-verify.ts +1 -1
- package/src/upgrade.ts +27 -8
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +52 -1
- package/src/wizard-ui.ts +261 -46
- 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
|
-
/**
|
|
40
|
-
*
|
|
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
|
-
|
|
93
|
+
let rl: Interface = createInterface({ input, output });
|
|
82
94
|
let closed = false;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
141
|
+
ended = false;
|
|
142
|
+
rl = createInterface({ input, output });
|
|
143
|
+
attach(rl);
|
|
109
144
|
};
|
|
110
|
-
rl
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|