omp-conductor 0.18.2 → 0.19.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 (60) hide show
  1. package/README.md +105 -40
  2. package/REFERENCE.md +865 -30
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +26 -0
  5. package/src/admission.ts +212 -26
  6. package/src/ask.ts +288 -1
  7. package/src/briefs/orchestrator.md +6 -5
  8. package/src/cli.ts +5 -1
  9. package/src/command-help.ts +9 -1
  10. package/src/command-manifest.ts +36 -3
  11. package/src/commands/arm.ts +5 -1
  12. package/src/commands/context.ts +2 -0
  13. package/src/commands/message.ts +26 -2
  14. package/src/commands/reconcile-units.ts +104 -0
  15. package/src/commands/release-composition.ts +232 -0
  16. package/src/commands/resume.ts +2 -27
  17. package/src/commands/setup.ts +101 -16
  18. package/src/commands/stats.ts +11 -30
  19. package/src/commands/tail.ts +31 -1
  20. package/src/commands/upgrade.ts +20 -3
  21. package/src/commands/verb.ts +2 -1
  22. package/src/config-schema.ts +19 -0
  23. package/src/config.ts +80 -0
  24. package/src/credential-class.ts +366 -0
  25. package/src/daemon.ts +1218 -288
  26. package/src/dashboard/app.js +504 -2
  27. package/src/dashboard/controls.ts +336 -0
  28. package/src/dashboard/index.html +30 -0
  29. package/src/dashboard/server.ts +271 -30
  30. package/src/dashboard/style.css +116 -0
  31. package/src/dashboard/transcript.ts +173 -0
  32. package/src/doctor.ts +377 -20
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +497 -15
  35. package/src/host.ts +6 -130
  36. package/src/omp.ts +29 -0
  37. package/src/orchestrator-tick.ts +343 -88
  38. package/src/pause.ts +233 -0
  39. package/src/settlement.ts +159 -2
  40. package/src/setup-answers.ts +97 -0
  41. package/src/setup-host.ts +321 -1155
  42. package/src/setup-install.ts +204 -27
  43. package/src/setup-wizard.ts +111 -50
  44. package/src/setup.ts +33 -0
  45. package/src/spend-telemetry.ts +117 -0
  46. package/src/stats.ts +35 -0
  47. package/src/status-render.ts +348 -19
  48. package/src/store.ts +1229 -55
  49. package/src/telegram-freshness.ts +269 -0
  50. package/src/to-spec.ts +27 -0
  51. package/src/types.ts +697 -4
  52. package/src/unblock.ts +22 -0
  53. package/src/unit-reconcile.ts +303 -0
  54. package/src/upgrade-verify.ts +8 -1
  55. package/src/upgrade.ts +299 -12
  56. package/src/verbs/actions.ts +124 -10
  57. package/src/verbs/protocol.ts +70 -2
  58. package/src/verbs/server.ts +447 -8
  59. package/src/wake.ts +48 -0
  60. package/src/worker.ts +403 -3
package/src/store.ts CHANGED
@@ -16,7 +16,12 @@ import { dirname, join } from "node:path";
16
16
 
17
17
  import { backupTimestamp, publishTempFile } from "./backups.ts";
18
18
  import { stateDir } from "./config.ts";
19
- import { DECISION_TTL_MS, DEFAULT_CAPS, DIGEST_BACKLOG_LIMIT } from "./types.ts";
19
+ import {
20
+ DECISION_TTL_MS,
21
+ DEFAULT_CAPS,
22
+ DIGEST_BACKLOG_LIMIT,
23
+ TERMINAL_REVIEW_ADJUDICATION_STATES,
24
+ } from "./types.ts";
20
25
 
21
26
  /**
22
27
  * `expiresAt` for a watch (#459). It has no human deadline — a condition that
@@ -53,24 +58,34 @@ import type {
53
58
  HeldNoticeDraft,
54
59
  IntakeDraft,
55
60
  IntakeItem,
61
+ InstallSurfaceObservation,
56
62
  IntakeState,
57
63
  MaterialEvent,
58
64
  MaterialEventDraft,
59
65
  LabelOp,
60
66
  MergeLock,
67
+ ModelEscalation,
61
68
  OrchestratorDownMode,
62
69
  OrchestratorIncident,
63
70
  OrchestratorIncidentDraft,
71
+ ReleaseComposition,
72
+ ReleaseCompositionOverride,
64
73
  ReportDeliveryState,
65
74
  ReportDraft,
66
75
  ReportEnqueue,
67
76
  ReportKind,
68
77
  ReportRecord,
69
78
  RecoveryAction,
79
+ ReviewClearance,
70
80
  ReviewReason,
81
+ ReviewAdjudicationAdmission,
82
+ ReviewAdjudicationProvenance,
83
+ ReviewAdjudicationRecord,
84
+ ReviewAdjudicationState,
71
85
  ReviewRevisionEnqueue,
72
86
  ReviewRevisionOutcome,
73
87
  ReviewRevisionRecord,
88
+ ReviewRevisionRetry,
74
89
  RunPatch,
75
90
  RunRecord,
76
91
  RunState,
@@ -156,6 +171,7 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
156
171
  turns: true,
157
172
  maxTurns: true,
158
173
  spendUsd: true,
174
+ spendReservedUsd: true,
159
175
  provider429Count: true,
160
176
  resolvedModel: true,
161
177
  resolvedProvider: true,
@@ -165,6 +181,12 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
165
181
  autoRetryCount: true,
166
182
  autoCompactionCount: true,
167
183
  sessionFile: true,
184
+ workerPid: true,
185
+ paneId: true,
186
+ paneLabel: true,
187
+ paneUnavailable: true,
188
+ outputTokens: true,
189
+ reasoningTokens: true,
168
190
  resumedFromRunId: true,
169
191
  lane: true,
170
192
  prUrl: true,
@@ -197,6 +219,12 @@ type SqlValue = string | number | null;
197
219
  /** The `runs` table exactly as SQLite hands it back: optional means NULL. */
198
220
  interface RunRow {
199
221
  id: string;
222
+ workerPid: number | null;
223
+ paneId: string | null;
224
+ paneLabel: string | null;
225
+ paneUnavailable: string | null;
226
+ outputTokens: number | null;
227
+ reasoningTokens: number | null;
200
228
  project: string;
201
229
  issue: number;
202
230
  repo: string;
@@ -207,6 +235,8 @@ interface RunRow {
207
235
  turns: number;
208
236
  maxTurns: number;
209
237
  spendUsd: number;
238
+ /** NULL for a row admitted with no spend cap, or one written before #851. */
239
+ spendReservedUsd: number | null;
210
240
  provider429Count: number | null;
211
241
  resolvedModel: string | null;
212
242
  resolvedProvider: string | null;
@@ -294,6 +324,45 @@ function toBaseFreeze(row: BaseFreezeRow): BaseFreeze {
294
324
  return freeze;
295
325
  }
296
326
 
327
+ /** The `release_composition` table exactly as SQLite hands it back (#850). */
328
+ interface ReleaseCompositionRow {
329
+ project: string;
330
+ campaign: string;
331
+ /** JSON-encoded array of full PR URLs. */
332
+ allowedPrUrls: string;
333
+ declaredBy: string | null;
334
+ declaredAt: number;
335
+ closedAt: number | null;
336
+ closedBy: string | null;
337
+ closedReason: string | null;
338
+ }
339
+
340
+ function toReleaseComposition(row: ReleaseCompositionRow): ReleaseComposition {
341
+ const composition: ReleaseComposition = {
342
+ campaign: row.campaign,
343
+ allowedPrUrls: JSON.parse(row.allowedPrUrls) as string[],
344
+ declaredAt: row.declaredAt,
345
+ };
346
+ if (row.declaredBy !== null) composition.declaredBy = row.declaredBy;
347
+ if (row.closedAt !== null) {
348
+ composition.closedAt = row.closedAt;
349
+ if (row.closedBy !== null) composition.closedBy = row.closedBy;
350
+ if (row.closedReason !== null) composition.closedReason = row.closedReason;
351
+ }
352
+ return composition;
353
+ }
354
+
355
+ /** The `release_composition_overrides` table exactly as SQLite hands it back (#850). */
356
+ interface ReleaseCompositionOverrideRow {
357
+ id: string;
358
+ project: string;
359
+ campaign: string;
360
+ prUrl: string;
361
+ at: number;
362
+ by: string;
363
+ reason: string;
364
+ }
365
+
297
366
  interface FrictionRollupRow {
298
367
  project: string;
299
368
  day: string;
@@ -413,6 +482,19 @@ interface ReviewRevisionRow {
413
482
  dispatchedAt: number | null;
414
483
  settledAt: number | null;
415
484
  outcome: string | null;
485
+ /** Nullable rather than NOT NULL DEFAULT 0: rows written before #903 carry
486
+ * NULL, which reads back as absent and counts as zero retries. */
487
+ infraRetries: number | null;
488
+ }
489
+
490
+ /** The `model_escalations` table exactly as SQLite hands it back (#807). Every
491
+ * column is NOT NULL, so the row is the record once `failureClass` is read
492
+ * back as the enum the writer narrowed it from. */
493
+ interface ModelEscalationRow {
494
+ model: string;
495
+ failureClass: string;
496
+ runId: string;
497
+ at: number;
416
498
  }
417
499
 
418
500
  /**
@@ -436,6 +518,64 @@ function toReviewRevision(row: ReviewRevisionRow): ReviewRevisionRecord {
436
518
  if (row.dispatchedAt !== null) record.dispatchedAt = row.dispatchedAt;
437
519
  if (row.settledAt !== null) record.settledAt = row.settledAt;
438
520
  if (row.outcome !== null) record.outcome = row.outcome as ReviewRevisionOutcome;
521
+ // Zero and NULL both read back as absent: a round nobody has retried and a
522
+ // row written before the column existed are the same fact.
523
+ if (row.infraRetries !== null && row.infraRetries > 0) record.infraRetries = row.infraRetries;
524
+ return record;
525
+ }
526
+
527
+ /**
528
+ * The `review_clearances` table exactly as SQLite hands it back (#913). Every
529
+ * column is NOT NULL, so the row *is* the record — no converter needed.
530
+ */
531
+ type ReviewClearanceRow = ReviewClearance;
532
+
533
+ /** The `review_adjudications` table exactly as SQLite hands it back (#874). */
534
+ interface ReviewAdjudicationRow {
535
+ id: string;
536
+ project: string;
537
+ issue: number;
538
+ prUrl: string;
539
+ headSha: string;
540
+ role: string;
541
+ provenance: string | null;
542
+ state: string;
543
+ evidence: string | null;
544
+ disposition: string | null;
545
+ requestedAt: number;
546
+ dispatchedAt: number | null;
547
+ settledAt: number | null;
548
+ findings: string | null;
549
+ }
550
+
551
+ /** NULL columns become absent properties, like every other converter here, so
552
+ * a record read back deep-equals the one that went in. Unparseable
553
+ * provenance reads as absent rather than throwing: a corrupt JSON blob must
554
+ * not make an otherwise-readable adjudication unreadable, and "no launch
555
+ * recorded" is the honest degraded answer. */
556
+ function toReviewAdjudication(row: ReviewAdjudicationRow): ReviewAdjudicationRecord {
557
+ const record: ReviewAdjudicationRecord = {
558
+ id: row.id,
559
+ project: row.project,
560
+ issue: row.issue,
561
+ prUrl: row.prUrl,
562
+ headSha: row.headSha,
563
+ role: row.role,
564
+ state: row.state as ReviewAdjudicationState,
565
+ requestedAt: row.requestedAt,
566
+ };
567
+ if (row.provenance !== null) {
568
+ try {
569
+ record.provenance = JSON.parse(row.provenance) as ReviewAdjudicationProvenance;
570
+ } catch {
571
+ // left absent
572
+ }
573
+ }
574
+ if (row.evidence !== null) record.evidence = row.evidence;
575
+ if (row.disposition !== null) record.disposition = row.disposition;
576
+ if (row.dispatchedAt !== null) record.dispatchedAt = row.dispatchedAt;
577
+ if (row.settledAt !== null) record.settledAt = row.settledAt;
578
+ if (row.findings !== null) record.findings = row.findings;
439
579
  return record;
440
580
  }
441
581
 
@@ -560,6 +700,9 @@ CREATE TABLE IF NOT EXISTS runs (
560
700
  turns INTEGER NOT NULL,
561
701
  maxTurns INTEGER NOT NULL,
562
702
  spendUsd REAL NOT NULL,
703
+ -- What admission reserved out of the daily budget for this run (#851).
704
+ -- Nullable: an older row, or one admitted while the fleet had no cap.
705
+ spendReservedUsd REAL,
563
706
  provider429Count INTEGER,
564
707
  resolvedModel TEXT,
565
708
  resolvedProvider TEXT,
@@ -569,6 +712,25 @@ CREATE TABLE IF NOT EXISTS runs (
569
712
  autoRetryCount INTEGER,
570
713
  autoCompactionCount INTEGER,
571
714
  sessionFile TEXT,
715
+ -- The worker's exact process and workspace identity (#842). The pid is the
716
+ -- session-host child the pane was reported for; the pane id and label are what
717
+ -- Herdr knows it as. Nullable throughout: a historical row never had one, a
718
+ -- run whose host has no Herdr never gets one, and NULL is the honest answer in
719
+ -- both cases rather than a pane nobody can find.
720
+ workerPid INTEGER,
721
+ paneId TEXT,
722
+ paneLabel TEXT,
723
+ -- Why this run has no workspace representation (#841). Set when a pane could
724
+ -- not be established or was lost, cleared when one is. Its presence beside a
725
+ -- live run is what makes "degraded" a named state in status output rather than a
726
+ -- silence the operator has to notice.
727
+ paneUnavailable TEXT,
728
+ -- Cumulative output and reasoning tokens (#518). Nullable: a historical row
729
+ -- predates the accounting, and a provider that reports no token block leaves
730
+ -- them absent rather than zero -- "reported nothing" and "produced nothing"
731
+ -- invite opposite readings, and a zero would make the share silently wrong.
732
+ outputTokens INTEGER,
733
+ reasoningTokens INTEGER,
572
734
  resumedFromRunId TEXT,
573
735
  lane TEXT,
574
736
  prUrl TEXT,
@@ -641,6 +803,38 @@ CREATE TABLE IF NOT EXISTS base_freeze (
641
803
  PRIMARY KEY (project, repo)
642
804
  );
643
805
 
806
+ -- The active release composition (#850): the campaign whose release a project
807
+ -- is assembling plus the exact PRs allowed into it. While closedAt IS NULL,
808
+ -- prMergeVerb refuses every other conductor_pr_merge with the
809
+ -- outside-active-release refusal, so an early patch cannot accumulate
810
+ -- unrelated merges; the clear columns hold the completion/cancellation audit
811
+ -- on the row itself, and material_events carry the immutable lifecycle trail.
812
+ CREATE TABLE IF NOT EXISTS release_composition (
813
+ project TEXT PRIMARY KEY,
814
+ campaign TEXT NOT NULL,
815
+ allowedPrUrls TEXT NOT NULL,
816
+ declaredBy TEXT,
817
+ declaredAt INTEGER NOT NULL,
818
+ closedAt INTEGER,
819
+ closedBy TEXT,
820
+ closedReason TEXT
821
+ );
822
+
823
+ -- Append-only operator overrides admitting exactly one PR into one campaign
824
+ -- (#850). An override applies to its campaign only — it never outlives the
825
+ -- release it was recorded for, and bypasses nothing but the composition check.
826
+ CREATE TABLE IF NOT EXISTS release_composition_overrides (
827
+ id TEXT PRIMARY KEY,
828
+ project TEXT NOT NULL,
829
+ campaign TEXT NOT NULL,
830
+ prUrl TEXT NOT NULL,
831
+ at INTEGER NOT NULL,
832
+ by TEXT NOT NULL,
833
+ reason TEXT NOT NULL
834
+ );
835
+ CREATE INDEX IF NOT EXISTS release_composition_overrides_lookup
836
+ ON release_composition_overrides (project, campaign, prUrl, at);
837
+
644
838
  CREATE TABLE IF NOT EXISTS notifications (
645
839
  "key" TEXT PRIMARY KEY,
646
840
  at INTEGER NOT NULL
@@ -668,6 +862,23 @@ CREATE TABLE IF NOT EXISTS turn_override_ledger (
668
862
  CREATE INDEX IF NOT EXISTS turn_override_ledger_project_at
669
863
  ON turn_override_ledger (project, setAt);
670
864
 
865
+ -- The one-shot model escalation an issue chain has spent (#807). The primary
866
+ -- key IS the cap: the insert either creates the chain's only escalation or
867
+ -- changes nothing, so a settlement that crashes between claiming and queueing
868
+ -- the continuation can never hand the same chain a second tier on restart, and
869
+ -- a second spinning cap falls through to the existing decomposition verdict.
870
+ -- Never deleted: the row is also what dispatch reads to keep the escalated
871
+ -- chain on its stronger selector.
872
+ CREATE TABLE IF NOT EXISTS model_escalations (
873
+ project TEXT NOT NULL,
874
+ issue INTEGER NOT NULL CHECK (issue > 0),
875
+ model TEXT NOT NULL,
876
+ failureClass TEXT NOT NULL,
877
+ runId TEXT NOT NULL,
878
+ at INTEGER NOT NULL CHECK (at >= 0),
879
+ PRIMARY KEY (project, issue)
880
+ );
881
+
671
882
  CREATE TABLE IF NOT EXISTS dispatch_summaries (
672
883
  project TEXT PRIMARY KEY,
673
884
  summary TEXT NOT NULL
@@ -833,7 +1044,11 @@ CREATE TABLE IF NOT EXISTS review_revisions (
833
1044
  requestedAt INTEGER NOT NULL,
834
1045
  dispatchedAt INTEGER,
835
1046
  settledAt INTEGER,
836
- outcome TEXT
1047
+ outcome TEXT,
1048
+ -- Infrastructure kills that returned this round to the pending set (#903).
1049
+ -- Nullable so an older database migrates by ALTER without a rewrite; NULL
1050
+ -- and 0 mean the same thing.
1051
+ infraRetries INTEGER
837
1052
  );
838
1053
  CREATE INDEX IF NOT EXISTS review_revisions_pending
839
1054
  ON review_revisions (project, dispatchedAt, runId);
@@ -847,6 +1062,111 @@ CREATE UNIQUE INDEX IF NOT EXISTS review_revisions_one_pending_per_run
847
1062
  ON review_revisions (runId)
848
1063
  WHERE dispatchedAt IS NULL AND settledAt IS NULL;
849
1064
 
1065
+ -- One recorded review clearance (#913): the explicit orchestrator disposition
1066
+ -- that the durable review findings standing at one exact head are settled.
1067
+ --
1068
+ -- The #888 merge gate had no reachable clearance at an unchanged head. A
1069
+ -- re-review at the same head appends to (or opens) an unsettled row, so it
1070
+ -- blocks too, and nothing an orchestrator can call settles a round — a PR at
1071
+ -- the review-round ceiling was therefore permanently unmergeable at that head.
1072
+ -- This table is that missing disposition, kept deliberately separate from the
1073
+ -- merge call's own reason argument so the override is a recorded act with its
1074
+ -- own ledger row rather than a sentence in a merge.
1075
+ --
1076
+ -- Append-only: a second clearance at the same head is another row, and its
1077
+ -- recorded timestamp is load-bearing -- the gate honours a clearance only for
1078
+ -- revision rows whose own last activity is at or before it, so a clearance can
1079
+ -- never bless a *later* finding at the same head.
1080
+ CREATE TABLE IF NOT EXISTS review_clearances (
1081
+ id TEXT PRIMARY KEY,
1082
+ project TEXT NOT NULL,
1083
+ prUrl TEXT NOT NULL,
1084
+ headSha TEXT NOT NULL,
1085
+ at INTEGER NOT NULL,
1086
+ by TEXT NOT NULL,
1087
+ reason TEXT NOT NULL
1088
+ );
1089
+ CREATE INDEX IF NOT EXISTS review_clearances_lookup
1090
+ ON review_clearances (project, prUrl, headSha, at);
1091
+
1092
+ -- One terminal review-ceiling adjudication (#874): the durable lifecycle that
1093
+ -- makes the final adjudication a recorded act rather than an in-memory fourth
1094
+ -- review round.
1095
+ --
1096
+ -- The one-shot guarantee is the UNIQUE index below, not a flag a caller
1097
+ -- remembers to check: a repeated tick, two dispatchers, or a daemon restart all
1098
+ -- collide on (project, prUrl, headSha) and re-read the existing row. The exact
1099
+ -- head is part of that key on purpose — a verdict describes the diff it read,
1100
+ -- so it must never travel to a head nobody adjudicated.
1101
+ --
1102
+ -- Deliberately NOT a row in review_revisions. Reusing round N+1 there would
1103
+ -- spend a worker review round the ceiling has already exhausted, and would
1104
+ -- resume the same session and model for a nominal fourth opinion, which is the
1105
+ -- failure #873 exists to end. The tables also answer different questions: a
1106
+ -- revision is "go and change this"; an adjudication is "decide about this".
1107
+ --
1108
+ -- state carries the verdict: the terminal states ARE it (cleared, rejected,
1109
+ -- unavailable-model, stale-head, failed), so there is no second verdict column
1110
+ -- to disagree with them. evidence says why, and disposition (written by
1111
+ -- #876) says what was done about it.
1112
+ CREATE TABLE IF NOT EXISTS review_adjudications (
1113
+ id TEXT PRIMARY KEY,
1114
+ project TEXT NOT NULL,
1115
+ issue INTEGER NOT NULL,
1116
+ prUrl TEXT NOT NULL,
1117
+ headSha TEXT NOT NULL,
1118
+ role TEXT NOT NULL,
1119
+ -- The resolved launch provenance (#875) as JSON, absent while pending: no
1120
+ -- model has resolved yet, and recording an intention as provenance would make
1121
+ -- status claim a launch that never happened.
1122
+ provenance TEXT,
1123
+ state TEXT NOT NULL,
1124
+ evidence TEXT,
1125
+ disposition TEXT,
1126
+ requestedAt INTEGER NOT NULL,
1127
+ dispatchedAt INTEGER,
1128
+ settledAt INTEGER,
1129
+ -- The findings that provoked the escalation (#932): what the orchestrator was
1130
+ -- submitting when it hit the ceiling. Nullable so an older database migrates
1131
+ -- by ALTER, and because an adjudication opened by any other path has none.
1132
+ -- Kept here rather than as a review_revisions round because a round would
1133
+ -- spend a ceiling that is already exhausted and would wake a worker; this is
1134
+ -- evidence for the adjudicator, not work for an implementer.
1135
+ findings TEXT
1136
+ );
1137
+ -- The one-shot identity, structural rather than advisory: at most one
1138
+ -- adjudication per PR head, ever.
1139
+ CREATE UNIQUE INDEX IF NOT EXISTS review_adjudications_one_per_head
1140
+ ON review_adjudications (project, prUrl, headSha);
1141
+ -- Restart recovery and the status projection both read the non-terminal rows
1142
+ -- for one project; the PR lookup answers "is this head adjudicated".
1143
+ CREATE INDEX IF NOT EXISTS review_adjudications_open
1144
+ ON review_adjudications (project, settledAt);
1145
+
1146
+ -- The install identities the host carried when the daemon last looked (#919).
1147
+ -- One row (the primary key is a constant): the question is what is installed
1148
+ -- NOW, and the install history is the upgrade journal's job. Recorded by the
1149
+ -- dispatch pass because reading it costs three subprocesses -- doing that at
1150
+ -- render time took the tick suite from 8.4s to 83.4s and would spawn three
1151
+ -- children every fifteen minutes forever.
1152
+ CREATE TABLE IF NOT EXISTS install_surfaces (
1153
+ host TEXT PRIMARY KEY,
1154
+ at INTEGER NOT NULL,
1155
+ cliVersion TEXT NOT NULL,
1156
+ ompVersion TEXT,
1157
+ herdrSource TEXT,
1158
+ -- The one peer plugin a documented conductor contract runs on (#961). Carried
1159
+ -- on this row rather than in a table of its own because it is read by the same
1160
+ -- periodic pass, about the same host, for the same reason: an install that
1161
+ -- diverged from what is running or published, observed without an on-demand
1162
+ -- doctor run (#919). Every column is nullable — a pass that could not reach
1163
+ -- the registry records what it did read and leaves the rest unobserved, which
1164
+ -- the renderer must show as "unverified" and never as agreement.
1165
+ tgInstalled TEXT,
1166
+ tgDaemon TEXT,
1167
+ tgPublished TEXT
1168
+ );
1169
+
850
1170
  -- Questions the orchestrator has put to its operator, and their answers (#136).
851
1171
  --
852
1172
  -- The reason this is a table and not the session's memory: a question lived only
@@ -879,9 +1199,20 @@ CREATE TABLE IF NOT EXISTS decisions (
879
1199
  conditionHead TEXT,
880
1200
  state TEXT NOT NULL,
881
1201
  resolvedAt INTEGER,
882
- resolution TEXT
1202
+ resolution TEXT,
1203
+ -- One spec-out questionnaire (#947): N rows written in a single transaction,
1204
+ -- delivered as one message, resolved independently and in any order. Nullable
1205
+ -- because every ordinary single ask has neither: a historical row was never
1206
+ -- part of a group and must not read as a group of one, and specIssue is what
1207
+ -- makes a group's answers the spec's provenance rather than chat history.
1208
+ groupId TEXT,
1209
+ specIssue INTEGER
883
1210
  );
884
1211
  CREATE INDEX IF NOT EXISTS decisions_project_state ON decisions (project, state);
1212
+ -- The groupId index is NOT here on purpose: this whole script runs before the
1213
+ -- additive migrations, and on a database written before #947 the column does not
1214
+ -- exist yet, so an index over it here fails the entire schema step. It is created
1215
+ -- beside the ALTER that adds the column, which every open path runs.
885
1216
 
886
1217
  -- One issue's current grooming verdict (#735), the durable answer to "has
887
1218
  -- this been groomed, and what was decided" that the tick reads instead of
@@ -1133,7 +1464,14 @@ function toRecord(row: RunRow): RunRecord {
1133
1464
  spendUsd: row.spendUsd,
1134
1465
  startedAt: row.startedAt,
1135
1466
  };
1467
+ if (row.spendReservedUsd !== null) record.spendReservedUsd = row.spendReservedUsd;
1136
1468
  if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
1469
+ if (row.workerPid !== null) record.workerPid = row.workerPid;
1470
+ if (row.paneId !== null) record.paneId = row.paneId;
1471
+ if (row.paneLabel !== null) record.paneLabel = row.paneLabel;
1472
+ if (row.paneUnavailable !== null) record.paneUnavailable = row.paneUnavailable;
1473
+ if (row.outputTokens !== null) record.outputTokens = row.outputTokens;
1474
+ if (row.reasoningTokens !== null) record.reasoningTokens = row.reasoningTokens;
1137
1475
  if (row.resumedFromRunId !== null) record.resumedFromRunId = row.resumedFromRunId;
1138
1476
  if (row.lane !== null) {
1139
1477
  const lane = toLane(row.lane);
@@ -1246,6 +1584,8 @@ interface DecisionRow {
1246
1584
  state: string;
1247
1585
  resolvedAt: number | null;
1248
1586
  resolution: string | null;
1587
+ groupId: string | null;
1588
+ specIssue: number | null;
1249
1589
  }
1250
1590
 
1251
1591
  /** Same NULL-to-absent contract as {@link toReport}. */
@@ -1268,6 +1608,8 @@ function toDecision(row: DecisionRow): DecisionRecord {
1268
1608
  if (row.conditionHead !== null) record.conditionHead = row.conditionHead;
1269
1609
  if (row.resolvedAt !== null) record.resolvedAt = row.resolvedAt;
1270
1610
  if (row.resolution !== null) record.resolution = row.resolution;
1611
+ if (row.groupId !== null) record.groupId = row.groupId;
1612
+ if (row.specIssue !== null) record.specIssue = row.specIssue;
1271
1613
  return record;
1272
1614
  }
1273
1615
 
@@ -1619,6 +1961,23 @@ export function openStore(dbPath: string): Store {
1619
1961
  if (!columns.some((column) => column.name === "quarantineDetail")) {
1620
1962
  db.exec("ALTER TABLE runs ADD COLUMN quarantineDetail TEXT");
1621
1963
  }
1964
+ // The worker's process/workspace identity (#842). Rows written before it exists
1965
+ // carry none, which is the truth: nothing reported a pane for them, so there is
1966
+ // no pane for a restart to reconcile. Nothing is backfilled — a pid guessed
1967
+ // after the fact is the one thing worse than no pid, because a reused pid reads
1968
+ // as a live worker.
1969
+ for (const [name, type] of [
1970
+ ["workerPid", "INTEGER"],
1971
+ ["paneId", "TEXT"],
1972
+ ["paneLabel", "TEXT"],
1973
+ ["paneUnavailable", "TEXT"],
1974
+ ["outputTokens", "INTEGER"],
1975
+ ["reasoningTokens", "INTEGER"],
1976
+ ] as const) {
1977
+ if (!columns.some((column) => column.name === name)) {
1978
+ db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
1979
+ }
1980
+ }
1622
1981
  // Rows written before the settlement audit existed (#128) were never audited,
1623
1982
  // so a NULL here means "no audit ran" and reads the same as "found nothing".
1624
1983
  // That conflation is deliberate and harmless: the flags are advisory, and no
@@ -1646,6 +2005,17 @@ export function openStore(dbPath: string): Store {
1646
2005
  db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
1647
2006
  }
1648
2007
  }
2008
+ // The telegram plugin's three versions (#961), on the existing host row. A
2009
+ // store written before this release has no observation of them, and NULL is
2010
+ // the honest reading: the periodic pass that reads them had not shipped, so
2011
+ // the renderer says "unverified" until the next pass rather than implying the
2012
+ // versions agreed.
2013
+ const surfaceColumns = db.query<{ name: string }, []>("PRAGMA table_info(install_surfaces)").all();
2014
+ for (const name of ["tgInstalled", "tgDaemon", "tgPublished"] as const) {
2015
+ if (!surfaceColumns.some((column) => column.name === name)) {
2016
+ db.exec(`ALTER TABLE install_surfaces ADD COLUMN ${name} TEXT`);
2017
+ }
2018
+ }
1649
2019
  // The model a run dispatched on was first written by the modelFallbacks
1650
2020
  // failover chain (#286). Rows predating the column are NULL, which is the
1651
2021
  // honest reading: only a chain-configured project records a model, and the
@@ -1762,7 +2132,64 @@ export function openStore(dbPath: string): Store {
1762
2132
  if (!decisionColumns.some((column) => column.name === "conditionHead")) {
1763
2133
  db.exec("ALTER TABLE decisions ADD COLUMN conditionHead TEXT");
1764
2134
  }
2135
+ // The spec-out questionnaire (#947). Both columns are nullable and NULL is the
2136
+ // truthful value for every historical row: it was a single ask, not one item
2137
+ // of a group, and it specced nothing. The index is created here too, so an
2138
+ // upgraded database reads groups as fast as a fresh one.
2139
+ for (const [name, type] of [
2140
+ ["groupId", "TEXT"],
2141
+ ["specIssue", "INTEGER"],
2142
+ ] as const) {
2143
+ if (!decisionColumns.some((column) => column.name === name)) {
2144
+ db.exec(`ALTER TABLE decisions ADD COLUMN ${name} ${type}`);
2145
+ }
2146
+ }
2147
+ db.exec("CREATE INDEX IF NOT EXISTS decisions_group ON decisions (groupId)");
2148
+ // Already-met watches, written before the deadline existed (#966). Unlike the
2149
+ // head binding above this IS backfilled, and the difference is that nothing is
2150
+ // being guessed: `conditionMetAt` is a recorded observation, and the deadline
2151
+ // is arithmetic on it.
2152
+ //
2153
+ // It has to be a migration rather than a re-observation, because
2154
+ // `markDecisionConditionMet` refuses a row whose `conditionMetAt` is already
2155
+ // set — deliberately, so the digest flag cannot look new every tick. Without
2156
+ // this, the three watches that motivated the fix would have kept
2157
+ // "[CONDITION MET — act on this now]" forever and the fix would have applied
2158
+ // only to conditions that fire in the future.
2159
+ //
2160
+ // Narrow on purpose: open watches only, only where the deadline is still the
2161
+ // infinite sentinel. A resolved row is history, a question's deadline runs
2162
+ // from `askedAt`, and a watch that somehow already carries a finite deadline
2163
+ // is left exactly as it is.
2164
+ db.run(
2165
+ `UPDATE decisions
2166
+ SET expiresAt = conditionMetAt + ?
2167
+ WHERE kind = 'watch'
2168
+ AND state = 'open'
2169
+ AND conditionMetAt IS NOT NULL
2170
+ AND expiresAt = ?`,
2171
+ [DECISION_TTL_MS, DECISION_WATCH_NEVER_EXPIRES],
2172
+ );
2173
+ // The per-run spend reservation (#851). Rows written before it existed hold
2174
+ // no reservation, which is the truth: admission never took one for them.
2175
+ if (!columns.some((column) => column.name === "spendReservedUsd")) {
2176
+ db.exec("ALTER TABLE runs ADD COLUMN spendReservedUsd REAL");
2177
+ }
2178
+ // Infra retries on a review round (#903). Historical rows were never
2179
+ // retried — the path did not exist, every infra kill settled `failed` — so
2180
+ // NULL is the honest reading and counts as zero.
2181
+ const reviewRevisionColumns = db.query<{ name: string }, []>("PRAGMA table_info(review_revisions)").all();
2182
+ if (!reviewRevisionColumns.some((column) => column.name === "infraRetries")) {
2183
+ db.exec("ALTER TABLE review_revisions ADD COLUMN infraRetries INTEGER");
2184
+ }
1765
2185
 
2186
+ // The escalation findings on an adjudication (#932). Rows written by #874's
2187
+ // lifecycle carried none — the ceiling still refused then — so NULL is the
2188
+ // honest reading and renders as "no findings recorded".
2189
+ const adjudicationColumns = db.query<{ name: string }, []>("PRAGMA table_info(review_adjudications)").all();
2190
+ if (!adjudicationColumns.some((column) => column.name === "findings")) {
2191
+ db.exec("ALTER TABLE review_adjudications ADD COLUMN findings TEXT");
2192
+ }
1766
2193
  // One-time repair, and the reason it lives here rather than in the classifier:
1767
2194
  // a turn-0 environment fault that was ALREADY classified `unknown` and
1768
2195
  // escalated is never revisited. `runsNeedingClassification` only offers a row
@@ -1855,10 +2282,10 @@ export function openStore(dbPath: string): Store {
1855
2282
  const insertRun = db.query<unknown, SqlValue[]>(
1856
2283
  `INSERT INTO runs (
1857
2284
  id, project, issue, repo, branch, worktree, state, attempt, turns,
1858
- maxTurns, spendUsd, sessionFile, resumedFromRunId, lane, prUrl, headSha, mergeSha, baseRef,
2285
+ maxTurns, spendUsd, spendReservedUsd, sessionFile, resumedFromRunId, lane, prUrl, headSha, mergeSha, baseRef,
1859
2286
  baseCheck, baseCheckAt, salvageSha, salvageError, salvageAckAt, startedAt,
1860
2287
  endedAt, lastError, settlementFlags, report, graphTools
1861
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2288
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1862
2289
  );
1863
2290
  const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
1864
2291
  const selectGraphToolsObs = db.query<{ graphTools: string }, [string]>(
@@ -1874,6 +2301,26 @@ export function openStore(dbPath: string): Store {
1874
2301
  WHERE project = ? AND state IN (${LIVE_PLACEHOLDERS})
1875
2302
  ORDER BY startedAt ASC`,
1876
2303
  );
2304
+ // Runs holding an ACTIVE MUTATION LEASE (#899, #925): a live worker, or a
2305
+ // review revision the daemon has handed to one and that has not settled.
2306
+ // The second half is belt-and-braces — `claimRunForReview` moves the row to
2307
+ // `running` — but the claim and the launch are not the same instant, and a
2308
+ // lease the reader briefly forgets is a lane two writers can share. A
2309
+ // *queued* revision holds nothing: no worker has been woken for it.
2310
+ //
2311
+ // One query rather than a composition at each call site, because two
2312
+ // readers now decide "may this file be written" from it — admission's
2313
+ // file-lane gate and `conductor_pr_recover`'s fail-closed lane check — and
2314
+ // a lease rule that disagrees between them is the bug both are guarding.
2315
+ const selectLeased = db.query<RunRow, SqlValue[]>(
2316
+ `SELECT * FROM runs
2317
+ WHERE project = ?
2318
+ AND (state IN (${LIVE_PLACEHOLDERS})
2319
+ OR id IN (
2320
+ SELECT runId FROM review_revisions
2321
+ WHERE project = ? AND dispatchedAt IS NOT NULL AND settledAt IS NULL))
2322
+ ORDER BY startedAt ASC`,
2323
+ );
1877
2324
  const selectRetained = db.query<RunRow, [string]>(
1878
2325
  `SELECT * FROM runs
1879
2326
  WHERE project = ?
@@ -1987,6 +2434,73 @@ export function openStore(dbPath: string): Store {
1987
2434
  SET clearedAt = ?, clearedBy = ?, clearedReason = ?
1988
2435
  WHERE project = ? AND repo = ? AND clearedAt IS NULL`,
1989
2436
  );
2437
+ const selectActiveReleaseComposition = db.query<ReleaseCompositionRow, [string]>(
2438
+ `SELECT * FROM release_composition WHERE project = ? AND closedAt IS NULL`,
2439
+ );
2440
+ const declareReleaseCompositionRow = db.query<
2441
+ unknown,
2442
+ [string, string, string, SqlValue, number]
2443
+ >(
2444
+ `INSERT INTO release_composition (project, campaign, allowedPrUrls, declaredBy, declaredAt)
2445
+ VALUES (?, ?, ?, ?, ?)
2446
+ ON CONFLICT(project) DO UPDATE SET
2447
+ campaign = excluded.campaign,
2448
+ allowedPrUrls = excluded.allowedPrUrls,
2449
+ declaredBy = excluded.declaredBy,
2450
+ declaredAt = excluded.declaredAt,
2451
+ closedAt = NULL,
2452
+ closedBy = NULL,
2453
+ closedReason = NULL`,
2454
+ );
2455
+ const retireReleaseCompositionRow = db.query<unknown, [number, string, string, string]>(
2456
+ `UPDATE release_composition
2457
+ SET closedAt = ?, closedBy = ?, closedReason = ?
2458
+ WHERE project = ? AND closedAt IS NULL`,
2459
+ );
2460
+ const insertReleaseCompositionOverride = db.query<
2461
+ unknown,
2462
+ [string, string, string, string, number, string, string]
2463
+ >(
2464
+ `INSERT INTO release_composition_overrides (id, project, campaign, prUrl, at, by, reason)
2465
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
2466
+ );
2467
+ const selectReleaseCompositionOverride = db.query<
2468
+ ReleaseCompositionOverrideRow,
2469
+ [string, string, string]
2470
+ >(
2471
+ `SELECT * FROM release_composition_overrides
2472
+ WHERE project = ? AND campaign = ? AND prUrl = ?
2473
+ ORDER BY at DESC LIMIT 1`,
2474
+ );
2475
+ const selectReleaseCompositionOverrides = db.query<
2476
+ ReleaseCompositionOverrideRow,
2477
+ [string, string]
2478
+ >(
2479
+ `SELECT * FROM release_composition_overrides
2480
+ WHERE project = ? AND campaign = ?
2481
+ ORDER BY at DESC`,
2482
+ );
2483
+
2484
+ // Declaring is a check-then-insert, and two processes (CLI + daemon) can
2485
+ // race: without the transaction the second declarer would silently replace
2486
+ // a still-active composition — exactly the overwrite this guard exists to
2487
+ // prevent. Inside one write transaction the re-check sees the row the first
2488
+ // declarer just wrote. Returns the conflicting active row, or null when the
2489
+ // declaration took.
2490
+ const declareReleaseCompositionTx = db.transaction(
2491
+ (
2492
+ project: string,
2493
+ campaign: string,
2494
+ allowedPrUrls: string,
2495
+ declaredBy: string | null,
2496
+ declaredAt: number,
2497
+ ): ReleaseCompositionRow | null => {
2498
+ const existing = selectActiveReleaseComposition.get(project);
2499
+ if (existing !== null) return existing;
2500
+ declareReleaseCompositionRow.run(project, campaign, allowedPrUrls, declaredBy, declaredAt);
2501
+ return null;
2502
+ },
2503
+ );
1990
2504
  const countAttempts = db.query<{ n: number }, [string, number]>(
1991
2505
  `SELECT COUNT(*) AS n FROM runs WHERE project = ? AND issue = ?`,
1992
2506
  );
@@ -2111,10 +2625,43 @@ export function openStore(dbPath: string): Store {
2111
2625
  const reclassifyToInfra = db.query<unknown, [string]>(
2112
2626
  `UPDATE runs SET failureClass = 'ci-infra' WHERE id = ? AND failureClass = 'ci-deterministic'`,
2113
2627
  );
2628
+ // Two different questions, deliberately not one number (#132).
2629
+ //
2630
+ // `recoveredAt IS NULL` alone does NOT mean "awaiting recovery": two actions
2631
+ // are **recorded only** by design and never stamp it. `hold` is documented as
2632
+ // such right above `selectUnclassified`; `none` is what the settle sweep
2633
+ // writes beside `returned-for-revision` when a reviewer closes pushed work
2634
+ // without merging, because the remedy there is the queue label the sweep
2635
+ // deliberately leaves on, not an action any sweep takes.
2636
+ //
2637
+ // Counting them together is the #109 defect restated: on this fleet the line
2638
+ // read `returned-for-revision 44` while every genuinely actionable class sat
2639
+ // at zero, so a monotonically growing tally of *review decisions* crowded out
2640
+ // the classes an operator can act on. The split is on `recoveryAction`, which
2641
+ // the sweep already treats this way, so the surface now agrees with the model
2642
+ // instead of contradicting it.
2643
+ // Newest terminal runs, reduced to the two columns the spend-telemetry
2644
+ // judgement needs (#970). Terminal only: an in-flight run has not finished
2645
+ // reporting cost, so its 0.00 is not a telemetry statement.
2646
+ const selectSpendSamples = db.query<{ turns: number; spendUsd: number }, [string, number]>(
2647
+ `SELECT turns, spendUsd FROM runs
2648
+ WHERE project = ? AND endedAt IS NOT NULL
2649
+ ORDER BY endedAt DESC, rowid DESC
2650
+ LIMIT ?`,
2651
+ );
2114
2652
  const selectFailureClassCounts = db.query<{ cls: string; n: number }, [string]>(
2115
2653
  `SELECT failureClass AS cls, COUNT(*) AS n FROM runs
2116
2654
  WHERE project = ? AND failureClass IS NOT NULL AND recoveredAt IS NULL
2117
2655
  AND state IN ('blocked', 'killed', 'failed', 'orphaned', 'pushed-green')
2656
+ AND recoveryAction NOT IN ('none', 'hold')
2657
+ GROUP BY failureClass
2658
+ ORDER BY n DESC, failureClass ASC`,
2659
+ );
2660
+ const selectRecordedOnlyClassCounts = db.query<{ cls: string; n: number }, [string]>(
2661
+ `SELECT failureClass AS cls, COUNT(*) AS n FROM runs
2662
+ WHERE project = ? AND failureClass IS NOT NULL AND recoveredAt IS NULL
2663
+ AND state IN ('blocked', 'killed', 'failed', 'orphaned', 'pushed-green')
2664
+ AND recoveryAction IN ('none', 'hold')
2118
2665
  GROUP BY failureClass
2119
2666
  ORDER BY n DESC, failureClass ASC`,
2120
2667
  );
@@ -2203,6 +2750,24 @@ export function openStore(dbPath: string): Store {
2203
2750
  ORDER BY setAt DESC, rowid DESC
2204
2751
  LIMIT ?`,
2205
2752
  );
2753
+ // #807. `INSERT OR IGNORE` is the whole one-shot rule: the second spinning
2754
+ // cap on a chain changes no rows, so the decomposition verdict is reached
2755
+ // without any read-then-write window. Composed into `escalateModelTx`, which
2756
+ // is what makes the marker and the continuation it authorises one fact.
2757
+ const claimModelEscalation = db.query<
2758
+ unknown,
2759
+ [string, number, string, string, string, number]
2760
+ >(
2761
+ `INSERT OR IGNORE INTO model_escalations (project, issue, model, failureClass, runId, at)
2762
+ VALUES (?, ?, ?, ?, ?, ?)`,
2763
+ );
2764
+ const selectModelEscalation = db.query<ModelEscalationRow, [string, number]>(
2765
+ `SELECT model, failureClass, runId, at FROM model_escalations
2766
+ WHERE project = ? AND issue = ?`,
2767
+ );
2768
+ const stampRecovered = db.query<unknown, [number, string]>(
2769
+ `UPDATE runs SET recoveredAt = ? WHERE id = ?`,
2770
+ );
2206
2771
  // The settlement report row is kept for the audit trail; nothing pools it
2207
2772
  // across attempts any more (#488).
2208
2773
  const countStartedSince = db.query<{ n: number }, [string, number]>(
@@ -2212,6 +2777,47 @@ export function openStore(dbPath: string): Store {
2212
2777
  `SELECT COALESCE(SUM(spendUsd), 0) AS total
2213
2778
  FROM runs WHERE project = ? AND startedAt >= ?`,
2214
2779
  );
2780
+ // Reservations held by runs that are still live (#851). Summed rather than
2781
+ // tracked: a settle releases the unspent remainder by leaving LIVE_STATES,
2782
+ // with nothing to remember to write, and a daemon restart re-reads the same
2783
+ // reservations off the same rows. A run that spent MORE than it reserved is
2784
+ // counted at what it reserved — its overspend is already in `spendSince`.
2785
+ const sumLiveReservations = db.query<{ total: number }, SqlValue[]>(
2786
+ `SELECT COALESCE(SUM(spendReservedUsd), 0) AS total
2787
+ FROM runs
2788
+ WHERE project = ? AND state IN (${LIVE_PLACEHOLDERS}) AND spendReservedUsd IS NOT NULL`,
2789
+ );
2790
+ const upsertInstallSurfaces = db.query<
2791
+ unknown,
2792
+ [number, string, string | null, string | null, string | null, string | null, string | null]
2793
+ >(
2794
+ `INSERT INTO install_surfaces
2795
+ (host, at, cliVersion, ompVersion, herdrSource, tgInstalled, tgDaemon, tgPublished)
2796
+ VALUES ('host', ?, ?, ?, ?, ?, ?, ?)
2797
+ ON CONFLICT(host) DO UPDATE SET
2798
+ at = excluded.at,
2799
+ cliVersion = excluded.cliVersion,
2800
+ ompVersion = excluded.ompVersion,
2801
+ herdrSource = excluded.herdrSource,
2802
+ tgInstalled = excluded.tgInstalled,
2803
+ tgDaemon = excluded.tgDaemon,
2804
+ tgPublished = excluded.tgPublished`,
2805
+ );
2806
+ const selectInstallSurfaces = db.query<
2807
+ {
2808
+ at: number;
2809
+ cliVersion: string;
2810
+ ompVersion: string | null;
2811
+ herdrSource: string | null;
2812
+ tgInstalled: string | null;
2813
+ tgDaemon: string | null;
2814
+ tgPublished: string | null;
2815
+ },
2816
+ []
2817
+ >(
2818
+ `SELECT at, cliVersion, ompVersion, herdrSource, tgInstalled, tgDaemon, tgPublished
2819
+ FROM install_surfaces WHERE host = 'host'`,
2820
+ );
2215
2821
  const countNotified = db.query<{ n: number }, [string]>(
2216
2822
  `SELECT COUNT(*) AS n FROM notifications WHERE "key" = ?`,
2217
2823
  );
@@ -2282,6 +2888,26 @@ export function openStore(dbPath: string): Store {
2282
2888
  }
2283
2889
  },
2284
2890
  );
2891
+ // A groomed verdict describes an OPEN issue, and nothing retired one when its
2892
+ // issue closed (#964). `clearGroomedBlocked` above only ever cleared the two
2893
+ // per-pass admission holds, so every other row — `promotable`, `considered`,
2894
+ // `needs-product-decision`, an in-flight batch marker — accumulated forever.
2895
+ // Measured on this fleet 2026-08-23: 64 of 65 rows named a closed issue, and
2896
+ // all 26 `promotable` ones did, so `status` advertised 26 candidates ready to
2897
+ // promote on a repo with exactly one open issue.
2898
+ //
2899
+ // Deleted one row at a time inside a transaction rather than with a generated
2900
+ // `NOT IN (...)`: the closed set is computed by the caller from a complete
2901
+ // open-issue snapshot, and an over-long IN list is exactly where a partial
2902
+ // snapshot would quietly delete verdicts it never verified.
2903
+ const deleteGroomingRow = db.query<unknown, [string, number]>(
2904
+ `DELETE FROM grooming WHERE project = ? AND issue = ?`,
2905
+ );
2906
+ const retireGroomingTx = db.transaction((project: string, issues: readonly number[]): number => {
2907
+ let removed = 0;
2908
+ for (const issue of issues) removed += deleteGroomingRow.run(project, issue).changes;
2909
+ return removed;
2910
+ });
2285
2911
  const selectFrictionRollup = db.query<FrictionRollupRow, [string, string, string]>(
2286
2912
  `SELECT * FROM friction_rollups WHERE project = ? AND day = ? AND kind = ?`,
2287
2913
  );
@@ -2762,10 +3388,16 @@ export function openStore(dbPath: string): Store {
2762
3388
  },
2763
3389
  );
2764
3390
 
2765
- const insertDecision = db.query<never, [string, string, string, string, SqlValue, number, number, SqlValue, string]>(
3391
+ const insertDecision = db.query<
3392
+ never,
3393
+ [string, string, string, string, SqlValue, number, number, SqlValue, string, SqlValue, SqlValue]
3394
+ >(
2766
3395
  `INSERT INTO decisions
2767
- (id, project, kind, question, blocks, askedAt, expiresAt, condition, state)
2768
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
3396
+ (id, project, kind, question, blocks, askedAt, expiresAt, condition, state, groupId, specIssue)
3397
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
3398
+ );
3399
+ const selectDecisionGroup = db.query<DecisionRow, [string]>(
3400
+ `SELECT * FROM decisions WHERE groupId = ? ORDER BY askedAt ASC, rowid ASC`,
2769
3401
  );
2770
3402
  const selectDecision = db.query<DecisionRow, [string]>(`SELECT * FROM decisions WHERE id = ?`);
2771
3403
  const selectOpenDecisions = db.query<DecisionRow, [string]>(
@@ -2793,8 +3425,27 @@ export function openStore(dbPath: string): Store {
2793
3425
  // every tick. A `pr-checks-green` / `pr-review-ready` row whose PR head
2794
3426
  // moved is cleared first and re-marks here against the new head (`head`
2795
3427
  // binds the verdict to the exact commit it was observed at, #808).
2796
- const markConditionMetRow = db.query<never, [number, SqlValue, string]>(
2797
- `UPDATE decisions SET conditionMetAt = ?, conditionHead = ?
3428
+ // A watch's deadline starts when its condition fires, not when it was opened
3429
+ // (#966).
3430
+ //
3431
+ // `DECISION_WATCH_NEVER_EXPIRES` is right for the waiting phase, and its
3432
+ // reasoning is quoted where it is defined: a condition that has not fired is
3433
+ // not a question nobody answered, and expiring it could drop a release. But
3434
+ // the instant `conditionMetAt` is stamped the row stops waiting — it becomes
3435
+ // an actionable item, which is exactly what a question's TTL bounds. Measured
3436
+ // on this fleet 2026-08-23: three watches had carried
3437
+ // "[CONDITION MET — act on this now]" for 10-12 hours over work that was
3438
+ // entirely closed, and would have carried it forever.
3439
+ //
3440
+ // Only watches are touched: a question's `expiresAt` already runs from
3441
+ // `askedAt` and must keep doing so, so the deadline is written only where it
3442
+ // is currently infinite. `kind = 'watch'` is the guard rather than a value
3443
+ // comparison, because a question with a condition is still a question.
3444
+ const markConditionMetRow = db.query<never, [number, SqlValue, number, string]>(
3445
+ `UPDATE decisions
3446
+ SET conditionMetAt = ?,
3447
+ conditionHead = ?,
3448
+ expiresAt = CASE WHEN kind = 'watch' THEN ? ELSE expiresAt END
2798
3449
  WHERE id = ? AND state = 'open' AND conditionMetAt IS NULL`,
2799
3450
  );
2800
3451
  // The inverse of the mark, for the two conditions whose observation goes
@@ -2802,8 +3453,15 @@ export function openStore(dbPath: string): Store {
2802
3453
  // so a head change drops both the timestamp and the binding and the row
2803
3454
  // returns to pending (#808). Returns false when no met state existed to
2804
3455
  // drop, so re-runs are a no-op.
2805
- const clearConditionMetRow = db.query<never, [string]>(
2806
- `UPDATE decisions SET conditionMetAt = NULL, conditionHead = NULL
3456
+ //
3457
+ // The deadline goes back with it (#966): a PR that went red again is waiting,
3458
+ // not ignored, and leaving a countdown on it would expire a watch whose
3459
+ // condition is once again unfired.
3460
+ const clearConditionMetRow = db.query<never, [number, string]>(
3461
+ `UPDATE decisions
3462
+ SET conditionMetAt = NULL,
3463
+ conditionHead = NULL,
3464
+ expiresAt = CASE WHEN kind = 'watch' THEN ? ELSE expiresAt END
2807
3465
  WHERE id = ? AND state = 'open' AND conditionMetAt IS NOT NULL`,
2808
3466
  );
2809
3467
 
@@ -2889,6 +3547,155 @@ export function openStore(dbPath: string): Store {
2889
3547
  const requeueReviewRevisionRow = db.query<unknown, [string]>(
2890
3548
  `UPDATE review_revisions SET dispatchedAt = NULL WHERE id = ?`,
2891
3549
  );
3550
+ // The bounded infra retry (#903). One statement decides and writes: the
3551
+ // WHERE carries the bound, so two daemons cannot both spend the last retry
3552
+ // and a settled row can never be revived. `changes` is the decision.
3553
+ const retryReviewRevisionRow = db.query<unknown, [string, number]>(
3554
+ `UPDATE review_revisions
3555
+ SET dispatchedAt = NULL,
3556
+ infraRetries = COALESCE(infraRetries, 0) + 1
3557
+ WHERE id = ? AND settledAt IS NULL AND COALESCE(infraRetries, 0) < ?`,
3558
+ );
3559
+ const selectReviewRevisionById = db.query<ReviewRevisionRow, [string]>(
3560
+ `SELECT * FROM review_revisions WHERE id = ?`,
3561
+ );
3562
+ // The exact-head merge gate (#888): every row that stands between a PR and
3563
+ // a merge at one head — anything not yet settled (queued, or dispatched and
3564
+ // crashed mid-review) plus anything settled `failed` there. `skipped`,
3565
+ // `revised` and `pending` outcomes never block: a skip is an assessment
3566
+ // that never ran (the escalation for one says "merge it as it stands"), and
3567
+ // a revised/pending outcome belongs to a worker that pushed past the
3568
+ // reviewed head. Head comparison is case-insensitive; prUrl is matched
3569
+ // exactly — every writer records it from the same tracker payloads.
3570
+ //
3571
+ // A recorded clearance (#913) is the one disposition available at an
3572
+ // *unchanged* head, and it applies strictly backwards: the NOT EXISTS
3573
+ // requires the clearance to be at or after the row's own last activity
3574
+ // (`settledAt` when the round finished, else `requestedAt`). Without that
3575
+ // comparison a single clearance would bless every future finding at that
3576
+ // head — a strictly worse hole than the deadlock it fixes — so the
3577
+ // timestamp is the load-bearing half of this clause, not decoration. The
3578
+ // `outcome = 'failed'` term stays exactly as it was: a clearance is how
3579
+ // failed evidence is dispositioned, never by widening what counts as
3580
+ // blocking.
3581
+ const selectMergeBlockingReviews = db.query<ReviewRevisionRow, [string, string, string]>(
3582
+ `SELECT * FROM review_revisions
3583
+ WHERE project = ? AND prUrl = ? AND lower(headSha) = lower(?)
3584
+ AND (settledAt IS NULL OR outcome = 'failed')
3585
+ AND NOT EXISTS (
3586
+ SELECT 1 FROM review_clearances c
3587
+ WHERE c.project = review_revisions.project
3588
+ AND c.prUrl = review_revisions.prUrl
3589
+ AND lower(c.headSha) = lower(review_revisions.headSha)
3590
+ AND c.at >= COALESCE(review_revisions.settledAt, review_revisions.requestedAt)
3591
+ )
3592
+ ORDER BY requestedAt ASC, rowid ASC`,
3593
+ );
3594
+ const insertReviewClearance = db.query<unknown, [string, string, string, string, number, string, string]>(
3595
+ `INSERT INTO review_clearances (id, project, prUrl, headSha, at, by, reason)
3596
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
3597
+ );
3598
+ const selectReviewClearances = db.query<ReviewClearanceRow, [string, string, string]>(
3599
+ `SELECT * FROM review_clearances
3600
+ WHERE project = ? AND prUrl = ? AND lower(headSha) = lower(?)
3601
+ ORDER BY at DESC, rowid DESC`,
3602
+ );
3603
+
3604
+ // The review-ceiling adjudication lifecycle (#874). Head comparison is
3605
+ // case-insensitive everywhere, exactly as the merge gate and the clearance
3606
+ // lookup are: a 40-hex SHA read from two GitHub surfaces can differ in case
3607
+ // and mean the same commit.
3608
+ const insertReviewAdjudication = db.query<unknown, SqlValue[]>(
3609
+ `INSERT INTO review_adjudications
3610
+ (id, project, issue, prUrl, headSha, role, provenance, state, evidence, disposition,
3611
+ requestedAt, dispatchedAt, settledAt, findings)
3612
+ VALUES (?, ?, ?, ?, ?, ?, NULL, 'pending', NULL, NULL, ?, NULL, NULL, ?)`,
3613
+ );
3614
+ const selectAdjudicationForHead = db.query<ReviewAdjudicationRow, [string, string, string]>(
3615
+ `SELECT * FROM review_adjudications
3616
+ WHERE project = ? AND prUrl = ? AND lower(headSha) = lower(?)`,
3617
+ );
3618
+ // The non-terminal row for one PR, whatever head it is on. This is what makes
3619
+ // a moved head refuse rather than open a second live adjudication.
3620
+ const selectOpenAdjudicationForPr = db.query<ReviewAdjudicationRow, [string, string]>(
3621
+ `SELECT * FROM review_adjudications
3622
+ WHERE project = ? AND prUrl = ? AND settledAt IS NULL
3623
+ ORDER BY requestedAt ASC, rowid ASC
3624
+ LIMIT 1`,
3625
+ );
3626
+ const selectAdjudicationById = db.query<ReviewAdjudicationRow, [string]>(
3627
+ `SELECT * FROM review_adjudications WHERE id = ?`,
3628
+ );
3629
+ const selectOpenAdjudications = db.query<ReviewAdjudicationRow, [string]>(
3630
+ `SELECT * FROM review_adjudications
3631
+ WHERE project = ? AND settledAt IS NULL
3632
+ ORDER BY requestedAt ASC, rowid ASC`,
3633
+ );
3634
+ // Every review round recorded for one PR, oldest first (#932): the history the
3635
+ // adjudicator is shown. Keyed by PR rather than by run so a continuation that
3636
+ // inherited the PR does not hide its predecessor's findings.
3637
+ const selectRevisionsForPr = db.query<ReviewRevisionRow, [string, string]>(
3638
+ `SELECT * FROM review_revisions
3639
+ WHERE project = ? AND prUrl = ?
3640
+ ORDER BY round ASC, requestedAt ASC, rowid ASC`,
3641
+ );
3642
+ const selectAdjudicationsForPr = db.query<ReviewAdjudicationRow, [string, string]>(
3643
+ `SELECT * FROM review_adjudications
3644
+ WHERE project = ? AND prUrl = ?
3645
+ ORDER BY requestedAt DESC, rowid DESC`,
3646
+ );
3647
+ // Dispatch: pending -> running, recording the launch provenance. The WHERE
3648
+ // carries the transition, so two dispatchers racing one row cannot both
3649
+ // launch — the loser changes nothing and reads `false`.
3650
+ const markAdjudicationRunningRow = db.query<unknown, [string, number, string]>(
3651
+ `UPDATE review_adjudications
3652
+ SET state = 'running', provenance = ?, dispatchedAt = ?
3653
+ WHERE id = ? AND state = 'pending' AND settledAt IS NULL`,
3654
+ );
3655
+ // Settle: only from a non-terminal state, and only once. An invalid
3656
+ // transition changes nothing and preserves whatever the row already said,
3657
+ // which is the diagnostic evidence a later reader needs.
3658
+ const settleAdjudicationRow = db.query<unknown, SqlValue[]>(
3659
+ `UPDATE review_adjudications
3660
+ SET state = ?, evidence = COALESCE(?, evidence), settledAt = ?
3661
+ WHERE id = ? AND settledAt IS NULL`,
3662
+ );
3663
+ const recordAdjudicationDispositionRow = db.query<unknown, [string, string]>(
3664
+ `UPDATE review_adjudications SET disposition = ? WHERE id = ?`,
3665
+ );
3666
+
3667
+ // Admission is a transaction for the same reason the revision enqueue is: the
3668
+ // CLI and the daemon are separate processes with separate connections, so a
3669
+ // deferred read-then-write would let both see "no row" and both insert. It
3670
+ // begins IMMEDIATE, taking the write lock before the read, and the UNIQUE
3671
+ // index on (project, prUrl, headSha) is the structural backstop underneath.
3672
+ const openAdjudicationTx = db.transaction(
3673
+ (
3674
+ id: string,
3675
+ project: string,
3676
+ issue: number,
3677
+ prUrl: string,
3678
+ headSha: string,
3679
+ role: string,
3680
+ requestedAt: number,
3681
+ findings: string | undefined,
3682
+ ): ReviewAdjudicationAdmission => {
3683
+ const sameHead = selectAdjudicationForHead.get(project, prUrl, headSha);
3684
+ if (sameHead !== null && sameHead !== undefined) {
3685
+ return { kind: "existing", record: toReviewAdjudication(sameHead) };
3686
+ }
3687
+ const open = selectOpenAdjudicationForPr.get(project, prUrl);
3688
+ if (open !== null && open !== undefined) {
3689
+ return {
3690
+ kind: "refused",
3691
+ block: "in-flight-different-head",
3692
+ record: toReviewAdjudication(open),
3693
+ };
3694
+ }
3695
+ insertReviewAdjudication.run(id, project, issue, prUrl, headSha, role, requestedAt, findings ?? null);
3696
+ return { kind: "created", record: toReviewAdjudication(selectAdjudicationById.get(id)!) };
3697
+ },
3698
+ );
2892
3699
  // The states the review verb admits and the dispatch pass may therefore
2893
3700
  // claim (#795): a settled green run, or a capped/failed run that pushed one
2894
3701
  // (`failed` / `killed`). The terminal set is closed on purpose — a live row
@@ -3046,21 +3853,62 @@ export function openStore(dbPath: string): Store {
3046
3853
  WHERE project = ? AND issue = ? AND label = ?
3047
3854
  ORDER BY id DESC LIMIT 1`,
3048
3855
  );
3049
- const enqueueLabelOpsTx = db.transaction(
3856
+ /** The coalescing outbox insert, without a transaction of its own: the caller
3857
+ * owns one, so a decision that must be durable *with* its label transition
3858
+ * (the #807 escalation) can commit both or neither. */
3859
+ const enqueueLabelOpRows = (
3860
+ rows: readonly {
3861
+ project: string;
3862
+ issue: number;
3863
+ op: "add" | "remove";
3864
+ label: string;
3865
+ createdAt: number;
3866
+ }[],
3867
+ ): void => {
3868
+ for (const row of rows) {
3869
+ const latest = selectLatestLabelOp.get(row.project, row.issue, row.label);
3870
+ if (latest !== undefined && latest !== null && latest.op === row.op) continue;
3871
+ insertLabelOp.run(toSql(row.project), row.issue, row.op, row.label, row.createdAt);
3872
+ }
3873
+ };
3874
+ const enqueueLabelOpsTx = db.transaction(enqueueLabelOpRows);
3875
+
3876
+ /**
3877
+ * The whole #807 escalation as one durable decision: the chain's one-shot
3878
+ * marker, the failed → queue label swap that spends it, and the settling
3879
+ * run's recovery stamp.
3880
+ *
3881
+ * They commit together or not at all, which is the only way the promise
3882
+ * survives a restart. As three separate writes led by the marker, a daemon
3883
+ * that died right after it would come back to a chain reading as
3884
+ * already-escalated with no queue label owed: the continuation silently lost,
3885
+ * and the next cap on that chain judged a second one.
3886
+ *
3887
+ * A run id with no row is a caller bug, not a state: throwing rolls the
3888
+ * marker back rather than recording an escalation nothing can ever spend.
3889
+ */
3890
+ const escalateModelTx = db.transaction(
3050
3891
  (
3051
- rows: readonly {
3052
- project: string;
3053
- issue: number;
3054
- op: "add" | "remove";
3055
- label: string;
3056
- createdAt: number;
3057
- }[],
3058
- ): void => {
3059
- for (const row of rows) {
3060
- const latest = selectLatestLabelOp.get(row.project, row.issue, row.label);
3061
- if (latest !== undefined && latest !== null && latest.op === row.op) continue;
3062
- insertLabelOp.run(toSql(row.project), row.issue, row.op, row.label, row.createdAt);
3892
+ project: string,
3893
+ issue: number,
3894
+ model: string,
3895
+ failureClass: string,
3896
+ runId: string,
3897
+ swapFrom: string,
3898
+ queueLabel: string,
3899
+ at: number,
3900
+ ): boolean => {
3901
+ if (claimModelEscalation.run(project, issue, model, failureClass, runId, at).changes === 0) {
3902
+ return false;
3903
+ }
3904
+ enqueueLabelOpRows([
3905
+ { project, issue, op: "remove", label: swapFrom, createdAt: at },
3906
+ { project, issue, op: "add", label: queueLabel, createdAt: at },
3907
+ ]);
3908
+ if (stampRecovered.run(at, runId).changes === 0) {
3909
+ throw new Error(`escalateModel: run ${runId} does not exist`);
3063
3910
  }
3911
+ return true;
3064
3912
  },
3065
3913
  );
3066
3914
 
@@ -3121,6 +3969,7 @@ export function openStore(dbPath: string): Store {
3121
3969
  record.turns,
3122
3970
  record.maxTurns,
3123
3971
  record.spendUsd,
3972
+ toSql(record.spendReservedUsd),
3124
3973
  toSql(record.sessionFile),
3125
3974
  toSql(record.resumedFromRunId),
3126
3975
  toSql(record.lane),
@@ -3142,6 +3991,63 @@ export function openStore(dbPath: string): Store {
3142
3991
  );
3143
3992
  return record;
3144
3993
  };
3994
+ /** The one row writer both the single ask and a questionnaire item use. */
3995
+ const writeDecision = (draft: DecisionDraft): DecisionRecord => {
3996
+ const kind = draft.kind ?? "question";
3997
+ const record: DecisionRecord = {
3998
+ id: crypto.randomUUID(),
3999
+ project: draft.project,
4000
+ kind,
4001
+ question: draft.question,
4002
+ askedAt: draft.at,
4003
+ // The seven-day deadline is for a question a human never answered. A
4004
+ // watch has no such deadline: it is the orchestrator's own bookkeeping,
4005
+ // and expiring it silently could drop a release the day its condition
4006
+ // finally fires, so it is never due.
4007
+ expiresAt: kind === "watch" ? DECISION_WATCH_NEVER_EXPIRES : draft.at + DECISION_TTL_MS,
4008
+ state: "open",
4009
+ };
4010
+ if (draft.blocks !== undefined) record.blocks = draft.blocks;
4011
+ if (draft.condition !== undefined) record.condition = draft.condition;
4012
+ if (draft.groupId !== undefined) record.groupId = draft.groupId;
4013
+ if (draft.specIssue !== undefined) record.specIssue = draft.specIssue;
4014
+ insertDecision.run(
4015
+ record.id,
4016
+ record.project,
4017
+ record.kind,
4018
+ record.question,
4019
+ toSql(record.blocks),
4020
+ record.askedAt,
4021
+ record.expiresAt,
4022
+ toSql(record.condition),
4023
+ record.state,
4024
+ toSql(record.groupId),
4025
+ toSql(record.specIssue),
4026
+ );
4027
+ return record;
4028
+ };
4029
+
4030
+ /**
4031
+ * One spec-out questionnaire, written in one transaction (#947).
4032
+ *
4033
+ * The atomicity is the whole guarantee: every item is durable BEFORE the
4034
+ * single delivery, so a crash halfway can never put questions in front of an
4035
+ * operator that no row is waiting on. The group id is minted here rather than
4036
+ * supplied, because a caller that can name the group can also collide with
4037
+ * another one, and an empty batch is refused — a questionnaire of nothing is a
4038
+ * caller bug, not an empty delivery.
4039
+ */
4040
+ const createDecisionGroupTx = db.transaction(
4041
+ (drafts: readonly DecisionDraft[], specIssue: number): DecisionRecord[] => {
4042
+ if (drafts.length === 0) throw new Error("a questionnaire needs at least one item");
4043
+ if (!Number.isInteger(specIssue) || specIssue <= 0) {
4044
+ throw new Error(`a questionnaire must name the issue it specs, got ${String(specIssue)}`);
4045
+ }
4046
+ const groupId = crypto.randomUUID();
4047
+ return drafts.map((draft) => writeDecision({ ...draft, groupId, specIssue }));
4048
+ },
4049
+ );
4050
+
3145
4051
  const createRunTx = db.transaction((r: Omit<RunRecord, "id">): RunRecord => {
3146
4052
  if (r.state !== "claimed") return insertRunRecord(r);
3147
4053
  const turnOverride = selectTurnOverride.get(r.project, r.issue)?.maxTurns;
@@ -3212,6 +4118,10 @@ export function openStore(dbPath: string): Store {
3212
4118
  return selectLive.all(project, ...LIVE_STATES).map(toRecord);
3213
4119
  },
3214
4120
 
4121
+ leasedRuns(project: string): RunRecord[] {
4122
+ return selectLeased.all(project, ...LIVE_STATES, project).map(toRecord);
4123
+ },
4124
+
3215
4125
  retainedRuns(project: string): RunRecord[] {
3216
4126
  return selectRetained.all(project).map(toRecord);
3217
4127
  },
@@ -3250,6 +4160,111 @@ export function openStore(dbPath: string): Store {
3250
4160
  requeueReviewRevision(id: string): void {
3251
4161
  requeueReviewRevisionRow.run(id);
3252
4162
  },
4163
+ retryReviewRevisionAfterInfra(id: string, max: number): ReviewRevisionRetry {
4164
+ const requeued = retryReviewRevisionRow.run(id, max).changes > 0;
4165
+ const retries = selectReviewRevisionById.get(id)?.infraRetries ?? 0;
4166
+ return requeued ? { kind: "requeued", retries } : { kind: "exhausted", retries };
4167
+ },
4168
+ mergeBlockingReviews(project: string, prUrl: string, headSha: string): ReviewRevisionRecord[] {
4169
+ return selectMergeBlockingReviews.all(project, prUrl, headSha).map(toReviewRevision);
4170
+ },
4171
+ recordReviewClearance(clearance: {
4172
+ project: string;
4173
+ prUrl: string;
4174
+ headSha: string;
4175
+ by: string;
4176
+ reason: string;
4177
+ at?: number;
4178
+ }): ReviewClearance {
4179
+ const record: ReviewClearance = {
4180
+ id: newReportId(),
4181
+ project: clearance.project,
4182
+ prUrl: clearance.prUrl,
4183
+ headSha: clearance.headSha,
4184
+ at: clearance.at ?? Date.now(),
4185
+ by: clearance.by,
4186
+ reason: clearance.reason,
4187
+ };
4188
+ insertReviewClearance.run(
4189
+ record.id,
4190
+ record.project,
4191
+ record.prUrl,
4192
+ record.headSha,
4193
+ record.at,
4194
+ record.by,
4195
+ record.reason,
4196
+ );
4197
+ return record;
4198
+ },
4199
+ reviewClearances(project: string, prUrl: string, headSha: string): ReviewClearance[] {
4200
+ return selectReviewClearances.all(project, prUrl, headSha).map((row) => ({ ...row }));
4201
+ },
4202
+ openReviewAdjudication(draft: {
4203
+ project: string;
4204
+ issue: number;
4205
+ prUrl: string;
4206
+ headSha: string;
4207
+ role: string;
4208
+ requestedAt?: number;
4209
+ findings?: string;
4210
+ }): ReviewAdjudicationAdmission {
4211
+ // `immediate` = BEGIN IMMEDIATE: the write lock is taken before the
4212
+ // one-shot read, so two processes cannot both see "no adjudication" and
4213
+ // both insert one (#874).
4214
+ return openAdjudicationTx.immediate(
4215
+ newReportId(),
4216
+ draft.project,
4217
+ draft.issue,
4218
+ draft.prUrl,
4219
+ draft.headSha,
4220
+ draft.role,
4221
+ draft.requestedAt ?? Date.now(),
4222
+ draft.findings,
4223
+ );
4224
+ },
4225
+ markReviewAdjudicationRunning(
4226
+ id: string,
4227
+ provenance: ReviewAdjudicationProvenance,
4228
+ at: number = Date.now(),
4229
+ ): boolean {
4230
+ return markAdjudicationRunningRow.run(JSON.stringify(provenance), at, id).changes > 0;
4231
+ },
4232
+ settleReviewAdjudication(
4233
+ id: string,
4234
+ state: ReviewAdjudicationState,
4235
+ evidence?: string,
4236
+ at: number = Date.now(),
4237
+ ): boolean {
4238
+ // Terminal only, and once. A caller asking to settle into `pending` or
4239
+ // `running` is a bug, not a transition: refusing it here keeps "settled"
4240
+ // meaning "finished forever" for every reader.
4241
+ if (!TERMINAL_REVIEW_ADJUDICATION_STATES.some((terminal) => terminal === state)) return false;
4242
+ return settleAdjudicationRow.run(state, evidence ?? null, at, id).changes > 0;
4243
+ },
4244
+ recordReviewAdjudicationDisposition(id: string, disposition: string): boolean {
4245
+ return recordAdjudicationDispositionRow.run(disposition, id).changes > 0;
4246
+ },
4247
+ reviewAdjudication(id: string): ReviewAdjudicationRecord | undefined {
4248
+ const row = selectAdjudicationById.get(id);
4249
+ return row === null || row === undefined ? undefined : toReviewAdjudication(row);
4250
+ },
4251
+ reviewAdjudicationForHead(
4252
+ project: string,
4253
+ prUrl: string,
4254
+ headSha: string,
4255
+ ): ReviewAdjudicationRecord | undefined {
4256
+ const row = selectAdjudicationForHead.get(project, prUrl, headSha);
4257
+ return row === null || row === undefined ? undefined : toReviewAdjudication(row);
4258
+ },
4259
+ openReviewAdjudications(project: string): ReviewAdjudicationRecord[] {
4260
+ return selectOpenAdjudications.all(project).map(toReviewAdjudication);
4261
+ },
4262
+ reviewRevisionsForPr(project: string, prUrl: string): ReviewRevisionRecord[] {
4263
+ return selectRevisionsForPr.all(project, prUrl).map(toReviewRevision);
4264
+ },
4265
+ reviewAdjudicationsForPr(project: string, prUrl: string): ReviewAdjudicationRecord[] {
4266
+ return selectAdjudicationsForPr.all(project, prUrl).map(toReviewAdjudication);
4267
+ },
3253
4268
  claimRunForReview(runId: string): boolean {
3254
4269
  return claimRunForReviewTx(runId);
3255
4270
  },
@@ -3318,6 +4333,78 @@ export function openStore(dbPath: string): Store {
3318
4333
  return clearBaseFreezeRow.run(at, by, reason, project, repo).changes > 0;
3319
4334
  },
3320
4335
 
4336
+ activeReleaseComposition(project: string): ReleaseComposition | undefined {
4337
+ const row = selectActiveReleaseComposition.get(project);
4338
+ return row === null ? undefined : toReleaseComposition(row);
4339
+ },
4340
+
4341
+ declareReleaseComposition(
4342
+ project: string,
4343
+ composition: {
4344
+ campaign: string;
4345
+ allowedPrUrls: string[];
4346
+ declaredBy?: string;
4347
+ declaredAt?: number;
4348
+ },
4349
+ ): ReleaseComposition | undefined {
4350
+ // One active composition per project: a second declaration while one
4351
+ // is live is the caller's error to see, so hand the conflict straight
4352
+ // back. The transactional re-check keeps two declarers from racing.
4353
+ const conflict = declareReleaseCompositionTx(
4354
+ project,
4355
+ composition.campaign,
4356
+ JSON.stringify(composition.allowedPrUrls),
4357
+ composition.declaredBy ?? null,
4358
+ composition.declaredAt ?? Date.now(),
4359
+ );
4360
+ return conflict === null ? undefined : toReleaseComposition(conflict);
4361
+ },
4362
+
4363
+ completeReleaseComposition(project: string, by: string, reason: string, at = Date.now()): boolean {
4364
+ return retireReleaseCompositionRow.run(at, by, reason, project).changes > 0;
4365
+ },
4366
+
4367
+ cancelReleaseComposition(project: string, by: string, reason: string, at = Date.now()): boolean {
4368
+ return retireReleaseCompositionRow.run(at, by, reason, project).changes > 0;
4369
+ },
4370
+
4371
+ grantReleaseCompositionOverride(
4372
+ project: string,
4373
+ override: { campaign: string; prUrl: string; by: string; reason: string; at?: number },
4374
+ ): ReleaseCompositionOverride {
4375
+ const record: ReleaseCompositionOverride = {
4376
+ id: newReportId(),
4377
+ project,
4378
+ campaign: override.campaign,
4379
+ prUrl: override.prUrl,
4380
+ at: override.at ?? Date.now(),
4381
+ by: override.by,
4382
+ reason: override.reason,
4383
+ };
4384
+ insertReleaseCompositionOverride.run(
4385
+ record.id,
4386
+ record.project,
4387
+ record.campaign,
4388
+ record.prUrl,
4389
+ record.at,
4390
+ record.by,
4391
+ record.reason,
4392
+ );
4393
+ return record;
4394
+ },
4395
+
4396
+ releaseCompositionOverride(
4397
+ project: string,
4398
+ campaign: string,
4399
+ prUrl: string,
4400
+ ): ReleaseCompositionOverride | undefined {
4401
+ const row = selectReleaseCompositionOverride.get(project, campaign, prUrl);
4402
+ return row === null ? undefined : { ...row };
4403
+ },
4404
+
4405
+ releaseCompositionOverrides(project: string, campaign: string): ReleaseCompositionOverride[] {
4406
+ return selectReleaseCompositionOverrides.all(project, campaign).map((row) => ({ ...row }));
4407
+ },
3321
4408
 
3322
4409
  salvagedRuns(project: string): RunRecord[] {
3323
4410
  return selectSalvaged.all(project).map(toRecord);
@@ -3351,6 +4438,54 @@ export function openStore(dbPath: string): Store {
3351
4438
  return selectRunsForIssue.all(project, issue).map(toRecord);
3352
4439
  },
3353
4440
 
4441
+ /**
4442
+ * Spends the one model escalation this issue chain is allowed (#807), and
4443
+ * everything that makes it true: the marker, the `swapFrom` → `queueLabel`
4444
+ * transition that hands the continuation back, and the settling run's
4445
+ * recovery stamp — one transaction, so a restart never finds a chain that
4446
+ * reads as escalated with no continuation owed.
4447
+ *
4448
+ * `true` means this call spent it; `false` means the chain had already
4449
+ * escalated and nothing was written, which is the second-cap decomposition
4450
+ * verdict. Throws when `runId` names no row: the marker rolls back with it
4451
+ * rather than recording an escalation nothing can spend.
4452
+ */
4453
+ escalateModel(escalation: {
4454
+ project: string;
4455
+ issue: number;
4456
+ model: string;
4457
+ failureClass: FailureClass;
4458
+ runId: string;
4459
+ swapFrom: string;
4460
+ queueLabel: string;
4461
+ at?: number;
4462
+ }): boolean {
4463
+ return escalateModelTx(
4464
+ escalation.project,
4465
+ escalation.issue,
4466
+ escalation.model,
4467
+ escalation.failureClass,
4468
+ escalation.runId,
4469
+ escalation.swapFrom,
4470
+ escalation.queueLabel,
4471
+ escalation.at ?? Date.now(),
4472
+ );
4473
+ },
4474
+
4475
+ /** The escalation this chain has already spent, or `undefined` for a chain
4476
+ * that never capped — which is what a fresh issue always reads. */
4477
+ modelEscalation(project: string, issue: number): ModelEscalation | undefined {
4478
+ const row = selectModelEscalation.get(project, issue);
4479
+ return row === null
4480
+ ? undefined
4481
+ : {
4482
+ model: row.model,
4483
+ failureClass: row.failureClass as FailureClass,
4484
+ runId: row.runId,
4485
+ at: row.at,
4486
+ };
4487
+ },
4488
+
3354
4489
  setTurnOverride(project: string, issue: number, maxTurns: number, setAt = Date.now()): void {
3355
4490
  setTurnOverrideTx(project, issue, maxTurns, setAt);
3356
4491
  },
@@ -3383,6 +4518,10 @@ export function openStore(dbPath: string): Store {
3383
4518
  return sumSpendSince.get(project, sinceEpochMs)?.total ?? 0;
3384
4519
  },
3385
4520
 
4521
+ reservedSpendUsd(project: string): number {
4522
+ return sumLiveReservations.get(project, ...LIVE_STATES)?.total ?? 0;
4523
+ },
4524
+
3386
4525
  statsRuns(project: string, sinceEpochMs: number): RunRecord[] {
3387
4526
  return selectStatsRuns.all(project, sinceEpochMs, project, sinceEpochMs).map(toRecord);
3388
4527
  },
@@ -3524,6 +4663,32 @@ export function openStore(dbPath: string): Store {
3524
4663
  }));
3525
4664
  },
3526
4665
 
4666
+ recordInstallSurfaces(observation: InstallSurfaceObservation): void {
4667
+ upsertInstallSurfaces.run(
4668
+ observation.at,
4669
+ observation.cliVersion,
4670
+ observation.ompVersion ?? null,
4671
+ observation.herdrSource ?? null,
4672
+ observation.telegramInstalled ?? null,
4673
+ observation.telegramDaemon ?? null,
4674
+ observation.telegramPublished ?? null,
4675
+ );
4676
+ },
4677
+
4678
+ installSurfaces(): InstallSurfaceObservation | undefined {
4679
+ const row = selectInstallSurfaces.get();
4680
+ if (row === null) return undefined;
4681
+ return {
4682
+ at: row.at,
4683
+ cliVersion: row.cliVersion,
4684
+ ...(row.ompVersion === null ? {} : { ompVersion: row.ompVersion }),
4685
+ ...(row.herdrSource === null ? {} : { herdrSource: row.herdrSource }),
4686
+ ...(row.tgInstalled === null ? {} : { telegramInstalled: row.tgInstalled }),
4687
+ ...(row.tgDaemon === null ? {} : { telegramDaemon: row.tgDaemon }),
4688
+ ...(row.tgPublished === null ? {} : { telegramPublished: row.tgPublished }),
4689
+ };
4690
+ },
4691
+
3527
4692
  recordMaterialEvent(event: MaterialEventDraft): MaterialEvent {
3528
4693
  const record: MaterialEvent = {
3529
4694
  id: newReportId(),
@@ -3829,45 +4994,36 @@ export function openStore(dbPath: string): Store {
3829
4994
  return reclassifyToInfra.run(id).changes > 0;
3830
4995
  },
3831
4996
 
4997
+ recentSpendSamples(project: string, limit: number): { turns: number; spendUsd: number }[] {
4998
+ return selectSpendSamples.all(project, limit).map((row) => ({ ...row }));
4999
+ },
5000
+
3832
5001
  failureClassCounts(project: string): { cls: FailureClass; n: number }[] {
3833
5002
  return selectFailureClassCounts
3834
5003
  .all(project)
3835
5004
  .map((row) => ({ cls: row.cls as FailureClass, n: row.n }));
3836
5005
  },
3837
5006
 
5007
+ recordedOnlyClassCounts(project: string): { cls: FailureClass; n: number }[] {
5008
+ return selectRecordedOnlyClassCounts
5009
+ .all(project)
5010
+ .map((row) => ({ cls: row.cls as FailureClass, n: row.n }));
5011
+ },
5012
+
3838
5013
  recoveredSince(project: string, since: number): RunRecord[] {
3839
5014
  return selectRecoveredSince.all(project, since).map(toRecord);
3840
5015
  },
3841
5016
 
3842
5017
  createDecision(draft: DecisionDraft): DecisionRecord {
3843
- const kind = draft.kind ?? "question";
3844
- const record: DecisionRecord = {
3845
- id: crypto.randomUUID(),
3846
- project: draft.project,
3847
- kind,
3848
- question: draft.question,
3849
- askedAt: draft.at,
3850
- // The seven-day deadline is for a question a human never answered. A
3851
- // watch has no such deadline: it is the orchestrator's own bookkeeping,
3852
- // and expiring it silently could drop a release the day its condition
3853
- // finally fires, so it is never due.
3854
- expiresAt: kind === "watch" ? DECISION_WATCH_NEVER_EXPIRES : draft.at + DECISION_TTL_MS,
3855
- state: "open",
3856
- };
3857
- if (draft.blocks !== undefined) record.blocks = draft.blocks;
3858
- if (draft.condition !== undefined) record.condition = draft.condition;
3859
- insertDecision.run(
3860
- record.id,
3861
- record.project,
3862
- record.kind,
3863
- record.question,
3864
- toSql(record.blocks),
3865
- record.askedAt,
3866
- record.expiresAt,
3867
- toSql(record.condition),
3868
- record.state,
3869
- );
3870
- return record;
5018
+ return writeDecision(draft);
5019
+ },
5020
+
5021
+ createDecisionGroup(drafts: readonly DecisionDraft[], specIssue: number): DecisionRecord[] {
5022
+ return createDecisionGroupTx(drafts, specIssue);
5023
+ },
5024
+
5025
+ decisionGroup(groupId: string): DecisionRecord[] {
5026
+ return selectDecisionGroup.all(groupId).map(toDecision);
3871
5027
  },
3872
5028
 
3873
5029
  openDecisions(project: string): DecisionRecord[] {
@@ -3884,11 +5040,14 @@ export function openStore(dbPath: string): Store {
3884
5040
  },
3885
5041
 
3886
5042
  markDecisionConditionMet(id: string, at: number, head?: string): boolean {
3887
- return markConditionMetRow.run(at, toSql(head), id).changes > 0;
5043
+ // The window is computed from the observation instant, not from `now()`,
5044
+ // so a pass that observed a condition and wrote it a second later cannot
5045
+ // give the row a second longer to live.
5046
+ return markConditionMetRow.run(at, toSql(head), at + DECISION_TTL_MS, id).changes > 0;
3888
5047
  },
3889
5048
 
3890
5049
  clearDecisionConditionMet(id: string): boolean {
3891
- return clearConditionMetRow.run(id).changes > 0;
5050
+ return clearConditionMetRow.run(DECISION_WATCH_NEVER_EXPIRES, id).changes > 0;
3892
5051
  },
3893
5052
 
3894
5053
  expireDueDecisions(project: string, now: number): DecisionRecord[] {
@@ -4046,6 +5205,21 @@ export function openStore(dbPath: string): Store {
4046
5205
  reconcileGroomingTx(project, holds);
4047
5206
  },
4048
5207
 
5208
+ retireGroomingNotOpen(project: string, openIssues: readonly number[]): number[] {
5209
+ // The caller owns "is this snapshot complete"; this owns "which rows does
5210
+ // it condemn". Computed here rather than in SQL so the returned list is
5211
+ // exactly what was deleted, which is what the log line reports.
5212
+ const open = new Set(openIssues);
5213
+ const condemned = selectGroomingAll
5214
+ .all(project)
5215
+ .map(toGrooming)
5216
+ .filter((row) => !open.has(row.issue))
5217
+ .map((row) => row.issue);
5218
+ if (condemned.length === 0) return [];
5219
+ retireGroomingTx(project, condemned);
5220
+ return condemned;
5221
+ },
5222
+
4049
5223
  close(): void {
4050
5224
  db.close(false);
4051
5225
  },