omp-conductor 0.19.7 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) 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/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7923
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/failure-class.ts +75 -1
  49. package/src/fleet.ts +290 -164
  50. package/src/groom.ts +461 -0
  51. package/src/http-token.ts +142 -0
  52. package/src/knowledge.ts +229 -0
  53. package/src/mining.ts +316 -0
  54. package/src/orchestrator-tick.ts +428 -1681
  55. package/src/ready-gate.ts +267 -0
  56. package/src/settlement.ts +72 -6
  57. package/src/setup-host.ts +32 -9
  58. package/src/setup-wizard.ts +55 -7
  59. package/src/setup.ts +229 -3
  60. package/src/stats.ts +257 -2
  61. package/src/status-render.ts +158 -7
  62. package/src/store.ts +604 -26
  63. package/src/to-spec.ts +194 -21
  64. package/src/tracker/github.ts +50 -0
  65. package/src/types.ts +416 -15
  66. package/src/verbs/protocol.ts +28 -0
  67. package/src/verbs/server.ts +330 -39
  68. package/src/wake.ts +19 -2
  69. package/src/worker.ts +456 -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".
@@ -2987,18 +3280,32 @@ export function openStore(dbPath: string): Store {
2987
3280
  const selectDispatch = db.query<{ summary: string }, [string]>(
2988
3281
  `SELECT summary FROM dispatch_summaries WHERE project = ?`,
2989
3282
  );
3283
+ // One projection list for every grooming read, so a new column can never be
3284
+ // visible through one accessor and NULL through another.
3285
+ const GROOMING_COLUMNS = "project, issue, verdict, reason, evidence, recordedAt, promotedAt, promotedBy";
2990
3286
  const selectGrooming = db.query<GroomingRow, [string, number]>(
2991
- `SELECT project, issue, verdict, reason, evidence, recordedAt
2992
- FROM grooming WHERE project = ? AND issue = ?`,
3287
+ `SELECT ${GROOMING_COLUMNS} FROM grooming WHERE project = ? AND issue = ?`,
2993
3288
  );
2994
3289
  const selectGroomingAll = db.query<GroomingRow, [string]>(
2995
- `SELECT project, issue, verdict, reason, evidence, recordedAt
2996
- FROM grooming WHERE project = ? ORDER BY issue ASC`,
3290
+ `SELECT ${GROOMING_COLUMNS} FROM grooming WHERE project = ? ORDER BY issue ASC`,
2997
3291
  );
2998
3292
  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
- );
3293
+ `SELECT ${GROOMING_COLUMNS} FROM grooming WHERE project = ? AND verdict = ? ORDER BY issue ASC`,
3294
+ );
3295
+ // Newest promotion first, and `promotedAt DESC` alone would be a non-
3296
+ // deterministic order for a batch promoted inside one millisecond, which the
3297
+ // daemon's promotion loop does routinely — the issue number breaks the tie so
3298
+ // an audit digest renders the same list twice.
3299
+ const selectPromotionsSince = db.query<GroomingRow, [string, number]>(
3300
+ `SELECT ${GROOMING_COLUMNS} FROM grooming
3301
+ WHERE project = ? AND promotedAt IS NOT NULL AND promotedAt >= ?
3302
+ ORDER BY promotedAt DESC, issue DESC`,
3303
+ );
3304
+ // The upsert deliberately does NOT carry the promotion columns forward: on
3305
+ // conflict they are reset to NULL, because a re-recorded verdict is a new
3306
+ // judgement and provenance that outlives its judgement audits the wrong
3307
+ // decision (#1041). Spelled as explicit `= NULL` rather than left out, so the
3308
+ // clearing is a stated choice a reader can weigh rather than an omission.
3002
3309
  const upsertGroomingRow = db.query<unknown, [string, number, string, string, string, number]>(
3003
3310
  `INSERT INTO grooming (project, issue, verdict, reason, evidence, recordedAt)
3004
3311
  VALUES (?, ?, ?, ?, ?, ?)
@@ -3006,12 +3313,33 @@ export function openStore(dbPath: string): Store {
3006
3313
  verdict = excluded.verdict,
3007
3314
  reason = excluded.reason,
3008
3315
  evidence = excluded.evidence,
3009
- recordedAt = excluded.recordedAt`,
3010
- );
3316
+ recordedAt = excluded.recordedAt,
3317
+ promotedAt = NULL,
3318
+ promotedBy = NULL`,
3319
+ );
3320
+ // Stamp-once, and the `IS NULL` guard is the whole contract: `changes === 0`
3321
+ // means either no such verdict or one already promoted, which is exactly the
3322
+ // set of cases a caller must not queue the issue for. That makes this write
3323
+ // the promotion latch, so it runs before the label op (#1041).
3324
+ const markGroomingPromotedRow = db.query<unknown, [number, string, string, number]>(
3325
+ `UPDATE grooming SET promotedAt = ?, promotedBy = ?
3326
+ WHERE project = ? AND issue = ? AND promotedAt IS NULL`,
3327
+ );
3328
+ // A promoted row is inert to admission's blocked bookkeeping, in both
3329
+ // directions (#1041): the sweep below skips it and the per-hold upsert never
3330
+ // rewrites it. Promotion handed the issue to dispatch, and a lane/dependency
3331
+ // hold on a queued issue is flow control of the moment, not a re-grooming.
3332
+ // Without the exclusion the audit line would die inside one 5-minute
3333
+ // admission pass — rewritten `blocked`, then swept — while the tick that must
3334
+ // review the promotion runs every ~30 minutes.
3011
3335
  const clearGroomedBlocked = db.query<unknown, [string]>(
3012
3336
  `DELETE FROM grooming
3013
3337
  WHERE project = ? AND verdict = 'blocked'
3014
- AND reason IN ('file-lane', 'depends-on')`,
3338
+ AND reason IN ('file-lane', 'depends-on')
3339
+ AND promotedAt IS NULL`,
3340
+ );
3341
+ const selectGroomingPromotedAt = db.query<{ promotedAt: number | null }, [string, number]>(
3342
+ `SELECT promotedAt FROM grooming WHERE project = ? AND issue = ?`,
3015
3343
  );
3016
3344
  // One transaction, so a pass can never leave the blocked inventory half
3017
3345
  // written: the stale rows are cleared (the hold no longer applies) and the
@@ -3021,6 +3349,7 @@ export function openStore(dbPath: string): Store {
3021
3349
  clearGroomedBlocked.run(project);
3022
3350
  for (const hold of holds) {
3023
3351
  if (BLOCKED_GROOMING_HOLDS[hold.reason] !== true) continue;
3352
+ if (selectGroomingPromotedAt.get(project, hold.issue)?.promotedAt != null) continue;
3024
3353
  upsertGroomingRow.run(project, hold.issue, "blocked", hold.reason, hold.detail ?? hold.reason, Date.now());
3025
3354
  }
3026
3355
  },
@@ -3045,6 +3374,26 @@ export function openStore(dbPath: string): Store {
3045
3374
  for (const issue of issues) removed += deleteGroomingRow.run(project, issue).changes;
3046
3375
  return removed;
3047
3376
  });
3377
+ // Epic scope approvals (#1041). `DO NOTHING` rather than `DO UPDATE` is the
3378
+ // idempotence: the durable fact is when consent was first given, so a second
3379
+ // `epic approve` must not silently restamp the timestamp and make an old
3380
+ // approval look fresh in an audit.
3381
+ const insertEpicApproval = db.query<unknown, [string, number, number, string]>(
3382
+ `INSERT INTO epic_approvals (project, issue, approvedAt, approvedBy)
3383
+ VALUES (?, ?, ?, ?)
3384
+ ON CONFLICT(project, issue) DO NOTHING`,
3385
+ );
3386
+ const deleteEpicApproval = db.query<unknown, [string, number]>(
3387
+ `DELETE FROM epic_approvals WHERE project = ? AND issue = ?`,
3388
+ );
3389
+ const selectEpicApproval = db.query<EpicApprovalRow, [string, number]>(
3390
+ `SELECT issue, approvedAt, approvedBy FROM epic_approvals
3391
+ WHERE project = ? AND issue = ?`,
3392
+ );
3393
+ const selectEpicApprovals = db.query<EpicApprovalRow, [string]>(
3394
+ `SELECT issue, approvedAt, approvedBy FROM epic_approvals
3395
+ WHERE project = ? ORDER BY issue ASC`,
3396
+ );
3048
3397
  const selectFrictionRollup = db.query<FrictionRollupRow, [string, string, string]>(
3049
3398
  `SELECT * FROM friction_rollups WHERE project = ? AND day = ? AND kind = ?`,
3050
3399
  );
@@ -3091,6 +3440,43 @@ export function openStore(dbPath: string): Store {
3091
3440
  pruneGhRefusals.run(at - 24 * 60 * 60 * 1000);
3092
3441
  });
3093
3442
 
3443
+ // Auto-restarts of a wedged orchestrator (Phase 4). Insert + prune share one
3444
+ // transaction, exactly as above, so a project that keeps wedging can never
3445
+ // grow this table without bound. The retention (7d) is deliberately longer
3446
+ // than the 6h cap window: the cap needs the last few hours, but an operator
3447
+ // reading the page wants to know whether this is the first bad night or the
3448
+ // fourth.
3449
+ const insertOrchestratorRestart = db.query<unknown, [string, number, string]>(
3450
+ `INSERT INTO orchestrator_restarts (project, at, reason) VALUES (?, ?, ?)`,
3451
+ );
3452
+ const pruneOrchestratorRestarts = db.query<unknown, [number]>(
3453
+ `DELETE FROM orchestrator_restarts WHERE at < ?`,
3454
+ );
3455
+ const selectOrchestratorRestartsSince = db.query<{ at: number; reason: string }, [string, number]>(
3456
+ `SELECT at, reason FROM orchestrator_restarts
3457
+ WHERE project = ? AND at >= ?
3458
+ ORDER BY at DESC, rowid DESC`,
3459
+ );
3460
+ const recordOrchestratorRestartTx = db.transaction(
3461
+ (project: string, at: number, reason: string): void => {
3462
+ insertOrchestratorRestart.run(project, at, reason);
3463
+ pruneOrchestratorRestarts.run(at - 7 * 24 * 60 * 60 * 1000);
3464
+ },
3465
+ );
3466
+
3467
+ // Daemon-owned session spend (Phase 4). Append-only: these sessions have no
3468
+ // lifecycle, they either ran or they did not.
3469
+ const insertSessionSpend = db.query<unknown, SqlValue[]>(
3470
+ `INSERT INTO session_spend (project, role, issue, model, resolvedModel, turns, spendUsd, at)
3471
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
3472
+ );
3473
+ const selectSessionSpendSince = db.query<SessionSpendSqlRow, [string, number]>(
3474
+ `SELECT project, role, issue, model, resolvedModel, turns, spendUsd, at
3475
+ FROM session_spend
3476
+ WHERE project = ? AND at >= ?
3477
+ ORDER BY at DESC, id DESC`,
3478
+ );
3479
+
3094
3480
  // The daemon's tracked github call counts (#198): one UPSERT per spawn.
3095
3481
  const bumpGhCall = db.query<unknown, [string, string]>(
3096
3482
  `INSERT INTO gh_calls (day, source, calls) VALUES (?, ?, 1)
@@ -3437,9 +3823,23 @@ export function openStore(dbPath: string): Store {
3437
3823
  OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state IN ('failed', 'withdrawn'))
3438
3824
  )`,
3439
3825
  );
3826
+ // INSERT OR IGNORE, not plain INSERT (Phase 4). For an operator's own idea
3827
+ // the id is random and the clause never fires; for a mined signal the id is
3828
+ // derived from project + source, so the second filing of a signal already on
3829
+ // file collides with its own row and does nothing. Crucially "does nothing"
3830
+ // includes not touching `state`: an UPSERT here would resurrect an item the
3831
+ // operator dismissed on every mining pass, which is the failure mode this
3832
+ // shape exists to prevent.
3440
3833
  const insertIntake = db.query<unknown, SqlValue[]>(
3441
- `INSERT INTO intake_items (id, project, text, createdAt, state)
3442
- VALUES (?, ?, ?, ?, 'pending')`,
3834
+ `INSERT OR IGNORE INTO intake_items (id, project, text, createdAt, state, source)
3835
+ VALUES (?, ?, ?, ?, 'pending', ?)`,
3836
+ );
3837
+ // Read back what is actually on file after the insert, which for a collision
3838
+ // is the row that was already there. The caller gets the truth (including a
3839
+ // `dismissed` state and the original `createdAt`) rather than the draft it
3840
+ // hoped to write.
3841
+ const selectIntakeById = db.query<IntakeRow, [string]>(
3842
+ `SELECT * FROM intake_items WHERE id = ?`,
3443
3843
  );
3444
3844
  const selectPendingIntake = db.query<IntakeRow, [string]>(
3445
3845
  `SELECT * FROM intake_items
@@ -3875,6 +4275,62 @@ export function openStore(dbPath: string): Store {
3875
4275
  const selectReviewRevisionById = db.query<ReviewRevisionRow, [string]>(
3876
4276
  `SELECT * FROM review_revisions WHERE id = ?`,
3877
4277
  );
4278
+ // The write-once launch decision (#1045). ONE guarded UPDATE decides and
4279
+ // writes, exactly as the infra retry above does, because a read-then-write
4280
+ // is precisely how two dispatch passes both "find no decision" and both
4281
+ // launch. `changes` is the answer:
4282
+ //
4283
+ // - launchMode IS NULL -> nobody has decided; this call wins.
4284
+ // - the guard triple matches -> the SAME decision again (a retry, a
4285
+ // restart deciding identically). The row
4286
+ // is re-affirmed rather than refused, so
4287
+ // a dispatch retry is idempotent.
4288
+ // - anything else -> 0 changes, and the caller must read the
4289
+ // recorded decision and honour it.
4290
+ //
4291
+ // `IS` rather than `=` on the guard: NULL = NULL is NULL in SQL, so `=`
4292
+ // would refuse a re-affirmation of a decision that carried no requested
4293
+ // model or no origin lineage — the shape a legacy-session round has.
4294
+ //
4295
+ // launchDecidedAt is COALESCE'd, never overwritten: a re-affirmation must
4296
+ // leave the moment of the original decision alone, or a retry loop would
4297
+ // make an old decision look freshly made in every audit that reads it.
4298
+ //
4299
+ // The SET touches decision columns only. The issue, run, round, branch, PR
4300
+ // URL, reviewed head, findings, charges and settlement history are the
4301
+ // round's identity and are never written by this lane.
4302
+ const recordReviewRevisionLaunchRow = db.query<
4303
+ unknown,
4304
+ [string, string | null, string | null, number, string, string, string, string | null, string | null]
4305
+ >(
4306
+ `UPDATE review_revisions
4307
+ SET launchMode = ?,
4308
+ requestedModel = ?,
4309
+ originSessionRef = ?,
4310
+ launchDecidedAt = COALESCE(launchDecidedAt, ?)
4311
+ WHERE id = ? AND project = ?
4312
+ AND (launchMode IS NULL
4313
+ OR (launchMode IS ? AND requestedModel IS ? AND originSessionRef IS ?))`,
4314
+ );
4315
+ // The fresh correction's own session lineage (#1045), attachable only to a
4316
+ // round that was decided `fresh-correction`: a resumed round IS its origin
4317
+ // session, so a separate correction lineage on one would name a session that
4318
+ // never existed. The mode is bound rather than inlined so the vocabulary
4319
+ // lives in exactly one place — {@link ReviewLaunchMode}.
4320
+ //
4321
+ // Deliberately not write-once: an infra-killed round relaunches under the
4322
+ // same decision, and the newest fresh session is then the truth about what
4323
+ // is running. `resolvedModel` is COALESCE'd from the argument side, so a
4324
+ // later call that does not know the resolution cannot erase a known one.
4325
+ const recordReviewCorrectionSessionRow = db.query<unknown, [string, string | null, string, string, string]>(
4326
+ `UPDATE review_revisions
4327
+ SET correctionSessionRef = ?,
4328
+ resolvedModel = COALESCE(?, resolvedModel)
4329
+ WHERE id = ? AND project = ? AND launchMode = ?`,
4330
+ );
4331
+ const selectReviewRevisionForProject = db.query<ReviewRevisionRow, [string, string]>(
4332
+ `SELECT * FROM review_revisions WHERE id = ? AND project = ?`,
4333
+ );
3878
4334
  // The exact-head merge gate (#888): every row that stands between a PR and
3879
4335
  // a merge at one head — anything not yet settled (queued, or dispatched and
3880
4336
  // crashed mid-review) plus anything settled `failed` there. `skipped`,
@@ -4093,7 +4549,7 @@ export function openStore(dbPath: string): Store {
4093
4549
  // appends to the winner's row. The unique pending-per-run index remains as
4094
4550
  // the schema-level backstop.
4095
4551
  const enqueueReviewRevisionTx = db.transaction(
4096
- (draft: Omit<ReviewRevisionRecord, "id">): ReviewRevisionEnqueue => {
4552
+ (draft: ReviewRevisionDraft): ReviewRevisionEnqueue => {
4097
4553
  const pending = selectPendingReviewForRun.get(draft.project, draft.runId);
4098
4554
  if (pending !== null) {
4099
4555
  if (pending.headSha !== draft.headSha) {
@@ -4448,7 +4904,7 @@ export function openStore(dbPath: string): Store {
4448
4904
  runsForProjectPr(project: string, prUrl: string, mergedSinceEpochMs: number): RunRecord[] {
4449
4905
  return selectRunsForPr.all(project, prUrl, mergedSinceEpochMs).map(toRecord);
4450
4906
  },
4451
- enqueueReviewRevision(draft: Omit<ReviewRevisionRecord, "id">): ReviewRevisionEnqueue {
4907
+ enqueueReviewRevision(draft: ReviewRevisionDraft): ReviewRevisionEnqueue {
4452
4908
  // `immediate` = BEGIN IMMEDIATE: the write lock is acquired before the
4453
4909
  // pending-row read, so a second connection's transaction cannot observe
4454
4910
  // a snapshot older than the first connection's commit (#786).
@@ -4467,6 +4923,51 @@ export function openStore(dbPath: string): Store {
4467
4923
  markReviewRevisionDispatched(id: string, at: number): void {
4468
4924
  markReviewRevisionDispatchedRow.run(at, id);
4469
4925
  },
4926
+ recordReviewRevisionLaunch(
4927
+ project: string,
4928
+ id: string,
4929
+ decision: {
4930
+ launchMode: ReviewLaunchMode;
4931
+ requestedModel?: string;
4932
+ originSessionRef?: string;
4933
+ at: number;
4934
+ },
4935
+ ): boolean {
4936
+ // The decision and the write are one statement (#1045): `changes` is the
4937
+ // verdict, so a second dispatch pass cannot observe "undecided" and
4938
+ // launch its own. Re-affirming the identical decision reports true —
4939
+ // a dispatch retry after a killed launch must be idempotent, not fatal.
4940
+ const requestedModel = decision.requestedModel ?? null;
4941
+ const originSessionRef = decision.originSessionRef ?? null;
4942
+ return (
4943
+ recordReviewRevisionLaunchRow.run(
4944
+ decision.launchMode,
4945
+ requestedModel,
4946
+ originSessionRef,
4947
+ decision.at,
4948
+ id,
4949
+ project,
4950
+ decision.launchMode,
4951
+ requestedModel,
4952
+ originSessionRef,
4953
+ ).changes > 0
4954
+ );
4955
+ },
4956
+ recordReviewCorrectionSession(project: string, id: string, ref: string, resolvedModel?: string): boolean {
4957
+ // Guarded on the recorded mode, in the statement: a round decided
4958
+ // `resume-original` has no correction session to name, and answering
4959
+ // false here is how the caller learns it read the wrong decision.
4960
+ return (
4961
+ recordReviewCorrectionSessionRow.run(ref, resolvedModel ?? null, id, project, FRESH_CORRECTION).changes > 0
4962
+ );
4963
+ },
4964
+ reviewRevisionLaunch(project: string, id: string): ReviewCorrectionProvenance | undefined {
4965
+ const row = selectReviewRevisionForProject.get(id, project);
4966
+ // A missing round is `undefined`; a round nobody has decided is an empty
4967
+ // provenance. The distinction matters: the first is "no such round", the
4968
+ // second is "this round exists and nothing was written down".
4969
+ return row === null || row === undefined ? undefined : toCorrectionProvenance(row);
4970
+ },
4470
4971
  settleReviewRevision(id: string, outcome: ReviewRevisionOutcome, at: number): void {
4471
4972
  settleReviewRevisionRow.run(outcome, at, id);
4472
4973
  },
@@ -5081,6 +5582,38 @@ export function openStore(dbPath: string): Store {
5081
5582
  return { count: row.n, latestAt: row.latest };
5082
5583
  },
5083
5584
 
5585
+ recordOrchestratorRestart(project: string, at: number, reason: string): void {
5586
+ // Same guard as `recordGhRefusal`: a bad clock must not corrupt the
5587
+ // store, and here it would corrupt the cap itself — a restart stamped in
5588
+ // 1970 falls outside every window and is a free restart forever.
5589
+ if (!Number.isSafeInteger(at) || at < 0) return;
5590
+ recordOrchestratorRestartTx(project, at, reason);
5591
+ },
5592
+
5593
+ orchestratorRestartsSince(project: string, since: number): { at: number; reason: string }[] {
5594
+ return selectOrchestratorRestartsSince.all(project, since);
5595
+ },
5596
+
5597
+ recordSessionSpend(row: SessionSpendRow): void {
5598
+ insertSessionSpend.run(
5599
+ row.project,
5600
+ row.role,
5601
+ row.issue ?? null,
5602
+ row.model ?? null,
5603
+ row.resolvedModel ?? null,
5604
+ row.turns,
5605
+ // `?? null` and NOT `?? 0`: an absent spend is an unmetered session,
5606
+ // and writing 0 here would report it as free. The two readings are
5607
+ // distinguishable at the row precisely because this stays NULL.
5608
+ row.spendUsd ?? null,
5609
+ row.at,
5610
+ );
5611
+ },
5612
+
5613
+ sessionSpendSince(project: string, since: number): SessionSpendRow[] {
5614
+ return selectSessionSpendSince.all(project, since).map(toSessionSpend);
5615
+ },
5616
+
5084
5617
  bumpGhCalls(day: string, source: string): void {
5085
5618
  bumpGhCall.run(day, source);
5086
5619
  },
@@ -5506,15 +6039,23 @@ export function openStore(dbPath: string): Store {
5506
6039
  },
5507
6040
 
5508
6041
  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;
6042
+ // A mined signal's id is a function of its provenance, an operator's own
6043
+ // idea gets a fresh random one. That single choice is the whole
6044
+ // idempotency mechanism: the machine keeps landing on its own row, the
6045
+ // human never does, and two identical thoughts on two days stay two
6046
+ // thoughts.
6047
+ const id =
6048
+ draft.source === undefined ? newReportId() : minedIntakeId(draft.project, draft.source);
6049
+ insertIntake.run(id, draft.project, draft.text, draft.at, draft.source ?? null);
6050
+ const stored = selectIntakeById.get(id);
6051
+ // The read cannot miss — the row is either the one just inserted or the
6052
+ // one that made the insert a no-op — but a fabricated pending item would
6053
+ // be worse than a loud failure, since the caller would report having
6054
+ // filed something that is not on file.
6055
+ if (stored === null) {
6056
+ throw new Error(`intake item ${id} vanished immediately after insert`);
6057
+ }
6058
+ return toIntakeItem(stored);
5518
6059
  },
5519
6060
 
5520
6061
  pendingIntake(project: string): IntakeItem[] {
@@ -5572,6 +6113,43 @@ export function openStore(dbPath: string): Store {
5572
6113
  return condemned;
5573
6114
  },
5574
6115
 
6116
+ markGroomingPromoted(project: string, issue: number, at: number, by: PromotedBy): boolean {
6117
+ // One guarded UPDATE, so "did I win" is SQLite's answer rather than a
6118
+ // read-then-write that two daemon passes could both pass (#1041).
6119
+ return markGroomingPromotedRow.run(at, by, project, issue).changes > 0;
6120
+ },
6121
+
6122
+ promotionsSince(project: string, since: number): GroomingRecord[] {
6123
+ return selectPromotionsSince.all(project, since).map(toGrooming);
6124
+ },
6125
+
6126
+ approveEpic(project: string, issue: number, at: number, by: string): void {
6127
+ // Refused rather than stored: an approval is a standing gate other code
6128
+ // reads, and a row keyed on #0 or #1.5 would be a permanent fact about an
6129
+ // issue that cannot exist. The table's CHECK would catch the non-positive
6130
+ // case as an opaque SQLite error; this names the argument instead.
6131
+ if (!Number.isInteger(issue) || issue <= 0) {
6132
+ throw new Error(`an epic approval needs a positive issue number, got ${String(issue)}`);
6133
+ }
6134
+ insertEpicApproval.run(project, issue, at, by);
6135
+ },
6136
+
6137
+ revokeEpicApproval(project: string, issue: number): boolean {
6138
+ if (!Number.isInteger(issue) || issue <= 0) {
6139
+ throw new Error(`an epic approval needs a positive issue number, got ${String(issue)}`);
6140
+ }
6141
+ return deleteEpicApproval.run(project, issue).changes > 0;
6142
+ },
6143
+
6144
+ epicApproval(project: string, issue: number): EpicApproval | undefined {
6145
+ const row = selectEpicApproval.get(project, issue);
6146
+ return row === null ? undefined : { ...row };
6147
+ },
6148
+
6149
+ epicApprovals(project: string): EpicApproval[] {
6150
+ return selectEpicApprovals.all(project).map((row) => ({ ...row }));
6151
+ },
6152
+
5575
6153
  close(): void {
5576
6154
  db.close(false);
5577
6155
  },