omp-conductor 0.18.2 → 0.19.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.
Files changed (60) hide show
  1. package/README.md +105 -40
  2. package/REFERENCE.md +865 -30
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +26 -0
  5. package/src/admission.ts +212 -26
  6. package/src/ask.ts +288 -1
  7. package/src/briefs/orchestrator.md +6 -5
  8. package/src/cli.ts +5 -1
  9. package/src/command-help.ts +9 -1
  10. package/src/command-manifest.ts +36 -3
  11. package/src/commands/arm.ts +5 -1
  12. package/src/commands/context.ts +2 -0
  13. package/src/commands/message.ts +26 -2
  14. package/src/commands/reconcile-units.ts +104 -0
  15. package/src/commands/release-composition.ts +232 -0
  16. package/src/commands/resume.ts +2 -27
  17. package/src/commands/setup.ts +101 -16
  18. package/src/commands/stats.ts +11 -30
  19. package/src/commands/tail.ts +31 -1
  20. package/src/commands/upgrade.ts +20 -3
  21. package/src/commands/verb.ts +2 -1
  22. package/src/config-schema.ts +19 -0
  23. package/src/config.ts +80 -0
  24. package/src/credential-class.ts +366 -0
  25. package/src/daemon.ts +1218 -288
  26. package/src/dashboard/app.js +504 -2
  27. package/src/dashboard/controls.ts +336 -0
  28. package/src/dashboard/index.html +30 -0
  29. package/src/dashboard/server.ts +271 -30
  30. package/src/dashboard/style.css +116 -0
  31. package/src/dashboard/transcript.ts +173 -0
  32. package/src/doctor.ts +377 -20
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +497 -15
  35. package/src/host.ts +6 -130
  36. package/src/omp.ts +29 -0
  37. package/src/orchestrator-tick.ts +343 -88
  38. package/src/pause.ts +233 -0
  39. package/src/settlement.ts +159 -2
  40. package/src/setup-answers.ts +97 -0
  41. package/src/setup-host.ts +321 -1155
  42. package/src/setup-install.ts +204 -27
  43. package/src/setup-wizard.ts +111 -50
  44. package/src/setup.ts +33 -0
  45. package/src/spend-telemetry.ts +117 -0
  46. package/src/stats.ts +35 -0
  47. package/src/status-render.ts +348 -19
  48. package/src/store.ts +1229 -55
  49. package/src/telegram-freshness.ts +269 -0
  50. package/src/to-spec.ts +27 -0
  51. package/src/types.ts +697 -4
  52. package/src/unblock.ts +22 -0
  53. package/src/unit-reconcile.ts +303 -0
  54. package/src/upgrade-verify.ts +8 -1
  55. package/src/upgrade.ts +299 -12
  56. package/src/verbs/actions.ts +124 -10
  57. package/src/verbs/protocol.ts +70 -2
  58. package/src/verbs/server.ts +447 -8
  59. package/src/wake.ts +48 -0
  60. package/src/worker.ts +403 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.18.2",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -35,6 +35,18 @@
35
35
  ],
36
36
  "description": "Rolling-day spend ceiling; null means no spend gate (default 25)"
37
37
  },
38
+ "maxRunSpendUsd": {
39
+ "anyOf": [
40
+ {
41
+ "type": "number",
42
+ "exclusiveMinimum": 0
43
+ },
44
+ {
45
+ "type": "null"
46
+ }
47
+ ],
48
+ "description": "Ceiling on one run, reserved from the daily budget before it launches; null derives it from dailySpendUsd. Must not exceed dailySpendUsd"
49
+ },
38
50
  "planUsage": {
39
51
  "anyOf": [
40
52
  {
@@ -272,6 +284,18 @@
272
284
  ],
273
285
  "description": "Rolling-day spend ceiling; null means no spend gate (default 25)"
274
286
  },
287
+ "maxRunSpendUsd": {
288
+ "anyOf": [
289
+ {
290
+ "type": "number",
291
+ "exclusiveMinimum": 0
292
+ },
293
+ {
294
+ "type": "null"
295
+ }
296
+ ],
297
+ "description": "Ceiling on one run, reserved from the daily budget before it launches; null derives it from dailySpendUsd. Must not exceed dailySpendUsd"
298
+ },
275
299
  "planUsage": {
276
300
  "anyOf": [
277
301
  {
@@ -334,6 +358,8 @@
334
358
  "workerModel": {},
335
359
  "modelFallbacks": {},
336
360
  "modelFallbackThreshold": {},
361
+ "workerEscalationModel": {},
362
+ "requireOauthProviders": {},
337
363
  "ompSettings": {},
338
364
  "workerAdvisor": {},
339
365
  "escalation": {
package/src/admission.ts CHANGED
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { join } from "node:path";
18
18
  import { log, errText, safeEscalate } from "./log.ts";
19
+ import { oauthFenceVerdict, type CredentialClassProbeResult } from "./credential-class.ts";
19
20
  import type {
20
21
  Caps,
21
22
  EffectiveModel,
@@ -35,6 +36,7 @@ import type {
35
36
  import { readPlanUsage, type PlanUsageStatus, type UsageSource } from "./usage.ts";
36
37
  import type { CriticalBaseProbe, CriticalBaseVerdict, LaneFile, LaneSource, RunLaneProbe } from "./gitops.ts";
37
38
  import { repoSlugFor } from "./gitops.ts";
39
+ import { LIVE_STATES } from "./store.ts";
38
40
  import { branchName, type Routed } from "./routing.ts";
39
41
  import { parseDependsOn, buildDependencyCycles } from "./depends-on.ts";
40
42
  import type { DependencyCycle, DependencyNodeReader } from "./depends-on.ts";
@@ -79,6 +81,18 @@ export interface AdmissionDeps {
79
81
  * {@link Tracker.issueBody}. The daemon always wires it.
80
82
  */
81
83
  probeBodyIn?: (repo: string, issue: number) => Promise<string | undefined>;
84
+ /**
85
+ * Reads one provider's current credential class from the harness (#852).
86
+ *
87
+ * Injected because the production reader spawns a subprocess against the live
88
+ * credential store, and admission's tests must be able to drive every verdict
89
+ * — including "the probe could not answer" — without one. Unset, the fence
90
+ * cannot verify anything, so a project that declares
91
+ * `requireOauthProviders` is held rather than dispatched: the whole point is
92
+ * that unverified and wrong cost the same. A project that declares nothing
93
+ * never calls it.
94
+ */
95
+ probeCredentialClass?: (provider: string) => Promise<CredentialClassProbeResult>;
82
96
  }
83
97
  /** `stops` are the operational ends that each require one resume. */
84
98
  export function hasContinuationBudget(stops: number, maxContinuations: number): boolean {
@@ -93,6 +107,52 @@ export function hasFailedAttemptBudget(failures: number, maxAttempts: number): b
93
107
  return failures < maxAttempts;
94
108
  }
95
109
 
110
+ /**
111
+ * Local midnight, matching how a human reads "today".
112
+ *
113
+ * ponytail: a rolling 24h window would be fairer to a run that started at
114
+ * 23:50, but midnight is what someone checking a morning spend report expects.
115
+ * Upgrade path is a `capWindow: "day" | "rolling24h"` config key.
116
+ *
117
+ * Lives here, with the spend gates that read it, and is imported by the
118
+ * composition root rather than the other way round: one definition of "today"
119
+ * for the fleet-stop check, the reservation gate and the status line, because
120
+ * three midnights would eventually disagree.
121
+ */
122
+ export function startOfToday(): number {
123
+ const d = new Date();
124
+ d.setHours(0, 0, 0, 0);
125
+ return d.getTime();
126
+ }
127
+
128
+ /**
129
+ * What one run is allowed to spend, and therefore what admission reserves for
130
+ * it before launching anything (#851).
131
+ *
132
+ * `null` means unbounded: no daily cap and no explicit per-run ceiling, so
133
+ * there is nothing to reserve and nothing to enforce.
134
+ *
135
+ * An explicit `maxRunSpendUsd` always wins. Otherwise the allowance is derived
136
+ * as the daily cap divided by the worker cap — the largest per-run reservation
137
+ * that still lets the fleet run at its configured concurrency. Reserving the
138
+ * WHOLE daily cap per run would bound overshoot just as well, but it would
139
+ * also serialise every fleet on defaults (a $25 cap and two workers would only
140
+ * ever admit one run), turning a spend guard into a concurrency change nobody
141
+ * asked for. Dividing keeps both promises: the day's total is still bounded by
142
+ * the cap, because N reservations of cap/N are the cap.
143
+ *
144
+ * A zero worker cap has no runs to allow, and is guarded rather than divided
145
+ * by.
146
+ */
147
+ export function runSpendAllowanceUsd(
148
+ caps: Pick<Caps, "dailySpendUsd" | "maxRunSpendUsd" | "maxConcurrentWorkers">,
149
+ ): number | null {
150
+ if (caps.maxRunSpendUsd !== null) return caps.maxRunSpendUsd;
151
+ if (caps.dailySpendUsd === null) return null;
152
+ const workers = Math.max(1, caps.maxConcurrentWorkers);
153
+ return caps.dailySpendUsd / workers;
154
+ }
155
+
96
156
  /** A candidate cleared for dispatch, with the attempt number it will run as.
97
157
  * `lane` is the effective file-lane declaration admission resolved for it
98
158
  * (#608): the exact snapshot the overlap gate enforced. Dispatch renders this
@@ -437,13 +497,58 @@ export async function admitCandidates(
437
497
  holds.push({ issue, reason, ...(detail === undefined ? {} : { detail }) });
438
498
  };
439
499
 
500
+ // Runs holding an ACTIVE MUTATION LEASE (#899): the only rows whose files a
501
+ // candidate must not touch, because they are the only rows something is
502
+ // currently writing through.
503
+ //
504
+ // Deliberately narrower than `activeRuns`, and this is the whole of the fix.
505
+ // A `pushed-green` / `pushed-pending` row is worker-free by construction —
506
+ // its PR is live but no process owns its branch — and seeding lane occupancy
507
+ // from it meant a settled artifact held its entire diff forever: measured on
508
+ // this fleet, worker-free #782 / PR #870 held `omp/src/daemon.ts` and idled
509
+ // slots against unrelated candidates that merely overlapped it.
510
+ //
511
+ // What does NOT change: `activeRuns` still answers same-issue ownership, the
512
+ // sibling/parent occupancy and the open-PR gate, so releasing the *lane*
513
+ // never lets the artifact's own issue be re-implemented (the named silent
514
+ // fake of #899). Only file occupancy narrows.
515
+ //
516
+ // A dispatched, unsettled review revision is a lease too. Its run row is
517
+ // `running` the moment `claimRunForReview` fires, so it is normally already
518
+ // in `LIVE_STATES`; it is unioned here anyway because the claim and the
519
+ // launch are not the same instant, and a lane the gate briefly forgets is a
520
+ // lane two writers can share.
521
+ const leasedRuns = store.leasedRuns(project.name);
522
+
523
+ // One run's actual lane: the files it has touched relative to base, read from
524
+ // its worktree and/or its branch in the mirror. Fails OPEN by construction —
525
+ // an unreadable probe yields no files — because this side of the interlock is
526
+ // automatic and continuous, and a gate that refused well-formed work whenever
527
+ // a git read hiccuped would idle the fleet on a transient. (The deliberate
528
+ // counterpart is `conductor_pr_recover`, which is a single human-initiated
529
+ // call with a retryable refusal, and therefore fails closed — #925.)
530
+ const probeRunFiles = async (run: RunRecord): Promise<LaneFile[]> => {
531
+ if (d.probeWorktreeLane === undefined) return [];
532
+ const base = project.routing.repos[run.repo]?.defaultBranch ?? "main";
533
+ try {
534
+ return await d.probeWorktreeLane({
535
+ worktree: run.worktree,
536
+ baseRef: `refs/remotes/origin/${base}`,
537
+ ...(run.branch === "" ? {} : { branchRef: `refs/heads/${run.branch}` }),
538
+ mirror: join(project.mirrorRoot, `${run.repo}.git`),
539
+ });
540
+ } catch {
541
+ return [];
542
+ }
543
+ };
544
+
440
545
  // The file-lane interlock (#555): repo -> file -> the issue holding that file.
441
- // Seeded from what a live run actually *occupies*, never from its issue body:
442
- // the probed worktrees/branches of active runs (#288) unioned since #744 —
443
- // with the lane declaration admission persisted on the row at dispatch, which
444
- // is the exact enforced snapshot, not a re-parse. The declared half is what
445
- // survives across passes: a run admitted with a lane it has not yet written
446
- // still occupies until it reaches a terminal state and leaves the active set,
546
+ // Seeded from what a leaseholder actually *occupies*, never from its issue
547
+ // body: the probed worktrees/branches of runs holding a mutation lease (#288,
548
+ // #899) unioned since #744 with the lane declaration admission persisted
549
+ // on the row at dispatch, which is the exact enforced snapshot, not a
550
+ // re-parse. The declared half is what survives across passes: a run admitted
551
+ // with a lane it has not yet written still occupies while it holds its lease,
447
552
  // and the declared lanes are extended as this pass admits candidates, so two
448
553
  // overlapping candidates cannot both clear the gate in one tick. Keyed by
449
554
  // repo because a path only collides within its own checkout: a `daemon.ts` on
@@ -466,31 +571,16 @@ export async function admitCandidates(
466
571
  }
467
572
  if (!perRepo.has(file)) perRepo.set(file, { issue: run.issue, source });
468
573
  };
469
- for (const run of activeRuns) {
574
+ for (const run of leasedRuns) {
470
575
  // The probed actual half first, so the concrete read outnames the
471
576
  // declaration for a file the run has already written.
472
- if (d.probeWorktreeLane !== undefined) {
473
- const base = project.routing.repos[run.repo]?.defaultBranch ?? "main";
474
- let files: LaneFile[];
475
- try {
476
- files = await d.probeWorktreeLane({
477
- worktree: run.worktree,
478
- baseRef: `refs/remotes/origin/${base}`,
479
- ...(run.branch === "" ? {} : { branchRef: `refs/heads/${run.branch}` }),
480
- mirror: join(project.mirrorRoot, `${run.repo}.git`),
481
- });
482
- } catch {
483
- // Fail open, like an unreadable lane: the gate never refuses a
484
- // well-formed issue because one probe could not be answered.
485
- files = [];
486
- }
487
- for (const { file, source } of files) occupy(run, file, source);
488
- }
577
+ for (const { file, source } of await probeRunFiles(run)) occupy(run, file, source);
489
578
  // The declared durable half (#744): the lane admission persisted at
490
579
  // dispatch. Occupies even before the run has written anything, on every
491
580
  // pass — the declaration lives on the row, not in a per-pass memo.
492
- // Released by the active-set filter exactly like probed occupancy: a
493
- // terminal run leaves `activeRuns` and its files stop occupying here.
581
+ // Released by the lease filter exactly like probed occupancy: a run that
582
+ // settles or is terminal stops holding a lease and its files stop
583
+ // occupying here (#899).
494
584
  if (run.lane !== undefined && run.lane.files.length > 0) {
495
585
  for (const file of run.lane.files) occupy(run, file, "declared");
496
586
  }
@@ -566,6 +656,36 @@ export async function admitCandidates(
566
656
  //
567
657
  // Placed after the cheap local busy-set read and before the first tracker
568
658
  // call, so a held fleet spends no GitHub API budget discovering it is held.
659
+ // The credential-class fence (#852), asked once per pass for the same reason
660
+ // the plan allowance is: it is a property of the fleet's credentials, not of
661
+ // any candidate, and its answer is identical for all of them. Placed
662
+ // immediately before the plan gate so both fleet-wide questions sit together,
663
+ // and so a fleet billing to the wrong credential spends no tracker budget
664
+ // discovering it is held.
665
+ //
666
+ // It clears by itself: the operator re-authenticates and the next pass passes.
667
+ // No `resume` is required and no escalation is raised here — a held candidate
668
+ // renders its reason in `status`, and inventing a page for a condition an
669
+ // operator fixes in a minute is how a guard costs more attention than it saves
670
+ // (the #110 reasoning, applied to the same shape of gate).
671
+ const requiredOauth = project.requireOauthProviders ?? [];
672
+ if (requiredOauth.length > 0 && routed.length > 0) {
673
+ const fence = await oauthFenceVerdict(
674
+ requiredOauth,
675
+ d.probeCredentialClass ??
676
+ (async (provider) => ({
677
+ ok: false,
678
+ reason: `no credential probe is wired for ${provider}`,
679
+ })),
680
+ );
681
+ if (!fence.ok) {
682
+ for (const r of routed) hold(r.issue.number, "credential-class", fence.reason);
683
+ log(`credential-class gate holding ${String(routed.length)} candidate(s): ${fence.reason}`);
684
+ store.reconcileGrooming(project.name, holds);
685
+ return { admitted: [], holds };
686
+ }
687
+ }
688
+
569
689
  const plan = await readPlanUsage(caps.planUsage, d.usage);
570
690
  if (plan.blocking) {
571
691
  for (const r of routed) hold(r.issue.number, "plan-usage-cap");
@@ -682,6 +802,23 @@ export async function admitCandidates(
682
802
  });
683
803
  }
684
804
 
805
+ // The spend reservation (#851). Read once per pass: what today has already
806
+ // cost, plus what every live run has committed but not yet spent. The daily
807
+ // cap on its own is a post-spend stop — it compares spend-to-date at
808
+ // admission and pauses the fleet on the NEXT pass — so one expensive run
809
+ // could cross it by its entire cost before anything objected. On 2026-08-21
810
+ // one `@slow` run spent an estimated $25.16 against a $25.00 cap and the
811
+ // fleet paused only after it had settled.
812
+ const runAllowanceUsd = runSpendAllowanceUsd(caps);
813
+ const dailyCapUsd = caps.dailySpendUsd;
814
+ const budgeted = runAllowanceUsd !== null && dailyCapUsd !== null;
815
+ const spentTodayUsd = budgeted ? store.spendSince(project.name, startOfToday()) : 0;
816
+ const liveReservedUsd = budgeted ? store.reservedSpendUsd(project.name) : 0;
817
+ // Candidates admitted earlier in THIS pass have no run row yet (the claim
818
+ // happens in dispatch), so the pass counts its own reservations or it would
819
+ // admit a whole slot's worth of runs against one slot's budget.
820
+ let passReservedUsd = 0;
821
+
685
822
  for (const r of routed) {
686
823
  const issue = r.issue.number;
687
824
  const cyclePath = memberCycle.get(issue);
@@ -718,6 +855,24 @@ export async function admitCandidates(
718
855
  continue;
719
856
  }
720
857
 
858
+ // Reserve before launching, never after settling: the whole allowance is
859
+ // taken out of the day's budget up front, so the worst case is bounded by
860
+ // what was reserved rather than by what a run turns out to cost. A run
861
+ // that settles for less releases the remainder simply by leaving the live
862
+ // set, which is why the next pass can admit again without any release
863
+ // bookkeeping. Distinct from the fleet-stopping `daily-spend-cap`: this
864
+ // holds one candidate while admitted work is still running.
865
+ if (budgeted && spentTodayUsd + liveReservedUsd + passReservedUsd + runAllowanceUsd > dailyCapUsd) {
866
+ const remaining = dailyCapUsd - spentTodayUsd - liveReservedUsd - passReservedUsd;
867
+ const detail =
868
+ `reserving $${runAllowanceUsd.toFixed(2)} for this run would exceed today's $${dailyCapUsd.toFixed(2)} ` +
869
+ `budget: $${spentTodayUsd.toFixed(2)} spent, $${(liveReservedUsd + passReservedUsd).toFixed(2)} reserved by ` +
870
+ `runs in flight, $${Math.max(0, remaining).toFixed(2)} left`;
871
+ hold(issue, "spend-reservation", detail);
872
+ log(`#${issue} held (spend-reservation): ${detail}`);
873
+ continue;
874
+ }
875
+
721
876
  const priorRuns = store.attemptsFor(project.name, issue);
722
877
  const failures = store.failuresFor(project.name, issue);
723
878
  if (!hasFailedAttemptBudget(failures, caps.maxAttemptsPerIssue)) {
@@ -952,6 +1107,36 @@ export async function admitCandidates(
952
1107
  log(`#${issue} skipped: open PR ${closer.url} already closes it`);
953
1108
  continue;
954
1109
  }
1110
+ // The continuation reacquire (#925). A continuation resumes writing the
1111
+ // retained branch, so the files that matter are the ones that branch
1112
+ // ACTUALLY carries — not the lane its issue declares. The declared gate
1113
+ // below cannot stand in for this: a continuation's declaration may be
1114
+ // narrower than its predecessor's diff (or absent entirely), and the
1115
+ // whole point of #899 is that the retained branch's files were released
1116
+ // the moment its worker finished, so an unrelated worker may legitimately
1117
+ // hold them by the time the continuation is admitted.
1118
+ //
1119
+ // Its own predecessor can never block it: a retained row is terminal or
1120
+ // `pushed-*`, so it holds no lease, and the holder check ignores this
1121
+ // issue's own rows in any case.
1122
+ if (retained !== undefined) {
1123
+ const carried = await probeRunFiles(retained);
1124
+ if (carried.length > 0) {
1125
+ const occupied = await ensureLaneOccupancy();
1126
+ const perRepo = occupied.get(r.repo.name);
1127
+ const collision = carried
1128
+ .map(({ file }) => ({ file, holder: perRepo?.get(file) }))
1129
+ .find(({ holder }) => holder !== undefined && holder.issue !== issue);
1130
+ if (collision !== undefined) {
1131
+ const detail =
1132
+ `${collision.file} held by run #${collision.holder!.issue} (${collision.holder!.source})` +
1133
+ ` — carried by retained branch ${retained.branch}`;
1134
+ hold(issue, "file-lane", detail);
1135
+ log(`#${issue} held (file-lane): ${detail}`);
1136
+ continue;
1137
+ }
1138
+ }
1139
+ }
955
1140
  log(`#${issue} continuing retained PR ${closer.url} ${resume}`);
956
1141
  }
957
1142
 
@@ -1161,6 +1346,7 @@ export async function admitCandidates(
1161
1346
  }
1162
1347
 
1163
1348
  admitted.push({ r, attempt: priorRuns + 1, lane, model: await modelFor(r) });
1349
+ if (budgeted) passReservedUsd += runAllowanceUsd;
1164
1350
  liveByRepo.set(r.repo.name, (liveByRepo.get(r.repo.name) ?? 0) + 1);
1165
1351
  // Same-pass sibling occupancy for the file-lane gate: once admitted, a
1166
1352
  // candidate's declared lane occupies for the rest of the pass, so a later