omp-conductor 0.10.0 → 0.13.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.
package/src/store.ts CHANGED
@@ -26,6 +26,8 @@ import type {
26
26
  FrictionKind,
27
27
  FrictionObservation,
28
28
  FrictionSignal,
29
+ HeldNotice,
30
+ HeldNoticeDraft,
29
31
  LabelOp,
30
32
  MergeLock,
31
33
  ReportDeliveryState,
@@ -39,6 +41,7 @@ import type {
39
41
  SessionRole,
40
42
  SettlementFlag,
41
43
  Store,
44
+ TurnOverride,
42
45
  VerbDecision,
43
46
  VerbLedgerDraft,
44
47
  VerbLedgerEntry,
@@ -71,6 +74,7 @@ const FRICTION_HOLD_REASONS: ReadonlySet<FrictionAdmissionReason> = new Set([
71
74
  "failed-attempts",
72
75
  "continuations",
73
76
  "parent-lookup-error",
77
+ "issue-state-lookup-error",
74
78
  "open-pr-lookup-error",
75
79
  "unroutable:no-repo-label",
76
80
  "unroutable:multiple-repo-labels",
@@ -105,6 +109,10 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
105
109
  sessionFile: true,
106
110
  prUrl: true,
107
111
  headSha: true,
112
+ mergeSha: true,
113
+ baseRef: true,
114
+ baseCheck: true,
115
+ baseCheckAt: true,
108
116
  salvageSha: true,
109
117
  salvageError: true,
110
118
  salvageAckAt: true,
@@ -137,6 +145,10 @@ interface RunRow {
137
145
  sessionFile: string | null;
138
146
  prUrl: string | null;
139
147
  headSha: string | null;
148
+ mergeSha: string | null;
149
+ baseRef: string | null;
150
+ baseCheck: string | null;
151
+ baseCheckAt: number | null;
140
152
  salvageSha: string | null;
141
153
  salvageError: string | null;
142
154
  salvageAckAt: number | null;
@@ -254,6 +266,10 @@ CREATE TABLE IF NOT EXISTS runs (
254
266
  sessionFile TEXT,
255
267
  prUrl TEXT,
256
268
  headSha TEXT,
269
+ mergeSha TEXT,
270
+ baseRef TEXT,
271
+ baseCheck TEXT,
272
+ baseCheckAt INTEGER,
257
273
  salvageSha TEXT,
258
274
  salvageError TEXT,
259
275
  salvageAckAt INTEGER,
@@ -274,6 +290,28 @@ CREATE TABLE IF NOT EXISTS notifications (
274
290
  at INTEGER NOT NULL
275
291
  );
276
292
 
293
+ -- A one-shot operator-selected ceiling for an issue's next claimed attempt.
294
+ -- The run insert and consumption happen in one transaction.
295
+ CREATE TABLE IF NOT EXISTS turn_overrides (
296
+ project TEXT NOT NULL,
297
+ issue INTEGER NOT NULL CHECK (issue > 0),
298
+ maxTurns INTEGER NOT NULL CHECK (maxTurns > 0),
299
+ setAt INTEGER NOT NULL CHECK (setAt >= 0),
300
+ PRIMARY KEY (project, issue)
301
+ );
302
+
303
+ -- Append-only audit of every operator-selected ceiling. Consuming or replacing
304
+ -- the pending value never erases what was requested.
305
+ CREATE TABLE IF NOT EXISTS turn_override_ledger (
306
+ id TEXT PRIMARY KEY,
307
+ project TEXT NOT NULL,
308
+ issue INTEGER NOT NULL CHECK (issue > 0),
309
+ maxTurns INTEGER NOT NULL CHECK (maxTurns > 0),
310
+ setAt INTEGER NOT NULL CHECK (setAt >= 0)
311
+ );
312
+ CREATE INDEX IF NOT EXISTS turn_override_ledger_project_at
313
+ ON turn_override_ledger (project, setAt);
314
+
277
315
  CREATE TABLE IF NOT EXISTS dispatch_summaries (
278
316
  project TEXT PRIMARY KEY,
279
317
  summary TEXT NOT NULL
@@ -332,6 +370,24 @@ CREATE UNIQUE INDEX IF NOT EXISTS reports_dedupe
332
370
  CREATE INDEX IF NOT EXISTS reports_project_state
333
371
  ON reports (project, state, nextAttemptAt);
334
372
 
373
+ -- Interrupts a project's reporting.interruptOn policy deferred to the digest
374
+ -- (#229): an escalation that must not page the operator's phone now is held
375
+ -- here so the daily rollup can surface it. digestedAt set -> re-surfaced by a
376
+ -- digest pass and no longer owed. A table rather than a flag on reports: these
377
+ -- are escalations that never reached a send, distinct from reports the model
378
+ -- wrote on purpose.
379
+ CREATE TABLE IF NOT EXISTS held_notices (
380
+ id TEXT PRIMARY KEY,
381
+ project TEXT NOT NULL,
382
+ category TEXT NOT NULL,
383
+ summary TEXT NOT NULL,
384
+ detail TEXT NOT NULL,
385
+ createdAt INTEGER NOT NULL,
386
+ digestedAt INTEGER
387
+ );
388
+ CREATE INDEX IF NOT EXISTS held_notices_project_undigested
389
+ ON held_notices (project, digestedAt) WHERE digestedAt IS NULL;
390
+
335
391
  -- The action ledger for the conductor-owned mutation verbs (#126). Every
336
392
  -- decided call lands here, refusals included: the question an escalation asks
337
393
  -- is "what did this run try", and a table that only records what succeeded
@@ -499,6 +555,10 @@ function toRecord(row: RunRow): RunRecord {
499
555
  if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
500
556
  if (row.prUrl !== null) record.prUrl = row.prUrl;
501
557
  if (row.headSha !== null) record.headSha = row.headSha;
558
+ if (row.mergeSha !== null) record.mergeSha = row.mergeSha;
559
+ if (row.baseRef !== null) record.baseRef = row.baseRef;
560
+ if (row.baseCheck !== null) record.baseCheck = row.baseCheck as RunRecord["baseCheck"];
561
+ if (row.baseCheckAt !== null) record.baseCheckAt = row.baseCheckAt;
502
562
  if (row.salvageSha !== null) record.salvageSha = row.salvageSha;
503
563
  if (row.salvageError !== null) record.salvageError = row.salvageError;
504
564
  if (row.salvageAckAt !== null) record.salvageAckAt = row.salvageAckAt;
@@ -724,6 +784,18 @@ export function openStore(dbPath: string): Store {
724
784
  if (!columns.some((column) => column.name === "headSha")) {
725
785
  db.exec("ALTER TABLE runs ADD COLUMN headSha TEXT");
726
786
  }
787
+ // Historical merged rows were never watched after settlement, so NULL is
788
+ // "not observed", never permission to claim their base branch was green.
789
+ for (const [name, type] of [
790
+ ["mergeSha", "TEXT"],
791
+ ["baseRef", "TEXT"],
792
+ ["baseCheck", "TEXT"],
793
+ ["baseCheckAt", "INTEGER"],
794
+ ] as const) {
795
+ if (!columns.some((column) => column.name === name)) {
796
+ db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
797
+ }
798
+ }
727
799
  // v0.3.22 and earlier removed a blocked run's dirty tree without saving it
728
800
  // (#118), so no row before this release has anywhere to record where the
729
801
  // work went. A NULL `salvageSha` on a historical row therefore means "never
@@ -858,9 +930,10 @@ export function openStore(dbPath: string): Store {
858
930
  const insertRun = db.query<unknown, SqlValue[]>(
859
931
  `INSERT INTO runs (
860
932
  id, project, issue, repo, branch, worktree, state, attempt, turns,
861
- maxTurns, spendUsd, sessionFile, prUrl, headSha, salvageSha, salvageError,
862
- salvageAckAt, startedAt, endedAt, lastError, settlementFlags, report
863
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
933
+ maxTurns, spendUsd, sessionFile, prUrl, headSha, mergeSha, baseRef,
934
+ baseCheck, baseCheckAt, salvageSha, salvageError, salvageAckAt, startedAt,
935
+ endedAt, lastError, settlementFlags, report
936
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
864
937
  );
865
938
  const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
866
939
  const selectActive = db.query<RunRow, SqlValue[]>(
@@ -890,6 +963,26 @@ export function openStore(dbPath: string): Store {
890
963
  AND (state <> 'merged' OR COALESCE(endedAt, startedAt) >= ?)
891
964
  ORDER BY startedAt DESC`,
892
965
  );
966
+ const selectPendingBaseChecks = db.query<RunRow, [string, number]>(
967
+ `SELECT * FROM runs
968
+ WHERE project = ? AND state = 'merged' AND baseCheck = 'pending'
969
+ ORDER BY COALESCE(endedAt, startedAt) ASC, rowid ASC
970
+ LIMIT ?`,
971
+ );
972
+ const selectLatestBaseChecks = db.query<RunRow & { rank: number }, [string, number]>(
973
+ `SELECT * FROM (
974
+ SELECT runs.*,
975
+ ROW_NUMBER() OVER (
976
+ PARTITION BY repo
977
+ ORDER BY COALESCE(endedAt, startedAt) DESC, runs.rowid DESC
978
+ ) AS rank
979
+ FROM runs
980
+ WHERE project = ? AND state = 'merged' AND baseCheck IS NOT NULL
981
+ AND COALESCE(baseCheckAt, endedAt, startedAt) >= ?
982
+ )
983
+ WHERE rank = 1
984
+ ORDER BY repo ASC`,
985
+ );
893
986
  const countAttempts = db.query<{ n: number }, [string, number]>(
894
987
  `SELECT COUNT(*) AS n FROM runs WHERE project = ? AND issue = ?`,
895
988
  );
@@ -902,12 +995,12 @@ export function openStore(dbPath: string): Store {
902
995
  const countFailures = db.query<{ n: number }, [string, number]>(
903
996
  `SELECT COUNT(*) AS n FROM runs
904
997
  WHERE project = ? AND issue = ? AND state = 'failed'
905
- AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit'))`,
998
+ AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient'))`,
906
999
  );
907
1000
  const countContinuations = db.query<{ n: number }, [string, number]>(
908
1001
  `SELECT COUNT(*) AS n FROM runs
909
1002
  WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')
910
- AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit'))`,
1003
+ AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient'))`,
911
1004
  );
912
1005
  // How many times one issue reached a given class. Recovery uses it to bound a
913
1006
  // retry loop whose cause is persistent (e.g. a mirror that will not refresh):
@@ -973,6 +1066,38 @@ export function openStore(dbPath: string): Store {
973
1066
  ORDER BY startedAt DESC, rowid DESC
974
1067
  LIMIT 1`,
975
1068
  );
1069
+ const upsertTurnOverride = db.query<unknown, [string, number, number, number]>(
1070
+ `INSERT INTO turn_overrides (project, issue, maxTurns, setAt) VALUES (?, ?, ?, ?)
1071
+ ON CONFLICT(project, issue) DO UPDATE SET
1072
+ maxTurns = excluded.maxTurns,
1073
+ setAt = excluded.setAt`,
1074
+ );
1075
+ const insertTurnOverrideLedger = db.query<
1076
+ unknown,
1077
+ [string, string, number, number, number]
1078
+ >(
1079
+ `INSERT INTO turn_override_ledger (id, project, issue, maxTurns, setAt)
1080
+ VALUES (?, ?, ?, ?, ?)`,
1081
+ );
1082
+ const selectTurnOverride = db.query<{ maxTurns: number }, [string, number]>(
1083
+ `SELECT maxTurns FROM turn_overrides WHERE project = ? AND issue = ?`,
1084
+ );
1085
+ const deleteTurnOverride = db.query<unknown, [string, number]>(
1086
+ `DELETE FROM turn_overrides WHERE project = ? AND issue = ?`,
1087
+ );
1088
+ const selectTurnOverrides = db.query<TurnOverride, [string]>(
1089
+ `SELECT project, issue, maxTurns, setAt FROM turn_overrides
1090
+ WHERE project = ? ORDER BY issue ASC`,
1091
+ );
1092
+ const selectTurnOverrideLedger = db.query<
1093
+ TurnOverride,
1094
+ [string, number, number, number]
1095
+ >(
1096
+ `SELECT project, issue, maxTurns, setAt FROM turn_override_ledger
1097
+ WHERE project = ? AND (? = 0 OR issue = ?)
1098
+ ORDER BY setAt DESC, rowid DESC
1099
+ LIMIT ?`,
1100
+ );
976
1101
  // Every attempt that settled with a report, in attempt order. NULLs are the
977
1102
  // rows written before #199, or one killed before the worker returned a
978
1103
  // report — skipping them makes a continuation read exactly today's priors.
@@ -1079,6 +1204,33 @@ export function openStore(dbPath: string): Store {
1079
1204
  ORDER BY createdAt ASC, rowid ASC
1080
1205
  LIMIT ?`,
1081
1206
  );
1207
+
1208
+ // Held notices (#229): escalations deferred to the digest by the project's
1209
+ // `reporting.interruptOn` policy. `digestedAt` NULL = still owed.
1210
+ const insertHeldNotice = db.query<unknown, SqlValue[]>(
1211
+ `INSERT INTO held_notices (id, project, category, summary, detail, createdAt, digestedAt)
1212
+ VALUES (?, ?, ?, ?, ?, ?, NULL)`,
1213
+ );
1214
+ const selectUndigestedNotices = db.query<
1215
+ { id: string; category: string; summary: string; detail: string; createdAt: number },
1216
+ [string]
1217
+ >(
1218
+ `SELECT id, category, summary, detail, createdAt FROM held_notices
1219
+ WHERE project = ? AND digestedAt IS NULL
1220
+ ORDER BY createdAt ASC`,
1221
+ );
1222
+ const markNoticesDigestedRow = db.query<unknown, [number, string]>(
1223
+ `UPDATE held_notices SET digestedAt = ? WHERE project = ? AND digestedAt IS NULL`,
1224
+ );
1225
+ // The newest digest dedupe key a project has actually run toward — an
1226
+ // `updatedAt`-newest row whose key is a digest prefix and that did not end in
1227
+ // failure. The CLI refuses an off-schedule digest by comparing this against
1228
+ // today's key under `digestDue` (#229).
1229
+ const selectLastDigestKey = db.query<{ key: string | null }, [string]>(
1230
+ `SELECT dedupeKey AS key FROM reports
1231
+ WHERE project = ? AND dedupeKey LIKE 'digest:%' AND state <> 'failed'
1232
+ ORDER BY createdAt DESC LIMIT 1`,
1233
+ );
1082
1234
  const claimReportRow = db.query<unknown, [string, number, string, number]>(
1083
1235
  `UPDATE reports
1084
1236
  SET state = 'sending', attemptId = ?, attempts = attempts + 1, updatedAt = ?
@@ -1300,34 +1452,58 @@ export function openStore(dbPath: string): Store {
1300
1452
  recordGhRefusalTx(at);
1301
1453
  };
1302
1454
 
1455
+ const insertRunRecord = (r: Omit<RunRecord, "id">): RunRecord => {
1456
+ const record: RunRecord = { ...r, id: crypto.randomUUID() };
1457
+ insertRun.run(
1458
+ record.id,
1459
+ record.project,
1460
+ record.issue,
1461
+ record.repo,
1462
+ record.branch,
1463
+ record.worktree,
1464
+ record.state,
1465
+ record.attempt,
1466
+ record.turns,
1467
+ record.maxTurns,
1468
+ record.spendUsd,
1469
+ toSql(record.sessionFile),
1470
+ toSql(record.prUrl),
1471
+ toSql(record.headSha),
1472
+ toSql(record.mergeSha),
1473
+ toSql(record.baseRef),
1474
+ toSql(record.baseCheck),
1475
+ toSql(record.baseCheckAt),
1476
+ toSql(record.salvageSha),
1477
+ toSql(record.salvageError),
1478
+ toSql(record.salvageAckAt),
1479
+ record.startedAt,
1480
+ toSql(record.endedAt),
1481
+ toSql(record.lastError),
1482
+ toSql(record.settlementFlags),
1483
+ toSql(record.report),
1484
+ );
1485
+ return record;
1486
+ };
1487
+ const createRunTx = db.transaction((r: Omit<RunRecord, "id">): RunRecord => {
1488
+ if (r.state !== "claimed") return insertRunRecord(r);
1489
+ const turnOverride = selectTurnOverride.get(r.project, r.issue)?.maxTurns;
1490
+ const record = insertRunRecord({
1491
+ ...r,
1492
+ maxTurns: Math.max(r.maxTurns, turnOverride ?? r.maxTurns),
1493
+ });
1494
+ if (turnOverride !== undefined) deleteTurnOverride.run(r.project, r.issue);
1495
+ return record;
1496
+ });
1497
+ const setTurnOverrideTx = db.transaction(
1498
+ (project: string, issue: number, maxTurns: number, setAt: number): void => {
1499
+ upsertTurnOverride.run(project, issue, maxTurns, setAt);
1500
+ insertTurnOverrideLedger.run(crypto.randomUUID(), project, issue, maxTurns, setAt);
1501
+ },
1502
+ );
1503
+
1303
1504
  return {
1304
1505
  createRun(r: Omit<RunRecord, "id">): RunRecord {
1305
- const record: RunRecord = { ...r, id: crypto.randomUUID() };
1306
- insertRun.run(
1307
- record.id,
1308
- record.project,
1309
- record.issue,
1310
- record.repo,
1311
- record.branch,
1312
- record.worktree,
1313
- record.state,
1314
- record.attempt,
1315
- record.turns,
1316
- record.maxTurns,
1317
- record.spendUsd,
1318
- toSql(record.sessionFile),
1319
- toSql(record.prUrl),
1320
- toSql(record.headSha),
1321
- toSql(record.salvageSha),
1322
- toSql(record.salvageError),
1323
- toSql(record.salvageAckAt),
1324
- record.startedAt,
1325
- toSql(record.endedAt),
1326
- toSql(record.lastError),
1327
- toSql(record.settlementFlags),
1328
- toSql(record.report),
1329
- );
1330
- return record;
1506
+ return createRunTx(r);
1331
1507
  },
1332
1508
 
1333
1509
  updateRun(id: string, patch: Partial<RunRecord>): void {
@@ -1367,6 +1543,14 @@ export function openStore(dbPath: string): Store {
1367
1543
  recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[] {
1368
1544
  return selectRecentRuns.all(project, mergedSinceEpochMs).map(toRecord);
1369
1545
  },
1546
+ runsNeedingBaseCheck(project: string, limit = 20): RunRecord[] {
1547
+ return selectPendingBaseChecks.all(project, limit).map(toRecord);
1548
+ },
1549
+
1550
+ latestBaseChecks(project: string, sinceEpochMs: number): RunRecord[] {
1551
+ return selectLatestBaseChecks.all(project, sinceEpochMs).map(toRecord);
1552
+ },
1553
+
1370
1554
 
1371
1555
  salvagedRuns(project: string): RunRecord[] {
1372
1556
  return selectSalvaged.all(project).map(toRecord);
@@ -1393,6 +1577,30 @@ export function openStore(dbPath: string): Store {
1393
1577
  return row ? toRecord(row) : undefined;
1394
1578
  },
1395
1579
 
1580
+ setTurnOverride(project: string, issue: number, maxTurns: number, setAt = Date.now()): void {
1581
+ setTurnOverrideTx(project, issue, maxTurns, setAt);
1582
+ },
1583
+
1584
+ turnOverride(project: string, issue: number): number | undefined {
1585
+ return selectTurnOverride.get(project, issue)?.maxTurns;
1586
+ },
1587
+
1588
+ clearTurnOverride(project: string, issue: number): void {
1589
+ deleteTurnOverride.run(project, issue);
1590
+ },
1591
+
1592
+ listTurnOverrides(project: string): TurnOverride[] {
1593
+ return selectTurnOverrides.all(project);
1594
+ },
1595
+
1596
+ turnOverrideLedger(
1597
+ project: string,
1598
+ opts: { issue?: number; limit?: number } = {},
1599
+ ): TurnOverride[] {
1600
+ const issue = opts.issue ?? 0;
1601
+ return selectTurnOverrideLedger.all(project, issue, issue, opts.limit ?? 50);
1602
+ },
1603
+
1396
1604
  attemptReports(project: string, issue: number): { attempt: number; report: string }[] {
1397
1605
  return selectAttemptReports.all(project, issue);
1398
1606
  },
@@ -1445,6 +1653,30 @@ export function openStore(dbPath: string): Store {
1445
1653
  return row === null ? undefined : toDispatchSummary(row.summary);
1446
1654
  },
1447
1655
 
1656
+ lastDigestDedupeKey(project: string): string | undefined {
1657
+ const row = selectLastDigestKey.get(project);
1658
+ return row?.key ?? undefined;
1659
+ },
1660
+
1661
+ addHeldNotice(notice: HeldNoticeDraft): void {
1662
+ insertHeldNotice.run(
1663
+ crypto.randomUUID(),
1664
+ notice.project,
1665
+ notice.category,
1666
+ notice.summary,
1667
+ notice.detail,
1668
+ notice.createdAt,
1669
+ );
1670
+ },
1671
+
1672
+ undigestedNotices(project: string): HeldNotice[] {
1673
+ return selectUndigestedNotices.all(project).map((row) => ({ ...row }) as HeldNotice);
1674
+ },
1675
+
1676
+ markNoticesDigested(project: string, at: number): void {
1677
+ markNoticesDigestedRow.run(at, project);
1678
+ },
1679
+
1448
1680
  recordFriction,
1449
1681
 
1450
1682
  recordGhRefusal,