omp-conductor 0.15.12 → 0.16.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 (55) hide show
  1. package/REFERENCE.md +81 -6
  2. package/package.json +2 -1
  3. package/schema/config.schema.json +6 -0
  4. package/src/admission.ts +745 -0
  5. package/src/ask.ts +47 -0
  6. package/src/backups.ts +19 -7
  7. package/src/board.ts +1 -2
  8. package/src/briefs/orchestrator.md +62 -4
  9. package/src/cli.ts +26 -0
  10. package/src/commands/context.ts +3 -0
  11. package/src/commands/decision.ts +10 -1
  12. package/src/commands/doctor.ts +2 -0
  13. package/src/commands/message.ts +8 -1
  14. package/src/commands/restart.ts +93 -54
  15. package/src/commands/restore-db.ts +146 -0
  16. package/src/commands/stop.ts +66 -34
  17. package/src/commands/unfreeze.ts +56 -0
  18. package/src/commands/watch.ts +77 -0
  19. package/src/config-schema.ts +9 -0
  20. package/src/config.ts +24 -0
  21. package/src/daemon.ts +485 -577
  22. package/src/dashboard/server.ts +2 -1
  23. package/src/decisions.ts +32 -7
  24. package/src/depends-on.ts +73 -0
  25. package/src/doctor.ts +418 -8
  26. package/src/escalate.ts +122 -15
  27. package/src/failure-class.ts +47 -0
  28. package/src/fleet.ts +55 -377
  29. package/src/gitops.ts +86 -1
  30. package/src/lifecycle.ts +113 -2
  31. package/src/log.ts +40 -0
  32. package/src/model-fallback.ts +3 -2
  33. package/src/omp-settings.ts +114 -0
  34. package/src/omp.ts +63 -0
  35. package/src/orchestrator-down.ts +231 -0
  36. package/src/orchestrator-tick.ts +14 -1
  37. package/src/orchestrator.ts +14 -0
  38. package/src/release-policy.ts +163 -18
  39. package/src/reports.ts +124 -12
  40. package/src/session-host.ts +6 -0
  41. package/src/setup-host.ts +386 -17
  42. package/src/setup-install.ts +40 -2
  43. package/src/setup-wizard.ts +314 -113
  44. package/src/setup.ts +58 -1
  45. package/src/status-render.ts +445 -0
  46. package/src/stop-provenance.ts +119 -0
  47. package/src/store.ts +533 -11
  48. package/src/types.ts +298 -4
  49. package/src/unblock.ts +1 -1
  50. package/src/upgrade-verify.ts +1 -1
  51. package/src/upgrade.ts +27 -8
  52. package/src/verbs/protocol.ts +16 -3
  53. package/src/verbs/server.ts +52 -1
  54. package/src/wizard-ui.ts +261 -46
  55. package/src/worker.ts +183 -10
package/src/store.ts CHANGED
@@ -10,13 +10,24 @@
10
10
  */
11
11
 
12
12
  import { Database } from "bun:sqlite";
13
- import { mkdirSync } from "node:fs";
13
+ import { randomUUID } from "node:crypto";
14
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
14
15
  import { dirname, join } from "node:path";
15
16
 
17
+ import { backupTimestamp, publishTempFile } from "./backups.ts";
16
18
  import { stateDir } from "./config.ts";
17
19
  import { DECISION_TTL_MS, DEFAULT_CAPS, DIGEST_BACKLOG_LIMIT } from "./types.ts";
20
+
21
+ /**
22
+ * `expiresAt` for a watch (#459). It has no human deadline — a condition that
23
+ * simply has not fired yet is a different thing from a question nobody
24
+ * answered, and silently expiring it could drop a release. Far enough ahead
25
+ * that `expireDueDecisions` (`expiresAt <= now`) never closes it.
26
+ */
27
+ const DECISION_WATCH_NEVER_EXPIRES = Number.MAX_SAFE_INTEGER;
18
28
  import { dispatchInfra, neverStarted } from "./failure-class.ts";
19
29
  import type {
30
+ BaseFreeze,
20
31
  BaseHealth,
21
32
  DecisionDraft,
22
33
  FailureClass,
@@ -24,6 +35,8 @@ import type {
24
35
  DecisionState,
25
36
  DigestBacklog,
26
37
  DispatchSummary,
38
+ DaemonStop,
39
+ DaemonStopDraft,
27
40
  FrictionAdmissionReason,
28
41
  FrictionKind,
29
42
  FrictionObservation,
@@ -38,6 +51,9 @@ import type {
38
51
  MaterialEventDraft,
39
52
  LabelOp,
40
53
  MergeLock,
54
+ OrchestratorDownMode,
55
+ OrchestratorIncident,
56
+ OrchestratorIncidentDraft,
41
57
  ReportDeliveryState,
42
58
  ReportDraft,
43
59
  ReportEnqueue,
@@ -116,6 +132,14 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
116
132
  turns: true,
117
133
  maxTurns: true,
118
134
  spendUsd: true,
135
+ provider429Count: true,
136
+ resolvedModel: true,
137
+ resolvedProvider: true,
138
+ retryFallbacks: true,
139
+ retryFallbackSucceeded: true,
140
+ modelRecoveries: true,
141
+ autoRetryCount: true,
142
+ autoCompactionCount: true,
119
143
  sessionFile: true,
120
144
  prUrl: true,
121
145
  headSha: true,
@@ -153,6 +177,14 @@ interface RunRow {
153
177
  turns: number;
154
178
  maxTurns: number;
155
179
  spendUsd: number;
180
+ provider429Count: number | null;
181
+ resolvedModel: string | null;
182
+ resolvedProvider: string | null;
183
+ retryFallbacks: string | null;
184
+ retryFallbackSucceeded: number | null;
185
+ modelRecoveries: number | null;
186
+ autoRetryCount: number | null;
187
+ autoCompactionCount: number | null;
156
188
  sessionFile: string | null;
157
189
  prUrl: string | null;
158
190
  headSha: string | null;
@@ -199,6 +231,33 @@ function toBaseHealth(row: BaseHealthRow): BaseHealth {
199
231
  return health;
200
232
  }
201
233
 
234
+ /** The `base_freeze` table exactly as SQLite hands it back. */
235
+ interface BaseFreezeRow {
236
+ project: string;
237
+ repo: string;
238
+ culpritSha: string;
239
+ detail: string | null;
240
+ setAt: number;
241
+ clearedAt: number | null;
242
+ clearedBy: string | null;
243
+ clearedReason: string | null;
244
+ }
245
+
246
+ function toBaseFreeze(row: BaseFreezeRow): BaseFreeze {
247
+ const freeze: BaseFreeze = {
248
+ repo: row.repo,
249
+ culpritSha: row.culpritSha,
250
+ setAt: row.setAt,
251
+ };
252
+ if (row.detail !== null) freeze.detail = row.detail;
253
+ if (row.clearedAt !== null) {
254
+ freeze.clearedAt = row.clearedAt;
255
+ if (row.clearedBy !== null) freeze.clearedBy = row.clearedBy;
256
+ if (row.clearedReason !== null) freeze.clearedReason = row.clearedReason;
257
+ }
258
+ return freeze;
259
+ }
260
+
202
261
  interface FrictionRollupRow {
203
262
  project: string;
204
263
  day: string;
@@ -259,7 +318,10 @@ interface ReportRow {
259
318
  updatedAt: number;
260
319
  nextAttemptAt: number;
261
320
  messageId: number | null;
321
+ messageIds: string | null;
262
322
  deliveredAt: number | null;
323
+ sentParts: number;
324
+ sentPartsHash: string | null;
263
325
  lastError: string | null;
264
326
  }
265
327
 
@@ -327,6 +389,65 @@ function toIntakeItem(row: IntakeRow): IntakeItem {
327
389
  return item;
328
390
  }
329
391
 
392
+ /** The `orchestrator_incidents` table exactly as SQLite hands it back (#288). */
393
+ interface OrchestratorIncidentRow {
394
+ project: string;
395
+ mode: string;
396
+ cause: string | null;
397
+ since: number;
398
+ diverted: number;
399
+ }
400
+
401
+ /** NULL columns become absent properties, matching the other row converters. */
402
+ function toOrchestratorIncident(row: OrchestratorIncidentRow): OrchestratorIncident {
403
+ return {
404
+ project: row.project,
405
+ mode: row.mode as OrchestratorDownMode,
406
+ ...(row.cause === null ? {} : { cause: row.cause }),
407
+ since: row.since,
408
+ diverted: row.diverted,
409
+ };
410
+ }
411
+
412
+ /** The `daemon_stops` table exactly as SQLite hands it back (#378). */
413
+ interface DaemonStopRow {
414
+ id: string;
415
+ at: number;
416
+ controlPath: string;
417
+ callerPid: number | null;
418
+ callerUid: number | null;
419
+ role: string | null;
420
+ scope: string;
421
+ project: string | null;
422
+ daemonPid: number | null;
423
+ runtimeDir: string | null;
424
+ affected: string;
425
+ reason: string;
426
+ unattributed: number;
427
+ }
428
+
429
+ /**
430
+ * NULL columns become absent properties, matching the other row converters:
431
+ * a record read back out of the store deep-equals the one that went in.
432
+ */
433
+ function toDaemonStop(row: DaemonStopRow): DaemonStop {
434
+ return {
435
+ id: row.id,
436
+ at: row.at,
437
+ controlPath: row.controlPath,
438
+ ...(row.callerPid === null ? {} : { callerPid: row.callerPid }),
439
+ ...(row.callerUid === null ? {} : { callerUid: row.callerUid }),
440
+ ...(row.role === null ? {} : { role: row.role as SessionRole }),
441
+ scope: row.scope as "global" | "project",
442
+ ...(row.project === null ? {} : { project: row.project }),
443
+ ...(row.daemonPid === null ? {} : { daemonPid: row.daemonPid }),
444
+ ...(row.runtimeDir === null ? {} : { runtimeDir: row.runtimeDir }),
445
+ affected: JSON.parse(row.affected) as { project: string; live: number }[],
446
+ reason: row.reason,
447
+ unattributed: row.unattributed === 1,
448
+ };
449
+ }
450
+
330
451
  const SCHEMA = `
331
452
  CREATE TABLE IF NOT EXISTS runs (
332
453
  id TEXT PRIMARY KEY,
@@ -340,6 +461,14 @@ CREATE TABLE IF NOT EXISTS runs (
340
461
  turns INTEGER NOT NULL,
341
462
  maxTurns INTEGER NOT NULL,
342
463
  spendUsd REAL NOT NULL,
464
+ provider429Count INTEGER,
465
+ resolvedModel TEXT,
466
+ resolvedProvider TEXT,
467
+ retryFallbacks TEXT,
468
+ retryFallbackSucceeded INTEGER,
469
+ modelRecoveries INTEGER,
470
+ autoRetryCount INTEGER,
471
+ autoCompactionCount INTEGER,
343
472
  sessionFile TEXT,
344
473
  prUrl TEXT,
345
474
  headSha TEXT,
@@ -375,6 +504,23 @@ CREATE TABLE IF NOT EXISTS base_health (
375
504
  PRIMARY KEY (project, repo)
376
505
  );
377
506
 
507
+ -- A per-repo merge freeze (#283). While clearedAt IS NULL the repo is frozen:
508
+ -- prMergeVerb refuses further merges to it with the base-red-freeze refusal. Set
509
+ -- when a watched merge (or live base observation) turns the base red; the clear
510
+ -- holds the mechanical green recovery and the operator's unfreeze verb, and acts
511
+ -- as the durable override/clear audit ledger.
512
+ CREATE TABLE IF NOT EXISTS base_freeze (
513
+ project TEXT NOT NULL,
514
+ repo TEXT NOT NULL,
515
+ culpritSha TEXT NOT NULL,
516
+ detail TEXT,
517
+ setAt INTEGER NOT NULL,
518
+ clearedAt INTEGER,
519
+ clearedBy TEXT,
520
+ clearedReason TEXT,
521
+ PRIMARY KEY (project, repo)
522
+ );
523
+
378
524
  CREATE TABLE IF NOT EXISTS notifications (
379
525
  "key" TEXT PRIMARY KEY,
380
526
  at INTEGER NOT NULL
@@ -452,7 +598,10 @@ CREATE TABLE IF NOT EXISTS reports (
452
598
  updatedAt INTEGER NOT NULL,
453
599
  nextAttemptAt INTEGER NOT NULL,
454
600
  messageId INTEGER,
601
+ messageIds TEXT,
455
602
  deliveredAt INTEGER,
603
+ sentParts INTEGER NOT NULL DEFAULT 0,
604
+ sentPartsHash TEXT,
456
605
  lastError TEXT
457
606
  );
458
607
  CREATE UNIQUE INDEX IF NOT EXISTS reports_dedupe
@@ -560,6 +709,7 @@ CREATE TABLE IF NOT EXISTS merge_locks (
560
709
  CREATE TABLE IF NOT EXISTS decisions (
561
710
  id TEXT PRIMARY KEY,
562
711
  project TEXT NOT NULL,
712
+ kind TEXT NOT NULL DEFAULT 'question',
563
713
  question TEXT NOT NULL,
564
714
  blocks TEXT,
565
715
  askedAt INTEGER NOT NULL,
@@ -628,6 +778,44 @@ CREATE TABLE IF NOT EXISTS intake_items (
628
778
  );
629
779
  CREATE INDEX IF NOT EXISTS intake_items_project_state
630
780
  ON intake_items (project, state, createdAt);
781
+ -- The embedded orchestrator's lost-liveness incident (#288): durable so a
782
+ -- daemon restarted while its orchestrator is still down rediscovers the open
783
+ -- incident instead of forgetting it, and the diverted counter accumulates
784
+ -- across the outage and across restarts. One row per project; removed on
785
+ -- recovery. since is the dedupe anchor for both the down page and its
786
+ -- closing recovery page. The mode CHECK keeps the two causes distinct.
787
+ CREATE TABLE IF NOT EXISTS orchestrator_incidents (
788
+ project TEXT PRIMARY KEY,
789
+ mode TEXT NOT NULL CHECK (mode IN ('start-failed', 'crashed')),
790
+ cause TEXT,
791
+ since INTEGER NOT NULL,
792
+ diverted INTEGER NOT NULL DEFAULT 0
793
+ );
794
+
795
+ -- Every stop/restart of the shared daemon, and who asked for it and why
796
+ -- (#378). Deliberately NOT partitioned by project: the daemon serves every
797
+ -- configured project, so a record written by one project's CLI must be
798
+ -- readable from another project's status the moment the daemon is down —
799
+ -- that cross-project read is the whole point. Written before signalling on
800
+ -- the mediated paths (cli stop/restart) and as an honest unattributed
801
+ -- fallback when the daemon receives a signal with no mediated request.
802
+ -- Explicit fields only: never raw argv, environment values or credentials.
803
+ CREATE TABLE IF NOT EXISTS daemon_stops (
804
+ id TEXT PRIMARY KEY,
805
+ at INTEGER NOT NULL,
806
+ controlPath TEXT NOT NULL,
807
+ callerPid INTEGER,
808
+ callerUid INTEGER,
809
+ role TEXT,
810
+ scope TEXT NOT NULL,
811
+ project TEXT,
812
+ daemonPid INTEGER,
813
+ runtimeDir TEXT,
814
+ affected TEXT NOT NULL,
815
+ reason TEXT NOT NULL,
816
+ unattributed INTEGER NOT NULL
817
+ );
818
+ CREATE INDEX IF NOT EXISTS daemon_stops_at ON daemon_stops (at);
631
819
  `;
632
820
 
633
821
  /**
@@ -654,6 +842,29 @@ function toSql(value: unknown): SqlValue {
654
842
  * row hand-edited or written by a future version drops its flags rather than
655
843
  * throwing and taking `status` down with it.
656
844
  */
845
+ /**
846
+ * Within-run fallback pairs out of their JSON column, or undefined for
847
+ * anything that is not an array of from/to string pairs. Same defensive
848
+ * posture as {@link toSettlementFlags}: a row hand-edited or written by a
849
+ * future version drops its fallbacks rather than throwing.
850
+ */
851
+ function toRetryFallbacks(text: string): { from: string; to: string }[] | undefined {
852
+ try {
853
+ const value: unknown = JSON.parse(text);
854
+ if (!Array.isArray(value) || value.length === 0) return undefined;
855
+ const pairs = value.filter(
856
+ (pair): pair is { from: string; to: string } =>
857
+ typeof pair === "object" &&
858
+ pair !== null &&
859
+ typeof (pair as { from?: unknown }).from === "string" &&
860
+ typeof (pair as { to?: unknown }).to === "string",
861
+ );
862
+ return pairs.length === 0 ? undefined : pairs;
863
+ } catch {
864
+ return undefined;
865
+ }
866
+ }
867
+
657
868
  function toSettlementFlags(text: string): SettlementFlag[] | undefined {
658
869
  try {
659
870
  const value: unknown = JSON.parse(text);
@@ -708,6 +919,17 @@ function toRecord(row: RunRow): RunRecord {
708
919
  if (flags !== undefined) record.settlementFlags = flags;
709
920
  }
710
921
  if (row.report !== null) record.report = row.report;
922
+ if (row.provider429Count !== null) record.provider429Count = row.provider429Count;
923
+ if (row.resolvedModel !== null) record.resolvedModel = row.resolvedModel;
924
+ if (row.resolvedProvider !== null) record.resolvedProvider = row.resolvedProvider;
925
+ if (row.retryFallbacks !== null) {
926
+ const fallbacks = toRetryFallbacks(row.retryFallbacks);
927
+ if (fallbacks !== undefined) record.retryFallbacks = fallbacks;
928
+ }
929
+ if (row.retryFallbackSucceeded !== null) record.retryFallbackSucceeded = row.retryFallbackSucceeded;
930
+ if (row.modelRecoveries !== null) record.modelRecoveries = row.modelRecoveries;
931
+ if (row.autoRetryCount !== null) record.autoRetryCount = row.autoRetryCount;
932
+ if (row.autoCompactionCount !== null) record.autoCompactionCount = row.autoCompactionCount;
711
933
  if (row.failureClass !== null) record.failureClass = row.failureClass as FailureClass;
712
934
  if (row.recoveryAction !== null) record.recoveryAction = row.recoveryAction as RecoveryAction;
713
935
  if (row.recoveredAt !== null) record.recoveredAt = row.recoveredAt;
@@ -735,6 +957,18 @@ function toReport(row: ReportRow): ReportRecord {
735
957
  if (row.attemptId !== null) record.attemptId = row.attemptId;
736
958
  if (row.dedupeKey !== null) record.dedupeKey = row.dedupeKey;
737
959
  if (row.messageId !== null) record.messageId = row.messageId;
960
+ if (row.messageIds !== null) {
961
+ // Written as JSON by this store; a row that fails to parse reads as if
962
+ // no ids were recorded, which is the honest reading of unreadable bytes.
963
+ try {
964
+ const ids: unknown = JSON.parse(row.messageIds);
965
+ if (Array.isArray(ids) && ids.every((id) => typeof id === "number")) record.messageIds = ids;
966
+ } catch {
967
+ // treated as absent
968
+ }
969
+ }
970
+ if (row.sentParts !== 0) record.sentParts = row.sentParts;
971
+ if (row.sentPartsHash !== null) record.sentPartsHash = row.sentPartsHash;
738
972
  if (row.deliveredAt !== null) record.deliveredAt = row.deliveredAt;
739
973
  if (row.lastError !== null) record.lastError = row.lastError;
740
974
  return record;
@@ -756,6 +990,7 @@ function toMaterialEvent(row: MaterialEventRow): MaterialEvent {
756
990
  interface DecisionRow {
757
991
  id: string;
758
992
  project: string;
993
+ kind: string;
759
994
  question: string;
760
995
  blocks: string | null;
761
996
  askedAt: number;
@@ -772,6 +1007,10 @@ function toDecision(row: DecisionRow): DecisionRecord {
772
1007
  const record: DecisionRecord = {
773
1008
  id: row.id,
774
1009
  project: row.project,
1010
+ // Only `watch` is ever a watch: anything else — and especially the historic
1011
+ // NULL the migration backfilled as `question` — reads as a human question.
1012
+ // A real question may carry a condition; that must not relabel it.
1013
+ kind: row.kind === "watch" ? "watch" : "question",
775
1014
  question: row.question,
776
1015
  askedAt: row.askedAt,
777
1016
  expiresAt: row.expiresAt,
@@ -847,7 +1086,11 @@ function toDispatchSummary(text: string): DispatchSummary | undefined {
847
1086
  !Array.isArray(hold.issues) ||
848
1087
  hold.issues.length > 5 ||
849
1088
  hold.count < hold.issues.length ||
850
- !hold.issues.every((issue) => Number.isSafeInteger(issue) && issue > 0),
1089
+ !hold.issues.every((issue) => Number.isSafeInteger(issue) && issue > 0) ||
1090
+ (hold.details !== undefined &&
1091
+ (!Array.isArray(hold.details) ||
1092
+ hold.details.length > 5 ||
1093
+ !hold.details.every((detail) => typeof detail === "string" && detail.length > 0))),
851
1094
  )
852
1095
  ) {
853
1096
  return undefined;
@@ -900,6 +1143,51 @@ export function dbPath(): string {
900
1143
  return join(stateDir(), "conductor.db");
901
1144
  }
902
1145
 
1146
+ /** The stem every restorable `conductor.db` snapshot is published under. */
1147
+ export const DB_SNAPSHOT_STEM = "conductor.db.bak";
1148
+
1149
+ /**
1150
+ * A fresh, restorable copy of the store at `source`, published under `destDir`
1151
+ * with the same temp-then-atomic-publish convention as the config backups.
1152
+ *
1153
+ * Uses SQLite's own `VACUUM INTO`, never a plain file copy: the store opens
1154
+ * with `journal_mode=WAL`, and a `copyFileSync` can capture a torn database
1155
+ * while committed rows still sit uncheckpointed in the live WAL — a file that
1156
+ * exists, looks fresh and passes `integrity_check` yet is missing the newest
1157
+ * commits. `VACUUM INTO` reads the source as a whole (main file plus WAL) and
1158
+ * writes a single-file snapshot, so the result opens cleanly in a fresh
1159
+ * connection and contains every committed row.
1160
+ *
1161
+ * The snapshot runs against its own read of the source, deliberately not the
1162
+ * caller's writer connection, so a snapshot can be taken while a writer holds
1163
+ * an uncheckpointed WAL. `:memory:` has no on-disk state to snapshot and is
1164
+ * refused.
1165
+ *
1166
+ * Returns the published snapshot path.
1167
+ */
1168
+ export function snapshotDb(source: string, destDir: string): string {
1169
+ if (source === ":memory:") throw new Error("cannot snapshot an in-memory store");
1170
+ if (!existsSync(source)) {
1171
+ throw new Error(`cannot snapshot ${source}: the store does not exist`);
1172
+ }
1173
+ mkdirSync(destDir, { recursive: true });
1174
+ const temporary = join(destDir, `.${DB_SNAPSHOT_STEM}-${process.pid}.${randomUUID()}.tmp`);
1175
+ let connection: Database | undefined;
1176
+ try {
1177
+ connection = new Database(source);
1178
+ connection.exec("PRAGMA busy_timeout = 5000;");
1179
+ // `VACUUM INTO` takes the target as an SQL literal, not a bound parameter.
1180
+ connection.exec(`VACUUM INTO '${temporary.replaceAll("'", "''")}'`);
1181
+ } catch (err) {
1182
+ // Never leave a half-written snapshot on disk.
1183
+ rmSync(temporary, { force: true });
1184
+ throw err;
1185
+ } finally {
1186
+ connection?.close();
1187
+ }
1188
+ return publishTempFile(temporary, destDir, `${DB_SNAPSHOT_STEM}-${backupTimestamp()}`);
1189
+ }
1190
+
903
1191
  /** The UTC calendar day (`YYYY-MM-DD`) a moment falls on — the partition key
904
1192
  * for the daemon's observed github call counter (#198). */
905
1193
  export function utcDay(now: number = Date.now()): string {
@@ -1018,6 +1306,30 @@ export function openStore(dbPath: string): Store {
1018
1306
  if (!columns.some((column) => column.name === "model")) {
1019
1307
  db.exec("ALTER TABLE runs ADD COLUMN model TEXT");
1020
1308
  }
1309
+ // The count of in-session provider 429s a run recorded was first written by
1310
+ // the worker (#573). Rows predating the column are NULL, which is the honest
1311
+ // reading: the count was not recorded, so the classifier must not treat the
1312
+ // absence as "no rate limiting happened".
1313
+ if (!columns.some((column) => column.name === "provider429Count")) {
1314
+ db.exec("ALTER TABLE runs ADD COLUMN provider429Count INTEGER");
1315
+ }
1316
+ // The within-run harness reliability surface (#539/#581): the resolved
1317
+ // model/provider and the fallback/retry/compaction counters. Rows predating
1318
+ // the columns are NULL, the honest reading of "the collection side had not
1319
+ // landed yet" — never "the run was healthy".
1320
+ for (const [name, type] of [
1321
+ ["resolvedModel", "TEXT"],
1322
+ ["resolvedProvider", "TEXT"],
1323
+ ["retryFallbacks", "TEXT"],
1324
+ ["retryFallbackSucceeded", "INTEGER"],
1325
+ ["modelRecoveries", "INTEGER"],
1326
+ ["autoRetryCount", "INTEGER"],
1327
+ ["autoCompactionCount", "INTEGER"],
1328
+ ] as const) {
1329
+ if (!columns.some((column) => column.name === name)) {
1330
+ db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
1331
+ }
1332
+ }
1021
1333
  // Existing held-notice rows predate exact digest ownership (#274). A NULL
1022
1334
  // report id is truthful for already-digested history and means "still owed"
1023
1335
  // only while digestedAt is also NULL.
@@ -1036,6 +1348,30 @@ export function openStore(dbPath: string): Store {
1036
1348
  if (!heldNoticeColumns.some((column) => column.name === "urgent")) {
1037
1349
  db.exec("ALTER TABLE held_notices ADD COLUMN urgent INTEGER NOT NULL DEFAULT 0");
1038
1350
  }
1351
+ // Multi-part delivery (#566): reports created before splitting existed carry
1352
+ // a single `messageId` and no per-part state, which is the honest reading of
1353
+ // a row that predates the watermark. `sentParts` defaults to 0 — "nothing
1354
+ // confirmed" — so a retry of a historical row starts from the first part.
1355
+ const reportColumns = db.query<{ name: string }, []>("PRAGMA table_info(reports)").all();
1356
+ if (!reportColumns.some((column) => column.name === "messageIds")) {
1357
+ db.exec("ALTER TABLE reports ADD COLUMN messageIds TEXT");
1358
+ }
1359
+ if (!reportColumns.some((column) => column.name === "sentParts")) {
1360
+ db.exec("ALTER TABLE reports ADD COLUMN sentParts INTEGER NOT NULL DEFAULT 0");
1361
+ }
1362
+ if (!reportColumns.some((column) => column.name === "sentPartsHash")) {
1363
+ db.exec("ALTER TABLE reports ADD COLUMN sentPartsHash TEXT");
1364
+ }
1365
+ // The kind split (#459): a row is either a question a human must answer or a
1366
+ // watch the orchestrator set for itself. Databases written before the split
1367
+ // never marked it, and every such row was a question — `watch` has no
1368
+ // historical form, because the old `decision open --resolves-when` was the
1369
+ // orchestrator's own misjudgement, not a durable kind. Defaulting the
1370
+ // historical rows to `question` is the honest reading.
1371
+ const decisionColumns = db.query<{ name: string }, []>("PRAGMA table_info(decisions)").all();
1372
+ if (!decisionColumns.some((column) => column.name === "kind")) {
1373
+ db.exec("ALTER TABLE decisions ADD COLUMN kind TEXT NOT NULL DEFAULT 'question'");
1374
+ }
1039
1375
 
1040
1376
  // One-time repair, and the reason it lives here rather than in the classifier:
1041
1377
  // a turn-0 environment fault that was ALREADY classified `unknown` and
@@ -1221,6 +1557,43 @@ export function openStore(dbPath: string): Store {
1221
1557
  GROUP BY repo, baseRef
1222
1558
  ORDER BY repo ASC, baseRef ASC`,
1223
1559
  );
1560
+ // Re-open (or refresh) an active freeze. On conflict we reset the clear
1561
+ // columns — a red re-observation after an override must re-arm the freeze —
1562
+ // and take the newest culprit/evidence/setAt as the current truth.
1563
+ const setBaseFreezeRow = db.query<
1564
+ unknown,
1565
+ [string, string, string, SqlValue, number]
1566
+ >(
1567
+ `INSERT INTO base_freeze
1568
+ (project, repo, culpritSha, detail, setAt, clearedAt, clearedBy, clearedReason)
1569
+ VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL)
1570
+ ON CONFLICT(project, repo) DO UPDATE SET
1571
+ culpritSha = excluded.culpritSha,
1572
+ detail = excluded.detail,
1573
+ setAt = excluded.setAt,
1574
+ clearedAt = NULL,
1575
+ clearedBy = NULL,
1576
+ clearedReason = NULL`,
1577
+ );
1578
+ const wasBaseFrozen = db.query<{ n: number }, [string, string]>(
1579
+ `SELECT COUNT(*) AS n FROM base_freeze
1580
+ WHERE project = ? AND repo = ? AND clearedAt IS NULL`,
1581
+ );
1582
+ const selectBaseFreeze = db.query<BaseFreezeRow, [string, string]>(
1583
+ `SELECT * FROM base_freeze WHERE project = ? AND repo = ?`,
1584
+ );
1585
+ const selectFreezes = db.query<BaseFreezeRow, [string]>(
1586
+ `SELECT * FROM base_freeze WHERE project = ?
1587
+ ORDER BY (clearedAt IS NULL) DESC, setAt DESC, repo ASC`,
1588
+ );
1589
+ const clearBaseFreezeRow = db.query<
1590
+ unknown,
1591
+ [number, string, string, string, string]
1592
+ >(
1593
+ `UPDATE base_freeze
1594
+ SET clearedAt = ?, clearedBy = ?, clearedReason = ?
1595
+ WHERE project = ? AND repo = ? AND clearedAt IS NULL`,
1596
+ );
1224
1597
  const countAttempts = db.query<{ n: number }, [string, number]>(
1225
1598
  `SELECT COUNT(*) AS n FROM runs WHERE project = ? AND issue = ?`,
1226
1599
  );
@@ -1233,14 +1606,14 @@ export function openStore(dbPath: string): Store {
1233
1606
  const countFailures = db.query<{ n: number }, [string, number]>(
1234
1607
  `SELECT COUNT(*) AS n FROM runs
1235
1608
  WHERE project = ? AND issue = ? AND state = 'failed'
1236
- AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient', 'returned-for-revision'))`,
1609
+ 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'))`,
1237
1610
  );
1238
1611
  const countContinuations = db.query<{ n: number }, [string, number]>(
1239
1612
  `SELECT COUNT(*) AS n FROM runs
1240
1613
  WHERE project = ? AND issue = ?
1241
1614
  AND (
1242
1615
  (state IN ('killed', 'orphaned', 'blocked')
1243
- AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient')))
1616
+ AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient', 'provider-capacity')))
1244
1617
  OR (state = 'failed' AND failureClass = 'returned-for-revision')
1245
1618
  )`,
1246
1619
  );
@@ -1365,6 +1738,23 @@ export function openStore(dbPath: string): Store {
1365
1738
  const insertNotified = db.query<unknown, [string, number]>(
1366
1739
  `INSERT OR IGNORE INTO notifications ("key", at) VALUES (?, ?)`,
1367
1740
  );
1741
+ // Orchestrator-down incident (#288). One row per project; INSERT OR IGNORE is
1742
+ // the dedupe so a flapping orchestrator opens (and pages) once per incident.
1743
+ const selectOrchestratorIncident = db.query<OrchestratorIncidentRow, [string]>(
1744
+ `SELECT project, mode, cause, since, diverted FROM orchestrator_incidents WHERE project = ?`,
1745
+ );
1746
+ const insertOrchestratorIncident = db.query<unknown, [string, string, string | null, number]>(
1747
+ `INSERT OR IGNORE INTO orchestrator_incidents (project, mode, cause, since)
1748
+ VALUES (?, ?, ?, ?)`,
1749
+ );
1750
+ const bumpOrchestratorIncident = db.query<unknown, [number, string]>(
1751
+ `UPDATE orchestrator_incidents SET diverted = diverted + ? WHERE project = ?`,
1752
+ );
1753
+ const deleteOrchestratorIncident = db.query<OrchestratorIncidentRow, [string]>(
1754
+ `DELETE FROM orchestrator_incidents
1755
+ WHERE project = ?
1756
+ RETURNING project, mode, cause, since, diverted`,
1757
+ );
1368
1758
  const upsertDispatch = db.query<unknown, [string, string]>(
1369
1759
  `INSERT INTO dispatch_summaries (project, summary) VALUES (?, ?)
1370
1760
  ON CONFLICT(project) DO UPDATE SET summary = excluded.summary`,
@@ -1721,10 +2111,19 @@ export function openStore(dbPath: string): Store {
1721
2111
  );
1722
2112
  const deliverReportRow = db.query<unknown, SqlValue[]>(
1723
2113
  `UPDATE reports
1724
- SET state = 'delivered', attemptId = NULL, messageId = ?, deliveredAt = ?,
2114
+ SET state = 'delivered', attemptId = NULL, messageId = ?, messageIds = ?, deliveredAt = ?,
1725
2115
  updatedAt = ?, lastError = NULL
1726
2116
  WHERE id = ? AND state = 'sending' AND attemptId = ?`,
1727
2117
  );
2118
+ // Mid-split progress, written before the next part ships: the first
2119
+ // `sentParts` parts of the fingerprinted message are confirmed accepted, so
2120
+ // a crash or a definitive failure can only ever re-send the part that was in
2121
+ // flight (#566). Guarded by attempt id like every other transition.
2122
+ const markPartsSentRow = db.query<unknown, [number, string, number, string, string]>(
2123
+ `UPDATE reports
2124
+ SET sentParts = ?, sentPartsHash = ?, updatedAt = ?
2125
+ WHERE id = ? AND state = 'sending' AND attemptId = ?`,
2126
+ );
1728
2127
  const requeueReportRow = db.query<unknown, [number, string, number, string, string]>(
1729
2128
  `UPDATE reports
1730
2129
  SET state = 'pending', attemptId = NULL, nextAttemptAt = ?, lastError = ?, updatedAt = ?
@@ -1843,10 +2242,10 @@ export function openStore(dbPath: string): Store {
1843
2242
  },
1844
2243
  );
1845
2244
 
1846
- const insertDecision = db.query<never, [string, string, string, SqlValue, number, number, SqlValue, string]>(
2245
+ const insertDecision = db.query<never, [string, string, string, string, SqlValue, number, number, SqlValue, string]>(
1847
2246
  `INSERT INTO decisions
1848
- (id, project, question, blocks, askedAt, expiresAt, condition, state)
1849
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
2247
+ (id, project, kind, question, blocks, askedAt, expiresAt, condition, state)
2248
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1850
2249
  );
1851
2250
  const selectDecision = db.query<DecisionRow, [string]>(`SELECT * FROM decisions WHERE id = ?`);
1852
2251
  const selectOpenDecisions = db.query<DecisionRow, [string]>(
@@ -1889,6 +2288,16 @@ export function openStore(dbPath: string): Store {
1889
2288
  ORDER BY at DESC, rowid DESC
1890
2289
  LIMIT ?`,
1891
2290
  );
2291
+ const insertDaemonStop = db.query<unknown, SqlValue[]>(
2292
+ `INSERT INTO daemon_stops
2293
+ (id, at, controlPath, callerPid, callerUid, role, scope, project, daemonPid, runtimeDir, affected, reason, unattributed)
2294
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2295
+ );
2296
+ // `rowid` breaks the tie when two requests land in the same millisecond, so
2297
+ // "newest first" never silently hides one stop behind another.
2298
+ const selectLatestDaemonStop = db.query<DaemonStopRow, []>(
2299
+ `SELECT * FROM daemon_stops ORDER BY at DESC, rowid DESC LIMIT 1`,
2300
+ );
1892
2301
  // Breaking a stale claim and taking it must be one transaction, or two
1893
2302
  // daemons both see the stale row, both delete it, and both insert.
1894
2303
  const breakStaleMergeLock = db.query<unknown, [string, number]>(
@@ -2147,6 +2556,41 @@ export function openStore(dbPath: string): Store {
2147
2556
  );
2148
2557
  },
2149
2558
 
2559
+ baseFreeze(project: string, repo: string): BaseFreeze | undefined {
2560
+ const row = selectBaseFreeze.get(project, repo);
2561
+ return row === null ? undefined : toBaseFreeze(row);
2562
+ },
2563
+
2564
+ freezes(project: string): BaseFreeze[] {
2565
+ return selectFreezes.all(project).map(toBaseFreeze);
2566
+ },
2567
+
2568
+ setBaseFreeze(
2569
+ project: string,
2570
+ freeze: { repo: string; culpritSha: string; detail?: string; setAt?: number },
2571
+ ): boolean {
2572
+ const setAt = freeze.setAt ?? Date.now();
2573
+ const wasActive = (wasBaseFrozen.get(project, freeze.repo)?.n ?? 0) > 0;
2574
+ setBaseFreezeRow.run(
2575
+ project,
2576
+ freeze.repo,
2577
+ freeze.culpritSha,
2578
+ freeze.detail ?? null,
2579
+ setAt,
2580
+ );
2581
+ return !wasActive;
2582
+ },
2583
+
2584
+ clearBaseFreeze(
2585
+ project: string,
2586
+ repo: string,
2587
+ by: string,
2588
+ reason: string,
2589
+ at = Date.now(),
2590
+ ): boolean {
2591
+ return clearBaseFreezeRow.run(at, by, reason, project, repo).changes > 0;
2592
+ },
2593
+
2150
2594
 
2151
2595
  salvagedRuns(project: string): RunRecord[] {
2152
2596
  return selectSalvaged.all(project).map(toRecord);
@@ -2223,6 +2667,58 @@ export function openStore(dbPath: string): Store {
2223
2667
  insertNotified.run(key, Date.now());
2224
2668
  },
2225
2669
 
2670
+ orchestratorIncident(project: string): OrchestratorIncident | undefined {
2671
+ const row = selectOrchestratorIncident.get(project);
2672
+ return row === null ? undefined : toOrchestratorIncident(row);
2673
+ },
2674
+
2675
+ openOrchestratorIncident(draft: OrchestratorIncidentDraft): boolean {
2676
+ return (
2677
+ insertOrchestratorIncident.run(
2678
+ draft.project,
2679
+ draft.mode,
2680
+ draft.cause ?? null,
2681
+ draft.since,
2682
+ ).changes === 1
2683
+ );
2684
+ },
2685
+
2686
+ bumpOrchestratorDiverted(project: string, by = 1): void {
2687
+ // Only meaningful while an incident is open: a healthy or external
2688
+ // orchestrator has no row, and the UPDATE touches nothing.
2689
+ bumpOrchestratorIncident.run(by, project);
2690
+ },
2691
+
2692
+ closeOrchestratorIncident(project: string, _at: number): OrchestratorIncident | undefined {
2693
+ const row = deleteOrchestratorIncident.get(project);
2694
+ return row === null ? undefined : toOrchestratorIncident(row);
2695
+ },
2696
+
2697
+ recordDaemonStop(draft: DaemonStopDraft): DaemonStop {
2698
+ const entry: DaemonStop = { ...draft, id: crypto.randomUUID(), at: Date.now() };
2699
+ insertDaemonStop.run(
2700
+ entry.id,
2701
+ entry.at,
2702
+ entry.controlPath,
2703
+ toSql(entry.callerPid),
2704
+ toSql(entry.callerUid),
2705
+ toSql(entry.role),
2706
+ entry.scope,
2707
+ toSql(entry.project),
2708
+ toSql(entry.daemonPid),
2709
+ toSql(entry.runtimeDir),
2710
+ JSON.stringify(entry.affected),
2711
+ entry.reason,
2712
+ entry.unattributed ? 1 : 0,
2713
+ );
2714
+ return entry;
2715
+ },
2716
+
2717
+ latestDaemonStop(): DaemonStop | undefined {
2718
+ const row = selectLatestDaemonStop.get();
2719
+ return row === null ? undefined : toDaemonStop(row);
2720
+ },
2721
+
2226
2722
  recordDispatch(project: string, summary: DispatchSummary): void {
2227
2723
  const previous = selectDispatch.get(project);
2228
2724
  upsertDispatch.run(project, JSON.stringify(summary));
@@ -2479,14 +2975,33 @@ export function openStore(dbPath: string): Store {
2479
2975
  markReportDelivered(
2480
2976
  id: string,
2481
2977
  attemptId: string,
2482
- messageId: number | undefined,
2978
+ messageIds: readonly number[],
2483
2979
  at: number,
2484
2980
  ): boolean {
2981
+ // `messageId` stays the first accepted id so rows written before
2982
+ // multi-part sends and the operator-facing rendering never disagree.
2485
2983
  return (
2486
- deliverReportRow.run(toSql(messageId), at, at, id, attemptId).changes > 0
2984
+ deliverReportRow.run(
2985
+ toSql(messageIds[0]),
2986
+ toSql(messageIds.length === 0 ? null : JSON.stringify(messageIds)),
2987
+ at,
2988
+ at,
2989
+ id,
2990
+ attemptId,
2991
+ ).changes > 0
2487
2992
  );
2488
2993
  },
2489
2994
 
2995
+ markReportPartsSent(
2996
+ id: string,
2997
+ attemptId: string,
2998
+ sentParts: number,
2999
+ sentPartsHash: string,
3000
+ at: number,
3001
+ ): boolean {
3002
+ return markPartsSentRow.run(sentParts, sentPartsHash, at, id, attemptId).changes > 0;
3003
+ },
3004
+
2490
3005
  markReportUncertain(id: string, attemptId: string, error: string): boolean {
2491
3006
  return uncertainReportRow.run(error, id, attemptId).changes > 0;
2492
3007
  },
@@ -2562,12 +3077,18 @@ export function openStore(dbPath: string): Store {
2562
3077
  },
2563
3078
 
2564
3079
  createDecision(draft: DecisionDraft): DecisionRecord {
3080
+ const kind = draft.kind ?? "question";
2565
3081
  const record: DecisionRecord = {
2566
3082
  id: crypto.randomUUID(),
2567
3083
  project: draft.project,
3084
+ kind,
2568
3085
  question: draft.question,
2569
3086
  askedAt: draft.at,
2570
- expiresAt: draft.at + DECISION_TTL_MS,
3087
+ // The seven-day deadline is for a question a human never answered. A
3088
+ // watch has no such deadline: it is the orchestrator's own bookkeeping,
3089
+ // and expiring it silently could drop a release the day its condition
3090
+ // finally fires, so it is never due.
3091
+ expiresAt: kind === "watch" ? DECISION_WATCH_NEVER_EXPIRES : draft.at + DECISION_TTL_MS,
2571
3092
  state: "open",
2572
3093
  };
2573
3094
  if (draft.blocks !== undefined) record.blocks = draft.blocks;
@@ -2575,6 +3096,7 @@ export function openStore(dbPath: string): Store {
2575
3096
  insertDecision.run(
2576
3097
  record.id,
2577
3098
  record.project,
3099
+ record.kind,
2578
3100
  record.question,
2579
3101
  toSql(record.blocks),
2580
3102
  record.askedAt,