omp-conductor 0.3.18 → 0.3.19
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 +344 -179
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +43 -50
- package/skills/conductor-update/SKILL.md +15 -165
- package/src/board.ts +734 -0
- package/src/brief-upgrade.ts +102 -19
- package/src/briefs/orchestrator.md +32 -16
- package/src/briefs/worker.md +7 -2
- package/src/cli.ts +136 -53
- package/src/config.ts +22 -6
- package/src/daemon.ts +673 -126
- package/src/fleet.ts +64 -6
- package/src/graph-health.ts +296 -0
- package/src/graph.ts +6 -6
- package/src/omp.ts +15 -4
- package/src/orchestrator-tick.ts +157 -10
- package/src/orchestrator.ts +8 -1
- package/src/plugin.ts +173 -45
- package/src/release-policy.ts +202 -0
- package/src/setup-host.ts +285 -0
- package/src/setup.ts +24 -20
- package/src/store.ts +373 -13
- package/src/tracker/github.ts +171 -3
- package/src/transcript.ts +45 -0
- package/src/types.ts +145 -2
- package/src/unblock.ts +26 -14
- package/src/upgrade.ts +327 -0
- package/src/worker.ts +67 -30
- package/src/worktree.ts +66 -0
- package/systemd/omp-conductor.service.example +5 -3
package/src/store.ts
CHANGED
|
@@ -11,9 +11,20 @@
|
|
|
11
11
|
|
|
12
12
|
import { Database } from "bun:sqlite";
|
|
13
13
|
import { mkdirSync } from "node:fs";
|
|
14
|
-
import { dirname } from "node:path";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
15
|
|
|
16
|
-
import
|
|
16
|
+
import { stateDir } from "./config.ts";
|
|
17
|
+
import { DEFAULT_CAPS } from "./types.ts";
|
|
18
|
+
import type {
|
|
19
|
+
DispatchSummary,
|
|
20
|
+
FrictionAdmissionReason,
|
|
21
|
+
FrictionKind,
|
|
22
|
+
FrictionObservation,
|
|
23
|
+
FrictionSignal,
|
|
24
|
+
RunRecord,
|
|
25
|
+
RunState,
|
|
26
|
+
Store,
|
|
27
|
+
} from "./types.ts";
|
|
17
28
|
|
|
18
29
|
/**
|
|
19
30
|
* States backed by a worker process. These are what worker capacity counts:
|
|
@@ -26,17 +37,34 @@ import type { RunRecord, RunState, Store } from "./types.ts";
|
|
|
26
37
|
export const LIVE_STATES: readonly RunState[] = ["claimed", "running"];
|
|
27
38
|
|
|
28
39
|
/**
|
|
29
|
-
* States that keep an *issue* occupied.
|
|
30
|
-
* {@link LIVE_STATES}:
|
|
31
|
-
* so
|
|
32
|
-
*
|
|
33
|
-
* attempt must not land on.
|
|
40
|
+
* States that keep an *issue* occupied. Pending and green pushes belong here
|
|
41
|
+
* but not in {@link LIVE_STATES}: their workers are finished and their
|
|
42
|
+
* worktrees removed, so they must not consume slots, while their live PRs must
|
|
43
|
+
* still block duplicate attempts.
|
|
34
44
|
*/
|
|
35
|
-
const ACTIVE_STATES: readonly RunState[] = [...LIVE_STATES, "pushed-green"];
|
|
45
|
+
const ACTIVE_STATES: readonly RunState[] = [...LIVE_STATES, "pushed-pending", "pushed-green"];
|
|
36
46
|
|
|
37
47
|
const LIVE_PLACEHOLDERS = LIVE_STATES.map(() => "?").join(", ");
|
|
38
48
|
const ACTIVE_PLACEHOLDERS = ACTIVE_STATES.map(() => "?").join(", ");
|
|
39
49
|
|
|
50
|
+
const FRICTION_HOLD_REASONS: ReadonlySet<FrictionAdmissionReason> = new Set([
|
|
51
|
+
"failed-attempts",
|
|
52
|
+
"continuations",
|
|
53
|
+
"parent-lookup-error",
|
|
54
|
+
"open-pr-lookup-error",
|
|
55
|
+
"unroutable:no-repo-label",
|
|
56
|
+
"unroutable:multiple-repo-labels",
|
|
57
|
+
"unroutable:unknown-repo",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const FRICTION_ISSUE_SAMPLES = 5;
|
|
61
|
+
const FRICTION_TEXT_SAMPLES = 3;
|
|
62
|
+
const FRICTION_TEXT_LIMIT = 160;
|
|
63
|
+
|
|
64
|
+
function isFrictionHoldReason(reason: string): reason is FrictionAdmissionReason {
|
|
65
|
+
return FRICTION_HOLD_REASONS.has(reason as FrictionAdmissionReason);
|
|
66
|
+
}
|
|
67
|
+
|
|
40
68
|
/**
|
|
41
69
|
* Allowlist for `updateRun`'s dynamic SET clause. Column names cannot be bound
|
|
42
70
|
* as parameters, so they are matched against this table rather than
|
|
@@ -52,9 +80,11 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
52
80
|
state: true,
|
|
53
81
|
attempt: true,
|
|
54
82
|
turns: true,
|
|
83
|
+
maxTurns: true,
|
|
55
84
|
spendUsd: true,
|
|
56
85
|
sessionFile: true,
|
|
57
86
|
prUrl: true,
|
|
87
|
+
headSha: true,
|
|
58
88
|
startedAt: true,
|
|
59
89
|
endedAt: true,
|
|
60
90
|
lastError: true,
|
|
@@ -74,14 +104,32 @@ interface RunRow {
|
|
|
74
104
|
state: string;
|
|
75
105
|
attempt: number;
|
|
76
106
|
turns: number;
|
|
107
|
+
maxTurns: number;
|
|
77
108
|
spendUsd: number;
|
|
78
109
|
sessionFile: string | null;
|
|
79
110
|
prUrl: string | null;
|
|
111
|
+
headSha: string | null;
|
|
80
112
|
startedAt: number;
|
|
81
113
|
endedAt: number | null;
|
|
82
114
|
lastError: string | null;
|
|
83
115
|
}
|
|
84
116
|
|
|
117
|
+
interface FrictionRollupRow {
|
|
118
|
+
project: string;
|
|
119
|
+
day: string;
|
|
120
|
+
kind: string;
|
|
121
|
+
observations: number;
|
|
122
|
+
occurrences: number;
|
|
123
|
+
issues: string;
|
|
124
|
+
samples: string;
|
|
125
|
+
latestAt: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface FrictionSurfaceRow {
|
|
129
|
+
kind: string;
|
|
130
|
+
at: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
85
133
|
const SCHEMA = `
|
|
86
134
|
CREATE TABLE IF NOT EXISTS runs (
|
|
87
135
|
id TEXT PRIMARY KEY,
|
|
@@ -93,9 +141,11 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
93
141
|
state TEXT NOT NULL,
|
|
94
142
|
attempt INTEGER NOT NULL,
|
|
95
143
|
turns INTEGER NOT NULL,
|
|
144
|
+
maxTurns INTEGER NOT NULL,
|
|
96
145
|
spendUsd REAL NOT NULL,
|
|
97
146
|
sessionFile TEXT,
|
|
98
147
|
prUrl TEXT,
|
|
148
|
+
headSha TEXT,
|
|
99
149
|
startedAt INTEGER NOT NULL,
|
|
100
150
|
endedAt INTEGER,
|
|
101
151
|
lastError TEXT
|
|
@@ -107,6 +157,32 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|
|
107
157
|
"key" TEXT PRIMARY KEY,
|
|
108
158
|
at INTEGER NOT NULL
|
|
109
159
|
);
|
|
160
|
+
|
|
161
|
+
CREATE TABLE IF NOT EXISTS dispatch_summaries (
|
|
162
|
+
project TEXT PRIMARY KEY,
|
|
163
|
+
summary TEXT NOT NULL
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
CREATE TABLE IF NOT EXISTS friction_rollups (
|
|
167
|
+
project TEXT NOT NULL,
|
|
168
|
+
day TEXT NOT NULL,
|
|
169
|
+
kind TEXT NOT NULL,
|
|
170
|
+
observations INTEGER NOT NULL,
|
|
171
|
+
occurrences INTEGER NOT NULL,
|
|
172
|
+
issues TEXT NOT NULL,
|
|
173
|
+
samples TEXT NOT NULL,
|
|
174
|
+
latestAt INTEGER NOT NULL,
|
|
175
|
+
PRIMARY KEY (project, day, kind)
|
|
176
|
+
);
|
|
177
|
+
CREATE INDEX IF NOT EXISTS friction_rollups_project_day
|
|
178
|
+
ON friction_rollups (project, day);
|
|
179
|
+
|
|
180
|
+
CREATE TABLE IF NOT EXISTS friction_surfaces (
|
|
181
|
+
project TEXT NOT NULL,
|
|
182
|
+
kind TEXT NOT NULL,
|
|
183
|
+
at INTEGER NOT NULL,
|
|
184
|
+
PRIMARY KEY (project, kind)
|
|
185
|
+
);
|
|
110
186
|
`;
|
|
111
187
|
|
|
112
188
|
/**
|
|
@@ -134,16 +210,89 @@ function toRecord(row: RunRow): RunRecord {
|
|
|
134
210
|
state: row.state as RunState,
|
|
135
211
|
attempt: row.attempt,
|
|
136
212
|
turns: row.turns,
|
|
213
|
+
maxTurns: row.maxTurns,
|
|
137
214
|
spendUsd: row.spendUsd,
|
|
138
215
|
startedAt: row.startedAt,
|
|
139
216
|
};
|
|
140
217
|
if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
|
|
141
218
|
if (row.prUrl !== null) record.prUrl = row.prUrl;
|
|
219
|
+
if (row.headSha !== null) record.headSha = row.headSha;
|
|
142
220
|
if (row.endedAt !== null) record.endedAt = row.endedAt;
|
|
143
221
|
if (row.lastError !== null) record.lastError = row.lastError;
|
|
144
222
|
return record;
|
|
145
223
|
}
|
|
146
224
|
|
|
225
|
+
function toDispatchSummary(text: string): DispatchSummary | undefined {
|
|
226
|
+
try {
|
|
227
|
+
const value = JSON.parse(text) as DispatchSummary;
|
|
228
|
+
const counts = [value.completedAt, value.ready, value.routed, value.admitted];
|
|
229
|
+
if (
|
|
230
|
+
!counts.every((count) => Number.isSafeInteger(count) && count >= 0) ||
|
|
231
|
+
typeof value.degraded !== "boolean" ||
|
|
232
|
+
!Array.isArray(value.holds) ||
|
|
233
|
+
value.holds.some(
|
|
234
|
+
(hold) =>
|
|
235
|
+
typeof hold !== "object" ||
|
|
236
|
+
hold === null ||
|
|
237
|
+
typeof hold.reason !== "string" ||
|
|
238
|
+
!Number.isSafeInteger(hold.count) ||
|
|
239
|
+
hold.count < 0 ||
|
|
240
|
+
!Array.isArray(hold.issues) ||
|
|
241
|
+
hold.issues.length > 5 ||
|
|
242
|
+
hold.count < hold.issues.length ||
|
|
243
|
+
!hold.issues.every((issue) => Number.isSafeInteger(issue) && issue > 0),
|
|
244
|
+
)
|
|
245
|
+
) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
return value;
|
|
249
|
+
} catch {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function parseNumberList(text: string): number[] {
|
|
255
|
+
try {
|
|
256
|
+
const value = JSON.parse(text) as unknown;
|
|
257
|
+
return Array.isArray(value)
|
|
258
|
+
? value.filter((item): item is number => Number.isSafeInteger(item) && item > 0)
|
|
259
|
+
: [];
|
|
260
|
+
} catch {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function parseTextList(text: string): string[] {
|
|
266
|
+
try {
|
|
267
|
+
const value = JSON.parse(text) as unknown;
|
|
268
|
+
return Array.isArray(value)
|
|
269
|
+
? value.filter((item): item is string => typeof item === "string" && item.length > 0)
|
|
270
|
+
: [];
|
|
271
|
+
} catch {
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function boundedUnique<T>(current: readonly T[], additions: readonly T[], limit: number): T[] {
|
|
277
|
+
return [...new Set([...current, ...additions])].slice(0, limit);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function isFrictionKind(value: string): value is FrictionKind {
|
|
281
|
+
if (value.startsWith("admission:")) {
|
|
282
|
+
return isFrictionHoldReason(value.slice("admission:".length));
|
|
283
|
+
}
|
|
284
|
+
return (
|
|
285
|
+
value === "feedback:escalation-should-digest" ||
|
|
286
|
+
value === "feedback:report-noise" ||
|
|
287
|
+
value === "feedback:report-surprise"
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Single database for every project; every table partitions by project name. */
|
|
292
|
+
export function dbPath(): string {
|
|
293
|
+
return join(stateDir(), "conductor.db");
|
|
294
|
+
}
|
|
295
|
+
|
|
147
296
|
/**
|
|
148
297
|
* Open (creating if needed) the run store at `dbPath`; `:memory:` is honoured
|
|
149
298
|
* for tests. Safe to call on a fresh path — the schema is applied on open, so
|
|
@@ -161,16 +310,26 @@ export function openStore(dbPath: string): Store {
|
|
|
161
310
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
162
311
|
db.exec("PRAGMA foreign_keys = ON;");
|
|
163
312
|
db.exec("PRAGMA busy_timeout = 5000;");
|
|
164
|
-
// ponytail: the schema is created if absent and never migrated — a column
|
|
165
|
-
// change means hand-editing or deleting the file. Upgrade path for the first
|
|
166
|
-
// shape change: PRAGMA user_version plus an ordered migration list here.
|
|
167
313
|
db.exec(SCHEMA);
|
|
314
|
+
// Additive migrations are idempotent and preserve every existing row.
|
|
315
|
+
// Historical rows predate per-run ceilings, so the package default is the
|
|
316
|
+
// only truthful recoverable value; every new run persists its actual cap.
|
|
317
|
+
const columns = db.query<{ name: string }, []>("PRAGMA table_info(runs)").all();
|
|
318
|
+
if (!columns.some((column) => column.name === "maxTurns")) {
|
|
319
|
+
db.exec(
|
|
320
|
+
`ALTER TABLE runs ADD COLUMN maxTurns INTEGER NOT NULL DEFAULT ${DEFAULT_CAPS.workerMaxTurns}`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
// v0.3.18 and earlier created `runs` without the worker-observed PR head.
|
|
324
|
+
if (!columns.some((column) => column.name === "headSha")) {
|
|
325
|
+
db.exec("ALTER TABLE runs ADD COLUMN headSha TEXT");
|
|
326
|
+
}
|
|
168
327
|
|
|
169
328
|
const insertRun = db.query<unknown, SqlValue[]>(
|
|
170
329
|
`INSERT INTO runs (
|
|
171
330
|
id, project, issue, repo, branch, worktree, state, attempt, turns,
|
|
172
|
-
spendUsd, sessionFile, prUrl, startedAt, endedAt, lastError
|
|
173
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
331
|
+
maxTurns, spendUsd, sessionFile, prUrl, headSha, startedAt, endedAt, lastError
|
|
332
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
174
333
|
);
|
|
175
334
|
const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
|
|
176
335
|
const selectActive = db.query<RunRow, SqlValue[]>(
|
|
@@ -183,9 +342,34 @@ export function openStore(dbPath: string): Store {
|
|
|
183
342
|
WHERE project = ? AND state IN (${LIVE_PLACEHOLDERS})
|
|
184
343
|
ORDER BY startedAt ASC`,
|
|
185
344
|
);
|
|
345
|
+
const selectRetained = db.query<RunRow, [string]>(
|
|
346
|
+
`SELECT * FROM runs
|
|
347
|
+
WHERE project = ?
|
|
348
|
+
AND state IN ('failed', 'killed', 'orphaned')
|
|
349
|
+
AND worktree <> ''
|
|
350
|
+
ORDER BY endedAt ASC, startedAt ASC`,
|
|
351
|
+
);
|
|
352
|
+
const selectRecentRuns = db.query<RunRow, [string, number]>(
|
|
353
|
+
`SELECT * FROM runs
|
|
354
|
+
WHERE rowid IN (
|
|
355
|
+
SELECT MAX(rowid) FROM runs
|
|
356
|
+
WHERE project = ?
|
|
357
|
+
GROUP BY issue
|
|
358
|
+
)
|
|
359
|
+
AND (state <> 'merged' OR COALESCE(endedAt, startedAt) >= ?)
|
|
360
|
+
ORDER BY startedAt DESC`,
|
|
361
|
+
);
|
|
186
362
|
const countAttempts = db.query<{ n: number }, [string, number]>(
|
|
187
363
|
`SELECT COUNT(*) AS n FROM runs WHERE project = ? AND issue = ?`,
|
|
188
364
|
);
|
|
365
|
+
const countFailures = db.query<{ n: number }, [string, number]>(
|
|
366
|
+
`SELECT COUNT(*) AS n FROM runs
|
|
367
|
+
WHERE project = ? AND issue = ? AND state = 'failed'`,
|
|
368
|
+
);
|
|
369
|
+
const countContinuations = db.query<{ n: number }, [string, number]>(
|
|
370
|
+
`SELECT COUNT(*) AS n FROM runs
|
|
371
|
+
WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')`,
|
|
372
|
+
);
|
|
189
373
|
// Newest attempt for one issue. `startedAt` is millisecond-resolution and two
|
|
190
374
|
// attempts could in principle share one, so rowid breaks the tie by insertion
|
|
191
375
|
// order — a `tail` that attached to the older of two same-millisecond attempts
|
|
@@ -211,6 +395,79 @@ export function openStore(dbPath: string): Store {
|
|
|
211
395
|
const insertNotified = db.query<unknown, [string, number]>(
|
|
212
396
|
`INSERT OR IGNORE INTO notifications ("key", at) VALUES (?, ?)`,
|
|
213
397
|
);
|
|
398
|
+
const upsertDispatch = db.query<unknown, [string, string]>(
|
|
399
|
+
`INSERT INTO dispatch_summaries (project, summary) VALUES (?, ?)
|
|
400
|
+
ON CONFLICT(project) DO UPDATE SET summary = excluded.summary`,
|
|
401
|
+
);
|
|
402
|
+
const selectDispatch = db.query<{ summary: string }, [string]>(
|
|
403
|
+
`SELECT summary FROM dispatch_summaries WHERE project = ?`,
|
|
404
|
+
);
|
|
405
|
+
const selectFrictionRollup = db.query<FrictionRollupRow, [string, string, string]>(
|
|
406
|
+
`SELECT * FROM friction_rollups WHERE project = ? AND day = ? AND kind = ?`,
|
|
407
|
+
);
|
|
408
|
+
const upsertFrictionRollup = db.query<
|
|
409
|
+
unknown,
|
|
410
|
+
[string, string, string, number, number, string, string, number]
|
|
411
|
+
>(
|
|
412
|
+
`INSERT INTO friction_rollups
|
|
413
|
+
(project, day, kind, observations, occurrences, issues, samples, latestAt)
|
|
414
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
415
|
+
ON CONFLICT(project, day, kind) DO UPDATE SET
|
|
416
|
+
observations = excluded.observations,
|
|
417
|
+
occurrences = excluded.occurrences,
|
|
418
|
+
issues = excluded.issues,
|
|
419
|
+
samples = excluded.samples,
|
|
420
|
+
latestAt = excluded.latestAt`,
|
|
421
|
+
);
|
|
422
|
+
const selectFrictionSince = db.query<FrictionRollupRow, [string, string]>(
|
|
423
|
+
`SELECT * FROM friction_rollups
|
|
424
|
+
WHERE project = ? AND day >= ?
|
|
425
|
+
ORDER BY latestAt DESC`,
|
|
426
|
+
);
|
|
427
|
+
const selectFrictionSurfaces = db.query<FrictionSurfaceRow, [string]>(
|
|
428
|
+
`SELECT kind, at FROM friction_surfaces WHERE project = ?`,
|
|
429
|
+
);
|
|
430
|
+
const upsertFrictionSurface = db.query<unknown, [string, string, number]>(
|
|
431
|
+
`INSERT INTO friction_surfaces (project, kind, at) VALUES (?, ?, ?)
|
|
432
|
+
ON CONFLICT(project, kind) DO UPDATE SET at = excluded.at`,
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const recordFriction = (project: string, observation: FrictionObservation): void => {
|
|
436
|
+
if (
|
|
437
|
+
!Number.isSafeInteger(observation.occurrences) ||
|
|
438
|
+
observation.occurrences < 1 ||
|
|
439
|
+
!Number.isSafeInteger(observation.at) ||
|
|
440
|
+
observation.at < 0
|
|
441
|
+
) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const day = new Date(observation.at).toISOString().slice(0, 10);
|
|
445
|
+
const prior = selectFrictionRollup.get(project, day, observation.kind);
|
|
446
|
+
const issues = boundedUnique(
|
|
447
|
+
prior === null ? [] : parseNumberList(prior.issues),
|
|
448
|
+
[
|
|
449
|
+
...(observation.issues ?? []),
|
|
450
|
+
...(observation.issue === undefined || observation.issue <= 0 ? [] : [observation.issue]),
|
|
451
|
+
],
|
|
452
|
+
FRICTION_ISSUE_SAMPLES,
|
|
453
|
+
);
|
|
454
|
+
const sample = observation.sample?.replace(/\s+/g, " ").trim().slice(0, FRICTION_TEXT_LIMIT);
|
|
455
|
+
const samples = boundedUnique(
|
|
456
|
+
prior === null ? [] : parseTextList(prior.samples),
|
|
457
|
+
sample === undefined || sample.length === 0 ? [] : [sample],
|
|
458
|
+
FRICTION_TEXT_SAMPLES,
|
|
459
|
+
);
|
|
460
|
+
upsertFrictionRollup.run(
|
|
461
|
+
project,
|
|
462
|
+
day,
|
|
463
|
+
observation.kind,
|
|
464
|
+
(prior?.observations ?? 0) + 1,
|
|
465
|
+
(prior?.occurrences ?? 0) + observation.occurrences,
|
|
466
|
+
JSON.stringify(issues),
|
|
467
|
+
JSON.stringify(samples),
|
|
468
|
+
Math.max(prior?.latestAt ?? 0, observation.at),
|
|
469
|
+
);
|
|
470
|
+
};
|
|
214
471
|
|
|
215
472
|
return {
|
|
216
473
|
createRun(r: Omit<RunRecord, "id">): RunRecord {
|
|
@@ -225,9 +482,11 @@ export function openStore(dbPath: string): Store {
|
|
|
225
482
|
record.state,
|
|
226
483
|
record.attempt,
|
|
227
484
|
record.turns,
|
|
485
|
+
record.maxTurns,
|
|
228
486
|
record.spendUsd,
|
|
229
487
|
toSql(record.sessionFile),
|
|
230
488
|
toSql(record.prUrl),
|
|
489
|
+
toSql(record.headSha),
|
|
231
490
|
record.startedAt,
|
|
232
491
|
toSql(record.endedAt),
|
|
233
492
|
toSql(record.lastError),
|
|
@@ -265,15 +524,32 @@ export function openStore(dbPath: string): Store {
|
|
|
265
524
|
return selectLive.all(project, ...LIVE_STATES).map(toRecord);
|
|
266
525
|
},
|
|
267
526
|
|
|
527
|
+
retainedRuns(project: string): RunRecord[] {
|
|
528
|
+
return selectRetained.all(project).map(toRecord);
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[] {
|
|
532
|
+
return selectRecentRuns.all(project, mergedSinceEpochMs).map(toRecord);
|
|
533
|
+
},
|
|
534
|
+
|
|
268
535
|
attemptsFor(project: string, issue: number): number {
|
|
269
536
|
return countAttempts.get(project, issue)?.n ?? 0;
|
|
270
537
|
},
|
|
271
538
|
|
|
539
|
+
failuresFor(project: string, issue: number): number {
|
|
540
|
+
return countFailures.get(project, issue)?.n ?? 0;
|
|
541
|
+
},
|
|
542
|
+
|
|
543
|
+
continuationsFor(project: string, issue: number): number {
|
|
544
|
+
return countContinuations.get(project, issue)?.n ?? 0;
|
|
545
|
+
},
|
|
546
|
+
|
|
272
547
|
latestRun(project: string, issue: number): RunRecord | undefined {
|
|
273
548
|
const row = selectLatestRun.get(project, issue);
|
|
274
549
|
return row ? toRecord(row) : undefined;
|
|
275
550
|
},
|
|
276
551
|
|
|
552
|
+
|
|
277
553
|
runsStartedSince(project: string, sinceEpochMs: number): number {
|
|
278
554
|
return countStartedSince.get(project, sinceEpochMs)?.n ?? 0;
|
|
279
555
|
},
|
|
@@ -293,6 +569,90 @@ export function openStore(dbPath: string): Store {
|
|
|
293
569
|
insertNotified.run(key, Date.now());
|
|
294
570
|
},
|
|
295
571
|
|
|
572
|
+
recordDispatch(project: string, summary: DispatchSummary): void {
|
|
573
|
+
const previous = selectDispatch.get(project);
|
|
574
|
+
upsertDispatch.run(project, JSON.stringify(summary));
|
|
575
|
+
if (
|
|
576
|
+
previous !== null &&
|
|
577
|
+
toDispatchSummary(previous.summary)?.completedAt === summary.completedAt
|
|
578
|
+
) {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
for (const hold of summary.holds) {
|
|
582
|
+
if (!isFrictionHoldReason(hold.reason)) continue;
|
|
583
|
+
recordFriction(project, {
|
|
584
|
+
kind: `admission:${hold.reason}`,
|
|
585
|
+
occurrences: hold.count,
|
|
586
|
+
issues: hold.issues,
|
|
587
|
+
...(hold.issues.length === 0
|
|
588
|
+
? {}
|
|
589
|
+
: { sample: `issues ${hold.issues.map((issue) => `#${issue}`).join(", ")}` }),
|
|
590
|
+
at: summary.completedAt,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
latestDispatch(project: string): DispatchSummary | undefined {
|
|
596
|
+
const row = selectDispatch.get(project);
|
|
597
|
+
return row === null ? undefined : toDispatchSummary(row.summary);
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
recordFriction,
|
|
601
|
+
|
|
602
|
+
pendingFriction(
|
|
603
|
+
project: string,
|
|
604
|
+
sinceEpochMs: number,
|
|
605
|
+
minimumObservations: number,
|
|
606
|
+
surfacedBeforeEpochMs: number,
|
|
607
|
+
): FrictionSignal[] {
|
|
608
|
+
const sinceDay = new Date(sinceEpochMs).toISOString().slice(0, 10);
|
|
609
|
+
const surfaces = new Map(selectFrictionSurfaces.all(project).map((row) => [row.kind, row.at]));
|
|
610
|
+
const combined = new Map<FrictionKind, FrictionSignal>();
|
|
611
|
+
for (const row of selectFrictionSince.all(project, sinceDay)) {
|
|
612
|
+
if (
|
|
613
|
+
!isFrictionKind(row.kind) ||
|
|
614
|
+
row.latestAt < sinceEpochMs ||
|
|
615
|
+
row.observations < 1 ||
|
|
616
|
+
row.occurrences < 1
|
|
617
|
+
) {
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
const prior = combined.get(row.kind);
|
|
621
|
+
combined.set(row.kind, {
|
|
622
|
+
kind: row.kind,
|
|
623
|
+
observations: (prior?.observations ?? 0) + row.observations,
|
|
624
|
+
occurrences: (prior?.occurrences ?? 0) + row.occurrences,
|
|
625
|
+
issues: boundedUnique(
|
|
626
|
+
prior?.issues ?? [],
|
|
627
|
+
parseNumberList(row.issues),
|
|
628
|
+
FRICTION_ISSUE_SAMPLES,
|
|
629
|
+
),
|
|
630
|
+
samples: boundedUnique(
|
|
631
|
+
prior?.samples ?? [],
|
|
632
|
+
parseTextList(row.samples),
|
|
633
|
+
FRICTION_TEXT_SAMPLES,
|
|
634
|
+
),
|
|
635
|
+
latestAt: Math.max(prior?.latestAt ?? 0, row.latestAt),
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
return [...combined.values()]
|
|
639
|
+
.filter(
|
|
640
|
+
(signal) =>
|
|
641
|
+
signal.observations >= minimumObservations &&
|
|
642
|
+
(surfaces.get(signal.kind) ?? 0) <= surfacedBeforeEpochMs,
|
|
643
|
+
)
|
|
644
|
+
.sort(
|
|
645
|
+
(a, b) =>
|
|
646
|
+
b.observations - a.observations ||
|
|
647
|
+
b.occurrences - a.occurrences ||
|
|
648
|
+
b.latestAt - a.latestAt,
|
|
649
|
+
);
|
|
650
|
+
},
|
|
651
|
+
|
|
652
|
+
markFrictionSurfaced(project: string, kinds: readonly FrictionKind[], at: number): void {
|
|
653
|
+
for (const kind of new Set(kinds)) upsertFrictionSurface.run(project, kind, at);
|
|
654
|
+
},
|
|
655
|
+
|
|
296
656
|
close(): void {
|
|
297
657
|
db.close(false);
|
|
298
658
|
},
|