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.
- package/REFERENCE.md +10 -1
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/arm-challenge.ts +204 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +253 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +113 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +412 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +736 -0
- package/src/daemon/settle-pass.ts +589 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7923
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +24 -3
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +290 -164
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +428 -1681
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +72 -6
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +158 -7
- package/src/store.ts +604 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +416 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- 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
|
|
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
|
|
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
|
|
3000
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
insertIntake.run(
|
|
5517
|
-
|
|
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
|
},
|