omp-conductor 0.12.0 → 0.14.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
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { createSession, disposeSession } from "./omp.ts";
14
+ import type { ReleaseBlockContext } from "./release-policy.ts";
14
15
  import type { Caps, ReleaseShape, ResolvedGrants, RunState } from "./types.ts";
15
16
 
16
17
  /** Structured evidence fields from the worker's final report. */
@@ -21,6 +22,8 @@ const BLOCKED_PATTERN = /^state:\s*blocked\s*$/im;
21
22
 
22
23
  /** Any explicit verdict line, whatever it claims. */
23
24
  const STATE_LINE_PATTERN = /^state:\s*\S+\s*$/im;
25
+ /** GitHub PR URLs in unstructured prose; capture their canonical `owner/repo`. */
26
+ const GITHUB_PR_URL_PATTERN = /https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/\d+\b/gi;
24
27
 
25
28
  /** `{{KEY}}` placeholders in a brief template. */
26
29
  const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
@@ -47,6 +50,9 @@ export interface WorkerPauseControl {
47
50
  /** Returns a parked session to work with a continuation prompt. Throws
48
51
  * when the phase is not `paused`. */
49
52
  resume(): void;
53
+ /** Terminally ends this run for an operator-supplied reason. The run is
54
+ * recorded as an administrative kill and cannot be resumed. */
55
+ stop(reason: string): void;
50
56
  }
51
57
 
52
58
  /** What a resumed session is told. One literal so tests can pin it. */
@@ -59,6 +65,8 @@ export interface WorkerOpts {
59
65
  brief: string;
60
66
  cwd: string;
61
67
  caps: Caps;
68
+ /** Canonical `owner/repo` identity used to scope prose-only PR URLs. */
69
+ repoSlug?: string;
62
70
  /**
63
71
  * Directory the harness writes this run's transcript into — a directory, not
64
72
  * a file. The SDK takes no `sessionFile` input, so naming a path here would
@@ -78,7 +86,7 @@ export interface WorkerOpts {
78
86
  */
79
87
  releaseGrants?: ResolvedGrants;
80
88
  /** Durable audit sink for rejected release/deploy calls. */
81
- onReleaseBlocked?: (shape: ReleaseShape) => void;
89
+ onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
82
90
  /** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
83
91
  onSpawn?: (pid: number) => void;
84
92
  /** Control socket for that child, beside the run's own session directory. */
@@ -135,6 +143,8 @@ export interface WorkerResult {
135
143
  spendUsd: number;
136
144
  report: string;
137
145
  killedBy?: KilledBy;
146
+ /** Present only when an operator terminally stopped this run. */
147
+ stoppedReason?: string;
138
148
  /**
139
149
  * Transcript the session actually opened, absent if it opened none. Recorded
140
150
  * per run because it is the only readable evidence left once the worktree is
@@ -173,15 +183,23 @@ export function renderBrief(template: string, vars: Record<string, string>): str
173
183
  * asks the tracker to verify those facts independently. Missing or malformed
174
184
  * evidence fails closed.
175
185
  */
176
- export function deriveResult(report: string): {
186
+ export function deriveResult(report: string, repoSlug?: string): {
177
187
  state: RunState;
178
188
  prUrl?: string;
179
189
  headSha?: string;
180
190
  } {
181
- const prUrl = PR_URL_PATTERN.exec(report)?.[1];
191
+ const structuredPrUrl = PR_URL_PATTERN.exec(report)?.[1];
182
192
  const headSha = HEAD_SHA_PATTERN.exec(report)?.[1]?.toLowerCase();
183
- if (PUSHED_GREEN_PATTERN.test(report) && prUrl !== undefined && headSha !== undefined) {
184
- return { state: "pushed-green", prUrl, headSha };
193
+ if (PUSHED_GREEN_PATTERN.test(report) && structuredPrUrl !== undefined && headSha !== undefined) {
194
+ return { state: "pushed-green", prUrl: structuredPrUrl, headSha };
195
+ }
196
+
197
+ let prUrl = structuredPrUrl;
198
+ if (prUrl === undefined && repoSlug !== undefined) {
199
+ const expectedRepo = repoSlug.toLowerCase();
200
+ for (const match of report.matchAll(GITHUB_PR_URL_PATTERN)) {
201
+ if (match[1]?.toLowerCase() === expectedRepo) prUrl = match[0];
202
+ }
185
203
  }
186
204
 
187
205
  const state: RunState = BLOCKED_PATTERN.test(report) ? "blocked" : "failed";
@@ -280,6 +298,7 @@ export async function runWorker(
280
298
  // merged (#217).
281
299
  let claim: { prUrl: string; headSha: string } | undefined;
282
300
  let killedBy: KilledBy | undefined;
301
+ let stoppedReason: string | undefined;
283
302
  // Canceler for the armed wall clock; invoked on every exit path below.
284
303
  let cancelWallClock: (() => void) | undefined;
285
304
  let wallClockRemainingMs = workerWallClockMs;
@@ -309,7 +328,7 @@ export async function runWorker(
309
328
  };
310
329
 
311
330
  const kill = (by: KilledBy) => {
312
- if (killedBy !== undefined) return;
331
+ if (killedBy !== undefined || stoppedReason !== undefined) return;
313
332
  killedBy = by;
314
333
  o.onKilled?.(by);
315
334
  clearWallClock();
@@ -331,7 +350,7 @@ export async function runWorker(
331
350
  resumeWaiter = undefined;
332
351
  pauseRequested = Promise.withResolvers<void>();
333
352
  }
334
- return killedBy !== undefined || done ? undefined : prompt;
353
+ return killedBy !== undefined || stoppedReason !== undefined || done ? undefined : prompt;
335
354
  };
336
355
 
337
356
  o.onPauseControl?.({
@@ -388,6 +407,16 @@ export async function runWorker(
388
407
  armWallClock();
389
408
  resumeWaiter?.resolve(RESUME_PROMPT);
390
409
  },
410
+ stop: (reason) => {
411
+ if (killedBy !== undefined || done) {
412
+ throw new Error("the run is settling; nothing left to stop");
413
+ }
414
+ stoppedReason = reason;
415
+ clearWallClock();
416
+ done = true;
417
+ settle();
418
+ session.abort();
419
+ },
391
420
  });
392
421
 
393
422
  session.on("turn_start", () => {
@@ -407,7 +436,7 @@ export async function runWorker(
407
436
  const text = reportText(field(message, "content"));
408
437
  if (text !== "") {
409
438
  report = text;
410
- const stated = deriveResult(text);
439
+ const stated = deriveResult(text, o.repoSlug);
411
440
  if (stated.state === "pushed-green" && stated.prUrl !== undefined && stated.headSha !== undefined) {
412
441
  claim = { prUrl: stated.prUrl, headSha: stated.headSha };
413
442
  }
@@ -464,7 +493,7 @@ export async function runWorker(
464
493
  } catch (cause) {
465
494
  // Our own abort surfaces here on some paths: a cap kill (existing
466
495
  // behavior) or an operator park (new). Anything else is a real failure.
467
- if (killedBy === undefined && resumeWaiter === undefined) throw cause;
496
+ if (killedBy === undefined && stoppedReason === undefined && resumeWaiter === undefined) throw cause;
468
497
  }
469
498
  next = undefined;
470
499
  if (killedBy !== undefined) break;
@@ -483,7 +512,7 @@ export async function runWorker(
483
512
  }
484
513
  } catch (cause) {
485
514
  // Our own abort surfaces here on some paths; that is a kill, not a crash.
486
- if (killedBy === undefined) {
515
+ if (killedBy === undefined && stoppedReason === undefined) {
487
516
  const detail = cause instanceof Error ? cause.message : String(cause);
488
517
  return withSessionFacts({
489
518
  state: "failed",
@@ -504,6 +533,17 @@ export async function runWorker(
504
533
  }
505
534
  }
506
535
 
536
+ if (stoppedReason !== undefined) {
537
+ return withSessionFacts({
538
+ state: "stopped",
539
+ turns,
540
+ spendUsd,
541
+ report,
542
+ stoppedReason,
543
+ ...(claim === undefined ? {} : { prUrl: claim.prUrl, headSha: claim.headSha }),
544
+ });
545
+ }
546
+
507
547
  if (killedBy !== undefined) {
508
548
  // The PR and head are facts the session already established, so they
509
549
  // survive the kill. Without them `shouldContinueAfterTurnsCap` sees no
@@ -526,7 +566,7 @@ export async function runWorker(
526
566
  return withSessionFacts({ state: "pushed-green", ...claim, turns, spendUsd, report });
527
567
  }
528
568
  return withSessionFacts({
529
- ...deriveResult(report),
569
+ ...deriveResult(report, o.repoSlug),
530
570
  turns,
531
571
  spendUsd,
532
572
  report,
package/src/worktree.ts CHANGED
@@ -317,6 +317,11 @@ async function ensureMirrorUnlocked(repo: RepoTarget, mirrorRoot: string): Promi
317
317
  throw err;
318
318
  }
319
319
  await configureMirror(mirrorPath);
320
+ // `clone --mirror` writes upstream heads under `refs/heads/*`; the safe
321
+ // normal-clone refspec above uses `refs/remotes/origin/*`. Populate that
322
+ // namespace before returning so first-use callers see the same fresh mirror
323
+ // shape as every later refresh.
324
+ await refreshMirror(mirrorPath);
320
325
  return mirrorPath;
321
326
  }
322
327