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/src/setup.ts CHANGED
@@ -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;
@@ -1776,6 +1804,11 @@ export function summarisePlan(
1776
1804
  if (a.workerModel !== undefined && a.workerModel.trim().length > 0) {
1777
1805
  lines.push(` ${"worker model".padEnd(CAPS_PLAN_KEY_PAD)}${a.workerModel.trim()} (answered)`);
1778
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
+ }
1779
1812
 
1780
1813
  lines.push("", "escalation");
1781
1814
  if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) {
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Is the daily spend cap's own input still arriving? (#970)
3
+ *
4
+ * The cap compares `store.spendSince(project, startOfToday())` — a sum of the
5
+ * per-run `spendUsd` column — against its ceiling. When the harness stops
6
+ * reporting cost, that column reads 0.00 and the cap silently measures a
7
+ * fraction of reality. #46 named this once already: **$0.00 spend is not proof
8
+ * of no spend.**
9
+ *
10
+ * Measured on this fleet, counting only runs that actually did work:
11
+ *
12
+ * | day | worked | reported $0.00 |
13
+ * |------------|--------|----------------|
14
+ * | 2026-08-15 | 53 | 3.8% |
15
+ * | … | | 0–2.5% |
16
+ * | 2026-08-20 | 15 | 0.0% |
17
+ * | 2026-08-21 | 22 | 31.8% |
18
+ * | 2026-08-22 | 12 | 75.0% |
19
+ *
20
+ * Six days of ≤3.8% baseline, then 32%, then 75% — with #614 running 213 turns
21
+ * for $0.00.
22
+ *
23
+ * Two properties are load-bearing, and both were wrong in the predicate this
24
+ * replaces:
25
+ *
26
+ * - **Only runs that did work are judged.** A run killed before its first turn
27
+ * records $0.00 honestly; 26 of this project's 48 zero-spend rows are exactly
28
+ * that. Counting them inflates the ratio *and* lets a run of administrative
29
+ * kills fire a telemetry finding that says nothing about telemetry.
30
+ * - **A ratio, not `every`.** The old check fired only when every sampled run
31
+ * reported zero. Replayed over all 410 windows in this fleet's history it
32
+ * would have fired 14 times while staying silent through 37 windows that had
33
+ * lost a majority of their telemetry — so the common case, partial loss, was
34
+ * the invisible one. On 2026-08-21, at 31.8% loss, every window that day
35
+ * contained one metered run and the probe was silent all day.
36
+ *
37
+ * One judgement, two consumers: `doctor`'s finding and the `status` row. The
38
+ * cap's figure and the statement about whether that figure means anything must
39
+ * never come from two implementations that can disagree.
40
+ */
41
+
42
+ /** One sampled run, reduced to what the judgement needs. */
43
+ export interface SpendSample {
44
+ /** Turns taken. Zero means the run never worked, so its $0.00 is honest. */
45
+ turns: number;
46
+ spendUsd: number;
47
+ }
48
+
49
+ /**
50
+ * The share of *working* runs whose spend is missing at or above which this is
51
+ * reported.
52
+ *
53
+ * A majority, deliberately — the observed healthy baseline is 0–3.8%, not zero.
54
+ * A legitimately free or aborted-after-one-turn run happens, and a check that
55
+ * fires on one of them is a check that gets ignored, which is the failure the
56
+ * install-surface rows already name: a warning repeated every fifteen minutes
57
+ * trains an operator to ignore the row that matters.
58
+ */
59
+ export const SPEND_MISSING_SHARE = 0.5;
60
+
61
+ export type SpendTelemetryVerdict =
62
+ /** Enough working runs, and most of them metered. */
63
+ | { kind: "healthy"; worked: number; missing: number; totalUsd: number }
64
+ /** Not enough working runs to say anything — never a pass by default. */
65
+ | { kind: "insufficient"; worked: number; needed: number }
66
+ /** A majority of working runs reported nothing. */
67
+ | { kind: "partial"; worked: number; missing: number }
68
+ /** Every working run reported nothing — the same fact, stated louder. */
69
+ | { kind: "absent"; worked: number };
70
+
71
+ /**
72
+ * Judge a newest-first sample.
73
+ *
74
+ * `limit` is how many *working* runs are wanted, not how many rows to read: the
75
+ * caller over-samples and this takes the first `limit` runs that did work, so a
76
+ * burst of 0-turn kills cannot push the real evidence out of the window.
77
+ */
78
+ export function judgeSpendTelemetry(
79
+ samples: readonly SpendSample[],
80
+ limit: number,
81
+ ): SpendTelemetryVerdict {
82
+ const working = samples.filter((row) => row.turns > 0).slice(0, limit);
83
+ if (working.length < limit) {
84
+ return { kind: "insufficient", worked: working.length, needed: limit };
85
+ }
86
+ const missing = working.filter((row) => row.spendUsd === 0).length;
87
+ if (missing === working.length) return { kind: "absent", worked: working.length };
88
+ if (missing / working.length >= SPEND_MISSING_SHARE) {
89
+ return { kind: "partial", worked: working.length, missing };
90
+ }
91
+ return {
92
+ kind: "healthy",
93
+ worked: working.length,
94
+ missing,
95
+ totalUsd: working.reduce((sum, row) => sum + row.spendUsd, 0),
96
+ };
97
+ }
98
+
99
+ /** The operator-facing sentence, or undefined when there is nothing to say.
100
+ * Shared so `doctor` and `status` cannot word the same fact differently. */
101
+ export function spendTelemetryDetail(verdict: SpendTelemetryVerdict): string | undefined {
102
+ switch (verdict.kind) {
103
+ case "absent":
104
+ return (
105
+ `the last ${verdict.worked} completed runs that did any work all recorded $0.00 spend` +
106
+ " — the USD cap cannot fire on zeros, and $0.00 is not proof of no spend"
107
+ );
108
+ case "partial":
109
+ return (
110
+ `${verdict.missing} of the last ${verdict.worked} completed runs that did any work recorded ` +
111
+ "$0.00 spend — the USD cap is comparing a fraction of actual spend against its ceiling"
112
+ );
113
+ case "healthy":
114
+ case "insufficient":
115
+ return undefined;
116
+ }
117
+ }
package/src/stats.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  * counted separately as **unmetered** and never averaged into a per-issue cost.
20
20
  */
21
21
 
22
+ import { utcDay } from "./store.ts";
22
23
  import type { RunRecord } from "./types.ts";
23
24
 
24
25
  /** The bounded window a report answers over. Day keys are UTC (`YYYY-MM-DD`). */
@@ -181,6 +182,40 @@ function summarize(args: {
181
182
  * settled there. Rows within an issue must be ordered by `startedAt`, so the
182
183
  * first row of a chain is its first queue-label claim.
183
184
  */
185
+ /** `7d` / `30d` / any positive whole-day duration. */
186
+ const DURATION_FORM = /^(\d+)d$/;
187
+ /** A bare UTC date: the window starts at 00:00:00Z that day. */
188
+ const DATE_FORM = /^\d{4}-\d{2}-\d{2}$/;
189
+ const DAY_MS = 86_400_000;
190
+
191
+ /**
192
+ * `--since` (or `?since=`) to a full window, or `undefined` when it is not one.
193
+ *
194
+ * Here rather than in the CLI because the dashboard needs the identical parse
195
+ * (#297): two parsers would eventually disagree about what `30d` means, and the
196
+ * page's numbers are supposed to equal `stats --json` for the same window. The
197
+ * CLI turns `undefined` into its exit-2 message and the endpoint turns it into a
198
+ * 400 — neither silently defaults a typo'd window, because measuring the wrong
199
+ * week and saying nothing is the failure this shape prevents.
200
+ */
201
+ export function parseStatsWindow(raw: string, now: number): StatsWindow | undefined {
202
+ const until = { untilEpochMs: now, untilDay: utcDay(now) };
203
+ const duration = DURATION_FORM.exec(raw);
204
+ if (duration !== null) {
205
+ const days = Number(duration[1]);
206
+ if (days >= 1) {
207
+ const sinceEpochMs = now - days * DAY_MS;
208
+ return { sinceEpochMs, sinceDay: utcDay(sinceEpochMs), ...until };
209
+ }
210
+ return undefined;
211
+ }
212
+ if (DATE_FORM.test(raw)) {
213
+ const sinceEpochMs = Date.parse(`${raw}T00:00:00Z`);
214
+ if (Number.isFinite(sinceEpochMs)) return { sinceEpochMs, sinceDay: raw, ...until };
215
+ }
216
+ return undefined;
217
+ }
218
+
184
219
  export function computeStats(args: {
185
220
  project: string;
186
221
  window: StatsWindow;