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
@@ -0,0 +1,232 @@
1
+ /**
2
+ * `release-composition` — the operator's explicit lifecycle for a project's
3
+ * active release (#850).
4
+ *
5
+ * A reliability patch is only trustworthy while nothing unrelated slips into
6
+ * it, so a project can declare one active release composition: a campaign
7
+ * identifier plus the exact PRs allowed into it. While one is active,
8
+ * `conductor_pr_merge` refuses every other PR with the
9
+ * `outside-active-release` refusal — this command family is the only way to
10
+ * move that guard: `declare` it, admit exactly one extra PR with `override`,
11
+ * or retire it with `complete` / `cancel`. Every transition is written to the
12
+ * store as durable state that survives daemon restarts and recorded as an
13
+ * immutable material event as its audit trail; a merge call's free-form
14
+ * reason is never an override.
15
+ */
16
+
17
+ import type { CommandContext } from "./context.ts";
18
+ import { findProject, loadConfig } from "../config.ts";
19
+ import { dbPath, openStore } from "../store.ts";
20
+
21
+ /** Every `--pr URL` / `--pr=URL` value in the invocation tail, in order. */
22
+ function prUrls(argv: readonly string[]): string[] {
23
+ const urls: string[] = [];
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const token = argv[i]!;
26
+ if (token === "--pr") {
27
+ const value = argv[i + 1];
28
+ if (value !== undefined && !value.startsWith("-")) {
29
+ urls.push(value);
30
+ i++;
31
+ }
32
+ } else if (token.startsWith("--pr=")) {
33
+ urls.push(token.slice("--pr=".length));
34
+ }
35
+ }
36
+ return urls;
37
+ }
38
+
39
+ /** A full pull-request URL — the exact shape the merge gate compares against. */
40
+ function isFullPrUrl(url: string): boolean {
41
+ return /^https:\/\/[^/]+\/.+\/pull\/\d+$/.test(url);
42
+ }
43
+
44
+ const USAGE_LINE =
45
+ "omp-conductor: release-composition needs a subcommand: " +
46
+ "declare | override | complete | cancel | status\n";
47
+
48
+ export async function releaseCompositionCommand(ctx: CommandContext): Promise<void> {
49
+ const sub = ctx.argv[1];
50
+ if (sub === undefined || sub.length === 0 || sub.startsWith("-")) {
51
+ process.stderr.write(USAGE_LINE);
52
+ process.exit(2);
53
+ }
54
+
55
+ // Argument shape is validated before anything opens: a malformed invocation
56
+ // never touches the store, and every store-backed refusal below still runs
57
+ // the `close()` in the finally before the process exits.
58
+ let campaign: string | undefined;
59
+ let urls: string[] = [];
60
+ if (sub === "declare") {
61
+ campaign = ctx.flag("campaign");
62
+ if (campaign === undefined || campaign.length === 0) {
63
+ process.stderr.write(
64
+ "omp-conductor: release-composition declare needs --campaign ID, e.g. v0.18.1-reliability\n",
65
+ );
66
+ process.exit(2);
67
+ }
68
+ urls = prUrls(ctx.argv.slice(2));
69
+ const malformed = urls.filter((url) => !isFullPrUrl(url));
70
+ if (malformed.length > 0) {
71
+ process.stderr.write(
72
+ "omp-conductor: --pr takes full pull-request URLs like https://github.com/owner/repo/pull/7; " +
73
+ `refusing: ${malformed.join(", ")}\n`,
74
+ );
75
+ process.exit(2);
76
+ }
77
+ }
78
+ if (sub === "override") {
79
+ urls = prUrls(ctx.argv.slice(2));
80
+ if (urls.length !== 1 || !isFullPrUrl(urls[0]!)) {
81
+ process.stderr.write(
82
+ "omp-conductor: release-composition override needs exactly one full --pr URL " +
83
+ "(https://github.com/owner/repo/pull/N)\n",
84
+ );
85
+ process.exit(2);
86
+ }
87
+ }
88
+
89
+ const cfg = loadConfig();
90
+ const project = findProject(cfg, ctx.projectFlag);
91
+ const store = openStore(dbPath());
92
+ let failed = false;
93
+ try {
94
+ const now = Date.now();
95
+ switch (sub) {
96
+ case "status": {
97
+ const active = store.activeReleaseComposition(project.name);
98
+ if (active === undefined) {
99
+ process.stdout.write(
100
+ `${project.name}: no active release composition — merges are ungated by release scope.\n`,
101
+ );
102
+ break;
103
+ }
104
+ const overrides = store.releaseCompositionOverrides(project.name, active.campaign);
105
+ process.stdout.write(
106
+ `${project.name}: active release "${active.campaign}", declared ` +
107
+ `${new Date(active.declaredAt).toISOString()}${active.declaredBy ? ` by ${active.declaredBy}` : ""}. ` +
108
+ `Allowed PRs (${active.allowedPrUrls.length}): ` +
109
+ `${active.allowedPrUrls.length === 0 ? "none yet" : active.allowedPrUrls.join(", ")}. ` +
110
+ `Operator overrides: ${overrides.length === 0 ? "none" : overrides.map((o) => o.prUrl).join(", ")}. ` +
111
+ "conductor_pr_merge refuses every other PR until complete|cancel.\n",
112
+ );
113
+ break;
114
+ }
115
+ case "declare": {
116
+ const conflict = store.declareReleaseComposition(project.name, {
117
+ campaign: campaign!,
118
+ allowedPrUrls: urls,
119
+ declaredBy: "operator",
120
+ declaredAt: now,
121
+ });
122
+ if (conflict !== undefined) {
123
+ process.stderr.write(
124
+ `release-composition: ${project.name} already has an active release "${conflict.campaign}" ` +
125
+ `(declared ${new Date(conflict.declaredAt).toISOString()}, ${conflict.allowedPrUrls.length} PR(s) allowed). ` +
126
+ "One release at a time: complete or cancel it first with " +
127
+ "`omp-conductor release-composition complete|cancel`.\n",
128
+ );
129
+ failed = true;
130
+ break;
131
+ }
132
+ store.recordMaterialEvent({
133
+ project: project.name,
134
+ category: "release-composition",
135
+ summary: `operator declared release ${campaign} (${urls.length} PR(s) allowed)`,
136
+ evidence:
137
+ `An operator declared the active release composition "${campaign}" for ${project.name} with ` +
138
+ `${urls.length} allowed PR(s): ${urls.length === 0 ? "none yet" : urls.join(", ")}. While it is ` +
139
+ "active, conductor_pr_merge refuses every other PR with outside-active-release until the " +
140
+ "release completes, is cancelled, or a PR is explicitly overridden.",
141
+ occurredAt: now,
142
+ recordedAt: now,
143
+ });
144
+ process.stdout.write(
145
+ `release-composition: ${project.name} now assembling release "${campaign}" with ` +
146
+ `${urls.length} allowed PR(s). Unrelated merges are refused until complete|cancel.\n`,
147
+ );
148
+ break;
149
+ }
150
+ case "override": {
151
+ const active = store.activeReleaseComposition(project.name);
152
+ if (active === undefined) {
153
+ process.stderr.write(
154
+ `release-composition: ${project.name} has no active release to override — ` +
155
+ "merges are already ungated by release scope.\n",
156
+ );
157
+ failed = true;
158
+ break;
159
+ }
160
+ const reason = ctx.flag("reason") ?? "operator-override";
161
+ const granted = store.grantReleaseCompositionOverride(project.name, {
162
+ campaign: active.campaign,
163
+ prUrl: urls[0]!,
164
+ by: "operator",
165
+ reason,
166
+ at: now,
167
+ });
168
+ store.recordMaterialEvent({
169
+ project: project.name,
170
+ category: "release-composition",
171
+ summary: `operator overrode release ${active.campaign} for ${granted.prUrl}`,
172
+ evidence:
173
+ `An operator admitted ${granted.prUrl} into the active release "${active.campaign}" of ` +
174
+ `${project.name} with reason "${reason}". The override bypasses only the composition check — ` +
175
+ "authority, pause, base-red-freeze, exact-head, green-checks and single-flight still apply.",
176
+ occurredAt: now,
177
+ recordedAt: now,
178
+ });
179
+ process.stdout.write(
180
+ `release-composition: ${granted.prUrl} is now allowed into release "${active.campaign}" ` +
181
+ `in ${project.name} (reason: ${reason}). Every other merge gate still applies.\n`,
182
+ );
183
+ break;
184
+ }
185
+ case "complete":
186
+ case "cancel": {
187
+ const retired =
188
+ sub === "complete"
189
+ ? store.completeReleaseComposition(
190
+ project.name,
191
+ "operator",
192
+ ctx.flag("reason") ?? "operator-completed",
193
+ now,
194
+ )
195
+ : store.cancelReleaseComposition(
196
+ project.name,
197
+ "operator",
198
+ ctx.flag("reason") ?? "operator-cancelled",
199
+ now,
200
+ );
201
+ if (!retired) {
202
+ process.stdout.write(
203
+ `${project.name}: no active release composition — nothing to ${sub}. Merges are ungated by release scope.\n`,
204
+ );
205
+ break;
206
+ }
207
+ store.recordMaterialEvent({
208
+ project: project.name,
209
+ category: "release-composition",
210
+ summary: `operator ${sub === "complete" ? "completed" : "cancelled"} the active release`,
211
+ evidence:
212
+ `An operator ${sub}d the active release composition of ${project.name} with reason ` +
213
+ `"${ctx.flag("reason") ?? `operator-${sub}d`}". conductor_pr_merge merges are no longer ` +
214
+ "restricted by that release's scope; the audit trail keeps the closed row and its overrides.",
215
+ occurredAt: now,
216
+ recordedAt: now,
217
+ });
218
+ process.stdout.write(
219
+ `release-composition: ${project.name}'s active release ${sub}d. Merges are no longer gated ` +
220
+ "by release scope; overrides stay in the audit trail scoped to their campaign.\n",
221
+ );
222
+ break;
223
+ }
224
+ default:
225
+ process.stderr.write(USAGE_LINE);
226
+ process.exit(2);
227
+ }
228
+ } finally {
229
+ store.close();
230
+ }
231
+ if (failed) process.exit(2);
232
+ }
@@ -13,32 +13,7 @@
13
13
 
14
14
  import type { CommandContext } from "./context.ts";
15
15
  import { armState, clearPaneHaltIfResolvable, releaseHold } from "../fleet.ts";
16
- import { livingDaemon } from "../lifecycle.ts";
17
-
18
- /** Wakes a running daemon, returning the human line that states what happened.
19
- * State-only clients (no live daemon) get an honest fallback rather than an
20
- * error: the hold is already cleared and the next pass or daemon start
21
- * claims without this poke. */
22
- async function wakeDaemon(projectName: string): Promise<string> {
23
- const daemon = livingDaemon();
24
- if (daemon === undefined) {
25
- return "daemon not running — claiming starts when the daemon next starts or ticks";
26
- }
27
- let response: Response;
28
- try {
29
- response = await fetch(`http://127.0.0.1:${daemon.port}/wake`, {
30
- method: "POST",
31
- headers: { "content-type": "application/json" },
32
- body: JSON.stringify({ project: projectName }),
33
- });
34
- } catch {
35
- return "daemon wake failed — daemon unreachable; the next scheduled pass will claim";
36
- }
37
- if (!response.ok) {
38
- return `daemon wake failed (HTTP ${response.status}); the next scheduled pass will claim`;
39
- }
40
- return "daemon dispatch loop woken — claiming starts on an immediate pass";
41
- }
16
+ import { wakeDispatch } from "../wake.ts";
42
17
 
43
18
  export async function resumeCommand(ctx: CommandContext): Promise<void> {
44
19
  for (const project of ctx.targetProjects()) {
@@ -46,7 +21,7 @@ for (const project of ctx.targetProjects()) {
46
21
  const pin = clearPaneHaltIfResolvable(project.name);
47
22
  const arm = armState(project.name);
48
23
  process.stdout.write(
49
- `resumed — claiming allowed — ${await wakeDaemon(project.name)}\n` +
24
+ `resumed — claiming allowed — ${await wakeDispatch(project.name)}\n` +
50
25
  (pin.wasHalted
51
26
  ? `pane recovery pin cleared — ${pin.path}\n`
52
27
  : "no pane recovery pin to clear\n") +
@@ -6,13 +6,13 @@
6
6
  * changed from the original bodies.
7
7
  */
8
8
 
9
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { basename, join } from "node:path";
12
12
 
13
13
  import type { CommandContext } from "./context.ts";
14
14
  import type { CompletionShell } from "./complete.ts";
15
- import { findProject, loadConfig, resolveCaps } from "../config.ts";
15
+ import { configPath, findProject, loadConfig, resolveCaps, stateDir } from "../config.ts";
16
16
  import { AMEND_AREA_IDS, type AmendAreaId } from "../setup.ts";
17
17
  import { runGraphInstall, runHostInstall, type InstallOutcome } from "../setup-install.ts";
18
18
  import { DEFAULT_PROBES, NO_PROBES, setup } from "../setup-wizard.ts";
@@ -21,8 +21,11 @@ import {
21
21
  answersUi,
22
22
  guardNonInteractiveUi,
23
23
  loadAnswersFile,
24
+ readSetupResume,
24
25
  recordingAnswersUi,
25
26
  saveAnswersFile,
27
+ saveSetupResume,
28
+ setupConfigHash,
26
29
  type RecordedAnswersUi,
27
30
  } from "../setup-answers.ts";
28
31
  import { terminalUi, type WizardUi } from "../wizard-ui.ts";
@@ -39,7 +42,7 @@ const SETUP_USAGE = `omp-conductor setup — interview, then write config.json,
39
42
  and the staged host files behind one confirm.
40
43
 
41
44
  usage:
42
- omp-conductor setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--project NAME]
45
+ omp-conductor setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--resume FILE] [--project NAME]
43
46
  omp-conductor setup host [NAME] [--project NAME]
44
47
  omp-conductor setup graph [--no-seed] [--print] [--project NAME]
45
48
 
@@ -57,7 +60,9 @@ flags:
57
60
  for \`setup host\`)
58
61
  --no-ai ask every question, propose nothing (no AI repo reads)
59
62
  --answers FILE answer every prompt from JSON; never opens a prompt
60
- --save-answers FILE save successful prompt answers as replayable JSON`;
63
+ --save-answers FILE save successful prompt answers as replayable JSON
64
+ --resume FILE replay the answers a failed apply kept, bound to the
65
+ config they were given against; refuses a changed config`;
61
66
 
62
67
  /**
63
68
  * Resolve the project an install subcommand (`setup host`, `setup graph`)
@@ -126,7 +131,37 @@ async function offerCompletionInstall(ui: WizardUi): Promise<void> {
126
131
  ui.notify(`Installed completions at ${installed.scriptPath}; sourced from ${installed.rcPath}.`);
127
132
  }
128
133
 
129
- function setupValueFlag(argv: readonly string[], name: "--answers" | "--save-answers"): string | undefined {
134
+ /** This build's version the schema fence a resume file is checked against. */
135
+ const PACKAGE_VERSION: string = ((): string => {
136
+ try {
137
+ const raw: unknown = JSON.parse(readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8"));
138
+ const version = raw !== null && typeof raw === "object" ? Reflect.get(raw, "version") : undefined;
139
+ return typeof version === "string" && version.length > 0 ? version : "unknown";
140
+ } catch {
141
+ return "unknown";
142
+ }
143
+ })();
144
+
145
+ /** The live config generation a resume file must match, or `absent`. */
146
+ function liveSetupConfigHash(): string {
147
+ return setupConfigHash(() => {
148
+ try {
149
+ return readFileSync(configPath(), "utf8");
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Where a failed post-interview apply leaves its answers (#864), per project so
158
+ * two fleets on one host cannot resume each other's interview.
159
+ */
160
+ export function setupResumePath(project: string | undefined): string {
161
+ return join(stateDir(), `setup-resume-${project ?? "host"}.json`);
162
+ }
163
+
164
+ function setupValueFlag(argv: readonly string[], name: "--answers" | "--save-answers" | "--resume"): string | undefined {
130
165
  const inline = argv.find((arg) => arg.startsWith(`${name}=`));
131
166
  const at = argv.indexOf(name);
132
167
  if (inline !== undefined && at !== -1) throw new Error(`${name} may be passed only once`);
@@ -154,18 +189,42 @@ const positional = sub !== undefined && !sub.startsWith("--") ? sub : undefined;
154
189
  // protocol, while --answers installs a prompt-free driver.
155
190
  const answersPath = setupValueFlag(ctx.argv, "--answers");
156
191
  const saveAnswersPath = setupValueFlag(ctx.argv, "--save-answers");
157
- const useClack = answersPath === undefined && interactiveUi();
158
- const baseUi = answersPath
159
- ? answersUi(loadAnswersFile(answersPath))
160
- : useClack
161
- ? (await import("../clack-ui.ts")).clackUi()
162
- : terminalUi();
163
- const guardedUi = answersPath === undefined && !process.stdin.isTTY ? guardNonInteractiveUi(baseUi) : baseUi;
164
- const recording: RecordedAnswersUi | undefined =
165
- saveAnswersPath === undefined ? undefined : recordingAnswersUi(guardedUi);
166
- const ui = recording?.ui ?? guardedUi;
192
+ // `--resume` is `--answers` bound to the config it was answered against (#864):
193
+ // the apply phase failed, the interview did not, and re-typing it is the cost
194
+ // this flag removes. The binding is checked before a single prompt is skipped,
195
+ // because replaying answers over a config that changed since is how an old plan
196
+ // silently overwrites newer policy.
197
+ const resumePath = setupValueFlag(ctx.argv, "--resume");
198
+ const resumed =
199
+ resumePath === undefined
200
+ ? undefined
201
+ : readSetupResume(resumePath, liveSetupConfigHash(), PACKAGE_VERSION);
202
+ if (resumed !== undefined && !resumed.ok) {
203
+ process.stderr.write(`omp-conductor: setup: ${resumed.problem}\n`);
204
+ process.exitCode = 2;
205
+ return;
206
+ }
207
+ const scriptedAnswers = resumed?.ok === true ? resumed.file.answers : undefined;
208
+ const useClack = answersPath === undefined && scriptedAnswers === undefined && interactiveUi();
209
+ const baseUi = scriptedAnswers
210
+ ? answersUi(scriptedAnswers)
211
+ : answersPath
212
+ ? answersUi(loadAnswersFile(answersPath))
213
+ : useClack
214
+ ? (await import("../clack-ui.ts")).clackUi()
215
+ : terminalUi();
216
+ const guardedUi =
217
+ answersPath === undefined && scriptedAnswers === undefined && !process.stdin.isTTY
218
+ ? guardNonInteractiveUi(baseUi)
219
+ : baseUi;
220
+ // Always recording now, not only under `--save-answers`: the answers of a failed
221
+ // apply are exactly what the retry needs, and a flag nobody passed in advance
222
+ // cannot help the failure that has already happened.
223
+ const recording: RecordedAnswersUi = recordingAnswersUi(guardedUi);
224
+ const ui = recording.ui;
167
225
  let saveCompletedAnswers = false;
168
226
  let closeMessage = "Setup failed.";
227
+ let applyFailed = false;
169
228
  try {
170
229
  // `host` and `graph` are install subcommands, checked BEFORE the amend
171
230
  // areas. They are not areas — routing them through the area validation
@@ -244,12 +303,38 @@ try {
244
303
  }
245
304
  saveCompletedAnswers = completed;
246
305
  closeMessage = completed ? "Setup complete." : "Setup incomplete.";
306
+ } catch (err) {
307
+ // The interview succeeded and the apply phase did not — the shape of the
308
+ // 2026-08-21 incident, where five retries re-asked every question while the
309
+ // real fault was a runtime gate. Keep the answers, bound to the config they
310
+ // were given against, and name the exact command that replays them.
311
+ applyFailed = true;
312
+ throw err;
247
313
  } finally {
248
314
  try {
249
- if (saveCompletedAnswers && saveAnswersPath !== undefined && recording !== undefined) {
315
+ if (applyFailed && Object.keys(recording.answers).length > 0) {
316
+ const path = setupResumePath(ctx.projectFlag);
317
+ saveSetupResume(path, {
318
+ configHash: liveSetupConfigHash(),
319
+ version: PACKAGE_VERSION,
320
+ ...(positional === undefined ? {} : { area: positional }),
321
+ ...(ctx.projectFlag === undefined ? {} : { project: ctx.projectFlag }),
322
+ answers: recording.answers,
323
+ });
324
+ ui.notify(
325
+ `Answers kept — retry without re-answering:\n omp-conductor setup${
326
+ positional === undefined ? "" : ` ${positional}`
327
+ }${ctx.projectFlag === undefined ? "" : ` --project ${ctx.projectFlag}`} --resume ${path}`,
328
+ "warning",
329
+ );
330
+ }
331
+ if (saveCompletedAnswers && saveAnswersPath !== undefined) {
250
332
  saveAnswersFile(saveAnswersPath, recording.answers);
251
333
  ui.notify(`Saved setup answers to ${saveAnswersPath}.`);
252
334
  }
335
+ // A completed setup retires its own resume file: a stale one invites a
336
+ // replay of answers that have already been applied.
337
+ if (saveCompletedAnswers) rmSync(setupResumePath(ctx.projectFlag), { force: true });
253
338
  } catch (err) {
254
339
  closeMessage = "Setup failed.";
255
340
  throw err;
@@ -12,7 +12,7 @@
12
12
  import type { CommandContext } from "./context.ts";
13
13
  import { findProject, loadConfig } from "../config.ts";
14
14
  import { dbPath, openStore, utcDay } from "../store.ts";
15
- import { computeStats, renderStatsHuman, type StatsWindow } from "../stats.ts";
15
+ import { type StatsWindow, computeStats, parseStatsWindow, renderStatsHuman } from "../stats.ts";
16
16
  import { heading } from "../ui/style.ts";
17
17
 
18
18
  const DAY_MS = 24 * 60 * 60 * 1_000;
@@ -48,30 +48,6 @@ flags:
48
48
  When nothing settled in the window the report says so explicitly ("empty:
49
49
  true" in --json) rather than printing zeros that could read as measurements.`;
50
50
 
51
- /** `--since` to a window. The bare date form means from 00:00:00Z of that day;
52
- * the duration form (`7d`) is now minus whole days. Anything else exits 2,
53
- * because silently defaulting a typo'd window would measure the wrong week. */
54
- function sinceWindow(raw: string, now: number): Pick<StatsWindow, "sinceDay" | "sinceEpochMs"> {
55
- const duration = DURATION_FORM.exec(raw);
56
- if (duration !== null) {
57
- const days = Number(duration[1]);
58
- if (days >= 1) {
59
- const sinceEpochMs = now - days * DAY_MS;
60
- return { sinceEpochMs, sinceDay: utcDay(sinceEpochMs) };
61
- }
62
- }
63
- if (DATE_FORM.test(raw)) {
64
- const sinceEpochMs = Date.parse(`${raw}T00:00:00Z`);
65
- if (Number.isFinite(sinceEpochMs)) {
66
- return { sinceEpochMs, sinceDay: raw };
67
- }
68
- }
69
- process.stderr.write(
70
- `omp-conductor: stats --since needs 7d, 30d or YYYY-MM-DD, got "${raw}"\n`,
71
- );
72
- process.exit(2);
73
- }
74
-
75
51
  export async function statsCommand(ctx: CommandContext): Promise<void> {
76
52
  // Help first: parsing stops before any config read or store open.
77
53
  if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
@@ -108,11 +84,16 @@ export async function statsCommand(ctx: CommandContext): Promise<void> {
108
84
  process.stderr.write(`omp-conductor: stats --since needs 7d, 30d or YYYY-MM-DD\n`);
109
85
  process.exit(2);
110
86
  }
111
- const window: StatsWindow = {
112
- ...sinceWindow(rawSince ?? "7d", now),
113
- untilEpochMs: now,
114
- untilDay: utcDay(now),
115
- };
87
+ // One parser for the CLI and the dashboard endpoint (#297): two would
88
+ // eventually disagree about what `30d` means, and the dashboard's numbers are
89
+ // required to equal these for the same window.
90
+ const window = parseStatsWindow(rawSince ?? "7d", now);
91
+ if (window === undefined) {
92
+ process.stderr.write(
93
+ `omp-conductor: stats --since needs 7d, 30d or YYYY-MM-DD, got "${rawSince ?? ""}"\n`,
94
+ );
95
+ process.exit(2);
96
+ }
116
97
  const store = openStore(dbPath());
117
98
  try {
118
99
  const report = computeStats({
@@ -153,6 +153,21 @@ function styleTailLine(line: string): string {
153
153
  return line;
154
154
  }
155
155
 
156
+ /** A poll delay that ends early when the follow is aborted. */
157
+ function sleepUntilAbort(ms: number, signal?: AbortSignal): Promise<void> {
158
+ if (signal === undefined) return new Promise((resolve) => setTimeout(resolve, ms));
159
+ const abort = signal;
160
+ return new Promise((resolve) => {
161
+ const done = (): void => {
162
+ clearTimeout(timer);
163
+ abort.removeEventListener("abort", done);
164
+ resolve();
165
+ };
166
+ const timer = setTimeout(done, ms);
167
+ abort.addEventListener("abort", done, { once: true });
168
+ });
169
+ }
170
+
156
171
  /**
157
172
  * Follow one run's transcripts the way `tail -f` follows a log: the worker's
158
173
  * own transcript plus any `__advisor*.jsonl` sitting beside it (the mid-run
@@ -181,6 +196,16 @@ export async function tailRun(
181
196
  quietMs?: number;
182
197
  /** Where rendered lines go; the CLI writes to stdout, tests capture. */
183
198
  write?: (line: string) => void;
199
+ /**
200
+ * Stops the follow before the run is terminal (#296).
201
+ *
202
+ * The CLI passes none: `tail` runs until the run ends or the operator hits
203
+ * Ctrl-C, and SIGINT's default is immediate exit. The dashboard's SSE stream
204
+ * passes the request's signal, because a browser that closed its tab must
205
+ * not leave a poll loop holding file descriptors until the run finishes —
206
+ * repeated opens would accumulate one follow each.
207
+ */
208
+ signal?: AbortSignal;
184
209
  } = {},
185
210
  ): Promise<void> {
186
211
  const store = deps.store ?? openStore(dbPath());
@@ -204,6 +229,9 @@ export async function tailRun(
204
229
  let lastChange = Date.now();
205
230
 
206
231
  for (;;) {
232
+ // Checked at the top as well as after the sleep: a signal that was
233
+ // already aborted must not buy one whole poll's worth of work.
234
+ if (deps.signal?.aborted === true) return;
207
235
  let changed = false;
208
236
  const primaryDelta = followLines(primary, path);
209
237
  changed = primaryDelta.changed || changed;
@@ -264,7 +292,9 @@ export async function tailRun(
264
292
  write(`run ended: ${state}`);
265
293
  return;
266
294
  }
267
- await new Promise<void>((resolve) => setTimeout(resolve, pollMs));
295
+ // Wakes on the signal rather than after the full poll: a closed browser
296
+ // should release the descriptors now, not up to a second later.
297
+ await sleepUntilAbort(pollMs, deps.signal);
268
298
  }
269
299
  } finally {
270
300
  closeSync(primary.fd);
@@ -10,14 +10,31 @@ import type { CommandContext } from "./context.ts";
10
10
  import { upgradeConductor } from "../upgrade.ts";
11
11
 
12
12
  export async function upgradeCommand(ctx: CommandContext): Promise<void> {
13
+ // `--bootstrap SHA --source PATH` installs an exact source/build identity
14
+ // instead of a published semver (#908): the path that exists because a
15
+ // reliability release cannot be cut when the installed conductor is what is
16
+ // broken. Both flags or neither — a sha with no source has nothing to run the
17
+ // package's own checks against, and a source with no sha is not an identity.
18
+ const sha = ctx.flag("bootstrap");
19
+ const source = ctx.flag("source");
20
+ if ((sha === undefined) !== (source === undefined)) {
21
+ process.stderr.write(
22
+ "upgrade: --bootstrap SHA and --source PATH go together — the sha is the identity, the source is " +
23
+ "the tree its checks run against\n",
24
+ );
25
+ process.exitCode = 1;
26
+ return;
27
+ }
13
28
  const result = await upgradeConductor({
14
29
  version: ctx.flag("to"),
30
+ ...(sha === undefined || source === undefined ? {} : { bootstrap: { sha, source } }),
15
31
  project: ctx.projectFlag,
16
32
  });
33
+ const spec = sha === undefined ? `omp-conductor@${result.version}` : `${result.version} @ ${result.gitHead.slice(0, 12)}`;
17
34
  process.stdout.write(
18
- `${result.alreadyCurrent ? "already current" : "upgrade complete"}:\n` +
19
- ` Bun-global CLI omp-conductor@${result.version}\n` +
20
- ` omp plugin omp-conductor@${result.version}\n` +
35
+ `${result.alreadyCurrent ? "already current" : sha === undefined ? "upgrade complete" : "bootstrap complete"}:\n` +
36
+ ` Bun-global CLI ${spec}\n` +
37
+ ` omp plugin ${spec}\n` +
21
38
  ` Herdr plugin herdr-conductor@${result.gitHead}\n` +
22
39
  ` orchestrator brief managed ORCHESTRATOR.md floor current; POLICY.md preserved\n` +
23
40
  ` dispatch ${result.dispatch}${result.alreadyCurrent ? "" : " restored"}\n`,
@@ -9,7 +9,7 @@
9
9
  import type { CommandContext } from "./context.ts";
10
10
  import { findProject, loadConfig } from "../config.ts";
11
11
  import { isPaused, pausedAt } from "../daemon.ts";
12
- import { readBaseChain } from "../gitops.ts";
12
+ import { probeRunLane, readBaseChain } from "../gitops.ts";
13
13
  import { dbPath, openStore } from "../store.ts";
14
14
  import { makeTracker } from "../tracker/github.ts";
15
15
  import { VERB_NAMES } from "../types.ts";
@@ -70,6 +70,7 @@ try {
70
70
  log: (m) => process.stderr.write(`${m}\n`),
71
71
  now: () => Date.now(),
72
72
  chain: { readBaseChain },
73
+ lane: { probeRunLane },
73
74
  },
74
75
  channel,
75
76
  { verb: name, args },
@@ -125,6 +125,14 @@ const capsSchema = z
125
125
  .min(0)
126
126
  .nullable()
127
127
  .describe(`Rolling-day spend ceiling; null means no spend gate (default ${DEFAULT_CAPS.dailySpendUsd})`),
128
+ maxRunSpendUsd: z
129
+ .number()
130
+ .positive()
131
+ .nullable()
132
+ .describe(
133
+ "Ceiling on one run, reserved from the daily budget before it launches; " +
134
+ "null derives it from dailySpendUsd. Must not exceed dailySpendUsd",
135
+ ),
128
136
  planUsage: z.union([planUsageCap, z.null()]).describe(`Plan-allowance guard, or null for unmetered`),
129
137
  workerMaxTurns: z.number().min(0).describe(`Turn ceiling for one worker (default ${DEFAULT_CAPS.workerMaxTurns})`),
130
138
  workerMaxTurnsCeiling: z.number().min(0).describe(`Maximum turn budget assignable to one issue's next attempt`),
@@ -399,6 +407,17 @@ const projectSchema = z
399
407
  // usable, exactly like `workerModel`.
400
408
  modelFallbacks: z.unknown().optional(),
401
409
  modelFallbackThreshold: z.unknown().optional(),
410
+ // One stronger opaque omp selector the first spinning cap in a chain
411
+ // retries on (#807). Normalised (trimmed, blank dropped) by the loader
412
+ // like `workerModel`, and deliberately separate from `modelFallbacks`:
413
+ // that chain answers provider faults only.
414
+ workerEscalationModel: z.unknown().optional(),
415
+ // Providers that must bill to a subscription credential (#852). Normalised
416
+ // by the loader (trimmed, blanks and non-strings dropped) like
417
+ // `modelFallbacks`, so a malformed entry weakens the fence's *scope* rather
418
+ // than failing the whole config load — and an entry that survives is one the
419
+ // fence will actually enforce.
420
+ requireOauthProviders: z.unknown().optional(),
402
421
  // The fleet-owned omp settings overlay (#537): an opaque map omp's own
403
422
  // schema owns. Conductor validates YAML shape only — the loader keeps it
404
423
  // when it is a mapping and drops anything else, like `modelFallbacks`.