omp-conductor 0.17.1 → 0.18.1
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 +34 -0
- package/REFERENCE.md +71 -17
- package/agents/to-spec.md +90 -0
- package/package.json +2 -1
- package/schema/config.schema.json +53 -1
- package/src/admission.ts +308 -76
- package/src/ask.ts +307 -10
- package/src/backups.ts +2 -2
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +43 -14
- package/src/briefs/to-spec.md +84 -0
- package/src/briefs/worker.md +37 -19
- package/src/cli.ts +2 -0
- package/src/command-help.ts +19 -1
- package/src/command-manifest.ts +27 -2
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +110 -3
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +57 -0
- package/src/config.ts +102 -2
- package/src/daemon.ts +1220 -1517
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +279 -16
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +425 -1
- package/src/digest-schedule.ts +37 -0
- package/src/doctor.ts +52 -0
- package/src/escalate.ts +9 -3
- package/src/failure-class.ts +43 -4
- package/src/fleet.ts +166 -24
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +55 -8
- package/src/graph.ts +379 -69
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +567 -2
- package/src/lifecycle.ts +158 -6
- package/src/omp.ts +269 -20
- package/src/orchestrator-tick.ts +1489 -26
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/routing.ts +11 -3
- package/src/session-host.ts +115 -5
- package/src/settlement.ts +1780 -0
- package/src/setup-host.ts +1205 -6
- package/src/setup-install.ts +119 -30
- package/src/setup-wizard.ts +88 -2
- package/src/setup.ts +119 -13
- package/src/shell.ts +15 -0
- package/src/status-render.ts +100 -11
- package/src/store.ts +519 -45
- package/src/to-spec.ts +387 -0
- package/src/tracker/github.ts +150 -14
- package/src/types.ts +470 -16
- 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 +770 -40
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +239 -9
- package/src/worktree.ts +142 -18
package/src/briefs/worker.md
CHANGED
|
@@ -47,7 +47,7 @@ files are canonical; your priors are not.
|
|
|
47
47
|
|
|
48
48
|
{{ACCEPTANCE_CRITERIA}}
|
|
49
49
|
|
|
50
|
-
{{ISSUE_COMMENTS}}## How to work
|
|
50
|
+
{{ISSUE_COMMENTS}}{{FILE_LANE}}{{MODEL}}## How to work
|
|
51
51
|
|
|
52
52
|
1. **Understand before editing — and ask the graph before you grep.** Your turns
|
|
53
53
|
are mostly spent finding code, not writing it, and running out of turns
|
|
@@ -76,6 +76,18 @@ files are canonical; your priors are not.
|
|
|
76
76
|
genuinely cannot be done small, stop and escalate rather than ballooning.
|
|
77
77
|
5. **Fix the root cause, never the symptom.** Do not suppress a warning, delete an
|
|
78
78
|
assertion, or special-case an input to make a check pass.
|
|
79
|
+
6. **Prefer the structured edit tool for file changes, and the structured
|
|
80
|
+
search tool over shelling out.** A structured edit is one call: anchored and
|
|
81
|
+
verified, and a stale anchor fails loudly instead of silently editing the
|
|
82
|
+
wrong line. The shell equivalent is three or four — compose the script,
|
|
83
|
+
escape it correctly, run it, then read the file back to confirm it did what
|
|
84
|
+
was intended — and a mis-escaped `sed -i` pattern silently edits nothing or
|
|
85
|
+
the wrong line. So do not hand-roll edits through `sed -i`, `python3`
|
|
86
|
+
heredocs, `node -e` or shell redirection except where no structured tool can
|
|
87
|
+
express the change (a binary file, a generated artefact). The same holds for
|
|
88
|
+
finding code: use the structured search tool rather than shelling out to
|
|
89
|
+
grep, for the same reason the graph line exists — it is cheaper per call and
|
|
90
|
+
its output is already scoped.
|
|
79
91
|
|
|
80
92
|
## Tests — read this carefully
|
|
81
93
|
|
|
@@ -99,6 +111,7 @@ These are the exact gates for `{{REPO}}`:
|
|
|
99
111
|
{{GATES}}
|
|
100
112
|
|
|
101
113
|
{{SHARED_HOST_NOTICE}}
|
|
114
|
+
{{HOST_CONSTRAINTS}}
|
|
102
115
|
|
|
103
116
|
Run every one of them, from the directory listed, over the **whole tree** — not
|
|
104
117
|
just the directory you edited. Linting only the source dir is how an error in a
|
|
@@ -210,24 +223,29 @@ Escalating is a successful outcome. Guessing is not.
|
|
|
210
223
|
|
|
211
224
|
## Your final report
|
|
212
225
|
|
|
213
|
-
End
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
226
|
+
End your run by yielding the settlement through the `yield` tool — one
|
|
227
|
+
structured call, and the schema is the contract: the harness showed it to you
|
|
228
|
+
at session start. The expected shape:
|
|
229
|
+
|
|
230
|
+
```json
|
|
231
|
+
{
|
|
232
|
+
"status": "green",
|
|
233
|
+
"prUrl": "https://github.com/.../pull/N",
|
|
234
|
+
"headSha": "<the 40-char head you watched go green>",
|
|
235
|
+
"summary": "What you changed and why — the narrative a reviewer reads.",
|
|
236
|
+
"proof": ["bun test omp/src/worker.test.ts"]
|
|
237
|
+
}
|
|
223
238
|
```
|
|
224
239
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
240
|
+
Call it as `yield({ result: { data: <the object> } })` with no `type` — the
|
|
241
|
+
usual terminal yield. `status: "green"` means you pushed and **watched the
|
|
242
|
+
checks go green**, and it requires both `prUrl` and `headSha`. Use `blocked`
|
|
243
|
+
(with `blockers`) when a decision or credential is missing, `failed` when the
|
|
244
|
+
run could not complete. The dispatcher renders your yielded settlement into
|
|
245
|
+
the stored report, so `summary` is what a reviewer reads and `proof` is the
|
|
246
|
+
evidence; the `changed:` file list is derived from the PR's own diff, never
|
|
247
|
+
written by you.
|
|
248
|
+
|
|
249
|
+
Never report success you have not observed. "Should pass CI" is not a state,
|
|
250
|
+
and `green` means you watched the checks go green — not that you expect them
|
|
233
251
|
to.
|
package/src/cli.ts
CHANGED
|
@@ -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";
|
|
@@ -178,6 +179,7 @@ export function commandHandlers(ctx: CommandContext): Record<string, CommandHand
|
|
|
178
179
|
board: () => boardCommand(ctx),
|
|
179
180
|
dashboard: () => dashboardCommand(ctx),
|
|
180
181
|
hold: () => holdCommand(ctx),
|
|
182
|
+
drain: () => drainCommand(ctx),
|
|
181
183
|
arm: () => armCommand(ctx),
|
|
182
184
|
disarm: () => disarmCommand(ctx),
|
|
183
185
|
tail: () => tailCommand(ctx),
|
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.
|
|
@@ -168,9 +179,16 @@ export const COMMAND_DETAILS = ` setup interview, then write config.json, th
|
|
|
168
179
|
condition the daemon checks for you; a met watch wakes the next tick
|
|
169
180
|
with its note, exactly as a met question does, but it is listed under
|
|
170
181
|
its own heading and never under "Open operator decisions", and it has
|
|
171
|
-
no seven-day expiry. \`watch list\` shows open watches
|
|
182
|
+
no seven-day expiry. \`watch list\` shows open watches; \`watch
|
|
183
|
+
withdraw <id>\` ends one with a recorded reason — the verb that
|
|
184
|
+
creates a watch is the verb that ends it (watches are decision rows,
|
|
185
|
+
so \`decision withdraw <id>\` also works). A watch whose PR condition
|
|
186
|
+
can no longer be observed — the PR merged or closed before the
|
|
187
|
+
condition was seen — is withdrawn by the daemon itself with the
|
|
188
|
+
reason recorded.
|
|
172
189
|
watch add --note TEXT [--blocks TEXT] [--resolves-when COND]
|
|
173
190
|
watch list
|
|
191
|
+
watch withdraw <id> [--reason TEXT]
|
|
174
192
|
intake keep a raw idea durably before it becomes anything: record it now
|
|
175
193
|
with \`omp-conductor intake "<text>"\`, list what is still pending,
|
|
176
194
|
dismiss what turned out to be nothing. Backed by the sqlite store,
|
package/src/command-manifest.ts
CHANGED
|
@@ -205,6 +205,27 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
205
205
|
usage: ["hold [--keep-ticks] [--project NAME | --all]"],
|
|
206
206
|
flags: [toggle("--keep-ticks", "pause claims without disarming ticks"), project(), all()],
|
|
207
207
|
},
|
|
208
|
+
{
|
|
209
|
+
name: "drain",
|
|
210
|
+
description: "start, inspect, or cancel the project's self-expiring admission drain",
|
|
211
|
+
scope: "project",
|
|
212
|
+
usage: [
|
|
213
|
+
"drain start --until ISO|DURATION [--reason TEXT] [--project NAME]",
|
|
214
|
+
"drain status [--project NAME]",
|
|
215
|
+
"drain cancel [--project NAME]",
|
|
216
|
+
],
|
|
217
|
+
subcommands: [
|
|
218
|
+
{ name: "start", description: "record a bounded drain intent" },
|
|
219
|
+
{ name: "status", description: "report the drain and its remaining active runs" },
|
|
220
|
+
{ name: "cancel", description: "remove the project's drain" },
|
|
221
|
+
],
|
|
222
|
+
flags: [
|
|
223
|
+
value("--until", "absolute ISO instant or relative duration (90s, 45m, 2h, 1d)"),
|
|
224
|
+
value("--reason", "purpose, persisted on the drain record"),
|
|
225
|
+
project(),
|
|
226
|
+
],
|
|
227
|
+
positionals: [{ name: "action" }],
|
|
228
|
+
},
|
|
208
229
|
{
|
|
209
230
|
name: "arm",
|
|
210
231
|
description: "prove Telegram delivery and arm scheduled ticks",
|
|
@@ -390,24 +411,27 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
390
411
|
},
|
|
391
412
|
{
|
|
392
413
|
name: "watch",
|
|
393
|
-
description: "record or
|
|
414
|
+
description: "record, list, or withdraw orchestrator-only conditions and carry notes",
|
|
394
415
|
scope: "project",
|
|
395
416
|
usage: [
|
|
396
417
|
"watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]",
|
|
397
418
|
"watch list [--project NAME] [--json]",
|
|
419
|
+
"watch withdraw <id> [--reason TEXT] [--project NAME]",
|
|
398
420
|
],
|
|
399
421
|
subcommands: [
|
|
400
422
|
{ name: "add", description: "record a watch" },
|
|
401
423
|
{ name: "list", description: "list open watches" },
|
|
424
|
+
{ name: "withdraw", description: "withdraw an obsolete watch" },
|
|
402
425
|
],
|
|
403
426
|
flags: [
|
|
404
427
|
value("--note", "note carried when the watch resolves"),
|
|
405
428
|
value("--blocks", "what the watch blocks"),
|
|
406
429
|
value("--resolves-when", "automatic resolution condition"),
|
|
430
|
+
value("--reason", "withdrawal reason"),
|
|
407
431
|
toggle("--json", "print watch list as stable JSON"),
|
|
408
432
|
project(),
|
|
409
433
|
],
|
|
410
|
-
positionals: [{ name: "action" }],
|
|
434
|
+
positionals: [{ name: "action" }, { name: "id" }],
|
|
411
435
|
},
|
|
412
436
|
{
|
|
413
437
|
name: "intake",
|
|
@@ -472,6 +496,7 @@ export function renderUsage(manifest: readonly CommandManifestEntry[] = COMMAND_
|
|
|
472
496
|
...details,
|
|
473
497
|
"recipes:",
|
|
474
498
|
" hold no claims, no tick sends (inspectable)",
|
|
499
|
+
" drain start bounded release window, self-expiring (no hold)",
|
|
475
500
|
" stop hold + stop dispatch daemon",
|
|
476
501
|
" stop --pane stop + pin conductor-pane recovery off",
|
|
477
502
|
" resume clear pause and pane pin (ticks stay disarmed)",
|
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/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
|
@@ -10,19 +10,82 @@
|
|
|
10
10
|
* next tick should read, neither of which ever needs an operator answer. It is
|
|
11
11
|
* distinguished durably by its `kind`, never by whether it carries a
|
|
12
12
|
* condition — a real question may carry one too.
|
|
13
|
+
*
|
|
14
|
+
* Watches are decision rows with `kind === "watch"`, so `watch withdraw`
|
|
15
|
+
* delegates to the same store resolution `decision withdraw` uses rather than
|
|
16
|
+
* inventing a second mechanism: both verbs write the same durable terminal
|
|
17
|
+
* state with a recorded reason, and a watch id remains acceptable to `decision
|
|
18
|
+
* withdraw` for anyone who learned that vocabulary first (#664).
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import type { CommandContext } from "./context.ts";
|
|
16
22
|
import { findProject, loadConfig } from "../config.ts";
|
|
17
23
|
import { CONDITION_FORMS, parseCondition } from "../decisions.ts";
|
|
24
|
+
import { shellQuote } from "../shell.ts";
|
|
18
25
|
import { dbPath, openStore } from "../store.ts";
|
|
19
26
|
|
|
27
|
+
const WATCH_USAGE = `omp-conductor watch — set a condition or carry note for the orchestrator itself.
|
|
28
|
+
|
|
29
|
+
usage:
|
|
30
|
+
omp-conductor watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
|
|
31
|
+
omp-conductor watch list [--project NAME] [--json]
|
|
32
|
+
omp-conductor watch withdraw <id> [--reason TEXT] [--project NAME]
|
|
33
|
+
|
|
34
|
+
add records a row the daemon checks for you and the next tick reads, with no
|
|
35
|
+
operator answer needed. list shows open watches, oldest first. withdraw ends
|
|
36
|
+
one with a recorded reason — the verb that creates a watch is the verb that
|
|
37
|
+
ends it. A watch whose PR condition can no longer be observed (the PR merged
|
|
38
|
+
or closed first) is withdrawn by the daemon itself.`;
|
|
39
|
+
|
|
40
|
+
/** The flags each watch subcommand accepts, after the subcommand itself.
|
|
41
|
+
* `add`'s positional note and `withdraw`'s positional id are validated by
|
|
42
|
+
* the branches below, not through this table. */
|
|
43
|
+
const WATCH_FLAGS: Readonly<Record<string, readonly string[]>> = {
|
|
44
|
+
add: ["--blocks", "--note", "--project", "--resolves-when"],
|
|
45
|
+
list: ["--json", "--project"],
|
|
46
|
+
withdraw: ["--project", "--reason"],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Flags that take no value — `list --json` must not consume the token after it. */
|
|
50
|
+
const WATCH_BOOL_FLAGS: Readonly<Record<string, true>> = { "--json": true };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Rejects any token after the subcommand that its surface does not declare,
|
|
54
|
+
* so a typo'd flag can never be silently ignored — the defect where
|
|
55
|
+
* `--condition` created a watch with no condition at all. Both `--flag VALUE`
|
|
56
|
+
* and `--flag=VALUE` are accepted, matching the shared `flag()` parser; a
|
|
57
|
+
* value flag consumes the next token, a `--flag=VALUE` form or a boolean flag
|
|
58
|
+
* never does. The same exit-2 scan `drain` and `stats` run (#462).
|
|
59
|
+
*/
|
|
60
|
+
function assertKnownWatchArgs(ctx: CommandContext, sub: string, from: number): void {
|
|
61
|
+
const allowed = WATCH_FLAGS[sub] ?? [];
|
|
62
|
+
for (let i = from; i < ctx.argv.length; i++) {
|
|
63
|
+
const token = ctx.argv[i];
|
|
64
|
+
if (token === undefined) continue;
|
|
65
|
+
const eq = token.startsWith("--") ? token.indexOf("=") : -1;
|
|
66
|
+
const name = eq < 0 ? token : token.slice(0, eq);
|
|
67
|
+
if (allowed.includes(name)) {
|
|
68
|
+
if (eq < 0 && WATCH_BOOL_FLAGS[name] !== true) i += 1; // consume the flag's value token
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
process.stderr.write(
|
|
72
|
+
`omp-conductor: watch ${sub}: unexpected argument "${token}" (known: ${allowed.join(" ")}) — nothing was recorded\n`,
|
|
73
|
+
);
|
|
74
|
+
process.exit(2);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
20
78
|
export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
21
79
|
const sub = ctx.argv[1];
|
|
80
|
+
if (sub === "--help" || sub === "-h") {
|
|
81
|
+
process.stdout.write(WATCH_USAGE);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
22
84
|
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
23
85
|
const store = openStore(dbPath());
|
|
24
86
|
try {
|
|
25
87
|
if (sub === "add") {
|
|
88
|
+
assertKnownWatchArgs(ctx, "add", 2);
|
|
26
89
|
const note = ctx.flag("note")?.trim();
|
|
27
90
|
if (note === undefined || note.length === 0 || note.startsWith("--")) {
|
|
28
91
|
process.stderr.write("omp-conductor: watch add needs --note with what the next tick should know\n");
|
|
@@ -45,11 +108,17 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
|
45
108
|
at: Date.now(),
|
|
46
109
|
});
|
|
47
110
|
const wake = condition === undefined ? "read by the next tick" : "the daemon wakes the next tick when it is met";
|
|
48
|
-
|
|
111
|
+
// The verb that ends the row names the project it was created in: this
|
|
112
|
+
// command is copied, and on a host with several projects the bare form is
|
|
113
|
+
// ambiguous (#810).
|
|
114
|
+
process.stdout.write(
|
|
115
|
+
`watch ${watch.id} added — ${wake} (no operator answer needed); end with: omp-conductor watch withdraw ${watch.id} --project ${shellQuote(project.name)}\n`,
|
|
116
|
+
);
|
|
49
117
|
return;
|
|
50
118
|
}
|
|
51
119
|
|
|
52
120
|
if (sub === "list" || sub === undefined) {
|
|
121
|
+
assertKnownWatchArgs(ctx, "list", 2);
|
|
53
122
|
const open = store.openDecisions(project.name).filter((d) => d.kind === "watch");
|
|
54
123
|
const now = Date.now();
|
|
55
124
|
const watches = open.map((d) => ({
|
|
@@ -70,14 +139,52 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
|
70
139
|
for (const watch of watches) {
|
|
71
140
|
process.stdout.write(
|
|
72
141
|
`${watch.id} ${watch.ageHours}h blocks:${watch.blocks ?? "-"} ` +
|
|
73
|
-
|
|
142
|
+
// The listing already resolved the project, so the cleanup command
|
|
143
|
+
// it displays must carry it: an operator copying a command from
|
|
144
|
+
// this output should get one that runs as written (#810).
|
|
145
|
+
`condition:${watch.condition ?? "-"} ${watch.note} (end: omp-conductor watch withdraw ${watch.id} --project ${shellQuote(project.name)})\n`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (sub === "withdraw") {
|
|
152
|
+
assertKnownWatchArgs(ctx, "withdraw", 3);
|
|
153
|
+
const id = ctx.argv[2];
|
|
154
|
+
if (id === undefined || id.startsWith("--")) {
|
|
155
|
+
process.stderr.write("omp-conductor: watch withdraw needs the watch id\n");
|
|
156
|
+
process.exit(2);
|
|
157
|
+
}
|
|
158
|
+
const row = store.decision(id);
|
|
159
|
+
if (row !== undefined && row.kind !== "watch") {
|
|
160
|
+
// The same underlying row, but a question answers to a human: the
|
|
161
|
+
// watch verb must not silently close an operator decision.
|
|
162
|
+
process.stderr.write(
|
|
163
|
+
`omp-conductor: watch withdraw targets watches — ${id} is an operator decision; use decision withdraw\n`,
|
|
164
|
+
);
|
|
165
|
+
process.exit(2);
|
|
166
|
+
}
|
|
167
|
+
const reason = ctx.flag("reason")?.trim();
|
|
168
|
+
const ok = store.resolveDecision(
|
|
169
|
+
id,
|
|
170
|
+
"withdrawn",
|
|
171
|
+
reason === undefined || reason.length === 0 ? "withdrawn" : reason,
|
|
172
|
+
Date.now(),
|
|
173
|
+
);
|
|
174
|
+
if (!ok) {
|
|
175
|
+
// A watch that is not open is a different mistake from an id that
|
|
176
|
+
// never existed, and the operator can only act on one of them.
|
|
177
|
+
process.stderr.write(
|
|
178
|
+
`omp-conductor: no open watch ${id} for ${project.name} — it was already withdrawn, or the id is wrong\n`,
|
|
74
179
|
);
|
|
180
|
+
process.exit(1);
|
|
75
181
|
}
|
|
182
|
+
process.stdout.write(`watch ${id} withdrawn\n`);
|
|
76
183
|
return;
|
|
77
184
|
}
|
|
78
185
|
|
|
79
186
|
process.stderr.write(
|
|
80
|
-
`omp-conductor: unknown watch subcommand "${sub}" — expected add or
|
|
187
|
+
`omp-conductor: unknown watch subcommand "${sub}" — expected add, list or withdraw\n`,
|
|
81
188
|
);
|
|
82
189
|
process.exit(2);
|
|
83
190
|
} finally {
|
package/src/commands/worker.ts
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* `worker` — pause/resume/stop one live worker session through the daemon's run-control HTTP endpoints.
|
|
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} so a
|
|
6
|
+
* live systemd-run daemon stays reachable when its pidfile is missing (#811).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { CommandContext } from "./context.ts";
|
|
10
10
|
import { findProject, loadConfig } from "../config.ts";
|
|
11
|
-
import {
|
|
11
|
+
import { requireDaemonControl } from "../lifecycle.ts";
|
|
12
12
|
|
|
13
13
|
export async function workerCommand(ctx: CommandContext): Promise<void> {
|
|
14
14
|
const sub = ctx.argv[1];
|
|
@@ -28,13 +28,12 @@ if (sub === "stop" && (reason === undefined || reason === "" || reason.length >
|
|
|
28
28
|
process.exit(2);
|
|
29
29
|
}
|
|
30
30
|
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
31
|
+
// The pidfile is authoritative when it names a live process; a missing or
|
|
32
|
+
// stale record falls back to the systemd unit, proved through its own
|
|
33
|
+
// /healthz answer, so a live unit-owned daemon is never reported stopped
|
|
34
|
+
// because the runtime directory was wiped (#811). The refusal chain is
|
|
35
|
+
// shared with `extend` through requireDaemonControl.
|
|
36
|
+
const daemon = await requireDaemonControl(project.name);
|
|
38
37
|
const response = await fetch(
|
|
39
38
|
`http://127.0.0.1:${daemon.port}/runs/${issue}/${sub}`,
|
|
40
39
|
{
|