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.
Files changed (65) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +71 -17
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +53 -1
  6. package/src/admission.ts +308 -76
  7. package/src/ask.ts +307 -10
  8. package/src/backups.ts +2 -2
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +43 -14
  11. package/src/briefs/to-spec.md +84 -0
  12. package/src/briefs/worker.md +37 -19
  13. package/src/cli.ts +2 -0
  14. package/src/command-help.ts +19 -1
  15. package/src/command-manifest.ts +27 -2
  16. package/src/commands/context.ts +1 -0
  17. package/src/commands/drain.ts +176 -0
  18. package/src/commands/extend.ts +6 -10
  19. package/src/commands/status.ts +5 -1
  20. package/src/commands/watch.ts +110 -3
  21. package/src/commands/worker.ts +9 -10
  22. package/src/config-schema.ts +57 -0
  23. package/src/config.ts +102 -2
  24. package/src/daemon.ts +1220 -1517
  25. package/src/dashboard/app.js +4 -1
  26. package/src/dashboard/server.ts +5 -2
  27. package/src/decisions.ts +279 -16
  28. package/src/depends-on.ts +261 -1
  29. package/src/diff-flags.ts +425 -1
  30. package/src/digest-schedule.ts +37 -0
  31. package/src/doctor.ts +52 -0
  32. package/src/escalate.ts +9 -3
  33. package/src/failure-class.ts +43 -4
  34. package/src/fleet.ts +166 -24
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +55 -8
  37. package/src/graph.ts +379 -69
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +567 -2
  40. package/src/lifecycle.ts +158 -6
  41. package/src/omp.ts +269 -20
  42. package/src/orchestrator-tick.ts +1489 -26
  43. package/src/orchestrator.ts +12 -0
  44. package/src/privileged.ts +1 -4
  45. package/src/release-policy.ts +503 -9
  46. package/src/routing.ts +11 -3
  47. package/src/session-host.ts +115 -5
  48. package/src/settlement.ts +1780 -0
  49. package/src/setup-host.ts +1205 -6
  50. package/src/setup-install.ts +119 -30
  51. package/src/setup-wizard.ts +88 -2
  52. package/src/setup.ts +119 -13
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +100 -11
  55. package/src/store.ts +519 -45
  56. package/src/to-spec.ts +387 -0
  57. package/src/tracker/github.ts +150 -14
  58. package/src/types.ts +470 -16
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +770 -40
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +239 -9
  65. package/src/worktree.ts +142 -18
@@ -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 with exactly these seven lines, evidence only — no narration:
214
-
215
- ```
216
- issue: {{TRACKER_REPO}}#{{ISSUE_NUMBER}}
217
- pr: <url or "none">
218
- head: <40-character head SHA or "none">
219
- state: pushed-green | blocked | failed
220
- gates: <exact commands run and their results>
221
- changed: <the settlement derives this from the PR diff — omit the line>
222
- next: <nothing | the specific decision needed>
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
- The `changed:` line is not yours to write from memory: the settlement replaces
226
- it with the actual file list from the PR's diff. Omit it, or write it wrongly —
227
- the settled report carries the diff's list either way. The narrative in your
228
- report (what you changed and why, above these lines) is the part only you can
229
- write, and it is the part a reviewer reads.
230
-
231
- Never report success you have not observed. "Should pass CI" is not a state, and
232
- `pushed-green` means you watched the checks go green — not that you expect them
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),
@@ -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,
@@ -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 list orchestrator-only conditions and carry notes",
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)",
@@ -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
+ }
@@ -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
- * only the case wrapper, the injected `ctx` lookups and the imports
6
- * changed from the original bodies.
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 { livingDaemon } from "../lifecycle.ts";
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 = livingDaemon();
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
  {
@@ -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;
@@ -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
- process.stdout.write(`watch ${watch.id} added — ${wake} (no operator answer needed)\n`);
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
- `condition:${watch.condition ?? "-"} ${watch.note}\n`,
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 list\n`,
187
+ `omp-conductor: unknown watch subcommand "${sub}" — expected add, list or withdraw\n`,
81
188
  );
82
189
  process.exit(2);
83
190
  } finally {
@@ -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
- * only the case wrapper, the injected `ctx` lookups and the imports
6
- * changed from the original bodies.
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 { livingDaemon } from "../lifecycle.ts";
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
- const daemon = livingDaemon();
32
- if (daemon === undefined) throw new Error("daemon is not running");
33
- if (daemon.project !== undefined && daemon.project !== project.name) {
34
- throw new Error(
35
- `daemon serves project "${daemon.project}", not requested project "${project.name}"`,
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
  {