omp-conductor 0.10.0 → 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/types.ts CHANGED
@@ -114,6 +114,14 @@ export interface RepoTarget {
114
114
  * the worker brief.
115
115
  */
116
116
  graphProject?: string;
117
+ /**
118
+ * Ordered-migration-chain guard (#227): repo-relative directory whose *.py
119
+ * files declare `revision`/`down_revision`. When set, `conductor_pr_merge`
120
+ * refuses a merge that would collide with the base branch's chain (id reuse,
121
+ * a deleted migration, or a combined graph with more than one head). Absent
122
+ * = no chain check.
123
+ */
124
+ migrations?: { dir: string };
117
125
  }
118
126
 
119
127
  /**
@@ -140,6 +148,59 @@ export type ReportScope = (typeof REPORT_SCOPES)[number];
140
148
  */
141
149
  export const DEFAULT_REPORT_SCOPE: ReportScope = "material";
142
150
 
151
+ /**
152
+ * The four answers the wizard offers for "how loud should the orchestrator be".
153
+ * Three are legacy presets; `quiet` is the explicit form: interrupt only for
154
+ * escalations, fleet stops and confirmed failures, with one daily rollup
155
+ * (#229).
156
+ */
157
+ export type ReportScopeChoice = ReportScope | "quiet";
158
+
159
+ /**
160
+ * Which categories may interrupt the operator's phone. Each maps to a tier-2
161
+ * escalation channel: `tier2` (a plain decision), `decision-needed`, a
162
+ * fleet-stopping condition, a confirmed failure, or any material event. What an
163
+ * `interruptOn` list omits is not dropped — it accumulates for the digest.
164
+ * Data rather than booleans so the config validator, the escalator and the
165
+ * orchestrator prompt all enumerate the same vocabulary (#229).
166
+ */
167
+ export const INTERRUPT_CATEGORIES = [
168
+ "tier2",
169
+ "decision-needed",
170
+ "fleet-stopped",
171
+ "confirmed-failure",
172
+ "material",
173
+ ] as const;
174
+
175
+ export type InterruptCategory = (typeof INTERRUPT_CATEGORIES)[number];
176
+
177
+ /** When the daily/report rollup is due. */
178
+ export const DIGEST_CADENCES = ["none", "per-tick", "daily"] as const;
179
+
180
+ export type DigestCadence = (typeof DIGEST_CADENCES)[number];
181
+
182
+ /**
183
+ * Explicit reporting policy: which categories interrupt, and when the daily
184
+ * rollup happens. `scopePreset` is set when the config came from a legacy
185
+ * `scope:` preset, and drives the preset tick prompt text.
186
+ */
187
+ export interface ReportingPolicy {
188
+ interruptOn: InterruptCategory[];
189
+ digest: { cadence: DigestCadence; at?: string; timezone?: string };
190
+ /** Set when the policy came from a legacy `scope` preset. */
191
+ scopePreset?: ReportScope;
192
+ }
193
+
194
+ /**
195
+ * The default policy: the legacy `material` preset, spelled out so an explicit
196
+ * config can name it and the wizard can show it.
197
+ */
198
+ export const DEFAULT_REPORT_POLICY: ReportingPolicy = {
199
+ interruptOn: [...INTERRUPT_CATEGORIES],
200
+ digest: { cadence: "per-tick" },
201
+ scopePreset: DEFAULT_REPORT_SCOPE,
202
+ };
203
+
143
204
  /**
144
205
  * The irreversible tool-call shapes this package recognises and gates. Data
145
206
  * here rather than in `release-policy.ts` because the config validator, the
@@ -431,6 +492,9 @@ export const LABEL_REASONS = [
431
492
  "promoted-to-queue",
432
493
  /** Re-briefed after a failed attempt, and put back on the queue. */
433
494
  "re-briefed",
495
+ /** Delivered: the PR merged and the issue closed, and the queue label comes
496
+ * off as completed lifecycle cleanup — not superseded, not a re-brief (#230). */
497
+ "completed",
434
498
  /** Parked: it needs a decision only a human can make. */
435
499
  "needs-human",
436
500
  "duplicate",
@@ -534,11 +598,13 @@ export interface ProjectConfig {
534
598
  */
535
599
  policy?: ProjectPolicy;
536
600
  /**
537
- * How loud the orchestrator is. Optional on disk a config written before
538
- * this key existed loads as {@link DEFAULT_REPORT_SCOPE} so read it through
539
- * `resolveReportScope` rather than reaching for `.scope` directly.
601
+ * How loud the orchestrator is, and when the daily rollup happens. Optional
602
+ * on disk a config written before this key existed loads as
603
+ * {@link DEFAULT_REPORT_POLICY} so read it through `resolveReportScope`
604
+ * rather than reaching for `.scope` directly. A legacy `scope:` value is
605
+ * accepted and materialised as a preset (`.scopePreset`).
540
606
  */
541
- reporting?: { scope: ReportScope };
607
+ reporting?: ReportingPolicy;
542
608
  /** Parent directory for per-run worktrees. */
543
609
  workspaceRoot: string;
544
610
  /** Cache of bare clones, so N runs share one fetch instead of N. */
@@ -832,6 +898,7 @@ export const FAILURE_CLASSES = [
832
898
  "orphan-dirty",
833
899
  "settlement-stuck",
834
900
  "provider-credit",
901
+ "provider-transient",
835
902
  "unknown",
836
903
  ] as const;
837
904
 
@@ -969,6 +1036,9 @@ export interface DispatchSummary {
969
1036
  ready: number;
970
1037
  routed: number;
971
1038
  admitted: number;
1039
+ /** Ready issues carrying a state label — claimed by a live or settling run.
1040
+ * Optional: persisted old rows lack it, so readers use `?? 0` (#228). */
1041
+ claimed?: number;
972
1042
  /** True only for system/API failures, never ordinary policy holds. */
973
1043
  degraded: boolean;
974
1044
  holds: AdmissionHoldSummary[];
@@ -1223,6 +1293,15 @@ export interface Store {
1223
1293
  wasNotified(key: string): boolean;
1224
1294
  recordDispatch(project: string, summary: DispatchSummary): void;
1225
1295
  latestDispatch(project: string): DispatchSummary | undefined;
1296
+ /** The newest `digest:` dedupe key a project has run toward without ending in
1297
+ * failure — what the off-schedule-digest refusal compares against (#229). */
1298
+ lastDigestDedupeKey(project: string): string | undefined;
1299
+ /** Hold a tier-2 escalation the interrupt policy deferred to the digest. */
1300
+ addHeldNotice(notice: HeldNoticeDraft): void;
1301
+ /** Held notices still owed (not yet re-surfaced by a digest pass). */
1302
+ undigestedNotices(project: string): HeldNotice[];
1303
+ /** Mark every undigested notice for a project as re-surfaced. */
1304
+ markNoticesDigested(project: string, at: number): void;
1226
1305
  /** Add one bounded observation to the per-day friction rollup. */
1227
1306
  recordFriction(project: string, observation: FrictionObservation): void;
1228
1307
  /** Repeated signals not surfaced within the supplied cooldown window. */
@@ -1385,6 +1464,28 @@ export interface Escalation {
1385
1464
  summary: string;
1386
1465
  detail?: string;
1387
1466
  runId?: string;
1467
+ /** Tier-2-only interrupt category; defaults to `"tier2"`. Drives whether the
1468
+ * escalation pages now or is held for the digest under the project's
1469
+ * `reporting.interruptOn` policy (#229). */
1470
+ category?: InterruptCategory;
1471
+ }
1472
+
1473
+ /** A tier-2 escalation the project's interrupt policy deferred to the digest. */
1474
+ export interface HeldNotice {
1475
+ id: string;
1476
+ category: InterruptCategory;
1477
+ summary: string;
1478
+ detail: string;
1479
+ createdAt: number;
1480
+ }
1481
+
1482
+ /** What it takes to persist one held notice. */
1483
+ export interface HeldNoticeDraft {
1484
+ project: string;
1485
+ category: InterruptCategory;
1486
+ summary: string;
1487
+ detail: string;
1488
+ createdAt: number;
1388
1489
  }
1389
1490
 
1390
1491
  /**
@@ -1511,6 +1612,11 @@ export const VERB_REFUSALS = [
1511
1612
  "release-shape-not-executable",
1512
1613
  /** Config unreadable, project unknown, repo unrouted. Fail closed. */
1513
1614
  "config-unreadable",
1615
+ /** Merging would corrupt the ordered migration chain in `migrations.dir` (#227). */
1616
+ "chain-conflict",
1617
+ /** The PR diff or the base-branch chain could not be read — fail closed rather
1618
+ * than merge blind into a chain that might be about to fork (#227). */
1619
+ "chain-unreadable",
1514
1620
  /** The privileged half ran and the underlying command failed. */
1515
1621
  "action-failed",
1516
1622
  ] as const;
@@ -42,6 +42,8 @@ import { randomUUID } from "node:crypto";
42
42
  import { createServer, type Server, type Socket } from "node:net";
43
43
 
44
44
  import { resolvePolicy, resolveReleaseGrants } from "../config.ts";
45
+ import { chainEntriesFromDiff, chainViolations } from "../chain-check.ts";
46
+ import type { readBaseChain as readBaseChainType } from "../gitops.ts";
45
47
  import { releaseRefusal } from "../release-policy.ts";
46
48
  import { LIVE_STATES } from "../store.ts";
47
49
  import type {
@@ -181,6 +183,12 @@ export interface VerbDeps {
181
183
  pausedAt: () => number | undefined;
182
184
  log: (message: string) => void;
183
185
  now: () => number;
186
+ /**
187
+ * Read-only access to the ordered-migration-chain guard. A read seam, kept
188
+ * off `VerbActions` (the mutation surface) on purpose: `prMergeVerb` consults
189
+ * whether the merge would fork the chain, it never changes it (#227).
190
+ */
191
+ chain: { readBaseChain: typeof readBaseChainType };
184
192
  }
185
193
 
186
194
  /**
@@ -728,6 +736,47 @@ async function prMergeVerb(
728
736
  );
729
737
  }
730
738
 
739
+ const repoTarget = Object.values(project.routing.repos).find((r) => r.name === target.repo);
740
+ const chainDir = repoTarget?.migrations?.dir;
741
+ if (chainDir !== undefined && repoTarget !== undefined) {
742
+ // Checked against the base tip at merge time, not against a stale advisory:
743
+ // the author hit two merged PRs leaving main with two Alembic heads (#227).
744
+ const diff = await deps.tracker.prDiff(prUrl).catch(() => undefined);
745
+ if (diff === undefined) {
746
+ return refuse(
747
+ "chain-unreadable",
748
+ `refused: the diff of ${prUrl} could not be read, so the migration chain in ${chainDir} cannot be verified. Failing closed: a wrong merge here is a silent production schema drift (#227).`,
749
+ target.issue,
750
+ );
751
+ }
752
+ const { added, changed, deleted } = chainEntriesFromDiff(diff.files, chainDir);
753
+ if (added.length + changed.length + deleted.length > 0) {
754
+ if (diff.truncated) {
755
+ return refuse(
756
+ "chain-unreadable",
757
+ `refused: the diff of ${prUrl} was truncated and it touches ${chainDir} — the chain cannot be verified from a partial diff.`,
758
+ target.issue,
759
+ );
760
+ }
761
+ const base = await deps.chain.readBaseChain(project, repoTarget, chainDir);
762
+ if (!base.ok) {
763
+ return refuse(
764
+ "chain-unreadable",
765
+ `refused: could not read ${chainDir} at the tip of ${repoTarget.defaultBranch} (${base.stderr}).`,
766
+ target.issue,
767
+ );
768
+ }
769
+ const violations = chainViolations({ base: base.entries, added, changed, deleted });
770
+ if (violations.length > 0) {
771
+ return refuse(
772
+ "chain-conflict",
773
+ `refused: merging ${prUrl} would corrupt the ordered migration chain in ${chainDir} — ${violations.join("; ")}. Rebase onto the current tip, regenerate the migration, and call again.`,
774
+ target.issue,
775
+ );
776
+ }
777
+ }
778
+ }
779
+
731
780
  const outcome = await deps.actions.mergePr(prUrl, headSha);
732
781
  if (!outcome.ok) {
733
782
  return refuse("action-failed", `refused: gh could not merge:\n${outcome.stderr}`, target.issue);
package/src/worker.ts CHANGED
@@ -25,6 +25,11 @@ const STATE_LINE_PATTERN = /^state:\s*\S+\s*$/im;
25
25
  /** `{{KEY}}` placeholders in a brief template. */
26
26
  const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
27
27
 
28
+ function scheduleWallClock(callback: () => void, delayMs: number): () => void {
29
+ const timer = setTimeout(callback, delayMs);
30
+ return () => clearTimeout(timer);
31
+ }
32
+
28
33
  /**
29
34
  * Which ceiling stopped a run. Only ever set alongside `state: "killed"`: the
30
35
  * turn counter caught a loop, or the wall clock caught a session that was stuck
@@ -32,6 +37,24 @@ const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
32
37
  */
33
38
  export type KilledBy = "turns" | "wallclock";
34
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
+
35
58
  export interface WorkerOpts {
36
59
  brief: string;
37
60
  cwd: string;
@@ -87,15 +110,21 @@ export interface WorkerOpts {
87
110
  * would be a path that fails to open rather than an absence.
88
111
  */
89
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;
90
118
  }
91
119
 
92
120
  /**
93
- * The one collaborator worth injecting: starting a session is the only thing
94
- * `runWorker` does that needs a real harness. Defaulted, so production callers
95
- * 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.
96
123
  */
97
124
  export interface RunWorkerDeps {
98
125
  createSession: typeof createSession;
126
+ now?: () => number;
127
+ schedule?: (callback: () => void, delayMs: number) => () => void;
99
128
  }
100
129
 
101
130
  export interface WorkerResult {
@@ -203,6 +232,8 @@ export async function runWorker(
203
232
  ): Promise<WorkerResult> {
204
233
  const { workerWallClockMs } = o.caps;
205
234
  const maxTurns = o.maxTurns ?? (() => o.caps.workerMaxTurns);
235
+ const now = deps.now ?? Date.now;
236
+ const schedule = deps.schedule ?? scheduleWallClock;
206
237
 
207
238
  const session = await deps.createSession({
208
239
  cwd: o.cwd,
@@ -249,18 +280,32 @@ export async function runWorker(
249
280
  // merged (#217).
250
281
  let claim: { prUrl: string; headSha: string } | undefined;
251
282
  let killedBy: KilledBy | undefined;
252
- // Bun's global timer handle; cleared on every exit path below.
253
- 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;
254
291
  // Resolved by the first terminal `agent_end`, and by every cap kill. Only
255
292
  // ever awaited when the harness has already said it is not finished.
256
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>();
257
297
  // Set by a non-terminal `agent_end`: the harness will resume this session.
258
298
  let resuming = false;
259
299
 
260
300
  const clearWallClock = () => {
261
- if (timer === undefined) return;
262
- clearTimeout(timer);
263
- 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);
264
309
  };
265
310
 
266
311
  const kill = (by: KilledBy) => {
@@ -271,9 +316,80 @@ export async function runWorker(
271
316
  session.abort();
272
317
  // An aborted session may never reach a terminal `agent_end`. The cap is the
273
318
  // outcome now, so nothing may still be waiting for one.
319
+ done = true;
274
320
  settle();
275
321
  };
276
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
+
277
393
  session.on("turn_start", () => {
278
394
  // The documented watchdog signal, and the honest one: `turn_start` fires
279
395
  // exactly once per turn, whereas one turn can emit several assistant
@@ -326,6 +442,8 @@ export async function runWorker(
326
442
  // all — is a finished run.
327
443
  const isTerminal = field(event, "isTerminal");
328
444
  if (shouldComplete(typeof isTerminal === "boolean" ? { isTerminal } : {})) {
445
+ if (pausePhase !== "running") return;
446
+ done = true;
329
447
  settle();
330
448
  return;
331
449
  }
@@ -336,14 +454,33 @@ export async function runWorker(
336
454
  // callback does not drop the handle itself: every exit runs `clearWallClock()`
337
455
  // exactly once instead, and clearing an already-fired handle is a documented
338
456
  // no-op — cheaper than assuming a fired timer holds nothing.
339
- timer = setTimeout(() => kill("wallclock"), workerWallClockMs);
457
+ armWallClock();
340
458
 
341
459
  try {
342
- await session.prompt(o.brief);
343
- // `prompt()` returning is not the end of the run once the harness has
344
- // announced a resume: finishing here would hand back a truncated report as
345
- // the final result. Wait for the terminal `agent_end`, or for a cap.
346
- 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
+ }
347
484
  } catch (cause) {
348
485
  // Our own abort surfaces here on some paths; that is a kill, not a crash.
349
486
  if (killedBy === undefined) {
@@ -356,6 +493,7 @@ export async function runWorker(
356
493
  });
357
494
  }
358
495
  } finally {
496
+ done = true;
359
497
  // Runs on every exit, including the early return above: a live timer keeps
360
498
  // the dispatcher process alive long after the run it was guarding.
361
499
  clearWallClock();