omp-conductor 0.19.7 → 0.20.1

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 (71) hide show
  1. package/REFERENCE.md +10 -1
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/admission.ts +58 -14
  6. package/src/arm-challenge.ts +255 -85
  7. package/src/ask.ts +130 -615
  8. package/src/board.ts +7 -1
  9. package/src/brief-upgrade.ts +24 -0
  10. package/src/briefs/console.md +258 -0
  11. package/src/briefs/correction.md +203 -0
  12. package/src/briefs/orchestrator.md +167 -97
  13. package/src/briefs/policy.md +19 -16
  14. package/src/briefs/to-spec.md +76 -9
  15. package/src/briefs/worker.md +50 -16
  16. package/src/cli.ts +4 -0
  17. package/src/command-manifest.ts +54 -8
  18. package/src/commands/arm.ts +115 -49
  19. package/src/commands/console.ts +70 -0
  20. package/src/commands/context.ts +2 -0
  21. package/src/commands/epic.ts +132 -0
  22. package/src/commands/extend.ts +9 -1
  23. package/src/commands/intake.ts +44 -14
  24. package/src/commands/stats.ts +19 -4
  25. package/src/commands/worker.ts +9 -1
  26. package/src/config-schema.ts +13 -0
  27. package/src/config.ts +27 -0
  28. package/src/daemon/ack.ts +159 -0
  29. package/src/daemon/admission-pass.ts +135 -0
  30. package/src/daemon/brief.ts +461 -0
  31. package/src/daemon/deps.ts +539 -0
  32. package/src/daemon/dispatch.ts +1779 -0
  33. package/src/daemon/drain.ts +185 -0
  34. package/src/daemon/groom-pass.ts +422 -0
  35. package/src/daemon/http.ts +417 -0
  36. package/src/daemon/integrity.ts +108 -0
  37. package/src/daemon/panes.ts +180 -0
  38. package/src/daemon/review.ts +1888 -0
  39. package/src/daemon/runtime.ts +788 -0
  40. package/src/daemon/settle-pass.ts +606 -0
  41. package/src/daemon/supervision.ts +438 -0
  42. package/src/daemon/tick.ts +968 -0
  43. package/src/daemon/views.ts +751 -0
  44. package/src/daemon.ts +105 -7923
  45. package/src/dashboard/app.js +58 -0
  46. package/src/dashboard/controls.ts +22 -3
  47. package/src/dashboard/server.ts +4 -0
  48. package/src/diff-flags.ts +135 -9
  49. package/src/doctor.ts +2 -2
  50. package/src/failure-class.ts +257 -2
  51. package/src/fleet.ts +295 -176
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +689 -1670
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +107 -11
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +169 -14
  64. package/src/store.ts +618 -28
  65. package/src/to-spec.ts +426 -44
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +434 -18
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +330 -39
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +570 -1
package/src/store.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { Database } from "bun:sqlite";
13
- import { randomUUID } from "node:crypto";
13
+ import { createHash, randomUUID } from "node:crypto";
14
14
  import { existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
15
15
  import { dirname, join } from "node:path";
16
16
 
@@ -42,6 +42,7 @@ import type {
42
42
  DecisionState,
43
43
  DigestBacklog,
44
44
  DispatchSummary,
45
+ EpicApproval,
45
46
  DaemonStop,
46
47
  DaemonStopDraft,
47
48
  FrictionAdmissionReason,
@@ -70,6 +71,7 @@ import type {
70
71
  OrchestratorDownMode,
71
72
  OrchestratorIncident,
72
73
  OrchestratorIncidentDraft,
74
+ PromotedBy,
73
75
  ReleaseComposition,
74
76
  ReleaseCompositionOverride,
75
77
  ReportDeliveryState,
@@ -84,6 +86,9 @@ import type {
84
86
  ReviewAdjudicationProvenance,
85
87
  ReviewAdjudicationRecord,
86
88
  ReviewAdjudicationState,
89
+ ReviewCorrectionProvenance,
90
+ ReviewLaunchMode,
91
+ ReviewRevisionDraft,
87
92
  ReviewRevisionEnqueue,
88
93
  ReviewRevisionOutcome,
89
94
  ReviewRevisionRecord,
@@ -92,6 +97,8 @@ import type {
92
97
  RunRecord,
93
98
  RunState,
94
99
  SessionRole,
100
+ SessionSpendRole,
101
+ SessionSpendRow,
95
102
  SettlementFlag,
96
103
  Store,
97
104
  TurnOverride,
@@ -513,6 +520,15 @@ interface ReviewRevisionRow {
513
520
  /** Nullable rather than NOT NULL DEFAULT 0: rows written before #903 carry
514
521
  * NULL, which reads back as absent and counts as zero retries. */
515
522
  infraRetries: number | null;
523
+ /** The #1045 launch decision and provenance. All nullable, none backfilled:
524
+ * a pre-#1045 row and a round nobody has decided yet are the same fact —
525
+ * nobody wrote a decision down — and both must read back as unknown. */
526
+ launchMode: string | null;
527
+ requestedModel: string | null;
528
+ resolvedModel: string | null;
529
+ originSessionRef: string | null;
530
+ correctionSessionRef: string | null;
531
+ launchDecidedAt: number | null;
516
532
  }
517
533
 
518
534
  /** The `model_escalations` table exactly as SQLite hands it back (#807). Every
@@ -525,6 +541,34 @@ interface ModelEscalationRow {
525
541
  at: number;
526
542
  }
527
543
 
544
+ /**
545
+ * The #1045 launch decision and provenance, projected off one review-revision
546
+ * row. NULL becomes an absent property, so a round nobody has decided and a
547
+ * row written before the columns existed both read back as {} — the explicit
548
+ * unknown, never a fabricated `resume-original`.
549
+ *
550
+ * One helper, shared by the record converter and `reviewRevisionLaunch`, so
551
+ * the shape a status snapshot renders can never disagree with the shape the
552
+ * dispatcher reads.
553
+ */
554
+ function toCorrectionProvenance(row: ReviewRevisionRow): ReviewCorrectionProvenance {
555
+ const provenance: ReviewCorrectionProvenance = {};
556
+ if (row.launchMode !== null) provenance.launchMode = row.launchMode as ReviewLaunchMode;
557
+ if (row.requestedModel !== null) provenance.requestedModel = row.requestedModel;
558
+ if (row.resolvedModel !== null) provenance.resolvedModel = row.resolvedModel;
559
+ if (row.originSessionRef !== null) provenance.originSessionRef = row.originSessionRef;
560
+ if (row.correctionSessionRef !== null) provenance.correctionSessionRef = row.correctionSessionRef;
561
+ return provenance;
562
+ }
563
+
564
+ /**
565
+ * The one launch mode that has a correction session of its own (#1045). Bound
566
+ * into the guarded UPDATE rather than written inline in SQL, so renaming the
567
+ * union member is a compile error here instead of a WHERE clause that quietly
568
+ * stops matching anything.
569
+ */
570
+ const FRESH_CORRECTION: ReviewLaunchMode = "fresh-correction";
571
+
528
572
  /**
529
573
  * NULL columns become absent properties, matching the other row converters:
530
574
  * a record read back out of the store deep-equals the one that went in.
@@ -541,6 +585,10 @@ function toReviewRevision(row: ReviewRevisionRow): ReviewRevisionRecord {
541
585
  round: row.round,
542
586
  reason: row.reason as ReviewReason,
543
587
  requestedAt: row.requestedAt,
588
+ // Every read path — pending, unsettled, per-PR, by id — hands the launch
589
+ // provenance back on the row itself (#1045), so a caller never needs a
590
+ // second query per round to know how the round was launched.
591
+ ...toCorrectionProvenance(row),
544
592
  };
545
593
  if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
546
594
  if (row.dispatchedAt !== null) record.dispatchedAt = row.dispatchedAt;
@@ -549,6 +597,7 @@ function toReviewRevision(row: ReviewRevisionRow): ReviewRevisionRecord {
549
597
  // Zero and NULL both read back as absent: a round nobody has retried and a
550
598
  // row written before the column existed are the same fact.
551
599
  if (row.infraRetries !== null && row.infraRetries > 0) record.infraRetries = row.infraRetries;
600
+ if (row.launchDecidedAt !== null) record.launchDecidedAt = row.launchDecidedAt;
552
601
  return record;
553
602
  }
554
603
 
@@ -616,6 +665,7 @@ interface IntakeRow {
616
665
  state: string;
617
666
  issueUrl: string | null;
618
667
  groomedAt: number | null;
668
+ source: string | null;
619
669
  }
620
670
 
621
671
  /**
@@ -631,10 +681,45 @@ function toIntakeItem(row: IntakeRow): IntakeItem {
631
681
  state: row.state as IntakeState,
632
682
  ...(row.issueUrl === null ? {} : { issueUrl: row.issueUrl }),
633
683
  ...(row.groomedAt === null ? {} : { groomedAt: row.groomedAt }),
684
+ // Absent, not empty string: an operator's own idea has no provenance to
685
+ // print, and "" would render as a machine-filed item with a blank source.
686
+ ...(row.source === null ? {} : { source: row.source }),
634
687
  };
635
688
  return item;
636
689
  }
637
690
 
691
+ /** The `session_spend` table as SQLite hands it back (Phase 4). */
692
+ interface SessionSpendSqlRow {
693
+ project: string;
694
+ role: string;
695
+ issue: number | null;
696
+ model: string | null;
697
+ resolvedModel: string | null;
698
+ turns: number;
699
+ spendUsd: number | null;
700
+ at: number;
701
+ }
702
+
703
+ /**
704
+ * Same NULL-becomes-absent posture as the converters above, and here it is
705
+ * load-bearing rather than tidy: a NULL `spendUsd` must come back ABSENT, not
706
+ * as `0`, because absent means the provider reported no cost and `0` means it
707
+ * reported zero. Collapsing the two is exactly the ambiguity in the `runs`
708
+ * table that forces `spend-telemetry.ts` to guess at a subscription verdict.
709
+ */
710
+ function toSessionSpend(row: SessionSpendSqlRow): SessionSpendRow {
711
+ return {
712
+ project: row.project,
713
+ role: row.role as SessionSpendRole,
714
+ ...(row.issue === null ? {} : { issue: row.issue }),
715
+ ...(row.model === null ? {} : { model: row.model }),
716
+ ...(row.resolvedModel === null ? {} : { resolvedModel: row.resolvedModel }),
717
+ turns: row.turns,
718
+ ...(row.spendUsd === null ? {} : { spendUsd: row.spendUsd }),
719
+ at: row.at,
720
+ };
721
+ }
722
+
638
723
  /** The `grooming` table exactly as SQLite hands it back (#735). */
639
724
  interface GroomingRow {
640
725
  project: string;
@@ -643,6 +728,8 @@ interface GroomingRow {
643
728
  reason: string;
644
729
  evidence: string;
645
730
  recordedAt: number;
731
+ promotedAt: number | null;
732
+ promotedBy: string | null;
646
733
  }
647
734
 
648
735
  function toGrooming(row: GroomingRow): GroomingRecord {
@@ -653,9 +740,23 @@ function toGrooming(row: GroomingRow): GroomingRecord {
653
740
  reason: row.reason,
654
741
  evidence: row.evidence,
655
742
  recordedAt: row.recordedAt,
743
+ // Both columns or neither: `markGroomingPromoted` writes them in one
744
+ // statement, so a row carrying one without the other cannot arise. Read
745
+ // off `promotedAt` alone rather than testing both, which would invent a
746
+ // half-promoted state nothing can produce (#1041).
747
+ ...(row.promotedAt === null
748
+ ? {}
749
+ : { promotedAt: row.promotedAt, promotedBy: (row.promotedBy ?? "operator") as PromotedBy }),
656
750
  };
657
751
  }
658
752
 
753
+ /** The `epic_approvals` table exactly as SQLite hands it back (#1041). */
754
+ interface EpicApprovalRow {
755
+ issue: number;
756
+ approvedAt: number;
757
+ approvedBy: string;
758
+ }
759
+
659
760
  /** The `orchestrator_incidents` table exactly as SQLite hands it back (#288). */
660
761
  interface OrchestratorIncidentRow {
661
762
  project: string;
@@ -1101,7 +1202,20 @@ CREATE TABLE IF NOT EXISTS review_revisions (
1101
1202
  -- Infrastructure kills that returned this round to the pending set (#903).
1102
1203
  -- Nullable so an older database migrates by ALTER without a rewrite; NULL
1103
1204
  -- and 0 mean the same thing.
1104
- infraRetries INTEGER
1205
+ infraRetries INTEGER,
1206
+ -- How this round was launched, and by which model and session (#1045).
1207
+ -- launchMode is 'resume-original' or 'fresh-correction' and is written
1208
+ -- ONCE per round, by the deciding dispatch, through a guarded UPDATE -- so a
1209
+ -- restart re-reads the decision instead of making a second one. All six are
1210
+ -- nullable and nothing is backfilled: a row from before #1045 recorded no
1211
+ -- decision, and inventing 'resume-original' for it would fabricate the one
1212
+ -- fact this provenance exists to make honest.
1213
+ launchMode TEXT,
1214
+ requestedModel TEXT,
1215
+ resolvedModel TEXT,
1216
+ originSessionRef TEXT,
1217
+ correctionSessionRef TEXT,
1218
+ launchDecidedAt INTEGER
1105
1219
  );
1106
1220
  CREATE INDEX IF NOT EXISTS review_revisions_pending
1107
1221
  ON review_revisions (project, dispatchedAt, runId);
@@ -1282,9 +1396,39 @@ CREATE TABLE IF NOT EXISTS grooming (
1282
1396
  reason TEXT NOT NULL,
1283
1397
  evidence TEXT NOT NULL,
1284
1398
  recordedAt INTEGER NOT NULL CHECK (recordedAt >= 0),
1399
+ -- Promotion provenance (#1041): when this verdict was acted on by queueing
1400
+ -- the issue, and which of the three actors did it. Nullable because most
1401
+ -- verdicts are never promoted, and because a row written before mechanical
1402
+ -- promotion existed recorded nobody -- NULL is the only honest reading.
1403
+ -- Written once per verdict and cleared by the next upsert: a new verdict is a
1404
+ -- new judgement, and provenance outliving its judgement audits the wrong one.
1405
+ promotedAt INTEGER,
1406
+ promotedBy TEXT,
1285
1407
  PRIMARY KEY (project, issue)
1286
1408
  );
1287
1409
  CREATE INDEX IF NOT EXISTS grooming_project_verdict ON grooming (project, verdict);
1410
+ -- The promoted index is NOT here, for the same reason decisions_group is not:
1411
+ -- this script runs before the additive migrations, so on a database written
1412
+ -- before #1041 the column does not exist yet and an index over it would fail
1413
+ -- the whole schema step. Created beside its ALTER below.
1414
+
1415
+ -- The operator's standing approvals of epic scope (#1041). One row per approved
1416
+ -- epic, first write wins, revocable.
1417
+ --
1418
+ -- Its own table rather than a resolved decision row, and the reason is that a
1419
+ -- gate has to read it: a decision's resolution column is free text written by a
1420
+ -- human for a human, and no parser turns "yes, but do the migration first" into
1421
+ -- a boolean. Decision rows also expire after DECISION_TTL_MS, and scope consent
1422
+ -- must not lapse because nobody looked at it for a week. Project-scoped like
1423
+ -- every other table here, so one project's approvals can never gate another's
1424
+ -- children.
1425
+ CREATE TABLE IF NOT EXISTS epic_approvals (
1426
+ project TEXT NOT NULL,
1427
+ issue INTEGER NOT NULL CHECK (issue > 0),
1428
+ approvedAt INTEGER NOT NULL CHECK (approvedAt >= 0),
1429
+ approvedBy TEXT NOT NULL,
1430
+ PRIMARY KEY (project, issue)
1431
+ );
1288
1432
 
1289
1433
  -- GitHub rate-limit refusals the tracker observed (#198). Written by the
1290
1434
  -- daemon's tracker hook, not polled, so status can show what GitHub actually
@@ -1297,6 +1441,69 @@ CREATE TABLE IF NOT EXISTS gh_refusals (
1297
1441
  );
1298
1442
  CREATE INDEX IF NOT EXISTS gh_refusals_at ON gh_refusals (at);
1299
1443
 
1444
+ -- Auto-restarts the daemon fired at a wedged orchestrator (#1049, Phase 4).
1445
+ -- The same windowed-event shape as gh_refusals above and for the same reason:
1446
+ -- the only question anyone asks is "how many in the last 6h", so a bare row
1447
+ -- per restart is the whole record and there is no cursor, no latch and no
1448
+ -- counter to corrupt. A crash between the restart and the next read costs at
1449
+ -- worst one row, never a mis-set high-water mark that silently disables the
1450
+ -- cap or spends it twice.
1451
+ --
1452
+ -- Project-scoped, unlike gh_refusals: a rate-limit refusal is a property of
1453
+ -- the host's GitHub token, but a wedged orchestrator belongs to exactly one
1454
+ -- project, and one project burning its three restarts must never consume
1455
+ -- another project's budget.
1456
+ --
1457
+ -- reason is NOT NULL because the whole value of the row past the count is the
1458
+ -- page it produces when the cap is reached: "3 restarts in 6h, all
1459
+ -- stall-marker" reads very differently from three different causes.
1460
+ CREATE TABLE IF NOT EXISTS orchestrator_restarts (
1461
+ project TEXT NOT NULL,
1462
+ at INTEGER NOT NULL CHECK (at >= 0),
1463
+ reason TEXT NOT NULL
1464
+ );
1465
+ CREATE INDEX IF NOT EXISTS orchestrator_restarts_project_at
1466
+ ON orchestrator_restarts (project, at);
1467
+
1468
+ -- Spend and turns for the sessions the DAEMON owns rather than dispatches
1469
+ -- (Phase 4 attribution). Today that is to-spec grooming and review
1470
+ -- adjudication: both are real model sessions the fleet pays for, and both
1471
+ -- were previously logged to stdout and then forgotten, so a fleet's stats
1472
+ -- reported only the worker half of its bill.
1473
+ --
1474
+ -- Deliberately NOT the runs table. computeStats walks runs as issue journeys
1475
+ -- keyed on run.issue and folds FAILED_STATES into merged/settled counts, so a
1476
+ -- groom row parked in there would invent an extra journey for an issue that
1477
+ -- never had a worker, corrupt the settled counts, and skew $-per-merge by
1478
+ -- attributing grooming spend to a merge it did not produce. A small dedicated
1479
+ -- table is the honest shape.
1480
+ --
1481
+ -- spendUsd is NULLABLE, and that is the point. In runs, an unmetered request
1482
+ -- and a genuinely free one are both stored as 0, which is why
1483
+ -- spend-telemetry.ts has to infer a "subscription" verdict from the SHARE of
1484
+ -- zeros (SPEND_MISSING_SHARE) instead of reading a fact. Here the two are
1485
+ -- distinguishable at the row: NULL means the provider reported no cost for
1486
+ -- this session (subscription billing, or a harness that emitted no usage
1487
+ -- event), 0.0 means it reported exactly zero. A reader must render NULL as
1488
+ -- unmetered, never as $0.00 — a fabricated zero reads as free.
1489
+ --
1490
+ -- model is what the caller asked for and resolvedModel is what the harness
1491
+ -- actually ran; both nullable because a caller that did not observe either
1492
+ -- must not have one guessed for it. The per-model breakdown groups on
1493
+ -- resolvedModel, since that is the model that was billed.
1494
+ CREATE TABLE IF NOT EXISTS session_spend (
1495
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1496
+ project TEXT NOT NULL,
1497
+ role TEXT NOT NULL CHECK (role IN ('groom', 'adjudicator')),
1498
+ issue INTEGER CHECK (issue IS NULL OR issue > 0),
1499
+ model TEXT,
1500
+ resolvedModel TEXT,
1501
+ turns INTEGER NOT NULL CHECK (turns >= 0),
1502
+ spendUsd REAL,
1503
+ at INTEGER NOT NULL CHECK (at >= 0)
1504
+ );
1505
+ CREATE INDEX IF NOT EXISTS session_spend_project_at ON session_spend (project, at);
1506
+
1300
1507
  -- The daemon's tracked gh call count, per UTC day and call source (#198).
1301
1508
  -- The single funnel in tracker/github.ts counts every spawn; the day/source
1302
1509
  -- pair is the partition, so today's column and today's row survive a restart.
@@ -1331,6 +1538,17 @@ CREATE INDEX IF NOT EXISTS label_ops_project_next ON label_ops (project, nextAtt
1331
1538
  -- and a reinstall, so it lives here rather than in any process's memory. The
1332
1539
  -- state CHECK keeps the three-state vocabulary exact; issueUrl and groomedAt
1333
1540
  -- stay NULL until an idea becomes (or is dropped instead of) an issue.
1541
+ --
1542
+ -- source is the provenance of a machine-filed item (Phase 4 signal mining):
1543
+ -- the stable key of the signal that produced it, e.g.
1544
+ -- "mined:settlement-weakening:omp/src/foo.ts". NULL means an operator typed
1545
+ -- this idea themselves, which is the only other way a row gets here. It is
1546
+ -- also the dedupe key: recordIntake derives a DETERMINISTIC id from
1547
+ -- project + source, so the hourly mining pass re-filing a signal it already
1548
+ -- filed is a no-op instead of an hourly duplicate. Declared here for fresh
1549
+ -- databases and added by a guarded ALTER for existing ones, with no backfill:
1550
+ -- every row written before mining existed was typed by the operator, and
1551
+ -- absent is exactly that.
1334
1552
  CREATE TABLE IF NOT EXISTS intake_items (
1335
1553
  id TEXT PRIMARY KEY,
1336
1554
  project TEXT NOT NULL,
@@ -1338,7 +1556,8 @@ CREATE TABLE IF NOT EXISTS intake_items (
1338
1556
  createdAt INTEGER NOT NULL,
1339
1557
  state TEXT NOT NULL CHECK (state IN ('pending', 'groomed', 'dismissed')),
1340
1558
  issueUrl TEXT,
1341
- groomedAt INTEGER
1559
+ groomedAt INTEGER,
1560
+ source TEXT
1342
1561
  );
1343
1562
  CREATE INDEX IF NOT EXISTS intake_items_project_state
1344
1563
  ON intake_items (project, state, createdAt);
@@ -1706,6 +1925,25 @@ function newReportId(): string {
1706
1925
  return crypto.randomUUID().replaceAll("-", "").slice(0, 12);
1707
1926
  }
1708
1927
 
1928
+ /**
1929
+ * The id of a machine-filed intake item (Phase 4): a pure function of the
1930
+ * project and the signal's source key, so the hourly mining pass re-deriving
1931
+ * it lands on the row it already wrote.
1932
+ *
1933
+ * Same width and alphabet as {@link newReportId} on purpose. The id is the
1934
+ * handle an operator types into `intake dismiss`, it is printed in a column
1935
+ * next to other ids, and nothing downstream should have to care whether a row
1936
+ * came from a machine — the `source` column says that, in the one place that
1937
+ * asks. 48 bits over a per-project namespace of at most a few thousand mined
1938
+ * signals is not a collision anyone has to handle.
1939
+ *
1940
+ * The project is part of the digest, not just a prefix, so two projects mining
1941
+ * the same file path never share a row.
1942
+ */
1943
+ function minedIntakeId(project: string, source: string): string {
1944
+ return createHash("sha256").update(`${project}\u0000${source}`).digest("hex").slice(0, 12);
1945
+ }
1946
+
1709
1947
  /**
1710
1948
  * A ledger row, with the same NULL-becomes-absent posture as {@link toReport}.
1711
1949
  *
@@ -2254,6 +2492,24 @@ export function openStore(dbPath: string): Store {
2254
2492
  }
2255
2493
  }
2256
2494
  db.exec("CREATE INDEX IF NOT EXISTS decisions_group ON decisions (groupId)");
2495
+ // Promotion provenance on a grooming verdict (#1041). Purely additive, both
2496
+ // nullable, nothing backfilled: a verdict recorded before mechanical
2497
+ // promotion existed was queued — if it was queued at all — by a hand nobody
2498
+ // wrote down, and inventing `promotedBy: 'operator'` for it would put a
2499
+ // fabricated line in the very audit these columns exist to make honest.
2500
+ const groomingColumns = db.query<{ name: string }, []>("PRAGMA table_info(grooming)").all();
2501
+ for (const [name, type] of [
2502
+ ["promotedAt", "INTEGER"],
2503
+ ["promotedBy", "TEXT"],
2504
+ ] as const) {
2505
+ if (!groomingColumns.some((column) => column.name === name)) {
2506
+ db.exec(`ALTER TABLE grooming ADD COLUMN ${name} ${type}`);
2507
+ }
2508
+ }
2509
+ // Beside the ALTER, not in SCHEMA, so an upgraded database reads the audit
2510
+ // window as fast as a fresh one without the schema step depending on a column
2511
+ // it may still have to add.
2512
+ db.exec("CREATE INDEX IF NOT EXISTS grooming_promoted ON grooming (project, promotedAt)");
2257
2513
  // Already-met watches, written before the deadline existed (#966). Unlike the
2258
2514
  // head binding above this IS backfilled, and the difference is that nothing is
2259
2515
  // being guessed: `conditionMetAt` is a recorded observation, and the deadline
@@ -2291,7 +2547,44 @@ export function openStore(dbPath: string): Store {
2291
2547
  if (!reviewRevisionColumns.some((column) => column.name === "infraRetries")) {
2292
2548
  db.exec("ALTER TABLE review_revisions ADD COLUMN infraRetries INTEGER");
2293
2549
  }
2550
+ // The launch decision and its model/session provenance (#1045). Purely
2551
+ // additive — one guarded ALTER per column, no backfill, no table rebuild —
2552
+ // because the identity of an existing round is exactly what this lane must
2553
+ // not disturb: its issue, run, attempt and continuation charges, round
2554
+ // number, branch, PR URL, reviewed head, findings and settlement history all
2555
+ // stay byte-identical across the upgrade.
2556
+ //
2557
+ // Nothing is backfilled, and that is the contract, not laziness. Every round
2558
+ // that ran before #1045 resumed its original session — but nobody wrote that
2559
+ // down, and a stamped 'resume-original' would be indistinguishable from a
2560
+ // decision the daemon actually made. These columns exist to explain #1035
2561
+ // afterwards; seeding them with a guess is precisely the lie they are meant
2562
+ // to prevent. Legacy rows therefore project every field as absent, and every
2563
+ // reader must render an explicit unknown.
2564
+ for (const [name, type] of [
2565
+ ["launchMode", "TEXT"],
2566
+ ["requestedModel", "TEXT"],
2567
+ ["resolvedModel", "TEXT"],
2568
+ ["originSessionRef", "TEXT"],
2569
+ ["correctionSessionRef", "TEXT"],
2570
+ ["launchDecidedAt", "INTEGER"],
2571
+ ] as const) {
2572
+ if (!reviewRevisionColumns.some((column) => column.name === name)) {
2573
+ db.exec(`ALTER TABLE review_revisions ADD COLUMN ${name} ${type}`);
2574
+ }
2575
+ }
2294
2576
 
2577
+ // Mined-signal provenance on an intake item (Phase 4). Purely additive, no
2578
+ // backfill: every row that predates signal mining was typed by an operator,
2579
+ // so an absent source is not a gap to be filled but the accurate reading.
2580
+ //
2581
+ // Nothing is rewritten, and in particular no id is: a legacy row keeps its
2582
+ // random id, and `intake dismiss <id>` on an id an operator wrote down last
2583
+ // week still resolves the same row after the upgrade.
2584
+ const intakeColumns = db.query<{ name: string }, []>("PRAGMA table_info(intake_items)").all();
2585
+ if (!intakeColumns.some((column) => column.name === "source")) {
2586
+ db.exec("ALTER TABLE intake_items ADD COLUMN source TEXT");
2587
+ }
2295
2588
  // The escalation findings on an adjudication (#932). Rows written by #874's
2296
2589
  // lifecycle carried none — the ceiling still refused then — so NULL is the
2297
2590
  // honest reading and renders as "no findings recorded".
@@ -2680,6 +2973,17 @@ export function openStore(dbPath: string): Store {
2680
2973
  // "retrying next tick" is a lie and the row is stranded with a class and no
2681
2974
  // action. `hold` is excluded because it is *recorded only* by design — its
2682
2975
  // `recoveredAt` stays NULL forever, and re-offering it would spin the sweep.
2976
+ //
2977
+ // One deliberate exception re-enters the sweep after classification: a
2978
+ // blocked row with a PR. It is the observation half of the
2979
+ // `awaiting-observation` recovery (#1068) — the sweep re-reads the PR every
2980
+ // pass, and the classifier's merged-PR branch names `settlement-stuck` the
2981
+ // way it does for any other stuck row, so a blocked run whose PR later merges
2982
+ // (the #1062 shape: merged at 15:02, row still `blocked`/`question`) settles
2983
+ // instead of listing for Duty 1 triage forever. A genuine question row is
2984
+ // re-offered too and its escalation is not repeated: the notifications ledger
2985
+ // dedupes on the class+run summary of the stable evidence. Rows without a PR
2986
+ // have nothing to observe and are only re-offered by the ordinary clauses.
2683
2987
  const selectUnclassified = db.query<RunRow, [string, number]>(
2684
2988
  `SELECT * FROM runs
2685
2989
  WHERE project = ?
@@ -2687,6 +2991,7 @@ export function openStore(dbPath: string): Store {
2687
2991
  AND (
2688
2992
  failureClass IS NULL
2689
2993
  OR (recoveredAt IS NULL AND recoveryAction IN ('settle', 'continue', 'requeue', 'rerun-checks'))
2994
+ OR (state = 'blocked' AND prUrl IS NOT NULL)
2690
2995
  )
2691
2996
  ORDER BY startedAt DESC, rowid DESC
2692
2997
  LIMIT ?`,
@@ -2769,7 +3074,7 @@ export function openStore(dbPath: string): Store {
2769
3074
  `SELECT failureClass AS cls, COUNT(*) AS n FROM runs
2770
3075
  WHERE project = ? AND failureClass IS NOT NULL AND recoveredAt IS NULL
2771
3076
  AND state IN ('blocked', 'killed', 'failed', 'orphaned', 'pushed-green')
2772
- AND recoveryAction NOT IN ('none', 'hold')
3077
+ AND recoveryAction NOT IN ('none', 'hold', 'observe')
2773
3078
  GROUP BY failureClass
2774
3079
  ORDER BY n DESC, failureClass ASC`,
2775
3080
  );
@@ -2777,7 +3082,7 @@ export function openStore(dbPath: string): Store {
2777
3082
  `SELECT failureClass AS cls, COUNT(*) AS n FROM runs
2778
3083
  WHERE project = ? AND failureClass IS NOT NULL AND recoveredAt IS NULL
2779
3084
  AND state IN ('blocked', 'killed', 'failed', 'orphaned', 'pushed-green')
2780
- AND recoveryAction IN ('none', 'hold')
3085
+ AND recoveryAction IN ('none', 'hold', 'observe')
2781
3086
  GROUP BY failureClass
2782
3087
  ORDER BY n DESC, failureClass ASC`,
2783
3088
  );
@@ -2987,18 +3292,32 @@ export function openStore(dbPath: string): Store {
2987
3292
  const selectDispatch = db.query<{ summary: string }, [string]>(
2988
3293
  `SELECT summary FROM dispatch_summaries WHERE project = ?`,
2989
3294
  );
3295
+ // One projection list for every grooming read, so a new column can never be
3296
+ // visible through one accessor and NULL through another.
3297
+ const GROOMING_COLUMNS = "project, issue, verdict, reason, evidence, recordedAt, promotedAt, promotedBy";
2990
3298
  const selectGrooming = db.query<GroomingRow, [string, number]>(
2991
- `SELECT project, issue, verdict, reason, evidence, recordedAt
2992
- FROM grooming WHERE project = ? AND issue = ?`,
3299
+ `SELECT ${GROOMING_COLUMNS} FROM grooming WHERE project = ? AND issue = ?`,
2993
3300
  );
2994
3301
  const selectGroomingAll = db.query<GroomingRow, [string]>(
2995
- `SELECT project, issue, verdict, reason, evidence, recordedAt
2996
- FROM grooming WHERE project = ? ORDER BY issue ASC`,
3302
+ `SELECT ${GROOMING_COLUMNS} FROM grooming WHERE project = ? ORDER BY issue ASC`,
2997
3303
  );
2998
3304
  const selectGroomingByVerdict = db.query<GroomingRow, [string, string]>(
2999
- `SELECT project, issue, verdict, reason, evidence, recordedAt
3000
- FROM grooming WHERE project = ? AND verdict = ? ORDER BY issue ASC`,
3001
- );
3305
+ `SELECT ${GROOMING_COLUMNS} FROM grooming WHERE project = ? AND verdict = ? ORDER BY issue ASC`,
3306
+ );
3307
+ // Newest promotion first, and `promotedAt DESC` alone would be a non-
3308
+ // deterministic order for a batch promoted inside one millisecond, which the
3309
+ // daemon's promotion loop does routinely — the issue number breaks the tie so
3310
+ // an audit digest renders the same list twice.
3311
+ const selectPromotionsSince = db.query<GroomingRow, [string, number]>(
3312
+ `SELECT ${GROOMING_COLUMNS} FROM grooming
3313
+ WHERE project = ? AND promotedAt IS NOT NULL AND promotedAt >= ?
3314
+ ORDER BY promotedAt DESC, issue DESC`,
3315
+ );
3316
+ // The upsert deliberately does NOT carry the promotion columns forward: on
3317
+ // conflict they are reset to NULL, because a re-recorded verdict is a new
3318
+ // judgement and provenance that outlives its judgement audits the wrong
3319
+ // decision (#1041). Spelled as explicit `= NULL` rather than left out, so the
3320
+ // clearing is a stated choice a reader can weigh rather than an omission.
3002
3321
  const upsertGroomingRow = db.query<unknown, [string, number, string, string, string, number]>(
3003
3322
  `INSERT INTO grooming (project, issue, verdict, reason, evidence, recordedAt)
3004
3323
  VALUES (?, ?, ?, ?, ?, ?)
@@ -3006,12 +3325,33 @@ export function openStore(dbPath: string): Store {
3006
3325
  verdict = excluded.verdict,
3007
3326
  reason = excluded.reason,
3008
3327
  evidence = excluded.evidence,
3009
- recordedAt = excluded.recordedAt`,
3010
- );
3328
+ recordedAt = excluded.recordedAt,
3329
+ promotedAt = NULL,
3330
+ promotedBy = NULL`,
3331
+ );
3332
+ // Stamp-once, and the `IS NULL` guard is the whole contract: `changes === 0`
3333
+ // means either no such verdict or one already promoted, which is exactly the
3334
+ // set of cases a caller must not queue the issue for. That makes this write
3335
+ // the promotion latch, so it runs before the label op (#1041).
3336
+ const markGroomingPromotedRow = db.query<unknown, [number, string, string, number]>(
3337
+ `UPDATE grooming SET promotedAt = ?, promotedBy = ?
3338
+ WHERE project = ? AND issue = ? AND promotedAt IS NULL`,
3339
+ );
3340
+ // A promoted row is inert to admission's blocked bookkeeping, in both
3341
+ // directions (#1041): the sweep below skips it and the per-hold upsert never
3342
+ // rewrites it. Promotion handed the issue to dispatch, and a lane/dependency
3343
+ // hold on a queued issue is flow control of the moment, not a re-grooming.
3344
+ // Without the exclusion the audit line would die inside one 5-minute
3345
+ // admission pass — rewritten `blocked`, then swept — while the tick that must
3346
+ // review the promotion runs every ~30 minutes.
3011
3347
  const clearGroomedBlocked = db.query<unknown, [string]>(
3012
3348
  `DELETE FROM grooming
3013
3349
  WHERE project = ? AND verdict = 'blocked'
3014
- AND reason IN ('file-lane', 'depends-on')`,
3350
+ AND reason IN ('file-lane', 'depends-on')
3351
+ AND promotedAt IS NULL`,
3352
+ );
3353
+ const selectGroomingPromotedAt = db.query<{ promotedAt: number | null }, [string, number]>(
3354
+ `SELECT promotedAt FROM grooming WHERE project = ? AND issue = ?`,
3015
3355
  );
3016
3356
  // One transaction, so a pass can never leave the blocked inventory half
3017
3357
  // written: the stale rows are cleared (the hold no longer applies) and the
@@ -3021,6 +3361,7 @@ export function openStore(dbPath: string): Store {
3021
3361
  clearGroomedBlocked.run(project);
3022
3362
  for (const hold of holds) {
3023
3363
  if (BLOCKED_GROOMING_HOLDS[hold.reason] !== true) continue;
3364
+ if (selectGroomingPromotedAt.get(project, hold.issue)?.promotedAt != null) continue;
3024
3365
  upsertGroomingRow.run(project, hold.issue, "blocked", hold.reason, hold.detail ?? hold.reason, Date.now());
3025
3366
  }
3026
3367
  },
@@ -3045,6 +3386,26 @@ export function openStore(dbPath: string): Store {
3045
3386
  for (const issue of issues) removed += deleteGroomingRow.run(project, issue).changes;
3046
3387
  return removed;
3047
3388
  });
3389
+ // Epic scope approvals (#1041). `DO NOTHING` rather than `DO UPDATE` is the
3390
+ // idempotence: the durable fact is when consent was first given, so a second
3391
+ // `epic approve` must not silently restamp the timestamp and make an old
3392
+ // approval look fresh in an audit.
3393
+ const insertEpicApproval = db.query<unknown, [string, number, number, string]>(
3394
+ `INSERT INTO epic_approvals (project, issue, approvedAt, approvedBy)
3395
+ VALUES (?, ?, ?, ?)
3396
+ ON CONFLICT(project, issue) DO NOTHING`,
3397
+ );
3398
+ const deleteEpicApproval = db.query<unknown, [string, number]>(
3399
+ `DELETE FROM epic_approvals WHERE project = ? AND issue = ?`,
3400
+ );
3401
+ const selectEpicApproval = db.query<EpicApprovalRow, [string, number]>(
3402
+ `SELECT issue, approvedAt, approvedBy FROM epic_approvals
3403
+ WHERE project = ? AND issue = ?`,
3404
+ );
3405
+ const selectEpicApprovals = db.query<EpicApprovalRow, [string]>(
3406
+ `SELECT issue, approvedAt, approvedBy FROM epic_approvals
3407
+ WHERE project = ? ORDER BY issue ASC`,
3408
+ );
3048
3409
  const selectFrictionRollup = db.query<FrictionRollupRow, [string, string, string]>(
3049
3410
  `SELECT * FROM friction_rollups WHERE project = ? AND day = ? AND kind = ?`,
3050
3411
  );
@@ -3091,6 +3452,43 @@ export function openStore(dbPath: string): Store {
3091
3452
  pruneGhRefusals.run(at - 24 * 60 * 60 * 1000);
3092
3453
  });
3093
3454
 
3455
+ // Auto-restarts of a wedged orchestrator (Phase 4). Insert + prune share one
3456
+ // transaction, exactly as above, so a project that keeps wedging can never
3457
+ // grow this table without bound. The retention (7d) is deliberately longer
3458
+ // than the 6h cap window: the cap needs the last few hours, but an operator
3459
+ // reading the page wants to know whether this is the first bad night or the
3460
+ // fourth.
3461
+ const insertOrchestratorRestart = db.query<unknown, [string, number, string]>(
3462
+ `INSERT INTO orchestrator_restarts (project, at, reason) VALUES (?, ?, ?)`,
3463
+ );
3464
+ const pruneOrchestratorRestarts = db.query<unknown, [number]>(
3465
+ `DELETE FROM orchestrator_restarts WHERE at < ?`,
3466
+ );
3467
+ const selectOrchestratorRestartsSince = db.query<{ at: number; reason: string }, [string, number]>(
3468
+ `SELECT at, reason FROM orchestrator_restarts
3469
+ WHERE project = ? AND at >= ?
3470
+ ORDER BY at DESC, rowid DESC`,
3471
+ );
3472
+ const recordOrchestratorRestartTx = db.transaction(
3473
+ (project: string, at: number, reason: string): void => {
3474
+ insertOrchestratorRestart.run(project, at, reason);
3475
+ pruneOrchestratorRestarts.run(at - 7 * 24 * 60 * 60 * 1000);
3476
+ },
3477
+ );
3478
+
3479
+ // Daemon-owned session spend (Phase 4). Append-only: these sessions have no
3480
+ // lifecycle, they either ran or they did not.
3481
+ const insertSessionSpend = db.query<unknown, SqlValue[]>(
3482
+ `INSERT INTO session_spend (project, role, issue, model, resolvedModel, turns, spendUsd, at)
3483
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
3484
+ );
3485
+ const selectSessionSpendSince = db.query<SessionSpendSqlRow, [string, number]>(
3486
+ `SELECT project, role, issue, model, resolvedModel, turns, spendUsd, at
3487
+ FROM session_spend
3488
+ WHERE project = ? AND at >= ?
3489
+ ORDER BY at DESC, id DESC`,
3490
+ );
3491
+
3094
3492
  // The daemon's tracked github call counts (#198): one UPSERT per spawn.
3095
3493
  const bumpGhCall = db.query<unknown, [string, string]>(
3096
3494
  `INSERT INTO gh_calls (day, source, calls) VALUES (?, ?, 1)
@@ -3437,9 +3835,23 @@ export function openStore(dbPath: string): Store {
3437
3835
  OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state IN ('failed', 'withdrawn'))
3438
3836
  )`,
3439
3837
  );
3838
+ // INSERT OR IGNORE, not plain INSERT (Phase 4). For an operator's own idea
3839
+ // the id is random and the clause never fires; for a mined signal the id is
3840
+ // derived from project + source, so the second filing of a signal already on
3841
+ // file collides with its own row and does nothing. Crucially "does nothing"
3842
+ // includes not touching `state`: an UPSERT here would resurrect an item the
3843
+ // operator dismissed on every mining pass, which is the failure mode this
3844
+ // shape exists to prevent.
3440
3845
  const insertIntake = db.query<unknown, SqlValue[]>(
3441
- `INSERT INTO intake_items (id, project, text, createdAt, state)
3442
- VALUES (?, ?, ?, ?, 'pending')`,
3846
+ `INSERT OR IGNORE INTO intake_items (id, project, text, createdAt, state, source)
3847
+ VALUES (?, ?, ?, ?, 'pending', ?)`,
3848
+ );
3849
+ // Read back what is actually on file after the insert, which for a collision
3850
+ // is the row that was already there. The caller gets the truth (including a
3851
+ // `dismissed` state and the original `createdAt`) rather than the draft it
3852
+ // hoped to write.
3853
+ const selectIntakeById = db.query<IntakeRow, [string]>(
3854
+ `SELECT * FROM intake_items WHERE id = ?`,
3443
3855
  );
3444
3856
  const selectPendingIntake = db.query<IntakeRow, [string]>(
3445
3857
  `SELECT * FROM intake_items
@@ -3875,6 +4287,62 @@ export function openStore(dbPath: string): Store {
3875
4287
  const selectReviewRevisionById = db.query<ReviewRevisionRow, [string]>(
3876
4288
  `SELECT * FROM review_revisions WHERE id = ?`,
3877
4289
  );
4290
+ // The write-once launch decision (#1045). ONE guarded UPDATE decides and
4291
+ // writes, exactly as the infra retry above does, because a read-then-write
4292
+ // is precisely how two dispatch passes both "find no decision" and both
4293
+ // launch. `changes` is the answer:
4294
+ //
4295
+ // - launchMode IS NULL -> nobody has decided; this call wins.
4296
+ // - the guard triple matches -> the SAME decision again (a retry, a
4297
+ // restart deciding identically). The row
4298
+ // is re-affirmed rather than refused, so
4299
+ // a dispatch retry is idempotent.
4300
+ // - anything else -> 0 changes, and the caller must read the
4301
+ // recorded decision and honour it.
4302
+ //
4303
+ // `IS` rather than `=` on the guard: NULL = NULL is NULL in SQL, so `=`
4304
+ // would refuse a re-affirmation of a decision that carried no requested
4305
+ // model or no origin lineage — the shape a legacy-session round has.
4306
+ //
4307
+ // launchDecidedAt is COALESCE'd, never overwritten: a re-affirmation must
4308
+ // leave the moment of the original decision alone, or a retry loop would
4309
+ // make an old decision look freshly made in every audit that reads it.
4310
+ //
4311
+ // The SET touches decision columns only. The issue, run, round, branch, PR
4312
+ // URL, reviewed head, findings, charges and settlement history are the
4313
+ // round's identity and are never written by this lane.
4314
+ const recordReviewRevisionLaunchRow = db.query<
4315
+ unknown,
4316
+ [string, string | null, string | null, number, string, string, string, string | null, string | null]
4317
+ >(
4318
+ `UPDATE review_revisions
4319
+ SET launchMode = ?,
4320
+ requestedModel = ?,
4321
+ originSessionRef = ?,
4322
+ launchDecidedAt = COALESCE(launchDecidedAt, ?)
4323
+ WHERE id = ? AND project = ?
4324
+ AND (launchMode IS NULL
4325
+ OR (launchMode IS ? AND requestedModel IS ? AND originSessionRef IS ?))`,
4326
+ );
4327
+ // The fresh correction's own session lineage (#1045), attachable only to a
4328
+ // round that was decided `fresh-correction`: a resumed round IS its origin
4329
+ // session, so a separate correction lineage on one would name a session that
4330
+ // never existed. The mode is bound rather than inlined so the vocabulary
4331
+ // lives in exactly one place — {@link ReviewLaunchMode}.
4332
+ //
4333
+ // Deliberately not write-once: an infra-killed round relaunches under the
4334
+ // same decision, and the newest fresh session is then the truth about what
4335
+ // is running. `resolvedModel` is COALESCE'd from the argument side, so a
4336
+ // later call that does not know the resolution cannot erase a known one.
4337
+ const recordReviewCorrectionSessionRow = db.query<unknown, [string, string | null, string, string, string]>(
4338
+ `UPDATE review_revisions
4339
+ SET correctionSessionRef = ?,
4340
+ resolvedModel = COALESCE(?, resolvedModel)
4341
+ WHERE id = ? AND project = ? AND launchMode = ?`,
4342
+ );
4343
+ const selectReviewRevisionForProject = db.query<ReviewRevisionRow, [string, string]>(
4344
+ `SELECT * FROM review_revisions WHERE id = ? AND project = ?`,
4345
+ );
3878
4346
  // The exact-head merge gate (#888): every row that stands between a PR and
3879
4347
  // a merge at one head — anything not yet settled (queued, or dispatched and
3880
4348
  // crashed mid-review) plus anything settled `failed` there. `skipped`,
@@ -4093,7 +4561,7 @@ export function openStore(dbPath: string): Store {
4093
4561
  // appends to the winner's row. The unique pending-per-run index remains as
4094
4562
  // the schema-level backstop.
4095
4563
  const enqueueReviewRevisionTx = db.transaction(
4096
- (draft: Omit<ReviewRevisionRecord, "id">): ReviewRevisionEnqueue => {
4564
+ (draft: ReviewRevisionDraft): ReviewRevisionEnqueue => {
4097
4565
  const pending = selectPendingReviewForRun.get(draft.project, draft.runId);
4098
4566
  if (pending !== null) {
4099
4567
  if (pending.headSha !== draft.headSha) {
@@ -4448,7 +4916,7 @@ export function openStore(dbPath: string): Store {
4448
4916
  runsForProjectPr(project: string, prUrl: string, mergedSinceEpochMs: number): RunRecord[] {
4449
4917
  return selectRunsForPr.all(project, prUrl, mergedSinceEpochMs).map(toRecord);
4450
4918
  },
4451
- enqueueReviewRevision(draft: Omit<ReviewRevisionRecord, "id">): ReviewRevisionEnqueue {
4919
+ enqueueReviewRevision(draft: ReviewRevisionDraft): ReviewRevisionEnqueue {
4452
4920
  // `immediate` = BEGIN IMMEDIATE: the write lock is acquired before the
4453
4921
  // pending-row read, so a second connection's transaction cannot observe
4454
4922
  // a snapshot older than the first connection's commit (#786).
@@ -4467,6 +4935,51 @@ export function openStore(dbPath: string): Store {
4467
4935
  markReviewRevisionDispatched(id: string, at: number): void {
4468
4936
  markReviewRevisionDispatchedRow.run(at, id);
4469
4937
  },
4938
+ recordReviewRevisionLaunch(
4939
+ project: string,
4940
+ id: string,
4941
+ decision: {
4942
+ launchMode: ReviewLaunchMode;
4943
+ requestedModel?: string;
4944
+ originSessionRef?: string;
4945
+ at: number;
4946
+ },
4947
+ ): boolean {
4948
+ // The decision and the write are one statement (#1045): `changes` is the
4949
+ // verdict, so a second dispatch pass cannot observe "undecided" and
4950
+ // launch its own. Re-affirming the identical decision reports true —
4951
+ // a dispatch retry after a killed launch must be idempotent, not fatal.
4952
+ const requestedModel = decision.requestedModel ?? null;
4953
+ const originSessionRef = decision.originSessionRef ?? null;
4954
+ return (
4955
+ recordReviewRevisionLaunchRow.run(
4956
+ decision.launchMode,
4957
+ requestedModel,
4958
+ originSessionRef,
4959
+ decision.at,
4960
+ id,
4961
+ project,
4962
+ decision.launchMode,
4963
+ requestedModel,
4964
+ originSessionRef,
4965
+ ).changes > 0
4966
+ );
4967
+ },
4968
+ recordReviewCorrectionSession(project: string, id: string, ref: string, resolvedModel?: string): boolean {
4969
+ // Guarded on the recorded mode, in the statement: a round decided
4970
+ // `resume-original` has no correction session to name, and answering
4971
+ // false here is how the caller learns it read the wrong decision.
4972
+ return (
4973
+ recordReviewCorrectionSessionRow.run(ref, resolvedModel ?? null, id, project, FRESH_CORRECTION).changes > 0
4974
+ );
4975
+ },
4976
+ reviewRevisionLaunch(project: string, id: string): ReviewCorrectionProvenance | undefined {
4977
+ const row = selectReviewRevisionForProject.get(id, project);
4978
+ // A missing round is `undefined`; a round nobody has decided is an empty
4979
+ // provenance. The distinction matters: the first is "no such round", the
4980
+ // second is "this round exists and nothing was written down".
4981
+ return row === null || row === undefined ? undefined : toCorrectionProvenance(row);
4982
+ },
4470
4983
  settleReviewRevision(id: string, outcome: ReviewRevisionOutcome, at: number): void {
4471
4984
  settleReviewRevisionRow.run(outcome, at, id);
4472
4985
  },
@@ -5081,6 +5594,38 @@ export function openStore(dbPath: string): Store {
5081
5594
  return { count: row.n, latestAt: row.latest };
5082
5595
  },
5083
5596
 
5597
+ recordOrchestratorRestart(project: string, at: number, reason: string): void {
5598
+ // Same guard as `recordGhRefusal`: a bad clock must not corrupt the
5599
+ // store, and here it would corrupt the cap itself — a restart stamped in
5600
+ // 1970 falls outside every window and is a free restart forever.
5601
+ if (!Number.isSafeInteger(at) || at < 0) return;
5602
+ recordOrchestratorRestartTx(project, at, reason);
5603
+ },
5604
+
5605
+ orchestratorRestartsSince(project: string, since: number): { at: number; reason: string }[] {
5606
+ return selectOrchestratorRestartsSince.all(project, since);
5607
+ },
5608
+
5609
+ recordSessionSpend(row: SessionSpendRow): void {
5610
+ insertSessionSpend.run(
5611
+ row.project,
5612
+ row.role,
5613
+ row.issue ?? null,
5614
+ row.model ?? null,
5615
+ row.resolvedModel ?? null,
5616
+ row.turns,
5617
+ // `?? null` and NOT `?? 0`: an absent spend is an unmetered session,
5618
+ // and writing 0 here would report it as free. The two readings are
5619
+ // distinguishable at the row precisely because this stays NULL.
5620
+ row.spendUsd ?? null,
5621
+ row.at,
5622
+ );
5623
+ },
5624
+
5625
+ sessionSpendSince(project: string, since: number): SessionSpendRow[] {
5626
+ return selectSessionSpendSince.all(project, since).map(toSessionSpend);
5627
+ },
5628
+
5084
5629
  bumpGhCalls(day: string, source: string): void {
5085
5630
  bumpGhCall.run(day, source);
5086
5631
  },
@@ -5506,15 +6051,23 @@ export function openStore(dbPath: string): Store {
5506
6051
  },
5507
6052
 
5508
6053
  recordIntake(draft: IntakeDraft): IntakeItem {
5509
- const item: IntakeItem = {
5510
- id: newReportId(),
5511
- project: draft.project,
5512
- text: draft.text,
5513
- createdAt: draft.at,
5514
- state: "pending",
5515
- };
5516
- insertIntake.run(item.id, item.project, item.text, item.createdAt);
5517
- return item;
6054
+ // A mined signal's id is a function of its provenance, an operator's own
6055
+ // idea gets a fresh random one. That single choice is the whole
6056
+ // idempotency mechanism: the machine keeps landing on its own row, the
6057
+ // human never does, and two identical thoughts on two days stay two
6058
+ // thoughts.
6059
+ const id =
6060
+ draft.source === undefined ? newReportId() : minedIntakeId(draft.project, draft.source);
6061
+ insertIntake.run(id, draft.project, draft.text, draft.at, draft.source ?? null);
6062
+ const stored = selectIntakeById.get(id);
6063
+ // The read cannot miss — the row is either the one just inserted or the
6064
+ // one that made the insert a no-op — but a fabricated pending item would
6065
+ // be worse than a loud failure, since the caller would report having
6066
+ // filed something that is not on file.
6067
+ if (stored === null) {
6068
+ throw new Error(`intake item ${id} vanished immediately after insert`);
6069
+ }
6070
+ return toIntakeItem(stored);
5518
6071
  },
5519
6072
 
5520
6073
  pendingIntake(project: string): IntakeItem[] {
@@ -5572,6 +6125,43 @@ export function openStore(dbPath: string): Store {
5572
6125
  return condemned;
5573
6126
  },
5574
6127
 
6128
+ markGroomingPromoted(project: string, issue: number, at: number, by: PromotedBy): boolean {
6129
+ // One guarded UPDATE, so "did I win" is SQLite's answer rather than a
6130
+ // read-then-write that two daemon passes could both pass (#1041).
6131
+ return markGroomingPromotedRow.run(at, by, project, issue).changes > 0;
6132
+ },
6133
+
6134
+ promotionsSince(project: string, since: number): GroomingRecord[] {
6135
+ return selectPromotionsSince.all(project, since).map(toGrooming);
6136
+ },
6137
+
6138
+ approveEpic(project: string, issue: number, at: number, by: string): void {
6139
+ // Refused rather than stored: an approval is a standing gate other code
6140
+ // reads, and a row keyed on #0 or #1.5 would be a permanent fact about an
6141
+ // issue that cannot exist. The table's CHECK would catch the non-positive
6142
+ // case as an opaque SQLite error; this names the argument instead.
6143
+ if (!Number.isInteger(issue) || issue <= 0) {
6144
+ throw new Error(`an epic approval needs a positive issue number, got ${String(issue)}`);
6145
+ }
6146
+ insertEpicApproval.run(project, issue, at, by);
6147
+ },
6148
+
6149
+ revokeEpicApproval(project: string, issue: number): boolean {
6150
+ if (!Number.isInteger(issue) || issue <= 0) {
6151
+ throw new Error(`an epic approval needs a positive issue number, got ${String(issue)}`);
6152
+ }
6153
+ return deleteEpicApproval.run(project, issue).changes > 0;
6154
+ },
6155
+
6156
+ epicApproval(project: string, issue: number): EpicApproval | undefined {
6157
+ const row = selectEpicApproval.get(project, issue);
6158
+ return row === null ? undefined : { ...row };
6159
+ },
6160
+
6161
+ epicApprovals(project: string): EpicApproval[] {
6162
+ return selectEpicApprovals.all(project).map((row) => ({ ...row }));
6163
+ },
6164
+
5575
6165
  close(): void {
5576
6166
  db.close(false);
5577
6167
  },