omp-conductor 0.19.6 → 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 (71) hide show
  1. package/REFERENCE.md +27 -2
  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 -7832
  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/doctor.ts +17 -12
  49. package/src/escalate.ts +39 -21
  50. package/src/failure-class.ts +75 -1
  51. package/src/fleet.ts +1218 -304
  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 +428 -1681
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +72 -6
  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 +158 -7
  64. package/src/store.ts +646 -26
  65. package/src/to-spec.ts +194 -21
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +435 -15
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +384 -12
  70. package/src/wake.ts +19 -2
  71. 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);
@@ -1356,6 +1575,20 @@ CREATE TABLE IF NOT EXISTS orchestrator_incidents (
1356
1575
  diverted INTEGER NOT NULL DEFAULT 0
1357
1576
  );
1358
1577
 
1578
+ -- Conductor-owned Herdr worker workspaces (#1035 review). Herdr restores a
1579
+ -- session's workspaces and panes across a server restart but drops
1580
+ -- report-metadata tokens, so the live-only token cannot remain the only
1581
+ -- ownership authority: the restored surface would be invisible to discovery,
1582
+ -- a duplicate would be created beside it, and its stale panes could never be
1583
+ -- reconciled or removed. One row per workspace conductor created and still
1584
+ -- owns; forgotten when the workspace is removed or fails to create cleanly.
1585
+ CREATE TABLE IF NOT EXISTS herdr_worker_workspaces (
1586
+ project TEXT NOT NULL,
1587
+ workspace_id TEXT NOT NULL,
1588
+ created_at INTEGER NOT NULL,
1589
+ PRIMARY KEY (project, workspace_id)
1590
+ );
1591
+
1359
1592
  -- Every stop/restart of the shared daemon, and who asked for it and why
1360
1593
  -- (#378). Deliberately NOT partitioned by project: the daemon serves every
1361
1594
  -- configured project, so a record written by one project's CLI must be
@@ -1692,6 +1925,25 @@ function newReportId(): string {
1692
1925
  return crypto.randomUUID().replaceAll("-", "").slice(0, 12);
1693
1926
  }
1694
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
+
1695
1947
  /**
1696
1948
  * A ledger row, with the same NULL-becomes-absent posture as {@link toReport}.
1697
1949
  *
@@ -2240,6 +2492,24 @@ export function openStore(dbPath: string): Store {
2240
2492
  }
2241
2493
  }
2242
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)");
2243
2513
  // Already-met watches, written before the deadline existed (#966). Unlike the
2244
2514
  // head binding above this IS backfilled, and the difference is that nothing is
2245
2515
  // being guessed: `conditionMetAt` is a recorded observation, and the deadline
@@ -2277,7 +2547,44 @@ export function openStore(dbPath: string): Store {
2277
2547
  if (!reviewRevisionColumns.some((column) => column.name === "infraRetries")) {
2278
2548
  db.exec("ALTER TABLE review_revisions ADD COLUMN infraRetries INTEGER");
2279
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
+ }
2280
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
+ }
2281
2588
  // The escalation findings on an adjudication (#932). Rows written by #874's
2282
2589
  // lifecycle carried none — the ceiling still refused then — so NULL is the
2283
2590
  // honest reading and renders as "no findings recorded".
@@ -2954,6 +3261,18 @@ export function openStore(dbPath: string): Store {
2954
3261
  WHERE project = ?
2955
3262
  RETURNING project, mode, cause, since, diverted`,
2956
3263
  );
3264
+ // Worker-workspace ownership rows (#1035 review). See the table comment:
3265
+ // the durable half of discovery, so a Herdr restart that drops metadata
3266
+ // tokens cannot orphan the surface conductor created.
3267
+ const selectHerdrWorkerWorkspaces = db.query<{ workspace_id: string }, [string]>(
3268
+ `SELECT workspace_id FROM herdr_worker_workspaces WHERE project = ? ORDER BY created_at ASC, workspace_id ASC`,
3269
+ );
3270
+ const insertHerdrWorkerWorkspace = db.query<unknown, [string, string, number]>(
3271
+ `INSERT OR IGNORE INTO herdr_worker_workspaces (project, workspace_id, created_at) VALUES (?, ?, ?)`,
3272
+ );
3273
+ const deleteHerdrWorkerWorkspace = db.query<unknown, [string, string]>(
3274
+ `DELETE FROM herdr_worker_workspaces WHERE project = ? AND workspace_id = ?`,
3275
+ );
2957
3276
  const upsertDispatch = db.query<unknown, [string, string]>(
2958
3277
  `INSERT INTO dispatch_summaries (project, summary) VALUES (?, ?)
2959
3278
  ON CONFLICT(project) DO UPDATE SET summary = excluded.summary`,
@@ -2961,18 +3280,32 @@ export function openStore(dbPath: string): Store {
2961
3280
  const selectDispatch = db.query<{ summary: string }, [string]>(
2962
3281
  `SELECT summary FROM dispatch_summaries WHERE project = ?`,
2963
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";
2964
3286
  const selectGrooming = db.query<GroomingRow, [string, number]>(
2965
- `SELECT project, issue, verdict, reason, evidence, recordedAt
2966
- FROM grooming WHERE project = ? AND issue = ?`,
3287
+ `SELECT ${GROOMING_COLUMNS} FROM grooming WHERE project = ? AND issue = ?`,
2967
3288
  );
2968
3289
  const selectGroomingAll = db.query<GroomingRow, [string]>(
2969
- `SELECT project, issue, verdict, reason, evidence, recordedAt
2970
- FROM grooming WHERE project = ? ORDER BY issue ASC`,
3290
+ `SELECT ${GROOMING_COLUMNS} FROM grooming WHERE project = ? ORDER BY issue ASC`,
2971
3291
  );
2972
3292
  const selectGroomingByVerdict = db.query<GroomingRow, [string, string]>(
2973
- `SELECT project, issue, verdict, reason, evidence, recordedAt
2974
- FROM grooming WHERE project = ? AND verdict = ? ORDER BY issue ASC`,
2975
- );
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.
2976
3309
  const upsertGroomingRow = db.query<unknown, [string, number, string, string, string, number]>(
2977
3310
  `INSERT INTO grooming (project, issue, verdict, reason, evidence, recordedAt)
2978
3311
  VALUES (?, ?, ?, ?, ?, ?)
@@ -2980,12 +3313,33 @@ export function openStore(dbPath: string): Store {
2980
3313
  verdict = excluded.verdict,
2981
3314
  reason = excluded.reason,
2982
3315
  evidence = excluded.evidence,
2983
- recordedAt = excluded.recordedAt`,
2984
- );
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.
2985
3335
  const clearGroomedBlocked = db.query<unknown, [string]>(
2986
3336
  `DELETE FROM grooming
2987
3337
  WHERE project = ? AND verdict = 'blocked'
2988
- 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 = ?`,
2989
3343
  );
2990
3344
  // One transaction, so a pass can never leave the blocked inventory half
2991
3345
  // written: the stale rows are cleared (the hold no longer applies) and the
@@ -2995,6 +3349,7 @@ export function openStore(dbPath: string): Store {
2995
3349
  clearGroomedBlocked.run(project);
2996
3350
  for (const hold of holds) {
2997
3351
  if (BLOCKED_GROOMING_HOLDS[hold.reason] !== true) continue;
3352
+ if (selectGroomingPromotedAt.get(project, hold.issue)?.promotedAt != null) continue;
2998
3353
  upsertGroomingRow.run(project, hold.issue, "blocked", hold.reason, hold.detail ?? hold.reason, Date.now());
2999
3354
  }
3000
3355
  },
@@ -3019,6 +3374,26 @@ export function openStore(dbPath: string): Store {
3019
3374
  for (const issue of issues) removed += deleteGroomingRow.run(project, issue).changes;
3020
3375
  return removed;
3021
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
+ );
3022
3397
  const selectFrictionRollup = db.query<FrictionRollupRow, [string, string, string]>(
3023
3398
  `SELECT * FROM friction_rollups WHERE project = ? AND day = ? AND kind = ?`,
3024
3399
  );
@@ -3065,6 +3440,43 @@ export function openStore(dbPath: string): Store {
3065
3440
  pruneGhRefusals.run(at - 24 * 60 * 60 * 1000);
3066
3441
  });
3067
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
+
3068
3480
  // The daemon's tracked github call counts (#198): one UPSERT per spawn.
3069
3481
  const bumpGhCall = db.query<unknown, [string, string]>(
3070
3482
  `INSERT INTO gh_calls (day, source, calls) VALUES (?, ?, 1)
@@ -3411,9 +3823,23 @@ export function openStore(dbPath: string): Store {
3411
3823
  OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state IN ('failed', 'withdrawn'))
3412
3824
  )`,
3413
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.
3414
3833
  const insertIntake = db.query<unknown, SqlValue[]>(
3415
- `INSERT INTO intake_items (id, project, text, createdAt, state)
3416
- 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 = ?`,
3417
3843
  );
3418
3844
  const selectPendingIntake = db.query<IntakeRow, [string]>(
3419
3845
  `SELECT * FROM intake_items
@@ -3849,6 +4275,62 @@ export function openStore(dbPath: string): Store {
3849
4275
  const selectReviewRevisionById = db.query<ReviewRevisionRow, [string]>(
3850
4276
  `SELECT * FROM review_revisions WHERE id = ?`,
3851
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
+ );
3852
4334
  // The exact-head merge gate (#888): every row that stands between a PR and
3853
4335
  // a merge at one head — anything not yet settled (queued, or dispatched and
3854
4336
  // crashed mid-review) plus anything settled `failed` there. `skipped`,
@@ -4067,7 +4549,7 @@ export function openStore(dbPath: string): Store {
4067
4549
  // appends to the winner's row. The unique pending-per-run index remains as
4068
4550
  // the schema-level backstop.
4069
4551
  const enqueueReviewRevisionTx = db.transaction(
4070
- (draft: Omit<ReviewRevisionRecord, "id">): ReviewRevisionEnqueue => {
4552
+ (draft: ReviewRevisionDraft): ReviewRevisionEnqueue => {
4071
4553
  const pending = selectPendingReviewForRun.get(draft.project, draft.runId);
4072
4554
  if (pending !== null) {
4073
4555
  if (pending.headSha !== draft.headSha) {
@@ -4422,7 +4904,7 @@ export function openStore(dbPath: string): Store {
4422
4904
  runsForProjectPr(project: string, prUrl: string, mergedSinceEpochMs: number): RunRecord[] {
4423
4905
  return selectRunsForPr.all(project, prUrl, mergedSinceEpochMs).map(toRecord);
4424
4906
  },
4425
- enqueueReviewRevision(draft: Omit<ReviewRevisionRecord, "id">): ReviewRevisionEnqueue {
4907
+ enqueueReviewRevision(draft: ReviewRevisionDraft): ReviewRevisionEnqueue {
4426
4908
  // `immediate` = BEGIN IMMEDIATE: the write lock is acquired before the
4427
4909
  // pending-row read, so a second connection's transaction cannot observe
4428
4910
  // a snapshot older than the first connection's commit (#786).
@@ -4441,6 +4923,51 @@ export function openStore(dbPath: string): Store {
4441
4923
  markReviewRevisionDispatched(id: string, at: number): void {
4442
4924
  markReviewRevisionDispatchedRow.run(at, id);
4443
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
+ },
4444
4971
  settleReviewRevision(id: string, outcome: ReviewRevisionOutcome, at: number): void {
4445
4972
  settleReviewRevisionRow.run(outcome, at, id);
4446
4973
  },
@@ -4849,6 +5376,22 @@ export function openStore(dbPath: string): Store {
4849
5376
  bumpOrchestratorIncident.run(by, project);
4850
5377
  },
4851
5378
 
5379
+ /** The workspace ids conductor recorded as its own worker surface for
5380
+ * this project, oldest first (#1035 review). Durable across daemon AND
5381
+ * Herdr restarts, which is the point: Herdr restores workspaces without
5382
+ * their metadata tokens, so discovery needs a second authority. */
5383
+ workerWorkspaceIds(project: string): string[] {
5384
+ return selectHerdrWorkerWorkspaces.all(project).map((row) => row.workspace_id);
5385
+ },
5386
+
5387
+ rememberWorkerWorkspace(project: string, workspaceId: string): void {
5388
+ insertHerdrWorkerWorkspace.run(project, workspaceId, Date.now());
5389
+ },
5390
+
5391
+ forgetWorkerWorkspace(project: string, workspaceId: string): void {
5392
+ deleteHerdrWorkerWorkspace.run(project, workspaceId);
5393
+ },
5394
+
4852
5395
  closeOrchestratorIncident(project: string, _at: number): OrchestratorIncident | undefined {
4853
5396
  const row = deleteOrchestratorIncident.get(project);
4854
5397
  return row === null ? undefined : toOrchestratorIncident(row);
@@ -5039,6 +5582,38 @@ export function openStore(dbPath: string): Store {
5039
5582
  return { count: row.n, latestAt: row.latest };
5040
5583
  },
5041
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
+
5042
5617
  bumpGhCalls(day: string, source: string): void {
5043
5618
  bumpGhCall.run(day, source);
5044
5619
  },
@@ -5464,15 +6039,23 @@ export function openStore(dbPath: string): Store {
5464
6039
  },
5465
6040
 
5466
6041
  recordIntake(draft: IntakeDraft): IntakeItem {
5467
- const item: IntakeItem = {
5468
- id: newReportId(),
5469
- project: draft.project,
5470
- text: draft.text,
5471
- createdAt: draft.at,
5472
- state: "pending",
5473
- };
5474
- insertIntake.run(item.id, item.project, item.text, item.createdAt);
5475
- 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);
5476
6059
  },
5477
6060
 
5478
6061
  pendingIntake(project: string): IntakeItem[] {
@@ -5530,6 +6113,43 @@ export function openStore(dbPath: string): Store {
5530
6113
  return condemned;
5531
6114
  },
5532
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
+
5533
6153
  close(): void {
5534
6154
  db.close(false);
5535
6155
  },