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
package/src/ask.ts CHANGED
@@ -46,6 +46,26 @@ export const MIN_ASK_TIMEOUT_SECONDS = 60;
46
46
  export const MAX_ASK_TIMEOUT_SECONDS = 3_600;
47
47
  export const DEFAULT_ASK_TIMEOUT_SECONDS = 300;
48
48
 
49
+ /**
50
+ * The shape ceilings for a decision row's question (#593). A question is the
51
+ * bare ask an operator reads on a phone — one sentence, the recommendation, the
52
+ * options — not an essay. On 2026-08-17 three rows opened at 41/22/26 lines and
53
+ * 2,043/1,168/1,392 characters each, embedding their measurements, timeline and
54
+ * rationale inline; every one was accepted and every one rendered in full. The
55
+ * walls are set so a genuine four-option ask fits and an essay does not: the
56
+ * measured detail belongs in the issue or a separate backdrop, never inline.
57
+ */
58
+ export const MAX_QUESTION_LINES = 10;
59
+ export const MAX_QUESTION_CHARACTERS = 700;
60
+
61
+ /**
62
+ * The result of {@link validateQuestionShape}: fine, or refused with a problem
63
+ * that names the measured counts and where the overflow belongs.
64
+ */
65
+ export type QuestionShape =
66
+ | { ok: true }
67
+ | { ok: false; problem: string };
68
+
49
69
  /**
50
70
  * The suffix a decision row carries when nobody human chose the answer. Fixed
51
71
  * wording on purpose — the same reason `types.ts` gives for closed vocabularies
@@ -89,6 +109,29 @@ function isInterruptCategory(value: unknown): value is InterruptCategory {
89
109
  return typeof value === "string" && (INTERRUPT_CATEGORIES as readonly string[]).includes(value);
90
110
  }
91
111
 
112
+ /**
113
+ * Refuse a question that outgrew the phone-sized decision row. Counts the
114
+ * trimmed question — the exact text archived on the row — against
115
+ * {@link MAX_QUESTION_LINES} / {@link MAX_QUESTION_CHARACTERS}, and refuses
116
+ * rather than truncating: a truncated question reads as complete and has lost
117
+ * its options, which is strictly worse than a rejection the caller must fix.
118
+ */
119
+ export function validateQuestionShape(question: string): QuestionShape {
120
+ const lines = question.split("\n").length;
121
+ const chars = question.length;
122
+ if (lines <= MAX_QUESTION_LINES && chars <= MAX_QUESTION_CHARACTERS) {
123
+ return { ok: true };
124
+ }
125
+ return {
126
+ ok: false,
127
+ problem:
128
+ `the question is ${lines} lines / ${chars} characters — over the ${MAX_QUESTION_LINES}-line / ` +
129
+ `${MAX_QUESTION_CHARACTERS}-character ceiling. Trim it to the bare question the operator must answer; ` +
130
+ `the measurements and rationale belong in the issue, or as a separate rationale recorded with ` +
131
+ "`omp-conductor event record`, never inline in the question",
132
+ };
133
+ }
134
+
92
135
  /**
93
136
  * Validate one raw tool call. Strict like the verb arguments: an unknown shape
94
137
  * is refused with the reason, never coerced into a default that could hide what
@@ -106,6 +149,10 @@ export function parseAskRequest(raw: unknown): AskParse {
106
149
  if (typeof question !== "string" || question.trim().length === 0) {
107
150
  return { ok: false, problem: `conductor_ask needs "question": the question you put to the operator` };
108
151
  }
152
+ const shape = validateQuestionShape(question.trim());
153
+ if (!shape.ok) {
154
+ return { ok: false, problem: `conductor_ask ${shape.problem}` };
155
+ }
109
156
 
110
157
  const onTimeoutRaw = input["on-timeout"];
111
158
  if (
package/src/backups.ts CHANGED
@@ -22,17 +22,21 @@ export function backupTimestamp(): string {
22
22
  return new Date().toISOString().replace(/[:.]/g, "-");
23
23
  }
24
24
 
25
- /** Publishes `source` under `backupRoot` as `stem`, suffixing on collision. */
26
- export function copyToUniqueBackup(source: string, backupRoot: string, stem: string): string {
27
- mkdirSync(backupRoot, { recursive: true });
28
- const temporary = join(backupRoot, `.${stem}.${process.pid}.${randomUUID()}.tmp`);
29
- copyFileSync(source, temporary, constants.COPYFILE_EXCL);
25
+ /**
26
+ * Atomically publish a complete temp file as `stem` under `backupRoot`,
27
+ * suffixing on collision, then remove the temp. Linking a complete file
28
+ * publishes without ever overwriting a backup created concurrently at the
29
+ * same millisecond, and it stays on one filesystem because the temp lives in
30
+ * the same directory as the destination.
31
+ *
32
+ * Shared by {@link copyToUniqueBackup} and the store snapshot path, so every
33
+ * durable copy under state uses the same temp-then-atomic-publish convention.
34
+ */
35
+ export function publishTempFile(temporary: string, backupRoot: string, stem: string): string {
30
36
  try {
31
37
  for (let suffix = 0; ; suffix += 1) {
32
38
  const destination = join(backupRoot, suffix === 0 ? stem : `${stem}-${suffix}`);
33
39
  try {
34
- // Linking a complete temp file publishes the backup atomically without
35
- // overwriting a backup created concurrently at the same millisecond.
36
40
  linkSync(temporary, destination);
37
41
  return destination;
38
42
  } catch (err) {
@@ -44,3 +48,11 @@ export function copyToUniqueBackup(source: string, backupRoot: string, stem: str
44
48
  unlinkSync(temporary);
45
49
  }
46
50
  }
51
+
52
+ /** Publishes `source` under `backupRoot` as `stem`, suffixing on collision. */
53
+ export function copyToUniqueBackup(source: string, backupRoot: string, stem: string): string {
54
+ mkdirSync(backupRoot, { recursive: true });
55
+ const temporary = join(backupRoot, `.${stem}.${process.pid}.${randomUUID()}.tmp`);
56
+ copyFileSync(source, temporary, constants.COPYFILE_EXCL);
57
+ return publishTempFile(temporary, backupRoot, stem);
58
+ }
package/src/board.ts CHANGED
@@ -9,9 +9,8 @@ import {
9
9
  fleetLayers,
10
10
  probeTelegramHealth,
11
11
  workerPhasesFromHealthz,
12
- type FleetLayers,
13
- type TelegramHealth,
14
12
  } from "./fleet.ts";
13
+ import type { FleetLayers, TelegramHealth } from "./status-render.ts";
15
14
  import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
16
15
  import { healthCheck, livingDaemon } from "./lifecycle.ts";
17
16
  import type { WorkerPausePhase } from "./worker.ts";
@@ -193,6 +193,38 @@ Keep the queue worth draining.
193
193
  the one issue whose defect had been traced first landed in 92 of 120 turns.
194
194
  Tracing before promoting is your work, once — or it is every worker's work,
195
195
  every attempt.
196
+ - **Size every promotion to one budget: 180 turns and 90 minutes of wall clock
197
+ per attempt — the caps this fleet enforces, not adjectives.** Work that cannot
198
+ fit in one budget is not one issue: split it before you promote it. "Prefer
199
+ small slices" is a taste, and taste did not govern 2026-08-16 — #486 hit the
200
+ cap twice (181/180, then 168/180) before landing, #541 settled at 170/180,
201
+ #288 was killed at 181/180, and #365 burned the full 90-minute clock. None
202
+ were worker failures; each was a slice too big when it was promoted,
203
+ discovered only by spending an attempt.
204
+ - **A cap kill is a size verdict, not a worker verdict — so a second cap kill
205
+ on one issue is a decomposition defect, not an implementation defect.** The
206
+ failure classifier already records this signal (#490, #494): a run killed at
207
+ its ceilings is written `turn-cap-*` or `wall-clock-cap-*`. On the **second**
208
+ cap kill, decompose, do not re-queue: keep the work the attempts shipped,
209
+ split the remainder into issues that each fit one budget, and promote those
210
+ slices. A fresh attempt at the same uncut issue is how a third budget gets
211
+ spent proving what the second one already proved.
212
+ - **A decomposition has a required output.** When an issue is too big for one
213
+ budget, do not re-file it smaller and hope. Produce an epic plus children, and
214
+ give every child, in its body: the files it writes, its acceptance criteria,
215
+ the one thing most likely to be silently faked, and the commands that prove
216
+ it. A child missing any of those is not a slice, it is the same issue with a
217
+ smaller title.
218
+ - **Record the decomposition on the parent, not in a chat turn.** The parent
219
+ carries the child list, the order they must land in, and one sentence per
220
+ child saying why it is a separate slice — a shared file lane, a dependency,
221
+ an operator decision that is still open. That note is what stops the next
222
+ reader re-litigating a split you already reasoned through, and what tells you
223
+ which child is claimable now.
224
+ - **Sequence explicitly.** Children that write the same file are one lane and
225
+ go in order; say so on the parent. A child blocked on an unbuilt capability
226
+ is named as blocked, with what unblocks it — never promoted in the hope that
227
+ the capability appears.
196
228
  - An issue that has exhausted its attempts is not a retry candidate. Diagnose it,
197
229
  split it, or hand it back to a human.
198
230
  - **Grooming is throughput-bound, so delegate the finding.** N workers drain the
@@ -458,12 +490,15 @@ Not yours to relax:
458
490
  **You never sleep, poll, or wait inside a tick.** No `sleep`, no retry loop, no
459
491
  "watch this PR until green" — a tool call that exists to pass time is a tool
460
492
  call that blocks your operator's messages. Anything that needs waiting for is
461
- either a watch (`decision open --resolves-when pr-checks-green:<url>`,
493
+ either a watch (`watch add --resolves-when pr-checks-green:<url>`,
462
494
  `pr-mergeable:<url>`, `pr-merged:<url>`, `issue-closed:<n>`,
463
495
  `npm-version:<pkg>@<version>`, `rate-limit-reset:github`) or a subagent's
464
496
  problem. The harness refuses further tool calls once a turn exceeds its budget
465
497
  or an operator message is queued — end the turn and let the next tick act on
466
- `[CONDITION MET]`.
498
+ `[CONDITION MET]`. A watch is yours alone: it renders under its own "Watches"
499
+ heading, is never offered to your operator to resolve, and has no seven-day
500
+ expiry — so use it for anything you set for yourself, never for a question that
501
+ actually needs an answer.
467
502
 
468
503
  ## Your verb surface
469
504
 
@@ -497,7 +532,7 @@ to land that exact head, then cut the tag.
497
532
  | --- | --- | --- |
498
533
  | `conductor_pr_status` | always | Nothing to gate: it reads. |
499
534
  | `conductor_pr_update_branch` | always | The PR belongs to this project and is open. |
500
- | `conductor_pr_merge` | `authority.merge` is yours | You are the configured holder; `headSha` still equals the live head *at execution time*; checks green at that same SHA; the project's single merge slot is free. |
535
+ | `conductor_pr_merge` | `authority.merge` is yours | You are the configured holder; `headSha` still equals the live head *at execution time*; checks green at that same SHA; the repo's base is not red-frozen (`base-red-freeze`); the project's single merge slot is free. |
501
536
  | `conductor_label` | always | The label is one this project declared. Lifecycle labels (`agent:in-progress`, `agent:blocked`, `agent:failed`) are refused — those stay the dispatcher's, and `omp-conductor unblock` is how you clear them. |
502
537
  | `conductor_release` | `authority.release` is yours | You are the configured holder, the shape is granted, the artefact or environment was declared, and the release preconditions hold. `version-bump-pr` is the source-change exception: first call opens only the declared version-file change; a later call re-validates and merges its exact green head through the project's single merge slot. |
503
538
 
@@ -524,6 +559,12 @@ Four controls stop different work:
524
559
  it needs stopping rather than to run this yourself.
525
560
  - **Orchestrator ticks:** `omp-conductor disarm` removes the operator-owned
526
561
  `ARMED` marker so ticks skip. It does not pause workers or stop processes.
562
+ - **Lift a base-red freeze:** `omp-conductor unfreeze <repo>` is the operator's
563
+ sanctioned override for the `base-red-freeze` merge gate — use it only after
564
+ you judge the base repaired (or the merge warranted anyway). It is
565
+ ledger-recorded and re-arms automatically if the base is still red. Never
566
+ bypass a `base-red-freeze` refusal with label surgery or a DB edit; this verb
567
+ is the one allowed path.
527
568
 
528
569
  Per-worker pause is not SIGSTOP/SIGCONT, fleet pause, unblock/requeue, or a
529
570
  durable restart boundary. Daemon loss still follows the normal salvage and
@@ -547,6 +588,18 @@ to read the refusal instead of retrying in a loop.
547
588
  the SHA that produced it. Never work around one: there is no path around it, and
548
589
  the attempt is in the ledger.
549
590
 
591
+ **A red base freezes merges to that repo (`base-red-freeze`).** When a watched
592
+ merge turns the base branch red, the daemon freezes `conductor_pr_merge` for
593
+ that repo until the base is observed green again — so the next merge cannot land
594
+ on top of a broken base and compound it. The refusal names the suspected culprit
595
+ merge SHA and suggests the revert. Only the frozen repo is gated: sibling repos
596
+ keep merging, and a frozen repo's candidates keep dispatching (workers can still
597
+ build the fix); only merge is held. The freeze lifts by itself on a green
598
+ re-observation, or the operator can lift it early with the sanctioned
599
+ `omp-conductor unfreeze <repo>` — the one allowed path, never label surgery or a
600
+ DB edit, and ledger-recorded. Opening the revert PR stays yours: the daemon only
601
+ freezes and attributes.
602
+
550
603
  **Your file tools are not gated — the workers' are.** Every worker session runs
551
604
  under a mechanical worktree gate. Your structured tools are gated by nothing,
552
605
  and that is a deliberate operator decision rather than an oversight. What holds
@@ -652,7 +705,12 @@ observable happens: `pr-merged:<url>`, `pr-checks-green:<url>`,
652
705
  `pr-mergeable:<url>`, `issue-closed:<n>`, `npm-version:<pkg>@<version>`,
653
706
  `rate-limit-reset:github`. The daemon checks it for you and flags the row as
654
707
  `[CONDITION MET — act on this now]` in your tick digest, so a parked question
655
- wakes up on its own instead of waiting for you to think of it.
708
+ wakes up on its own instead of waiting for you to think of it. A condition
709
+ governs *when* to ask, not *who* answers: a question stays a question under
710
+ `decision open` even when it carries one. Anything you set for yourself — a
711
+ condition you are waiting on, or an instruction the next tick should read — is
712
+ a watch, not a question: open it with `watch add ...` so it renders under
713
+ "Watches" and is never mistaken for an ask aimed at your operator.
656
714
 
657
715
  **Trust the digest, never your recollection.** Every tick's prompt lists what is
658
716
  still open. A question that exists only in your context is gone at the next
package/src/cli.ts CHANGED
@@ -29,6 +29,7 @@ import { ledgerCommand } from "./commands/ledger.ts";
29
29
  import { messageCommand } from "./commands/message.ts";
30
30
  import { reportCommand } from "./commands/report.ts";
31
31
  import { restartCommand } from "./commands/restart.ts";
32
+ import { restoreDbCommand } from "./commands/restore-db.ts";
32
33
  import { resumeCommand } from "./commands/resume.ts";
33
34
  import { setupCommand } from "./commands/setup.ts";
34
35
  import { startCommand } from "./commands/start.ts";
@@ -37,11 +38,13 @@ import { statusCommand } from "./commands/status.ts";
37
38
  import { stopCommand } from "./commands/stop.ts";
38
39
  import { tailCommand } from "./commands/tail.ts";
39
40
  import { unblockCommand } from "./commands/unblock.ts";
41
+ import { unfreezeCommand } from "./commands/unfreeze.ts";
40
42
  import { upgradeInstallCommand } from "./commands/upgrade-install.ts";
41
43
  import { upgradeRollbackCommand } from "./commands/upgrade-rollback.ts";
42
44
  import { upgradeCommand } from "./commands/upgrade.ts";
43
45
  import { verbCommand } from "./commands/verb.ts";
44
46
  import { versionCommand } from "./commands/version.ts";
47
+ import { watchCommand } from "./commands/watch.ts";
45
48
  import { workerCommand } from "./commands/worker.ts";
46
49
  import {
47
50
  COMMAND_SCOPES,
@@ -66,6 +69,7 @@ usage:
66
69
  omp-conductor status [--project NAME]
67
70
  omp-conductor stats [--since 7d|30d|YYYY-MM-DD] [--project NAME] [--json]
68
71
  omp-conductor doctor [--project NAME] [--json] [--probe-telegram]
72
+ omp-conductor restore-db [SNAPSHOT]
69
73
  omp-conductor ledger [--issue N] [--limit N] [--project NAME]
70
74
  omp-conductor hold [--keep-ticks] [--project NAME | --all]
71
75
  omp-conductor arm [--project NAME | --all]
@@ -76,6 +80,7 @@ usage:
76
80
  omp-conductor worker resume <issue> [--project NAME]
77
81
  omp-conductor worker stop <issue> --reason TEXT [--project NAME]
78
82
  omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
83
+ omp-conductor unfreeze <repo> [--reason TEXT] [--project NAME]
79
84
  omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
80
85
  omp-conductor daemon [--once] [--port N] [--project NAME]
81
86
  omp-conductor resume [--project NAME | --all]
@@ -88,6 +93,8 @@ usage:
88
93
  omp-conductor decision resolve <id> --answer TEXT [--project NAME]
89
94
  omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
90
95
  omp-conductor decision list [--project NAME]
96
+ omp-conductor watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
97
+ omp-conductor watch list [--project NAME]
91
98
  omp-conductor intake "<text>" [--project NAME]
92
99
  omp-conductor intake list [--project NAME]
93
100
  omp-conductor intake dismiss <id> [--project NAME]
@@ -154,6 +161,12 @@ usage:
154
161
  --probe-telegram sends through the report transport. Exit code 0
155
162
  only when nothing failed; --json prints the stable CI shape. Run
156
163
  it after install and after every upgrade.
164
+ restore-db
165
+ put conductor.db back to a restorable snapshot, replacing the live
166
+ file and dropping stale -wal/-shm sidecars. Refuses while a daemon
167
+ is live — restoring under a running daemon is a corruption path.
168
+ The default source is the newest snapshot in the configured db
169
+ backup directory; an explicit snapshot path overrides it.
157
170
  board open the live keyboard-driven fleet board. It renders queue holds,
158
171
  every run lifecycle stage, recent merges, spend and health; Enter
159
172
  follows a selected transcript without leaving the board. --json (or
@@ -248,6 +261,16 @@ usage:
248
261
  decision resolve <id> --answer TEXT
249
262
  decision withdraw <id> [--reason TEXT]
250
263
  decision list
264
+ watch set a condition or carry note for the orchestrator itself, with no
265
+ human in the loop — the same no-sleep surface \`decision open
266
+ --resolves-when\` used to serve, given its own verb so it never renders
267
+ as a question put to the operator. \`--resolves-when\` attaches a
268
+ condition the daemon checks for you; a met watch wakes the next tick
269
+ with its note, exactly as a met question does, but it is listed under
270
+ its own heading and never under "Open operator decisions", and it has
271
+ no seven-day expiry. \`watch list\` shows open watches.
272
+ watch add --note TEXT [--blocks TEXT] [--resolves-when COND]
273
+ watch list
251
274
  intake keep a raw idea durably before it becomes anything: record it now
252
275
  with \`omp-conductor intake "<text>"\`, list what is still pending,
253
276
  dismiss what turned out to be nothing. Backed by the sqlite store,
@@ -469,13 +492,16 @@ const COMMANDS: Record<string, CommandHandler> = {
469
492
  extend: () => extendCommand(ctx),
470
493
  worker: () => workerCommand(ctx),
471
494
  unblock: () => unblockCommand(ctx),
495
+ unfreeze: () => unfreezeCommand(ctx),
472
496
  verb: () => verbCommand(ctx),
473
497
  event: () => eventCommand(ctx),
474
498
  report: () => reportCommand(ctx),
475
499
  message: () => messageCommand(ctx),
476
500
  decision: () => decisionCommand(ctx),
501
+ watch: () => watchCommand(ctx),
477
502
  intake: () => intakeCommand(ctx),
478
503
  friction: () => frictionCommand(ctx),
504
+ "restore-db": () => restoreDbCommand(ctx),
479
505
  resume: () => resumeCommand(ctx),
480
506
  "brief-upgrade": () => briefUpgradeCommand(ctx),
481
507
  help: () => helpCommand(USAGE),
@@ -93,11 +93,14 @@ export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
93
93
  stats: "project",
94
94
  tail: "project",
95
95
  unblock: "project",
96
+ unfreeze: "project",
96
97
  verb: "project",
98
+ watch: "project",
97
99
  worker: "project",
98
100
  // host — the shared daemon/package surfaces; never resolves a project
99
101
  daemon: "host",
100
102
  restart: "host",
103
+ "restore-db": "host",
101
104
  start: "host",
102
105
  upgrade: "host",
103
106
  "upgrade-install": "host",
@@ -10,6 +10,7 @@ import type { CommandContext } from "./context.ts";
10
10
  import { findProject, loadConfig } from "../config.ts";
11
11
  import { CONDITION_FORMS, parseCondition } from "../decisions.ts";
12
12
  import { dbPath, openStore } from "../store.ts";
13
+ import { validateQuestionShape } from "../ask.ts";
13
14
 
14
15
  export async function decisionCommand(ctx: CommandContext): Promise<void> {
15
16
  const sub = ctx.argv[1];
@@ -24,6 +25,11 @@ try {
24
25
  );
25
26
  process.exit(2);
26
27
  }
28
+ const shape = validateQuestionShape(question);
29
+ if (!shape.ok) {
30
+ process.stderr.write(`omp-conductor: decision open: ${shape.problem}\n`);
31
+ process.exit(2);
32
+ }
27
33
  const condition = ctx.flag("resolves-when")?.trim();
28
34
  if (condition !== undefined && parseCondition(condition) === undefined) {
29
35
  process.stderr.write(
@@ -76,7 +82,10 @@ try {
76
82
  }
77
83
 
78
84
  if (sub === "list" || sub === undefined) {
79
- const open = store.openDecisions(project.name);
85
+ // Only rows a human must answer. Watches would render identically here and
86
+ // were the whole reason an operator read the orchestrator's own reminder as
87
+ // a question aimed at them (#459) — `watch list` shows those.
88
+ const open = store.openDecisions(project.name).filter((d) => d.kind !== "watch");
80
89
  if (open.length === 0) {
81
90
  process.stdout.write("no open decisions\n");
82
91
  return;
@@ -34,6 +34,8 @@ Checks, each one the mechanical version of a past production incident:
34
34
  spend telemetry every completed run recording $0.00 means the USD cap cannot
35
35
  fire — $0.00 is not proof of no spend
36
36
  timezone reporting timezones are known IANA zones
37
+ omp settings the effective omp settings overlay each project's workers
38
+ load, and whether it can be materialised under the session root
37
39
  telegram bot health, and one self-identified probe message
38
40
 
39
41
  flags:
@@ -19,6 +19,7 @@ import { findProject, loadConfig } from "../config.ts";
19
19
  import { deliverOperatorMessage, operatorMessageCategory, type OperatorMessageOutcome } from "../reports.ts";
20
20
  import { dbPath, openStore } from "../store.ts";
21
21
  import { INTERRUPT_CATEGORIES, type DecisionRecord, type InterruptCategory } from "../types.ts";
22
+ import { validateQuestionShape } from "../ask.ts";
22
23
 
23
24
  /** The floor's "this needs an answer" marker, as every other classifier reads it. */
24
25
  const QUESTION_MARKER = /^\s*QUESTION:\s/i;
@@ -56,10 +57,16 @@ export async function messageCommand(ctx: CommandContext): Promise<void> {
56
57
  let outcome: OperatorMessageOutcome;
57
58
  try {
58
59
  if (isAsk) {
60
+ const question = body.replace(QUESTION_MARKER, "").trim();
61
+ const shape = validateQuestionShape(question);
62
+ if (!shape.ok) {
63
+ process.stderr.write(`omp-conductor: message: ${shape.problem}\n`);
64
+ process.exit(2);
65
+ }
59
66
  row = store.createDecision({
60
67
  project: project.name,
61
68
  // The row archives the question, not the delivery prefix.
62
- question: body.replace(QUESTION_MARKER, "").trim(),
69
+ question,
63
70
  ...(blocks === undefined ? {} : { blocks }),
64
71
  at: Date.now(),
65
72
  });
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { CommandContext } from "./context.ts";
10
- import { buildStopProvenance } from "../stop-provenance.ts";
10
+ import { buildStopProvenance, liveWorkload, refuseBusyDaemon } from "../stop-provenance.ts";
11
11
  import { setPaused } from "../daemon.ts";
12
12
  import { restartDaemon, type RestartResult, type StopDelivery } from "../lifecycle.ts";
13
13
  import { openStore, dbPath } from "../store.ts";
@@ -16,12 +16,24 @@ import { DEFAULT_DEPS, drainAndRestart, type UpgradeDeps } from "../upgrade.ts";
16
16
  export async function restartCommand(ctx: CommandContext): Promise<void> {
17
17
  const store = openStore(dbPath());
18
18
  try {
19
+ const forced = ctx.argv.includes("--force");
20
+ // Refusal (#545), the immediate path only. `restart --now` skips the drain
21
+ // and replaces the running daemon at once, so it orphans any project's live
22
+ // workers exactly like `stop`. The default restart is safe rather than
23
+ // refused: it pauses and drains EVERY configured project before restarting
24
+ // (resolveScope selects them all, #389), so it cannot orphan a sibling.
25
+ // `--force` skips the gate — a wedged daemon stays restartable — and the
26
+ // override is recorded in the provenance.
27
+ if (ctx.argv.includes("--now") && !forced) {
28
+ refuseBusyDaemon(liveWorkload(store), { override: false, verb: "restart" });
29
+ }
19
30
  // Provenance (#378): one record naming the request, every configured
20
31
  // project with its live-run count, and — via the lifecycle chokepoint — the
21
32
  // delivery method used. The shared daemon serves all projects, so a restart
22
- // is never silent even when the operator narrows nothing.
33
+ // is never silent even when the operator narrows nothing. A forced restart
34
+ // keeps the override visible in the control path, never silent.
23
35
  const provenance = buildStopProvenance({
24
- controlPath: "cli restart",
36
+ controlPath: forced ? "cli restart --force" : "cli restart",
25
37
  reason: "operator restart",
26
38
  scope: "global",
27
39
  });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `restore-db` — put `conductor.db` back to a restorable snapshot.
3
+ *
4
+ * The store is the verb ledger, the decision rows, the run rows and the
5
+ * material-event ledger (#579); a restore replaces the live file wholesale
6
+ * from a snapshot the snapshot primitive produced. It refuses to run while a
7
+ * daemon is live — overwriting the store a live dispatch loop is writing is a
8
+ * corruption path, and `livingDaemon()` is the existing detect for it — and
9
+ * replaces the file atomically (temp file in the same directory, then rename),
10
+ * then drops any stale `-wal`/`-shm` sidecars so the restored database opens
11
+ * cleanly. The pre-restore `conductor.db-wal`/`-shm` are gone by design: the
12
+ * old WAL belongs to the file being replaced.
13
+ */
14
+
15
+ import { Database } from "bun:sqlite";
16
+ import { randomUUID } from "node:crypto";
17
+ import { copyFileSync, existsSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
18
+ import { join, resolve } from "node:path";
19
+
20
+ import type { CommandContext } from "./context.ts";
21
+ import { dbBackupDirFor, loadConfig, stateDir } from "../config.ts";
22
+ import { livingDaemon } from "../lifecycle.ts";
23
+ import { DB_SNAPSHOT_STEM, dbPath } from "../store.ts";
24
+
25
+ const RESTORE_DB_USAGE = `omp-conductor restore-db [SNAPSHOT]
26
+
27
+ Put conductor.db back to a restorable snapshot, replacing the live file
28
+ wholesale and dropping stale -wal/-shm sidecars so the result opens cleanly.
29
+
30
+ Refuses to run while a daemon is live — restoring under a running daemon is a
31
+ corruption path, not an interruption. Stop the daemon first
32
+ (\`omp-conductor stop\`).
33
+
34
+ usage:
35
+ omp-conductor restore-db restore the newest snapshot in the
36
+ configured db backup directory
37
+ omp-conductor restore-db PATH restore this exact snapshot file
38
+
39
+ SNAPSHOT is a file the store snapshot primitive produced. A relative PATH is
40
+ resolved against the working directory.
41
+
42
+ Before restoring, the command verifies the restored database reads cleanly
43
+ (PRAGMA integrity_check) and prints the verb_ledger / decisions / runs counts.`;
44
+
45
+ /**
46
+ * The newest snapshot already published under `dir`, by mtime — the default
47
+ * restore source. Undefined when none exists (a fresh store with no snapshot).
48
+ */
49
+ function newestSnapshot(dir: string): string | undefined {
50
+ let names: string[];
51
+ try {
52
+ names = readdirSync(dir).filter((name) => name.startsWith(DB_SNAPSHOT_STEM));
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ if (names.length === 0) return undefined;
57
+ const withMtime = names
58
+ .map((name) => ({ name, mtime: statSync(join(dir, name)).mtimeMs }))
59
+ .sort((a, b) => b.mtime - a.mtime);
60
+ return join(dir, withMtime[0]!.name);
61
+ }
62
+
63
+ /** Replace `conductor.db` with `snapshot`, atomically, dropping stale sidecars. */
64
+ function overwriteStoreFrom(snapshot: string): void {
65
+ const target = dbPath();
66
+ const temporary = join(stateDir(), `.${DB_SNAPSHOT_STEM}-restore.${process.pid}.${randomUUID()}.tmp`);
67
+ copyFileSync(snapshot, temporary);
68
+ renameSync(temporary, target);
69
+ rmSync(`${target}-wal`, { force: true });
70
+ rmSync(`${target}-shm`, { force: true });
71
+ }
72
+
73
+ interface RowsRead {
74
+ integrity: string;
75
+ runs: number;
76
+ decisions: number;
77
+ verbLedger: number;
78
+ }
79
+
80
+ /** Read the restored store back — the proof the restore landed cleanly. */
81
+ function readRestoredStore(target: string): RowsRead {
82
+ const db = new Database(target);
83
+ try {
84
+ const integrity = (db.query<{ integrity_check: string }, []>("PRAGMA integrity_check").get() as {
85
+ integrity_check: string;
86
+ }).integrity_check;
87
+ const count = (table: string): number =>
88
+ (db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
89
+ return { integrity, runs: count("runs"), decisions: count("decisions"), verbLedger: count("verb_ledger") };
90
+ } finally {
91
+ db.close();
92
+ }
93
+ }
94
+
95
+ export async function restoreDbCommand(ctx: CommandContext): Promise<void> {
96
+ // Help first: parsing stops before any config read or liveness check.
97
+ if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
98
+ process.stdout.write(`${RESTORE_DB_USAGE}\n`);
99
+ return;
100
+ }
101
+ // Host-scoped: the store is one host-wide file, so --project cannot narrow it.
102
+ if (ctx.projectFlag !== undefined) {
103
+ process.stderr.write(`omp-conductor: restore-db does not take a project — the store is host-wide\n`);
104
+ process.exit(2);
105
+ }
106
+ const positionals = ctx.argv.slice(1).filter((a) => !a.startsWith("--"));
107
+ const flags = ctx.argv.slice(1).filter((a) => a.startsWith("--"));
108
+ if (flags.length > 0) {
109
+ process.stderr.write(`omp-conductor: restore-db: unexpected argument "${flags[0]}"\n`);
110
+ process.exit(2);
111
+ }
112
+ if (positionals.length > 1) {
113
+ process.stderr.write(`omp-conductor: restore-db takes at most one snapshot path\n`);
114
+ process.exit(2);
115
+ }
116
+
117
+ // The corruption-path guard: never restore under a daemon that is writing.
118
+ const daemon = livingDaemon();
119
+ if (daemon !== undefined) {
120
+ throw new Error(
121
+ `restore-db refuses while a daemon is running (pid ${daemon.pid}) — it would overwrite the store a live dispatch is writing; stop it first (\`omp-conductor stop\`)`,
122
+ );
123
+ }
124
+
125
+ const cfg = loadConfig();
126
+ const snapDir = dbBackupDirFor(cfg);
127
+ const requested = positionals[0];
128
+ const snapshot = requested === undefined ? newestSnapshot(snapDir) : resolve(requested);
129
+ if (snapshot === undefined) {
130
+ throw new Error(
131
+ `no conductor.db snapshot in ${snapDir} — take one (the snapshot primitive) or pass an explicit snapshot path`,
132
+ );
133
+ }
134
+ if (!existsSync(snapshot)) {
135
+ throw new Error(`snapshot ${snapshot} does not exist`);
136
+ }
137
+
138
+ overwriteStoreFrom(snapshot);
139
+ const restored = readRestoredStore(dbPath());
140
+ const integrityLine =
141
+ restored.integrity === "ok" ? "integrity ok" : `integrity: ${restored.integrity}`;
142
+ process.stdout.write(
143
+ `restored conductor.db from ${snapshot}\n` +
144
+ `\u2003${integrityLine} · runs ${restored.runs} · decisions ${restored.decisions} · verb_ledger ${restored.verbLedger}\n`,
145
+ );
146
+ }
@@ -7,32 +7,41 @@
7
7
  */
8
8
 
9
9
  import type { CommandContext } from "./context.ts";
10
- import { buildStopProvenance } from "../stop-provenance.ts";
10
+ import { buildStopProvenance, liveWorkload, refuseBusyDaemon } from "../stop-provenance.ts";
11
11
  import { hold, pinPaneHalt, stopConductorPane } from "../fleet.ts";
12
12
  import { stopDaemon } from "../lifecycle.ts";
13
13
  import { openStore, dbPath } from "../store.ts";
14
14
 
15
15
  export async function stopCommand(ctx: CommandContext): Promise<void> {
16
16
  const withPane = ctx.argv.includes("--pane");
17
- const targets = ctx.targetProjects().map((project) => {
18
- // `stop` takes the fleet down, so it always disarms — `--keep-ticks` is a
19
- // `hold` affordance. Assert the invariant instead of printing a path that
20
- // might not exist.
21
- const held = hold(project.name, "halt");
22
- if (held.disarmed === undefined) throw new Error("stop must disarm ticks");
23
- return {
24
- project,
25
- hold: { ...held, disarmed: held.disarmed },
26
- pin: withPane ? pinPaneHalt(project.name).path : undefined,
27
- };
28
- });
17
+ const forced = ctx.argv.includes("--force");
29
18
  const store = openStore(dbPath());
30
19
  try {
20
+ // Refusal (#545): the shared daemon serves every configured project, so a
21
+ // stop orphans any project's live workers — not just the one named. Refuse
22
+ // while anything is live, naming project + issues, before any hold/disarm/
23
+ // pin side-effect, so a refused stop leaves the fleet untouched. `--force`
24
+ // skips the whole gate (so a wedged daemon stays stoppable even when the
25
+ // store is unreadable) and the override is recorded in the provenance.
26
+ if (!forced) refuseBusyDaemon(liveWorkload(store), { override: false, verb: "stop" });
27
+ const targets = ctx.targetProjects().map((project) => {
28
+ // `stop` takes the fleet down, so it always disarms — `--keep-ticks` is a
29
+ // `hold` affordance. Assert the invariant instead of printing a path that
30
+ // might not exist.
31
+ const held = hold(project.name, "halt");
32
+ if (held.disarmed === undefined) throw new Error("stop must disarm ticks");
33
+ return {
34
+ project,
35
+ hold: { ...held, disarmed: held.disarmed },
36
+ pin: withPane ? pinPaneHalt(project.name).path : undefined,
37
+ };
38
+ });
31
39
  // Provenance (#378): the shared daemon serves every configured project, so
32
40
  // the record names the request's own project (or the global scope) AND every
33
- // sibling with its live-run count — no silent cross-project stop.
41
+ // sibling with its live-run count — no silent cross-project stop. A forced
42
+ // stop keeps the override visible in the control path, never silent.
34
43
  const provenance = buildStopProvenance({
35
- controlPath: "cli stop",
44
+ controlPath: forced ? "cli stop --force" : "cli stop",
36
45
  reason: "operator stop",
37
46
  scope: targets.length > 1 ? "global" : "project",
38
47
  project: targets.length === 1 ? targets[0]!.project.name : undefined,