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.
Files changed (65) hide show
  1. package/README.md +35 -1
  2. package/REFERENCE.md +61 -11
  3. package/agents/to-spec.md +94 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +35 -1
  6. package/src/admission.ts +204 -75
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +268 -7
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +62 -21
  11. package/src/briefs/to-spec.md +88 -0
  12. package/src/briefs/worker.md +2 -1
  13. package/src/cli.ts +124 -1
  14. package/src/command-help.ts +11 -0
  15. package/src/command-manifest.ts +38 -5
  16. package/src/commands/arm.ts +1 -1
  17. package/src/commands/context.ts +1 -0
  18. package/src/commands/drain.ts +176 -0
  19. package/src/commands/extend.ts +6 -10
  20. package/src/commands/intake.ts +4 -19
  21. package/src/commands/status.ts +5 -1
  22. package/src/commands/watch.ts +51 -16
  23. package/src/commands/worker.ts +9 -10
  24. package/src/config-schema.ts +43 -6
  25. package/src/config.ts +65 -9
  26. package/src/daemon.ts +879 -41
  27. package/src/dashboard/app.js +4 -1
  28. package/src/dashboard/server.ts +5 -2
  29. package/src/decisions.ts +243 -17
  30. package/src/diff-flags.ts +75 -1
  31. package/src/doctor.ts +60 -82
  32. package/src/escalate.ts +31 -14
  33. package/src/failure-class.ts +28 -2
  34. package/src/fleet.ts +239 -240
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +35 -1
  37. package/src/graph.ts +66 -1
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +242 -2
  40. package/src/lifecycle.ts +122 -1
  41. package/src/omp-settings.ts +19 -0
  42. package/src/omp.ts +183 -21
  43. package/src/orchestrator-tick.ts +1591 -32
  44. package/src/orchestrator.ts +12 -0
  45. package/src/privileged.ts +1 -4
  46. package/src/release-policy.ts +503 -9
  47. package/src/session-host.ts +65 -6
  48. package/src/settlement.ts +69 -17
  49. package/src/setup-host.ts +1225 -9
  50. package/src/setup-install.ts +28 -0
  51. package/src/setup-wizard.ts +154 -3
  52. package/src/setup.ts +83 -17
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +216 -12
  55. package/src/store.ts +443 -42
  56. package/src/to-spec.ts +408 -0
  57. package/src/tracker/github.ts +104 -14
  58. package/src/types.ts +405 -19
  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 +765 -56
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +12 -2
  65. package/src/worktree.ts +29 -12
@@ -18,13 +18,13 @@
18
18
  */
19
19
 
20
20
  import { formatZonedMinute } from "./availability.ts";
21
- import type { DaemonStop } from "./types.ts";
21
+ import type { DaemonStop, GroomingRecord } from "./types.ts";
22
22
  import { settlementFlagSummary } from "./diff-flags.ts";
23
23
  import type { CodeGraphHealth } from "./graph-health.ts";
24
24
  import { formatDigestBacklog, formatOpenReports } from "./reports.ts";
25
25
  import { formatDownDuration, formatOrchestratorDown } from "./orchestrator-down.ts";
26
26
  import { planUsageLine } from "./usage.ts";
27
- import { SYSTEMD_UNIT, type UnitOwnership } from "./lifecycle.ts";
27
+ import { HEALTH_TIMEOUT_MS, SYSTEMD_UNIT, type UnitOwnership } from "./lifecycle.ts";
28
28
  import type { WorkerPausePhase } from "./worker.ts";
29
29
  import { formatRss, rssBytesFromHealthz } from "./host.ts";
30
30
  import {
@@ -90,6 +90,14 @@ export function formatCodeGraphHealth(graph: CodeGraphHealth, now = Date.now()):
90
90
  `code graph ${graph.status} ${indexed}/${graph.repos.length} repos indexed`,
91
91
  ` indexer ${graph.prerequisites.indexer}`,
92
92
  ` MCP mount ${graph.prerequisites.mcpMount}`,
93
+ // The runtime half of the mount finding (#726): what dispatched sessions
94
+ // actually held in their registries. Rendered only when a run recorded
95
+ // it — "observed" is never claimed without evidence.
96
+ ...(graph.session.recorded === 0
97
+ ? []
98
+ : [
99
+ ` session observed in ${graph.session.recorded} run(s): present ${graph.session.present} / absent ${graph.session.absent}`,
100
+ ]),
93
101
  ` timer ${graph.timer.enabled} / ${graph.timer.active}`,
94
102
  ` refresh ${refresh}`,
95
103
  ...graph.reasons.map((reason) => ` - ${reason}`),
@@ -108,6 +116,7 @@ export type DaemonProjectHealth =
108
116
  | { kind: "stopped" }
109
117
  | { kind: "ok" }
110
118
  | { kind: "unreachable" }
119
+ | { kind: "unresponsive" }
111
120
  | { kind: "other-project"; serves?: string };
112
121
 
113
122
  /**
@@ -122,6 +131,14 @@ export type FleetDaemonProbe = {
122
131
  unit?: UnitOwnership;
123
132
  };
124
133
 
134
+ /**
135
+ * #685: the healthz timeout is a probe outcome, never a death verdict — the
136
+ * same discipline the indexer probe uses when it reports `probe timed out`
137
+ * instead of declaring the indexer missing.
138
+ */
139
+ const UNRESPONSIVE_HEALTHZ =
140
+ `unresponsive (healthz timed out after ${HEALTH_TIMEOUT_MS / 1000}s)`;
141
+
125
142
  function formatDaemonHealthz(probe: FleetDaemonProbe | undefined): string {
126
143
  if (probe === undefined) return "unprobed";
127
144
  switch (probe.project.kind) {
@@ -129,6 +146,8 @@ function formatDaemonHealthz(probe: FleetDaemonProbe | undefined): string {
129
146
  return "stopped";
130
147
  case "ok":
131
148
  return "ok";
149
+ case "unresponsive":
150
+ return UNRESPONSIVE_HEALTHZ;
132
151
  case "unreachable":
133
152
  return "unreachable — the process is up but not serving";
134
153
  case "other-project":
@@ -187,6 +206,141 @@ function formatLastStop(lastStop: DaemonStop | undefined): string[] {
187
206
  }
188
207
 
189
208
 
209
+ // durable to-spec grooming lifecycle (#809)
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /**
213
+ * The durable mechanical-hold reasons admission's reconcile writes as blocked
214
+ * rows (#735) — the lane/dependency holds that clear by themselves. Mirrors
215
+ * `BLOCKED_GROOMING_HOLDS` in `store.ts`; the status reads the stored reason
216
+ * strings so both sides of the contract stay on the store vocabulary.
217
+ */
218
+ const MECHANICAL_GROOMING_REASONS: Record<string, true> = {
219
+ "file-lane": true,
220
+ "depends-on": true,
221
+ };
222
+
223
+ /**
224
+ * The to-spec refusal classes persisted as blocked rows (#772) — a result
225
+ * that failed validation is a mechanical block, never a verdict. Mirrors the
226
+ * failure kinds of `ToSpecFailure` in `to-spec.ts`.
227
+ */
228
+ const REFUSED_GROOMING_REASONS: Record<string, true> = {
229
+ malformed: true,
230
+ "missing-source": true,
231
+ "stale-source": true,
232
+ };
233
+
234
+ /** The durable in-flight launch marker (#777) — a batch is running right now.
235
+ * Mirrors `TO_SPEC_IN_FLIGHT_REASON` in `orchestrator-tick.ts`. */
236
+ const GROOMING_IN_FLIGHT_REASON = "in-flight";
237
+
238
+ /**
239
+ * One project's durable grooming state as status lines, or nothing when there
240
+ * is nothing to report (#809).
241
+ *
242
+ * Renders the state already owned by the `grooming_verdicts` table plus the
243
+ * dispatch snapshot's parked count — each category below is derived from the
244
+ * project-scoped durable rows the caller passes, never from parsing evidence
245
+ * strings and never from a second cache:
246
+ *
247
+ * - `awaiting to-spec` — the routable queue candidates the last dispatch
248
+ * pass saw that no durable row covers: `routed − rows`, clamped at zero so
249
+ * stale rows (candidates since removed from the queue) cannot push it
250
+ * negative. `routed` is the same denominator the tick's grooming trigger
251
+ * reads (`summary.routed >= groomBelow`), and the fleet's queue-digest
252
+ * arithmetic already treats durable rows as current-queue facts
253
+ * (`claimable = routed − knownBlocked`), so this is the operator-facing
254
+ * half of the same subtraction. Parent/epic exclusions and backlog
255
+ * candidates that never entered the queue are tracker-side and not
256
+ * store-knowable; the row lines below carry the durable results either
257
+ * way.
258
+ * - `promotable` / `considered` / `blocked` — completed to-spec results
259
+ * (`blocked` rows whose reason is a groomer verdict or a product-judgement
260
+ * label, e.g. `needs-product-decision`).
261
+ * - `mechanically blocked` — admission's lane/dependency holds.
262
+ * - `refused` — to-spec results that failed validation (`malformed`,
263
+ * `missing-source`, `stale-source`), told apart from the holds so an
264
+ * operator sees whether the runway cannot move or a result cannot be
265
+ * trusted.
266
+ * - `in-flight` — a launched batch is running (#777).
267
+ * - `operator-parked` — the dispatch snapshot's parked count (#507).
268
+ *
269
+ * Rendering is read-only: nothing here reconciles, promotes, retries, or
270
+ * mutates grooming records. Empty categories are omitted — zero-value rows
271
+ * would be noise, and an absent block means a fleet with no grooming state.
272
+ */
273
+ export interface GroomingStatusInput {
274
+ /** Project-scoped durable rows (`Store.groomingVerdicts`), issue-ascending. */
275
+ records: readonly GroomingRecord[];
276
+ /** Routable queue candidates from the last dispatch pass
277
+ * (`DispatchSummary.routed`). */
278
+ routed: number;
279
+ /** Operator-parked candidates from the last dispatch pass
280
+ * (`DispatchSummary.parked`). */
281
+ parked: number;
282
+ }
283
+
284
+ /** How many issue identifiers one category line may carry — enough to act on,
285
+ * never a queue dump. Mirrors the dispatch hold sampling. */
286
+ const GROOMING_STATUS_SAMPLE = 5;
287
+
288
+ /** One category line: label, count, and a bounded sample of issue identifiers
289
+ * with their durable reasons where the reason differs from the line label
290
+ * (e.g. `#13 already-done` under `considered`, `#19 malformed` under
291
+ * `refused`). Absent at zero. */
292
+ function groomingStatusLine(
293
+ label: string,
294
+ rows: readonly GroomingRecord[],
295
+ ): string | undefined {
296
+ if (rows.length === 0) return undefined;
297
+ const sample = rows
298
+ .slice(0, GROOMING_STATUS_SAMPLE)
299
+ .map((row) => (row.reason === label ? `#${row.issue}` : `#${row.issue} ${row.reason}`));
300
+ const more = rows.length > GROOMING_STATUS_SAMPLE ? ", …" : "";
301
+ return ` ${label.padEnd(21)}${rows.length} ${sample.join(", ")}${more}`;
302
+ }
303
+
304
+ export function formatGroomingStatus(input: GroomingStatusInput): string | undefined {
305
+ const { records, routed, parked } = input;
306
+ const promotable = records.filter((row) => row.verdict === "promotable");
307
+ const considered = records.filter((row) => row.verdict === "considered");
308
+ const blockedResults = records.filter(
309
+ (row) =>
310
+ row.verdict === "blocked" &&
311
+ MECHANICAL_GROOMING_REASONS[row.reason] !== true &&
312
+ REFUSED_GROOMING_REASONS[row.reason] !== true &&
313
+ row.reason !== GROOMING_IN_FLIGHT_REASON,
314
+ );
315
+ const mechanical = records.filter(
316
+ (row) => row.verdict === "blocked" && MECHANICAL_GROOMING_REASONS[row.reason] === true,
317
+ );
318
+ const refused = records.filter(
319
+ (row) => row.verdict === "blocked" && REFUSED_GROOMING_REASONS[row.reason] === true,
320
+ );
321
+ const inFlight = records.filter(
322
+ (row) => row.verdict === "blocked" && row.reason === GROOMING_IN_FLIGHT_REASON,
323
+ );
324
+ const awaiting = Math.max(0, routed - records.length);
325
+ const lines: string[] = [];
326
+ if (awaiting > 0) {
327
+ lines.push(` ${"awaiting to-spec".padEnd(21)}${awaiting} (of ${routed} routable)`);
328
+ }
329
+ for (const line of [
330
+ groomingStatusLine("promotable", promotable),
331
+ groomingStatusLine("considered", considered),
332
+ groomingStatusLine("blocked", blockedResults),
333
+ groomingStatusLine("mechanically blocked", mechanical),
334
+ groomingStatusLine("refused", refused),
335
+ groomingStatusLine("in-flight", inFlight),
336
+ ]) {
337
+ if (line !== undefined) lines.push(line);
338
+ }
339
+ if (parked > 0) lines.push(` ${"operator-parked".padEnd(21)}${parked}`);
340
+ if (lines.length === 0) return undefined;
341
+ return ["grooming (to-spec)", ...lines].join("\n");
342
+ }
343
+
190
344
  export function formatFleetStatus(
191
345
  s: StatusSnapshot,
192
346
  layers: FleetLayers,
@@ -201,6 +355,7 @@ export function formatFleetStatus(
201
355
  intake: string | undefined = undefined,
202
356
  lastStop: DaemonStop | undefined = undefined,
203
357
  siblings: { project: string; live: number }[] = [],
358
+ grooming: string | undefined = undefined,
204
359
  ): string {
205
360
  const tickLine =
206
361
  layers.ticksDetail === undefined
@@ -239,8 +394,13 @@ export function formatFleetStatus(
239
394
  // not this project's daemon facts (#379).
240
395
  const rss =
241
396
  daemon?.project.kind === "ok" ? rssBytesFromHealthz(daemon.body) : undefined;
397
+ // Three states, never two (#685): `not running` is reserved for a pid
398
+ // that is actually gone, and a timed-out `/healthz` is its own state —
399
+ // a healthy fleet that answers slowly must not read as stopped.
400
+ const state =
401
+ daemon?.project.kind === "unresponsive" ? UNRESPONSIVE_HEALTHZ : "running";
242
402
  daemonBlock = [
243
- "daemon",
403
+ `daemon ${state}`,
244
404
  ` pid ${layers.daemon.pid}`,
245
405
  ` port ${layers.daemon.port ?? "?"}`,
246
406
  ...(rss === undefined ? [] : [` rss ${formatRss(rss)}`]),
@@ -282,6 +442,7 @@ export function formatFleetStatus(
282
442
  ...(decisions === undefined ? [] : [decisions]),
283
443
  ...(failureClasses === undefined ? [] : [failureClasses]),
284
444
  ...(intake === undefined ? [] : [intake]),
445
+ ...(grooming === undefined ? [] : [grooming]),
285
446
  ...(graphBlock === undefined ? [] : [graphBlock]),
286
447
  daemonBlock,
287
448
  ...formatLastStop(lastStop),
@@ -319,6 +480,31 @@ function formatDigestScheduleStatus(s: StatusSnapshot): string[] {
319
480
  ];
320
481
  }
321
482
 
483
+ /** The reporting row: which categories interrupt the operator's phone, and
484
+ * where everything else goes. It reads the snapshot's effective summary —
485
+ * the legacy preset name is a label, never the source of truth: an explicit
486
+ * policy without `scopePreset` renders truthfully from `interruptOn` and the
487
+ * digest cadence, so a routine outcome under `decisions` explains itself as
488
+ * digest-only instead of reading like a broken Telegram (#633). */
489
+ function formatReportingStatus(s: StatusSnapshot): string[] {
490
+ const r = s.reporting;
491
+ if (r === undefined) return [];
492
+ const interrupt =
493
+ r.interruptOn.length === 0 ? "nothing interrupts" : `${r.interruptOn.join(", ")} interrupt`;
494
+ const label = r.scopePreset === undefined ? "reporting —" : `reporting ${r.scopePreset} —`;
495
+ let consequence: string;
496
+ if (r.interruptOn.includes("material")) {
497
+ consequence = "no outcome waits for the digest";
498
+ } else if (r.digest.cadence === "daily") {
499
+ consequence = "material outcomes wait for the daily digest";
500
+ } else if (r.digest.cadence === "none") {
501
+ consequence = "material outcomes are held; no digest is configured";
502
+ } else {
503
+ consequence = "material outcomes wait for the per-tick digest";
504
+ }
505
+ return [`${label} ${interrupt}; ${consequence}`];
506
+ }
507
+
322
508
  /**
323
509
  * The shared-daemon visibility row (#545). When the daemon also serves sibling
324
510
  * projects, tell the reader how many runs each has live, so they can tell "my
@@ -342,6 +528,14 @@ function formatProjectBody(
342
528
  const lines = [
343
529
  `project ${s.project}${s.paused ? " (PAUSED)" : ""}`,
344
530
  ...(s.pauseReason === undefined ? [] : [`paused ${s.pauseReason}`]),
531
+ // Structured-exposure half of the drain surface (#484): a plain row for
532
+ // the renderer, so the active drain is visible in `status` before the
533
+ // human-wording work of the later #484 child lands.
534
+ ...(s.drain === undefined
535
+ ? []
536
+ : [
537
+ `drain active — expires ${new Date(s.drain.expiresAt).toISOString()}, ${s.drain.remainingRuns} run(s) remaining`,
538
+ ]),
345
539
  `config ${s.configPath}`,
346
540
  `state ${s.stateDir}`,
347
541
  ...(siblingLine === undefined ? [] : [siblingLine]),
@@ -350,6 +544,7 @@ function formatProjectBody(
350
544
  ...(s.orchestratorDown === undefined
351
545
  ? []
352
546
  : formatOrchestratorDown(s.orchestratorDown, now)),
547
+ ...formatReportingStatus(s),
353
548
  ...formatAvailabilityStatus(s),
354
549
  ...formatDigestScheduleStatus(s),
355
550
  "",
@@ -433,20 +628,29 @@ function formatProjectBody(
433
628
  : round !== undefined
434
629
  ? `review-revision ${round}`
435
630
  : r.state;
436
- // Turn rate (#730): a stalled run and a fast one used to render
437
- // identically as a bare turn count. The rate is the lifetime average
438
- // over the elapsed shown — the snapshot carries no checkpoint from
439
- // which a recent-interval rate could be derived — so it is labelled
440
- // `avg`, and `elapsed` is elapsed since claim, never "remaining wall
441
- // clock": a paused worker banks its budget, which the snapshot cannot
442
- // know. A paused run gets neither number, because its elapsed includes
443
- // banked pause time and its state already says `paused`.
631
+ // Turn rate and cap projection (#730/#767): a stalled run and a fast one
632
+ // used to render identically as a bare turn count. The rate is the
633
+ // lifetime average over the elapsed shown — the snapshot carries no
634
+ // checkpoint from which a recent-interval rate could be derived — so it
635
+ // is labelled `avg`, and `elapsed` is elapsed since claim, never
636
+ // "remaining wall clock": a paused worker banks its budget, which the
637
+ // snapshot cannot know. #767 adds the projection #518 asked for: the
638
+ // same claim-elapsed rate held forward to the wall-clock budget
639
+ // (`caps.workerWallClockMs`, rendered in the caps block) as the turn
640
+ // count the run would reach at that cap — trajectory math over
641
+ // elapsed-so-far, so reading it against the run's `maxTurns` tells
642
+ // which cap fires first. A paused run gets neither number, because its
643
+ // elapsed includes banked pause time and its state already says
644
+ // `paused`.
444
645
  const elapsedMs = Math.max(0, now - r.startedAt);
445
646
  const progress = paused
446
647
  ? ""
447
648
  : ` ${formatDownDuration(elapsedMs)} elapsed ${(
448
649
  (r.turns * 60_000) / Math.max(elapsedMs, 1_000)
449
- ).toFixed(1)} turns/min avg`;
650
+ ).toFixed(1)} turns/min avg` +
651
+ ` projects ${Math.round(
652
+ (r.turns * s.caps.workerWallClockMs) / Math.max(elapsedMs, 1_000),
653
+ )} turns at cap`;
450
654
  lines.push(
451
655
  ` #${r.issue} ${r.repo} ${state} attempt ${r.attempt} ` +
452
656
  `${r.turns}/${r.maxTurns} turns${progress} ${r.spendUsd.toFixed(2)} ${r.branch}` +