omp-conductor 0.19.7 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/REFERENCE.md +10 -1
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7923
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/failure-class.ts +75 -1
  49. package/src/fleet.ts +290 -164
  50. package/src/groom.ts +461 -0
  51. package/src/http-token.ts +142 -0
  52. package/src/knowledge.ts +229 -0
  53. package/src/mining.ts +316 -0
  54. package/src/orchestrator-tick.ts +428 -1681
  55. package/src/ready-gate.ts +267 -0
  56. package/src/settlement.ts +72 -6
  57. package/src/setup-host.ts +32 -9
  58. package/src/setup-wizard.ts +55 -7
  59. package/src/setup.ts +229 -3
  60. package/src/stats.ts +257 -2
  61. package/src/status-render.ts +158 -7
  62. package/src/store.ts +604 -26
  63. package/src/to-spec.ts +194 -21
  64. package/src/tracker/github.ts +50 -0
  65. package/src/types.ts +416 -15
  66. package/src/verbs/protocol.ts +28 -0
  67. package/src/verbs/server.ts +330 -39
  68. package/src/wake.ts +19 -2
  69. package/src/worker.ts +456 -1
@@ -0,0 +1,751 @@
1
+ /**
2
+ * Read-only projections: everything that answers "what is the fleet doing"
3
+ * without changing it.
4
+ *
5
+ * `StatusSnapshot` is the whole reason this is one module. It is the single
6
+ * shape the CLI, the dashboard, the board and `/healthz` all render, and it is
7
+ * built here from the store rather than assembled per caller — three renderers
8
+ * projecting three nearly-identical shapes is how a status display starts
9
+ * disagreeing with itself. The `format*` helpers beside it are the shared
10
+ * fragments those renderers reach for.
11
+ *
12
+ * The rule that keeps the boundary honest: nothing here writes. A projection
13
+ * that also reconciled would make every caller of a display a caller of a
14
+ * mutation — which is why the historical-infra backfill lives in
15
+ * `settle-pass.ts` and only its results are read from here.
16
+ *
17
+ * `prepareConductor` is the one exception, and a deliberate one: it is setup's
18
+ * "create the store and hold dispatch" barrier, and it lives beside the snapshot
19
+ * because holding and then reading back the held state is one operator gesture.
20
+ */
21
+ import { startOfToday } from "../admission.ts";
22
+ import { availabilityState, type AvailabilityState } from "../availability.ts";
23
+ import { configPath, findProject, loadConfig, resolveCaps, resolveReleaseGrants, resolveReview, stateDir } from "../config.ts";
24
+ import { digestScheduleState, type DigestScheduleState } from "../digest-schedule.ts";
25
+ import { SPEND_SAMPLE_ROWS, SPEND_SAMPLE_RUNS } from "../doctor.ts";
26
+ import type { CodeGraphHealth } from "../graph-health.ts";
27
+ import { isPaused, pauseProvenance, setPaused } from "../pause.ts";
28
+ import { branchName, route } from "../routing.ts";
29
+ import { judgeSpendTelemetry, type SpendTelemetryVerdict } from "../spend-telemetry.ts";
30
+ import { dbPath, openStore, utcDay } from "../store.ts";
31
+ import { makeTracker, type RateLimitStatus } from "../tracker/github.ts";
32
+ import { DEFAULT_REPORT_POLICY, RELEASE_SHAPES, type BaseFreeze, type BaseHealth, type Caps, type DigestBacklog, type DispatchSummary, type HandoffWithdrawal, type InstallSurfaceObservation, type InterruptCategory, type OrchestratorIncident, type ProjectConfig, type ReportRecord, type ReportScope, type ReportingPolicy, type ResolvedGrants, type ReviewAdjudicationRecord, type ReviewCorrectionRound, type ReviewHeadBlocker, type ReviewPolicy, type RunRecord, type Store, type TurnOverride, type VerbLedgerEntry } from "../types.ts";
33
+ import { readUpgradeJournal } from "../upgrade-journal.ts";
34
+ import { upgradeRecoveryStatus, type UpgradeRecoveryStatus } from "../upgrade-verify.ts";
35
+ import type { PlanUsageStatus } from "../usage.ts";
36
+ import { STATUS_LEDGER_SCAN } from "../verbs/ledger.ts";
37
+ import type { WorkerPausePhase } from "../worker.ts";
38
+ import { UNROUTABLE_TEXT, type WorkerControlRegistry } from "./deps.ts";
39
+ import { readDrain } from "./drain.ts";
40
+
41
+ export interface DaemonHealthSnapshot {
42
+ ok: boolean;
43
+ paused: boolean;
44
+ activeRuns: number;
45
+ project: string;
46
+ /** One-shot issue ceilings waiting for the next claim. */
47
+ turnOverrides: TurnOverride[];
48
+ dispatch?: DispatchSummary;
49
+ codeGraph?: CodeGraphHealth;
50
+ /** Live workers in a non-running pause phase; absent/empty = nothing paused.
51
+ * `source`/`pausedAtMs` carry the pause provenance when one was recorded (#997). */
52
+ workers?: { issue: number; runId: string; phase: WorkerPausePhase; source?: string; pausedAtMs?: number }[];
53
+ /**
54
+ * The live orchestrator surface, attested by the running daemon (#832):
55
+ * which mode this project's fleet is in, and — when the daemon hosts the
56
+ * orchestrator session itself — the extension version that session *loaded*
57
+ * and the transcript it resumed. This is the read the upgrade's
58
+ * session-reload verification checks: a restarted daemon whose orchestrator
59
+ * child came up on the installed release answers `loaded` equal to that
60
+ * release; one that never reloaded answers an older version; one that
61
+ * failed to start answers `mode: "failed"`. `external` means the pane owns
62
+ * the session and the daemon cannot attest it from here.
63
+ */
64
+ orchestrator?: {
65
+ mode: "embedded" | "external" | "failed";
66
+ loaded?: string;
67
+ sessionFile?: string;
68
+ alive?: boolean;
69
+ };
70
+ }
71
+
72
+ export interface DaemonHealth {
73
+ ok: boolean;
74
+ /** Resident set of this daemon; workers are in-process omp sessions. */
75
+ rssBytes: number;
76
+ projects: DaemonHealthSnapshot[];
77
+ }
78
+
79
+ export function daemonHealthSnapshot(
80
+ store: Store,
81
+ project: string,
82
+ paused = isPaused(project),
83
+ codeGraph?: CodeGraphHealth,
84
+ workerControls?: WorkerControlRegistry,
85
+ orchestrator?: DaemonHealthSnapshot["orchestrator"],
86
+ ): DaemonHealthSnapshot {
87
+ const dispatch = store.latestDispatch(project);
88
+ return {
89
+ ok: true,
90
+ paused,
91
+ activeRuns: store.activeRuns(project).length,
92
+ turnOverrides: store.listTurnOverrides(project),
93
+ project,
94
+ ...(dispatch === undefined ? {} : { dispatch }),
95
+ ...(codeGraph?.configured === true ? { codeGraph } : {}),
96
+ ...(workerControls === undefined ? {} : { workers: workerControls.snapshot(project) }),
97
+ ...(orchestrator === undefined ? {} : { orchestrator }),
98
+ };
99
+ }
100
+
101
+ export function daemonHealth(
102
+ projects: DaemonHealthSnapshot[],
103
+ rssBytes = process.memoryUsage().rss,
104
+ ): DaemonHealth {
105
+ return { ok: projects.every((project) => project.ok), rssBytes, projects };
106
+ }
107
+
108
+ /**
109
+ * The effective reporting surface, derived from the configured policy — never
110
+ * from the legacy preset name alone: an explicit policy without the
111
+ * `scopePreset` back-annotation must present the same truth from `interruptOn`
112
+ * and the digest cadence (#633).
113
+ */
114
+ export interface ReportingSummary {
115
+ /** The legacy preset this policy came from, when the config back-annotates one. */
116
+ scopePreset?: ReportScope;
117
+ /** Categories allowed to interrupt the operator's phone. */
118
+ interruptOn: InterruptCategory[];
119
+ /** Where non-interrupting outcomes accumulate: the effective digest config. */
120
+ digest: ReportingPolicy["digest"];
121
+ }
122
+
123
+ /**
124
+ * The effective reporting summary. The loader always materialises the policy,
125
+ * so `undefined` means the default (`DEFAULT_REPORT_POLICY`); the preset name
126
+ * is carried only when the policy actually back-annotates one, so a rendered
127
+ * status names the preset without ever letting a stale or absent preset
128
+ * misstate what the policy does (#633).
129
+ */
130
+ export function reportingSummary(policy: ReportingPolicy | undefined): ReportingSummary {
131
+ const effective = policy ?? DEFAULT_REPORT_POLICY;
132
+ return {
133
+ ...(effective.scopePreset === undefined ? {} : { scopePreset: effective.scopePreset }),
134
+ interruptOn: [...effective.interruptOn],
135
+ digest: { ...effective.digest },
136
+ };
137
+ }
138
+
139
+ /**
140
+ * The active drain as structured status exposes it: the creation instant, the
141
+ * absolute deadline, the purpose, and the live-run count the drain is waiting
142
+ * to reach zero. Only present on the snapshot while the record is fresh.
143
+ */
144
+ export interface DrainStatus {
145
+ /** Epoch-ms instant the drain intent was recorded. */
146
+ since: number;
147
+ /** Absolute epoch-ms deadline — admission resumes automatically after it. */
148
+ expiresAt: number;
149
+ /** Purpose recorded at creation. */
150
+ reason?: string;
151
+ /** Runs still in the active set (live workers plus pushed-state PRs) — the
152
+ * count a drain waits to reach zero, matching the `runs-settled` release
153
+ * gate (#776 review #2). */
154
+ remainingRuns: number;
155
+ }
156
+
157
+ export interface StatusSnapshot {
158
+ project: string;
159
+ configPath: string;
160
+ stateDir: string;
161
+ paused: boolean;
162
+ /**
163
+ * Why the fleet is paused, when whatever paused it said. Rendered beside
164
+ * `(PAUSED)` so a pause an operator did not issue names itself instead of
165
+ * reading like a mistake (#220).
166
+ */
167
+ pauseReason?: string;
168
+ /** Unresolved host-wide upgrade recovery, rendered identically for every project. */
169
+ upgradeRecovery?: UpgradeRecoveryStatus;
170
+ /**
171
+ * The project's active self-expiring drain (#484): present only while a
172
+ * fresh, valid drain record exists. Paused and drained are deliberately two
173
+ * fields — a drain is bounded and self-clearing where a pause is not, and
174
+ * the structured surface has to tell them apart without reading the pass
175
+ * history. Human CLI wording over it is a later #484 child.
176
+ */
177
+ drain?: DrainStatus;
178
+ /** Mechanical operator availability at the moment this snapshot was read. */
179
+ availability?: AvailabilityState;
180
+ /** Next digest opportunity under the same predicate that gates submission. */
181
+ digestSchedule?: DigestScheduleState;
182
+ /**
183
+ * The effective reporting policy: what interrupts, where everything else
184
+ * goes, and the legacy preset name when one is back-annotated. On the
185
+ * snapshot so the human `status`, the dashboard API and the CLI all read
186
+ * the same truth about the reporting surface without opening config — a
187
+ * routine outcome that reads as "Telegram is broken" unless the policy says
188
+ * it is digest-only (#633).
189
+ */
190
+ reporting?: ReportingSummary;
191
+ caps: Caps;
192
+ /**
193
+ * The effective per-shape release grants. On the snapshot rather than re-read
194
+ * by each renderer because #122 began with a grant nobody had looked at in
195
+ * weeks: `status` names them so a stale one is visible without opening either
196
+ * the config or the brief.
197
+ */
198
+ releaseGrants: ResolvedGrants;
199
+ /**
200
+ * The effective review policy (#678). On the snapshot for the reason
201
+ * `releaseGrants` is: the orchestrator's Duty 1 has to act on it every
202
+ * tick, and a stale level or ceiling sitting only in a config file nobody
203
+ * opens is exactly the drift this field exists to surface. The loader always
204
+ * materialises it, so this is never absent on a real daemon.
205
+ */
206
+ review: ReviewPolicy;
207
+ /** Occupied issues: live workers plus green PRs awaiting a human merge. Both
208
+ * lifecycle kinds, told apart by {@link StatusSnapshot.leasedRunIds}. */
209
+ activeRuns: RunRecord[];
210
+ /**
211
+ * The subset of {@link StatusSnapshot.activeRuns} holding an **active
212
+ * mutation lease** (#898): a live worker, or a dispatched unsettled review
213
+ * revision. Everything else in `activeRuns` is a worker-free preserved
214
+ * artifact — durable work awaiting review, merge or recovery, with nothing
215
+ * writing to it.
216
+ *
217
+ * Carried as ids rather than a second run list so the two renderings cannot
218
+ * disagree about the rows themselves, and derived from `Store.leasedRuns` —
219
+ * the same query admission's file-lane gate and `conductor_pr_recover` read,
220
+ * so what status calls a lease is exactly what those two enforce (#899,
221
+ * #925). A plain array so the dashboard's JSON round-trip preserves it.
222
+ */
223
+ leasedRunIds?: readonly string[];
224
+ /**
225
+ * The review-ceiling adjudications an operator needs to see (#874): every
226
+ * non-terminal one, plus the terminal one for each active run's PR head — so a
227
+ * cleared or rejected verdict stays visible for exactly as long as the PR it
228
+ * decided is still in flight, and disappears with it rather than accumulating.
229
+ *
230
+ * Keyed by nothing: the records carry their own `prUrl` and `headSha`, which is
231
+ * how the renderer matches them to a run without inventing a second identity
232
+ * for a PR.
233
+ */
234
+ reviewAdjudications?: readonly ReviewAdjudicationRecord[];
235
+ /**
236
+ * runId → live review-revision round, for runs whose revision worker is
237
+ * currently dispatched (#692). Derived from the durable `review_revisions`
238
+ * rows, so the rendered `review-revision N` state rests on the same facts
239
+ * the restart recovery reads — never on a label or a guess. A plain object
240
+ * (not a Map) so the dashboard's JSON round-trip of the snapshot preserves
241
+ * it byte for byte.
242
+ */
243
+ /**
244
+ * The live review round per run: its number AND the instant it was dispatched
245
+ * (#802). The instant is what makes the line honest — a resumed session's
246
+ * `turns` and `startedAt` are cumulative over the whole attempt, so without a
247
+ * phase boundary a fresh revision reads as though it had been running for
248
+ * hours.
249
+ */
250
+ reviewRounds?: Readonly<Record<string, { round: number; dispatchedAt: number }>>;
251
+ /**
252
+ * runId → the durable review evidence blocking a merge of the run's
253
+ * pushed-green PR at its exact recorded head (#888). Derived from the same
254
+ * `review_revisions` rows `conductor_pr_merge` refuses on, so status can
255
+ * never present a green PR as merge-ready while the privileged verb will
256
+ * refuse it. An absent entry means nothing blocks that run's head.
257
+ */
258
+ mergeBlockers?: Readonly<Record<string, ReviewHeadBlocker>>;
259
+ /**
260
+ * runId → every review-correction round recorded for that run, ascending by
261
+ * round, carrying #1045's durable launch provenance (#1048).
262
+ *
263
+ * Separate from {@link StatusSnapshot.reviewRounds} and
264
+ * {@link StatusSnapshot.mergeBlockers} on purpose: those two answer "what
265
+ * state does this run render as" and "what stands between this head and a
266
+ * merge", and both are deliberately narrow (one live round; one blocking
267
+ * round). This is the round *history* an operator reads to answer the #1035
268
+ * question — did conductor resume the diagnosed transcript or open a fresh
269
+ * correction, and under which model — so it includes settled rounds, which
270
+ * neither of the other two can see once they stop blocking anything.
271
+ *
272
+ * Every provenance field is copied off the row and never derived from the
273
+ * current config: rendering today's `workerModel` for a round dispatched
274
+ * under yesterday's is the exact silent fake #1048 names. A pre-#1045 row
275
+ * therefore projects with no provenance at all, which the renderer prints as
276
+ * an explicit unknown.
277
+ */
278
+ reviewCorrections?: Readonly<Record<string, readonly ReviewCorrectionRound[]>>;
279
+ /** Newest attempts holding a preserved WIP tip, or a tree that is still the
280
+ * only copy of work the daemon could not save. */
281
+ salvagedRuns: RunRecord[];
282
+ /** Retained trees whose object store could not be made sound, so the daemon
283
+ * refused to fetch into them and their commits cannot be verified against
284
+ * any remote (#737). Distinguished from ordinary retention on purpose —
285
+ * quarantine is "potentially stranded work", not routine housekeeping. */
286
+ quarantinedRuns: RunRecord[];
287
+ /** One-shot issue ceilings waiting for the next claim. */
288
+ turnOverrides: TurnOverride[];
289
+ /** Reports the operator has not provably received: pending, in-flight with an
290
+ * unknown outcome, or written off. An empty list is the only honest way to
291
+ * say "everything authored this cycle actually went out" (#123). */
292
+ openReports: ReportRecord[];
293
+ /** Recent successful report/notice withdrawals, newest first. */
294
+ handoffWithdrawals?: HandoffWithdrawal[];
295
+ /** Ordinary outcomes and deferred escalations not yet associated with an
296
+ * accepted digest report. */
297
+ digestBacklog: DigestBacklog;
298
+ /**
299
+ * The most recent conductor-verb calls and how the daemon decided them
300
+ * (#126). On `status` rather than only behind `omp-conductor ledger` because
301
+ * a refused merge is news: it means a session tried to do something the
302
+ * config does not let it, and an operator who has to know to go looking is an
303
+ * operator who finds out from the tracker instead.
304
+ */
305
+ /** Current live-head push-workflow verdict per recently merged repository. */
306
+ baseHealth: BaseHealth[];
307
+ /** Per-repo base-red merge freezes, active first (#283). */
308
+ freezes: BaseFreeze[];
309
+ verbLedger: VerbLedgerEntry[];
310
+ /** Runs backed by a worker process — the number capacity compares against. */
311
+ liveWorkers: number;
312
+ runsToday: number;
313
+ spendTodayUsd: number;
314
+ /** The install identities the last dispatch pass recorded (#919). Absent
315
+ * means no pass has looked yet, which the renderer says out loud rather
316
+ * than presenting as agreement. */
317
+ installSurfaces?: InstallSurfaceObservation;
318
+ /** What live runs have reserved out of today's budget but not yet spent
319
+ * (#851). Optional so a caller that built a snapshot before reservations
320
+ * existed renders no figure rather than a fabricated zero. */
321
+ reservedSpendUsd?: number;
322
+ /**
323
+ * Whether the figure above is built on runs that actually reported cost
324
+ * (#970). Absent when the caller did not judge it; the renderer then says
325
+ * nothing rather than implying the telemetry is sound.
326
+ */
327
+ spendTelemetry?: SpendTelemetryVerdict;
328
+ dispatch?: DispatchSummary;
329
+ /**
330
+ * Latest plan-allowance verdict, when the caller read one. Optional because
331
+ * this snapshot is built synchronously off the store while the provider read
332
+ * is I/O: a renderer that did not do that work must say "not read" rather
333
+ * than print a percentage nobody measured (#110).
334
+ */
335
+ planUsage?: PlanUsageStatus;
336
+ /**
337
+ * The GitHub API rate-limit budget, when the caller read one. Optional for
338
+ * the same reason as `planUsage` — the read is I/O and the snapshot is
339
+ * synchronous — and a broken `gh` must cost one status row, not the report
340
+ * (#188).
341
+ */
342
+ github?: RateLimitStatus;
343
+ /**
344
+ * Observed GitHub rate-limit refusals within the last five minutes, and the
345
+ * daemon's tracked per-source `gh` call counts for the UTC day. Unlike
346
+ * `github`, which a caller polls, these are written by the tracker's hooks —
347
+ * the polled budget sat beside what actually happened (#198).
348
+ */
349
+ ghRefusals?: { count: number; latestAt?: number };
350
+ ghCallsToday?: readonly { source: string; calls: number }[];
351
+ /**
352
+ * Label-projection ops still owed to the tracker (#201). Present only while
353
+ * one is pending: GitHub has not yet converged on what the store decided —
354
+ * a refused or deferred label write is exactly the state an operator should
355
+ * see rather than a silent gap.
356
+ */
357
+ labelOps?: { pending: number; oldestAgeMs: number };
358
+ /**
359
+ * The orchestrator-down incident, when the embedded orchestrator is down:
360
+ * mode, since-moment and the tier-1 escalations diverted to issue comments
361
+ * so far. Absent when the orchestrator is healthy (or external), so recovery
362
+ * drops the degrade row from `status` (#288).
363
+ */
364
+ orchestratorDown?: OrchestratorIncident;
365
+ }
366
+
367
+ /** Builds a status reading from an already-open store. Long-lived operator
368
+ * surfaces use this path so a one-second refresh does not repeatedly open and
369
+ * initialise SQLite connections. */
370
+ export function statusSnapshotFromStore(
371
+ p: ProjectConfig,
372
+ caps: Caps,
373
+ store: Store,
374
+ planUsage?: PlanUsageStatus,
375
+ ): StatusSnapshot {
376
+ const now = Date.now();
377
+ const lastDigestKey = store.lastDigestDedupeKey(p.name);
378
+ const lastDigestDay =
379
+ lastDigestKey === undefined ? undefined : lastDigestKey.slice("digest:".length);
380
+ const since = startOfToday();
381
+ const dispatch = store.latestDispatch(p.name);
382
+ const labelOpsPending = store.countPendingLabelOps(p.name);
383
+ const oldestLabelOpAt = store.oldestPendingLabelOpAt(p.name);
384
+ // Read once: `status` renders the degrade row off this while it is down.
385
+ const orchestratorDown = store.orchestratorIncident(p.name);
386
+ // The journal is the authoritative recovery lifecycle. Render unresolved
387
+ // recovery even when an operator/project pause currently wins provenance,
388
+ // or before the verifier has re-created a missing global fence.
389
+ const pause = pauseProvenance(p.name);
390
+ const reason = pause?.reason;
391
+ const upgradeRecovery = upgradeRecoveryStatus(readUpgradeJournal(stateDir()), stateDir());
392
+ // Same cost discipline as the pause read: the drain record is a file read,
393
+ // and the active-drain view is only built when one is actually fresh. The
394
+ // read is observational — it never mutates the record — so a status read
395
+ // cannot consume a malformed marker the next dispatch pass still has to fail
396
+ // closed on; the dispatch consume owns cleanup (#776 review #2).
397
+ const drain = readDrain(p.name);
398
+ // The live run list feeds the worker-capacity row; the drain's
399
+ // remaining-runs count waits on the ACTIVE set (live workers plus
400
+ // pushed-state PRs), the same population the `runs-settled` release gate
401
+ // reads, so the two can never disagree about when a batch is finished
402
+ // (#776 review #2).
403
+ const live = store.liveRuns(p.name);
404
+ const active = store.activeRuns(p.name);
405
+ // Which of those active rows something is actually writing through (#898).
406
+ // The same query admission's file-lane gate and `conductor_pr_recover` read,
407
+ // so status cannot call a row a lease that those two treat as released, or
408
+ // vice versa (#899, #925).
409
+ const leasedRunIds = store.leasedRuns(p.name).map((r) => r.id);
410
+ // Adjudications worth rendering (#874): the open ones always, plus a terminal
411
+ // verdict for a head an active run is still sitting on. Bounded by the active
412
+ // set rather than by a history window, so a fleet with a thousand settled
413
+ // adjudications renders the handful that still describe live work.
414
+ const openAdjudications = store.openReviewAdjudications(p.name);
415
+ const adjudicationIds = new Set(openAdjudications.map((a) => a.id));
416
+ const reviewAdjudications = [...openAdjudications];
417
+ for (const r of active) {
418
+ if (r.prUrl === undefined || r.headSha === undefined) continue;
419
+ const decided = store.reviewAdjudicationForHead(p.name, r.prUrl, r.headSha);
420
+ if (decided !== undefined && !adjudicationIds.has(decided.id)) {
421
+ adjudicationIds.add(decided.id);
422
+ reviewAdjudications.push(decided);
423
+ }
424
+ }
425
+ // The live review-revision rounds, read from the same durable rows the
426
+ // restart recovery uses: a run whose revision is dispatched is read as
427
+ // `review-revision N` while its worker is live (#692).
428
+ const reviewRounds: Record<string, { round: number; dispatchedAt: number }> = {};
429
+ for (const revision of store.unsettledReviewRevisions(p.name)) {
430
+ if (revision.dispatchedAt !== undefined) {
431
+ reviewRounds[revision.runId] = { round: revision.round, dispatchedAt: revision.dispatchedAt };
432
+ }
433
+ }
434
+ // The exact-head merge blockers (#888), read from the same durable rows the
435
+ // merge verb consults: a pushed-green run whose PR stands at a head carrying
436
+ // unresolved review evidence — a round queued there, crashed mid-review
437
+ // there, or settled `failed` there.
438
+ const mergeBlockers: Record<string, ReviewHeadBlocker> = {};
439
+ for (const r of active) {
440
+ if (r.prUrl === undefined || r.headSha === undefined) continue;
441
+ const blocker = store.mergeBlockingReviews(p.name, r.prUrl, r.headSha)[0];
442
+ if (blocker !== undefined) {
443
+ mergeBlockers[r.id] = {
444
+ round: blocker.round,
445
+ state:
446
+ blocker.settledAt !== undefined ? "failed" : blocker.dispatchedAt !== undefined ? "crashed" : "pending",
447
+ };
448
+ }
449
+ }
450
+ // The review-correction round history per active run, with #1045's durable
451
+ // launch provenance (#1048). Read from the rows keyed by the run's own PR and
452
+ // then narrowed to the run: a continuation that inherited the PR must not
453
+ // show its predecessor's rounds as its own. Bounded by the active set for the
454
+ // same reason the adjudication projection is — a fleet with a thousand
455
+ // settled rounds renders the handful that still describe live work.
456
+ //
457
+ // Every provenance value is copied off the row verbatim, and an absent column
458
+ // stays absent: the renderer prints "unknown" for a pre-#1045 round, which is
459
+ // the truth, where filling it from `project.workerModel` would be an
460
+ // invention (#1048's named silent fake).
461
+ const reviewCorrections: Record<string, ReviewCorrectionRound[]> = {};
462
+ for (const r of active) {
463
+ if (r.prUrl === undefined) continue;
464
+ const rounds = store
465
+ .reviewRevisionsForPr(p.name, r.prUrl)
466
+ .filter((revision) => revision.runId === r.id)
467
+ .sort((a, b) => a.round - b.round)
468
+ .map((revision) => ({
469
+ round: revision.round,
470
+ state:
471
+ revision.settledAt !== undefined
472
+ ? ("settled" as const)
473
+ : revision.dispatchedAt !== undefined
474
+ ? ("dispatched" as const)
475
+ : ("pending" as const),
476
+ ...(revision.outcome === undefined ? {} : { outcome: revision.outcome }),
477
+ ...(revision.dispatchedAt === undefined ? {} : { dispatchedAt: revision.dispatchedAt }),
478
+ ...(revision.launchMode === undefined ? {} : { launchMode: revision.launchMode }),
479
+ ...(revision.requestedModel === undefined ? {} : { requestedModel: revision.requestedModel }),
480
+ ...(revision.resolvedModel === undefined ? {} : { resolvedModel: revision.resolvedModel }),
481
+ ...(revision.originSessionRef === undefined ? {} : { originSessionRef: revision.originSessionRef }),
482
+ ...(revision.correctionSessionRef === undefined
483
+ ? {}
484
+ : { correctionSessionRef: revision.correctionSessionRef }),
485
+ }));
486
+ if (rounds.length > 0) reviewCorrections[r.id] = rounds;
487
+ }
488
+ return {
489
+ project: p.name,
490
+ configPath: configPath(),
491
+ stateDir: stateDir(),
492
+ paused: isPaused(p.name),
493
+ ...(reason === undefined ? {} : { pauseReason: reason }),
494
+ ...(upgradeRecovery === undefined ? {} : { upgradeRecovery }),
495
+ ...(drain.kind === "active"
496
+ ? {
497
+ drain: {
498
+ since: Date.parse(drain.drain.createdAt),
499
+ expiresAt: Date.parse(drain.drain.expiresAt),
500
+ ...(drain.drain.reason === undefined ? {} : { reason: drain.drain.reason }),
501
+ remainingRuns: active.length,
502
+ } satisfies DrainStatus,
503
+ }
504
+ : {}),
505
+ availability: availabilityState(p.reporting, now),
506
+ digestSchedule: digestScheduleState(p.reporting ?? DEFAULT_REPORT_POLICY, lastDigestDay, now),
507
+ reporting: reportingSummary(p.reporting ?? DEFAULT_REPORT_POLICY),
508
+ caps,
509
+ releaseGrants: resolveReleaseGrants(p),
510
+ review: resolveReview(p),
511
+ activeRuns: active,
512
+ leasedRunIds,
513
+ reviewAdjudications,
514
+ reviewRounds,
515
+ reviewCorrections,
516
+ salvagedRuns: store.salvagedRuns(p.name),
517
+ quarantinedRuns: store.quarantinedRuns(p.name),
518
+ turnOverrides: store.listTurnOverrides(p.name),
519
+ openReports: store.openReports(p.name),
520
+ handoffWithdrawals: store.handoffWithdrawals(p.name),
521
+ digestBacklog: store.digestBacklog(p.name),
522
+ verbLedger: store.verbLedger(p.name, { limit: STATUS_LEDGER_SCAN }),
523
+ liveWorkers: live.length,
524
+ runsToday: store.runsStartedSince(p.name, since),
525
+ spendTodayUsd: store.spendSince(p.name, since),
526
+ reservedSpendUsd: store.reservedSpendUsd(p.name),
527
+ // Judged from the store on the same read as the figure above (#970), so the
528
+ // row that qualifies the spend number cannot disagree with it, and nothing
529
+ // is probed at render time.
530
+ spendTelemetry: judgeSpendTelemetry(
531
+ store.recentSpendSamples?.(p.name, SPEND_SAMPLE_ROWS) ?? [],
532
+ SPEND_SAMPLE_RUNS,
533
+ {
534
+ // Config and an already-read status only — this snapshot probes nothing
535
+ // at render time, which is the property the figure above depends on
536
+ // (#970). The declaration alone is enough to know a dollar cap cannot
537
+ // fire; the window name rides along when a configured plan cap has
538
+ // already resolved one, and `doctor` (which may probe) reads it live.
539
+ declaredSubscription: (p.requireOauthProviders ?? []).length > 0,
540
+ ...(planUsage?.window === undefined
541
+ ? {}
542
+ : {
543
+ allowanceWindow:
544
+ planUsage.window.label === undefined
545
+ ? planUsage.window.id
546
+ : `${planUsage.window.id} (${planUsage.window.label})`,
547
+ }),
548
+ },
549
+ ),
550
+ // Read, never probed: the recorded row is the whole point (#919).
551
+ ...(() => {
552
+ const observed = store.installSurfaces();
553
+ return observed === undefined ? {} : { installSurfaces: observed };
554
+ })(),
555
+ ...(dispatch === undefined ? {} : { dispatch }),
556
+ ...(planUsage === undefined ? {} : { planUsage }),
557
+ // Written by the tracker's hooks rather than polled, so the renderer does
558
+ // not re-read GitHub to know it is being refused (#198).
559
+ ghRefusals: store.ghRefusalsSince?.(now - 5 * 60_000),
560
+ ghCallsToday: store.ghCallsToday?.(utcDay()),
561
+ ...(labelOpsPending === 0 || oldestLabelOpAt === undefined
562
+ ? {}
563
+ : { labelOps: { pending: labelOpsPending, oldestAgeMs: now - oldestLabelOpAt } }),
564
+ baseHealth: store.baseHealth(p.name),
565
+ freezes: store.freezes(p.name),
566
+ mergeBlockers,
567
+ ...(orchestratorDown === undefined ? {} : { orchestratorDown }),
568
+ };
569
+ }
570
+
571
+ /** Opens and closes its own store handle so one-shot CLI and plugin readers can
572
+ * read status while a daemon in another process is writing (the store runs in
573
+ * WAL mode for exactly this). */
574
+ export function statusSnapshot(project?: string): StatusSnapshot {
575
+ const cfg = loadConfig();
576
+ const p = findProject(cfg, project);
577
+ const store = openStore(dbPath());
578
+ try {
579
+ return statusSnapshotFromStore(p, resolveCaps(p, cfg.defaults), store);
580
+ } finally {
581
+ store.close();
582
+ }
583
+ }
584
+
585
+ export function formatDispatchSummary(summary?: DispatchSummary): string {
586
+ if (summary === undefined) return "last dispatch (none recorded)";
587
+ const lines = [
588
+ `last dispatch ${new Date(summary.completedAt).toISOString()}${summary.degraded ? " DEGRADED" : ""}`,
589
+ ];
590
+ if (summary.paused === true) {
591
+ // A held pass never routed the queue: printing zero candidates here would
592
+ // read as "the queue was empty". Say what the pass was and attribute what
593
+ // it did instead (#497).
594
+ lines.push(` pass held — nothing admitted`);
595
+ } else {
596
+ lines.push(
597
+ ` candidates ${summary.ready} ready / ${summary.claimed ?? 0} in flight / ${summary.routed} spare`,
598
+ ` admitted ${summary.admitted}`,
599
+ );
600
+ // The operator's park label, counted from the same eligibility read the
601
+ // claim gate uses (#507): "0 claimable, 12 parked" and "0 claimable,
602
+ // nothing to do" demand opposite orchestrator responses and must not
603
+ // render alike. Omitted at zero so an empty queue stays the old shape.
604
+ const parked = summary.parked ?? 0;
605
+ if (parked > 0) {
606
+ lines.push(` parked ${parked} — operator-held, never claimed`);
607
+ }
608
+ if (summary.holds.length === 0) {
609
+ lines.push(" held 0");
610
+ } else {
611
+ lines.push(" held");
612
+ for (const hold of summary.holds) {
613
+ const sample =
614
+ hold.issues.length === 0
615
+ ? ""
616
+ : ` (#${hold.issues.join(", #")}${hold.count > hold.issues.length ? ", …" : ""})`;
617
+ lines.push(` ${hold.reason} ${hold.count}${sample}`);
618
+ // A `file-lane` hold groups several issues, each blocked by a different
619
+ // file and holder; the grouped line says how many, this says which.
620
+ //
621
+ // Deduplicated for display only, never in the record: a fleet-wide hold
622
+ // (`credential-class`, #852) gives every held candidate the *same*
623
+ // sentence, and printing one reason five times reads as five problems.
624
+ // The persisted details stay index-aligned with `issues` — a reader that
625
+ // needs "which issue got which detail" still has it.
626
+ const details = [...new Set(hold.details ?? [])];
627
+ if (details.length > 0) {
628
+ lines.push(` ${details.join(" | ")}`);
629
+ }
630
+ }
631
+ }
632
+ }
633
+ if ((summary.settled ?? 0) > 0) lines.push(` settled ${summary.settled}`);
634
+ return lines.join("\n");
635
+ }
636
+
637
+ /**
638
+ * The grant table, named shape by shape.
639
+ *
640
+ * One renderer for both status surfaces (this one and the fleet view), because
641
+ * #122 began with a POLICY grant that no longer matched anyone's intent and went
642
+ * unnoticed: a stale grant has to be visible from `status` alone, and two
643
+ * renderers would eventually show it in only one of them.
644
+ */
645
+ export function formatReleaseGrants(grants: ResolvedGrants): string[] {
646
+ const granted = RELEASE_SHAPES.filter((shape) => grants[shape] === "orchestrator");
647
+ return [
648
+ granted.length === 0
649
+ ? "release no shape granted — every release/deploy tool call is blocked"
650
+ : `release granted to the orchestrator: ${granted.join(", ")}`,
651
+ ...RELEASE_SHAPES.map((shape) => ` ${shape.padEnd(19)}${grants[shape]}`),
652
+ ];
653
+ }
654
+
655
+ export function formatBaseHealth(rows: readonly BaseHealth[]): string[] {
656
+ return rows.map((row) => {
657
+ const head = row.headSha.slice(0, 8);
658
+ if (row.verdict === "green") {
659
+ return `base ${row.repo}/${row.branch} green (${row.runsCount} run(s)) at ${head}`;
660
+ }
661
+ if (row.verdict === "red") {
662
+ return `base ${row.repo}/${row.branch} RED — ${row.detail ?? `workflow failed at ${head}`}`;
663
+ }
664
+ if (row.verdict === "pending") {
665
+ return `base ${row.repo}/${row.branch} pending (${row.runsCount} run(s)) at ${head}`;
666
+ }
667
+ return (
668
+ `base ${row.repo}/${row.branch} unknown — ` +
669
+ (row.detail ?? `no push-triggered workflow run for ${head}`)
670
+ );
671
+ });
672
+ }
673
+
674
+ /**
675
+ * The active base-red freezes as status lines — merges refused until the base
676
+ * is green again or the operator overrides. Active freezes only: a cleared
677
+ * freeze is history the ledger and digest already told an operator about.
678
+ */
679
+ export function formatFreezes(freezes: readonly BaseFreeze[]): string[] {
680
+ const active = freezes.filter((f) => f.clearedAt === undefined);
681
+ if (active.length === 0) return [];
682
+ return [
683
+ "frozen repos (merges refused until base green)",
684
+ ...active.map(
685
+ (f) =>
686
+ ` ${f.repo} base red at ${f.culpritSha.slice(0, 8)}` +
687
+ (f.detail === undefined ? "" : ` ${f.detail}`) +
688
+ ` — override: omp-conductor unfreeze ${f.repo}`,
689
+ ),
690
+ ];
691
+ }
692
+
693
+
694
+ export interface QueuePreview {
695
+ project: string;
696
+ configPath: string;
697
+ queueDescription: string;
698
+ paused: boolean;
699
+ ready: { number: number; title: string; repo: string; branch: string }[];
700
+ unroutable: { number: number; title: string; reason: string; labels: string[] }[];
701
+ }
702
+
703
+ /**
704
+ * Exactly what the next tick would pick up, computed without touching a single
705
+ * label, run row or worktree. This is what makes `omp-conductor setup` honest: the
706
+ * dry run is the same routing code the loop uses, not a description of it.
707
+ */
708
+ export async function previewProject(
709
+ p: ProjectConfig,
710
+ path: string = configPath(),
711
+ ): Promise<QueuePreview> {
712
+ const { routed, unroutable } = route(await makeTracker(p).listReady(), p);
713
+ const states = Object.values(p.stateLabels).join(", ");
714
+ return {
715
+ project: p.name,
716
+ configPath: path,
717
+ queueDescription:
718
+ `open issues in ${p.tracker.repo} labelled "${p.queueLabel}", ` +
719
+ `minus anything already labelled ${states}, ` +
720
+ `routed by one "${p.routing.labelPrefix}<repo>" label`,
721
+ paused: isPaused(p.name),
722
+ ready: routed.map((r) => ({
723
+ number: r.issue.number,
724
+ title: r.issue.title,
725
+ repo: r.repo.name,
726
+ branch: branchName(r.issue),
727
+ })),
728
+ unroutable: unroutable.map((u) => ({
729
+ number: u.issue.number,
730
+ title: u.issue.title,
731
+ reason: UNROUTABLE_TEXT[u.reason],
732
+ labels: u.labels,
733
+ })),
734
+ };
735
+ }
736
+
737
+ export async function previewQueue(project?: string): Promise<QueuePreview> {
738
+ const cfg = loadConfig();
739
+ return previewProject(findProject(cfg, project));
740
+ }
741
+
742
+ /**
743
+ * Creates the state store and holds dispatch while setup verifies the host.
744
+ *
745
+ * Setup calls this immediately after consent. Every later setup error therefore
746
+ * leaves the fleet paused instead of exposing a partially written runtime.
747
+ */
748
+ export function prepareConductor(project?: string): void {
749
+ openStore(dbPath()).close();
750
+ setPaused(true, { source: "setup" }, project);
751
+ }