omp-conductor 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +35 -1
  2. package/REFERENCE.md +61 -11
  3. package/agents/to-spec.md +94 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +35 -1
  6. package/src/admission.ts +204 -75
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +268 -7
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +62 -21
  11. package/src/briefs/to-spec.md +88 -0
  12. package/src/briefs/worker.md +2 -1
  13. package/src/cli.ts +124 -1
  14. package/src/command-help.ts +11 -0
  15. package/src/command-manifest.ts +38 -5
  16. package/src/commands/arm.ts +1 -1
  17. package/src/commands/context.ts +1 -0
  18. package/src/commands/drain.ts +176 -0
  19. package/src/commands/extend.ts +6 -10
  20. package/src/commands/intake.ts +4 -19
  21. package/src/commands/status.ts +5 -1
  22. package/src/commands/watch.ts +51 -16
  23. package/src/commands/worker.ts +9 -10
  24. package/src/config-schema.ts +43 -6
  25. package/src/config.ts +65 -9
  26. package/src/daemon.ts +879 -41
  27. package/src/dashboard/app.js +4 -1
  28. package/src/dashboard/server.ts +5 -2
  29. package/src/decisions.ts +243 -17
  30. package/src/diff-flags.ts +75 -1
  31. package/src/doctor.ts +60 -82
  32. package/src/escalate.ts +31 -14
  33. package/src/failure-class.ts +28 -2
  34. package/src/fleet.ts +239 -240
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +35 -1
  37. package/src/graph.ts +66 -1
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +242 -2
  40. package/src/lifecycle.ts +122 -1
  41. package/src/omp-settings.ts +19 -0
  42. package/src/omp.ts +183 -21
  43. package/src/orchestrator-tick.ts +1591 -32
  44. package/src/orchestrator.ts +12 -0
  45. package/src/privileged.ts +1 -4
  46. package/src/release-policy.ts +503 -9
  47. package/src/session-host.ts +65 -6
  48. package/src/settlement.ts +69 -17
  49. package/src/setup-host.ts +1225 -9
  50. package/src/setup-install.ts +28 -0
  51. package/src/setup-wizard.ts +154 -3
  52. package/src/setup.ts +83 -17
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +216 -12
  55. package/src/store.ts +443 -42
  56. package/src/to-spec.ts +408 -0
  57. package/src/tracker/github.ts +104 -14
  58. package/src/types.ts +405 -19
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +765 -56
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +12 -2
  65. package/src/worktree.ts +29 -12
package/src/store.ts CHANGED
@@ -27,10 +27,12 @@ import { DECISION_TTL_MS, DEFAULT_CAPS, DIGEST_BACKLOG_LIMIT } from "./types.ts"
27
27
  const DECISION_WATCH_NEVER_EXPIRES = Number.MAX_SAFE_INTEGER;
28
28
  import { dispatchInfra, neverStarted } from "./failure-class.ts";
29
29
  import type {
30
+ AdmissionHoldReason,
30
31
  BaseFreeze,
31
32
  BaseHealth,
32
33
  DecisionDraft,
33
34
  FailureClass,
35
+ FileLane,
34
36
  DecisionRecord,
35
37
  DecisionState,
36
38
  DigestBacklog,
@@ -41,6 +43,10 @@ import type {
41
43
  FrictionKind,
42
44
  FrictionObservation,
43
45
  FrictionSignal,
46
+ GraphToolsObservation,
47
+ GroomingDraft,
48
+ GroomingRecord,
49
+ GroomingVerdict,
44
50
  HistoricalInfraCandidate,
45
51
  HeldNotice,
46
52
  InterruptCategory,
@@ -62,6 +68,7 @@ import type {
62
68
  ReportRecord,
63
69
  RecoveryAction,
64
70
  ReviewReason,
71
+ ReviewRevisionEnqueue,
65
72
  ReviewRevisionOutcome,
66
73
  ReviewRevisionRecord,
67
74
  RunPatch,
@@ -120,6 +127,18 @@ function isFrictionHoldReason(reason: string): reason is FrictionAdmissionReason
120
127
  return FRICTION_HOLD_REASONS.has(reason as FrictionAdmissionReason);
121
128
  }
122
129
 
130
+ /**
131
+ * The admission hold reasons the grooming store persists as `blocked` verdicts
132
+ * (#735): the mechanical holds that will clear by themselves once the lane
133
+ * frees or the dependency closes. Every other hold reason is flow control of
134
+ * the moment (capacity, spend, parking) rather than durable per-issue
135
+ * inventory, so admission's reconcile records and clears exactly these.
136
+ */
137
+ const BLOCKED_GROOMING_HOLDS: Record<string, true> = {
138
+ "file-lane": true,
139
+ "depends-on": true,
140
+ };
141
+
123
142
  /**
124
143
  * Allowlist for `updateRun`'s dynamic SET clause. Column names cannot be bound
125
144
  * as parameters, so they are matched against this table rather than
@@ -147,6 +166,7 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
147
166
  autoCompactionCount: true,
148
167
  sessionFile: true,
149
168
  resumedFromRunId: true,
169
+ lane: true,
150
170
  prUrl: true,
151
171
  headSha: true,
152
172
  mergeSha: true,
@@ -166,6 +186,9 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
166
186
  recoveryAction: true,
167
187
  recoveredAt: true,
168
188
  model: true,
189
+ graphTools: true,
190
+ failureCharges: true,
191
+ continuationCharges: true,
169
192
  };
170
193
 
171
194
  /** Everything SQLite will accept from us. */
@@ -194,6 +217,7 @@ interface RunRow {
194
217
  autoCompactionCount: number | null;
195
218
  sessionFile: string | null;
196
219
  resumedFromRunId: string | null;
220
+ lane: string | null;
197
221
  prUrl: string | null;
198
222
  headSha: string | null;
199
223
  mergeSha: string | null;
@@ -213,6 +237,9 @@ interface RunRow {
213
237
  recoveryAction: string | null;
214
238
  recoveredAt: number | null;
215
239
  model: string | null;
240
+ graphTools: string | null;
241
+ failureCharges: number | null;
242
+ continuationCharges: number | null;
216
243
  }
217
244
 
218
245
  /** The `base_health` table exactly as SQLite hands it back. */
@@ -440,6 +467,27 @@ function toIntakeItem(row: IntakeRow): IntakeItem {
440
467
  return item;
441
468
  }
442
469
 
470
+ /** The `grooming` table exactly as SQLite hands it back (#735). */
471
+ interface GroomingRow {
472
+ project: string;
473
+ issue: number;
474
+ verdict: string;
475
+ reason: string;
476
+ evidence: string;
477
+ recordedAt: number;
478
+ }
479
+
480
+ function toGrooming(row: GroomingRow): GroomingRecord {
481
+ return {
482
+ project: row.project,
483
+ issue: row.issue,
484
+ verdict: row.verdict as GroomingVerdict,
485
+ reason: row.reason,
486
+ evidence: row.evidence,
487
+ recordedAt: row.recordedAt,
488
+ };
489
+ }
490
+
443
491
  /** The `orchestrator_incidents` table exactly as SQLite hands it back (#288). */
444
492
  interface OrchestratorIncidentRow {
445
493
  project: string;
@@ -522,6 +570,7 @@ CREATE TABLE IF NOT EXISTS runs (
522
570
  autoCompactionCount INTEGER,
523
571
  sessionFile TEXT,
524
572
  resumedFromRunId TEXT,
573
+ lane TEXT,
525
574
  prUrl TEXT,
526
575
  headSha TEXT,
527
576
  mergeSha TEXT,
@@ -539,7 +588,10 @@ CREATE TABLE IF NOT EXISTS runs (
539
588
  failureClass TEXT,
540
589
  recoveryAction TEXT,
541
590
  recoveredAt INTEGER,
542
- model TEXT
591
+ model TEXT,
592
+ graphTools TEXT,
593
+ failureCharges INTEGER,
594
+ continuationCharges INTEGER
543
595
  );
544
596
  CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
545
597
  CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
@@ -785,6 +837,15 @@ CREATE TABLE IF NOT EXISTS review_revisions (
785
837
  );
786
838
  CREATE INDEX IF NOT EXISTS review_revisions_pending
787
839
  ON review_revisions (project, dispatchedAt, runId);
840
+ -- One pending revision per run, enforced in the schema itself: the
841
+ -- schema-level backstop for the enqueue transaction (#786). The transaction
842
+ -- begins IMMEDIATE, so two connections serialize on the write lock before
843
+ -- either reads — this index makes a second pending row for one run
844
+ -- structurally impossible even if some other write path ever bypassed that
845
+ -- transaction.
846
+ CREATE UNIQUE INDEX IF NOT EXISTS review_revisions_one_pending_per_run
847
+ ON review_revisions (runId)
848
+ WHERE dispatchedAt IS NULL AND settledAt IS NULL;
788
849
 
789
850
  -- Questions the orchestrator has put to its operator, and their answers (#136).
790
851
  --
@@ -799,6 +860,12 @@ CREATE INDEX IF NOT EXISTS review_revisions_pending
799
860
  -- stored as the raw string an operator or session wrote, parsed on read. Once
800
861
  -- met, 'conditionMetAt' is what turns a parked question into one the tick digest
801
862
  -- pushes at the session.
863
+ --
864
+ -- 'conditionHead' binds a pr-checks-green / pr-review-ready verdict to the
865
+ -- exact PR head it was observed at (#808). The checks surface answers per
866
+ -- commit, so once the head changes the old verdict says nothing about the new
867
+ -- head: the evaluator clears the met state on a head move and the row returns
868
+ -- to pending.
802
869
  CREATE TABLE IF NOT EXISTS decisions (
803
870
  id TEXT PRIMARY KEY,
804
871
  project TEXT NOT NULL,
@@ -809,12 +876,32 @@ CREATE TABLE IF NOT EXISTS decisions (
809
876
  expiresAt INTEGER NOT NULL,
810
877
  condition TEXT,
811
878
  conditionMetAt INTEGER,
879
+ conditionHead TEXT,
812
880
  state TEXT NOT NULL,
813
881
  resolvedAt INTEGER,
814
882
  resolution TEXT
815
883
  );
816
884
  CREATE INDEX IF NOT EXISTS decisions_project_state ON decisions (project, state);
817
885
 
886
+ -- One issue's current grooming verdict (#735), the durable answer to "has
887
+ -- this been groomed, and what was decided" that the tick reads instead of
888
+ -- re-deriving why a runway cannot move. Admission's lane/dependency holds land
889
+ -- here as blocked (self-clearing on the next pass when the hold no longer
890
+ -- applies); #679's scout loop writes promotable and considered into the
891
+ -- same table. Keyed by project + issue, one row per issue, replaced in place.
892
+ -- Deliberately separate from decisions (operator questions, 7-day expiry) and
893
+ -- material_events (append-only digest outbox).
894
+ CREATE TABLE IF NOT EXISTS grooming (
895
+ project TEXT NOT NULL,
896
+ issue INTEGER NOT NULL CHECK (issue > 0),
897
+ verdict TEXT NOT NULL,
898
+ reason TEXT NOT NULL,
899
+ evidence TEXT NOT NULL,
900
+ recordedAt INTEGER NOT NULL CHECK (recordedAt >= 0),
901
+ PRIMARY KEY (project, issue)
902
+ );
903
+ CREATE INDEX IF NOT EXISTS grooming_project_verdict ON grooming (project, verdict);
904
+
818
905
  -- GitHub rate-limit refusals the tracker observed (#198). Written by the
819
906
  -- daemon's tracker hook, not polled, so status can show what GitHub actually
820
907
  -- refused next to the polled budget that might still look healthy. Only one
@@ -918,12 +1005,14 @@ CREATE INDEX IF NOT EXISTS daemon_stops_at ON daemon_stops (at);
918
1005
  function toSql(value: unknown): SqlValue {
919
1006
  if (value === undefined || value === null) return null;
920
1007
  if (typeof value === "boolean") return value ? 1 : 0;
921
- // `RunRecord.settlementFlags` is the only structured field on a run row, and
922
- // it is JSON in one TEXT column rather than a table of its own: the flags are
923
- // 1:1 with the run, written once at settlement, and never read except beside
924
- // the row they belong to. A join per `status` render would buy a query nobody
925
- // makes (#128).
926
- if (Array.isArray(value)) return JSON.stringify(value);
1008
+ // `RunRecord.settlementFlags` and `RunRecord.lane` are the structured fields
1009
+ // on a run row, and each is JSON in one TEXT column rather than a table of
1010
+ // its own: the flags are 1:1 with the run, written once at settlement, and
1011
+ // never read except beside the row they belong to (#128); the lane is the
1012
+ // enforced declaration, written once at dispatch and read by the admission
1013
+ // gate beside the row that owns it (#744). A join per read would buy a query
1014
+ // nobody makes.
1015
+ if (typeof value === "object") return JSON.stringify(value);
927
1016
  return value as SqlValue;
928
1017
  }
929
1018
 
@@ -976,6 +1065,55 @@ function toSettlementFlags(text: string): SettlementFlag[] | undefined {
976
1065
  }
977
1066
  }
978
1067
 
1068
+ /**
1069
+ * The persisted file lane out of its JSON column, or undefined for anything
1070
+ * that is not a `FileLane`-shaped object. Same defensive posture as
1071
+ * {@link toSettlementFlags}: a row hand-edited or written by a future version
1072
+ * drops its lane rather than throwing, and a dropped lane reads as "no
1073
+ * declaration" — the fail-open admission, never a refusal (#744).
1074
+ */
1075
+ function toLane(text: string): FileLane | undefined {
1076
+ try {
1077
+ const value: unknown = JSON.parse(text);
1078
+ const files = (value as { files?: unknown }).files;
1079
+ const source = (value as { source?: unknown }).source;
1080
+ const at = (value as { at?: unknown }).at;
1081
+ if (
1082
+ typeof value !== "object" ||
1083
+ value === null ||
1084
+ !Array.isArray(files) ||
1085
+ files.some((file) => typeof file !== "string") ||
1086
+ typeof source !== "string" ||
1087
+ (at !== "body" && typeof at !== "number")
1088
+ ) {
1089
+ return undefined;
1090
+ }
1091
+ return value as FileLane;
1092
+ } catch {
1093
+ return undefined;
1094
+ }
1095
+ }
1096
+
1097
+ /**
1098
+ * A run's graph-tools session observation out of its JSON column, or
1099
+ * undefined for anything that is not a `{ present, at }` object. Same
1100
+ * defensive posture as {@link toSettlementFlags}: a row hand-edited or written
1101
+ * by a future version drops its observation rather than throwing, and a
1102
+ * dropped observation reads as "no observation recorded" — never "graph tools
1103
+ * absent", which is the `present: false` truth value (#726).
1104
+ */
1105
+ function toGraphTools(text: string): GraphToolsObservation | undefined {
1106
+ try {
1107
+ const value: unknown = JSON.parse(text);
1108
+ const present = (value as { present?: unknown } | null | undefined)?.present;
1109
+ const at = (value as { at?: unknown } | null | undefined)?.at;
1110
+ if (typeof present !== "boolean" || typeof at !== "number") return undefined;
1111
+ return { present, at };
1112
+ } catch {
1113
+ return undefined;
1114
+ }
1115
+ }
1116
+
979
1117
  /**
980
1118
  * A NULL column becomes an absent property rather than an `undefined` one, so
981
1119
  * a record read back out of the store deep-equals the one that went in.
@@ -997,6 +1135,10 @@ function toRecord(row: RunRow): RunRecord {
997
1135
  };
998
1136
  if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
999
1137
  if (row.resumedFromRunId !== null) record.resumedFromRunId = row.resumedFromRunId;
1138
+ if (row.lane !== null) {
1139
+ const lane = toLane(row.lane);
1140
+ if (lane !== undefined) record.lane = lane;
1141
+ }
1000
1142
  if (row.prUrl !== null) record.prUrl = row.prUrl;
1001
1143
  if (row.headSha !== null) record.headSha = row.headSha;
1002
1144
  if (row.mergeSha !== null) record.mergeSha = row.mergeSha;
@@ -1028,7 +1170,15 @@ function toRecord(row: RunRow): RunRecord {
1028
1170
  if (row.failureClass !== null) record.failureClass = row.failureClass as FailureClass;
1029
1171
  if (row.recoveryAction !== null) record.recoveryAction = row.recoveryAction as RecoveryAction;
1030
1172
  if (row.recoveredAt !== null) record.recoveredAt = row.recoveredAt;
1173
+ if (row.failureCharges !== null && row.failureCharges > 0) record.failureCharges = row.failureCharges;
1174
+ if (row.continuationCharges !== null && row.continuationCharges > 0) {
1175
+ record.continuationCharges = row.continuationCharges;
1176
+ }
1031
1177
  if (row.model !== null) record.model = row.model;
1178
+ if (row.graphTools !== null) {
1179
+ const observed = toGraphTools(row.graphTools);
1180
+ if (observed !== undefined) record.graphTools = observed;
1181
+ }
1032
1182
  return record;
1033
1183
  }
1034
1184
 
@@ -1092,6 +1242,7 @@ interface DecisionRow {
1092
1242
  expiresAt: number;
1093
1243
  condition: string | null;
1094
1244
  conditionMetAt: number | null;
1245
+ conditionHead: string | null;
1095
1246
  state: string;
1096
1247
  resolvedAt: number | null;
1097
1248
  resolution: string | null;
@@ -1114,6 +1265,7 @@ function toDecision(row: DecisionRow): DecisionRecord {
1114
1265
  if (row.blocks !== null) record.blocks = row.blocks;
1115
1266
  if (row.condition !== null) record.condition = row.condition;
1116
1267
  if (row.conditionMetAt !== null) record.conditionMetAt = row.conditionMetAt;
1268
+ if (row.conditionHead !== null) record.conditionHead = row.conditionHead;
1117
1269
  if (row.resolvedAt !== null) record.resolvedAt = row.resolvedAt;
1118
1270
  if (row.resolution !== null) record.resolution = row.resolution;
1119
1271
  return record;
@@ -1501,6 +1653,13 @@ export function openStore(dbPath: string): Store {
1501
1653
  if (!columns.some((column) => column.name === "model")) {
1502
1654
  db.exec("ALTER TABLE runs ADD COLUMN model TEXT");
1503
1655
  }
1656
+ // The code-graph session observation (#726): rows written before the column
1657
+ // existed were never observed, so NULL is the honest reading of "no
1658
+ // observation recorded" — never "graph tools absent", which is the
1659
+ // `present: false` truth value of a run that did record one.
1660
+ if (!columns.some((column) => column.name === "graphTools")) {
1661
+ db.exec("ALTER TABLE runs ADD COLUMN graphTools TEXT");
1662
+ }
1504
1663
  // Resume provenance (#567): rows written before #564 landed were never
1505
1664
  // resumed, so NULL is the honest reading of "no resume happened" — never
1506
1665
  // "resumed from unknown". No backfill: the identity existed only in the
@@ -1508,6 +1667,15 @@ export function openStore(dbPath: string): Store {
1508
1667
  if (!columns.some((column) => column.name === "resumedFromRunId")) {
1509
1668
  db.exec("ALTER TABLE runs ADD COLUMN resumedFromRunId TEXT");
1510
1669
  }
1670
+ // The declared file lane (#744): rows written before the column existed were
1671
+ // admitted before the declaration was persisted, so NULL is the honest
1672
+ // reading of "no declaration survives on this row" — the same fail-open as
1673
+ // a run admitted without a lane today. No backfill: re-parsing issue bodies
1674
+ // now would re-derive a snapshot admission no longer enforces, which is
1675
+ // exactly the #288 shape the gate exists to avoid.
1676
+ if (!columns.some((column) => column.name === "lane")) {
1677
+ db.exec("ALTER TABLE runs ADD COLUMN lane TEXT");
1678
+ }
1511
1679
  // The count of in-session provider 429s a run recorded was first written by
1512
1680
  // the worker (#573). Rows predating the column are NULL, which is the honest
1513
1681
  // reading: the count was not recorded, so the classifier must not treat the
@@ -1532,6 +1700,18 @@ export function openStore(dbPath: string): Store {
1532
1700
  db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
1533
1701
  }
1534
1702
  }
1703
+ // The preserved budget charges (#795 review round 2): rows written before the
1704
+ // columns existed were never returned for revision from a terminal state, so
1705
+ // NULL is the honest reading of "no review claim preserved a charge on this
1706
+ // row", and the counters treat it as zero.
1707
+ for (const [name, type] of [
1708
+ ["failureCharges", "INTEGER"],
1709
+ ["continuationCharges", "INTEGER"],
1710
+ ] as const) {
1711
+ if (!columns.some((column) => column.name === name)) {
1712
+ db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
1713
+ }
1714
+ }
1535
1715
  // Existing held-notice rows predate exact digest ownership (#274). A NULL
1536
1716
  // report id is truthful for already-digested history and means "still owed"
1537
1717
  // only while digestedAt is also NULL.
@@ -1574,6 +1754,14 @@ export function openStore(dbPath: string): Store {
1574
1754
  if (!decisionColumns.some((column) => column.name === "kind")) {
1575
1755
  db.exec("ALTER TABLE decisions ADD COLUMN kind TEXT NOT NULL DEFAULT 'question'");
1576
1756
  }
1757
+ // The head binding (#808): rows written before it existed — met or not —
1758
+ // carry no binding and read back without one. Nothing is backfilled: fabricating
1759
+ // a head for an observation made before heads were recorded would be guessing
1760
+ // at history. The evaluator re-binds such a row on the next pass from a live
1761
+ // read, never from a migration guess.
1762
+ if (!decisionColumns.some((column) => column.name === "conditionHead")) {
1763
+ db.exec("ALTER TABLE decisions ADD COLUMN conditionHead TEXT");
1764
+ }
1577
1765
 
1578
1766
  // One-time repair, and the reason it lives here rather than in the classifier:
1579
1767
  // a turn-0 environment fault that was ALREADY classified `unknown` and
@@ -1667,12 +1855,15 @@ export function openStore(dbPath: string): Store {
1667
1855
  const insertRun = db.query<unknown, SqlValue[]>(
1668
1856
  `INSERT INTO runs (
1669
1857
  id, project, issue, repo, branch, worktree, state, attempt, turns,
1670
- maxTurns, spendUsd, sessionFile, resumedFromRunId, prUrl, headSha, mergeSha, baseRef,
1858
+ maxTurns, spendUsd, sessionFile, resumedFromRunId, lane, prUrl, headSha, mergeSha, baseRef,
1671
1859
  baseCheck, baseCheckAt, salvageSha, salvageError, salvageAckAt, startedAt,
1672
- endedAt, lastError, settlementFlags, report
1673
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1860
+ endedAt, lastError, settlementFlags, report, graphTools
1861
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1674
1862
  );
1675
1863
  const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
1864
+ const selectGraphToolsObs = db.query<{ graphTools: string }, [string]>(
1865
+ `SELECT graphTools FROM runs WHERE project = ? AND graphTools IS NOT NULL`,
1866
+ );
1676
1867
  const selectActive = db.query<RunRow, SqlValue[]>(
1677
1868
  `SELECT * FROM runs
1678
1869
  WHERE project = ? AND state IN (${ACTIVE_PLACEHOLDERS})
@@ -1805,19 +1996,43 @@ export function openStore(dbPath: string): Store {
1805
1996
  // fleet #350 spent its whole continuation budget on causes like those and then
1806
1997
  // sat in admission hold for six dispatch cycles. An unclassified row (NULL) is
1807
1998
  // counted exactly as before, so upgrading changes no existing budget.
1808
- const countFailures = db.query<{ n: number }, [string, number]>(
1809
- `SELECT COUNT(*) AS n FROM runs
1810
- WHERE project = ? AND issue = ? AND state = 'failed'
1811
- AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient', 'provider-capacity', 'returned-for-revision'))`,
1812
- );
1813
- const countContinuations = db.query<{ n: number }, [string, number]>(
1814
- `SELECT COUNT(*) AS n FROM runs
1815
- WHERE project = ? AND issue = ?
1816
- AND (
1817
- (state IN ('killed', 'orphaned', 'blocked')
1818
- AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient', 'provider-capacity')))
1819
- OR (state = 'failed' AND failureClass = 'returned-for-revision')
1820
- )`,
1999
+ // The classes a terminal row must NOT carry to count as a charged failed
2000
+ // attempt or continuation the budget counters' exclusions, shared verbatim
2001
+ // with the review claim that preserves a charge (#795): the claim evaluates
2002
+ // the same predicate against the pre-claim row, so a preserved charge can
2003
+ // never disagree with what the counters would have counted.
2004
+ const CHARGEABLE_FAILURE_EXCLUSIONS =
2005
+ "'ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient', 'provider-capacity', 'returned-for-revision'";
2006
+ const CHARGEABLE_CONTINUATION_EXCLUSIONS =
2007
+ "'admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient', 'provider-capacity'";
2008
+
2009
+ // A row whose review revision preserved a charge (#795 review round 2) keeps
2010
+ // counting after the claim leaves it `pushed-green`. The preserved columns
2011
+ // are per-category INTEGER totals, summed here — not a single enum over
2012
+ // rows — so repeated same-category terminal events across consecutive review
2013
+ // rounds each keep their charge: a killed run contributes one continuation,
2014
+ // its review claim moves that charge into `continuationCharges`, and a
2015
+ // second cap on the resumed round is counted by the state branch on top of
2016
+ // it. The two sums can never double-count one event: a terminal event is
2017
+ // counted by the state branch only while the row is still terminal, and the
2018
+ // claim that flips it to `running` is the same atomic statement that adds it
2019
+ // to the preserved column.
2020
+ const countFailures = db.query<{ n: number }, SqlValue[]>(
2021
+ `SELECT
2022
+ (SELECT COALESCE(SUM(failureCharges), 0) FROM runs WHERE project = ? AND issue = ?)
2023
+ + (SELECT COUNT(*) FROM runs WHERE project = ? AND issue = ?
2024
+ AND state = 'failed'
2025
+ AND (failureClass IS NULL OR failureClass NOT IN (${CHARGEABLE_FAILURE_EXCLUSIONS}))) AS n`,
2026
+ );
2027
+ const countContinuations = db.query<{ n: number }, SqlValue[]>(
2028
+ `SELECT
2029
+ (SELECT COALESCE(SUM(continuationCharges), 0) FROM runs WHERE project = ? AND issue = ?)
2030
+ + (SELECT COUNT(*) FROM runs WHERE project = ? AND issue = ?
2031
+ AND (
2032
+ (state IN ('killed', 'orphaned', 'blocked')
2033
+ AND (failureClass IS NULL OR failureClass NOT IN (${CHARGEABLE_CONTINUATION_EXCLUSIONS})))
2034
+ OR (state = 'failed' AND failureClass = 'returned-for-revision')
2035
+ )) AS n`,
1821
2036
  );
1822
2037
  // How many times one issue reached a given class. Recovery uses it to bound a
1823
2038
  // retry loop whose cause is persistent (e.g. a mirror that will not refresh):
@@ -2029,6 +2244,44 @@ export function openStore(dbPath: string): Store {
2029
2244
  const selectDispatch = db.query<{ summary: string }, [string]>(
2030
2245
  `SELECT summary FROM dispatch_summaries WHERE project = ?`,
2031
2246
  );
2247
+ const selectGrooming = db.query<GroomingRow, [string, number]>(
2248
+ `SELECT project, issue, verdict, reason, evidence, recordedAt
2249
+ FROM grooming WHERE project = ? AND issue = ?`,
2250
+ );
2251
+ const selectGroomingAll = db.query<GroomingRow, [string]>(
2252
+ `SELECT project, issue, verdict, reason, evidence, recordedAt
2253
+ FROM grooming WHERE project = ? ORDER BY issue ASC`,
2254
+ );
2255
+ const selectGroomingByVerdict = db.query<GroomingRow, [string, string]>(
2256
+ `SELECT project, issue, verdict, reason, evidence, recordedAt
2257
+ FROM grooming WHERE project = ? AND verdict = ? ORDER BY issue ASC`,
2258
+ );
2259
+ const upsertGroomingRow = db.query<unknown, [string, number, string, string, string, number]>(
2260
+ `INSERT INTO grooming (project, issue, verdict, reason, evidence, recordedAt)
2261
+ VALUES (?, ?, ?, ?, ?, ?)
2262
+ ON CONFLICT(project, issue) DO UPDATE SET
2263
+ verdict = excluded.verdict,
2264
+ reason = excluded.reason,
2265
+ evidence = excluded.evidence,
2266
+ recordedAt = excluded.recordedAt`,
2267
+ );
2268
+ const clearGroomedBlocked = db.query<unknown, [string]>(
2269
+ `DELETE FROM grooming
2270
+ WHERE project = ? AND verdict = 'blocked'
2271
+ AND reason IN ('file-lane', 'depends-on')`,
2272
+ );
2273
+ // One transaction, so a pass can never leave the blocked inventory half
2274
+ // written: the stale rows are cleared (the hold no longer applies) and the
2275
+ // current holds re-recorded atomically.
2276
+ const reconcileGroomingTx = db.transaction(
2277
+ (project: string, holds: readonly { issue: number; reason: AdmissionHoldReason; detail?: string }[]): void => {
2278
+ clearGroomedBlocked.run(project);
2279
+ for (const hold of holds) {
2280
+ if (BLOCKED_GROOMING_HOLDS[hold.reason] !== true) continue;
2281
+ upsertGroomingRow.run(project, hold.issue, "blocked", hold.reason, hold.detail ?? hold.reason, Date.now());
2282
+ }
2283
+ },
2284
+ );
2032
2285
  const selectFrictionRollup = db.query<FrictionRollupRow, [string, string, string]>(
2033
2286
  `SELECT * FROM friction_rollups WHERE project = ? AND day = ? AND kind = ?`,
2034
2287
  );
@@ -2535,12 +2788,24 @@ export function openStore(dbPath: string): Store {
2535
2788
  `UPDATE decisions SET state = 'expired', resolvedAt = ?, resolution = 'expired unanswered'
2536
2789
  WHERE project = ? AND state = 'open' AND expiresAt <= ?`,
2537
2790
  );
2538
- // Set once: a condition that flickers must not keep moving its own timestamp,
2539
- // or the digest's "act on this now" would reset to looking new every tick.
2540
- const markConditionMetRow = db.query<never, [number, string]>(
2541
- `UPDATE decisions SET conditionMetAt = ?
2791
+ // Set once per head: a condition that flickers must not keep moving its own
2792
+ // timestamp, or the digest's "act on this now" would reset to looking new
2793
+ // every tick. A `pr-checks-green` / `pr-review-ready` row whose PR head
2794
+ // moved is cleared first and re-marks here against the new head (`head`
2795
+ // 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 = ?
2542
2798
  WHERE id = ? AND state = 'open' AND conditionMetAt IS NULL`,
2543
2799
  );
2800
+ // The inverse of the mark, for the two conditions whose observation goes
2801
+ // stale: a `pr-checks-green` / `pr-review-ready` binding is about a commit,
2802
+ // so a head change drops both the timestamp and the binding and the row
2803
+ // returns to pending (#808). Returns false when no met state existed to
2804
+ // drop, so re-runs are a no-op.
2805
+ const clearConditionMetRow = db.query<never, [string]>(
2806
+ `UPDATE decisions SET conditionMetAt = NULL, conditionHead = NULL
2807
+ WHERE id = ? AND state = 'open' AND conditionMetAt IS NOT NULL`,
2808
+ );
2544
2809
 
2545
2810
  const insertVerbLedger = db.query<unknown, SqlValue[]>(
2546
2811
  `INSERT INTO verb_ledger
@@ -2624,16 +2889,97 @@ export function openStore(dbPath: string): Store {
2624
2889
  const requeueReviewRevisionRow = db.query<unknown, [string]>(
2625
2890
  `UPDATE review_revisions SET dispatchedAt = NULL WHERE id = ?`,
2626
2891
  );
2627
- const claimRunForReviewRow = db.query<unknown, [string]>(
2892
+ // The states the review verb admits and the dispatch pass may therefore
2893
+ // claim (#795): a settled green run, or a capped/failed run that pushed one
2894
+ // (`failed` / `killed`). The terminal set is closed on purpose — a live row
2895
+ // (`running` / `claimed`) is already doing its own work, a `pushed-pending`
2896
+ // PR is not green yet, and a `blocked` / `orphaned` / `stopped` / `merged`
2897
+ // row is not work the orchestrator returned for revision.
2898
+ //
2899
+ // The terminal leg does two things in the same atomic statement (#795 review
2900
+ // rounds 1-2): it PRESERVES the budget charge the row's terminal event was
2901
+ // charging — added to a per-category INTEGER column, so repeated
2902
+ // same-category terminal events across consecutive review rounds each keep
2903
+ // their charge and `maxAttemptsPerIssue` / `maxContinuationsPerIssue` keep
2904
+ // bounding the lifecycle — and it CLEARS the lifecycle recovery metadata
2905
+ // (`failureClass` / `recoveryAction` / `recoveredAt`), so the resumed
2906
+ // execution's own terminal failure is classified and recovered fresh instead
2907
+ // of being excluded from the classification sweep by the original terminal's
2908
+ // recovered state. The state branch stops counting the row the instant this
2909
+ // statement flips it to `running`, and the preserved column starts counting
2910
+ // it in the same statement: one event, one count, never both.
2911
+ const claimRunForReviewTerminal = db.query<unknown, [string]>(
2912
+ `UPDATE runs SET
2913
+ state = 'running',
2914
+ endedAt = NULL,
2915
+ failureClass = NULL,
2916
+ recoveryAction = NULL,
2917
+ recoveredAt = NULL,
2918
+ failureCharges = COALESCE(failureCharges, 0) + CASE
2919
+ WHEN state = 'failed' AND (failureClass IS NULL OR failureClass NOT IN (${CHARGEABLE_FAILURE_EXCLUSIONS})) THEN 1
2920
+ ELSE 0
2921
+ END,
2922
+ continuationCharges = COALESCE(continuationCharges, 0) + CASE
2923
+ WHEN state = 'failed' AND failureClass = 'returned-for-revision' THEN 1
2924
+ WHEN state = 'killed' AND (failureClass IS NULL OR failureClass NOT IN (${CHARGEABLE_CONTINUATION_EXCLUSIONS})) THEN 1
2925
+ ELSE 0
2926
+ END
2927
+ WHERE id = ? AND state IN ('failed', 'killed')`,
2928
+ );
2929
+ // The settled-green leg: a pushed row carries no charge and no recovery
2930
+ // metadata, so the claim stays exactly what it always was.
2931
+ const claimRunForReviewGreen = db.query<unknown, [string]>(
2628
2932
  `UPDATE runs SET state = 'running', endedAt = NULL WHERE id = ? AND state = 'pushed-green'`,
2629
2933
  );
2934
+ // Exactly one leg can match (the row is either terminal or pushed-green at
2935
+ // the moment of the claim), a repeated claim matches neither, and the two
2936
+ // statements plus the read of `changes` are one transaction — the dispatch
2937
+ // pass can never see a half-claimed row.
2938
+ const claimRunForReviewTx = db.transaction((runId: string): boolean => {
2939
+ if (claimRunForReviewTerminal.run(runId).changes > 0) return true;
2940
+ return claimRunForReviewGreen.run(runId).changes > 0;
2941
+ });
2630
2942
 
2631
- // The claim and the insert share a transaction so two concurrent revision
2632
- // requests for one run cannot both win: the second sees the first's pending
2633
- // row and returns undefined, which the verb turns into the in-flight refusal.
2634
- const claimReviewRevision = db.transaction(
2635
- (draft: Omit<ReviewRevisionRecord, "id">): ReviewRevisionRecord | undefined => {
2636
- if (selectPendingReviewForRun.get(draft.project, draft.runId) !== null) return undefined;
2943
+ // Appending is a single-statement concatenation so even a write from a
2944
+ // second connection cannot lose one side of the merge: SQLite holds the
2945
+ // row's write lock for the whole UPDATE and re-reads the current value, so
2946
+ // the second append lands on the first's result rather than on the read it
2947
+ // took before the first committed.
2948
+ const appendReviewFindingsRow = db.query<unknown, [string, string]>(
2949
+ `UPDATE review_revisions SET findings = findings || ? WHERE id = ?`,
2950
+ );
2951
+
2952
+ // The decision and the write share one transaction (#677, #786): a same-run,
2953
+ // same-head finding folds into the revision still waiting to dispatch
2954
+ // (appended — one round, one row, one worker attempt, and the resumed
2955
+ // session reads both findings), while a revision already pending at a
2956
+ // different head — a review of a moved PR — is refused rather than amended.
2957
+ // Two concurrent same-head requests therefore cannot both create a row or
2958
+ // overwrite each other: whichever commits first, the other sees its pending
2959
+ // row inside this transaction and appends to it. The verb turns the `refused`
2960
+ // outcome into the in-flight refusal.
2961
+ //
2962
+ // The transaction is begun IMMEDIATE — the write lock is taken BEFORE the
2963
+ // pending-row read. The CLI and the daemon are separate processes with
2964
+ // separate SQLite connections; under a deferred read-then-write both could
2965
+ // read "no pending row" before either wrote, and the second INSERT would
2966
+ // then fail with SQLITE_BUSY_SNAPSHOT instead of re-reading the winner's
2967
+ // committed row — losing the second finding as an untyped verb error. With
2968
+ // IMMEDIATE, the losing connection blocks on the write lock (bounded by the
2969
+ // 5s busy timeout) and, once the winner commits, reads the fresh state and
2970
+ // appends to the winner's row. The unique pending-per-run index remains as
2971
+ // the schema-level backstop.
2972
+ const enqueueReviewRevisionTx = db.transaction(
2973
+ (draft: Omit<ReviewRevisionRecord, "id">): ReviewRevisionEnqueue => {
2974
+ const pending = selectPendingReviewForRun.get(draft.project, draft.runId);
2975
+ if (pending !== null) {
2976
+ if (pending.headSha !== draft.headSha) {
2977
+ return { kind: "refused", block: "pending-different-head" };
2978
+ }
2979
+ const merged = `${pending.findings}\n\n${draft.findings}`;
2980
+ appendReviewFindingsRow.run(`\n\n${draft.findings}`, pending.id);
2981
+ return { kind: "appended", record: { ...toReviewRevision(pending), findings: merged } };
2982
+ }
2637
2983
  const record: ReviewRevisionRecord = { ...draft, id: crypto.randomUUID() };
2638
2984
  insertReviewRevision.run(
2639
2985
  record.id,
@@ -2648,7 +2994,7 @@ export function openStore(dbPath: string): Store {
2648
2994
  record.sessionFile ?? null,
2649
2995
  record.requestedAt,
2650
2996
  );
2651
- return record;
2997
+ return { kind: "created", record };
2652
2998
  },
2653
2999
  );
2654
3000
 
@@ -2777,6 +3123,7 @@ export function openStore(dbPath: string): Store {
2777
3123
  record.spendUsd,
2778
3124
  toSql(record.sessionFile),
2779
3125
  toSql(record.resumedFromRunId),
3126
+ toSql(record.lane),
2780
3127
  toSql(record.prUrl),
2781
3128
  toSql(record.headSha),
2782
3129
  toSql(record.mergeSha),
@@ -2791,6 +3138,7 @@ export function openStore(dbPath: string): Store {
2791
3138
  toSql(record.lastError),
2792
3139
  toSql(record.settlementFlags),
2793
3140
  toSql(record.report),
3141
+ toSql(record.graphTools),
2794
3142
  );
2795
3143
  return record;
2796
3144
  };
@@ -2842,6 +3190,20 @@ export function openStore(dbPath: string): Store {
2842
3190
  return row ? toRecord(row) : undefined;
2843
3191
  },
2844
3192
 
3193
+ graphToolsObservationCounts(project: string): { recorded: number; present: number; absent: number } {
3194
+ // Counted in JS through the same defensive converter `toRecord` uses, so
3195
+ // a hand-edited row can never move a truth-value count (#726).
3196
+ let present = 0;
3197
+ let absent = 0;
3198
+ for (const row of selectGraphToolsObs.all(project)) {
3199
+ const observed = toGraphTools(row.graphTools);
3200
+ if (observed === undefined) continue;
3201
+ if (observed.present) present += 1;
3202
+ else absent += 1;
3203
+ }
3204
+ return { recorded: present + absent, present, absent };
3205
+ },
3206
+
2845
3207
  activeRuns(project: string): RunRecord[] {
2846
3208
  return selectActive.all(project, ...ACTIVE_STATES).map(toRecord);
2847
3209
  },
@@ -2860,8 +3222,15 @@ export function openStore(dbPath: string): Store {
2860
3222
  runsForProjectPr(project: string, prUrl: string, mergedSinceEpochMs: number): RunRecord[] {
2861
3223
  return selectRunsForPr.all(project, prUrl, mergedSinceEpochMs).map(toRecord);
2862
3224
  },
2863
- createReviewRevision(draft: Omit<ReviewRevisionRecord, "id">): ReviewRevisionRecord | undefined {
2864
- return claimReviewRevision(draft);
3225
+ enqueueReviewRevision(draft: Omit<ReviewRevisionRecord, "id">): ReviewRevisionEnqueue {
3226
+ // `immediate` = BEGIN IMMEDIATE: the write lock is acquired before the
3227
+ // pending-row read, so a second connection's transaction cannot observe
3228
+ // a snapshot older than the first connection's commit (#786).
3229
+ return enqueueReviewRevisionTx.immediate(draft);
3230
+ },
3231
+ pendingReviewForRun(project: string, runId: string): ReviewRevisionRecord | undefined {
3232
+ const row = selectPendingReviewForRun.get(project, runId);
3233
+ return row === null ? undefined : toReviewRevision(row);
2865
3234
  },
2866
3235
  pendingReviewRevisions(project: string): ReviewRevisionRecord[] {
2867
3236
  return selectPendingReviewRevisions.all(project).map(toReviewRevision);
@@ -2882,7 +3251,7 @@ export function openStore(dbPath: string): Store {
2882
3251
  requeueReviewRevisionRow.run(id);
2883
3252
  },
2884
3253
  claimRunForReview(runId: string): boolean {
2885
- return claimRunForReviewRow.run(runId).changes > 0;
3254
+ return claimRunForReviewTx(runId);
2886
3255
  },
2887
3256
  runsNeedingBaseCheck(project: string, limit = 20): RunRecord[] {
2888
3257
  return selectPendingBaseChecks.all(project, limit).map(toRecord);
@@ -2963,11 +3332,11 @@ export function openStore(dbPath: string): Store {
2963
3332
  },
2964
3333
 
2965
3334
  failuresFor(project: string, issue: number): number {
2966
- return countFailures.get(project, issue)?.n ?? 0;
3335
+ return countFailures.get(project, issue, project, issue)?.n ?? 0;
2967
3336
  },
2968
3337
 
2969
3338
  continuationsFor(project: string, issue: number): number {
2970
- return countContinuations.get(project, issue)?.n ?? 0;
3339
+ return countContinuations.get(project, issue, project, issue)?.n ?? 0;
2971
3340
  },
2972
3341
 
2973
3342
  classCountFor(project: string, issue: number, cls: FailureClass): number {
@@ -3514,8 +3883,12 @@ export function openStore(dbPath: string): Store {
3514
3883
  return resolveDecisionRow.run(state, resolution, at, id).changes > 0;
3515
3884
  },
3516
3885
 
3517
- markDecisionConditionMet(id: string, at: number): boolean {
3518
- return markConditionMetRow.run(at, id).changes > 0;
3886
+ markDecisionConditionMet(id: string, at: number, head?: string): boolean {
3887
+ return markConditionMetRow.run(at, toSql(head), id).changes > 0;
3888
+ },
3889
+
3890
+ clearDecisionConditionMet(id: string): boolean {
3891
+ return clearConditionMetRow.run(id).changes > 0;
3519
3892
  },
3520
3893
 
3521
3894
  expireDueDecisions(project: string, now: number): DecisionRecord[] {
@@ -3645,6 +4018,34 @@ export function openStore(dbPath: string): Store {
3645
4018
  return result.changes > 0;
3646
4019
  },
3647
4020
 
4021
+ upsertGrooming(draft: GroomingDraft): void {
4022
+ upsertGroomingRow.run(
4023
+ draft.project,
4024
+ draft.issue,
4025
+ draft.verdict,
4026
+ draft.reason,
4027
+ draft.evidence,
4028
+ draft.at,
4029
+ );
4030
+ },
4031
+
4032
+ grooming(project: string, issue: number): GroomingRecord | undefined {
4033
+ const row = selectGrooming.get(project, issue);
4034
+ return row === null ? undefined : toGrooming(row);
4035
+ },
4036
+
4037
+ groomingVerdicts(project: string, verdict?: GroomingVerdict): GroomingRecord[] {
4038
+ const rows = verdict === undefined ? selectGroomingAll.all(project) : selectGroomingByVerdict.all(project, verdict);
4039
+ return rows.map(toGrooming);
4040
+ },
4041
+
4042
+ reconcileGrooming(
4043
+ project: string,
4044
+ holds: readonly { issue: number; reason: AdmissionHoldReason; detail?: string }[],
4045
+ ): void {
4046
+ reconcileGroomingTx(project, holds);
4047
+ },
4048
+
3648
4049
  close(): void {
3649
4050
  db.close(false);
3650
4051
  },