omp-conductor 0.9.1 → 0.12.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/README.md +57 -12
- package/package.json +1 -1
- package/src/board.ts +203 -63
- package/src/briefs/orchestrator.md +45 -1
- package/src/briefs/policy.md +4 -1
- package/src/chain-check.ts +157 -0
- package/src/cli.ts +90 -2
- package/src/config.ts +185 -13
- package/src/daemon.ts +331 -20
- package/src/diff-flags.ts +44 -3
- package/src/digest-schedule.ts +59 -0
- package/src/escalate.ts +17 -0
- package/src/failure-class.ts +72 -0
- package/src/fleet.ts +2 -1
- package/src/gitops.ts +49 -0
- package/src/omp.ts +36 -5
- package/src/orchestrator-tick.ts +73 -3
- package/src/plugin.ts +18 -2
- package/src/reports.ts +5 -6
- package/src/session-host.ts +19 -3
- package/src/setup.ts +39 -8
- package/src/store.ts +73 -2
- package/src/types.ts +111 -4
- package/src/verbs/server.ts +49 -0
- package/src/worker.ts +201 -16
package/src/worker.ts
CHANGED
|
@@ -19,9 +19,17 @@ const HEAD_SHA_PATTERN = /^head:\s*([0-9a-f]{40})\s*$/im;
|
|
|
19
19
|
const PUSHED_GREEN_PATTERN = /^state:\s*pushed-green\s*$/im;
|
|
20
20
|
const BLOCKED_PATTERN = /^state:\s*blocked\s*$/im;
|
|
21
21
|
|
|
22
|
+
/** Any explicit verdict line, whatever it claims. */
|
|
23
|
+
const STATE_LINE_PATTERN = /^state:\s*\S+\s*$/im;
|
|
24
|
+
|
|
22
25
|
/** `{{KEY}}` placeholders in a brief template. */
|
|
23
26
|
const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
|
|
24
27
|
|
|
28
|
+
function scheduleWallClock(callback: () => void, delayMs: number): () => void {
|
|
29
|
+
const timer = setTimeout(callback, delayMs);
|
|
30
|
+
return () => clearTimeout(timer);
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
/**
|
|
26
34
|
* Which ceiling stopped a run. Only ever set alongside `state: "killed"`: the
|
|
27
35
|
* turn counter caught a loop, or the wall clock caught a session that was stuck
|
|
@@ -29,6 +37,24 @@ const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
|
|
|
29
37
|
*/
|
|
30
38
|
export type KilledBy = "turns" | "wallclock";
|
|
31
39
|
|
|
40
|
+
export type WorkerPausePhase = "running" | "pausing" | "paused";
|
|
41
|
+
|
|
42
|
+
export interface WorkerPauseControl {
|
|
43
|
+
phase(): WorkerPausePhase;
|
|
44
|
+
/** Cooperative park; resolves when the harness is idle. Rejects when the
|
|
45
|
+
* run is settling, cap-killed, or already pausing/paused. */
|
|
46
|
+
pause(): Promise<void>;
|
|
47
|
+
/** Returns a parked session to work with a continuation prompt. Throws
|
|
48
|
+
* when the phase is not `paused`. */
|
|
49
|
+
resume(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What a resumed session is told. One literal so tests can pin it. */
|
|
53
|
+
export const RESUME_PROMPT =
|
|
54
|
+
"The operator paused this session and has now resumed it. Continue exactly where you left off: " +
|
|
55
|
+
"re-check the outcome of your last action before repeating it, then keep working your original " +
|
|
56
|
+
"brief to the same report contract.";
|
|
57
|
+
|
|
32
58
|
export interface WorkerOpts {
|
|
33
59
|
brief: string;
|
|
34
60
|
cwd: string;
|
|
@@ -84,15 +110,21 @@ export interface WorkerOpts {
|
|
|
84
110
|
* would be a path that fails to open rather than an absence.
|
|
85
111
|
*/
|
|
86
112
|
onSessionFile?: (path: string) => void;
|
|
113
|
+
/**
|
|
114
|
+
* Installs the run's operator pause controller the moment the session
|
|
115
|
+
* exists; absent = no pause surface (tests, one-shot callers).
|
|
116
|
+
*/
|
|
117
|
+
onPauseControl?: (control: WorkerPauseControl) => void;
|
|
87
118
|
}
|
|
88
119
|
|
|
89
120
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* never pass it and a test can hand over a fake without a live peer dependency.
|
|
121
|
+
* Session creation plus the clock seam needed to prove wall-clock behavior
|
|
122
|
+
* without sleeping. Production callers use the defaults.
|
|
93
123
|
*/
|
|
94
124
|
export interface RunWorkerDeps {
|
|
95
125
|
createSession: typeof createSession;
|
|
126
|
+
now?: () => number;
|
|
127
|
+
schedule?: (callback: () => void, delayMs: number) => () => void;
|
|
96
128
|
}
|
|
97
129
|
|
|
98
130
|
export interface WorkerResult {
|
|
@@ -160,6 +192,19 @@ export function deriveResult(report: string): {
|
|
|
160
192
|
};
|
|
161
193
|
}
|
|
162
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Did this report state a verdict at all?
|
|
197
|
+
*
|
|
198
|
+
* The discriminator between "the worker said something about its outcome" and
|
|
199
|
+
* "the worker said something else entirely". A finished worker woken by its own
|
|
200
|
+
* stale polling timers emits neither `state: pushed-green` nor `state: blocked`
|
|
201
|
+
* — it emits prose — and that is the only case where an earlier verdict is
|
|
202
|
+
* still the run's real answer.
|
|
203
|
+
*/
|
|
204
|
+
export function hasVerdictLine(report: string): boolean {
|
|
205
|
+
return STATE_LINE_PATTERN.test(report);
|
|
206
|
+
}
|
|
207
|
+
|
|
163
208
|
/**
|
|
164
209
|
* Is this `agent_end` the end of the run?
|
|
165
210
|
*
|
|
@@ -187,6 +232,8 @@ export async function runWorker(
|
|
|
187
232
|
): Promise<WorkerResult> {
|
|
188
233
|
const { workerWallClockMs } = o.caps;
|
|
189
234
|
const maxTurns = o.maxTurns ?? (() => o.caps.workerMaxTurns);
|
|
235
|
+
const now = deps.now ?? Date.now;
|
|
236
|
+
const schedule = deps.schedule ?? scheduleWallClock;
|
|
190
237
|
|
|
191
238
|
const session = await deps.createSession({
|
|
192
239
|
cwd: o.cwd,
|
|
@@ -224,19 +271,41 @@ export async function runWorker(
|
|
|
224
271
|
let turns = 0;
|
|
225
272
|
let spendUsd = 0;
|
|
226
273
|
let report = "";
|
|
274
|
+
// The newest COMPLETE `pushed-green` verdict this session emitted. Tracked
|
|
275
|
+
// apart from `report` because `report` is deliberately the newest non-empty
|
|
276
|
+
// text — a run cut off mid-sentence must still report what it said last —
|
|
277
|
+
// and a worker that finishes, then wakes on its own stale polling timers,
|
|
278
|
+
// overwrites its own verdict with "nothing to resume" chatter. veltro#406
|
|
279
|
+
// burned 180/180 turns that way and charged an attempt against a PR that
|
|
280
|
+
// merged (#217).
|
|
281
|
+
let claim: { prUrl: string; headSha: string } | undefined;
|
|
227
282
|
let killedBy: KilledBy | undefined;
|
|
228
|
-
//
|
|
229
|
-
let
|
|
283
|
+
// Canceler for the armed wall clock; invoked on every exit path below.
|
|
284
|
+
let cancelWallClock: (() => void) | undefined;
|
|
285
|
+
let wallClockRemainingMs = workerWallClockMs;
|
|
286
|
+
let wallClockArmedAt = now();
|
|
287
|
+
let pausePhase: WorkerPausePhase = "running";
|
|
288
|
+
let resumeWaiter: PromiseWithResolvers<string> | undefined;
|
|
289
|
+
// A terminal agent_end or cap has settled the run.
|
|
290
|
+
let done = false;
|
|
230
291
|
// Resolved by the first terminal `agent_end`, and by every cap kill. Only
|
|
231
292
|
// ever awaited when the harness has already said it is not finished.
|
|
232
293
|
const { promise: settled, resolve: settle } = Promise.withResolvers<void>();
|
|
294
|
+
// Resolves whenever a pause request must interrupt the existing
|
|
295
|
+
// non-terminal agent_end wait. Re-armed after its resume prompt is consumed.
|
|
296
|
+
let pauseRequested = Promise.withResolvers<void>();
|
|
233
297
|
// Set by a non-terminal `agent_end`: the harness will resume this session.
|
|
234
298
|
let resuming = false;
|
|
235
299
|
|
|
236
300
|
const clearWallClock = () => {
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
|
|
301
|
+
if (cancelWallClock === undefined) return;
|
|
302
|
+
cancelWallClock();
|
|
303
|
+
cancelWallClock = undefined;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const armWallClock = () => {
|
|
307
|
+
wallClockArmedAt = now();
|
|
308
|
+
cancelWallClock = schedule(() => kill("wallclock"), wallClockRemainingMs);
|
|
240
309
|
};
|
|
241
310
|
|
|
242
311
|
const kill = (by: KilledBy) => {
|
|
@@ -247,9 +316,80 @@ export async function runWorker(
|
|
|
247
316
|
session.abort();
|
|
248
317
|
// An aborted session may never reach a terminal `agent_end`. The cap is the
|
|
249
318
|
// outcome now, so nothing may still be waiting for one.
|
|
319
|
+
done = true;
|
|
250
320
|
settle();
|
|
251
321
|
};
|
|
252
322
|
|
|
323
|
+
const takeResumePrompt = async (): Promise<string | undefined> => {
|
|
324
|
+
const waiter = resumeWaiter;
|
|
325
|
+
if (waiter === undefined) return undefined;
|
|
326
|
+
const prompt = await Promise.race([
|
|
327
|
+
waiter.promise,
|
|
328
|
+
settled.then(() => undefined),
|
|
329
|
+
]);
|
|
330
|
+
if (resumeWaiter === waiter) {
|
|
331
|
+
resumeWaiter = undefined;
|
|
332
|
+
pauseRequested = Promise.withResolvers<void>();
|
|
333
|
+
}
|
|
334
|
+
return killedBy !== undefined || done ? undefined : prompt;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
o.onPauseControl?.({
|
|
338
|
+
phase: () => pausePhase,
|
|
339
|
+
pause: async () => {
|
|
340
|
+
if (killedBy !== undefined || done) {
|
|
341
|
+
throw new Error("the run is settling; nothing left to pause");
|
|
342
|
+
}
|
|
343
|
+
if (pausePhase !== "running") throw new Error(`the worker is already ${pausePhase}`);
|
|
344
|
+
if (resumeWaiter !== undefined) {
|
|
345
|
+
throw new Error("the worker resume is still starting");
|
|
346
|
+
}
|
|
347
|
+
pausePhase = "pausing";
|
|
348
|
+
resumeWaiter = Promise.withResolvers<string>();
|
|
349
|
+
pauseRequested.resolve();
|
|
350
|
+
// Bank the remaining wall clock before the abort: a slow drain must not
|
|
351
|
+
// be cap-killed mid-park (#238 acceptance 3).
|
|
352
|
+
wallClockRemainingMs = Math.max(
|
|
353
|
+
1_000,
|
|
354
|
+
wallClockRemainingMs - (now() - wallClockArmedAt),
|
|
355
|
+
);
|
|
356
|
+
clearWallClock();
|
|
357
|
+
try {
|
|
358
|
+
await session.park();
|
|
359
|
+
} catch (err) {
|
|
360
|
+
pausePhase = "running";
|
|
361
|
+
if (killedBy !== undefined || done) {
|
|
362
|
+
resumeWaiter = undefined;
|
|
363
|
+
throw err;
|
|
364
|
+
}
|
|
365
|
+
// The abort may already have unwound prompt() even though park itself
|
|
366
|
+
// failed. Wake that path with the same defensive continuation rather
|
|
367
|
+
// than leaving the worker hung on an orphaned waiter.
|
|
368
|
+
armWallClock();
|
|
369
|
+
resumeWaiter?.resolve(RESUME_PROMPT);
|
|
370
|
+
throw err;
|
|
371
|
+
}
|
|
372
|
+
if (killedBy !== undefined || done) {
|
|
373
|
+
pausePhase = "running";
|
|
374
|
+
resumeWaiter = undefined;
|
|
375
|
+
throw new Error("the run settled while pausing");
|
|
376
|
+
}
|
|
377
|
+
pausePhase = "paused";
|
|
378
|
+
},
|
|
379
|
+
resume: () => {
|
|
380
|
+
if (pausePhase !== "paused") {
|
|
381
|
+
throw new Error(
|
|
382
|
+
pausePhase === "pausing"
|
|
383
|
+
? "still pausing — wait until it reports paused"
|
|
384
|
+
: "the worker is not paused",
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
pausePhase = "running";
|
|
388
|
+
armWallClock();
|
|
389
|
+
resumeWaiter?.resolve(RESUME_PROMPT);
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
|
|
253
393
|
session.on("turn_start", () => {
|
|
254
394
|
// The documented watchdog signal, and the honest one: `turn_start` fires
|
|
255
395
|
// exactly once per turn, whereas one turn can emit several assistant
|
|
@@ -265,7 +405,13 @@ export async function runWorker(
|
|
|
265
405
|
// Keep the newest non-empty assistant text: whatever the worker said last
|
|
266
406
|
// is its report, whether it finished cleanly or was cut off.
|
|
267
407
|
const text = reportText(field(message, "content"));
|
|
268
|
-
if (text !== "")
|
|
408
|
+
if (text !== "") {
|
|
409
|
+
report = text;
|
|
410
|
+
const stated = deriveResult(text);
|
|
411
|
+
if (stated.state === "pushed-green" && stated.prUrl !== undefined && stated.headSha !== undefined) {
|
|
412
|
+
claim = { prUrl: stated.prUrl, headSha: stated.headSha };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
269
415
|
|
|
270
416
|
// Real cost lives on assistant messages as `usage.cost.total` (live hermes
|
|
271
417
|
// transcripts, 2026-08-07). The earlier agent_end.telemetry path never
|
|
@@ -296,6 +442,8 @@ export async function runWorker(
|
|
|
296
442
|
// all — is a finished run.
|
|
297
443
|
const isTerminal = field(event, "isTerminal");
|
|
298
444
|
if (shouldComplete(typeof isTerminal === "boolean" ? { isTerminal } : {})) {
|
|
445
|
+
if (pausePhase !== "running") return;
|
|
446
|
+
done = true;
|
|
299
447
|
settle();
|
|
300
448
|
return;
|
|
301
449
|
}
|
|
@@ -306,14 +454,33 @@ export async function runWorker(
|
|
|
306
454
|
// callback does not drop the handle itself: every exit runs `clearWallClock()`
|
|
307
455
|
// exactly once instead, and clearing an already-fired handle is a documented
|
|
308
456
|
// no-op — cheaper than assuming a fired timer holds nothing.
|
|
309
|
-
|
|
457
|
+
armWallClock();
|
|
310
458
|
|
|
311
459
|
try {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
460
|
+
let next: string | undefined = o.brief;
|
|
461
|
+
while (next !== undefined) {
|
|
462
|
+
try {
|
|
463
|
+
await session.prompt(next);
|
|
464
|
+
} catch (cause) {
|
|
465
|
+
// Our own abort surfaces here on some paths: a cap kill (existing
|
|
466
|
+
// behavior) or an operator park (new). Anything else is a real failure.
|
|
467
|
+
if (killedBy === undefined && resumeWaiter === undefined) throw cause;
|
|
468
|
+
}
|
|
469
|
+
next = undefined;
|
|
470
|
+
if (killedBy !== undefined) break;
|
|
471
|
+
if (resumeWaiter === undefined && resuming && !done) {
|
|
472
|
+
// A non-terminal agent_end ordinarily waits for the harness to finish
|
|
473
|
+
// later. A pause request must wake that wait so resume can prompt the
|
|
474
|
+
// same session instead of hanging behind the old settlement promise.
|
|
475
|
+
await Promise.race([settled, pauseRequested.promise]);
|
|
476
|
+
}
|
|
477
|
+
if (killedBy !== undefined) break;
|
|
478
|
+
if (resumeWaiter !== undefined) {
|
|
479
|
+
next = await takeResumePrompt();
|
|
480
|
+
if (next === undefined) break;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
317
484
|
} catch (cause) {
|
|
318
485
|
// Our own abort surfaces here on some paths; that is a kill, not a crash.
|
|
319
486
|
if (killedBy === undefined) {
|
|
@@ -326,6 +493,7 @@ export async function runWorker(
|
|
|
326
493
|
});
|
|
327
494
|
}
|
|
328
495
|
} finally {
|
|
496
|
+
done = true;
|
|
329
497
|
// Runs on every exit, including the early return above: a live timer keeps
|
|
330
498
|
// the dispatcher process alive long after the run it was guarding.
|
|
331
499
|
clearWallClock();
|
|
@@ -337,9 +505,26 @@ export async function runWorker(
|
|
|
337
505
|
}
|
|
338
506
|
|
|
339
507
|
if (killedBy !== undefined) {
|
|
340
|
-
|
|
508
|
+
// The PR and head are facts the session already established, so they
|
|
509
|
+
// survive the kill. Without them `shouldContinueAfterTurnsCap` sees no
|
|
510
|
+
// artifacts and charges an implementation attempt for a cap kill that had
|
|
511
|
+
// real work to continue from.
|
|
512
|
+
return withSessionFacts({
|
|
513
|
+
state: "killed",
|
|
514
|
+
turns,
|
|
515
|
+
spendUsd,
|
|
516
|
+
report,
|
|
517
|
+
killedBy,
|
|
518
|
+
...(claim === undefined ? {} : { prUrl: claim.prUrl, headSha: claim.headSha }),
|
|
519
|
+
});
|
|
341
520
|
}
|
|
342
521
|
|
|
522
|
+
// An explicit later verdict always wins: a worker that pushed green and then
|
|
523
|
+
// stopped to ask a question means the question. The earlier claim is only
|
|
524
|
+
// restored when the last thing said was not a verdict at all.
|
|
525
|
+
if (claim !== undefined && !hasVerdictLine(report)) {
|
|
526
|
+
return withSessionFacts({ state: "pushed-green", ...claim, turns, spendUsd, report });
|
|
527
|
+
}
|
|
343
528
|
return withSessionFacts({
|
|
344
529
|
...deriveResult(report),
|
|
345
530
|
turns,
|