omp-conductor 0.15.10 → 0.15.12

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 (92) hide show
  1. package/README.md +273 -2544
  2. package/REFERENCE.md +2680 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +11 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +113 -34
  10. package/src/briefs/policy.md +33 -8
  11. package/src/briefs/worker.md +18 -9
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +225 -1406
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +150 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +100 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +155 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +96 -0
  32. package/src/commands/report.ts +206 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +143 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +32 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +47 -1
  49. package/src/config.ts +45 -4
  50. package/src/daemon.ts +972 -101
  51. package/src/dashboard/app.js +459 -0
  52. package/src/dashboard/index.html +61 -0
  53. package/src/dashboard/server.ts +481 -0
  54. package/src/dashboard/style.css +348 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +932 -0
  58. package/src/escalate.ts +2 -2
  59. package/src/failure-class.ts +66 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/gitops.ts +157 -0
  62. package/src/graph-health.ts +1 -1
  63. package/src/label-projection.ts +1 -1
  64. package/src/lifecycle.ts +198 -2
  65. package/src/model-fallback.ts +177 -0
  66. package/src/notices.ts +9 -0
  67. package/src/omp.ts +93 -13
  68. package/src/orchestrator-tick.ts +414 -20
  69. package/src/orchestrator.ts +4 -4
  70. package/src/privileged.ts +10 -0
  71. package/src/release-policy.ts +342 -30
  72. package/src/reports.ts +19 -5
  73. package/src/session-host.ts +11 -5
  74. package/src/setup-host.ts +663 -25
  75. package/src/setup-install.ts +292 -28
  76. package/src/setup-wizard.ts +255 -74
  77. package/src/setup.ts +156 -35
  78. package/src/stats.ts +331 -0
  79. package/src/store.ts +219 -22
  80. package/src/tracker/github.ts +74 -4
  81. package/src/types.ts +215 -31
  82. package/src/unblock.ts +55 -11
  83. package/src/upgrade-journal.ts +220 -0
  84. package/src/upgrade-verify.ts +506 -0
  85. package/src/upgrade.ts +385 -58
  86. package/src/verbs/actions.ts +73 -1
  87. package/src/verbs/protocol.ts +29 -4
  88. package/src/verbs/server.ts +183 -20
  89. package/src/worker.ts +3 -3
  90. package/systemd/omp-conductor-recover.sh +433 -0
  91. package/systemd/omp-conductor.service.example +14 -3
  92. package/systemd/recover-unit-test.sh +428 -0
package/src/store.ts CHANGED
@@ -31,6 +31,9 @@ import type {
31
31
  HeldNotice,
32
32
  InterruptCategory,
33
33
  HeldNoticeDraft,
34
+ IntakeDraft,
35
+ IntakeItem,
36
+ IntakeState,
34
37
  MaterialEvent,
35
38
  MaterialEventDraft,
36
39
  LabelOp,
@@ -41,6 +44,7 @@ import type {
41
44
  ReportKind,
42
45
  ReportRecord,
43
46
  RecoveryAction,
47
+ RunPatch,
44
48
  RunRecord,
45
49
  RunState,
46
50
  SessionRole,
@@ -78,6 +82,7 @@ const ACTIVE_PLACEHOLDERS = ACTIVE_STATES.map(() => "?").join(", ");
78
82
  const FRICTION_HOLD_REASONS: ReadonlySet<FrictionAdmissionReason> = new Set([
79
83
  "failed-attempts",
80
84
  "continuations",
85
+ "stale-base",
81
86
  "parent-lookup-error",
82
87
  "issue-state-lookup-error",
83
88
  "open-pr-lookup-error",
@@ -129,6 +134,7 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
129
134
  failureClass: true,
130
135
  recoveryAction: true,
131
136
  recoveredAt: true,
137
+ model: true,
132
138
  };
133
139
 
134
140
  /** Everything SQLite will accept from us. */
@@ -165,6 +171,7 @@ interface RunRow {
165
171
  failureClass: string | null;
166
172
  recoveryAction: string | null;
167
173
  recoveredAt: number | null;
174
+ model: string | null;
168
175
  }
169
176
 
170
177
  /** The `base_health` table exactly as SQLite hands it back. */
@@ -292,6 +299,34 @@ interface MergeLockRow {
292
299
  at: number;
293
300
  }
294
301
 
302
+ /** The `intake_items` table exactly as SQLite hands it back (#299). */
303
+ interface IntakeRow {
304
+ id: string;
305
+ project: string;
306
+ text: string;
307
+ createdAt: number;
308
+ state: string;
309
+ issueUrl: string | null;
310
+ groomedAt: number | null;
311
+ }
312
+
313
+ /**
314
+ * NULL columns become absent properties, matching the other row converters:
315
+ * a record read back out of the store deep-equals the one that went in.
316
+ */
317
+ function toIntakeItem(row: IntakeRow): IntakeItem {
318
+ const item: IntakeItem = {
319
+ id: row.id,
320
+ project: row.project,
321
+ text: row.text,
322
+ createdAt: row.createdAt,
323
+ state: row.state as IntakeState,
324
+ ...(row.issueUrl === null ? {} : { issueUrl: row.issueUrl }),
325
+ ...(row.groomedAt === null ? {} : { groomedAt: row.groomedAt }),
326
+ };
327
+ return item;
328
+ }
329
+
295
330
  const SCHEMA = `
296
331
  CREATE TABLE IF NOT EXISTS runs (
297
332
  id TEXT PRIMARY KEY,
@@ -322,7 +357,8 @@ CREATE TABLE IF NOT EXISTS runs (
322
357
  report TEXT,
323
358
  failureClass TEXT,
324
359
  recoveryAction TEXT,
325
- recoveredAt INTEGER
360
+ recoveredAt INTEGER,
361
+ model TEXT
326
362
  );
327
363
  CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
328
364
  CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
@@ -575,6 +611,23 @@ CREATE TABLE IF NOT EXISTS label_ops (
575
611
  lastError TEXT
576
612
  );
577
613
  CREATE INDEX IF NOT EXISTS label_ops_project_next ON label_ops (project, nextAttemptAt);
614
+
615
+ -- Raw ideas captured by the intake command (#299), held durably until
616
+ -- #300 resolves them into issues. A thought from 02:00 must survive a restart
617
+ -- and a reinstall, so it lives here rather than in any process's memory. The
618
+ -- state CHECK keeps the three-state vocabulary exact; issueUrl and groomedAt
619
+ -- stay NULL until an idea becomes (or is dropped instead of) an issue.
620
+ CREATE TABLE IF NOT EXISTS intake_items (
621
+ id TEXT PRIMARY KEY,
622
+ project TEXT NOT NULL,
623
+ text TEXT NOT NULL,
624
+ createdAt INTEGER NOT NULL,
625
+ state TEXT NOT NULL CHECK (state IN ('pending', 'groomed', 'dismissed')),
626
+ issueUrl TEXT,
627
+ groomedAt INTEGER
628
+ );
629
+ CREATE INDEX IF NOT EXISTS intake_items_project_state
630
+ ON intake_items (project, state, createdAt);
578
631
  `;
579
632
 
580
633
  /**
@@ -658,6 +711,7 @@ function toRecord(row: RunRow): RunRecord {
658
711
  if (row.failureClass !== null) record.failureClass = row.failureClass as FailureClass;
659
712
  if (row.recoveryAction !== null) record.recoveryAction = row.recoveryAction as RecoveryAction;
660
713
  if (row.recoveredAt !== null) record.recoveredAt = row.recoveredAt;
714
+ if (row.model !== null) record.model = row.model;
661
715
  return record;
662
716
  }
663
717
 
@@ -937,10 +991,10 @@ export function openStore(dbPath: string): Store {
937
991
  if (!columns.some((column) => column.name === "settlementFlags")) {
938
992
  db.exec("ALTER TABLE runs ADD COLUMN settlementFlags TEXT");
939
993
  }
940
- // Rows written before the settlement report was persisted (#199) have no
941
- // text to pool across attempts, so a continuation reads empty priors and
942
- // audits exactly as it did before this release. No backfill: the bytes only
943
- // ever existed in a worker's memory.
994
+ // The settlement report was first persisted in #199; rows written before it
995
+ // simply have no report, which is the honest reading of a kill that never
996
+ // produced one. No backfill: the bytes only ever existed in a worker's
997
+ // memory.
944
998
  if (!columns.some((column) => column.name === "report")) {
945
999
  db.exec("ALTER TABLE runs ADD COLUMN report TEXT");
946
1000
  }
@@ -957,6 +1011,13 @@ export function openStore(dbPath: string): Store {
957
1011
  db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
958
1012
  }
959
1013
  }
1014
+ // The model a run dispatched on was first written by the modelFallbacks
1015
+ // failover chain (#286). Rows predating the column are NULL, which is the
1016
+ // honest reading: only a chain-configured project records a model, and the
1017
+ // harness's own downgrade stays on the worker result, never in this column.
1018
+ if (!columns.some((column) => column.name === "model")) {
1019
+ db.exec("ALTER TABLE runs ADD COLUMN model TEXT");
1020
+ }
960
1021
  // Existing held-notice rows predate exact digest ownership (#274). A NULL
961
1022
  // report id is truthful for already-digested history and means "still owed"
962
1023
  // only while digestedAt is also NULL.
@@ -1101,6 +1162,38 @@ export function openStore(dbPath: string): Store {
1101
1162
  AND (state <> 'merged' OR COALESCE(endedAt, startedAt) >= ?)
1102
1163
  ORDER BY startedAt DESC`,
1103
1164
  );
1165
+ // Every attempt that recorded one PR URL, newest first — deliberately NOT the
1166
+ // `recentRuns` newest-attempt-per-issue view. Mediated PR verbs resolve a
1167
+ // PR's owner through this: a requeued continuation hides its predecessor's
1168
+ // PR from `recentRuns` the moment it is created, so ownership must be able
1169
+ // to read past the issue's newest row (#434). Merged rows keep the same
1170
+ // recent-history bound as `recentRuns`.
1171
+ const selectRunsForPr = db.query<RunRow, [string, string, number]>(
1172
+ `SELECT * FROM runs
1173
+ WHERE project = ? AND prUrl = ?
1174
+ AND (state <> 'merged' OR COALESCE(endedAt, startedAt) >= ?)
1175
+ ORDER BY startedAt DESC, rowid DESC`,
1176
+ );
1177
+ // The stats window (#282): rows settling at or after `since` — merged rows
1178
+ // included, so a chain whose PR merged inside the window is an outcome —
1179
+ // plus, for those merged issues, their WHOLE attempt chain even when it
1180
+ // started before the window. A continuation that began weeks ago and merged
1181
+ // today must report one lead time from its first claim, not from the window
1182
+ // start; ordering by issue/startedAt makes the first row of each chain its
1183
+ // first queue-label claim.
1184
+ const selectStatsRuns = db.query<RunRow, [string, number, string, number]>(
1185
+ `SELECT * FROM runs
1186
+ WHERE project = ?
1187
+ AND (
1188
+ COALESCE(endedAt, startedAt) >= ?
1189
+ OR issue IN (
1190
+ SELECT issue FROM runs
1191
+ WHERE project = ? AND state = 'merged'
1192
+ AND COALESCE(endedAt, startedAt) >= ?
1193
+ )
1194
+ )
1195
+ ORDER BY issue ASC, startedAt ASC, rowid ASC`,
1196
+ );
1104
1197
  const selectPendingBaseChecks = db.query<RunRow, [string, number]>(
1105
1198
  `SELECT * FROM runs
1106
1199
  WHERE project = ? AND state = 'merged' AND baseCheck = 'pending'
@@ -1215,6 +1308,14 @@ export function openStore(dbPath: string): Store {
1215
1308
  ORDER BY startedAt DESC, rowid DESC
1216
1309
  LIMIT 1`,
1217
1310
  );
1311
+ // Every attempt for one issue, oldest first. An exhaustion escalation groups
1312
+ // these by `failureClass` to report how the continuation budget was spent
1313
+ // (#439); the numeric `*For` counters cannot reconstruct that on their own.
1314
+ const selectRunsForIssue = db.query<RunRow, [string, number]>(
1315
+ `SELECT * FROM runs
1316
+ WHERE project = ? AND issue = ?
1317
+ ORDER BY startedAt ASC, rowid ASC`,
1318
+ );
1218
1319
  const upsertTurnOverride = db.query<unknown, [string, number, number, number]>(
1219
1320
  `INSERT INTO turn_overrides (project, issue, maxTurns, setAt) VALUES (?, ?, ?, ?)
1220
1321
  ON CONFLICT(project, issue) DO UPDATE SET
@@ -1247,14 +1348,8 @@ export function openStore(dbPath: string): Store {
1247
1348
  ORDER BY setAt DESC, rowid DESC
1248
1349
  LIMIT ?`,
1249
1350
  );
1250
- // Every attempt that settled with a report, in attempt order. NULLs are the
1251
- // rows written before #199, or one killed before the worker returned a
1252
- // report — skipping them makes a continuation read exactly today's priors.
1253
- const selectAttemptReports = db.query<{ attempt: number; report: string }, [string, number]>(
1254
- `SELECT attempt, report FROM runs
1255
- WHERE project = ? AND issue = ? AND report IS NOT NULL
1256
- ORDER BY attempt`,
1257
- );
1351
+ // The settlement report row is kept for the audit trail; nothing pools it
1352
+ // across attempts any more (#488).
1258
1353
  const countStartedSince = db.query<{ n: number }, [string, number]>(
1259
1354
  `SELECT COUNT(*) AS n FROM runs WHERE project = ? AND startedAt >= ?`,
1260
1355
  );
@@ -1331,6 +1426,10 @@ export function openStore(dbPath: string): Store {
1331
1426
  const selectGhCalls = db.query<{ source: string; calls: number }, [string]>(
1332
1427
  `SELECT source, calls FROM gh_calls WHERE day = ? ORDER BY source`,
1333
1428
  );
1429
+ const sumGhCallsBetween = db.query<{ total: number }, [string, string]>(
1430
+ `SELECT COALESCE(SUM(calls), 0) AS total FROM gh_calls
1431
+ WHERE day BETWEEN ? AND ?`,
1432
+ );
1334
1433
 
1335
1434
  // The report outbox (#123). Every terminal transition names the attempt it is
1336
1435
  // settling, because a request can come back *after* the stale-`sending` sweep
@@ -1531,12 +1630,20 @@ export function openStore(dbPath: string): Store {
1531
1630
  LIMIT ?`,
1532
1631
  );
1533
1632
  const selectHeldNoticeBacklog = db.query<
1534
- { count: number; availabilityCount: number | null; oldestAt: number | null },
1633
+ {
1634
+ count: number;
1635
+ availabilityCount: number | null;
1636
+ oldestAt: number | null;
1637
+ digestOldestAt: number | null;
1638
+ availabilityOldestAt: number | null;
1639
+ },
1535
1640
  [string]
1536
1641
  >(
1537
1642
  `SELECT COUNT(*) AS count,
1538
1643
  SUM(CASE WHEN releaseOnAvailable = 1 THEN 1 ELSE 0 END) AS availabilityCount,
1539
- MIN(createdAt) AS oldestAt
1644
+ MIN(createdAt) AS oldestAt,
1645
+ MIN(CASE WHEN releaseOnAvailable = 0 THEN createdAt END) AS digestOldestAt,
1646
+ MIN(CASE WHEN releaseOnAvailable = 1 THEN createdAt END) AS availabilityOldestAt
1540
1647
  FROM held_notices
1541
1648
  WHERE project = ? AND (
1542
1649
  (digestReportId IS NULL AND digestedAt IS NULL)
@@ -1581,6 +1688,23 @@ export function openStore(dbPath: string): Store {
1581
1688
  OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state = 'failed')
1582
1689
  )`,
1583
1690
  );
1691
+ const insertIntake = db.query<unknown, SqlValue[]>(
1692
+ `INSERT INTO intake_items (id, project, text, createdAt, state)
1693
+ VALUES (?, ?, ?, ?, 'pending')`,
1694
+ );
1695
+ const selectPendingIntake = db.query<IntakeRow, [string]>(
1696
+ `SELECT * FROM intake_items
1697
+ WHERE project = ? AND state = 'pending'
1698
+ ORDER BY createdAt ASC, rowid ASC`,
1699
+ );
1700
+ const groomIntake = db.query<unknown, SqlValue[]>(
1701
+ `UPDATE intake_items SET state = 'groomed', issueUrl = ?, groomedAt = ?
1702
+ WHERE id = ? AND state = 'pending'`,
1703
+ );
1704
+ const dismissIntake = db.query<unknown, [string]>(
1705
+ `UPDATE intake_items SET state = 'dismissed', issueUrl = NULL, groomedAt = NULL
1706
+ WHERE id = ? AND state = 'pending'`,
1707
+ );
1584
1708
  // The newest digest dedupe key a project has actually run toward — an
1585
1709
  // `updatedAt`-newest row whose key is a digest prefix and that did not end in
1586
1710
  // failure. The CLI refuses an off-schedule digest by comparing this against
@@ -1949,11 +2073,15 @@ export function openStore(dbPath: string): Store {
1949
2073
  return createRunTx(r);
1950
2074
  },
1951
2075
 
1952
- updateRun(id: string, patch: Partial<RunRecord>): void {
2076
+ updateRun(id: string, patch: RunPatch): void {
1953
2077
  const assignments: string[] = [];
1954
2078
  const values: SqlValue[] = [];
1955
2079
  for (const [column, value] of Object.entries(patch)) {
1956
- if (!UPDATABLE_COLUMNS[column]) continue;
2080
+ // An explicitly-`undefined` key is the same as an absent one: a patch
2081
+ // built by spreading a partial result must not widen into columns the
2082
+ // caller did not mean to write (#468). Clearing a column still works by
2083
+ // naming it with an explicit `null`, which `toSql` maps to SQL NULL.
2084
+ if (!UPDATABLE_COLUMNS[column] || value === undefined) continue;
1957
2085
  assignments.push(`${column} = ?`);
1958
2086
  values.push(toSql(value));
1959
2087
  }
@@ -1986,6 +2114,9 @@ export function openStore(dbPath: string): Store {
1986
2114
  recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[] {
1987
2115
  return selectRecentRuns.all(project, mergedSinceEpochMs).map(toRecord);
1988
2116
  },
2117
+ runsForProjectPr(project: string, prUrl: string, mergedSinceEpochMs: number): RunRecord[] {
2118
+ return selectRunsForPr.all(project, prUrl, mergedSinceEpochMs).map(toRecord);
2119
+ },
1989
2120
  runsNeedingBaseCheck(project: string, limit = 20): RunRecord[] {
1990
2121
  return selectPendingBaseChecks.all(project, limit).map(toRecord);
1991
2122
  },
@@ -2041,6 +2172,9 @@ export function openStore(dbPath: string): Store {
2041
2172
  const row = selectLatestRun.get(project, issue);
2042
2173
  return row ? toRecord(row) : undefined;
2043
2174
  },
2175
+ runsForIssue(project: string, issue: number): RunRecord[] {
2176
+ return selectRunsForIssue.all(project, issue).map(toRecord);
2177
+ },
2044
2178
 
2045
2179
  setTurnOverride(project: string, issue: number, maxTurns: number, setAt = Date.now()): void {
2046
2180
  setTurnOverrideTx(project, issue, maxTurns, setAt);
@@ -2066,11 +2200,6 @@ export function openStore(dbPath: string): Store {
2066
2200
  return selectTurnOverrideLedger.all(project, issue, issue, opts.limit ?? 50);
2067
2201
  },
2068
2202
 
2069
- attemptReports(project: string, issue: number): { attempt: number; report: string }[] {
2070
- return selectAttemptReports.all(project, issue);
2071
- },
2072
-
2073
-
2074
2203
  runsStartedSince(project: string, sinceEpochMs: number): number {
2075
2204
  return countStartedSince.get(project, sinceEpochMs)?.n ?? 0;
2076
2205
  },
@@ -2079,6 +2208,10 @@ export function openStore(dbPath: string): Store {
2079
2208
  return sumSpendSince.get(project, sinceEpochMs)?.total ?? 0;
2080
2209
  },
2081
2210
 
2211
+ statsRuns(project: string, sinceEpochMs: number): RunRecord[] {
2212
+ return selectStatsRuns.all(project, sinceEpochMs, project, sinceEpochMs).map(toRecord);
2213
+ },
2214
+
2082
2215
  wasNotified(key: string): boolean {
2083
2216
  return (countNotified.get(key)?.n ?? 0) > 0;
2084
2217
  },
@@ -2203,6 +2336,12 @@ export function openStore(dbPath: string): Store {
2203
2336
  ...(held?.oldestAt === null || held?.oldestAt === undefined
2204
2337
  ? {}
2205
2338
  : { heldNoticeOldestAt: held.oldestAt }),
2339
+ ...(held?.digestOldestAt === null || held?.digestOldestAt === undefined
2340
+ ? {}
2341
+ : { digestHeldNoticeOldestAt: held.digestOldestAt }),
2342
+ ...(held?.availabilityOldestAt === null || held?.availabilityOldestAt === undefined
2343
+ ? {}
2344
+ : { availabilityHeldNoticeOldestAt: held.availabilityOldestAt }),
2206
2345
  };
2207
2346
  },
2208
2347
 
@@ -2226,6 +2365,10 @@ export function openStore(dbPath: string): Store {
2226
2365
  return selectGhCalls.all(day);
2227
2366
  },
2228
2367
 
2368
+ ghCallsBetween(sinceDay: string, untilDay: string): number {
2369
+ return sumGhCallsBetween.get(sinceDay, untilDay)?.total ?? 0;
2370
+ },
2371
+
2229
2372
  pendingFriction(
2230
2373
  project: string,
2231
2374
  sinceEpochMs: number,
@@ -2375,6 +2518,31 @@ export function openStore(dbPath: string): Store {
2375
2518
  .map(toReport);
2376
2519
  },
2377
2520
 
2521
+ /**
2522
+ * One escalation handoff per event while the first has not landed (#347,
2523
+ * #453). The notifications ledger cannot decide this — its rows are
2524
+ * permanent, so an identical retry would be refused forever once the first
2525
+ * occurrence was marked (#453). The question is instead "is the first
2526
+ * handoff still owed?": an escalation report still in the outbox (the
2527
+ * `openReports` pending/sending/failed set) for the same project, category
2528
+ * and folded text, or a held notice of the same category and text that has
2529
+ * not been digested yet. Once the report is delivered, or the notice
2530
+ * digested, the identical text is a new occurrence, not a double-send.
2531
+ * Material reports describe one ordinary outcome each time and never
2532
+ * dedupe.
2533
+ */
2534
+ hasOpenEscalationHandoff(project: string, category: InterruptCategory, body: string): boolean {
2535
+ const flat = body.replace(/\s+/g, " ").trim();
2536
+ return (
2537
+ selectOpenReports
2538
+ .all(project)
2539
+ .some((row) => row.kind === category && row.body.replace(/\s+/g, " ").trim() === flat) ||
2540
+ selectUndigestedNotices
2541
+ .all(project, DIGEST_BACKLOG_LIMIT)
2542
+ .some((row) => row.category === category && row.detail.replace(/\s+/g, " ").trim() === flat)
2543
+ );
2544
+ },
2545
+
2378
2546
  openReports(project: string): ReportRecord[] {
2379
2547
  return selectOpenReports.all(project).map(toReport);
2380
2548
  },
@@ -2421,6 +2589,11 @@ export function openStore(dbPath: string): Store {
2421
2589
  return selectOpenDecisions.all(project).map(toDecision);
2422
2590
  },
2423
2591
 
2592
+ decision(id: string): DecisionRecord | undefined {
2593
+ const row = selectDecision.get(id);
2594
+ return row === null ? undefined : toDecision(row);
2595
+ },
2596
+
2424
2597
  resolveDecision(id: string, state: "answered" | "withdrawn", resolution: string, at: number): boolean {
2425
2598
  return resolveDecisionRow.run(state, resolution, at, id).changes > 0;
2426
2599
  },
@@ -2532,6 +2705,30 @@ export function openStore(dbPath: string): Store {
2532
2705
  return row === null ? undefined : row.createdAt;
2533
2706
  },
2534
2707
 
2708
+ recordIntake(draft: IntakeDraft): IntakeItem {
2709
+ const item: IntakeItem = {
2710
+ id: newReportId(),
2711
+ project: draft.project,
2712
+ text: draft.text,
2713
+ createdAt: draft.at,
2714
+ state: "pending",
2715
+ };
2716
+ insertIntake.run(item.id, item.project, item.text, item.createdAt);
2717
+ return item;
2718
+ },
2719
+
2720
+ pendingIntake(project: string): IntakeItem[] {
2721
+ return selectPendingIntake.all(project).map(toIntakeItem);
2722
+ },
2723
+
2724
+ resolveIntake(id: string, state: "groomed" | "dismissed", issueUrl?: string): boolean {
2725
+ const result =
2726
+ state === "groomed"
2727
+ ? groomIntake.run(issueUrl ?? null, Date.now(), id)
2728
+ : dismissIntake.run(id);
2729
+ return result.changes > 0;
2730
+ },
2731
+
2535
2732
  close(): void {
2536
2733
  db.close(false);
2537
2734
  },
@@ -24,6 +24,7 @@
24
24
  import { credentialedEnv } from "../gitops.ts";
25
25
  import { parsePrDiff } from "../diff-flags.ts";
26
26
  import type {
27
+ IssueComment,
27
28
  IssueState,
28
29
  IssueSnapshot,
29
30
  MergedPrInfo,
@@ -136,7 +137,7 @@ export class GhError extends Error {
136
137
  * and closed, which is how bodies containing newlines, backticks or a leading
137
138
  * `-` reach `gh` without ever being interpolated into argv.
138
139
  */
139
- async function gh(argv: string[], stdin?: string): Promise<string> {
140
+ export async function gh(argv: string[], stdin?: string): Promise<string> {
140
141
  const proc = Bun.spawn(["gh", ...argv], {
141
142
  // Always a closed stream: commands that do not read stdin see immediate
142
143
  // EOF instead of an open pipe nobody ends.
@@ -392,14 +393,33 @@ function checkVerdict(check: GhCheck): CheckVerdict {
392
393
  * Verify one `gh pr view --json state,isDraft,headRefOid,statusCheckRollup`
393
394
  * payload, optionally against a caller-observed head. A missing rollup is
394
395
  * pending rather than green because GitHub may not have created the checks yet.
396
+ *
397
+ * By default this answers the merge gate's question — "may this merge" — and a
398
+ * non-open PR is a refusal (`expected OPEN`), so a mutating verb never acts on
399
+ * a merged or closed PR. `opts.read` (the `conductor_pr_status` surface) asks
400
+ * a different question: *what is this PR's state*, and a merged or closed PR
401
+ * is reported as a fact rather than a failure — the merge gate paths never
402
+ * pass `read`, so the `expected OPEN` refusal stays exactly where it gates.
395
403
  */
396
- export function prVerificationFrom(raw: string, expectedHead?: string): PrVerification {
404
+ export function prVerificationFrom(
405
+ raw: string,
406
+ expectedHead?: string,
407
+ opts?: { read?: boolean },
408
+ ): PrVerification {
397
409
  const pr = JSON.parse(raw) as GhPrVerification;
398
410
  if (typeof pr.headRefOid !== "string" || pr.headRefOid === "") {
399
411
  throw new Error("PR head is unavailable");
400
412
  }
401
413
  const headSha = pr.headRefOid;
402
414
  if (pr.state !== "OPEN") {
415
+ if (opts?.read === true) {
416
+ const stateName = pr.state?.toLowerCase() ?? "unknown";
417
+ return {
418
+ status: "failed",
419
+ reason: `PR is ${stateName}; checks are unavailable for a non-open PR`,
420
+ headSha,
421
+ };
422
+ }
403
423
  return { status: "failed", reason: `PR is ${pr.state ?? "unknown"}, expected OPEN`, headSha };
404
424
  }
405
425
  if (pr.isDraft !== false) {
@@ -741,6 +761,36 @@ export function readyIssuesFromRest(raw: string): ReadyIssue[] {
741
761
  return issues;
742
762
  }
743
763
 
764
+ /**
765
+ * Parse the REST `/repos/{owner}/{repo}/issues/{n}/comments` body — already
766
+ * projected by jq to `[{author, body}]` — onto {@link IssueComment}.
767
+ *
768
+ * The REST endpoint answers chronological order (oldest first), so the array
769
+ * order is the order the brief renders. Nulls and unrecognizable payloads are
770
+ * tolerated the same way {@link readyIssuesFromRest} tolerates them: an empty
771
+ * answer means "no comments", and *only* a thrown read means "could not tell" —
772
+ * the caller distinguishes the two, because eliding them is the #517 failure.
773
+ */
774
+ export function commentsFrom(raw: string): IssueComment[] {
775
+ let parsed: unknown;
776
+ try {
777
+ parsed = JSON.parse(raw) as unknown;
778
+ } catch {
779
+ return [];
780
+ }
781
+ if (!Array.isArray(parsed)) return [];
782
+ const comments: IssueComment[] = [];
783
+ for (const entry of parsed) {
784
+ if (entry === null || typeof entry !== "object") continue;
785
+ const row = entry as { readonly [key: string]: unknown };
786
+ const author = row["author"];
787
+ const body = row["body"];
788
+ if (typeof author !== "string" || typeof body !== "string") continue;
789
+ comments.push({ author, body });
790
+ }
791
+ return comments;
792
+ }
793
+
744
794
  /** One cached page per request URL: the ETag GitHub answered, the raw JSON
745
795
  * body it covered, and whether that page advertised a rel="next" successor.
746
796
  * Module-level default so every makeTracker() in the process (board probe,
@@ -1084,6 +1134,22 @@ export function makeTracker(
1084
1134
  return pages.flatMap(readyIssuesFromRest);
1085
1135
  },
1086
1136
 
1137
+ async listComments(issue: number): Promise<IssueComment[]> {
1138
+ // REST, like `issueSnapshot` — deliberately not `gh issue view
1139
+ // --comments`, whose renderer goes through the interactive pager and
1140
+ // prints nothing on a non-TTY while still exiting 0 (the #517 failure).
1141
+ // `gh api` output is never paged. One page: the brief truncates a huge
1142
+ // thread with an explicit marker rather than paginating.
1143
+ return commentsFrom(
1144
+ await runGh([
1145
+ "api",
1146
+ `repos/${repo}/issues/${issue}/comments`,
1147
+ "--jq",
1148
+ "[.[] | {author: .user.login, body: .body}]",
1149
+ ]),
1150
+ );
1151
+ },
1152
+
1087
1153
  async addLabel(issue: number, label: string): Promise<void> {
1088
1154
  try {
1089
1155
  // REST POST is natively idempotent: re-adding a label the issue already
@@ -1275,7 +1341,11 @@ export function makeTracker(
1275
1341
  }
1276
1342
  },
1277
1343
 
1278
- async verifyPr(url: string, expectedHead?: string): Promise<PrVerification | undefined> {
1344
+ async verifyPr(
1345
+ url: string,
1346
+ expectedHead?: string,
1347
+ opts?: { read?: boolean },
1348
+ ): Promise<PrVerification | undefined> {
1279
1349
  if (!PR_URL.test(url)) return undefined;
1280
1350
  try {
1281
1351
  const raw = await runGh([
@@ -1285,7 +1355,7 @@ export function makeTracker(
1285
1355
  "--json",
1286
1356
  "state,isDraft,headRefOid,statusCheckRollup",
1287
1357
  ]);
1288
- const verification = prVerificationFrom(raw, expectedHead);
1358
+ const verification = prVerificationFrom(raw, expectedHead, opts);
1289
1359
  if (verification.status !== "failed") return verification;
1290
1360
 
1291
1361
  const detailsUrl = failedCheck(raw)?.detailsUrl;