omp-conductor 0.19.7 → 0.20.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 (71) 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/admission.ts +58 -14
  6. package/src/arm-challenge.ts +255 -85
  7. package/src/ask.ts +130 -615
  8. package/src/board.ts +7 -1
  9. package/src/brief-upgrade.ts +24 -0
  10. package/src/briefs/console.md +258 -0
  11. package/src/briefs/correction.md +203 -0
  12. package/src/briefs/orchestrator.md +167 -97
  13. package/src/briefs/policy.md +19 -16
  14. package/src/briefs/to-spec.md +76 -9
  15. package/src/briefs/worker.md +50 -16
  16. package/src/cli.ts +4 -0
  17. package/src/command-manifest.ts +54 -8
  18. package/src/commands/arm.ts +115 -49
  19. package/src/commands/console.ts +70 -0
  20. package/src/commands/context.ts +2 -0
  21. package/src/commands/epic.ts +132 -0
  22. package/src/commands/extend.ts +9 -1
  23. package/src/commands/intake.ts +44 -14
  24. package/src/commands/stats.ts +19 -4
  25. package/src/commands/worker.ts +9 -1
  26. package/src/config-schema.ts +13 -0
  27. package/src/config.ts +27 -0
  28. package/src/daemon/ack.ts +159 -0
  29. package/src/daemon/admission-pass.ts +135 -0
  30. package/src/daemon/brief.ts +461 -0
  31. package/src/daemon/deps.ts +539 -0
  32. package/src/daemon/dispatch.ts +1779 -0
  33. package/src/daemon/drain.ts +185 -0
  34. package/src/daemon/groom-pass.ts +422 -0
  35. package/src/daemon/http.ts +417 -0
  36. package/src/daemon/integrity.ts +108 -0
  37. package/src/daemon/panes.ts +180 -0
  38. package/src/daemon/review.ts +1888 -0
  39. package/src/daemon/runtime.ts +788 -0
  40. package/src/daemon/settle-pass.ts +606 -0
  41. package/src/daemon/supervision.ts +438 -0
  42. package/src/daemon/tick.ts +968 -0
  43. package/src/daemon/views.ts +751 -0
  44. package/src/daemon.ts +105 -7923
  45. package/src/dashboard/app.js +58 -0
  46. package/src/dashboard/controls.ts +22 -3
  47. package/src/dashboard/server.ts +4 -0
  48. package/src/diff-flags.ts +135 -9
  49. package/src/doctor.ts +2 -2
  50. package/src/failure-class.ts +257 -2
  51. package/src/fleet.ts +295 -176
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +689 -1670
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +107 -11
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +169 -14
  64. package/src/store.ts +618 -28
  65. package/src/to-spec.ts +426 -44
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +434 -18
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +330 -39
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +570 -1
package/src/stats.ts CHANGED
@@ -17,10 +17,32 @@
17
17
  * the doctor `spend-telemetry` finding): a run whose `spendUsd` reads 0.00
18
18
  * means harness telemetry was absent, not that the run was free. Such runs are
19
19
  * counted separately as **unmetered** and never averaged into a per-issue cost.
20
+ *
21
+ * ## Attribution (Phase 4)
22
+ *
23
+ * Two additions, both about *where the money went* rather than what it bought:
24
+ *
25
+ * - **Per model.** Worker runs grouped by `resolvedModel` — the model the
26
+ * harness actually ran and therefore the one that was billed. Never grouped
27
+ * by the requested model: a requested model is not evidence of a resolved
28
+ * one, and a row that observed none groups under {@link UNRESOLVED_MODEL}
29
+ * rather than being guessed into a bucket.
30
+ * - **Daemon-owned sessions.** To-spec grooming and review adjudication run as
31
+ * the daemon's own sessions, not as worker runs, so their cost was logged and
32
+ * then lost. They arrive here as `sessionSpendSince` rows and are summed *per
33
+ * role*, deliberately outside the issue-journey aggregation: a role row in
34
+ * `runs` would corrupt the merged/settled counts, which is why the store
35
+ * keeps them in their own table.
36
+ *
37
+ * The orchestrator's own tick turns are absent from both, and that absence is
38
+ * reported rather than hidden ({@link StatsReport.unmeteredRoles}): the tick
39
+ * extension observes no usage events at all, so there is no number — and a
40
+ * `$0.00` for it would read as free.
20
41
  */
21
42
 
22
43
  import { utcDay } from "./store.ts";
23
- import type { RunRecord } from "./types.ts";
44
+ import { SESSION_SPEND_ROLES } from "./types.ts";
45
+ import type { RunRecord, SessionSpendRole, SessionSpendRow } from "./types.ts";
24
46
 
25
47
  /** The bounded window a report answers over. Day keys are UTC (`YYYY-MM-DD`). */
26
48
  export interface StatsWindow {
@@ -57,6 +79,51 @@ export interface RepoStats {
57
79
  failureClasses: Record<string, number>;
58
80
  }
59
81
 
82
+ /**
83
+ * Worker runs attributed to one billed model (Phase 4).
84
+ *
85
+ * Counted over every run in the outcomes this report counts — the failures
86
+ * included, because money spent losing is precisely what a per-model
87
+ * comparison exists to expose: a model that burns a budget and never merges
88
+ * must not read as cheap.
89
+ *
90
+ * So this is deliberately a *wider* figure than {@link RepoStats.spendUsd},
91
+ * which sums merged chains only. The two answer different questions ("what did
92
+ * delivered work cost" versus "what did this model cost us"), and they are not
93
+ * meant to add up to each other.
94
+ */
95
+ export interface ModelStats {
96
+ /** `resolvedModel`, or {@link UNRESOLVED_MODEL} when the run observed none. */
97
+ model: string;
98
+ /** Runs in the window on this model, across every issue. */
99
+ runs: number;
100
+ /** Runs whose state is `merged` — the attempt that actually delivered. */
101
+ merges: number;
102
+ /** Metered (spendUsd > 0) spend on this model, rounded to cents. */
103
+ spendUsd: number;
104
+ /** `spendUsd / merges`; `null` with no merge to divide by — never a zero,
105
+ * and never a division by zero. */
106
+ spendPerMerge: number | null;
107
+ /** Runs on this model whose spend read $0.00: telemetry absent, not free. */
108
+ unmeteredRuns: number;
109
+ }
110
+
111
+ /** One daemon-owned session role's accounting (Phase 4). */
112
+ export interface SessionRoleStats {
113
+ role: SessionSpendRole;
114
+ sessions: number;
115
+ turns: number;
116
+ /** Summed cost of the sessions that reported one, rounded to cents. */
117
+ spendUsd: number;
118
+ /**
119
+ * Sessions whose provider reported no cost at all. Distinct from a reported
120
+ * `0` by contract (`SessionSpendRow.spendUsd` is optional for exactly this
121
+ * reason), so this count is the honest "cost unknown" — never folded into
122
+ * `spendUsd` as a zero.
123
+ */
124
+ unmeteredSessions: number;
125
+ }
126
+
60
127
  /** The stable report shape `--json` prints; the human renderer reads it too. */
61
128
  export interface StatsReport {
62
129
  project: string;
@@ -72,6 +139,27 @@ export interface StatsReport {
72
139
  total: RepoStats;
73
140
  /** Per-repo aggregates of the same shape, busiest (by merges) first. */
74
141
  repos: RepoStats[];
142
+ /** Worker runs by billed model, busiest (by runs) first (Phase 4). */
143
+ models: ModelStats[];
144
+ /** Daemon-owned session spend by role, in `SESSION_SPEND_ROLES` order. */
145
+ sessions: SessionRoleStats[];
146
+ /**
147
+ * Metered spend on to-spec grooming sessions — the plan's `groomSpendUsd`,
148
+ * named at the top level because "what did keeping the queue full cost" is a
149
+ * question asked on its own. Same number as the `groom` row in
150
+ * {@link StatsReport.sessions}; `groomUnmeteredSessions` is the part of it
151
+ * whose cost is unknown rather than zero.
152
+ */
153
+ groomSpendUsd: number;
154
+ groomUnmeteredSessions: number;
155
+ /**
156
+ * Roles whose spend cannot be observed at all, so no row exists to sum and
157
+ * no total here includes them. Present in the shape so a consumer adding
158
+ * `total.spendUsd` to `sessions` knows what is still missing instead of
159
+ * reading the sum as the fleet's whole bill. Today: `orchestrator` — the
160
+ * tick extension observes no usage events.
161
+ */
162
+ unmeteredRoles: string[];
75
163
  }
76
164
 
77
165
  /** Terminal non-merged run states — everything the failure histogram counts. */
@@ -86,6 +174,16 @@ const FAILED_STATES: ReadonlySet<RunRecord["state"]> = new Set([
86
174
  /** Runs with no classified failure fall into this explicit bucket. */
87
175
  export const UNCLASSIFIED = "unclassified";
88
176
 
177
+ /**
178
+ * Runs that never observed a resolved model group here (Phase 4).
179
+ *
180
+ * Parenthesised so it cannot collide with a real model id, and explicit rather
181
+ * than omitted: a run predating the column, or one whose harness never reported
182
+ * the model it ran, still cost money, and dropping it would make the per-model
183
+ * spend quietly fail to add up to the total.
184
+ */
185
+ export const UNRESOLVED_MODEL = "(unresolved)";
186
+
89
187
  /** The timestamp a run is placed on the window timeline. Mirrors the store's
90
188
  * `COALESCE(endedAt, startedAt)` convention everywhere else in this package. */
91
189
  const WINDOW_KEY = (run: RunRecord): number => run.endedAt ?? run.startedAt;
@@ -216,11 +314,19 @@ export function parseStatsWindow(raw: string, now: number): StatsWindow | undefi
216
314
  return undefined;
217
315
  }
218
316
 
317
+ /**
318
+ * `sessions` is `store.sessionSpendSince(project, window.sinceEpochMs)`. It is
319
+ * optional because a caller with nothing to say about daemon-owned sessions —
320
+ * and every pre-Phase-4 caller — must still get a report; an empty list means
321
+ * "no grooming or adjudication in this window", which the renderer states as
322
+ * that rather than as a $0.00 bill.
323
+ */
219
324
  export function computeStats(args: {
220
325
  project: string;
221
326
  window: StatsWindow;
222
327
  ghCalls: number;
223
328
  runs: readonly RunRecord[];
329
+ sessions?: readonly SessionSpendRow[];
224
330
  }): StatsReport {
225
331
  const chains = new Map<number, RunRecord[]>();
226
332
  for (const run of args.runs) {
@@ -234,6 +340,7 @@ export function computeStats(args: {
234
340
  const failureRuns: { repo: string; cls: string }[] = [];
235
341
  let unmeteredRuns = 0;
236
342
  const unmeteredByRepo = new Map<string, number>();
343
+ const byModel = new Map<string, ModelBucket>();
237
344
 
238
345
  for (const chain of chains.values()) {
239
346
  // One issue merges once; if a weird history ever carried two merged rows,
@@ -262,6 +369,14 @@ export function computeStats(args: {
262
369
  unmeteredRuns += 1;
263
370
  unmeteredByRepo.set(run.repo, (unmeteredByRepo.get(run.repo) ?? 0) + 1);
264
371
  }
372
+ // Attributed over exactly the runs this report already counts, so the
373
+ // per-model breakdown adds up to the totals printed beside it. Grouped on
374
+ // the *resolved* model: that is the one that was billed.
375
+ const model = modelBucket(byModel, run.resolvedModel ?? UNRESOLVED_MODEL);
376
+ model.runs += 1;
377
+ if (run.state === "merged") model.merges += 1;
378
+ if (run.spendUsd > 0) model.spend += run.spendUsd;
379
+ else model.unmetered += 1;
265
380
  if (merged === undefined && FAILED_STATES.has(run.state) && WINDOW_KEY(run) >= args.window.sinceEpochMs) {
266
381
  failureRuns.push({ repo: run.repo, cls: run.failureClass ?? UNCLASSIFIED });
267
382
  }
@@ -297,6 +412,21 @@ export function computeStats(args: {
297
412
  )
298
413
  .sort((a, b) => b.merged - a.merged || b.settled - a.settled || a.repo.localeCompare(b.repo));
299
414
 
415
+ const models = [...byModel.entries()]
416
+ .map(([model, b]) => ({
417
+ model,
418
+ runs: b.runs,
419
+ merges: b.merges,
420
+ spendUsd: cents(b.spend),
421
+ // No merge to divide by is "not measured", never a zero and never a
422
+ // division by zero — the same rule the per-issue cost follows.
423
+ spendPerMerge: b.merges === 0 ? null : cents(b.spend / b.merges),
424
+ unmeteredRuns: b.unmetered,
425
+ }))
426
+ .sort((a, b) => b.runs - a.runs || b.spendUsd - a.spendUsd || a.model.localeCompare(b.model));
427
+ const sessions = summarizeSessions(args.sessions ?? []);
428
+ const groom = sessions.find((s) => s.role === "groom");
429
+
300
430
  return {
301
431
  project: args.project,
302
432
  window: args.window,
@@ -304,9 +434,63 @@ export function computeStats(args: {
304
434
  empty: outcomes.length === 0,
305
435
  total: summarize({ repo: "(all)", outcomes, failureRuns, unmeteredRuns }),
306
436
  repos,
437
+ models,
438
+ sessions,
439
+ groomSpendUsd: groom?.spendUsd ?? 0,
440
+ groomUnmeteredSessions: groom?.unmeteredSessions ?? 0,
441
+ unmeteredRoles: [...UNMETERED_ROLES],
307
442
  };
308
443
  }
309
444
 
445
+ /** One model's accumulating totals while the chains are walked. */
446
+ interface ModelBucket {
447
+ runs: number;
448
+ merges: number;
449
+ spend: number;
450
+ unmetered: number;
451
+ }
452
+
453
+ function modelBucket(map: Map<string, ModelBucket>, model: string): ModelBucket {
454
+ let found = map.get(model);
455
+ if (found === undefined) {
456
+ found = { runs: 0, merges: 0, spend: 0, unmetered: 0 };
457
+ map.set(model, found);
458
+ }
459
+ return found;
460
+ }
461
+
462
+ /**
463
+ * Roles the fleet runs whose spend nothing can observe, so no row exists to
464
+ * sum. The orchestrator session is the case: its tick extension reads content
465
+ * text out of `message_start` and no usage event at all, so there is no number
466
+ * to record. Named in the report rather than left out, because a total that
467
+ * silently omits a whole role is the misreading this surface exists to prevent.
468
+ */
469
+ const UNMETERED_ROLES = ["orchestrator"] as const;
470
+
471
+ /**
472
+ * Daemon-owned session rows reduced per role, in `SESSION_SPEND_ROLES` order so
473
+ * the shape is stable whether or not a role ran in the window.
474
+ *
475
+ * A session whose `spendUsd` is absent is counted in `unmeteredSessions` and
476
+ * contributes nothing to `spendUsd`: the store's contract distinguishes "the
477
+ * provider reported no cost" from "the provider reported zero", and collapsing
478
+ * them here would throw away the only place that distinction exists.
479
+ */
480
+ function summarizeSessions(rows: readonly SessionSpendRow[]): SessionRoleStats[] {
481
+ return SESSION_SPEND_ROLES.map((role) => {
482
+ const mine = rows.filter((r) => r.role === role);
483
+ const metered = mine.filter((r) => r.spendUsd !== undefined);
484
+ return {
485
+ role,
486
+ sessions: mine.length,
487
+ turns: mine.reduce((sum, r) => sum + r.turns, 0),
488
+ spendUsd: cents(metered.reduce((sum, r) => sum + (r.spendUsd ?? 0), 0)),
489
+ unmeteredSessions: mine.length - metered.length,
490
+ };
491
+ });
492
+ }
493
+
310
494
  /** `N` and its unit, for a count line that must not print "0" as a measure. */
311
495
  function countLine(n: number, noun: string, plural: string): string {
312
496
  return `${n} ${n === 1 ? noun : plural}`;
@@ -350,17 +534,88 @@ function renderRepo(repo: RepoStats, indent: string): string {
350
534
  return lines.join("\n");
351
535
  }
352
536
 
537
+ /**
538
+ * The attribution block (Phase 4): where the money went, as opposed to what it
539
+ * bought. Rendered from the same report `--json` carries, and rendered even in
540
+ * the empty case — a dry queue is exactly when daemon-owned grooming runs, so
541
+ * hiding this behind "nothing settled" would hide the spend that had no
542
+ * outcome, which is the one an operator most wants to see.
543
+ */
544
+ function renderAttribution(report: StatsReport, indent: string): string[] {
545
+ const lines: string[] = [];
546
+ if (report.models.length > 0) {
547
+ lines.push(`${indent}per model:`);
548
+ for (const m of report.models) {
549
+ // Nothing metered at all is stated as unknown, not as "$0.00 metered":
550
+ // the dollar figure is true but reads as free, which is the exact
551
+ // misreading this whole surface exists to prevent.
552
+ const cost =
553
+ m.spendUsd === 0 && m.unmeteredRuns === m.runs
554
+ ? `cost unknown — no run on this model metered`
555
+ : m.spendPerMerge === null
556
+ ? `$${m.spendUsd.toFixed(2)} metered (no merge on this model to divide by)`
557
+ : `$${m.spendUsd.toFixed(2)} metered ($${m.spendPerMerge.toFixed(2)}/merge)`;
558
+ const unmetered =
559
+ m.unmeteredRuns === 0 || m.unmeteredRuns === m.runs
560
+ ? ""
561
+ : ` · ${countLine(m.unmeteredRuns, "run", "runs")} unmetered`;
562
+ lines.push(
563
+ `${indent} ${m.model} ${countLine(m.runs, "run", "runs")}, ` +
564
+ `${countLine(m.merges, "merge", "merges")} ${cost}${unmetered}`,
565
+ );
566
+ }
567
+ }
568
+ const ran = report.sessions.filter((s) => s.sessions > 0);
569
+ // Silence rather than a row of zeros: "no grooming sessions in this window"
570
+ // and "grooming cost nothing" are different claims, and only the first is true.
571
+ if (ran.length > 0) {
572
+ lines.push(`${indent}daemon sessions:`);
573
+ for (const s of ran) {
574
+ // Same rule as the per-model line: an unmetered session's cost is
575
+ // unknown, so it is a count, never a zero folded into the dollar figure.
576
+ if (s.spendUsd === 0 && s.unmeteredSessions === s.sessions) {
577
+ lines.push(
578
+ `${indent} ${s.role} ${countLine(s.sessions, "session", "sessions")}, ` +
579
+ `${countLine(s.turns, "turn", "turns")} cost unknown — no session metered`,
580
+ );
581
+ continue;
582
+ }
583
+ const unknown =
584
+ s.unmeteredSessions === 0
585
+ ? ""
586
+ : ` · ${countLine(s.unmeteredSessions, "session", "sessions")} unmetered (cost unknown, not $0.00)`;
587
+ lines.push(
588
+ `${indent} ${s.role} ${countLine(s.sessions, "session", "sessions")}, ` +
589
+ `${countLine(s.turns, "turn", "turns")} $${s.spendUsd.toFixed(2)} metered${unknown}`,
590
+ );
591
+ }
592
+ }
593
+ if (report.unmeteredRoles.length > 0) {
594
+ lines.push(
595
+ `${indent} ${report.unmeteredRoles.join(", ")} unmetered — no usage events are observable for ` +
596
+ `${report.unmeteredRoles.length === 1 ? "this session" : "these sessions"}, so the cost is unknown and no figure above includes it`,
597
+ );
598
+ }
599
+ return lines;
600
+ }
601
+
353
602
  /** Render the human form of the same {@link StatsReport} `--json` prints. */
354
603
  export function renderStatsHuman(report: StatsReport): string {
355
604
  const w = report.window;
356
605
  const header = `${report.project} — outcomes ${w.sinceDay} → ${w.untilDay}`;
357
606
  if (report.empty) {
358
- return `${header}\n nothing settled in the window — no runs recorded, so there are no measurements to report yet (a fresh store or an idle fleet).\n`;
607
+ return [
608
+ header,
609
+ " nothing settled in the window — no runs recorded, so there are no measurements to report yet (a fresh store or an idle fleet).",
610
+ ...renderAttribution(report, " "),
611
+ "",
612
+ ].join("\n");
359
613
  }
360
614
  const lines = [header, renderRepo(report.total, " "), ` gh api ${report.ghCalls} tracked ${report.ghCalls === 1 ? "call" : "calls"} in the window`];
361
615
  if (report.repos.length > 0) {
362
616
  lines.push(" per repo:");
363
617
  for (const repo of report.repos) lines.push(renderRepo(repo, " "));
364
618
  }
619
+ lines.push(...renderAttribution(report, " "));
365
620
  return `${lines.join("\n")}\n`;
366
621
  }
@@ -19,7 +19,13 @@
19
19
 
20
20
  import { formatZonedMinute } from "./availability.ts";
21
21
  import { spendTelemetryDetail, type SpendTelemetryVerdict } from "./spend-telemetry.ts";
22
- import type { DaemonStop, GroomingRecord, InstallSurfaceObservation, RunRecord } from "./types.ts";
22
+ import type {
23
+ DaemonStop,
24
+ GroomingRecord,
25
+ InstallSurfaceObservation,
26
+ ReviewCorrectionRound,
27
+ RunRecord,
28
+ } from "./types.ts";
23
29
  import { settlementFlagSummary } from "./diff-flags.ts";
24
30
  import type { CodeGraphHealth } from "./graph-health.ts";
25
31
  import { formatDigestBacklog, formatOpenReports } from "./reports.ts";
@@ -28,6 +34,12 @@ import { planUsageLine } from "./usage.ts";
28
34
  import { HEALTH_TIMEOUT_MS, SYSTEMD_UNIT, type UnitOwnership } from "./lifecycle.ts";
29
35
  import type { WorkerPauseView } from "./fleet.ts";
30
36
  import { formatRss, rssBytesFromHealthz } from "./host.ts";
37
+ // One constant, two readers: the durable in-flight to-spec launch marker
38
+ // (#777) is written by the daemon's grooming launcher and read here, so the
39
+ // reason string is imported rather than hand-copied — the copy that used to
40
+ // live in this file was a second source of truth for a row shape nobody
41
+ // audits.
42
+ import { TO_SPEC_IN_FLIGHT_REASON } from "./groom.ts";
31
43
  import {
32
44
  formatBaseHealth,
33
45
  formatDispatchSummary,
@@ -242,20 +254,18 @@ const MECHANICAL_GROOMING_REASONS: Record<string, true> = {
242
254
  };
243
255
 
244
256
  /**
245
- * The to-spec refusal classes persisted as blocked rows (#772) — a result
246
- * that failed validation is a mechanical block, never a verdict. Mirrors the
247
- * failure kinds of `ToSpecFailure` in `to-spec.ts`.
257
+ * The to-spec refusal classes persisted as blocked rows (#772, #1064) — a
258
+ * result that failed validation, or a pass that produced no answer at all, is
259
+ * a mechanical block, never a verdict. Mirrors the failure kinds of
260
+ * `ToSpecFailure` in `to-spec.ts`.
248
261
  */
249
262
  const REFUSED_GROOMING_REASONS: Record<string, true> = {
250
263
  malformed: true,
264
+ "no-answer": true,
251
265
  "missing-source": true,
252
266
  "stale-source": true,
253
267
  };
254
268
 
255
- /** The durable in-flight launch marker (#777) — a batch is running right now.
256
- * Mirrors `TO_SPEC_IN_FLIGHT_REASON` in `orchestrator-tick.ts`. */
257
- const GROOMING_IN_FLIGHT_REASON = "in-flight";
258
-
259
269
  /**
260
270
  * One project's durable grooming state as status lines, or nothing when there
261
271
  * is nothing to report (#809).
@@ -281,10 +291,12 @@ const GROOMING_IN_FLIGHT_REASON = "in-flight";
281
291
  * (`blocked` rows whose reason is a groomer verdict or a product-judgement
282
292
  * label, e.g. `needs-product-decision`).
283
293
  * - `mechanically blocked` — admission's lane/dependency holds.
284
- * - `refused` — to-spec results that failed validation (`malformed`,
285
- * `missing-source`, `stale-source`), told apart from the holds so an
286
- * operator sees whether the runway cannot move or a result cannot be
287
- * trusted.
294
+ * - `refused` — to-spec passes that produced no usable verdict (`malformed`,
295
+ * `no-answer`, `missing-source`, `stale-source`), told apart from the
296
+ * holds so an operator sees whether the runway cannot move or a result
297
+ * cannot be trusted. Each row's line names its own class (`#19 malformed`,
298
+ * `#22 no-answer`), so a run of identical refusals is visible as a pattern
299
+ * rather than a wall of one word.
288
300
  * - `in-flight` — a launched batch is running (#777).
289
301
  * - `operator-parked` — the dispatch snapshot's parked count (#507).
290
302
  *
@@ -332,7 +344,7 @@ export function formatGroomingStatus(input: GroomingStatusInput): string | undef
332
344
  row.verdict === "blocked" &&
333
345
  MECHANICAL_GROOMING_REASONS[row.reason] !== true &&
334
346
  REFUSED_GROOMING_REASONS[row.reason] !== true &&
335
- row.reason !== GROOMING_IN_FLIGHT_REASON,
347
+ row.reason !== TO_SPEC_IN_FLIGHT_REASON,
336
348
  );
337
349
  const mechanical = records.filter(
338
350
  (row) => row.verdict === "blocked" && MECHANICAL_GROOMING_REASONS[row.reason] === true,
@@ -341,7 +353,7 @@ export function formatGroomingStatus(input: GroomingStatusInput): string | undef
341
353
  (row) => row.verdict === "blocked" && REFUSED_GROOMING_REASONS[row.reason] === true,
342
354
  );
343
355
  const inFlight = records.filter(
344
- (row) => row.verdict === "blocked" && row.reason === GROOMING_IN_FLIGHT_REASON,
356
+ (row) => row.verdict === "blocked" && row.reason === TO_SPEC_IN_FLIGHT_REASON,
345
357
  );
346
358
  const awaiting = Math.max(0, routed - records.length);
347
359
  const lines: string[] = [];
@@ -662,6 +674,143 @@ function formatSiblingLive(siblings: { project: string; live: number }[]): strin
662
674
  return `shared daemon also serves ${siblings.length} other project(s): ${counts}`;
663
675
  }
664
676
 
677
+ // review-correction provenance (#1048)
678
+ // ---------------------------------------------------------------------------
679
+
680
+ /**
681
+ * How many rounds of one run's correction chain print. A review ceiling is low
682
+ * single digits, so this bounds a pathological row rather than routinely
683
+ * truncating — and the last line printed says how many rounds it stands for,
684
+ * because a silently shortened chain is how a repeating pattern hides.
685
+ */
686
+ const CORRECTION_CHAIN_LINES = 3;
687
+
688
+ /**
689
+ * A session lineage as a status row carries it. A ref is a session file path,
690
+ * and the leading directories are the same for every session in a fleet: the
691
+ * identifying half is the run and the file, so the line prints those and marks
692
+ * what it dropped with `…/` — the same honesty `firstLine` uses above, and the
693
+ * full ref stays in the durable row this line is projected from. Two lineages
694
+ * plus two models on one row is already at the width `omp-conductor status`
695
+ * can be read at; a full 60-character path twice is not.
696
+ *
697
+ * An absent ref reads `unrecorded`: rows written before #1045 know nothing
698
+ * about their own launch, and saying so is the only honest projection of one.
699
+ */
700
+ function correctionRef(ref: string | undefined): string {
701
+ const value = ref?.trim() ?? "";
702
+ if (value === "") return "unrecorded";
703
+ const segments = value.split("/").filter((segment) => segment !== "");
704
+ const tail = segments.slice(-2).join("/");
705
+ return firstLine(segments.length > 2 ? `…/${tail}` : value, 60);
706
+ }
707
+
708
+ /**
709
+ * The models of one correction round: what the daemon asked for and what the
710
+ * harness actually resolved. Those two differing is the whole finding — in
711
+ * #1035 the fleet had already switched worker models while every correction
712
+ * round replayed the exhausted session under the old one — so the arrow form is
713
+ * kept whenever they differ and collapsed only when they agree.
714
+ *
715
+ * A resumed round is worded differently on purpose. `resolvedModel` is written
716
+ * only by `Store.recordReviewCorrectionSession`, which refuses a round decided
717
+ * `resume-original` (a resumed round *is* its origin session), so a resume
718
+ * durably has a request and no resolution. Rendering that through the fresh
719
+ * wording — `requested X → ran unrecorded` — would read as a launch that lost
720
+ * its model, when what actually happened is that the daemon asked the harness
721
+ * to continue an existing transcript on X and nothing re-recorded the
722
+ * resolution.
723
+ *
724
+ * Every branch reads the round's OWN recorded values. The project config is
725
+ * deliberately not a parameter: rendering it would make a round dispatched
726
+ * under a since-replaced model report the replacement, which is exactly the
727
+ * silent fake #1048 names.
728
+ */
729
+ function correctionModels(round: ReviewCorrectionRound): string {
730
+ const { requestedModel: requested, resolvedModel: resolved } = round;
731
+ if (round.launchMode === "resume-original") {
732
+ if (resolved !== undefined)
733
+ return requested === undefined || requested === resolved
734
+ ? `ran ${resolved}`
735
+ : `ran ${resolved} (asked ${requested})`;
736
+ if (requested !== undefined)
737
+ return round.state === "pending"
738
+ ? `to continue on ${requested}, not launched yet`
739
+ : `asked to continue on ${requested}`;
740
+ return "model unrecorded";
741
+ }
742
+ if (requested !== undefined && resolved !== undefined)
743
+ return requested === resolved ? `model ${resolved} as requested` : `requested ${requested} → ran ${resolved}`;
744
+ if (requested !== undefined)
745
+ return round.state === "pending"
746
+ ? `requested ${requested}, not launched yet`
747
+ : `requested ${requested} → ran unrecorded`;
748
+ // A resolution with no request: the harness named what it ran, the decision
749
+ // row that asked for it predates #1045. Still not a guess in either direction.
750
+ if (resolved !== undefined) return `ran ${resolved} (request unrecorded)`;
751
+ return "model unrecorded";
752
+ }
753
+
754
+ /**
755
+ * One run's review-correction chain as status lines (#1048), newest round
756
+ * first, six-space indented like every other run annotation.
757
+ *
758
+ * Before this, a correction round rendered as `review-revision N` and a
759
+ * dispatch age: an operator could not tell whether conductor had resumed the
760
+ * diagnosed implementation transcript or launched a fresh correction, nor under
761
+ * which model — which is why #1035 read as idle while an exhausted session
762
+ * consumed every round the fleet's new model was never asked to run.
763
+ *
764
+ * The per-line `current`/`prior` marker is where the runtime/history split
765
+ * lives: `current` is the newest round that has not settled, the one whose
766
+ * elapsed time and cumulative turns the run line above reports; every other
767
+ * line is chain history and carries no runtime at all. That is deliberately a
768
+ * property of each line rather than a header, because a header explaining how
769
+ * to read the following lines is a line nobody reads.
770
+ *
771
+ * Exported so the projection can be asserted directly on hand-built rounds:
772
+ * the interesting cases (a differing requested/resolved pair, a legacy row with
773
+ * no provenance) are properties of one row, not of a whole fleet.
774
+ */
775
+ export function formatReviewCorrections(rounds: readonly ReviewCorrectionRound[]): string[] {
776
+ if (rounds.length === 0) return [];
777
+ const newestFirst = [...rounds].sort((a, b) => b.round - a.round);
778
+ const shown = newestFirst.slice(0, CORRECTION_CHAIN_LINES);
779
+ const hidden = newestFirst.length - shown.length;
780
+ return shown.map((round, index) => {
781
+ const current = index === 0 && round.state !== "settled";
782
+ // The round's lifecycle as one phrase. `queued` covers both a round nobody
783
+ // has dispatched yet and one a restart returned to the queue — durably they
784
+ // are the same row, and status must not invent a distinction the store does
785
+ // not keep.
786
+ const state =
787
+ round.state === "settled"
788
+ ? `settled ${round.outcome ?? "outcome unrecorded"}`
789
+ : round.state === "pending"
790
+ ? "queued"
791
+ : "dispatched";
792
+ const provenance =
793
+ round.launchMode === undefined
794
+ ? // A row written before #1045 recorded no launch decision. Status says
795
+ // so and stops: the alternative — reading today's configuration back
796
+ // as though it had been this round's — is a fabricated provenance,
797
+ // and an operator auditing #1035 would have believed it.
798
+ "provenance unknown (pre-#1045 round)"
799
+ : round.launchMode === "resume-original"
800
+ ? // A resume continues one lineage, so there is one session to name:
801
+ // the implementation transcript the review diagnosed.
802
+ `resume-original resumed session ${correctionRef(round.originSessionRef)} ${correctionModels(round)}`
803
+ : // A fresh correction has two: the transcript that was reviewed, and
804
+ // the new session doing the correcting. Naming only one of them is
805
+ // how "fresh" became indistinguishable from "resumed".
806
+ `fresh-correction new session ${correctionRef(round.correctionSessionRef)} (diagnosed ${correctionRef(
807
+ round.originSessionRef,
808
+ )}) ${correctionModels(round)}`;
809
+ const tail = index === shown.length - 1 && hidden > 0 ? ` (+${hidden} earlier round(s))` : "";
810
+ return ` correction round ${round.round} ${current ? "current" : "prior"} (${state}) ${provenance}${tail}`;
811
+ });
812
+ }
813
+
665
814
 
666
815
  function formatProjectBody(
667
816
  s: StatusSnapshot,
@@ -867,6 +1016,12 @@ function formatProjectBody(
867
1016
  : "failed at this head"
868
1017
  } — unresolved findings; push a corrected head, or record a conductor_pr_review_clear for this exact head, before merge`,
869
1018
  );
1019
+ // The correction chain (#1048), rendered from the same annotation helper as
1020
+ // the merge blocker above so it reaches BOTH surfaces that show a run: the
1021
+ // live lease and the preserved artifact. A round whose worker has finished
1022
+ // leaves the lease list, and that is precisely when an operator asks what
1023
+ // the last correction actually did.
1024
+ out.push(...formatReviewCorrections(s.reviewCorrections?.[r.id] ?? []));
870
1025
  return out;
871
1026
  };
872
1027
  if (s.activeRuns.length === 0) {