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.
- package/REFERENCE.md +72 -2
- package/package.json +2 -1
- package/schema/config.schema.json +6 -0
- package/src/admission.ts +745 -0
- package/src/ask.ts +47 -0
- package/src/backups.ts +19 -7
- package/src/board.ts +1 -2
- package/src/briefs/orchestrator.md +62 -4
- package/src/cli.ts +26 -0
- package/src/commands/context.ts +3 -0
- package/src/commands/decision.ts +10 -1
- package/src/commands/doctor.ts +2 -0
- package/src/commands/message.ts +8 -1
- package/src/commands/restart.ts +15 -3
- package/src/commands/restore-db.ts +146 -0
- package/src/commands/stop.ts +24 -15
- package/src/commands/unfreeze.ts +56 -0
- package/src/commands/watch.ts +77 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +24 -0
- package/src/daemon.ts +239 -530
- package/src/dashboard/server.ts +2 -1
- package/src/decisions.ts +32 -7
- package/src/depends-on.ts +73 -0
- package/src/doctor.ts +178 -5
- package/src/escalate.ts +114 -15
- package/src/failure-class.ts +47 -0
- package/src/fleet.ts +41 -410
- package/src/gitops.ts +86 -1
- package/src/log.ts +40 -0
- package/src/model-fallback.ts +3 -2
- package/src/omp-settings.ts +114 -0
- package/src/omp.ts +39 -0
- package/src/orchestrator-tick.ts +7 -1
- package/src/reports.ts +124 -12
- package/src/session-host.ts +6 -0
- package/src/setup-wizard.ts +36 -0
- package/src/setup.ts +58 -1
- package/src/status-render.ts +445 -0
- package/src/stop-provenance.ts +53 -0
- package/src/store.ts +352 -11
- package/src/types.ts +187 -4
- package/src/unblock.ts +1 -1
- package/src/upgrade-verify.ts +1 -1
- package/src/upgrade.ts +1 -2
- package/src/verbs/server.ts +25 -0
- 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
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 (`
|
|
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),
|
package/src/commands/context.ts
CHANGED
|
@@ -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",
|
package/src/commands/decision.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/commands/doctor.ts
CHANGED
|
@@ -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:
|
package/src/commands/message.ts
CHANGED
|
@@ -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
|
|
69
|
+
question,
|
|
63
70
|
...(blocks === undefined ? {} : { blocks }),
|
|
64
71
|
at: Date.now(),
|
|
65
72
|
});
|
package/src/commands/restart.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/stop.ts
CHANGED
|
@@ -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
|
|
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,
|