omp-conductor 0.17.1 → 0.18.1
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 +34 -0
- package/REFERENCE.md +71 -17
- package/agents/to-spec.md +90 -0
- package/package.json +2 -1
- package/schema/config.schema.json +53 -1
- package/src/admission.ts +308 -76
- package/src/ask.ts +307 -10
- package/src/backups.ts +2 -2
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +43 -14
- package/src/briefs/to-spec.md +84 -0
- package/src/briefs/worker.md +37 -19
- package/src/cli.ts +2 -0
- package/src/command-help.ts +19 -1
- package/src/command-manifest.ts +27 -2
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +110 -3
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +57 -0
- package/src/config.ts +102 -2
- package/src/daemon.ts +1220 -1517
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +279 -16
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +425 -1
- package/src/digest-schedule.ts +37 -0
- package/src/doctor.ts +52 -0
- package/src/escalate.ts +9 -3
- package/src/failure-class.ts +43 -4
- package/src/fleet.ts +166 -24
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +55 -8
- package/src/graph.ts +379 -69
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +567 -2
- package/src/lifecycle.ts +158 -6
- package/src/omp.ts +269 -20
- package/src/orchestrator-tick.ts +1489 -26
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/routing.ts +11 -3
- package/src/session-host.ts +115 -5
- package/src/settlement.ts +1780 -0
- package/src/setup-host.ts +1205 -6
- package/src/setup-install.ts +119 -30
- package/src/setup-wizard.ts +88 -2
- package/src/setup.ts +119 -13
- package/src/shell.ts +15 -0
- package/src/status-render.ts +100 -11
- package/src/store.ts +519 -45
- package/src/to-spec.ts +387 -0
- package/src/tracker/github.ts +150 -14
- package/src/types.ts +470 -16
- 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 +770 -40
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +239 -9
- package/src/worktree.ts +142 -18
package/src/store.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { Database } from "bun:sqlite";
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
|
-
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
16
|
|
|
17
17
|
import { backupTimestamp, publishTempFile } from "./backups.ts";
|
|
@@ -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
|
|
@@ -146,6 +165,8 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
146
165
|
autoRetryCount: true,
|
|
147
166
|
autoCompactionCount: true,
|
|
148
167
|
sessionFile: true,
|
|
168
|
+
resumedFromRunId: true,
|
|
169
|
+
lane: true,
|
|
149
170
|
prUrl: true,
|
|
150
171
|
headSha: true,
|
|
151
172
|
mergeSha: true,
|
|
@@ -155,6 +176,7 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
155
176
|
salvageSha: true,
|
|
156
177
|
salvageError: true,
|
|
157
178
|
salvageAckAt: true,
|
|
179
|
+
quarantineDetail: true,
|
|
158
180
|
startedAt: true,
|
|
159
181
|
endedAt: true,
|
|
160
182
|
lastError: true,
|
|
@@ -164,6 +186,9 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
164
186
|
recoveryAction: true,
|
|
165
187
|
recoveredAt: true,
|
|
166
188
|
model: true,
|
|
189
|
+
graphTools: true,
|
|
190
|
+
failureCharges: true,
|
|
191
|
+
continuationCharges: true,
|
|
167
192
|
};
|
|
168
193
|
|
|
169
194
|
/** Everything SQLite will accept from us. */
|
|
@@ -191,6 +216,8 @@ interface RunRow {
|
|
|
191
216
|
autoRetryCount: number | null;
|
|
192
217
|
autoCompactionCount: number | null;
|
|
193
218
|
sessionFile: string | null;
|
|
219
|
+
resumedFromRunId: string | null;
|
|
220
|
+
lane: string | null;
|
|
194
221
|
prUrl: string | null;
|
|
195
222
|
headSha: string | null;
|
|
196
223
|
mergeSha: string | null;
|
|
@@ -200,6 +227,7 @@ interface RunRow {
|
|
|
200
227
|
salvageSha: string | null;
|
|
201
228
|
salvageError: string | null;
|
|
202
229
|
salvageAckAt: number | null;
|
|
230
|
+
quarantineDetail: string | null;
|
|
203
231
|
startedAt: number;
|
|
204
232
|
endedAt: number | null;
|
|
205
233
|
lastError: string | null;
|
|
@@ -209,6 +237,9 @@ interface RunRow {
|
|
|
209
237
|
recoveryAction: string | null;
|
|
210
238
|
recoveredAt: number | null;
|
|
211
239
|
model: string | null;
|
|
240
|
+
graphTools: string | null;
|
|
241
|
+
failureCharges: number | null;
|
|
242
|
+
continuationCharges: number | null;
|
|
212
243
|
}
|
|
213
244
|
|
|
214
245
|
/** The `base_health` table exactly as SQLite hands it back. */
|
|
@@ -436,6 +467,27 @@ function toIntakeItem(row: IntakeRow): IntakeItem {
|
|
|
436
467
|
return item;
|
|
437
468
|
}
|
|
438
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
|
+
|
|
439
491
|
/** The `orchestrator_incidents` table exactly as SQLite hands it back (#288). */
|
|
440
492
|
interface OrchestratorIncidentRow {
|
|
441
493
|
project: string;
|
|
@@ -517,6 +569,8 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
517
569
|
autoRetryCount INTEGER,
|
|
518
570
|
autoCompactionCount INTEGER,
|
|
519
571
|
sessionFile TEXT,
|
|
572
|
+
resumedFromRunId TEXT,
|
|
573
|
+
lane TEXT,
|
|
520
574
|
prUrl TEXT,
|
|
521
575
|
headSha TEXT,
|
|
522
576
|
mergeSha TEXT,
|
|
@@ -534,7 +588,10 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
534
588
|
failureClass TEXT,
|
|
535
589
|
recoveryAction TEXT,
|
|
536
590
|
recoveredAt INTEGER,
|
|
537
|
-
model TEXT
|
|
591
|
+
model TEXT,
|
|
592
|
+
graphTools TEXT,
|
|
593
|
+
failureCharges INTEGER,
|
|
594
|
+
continuationCharges INTEGER
|
|
538
595
|
);
|
|
539
596
|
CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
|
|
540
597
|
CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
|
|
@@ -780,6 +837,15 @@ CREATE TABLE IF NOT EXISTS review_revisions (
|
|
|
780
837
|
);
|
|
781
838
|
CREATE INDEX IF NOT EXISTS review_revisions_pending
|
|
782
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;
|
|
783
849
|
|
|
784
850
|
-- Questions the orchestrator has put to its operator, and their answers (#136).
|
|
785
851
|
--
|
|
@@ -794,6 +860,12 @@ CREATE INDEX IF NOT EXISTS review_revisions_pending
|
|
|
794
860
|
-- stored as the raw string an operator or session wrote, parsed on read. Once
|
|
795
861
|
-- met, 'conditionMetAt' is what turns a parked question into one the tick digest
|
|
796
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.
|
|
797
869
|
CREATE TABLE IF NOT EXISTS decisions (
|
|
798
870
|
id TEXT PRIMARY KEY,
|
|
799
871
|
project TEXT NOT NULL,
|
|
@@ -804,12 +876,32 @@ CREATE TABLE IF NOT EXISTS decisions (
|
|
|
804
876
|
expiresAt INTEGER NOT NULL,
|
|
805
877
|
condition TEXT,
|
|
806
878
|
conditionMetAt INTEGER,
|
|
879
|
+
conditionHead TEXT,
|
|
807
880
|
state TEXT NOT NULL,
|
|
808
881
|
resolvedAt INTEGER,
|
|
809
882
|
resolution TEXT
|
|
810
883
|
);
|
|
811
884
|
CREATE INDEX IF NOT EXISTS decisions_project_state ON decisions (project, state);
|
|
812
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
|
+
|
|
813
905
|
-- GitHub rate-limit refusals the tracker observed (#198). Written by the
|
|
814
906
|
-- daemon's tracker hook, not polled, so status can show what GitHub actually
|
|
815
907
|
-- refused next to the polled budget that might still look healthy. Only one
|
|
@@ -913,12 +1005,14 @@ CREATE INDEX IF NOT EXISTS daemon_stops_at ON daemon_stops (at);
|
|
|
913
1005
|
function toSql(value: unknown): SqlValue {
|
|
914
1006
|
if (value === undefined || value === null) return null;
|
|
915
1007
|
if (typeof value === "boolean") return value ? 1 : 0;
|
|
916
|
-
// `RunRecord.settlementFlags`
|
|
917
|
-
//
|
|
918
|
-
// 1:1 with the run, written once at settlement, and
|
|
919
|
-
// the row they belong to
|
|
920
|
-
//
|
|
921
|
-
|
|
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);
|
|
922
1016
|
return value as SqlValue;
|
|
923
1017
|
}
|
|
924
1018
|
|
|
@@ -971,6 +1065,55 @@ function toSettlementFlags(text: string): SettlementFlag[] | undefined {
|
|
|
971
1065
|
}
|
|
972
1066
|
}
|
|
973
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
|
+
|
|
974
1117
|
/**
|
|
975
1118
|
* A NULL column becomes an absent property rather than an `undefined` one, so
|
|
976
1119
|
* a record read back out of the store deep-equals the one that went in.
|
|
@@ -991,6 +1134,11 @@ function toRecord(row: RunRow): RunRecord {
|
|
|
991
1134
|
startedAt: row.startedAt,
|
|
992
1135
|
};
|
|
993
1136
|
if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
|
|
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
|
+
}
|
|
994
1142
|
if (row.prUrl !== null) record.prUrl = row.prUrl;
|
|
995
1143
|
if (row.headSha !== null) record.headSha = row.headSha;
|
|
996
1144
|
if (row.mergeSha !== null) record.mergeSha = row.mergeSha;
|
|
@@ -1000,6 +1148,7 @@ function toRecord(row: RunRow): RunRecord {
|
|
|
1000
1148
|
if (row.salvageSha !== null) record.salvageSha = row.salvageSha;
|
|
1001
1149
|
if (row.salvageError !== null) record.salvageError = row.salvageError;
|
|
1002
1150
|
if (row.salvageAckAt !== null) record.salvageAckAt = row.salvageAckAt;
|
|
1151
|
+
if (row.quarantineDetail !== null) record.quarantineDetail = row.quarantineDetail;
|
|
1003
1152
|
if (row.endedAt !== null) record.endedAt = row.endedAt;
|
|
1004
1153
|
if (row.lastError !== null) record.lastError = row.lastError;
|
|
1005
1154
|
if (row.settlementFlags !== null) {
|
|
@@ -1021,7 +1170,15 @@ function toRecord(row: RunRow): RunRecord {
|
|
|
1021
1170
|
if (row.failureClass !== null) record.failureClass = row.failureClass as FailureClass;
|
|
1022
1171
|
if (row.recoveryAction !== null) record.recoveryAction = row.recoveryAction as RecoveryAction;
|
|
1023
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
|
+
}
|
|
1024
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
|
+
}
|
|
1025
1182
|
return record;
|
|
1026
1183
|
}
|
|
1027
1184
|
|
|
@@ -1085,6 +1242,7 @@ interface DecisionRow {
|
|
|
1085
1242
|
expiresAt: number;
|
|
1086
1243
|
condition: string | null;
|
|
1087
1244
|
conditionMetAt: number | null;
|
|
1245
|
+
conditionHead: string | null;
|
|
1088
1246
|
state: string;
|
|
1089
1247
|
resolvedAt: number | null;
|
|
1090
1248
|
resolution: string | null;
|
|
@@ -1107,6 +1265,7 @@ function toDecision(row: DecisionRow): DecisionRecord {
|
|
|
1107
1265
|
if (row.blocks !== null) record.blocks = row.blocks;
|
|
1108
1266
|
if (row.condition !== null) record.condition = row.condition;
|
|
1109
1267
|
if (row.conditionMetAt !== null) record.conditionMetAt = row.conditionMetAt;
|
|
1268
|
+
if (row.conditionHead !== null) record.conditionHead = row.conditionHead;
|
|
1110
1269
|
if (row.resolvedAt !== null) record.resolvedAt = row.resolvedAt;
|
|
1111
1270
|
if (row.resolution !== null) record.resolution = row.resolution;
|
|
1112
1271
|
return record;
|
|
@@ -1318,9 +1477,13 @@ export function vacuumInto(source: string, target: string): void {
|
|
|
1318
1477
|
* A fresh, restorable copy of the store at `source`, published under `destDir`
|
|
1319
1478
|
* with the same temp-then-atomic-publish convention as the config backups.
|
|
1320
1479
|
*
|
|
1480
|
+
* The stem carries `at` (default now) so the filename's timestamp is the
|
|
1481
|
+
* snapshot's logical instant — the cadence passes its tick time, keeping the
|
|
1482
|
+
* name, the day marker and the retention ordering in one timeline.
|
|
1483
|
+
*
|
|
1321
1484
|
* Returns the published snapshot path.
|
|
1322
1485
|
*/
|
|
1323
|
-
export function snapshotDb(source: string, destDir: string): string {
|
|
1486
|
+
export function snapshotDb(source: string, destDir: string, at: number = Date.now()): string {
|
|
1324
1487
|
if (source === ":memory:") throw new Error("cannot snapshot an in-memory store");
|
|
1325
1488
|
if (!existsSync(source)) {
|
|
1326
1489
|
throw new Error(`cannot snapshot ${source}: the store does not exist`);
|
|
@@ -1334,7 +1497,34 @@ export function snapshotDb(source: string, destDir: string): string {
|
|
|
1334
1497
|
rmSync(temporary, { force: true });
|
|
1335
1498
|
throw err;
|
|
1336
1499
|
}
|
|
1337
|
-
return publishTempFile(temporary, destDir, `${DB_SNAPSHOT_STEM}-${backupTimestamp()}`);
|
|
1500
|
+
return publishTempFile(temporary, destDir, `${DB_SNAPSHOT_STEM}-${backupTimestamp(at)}`);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* How many conductor.db snapshots the daemon's daily cadence keeps — a week
|
|
1505
|
+
* of ledger history at one snapshot per day. Deliberately a fixed count, not
|
|
1506
|
+
* an age or space policy: the store's ledger is append-only and the issue
|
|
1507
|
+
* forbids inventing a fleet-wide retention policy, so pruning is
|
|
1508
|
+
* configured-count only (#289).
|
|
1509
|
+
*/
|
|
1510
|
+
export const DB_SNAPSHOT_RETENTION = 7;
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* Prune a snapshot directory to the newest `keep` conductor.db snapshots.
|
|
1514
|
+
*
|
|
1515
|
+
* Snapshot stems embed the publish timestamp (`YYYY-MM-DDTHH-MM-SS…`), so
|
|
1516
|
+
* lexicographic order is chronological; only files named with the shared
|
|
1517
|
+
* `DB_SNAPSHOT_STEM` prefix are considered, never unrelated backups next to
|
|
1518
|
+
* them. Idempotent: a directory already at or under the bound removes
|
|
1519
|
+
* nothing. Returns the number of files removed.
|
|
1520
|
+
*/
|
|
1521
|
+
export function pruneDbSnapshots(dir: string, keep: number): number {
|
|
1522
|
+
const names = readdirSync(dir)
|
|
1523
|
+
.filter((name) => name.startsWith(DB_SNAPSHOT_STEM))
|
|
1524
|
+
.sort();
|
|
1525
|
+
const stale = names.length > keep ? names.slice(0, names.length - keep) : [];
|
|
1526
|
+
for (const name of stale) unlinkSync(join(dir, name));
|
|
1527
|
+
return stale.length;
|
|
1338
1528
|
}
|
|
1339
1529
|
|
|
1340
1530
|
/** The UTC calendar day (`YYYY-MM-DD`) a moment falls on — the partition key
|
|
@@ -1421,6 +1611,14 @@ export function openStore(dbPath: string): Store {
|
|
|
1421
1611
|
db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
|
|
1422
1612
|
}
|
|
1423
1613
|
}
|
|
1614
|
+
// Rows written before #737 were never asked whether their tree was
|
|
1615
|
+
// quarantined, and a NULL means exactly that: the pre-quarantine passes
|
|
1616
|
+
// reaped the same trees they always had, so nothing before this release
|
|
1617
|
+
// needs backfilling — the column starts null and only a post-release pass
|
|
1618
|
+
// that actually refuses a broken store writes it.
|
|
1619
|
+
if (!columns.some((column) => column.name === "quarantineDetail")) {
|
|
1620
|
+
db.exec("ALTER TABLE runs ADD COLUMN quarantineDetail TEXT");
|
|
1621
|
+
}
|
|
1424
1622
|
// Rows written before the settlement audit existed (#128) were never audited,
|
|
1425
1623
|
// so a NULL here means "no audit ran" and reads the same as "found nothing".
|
|
1426
1624
|
// That conflation is deliberate and harmless: the flags are advisory, and no
|
|
@@ -1455,6 +1653,29 @@ export function openStore(dbPath: string): Store {
|
|
|
1455
1653
|
if (!columns.some((column) => column.name === "model")) {
|
|
1456
1654
|
db.exec("ALTER TABLE runs ADD COLUMN model TEXT");
|
|
1457
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
|
+
}
|
|
1663
|
+
// Resume provenance (#567): rows written before #564 landed were never
|
|
1664
|
+
// resumed, so NULL is the honest reading of "no resume happened" — never
|
|
1665
|
+
// "resumed from unknown". No backfill: the identity existed only in the
|
|
1666
|
+
// dispatch decision, and deriving it now would guess at history.
|
|
1667
|
+
if (!columns.some((column) => column.name === "resumedFromRunId")) {
|
|
1668
|
+
db.exec("ALTER TABLE runs ADD COLUMN resumedFromRunId TEXT");
|
|
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
|
+
}
|
|
1458
1679
|
// The count of in-session provider 429s a run recorded was first written by
|
|
1459
1680
|
// the worker (#573). Rows predating the column are NULL, which is the honest
|
|
1460
1681
|
// reading: the count was not recorded, so the classifier must not treat the
|
|
@@ -1479,6 +1700,18 @@ export function openStore(dbPath: string): Store {
|
|
|
1479
1700
|
db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
|
|
1480
1701
|
}
|
|
1481
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
|
+
}
|
|
1482
1715
|
// Existing held-notice rows predate exact digest ownership (#274). A NULL
|
|
1483
1716
|
// report id is truthful for already-digested history and means "still owed"
|
|
1484
1717
|
// only while digestedAt is also NULL.
|
|
@@ -1521,6 +1754,14 @@ export function openStore(dbPath: string): Store {
|
|
|
1521
1754
|
if (!decisionColumns.some((column) => column.name === "kind")) {
|
|
1522
1755
|
db.exec("ALTER TABLE decisions ADD COLUMN kind TEXT NOT NULL DEFAULT 'question'");
|
|
1523
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
|
+
}
|
|
1524
1765
|
|
|
1525
1766
|
// One-time repair, and the reason it lives here rather than in the classifier:
|
|
1526
1767
|
// a turn-0 environment fault that was ALREADY classified `unknown` and
|
|
@@ -1614,12 +1855,15 @@ export function openStore(dbPath: string): Store {
|
|
|
1614
1855
|
const insertRun = db.query<unknown, SqlValue[]>(
|
|
1615
1856
|
`INSERT INTO runs (
|
|
1616
1857
|
id, project, issue, repo, branch, worktree, state, attempt, turns,
|
|
1617
|
-
maxTurns, spendUsd, sessionFile, prUrl, headSha, mergeSha, baseRef,
|
|
1858
|
+
maxTurns, spendUsd, sessionFile, resumedFromRunId, lane, prUrl, headSha, mergeSha, baseRef,
|
|
1618
1859
|
baseCheck, baseCheckAt, salvageSha, salvageError, salvageAckAt, startedAt,
|
|
1619
|
-
endedAt, lastError, settlementFlags, report
|
|
1620
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1860
|
+
endedAt, lastError, settlementFlags, report, graphTools
|
|
1861
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1621
1862
|
);
|
|
1622
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
|
+
);
|
|
1623
1867
|
const selectActive = db.query<RunRow, SqlValue[]>(
|
|
1624
1868
|
`SELECT * FROM runs
|
|
1625
1869
|
WHERE project = ? AND state IN (${ACTIVE_PLACEHOLDERS})
|
|
@@ -1752,19 +1996,43 @@ export function openStore(dbPath: string): Store {
|
|
|
1752
1996
|
// fleet #350 spent its whole continuation budget on causes like those and then
|
|
1753
1997
|
// sat in admission hold for six dispatch cycles. An unclassified row (NULL) is
|
|
1754
1998
|
// counted exactly as before, so upgrading changes no existing budget.
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
const
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
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`,
|
|
1768
2036
|
);
|
|
1769
2037
|
// How many times one issue reached a given class. Recovery uses it to bound a
|
|
1770
2038
|
// retry loop whose cause is persistent (e.g. a mirror that will not refresh):
|
|
@@ -1870,6 +2138,21 @@ export function openStore(dbPath: string): Store {
|
|
|
1870
2138
|
AND (salvageSha IS NOT NULL OR salvageError IS NOT NULL)
|
|
1871
2139
|
ORDER BY issue ASC`,
|
|
1872
2140
|
);
|
|
2141
|
+
// Quarantined retained trees (#737), newest attempt per issue — the same
|
|
2142
|
+
// shape as `selectSalvaged` because the same change can land twice: a row
|
|
2143
|
+
// long since reaped must not keep its issue looking quarantined, and an
|
|
2144
|
+
// older quarantined attempt must not claim the issue while a newer,
|
|
2145
|
+
// healthy one replaced it.
|
|
2146
|
+
const selectQuarantined = db.query<RunRow, [string]>(
|
|
2147
|
+
`SELECT * FROM runs
|
|
2148
|
+
WHERE rowid IN (
|
|
2149
|
+
SELECT MAX(rowid) FROM runs
|
|
2150
|
+
WHERE project = ?
|
|
2151
|
+
GROUP BY issue
|
|
2152
|
+
)
|
|
2153
|
+
AND quarantineDetail IS NOT NULL
|
|
2154
|
+
ORDER BY issue ASC`,
|
|
2155
|
+
);
|
|
1873
2156
|
// Newest attempt for one issue. `startedAt` is millisecond-resolution and two
|
|
1874
2157
|
// attempts could in principle share one, so rowid breaks the tie by insertion
|
|
1875
2158
|
// order — a `tail` that attached to the older of two same-millisecond attempts
|
|
@@ -1961,6 +2244,44 @@ export function openStore(dbPath: string): Store {
|
|
|
1961
2244
|
const selectDispatch = db.query<{ summary: string }, [string]>(
|
|
1962
2245
|
`SELECT summary FROM dispatch_summaries WHERE project = ?`,
|
|
1963
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
|
+
);
|
|
1964
2285
|
const selectFrictionRollup = db.query<FrictionRollupRow, [string, string, string]>(
|
|
1965
2286
|
`SELECT * FROM friction_rollups WHERE project = ? AND day = ? AND kind = ?`,
|
|
1966
2287
|
);
|
|
@@ -2467,12 +2788,24 @@ export function openStore(dbPath: string): Store {
|
|
|
2467
2788
|
`UPDATE decisions SET state = 'expired', resolvedAt = ?, resolution = 'expired unanswered'
|
|
2468
2789
|
WHERE project = ? AND state = 'open' AND expiresAt <= ?`,
|
|
2469
2790
|
);
|
|
2470
|
-
// Set once: a condition that flickers must not keep moving its own
|
|
2471
|
-
// or the digest's "act on this now" would reset to looking new
|
|
2472
|
-
|
|
2473
|
-
|
|
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 = ?
|
|
2474
2798
|
WHERE id = ? AND state = 'open' AND conditionMetAt IS NULL`,
|
|
2475
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
|
+
);
|
|
2476
2809
|
|
|
2477
2810
|
const insertVerbLedger = db.query<unknown, SqlValue[]>(
|
|
2478
2811
|
`INSERT INTO verb_ledger
|
|
@@ -2556,16 +2889,97 @@ export function openStore(dbPath: string): Store {
|
|
|
2556
2889
|
const requeueReviewRevisionRow = db.query<unknown, [string]>(
|
|
2557
2890
|
`UPDATE review_revisions SET dispatchedAt = NULL WHERE id = ?`,
|
|
2558
2891
|
);
|
|
2559
|
-
|
|
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]>(
|
|
2560
2932
|
`UPDATE runs SET state = 'running', endedAt = NULL WHERE id = ? AND state = 'pushed-green'`,
|
|
2561
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
|
+
});
|
|
2562
2942
|
|
|
2563
|
-
//
|
|
2564
|
-
//
|
|
2565
|
-
// row
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
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
|
+
}
|
|
2569
2983
|
const record: ReviewRevisionRecord = { ...draft, id: crypto.randomUUID() };
|
|
2570
2984
|
insertReviewRevision.run(
|
|
2571
2985
|
record.id,
|
|
@@ -2580,7 +2994,7 @@ export function openStore(dbPath: string): Store {
|
|
|
2580
2994
|
record.sessionFile ?? null,
|
|
2581
2995
|
record.requestedAt,
|
|
2582
2996
|
);
|
|
2583
|
-
return record;
|
|
2997
|
+
return { kind: "created", record };
|
|
2584
2998
|
},
|
|
2585
2999
|
);
|
|
2586
3000
|
|
|
@@ -2708,6 +3122,8 @@ export function openStore(dbPath: string): Store {
|
|
|
2708
3122
|
record.maxTurns,
|
|
2709
3123
|
record.spendUsd,
|
|
2710
3124
|
toSql(record.sessionFile),
|
|
3125
|
+
toSql(record.resumedFromRunId),
|
|
3126
|
+
toSql(record.lane),
|
|
2711
3127
|
toSql(record.prUrl),
|
|
2712
3128
|
toSql(record.headSha),
|
|
2713
3129
|
toSql(record.mergeSha),
|
|
@@ -2722,6 +3138,7 @@ export function openStore(dbPath: string): Store {
|
|
|
2722
3138
|
toSql(record.lastError),
|
|
2723
3139
|
toSql(record.settlementFlags),
|
|
2724
3140
|
toSql(record.report),
|
|
3141
|
+
toSql(record.graphTools),
|
|
2725
3142
|
);
|
|
2726
3143
|
return record;
|
|
2727
3144
|
};
|
|
@@ -2773,6 +3190,20 @@ export function openStore(dbPath: string): Store {
|
|
|
2773
3190
|
return row ? toRecord(row) : undefined;
|
|
2774
3191
|
},
|
|
2775
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
|
+
|
|
2776
3207
|
activeRuns(project: string): RunRecord[] {
|
|
2777
3208
|
return selectActive.all(project, ...ACTIVE_STATES).map(toRecord);
|
|
2778
3209
|
},
|
|
@@ -2791,8 +3222,15 @@ export function openStore(dbPath: string): Store {
|
|
|
2791
3222
|
runsForProjectPr(project: string, prUrl: string, mergedSinceEpochMs: number): RunRecord[] {
|
|
2792
3223
|
return selectRunsForPr.all(project, prUrl, mergedSinceEpochMs).map(toRecord);
|
|
2793
3224
|
},
|
|
2794
|
-
|
|
2795
|
-
|
|
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);
|
|
2796
3234
|
},
|
|
2797
3235
|
pendingReviewRevisions(project: string): ReviewRevisionRecord[] {
|
|
2798
3236
|
return selectPendingReviewRevisions.all(project).map(toReviewRevision);
|
|
@@ -2813,7 +3251,7 @@ export function openStore(dbPath: string): Store {
|
|
|
2813
3251
|
requeueReviewRevisionRow.run(id);
|
|
2814
3252
|
},
|
|
2815
3253
|
claimRunForReview(runId: string): boolean {
|
|
2816
|
-
return
|
|
3254
|
+
return claimRunForReviewTx(runId);
|
|
2817
3255
|
},
|
|
2818
3256
|
runsNeedingBaseCheck(project: string, limit = 20): RunRecord[] {
|
|
2819
3257
|
return selectPendingBaseChecks.all(project, limit).map(toRecord);
|
|
@@ -2885,16 +3323,20 @@ export function openStore(dbPath: string): Store {
|
|
|
2885
3323
|
return selectSalvaged.all(project).map(toRecord);
|
|
2886
3324
|
},
|
|
2887
3325
|
|
|
3326
|
+
quarantinedRuns(project: string): RunRecord[] {
|
|
3327
|
+
return selectQuarantined.all(project).map(toRecord);
|
|
3328
|
+
},
|
|
3329
|
+
|
|
2888
3330
|
attemptsFor(project: string, issue: number): number {
|
|
2889
3331
|
return countAttempts.get(project, issue)?.n ?? 0;
|
|
2890
3332
|
},
|
|
2891
3333
|
|
|
2892
3334
|
failuresFor(project: string, issue: number): number {
|
|
2893
|
-
return countFailures.get(project, issue)?.n ?? 0;
|
|
3335
|
+
return countFailures.get(project, issue, project, issue)?.n ?? 0;
|
|
2894
3336
|
},
|
|
2895
3337
|
|
|
2896
3338
|
continuationsFor(project: string, issue: number): number {
|
|
2897
|
-
return countContinuations.get(project, issue)?.n ?? 0;
|
|
3339
|
+
return countContinuations.get(project, issue, project, issue)?.n ?? 0;
|
|
2898
3340
|
},
|
|
2899
3341
|
|
|
2900
3342
|
classCountFor(project: string, issue: number, cls: FailureClass): number {
|
|
@@ -3441,8 +3883,12 @@ export function openStore(dbPath: string): Store {
|
|
|
3441
3883
|
return resolveDecisionRow.run(state, resolution, at, id).changes > 0;
|
|
3442
3884
|
},
|
|
3443
3885
|
|
|
3444
|
-
markDecisionConditionMet(id: string, at: number): boolean {
|
|
3445
|
-
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;
|
|
3446
3892
|
},
|
|
3447
3893
|
|
|
3448
3894
|
expireDueDecisions(project: string, now: number): DecisionRecord[] {
|
|
@@ -3572,6 +4018,34 @@ export function openStore(dbPath: string): Store {
|
|
|
3572
4018
|
return result.changes > 0;
|
|
3573
4019
|
},
|
|
3574
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
|
+
|
|
3575
4049
|
close(): void {
|
|
3576
4050
|
db.close(false);
|
|
3577
4051
|
},
|