omp-conductor 0.18.2 → 0.19.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/README.md +105 -40
- package/REFERENCE.md +865 -30
- package/package.json +1 -1
- package/schema/config.schema.json +26 -0
- package/src/admission.ts +212 -26
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +6 -5
- package/src/cli.ts +5 -1
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +36 -3
- package/src/commands/arm.ts +5 -1
- package/src/commands/context.ts +2 -0
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/config-schema.ts +19 -0
- package/src/config.ts +80 -0
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1218 -288
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/doctor.ts +377 -20
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +497 -15
- package/src/host.ts +6 -130
- package/src/omp.ts +29 -0
- package/src/orchestrator-tick.ts +343 -88
- package/src/pause.ts +233 -0
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +321 -1155
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +111 -50
- package/src/setup.ts +33 -0
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +348 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +27 -0
- package/src/types.ts +697 -4
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +299 -12
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +447 -8
- package/src/wake.ts +48 -0
- package/src/worker.ts +403 -3
package/src/store.ts
CHANGED
|
@@ -16,7 +16,12 @@ import { dirname, join } from "node:path";
|
|
|
16
16
|
|
|
17
17
|
import { backupTimestamp, publishTempFile } from "./backups.ts";
|
|
18
18
|
import { stateDir } from "./config.ts";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
DECISION_TTL_MS,
|
|
21
|
+
DEFAULT_CAPS,
|
|
22
|
+
DIGEST_BACKLOG_LIMIT,
|
|
23
|
+
TERMINAL_REVIEW_ADJUDICATION_STATES,
|
|
24
|
+
} from "./types.ts";
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
27
|
* `expiresAt` for a watch (#459). It has no human deadline — a condition that
|
|
@@ -53,24 +58,34 @@ import type {
|
|
|
53
58
|
HeldNoticeDraft,
|
|
54
59
|
IntakeDraft,
|
|
55
60
|
IntakeItem,
|
|
61
|
+
InstallSurfaceObservation,
|
|
56
62
|
IntakeState,
|
|
57
63
|
MaterialEvent,
|
|
58
64
|
MaterialEventDraft,
|
|
59
65
|
LabelOp,
|
|
60
66
|
MergeLock,
|
|
67
|
+
ModelEscalation,
|
|
61
68
|
OrchestratorDownMode,
|
|
62
69
|
OrchestratorIncident,
|
|
63
70
|
OrchestratorIncidentDraft,
|
|
71
|
+
ReleaseComposition,
|
|
72
|
+
ReleaseCompositionOverride,
|
|
64
73
|
ReportDeliveryState,
|
|
65
74
|
ReportDraft,
|
|
66
75
|
ReportEnqueue,
|
|
67
76
|
ReportKind,
|
|
68
77
|
ReportRecord,
|
|
69
78
|
RecoveryAction,
|
|
79
|
+
ReviewClearance,
|
|
70
80
|
ReviewReason,
|
|
81
|
+
ReviewAdjudicationAdmission,
|
|
82
|
+
ReviewAdjudicationProvenance,
|
|
83
|
+
ReviewAdjudicationRecord,
|
|
84
|
+
ReviewAdjudicationState,
|
|
71
85
|
ReviewRevisionEnqueue,
|
|
72
86
|
ReviewRevisionOutcome,
|
|
73
87
|
ReviewRevisionRecord,
|
|
88
|
+
ReviewRevisionRetry,
|
|
74
89
|
RunPatch,
|
|
75
90
|
RunRecord,
|
|
76
91
|
RunState,
|
|
@@ -156,6 +171,7 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
156
171
|
turns: true,
|
|
157
172
|
maxTurns: true,
|
|
158
173
|
spendUsd: true,
|
|
174
|
+
spendReservedUsd: true,
|
|
159
175
|
provider429Count: true,
|
|
160
176
|
resolvedModel: true,
|
|
161
177
|
resolvedProvider: true,
|
|
@@ -165,6 +181,12 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
165
181
|
autoRetryCount: true,
|
|
166
182
|
autoCompactionCount: true,
|
|
167
183
|
sessionFile: true,
|
|
184
|
+
workerPid: true,
|
|
185
|
+
paneId: true,
|
|
186
|
+
paneLabel: true,
|
|
187
|
+
paneUnavailable: true,
|
|
188
|
+
outputTokens: true,
|
|
189
|
+
reasoningTokens: true,
|
|
168
190
|
resumedFromRunId: true,
|
|
169
191
|
lane: true,
|
|
170
192
|
prUrl: true,
|
|
@@ -197,6 +219,12 @@ type SqlValue = string | number | null;
|
|
|
197
219
|
/** The `runs` table exactly as SQLite hands it back: optional means NULL. */
|
|
198
220
|
interface RunRow {
|
|
199
221
|
id: string;
|
|
222
|
+
workerPid: number | null;
|
|
223
|
+
paneId: string | null;
|
|
224
|
+
paneLabel: string | null;
|
|
225
|
+
paneUnavailable: string | null;
|
|
226
|
+
outputTokens: number | null;
|
|
227
|
+
reasoningTokens: number | null;
|
|
200
228
|
project: string;
|
|
201
229
|
issue: number;
|
|
202
230
|
repo: string;
|
|
@@ -207,6 +235,8 @@ interface RunRow {
|
|
|
207
235
|
turns: number;
|
|
208
236
|
maxTurns: number;
|
|
209
237
|
spendUsd: number;
|
|
238
|
+
/** NULL for a row admitted with no spend cap, or one written before #851. */
|
|
239
|
+
spendReservedUsd: number | null;
|
|
210
240
|
provider429Count: number | null;
|
|
211
241
|
resolvedModel: string | null;
|
|
212
242
|
resolvedProvider: string | null;
|
|
@@ -294,6 +324,45 @@ function toBaseFreeze(row: BaseFreezeRow): BaseFreeze {
|
|
|
294
324
|
return freeze;
|
|
295
325
|
}
|
|
296
326
|
|
|
327
|
+
/** The `release_composition` table exactly as SQLite hands it back (#850). */
|
|
328
|
+
interface ReleaseCompositionRow {
|
|
329
|
+
project: string;
|
|
330
|
+
campaign: string;
|
|
331
|
+
/** JSON-encoded array of full PR URLs. */
|
|
332
|
+
allowedPrUrls: string;
|
|
333
|
+
declaredBy: string | null;
|
|
334
|
+
declaredAt: number;
|
|
335
|
+
closedAt: number | null;
|
|
336
|
+
closedBy: string | null;
|
|
337
|
+
closedReason: string | null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function toReleaseComposition(row: ReleaseCompositionRow): ReleaseComposition {
|
|
341
|
+
const composition: ReleaseComposition = {
|
|
342
|
+
campaign: row.campaign,
|
|
343
|
+
allowedPrUrls: JSON.parse(row.allowedPrUrls) as string[],
|
|
344
|
+
declaredAt: row.declaredAt,
|
|
345
|
+
};
|
|
346
|
+
if (row.declaredBy !== null) composition.declaredBy = row.declaredBy;
|
|
347
|
+
if (row.closedAt !== null) {
|
|
348
|
+
composition.closedAt = row.closedAt;
|
|
349
|
+
if (row.closedBy !== null) composition.closedBy = row.closedBy;
|
|
350
|
+
if (row.closedReason !== null) composition.closedReason = row.closedReason;
|
|
351
|
+
}
|
|
352
|
+
return composition;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The `release_composition_overrides` table exactly as SQLite hands it back (#850). */
|
|
356
|
+
interface ReleaseCompositionOverrideRow {
|
|
357
|
+
id: string;
|
|
358
|
+
project: string;
|
|
359
|
+
campaign: string;
|
|
360
|
+
prUrl: string;
|
|
361
|
+
at: number;
|
|
362
|
+
by: string;
|
|
363
|
+
reason: string;
|
|
364
|
+
}
|
|
365
|
+
|
|
297
366
|
interface FrictionRollupRow {
|
|
298
367
|
project: string;
|
|
299
368
|
day: string;
|
|
@@ -413,6 +482,19 @@ interface ReviewRevisionRow {
|
|
|
413
482
|
dispatchedAt: number | null;
|
|
414
483
|
settledAt: number | null;
|
|
415
484
|
outcome: string | null;
|
|
485
|
+
/** Nullable rather than NOT NULL DEFAULT 0: rows written before #903 carry
|
|
486
|
+
* NULL, which reads back as absent and counts as zero retries. */
|
|
487
|
+
infraRetries: number | null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The `model_escalations` table exactly as SQLite hands it back (#807). Every
|
|
491
|
+
* column is NOT NULL, so the row is the record once `failureClass` is read
|
|
492
|
+
* back as the enum the writer narrowed it from. */
|
|
493
|
+
interface ModelEscalationRow {
|
|
494
|
+
model: string;
|
|
495
|
+
failureClass: string;
|
|
496
|
+
runId: string;
|
|
497
|
+
at: number;
|
|
416
498
|
}
|
|
417
499
|
|
|
418
500
|
/**
|
|
@@ -436,6 +518,64 @@ function toReviewRevision(row: ReviewRevisionRow): ReviewRevisionRecord {
|
|
|
436
518
|
if (row.dispatchedAt !== null) record.dispatchedAt = row.dispatchedAt;
|
|
437
519
|
if (row.settledAt !== null) record.settledAt = row.settledAt;
|
|
438
520
|
if (row.outcome !== null) record.outcome = row.outcome as ReviewRevisionOutcome;
|
|
521
|
+
// Zero and NULL both read back as absent: a round nobody has retried and a
|
|
522
|
+
// row written before the column existed are the same fact.
|
|
523
|
+
if (row.infraRetries !== null && row.infraRetries > 0) record.infraRetries = row.infraRetries;
|
|
524
|
+
return record;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The `review_clearances` table exactly as SQLite hands it back (#913). Every
|
|
529
|
+
* column is NOT NULL, so the row *is* the record — no converter needed.
|
|
530
|
+
*/
|
|
531
|
+
type ReviewClearanceRow = ReviewClearance;
|
|
532
|
+
|
|
533
|
+
/** The `review_adjudications` table exactly as SQLite hands it back (#874). */
|
|
534
|
+
interface ReviewAdjudicationRow {
|
|
535
|
+
id: string;
|
|
536
|
+
project: string;
|
|
537
|
+
issue: number;
|
|
538
|
+
prUrl: string;
|
|
539
|
+
headSha: string;
|
|
540
|
+
role: string;
|
|
541
|
+
provenance: string | null;
|
|
542
|
+
state: string;
|
|
543
|
+
evidence: string | null;
|
|
544
|
+
disposition: string | null;
|
|
545
|
+
requestedAt: number;
|
|
546
|
+
dispatchedAt: number | null;
|
|
547
|
+
settledAt: number | null;
|
|
548
|
+
findings: string | null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** NULL columns become absent properties, like every other converter here, so
|
|
552
|
+
* a record read back deep-equals the one that went in. Unparseable
|
|
553
|
+
* provenance reads as absent rather than throwing: a corrupt JSON blob must
|
|
554
|
+
* not make an otherwise-readable adjudication unreadable, and "no launch
|
|
555
|
+
* recorded" is the honest degraded answer. */
|
|
556
|
+
function toReviewAdjudication(row: ReviewAdjudicationRow): ReviewAdjudicationRecord {
|
|
557
|
+
const record: ReviewAdjudicationRecord = {
|
|
558
|
+
id: row.id,
|
|
559
|
+
project: row.project,
|
|
560
|
+
issue: row.issue,
|
|
561
|
+
prUrl: row.prUrl,
|
|
562
|
+
headSha: row.headSha,
|
|
563
|
+
role: row.role,
|
|
564
|
+
state: row.state as ReviewAdjudicationState,
|
|
565
|
+
requestedAt: row.requestedAt,
|
|
566
|
+
};
|
|
567
|
+
if (row.provenance !== null) {
|
|
568
|
+
try {
|
|
569
|
+
record.provenance = JSON.parse(row.provenance) as ReviewAdjudicationProvenance;
|
|
570
|
+
} catch {
|
|
571
|
+
// left absent
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (row.evidence !== null) record.evidence = row.evidence;
|
|
575
|
+
if (row.disposition !== null) record.disposition = row.disposition;
|
|
576
|
+
if (row.dispatchedAt !== null) record.dispatchedAt = row.dispatchedAt;
|
|
577
|
+
if (row.settledAt !== null) record.settledAt = row.settledAt;
|
|
578
|
+
if (row.findings !== null) record.findings = row.findings;
|
|
439
579
|
return record;
|
|
440
580
|
}
|
|
441
581
|
|
|
@@ -560,6 +700,9 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
560
700
|
turns INTEGER NOT NULL,
|
|
561
701
|
maxTurns INTEGER NOT NULL,
|
|
562
702
|
spendUsd REAL NOT NULL,
|
|
703
|
+
-- What admission reserved out of the daily budget for this run (#851).
|
|
704
|
+
-- Nullable: an older row, or one admitted while the fleet had no cap.
|
|
705
|
+
spendReservedUsd REAL,
|
|
563
706
|
provider429Count INTEGER,
|
|
564
707
|
resolvedModel TEXT,
|
|
565
708
|
resolvedProvider TEXT,
|
|
@@ -569,6 +712,25 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
569
712
|
autoRetryCount INTEGER,
|
|
570
713
|
autoCompactionCount INTEGER,
|
|
571
714
|
sessionFile TEXT,
|
|
715
|
+
-- The worker's exact process and workspace identity (#842). The pid is the
|
|
716
|
+
-- session-host child the pane was reported for; the pane id and label are what
|
|
717
|
+
-- Herdr knows it as. Nullable throughout: a historical row never had one, a
|
|
718
|
+
-- run whose host has no Herdr never gets one, and NULL is the honest answer in
|
|
719
|
+
-- both cases rather than a pane nobody can find.
|
|
720
|
+
workerPid INTEGER,
|
|
721
|
+
paneId TEXT,
|
|
722
|
+
paneLabel TEXT,
|
|
723
|
+
-- Why this run has no workspace representation (#841). Set when a pane could
|
|
724
|
+
-- not be established or was lost, cleared when one is. Its presence beside a
|
|
725
|
+
-- live run is what makes "degraded" a named state in status output rather than a
|
|
726
|
+
-- silence the operator has to notice.
|
|
727
|
+
paneUnavailable TEXT,
|
|
728
|
+
-- Cumulative output and reasoning tokens (#518). Nullable: a historical row
|
|
729
|
+
-- predates the accounting, and a provider that reports no token block leaves
|
|
730
|
+
-- them absent rather than zero -- "reported nothing" and "produced nothing"
|
|
731
|
+
-- invite opposite readings, and a zero would make the share silently wrong.
|
|
732
|
+
outputTokens INTEGER,
|
|
733
|
+
reasoningTokens INTEGER,
|
|
572
734
|
resumedFromRunId TEXT,
|
|
573
735
|
lane TEXT,
|
|
574
736
|
prUrl TEXT,
|
|
@@ -641,6 +803,38 @@ CREATE TABLE IF NOT EXISTS base_freeze (
|
|
|
641
803
|
PRIMARY KEY (project, repo)
|
|
642
804
|
);
|
|
643
805
|
|
|
806
|
+
-- The active release composition (#850): the campaign whose release a project
|
|
807
|
+
-- is assembling plus the exact PRs allowed into it. While closedAt IS NULL,
|
|
808
|
+
-- prMergeVerb refuses every other conductor_pr_merge with the
|
|
809
|
+
-- outside-active-release refusal, so an early patch cannot accumulate
|
|
810
|
+
-- unrelated merges; the clear columns hold the completion/cancellation audit
|
|
811
|
+
-- on the row itself, and material_events carry the immutable lifecycle trail.
|
|
812
|
+
CREATE TABLE IF NOT EXISTS release_composition (
|
|
813
|
+
project TEXT PRIMARY KEY,
|
|
814
|
+
campaign TEXT NOT NULL,
|
|
815
|
+
allowedPrUrls TEXT NOT NULL,
|
|
816
|
+
declaredBy TEXT,
|
|
817
|
+
declaredAt INTEGER NOT NULL,
|
|
818
|
+
closedAt INTEGER,
|
|
819
|
+
closedBy TEXT,
|
|
820
|
+
closedReason TEXT
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
-- Append-only operator overrides admitting exactly one PR into one campaign
|
|
824
|
+
-- (#850). An override applies to its campaign only — it never outlives the
|
|
825
|
+
-- release it was recorded for, and bypasses nothing but the composition check.
|
|
826
|
+
CREATE TABLE IF NOT EXISTS release_composition_overrides (
|
|
827
|
+
id TEXT PRIMARY KEY,
|
|
828
|
+
project TEXT NOT NULL,
|
|
829
|
+
campaign TEXT NOT NULL,
|
|
830
|
+
prUrl TEXT NOT NULL,
|
|
831
|
+
at INTEGER NOT NULL,
|
|
832
|
+
by TEXT NOT NULL,
|
|
833
|
+
reason TEXT NOT NULL
|
|
834
|
+
);
|
|
835
|
+
CREATE INDEX IF NOT EXISTS release_composition_overrides_lookup
|
|
836
|
+
ON release_composition_overrides (project, campaign, prUrl, at);
|
|
837
|
+
|
|
644
838
|
CREATE TABLE IF NOT EXISTS notifications (
|
|
645
839
|
"key" TEXT PRIMARY KEY,
|
|
646
840
|
at INTEGER NOT NULL
|
|
@@ -668,6 +862,23 @@ CREATE TABLE IF NOT EXISTS turn_override_ledger (
|
|
|
668
862
|
CREATE INDEX IF NOT EXISTS turn_override_ledger_project_at
|
|
669
863
|
ON turn_override_ledger (project, setAt);
|
|
670
864
|
|
|
865
|
+
-- The one-shot model escalation an issue chain has spent (#807). The primary
|
|
866
|
+
-- key IS the cap: the insert either creates the chain's only escalation or
|
|
867
|
+
-- changes nothing, so a settlement that crashes between claiming and queueing
|
|
868
|
+
-- the continuation can never hand the same chain a second tier on restart, and
|
|
869
|
+
-- a second spinning cap falls through to the existing decomposition verdict.
|
|
870
|
+
-- Never deleted: the row is also what dispatch reads to keep the escalated
|
|
871
|
+
-- chain on its stronger selector.
|
|
872
|
+
CREATE TABLE IF NOT EXISTS model_escalations (
|
|
873
|
+
project TEXT NOT NULL,
|
|
874
|
+
issue INTEGER NOT NULL CHECK (issue > 0),
|
|
875
|
+
model TEXT NOT NULL,
|
|
876
|
+
failureClass TEXT NOT NULL,
|
|
877
|
+
runId TEXT NOT NULL,
|
|
878
|
+
at INTEGER NOT NULL CHECK (at >= 0),
|
|
879
|
+
PRIMARY KEY (project, issue)
|
|
880
|
+
);
|
|
881
|
+
|
|
671
882
|
CREATE TABLE IF NOT EXISTS dispatch_summaries (
|
|
672
883
|
project TEXT PRIMARY KEY,
|
|
673
884
|
summary TEXT NOT NULL
|
|
@@ -833,7 +1044,11 @@ CREATE TABLE IF NOT EXISTS review_revisions (
|
|
|
833
1044
|
requestedAt INTEGER NOT NULL,
|
|
834
1045
|
dispatchedAt INTEGER,
|
|
835
1046
|
settledAt INTEGER,
|
|
836
|
-
outcome TEXT
|
|
1047
|
+
outcome TEXT,
|
|
1048
|
+
-- Infrastructure kills that returned this round to the pending set (#903).
|
|
1049
|
+
-- Nullable so an older database migrates by ALTER without a rewrite; NULL
|
|
1050
|
+
-- and 0 mean the same thing.
|
|
1051
|
+
infraRetries INTEGER
|
|
837
1052
|
);
|
|
838
1053
|
CREATE INDEX IF NOT EXISTS review_revisions_pending
|
|
839
1054
|
ON review_revisions (project, dispatchedAt, runId);
|
|
@@ -847,6 +1062,111 @@ CREATE UNIQUE INDEX IF NOT EXISTS review_revisions_one_pending_per_run
|
|
|
847
1062
|
ON review_revisions (runId)
|
|
848
1063
|
WHERE dispatchedAt IS NULL AND settledAt IS NULL;
|
|
849
1064
|
|
|
1065
|
+
-- One recorded review clearance (#913): the explicit orchestrator disposition
|
|
1066
|
+
-- that the durable review findings standing at one exact head are settled.
|
|
1067
|
+
--
|
|
1068
|
+
-- The #888 merge gate had no reachable clearance at an unchanged head. A
|
|
1069
|
+
-- re-review at the same head appends to (or opens) an unsettled row, so it
|
|
1070
|
+
-- blocks too, and nothing an orchestrator can call settles a round — a PR at
|
|
1071
|
+
-- the review-round ceiling was therefore permanently unmergeable at that head.
|
|
1072
|
+
-- This table is that missing disposition, kept deliberately separate from the
|
|
1073
|
+
-- merge call's own reason argument so the override is a recorded act with its
|
|
1074
|
+
-- own ledger row rather than a sentence in a merge.
|
|
1075
|
+
--
|
|
1076
|
+
-- Append-only: a second clearance at the same head is another row, and its
|
|
1077
|
+
-- recorded timestamp is load-bearing -- the gate honours a clearance only for
|
|
1078
|
+
-- revision rows whose own last activity is at or before it, so a clearance can
|
|
1079
|
+
-- never bless a *later* finding at the same head.
|
|
1080
|
+
CREATE TABLE IF NOT EXISTS review_clearances (
|
|
1081
|
+
id TEXT PRIMARY KEY,
|
|
1082
|
+
project TEXT NOT NULL,
|
|
1083
|
+
prUrl TEXT NOT NULL,
|
|
1084
|
+
headSha TEXT NOT NULL,
|
|
1085
|
+
at INTEGER NOT NULL,
|
|
1086
|
+
by TEXT NOT NULL,
|
|
1087
|
+
reason TEXT NOT NULL
|
|
1088
|
+
);
|
|
1089
|
+
CREATE INDEX IF NOT EXISTS review_clearances_lookup
|
|
1090
|
+
ON review_clearances (project, prUrl, headSha, at);
|
|
1091
|
+
|
|
1092
|
+
-- One terminal review-ceiling adjudication (#874): the durable lifecycle that
|
|
1093
|
+
-- makes the final adjudication a recorded act rather than an in-memory fourth
|
|
1094
|
+
-- review round.
|
|
1095
|
+
--
|
|
1096
|
+
-- The one-shot guarantee is the UNIQUE index below, not a flag a caller
|
|
1097
|
+
-- remembers to check: a repeated tick, two dispatchers, or a daemon restart all
|
|
1098
|
+
-- collide on (project, prUrl, headSha) and re-read the existing row. The exact
|
|
1099
|
+
-- head is part of that key on purpose — a verdict describes the diff it read,
|
|
1100
|
+
-- so it must never travel to a head nobody adjudicated.
|
|
1101
|
+
--
|
|
1102
|
+
-- Deliberately NOT a row in review_revisions. Reusing round N+1 there would
|
|
1103
|
+
-- spend a worker review round the ceiling has already exhausted, and would
|
|
1104
|
+
-- resume the same session and model for a nominal fourth opinion, which is the
|
|
1105
|
+
-- failure #873 exists to end. The tables also answer different questions: a
|
|
1106
|
+
-- revision is "go and change this"; an adjudication is "decide about this".
|
|
1107
|
+
--
|
|
1108
|
+
-- state carries the verdict: the terminal states ARE it (cleared, rejected,
|
|
1109
|
+
-- unavailable-model, stale-head, failed), so there is no second verdict column
|
|
1110
|
+
-- to disagree with them. evidence says why, and disposition (written by
|
|
1111
|
+
-- #876) says what was done about it.
|
|
1112
|
+
CREATE TABLE IF NOT EXISTS review_adjudications (
|
|
1113
|
+
id TEXT PRIMARY KEY,
|
|
1114
|
+
project TEXT NOT NULL,
|
|
1115
|
+
issue INTEGER NOT NULL,
|
|
1116
|
+
prUrl TEXT NOT NULL,
|
|
1117
|
+
headSha TEXT NOT NULL,
|
|
1118
|
+
role TEXT NOT NULL,
|
|
1119
|
+
-- The resolved launch provenance (#875) as JSON, absent while pending: no
|
|
1120
|
+
-- model has resolved yet, and recording an intention as provenance would make
|
|
1121
|
+
-- status claim a launch that never happened.
|
|
1122
|
+
provenance TEXT,
|
|
1123
|
+
state TEXT NOT NULL,
|
|
1124
|
+
evidence TEXT,
|
|
1125
|
+
disposition TEXT,
|
|
1126
|
+
requestedAt INTEGER NOT NULL,
|
|
1127
|
+
dispatchedAt INTEGER,
|
|
1128
|
+
settledAt INTEGER,
|
|
1129
|
+
-- The findings that provoked the escalation (#932): what the orchestrator was
|
|
1130
|
+
-- submitting when it hit the ceiling. Nullable so an older database migrates
|
|
1131
|
+
-- by ALTER, and because an adjudication opened by any other path has none.
|
|
1132
|
+
-- Kept here rather than as a review_revisions round because a round would
|
|
1133
|
+
-- spend a ceiling that is already exhausted and would wake a worker; this is
|
|
1134
|
+
-- evidence for the adjudicator, not work for an implementer.
|
|
1135
|
+
findings TEXT
|
|
1136
|
+
);
|
|
1137
|
+
-- The one-shot identity, structural rather than advisory: at most one
|
|
1138
|
+
-- adjudication per PR head, ever.
|
|
1139
|
+
CREATE UNIQUE INDEX IF NOT EXISTS review_adjudications_one_per_head
|
|
1140
|
+
ON review_adjudications (project, prUrl, headSha);
|
|
1141
|
+
-- Restart recovery and the status projection both read the non-terminal rows
|
|
1142
|
+
-- for one project; the PR lookup answers "is this head adjudicated".
|
|
1143
|
+
CREATE INDEX IF NOT EXISTS review_adjudications_open
|
|
1144
|
+
ON review_adjudications (project, settledAt);
|
|
1145
|
+
|
|
1146
|
+
-- The install identities the host carried when the daemon last looked (#919).
|
|
1147
|
+
-- One row (the primary key is a constant): the question is what is installed
|
|
1148
|
+
-- NOW, and the install history is the upgrade journal's job. Recorded by the
|
|
1149
|
+
-- dispatch pass because reading it costs three subprocesses -- doing that at
|
|
1150
|
+
-- render time took the tick suite from 8.4s to 83.4s and would spawn three
|
|
1151
|
+
-- children every fifteen minutes forever.
|
|
1152
|
+
CREATE TABLE IF NOT EXISTS install_surfaces (
|
|
1153
|
+
host TEXT PRIMARY KEY,
|
|
1154
|
+
at INTEGER NOT NULL,
|
|
1155
|
+
cliVersion TEXT NOT NULL,
|
|
1156
|
+
ompVersion TEXT,
|
|
1157
|
+
herdrSource TEXT,
|
|
1158
|
+
-- The one peer plugin a documented conductor contract runs on (#961). Carried
|
|
1159
|
+
-- on this row rather than in a table of its own because it is read by the same
|
|
1160
|
+
-- periodic pass, about the same host, for the same reason: an install that
|
|
1161
|
+
-- diverged from what is running or published, observed without an on-demand
|
|
1162
|
+
-- doctor run (#919). Every column is nullable — a pass that could not reach
|
|
1163
|
+
-- the registry records what it did read and leaves the rest unobserved, which
|
|
1164
|
+
-- the renderer must show as "unverified" and never as agreement.
|
|
1165
|
+
tgInstalled TEXT,
|
|
1166
|
+
tgDaemon TEXT,
|
|
1167
|
+
tgPublished TEXT
|
|
1168
|
+
);
|
|
1169
|
+
|
|
850
1170
|
-- Questions the orchestrator has put to its operator, and their answers (#136).
|
|
851
1171
|
--
|
|
852
1172
|
-- The reason this is a table and not the session's memory: a question lived only
|
|
@@ -879,9 +1199,20 @@ CREATE TABLE IF NOT EXISTS decisions (
|
|
|
879
1199
|
conditionHead TEXT,
|
|
880
1200
|
state TEXT NOT NULL,
|
|
881
1201
|
resolvedAt INTEGER,
|
|
882
|
-
resolution TEXT
|
|
1202
|
+
resolution TEXT,
|
|
1203
|
+
-- One spec-out questionnaire (#947): N rows written in a single transaction,
|
|
1204
|
+
-- delivered as one message, resolved independently and in any order. Nullable
|
|
1205
|
+
-- because every ordinary single ask has neither: a historical row was never
|
|
1206
|
+
-- part of a group and must not read as a group of one, and specIssue is what
|
|
1207
|
+
-- makes a group's answers the spec's provenance rather than chat history.
|
|
1208
|
+
groupId TEXT,
|
|
1209
|
+
specIssue INTEGER
|
|
883
1210
|
);
|
|
884
1211
|
CREATE INDEX IF NOT EXISTS decisions_project_state ON decisions (project, state);
|
|
1212
|
+
-- The groupId index is NOT here on purpose: this whole script runs before the
|
|
1213
|
+
-- additive migrations, and on a database written before #947 the column does not
|
|
1214
|
+
-- exist yet, so an index over it here fails the entire schema step. It is created
|
|
1215
|
+
-- beside the ALTER that adds the column, which every open path runs.
|
|
885
1216
|
|
|
886
1217
|
-- One issue's current grooming verdict (#735), the durable answer to "has
|
|
887
1218
|
-- this been groomed, and what was decided" that the tick reads instead of
|
|
@@ -1133,7 +1464,14 @@ function toRecord(row: RunRow): RunRecord {
|
|
|
1133
1464
|
spendUsd: row.spendUsd,
|
|
1134
1465
|
startedAt: row.startedAt,
|
|
1135
1466
|
};
|
|
1467
|
+
if (row.spendReservedUsd !== null) record.spendReservedUsd = row.spendReservedUsd;
|
|
1136
1468
|
if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
|
|
1469
|
+
if (row.workerPid !== null) record.workerPid = row.workerPid;
|
|
1470
|
+
if (row.paneId !== null) record.paneId = row.paneId;
|
|
1471
|
+
if (row.paneLabel !== null) record.paneLabel = row.paneLabel;
|
|
1472
|
+
if (row.paneUnavailable !== null) record.paneUnavailable = row.paneUnavailable;
|
|
1473
|
+
if (row.outputTokens !== null) record.outputTokens = row.outputTokens;
|
|
1474
|
+
if (row.reasoningTokens !== null) record.reasoningTokens = row.reasoningTokens;
|
|
1137
1475
|
if (row.resumedFromRunId !== null) record.resumedFromRunId = row.resumedFromRunId;
|
|
1138
1476
|
if (row.lane !== null) {
|
|
1139
1477
|
const lane = toLane(row.lane);
|
|
@@ -1246,6 +1584,8 @@ interface DecisionRow {
|
|
|
1246
1584
|
state: string;
|
|
1247
1585
|
resolvedAt: number | null;
|
|
1248
1586
|
resolution: string | null;
|
|
1587
|
+
groupId: string | null;
|
|
1588
|
+
specIssue: number | null;
|
|
1249
1589
|
}
|
|
1250
1590
|
|
|
1251
1591
|
/** Same NULL-to-absent contract as {@link toReport}. */
|
|
@@ -1268,6 +1608,8 @@ function toDecision(row: DecisionRow): DecisionRecord {
|
|
|
1268
1608
|
if (row.conditionHead !== null) record.conditionHead = row.conditionHead;
|
|
1269
1609
|
if (row.resolvedAt !== null) record.resolvedAt = row.resolvedAt;
|
|
1270
1610
|
if (row.resolution !== null) record.resolution = row.resolution;
|
|
1611
|
+
if (row.groupId !== null) record.groupId = row.groupId;
|
|
1612
|
+
if (row.specIssue !== null) record.specIssue = row.specIssue;
|
|
1271
1613
|
return record;
|
|
1272
1614
|
}
|
|
1273
1615
|
|
|
@@ -1619,6 +1961,23 @@ export function openStore(dbPath: string): Store {
|
|
|
1619
1961
|
if (!columns.some((column) => column.name === "quarantineDetail")) {
|
|
1620
1962
|
db.exec("ALTER TABLE runs ADD COLUMN quarantineDetail TEXT");
|
|
1621
1963
|
}
|
|
1964
|
+
// The worker's process/workspace identity (#842). Rows written before it exists
|
|
1965
|
+
// carry none, which is the truth: nothing reported a pane for them, so there is
|
|
1966
|
+
// no pane for a restart to reconcile. Nothing is backfilled — a pid guessed
|
|
1967
|
+
// after the fact is the one thing worse than no pid, because a reused pid reads
|
|
1968
|
+
// as a live worker.
|
|
1969
|
+
for (const [name, type] of [
|
|
1970
|
+
["workerPid", "INTEGER"],
|
|
1971
|
+
["paneId", "TEXT"],
|
|
1972
|
+
["paneLabel", "TEXT"],
|
|
1973
|
+
["paneUnavailable", "TEXT"],
|
|
1974
|
+
["outputTokens", "INTEGER"],
|
|
1975
|
+
["reasoningTokens", "INTEGER"],
|
|
1976
|
+
] as const) {
|
|
1977
|
+
if (!columns.some((column) => column.name === name)) {
|
|
1978
|
+
db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1622
1981
|
// Rows written before the settlement audit existed (#128) were never audited,
|
|
1623
1982
|
// so a NULL here means "no audit ran" and reads the same as "found nothing".
|
|
1624
1983
|
// That conflation is deliberate and harmless: the flags are advisory, and no
|
|
@@ -1646,6 +2005,17 @@ export function openStore(dbPath: string): Store {
|
|
|
1646
2005
|
db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
|
|
1647
2006
|
}
|
|
1648
2007
|
}
|
|
2008
|
+
// The telegram plugin's three versions (#961), on the existing host row. A
|
|
2009
|
+
// store written before this release has no observation of them, and NULL is
|
|
2010
|
+
// the honest reading: the periodic pass that reads them had not shipped, so
|
|
2011
|
+
// the renderer says "unverified" until the next pass rather than implying the
|
|
2012
|
+
// versions agreed.
|
|
2013
|
+
const surfaceColumns = db.query<{ name: string }, []>("PRAGMA table_info(install_surfaces)").all();
|
|
2014
|
+
for (const name of ["tgInstalled", "tgDaemon", "tgPublished"] as const) {
|
|
2015
|
+
if (!surfaceColumns.some((column) => column.name === name)) {
|
|
2016
|
+
db.exec(`ALTER TABLE install_surfaces ADD COLUMN ${name} TEXT`);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
1649
2019
|
// The model a run dispatched on was first written by the modelFallbacks
|
|
1650
2020
|
// failover chain (#286). Rows predating the column are NULL, which is the
|
|
1651
2021
|
// honest reading: only a chain-configured project records a model, and the
|
|
@@ -1762,7 +2132,64 @@ export function openStore(dbPath: string): Store {
|
|
|
1762
2132
|
if (!decisionColumns.some((column) => column.name === "conditionHead")) {
|
|
1763
2133
|
db.exec("ALTER TABLE decisions ADD COLUMN conditionHead TEXT");
|
|
1764
2134
|
}
|
|
2135
|
+
// The spec-out questionnaire (#947). Both columns are nullable and NULL is the
|
|
2136
|
+
// truthful value for every historical row: it was a single ask, not one item
|
|
2137
|
+
// of a group, and it specced nothing. The index is created here too, so an
|
|
2138
|
+
// upgraded database reads groups as fast as a fresh one.
|
|
2139
|
+
for (const [name, type] of [
|
|
2140
|
+
["groupId", "TEXT"],
|
|
2141
|
+
["specIssue", "INTEGER"],
|
|
2142
|
+
] as const) {
|
|
2143
|
+
if (!decisionColumns.some((column) => column.name === name)) {
|
|
2144
|
+
db.exec(`ALTER TABLE decisions ADD COLUMN ${name} ${type}`);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
db.exec("CREATE INDEX IF NOT EXISTS decisions_group ON decisions (groupId)");
|
|
2148
|
+
// Already-met watches, written before the deadline existed (#966). Unlike the
|
|
2149
|
+
// head binding above this IS backfilled, and the difference is that nothing is
|
|
2150
|
+
// being guessed: `conditionMetAt` is a recorded observation, and the deadline
|
|
2151
|
+
// is arithmetic on it.
|
|
2152
|
+
//
|
|
2153
|
+
// It has to be a migration rather than a re-observation, because
|
|
2154
|
+
// `markDecisionConditionMet` refuses a row whose `conditionMetAt` is already
|
|
2155
|
+
// set — deliberately, so the digest flag cannot look new every tick. Without
|
|
2156
|
+
// this, the three watches that motivated the fix would have kept
|
|
2157
|
+
// "[CONDITION MET — act on this now]" forever and the fix would have applied
|
|
2158
|
+
// only to conditions that fire in the future.
|
|
2159
|
+
//
|
|
2160
|
+
// Narrow on purpose: open watches only, only where the deadline is still the
|
|
2161
|
+
// infinite sentinel. A resolved row is history, a question's deadline runs
|
|
2162
|
+
// from `askedAt`, and a watch that somehow already carries a finite deadline
|
|
2163
|
+
// is left exactly as it is.
|
|
2164
|
+
db.run(
|
|
2165
|
+
`UPDATE decisions
|
|
2166
|
+
SET expiresAt = conditionMetAt + ?
|
|
2167
|
+
WHERE kind = 'watch'
|
|
2168
|
+
AND state = 'open'
|
|
2169
|
+
AND conditionMetAt IS NOT NULL
|
|
2170
|
+
AND expiresAt = ?`,
|
|
2171
|
+
[DECISION_TTL_MS, DECISION_WATCH_NEVER_EXPIRES],
|
|
2172
|
+
);
|
|
2173
|
+
// The per-run spend reservation (#851). Rows written before it existed hold
|
|
2174
|
+
// no reservation, which is the truth: admission never took one for them.
|
|
2175
|
+
if (!columns.some((column) => column.name === "spendReservedUsd")) {
|
|
2176
|
+
db.exec("ALTER TABLE runs ADD COLUMN spendReservedUsd REAL");
|
|
2177
|
+
}
|
|
2178
|
+
// Infra retries on a review round (#903). Historical rows were never
|
|
2179
|
+
// retried — the path did not exist, every infra kill settled `failed` — so
|
|
2180
|
+
// NULL is the honest reading and counts as zero.
|
|
2181
|
+
const reviewRevisionColumns = db.query<{ name: string }, []>("PRAGMA table_info(review_revisions)").all();
|
|
2182
|
+
if (!reviewRevisionColumns.some((column) => column.name === "infraRetries")) {
|
|
2183
|
+
db.exec("ALTER TABLE review_revisions ADD COLUMN infraRetries INTEGER");
|
|
2184
|
+
}
|
|
1765
2185
|
|
|
2186
|
+
// The escalation findings on an adjudication (#932). Rows written by #874's
|
|
2187
|
+
// lifecycle carried none — the ceiling still refused then — so NULL is the
|
|
2188
|
+
// honest reading and renders as "no findings recorded".
|
|
2189
|
+
const adjudicationColumns = db.query<{ name: string }, []>("PRAGMA table_info(review_adjudications)").all();
|
|
2190
|
+
if (!adjudicationColumns.some((column) => column.name === "findings")) {
|
|
2191
|
+
db.exec("ALTER TABLE review_adjudications ADD COLUMN findings TEXT");
|
|
2192
|
+
}
|
|
1766
2193
|
// One-time repair, and the reason it lives here rather than in the classifier:
|
|
1767
2194
|
// a turn-0 environment fault that was ALREADY classified `unknown` and
|
|
1768
2195
|
// escalated is never revisited. `runsNeedingClassification` only offers a row
|
|
@@ -1855,10 +2282,10 @@ export function openStore(dbPath: string): Store {
|
|
|
1855
2282
|
const insertRun = db.query<unknown, SqlValue[]>(
|
|
1856
2283
|
`INSERT INTO runs (
|
|
1857
2284
|
id, project, issue, repo, branch, worktree, state, attempt, turns,
|
|
1858
|
-
maxTurns, spendUsd, sessionFile, resumedFromRunId, lane, prUrl, headSha, mergeSha, baseRef,
|
|
2285
|
+
maxTurns, spendUsd, spendReservedUsd, sessionFile, resumedFromRunId, lane, prUrl, headSha, mergeSha, baseRef,
|
|
1859
2286
|
baseCheck, baseCheckAt, salvageSha, salvageError, salvageAckAt, startedAt,
|
|
1860
2287
|
endedAt, lastError, settlementFlags, report, graphTools
|
|
1861
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
2288
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1862
2289
|
);
|
|
1863
2290
|
const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
|
|
1864
2291
|
const selectGraphToolsObs = db.query<{ graphTools: string }, [string]>(
|
|
@@ -1874,6 +2301,26 @@ export function openStore(dbPath: string): Store {
|
|
|
1874
2301
|
WHERE project = ? AND state IN (${LIVE_PLACEHOLDERS})
|
|
1875
2302
|
ORDER BY startedAt ASC`,
|
|
1876
2303
|
);
|
|
2304
|
+
// Runs holding an ACTIVE MUTATION LEASE (#899, #925): a live worker, or a
|
|
2305
|
+
// review revision the daemon has handed to one and that has not settled.
|
|
2306
|
+
// The second half is belt-and-braces — `claimRunForReview` moves the row to
|
|
2307
|
+
// `running` — but the claim and the launch are not the same instant, and a
|
|
2308
|
+
// lease the reader briefly forgets is a lane two writers can share. A
|
|
2309
|
+
// *queued* revision holds nothing: no worker has been woken for it.
|
|
2310
|
+
//
|
|
2311
|
+
// One query rather than a composition at each call site, because two
|
|
2312
|
+
// readers now decide "may this file be written" from it — admission's
|
|
2313
|
+
// file-lane gate and `conductor_pr_recover`'s fail-closed lane check — and
|
|
2314
|
+
// a lease rule that disagrees between them is the bug both are guarding.
|
|
2315
|
+
const selectLeased = db.query<RunRow, SqlValue[]>(
|
|
2316
|
+
`SELECT * FROM runs
|
|
2317
|
+
WHERE project = ?
|
|
2318
|
+
AND (state IN (${LIVE_PLACEHOLDERS})
|
|
2319
|
+
OR id IN (
|
|
2320
|
+
SELECT runId FROM review_revisions
|
|
2321
|
+
WHERE project = ? AND dispatchedAt IS NOT NULL AND settledAt IS NULL))
|
|
2322
|
+
ORDER BY startedAt ASC`,
|
|
2323
|
+
);
|
|
1877
2324
|
const selectRetained = db.query<RunRow, [string]>(
|
|
1878
2325
|
`SELECT * FROM runs
|
|
1879
2326
|
WHERE project = ?
|
|
@@ -1987,6 +2434,73 @@ export function openStore(dbPath: string): Store {
|
|
|
1987
2434
|
SET clearedAt = ?, clearedBy = ?, clearedReason = ?
|
|
1988
2435
|
WHERE project = ? AND repo = ? AND clearedAt IS NULL`,
|
|
1989
2436
|
);
|
|
2437
|
+
const selectActiveReleaseComposition = db.query<ReleaseCompositionRow, [string]>(
|
|
2438
|
+
`SELECT * FROM release_composition WHERE project = ? AND closedAt IS NULL`,
|
|
2439
|
+
);
|
|
2440
|
+
const declareReleaseCompositionRow = db.query<
|
|
2441
|
+
unknown,
|
|
2442
|
+
[string, string, string, SqlValue, number]
|
|
2443
|
+
>(
|
|
2444
|
+
`INSERT INTO release_composition (project, campaign, allowedPrUrls, declaredBy, declaredAt)
|
|
2445
|
+
VALUES (?, ?, ?, ?, ?)
|
|
2446
|
+
ON CONFLICT(project) DO UPDATE SET
|
|
2447
|
+
campaign = excluded.campaign,
|
|
2448
|
+
allowedPrUrls = excluded.allowedPrUrls,
|
|
2449
|
+
declaredBy = excluded.declaredBy,
|
|
2450
|
+
declaredAt = excluded.declaredAt,
|
|
2451
|
+
closedAt = NULL,
|
|
2452
|
+
closedBy = NULL,
|
|
2453
|
+
closedReason = NULL`,
|
|
2454
|
+
);
|
|
2455
|
+
const retireReleaseCompositionRow = db.query<unknown, [number, string, string, string]>(
|
|
2456
|
+
`UPDATE release_composition
|
|
2457
|
+
SET closedAt = ?, closedBy = ?, closedReason = ?
|
|
2458
|
+
WHERE project = ? AND closedAt IS NULL`,
|
|
2459
|
+
);
|
|
2460
|
+
const insertReleaseCompositionOverride = db.query<
|
|
2461
|
+
unknown,
|
|
2462
|
+
[string, string, string, string, number, string, string]
|
|
2463
|
+
>(
|
|
2464
|
+
`INSERT INTO release_composition_overrides (id, project, campaign, prUrl, at, by, reason)
|
|
2465
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
2466
|
+
);
|
|
2467
|
+
const selectReleaseCompositionOverride = db.query<
|
|
2468
|
+
ReleaseCompositionOverrideRow,
|
|
2469
|
+
[string, string, string]
|
|
2470
|
+
>(
|
|
2471
|
+
`SELECT * FROM release_composition_overrides
|
|
2472
|
+
WHERE project = ? AND campaign = ? AND prUrl = ?
|
|
2473
|
+
ORDER BY at DESC LIMIT 1`,
|
|
2474
|
+
);
|
|
2475
|
+
const selectReleaseCompositionOverrides = db.query<
|
|
2476
|
+
ReleaseCompositionOverrideRow,
|
|
2477
|
+
[string, string]
|
|
2478
|
+
>(
|
|
2479
|
+
`SELECT * FROM release_composition_overrides
|
|
2480
|
+
WHERE project = ? AND campaign = ?
|
|
2481
|
+
ORDER BY at DESC`,
|
|
2482
|
+
);
|
|
2483
|
+
|
|
2484
|
+
// Declaring is a check-then-insert, and two processes (CLI + daemon) can
|
|
2485
|
+
// race: without the transaction the second declarer would silently replace
|
|
2486
|
+
// a still-active composition — exactly the overwrite this guard exists to
|
|
2487
|
+
// prevent. Inside one write transaction the re-check sees the row the first
|
|
2488
|
+
// declarer just wrote. Returns the conflicting active row, or null when the
|
|
2489
|
+
// declaration took.
|
|
2490
|
+
const declareReleaseCompositionTx = db.transaction(
|
|
2491
|
+
(
|
|
2492
|
+
project: string,
|
|
2493
|
+
campaign: string,
|
|
2494
|
+
allowedPrUrls: string,
|
|
2495
|
+
declaredBy: string | null,
|
|
2496
|
+
declaredAt: number,
|
|
2497
|
+
): ReleaseCompositionRow | null => {
|
|
2498
|
+
const existing = selectActiveReleaseComposition.get(project);
|
|
2499
|
+
if (existing !== null) return existing;
|
|
2500
|
+
declareReleaseCompositionRow.run(project, campaign, allowedPrUrls, declaredBy, declaredAt);
|
|
2501
|
+
return null;
|
|
2502
|
+
},
|
|
2503
|
+
);
|
|
1990
2504
|
const countAttempts = db.query<{ n: number }, [string, number]>(
|
|
1991
2505
|
`SELECT COUNT(*) AS n FROM runs WHERE project = ? AND issue = ?`,
|
|
1992
2506
|
);
|
|
@@ -2111,10 +2625,43 @@ export function openStore(dbPath: string): Store {
|
|
|
2111
2625
|
const reclassifyToInfra = db.query<unknown, [string]>(
|
|
2112
2626
|
`UPDATE runs SET failureClass = 'ci-infra' WHERE id = ? AND failureClass = 'ci-deterministic'`,
|
|
2113
2627
|
);
|
|
2628
|
+
// Two different questions, deliberately not one number (#132).
|
|
2629
|
+
//
|
|
2630
|
+
// `recoveredAt IS NULL` alone does NOT mean "awaiting recovery": two actions
|
|
2631
|
+
// are **recorded only** by design and never stamp it. `hold` is documented as
|
|
2632
|
+
// such right above `selectUnclassified`; `none` is what the settle sweep
|
|
2633
|
+
// writes beside `returned-for-revision` when a reviewer closes pushed work
|
|
2634
|
+
// without merging, because the remedy there is the queue label the sweep
|
|
2635
|
+
// deliberately leaves on, not an action any sweep takes.
|
|
2636
|
+
//
|
|
2637
|
+
// Counting them together is the #109 defect restated: on this fleet the line
|
|
2638
|
+
// read `returned-for-revision 44` while every genuinely actionable class sat
|
|
2639
|
+
// at zero, so a monotonically growing tally of *review decisions* crowded out
|
|
2640
|
+
// the classes an operator can act on. The split is on `recoveryAction`, which
|
|
2641
|
+
// the sweep already treats this way, so the surface now agrees with the model
|
|
2642
|
+
// instead of contradicting it.
|
|
2643
|
+
// Newest terminal runs, reduced to the two columns the spend-telemetry
|
|
2644
|
+
// judgement needs (#970). Terminal only: an in-flight run has not finished
|
|
2645
|
+
// reporting cost, so its 0.00 is not a telemetry statement.
|
|
2646
|
+
const selectSpendSamples = db.query<{ turns: number; spendUsd: number }, [string, number]>(
|
|
2647
|
+
`SELECT turns, spendUsd FROM runs
|
|
2648
|
+
WHERE project = ? AND endedAt IS NOT NULL
|
|
2649
|
+
ORDER BY endedAt DESC, rowid DESC
|
|
2650
|
+
LIMIT ?`,
|
|
2651
|
+
);
|
|
2114
2652
|
const selectFailureClassCounts = db.query<{ cls: string; n: number }, [string]>(
|
|
2115
2653
|
`SELECT failureClass AS cls, COUNT(*) AS n FROM runs
|
|
2116
2654
|
WHERE project = ? AND failureClass IS NOT NULL AND recoveredAt IS NULL
|
|
2117
2655
|
AND state IN ('blocked', 'killed', 'failed', 'orphaned', 'pushed-green')
|
|
2656
|
+
AND recoveryAction NOT IN ('none', 'hold')
|
|
2657
|
+
GROUP BY failureClass
|
|
2658
|
+
ORDER BY n DESC, failureClass ASC`,
|
|
2659
|
+
);
|
|
2660
|
+
const selectRecordedOnlyClassCounts = db.query<{ cls: string; n: number }, [string]>(
|
|
2661
|
+
`SELECT failureClass AS cls, COUNT(*) AS n FROM runs
|
|
2662
|
+
WHERE project = ? AND failureClass IS NOT NULL AND recoveredAt IS NULL
|
|
2663
|
+
AND state IN ('blocked', 'killed', 'failed', 'orphaned', 'pushed-green')
|
|
2664
|
+
AND recoveryAction IN ('none', 'hold')
|
|
2118
2665
|
GROUP BY failureClass
|
|
2119
2666
|
ORDER BY n DESC, failureClass ASC`,
|
|
2120
2667
|
);
|
|
@@ -2203,6 +2750,24 @@ export function openStore(dbPath: string): Store {
|
|
|
2203
2750
|
ORDER BY setAt DESC, rowid DESC
|
|
2204
2751
|
LIMIT ?`,
|
|
2205
2752
|
);
|
|
2753
|
+
// #807. `INSERT OR IGNORE` is the whole one-shot rule: the second spinning
|
|
2754
|
+
// cap on a chain changes no rows, so the decomposition verdict is reached
|
|
2755
|
+
// without any read-then-write window. Composed into `escalateModelTx`, which
|
|
2756
|
+
// is what makes the marker and the continuation it authorises one fact.
|
|
2757
|
+
const claimModelEscalation = db.query<
|
|
2758
|
+
unknown,
|
|
2759
|
+
[string, number, string, string, string, number]
|
|
2760
|
+
>(
|
|
2761
|
+
`INSERT OR IGNORE INTO model_escalations (project, issue, model, failureClass, runId, at)
|
|
2762
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
2763
|
+
);
|
|
2764
|
+
const selectModelEscalation = db.query<ModelEscalationRow, [string, number]>(
|
|
2765
|
+
`SELECT model, failureClass, runId, at FROM model_escalations
|
|
2766
|
+
WHERE project = ? AND issue = ?`,
|
|
2767
|
+
);
|
|
2768
|
+
const stampRecovered = db.query<unknown, [number, string]>(
|
|
2769
|
+
`UPDATE runs SET recoveredAt = ? WHERE id = ?`,
|
|
2770
|
+
);
|
|
2206
2771
|
// The settlement report row is kept for the audit trail; nothing pools it
|
|
2207
2772
|
// across attempts any more (#488).
|
|
2208
2773
|
const countStartedSince = db.query<{ n: number }, [string, number]>(
|
|
@@ -2212,6 +2777,47 @@ export function openStore(dbPath: string): Store {
|
|
|
2212
2777
|
`SELECT COALESCE(SUM(spendUsd), 0) AS total
|
|
2213
2778
|
FROM runs WHERE project = ? AND startedAt >= ?`,
|
|
2214
2779
|
);
|
|
2780
|
+
// Reservations held by runs that are still live (#851). Summed rather than
|
|
2781
|
+
// tracked: a settle releases the unspent remainder by leaving LIVE_STATES,
|
|
2782
|
+
// with nothing to remember to write, and a daemon restart re-reads the same
|
|
2783
|
+
// reservations off the same rows. A run that spent MORE than it reserved is
|
|
2784
|
+
// counted at what it reserved — its overspend is already in `spendSince`.
|
|
2785
|
+
const sumLiveReservations = db.query<{ total: number }, SqlValue[]>(
|
|
2786
|
+
`SELECT COALESCE(SUM(spendReservedUsd), 0) AS total
|
|
2787
|
+
FROM runs
|
|
2788
|
+
WHERE project = ? AND state IN (${LIVE_PLACEHOLDERS}) AND spendReservedUsd IS NOT NULL`,
|
|
2789
|
+
);
|
|
2790
|
+
const upsertInstallSurfaces = db.query<
|
|
2791
|
+
unknown,
|
|
2792
|
+
[number, string, string | null, string | null, string | null, string | null, string | null]
|
|
2793
|
+
>(
|
|
2794
|
+
`INSERT INTO install_surfaces
|
|
2795
|
+
(host, at, cliVersion, ompVersion, herdrSource, tgInstalled, tgDaemon, tgPublished)
|
|
2796
|
+
VALUES ('host', ?, ?, ?, ?, ?, ?, ?)
|
|
2797
|
+
ON CONFLICT(host) DO UPDATE SET
|
|
2798
|
+
at = excluded.at,
|
|
2799
|
+
cliVersion = excluded.cliVersion,
|
|
2800
|
+
ompVersion = excluded.ompVersion,
|
|
2801
|
+
herdrSource = excluded.herdrSource,
|
|
2802
|
+
tgInstalled = excluded.tgInstalled,
|
|
2803
|
+
tgDaemon = excluded.tgDaemon,
|
|
2804
|
+
tgPublished = excluded.tgPublished`,
|
|
2805
|
+
);
|
|
2806
|
+
const selectInstallSurfaces = db.query<
|
|
2807
|
+
{
|
|
2808
|
+
at: number;
|
|
2809
|
+
cliVersion: string;
|
|
2810
|
+
ompVersion: string | null;
|
|
2811
|
+
herdrSource: string | null;
|
|
2812
|
+
tgInstalled: string | null;
|
|
2813
|
+
tgDaemon: string | null;
|
|
2814
|
+
tgPublished: string | null;
|
|
2815
|
+
},
|
|
2816
|
+
[]
|
|
2817
|
+
>(
|
|
2818
|
+
`SELECT at, cliVersion, ompVersion, herdrSource, tgInstalled, tgDaemon, tgPublished
|
|
2819
|
+
FROM install_surfaces WHERE host = 'host'`,
|
|
2820
|
+
);
|
|
2215
2821
|
const countNotified = db.query<{ n: number }, [string]>(
|
|
2216
2822
|
`SELECT COUNT(*) AS n FROM notifications WHERE "key" = ?`,
|
|
2217
2823
|
);
|
|
@@ -2282,6 +2888,26 @@ export function openStore(dbPath: string): Store {
|
|
|
2282
2888
|
}
|
|
2283
2889
|
},
|
|
2284
2890
|
);
|
|
2891
|
+
// A groomed verdict describes an OPEN issue, and nothing retired one when its
|
|
2892
|
+
// issue closed (#964). `clearGroomedBlocked` above only ever cleared the two
|
|
2893
|
+
// per-pass admission holds, so every other row — `promotable`, `considered`,
|
|
2894
|
+
// `needs-product-decision`, an in-flight batch marker — accumulated forever.
|
|
2895
|
+
// Measured on this fleet 2026-08-23: 64 of 65 rows named a closed issue, and
|
|
2896
|
+
// all 26 `promotable` ones did, so `status` advertised 26 candidates ready to
|
|
2897
|
+
// promote on a repo with exactly one open issue.
|
|
2898
|
+
//
|
|
2899
|
+
// Deleted one row at a time inside a transaction rather than with a generated
|
|
2900
|
+
// `NOT IN (...)`: the closed set is computed by the caller from a complete
|
|
2901
|
+
// open-issue snapshot, and an over-long IN list is exactly where a partial
|
|
2902
|
+
// snapshot would quietly delete verdicts it never verified.
|
|
2903
|
+
const deleteGroomingRow = db.query<unknown, [string, number]>(
|
|
2904
|
+
`DELETE FROM grooming WHERE project = ? AND issue = ?`,
|
|
2905
|
+
);
|
|
2906
|
+
const retireGroomingTx = db.transaction((project: string, issues: readonly number[]): number => {
|
|
2907
|
+
let removed = 0;
|
|
2908
|
+
for (const issue of issues) removed += deleteGroomingRow.run(project, issue).changes;
|
|
2909
|
+
return removed;
|
|
2910
|
+
});
|
|
2285
2911
|
const selectFrictionRollup = db.query<FrictionRollupRow, [string, string, string]>(
|
|
2286
2912
|
`SELECT * FROM friction_rollups WHERE project = ? AND day = ? AND kind = ?`,
|
|
2287
2913
|
);
|
|
@@ -2762,10 +3388,16 @@ export function openStore(dbPath: string): Store {
|
|
|
2762
3388
|
},
|
|
2763
3389
|
);
|
|
2764
3390
|
|
|
2765
|
-
const insertDecision = db.query<
|
|
3391
|
+
const insertDecision = db.query<
|
|
3392
|
+
never,
|
|
3393
|
+
[string, string, string, string, SqlValue, number, number, SqlValue, string, SqlValue, SqlValue]
|
|
3394
|
+
>(
|
|
2766
3395
|
`INSERT INTO decisions
|
|
2767
|
-
(id, project, kind, question, blocks, askedAt, expiresAt, condition, state)
|
|
2768
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
3396
|
+
(id, project, kind, question, blocks, askedAt, expiresAt, condition, state, groupId, specIssue)
|
|
3397
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
3398
|
+
);
|
|
3399
|
+
const selectDecisionGroup = db.query<DecisionRow, [string]>(
|
|
3400
|
+
`SELECT * FROM decisions WHERE groupId = ? ORDER BY askedAt ASC, rowid ASC`,
|
|
2769
3401
|
);
|
|
2770
3402
|
const selectDecision = db.query<DecisionRow, [string]>(`SELECT * FROM decisions WHERE id = ?`);
|
|
2771
3403
|
const selectOpenDecisions = db.query<DecisionRow, [string]>(
|
|
@@ -2793,8 +3425,27 @@ export function openStore(dbPath: string): Store {
|
|
|
2793
3425
|
// every tick. A `pr-checks-green` / `pr-review-ready` row whose PR head
|
|
2794
3426
|
// moved is cleared first and re-marks here against the new head (`head`
|
|
2795
3427
|
// binds the verdict to the exact commit it was observed at, #808).
|
|
2796
|
-
|
|
2797
|
-
|
|
3428
|
+
// A watch's deadline starts when its condition fires, not when it was opened
|
|
3429
|
+
// (#966).
|
|
3430
|
+
//
|
|
3431
|
+
// `DECISION_WATCH_NEVER_EXPIRES` is right for the waiting phase, and its
|
|
3432
|
+
// reasoning is quoted where it is defined: a condition that has not fired is
|
|
3433
|
+
// not a question nobody answered, and expiring it could drop a release. But
|
|
3434
|
+
// the instant `conditionMetAt` is stamped the row stops waiting — it becomes
|
|
3435
|
+
// an actionable item, which is exactly what a question's TTL bounds. Measured
|
|
3436
|
+
// on this fleet 2026-08-23: three watches had carried
|
|
3437
|
+
// "[CONDITION MET — act on this now]" for 10-12 hours over work that was
|
|
3438
|
+
// entirely closed, and would have carried it forever.
|
|
3439
|
+
//
|
|
3440
|
+
// Only watches are touched: a question's `expiresAt` already runs from
|
|
3441
|
+
// `askedAt` and must keep doing so, so the deadline is written only where it
|
|
3442
|
+
// is currently infinite. `kind = 'watch'` is the guard rather than a value
|
|
3443
|
+
// comparison, because a question with a condition is still a question.
|
|
3444
|
+
const markConditionMetRow = db.query<never, [number, SqlValue, number, string]>(
|
|
3445
|
+
`UPDATE decisions
|
|
3446
|
+
SET conditionMetAt = ?,
|
|
3447
|
+
conditionHead = ?,
|
|
3448
|
+
expiresAt = CASE WHEN kind = 'watch' THEN ? ELSE expiresAt END
|
|
2798
3449
|
WHERE id = ? AND state = 'open' AND conditionMetAt IS NULL`,
|
|
2799
3450
|
);
|
|
2800
3451
|
// The inverse of the mark, for the two conditions whose observation goes
|
|
@@ -2802,8 +3453,15 @@ export function openStore(dbPath: string): Store {
|
|
|
2802
3453
|
// so a head change drops both the timestamp and the binding and the row
|
|
2803
3454
|
// returns to pending (#808). Returns false when no met state existed to
|
|
2804
3455
|
// drop, so re-runs are a no-op.
|
|
2805
|
-
|
|
2806
|
-
|
|
3456
|
+
//
|
|
3457
|
+
// The deadline goes back with it (#966): a PR that went red again is waiting,
|
|
3458
|
+
// not ignored, and leaving a countdown on it would expire a watch whose
|
|
3459
|
+
// condition is once again unfired.
|
|
3460
|
+
const clearConditionMetRow = db.query<never, [number, string]>(
|
|
3461
|
+
`UPDATE decisions
|
|
3462
|
+
SET conditionMetAt = NULL,
|
|
3463
|
+
conditionHead = NULL,
|
|
3464
|
+
expiresAt = CASE WHEN kind = 'watch' THEN ? ELSE expiresAt END
|
|
2807
3465
|
WHERE id = ? AND state = 'open' AND conditionMetAt IS NOT NULL`,
|
|
2808
3466
|
);
|
|
2809
3467
|
|
|
@@ -2889,6 +3547,155 @@ export function openStore(dbPath: string): Store {
|
|
|
2889
3547
|
const requeueReviewRevisionRow = db.query<unknown, [string]>(
|
|
2890
3548
|
`UPDATE review_revisions SET dispatchedAt = NULL WHERE id = ?`,
|
|
2891
3549
|
);
|
|
3550
|
+
// The bounded infra retry (#903). One statement decides and writes: the
|
|
3551
|
+
// WHERE carries the bound, so two daemons cannot both spend the last retry
|
|
3552
|
+
// and a settled row can never be revived. `changes` is the decision.
|
|
3553
|
+
const retryReviewRevisionRow = db.query<unknown, [string, number]>(
|
|
3554
|
+
`UPDATE review_revisions
|
|
3555
|
+
SET dispatchedAt = NULL,
|
|
3556
|
+
infraRetries = COALESCE(infraRetries, 0) + 1
|
|
3557
|
+
WHERE id = ? AND settledAt IS NULL AND COALESCE(infraRetries, 0) < ?`,
|
|
3558
|
+
);
|
|
3559
|
+
const selectReviewRevisionById = db.query<ReviewRevisionRow, [string]>(
|
|
3560
|
+
`SELECT * FROM review_revisions WHERE id = ?`,
|
|
3561
|
+
);
|
|
3562
|
+
// The exact-head merge gate (#888): every row that stands between a PR and
|
|
3563
|
+
// a merge at one head — anything not yet settled (queued, or dispatched and
|
|
3564
|
+
// crashed mid-review) plus anything settled `failed` there. `skipped`,
|
|
3565
|
+
// `revised` and `pending` outcomes never block: a skip is an assessment
|
|
3566
|
+
// that never ran (the escalation for one says "merge it as it stands"), and
|
|
3567
|
+
// a revised/pending outcome belongs to a worker that pushed past the
|
|
3568
|
+
// reviewed head. Head comparison is case-insensitive; prUrl is matched
|
|
3569
|
+
// exactly — every writer records it from the same tracker payloads.
|
|
3570
|
+
//
|
|
3571
|
+
// A recorded clearance (#913) is the one disposition available at an
|
|
3572
|
+
// *unchanged* head, and it applies strictly backwards: the NOT EXISTS
|
|
3573
|
+
// requires the clearance to be at or after the row's own last activity
|
|
3574
|
+
// (`settledAt` when the round finished, else `requestedAt`). Without that
|
|
3575
|
+
// comparison a single clearance would bless every future finding at that
|
|
3576
|
+
// head — a strictly worse hole than the deadlock it fixes — so the
|
|
3577
|
+
// timestamp is the load-bearing half of this clause, not decoration. The
|
|
3578
|
+
// `outcome = 'failed'` term stays exactly as it was: a clearance is how
|
|
3579
|
+
// failed evidence is dispositioned, never by widening what counts as
|
|
3580
|
+
// blocking.
|
|
3581
|
+
const selectMergeBlockingReviews = db.query<ReviewRevisionRow, [string, string, string]>(
|
|
3582
|
+
`SELECT * FROM review_revisions
|
|
3583
|
+
WHERE project = ? AND prUrl = ? AND lower(headSha) = lower(?)
|
|
3584
|
+
AND (settledAt IS NULL OR outcome = 'failed')
|
|
3585
|
+
AND NOT EXISTS (
|
|
3586
|
+
SELECT 1 FROM review_clearances c
|
|
3587
|
+
WHERE c.project = review_revisions.project
|
|
3588
|
+
AND c.prUrl = review_revisions.prUrl
|
|
3589
|
+
AND lower(c.headSha) = lower(review_revisions.headSha)
|
|
3590
|
+
AND c.at >= COALESCE(review_revisions.settledAt, review_revisions.requestedAt)
|
|
3591
|
+
)
|
|
3592
|
+
ORDER BY requestedAt ASC, rowid ASC`,
|
|
3593
|
+
);
|
|
3594
|
+
const insertReviewClearance = db.query<unknown, [string, string, string, string, number, string, string]>(
|
|
3595
|
+
`INSERT INTO review_clearances (id, project, prUrl, headSha, at, by, reason)
|
|
3596
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
3597
|
+
);
|
|
3598
|
+
const selectReviewClearances = db.query<ReviewClearanceRow, [string, string, string]>(
|
|
3599
|
+
`SELECT * FROM review_clearances
|
|
3600
|
+
WHERE project = ? AND prUrl = ? AND lower(headSha) = lower(?)
|
|
3601
|
+
ORDER BY at DESC, rowid DESC`,
|
|
3602
|
+
);
|
|
3603
|
+
|
|
3604
|
+
// The review-ceiling adjudication lifecycle (#874). Head comparison is
|
|
3605
|
+
// case-insensitive everywhere, exactly as the merge gate and the clearance
|
|
3606
|
+
// lookup are: a 40-hex SHA read from two GitHub surfaces can differ in case
|
|
3607
|
+
// and mean the same commit.
|
|
3608
|
+
const insertReviewAdjudication = db.query<unknown, SqlValue[]>(
|
|
3609
|
+
`INSERT INTO review_adjudications
|
|
3610
|
+
(id, project, issue, prUrl, headSha, role, provenance, state, evidence, disposition,
|
|
3611
|
+
requestedAt, dispatchedAt, settledAt, findings)
|
|
3612
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL, 'pending', NULL, NULL, ?, NULL, NULL, ?)`,
|
|
3613
|
+
);
|
|
3614
|
+
const selectAdjudicationForHead = db.query<ReviewAdjudicationRow, [string, string, string]>(
|
|
3615
|
+
`SELECT * FROM review_adjudications
|
|
3616
|
+
WHERE project = ? AND prUrl = ? AND lower(headSha) = lower(?)`,
|
|
3617
|
+
);
|
|
3618
|
+
// The non-terminal row for one PR, whatever head it is on. This is what makes
|
|
3619
|
+
// a moved head refuse rather than open a second live adjudication.
|
|
3620
|
+
const selectOpenAdjudicationForPr = db.query<ReviewAdjudicationRow, [string, string]>(
|
|
3621
|
+
`SELECT * FROM review_adjudications
|
|
3622
|
+
WHERE project = ? AND prUrl = ? AND settledAt IS NULL
|
|
3623
|
+
ORDER BY requestedAt ASC, rowid ASC
|
|
3624
|
+
LIMIT 1`,
|
|
3625
|
+
);
|
|
3626
|
+
const selectAdjudicationById = db.query<ReviewAdjudicationRow, [string]>(
|
|
3627
|
+
`SELECT * FROM review_adjudications WHERE id = ?`,
|
|
3628
|
+
);
|
|
3629
|
+
const selectOpenAdjudications = db.query<ReviewAdjudicationRow, [string]>(
|
|
3630
|
+
`SELECT * FROM review_adjudications
|
|
3631
|
+
WHERE project = ? AND settledAt IS NULL
|
|
3632
|
+
ORDER BY requestedAt ASC, rowid ASC`,
|
|
3633
|
+
);
|
|
3634
|
+
// Every review round recorded for one PR, oldest first (#932): the history the
|
|
3635
|
+
// adjudicator is shown. Keyed by PR rather than by run so a continuation that
|
|
3636
|
+
// inherited the PR does not hide its predecessor's findings.
|
|
3637
|
+
const selectRevisionsForPr = db.query<ReviewRevisionRow, [string, string]>(
|
|
3638
|
+
`SELECT * FROM review_revisions
|
|
3639
|
+
WHERE project = ? AND prUrl = ?
|
|
3640
|
+
ORDER BY round ASC, requestedAt ASC, rowid ASC`,
|
|
3641
|
+
);
|
|
3642
|
+
const selectAdjudicationsForPr = db.query<ReviewAdjudicationRow, [string, string]>(
|
|
3643
|
+
`SELECT * FROM review_adjudications
|
|
3644
|
+
WHERE project = ? AND prUrl = ?
|
|
3645
|
+
ORDER BY requestedAt DESC, rowid DESC`,
|
|
3646
|
+
);
|
|
3647
|
+
// Dispatch: pending -> running, recording the launch provenance. The WHERE
|
|
3648
|
+
// carries the transition, so two dispatchers racing one row cannot both
|
|
3649
|
+
// launch — the loser changes nothing and reads `false`.
|
|
3650
|
+
const markAdjudicationRunningRow = db.query<unknown, [string, number, string]>(
|
|
3651
|
+
`UPDATE review_adjudications
|
|
3652
|
+
SET state = 'running', provenance = ?, dispatchedAt = ?
|
|
3653
|
+
WHERE id = ? AND state = 'pending' AND settledAt IS NULL`,
|
|
3654
|
+
);
|
|
3655
|
+
// Settle: only from a non-terminal state, and only once. An invalid
|
|
3656
|
+
// transition changes nothing and preserves whatever the row already said,
|
|
3657
|
+
// which is the diagnostic evidence a later reader needs.
|
|
3658
|
+
const settleAdjudicationRow = db.query<unknown, SqlValue[]>(
|
|
3659
|
+
`UPDATE review_adjudications
|
|
3660
|
+
SET state = ?, evidence = COALESCE(?, evidence), settledAt = ?
|
|
3661
|
+
WHERE id = ? AND settledAt IS NULL`,
|
|
3662
|
+
);
|
|
3663
|
+
const recordAdjudicationDispositionRow = db.query<unknown, [string, string]>(
|
|
3664
|
+
`UPDATE review_adjudications SET disposition = ? WHERE id = ?`,
|
|
3665
|
+
);
|
|
3666
|
+
|
|
3667
|
+
// Admission is a transaction for the same reason the revision enqueue is: the
|
|
3668
|
+
// CLI and the daemon are separate processes with separate connections, so a
|
|
3669
|
+
// deferred read-then-write would let both see "no row" and both insert. It
|
|
3670
|
+
// begins IMMEDIATE, taking the write lock before the read, and the UNIQUE
|
|
3671
|
+
// index on (project, prUrl, headSha) is the structural backstop underneath.
|
|
3672
|
+
const openAdjudicationTx = db.transaction(
|
|
3673
|
+
(
|
|
3674
|
+
id: string,
|
|
3675
|
+
project: string,
|
|
3676
|
+
issue: number,
|
|
3677
|
+
prUrl: string,
|
|
3678
|
+
headSha: string,
|
|
3679
|
+
role: string,
|
|
3680
|
+
requestedAt: number,
|
|
3681
|
+
findings: string | undefined,
|
|
3682
|
+
): ReviewAdjudicationAdmission => {
|
|
3683
|
+
const sameHead = selectAdjudicationForHead.get(project, prUrl, headSha);
|
|
3684
|
+
if (sameHead !== null && sameHead !== undefined) {
|
|
3685
|
+
return { kind: "existing", record: toReviewAdjudication(sameHead) };
|
|
3686
|
+
}
|
|
3687
|
+
const open = selectOpenAdjudicationForPr.get(project, prUrl);
|
|
3688
|
+
if (open !== null && open !== undefined) {
|
|
3689
|
+
return {
|
|
3690
|
+
kind: "refused",
|
|
3691
|
+
block: "in-flight-different-head",
|
|
3692
|
+
record: toReviewAdjudication(open),
|
|
3693
|
+
};
|
|
3694
|
+
}
|
|
3695
|
+
insertReviewAdjudication.run(id, project, issue, prUrl, headSha, role, requestedAt, findings ?? null);
|
|
3696
|
+
return { kind: "created", record: toReviewAdjudication(selectAdjudicationById.get(id)!) };
|
|
3697
|
+
},
|
|
3698
|
+
);
|
|
2892
3699
|
// The states the review verb admits and the dispatch pass may therefore
|
|
2893
3700
|
// claim (#795): a settled green run, or a capped/failed run that pushed one
|
|
2894
3701
|
// (`failed` / `killed`). The terminal set is closed on purpose — a live row
|
|
@@ -3046,21 +3853,62 @@ export function openStore(dbPath: string): Store {
|
|
|
3046
3853
|
WHERE project = ? AND issue = ? AND label = ?
|
|
3047
3854
|
ORDER BY id DESC LIMIT 1`,
|
|
3048
3855
|
);
|
|
3049
|
-
|
|
3856
|
+
/** The coalescing outbox insert, without a transaction of its own: the caller
|
|
3857
|
+
* owns one, so a decision that must be durable *with* its label transition
|
|
3858
|
+
* (the #807 escalation) can commit both or neither. */
|
|
3859
|
+
const enqueueLabelOpRows = (
|
|
3860
|
+
rows: readonly {
|
|
3861
|
+
project: string;
|
|
3862
|
+
issue: number;
|
|
3863
|
+
op: "add" | "remove";
|
|
3864
|
+
label: string;
|
|
3865
|
+
createdAt: number;
|
|
3866
|
+
}[],
|
|
3867
|
+
): void => {
|
|
3868
|
+
for (const row of rows) {
|
|
3869
|
+
const latest = selectLatestLabelOp.get(row.project, row.issue, row.label);
|
|
3870
|
+
if (latest !== undefined && latest !== null && latest.op === row.op) continue;
|
|
3871
|
+
insertLabelOp.run(toSql(row.project), row.issue, row.op, row.label, row.createdAt);
|
|
3872
|
+
}
|
|
3873
|
+
};
|
|
3874
|
+
const enqueueLabelOpsTx = db.transaction(enqueueLabelOpRows);
|
|
3875
|
+
|
|
3876
|
+
/**
|
|
3877
|
+
* The whole #807 escalation as one durable decision: the chain's one-shot
|
|
3878
|
+
* marker, the failed → queue label swap that spends it, and the settling
|
|
3879
|
+
* run's recovery stamp.
|
|
3880
|
+
*
|
|
3881
|
+
* They commit together or not at all, which is the only way the promise
|
|
3882
|
+
* survives a restart. As three separate writes led by the marker, a daemon
|
|
3883
|
+
* that died right after it would come back to a chain reading as
|
|
3884
|
+
* already-escalated with no queue label owed: the continuation silently lost,
|
|
3885
|
+
* and the next cap on that chain judged a second one.
|
|
3886
|
+
*
|
|
3887
|
+
* A run id with no row is a caller bug, not a state: throwing rolls the
|
|
3888
|
+
* marker back rather than recording an escalation nothing can ever spend.
|
|
3889
|
+
*/
|
|
3890
|
+
const escalateModelTx = db.transaction(
|
|
3050
3891
|
(
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3892
|
+
project: string,
|
|
3893
|
+
issue: number,
|
|
3894
|
+
model: string,
|
|
3895
|
+
failureClass: string,
|
|
3896
|
+
runId: string,
|
|
3897
|
+
swapFrom: string,
|
|
3898
|
+
queueLabel: string,
|
|
3899
|
+
at: number,
|
|
3900
|
+
): boolean => {
|
|
3901
|
+
if (claimModelEscalation.run(project, issue, model, failureClass, runId, at).changes === 0) {
|
|
3902
|
+
return false;
|
|
3903
|
+
}
|
|
3904
|
+
enqueueLabelOpRows([
|
|
3905
|
+
{ project, issue, op: "remove", label: swapFrom, createdAt: at },
|
|
3906
|
+
{ project, issue, op: "add", label: queueLabel, createdAt: at },
|
|
3907
|
+
]);
|
|
3908
|
+
if (stampRecovered.run(at, runId).changes === 0) {
|
|
3909
|
+
throw new Error(`escalateModel: run ${runId} does not exist`);
|
|
3063
3910
|
}
|
|
3911
|
+
return true;
|
|
3064
3912
|
},
|
|
3065
3913
|
);
|
|
3066
3914
|
|
|
@@ -3121,6 +3969,7 @@ export function openStore(dbPath: string): Store {
|
|
|
3121
3969
|
record.turns,
|
|
3122
3970
|
record.maxTurns,
|
|
3123
3971
|
record.spendUsd,
|
|
3972
|
+
toSql(record.spendReservedUsd),
|
|
3124
3973
|
toSql(record.sessionFile),
|
|
3125
3974
|
toSql(record.resumedFromRunId),
|
|
3126
3975
|
toSql(record.lane),
|
|
@@ -3142,6 +3991,63 @@ export function openStore(dbPath: string): Store {
|
|
|
3142
3991
|
);
|
|
3143
3992
|
return record;
|
|
3144
3993
|
};
|
|
3994
|
+
/** The one row writer both the single ask and a questionnaire item use. */
|
|
3995
|
+
const writeDecision = (draft: DecisionDraft): DecisionRecord => {
|
|
3996
|
+
const kind = draft.kind ?? "question";
|
|
3997
|
+
const record: DecisionRecord = {
|
|
3998
|
+
id: crypto.randomUUID(),
|
|
3999
|
+
project: draft.project,
|
|
4000
|
+
kind,
|
|
4001
|
+
question: draft.question,
|
|
4002
|
+
askedAt: draft.at,
|
|
4003
|
+
// The seven-day deadline is for a question a human never answered. A
|
|
4004
|
+
// watch has no such deadline: it is the orchestrator's own bookkeeping,
|
|
4005
|
+
// and expiring it silently could drop a release the day its condition
|
|
4006
|
+
// finally fires, so it is never due.
|
|
4007
|
+
expiresAt: kind === "watch" ? DECISION_WATCH_NEVER_EXPIRES : draft.at + DECISION_TTL_MS,
|
|
4008
|
+
state: "open",
|
|
4009
|
+
};
|
|
4010
|
+
if (draft.blocks !== undefined) record.blocks = draft.blocks;
|
|
4011
|
+
if (draft.condition !== undefined) record.condition = draft.condition;
|
|
4012
|
+
if (draft.groupId !== undefined) record.groupId = draft.groupId;
|
|
4013
|
+
if (draft.specIssue !== undefined) record.specIssue = draft.specIssue;
|
|
4014
|
+
insertDecision.run(
|
|
4015
|
+
record.id,
|
|
4016
|
+
record.project,
|
|
4017
|
+
record.kind,
|
|
4018
|
+
record.question,
|
|
4019
|
+
toSql(record.blocks),
|
|
4020
|
+
record.askedAt,
|
|
4021
|
+
record.expiresAt,
|
|
4022
|
+
toSql(record.condition),
|
|
4023
|
+
record.state,
|
|
4024
|
+
toSql(record.groupId),
|
|
4025
|
+
toSql(record.specIssue),
|
|
4026
|
+
);
|
|
4027
|
+
return record;
|
|
4028
|
+
};
|
|
4029
|
+
|
|
4030
|
+
/**
|
|
4031
|
+
* One spec-out questionnaire, written in one transaction (#947).
|
|
4032
|
+
*
|
|
4033
|
+
* The atomicity is the whole guarantee: every item is durable BEFORE the
|
|
4034
|
+
* single delivery, so a crash halfway can never put questions in front of an
|
|
4035
|
+
* operator that no row is waiting on. The group id is minted here rather than
|
|
4036
|
+
* supplied, because a caller that can name the group can also collide with
|
|
4037
|
+
* another one, and an empty batch is refused — a questionnaire of nothing is a
|
|
4038
|
+
* caller bug, not an empty delivery.
|
|
4039
|
+
*/
|
|
4040
|
+
const createDecisionGroupTx = db.transaction(
|
|
4041
|
+
(drafts: readonly DecisionDraft[], specIssue: number): DecisionRecord[] => {
|
|
4042
|
+
if (drafts.length === 0) throw new Error("a questionnaire needs at least one item");
|
|
4043
|
+
if (!Number.isInteger(specIssue) || specIssue <= 0) {
|
|
4044
|
+
throw new Error(`a questionnaire must name the issue it specs, got ${String(specIssue)}`);
|
|
4045
|
+
}
|
|
4046
|
+
const groupId = crypto.randomUUID();
|
|
4047
|
+
return drafts.map((draft) => writeDecision({ ...draft, groupId, specIssue }));
|
|
4048
|
+
},
|
|
4049
|
+
);
|
|
4050
|
+
|
|
3145
4051
|
const createRunTx = db.transaction((r: Omit<RunRecord, "id">): RunRecord => {
|
|
3146
4052
|
if (r.state !== "claimed") return insertRunRecord(r);
|
|
3147
4053
|
const turnOverride = selectTurnOverride.get(r.project, r.issue)?.maxTurns;
|
|
@@ -3212,6 +4118,10 @@ export function openStore(dbPath: string): Store {
|
|
|
3212
4118
|
return selectLive.all(project, ...LIVE_STATES).map(toRecord);
|
|
3213
4119
|
},
|
|
3214
4120
|
|
|
4121
|
+
leasedRuns(project: string): RunRecord[] {
|
|
4122
|
+
return selectLeased.all(project, ...LIVE_STATES, project).map(toRecord);
|
|
4123
|
+
},
|
|
4124
|
+
|
|
3215
4125
|
retainedRuns(project: string): RunRecord[] {
|
|
3216
4126
|
return selectRetained.all(project).map(toRecord);
|
|
3217
4127
|
},
|
|
@@ -3250,6 +4160,111 @@ export function openStore(dbPath: string): Store {
|
|
|
3250
4160
|
requeueReviewRevision(id: string): void {
|
|
3251
4161
|
requeueReviewRevisionRow.run(id);
|
|
3252
4162
|
},
|
|
4163
|
+
retryReviewRevisionAfterInfra(id: string, max: number): ReviewRevisionRetry {
|
|
4164
|
+
const requeued = retryReviewRevisionRow.run(id, max).changes > 0;
|
|
4165
|
+
const retries = selectReviewRevisionById.get(id)?.infraRetries ?? 0;
|
|
4166
|
+
return requeued ? { kind: "requeued", retries } : { kind: "exhausted", retries };
|
|
4167
|
+
},
|
|
4168
|
+
mergeBlockingReviews(project: string, prUrl: string, headSha: string): ReviewRevisionRecord[] {
|
|
4169
|
+
return selectMergeBlockingReviews.all(project, prUrl, headSha).map(toReviewRevision);
|
|
4170
|
+
},
|
|
4171
|
+
recordReviewClearance(clearance: {
|
|
4172
|
+
project: string;
|
|
4173
|
+
prUrl: string;
|
|
4174
|
+
headSha: string;
|
|
4175
|
+
by: string;
|
|
4176
|
+
reason: string;
|
|
4177
|
+
at?: number;
|
|
4178
|
+
}): ReviewClearance {
|
|
4179
|
+
const record: ReviewClearance = {
|
|
4180
|
+
id: newReportId(),
|
|
4181
|
+
project: clearance.project,
|
|
4182
|
+
prUrl: clearance.prUrl,
|
|
4183
|
+
headSha: clearance.headSha,
|
|
4184
|
+
at: clearance.at ?? Date.now(),
|
|
4185
|
+
by: clearance.by,
|
|
4186
|
+
reason: clearance.reason,
|
|
4187
|
+
};
|
|
4188
|
+
insertReviewClearance.run(
|
|
4189
|
+
record.id,
|
|
4190
|
+
record.project,
|
|
4191
|
+
record.prUrl,
|
|
4192
|
+
record.headSha,
|
|
4193
|
+
record.at,
|
|
4194
|
+
record.by,
|
|
4195
|
+
record.reason,
|
|
4196
|
+
);
|
|
4197
|
+
return record;
|
|
4198
|
+
},
|
|
4199
|
+
reviewClearances(project: string, prUrl: string, headSha: string): ReviewClearance[] {
|
|
4200
|
+
return selectReviewClearances.all(project, prUrl, headSha).map((row) => ({ ...row }));
|
|
4201
|
+
},
|
|
4202
|
+
openReviewAdjudication(draft: {
|
|
4203
|
+
project: string;
|
|
4204
|
+
issue: number;
|
|
4205
|
+
prUrl: string;
|
|
4206
|
+
headSha: string;
|
|
4207
|
+
role: string;
|
|
4208
|
+
requestedAt?: number;
|
|
4209
|
+
findings?: string;
|
|
4210
|
+
}): ReviewAdjudicationAdmission {
|
|
4211
|
+
// `immediate` = BEGIN IMMEDIATE: the write lock is taken before the
|
|
4212
|
+
// one-shot read, so two processes cannot both see "no adjudication" and
|
|
4213
|
+
// both insert one (#874).
|
|
4214
|
+
return openAdjudicationTx.immediate(
|
|
4215
|
+
newReportId(),
|
|
4216
|
+
draft.project,
|
|
4217
|
+
draft.issue,
|
|
4218
|
+
draft.prUrl,
|
|
4219
|
+
draft.headSha,
|
|
4220
|
+
draft.role,
|
|
4221
|
+
draft.requestedAt ?? Date.now(),
|
|
4222
|
+
draft.findings,
|
|
4223
|
+
);
|
|
4224
|
+
},
|
|
4225
|
+
markReviewAdjudicationRunning(
|
|
4226
|
+
id: string,
|
|
4227
|
+
provenance: ReviewAdjudicationProvenance,
|
|
4228
|
+
at: number = Date.now(),
|
|
4229
|
+
): boolean {
|
|
4230
|
+
return markAdjudicationRunningRow.run(JSON.stringify(provenance), at, id).changes > 0;
|
|
4231
|
+
},
|
|
4232
|
+
settleReviewAdjudication(
|
|
4233
|
+
id: string,
|
|
4234
|
+
state: ReviewAdjudicationState,
|
|
4235
|
+
evidence?: string,
|
|
4236
|
+
at: number = Date.now(),
|
|
4237
|
+
): boolean {
|
|
4238
|
+
// Terminal only, and once. A caller asking to settle into `pending` or
|
|
4239
|
+
// `running` is a bug, not a transition: refusing it here keeps "settled"
|
|
4240
|
+
// meaning "finished forever" for every reader.
|
|
4241
|
+
if (!TERMINAL_REVIEW_ADJUDICATION_STATES.some((terminal) => terminal === state)) return false;
|
|
4242
|
+
return settleAdjudicationRow.run(state, evidence ?? null, at, id).changes > 0;
|
|
4243
|
+
},
|
|
4244
|
+
recordReviewAdjudicationDisposition(id: string, disposition: string): boolean {
|
|
4245
|
+
return recordAdjudicationDispositionRow.run(disposition, id).changes > 0;
|
|
4246
|
+
},
|
|
4247
|
+
reviewAdjudication(id: string): ReviewAdjudicationRecord | undefined {
|
|
4248
|
+
const row = selectAdjudicationById.get(id);
|
|
4249
|
+
return row === null || row === undefined ? undefined : toReviewAdjudication(row);
|
|
4250
|
+
},
|
|
4251
|
+
reviewAdjudicationForHead(
|
|
4252
|
+
project: string,
|
|
4253
|
+
prUrl: string,
|
|
4254
|
+
headSha: string,
|
|
4255
|
+
): ReviewAdjudicationRecord | undefined {
|
|
4256
|
+
const row = selectAdjudicationForHead.get(project, prUrl, headSha);
|
|
4257
|
+
return row === null || row === undefined ? undefined : toReviewAdjudication(row);
|
|
4258
|
+
},
|
|
4259
|
+
openReviewAdjudications(project: string): ReviewAdjudicationRecord[] {
|
|
4260
|
+
return selectOpenAdjudications.all(project).map(toReviewAdjudication);
|
|
4261
|
+
},
|
|
4262
|
+
reviewRevisionsForPr(project: string, prUrl: string): ReviewRevisionRecord[] {
|
|
4263
|
+
return selectRevisionsForPr.all(project, prUrl).map(toReviewRevision);
|
|
4264
|
+
},
|
|
4265
|
+
reviewAdjudicationsForPr(project: string, prUrl: string): ReviewAdjudicationRecord[] {
|
|
4266
|
+
return selectAdjudicationsForPr.all(project, prUrl).map(toReviewAdjudication);
|
|
4267
|
+
},
|
|
3253
4268
|
claimRunForReview(runId: string): boolean {
|
|
3254
4269
|
return claimRunForReviewTx(runId);
|
|
3255
4270
|
},
|
|
@@ -3318,6 +4333,78 @@ export function openStore(dbPath: string): Store {
|
|
|
3318
4333
|
return clearBaseFreezeRow.run(at, by, reason, project, repo).changes > 0;
|
|
3319
4334
|
},
|
|
3320
4335
|
|
|
4336
|
+
activeReleaseComposition(project: string): ReleaseComposition | undefined {
|
|
4337
|
+
const row = selectActiveReleaseComposition.get(project);
|
|
4338
|
+
return row === null ? undefined : toReleaseComposition(row);
|
|
4339
|
+
},
|
|
4340
|
+
|
|
4341
|
+
declareReleaseComposition(
|
|
4342
|
+
project: string,
|
|
4343
|
+
composition: {
|
|
4344
|
+
campaign: string;
|
|
4345
|
+
allowedPrUrls: string[];
|
|
4346
|
+
declaredBy?: string;
|
|
4347
|
+
declaredAt?: number;
|
|
4348
|
+
},
|
|
4349
|
+
): ReleaseComposition | undefined {
|
|
4350
|
+
// One active composition per project: a second declaration while one
|
|
4351
|
+
// is live is the caller's error to see, so hand the conflict straight
|
|
4352
|
+
// back. The transactional re-check keeps two declarers from racing.
|
|
4353
|
+
const conflict = declareReleaseCompositionTx(
|
|
4354
|
+
project,
|
|
4355
|
+
composition.campaign,
|
|
4356
|
+
JSON.stringify(composition.allowedPrUrls),
|
|
4357
|
+
composition.declaredBy ?? null,
|
|
4358
|
+
composition.declaredAt ?? Date.now(),
|
|
4359
|
+
);
|
|
4360
|
+
return conflict === null ? undefined : toReleaseComposition(conflict);
|
|
4361
|
+
},
|
|
4362
|
+
|
|
4363
|
+
completeReleaseComposition(project: string, by: string, reason: string, at = Date.now()): boolean {
|
|
4364
|
+
return retireReleaseCompositionRow.run(at, by, reason, project).changes > 0;
|
|
4365
|
+
},
|
|
4366
|
+
|
|
4367
|
+
cancelReleaseComposition(project: string, by: string, reason: string, at = Date.now()): boolean {
|
|
4368
|
+
return retireReleaseCompositionRow.run(at, by, reason, project).changes > 0;
|
|
4369
|
+
},
|
|
4370
|
+
|
|
4371
|
+
grantReleaseCompositionOverride(
|
|
4372
|
+
project: string,
|
|
4373
|
+
override: { campaign: string; prUrl: string; by: string; reason: string; at?: number },
|
|
4374
|
+
): ReleaseCompositionOverride {
|
|
4375
|
+
const record: ReleaseCompositionOverride = {
|
|
4376
|
+
id: newReportId(),
|
|
4377
|
+
project,
|
|
4378
|
+
campaign: override.campaign,
|
|
4379
|
+
prUrl: override.prUrl,
|
|
4380
|
+
at: override.at ?? Date.now(),
|
|
4381
|
+
by: override.by,
|
|
4382
|
+
reason: override.reason,
|
|
4383
|
+
};
|
|
4384
|
+
insertReleaseCompositionOverride.run(
|
|
4385
|
+
record.id,
|
|
4386
|
+
record.project,
|
|
4387
|
+
record.campaign,
|
|
4388
|
+
record.prUrl,
|
|
4389
|
+
record.at,
|
|
4390
|
+
record.by,
|
|
4391
|
+
record.reason,
|
|
4392
|
+
);
|
|
4393
|
+
return record;
|
|
4394
|
+
},
|
|
4395
|
+
|
|
4396
|
+
releaseCompositionOverride(
|
|
4397
|
+
project: string,
|
|
4398
|
+
campaign: string,
|
|
4399
|
+
prUrl: string,
|
|
4400
|
+
): ReleaseCompositionOverride | undefined {
|
|
4401
|
+
const row = selectReleaseCompositionOverride.get(project, campaign, prUrl);
|
|
4402
|
+
return row === null ? undefined : { ...row };
|
|
4403
|
+
},
|
|
4404
|
+
|
|
4405
|
+
releaseCompositionOverrides(project: string, campaign: string): ReleaseCompositionOverride[] {
|
|
4406
|
+
return selectReleaseCompositionOverrides.all(project, campaign).map((row) => ({ ...row }));
|
|
4407
|
+
},
|
|
3321
4408
|
|
|
3322
4409
|
salvagedRuns(project: string): RunRecord[] {
|
|
3323
4410
|
return selectSalvaged.all(project).map(toRecord);
|
|
@@ -3351,6 +4438,54 @@ export function openStore(dbPath: string): Store {
|
|
|
3351
4438
|
return selectRunsForIssue.all(project, issue).map(toRecord);
|
|
3352
4439
|
},
|
|
3353
4440
|
|
|
4441
|
+
/**
|
|
4442
|
+
* Spends the one model escalation this issue chain is allowed (#807), and
|
|
4443
|
+
* everything that makes it true: the marker, the `swapFrom` → `queueLabel`
|
|
4444
|
+
* transition that hands the continuation back, and the settling run's
|
|
4445
|
+
* recovery stamp — one transaction, so a restart never finds a chain that
|
|
4446
|
+
* reads as escalated with no continuation owed.
|
|
4447
|
+
*
|
|
4448
|
+
* `true` means this call spent it; `false` means the chain had already
|
|
4449
|
+
* escalated and nothing was written, which is the second-cap decomposition
|
|
4450
|
+
* verdict. Throws when `runId` names no row: the marker rolls back with it
|
|
4451
|
+
* rather than recording an escalation nothing can spend.
|
|
4452
|
+
*/
|
|
4453
|
+
escalateModel(escalation: {
|
|
4454
|
+
project: string;
|
|
4455
|
+
issue: number;
|
|
4456
|
+
model: string;
|
|
4457
|
+
failureClass: FailureClass;
|
|
4458
|
+
runId: string;
|
|
4459
|
+
swapFrom: string;
|
|
4460
|
+
queueLabel: string;
|
|
4461
|
+
at?: number;
|
|
4462
|
+
}): boolean {
|
|
4463
|
+
return escalateModelTx(
|
|
4464
|
+
escalation.project,
|
|
4465
|
+
escalation.issue,
|
|
4466
|
+
escalation.model,
|
|
4467
|
+
escalation.failureClass,
|
|
4468
|
+
escalation.runId,
|
|
4469
|
+
escalation.swapFrom,
|
|
4470
|
+
escalation.queueLabel,
|
|
4471
|
+
escalation.at ?? Date.now(),
|
|
4472
|
+
);
|
|
4473
|
+
},
|
|
4474
|
+
|
|
4475
|
+
/** The escalation this chain has already spent, or `undefined` for a chain
|
|
4476
|
+
* that never capped — which is what a fresh issue always reads. */
|
|
4477
|
+
modelEscalation(project: string, issue: number): ModelEscalation | undefined {
|
|
4478
|
+
const row = selectModelEscalation.get(project, issue);
|
|
4479
|
+
return row === null
|
|
4480
|
+
? undefined
|
|
4481
|
+
: {
|
|
4482
|
+
model: row.model,
|
|
4483
|
+
failureClass: row.failureClass as FailureClass,
|
|
4484
|
+
runId: row.runId,
|
|
4485
|
+
at: row.at,
|
|
4486
|
+
};
|
|
4487
|
+
},
|
|
4488
|
+
|
|
3354
4489
|
setTurnOverride(project: string, issue: number, maxTurns: number, setAt = Date.now()): void {
|
|
3355
4490
|
setTurnOverrideTx(project, issue, maxTurns, setAt);
|
|
3356
4491
|
},
|
|
@@ -3383,6 +4518,10 @@ export function openStore(dbPath: string): Store {
|
|
|
3383
4518
|
return sumSpendSince.get(project, sinceEpochMs)?.total ?? 0;
|
|
3384
4519
|
},
|
|
3385
4520
|
|
|
4521
|
+
reservedSpendUsd(project: string): number {
|
|
4522
|
+
return sumLiveReservations.get(project, ...LIVE_STATES)?.total ?? 0;
|
|
4523
|
+
},
|
|
4524
|
+
|
|
3386
4525
|
statsRuns(project: string, sinceEpochMs: number): RunRecord[] {
|
|
3387
4526
|
return selectStatsRuns.all(project, sinceEpochMs, project, sinceEpochMs).map(toRecord);
|
|
3388
4527
|
},
|
|
@@ -3524,6 +4663,32 @@ export function openStore(dbPath: string): Store {
|
|
|
3524
4663
|
}));
|
|
3525
4664
|
},
|
|
3526
4665
|
|
|
4666
|
+
recordInstallSurfaces(observation: InstallSurfaceObservation): void {
|
|
4667
|
+
upsertInstallSurfaces.run(
|
|
4668
|
+
observation.at,
|
|
4669
|
+
observation.cliVersion,
|
|
4670
|
+
observation.ompVersion ?? null,
|
|
4671
|
+
observation.herdrSource ?? null,
|
|
4672
|
+
observation.telegramInstalled ?? null,
|
|
4673
|
+
observation.telegramDaemon ?? null,
|
|
4674
|
+
observation.telegramPublished ?? null,
|
|
4675
|
+
);
|
|
4676
|
+
},
|
|
4677
|
+
|
|
4678
|
+
installSurfaces(): InstallSurfaceObservation | undefined {
|
|
4679
|
+
const row = selectInstallSurfaces.get();
|
|
4680
|
+
if (row === null) return undefined;
|
|
4681
|
+
return {
|
|
4682
|
+
at: row.at,
|
|
4683
|
+
cliVersion: row.cliVersion,
|
|
4684
|
+
...(row.ompVersion === null ? {} : { ompVersion: row.ompVersion }),
|
|
4685
|
+
...(row.herdrSource === null ? {} : { herdrSource: row.herdrSource }),
|
|
4686
|
+
...(row.tgInstalled === null ? {} : { telegramInstalled: row.tgInstalled }),
|
|
4687
|
+
...(row.tgDaemon === null ? {} : { telegramDaemon: row.tgDaemon }),
|
|
4688
|
+
...(row.tgPublished === null ? {} : { telegramPublished: row.tgPublished }),
|
|
4689
|
+
};
|
|
4690
|
+
},
|
|
4691
|
+
|
|
3527
4692
|
recordMaterialEvent(event: MaterialEventDraft): MaterialEvent {
|
|
3528
4693
|
const record: MaterialEvent = {
|
|
3529
4694
|
id: newReportId(),
|
|
@@ -3829,45 +4994,36 @@ export function openStore(dbPath: string): Store {
|
|
|
3829
4994
|
return reclassifyToInfra.run(id).changes > 0;
|
|
3830
4995
|
},
|
|
3831
4996
|
|
|
4997
|
+
recentSpendSamples(project: string, limit: number): { turns: number; spendUsd: number }[] {
|
|
4998
|
+
return selectSpendSamples.all(project, limit).map((row) => ({ ...row }));
|
|
4999
|
+
},
|
|
5000
|
+
|
|
3832
5001
|
failureClassCounts(project: string): { cls: FailureClass; n: number }[] {
|
|
3833
5002
|
return selectFailureClassCounts
|
|
3834
5003
|
.all(project)
|
|
3835
5004
|
.map((row) => ({ cls: row.cls as FailureClass, n: row.n }));
|
|
3836
5005
|
},
|
|
3837
5006
|
|
|
5007
|
+
recordedOnlyClassCounts(project: string): { cls: FailureClass; n: number }[] {
|
|
5008
|
+
return selectRecordedOnlyClassCounts
|
|
5009
|
+
.all(project)
|
|
5010
|
+
.map((row) => ({ cls: row.cls as FailureClass, n: row.n }));
|
|
5011
|
+
},
|
|
5012
|
+
|
|
3838
5013
|
recoveredSince(project: string, since: number): RunRecord[] {
|
|
3839
5014
|
return selectRecoveredSince.all(project, since).map(toRecord);
|
|
3840
5015
|
},
|
|
3841
5016
|
|
|
3842
5017
|
createDecision(draft: DecisionDraft): DecisionRecord {
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
// and expiring it silently could drop a release the day its condition
|
|
3853
|
-
// finally fires, so it is never due.
|
|
3854
|
-
expiresAt: kind === "watch" ? DECISION_WATCH_NEVER_EXPIRES : draft.at + DECISION_TTL_MS,
|
|
3855
|
-
state: "open",
|
|
3856
|
-
};
|
|
3857
|
-
if (draft.blocks !== undefined) record.blocks = draft.blocks;
|
|
3858
|
-
if (draft.condition !== undefined) record.condition = draft.condition;
|
|
3859
|
-
insertDecision.run(
|
|
3860
|
-
record.id,
|
|
3861
|
-
record.project,
|
|
3862
|
-
record.kind,
|
|
3863
|
-
record.question,
|
|
3864
|
-
toSql(record.blocks),
|
|
3865
|
-
record.askedAt,
|
|
3866
|
-
record.expiresAt,
|
|
3867
|
-
toSql(record.condition),
|
|
3868
|
-
record.state,
|
|
3869
|
-
);
|
|
3870
|
-
return record;
|
|
5018
|
+
return writeDecision(draft);
|
|
5019
|
+
},
|
|
5020
|
+
|
|
5021
|
+
createDecisionGroup(drafts: readonly DecisionDraft[], specIssue: number): DecisionRecord[] {
|
|
5022
|
+
return createDecisionGroupTx(drafts, specIssue);
|
|
5023
|
+
},
|
|
5024
|
+
|
|
5025
|
+
decisionGroup(groupId: string): DecisionRecord[] {
|
|
5026
|
+
return selectDecisionGroup.all(groupId).map(toDecision);
|
|
3871
5027
|
},
|
|
3872
5028
|
|
|
3873
5029
|
openDecisions(project: string): DecisionRecord[] {
|
|
@@ -3884,11 +5040,14 @@ export function openStore(dbPath: string): Store {
|
|
|
3884
5040
|
},
|
|
3885
5041
|
|
|
3886
5042
|
markDecisionConditionMet(id: string, at: number, head?: string): boolean {
|
|
3887
|
-
|
|
5043
|
+
// The window is computed from the observation instant, not from `now()`,
|
|
5044
|
+
// so a pass that observed a condition and wrote it a second later cannot
|
|
5045
|
+
// give the row a second longer to live.
|
|
5046
|
+
return markConditionMetRow.run(at, toSql(head), at + DECISION_TTL_MS, id).changes > 0;
|
|
3888
5047
|
},
|
|
3889
5048
|
|
|
3890
5049
|
clearDecisionConditionMet(id: string): boolean {
|
|
3891
|
-
return clearConditionMetRow.run(id).changes > 0;
|
|
5050
|
+
return clearConditionMetRow.run(DECISION_WATCH_NEVER_EXPIRES, id).changes > 0;
|
|
3892
5051
|
},
|
|
3893
5052
|
|
|
3894
5053
|
expireDueDecisions(project: string, now: number): DecisionRecord[] {
|
|
@@ -4046,6 +5205,21 @@ export function openStore(dbPath: string): Store {
|
|
|
4046
5205
|
reconcileGroomingTx(project, holds);
|
|
4047
5206
|
},
|
|
4048
5207
|
|
|
5208
|
+
retireGroomingNotOpen(project: string, openIssues: readonly number[]): number[] {
|
|
5209
|
+
// The caller owns "is this snapshot complete"; this owns "which rows does
|
|
5210
|
+
// it condemn". Computed here rather than in SQL so the returned list is
|
|
5211
|
+
// exactly what was deleted, which is what the log line reports.
|
|
5212
|
+
const open = new Set(openIssues);
|
|
5213
|
+
const condemned = selectGroomingAll
|
|
5214
|
+
.all(project)
|
|
5215
|
+
.map(toGrooming)
|
|
5216
|
+
.filter((row) => !open.has(row.issue))
|
|
5217
|
+
.map((row) => row.issue);
|
|
5218
|
+
if (condemned.length === 0) return [];
|
|
5219
|
+
retireGroomingTx(project, condemned);
|
|
5220
|
+
return condemned;
|
|
5221
|
+
},
|
|
5222
|
+
|
|
4049
5223
|
close(): void {
|
|
4050
5224
|
db.close(false);
|
|
4051
5225
|
},
|