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/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
- * The one collaborator worth injecting: starting a session is the only thing
91
- * `runWorker` does that needs a real harness. Defaulted, so production callers
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
- // Bun's global timer handle; cleared on every exit path below.
229
- let timer: Timer | undefined;
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 (timer === undefined) return;
238
- clearTimeout(timer);
239
- timer = undefined;
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 !== "") report = 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
- timer = setTimeout(() => kill("wallclock"), workerWallClockMs);
457
+ armWallClock();
310
458
 
311
459
  try {
312
- await session.prompt(o.brief);
313
- // `prompt()` returning is not the end of the run once the harness has
314
- // announced a resume: finishing here would hand back a truncated report as
315
- // the final result. Wait for the terminal `agent_end`, or for a cap.
316
- if (resuming && killedBy === undefined) await settled;
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
- return withSessionFacts({ state: "killed", turns, spendUsd, report, killedBy });
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,