omp-conductor 0.18.0 → 0.18.2
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 +35 -1
- package/REFERENCE.md +61 -11
- package/agents/to-spec.md +94 -0
- package/package.json +2 -1
- package/schema/config.schema.json +35 -1
- package/src/admission.ts +204 -75
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +62 -21
- package/src/briefs/to-spec.md +88 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +124 -1
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +38 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/intake.ts +4 -19
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +51 -16
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +43 -6
- package/src/config.ts +65 -9
- package/src/daemon.ts +879 -41
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +243 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +60 -82
- package/src/escalate.ts +31 -14
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +239 -240
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +242 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +183 -21
- package/src/orchestrator-tick.ts +1591 -32
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +65 -6
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1225 -9
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +154 -3
- package/src/setup.ts +83 -17
- package/src/shell.ts +15 -0
- package/src/status-render.ts +216 -12
- package/src/store.ts +443 -42
- package/src/to-spec.ts +408 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +405 -19
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +765 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +12 -2
- package/src/worktree.ts +29 -12
package/src/store.ts
CHANGED
|
@@ -27,10 +27,12 @@ import { DECISION_TTL_MS, DEFAULT_CAPS, DIGEST_BACKLOG_LIMIT } from "./types.ts"
|
|
|
27
27
|
const DECISION_WATCH_NEVER_EXPIRES = Number.MAX_SAFE_INTEGER;
|
|
28
28
|
import { dispatchInfra, neverStarted } from "./failure-class.ts";
|
|
29
29
|
import type {
|
|
30
|
+
AdmissionHoldReason,
|
|
30
31
|
BaseFreeze,
|
|
31
32
|
BaseHealth,
|
|
32
33
|
DecisionDraft,
|
|
33
34
|
FailureClass,
|
|
35
|
+
FileLane,
|
|
34
36
|
DecisionRecord,
|
|
35
37
|
DecisionState,
|
|
36
38
|
DigestBacklog,
|
|
@@ -41,6 +43,10 @@ import type {
|
|
|
41
43
|
FrictionKind,
|
|
42
44
|
FrictionObservation,
|
|
43
45
|
FrictionSignal,
|
|
46
|
+
GraphToolsObservation,
|
|
47
|
+
GroomingDraft,
|
|
48
|
+
GroomingRecord,
|
|
49
|
+
GroomingVerdict,
|
|
44
50
|
HistoricalInfraCandidate,
|
|
45
51
|
HeldNotice,
|
|
46
52
|
InterruptCategory,
|
|
@@ -62,6 +68,7 @@ import type {
|
|
|
62
68
|
ReportRecord,
|
|
63
69
|
RecoveryAction,
|
|
64
70
|
ReviewReason,
|
|
71
|
+
ReviewRevisionEnqueue,
|
|
65
72
|
ReviewRevisionOutcome,
|
|
66
73
|
ReviewRevisionRecord,
|
|
67
74
|
RunPatch,
|
|
@@ -120,6 +127,18 @@ function isFrictionHoldReason(reason: string): reason is FrictionAdmissionReason
|
|
|
120
127
|
return FRICTION_HOLD_REASONS.has(reason as FrictionAdmissionReason);
|
|
121
128
|
}
|
|
122
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The admission hold reasons the grooming store persists as `blocked` verdicts
|
|
132
|
+
* (#735): the mechanical holds that will clear by themselves once the lane
|
|
133
|
+
* frees or the dependency closes. Every other hold reason is flow control of
|
|
134
|
+
* the moment (capacity, spend, parking) rather than durable per-issue
|
|
135
|
+
* inventory, so admission's reconcile records and clears exactly these.
|
|
136
|
+
*/
|
|
137
|
+
const BLOCKED_GROOMING_HOLDS: Record<string, true> = {
|
|
138
|
+
"file-lane": true,
|
|
139
|
+
"depends-on": true,
|
|
140
|
+
};
|
|
141
|
+
|
|
123
142
|
/**
|
|
124
143
|
* Allowlist for `updateRun`'s dynamic SET clause. Column names cannot be bound
|
|
125
144
|
* as parameters, so they are matched against this table rather than
|
|
@@ -147,6 +166,7 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
147
166
|
autoCompactionCount: true,
|
|
148
167
|
sessionFile: true,
|
|
149
168
|
resumedFromRunId: true,
|
|
169
|
+
lane: true,
|
|
150
170
|
prUrl: true,
|
|
151
171
|
headSha: true,
|
|
152
172
|
mergeSha: true,
|
|
@@ -166,6 +186,9 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
166
186
|
recoveryAction: true,
|
|
167
187
|
recoveredAt: true,
|
|
168
188
|
model: true,
|
|
189
|
+
graphTools: true,
|
|
190
|
+
failureCharges: true,
|
|
191
|
+
continuationCharges: true,
|
|
169
192
|
};
|
|
170
193
|
|
|
171
194
|
/** Everything SQLite will accept from us. */
|
|
@@ -194,6 +217,7 @@ interface RunRow {
|
|
|
194
217
|
autoCompactionCount: number | null;
|
|
195
218
|
sessionFile: string | null;
|
|
196
219
|
resumedFromRunId: string | null;
|
|
220
|
+
lane: string | null;
|
|
197
221
|
prUrl: string | null;
|
|
198
222
|
headSha: string | null;
|
|
199
223
|
mergeSha: string | null;
|
|
@@ -213,6 +237,9 @@ interface RunRow {
|
|
|
213
237
|
recoveryAction: string | null;
|
|
214
238
|
recoveredAt: number | null;
|
|
215
239
|
model: string | null;
|
|
240
|
+
graphTools: string | null;
|
|
241
|
+
failureCharges: number | null;
|
|
242
|
+
continuationCharges: number | null;
|
|
216
243
|
}
|
|
217
244
|
|
|
218
245
|
/** The `base_health` table exactly as SQLite hands it back. */
|
|
@@ -440,6 +467,27 @@ function toIntakeItem(row: IntakeRow): IntakeItem {
|
|
|
440
467
|
return item;
|
|
441
468
|
}
|
|
442
469
|
|
|
470
|
+
/** The `grooming` table exactly as SQLite hands it back (#735). */
|
|
471
|
+
interface GroomingRow {
|
|
472
|
+
project: string;
|
|
473
|
+
issue: number;
|
|
474
|
+
verdict: string;
|
|
475
|
+
reason: string;
|
|
476
|
+
evidence: string;
|
|
477
|
+
recordedAt: number;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function toGrooming(row: GroomingRow): GroomingRecord {
|
|
481
|
+
return {
|
|
482
|
+
project: row.project,
|
|
483
|
+
issue: row.issue,
|
|
484
|
+
verdict: row.verdict as GroomingVerdict,
|
|
485
|
+
reason: row.reason,
|
|
486
|
+
evidence: row.evidence,
|
|
487
|
+
recordedAt: row.recordedAt,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
443
491
|
/** The `orchestrator_incidents` table exactly as SQLite hands it back (#288). */
|
|
444
492
|
interface OrchestratorIncidentRow {
|
|
445
493
|
project: string;
|
|
@@ -522,6 +570,7 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
522
570
|
autoCompactionCount INTEGER,
|
|
523
571
|
sessionFile TEXT,
|
|
524
572
|
resumedFromRunId TEXT,
|
|
573
|
+
lane TEXT,
|
|
525
574
|
prUrl TEXT,
|
|
526
575
|
headSha TEXT,
|
|
527
576
|
mergeSha TEXT,
|
|
@@ -539,7 +588,10 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
539
588
|
failureClass TEXT,
|
|
540
589
|
recoveryAction TEXT,
|
|
541
590
|
recoveredAt INTEGER,
|
|
542
|
-
model TEXT
|
|
591
|
+
model TEXT,
|
|
592
|
+
graphTools TEXT,
|
|
593
|
+
failureCharges INTEGER,
|
|
594
|
+
continuationCharges INTEGER
|
|
543
595
|
);
|
|
544
596
|
CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
|
|
545
597
|
CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
|
|
@@ -785,6 +837,15 @@ CREATE TABLE IF NOT EXISTS review_revisions (
|
|
|
785
837
|
);
|
|
786
838
|
CREATE INDEX IF NOT EXISTS review_revisions_pending
|
|
787
839
|
ON review_revisions (project, dispatchedAt, runId);
|
|
840
|
+
-- One pending revision per run, enforced in the schema itself: the
|
|
841
|
+
-- schema-level backstop for the enqueue transaction (#786). The transaction
|
|
842
|
+
-- begins IMMEDIATE, so two connections serialize on the write lock before
|
|
843
|
+
-- either reads — this index makes a second pending row for one run
|
|
844
|
+
-- structurally impossible even if some other write path ever bypassed that
|
|
845
|
+
-- transaction.
|
|
846
|
+
CREATE UNIQUE INDEX IF NOT EXISTS review_revisions_one_pending_per_run
|
|
847
|
+
ON review_revisions (runId)
|
|
848
|
+
WHERE dispatchedAt IS NULL AND settledAt IS NULL;
|
|
788
849
|
|
|
789
850
|
-- Questions the orchestrator has put to its operator, and their answers (#136).
|
|
790
851
|
--
|
|
@@ -799,6 +860,12 @@ CREATE INDEX IF NOT EXISTS review_revisions_pending
|
|
|
799
860
|
-- stored as the raw string an operator or session wrote, parsed on read. Once
|
|
800
861
|
-- met, 'conditionMetAt' is what turns a parked question into one the tick digest
|
|
801
862
|
-- pushes at the session.
|
|
863
|
+
--
|
|
864
|
+
-- 'conditionHead' binds a pr-checks-green / pr-review-ready verdict to the
|
|
865
|
+
-- exact PR head it was observed at (#808). The checks surface answers per
|
|
866
|
+
-- commit, so once the head changes the old verdict says nothing about the new
|
|
867
|
+
-- head: the evaluator clears the met state on a head move and the row returns
|
|
868
|
+
-- to pending.
|
|
802
869
|
CREATE TABLE IF NOT EXISTS decisions (
|
|
803
870
|
id TEXT PRIMARY KEY,
|
|
804
871
|
project TEXT NOT NULL,
|
|
@@ -809,12 +876,32 @@ CREATE TABLE IF NOT EXISTS decisions (
|
|
|
809
876
|
expiresAt INTEGER NOT NULL,
|
|
810
877
|
condition TEXT,
|
|
811
878
|
conditionMetAt INTEGER,
|
|
879
|
+
conditionHead TEXT,
|
|
812
880
|
state TEXT NOT NULL,
|
|
813
881
|
resolvedAt INTEGER,
|
|
814
882
|
resolution TEXT
|
|
815
883
|
);
|
|
816
884
|
CREATE INDEX IF NOT EXISTS decisions_project_state ON decisions (project, state);
|
|
817
885
|
|
|
886
|
+
-- One issue's current grooming verdict (#735), the durable answer to "has
|
|
887
|
+
-- this been groomed, and what was decided" that the tick reads instead of
|
|
888
|
+
-- re-deriving why a runway cannot move. Admission's lane/dependency holds land
|
|
889
|
+
-- here as blocked (self-clearing on the next pass when the hold no longer
|
|
890
|
+
-- applies); #679's scout loop writes promotable and considered into the
|
|
891
|
+
-- same table. Keyed by project + issue, one row per issue, replaced in place.
|
|
892
|
+
-- Deliberately separate from decisions (operator questions, 7-day expiry) and
|
|
893
|
+
-- material_events (append-only digest outbox).
|
|
894
|
+
CREATE TABLE IF NOT EXISTS grooming (
|
|
895
|
+
project TEXT NOT NULL,
|
|
896
|
+
issue INTEGER NOT NULL CHECK (issue > 0),
|
|
897
|
+
verdict TEXT NOT NULL,
|
|
898
|
+
reason TEXT NOT NULL,
|
|
899
|
+
evidence TEXT NOT NULL,
|
|
900
|
+
recordedAt INTEGER NOT NULL CHECK (recordedAt >= 0),
|
|
901
|
+
PRIMARY KEY (project, issue)
|
|
902
|
+
);
|
|
903
|
+
CREATE INDEX IF NOT EXISTS grooming_project_verdict ON grooming (project, verdict);
|
|
904
|
+
|
|
818
905
|
-- GitHub rate-limit refusals the tracker observed (#198). Written by the
|
|
819
906
|
-- daemon's tracker hook, not polled, so status can show what GitHub actually
|
|
820
907
|
-- refused next to the polled budget that might still look healthy. Only one
|
|
@@ -918,12 +1005,14 @@ CREATE INDEX IF NOT EXISTS daemon_stops_at ON daemon_stops (at);
|
|
|
918
1005
|
function toSql(value: unknown): SqlValue {
|
|
919
1006
|
if (value === undefined || value === null) return null;
|
|
920
1007
|
if (typeof value === "boolean") return value ? 1 : 0;
|
|
921
|
-
// `RunRecord.settlementFlags`
|
|
922
|
-
//
|
|
923
|
-
// 1:1 with the run, written once at settlement, and
|
|
924
|
-
// the row they belong to
|
|
925
|
-
//
|
|
926
|
-
|
|
1008
|
+
// `RunRecord.settlementFlags` and `RunRecord.lane` are the structured fields
|
|
1009
|
+
// on a run row, and each is JSON in one TEXT column rather than a table of
|
|
1010
|
+
// its own: the flags are 1:1 with the run, written once at settlement, and
|
|
1011
|
+
// never read except beside the row they belong to (#128); the lane is the
|
|
1012
|
+
// enforced declaration, written once at dispatch and read by the admission
|
|
1013
|
+
// gate beside the row that owns it (#744). A join per read would buy a query
|
|
1014
|
+
// nobody makes.
|
|
1015
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
927
1016
|
return value as SqlValue;
|
|
928
1017
|
}
|
|
929
1018
|
|
|
@@ -976,6 +1065,55 @@ function toSettlementFlags(text: string): SettlementFlag[] | undefined {
|
|
|
976
1065
|
}
|
|
977
1066
|
}
|
|
978
1067
|
|
|
1068
|
+
/**
|
|
1069
|
+
* The persisted file lane out of its JSON column, or undefined for anything
|
|
1070
|
+
* that is not a `FileLane`-shaped object. Same defensive posture as
|
|
1071
|
+
* {@link toSettlementFlags}: a row hand-edited or written by a future version
|
|
1072
|
+
* drops its lane rather than throwing, and a dropped lane reads as "no
|
|
1073
|
+
* declaration" — the fail-open admission, never a refusal (#744).
|
|
1074
|
+
*/
|
|
1075
|
+
function toLane(text: string): FileLane | undefined {
|
|
1076
|
+
try {
|
|
1077
|
+
const value: unknown = JSON.parse(text);
|
|
1078
|
+
const files = (value as { files?: unknown }).files;
|
|
1079
|
+
const source = (value as { source?: unknown }).source;
|
|
1080
|
+
const at = (value as { at?: unknown }).at;
|
|
1081
|
+
if (
|
|
1082
|
+
typeof value !== "object" ||
|
|
1083
|
+
value === null ||
|
|
1084
|
+
!Array.isArray(files) ||
|
|
1085
|
+
files.some((file) => typeof file !== "string") ||
|
|
1086
|
+
typeof source !== "string" ||
|
|
1087
|
+
(at !== "body" && typeof at !== "number")
|
|
1088
|
+
) {
|
|
1089
|
+
return undefined;
|
|
1090
|
+
}
|
|
1091
|
+
return value as FileLane;
|
|
1092
|
+
} catch {
|
|
1093
|
+
return undefined;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* A run's graph-tools session observation out of its JSON column, or
|
|
1099
|
+
* undefined for anything that is not a `{ present, at }` object. Same
|
|
1100
|
+
* defensive posture as {@link toSettlementFlags}: a row hand-edited or written
|
|
1101
|
+
* by a future version drops its observation rather than throwing, and a
|
|
1102
|
+
* dropped observation reads as "no observation recorded" — never "graph tools
|
|
1103
|
+
* absent", which is the `present: false` truth value (#726).
|
|
1104
|
+
*/
|
|
1105
|
+
function toGraphTools(text: string): GraphToolsObservation | undefined {
|
|
1106
|
+
try {
|
|
1107
|
+
const value: unknown = JSON.parse(text);
|
|
1108
|
+
const present = (value as { present?: unknown } | null | undefined)?.present;
|
|
1109
|
+
const at = (value as { at?: unknown } | null | undefined)?.at;
|
|
1110
|
+
if (typeof present !== "boolean" || typeof at !== "number") return undefined;
|
|
1111
|
+
return { present, at };
|
|
1112
|
+
} catch {
|
|
1113
|
+
return undefined;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
979
1117
|
/**
|
|
980
1118
|
* A NULL column becomes an absent property rather than an `undefined` one, so
|
|
981
1119
|
* a record read back out of the store deep-equals the one that went in.
|
|
@@ -997,6 +1135,10 @@ function toRecord(row: RunRow): RunRecord {
|
|
|
997
1135
|
};
|
|
998
1136
|
if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
|
|
999
1137
|
if (row.resumedFromRunId !== null) record.resumedFromRunId = row.resumedFromRunId;
|
|
1138
|
+
if (row.lane !== null) {
|
|
1139
|
+
const lane = toLane(row.lane);
|
|
1140
|
+
if (lane !== undefined) record.lane = lane;
|
|
1141
|
+
}
|
|
1000
1142
|
if (row.prUrl !== null) record.prUrl = row.prUrl;
|
|
1001
1143
|
if (row.headSha !== null) record.headSha = row.headSha;
|
|
1002
1144
|
if (row.mergeSha !== null) record.mergeSha = row.mergeSha;
|
|
@@ -1028,7 +1170,15 @@ function toRecord(row: RunRow): RunRecord {
|
|
|
1028
1170
|
if (row.failureClass !== null) record.failureClass = row.failureClass as FailureClass;
|
|
1029
1171
|
if (row.recoveryAction !== null) record.recoveryAction = row.recoveryAction as RecoveryAction;
|
|
1030
1172
|
if (row.recoveredAt !== null) record.recoveredAt = row.recoveredAt;
|
|
1173
|
+
if (row.failureCharges !== null && row.failureCharges > 0) record.failureCharges = row.failureCharges;
|
|
1174
|
+
if (row.continuationCharges !== null && row.continuationCharges > 0) {
|
|
1175
|
+
record.continuationCharges = row.continuationCharges;
|
|
1176
|
+
}
|
|
1031
1177
|
if (row.model !== null) record.model = row.model;
|
|
1178
|
+
if (row.graphTools !== null) {
|
|
1179
|
+
const observed = toGraphTools(row.graphTools);
|
|
1180
|
+
if (observed !== undefined) record.graphTools = observed;
|
|
1181
|
+
}
|
|
1032
1182
|
return record;
|
|
1033
1183
|
}
|
|
1034
1184
|
|
|
@@ -1092,6 +1242,7 @@ interface DecisionRow {
|
|
|
1092
1242
|
expiresAt: number;
|
|
1093
1243
|
condition: string | null;
|
|
1094
1244
|
conditionMetAt: number | null;
|
|
1245
|
+
conditionHead: string | null;
|
|
1095
1246
|
state: string;
|
|
1096
1247
|
resolvedAt: number | null;
|
|
1097
1248
|
resolution: string | null;
|
|
@@ -1114,6 +1265,7 @@ function toDecision(row: DecisionRow): DecisionRecord {
|
|
|
1114
1265
|
if (row.blocks !== null) record.blocks = row.blocks;
|
|
1115
1266
|
if (row.condition !== null) record.condition = row.condition;
|
|
1116
1267
|
if (row.conditionMetAt !== null) record.conditionMetAt = row.conditionMetAt;
|
|
1268
|
+
if (row.conditionHead !== null) record.conditionHead = row.conditionHead;
|
|
1117
1269
|
if (row.resolvedAt !== null) record.resolvedAt = row.resolvedAt;
|
|
1118
1270
|
if (row.resolution !== null) record.resolution = row.resolution;
|
|
1119
1271
|
return record;
|
|
@@ -1501,6 +1653,13 @@ export function openStore(dbPath: string): Store {
|
|
|
1501
1653
|
if (!columns.some((column) => column.name === "model")) {
|
|
1502
1654
|
db.exec("ALTER TABLE runs ADD COLUMN model TEXT");
|
|
1503
1655
|
}
|
|
1656
|
+
// The code-graph session observation (#726): rows written before the column
|
|
1657
|
+
// existed were never observed, so NULL is the honest reading of "no
|
|
1658
|
+
// observation recorded" — never "graph tools absent", which is the
|
|
1659
|
+
// `present: false` truth value of a run that did record one.
|
|
1660
|
+
if (!columns.some((column) => column.name === "graphTools")) {
|
|
1661
|
+
db.exec("ALTER TABLE runs ADD COLUMN graphTools TEXT");
|
|
1662
|
+
}
|
|
1504
1663
|
// Resume provenance (#567): rows written before #564 landed were never
|
|
1505
1664
|
// resumed, so NULL is the honest reading of "no resume happened" — never
|
|
1506
1665
|
// "resumed from unknown". No backfill: the identity existed only in the
|
|
@@ -1508,6 +1667,15 @@ export function openStore(dbPath: string): Store {
|
|
|
1508
1667
|
if (!columns.some((column) => column.name === "resumedFromRunId")) {
|
|
1509
1668
|
db.exec("ALTER TABLE runs ADD COLUMN resumedFromRunId TEXT");
|
|
1510
1669
|
}
|
|
1670
|
+
// The declared file lane (#744): rows written before the column existed were
|
|
1671
|
+
// admitted before the declaration was persisted, so NULL is the honest
|
|
1672
|
+
// reading of "no declaration survives on this row" — the same fail-open as
|
|
1673
|
+
// a run admitted without a lane today. No backfill: re-parsing issue bodies
|
|
1674
|
+
// now would re-derive a snapshot admission no longer enforces, which is
|
|
1675
|
+
// exactly the #288 shape the gate exists to avoid.
|
|
1676
|
+
if (!columns.some((column) => column.name === "lane")) {
|
|
1677
|
+
db.exec("ALTER TABLE runs ADD COLUMN lane TEXT");
|
|
1678
|
+
}
|
|
1511
1679
|
// The count of in-session provider 429s a run recorded was first written by
|
|
1512
1680
|
// the worker (#573). Rows predating the column are NULL, which is the honest
|
|
1513
1681
|
// reading: the count was not recorded, so the classifier must not treat the
|
|
@@ -1532,6 +1700,18 @@ export function openStore(dbPath: string): Store {
|
|
|
1532
1700
|
db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
|
|
1533
1701
|
}
|
|
1534
1702
|
}
|
|
1703
|
+
// The preserved budget charges (#795 review round 2): rows written before the
|
|
1704
|
+
// columns existed were never returned for revision from a terminal state, so
|
|
1705
|
+
// NULL is the honest reading of "no review claim preserved a charge on this
|
|
1706
|
+
// row", and the counters treat it as zero.
|
|
1707
|
+
for (const [name, type] of [
|
|
1708
|
+
["failureCharges", "INTEGER"],
|
|
1709
|
+
["continuationCharges", "INTEGER"],
|
|
1710
|
+
] as const) {
|
|
1711
|
+
if (!columns.some((column) => column.name === name)) {
|
|
1712
|
+
db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1535
1715
|
// Existing held-notice rows predate exact digest ownership (#274). A NULL
|
|
1536
1716
|
// report id is truthful for already-digested history and means "still owed"
|
|
1537
1717
|
// only while digestedAt is also NULL.
|
|
@@ -1574,6 +1754,14 @@ export function openStore(dbPath: string): Store {
|
|
|
1574
1754
|
if (!decisionColumns.some((column) => column.name === "kind")) {
|
|
1575
1755
|
db.exec("ALTER TABLE decisions ADD COLUMN kind TEXT NOT NULL DEFAULT 'question'");
|
|
1576
1756
|
}
|
|
1757
|
+
// The head binding (#808): rows written before it existed — met or not —
|
|
1758
|
+
// carry no binding and read back without one. Nothing is backfilled: fabricating
|
|
1759
|
+
// a head for an observation made before heads were recorded would be guessing
|
|
1760
|
+
// at history. The evaluator re-binds such a row on the next pass from a live
|
|
1761
|
+
// read, never from a migration guess.
|
|
1762
|
+
if (!decisionColumns.some((column) => column.name === "conditionHead")) {
|
|
1763
|
+
db.exec("ALTER TABLE decisions ADD COLUMN conditionHead TEXT");
|
|
1764
|
+
}
|
|
1577
1765
|
|
|
1578
1766
|
// One-time repair, and the reason it lives here rather than in the classifier:
|
|
1579
1767
|
// a turn-0 environment fault that was ALREADY classified `unknown` and
|
|
@@ -1667,12 +1855,15 @@ export function openStore(dbPath: string): Store {
|
|
|
1667
1855
|
const insertRun = db.query<unknown, SqlValue[]>(
|
|
1668
1856
|
`INSERT INTO runs (
|
|
1669
1857
|
id, project, issue, repo, branch, worktree, state, attempt, turns,
|
|
1670
|
-
maxTurns, spendUsd, sessionFile, resumedFromRunId, prUrl, headSha, mergeSha, baseRef,
|
|
1858
|
+
maxTurns, spendUsd, sessionFile, resumedFromRunId, lane, prUrl, headSha, mergeSha, baseRef,
|
|
1671
1859
|
baseCheck, baseCheckAt, salvageSha, salvageError, salvageAckAt, startedAt,
|
|
1672
|
-
endedAt, lastError, settlementFlags, report
|
|
1673
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1860
|
+
endedAt, lastError, settlementFlags, report, graphTools
|
|
1861
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1674
1862
|
);
|
|
1675
1863
|
const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
|
|
1864
|
+
const selectGraphToolsObs = db.query<{ graphTools: string }, [string]>(
|
|
1865
|
+
`SELECT graphTools FROM runs WHERE project = ? AND graphTools IS NOT NULL`,
|
|
1866
|
+
);
|
|
1676
1867
|
const selectActive = db.query<RunRow, SqlValue[]>(
|
|
1677
1868
|
`SELECT * FROM runs
|
|
1678
1869
|
WHERE project = ? AND state IN (${ACTIVE_PLACEHOLDERS})
|
|
@@ -1805,19 +1996,43 @@ export function openStore(dbPath: string): Store {
|
|
|
1805
1996
|
// fleet #350 spent its whole continuation budget on causes like those and then
|
|
1806
1997
|
// sat in admission hold for six dispatch cycles. An unclassified row (NULL) is
|
|
1807
1998
|
// counted exactly as before, so upgrading changes no existing budget.
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
const
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1999
|
+
// The classes a terminal row must NOT carry to count as a charged failed
|
|
2000
|
+
// attempt or continuation — the budget counters' exclusions, shared verbatim
|
|
2001
|
+
// with the review claim that preserves a charge (#795): the claim evaluates
|
|
2002
|
+
// the same predicate against the pre-claim row, so a preserved charge can
|
|
2003
|
+
// never disagree with what the counters would have counted.
|
|
2004
|
+
const CHARGEABLE_FAILURE_EXCLUSIONS =
|
|
2005
|
+
"'ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient', 'provider-capacity', 'returned-for-revision'";
|
|
2006
|
+
const CHARGEABLE_CONTINUATION_EXCLUSIONS =
|
|
2007
|
+
"'admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient', 'provider-capacity'";
|
|
2008
|
+
|
|
2009
|
+
// A row whose review revision preserved a charge (#795 review round 2) keeps
|
|
2010
|
+
// counting after the claim leaves it `pushed-green`. The preserved columns
|
|
2011
|
+
// are per-category INTEGER totals, summed here — not a single enum over
|
|
2012
|
+
// rows — so repeated same-category terminal events across consecutive review
|
|
2013
|
+
// rounds each keep their charge: a killed run contributes one continuation,
|
|
2014
|
+
// its review claim moves that charge into `continuationCharges`, and a
|
|
2015
|
+
// second cap on the resumed round is counted by the state branch on top of
|
|
2016
|
+
// it. The two sums can never double-count one event: a terminal event is
|
|
2017
|
+
// counted by the state branch only while the row is still terminal, and the
|
|
2018
|
+
// claim that flips it to `running` is the same atomic statement that adds it
|
|
2019
|
+
// to the preserved column.
|
|
2020
|
+
const countFailures = db.query<{ n: number }, SqlValue[]>(
|
|
2021
|
+
`SELECT
|
|
2022
|
+
(SELECT COALESCE(SUM(failureCharges), 0) FROM runs WHERE project = ? AND issue = ?)
|
|
2023
|
+
+ (SELECT COUNT(*) FROM runs WHERE project = ? AND issue = ?
|
|
2024
|
+
AND state = 'failed'
|
|
2025
|
+
AND (failureClass IS NULL OR failureClass NOT IN (${CHARGEABLE_FAILURE_EXCLUSIONS}))) AS n`,
|
|
2026
|
+
);
|
|
2027
|
+
const countContinuations = db.query<{ n: number }, SqlValue[]>(
|
|
2028
|
+
`SELECT
|
|
2029
|
+
(SELECT COALESCE(SUM(continuationCharges), 0) FROM runs WHERE project = ? AND issue = ?)
|
|
2030
|
+
+ (SELECT COUNT(*) FROM runs WHERE project = ? AND issue = ?
|
|
2031
|
+
AND (
|
|
2032
|
+
(state IN ('killed', 'orphaned', 'blocked')
|
|
2033
|
+
AND (failureClass IS NULL OR failureClass NOT IN (${CHARGEABLE_CONTINUATION_EXCLUSIONS})))
|
|
2034
|
+
OR (state = 'failed' AND failureClass = 'returned-for-revision')
|
|
2035
|
+
)) AS n`,
|
|
1821
2036
|
);
|
|
1822
2037
|
// How many times one issue reached a given class. Recovery uses it to bound a
|
|
1823
2038
|
// retry loop whose cause is persistent (e.g. a mirror that will not refresh):
|
|
@@ -2029,6 +2244,44 @@ export function openStore(dbPath: string): Store {
|
|
|
2029
2244
|
const selectDispatch = db.query<{ summary: string }, [string]>(
|
|
2030
2245
|
`SELECT summary FROM dispatch_summaries WHERE project = ?`,
|
|
2031
2246
|
);
|
|
2247
|
+
const selectGrooming = db.query<GroomingRow, [string, number]>(
|
|
2248
|
+
`SELECT project, issue, verdict, reason, evidence, recordedAt
|
|
2249
|
+
FROM grooming WHERE project = ? AND issue = ?`,
|
|
2250
|
+
);
|
|
2251
|
+
const selectGroomingAll = db.query<GroomingRow, [string]>(
|
|
2252
|
+
`SELECT project, issue, verdict, reason, evidence, recordedAt
|
|
2253
|
+
FROM grooming WHERE project = ? ORDER BY issue ASC`,
|
|
2254
|
+
);
|
|
2255
|
+
const selectGroomingByVerdict = db.query<GroomingRow, [string, string]>(
|
|
2256
|
+
`SELECT project, issue, verdict, reason, evidence, recordedAt
|
|
2257
|
+
FROM grooming WHERE project = ? AND verdict = ? ORDER BY issue ASC`,
|
|
2258
|
+
);
|
|
2259
|
+
const upsertGroomingRow = db.query<unknown, [string, number, string, string, string, number]>(
|
|
2260
|
+
`INSERT INTO grooming (project, issue, verdict, reason, evidence, recordedAt)
|
|
2261
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
2262
|
+
ON CONFLICT(project, issue) DO UPDATE SET
|
|
2263
|
+
verdict = excluded.verdict,
|
|
2264
|
+
reason = excluded.reason,
|
|
2265
|
+
evidence = excluded.evidence,
|
|
2266
|
+
recordedAt = excluded.recordedAt`,
|
|
2267
|
+
);
|
|
2268
|
+
const clearGroomedBlocked = db.query<unknown, [string]>(
|
|
2269
|
+
`DELETE FROM grooming
|
|
2270
|
+
WHERE project = ? AND verdict = 'blocked'
|
|
2271
|
+
AND reason IN ('file-lane', 'depends-on')`,
|
|
2272
|
+
);
|
|
2273
|
+
// One transaction, so a pass can never leave the blocked inventory half
|
|
2274
|
+
// written: the stale rows are cleared (the hold no longer applies) and the
|
|
2275
|
+
// current holds re-recorded atomically.
|
|
2276
|
+
const reconcileGroomingTx = db.transaction(
|
|
2277
|
+
(project: string, holds: readonly { issue: number; reason: AdmissionHoldReason; detail?: string }[]): void => {
|
|
2278
|
+
clearGroomedBlocked.run(project);
|
|
2279
|
+
for (const hold of holds) {
|
|
2280
|
+
if (BLOCKED_GROOMING_HOLDS[hold.reason] !== true) continue;
|
|
2281
|
+
upsertGroomingRow.run(project, hold.issue, "blocked", hold.reason, hold.detail ?? hold.reason, Date.now());
|
|
2282
|
+
}
|
|
2283
|
+
},
|
|
2284
|
+
);
|
|
2032
2285
|
const selectFrictionRollup = db.query<FrictionRollupRow, [string, string, string]>(
|
|
2033
2286
|
`SELECT * FROM friction_rollups WHERE project = ? AND day = ? AND kind = ?`,
|
|
2034
2287
|
);
|
|
@@ -2535,12 +2788,24 @@ export function openStore(dbPath: string): Store {
|
|
|
2535
2788
|
`UPDATE decisions SET state = 'expired', resolvedAt = ?, resolution = 'expired unanswered'
|
|
2536
2789
|
WHERE project = ? AND state = 'open' AND expiresAt <= ?`,
|
|
2537
2790
|
);
|
|
2538
|
-
// Set once: a condition that flickers must not keep moving its own
|
|
2539
|
-
// or the digest's "act on this now" would reset to looking new
|
|
2540
|
-
|
|
2541
|
-
|
|
2791
|
+
// Set once per head: a condition that flickers must not keep moving its own
|
|
2792
|
+
// timestamp, or the digest's "act on this now" would reset to looking new
|
|
2793
|
+
// every tick. A `pr-checks-green` / `pr-review-ready` row whose PR head
|
|
2794
|
+
// moved is cleared first and re-marks here against the new head (`head`
|
|
2795
|
+
// binds the verdict to the exact commit it was observed at, #808).
|
|
2796
|
+
const markConditionMetRow = db.query<never, [number, SqlValue, string]>(
|
|
2797
|
+
`UPDATE decisions SET conditionMetAt = ?, conditionHead = ?
|
|
2542
2798
|
WHERE id = ? AND state = 'open' AND conditionMetAt IS NULL`,
|
|
2543
2799
|
);
|
|
2800
|
+
// The inverse of the mark, for the two conditions whose observation goes
|
|
2801
|
+
// stale: a `pr-checks-green` / `pr-review-ready` binding is about a commit,
|
|
2802
|
+
// so a head change drops both the timestamp and the binding and the row
|
|
2803
|
+
// returns to pending (#808). Returns false when no met state existed to
|
|
2804
|
+
// drop, so re-runs are a no-op.
|
|
2805
|
+
const clearConditionMetRow = db.query<never, [string]>(
|
|
2806
|
+
`UPDATE decisions SET conditionMetAt = NULL, conditionHead = NULL
|
|
2807
|
+
WHERE id = ? AND state = 'open' AND conditionMetAt IS NOT NULL`,
|
|
2808
|
+
);
|
|
2544
2809
|
|
|
2545
2810
|
const insertVerbLedger = db.query<unknown, SqlValue[]>(
|
|
2546
2811
|
`INSERT INTO verb_ledger
|
|
@@ -2624,16 +2889,97 @@ export function openStore(dbPath: string): Store {
|
|
|
2624
2889
|
const requeueReviewRevisionRow = db.query<unknown, [string]>(
|
|
2625
2890
|
`UPDATE review_revisions SET dispatchedAt = NULL WHERE id = ?`,
|
|
2626
2891
|
);
|
|
2627
|
-
|
|
2892
|
+
// The states the review verb admits and the dispatch pass may therefore
|
|
2893
|
+
// claim (#795): a settled green run, or a capped/failed run that pushed one
|
|
2894
|
+
// (`failed` / `killed`). The terminal set is closed on purpose — a live row
|
|
2895
|
+
// (`running` / `claimed`) is already doing its own work, a `pushed-pending`
|
|
2896
|
+
// PR is not green yet, and a `blocked` / `orphaned` / `stopped` / `merged`
|
|
2897
|
+
// row is not work the orchestrator returned for revision.
|
|
2898
|
+
//
|
|
2899
|
+
// The terminal leg does two things in the same atomic statement (#795 review
|
|
2900
|
+
// rounds 1-2): it PRESERVES the budget charge the row's terminal event was
|
|
2901
|
+
// charging — added to a per-category INTEGER column, so repeated
|
|
2902
|
+
// same-category terminal events across consecutive review rounds each keep
|
|
2903
|
+
// their charge and `maxAttemptsPerIssue` / `maxContinuationsPerIssue` keep
|
|
2904
|
+
// bounding the lifecycle — and it CLEARS the lifecycle recovery metadata
|
|
2905
|
+
// (`failureClass` / `recoveryAction` / `recoveredAt`), so the resumed
|
|
2906
|
+
// execution's own terminal failure is classified and recovered fresh instead
|
|
2907
|
+
// of being excluded from the classification sweep by the original terminal's
|
|
2908
|
+
// recovered state. The state branch stops counting the row the instant this
|
|
2909
|
+
// statement flips it to `running`, and the preserved column starts counting
|
|
2910
|
+
// it in the same statement: one event, one count, never both.
|
|
2911
|
+
const claimRunForReviewTerminal = db.query<unknown, [string]>(
|
|
2912
|
+
`UPDATE runs SET
|
|
2913
|
+
state = 'running',
|
|
2914
|
+
endedAt = NULL,
|
|
2915
|
+
failureClass = NULL,
|
|
2916
|
+
recoveryAction = NULL,
|
|
2917
|
+
recoveredAt = NULL,
|
|
2918
|
+
failureCharges = COALESCE(failureCharges, 0) + CASE
|
|
2919
|
+
WHEN state = 'failed' AND (failureClass IS NULL OR failureClass NOT IN (${CHARGEABLE_FAILURE_EXCLUSIONS})) THEN 1
|
|
2920
|
+
ELSE 0
|
|
2921
|
+
END,
|
|
2922
|
+
continuationCharges = COALESCE(continuationCharges, 0) + CASE
|
|
2923
|
+
WHEN state = 'failed' AND failureClass = 'returned-for-revision' THEN 1
|
|
2924
|
+
WHEN state = 'killed' AND (failureClass IS NULL OR failureClass NOT IN (${CHARGEABLE_CONTINUATION_EXCLUSIONS})) THEN 1
|
|
2925
|
+
ELSE 0
|
|
2926
|
+
END
|
|
2927
|
+
WHERE id = ? AND state IN ('failed', 'killed')`,
|
|
2928
|
+
);
|
|
2929
|
+
// The settled-green leg: a pushed row carries no charge and no recovery
|
|
2930
|
+
// metadata, so the claim stays exactly what it always was.
|
|
2931
|
+
const claimRunForReviewGreen = db.query<unknown, [string]>(
|
|
2628
2932
|
`UPDATE runs SET state = 'running', endedAt = NULL WHERE id = ? AND state = 'pushed-green'`,
|
|
2629
2933
|
);
|
|
2934
|
+
// Exactly one leg can match (the row is either terminal or pushed-green at
|
|
2935
|
+
// the moment of the claim), a repeated claim matches neither, and the two
|
|
2936
|
+
// statements plus the read of `changes` are one transaction — the dispatch
|
|
2937
|
+
// pass can never see a half-claimed row.
|
|
2938
|
+
const claimRunForReviewTx = db.transaction((runId: string): boolean => {
|
|
2939
|
+
if (claimRunForReviewTerminal.run(runId).changes > 0) return true;
|
|
2940
|
+
return claimRunForReviewGreen.run(runId).changes > 0;
|
|
2941
|
+
});
|
|
2630
2942
|
|
|
2631
|
-
//
|
|
2632
|
-
//
|
|
2633
|
-
// row
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2943
|
+
// Appending is a single-statement concatenation so even a write from a
|
|
2944
|
+
// second connection cannot lose one side of the merge: SQLite holds the
|
|
2945
|
+
// row's write lock for the whole UPDATE and re-reads the current value, so
|
|
2946
|
+
// the second append lands on the first's result rather than on the read it
|
|
2947
|
+
// took before the first committed.
|
|
2948
|
+
const appendReviewFindingsRow = db.query<unknown, [string, string]>(
|
|
2949
|
+
`UPDATE review_revisions SET findings = findings || ? WHERE id = ?`,
|
|
2950
|
+
);
|
|
2951
|
+
|
|
2952
|
+
// The decision and the write share one transaction (#677, #786): a same-run,
|
|
2953
|
+
// same-head finding folds into the revision still waiting to dispatch
|
|
2954
|
+
// (appended — one round, one row, one worker attempt, and the resumed
|
|
2955
|
+
// session reads both findings), while a revision already pending at a
|
|
2956
|
+
// different head — a review of a moved PR — is refused rather than amended.
|
|
2957
|
+
// Two concurrent same-head requests therefore cannot both create a row or
|
|
2958
|
+
// overwrite each other: whichever commits first, the other sees its pending
|
|
2959
|
+
// row inside this transaction and appends to it. The verb turns the `refused`
|
|
2960
|
+
// outcome into the in-flight refusal.
|
|
2961
|
+
//
|
|
2962
|
+
// The transaction is begun IMMEDIATE — the write lock is taken BEFORE the
|
|
2963
|
+
// pending-row read. The CLI and the daemon are separate processes with
|
|
2964
|
+
// separate SQLite connections; under a deferred read-then-write both could
|
|
2965
|
+
// read "no pending row" before either wrote, and the second INSERT would
|
|
2966
|
+
// then fail with SQLITE_BUSY_SNAPSHOT instead of re-reading the winner's
|
|
2967
|
+
// committed row — losing the second finding as an untyped verb error. With
|
|
2968
|
+
// IMMEDIATE, the losing connection blocks on the write lock (bounded by the
|
|
2969
|
+
// 5s busy timeout) and, once the winner commits, reads the fresh state and
|
|
2970
|
+
// appends to the winner's row. The unique pending-per-run index remains as
|
|
2971
|
+
// the schema-level backstop.
|
|
2972
|
+
const enqueueReviewRevisionTx = db.transaction(
|
|
2973
|
+
(draft: Omit<ReviewRevisionRecord, "id">): ReviewRevisionEnqueue => {
|
|
2974
|
+
const pending = selectPendingReviewForRun.get(draft.project, draft.runId);
|
|
2975
|
+
if (pending !== null) {
|
|
2976
|
+
if (pending.headSha !== draft.headSha) {
|
|
2977
|
+
return { kind: "refused", block: "pending-different-head" };
|
|
2978
|
+
}
|
|
2979
|
+
const merged = `${pending.findings}\n\n${draft.findings}`;
|
|
2980
|
+
appendReviewFindingsRow.run(`\n\n${draft.findings}`, pending.id);
|
|
2981
|
+
return { kind: "appended", record: { ...toReviewRevision(pending), findings: merged } };
|
|
2982
|
+
}
|
|
2637
2983
|
const record: ReviewRevisionRecord = { ...draft, id: crypto.randomUUID() };
|
|
2638
2984
|
insertReviewRevision.run(
|
|
2639
2985
|
record.id,
|
|
@@ -2648,7 +2994,7 @@ export function openStore(dbPath: string): Store {
|
|
|
2648
2994
|
record.sessionFile ?? null,
|
|
2649
2995
|
record.requestedAt,
|
|
2650
2996
|
);
|
|
2651
|
-
return record;
|
|
2997
|
+
return { kind: "created", record };
|
|
2652
2998
|
},
|
|
2653
2999
|
);
|
|
2654
3000
|
|
|
@@ -2777,6 +3123,7 @@ export function openStore(dbPath: string): Store {
|
|
|
2777
3123
|
record.spendUsd,
|
|
2778
3124
|
toSql(record.sessionFile),
|
|
2779
3125
|
toSql(record.resumedFromRunId),
|
|
3126
|
+
toSql(record.lane),
|
|
2780
3127
|
toSql(record.prUrl),
|
|
2781
3128
|
toSql(record.headSha),
|
|
2782
3129
|
toSql(record.mergeSha),
|
|
@@ -2791,6 +3138,7 @@ export function openStore(dbPath: string): Store {
|
|
|
2791
3138
|
toSql(record.lastError),
|
|
2792
3139
|
toSql(record.settlementFlags),
|
|
2793
3140
|
toSql(record.report),
|
|
3141
|
+
toSql(record.graphTools),
|
|
2794
3142
|
);
|
|
2795
3143
|
return record;
|
|
2796
3144
|
};
|
|
@@ -2842,6 +3190,20 @@ export function openStore(dbPath: string): Store {
|
|
|
2842
3190
|
return row ? toRecord(row) : undefined;
|
|
2843
3191
|
},
|
|
2844
3192
|
|
|
3193
|
+
graphToolsObservationCounts(project: string): { recorded: number; present: number; absent: number } {
|
|
3194
|
+
// Counted in JS through the same defensive converter `toRecord` uses, so
|
|
3195
|
+
// a hand-edited row can never move a truth-value count (#726).
|
|
3196
|
+
let present = 0;
|
|
3197
|
+
let absent = 0;
|
|
3198
|
+
for (const row of selectGraphToolsObs.all(project)) {
|
|
3199
|
+
const observed = toGraphTools(row.graphTools);
|
|
3200
|
+
if (observed === undefined) continue;
|
|
3201
|
+
if (observed.present) present += 1;
|
|
3202
|
+
else absent += 1;
|
|
3203
|
+
}
|
|
3204
|
+
return { recorded: present + absent, present, absent };
|
|
3205
|
+
},
|
|
3206
|
+
|
|
2845
3207
|
activeRuns(project: string): RunRecord[] {
|
|
2846
3208
|
return selectActive.all(project, ...ACTIVE_STATES).map(toRecord);
|
|
2847
3209
|
},
|
|
@@ -2860,8 +3222,15 @@ export function openStore(dbPath: string): Store {
|
|
|
2860
3222
|
runsForProjectPr(project: string, prUrl: string, mergedSinceEpochMs: number): RunRecord[] {
|
|
2861
3223
|
return selectRunsForPr.all(project, prUrl, mergedSinceEpochMs).map(toRecord);
|
|
2862
3224
|
},
|
|
2863
|
-
|
|
2864
|
-
|
|
3225
|
+
enqueueReviewRevision(draft: Omit<ReviewRevisionRecord, "id">): ReviewRevisionEnqueue {
|
|
3226
|
+
// `immediate` = BEGIN IMMEDIATE: the write lock is acquired before the
|
|
3227
|
+
// pending-row read, so a second connection's transaction cannot observe
|
|
3228
|
+
// a snapshot older than the first connection's commit (#786).
|
|
3229
|
+
return enqueueReviewRevisionTx.immediate(draft);
|
|
3230
|
+
},
|
|
3231
|
+
pendingReviewForRun(project: string, runId: string): ReviewRevisionRecord | undefined {
|
|
3232
|
+
const row = selectPendingReviewForRun.get(project, runId);
|
|
3233
|
+
return row === null ? undefined : toReviewRevision(row);
|
|
2865
3234
|
},
|
|
2866
3235
|
pendingReviewRevisions(project: string): ReviewRevisionRecord[] {
|
|
2867
3236
|
return selectPendingReviewRevisions.all(project).map(toReviewRevision);
|
|
@@ -2882,7 +3251,7 @@ export function openStore(dbPath: string): Store {
|
|
|
2882
3251
|
requeueReviewRevisionRow.run(id);
|
|
2883
3252
|
},
|
|
2884
3253
|
claimRunForReview(runId: string): boolean {
|
|
2885
|
-
return
|
|
3254
|
+
return claimRunForReviewTx(runId);
|
|
2886
3255
|
},
|
|
2887
3256
|
runsNeedingBaseCheck(project: string, limit = 20): RunRecord[] {
|
|
2888
3257
|
return selectPendingBaseChecks.all(project, limit).map(toRecord);
|
|
@@ -2963,11 +3332,11 @@ export function openStore(dbPath: string): Store {
|
|
|
2963
3332
|
},
|
|
2964
3333
|
|
|
2965
3334
|
failuresFor(project: string, issue: number): number {
|
|
2966
|
-
return countFailures.get(project, issue)?.n ?? 0;
|
|
3335
|
+
return countFailures.get(project, issue, project, issue)?.n ?? 0;
|
|
2967
3336
|
},
|
|
2968
3337
|
|
|
2969
3338
|
continuationsFor(project: string, issue: number): number {
|
|
2970
|
-
return countContinuations.get(project, issue)?.n ?? 0;
|
|
3339
|
+
return countContinuations.get(project, issue, project, issue)?.n ?? 0;
|
|
2971
3340
|
},
|
|
2972
3341
|
|
|
2973
3342
|
classCountFor(project: string, issue: number, cls: FailureClass): number {
|
|
@@ -3514,8 +3883,12 @@ export function openStore(dbPath: string): Store {
|
|
|
3514
3883
|
return resolveDecisionRow.run(state, resolution, at, id).changes > 0;
|
|
3515
3884
|
},
|
|
3516
3885
|
|
|
3517
|
-
markDecisionConditionMet(id: string, at: number): boolean {
|
|
3518
|
-
return markConditionMetRow.run(at, id).changes > 0;
|
|
3886
|
+
markDecisionConditionMet(id: string, at: number, head?: string): boolean {
|
|
3887
|
+
return markConditionMetRow.run(at, toSql(head), id).changes > 0;
|
|
3888
|
+
},
|
|
3889
|
+
|
|
3890
|
+
clearDecisionConditionMet(id: string): boolean {
|
|
3891
|
+
return clearConditionMetRow.run(id).changes > 0;
|
|
3519
3892
|
},
|
|
3520
3893
|
|
|
3521
3894
|
expireDueDecisions(project: string, now: number): DecisionRecord[] {
|
|
@@ -3645,6 +4018,34 @@ export function openStore(dbPath: string): Store {
|
|
|
3645
4018
|
return result.changes > 0;
|
|
3646
4019
|
},
|
|
3647
4020
|
|
|
4021
|
+
upsertGrooming(draft: GroomingDraft): void {
|
|
4022
|
+
upsertGroomingRow.run(
|
|
4023
|
+
draft.project,
|
|
4024
|
+
draft.issue,
|
|
4025
|
+
draft.verdict,
|
|
4026
|
+
draft.reason,
|
|
4027
|
+
draft.evidence,
|
|
4028
|
+
draft.at,
|
|
4029
|
+
);
|
|
4030
|
+
},
|
|
4031
|
+
|
|
4032
|
+
grooming(project: string, issue: number): GroomingRecord | undefined {
|
|
4033
|
+
const row = selectGrooming.get(project, issue);
|
|
4034
|
+
return row === null ? undefined : toGrooming(row);
|
|
4035
|
+
},
|
|
4036
|
+
|
|
4037
|
+
groomingVerdicts(project: string, verdict?: GroomingVerdict): GroomingRecord[] {
|
|
4038
|
+
const rows = verdict === undefined ? selectGroomingAll.all(project) : selectGroomingByVerdict.all(project, verdict);
|
|
4039
|
+
return rows.map(toGrooming);
|
|
4040
|
+
},
|
|
4041
|
+
|
|
4042
|
+
reconcileGrooming(
|
|
4043
|
+
project: string,
|
|
4044
|
+
holds: readonly { issue: number; reason: AdmissionHoldReason; detail?: string }[],
|
|
4045
|
+
): void {
|
|
4046
|
+
reconcileGroomingTx(project, holds);
|
|
4047
|
+
},
|
|
4048
|
+
|
|
3648
4049
|
close(): void {
|
|
3649
4050
|
db.close(false);
|
|
3650
4051
|
},
|