omp-conductor 0.18.1 → 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 (69) hide show
  1. package/README.md +106 -41
  2. package/REFERENCE.md +866 -31
  3. package/agents/to-spec.md +6 -2
  4. package/package.json +1 -1
  5. package/schema/config.schema.json +32 -1
  6. package/src/admission.ts +212 -26
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +288 -1
  9. package/src/briefs/orchestrator.md +27 -13
  10. package/src/briefs/to-spec.md +6 -2
  11. package/src/cli.ts +127 -2
  12. package/src/command-help.ts +9 -1
  13. package/src/command-manifest.ts +52 -8
  14. package/src/commands/arm.ts +6 -2
  15. package/src/commands/context.ts +2 -0
  16. package/src/commands/intake.ts +4 -19
  17. package/src/commands/message.ts +26 -2
  18. package/src/commands/reconcile-units.ts +104 -0
  19. package/src/commands/release-composition.ts +232 -0
  20. package/src/commands/resume.ts +2 -27
  21. package/src/commands/setup.ts +101 -16
  22. package/src/commands/stats.ts +11 -30
  23. package/src/commands/tail.ts +31 -1
  24. package/src/commands/upgrade.ts +20 -3
  25. package/src/commands/verb.ts +2 -1
  26. package/src/commands/watch.ts +4 -17
  27. package/src/config-schema.ts +38 -6
  28. package/src/config.ts +103 -8
  29. package/src/credential-class.ts +366 -0
  30. package/src/daemon.ts +1368 -529
  31. package/src/dashboard/app.js +504 -2
  32. package/src/dashboard/controls.ts +336 -0
  33. package/src/dashboard/index.html +30 -0
  34. package/src/dashboard/server.ts +271 -30
  35. package/src/dashboard/style.css +116 -0
  36. package/src/dashboard/transcript.ts +173 -0
  37. package/src/decisions.ts +19 -11
  38. package/src/doctor.ts +431 -148
  39. package/src/escalate.ts +22 -11
  40. package/src/failure-class.ts +59 -0
  41. package/src/fleet.ts +587 -230
  42. package/src/host.ts +6 -455
  43. package/src/omp-settings.ts +19 -0
  44. package/src/omp.ts +40 -56
  45. package/src/orchestrator-tick.ts +564 -121
  46. package/src/pause.ts +233 -0
  47. package/src/session-host.ts +6 -41
  48. package/src/settlement.ts +159 -2
  49. package/src/setup-answers.ts +97 -0
  50. package/src/setup-host.ts +343 -1160
  51. package/src/setup-install.ts +204 -27
  52. package/src/setup-wizard.ts +252 -51
  53. package/src/setup.ts +87 -4
  54. package/src/spend-telemetry.ts +117 -0
  55. package/src/stats.ts +35 -0
  56. package/src/status-render.ts +485 -19
  57. package/src/store.ts +1229 -55
  58. package/src/telegram-freshness.ts +269 -0
  59. package/src/to-spec.ts +50 -2
  60. package/src/types.ts +759 -10
  61. package/src/unblock.ts +22 -0
  62. package/src/unit-reconcile.ts +303 -0
  63. package/src/upgrade-verify.ts +8 -1
  64. package/src/upgrade.ts +299 -12
  65. package/src/verbs/actions.ts +124 -10
  66. package/src/verbs/protocol.ts +70 -2
  67. package/src/verbs/server.ts +485 -11
  68. package/src/wake.ts +48 -0
  69. package/src/worker.ts +401 -14
@@ -28,6 +28,7 @@ import {
28
28
  import { claimedTelegramTopics } from "./escalate.ts";
29
29
  import { hostRamBytes, recommendedMaxWorkers, workerOvercommit } from "./host.ts";
30
30
  import {
31
+ admissionAckPath,
31
32
  daemonGeneration,
32
33
  isPaused,
33
34
  pausedPath,
@@ -40,8 +41,10 @@ import {
40
41
  type AdmissionAckRecord,
41
42
  type QueuePreview,
42
43
  } from "./daemon.ts";
44
+ import { pauseBytesValid } from "./pause.ts";
43
45
  import { armedMarkerPath, armTicks, fleetLayers, telegramStateDir } from "./fleet.ts";
44
46
  import {
47
+ daemonControlTarget,
45
48
  healthCheck,
46
49
  healthServesProject,
47
50
  livingDaemon,
@@ -110,11 +113,13 @@ import {
110
113
  parseOmpSettingsYaml,
111
114
  planAgainstLabels,
112
115
  planLabels,
116
+ readConfiguredOmpRoles,
113
117
  summariseAmend,
114
118
  summarisePlan,
115
119
  wantedLabels,
116
120
  writeOrchestratorBrief,
117
121
  type AmendAreaId,
122
+ type ConfiguredOmpRoles,
118
123
  type LabelPlan,
119
124
  type OperatorJudgment,
120
125
  type ScopeCheck,
@@ -126,6 +131,7 @@ import {
126
131
  BASE_FRESHNESS,
127
132
  BEHIND_BASE_ACTIONS,
128
133
  DEFAULT_CAPS,
134
+ DEFAULT_REVIEW_ADJUDICATOR_ROLE,
129
135
  DEFAULT_REVIEW_MAX_ROUNDS,
130
136
  DEFAULT_REVIEW_STRICTNESS,
131
137
  DENIED_RELEASE_GRANTS,
@@ -133,6 +139,7 @@ import {
133
139
  RELEASE_REQUIREMENTS,
134
140
  INTERRUPT_CATEGORIES,
135
141
  RELEASE_SHAPES,
142
+ REVIEW_ADJUDICATOR_RE,
136
143
  REVIEW_MAX_ROUNDS_MAX,
137
144
  REVIEW_MAX_ROUNDS_MIN,
138
145
  REVIEW_STRICTNESS,
@@ -151,6 +158,7 @@ import {
151
158
  type ReviewPolicy,
152
159
  } from "./types.ts";
153
160
  import { withProgress } from "./ui/progress.ts";
161
+ import { modelRolesIn } from "./omp-settings.ts";
154
162
  import type { WizardUi } from "./wizard-ui.ts";
155
163
 
156
164
  /**
@@ -1431,6 +1439,136 @@ async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
1431
1439
  throw new Cancelled();
1432
1440
  }
1433
1441
 
1442
+ /** The answer-file key the adjudicator question is recorded under. */
1443
+ const REVIEW_ADJUDICATOR_KEY = "review-adjudicator-role";
1444
+
1445
+ /**
1446
+ * Whether one role is a legitimate adjudicator answer on this host right now:
1447
+ * it must be a single loadable role token (`REVIEW_ADJUDICATOR_RE`) AND a
1448
+ * `modelRoles` key of the daemon's global settings or of the project's omp
1449
+ * overlay — or the deterministic default, which setup must always be able to
1450
+ * write back. Anything else — including a project's current role that was
1451
+ * removed from both surfaces, or a malformed `modelRoles` key the loader
1452
+ * would refuse — is an unavailable role and must not be accepted, whatever
1453
+ * the config says (#875).
1454
+ */
1455
+ function isAdjudicatorAvailable(live: ConfiguredOmpRoles, overlay: readonly string[], role: string): boolean {
1456
+ if (!REVIEW_ADJUDICATOR_RE.test(role)) return false;
1457
+ return role === DEFAULT_REVIEW_ADJUDICATOR_ROLE || live.global.includes(role) || overlay.includes(role);
1458
+ }
1459
+
1460
+ /**
1461
+ * The adjudicator roles the dialog accepts, derived from the live OMP role
1462
+ * configuration — the daemon's global settings, then the project's omp
1463
+ * overlay — with the current answer first and the deterministic default
1464
+ * offered exactly once (#875). The order is the contract: an amend's Enter
1465
+ * re-affirms the current value, and the shipped default stays expressible for
1466
+ * a project whose operator never answered.
1467
+ *
1468
+ * Every role is filtered through the same token grammar the loader enforces:
1469
+ * a `modelRoles` key the config gate would reject must never be offered (or
1470
+ * accepted), because persisting it would write a policy the next loadConfig
1471
+ * refuses and stop the fleet. The current answer leads the list only while it
1472
+ * is still available: a role removed from both the global settings and the
1473
+ * overlay must not be accepted back into the config by a bare Enter — the
1474
+ * caller warns that it cannot be kept, and the accepted set excludes it, so
1475
+ * Entering it refuses exactly like typing any other unavailable role.
1476
+ */
1477
+ function adjudicatorChoices(live: ConfiguredOmpRoles, overlay: readonly string[], current: string): string[] {
1478
+ const choices: string[] = [];
1479
+ const seen = new Set<string>();
1480
+ const first = isAdjudicatorAvailable(live, overlay, current) ? [current] : [];
1481
+ for (const role of [...first, ...live.global, ...overlay, DEFAULT_REVIEW_ADJUDICATOR_ROLE]) {
1482
+ if (REVIEW_ADJUDICATOR_RE.test(role) && !seen.has(role)) {
1483
+ seen.add(role);
1484
+ choices.push(role);
1485
+ }
1486
+ }
1487
+ return choices;
1488
+ }
1489
+
1490
+ /** One role row's description: what makes this role available and why. */
1491
+ function adjudicatorDescription(
1492
+ role: string,
1493
+ live: ConfiguredOmpRoles,
1494
+ overlay: readonly string[],
1495
+ ): string | undefined {
1496
+ if (live.global.includes(role) || overlay.includes(role)) {
1497
+ return role === DEFAULT_REVIEW_ADJUDICATOR_ROLE
1498
+ ? "configured OMP model role — the deterministic default"
1499
+ : "configured OMP model role";
1500
+ }
1501
+ if (role === DEFAULT_REVIEW_ADJUDICATOR_ROLE) {
1502
+ return "the deterministic default every project loads until answered";
1503
+ }
1504
+ return undefined;
1505
+ }
1506
+
1507
+ /**
1508
+ * Which OMP model role runs a PR's terminal review-ceiling adjudication
1509
+ * (#875). The offered roles are derived from the live OMP role configuration
1510
+ * (the daemon account's global settings and the project's overlay, never a
1511
+ * conductor-owned registry), the prompt opens on the current answer, and any
1512
+ * answer that is not one of those configured roles is refused with the place
1513
+ * to configure it named.
1514
+ *
1515
+ * Driven through `askValid`, not a select, on purpose: a select surface can
1516
+ * only ever return an offered label, so the unconfigured-value refusal would
1517
+ * be unreachable on the real UIs and the answer-file path would fail with a
1518
+ * generic "must be a listed label" before this guidance ran. A validated text
1519
+ * answer gives every surface — terminal, scripted and `--answers` — the same
1520
+ * bounded three tries and the same actionable error naming the settings file.
1521
+ */
1522
+ async function askReviewAdjudicator(
1523
+ ui: WizardUi,
1524
+ current: string,
1525
+ live: ConfiguredOmpRoles,
1526
+ overlay: readonly string[],
1527
+ ): Promise<string> {
1528
+ const choices = adjudicatorChoices(live, overlay, current);
1529
+ if (!live.decoded) {
1530
+ ui.notify(
1531
+ `The daemon's OMP settings at ${live.path} could not be read — only the project's overlay roles and the default are offered.`,
1532
+ "warning",
1533
+ );
1534
+ }
1535
+ // A project whose configured role was removed from both surfaces must not be
1536
+ // allowed to keep it: the warning names the role and the way back, and the
1537
+ // accepted list above already excludes it — Entering it now refuses exactly
1538
+ // like typing any other unavailable role (#875).
1539
+ if (!isAdjudicatorAvailable(live, overlay, current)) {
1540
+ ui.notify(
1541
+ `"${current}" is this project's current adjudicator but not a currently configured OMP model role — it cannot be kept. ` +
1542
+ `Add "${current}" back as a modelRoles key to ${live.path} or this project's omp settings overlay, ` +
1543
+ `or type one of the available roles below.`,
1544
+ "warning",
1545
+ );
1546
+ }
1547
+ ui.notify(
1548
+ choices
1549
+ .map((role) => {
1550
+ const described = adjudicatorDescription(role, live, overlay);
1551
+ return `${role}${role === current ? " (current)" : ""}${described === undefined ? "" : ` — ${described}`}`;
1552
+ })
1553
+ .join("\n"),
1554
+ "info",
1555
+ );
1556
+ return askValid(
1557
+ ui,
1558
+ REVIEW_ADJUDICATOR_KEY,
1559
+ "Adjudicator for a PR at the review ceiling — one configured OMP model role, not a model",
1560
+ current,
1561
+ (value) => {
1562
+ if (choices.some((choice) => choice === value)) return undefined;
1563
+ return (
1564
+ `"${value}" is not one of the OMP model roles available here — add it as a modelRoles key to the daemon's OMP settings ` +
1565
+ `(${live.path}) or this project's omp settings overlay, or type an offered role. ` +
1566
+ `A role is a single token like "task" — never a provider or model.`
1567
+ );
1568
+ },
1569
+ );
1570
+ }
1571
+
1434
1572
  /**
1435
1573
  * How green PRs are reviewed (#678), asked with the merge preconditions and
1436
1574
  * the arming proof: they are the same kind of declared policy — a typed,
@@ -1441,7 +1579,10 @@ async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
1441
1579
  * rather than between three words. The select cursor opens on the current
1442
1580
  * answer (the configured level on a re-run, the recommended default on a
1443
1581
  * first run); the rounds are a validated integer within the same range the
1444
- * loader enforces.
1582
+ * loader enforces; the adjudicator role is offered from the live OMP role
1583
+ * configuration (#875) — the options and their availability come from the
1584
+ * daemon's OMP settings and the project's overlay, never from a conductor
1585
+ * model registry.
1445
1586
  */
1446
1587
  async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPolicy> {
1447
1588
  ui.notify(
@@ -1450,6 +1591,7 @@ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPol
1450
1591
  ),
1451
1592
  "info",
1452
1593
  );
1594
+ const live = readConfiguredOmpRoles();
1453
1595
  return {
1454
1596
  strictness: await askLiteral(
1455
1597
  ui,
@@ -1460,6 +1602,7 @@ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPol
1460
1602
  a.review.strictness,
1461
1603
  ),
1462
1604
  maxRounds: await askReviewRounds(ui, a.review.maxRounds),
1605
+ adjudicator: await askReviewAdjudicator(ui, a.review.adjudicator, live, modelRolesIn(a.ompSettings)),
1463
1606
  };
1464
1607
  }
1465
1608
 
@@ -2003,8 +2146,15 @@ interface DerivedPlan {
2003
2146
  export async function ensureSetupArm(
2004
2147
  projectName: string,
2005
2148
  arm: typeof armTicks = armTicks,
2149
+ /**
2150
+ * Where the arm proof's pending heartbeat goes while it waits (#861). Setup
2151
+ * holds dispatch under its own fence across this call, so a silent wait here
2152
+ * is the whole incident: the operator sees a stalled wizard and cannot tell
2153
+ * a live handshake from a dead process.
2154
+ */
2155
+ progress?: (line: string) => void,
2006
2156
  ): Promise<string> {
2007
- const armed = await arm(projectName);
2157
+ const armed = await arm(projectName, progress === undefined ? {} : { progress });
2008
2158
  return armed.alreadyArmed
2009
2159
  ? `existing heartbeat arm revalidated for owner ${armed.owner} at ${armed.path}`
2010
2160
  : `heartbeat armed for owner ${armed.owner} at ${armed.path}`;
@@ -2207,7 +2357,12 @@ export interface SetupApplyDeps {
2207
2357
  drain: DrainDeps;
2208
2358
  smoke: (project: string) => Promise<SetupSmokeResult>;
2209
2359
  restart: (o: { project?: string }) => Promise<RestartResult>;
2210
- arm: (project: string) => Promise<string>;
2360
+ /**
2361
+ * Prove the heartbeat channel for one project. `progress` is where the
2362
+ * pending proof's own heartbeat is written while it waits on the operator
2363
+ * (#861) — the wizard owns the surface, so the reporter comes from the call.
2364
+ */
2365
+ arm: (project: string, progress?: (line: string) => void) => Promise<string>;
2211
2366
  hostInstall: (
2212
2367
  project: ProjectConfig,
2213
2368
  caps: Caps,
@@ -2248,7 +2403,15 @@ function defaultSetupDrain(): DrainDeps {
2248
2403
  projectNames: () => loadConfig().projects.map((p) => p.name),
2249
2404
  daemonIdentity: setupDaemonIdentity,
2250
2405
  pauseState: (project) => pauseInstance(project),
2251
- setPaused: (v, project) => setPaused(v, { source: pauseSourceToken("setup"), reason: "setup apply fence" }, project),
2406
+ setPaused: (v, project) =>
2407
+ // `owner` is this process (#938): the apply fence holds dispatch across
2408
+ // this transaction only, and the 2026-08-21 incident is exactly what an
2409
+ // abandoned one looks like from outside.
2410
+ setPaused(
2411
+ v,
2412
+ { source: pauseSourceToken("setup"), reason: "setup apply fence", owner: process.pid },
2413
+ project,
2414
+ ),
2252
2415
  // The daemon-side admission acknowledgement: the file the running daemon
2253
2416
  // itself writes when it observes the fence at an admission boundary, so
2254
2417
  // the barrier never mistakes a second synchronous count for the daemon's
@@ -2318,7 +2481,7 @@ export const DEFAULT_APPLY: SetupApplyDeps = {
2318
2481
  drain: defaultSetupDrain(),
2319
2482
  smoke: runSetupSmoke,
2320
2483
  restart: (o) => restartDaemon(o),
2321
- arm: (project) => ensureSetupArm(project),
2484
+ arm: (project, progress) => ensureSetupArm(project, armTicks, progress),
2322
2485
  hostInstall: (project, caps, telegramStateDir, ui) =>
2323
2486
  runHostInstall(project, caps, telegramStateDir, ui),
2324
2487
  graphInstall: (project, ui, options) => runGraphInstall(project, ui, options),
@@ -2347,19 +2510,6 @@ type CapturedPause =
2347
2510
  | { kind: "malformed"; bytes: string; path: string }
2348
2511
  | { kind: "unreadable"; path: string };
2349
2512
 
2350
- /** Whether one sentinel's bytes parse as a valid pause instance — the exact
2351
- * grammar {@link pauseInstance} reads in `daemon.ts` (a parseable ISO
2352
- * timestamp line, then a `source=` provenance line). Readable bytes that
2353
- * fail this are an unreadable-as-state malformed hold: valid bytes are
2354
- * restored verbatim, malformed ones are refused at entry and never deleted
2355
- * (#650). */
2356
- function isValidPauseBytes(bytes: string): boolean {
2357
- const [line1, line2] = bytes.split("\n");
2358
- if (!Number.isFinite(Date.parse(line1?.trim() ?? ""))) return false;
2359
- if (line2 === undefined) return false;
2360
- return /^source=(\S+)(?: reason="(.*)")?$/.test(line2.trim());
2361
- }
2362
-
2363
2513
  /** Reads one pause sentinel, keeping absence, readable-valid bytes, malformed
2364
2514
  * bytes and unreadability apart — never conflated: collapsing malformed to
2365
2515
  * absence would let a refusal delete an operator hold it could not parse. */
@@ -2367,7 +2517,7 @@ function readSentinel(p: string): CapturedPause {
2367
2517
  if (!existsSync(p)) return { kind: "absent" };
2368
2518
  try {
2369
2519
  const bytes = readFileSync(p, "utf8");
2370
- return isValidPauseBytes(bytes) ? { kind: "bytes", bytes } : { kind: "malformed", bytes, path: p };
2520
+ return pauseBytesValid(bytes) ? { kind: "bytes", bytes } : { kind: "malformed", bytes, path: p };
2371
2521
  } catch {
2372
2522
  return { kind: "unreadable", path: p };
2373
2523
  }
@@ -2526,7 +2676,6 @@ function captureInventory(
2526
2676
  };
2527
2677
  captureIfWritten(plan.runtime.service);
2528
2678
  captureIfWritten(plan.runtime.herdrUnit);
2529
- captureIfWritten(plan.runtime.harnessMount);
2530
2679
  captureIfWritten(plan.runtime.herdrConfig);
2531
2680
  captureIfWritten(plan.runtime.herdrEnv);
2532
2681
  captureIfWritten(plan.runtime.recoverUnit);
@@ -2760,23 +2909,61 @@ const FENCE_ACK_POLL_MS = 250;
2760
2909
  * barrier froze, or `undefined` when it does: the acknowledgement must name
2761
2910
  * the exact pause instance the barrier proved (not some other pause, however
2762
2911
  * similar) and the exact daemon generation the barrier began with (not an
2763
- * older or newer instance's word) (#651 review #3).
2912
+ * older or newer instance's word) (#651 review #3). Every problem names the
2913
+ * observed and expected instances, so an operator can tell "no
2914
+ * acknowledgement yet" from "acknowledged something else" without opening a
2915
+ * debugger (#865).
2764
2916
  */
2765
2917
  function fenceAckProblem(ack: AdmissionAckRecord | undefined, begun: RestartBegun): string | undefined {
2766
- if (ack === undefined) return "the running daemon has not acknowledged the setup admission fence";
2918
+ if (ack === undefined) {
2919
+ return (
2920
+ "the running daemon has not acknowledged the setup admission fence " +
2921
+ `(fence ${describePauseInstance(begun.pauseToken)}; expected daemon generation ${begun.daemon.generation}; ` +
2922
+ `no readable acknowledgement at ${admissionAckPath()})`
2923
+ );
2924
+ }
2767
2925
  if (
2768
2926
  ack.pause.source !== begun.pauseToken.source ||
2769
2927
  ack.pause.since !== begun.pauseToken.since ||
2770
2928
  ack.pause.reason !== begun.pauseToken.reason
2771
2929
  ) {
2772
- return "the running daemon acknowledged a different pause than the setup admission fence";
2930
+ return (
2931
+ "the running daemon acknowledged a different pause than the setup admission fence " +
2932
+ `(acknowledged ${describePauseInstance(ack.pause)}; fence ${describePauseInstance(begun.pauseToken)})`
2933
+ );
2773
2934
  }
2774
2935
  if (ack.daemon !== begun.daemon.generation) {
2775
- return "the running daemon's acknowledgement belongs to a different daemon generation";
2936
+ return (
2937
+ "the running daemon's acknowledgement belongs to a different daemon generation " +
2938
+ `(acknowledged ${ack.daemon}; expected ${begun.daemon.generation})`
2939
+ );
2776
2940
  }
2777
2941
  return undefined;
2778
2942
  }
2779
2943
 
2944
+ /** One pause instance as one readable token for refusal diagnostics (#865). */
2945
+ function describePauseInstance(pause: { source: string; reason?: string; since: number }): string {
2946
+ const reason = pause.reason === undefined ? "" : ` reason="${pause.reason}"`;
2947
+ return `source=${pause.source}${reason} since=${new Date(pause.since).toISOString()}`;
2948
+ }
2949
+
2950
+ /**
2951
+ * Where a prompt-pass wake can provably land, or `undefined`: the pidfile
2952
+ * record's port while its process lives, else — a supervised daemon whose
2953
+ * runtime record went missing while the unit stayed active (#651 review #2) —
2954
+ * the run-control endpoint {@link daemonControlTarget} proves by answering
2955
+ * `/healthz`, the same consultation every run-control verb performs (#716).
2956
+ * A port is never guessed: when nothing provable answers, no wake is sent and
2957
+ * the caller's bounded wait covers the daemon's own next pass instead. The
2958
+ * pre-#865 barrier skipped BOTH the wake and the wait whenever the record was
2959
+ * unreadable, so a transient record loss refused three interviews in a row
2960
+ * against a healthy dispatcher that would have acknowledged within one tick.
2961
+ */
2962
+ async function reachableWakeEndpoint(): Promise<{ port: number } | undefined> {
2963
+ const target = await daemonControlTarget();
2964
+ return target.kind === "record" || target.kind === "unit" ? { port: target.port } : undefined;
2965
+ }
2966
+
2780
2967
  /**
2781
2968
  * Why the acknowledged barrier no longer holds, or `undefined` when it does:
2782
2969
  * a live worker anywhere in scope, the daemon-side admission acknowledgement
@@ -3251,39 +3438,41 @@ export async function setup(
3251
3438
  // boundary and written that observation down — a second synchronous worker
3252
3439
  // count is not an acknowledgement, because a tick already past its own
3253
3440
  // pause gate can claim after it and before setup mutates. A running daemon
3254
- // that has not yet acknowledged is woken to prompt an immediate pass; one
3255
- // that cannot be reached (its record is missing) or that still has not
3256
- // acknowledged by the bounded deadline refuses before anything is written.
3441
+ // that has not yet acknowledged is woken through any endpoint /healthz
3442
+ // proves, record or record-less (#865) to prompt an immediate pass; one
3443
+ // that still has not acknowledged by the bounded deadline refuses before
3444
+ // anything is written. The wait itself never depends on the pidfile: a
3445
+ // supervised daemon with a lost record still reaches its own gate within
3446
+ // one tick interval, and skipping the wait for it was the #865 refusal.
3257
3447
  let requireAck = true;
3258
3448
  if (begun.daemon.running) {
3259
3449
  let ack = apply.drain.admissionAck?.(scope.pauseKey);
3260
- let acknowledged = fenceAckProblem(ack, begun) === undefined;
3261
- if (!acknowledged) {
3262
- const reachable = livingDaemon();
3263
- if (reachable !== undefined) {
3264
- ui.notify(
3265
- "The running daemon has not yet acknowledged the setup admission fence — waking it to prompt a pass.",
3266
- "info",
3267
- );
3268
- void wakeDaemon(reachable.port);
3269
- // `OMP_CONDUCTOR_TEST_FENCE_ACK_WAIT_MS` is the #399 test seam: a
3270
- // regression proving this refusal burns the short test deadline
3271
- // instead of the production 60s.
3272
- const deadline =
3273
- Date.now() + Number(process.env["OMP_CONDUCTOR_TEST_FENCE_ACK_WAIT_MS"] ?? FENCE_ACK_WAIT_MS);
3274
- while (!acknowledged && Date.now() < deadline) {
3275
- await apply.drain.sleep(FENCE_ACK_POLL_MS);
3276
- ack = apply.drain.admissionAck?.(scope.pauseKey);
3277
- acknowledged = fenceAckProblem(ack, begun) === undefined;
3278
- }
3450
+ let problem = fenceAckProblem(ack, begun);
3451
+ if (problem !== undefined) {
3452
+ const wake = await reachableWakeEndpoint();
3453
+ ui.notify(
3454
+ wake !== undefined
3455
+ ? `The running daemon has not yet acknowledged the setup admission fence — waking it on :${wake.port} to prompt a pass.`
3456
+ : "The running daemon has not yet acknowledged the setup admission fence — nothing provable answers /healthz to wake, so waiting for its own dispatch pass.",
3457
+ "info",
3458
+ );
3459
+ if (wake !== undefined) void wakeDaemon(wake.port);
3460
+ // `OMP_CONDUCTOR_TEST_FENCE_ACK_WAIT_MS` is the #399 test seam: a
3461
+ // regression proving this refusal burns the short test deadline
3462
+ // instead of the production 60s.
3463
+ const deadline =
3464
+ Date.now() + Number(process.env["OMP_CONDUCTOR_TEST_FENCE_ACK_WAIT_MS"] ?? FENCE_ACK_WAIT_MS);
3465
+ while (problem !== undefined && Date.now() < deadline) {
3466
+ await apply.drain.sleep(FENCE_ACK_POLL_MS);
3467
+ ack = apply.drain.admissionAck?.(scope.pauseKey);
3468
+ problem = fenceAckProblem(ack, begun);
3279
3469
  }
3280
3470
  }
3281
- if (!acknowledged) {
3471
+ if (problem !== undefined) {
3282
3472
  restorePause(priorPause, ownedFence);
3283
3473
  ui.notify(
3284
- "Setup stopped before writing anything: the running daemon has not acknowledged the setup admission " +
3285
- "fence, so quiescence cannot be proven. Nothing has been changed; stop the daemon, or re-run setup " +
3286
- "once it has acknowledged the fence.",
3474
+ `Setup stopped before writing anything: ${problem} quiescence cannot be proven. ` +
3475
+ "Nothing has been changed; stop the daemon, or re-run setup once it has acknowledged the fence.",
3287
3476
  "error",
3288
3477
  );
3289
3478
  return false;
@@ -3528,7 +3717,7 @@ export async function setup(
3528
3717
  "info",
3529
3718
  );
3530
3719
  try {
3531
- armLine = await apply.arm(plan.project.name);
3720
+ armLine = await apply.arm(plan.project.name, (line) => ui.notify(line, "info"));
3532
3721
  } catch (err) {
3533
3722
  ui.notify(
3534
3723
  [
@@ -3641,6 +3830,18 @@ export async function setup(
3641
3830
  `Heartbeat: ${armLine}.`,
3642
3831
  "",
3643
3832
  "Use the documented toy-issue drill to prove one complete worker path.",
3833
+ "",
3834
+ // The one grooming recommendation setup makes, and it is only a sentence
3835
+ // (#827). Deliberately inert: nothing above or below this line installs,
3836
+ // probes, version-checks or refuses on it, because a conductor guarantee
3837
+ // must never depend on prose that releases on somebody else's cadence
3838
+ // (#506's judgement). Printed only here, on the committed success path,
3839
+ // so a failed or cancelled apply can never imply setup finished.
3840
+ "Optional: the spec-out skills pack at github.com/mattpocock/skills (`npx skills`) adds a",
3841
+ "grooming workflow this fleet has no opinion about — `to-spec` / `to-tickets` for turning a",
3842
+ "rough idea into sized slices, and `grill-me` for the questions worth asking before work",
3843
+ "starts. Nothing was installed or checked just now, and conductor is fully functional",
3844
+ "without it: skip it freely.",
3644
3845
  ].join("\n"),
3645
3846
  "info",
3646
3847
  );
package/src/setup.ts CHANGED
@@ -50,7 +50,7 @@ import {
50
50
  stateDir,
51
51
  } from "./config.ts";
52
52
  import { graphProjectPath, graphRepos } from "./graph.ts";
53
- import { ompSettingsOverlay } from "./omp-settings.ts";
53
+ import { modelRolesIn, ompSettingsOverlay } from "./omp-settings.ts";
54
54
  import {
55
55
  CONFIG_VERSION,
56
56
  DEFAULT_ARM_PROOF,
@@ -174,6 +174,12 @@ export interface SetupAnswers {
174
174
  */
175
175
  modelFallbacks?: string[];
176
176
  modelFallbackThreshold?: number;
177
+ /**
178
+ * The one stronger tier a spinning cap retries on (#807). Hand-edited like
179
+ * {@link modelFallbacks}, so it exists here only to survive an amend of some
180
+ * other area (#369).
181
+ */
182
+ workerEscalationModel?: string;
177
183
  /**
178
184
  * The project's omp settings overlay (#537): an opaque map layered into
179
185
  * every worker session via the fleet-owned settings channel. The wizard asks
@@ -241,6 +247,15 @@ export interface SetupAnswers {
241
247
  * stale-base interlock from a project that relies on it (#428).
242
248
  */
243
249
  criticalBase?: ProjectConfig["criticalBase"];
250
+ /**
251
+ * Hand-edited providers that must bill to a subscription credential, carried
252
+ * through setup unchanged like {@link criticalBase} (#852). The wizard never
253
+ * invents one — which providers a fleet pays for by subscription is the
254
+ * operator's billing arrangement, not something repo reading can answer — and
255
+ * dropping it during an unrelated amend would silently remove the fence that
256
+ * stops a disabled OAuth grant billing to an API key.
257
+ */
258
+ requireOauthProviders?: ProjectConfig["requireOauthProviders"];
244
259
  /**
245
260
  * Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not
246
261
  * part of the config — the brief is the operator's file, and the conductor
@@ -1001,6 +1016,12 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
1001
1016
  // would pin every run of the project onto a dead provider again (#286).
1002
1017
  ...(a.modelFallbacks === undefined ? {} : { modelFallbacks: [...a.modelFallbacks] }),
1003
1018
  ...(a.modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold: a.modelFallbackThreshold }),
1019
+ // Hand-edited like the failover chain, and the same reason to keep it: an
1020
+ // unrelated amend that dropped it would silently take the fleet's only
1021
+ // spinning-cap recovery away (#807).
1022
+ ...(a.workerEscalationModel === undefined
1023
+ ? {}
1024
+ : { workerEscalationModel: a.workerEscalationModel }),
1004
1025
  // The omp settings overlay is an opaque map the wizard collects as free-form
1005
1026
  // YAML; an unrelated amend must not delete it (#537).
1006
1027
  ...(a.ompSettings === undefined ? {} : { ompSettings: a.ompSettings }),
@@ -1026,6 +1047,9 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
1026
1047
  // Hand-edited safety markers carried unchanged on an unrelated amend
1027
1048
  // (#428): dropping them would silently disarm the stale-base interlock.
1028
1049
  ...(a.criticalBase === undefined ? {} : { criticalBase: [...a.criticalBase] }),
1050
+ ...(a.requireOauthProviders === undefined
1051
+ ? {}
1052
+ : { requireOauthProviders: [...a.requireOauthProviders] }),
1029
1053
  // Written out even when it is the default, so an operator amending the
1030
1054
  // volume has a line in the file to point at. Opting into availability makes
1031
1055
  // the schedule explicit and daily; omitting it preserves the preset's
@@ -1232,6 +1256,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
1232
1256
  if (p.groomBelow !== undefined) answers.groomBelow = p.groomBelow;
1233
1257
  if (p.modelFallbacks !== undefined) answers.modelFallbacks = [...p.modelFallbacks];
1234
1258
  if (p.modelFallbackThreshold !== undefined) answers.modelFallbackThreshold = p.modelFallbackThreshold;
1259
+ if (p.workerEscalationModel !== undefined) answers.workerEscalationModel = p.workerEscalationModel;
1235
1260
  if (p.ompSettings !== undefined) answers.ompSettings = { ...p.ompSettings };
1236
1261
  if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
1237
1262
  if (p.escalation.telegramTopicId !== undefined) answers.telegramTopicId = p.escalation.telegramTopicId;
@@ -1239,6 +1264,9 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
1239
1264
  answers.recoveryMerges = p.recoveryMerges.map((entry) => ({ ...entry }));
1240
1265
  }
1241
1266
  if (p.criticalBase !== undefined) answers.criticalBase = [...p.criticalBase];
1267
+ if (p.requireOauthProviders !== undefined) {
1268
+ answers.requireOauthProviders = [...p.requireOauthProviders];
1269
+ }
1242
1270
  if (p.reporting?.digest.at !== undefined) answers.dailyDigestAt = p.reporting.digest.at;
1243
1271
  if (p.reporting?.digest.timezone !== undefined) {
1244
1272
  answers.reportingTimezone = p.reporting.digest.timezone;
@@ -1575,6 +1603,53 @@ export function detectTelegram(): TelegramPresence {
1575
1603
  return result;
1576
1604
  }
1577
1605
 
1606
+ /**
1607
+ * Env override for the daemon account's global omp settings file, exactly the
1608
+ * seam `OMP_TELEGRAM_STATE_DIR` gives telegram discovery: a test (or a second
1609
+ * fleet on the same machine) can redirect the read without touching a real
1610
+ * `~/.omp/agent/config.yml`.
1611
+ */
1612
+ export const OMP_GLOBAL_SETTINGS_ENV = "OMP_CONDUCTOR_OMP_SETTINGS_PATH";
1613
+
1614
+ /**
1615
+ * The model roles the daemon account's global omp settings actually configure
1616
+ * — the keys of its `modelRoles` stanza (#875) — the "live OMP role
1617
+ * configuration" an adjudicator answer must be checked against. OMP's own
1618
+ * settings file (`~/.omp/agent/config.yml`) is the source rather than any
1619
+ * conductor-owned registry: conductor stores only role names, so the only
1620
+ * place an unrelated role could be invented is here, and reading this file is
1621
+ * what keeps setup from offering (or accepting) a role OMP never configured.
1622
+ *
1623
+ * A missing file is no roles; an unreadable or malformed one is also no roles
1624
+ * but is reported through `decoded: false` so the wizard can say why instead
1625
+ * of silently offering the default alone.
1626
+ */
1627
+ export function readConfiguredOmpRoles(): ConfiguredOmpRoles {
1628
+ const override = process.env[OMP_GLOBAL_SETTINGS_ENV]?.trim();
1629
+ const path = override && override.length > 0 ? override : join(homedir(), ".omp", "agent", "config.yml");
1630
+ if (!existsSync(path)) return { path, global: [], decoded: true };
1631
+ try {
1632
+ const parsed = parseYaml(readFileSync(path, "utf8"));
1633
+ return {
1634
+ path,
1635
+ global: [...new Set(modelRolesIn(parsed))].sort(),
1636
+ decoded: true,
1637
+ };
1638
+ } catch {
1639
+ return { path, global: [], decoded: false };
1640
+ }
1641
+ }
1642
+
1643
+ /** The global omp settings read (`readConfiguredOmpRoles`) reports. */
1644
+ export interface ConfiguredOmpRoles {
1645
+ /** The path read (the override, or the default under the operator's home). */
1646
+ readonly path: string;
1647
+ /** The model-role names the file's `modelRoles` stanza declares. */
1648
+ readonly global: readonly string[];
1649
+ /** False when the file exists but could not be read or parsed. */
1650
+ readonly decoded: boolean;
1651
+ }
1652
+
1578
1653
  /** True when `.env` carries a non-empty `TELEGRAM_BOT_TOKEN`. The value is
1579
1654
  * compared against emptiness and then dropped on the floor. */
1580
1655
  function hasTelegramToken(envPath: string): boolean {
@@ -1729,6 +1804,11 @@ export function summarisePlan(
1729
1804
  if (a.workerModel !== undefined && a.workerModel.trim().length > 0) {
1730
1805
  lines.push(` ${"worker model".padEnd(CAPS_PLAN_KEY_PAD)}${a.workerModel.trim()} (answered)`);
1731
1806
  }
1807
+ if (a.workerEscalationModel !== undefined && a.workerEscalationModel.trim().length > 0) {
1808
+ lines.push(
1809
+ ` ${"cap escalation".padEnd(CAPS_PLAN_KEY_PAD)}${a.workerEscalationModel.trim()} (hand-edited)`,
1810
+ );
1811
+ }
1732
1812
 
1733
1813
  lines.push("", "escalation");
1734
1814
  if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) {
@@ -1796,6 +1876,7 @@ export function summarisePlan(
1796
1876
 
1797
1877
  lines.push("", "review", ` strictness ${a.review.strictness} — ${REVIEW_STRICTNESS_CHOICES[a.review.strictness]}`);
1798
1878
  lines.push(` max rounds/PR ${a.review.maxRounds} — at the ceiling the PR is left open, the findings recorded, and it is escalated once`);
1879
+ lines.push(` adjudicator ${a.review.adjudicator} — the OMP model role that runs that ceiling adjudication`);
1799
1880
 
1800
1881
  const reporting = project.reporting as ReportingPolicy;
1801
1882
  const briefPath = orchestratorBriefPath(a);
@@ -2013,18 +2094,20 @@ export const AMEND_AREAS: {
2013
2094
  },
2014
2095
  policy: {
2015
2096
  name: "merge & release preconditions",
2016
- asks: "the checks, base freshness, draft rule and behind-base action for a merge, then what a release requires and what it ships — and how green PRs are reviewed, and how many rounds a PR may be returned",
2097
+ asks: "the checks, base freshness, draft rule and behind-base action for a merge, then what a release requires and what it ships — and how green PRs are reviewed, how many rounds a PR may be returned, and which OMP model role adjudicates a PR at that ceiling",
2017
2098
  describe: (p) => {
2018
2099
  const policy = resolvePolicy(p);
2019
2100
  const review = resolveReview(p);
2020
2101
  // Counted rather than listed: this row is elided at 96 characters, and the
2021
- // full table is in the plan summary the consent screen shows next.
2102
+ // full table is in the plan summary the consent screen shows next. The
2103
+ // adjudicator is the third review decision, so it is named with the other
2104
+ // two (#875).
2022
2105
  return (
2023
2106
  `merge: ${policy.merge.requiredChecks.length === 0 ? "every check" : `${policy.merge.requiredChecks.length} check(s)`}, ` +
2024
2107
  `base ${policy.merge.baseFreshness}, drafts ${policy.merge.drafts}, behind → ${policy.merge.whenBehindBase}; ` +
2025
2108
  `release: ${policy.release.requires.length} must-land, ${policy.release.artefacts.length} artefact(s), ` +
2026
2109
  `${policy.release.environments.length} env(s); ` +
2027
- `review ${review.strictness} ×${review.maxRounds}`
2110
+ `review ${review.strictness} ×${review.maxRounds} (adjudicator ${review.adjudicator})`
2028
2111
  );
2029
2112
  },
2030
2113
  },