omp-conductor 0.18.0 → 0.18.2
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/README.md +35 -1
- package/REFERENCE.md +61 -11
- package/agents/to-spec.md +94 -0
- package/package.json +2 -1
- package/schema/config.schema.json +35 -1
- package/src/admission.ts +204 -75
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +62 -21
- package/src/briefs/to-spec.md +88 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +124 -1
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +38 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/intake.ts +4 -19
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +51 -16
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +43 -6
- package/src/config.ts +65 -9
- package/src/daemon.ts +879 -41
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +243 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +60 -82
- package/src/escalate.ts +31 -14
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +239 -240
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +242 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +183 -21
- package/src/orchestrator-tick.ts +1591 -32
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +65 -6
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1225 -9
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +154 -3
- package/src/setup.ts +83 -17
- package/src/shell.ts +15 -0
- package/src/status-render.ts +216 -12
- package/src/store.ts +443 -42
- package/src/to-spec.ts +408 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +405 -19
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +765 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +12 -2
- package/src/worktree.ts +29 -12
package/src/cli.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { userInfo } from "node:os";
|
|
11
11
|
import { loadConfig } from "./config.ts";
|
|
12
|
-
import { COMMAND_MANIFEST, renderUsage } from "./command-manifest.ts";
|
|
12
|
+
import { COMMAND_MANIFEST, renderUsage, type CommandManifestEntry } from "./command-manifest.ts";
|
|
13
13
|
import { armCommand } from "./commands/arm.ts";
|
|
14
14
|
import { boardCommand } from "./commands/board.ts";
|
|
15
15
|
import { briefUpgradeCommand } from "./commands/brief-upgrade.ts";
|
|
@@ -18,6 +18,7 @@ import { dashboardCommand } from "./commands/dashboard.ts";
|
|
|
18
18
|
import { decisionCommand } from "./commands/decision.ts";
|
|
19
19
|
import { disarmCommand } from "./commands/disarm.ts";
|
|
20
20
|
import { doctorCommand } from "./commands/doctor.ts";
|
|
21
|
+
import { drainCommand } from "./commands/drain.ts";
|
|
21
22
|
import { eventCommand } from "./commands/event.ts";
|
|
22
23
|
import { extendCommand } from "./commands/extend.ts";
|
|
23
24
|
import { frictionCommand } from "./commands/friction.ts";
|
|
@@ -54,6 +55,104 @@ import {
|
|
|
54
55
|
|
|
55
56
|
const USAGE = renderUsage(COMMAND_MANIFEST);
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Verbs that define every `--help`/`-h` shape themselves, so the dispatch
|
|
60
|
+
* gate below must not pre-empt them: `help` and the version aliases print
|
|
61
|
+
* their own text, `setup` accepts help only as the first trailing token
|
|
62
|
+
* (`setup bogus --help` must reject `bogus`, never print a help read), and
|
|
63
|
+
* the hand-rolled usage verbs print help at `argv[1]` or refuse a trailing
|
|
64
|
+
* unknown token with exit 2 — every one of them already before any side
|
|
65
|
+
* effect. `intake` and `watch` are deliberately absent: they validate their
|
|
66
|
+
* tails only after `loadConfig`/`openStore`, so an unconsumed trailing help
|
|
67
|
+
* must be answered by the gate before the handler can create the store
|
|
68
|
+
* (#863).
|
|
69
|
+
*/
|
|
70
|
+
const HELP_OWNED_VERBS: Record<string, true> = {
|
|
71
|
+
"--help": true,
|
|
72
|
+
"-h": true,
|
|
73
|
+
help: true,
|
|
74
|
+
"--version": true,
|
|
75
|
+
"-V": true,
|
|
76
|
+
version: true,
|
|
77
|
+
setup: true,
|
|
78
|
+
dashboard: true,
|
|
79
|
+
doctor: true,
|
|
80
|
+
stats: true,
|
|
81
|
+
drain: true,
|
|
82
|
+
"restore-db": true,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `setup`'s install subcommands — the one `setup` shape the wizard does not
|
|
87
|
+
* safely own: help is recognized only at the first trailing token, so
|
|
88
|
+
* `setup host --help` would reach the privileged install planner (creating
|
|
89
|
+
* the state db and staging the host install) instead of printing help. The
|
|
90
|
+
* dispatch gate answers real help requests in their tail instead.
|
|
91
|
+
*/
|
|
92
|
+
const SETUP_INSTALL_SUBCOMMANDS: Record<string, true> = {
|
|
93
|
+
host: true,
|
|
94
|
+
graph: true,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether an invocation carries an actual `--help`/`-h` request — a token the
|
|
99
|
+
* verb's own grammar does not consume. A token immediately after a
|
|
100
|
+
* value-taking flag is that flag's value (`report --text --help` sends the
|
|
101
|
+
* literal text "--help", exactly as before), and a bare `--` makes everything
|
|
102
|
+
* after it positional; only an unconsumed help token is really a request.
|
|
103
|
+
*/
|
|
104
|
+
function helpRequested(argv: readonly string[], entry: CommandManifestEntry): boolean {
|
|
105
|
+
let consumeNext = false;
|
|
106
|
+
for (const token of argv.slice(1)) {
|
|
107
|
+
if (consumeNext) {
|
|
108
|
+
consumeNext = false;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (token === "--") {
|
|
112
|
+
// A bare `--` ends option parsing for a verb whose manifest actually
|
|
113
|
+
// declares positionals: after it, `--help` is positional data, not a
|
|
114
|
+
// request. A flag-only verb has no positional grammar for `--` to
|
|
115
|
+
// protect, so `stop --all -- --help` (#863) must stay a help read.
|
|
116
|
+
if ((entry.positionals?.length ?? 0) > 0) return false;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (token === "--help" || token === "-h") return true;
|
|
120
|
+
// `--flag VALUE` consumes the next token as the flag's value, but
|
|
121
|
+
// `--flag=VALUE` binds the value inline — so only the split form can
|
|
122
|
+
// swallow a following `--help`. `stop --project=conductor --help` is a
|
|
123
|
+
// help read (#863), and `--project conductor --help` must read the next
|
|
124
|
+
// token as the value, exactly as `report --text --help` sends literal
|
|
125
|
+
// text. A token without `=` is a separate argument: when it names a
|
|
126
|
+
// value-taking flag, its value is the next token.
|
|
127
|
+
consumeNext =
|
|
128
|
+
token.startsWith("--") &&
|
|
129
|
+
!token.includes("=") &&
|
|
130
|
+
entry.flags.some((flag) => flag.name === token && flag.takesValue);
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One verb's usage block from the manifest — what `VERB --help` prints. The
|
|
137
|
+
* manifest's `details` is that verb's long-form semantics, and rendering it
|
|
138
|
+
* here is what keeps the generated read complete: the dispatch gate above
|
|
139
|
+
* answers help for every verb that does not own it, so a verb whose prose
|
|
140
|
+
* lived only in its own hand-rolled usage string would silently lose it
|
|
141
|
+
* (#863 — `watch`'s no-answer/auto-withdraw semantics and `intake`'s
|
|
142
|
+
* durability/groomed no-op semantics).
|
|
143
|
+
*/
|
|
144
|
+
function renderCommandHelp(entry: CommandManifestEntry): string {
|
|
145
|
+
const lines = [`omp-conductor ${entry.name} — ${entry.description}`, "", "usage:"];
|
|
146
|
+
for (const usage of entry.usage) lines.push(` omp-conductor ${usage}`);
|
|
147
|
+
if (entry.flags.length > 0) {
|
|
148
|
+
lines.push("", "flags:");
|
|
149
|
+
const width = Math.max(...entry.flags.map((flag) => flag.name.length));
|
|
150
|
+
for (const flag of entry.flags) lines.push(` ${flag.name.padEnd(width)} ${flag.description}`);
|
|
151
|
+
}
|
|
152
|
+
if (entry.details !== undefined) lines.push("", entry.details);
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
57
156
|
/** Applies both `--port 9000` and `--port=9000`; returns undefined when absent. */
|
|
58
157
|
function flag(argv: string[], name: string): string | undefined {
|
|
59
158
|
const i = argv.indexOf(`--${name}`);
|
|
@@ -178,6 +277,7 @@ export function commandHandlers(ctx: CommandContext): Record<string, CommandHand
|
|
|
178
277
|
board: () => boardCommand(ctx),
|
|
179
278
|
dashboard: () => dashboardCommand(ctx),
|
|
180
279
|
hold: () => holdCommand(ctx),
|
|
280
|
+
drain: () => drainCommand(ctx),
|
|
181
281
|
arm: () => armCommand(ctx),
|
|
182
282
|
disarm: () => disarmCommand(ctx),
|
|
183
283
|
tail: () => tailCommand(ctx),
|
|
@@ -252,6 +352,29 @@ export async function runCli(argv: string[] = process.argv.slice(2)): Promise<vo
|
|
|
252
352
|
);
|
|
253
353
|
process.exit(2);
|
|
254
354
|
}
|
|
355
|
+
// A real `--help`/`-h` (one no declared flag consumes) is a help read and
|
|
356
|
+
// must never reach a mutating command: `stop --all --help` is how #863
|
|
357
|
+
// disarmed the fleet. Recognized here — before project-selection
|
|
358
|
+
// (`targetProjects` is a lazy closure; no config is loaded) and before
|
|
359
|
+
// handler execution — so a verb that does not validate its own tail can
|
|
360
|
+
// no longer treat the request as an argument.
|
|
361
|
+
const entry = cmd === undefined ? undefined : COMMAND_MANIFEST.find((entry) => entry.name === cmd);
|
|
362
|
+
let help = false;
|
|
363
|
+
if (entry !== undefined) {
|
|
364
|
+
if (cmd === "setup") {
|
|
365
|
+
// `setup` owns its help (only at the first trailing token, which is
|
|
366
|
+
// what keeps `setup bogus --help` rejecting `bogus`). The install
|
|
367
|
+
// subcommands are the exception: a help token in their tail must be
|
|
368
|
+
// answered here, before `loadConfig` and the install plan (#863).
|
|
369
|
+
help = SETUP_INSTALL_SUBCOMMANDS[argv[1] ?? ""] === true && helpRequested(argv, entry);
|
|
370
|
+
} else if (HELP_OWNED_VERBS[cmd ?? ""] !== true) {
|
|
371
|
+
help = helpRequested(argv, entry);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (help && entry !== undefined) {
|
|
375
|
+
process.stdout.write(`${renderCommandHelp(entry)}\n`);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
255
378
|
// A "none"-scope verb does no per-project work, so --project cannot change
|
|
256
379
|
// what it does: refuse it with a reason instead of silently ignoring it.
|
|
257
380
|
if (COMMAND_SCOPES[cmd ?? ""] === "none" && projectFlag !== undefined) {
|
package/src/command-help.ts
CHANGED
|
@@ -87,6 +87,17 @@ export const COMMAND_DETAILS = ` setup interview, then write config.json, th
|
|
|
87
87
|
hold soft stop: pause claiming AND disarm ticks. Daemon and pane stay up.
|
|
88
88
|
This is "stop the conductor overnight" without killing processes.
|
|
89
89
|
Use --all to target every configured project.
|
|
90
|
+
drain start, inspect, or cancel the project's self-expiring admission
|
|
91
|
+
drain — the durable, bounded alternative to queue-label churn
|
|
92
|
+
before a release. New claims pause while active runs settle, and
|
|
93
|
+
admission resumes at the absolute deadline on its own, even if the
|
|
94
|
+
orchestrator dies. start takes --until (an ISO instant or a
|
|
95
|
+
relative duration such as 90s/45m/2h/1d, always bounded and in the
|
|
96
|
+
future) and an optional --reason; status reports the active drain's
|
|
97
|
+
creation time, expiry, reason and remaining active runs; cancel
|
|
98
|
+
removes it and is idempotent. The drain never touches the pause
|
|
99
|
+
sentinel, the arm marker, or any queue label — it is a file record,
|
|
100
|
+
not a timer or a hold.
|
|
90
101
|
arm proof-gated: send a Telegram challenge and write the arm marker only
|
|
91
102
|
after your reply appears as a user turn in the orchestrator transcript.
|
|
92
103
|
Never auto-armed by resume/hold. Use --all for every project.
|
package/src/command-manifest.ts
CHANGED
|
@@ -63,7 +63,6 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
63
63
|
"setup host [NAME] [--project NAME]",
|
|
64
64
|
"setup graph [--no-seed] [--print] [--project NAME]",
|
|
65
65
|
],
|
|
66
|
-
details: COMMAND_DETAILS,
|
|
67
66
|
subcommands: [
|
|
68
67
|
{ name: "host", description: "stage and install the host services" },
|
|
69
68
|
{ name: "graph", description: "install and seed code-graph indexes" },
|
|
@@ -205,6 +204,27 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
205
204
|
usage: ["hold [--keep-ticks] [--project NAME | --all]"],
|
|
206
205
|
flags: [toggle("--keep-ticks", "pause claims without disarming ticks"), project(), all()],
|
|
207
206
|
},
|
|
207
|
+
{
|
|
208
|
+
name: "drain",
|
|
209
|
+
description: "start, inspect, or cancel the project's self-expiring admission drain",
|
|
210
|
+
scope: "project",
|
|
211
|
+
usage: [
|
|
212
|
+
"drain start --until ISO|DURATION [--reason TEXT] [--project NAME]",
|
|
213
|
+
"drain status [--project NAME]",
|
|
214
|
+
"drain cancel [--project NAME]",
|
|
215
|
+
],
|
|
216
|
+
subcommands: [
|
|
217
|
+
{ name: "start", description: "record a bounded drain intent" },
|
|
218
|
+
{ name: "status", description: "report the drain and its remaining active runs" },
|
|
219
|
+
{ name: "cancel", description: "remove the project's drain" },
|
|
220
|
+
],
|
|
221
|
+
flags: [
|
|
222
|
+
value("--until", "absolute ISO instant or relative duration (90s, 45m, 2h, 1d)"),
|
|
223
|
+
value("--reason", "purpose, persisted on the drain record"),
|
|
224
|
+
project(),
|
|
225
|
+
],
|
|
226
|
+
positionals: [{ name: "action" }],
|
|
227
|
+
},
|
|
208
228
|
{
|
|
209
229
|
name: "arm",
|
|
210
230
|
description: "prove Telegram delivery and arm scheduled ticks",
|
|
@@ -397,6 +417,11 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
397
417
|
"watch list [--project NAME] [--json]",
|
|
398
418
|
"watch withdraw <id> [--reason TEXT] [--project NAME]",
|
|
399
419
|
],
|
|
420
|
+
details: `add records a row the daemon checks for you and the next tick reads, with no
|
|
421
|
+
operator answer needed. list shows open watches, oldest first. withdraw ends
|
|
422
|
+
one with a recorded reason — the verb that creates a watch is the verb that
|
|
423
|
+
ends it. A watch whose PR condition can no longer be observed (the PR merged
|
|
424
|
+
or closed first) is withdrawn by the daemon itself.`,
|
|
400
425
|
subcommands: [
|
|
401
426
|
{ name: "add", description: "record a watch" },
|
|
402
427
|
{ name: "list", description: "list open watches" },
|
|
@@ -422,6 +447,12 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
422
447
|
"intake dismiss <id> [--project NAME]",
|
|
423
448
|
"intake groomed <id> --issue <url> [--project NAME]",
|
|
424
449
|
],
|
|
450
|
+
details: `Captures one raw idea into the local store and prints its id. list shows what
|
|
451
|
+
is still pending (id, age, text), oldest first; dismiss drops one by id. The
|
|
452
|
+
capture is durable — it lives in the sqlite store, not in a session — so it
|
|
453
|
+
survives daemon restarts. The orchestrator files the idea as an issue and then
|
|
454
|
+
marks that provenance with groomed: an id already resolved is a no-op with a
|
|
455
|
+
message, never an error, because ticks retry.`,
|
|
425
456
|
subcommands: [
|
|
426
457
|
{ name: "list", description: "list pending intake items" },
|
|
427
458
|
{ name: "dismiss", description: "dismiss an intake item" },
|
|
@@ -435,7 +466,6 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
435
466
|
description: "print command usage (also --help, -h)",
|
|
436
467
|
scope: "none",
|
|
437
468
|
usage: ["help"],
|
|
438
|
-
details: COMMAND_HELP_TAIL,
|
|
439
469
|
flags: [],
|
|
440
470
|
},
|
|
441
471
|
{
|
|
@@ -459,9 +489,11 @@ export function renderUsage(manifest: readonly CommandManifestEntry[] = COMMAND_
|
|
|
459
489
|
const commands = manifest.map(
|
|
460
490
|
(command) => ` ${command.name.padEnd(width)} ${command.description}`,
|
|
461
491
|
);
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
492
|
+
// The fleet-wide operator reference, printed once. It is *not* harvested
|
|
493
|
+
// from `details`: that field is one verb's own long-form help, which
|
|
494
|
+
// `VERB --help` renders, so collecting it here would print `setup`'s
|
|
495
|
+
// carrier copy of this very blob and repeat each verb's prose twice.
|
|
496
|
+
const details = [...COMMAND_DETAILS.split("\n"), "", ...COMMAND_HELP_TAIL.split("\n"), ""];
|
|
465
497
|
return [
|
|
466
498
|
"omp-conductor — dispatch ready issues to omp coding sessions",
|
|
467
499
|
"",
|
|
@@ -475,6 +507,7 @@ export function renderUsage(manifest: readonly CommandManifestEntry[] = COMMAND_
|
|
|
475
507
|
...details,
|
|
476
508
|
"recipes:",
|
|
477
509
|
" hold no claims, no tick sends (inspectable)",
|
|
510
|
+
" drain start bounded release window, self-expiring (no hold)",
|
|
478
511
|
" stop hold + stop dispatch daemon",
|
|
479
512
|
" stop --pane stop + pin conductor-pane recovery off",
|
|
480
513
|
" resume clear pause and pane pin (ticks stay disarmed)",
|
package/src/commands/arm.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `arm` — proof-gated: send a Telegram challenge and write the arm marker only after the
|
|
2
|
+
* `arm` — proof-gated: send a Telegram challenge and write the arm marker only after the orchestrator's inbound adapter acknowledges the reply in conductor state (#614).
|
|
3
3
|
*
|
|
4
4
|
* Moved out of cli.ts's switch by the per-verb module split (#462);
|
|
5
5
|
* only the case wrapper, the injected `ctx` lookups and the imports
|
package/src/commands/context.ts
CHANGED
|
@@ -82,6 +82,7 @@ export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
|
|
|
82
82
|
// project — exactly one project; findProject demands --project when several
|
|
83
83
|
"brief-upgrade": "project",
|
|
84
84
|
decision: "project",
|
|
85
|
+
drain: "project",
|
|
85
86
|
event: "project",
|
|
86
87
|
extend: "project",
|
|
87
88
|
friction: "project",
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `drain` — start, inspect, or cancel the project's self-expiring admission fence (#484).
|
|
3
|
+
*
|
|
4
|
+
* The bounded drain state machine landed in #776 (`createDrain`/`readDrain`/
|
|
5
|
+
* `cancelDrain` on daemon.ts, re-exported from fleet.ts); this verb is the
|
|
6
|
+
* operator surface over it. A drain is the release-window sibling of `hold`:
|
|
7
|
+
* the same admission boundary — settlement above it, nothing claimed below —
|
|
8
|
+
* but recorded with an absolute deadline, so admission resumes on its own even
|
|
9
|
+
* if the orchestrator dies. The only state it touches is the project's drain
|
|
10
|
+
* record: never the pause sentinel, the arm marker, or any queue label.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CommandContext } from "./context.ts";
|
|
14
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
15
|
+
import { statusSnapshot } from "../daemon.ts";
|
|
16
|
+
import { cancelDrain, createDrain, readDrain } from "../fleet.ts";
|
|
17
|
+
import { dim, ok } from "../ui/style.ts";
|
|
18
|
+
|
|
19
|
+
const DRAIN_USAGE = `omp-conductor drain — start, inspect, or cancel the project's admission drain.
|
|
20
|
+
|
|
21
|
+
usage:
|
|
22
|
+
omp-conductor drain start --until ISO|DURATION [--reason TEXT] [--project NAME]
|
|
23
|
+
omp-conductor drain status [--project NAME]
|
|
24
|
+
omp-conductor drain cancel [--project NAME]
|
|
25
|
+
|
|
26
|
+
A drain is a durable, self-expiring admission fence: new claims pause while
|
|
27
|
+
existing runs settle, and admission resumes automatically at the absolute
|
|
28
|
+
deadline — even if the orchestrator crashes. start replaces any prior drain of
|
|
29
|
+
the project; --until takes an ISO instant or a relative duration (90s, 45m,
|
|
30
|
+
2h, 1d) that must be bounded and in the future. status reports the active
|
|
31
|
+
drain's creation time, absolute expiry, reason, and remaining active runs.
|
|
32
|
+
cancel removes the project's drain and is idempotent. A drain never touches
|
|
33
|
+
the pause sentinel, the arm marker, or any queue label — it is the file record
|
|
34
|
+
that expires on its own.`;
|
|
35
|
+
|
|
36
|
+
/** The flags each drain subcommand accepts, after the subcommand itself. */
|
|
37
|
+
const DRAIN_FLAGS: Readonly<Record<string, readonly string[]>> = {
|
|
38
|
+
start: ["--project", "--until", "--reason"],
|
|
39
|
+
status: ["--project"],
|
|
40
|
+
cancel: ["--project"],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Relative durations accepted by `drain start --until`. */
|
|
44
|
+
const DURATION_RE = /^(\d+)([smhd])$/;
|
|
45
|
+
const DURATION_UNIT_MS: Readonly<Record<string, number>> = {
|
|
46
|
+
s: 1_000,
|
|
47
|
+
m: 60_000,
|
|
48
|
+
h: 3_600_000,
|
|
49
|
+
d: 86_400_000,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `--until` value → epoch-ms deadline, or undefined when the input is not a
|
|
54
|
+
* bounded expiry. A duration is relative to now; an ISO instant is absolute.
|
|
55
|
+
* Both forms must land on a finite safe-integer timestamp — the same bound
|
|
56
|
+
* `createDrain` persists as an absolute ISO instant, so a value beyond the
|
|
57
|
+
* Date range (e.g. 99999999999999999999d) is refused here instead of blowing
|
|
58
|
+
* up inside the record write. The absolute form must look like a calendar
|
|
59
|
+
* date: a bare number such as `3000` parses as a year in some engines, and an
|
|
60
|
+
* operator typing that almost certainly meant a duration.
|
|
61
|
+
*/
|
|
62
|
+
function parseExpiry(raw: string, now: number): number | undefined {
|
|
63
|
+
const duration = DURATION_RE.exec(raw);
|
|
64
|
+
if (duration !== null) {
|
|
65
|
+
const expiresAt = now + Number.parseInt(duration[1]!, 10) * DURATION_UNIT_MS[duration[2]!]!;
|
|
66
|
+
return Number.isSafeInteger(expiresAt) ? expiresAt : undefined;
|
|
67
|
+
}
|
|
68
|
+
if (!/^\d{4}-\d{2}-\d{2}/.test(raw)) return undefined;
|
|
69
|
+
const absolute = Date.parse(raw);
|
|
70
|
+
return Number.isSafeInteger(absolute) ? absolute : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Rejects trailing tokens a drain subcommand does not declare, so a typo'd
|
|
75
|
+
* flag cannot be silently ignored — the same exit-2 scan `intake` and `stats`
|
|
76
|
+
* run. Both `--flag VALUE` and `--flag=VALUE` are accepted, matching the
|
|
77
|
+
* shared `flag()` parser.
|
|
78
|
+
*/
|
|
79
|
+
function assertKnownArgs(ctx: CommandContext, sub: string): void {
|
|
80
|
+
const allowed = DRAIN_FLAGS[sub] ?? [];
|
|
81
|
+
for (let i = 2; i < ctx.argv.length; i++) {
|
|
82
|
+
const token = ctx.argv[i];
|
|
83
|
+
if (token === undefined) continue;
|
|
84
|
+
const eq = token.startsWith("--") ? token.indexOf("=") : -1;
|
|
85
|
+
const name = eq < 0 ? token : token.slice(0, eq);
|
|
86
|
+
if (allowed.includes(name)) {
|
|
87
|
+
if (eq < 0) i += 1; // consume the flag's value token
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
process.stderr.write(`omp-conductor: drain ${sub}: unexpected argument "${token}"\n`);
|
|
91
|
+
process.exit(2);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function drainCommand(ctx: CommandContext): Promise<void> {
|
|
96
|
+
const sub = ctx.argv[1];
|
|
97
|
+
if (sub === "--help" || sub === "-h") {
|
|
98
|
+
process.stdout.write(DRAIN_USAGE);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (sub !== "start" && sub !== "status" && sub !== "cancel") {
|
|
102
|
+
process.stderr.write("omp-conductor: drain needs start, status, or cancel\n");
|
|
103
|
+
process.exit(2);
|
|
104
|
+
}
|
|
105
|
+
assertKnownArgs(ctx, sub);
|
|
106
|
+
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
107
|
+
|
|
108
|
+
if (sub === "start") {
|
|
109
|
+
const rawUntil = ctx.flag("until");
|
|
110
|
+
if (rawUntil === undefined || rawUntil.length === 0) {
|
|
111
|
+
process.stderr.write(
|
|
112
|
+
"omp-conductor: drain start needs --until with an ISO instant or a duration (90s, 45m, 2h, 1d)\n",
|
|
113
|
+
);
|
|
114
|
+
process.exit(2);
|
|
115
|
+
}
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const expiresAt = parseExpiry(rawUntil, now);
|
|
118
|
+
if (expiresAt === undefined) {
|
|
119
|
+
process.stderr.write(
|
|
120
|
+
`omp-conductor: drain start: "${rawUntil}" is not a bounded expiry — ` +
|
|
121
|
+
"use an ISO instant (e.g. 2026-08-20T10:00:00Z) or a duration (90s, 45m, 2h, 1d)\n",
|
|
122
|
+
);
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
125
|
+
if (expiresAt <= now) {
|
|
126
|
+
process.stderr.write(`omp-conductor: drain start: --until "${rawUntil}" must be in the future\n`);
|
|
127
|
+
process.exit(2);
|
|
128
|
+
}
|
|
129
|
+
const rawReason = ctx.flag("reason")?.trim().replace(/\s+/g, " ");
|
|
130
|
+
if (rawReason !== undefined && (rawReason === "" || rawReason.length > 500)) {
|
|
131
|
+
process.stderr.write("omp-conductor: drain start --reason needs 1-500 characters\n");
|
|
132
|
+
process.exit(2);
|
|
133
|
+
}
|
|
134
|
+
createDrain(project.name, {
|
|
135
|
+
expiresAt,
|
|
136
|
+
...(rawReason === undefined ? {} : { reason: rawReason }),
|
|
137
|
+
});
|
|
138
|
+
process.stdout.write(
|
|
139
|
+
ok(
|
|
140
|
+
`drain started for ${project.name} — claiming paused until ${new Date(expiresAt).toISOString()}` +
|
|
141
|
+
(rawReason === undefined ? "" : ` (${rawReason})`),
|
|
142
|
+
) + "\n",
|
|
143
|
+
);
|
|
144
|
+
process.stdout.write(
|
|
145
|
+
dim(
|
|
146
|
+
"the drain is a durable record: it survives crashes and resumes admission at the deadline on its own",
|
|
147
|
+
) + "\n",
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (sub === "status") {
|
|
153
|
+
const drain = statusSnapshot(project.name).drain;
|
|
154
|
+
if (drain === undefined) {
|
|
155
|
+
process.stdout.write(`drain: ${project.name} inactive — admission is open\n`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const lines = [
|
|
159
|
+
`drain: ${project.name} active`,
|
|
160
|
+
` since ${new Date(drain.since).toISOString()}`,
|
|
161
|
+
` expires at ${new Date(drain.expiresAt).toISOString()}`,
|
|
162
|
+
...(drain.reason === undefined ? [] : [` reason ${drain.reason}`]),
|
|
163
|
+
` remaining ${drain.remainingRuns} active run${drain.remainingRuns === 1 ? "" : "s"}`,
|
|
164
|
+
];
|
|
165
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const had = readDrain(project.name);
|
|
170
|
+
cancelDrain(project.name);
|
|
171
|
+
process.stdout.write(
|
|
172
|
+
had.kind === "active"
|
|
173
|
+
? ok(`drain cancelled for ${project.name} — admission resumes on the next dispatch pass`) + "\n"
|
|
174
|
+
: `drain: ${project.name} had no active drain — nothing to cancel\n`,
|
|
175
|
+
);
|
|
176
|
+
}
|
package/src/commands/extend.ts
CHANGED
|
@@ -2,25 +2,21 @@
|
|
|
2
2
|
* `extend` — raise a live run's turn ceiling, or set a bounded one-shot ceiling after a failed, killed, orphaned or blocked run.
|
|
3
3
|
*
|
|
4
4
|
* Moved out of cli.ts's switch by the per-verb module split (#462);
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* the daemon discovery resolves through {@link requireDaemonControl}, the
|
|
6
|
+
* same run-control target `worker` uses, so a live systemd-run daemon
|
|
7
|
+
* stays reachable when its pidfile is missing (#811) and the refusal
|
|
8
|
+
* answers cannot drift between the two verbs.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import type { CommandContext } from "./context.ts";
|
|
10
12
|
import { findProject, loadConfig } from "../config.ts";
|
|
11
|
-
import {
|
|
13
|
+
import { requireDaemonControl } from "../lifecycle.ts";
|
|
12
14
|
|
|
13
15
|
export async function extendCommand(ctx: CommandContext): Promise<void> {
|
|
14
16
|
const issue = ctx.issueArg("extend", ctx.argv[1]);
|
|
15
17
|
const maxTurns = ctx.turnsFlag();
|
|
16
18
|
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
17
|
-
const daemon =
|
|
18
|
-
if (daemon === undefined) throw new Error("daemon is not running");
|
|
19
|
-
if (daemon.project !== undefined && daemon.project !== project.name) {
|
|
20
|
-
throw new Error(
|
|
21
|
-
`daemon serves project "${daemon.project}", not requested project "${project.name}"`,
|
|
22
|
-
);
|
|
23
|
-
}
|
|
19
|
+
const daemon = await requireDaemonControl(project.name);
|
|
24
20
|
const response = await fetch(
|
|
25
21
|
`http://127.0.0.1:${daemon.port}/runs/${issue}/turn-limit`,
|
|
26
22
|
{
|
package/src/commands/intake.ts
CHANGED
|
@@ -12,21 +12,6 @@ import type { CommandContext } from "./context.ts";
|
|
|
12
12
|
import { findProject, loadConfig } from "../config.ts";
|
|
13
13
|
import { dbPath, openStore } from "../store.ts";
|
|
14
14
|
|
|
15
|
-
const INTAKE_USAGE = `omp-conductor intake — capture raw ideas durably.
|
|
16
|
-
|
|
17
|
-
usage:
|
|
18
|
-
omp-conductor intake "<text>" [--project NAME]
|
|
19
|
-
omp-conductor intake list [--project NAME]
|
|
20
|
-
omp-conductor intake dismiss <id> [--project NAME]
|
|
21
|
-
omp-conductor intake groomed <id> --issue <url> [--project NAME]
|
|
22
|
-
|
|
23
|
-
Captures one raw idea into the local store and prints its id. list shows what
|
|
24
|
-
is still pending (id, age, text), oldest first; dismiss drops one by id. The
|
|
25
|
-
capture is durable — it lives in the sqlite store, not in a session — so it
|
|
26
|
-
survives daemon restarts. The orchestrator files the idea as an issue and then
|
|
27
|
-
marks that provenance with groomed: an id already resolved is a no-op with a
|
|
28
|
-
message, never an error, because ticks retry.`;
|
|
29
|
-
|
|
30
15
|
/** Flags the intake surface understands. `--project` is consumed by
|
|
31
16
|
* {@link CommandContext.projectFlag}; the value token stays in argv. */
|
|
32
17
|
const INTAKE_FLAGS: Record<string, true> = { "--project": true, "--issue": true };
|
|
@@ -49,11 +34,11 @@ function assertKnownArgs(ctx: CommandContext, from: number): void {
|
|
|
49
34
|
}
|
|
50
35
|
|
|
51
36
|
export async function intakeCommand(ctx: CommandContext): Promise<void> {
|
|
37
|
+
// `--help`/`-h` never arrives here: the CLI dispatch gate answers a real
|
|
38
|
+
// help request from the manifest before this handler runs (#863), which is
|
|
39
|
+
// why the long-form prose lives on the manifest's `details` field rather
|
|
40
|
+
// than in a usage string this module would print itself.
|
|
52
41
|
const sub = ctx.argv[1];
|
|
53
|
-
if (sub === "--help" || sub === "-h") {
|
|
54
|
-
process.stdout.write(INTAKE_USAGE);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
42
|
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
58
43
|
const store = openStore(dbPath());
|
|
59
44
|
try {
|
package/src/commands/status.ts
CHANGED
|
@@ -47,7 +47,6 @@ function styleStatus(text: string): string {
|
|
|
47
47
|
.map((line) => {
|
|
48
48
|
if (
|
|
49
49
|
line === "caps" ||
|
|
50
|
-
line === "daemon" ||
|
|
51
50
|
line === "active runs" ||
|
|
52
51
|
line === "active runs (none)" ||
|
|
53
52
|
line.startsWith("project ")
|
|
@@ -56,6 +55,11 @@ function styleStatus(text: string): string {
|
|
|
56
55
|
if (line.includes("STALLED")) return fail(line);
|
|
57
56
|
if (/^(dispatch|ticks|pane|herdr|telegram| healthz)\s+.*\b(running|healthy|ok)\b/.test(line))
|
|
58
57
|
return ok(line);
|
|
58
|
+
// The daemon headline carries its own three states (#685): green while
|
|
59
|
+
// serving, amber when the probe timed out or the pid is gone — a bare
|
|
60
|
+
// `daemon` heading line no longer exists to style.
|
|
61
|
+
if (/^daemon\s+.*\b(not running|unresponsive)\b/.test(line)) return warn(line);
|
|
62
|
+
if (/^daemon\s+running\b/.test(line)) return ok(line);
|
|
59
63
|
if (/^(dispatch|ticks|daemon)\s+.*\b(paused|stopped|not running|overdue)\b/.test(line))
|
|
60
64
|
return warn(line);
|
|
61
65
|
return line;
|
package/src/commands/watch.ts
CHANGED
|
@@ -21,31 +21,58 @@
|
|
|
21
21
|
import type { CommandContext } from "./context.ts";
|
|
22
22
|
import { findProject, loadConfig } from "../config.ts";
|
|
23
23
|
import { CONDITION_FORMS, parseCondition } from "../decisions.ts";
|
|
24
|
+
import { shellQuote } from "../shell.ts";
|
|
24
25
|
import { dbPath, openStore } from "../store.ts";
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
/** The flags each watch subcommand accepts, after the subcommand itself.
|
|
28
|
+
* `add`'s positional note and `withdraw`'s positional id are validated by
|
|
29
|
+
* the branches below, not through this table. */
|
|
30
|
+
const WATCH_FLAGS: Readonly<Record<string, readonly string[]>> = {
|
|
31
|
+
add: ["--blocks", "--note", "--project", "--resolves-when"],
|
|
32
|
+
list: ["--json", "--project"],
|
|
33
|
+
withdraw: ["--project", "--reason"],
|
|
34
|
+
};
|
|
27
35
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
omp-conductor watch list [--project NAME] [--json]
|
|
31
|
-
omp-conductor watch withdraw <id> [--reason TEXT] [--project NAME]
|
|
36
|
+
/** Flags that take no value — `list --json` must not consume the token after it. */
|
|
37
|
+
const WATCH_BOOL_FLAGS: Readonly<Record<string, true>> = { "--json": true };
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Rejects any token after the subcommand that its surface does not declare,
|
|
41
|
+
* so a typo'd flag can never be silently ignored — the defect where
|
|
42
|
+
* `--condition` created a watch with no condition at all. Both `--flag VALUE`
|
|
43
|
+
* and `--flag=VALUE` are accepted, matching the shared `flag()` parser; a
|
|
44
|
+
* value flag consumes the next token, a `--flag=VALUE` form or a boolean flag
|
|
45
|
+
* never does. The same exit-2 scan `drain` and `stats` run (#462).
|
|
46
|
+
*/
|
|
47
|
+
function assertKnownWatchArgs(ctx: CommandContext, sub: string, from: number): void {
|
|
48
|
+
const allowed = WATCH_FLAGS[sub] ?? [];
|
|
49
|
+
for (let i = from; i < ctx.argv.length; i++) {
|
|
50
|
+
const token = ctx.argv[i];
|
|
51
|
+
if (token === undefined) continue;
|
|
52
|
+
const eq = token.startsWith("--") ? token.indexOf("=") : -1;
|
|
53
|
+
const name = eq < 0 ? token : token.slice(0, eq);
|
|
54
|
+
if (allowed.includes(name)) {
|
|
55
|
+
if (eq < 0 && WATCH_BOOL_FLAGS[name] !== true) i += 1; // consume the flag's value token
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
process.stderr.write(
|
|
59
|
+
`omp-conductor: watch ${sub}: unexpected argument "${token}" (known: ${allowed.join(" ")}) — nothing was recorded\n`,
|
|
60
|
+
);
|
|
61
|
+
process.exit(2);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
38
64
|
|
|
39
65
|
export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
66
|
+
// `--help`/`-h` never arrives here: the CLI dispatch gate answers a real
|
|
67
|
+
// help request from the manifest before this handler runs (#863), which is
|
|
68
|
+
// why the long-form prose lives on the manifest's `details` field rather
|
|
69
|
+
// than in a usage string this module would print itself.
|
|
40
70
|
const sub = ctx.argv[1];
|
|
41
|
-
if (sub === "--help" || sub === "-h") {
|
|
42
|
-
process.stdout.write(WATCH_USAGE);
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
71
|
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
46
72
|
const store = openStore(dbPath());
|
|
47
73
|
try {
|
|
48
74
|
if (sub === "add") {
|
|
75
|
+
assertKnownWatchArgs(ctx, "add", 2);
|
|
49
76
|
const note = ctx.flag("note")?.trim();
|
|
50
77
|
if (note === undefined || note.length === 0 || note.startsWith("--")) {
|
|
51
78
|
process.stderr.write("omp-conductor: watch add needs --note with what the next tick should know\n");
|
|
@@ -68,13 +95,17 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
|
68
95
|
at: Date.now(),
|
|
69
96
|
});
|
|
70
97
|
const wake = condition === undefined ? "read by the next tick" : "the daemon wakes the next tick when it is met";
|
|
98
|
+
// The verb that ends the row names the project it was created in: this
|
|
99
|
+
// command is copied, and on a host with several projects the bare form is
|
|
100
|
+
// ambiguous (#810).
|
|
71
101
|
process.stdout.write(
|
|
72
|
-
`watch ${watch.id} added — ${wake} (no operator answer needed); end with: omp-conductor watch withdraw ${watch.id}\n`,
|
|
102
|
+
`watch ${watch.id} added — ${wake} (no operator answer needed); end with: omp-conductor watch withdraw ${watch.id} --project ${shellQuote(project.name)}\n`,
|
|
73
103
|
);
|
|
74
104
|
return;
|
|
75
105
|
}
|
|
76
106
|
|
|
77
107
|
if (sub === "list" || sub === undefined) {
|
|
108
|
+
assertKnownWatchArgs(ctx, "list", 2);
|
|
78
109
|
const open = store.openDecisions(project.name).filter((d) => d.kind === "watch");
|
|
79
110
|
const now = Date.now();
|
|
80
111
|
const watches = open.map((d) => ({
|
|
@@ -95,13 +126,17 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
|
95
126
|
for (const watch of watches) {
|
|
96
127
|
process.stdout.write(
|
|
97
128
|
`${watch.id} ${watch.ageHours}h blocks:${watch.blocks ?? "-"} ` +
|
|
98
|
-
|
|
129
|
+
// The listing already resolved the project, so the cleanup command
|
|
130
|
+
// it displays must carry it: an operator copying a command from
|
|
131
|
+
// this output should get one that runs as written (#810).
|
|
132
|
+
`condition:${watch.condition ?? "-"} ${watch.note} (end: omp-conductor watch withdraw ${watch.id} --project ${shellQuote(project.name)})\n`,
|
|
99
133
|
);
|
|
100
134
|
}
|
|
101
135
|
return;
|
|
102
136
|
}
|
|
103
137
|
|
|
104
138
|
if (sub === "withdraw") {
|
|
139
|
+
assertKnownWatchArgs(ctx, "withdraw", 3);
|
|
105
140
|
const id = ctx.argv[2];
|
|
106
141
|
if (id === undefined || id.startsWith("--")) {
|
|
107
142
|
process.stderr.write("omp-conductor: watch withdraw needs the watch id\n");
|