omp-conductor 0.15.13 → 0.16.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 (47) hide show
  1. package/REFERENCE.md +72 -2
  2. package/package.json +2 -1
  3. package/schema/config.schema.json +6 -0
  4. package/src/admission.ts +745 -0
  5. package/src/ask.ts +47 -0
  6. package/src/backups.ts +19 -7
  7. package/src/board.ts +1 -2
  8. package/src/briefs/orchestrator.md +62 -4
  9. package/src/cli.ts +26 -0
  10. package/src/commands/context.ts +3 -0
  11. package/src/commands/decision.ts +10 -1
  12. package/src/commands/doctor.ts +2 -0
  13. package/src/commands/message.ts +8 -1
  14. package/src/commands/restart.ts +15 -3
  15. package/src/commands/restore-db.ts +146 -0
  16. package/src/commands/stop.ts +24 -15
  17. package/src/commands/unfreeze.ts +56 -0
  18. package/src/commands/watch.ts +77 -0
  19. package/src/config-schema.ts +9 -0
  20. package/src/config.ts +24 -0
  21. package/src/daemon.ts +239 -530
  22. package/src/dashboard/server.ts +2 -1
  23. package/src/decisions.ts +32 -7
  24. package/src/depends-on.ts +73 -0
  25. package/src/doctor.ts +178 -5
  26. package/src/escalate.ts +114 -15
  27. package/src/failure-class.ts +47 -0
  28. package/src/fleet.ts +41 -410
  29. package/src/gitops.ts +86 -1
  30. package/src/log.ts +40 -0
  31. package/src/model-fallback.ts +3 -2
  32. package/src/omp-settings.ts +114 -0
  33. package/src/omp.ts +39 -0
  34. package/src/orchestrator-tick.ts +7 -1
  35. package/src/reports.ts +124 -12
  36. package/src/session-host.ts +6 -0
  37. package/src/setup-wizard.ts +36 -0
  38. package/src/setup.ts +58 -1
  39. package/src/status-render.ts +445 -0
  40. package/src/stop-provenance.ts +53 -0
  41. package/src/store.ts +352 -11
  42. package/src/types.ts +187 -4
  43. package/src/unblock.ts +1 -1
  44. package/src/upgrade-verify.ts +1 -1
  45. package/src/upgrade.ts +1 -2
  46. package/src/verbs/server.ts +25 -0
  47. package/src/worker.ts +162 -10
@@ -26,6 +26,7 @@ import type { FailureClass, RunRecord } from "./types.ts";
26
26
  export const FAILOVER_CLASSES: readonly FailureClass[] = [
27
27
  "provider-transient",
28
28
  "provider-credit",
29
+ "provider-capacity",
29
30
  ];
30
31
 
31
32
  /** The default for a project's `modelFallbackThreshold` when it is absent or
@@ -44,7 +45,7 @@ export interface ProviderFailureFacts {
44
45
  /** How many terminal runs at the head of the chain failed as a provider. */
45
46
  streak: number;
46
47
  /** Those runs' classes, newest first — the set is either one class or a mix
47
- * of `provider-transient` and `provider-credit`. */
48
+ * of `provider-transient`, `provider-credit` and `provider-capacity`. */
48
49
  classes: FailureClass[];
49
50
  /**
50
51
  * The model the most recent failure dispatched on, when its row recorded
@@ -97,7 +98,7 @@ export interface ModelChoice {
97
98
  * returned unchanged and `fallback` stays false — today's dispatch byte for
98
99
  * byte. Once the streak reaches the threshold the chain advances one slot per
99
100
  * extra failure, clamped to the last model: the chain is exhausted there, and
100
- * the existing escalation path (the provider-transient strike cap) settles it,
101
+ * the existing escalation path (the provider-class strike cap) settles it,
101
102
  * naming every model tried.
102
103
  */
103
104
  export function resolveDispatchModel(args: {
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The fleet-owned omp settings overlay (#537).
3
+ *
4
+ * Workers inherit omp configuration from the daemon account's global
5
+ * `~/.omp/agent/config.yml` and nothing else. This module is the channel for
6
+ * saying "this project's workers run with *these* omp settings" without
7
+ * editing that fleet-wide file: a project's `ompSettings` map (an opaque
8
+ * settings tree the harness owns) is materialised to a YAML overlay *outside
9
+ * the worktree* — under the run's own session directory, alongside the
10
+ * transcript and the control socket — and threaded to the session through
11
+ * omp's own `Settings.init({ configFiles: [<path>] })` seam.
12
+ *
13
+ * Two properties make this channel distinct from the obvious alternative of
14
+ * writing `<worktree>/.omp/config.yml`:
15
+ *
16
+ * - The overlay is fleet-owned, never a file in the git checkout, so the
17
+ * diff a worker eventually ships is untouched and nothing needs an ignore
18
+ * entry. The worktree ignore block's standing rule — an ignored new file
19
+ * is invisible to salvage — is exactly why an in-tree staging was refused.
20
+ * - It is rewritten from config on every dispatch, so a config edit takes
21
+ * effect on the next attempt (and a resumed attempt reuses the kept
22
+ * session directory with the *current* config, not the one as of first
23
+ * provision).
24
+ *
25
+ * Conductor validates YAML shape only — omp owns the schema. An unknown key or
26
+ * a wrong-typed value is omp's to reject when it resolves the overlay, never
27
+ * conductor's to interpret.
28
+ */
29
+
30
+ import { unlinkSync, writeFileSync } from "node:fs";
31
+ import { join } from "node:path";
32
+ import { stringify } from "yaml";
33
+ import { stateDir } from "./config.ts";
34
+ import type { ProjectConfig } from "./types.ts";
35
+
36
+ /** The overlay filename inside a run's session directory. */
37
+ export const OMP_SETTINGS_FILE = "omp-settings.yml";
38
+
39
+ /**
40
+ * The effective overlay map for a project: the opaque `ompSettings` map plus
41
+ * the retry keys (`retry.modelFallback`, `retry.fallbackChains.default`)
42
+ * derived from `modelFallbacks` — #539's staging half, moved onto this general
43
+ * channel so there is one way to stage settings into a worker session, not one
44
+ * seam per feature.
45
+ *
46
+ * `ompSettings.retry` is the operator's explicit word: when it is already a
47
+ * mapping, the derived retry block is skipped entirely (the overlay then says
48
+ * what it says, and the dispatch-failover chain #286 reads is unchanged — only
49
+ * omp's *retry-time* model swap respects the explicit stanza). Otherwise the
50
+ * derived block is merged in, preserving #539's behaviour exactly.
51
+ *
52
+ * Returns `undefined` when nothing is staged — a project with neither an
53
+ * `ompSettings` map nor a model-fallback chain gets no overlay at all, so its
54
+ * dispatch is byte-for-byte today's.
55
+ */
56
+ export function ompSettingsOverlay(
57
+ p: Pick<ProjectConfig, "ompSettings" | "modelFallbacks">,
58
+ ): Record<string, unknown> | undefined {
59
+ const map: Record<string, unknown> = { ...(p.ompSettings ?? {}) };
60
+ const chain = p.modelFallbacks;
61
+ const explicitRetry =
62
+ typeof map.retry === "object" && map.retry !== null && !Array.isArray(map.retry);
63
+ if (chain !== undefined && chain.length > 0 && !explicitRetry) {
64
+ map.retry = {
65
+ modelFallback: true,
66
+ fallbackChains: { default: [...chain] },
67
+ };
68
+ }
69
+ return Object.keys(map).length === 0 ? undefined : map;
70
+ }
71
+
72
+ /**
73
+ * The directory every run's session dir lives under — where the overlay is
74
+ * materialised at dispatch. `doctor` probes its writability to report whether
75
+ * the next dispatch's overlay can actually land.
76
+ */
77
+ export function sessionRootDir(): string {
78
+ return join(stateDir(), "sessions");
79
+ }
80
+
81
+ /**
82
+ * Materialises a project's effective overlay to `<sessionDir>/omp-settings.yml`
83
+ * and returns its absolute path, or `undefined` when nothing is staged.
84
+ *
85
+ * When nothing is staged, any overlay a previous attempt wrote is removed, so
86
+ * a project that dropped its map leaves no stale file in a kept session dir —
87
+ * the resume path reuses that directory, and an orphaned overlay with nobody
88
+ * reading it would be the exact silent fake this channel exists to avoid.
89
+ *
90
+ * Re-runs every dispatch, so a kept worktree picks up a config change on the
91
+ * next attempt rather than at the next worktree creation. A non-staged
92
+ * project's dispatch degrades to the harness discovering settings exactly as
93
+ * it always has.
94
+ */
95
+ export function materializeOmpSettings(
96
+ project: Pick<ProjectConfig, "ompSettings" | "modelFallbacks">,
97
+ sessionDir: string,
98
+ ): string | undefined {
99
+ const path = join(sessionDir, OMP_SETTINGS_FILE);
100
+ const overlay = ompSettingsOverlay(project);
101
+ if (overlay === undefined) {
102
+ try {
103
+ unlinkSync(path);
104
+ } catch {
105
+ // Nothing written — a fresh session dir, or the absent case on first
106
+ // dispatch. Either way there is no stale overlay to remove.
107
+ }
108
+ return undefined;
109
+ }
110
+ // 0600: the overlay can carry provider names and model selectors, and the
111
+ // session dir already holds the run's private channels.
112
+ writeFileSync(path, stringify(overlay), { mode: 0o600 });
113
+ return path;
114
+ }
package/src/omp.ts CHANGED
@@ -168,6 +168,17 @@ export async function createLocalSession(opts: {
168
168
  releaseGrants?: ResolvedGrants;
169
169
  /** Durable audit callback invoked only when that gate rejects a call. */
170
170
  onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
171
+ /**
172
+ * The fleet-owned omp settings overlay staged into this session's omp
173
+ * settings (#537): an absolute path to a YAML overlay the daemon materialised
174
+ * from the project's `ompSettings` map (plus the retry keys derived from
175
+ * `modelFallbacks`), passed to `Settings.init({ configFiles: [<path>] })` so
176
+ * it layers on top of the ordinary global/project discovery — never an
177
+ * isolated instance, which would drop providers and approvals out from under
178
+ * the worker. Absent, no settings are passed and the harness discovers
179
+ * exactly as it does today.
180
+ */
181
+ ompSettingsFile?: string;
171
182
  /**
172
183
  * The conductor verb socket this session's mutation tools call (#126).
173
184
  *
@@ -243,11 +254,31 @@ export async function createLocalSession(opts: {
243
254
  conductorVerbs(opts.verbSocketPath),
244
255
  ];
245
256
 
257
+ // The fleet-owned omp settings overlay (#537): when the daemon staged one,
258
+ // layer it into a Settings instance over the ordinary global/project
259
+ // discovery. `Settings.init({ cwd, configFiles })` keeps the daemon account's
260
+ // global config (providers, approvals, modelRoles) and any project
261
+ // `.omp/config.yml` intact — the overlay is deep-merged *after* them — and
262
+ // never `Settings.isolated()`, which would build from the overlay alone and
263
+ // drop everything out from under the worker. Absent a staged overlay, no
264
+ // settings are passed and the harness discovers exactly as it does today.
265
+ let overlaySettings: unknown = undefined;
266
+ if (opts.ompSettingsFile !== undefined) {
267
+ const Settings = Reflect.get(namespace, "Settings");
268
+ if (typeof Settings === "function" && typeof Settings.init === "function") {
269
+ overlaySettings = await Settings.init({
270
+ cwd: opts.cwd,
271
+ configFiles: [opts.ompSettingsFile],
272
+ });
273
+ }
274
+ }
275
+
246
276
  const created = await mod.createAgentSession({
247
277
  cwd: opts.cwd,
248
278
  // A raw pattern rather than a resolved Model: the harness resolves it
249
279
  // after extensions load, so we never have to import its model registry.
250
280
  ...(opts.model === undefined ? {} : { modelPattern: opts.model }),
281
+ ...(overlaySettings === undefined ? {} : { settings: overlaySettings }),
251
282
  sessionManager,
252
283
  // A private registry per session, never the process-global default: that
253
284
  // one admits only one "Main" identity per generation, so a second session
@@ -424,6 +455,13 @@ export interface CreateSessionOptions {
424
455
  role: SessionRole;
425
456
  releaseGrants?: ResolvedGrants;
426
457
  onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
458
+ /**
459
+ * Absolute path to the fleet-owned omp settings overlay (#537), forwarded to
460
+ * the child through the host spec so the far side's `createLocalSession`
461
+ * loads it via `Settings.init({ configFiles: [<path>] })`. Absent, nothing
462
+ * is staged and the session discovers settings as it does today.
463
+ */
464
+ ompSettingsFile?: string;
427
465
 
428
466
  /**
429
467
  * Where the control socket is bound. The daemon puts it beside the run's own
@@ -562,6 +600,7 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
562
600
  ...(opts.releaseGrants === undefined ? {} : { releaseGrants: opts.releaseGrants }),
563
601
  ...(opts.verbSocketPath === undefined ? {} : { verbSocketPath: opts.verbSocketPath }),
564
602
  ...(opts.readOnly === undefined ? {} : { readOnly: opts.readOnly }),
603
+ ...(opts.ompSettingsFile === undefined ? {} : { ompSettingsFile: opts.ompSettingsFile }),
565
604
  };
566
605
 
567
606
  const log = opts.onChildLog ?? ((line: string) => process.stderr.write(`${line}\n`));
@@ -519,7 +519,13 @@ export function queueDigestLine(
519
519
  if (summary.routed >= groomBelow) return undefined;
520
520
  let line = `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
521
521
  if (summary.admitted === 0 && summary.holds.length > 0) {
522
- line += ` All held: ${summary.holds.map((h) => `${h.reason} ${h.count}`).join(", ")}.`;
522
+ const held = summary.holds
523
+ .map((h) => {
524
+ const first = h.details?.[0];
525
+ return `${h.reason} ${h.count}${first === undefined ? "" : ` (${first})`}`;
526
+ })
527
+ .join(", ");
528
+ line += ` All held: ${held}.`;
523
529
  }
524
530
  return line;
525
531
  }
package/src/reports.ts CHANGED
@@ -37,7 +37,14 @@
37
37
  */
38
38
 
39
39
  import { availabilityDisposition, interruptDisposition } from "./availability.ts";
40
- import { readTelegramToken, resolveProjectTopicId, sendTelegram, TelegramSendError } from "./escalate.ts";
40
+ import {
41
+ readTelegramToken,
42
+ resolveProjectTopicId,
43
+ sendTelegram,
44
+ telegramTextParts,
45
+ TelegramSendError,
46
+ } from "./escalate.ts";
47
+ import { createHash } from "node:crypto";
41
48
  import { localDayKey } from "./digest-schedule.ts";
42
49
  import {
43
50
  DIGEST_BACKLOG_LIMIT,
@@ -86,11 +93,68 @@ const NO_ISSUE = 0;
86
93
  const ERROR_SAMPLE = 90;
87
94
 
88
95
  /**
89
- * One delivery attempt's transport. Injected so the delivery contract claim,
90
- * send, record, retry is testable without a live bot, which is the same split
91
- * `confineToolCall` and `verifyPr` use: the decision is separable from the I/O.
92
- * Resolves with Telegram's own message id when it returns one, throws on any
93
- * known failure.
96
+ * The within-run harness reliability a settlement now names where a human
97
+ * reads it (#584). The row always carries the resolved model/provider and the
98
+ * counts (written at settlement straight off the worker result); this is only
99
+ * the *wording* the sentence a settlement report and a tick digest tell an
100
+ * operator who would otherwise have to read a transcript to learn that the
101
+ * run finished on a fallback after a mid-run swap, or rode out a throttled
102
+ * provider on retries and compaction.
103
+ *
104
+ * Deliberately empty for a clean run: a run that never swapped, retried or
105
+ * compacted changes neither the settlement report nor the digest, so the
106
+ * "additive" claim is the observable one — byte-for-byte today's output on a
107
+ * quiet fleet. The resolved model is still recorded on the row every time (#584's
108
+ * second fake), but a clean run's narrative has nothing to add.
109
+ */
110
+ export interface ReliabilitySettlement {
111
+ resolvedModel?: string;
112
+ resolvedProvider?: string;
113
+ retryFallbacks: { from: string; to: string }[];
114
+ retryFallbackSucceeded: number;
115
+ modelRecoveries: number;
116
+ autoRetryCount: number;
117
+ autoCompactionCount: number;
118
+ }
119
+
120
+ /**
121
+ * The human sentence for the above reliability surface, or undefined when there
122
+ * is nothing worth saying (a clean run). One line so it drops into both the
123
+ * settlement report and a material event's summary unchanged.
124
+ */
125
+ export function reliabilitySettlementLine(
126
+ f: ReliabilitySettlement,
127
+ ): string | undefined {
128
+ const { resolvedModel, resolvedProvider } = f;
129
+ if (f.retryFallbacks.length === 0 && f.retryFallbackSucceeded === 0 && f.modelRecoveries === 0 && f.autoRetryCount === 0 && f.autoCompactionCount === 0) {
130
+ return undefined;
131
+ }
132
+ const activity = [
133
+ f.retryFallbacks.length > 0
134
+ ? `${f.retryFallbacks.length} within-run model swap${f.retryFallbacks.length === 1 ? "" : "s"}`
135
+ : undefined,
136
+ f.retryFallbackSucceeded > 0 ? `${f.retryFallbackSucceeded} recovered` : undefined,
137
+ f.modelRecoveries > 0 ? `${f.modelRecoveries} model recovery` : undefined,
138
+ f.autoRetryCount > 0 ? `${f.autoRetryCount} provider retry` : undefined,
139
+ f.autoCompactionCount > 0 ? `${f.autoCompactionCount} compaction` : undefined,
140
+ ].filter((part): part is string => part !== undefined);
141
+ const finished =
142
+ resolvedModel === undefined
143
+ ? "finished on the fallback"
144
+ : `finished on ${resolvedModel}${resolvedProvider === undefined ? "" : ` (${resolvedProvider})`}`;
145
+ return `Reliability: ${activity.join(", ")}; ${finished}.`;
146
+ }
147
+
148
+ /**
149
+ * One delivery attempt's transport for a *single message*. Injected so the
150
+ * delivery contract — claim, split, send, record progress, retry — is testable
151
+ * without a live bot, which is the same split `confineToolCall` and `verifyPr`
152
+ * use: the decision is separable from the I/O. The outbox splits a long report
153
+ * into labelled parts itself and drives one call per part (so partial progress
154
+ * can be persisted and resumed, #566); a text passed here is always under the
155
+ * wire limit. Resolves with Telegram's own message id when it returns one,
156
+ * throws on any known failure — {@link TelegramSendError} carries the
157
+ * definitive/unknown verdict the retry's honesty hangs on.
94
158
  */
95
159
  export type ReportSend = (text: string) => Promise<number | undefined>;
96
160
 
@@ -291,7 +355,14 @@ export function telegramReportSend(p: ProjectConfig): ReportSend {
291
355
  "definitive",
292
356
  );
293
357
  }
294
- return await sendTelegram(token, chatId, text, { topicId: resolveProjectTopicId(p) });
358
+ // `sendTelegram` splits internally and resolves with every part's id. A
359
+ // caller on this seam always passes one message under the wire limit, so a
360
+ // single-element array comes back; `[0]` is that one id. A text over the
361
+ // limit would still be delivered whole (never truncated) — the seam would
362
+ // simply under-report the extra ids, which is why the split lives at the
363
+ // caller, not here.
364
+ const ids = await sendTelegram(token, chatId, text, { topicId: resolveProjectTopicId(p) });
365
+ return ids[0];
295
366
  };
296
367
  }
297
368
 
@@ -345,7 +416,13 @@ export async function deliverOperatorMessage(
345
416
  });
346
417
  return { kind: "held", category, noticeId: deps.noticeId, reason: disposition };
347
418
  }
348
- await (deps.send ?? telegramReportSend(project))(text);
419
+ const send = deps.send ?? telegramReportSend(project);
420
+ // A long operator message is split and driven part-by-part through the same
421
+ // seam the outbox uses, so a custom transport sees the same per-part calls
422
+ // and the default one never truncates (#566).
423
+ for (const part of telegramTextParts(text)) {
424
+ await send(part);
425
+ }
349
426
  return { kind: "sent", category };
350
427
  }
351
428
 
@@ -596,9 +673,38 @@ export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
596
673
  return;
597
674
  }
598
675
 
599
- let messageId: number | undefined;
676
+ const text = formatReportMessage(claimed, project.name);
677
+ const parts = telegramTextParts(text);
678
+ // Fingerprint of the exact text the split below is computed from. The
679
+ // per-part watermark on the row is only meaningful while this is unchanged:
680
+ // the resume point is a count into a specific split, so any drift in the
681
+ // text invalidates it (#566).
682
+ const messageHash = createHash("sha256").update(text).digest("hex");
683
+ // Resume at the first part this attempt has not yet confirmed. A count is
684
+ // only a valid resume point while the text — and therefore the split — is
685
+ // byte-identical to the attempt that wrote the watermark. Any change,
686
+ // notably the possible-repeat banner an unknown outcome adds to an
687
+ // ambiguous report, reshapes the parts, so the watermark is deliberately
688
+ // not carried across it and the whole report is re-sent amber-flagged —
689
+ // the same at-least-once semantics as the single-message path (#566).
690
+ const resumeAt = claimed.sentPartsHash === messageHash ? (claimed.sentParts ?? 0) : 0;
691
+ const start = Math.min(resumeAt, parts.length);
692
+
693
+ const messageIds: number[] = [];
600
694
  try {
601
- messageId = await send(formatReportMessage(claimed, project.name));
695
+ // Strictly in order, one `sendMessage` at a time: parts never interleave
696
+ // with each other or with a message sent concurrently to the same chat.
697
+ for (let i = start; i < parts.length; i += 1) {
698
+ const id = await send(parts[i]!);
699
+ if (id !== undefined) messageIds.push(id);
700
+ // Persist progress before the next part ships. Only a multi-part send
701
+ // has a crossing point to protect: a crash or a definitive failure can
702
+ // then only ever re-send the part that was in flight, never the
703
+ // confirmed prefix. Guarded by attempt id like every other transition.
704
+ if (parts.length > 1) {
705
+ store.markReportPartsSent(claimed.id, attemptId, i + 1, messageHash, now());
706
+ }
707
+ }
602
708
  } catch (err) {
603
709
  const at = now();
604
710
  const error = err instanceof Error ? err.message : String(err);
@@ -641,15 +747,21 @@ export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
641
747
  pass.requeued.push(claimed.id);
642
748
  log(
643
749
  `report ${claimed.id} attempt ${claimed.attempts}/${REPORT_MAX_ATTEMPTS} was rejected, ` +
750
+ `${start + messageIds.length}/${parts.length} parts accepted, ` +
644
751
  `retrying in ${Math.round(delay / 1_000)}s: ${error}`,
645
752
  );
646
753
  return;
647
754
  }
648
755
 
649
- store.markReportDelivered(claimed.id, attemptId, messageId, now());
756
+ store.markReportDelivered(claimed.id, attemptId, messageIds, now());
650
757
  pass.delivered.push(claimed.id);
651
758
  log(
652
- `report ${claimed.id} delivered${messageId === undefined ? "" : ` as telegram message ${messageId}`}` +
759
+ `report ${claimed.id} delivered` +
760
+ (parts.length === 1
761
+ ? messageIds[0] === undefined
762
+ ? ""
763
+ : ` as telegram message ${messageIds[0]}`
764
+ : ` as ${parts.length} telegram messages (${messageIds.join(", ")})`) +
653
765
  `${claimed.ambiguous ? " (marked as a possible repeat)" : ""}`,
654
766
  );
655
767
  };
@@ -50,6 +50,11 @@ export interface SessionHostSpec {
50
50
  verbSocketPath?: string;
51
51
  /** Deny every tool but reading and searching (#307) — the setup probes. */
52
52
  readOnly?: boolean;
53
+ /** The fleet-owned omp settings overlay (#537): absolute path to the YAML
54
+ * overlay the daemon materialised from the project's `ompSettings` map.
55
+ * Carried as a plain scalar like the other fields; absent, nothing is staged
56
+ * on the far side either. */
57
+ ompSettingsFile?: string;
53
58
  }
54
59
 
55
60
  /** Parent → child. */
@@ -186,6 +191,7 @@ export async function runSessionHost(
186
191
  ...(spec.releaseGrants === undefined ? {} : { releaseGrants: spec.releaseGrants }),
187
192
  ...(spec.verbSocketPath === undefined ? {} : { verbSocketPath: spec.verbSocketPath }),
188
193
  ...(spec.readOnly === undefined ? {} : { readOnly: spec.readOnly }),
194
+ ...(spec.ompSettingsFile === undefined ? {} : { ompSettingsFile: spec.ompSettingsFile }),
189
195
  // The release audit lives in the daemon's state directory, which this
190
196
  // process may not be able to write and must not be trusted to. It
191
197
  // becomes a message; the parent performs the durable write.
@@ -71,6 +71,7 @@ import {
71
71
  detectTelegram,
72
72
  formatGates,
73
73
  orchestratorBriefPath,
74
+ parseOmpSettingsYaml,
74
75
  planAgainstLabels,
75
76
  planLabels,
76
77
  summariseAmend,
@@ -1204,6 +1205,37 @@ const askWorkerModel: AreaAsker = async (ui, a) => {
1204
1205
  return next;
1205
1206
  };
1206
1207
 
1208
+ /** The free-form omp settings overlay (#537): one YAML answer, omp's own
1209
+ * vocabulary rather than a list this package can offer. The seed is the
1210
+ * current map rendered as JSON — a YAML 1.2 subset — so a re-run that Enters
1211
+ * re-affirms it on one line; a blank answer clears the overlay; anything that
1212
+ * is not a YAML mapping re-asks, because an answer the dispatcher would
1213
+ * silently drop is exactly the input this wizard exists to catch. */
1214
+ const askOmpSettings: AreaAsker = async (ui, a) => {
1215
+ const next: SetupAnswers = { ...a };
1216
+ const seed = a.ompSettings === undefined ? "" : JSON.stringify(a.ompSettings);
1217
+ const answered = await askValid(
1218
+ ui,
1219
+ "Omp settings overlay for workers (YAML, blank = none — omp's schema, not conductor's)",
1220
+ seed,
1221
+ (value) => {
1222
+ if (value.trim().length === 0) return undefined;
1223
+ const parsed = parseOmpSettingsYaml(value);
1224
+ return parsed.ok ? undefined : parsed.problem;
1225
+ },
1226
+ );
1227
+ if (answered.trim().length === 0) {
1228
+ delete next.ompSettings;
1229
+ return next;
1230
+ }
1231
+ const parsed = parseOmpSettingsYaml(answered);
1232
+ // askValid already proved the shape on this exact string; a parse failure
1233
+ // here is an internal invariant, never a silently dropped overlay.
1234
+ if (!parsed.ok) throw new Error(`unreachable: askValid accepted unparseable omp settings: ${parsed.problem}`);
1235
+ next.ompSettings = parsed.value;
1236
+ return next;
1237
+ };
1238
+
1207
1239
  /** The three ownership questions, then the mechanical gate one shape at a time:
1208
1240
  * together they are what decides what an unattended fleet may do unasked. */
1209
1241
  const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
@@ -1492,6 +1524,7 @@ const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = {
1492
1524
  // The two per-worker knobs the full interview separates with the authority
1493
1525
  // grants; an amend has no reason to put anything between them.
1494
1526
  caps: async (ui, a, probes) => await askWorkerModel(ui, await askCaps(ui, a, probes), probes),
1527
+ "omp-settings": askOmpSettings,
1495
1528
  "code-graph": askGraph,
1496
1529
  authority: askAuthorityArea,
1497
1530
  policy: askPolicy,
@@ -1528,6 +1561,9 @@ const INTERVIEW_AREAS: readonly { label: string; asker: AreaAsker }[] = [
1528
1561
  { label: "authority & release grants", asker: askAuthorityArea },
1529
1562
  { label: "merge & release preconditions", asker: askPolicy },
1530
1563
  { label: "worker model", asker: askWorkerModel },
1564
+ // The omp settings overlay rides next to the worker model: both are
1565
+ // per-worker runtime knobs, and neither is a ceiling the caps block owns.
1566
+ { label: "omp settings overlay", asker: askOmpSettings },
1531
1567
  { label: "escalation & triage", asker: askEscalation },
1532
1568
  { label: "reporting", asker: askReporting },
1533
1569
  { label: "orchestrator brief", asker: askBrief },
package/src/setup.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
24
  import { homedir } from "node:os";
25
25
  import { dirname, join } from "node:path";
26
+ import { parse as parseYaml } from "yaml";
26
27
  import {
27
28
  COMPOSE_BANNER,
28
29
  ORCHESTRATOR_BRIEF_NAME,
@@ -46,6 +47,7 @@ import {
46
47
  stateDir,
47
48
  } from "./config.ts";
48
49
  import { graphProjectPath, graphRepos } from "./graph.ts";
50
+ import { ompSettingsOverlay } from "./omp-settings.ts";
49
51
  import {
50
52
  CONFIG_VERSION,
51
53
  DEFAULT_AUTHORITY,
@@ -163,6 +165,15 @@ export interface SetupAnswers {
163
165
  */
164
166
  modelFallbacks?: string[];
165
167
  modelFallbackThreshold?: number;
168
+ /**
169
+ * The project's omp settings overlay (#537): an opaque map layered into
170
+ * every worker session via the fleet-owned settings channel. The wizard asks
171
+ * for it as one free-form YAML answer (the only prompt this map gets — the
172
+ * keys inside it are omp's vocabulary, not a list conductor can offer); it
173
+ * also survives an amend of any other area unchanged, like
174
+ * {@link modelFallbacks}.
175
+ */
176
+ ompSettings?: Record<string, unknown>;
166
177
  telegramChatId?: string;
167
178
  /** Forum topic for tier-2 Telegram pages; absent keeps flat-chat 0.13 behaviour. */
168
179
  telegramTopicId?: number;
@@ -855,6 +866,9 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
855
866
  // would pin every run of the project onto a dead provider again (#286).
856
867
  ...(a.modelFallbacks === undefined ? {} : { modelFallbacks: [...a.modelFallbacks] }),
857
868
  ...(a.modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold: a.modelFallbackThreshold }),
869
+ // The omp settings overlay is an opaque map the wizard collects as free-form
870
+ // YAML; an unrelated amend must not delete it (#537).
871
+ ...(a.ompSettings === undefined ? {} : { ompSettings: a.ompSettings }),
858
872
  escalation,
859
873
  authority: { ...a.authority },
860
874
  // Written out in full, never as the legacy string: the file then says which
@@ -1061,6 +1075,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
1061
1075
  if (p.groomBelow !== undefined) answers.groomBelow = p.groomBelow;
1062
1076
  if (p.modelFallbacks !== undefined) answers.modelFallbacks = [...p.modelFallbacks];
1063
1077
  if (p.modelFallbackThreshold !== undefined) answers.modelFallbackThreshold = p.modelFallbackThreshold;
1078
+ if (p.ompSettings !== undefined) answers.ompSettings = { ...p.ompSettings };
1064
1079
  if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
1065
1080
  if (p.escalation.telegramTopicId !== undefined) answers.telegramTopicId = p.escalation.telegramTopicId;
1066
1081
  if (p.recoveryMerges !== undefined) {
@@ -1682,19 +1697,47 @@ export function formatGates(gates: readonly { cmd: string; cwd: string }[]): str
1682
1697
  return gates.map((g) => (g.cwd === "." ? g.cmd : `${g.cmd} @ ${g.cwd}`)).join(", ");
1683
1698
  }
1684
1699
 
1700
+ /**
1701
+ * Parses the wizard's free-form YAML answer for a project's omp settings
1702
+ * overlay (#537) and validates YAML shape only — the same boundary the config
1703
+ * loader enforces. A mapping at the document root is the whole contract:
1704
+ * everything inside it is omp's schema to own, so an unknown key or a
1705
+ * wrong-typed value is omp's to reject, never this module's to understand
1706
+ * (non-string keys are coerced by the YAML parser exactly as omp's own loader
1707
+ * coerces them, so a `true: x` mapping stays YAML-valid on both sides). Blank
1708
+ * input is a valid "no overlay" answer.
1709
+ */
1710
+ export function parseOmpSettingsYaml(
1711
+ text: string,
1712
+ ): { ok: true; value: Record<string, unknown> } | { ok: false; problem: string } {
1713
+ const trimmed = text.trim();
1714
+ if (trimmed.length === 0) return { ok: true, value: {} };
1715
+ let parsed: unknown;
1716
+ try {
1717
+ parsed = parseYaml(trimmed);
1718
+ } catch (err) {
1719
+ return { ok: false, problem: `"${text}" is not valid YAML: ${err instanceof Error ? err.message : String(err)}` };
1720
+ }
1721
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1722
+ return { ok: false, problem: "the omp settings overlay must be a YAML mapping at the root, e.g. `modelRoles: { worker: \"@slow\" }`" };
1723
+ }
1724
+ return { ok: true, value: parsed as Record<string, unknown> };
1725
+ }
1726
+
1685
1727
  /**
1686
1728
  * The wizard's questions, grouped as the areas a re-run can amend one of, in the
1687
1729
  * order the full interview asks them.
1688
1730
  *
1689
1731
  * Data rather than a switch so the menu, the CLI's positional area vocabulary,
1690
1732
  * ./setup-wizard.ts's `AREA_ASKERS` table and the amend summary all enumerate the
1691
- * same nine areas: an added area fails to compile until it has a name, a current
1733
+ * same ten areas: an added area fails to compile until it has a name, a current
1692
1734
  * value and a set of questions.
1693
1735
  */
1694
1736
  export const AMEND_AREA_IDS = [
1695
1737
  "tracker",
1696
1738
  "gates",
1697
1739
  "caps",
1740
+ "omp-settings",
1698
1741
  "code-graph",
1699
1742
  "authority",
1700
1743
  "policy",
@@ -1765,6 +1808,20 @@ export const AMEND_AREAS: {
1765
1808
  );
1766
1809
  },
1767
1810
  },
1811
+ "omp-settings": {
1812
+ // The free-form omp settings overlay (#537), the sibling per-worker knob to
1813
+ // the model: one area no menu offers is a setting only a full re-interview
1814
+ // can reach, so this one has its own row like the caps.
1815
+ name: "omp settings overlay",
1816
+ asks: "the free-form YAML map layered into every worker session's omp settings — omp's schema, not conductor's",
1817
+ // The *effective* overlay — `ompSettings` plus the retry keys derived from
1818
+ // `modelFallbacks` — so the menu says what a worker would actually load,
1819
+ // matching `doctor` and the dispatcher rather than echoing raw config.
1820
+ describe: (p) => {
1821
+ const overlay = ompSettingsOverlay(p);
1822
+ return overlay === undefined ? "no overlay — workers inherit global settings" : JSON.stringify(overlay);
1823
+ },
1824
+ },
1768
1825
  "code-graph": {
1769
1826
  name: "code graph",
1770
1827
  asks: "whether workers query a code-graph index, and the root its one-clone-per-repo lives under",