omp-conductor 0.3.24 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +775 -49
- package/package.json +1 -1
- package/src/approval-surface.ts +36 -1
- package/src/board.ts +30 -6
- package/src/briefs/orchestrator.md +114 -20
- package/src/briefs/policy.md +48 -27
- package/src/briefs/worker.md +82 -31
- package/src/cli.ts +164 -4
- package/src/config.ts +669 -16
- package/src/confinement.ts +506 -25
- package/src/credentials.ts +2029 -0
- package/src/daemon.ts +1213 -78
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +80 -4
- package/src/omp.ts +471 -15
- package/src/orchestrator-tick.ts +98 -25
- package/src/orchestrator.ts +32 -5
- package/src/plugin.ts +267 -15
- package/src/release-policy.ts +191 -29
- package/src/reports.ts +440 -0
- package/src/session-host.ts +307 -0
- package/src/setup-host.ts +19 -1
- package/src/setup.ts +207 -18
- package/src/store.ts +555 -3
- package/src/tracker/github.ts +45 -0
- package/src/types.ts +863 -10
- package/src/unblock.ts +53 -3
- package/src/usage.ts +726 -0
- package/src/verbs/actions.ts +142 -0
- package/src/verbs/client.ts +207 -0
- package/src/verbs/ledger.ts +77 -0
- package/src/verbs/protocol.ts +465 -0
- package/src/verbs/server.ts +1098 -0
- package/src/verbs/socket.ts +446 -0
- package/src/worker.ts +36 -7
- package/src/worktree.ts +227 -120
- package/systemd/omp-conductor.service.example +96 -8
package/src/store.ts
CHANGED
|
@@ -21,9 +21,22 @@ import type {
|
|
|
21
21
|
FrictionKind,
|
|
22
22
|
FrictionObservation,
|
|
23
23
|
FrictionSignal,
|
|
24
|
+
MergeLock,
|
|
25
|
+
ReportDeliveryState,
|
|
26
|
+
ReportDraft,
|
|
27
|
+
ReportEnqueue,
|
|
28
|
+
ReportKind,
|
|
29
|
+
ReportRecord,
|
|
24
30
|
RunRecord,
|
|
25
31
|
RunState,
|
|
32
|
+
SessionRole,
|
|
33
|
+
SettlementFlag,
|
|
26
34
|
Store,
|
|
35
|
+
VerbDecision,
|
|
36
|
+
VerbLedgerDraft,
|
|
37
|
+
VerbLedgerEntry,
|
|
38
|
+
VerbName,
|
|
39
|
+
VerbRefusal,
|
|
27
40
|
} from "./types.ts";
|
|
28
41
|
|
|
29
42
|
/**
|
|
@@ -85,9 +98,13 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
85
98
|
sessionFile: true,
|
|
86
99
|
prUrl: true,
|
|
87
100
|
headSha: true,
|
|
101
|
+
salvageSha: true,
|
|
102
|
+
salvageError: true,
|
|
103
|
+
salvageAckAt: true,
|
|
88
104
|
startedAt: true,
|
|
89
105
|
endedAt: true,
|
|
90
106
|
lastError: true,
|
|
107
|
+
settlementFlags: true,
|
|
91
108
|
};
|
|
92
109
|
|
|
93
110
|
/** Everything SQLite will accept from us. */
|
|
@@ -109,9 +126,13 @@ interface RunRow {
|
|
|
109
126
|
sessionFile: string | null;
|
|
110
127
|
prUrl: string | null;
|
|
111
128
|
headSha: string | null;
|
|
129
|
+
salvageSha: string | null;
|
|
130
|
+
salvageError: string | null;
|
|
131
|
+
salvageAckAt: number | null;
|
|
112
132
|
startedAt: number;
|
|
113
133
|
endedAt: number | null;
|
|
114
134
|
lastError: string | null;
|
|
135
|
+
settlementFlags: string | null;
|
|
115
136
|
}
|
|
116
137
|
|
|
117
138
|
interface FrictionRollupRow {
|
|
@@ -130,6 +151,49 @@ interface FrictionSurfaceRow {
|
|
|
130
151
|
at: number;
|
|
131
152
|
}
|
|
132
153
|
|
|
154
|
+
/** The `reports` table exactly as SQLite hands it back. */
|
|
155
|
+
interface ReportRow {
|
|
156
|
+
id: string;
|
|
157
|
+
project: string;
|
|
158
|
+
kind: string;
|
|
159
|
+
body: string;
|
|
160
|
+
state: string;
|
|
161
|
+
attempts: number;
|
|
162
|
+
attemptId: string | null;
|
|
163
|
+
ambiguous: number;
|
|
164
|
+
dedupeKey: string | null;
|
|
165
|
+
createdAt: number;
|
|
166
|
+
updatedAt: number;
|
|
167
|
+
nextAttemptAt: number;
|
|
168
|
+
messageId: number | null;
|
|
169
|
+
deliveredAt: number | null;
|
|
170
|
+
lastError: string | null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The `verb_ledger` table exactly as SQLite hands it back (#126). */
|
|
174
|
+
interface VerbLedgerRow {
|
|
175
|
+
id: string;
|
|
176
|
+
project: string;
|
|
177
|
+
runId: string | null;
|
|
178
|
+
issue: number | null;
|
|
179
|
+
verb: string;
|
|
180
|
+
role: string;
|
|
181
|
+
args: string;
|
|
182
|
+
decision: string;
|
|
183
|
+
refusal: string | null;
|
|
184
|
+
detail: string;
|
|
185
|
+
sha: string | null;
|
|
186
|
+
at: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The `merge_locks` table exactly as SQLite hands it back (#126). */
|
|
190
|
+
interface MergeLockRow {
|
|
191
|
+
project: string;
|
|
192
|
+
holder: string;
|
|
193
|
+
prUrl: string;
|
|
194
|
+
at: number;
|
|
195
|
+
}
|
|
196
|
+
|
|
133
197
|
const SCHEMA = `
|
|
134
198
|
CREATE TABLE IF NOT EXISTS runs (
|
|
135
199
|
id TEXT PRIMARY KEY,
|
|
@@ -146,9 +210,13 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
146
210
|
sessionFile TEXT,
|
|
147
211
|
prUrl TEXT,
|
|
148
212
|
headSha TEXT,
|
|
213
|
+
salvageSha TEXT,
|
|
214
|
+
salvageError TEXT,
|
|
215
|
+
salvageAckAt INTEGER,
|
|
149
216
|
startedAt INTEGER NOT NULL,
|
|
150
217
|
endedAt INTEGER,
|
|
151
|
-
lastError TEXT
|
|
218
|
+
lastError TEXT,
|
|
219
|
+
settlementFlags TEXT
|
|
152
220
|
);
|
|
153
221
|
CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
|
|
154
222
|
CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
|
|
@@ -183,6 +251,72 @@ CREATE TABLE IF NOT EXISTS friction_surfaces (
|
|
|
183
251
|
at INTEGER NOT NULL,
|
|
184
252
|
PRIMARY KEY (project, kind)
|
|
185
253
|
);
|
|
254
|
+
|
|
255
|
+
-- The report outbox (#123). Deliberately NOT an extension of notifications:
|
|
256
|
+
-- that table's primary key is its dedupe key, so it cannot hold two attempts of
|
|
257
|
+
-- one report, and widening it would put escalation dedupe and report delivery
|
|
258
|
+
-- on the same row. This one is keyed by report id and carries delivery state,
|
|
259
|
+
-- the attempt in flight, and the message id Telegram actually returned.
|
|
260
|
+
--
|
|
261
|
+
-- The partial unique index is the digest guard: a digest:<day> key can exist at
|
|
262
|
+
-- most once per project, so "has today's digest been handed over?" is a question
|
|
263
|
+
-- the ledger answers rather than the model's memory of the last tick. Material
|
|
264
|
+
-- reports carry no key and are never suppressed.
|
|
265
|
+
CREATE TABLE IF NOT EXISTS reports (
|
|
266
|
+
id TEXT PRIMARY KEY,
|
|
267
|
+
project TEXT NOT NULL,
|
|
268
|
+
kind TEXT NOT NULL,
|
|
269
|
+
body TEXT NOT NULL,
|
|
270
|
+
state TEXT NOT NULL,
|
|
271
|
+
attempts INTEGER NOT NULL,
|
|
272
|
+
attemptId TEXT,
|
|
273
|
+
ambiguous INTEGER NOT NULL,
|
|
274
|
+
dedupeKey TEXT,
|
|
275
|
+
createdAt INTEGER NOT NULL,
|
|
276
|
+
updatedAt INTEGER NOT NULL,
|
|
277
|
+
nextAttemptAt INTEGER NOT NULL,
|
|
278
|
+
messageId INTEGER,
|
|
279
|
+
deliveredAt INTEGER,
|
|
280
|
+
lastError TEXT
|
|
281
|
+
);
|
|
282
|
+
CREATE UNIQUE INDEX IF NOT EXISTS reports_dedupe
|
|
283
|
+
ON reports (project, dedupeKey) WHERE dedupeKey IS NOT NULL;
|
|
284
|
+
CREATE INDEX IF NOT EXISTS reports_project_state
|
|
285
|
+
ON reports (project, state, nextAttemptAt);
|
|
286
|
+
|
|
287
|
+
-- The action ledger for the conductor-owned mutation verbs (#126). Every
|
|
288
|
+
-- decided call lands here, refusals included: the question an escalation asks
|
|
289
|
+
-- is "what did this run try", and a table that only records what succeeded
|
|
290
|
+
-- cannot answer it. The 'args' column is the payload verbatim, so the row is
|
|
291
|
+
-- evidence rather than a summary of evidence.
|
|
292
|
+
CREATE TABLE IF NOT EXISTS verb_ledger (
|
|
293
|
+
id TEXT PRIMARY KEY,
|
|
294
|
+
project TEXT NOT NULL,
|
|
295
|
+
runId TEXT,
|
|
296
|
+
issue INTEGER,
|
|
297
|
+
verb TEXT NOT NULL,
|
|
298
|
+
role TEXT NOT NULL,
|
|
299
|
+
args TEXT NOT NULL,
|
|
300
|
+
decision TEXT NOT NULL,
|
|
301
|
+
refusal TEXT,
|
|
302
|
+
detail TEXT NOT NULL,
|
|
303
|
+
sha TEXT,
|
|
304
|
+
at INTEGER NOT NULL
|
|
305
|
+
);
|
|
306
|
+
CREATE INDEX IF NOT EXISTS verb_ledger_project_at ON verb_ledger (project, at);
|
|
307
|
+
CREATE INDEX IF NOT EXISTS verb_ledger_run ON verb_ledger (runId, at);
|
|
308
|
+
|
|
309
|
+
-- One merge in flight per project (#126). A table rather than a mutex because
|
|
310
|
+
-- the CLI, the daemon and every session are separate processes: an in-process
|
|
311
|
+
-- lock serialises one process against itself and nothing else, which is the
|
|
312
|
+
-- shape "one merge in flight" had on main -- none at all. The project is the
|
|
313
|
+
-- primary key, so the claim is the row's existence and the race is SQLite's.
|
|
314
|
+
CREATE TABLE IF NOT EXISTS merge_locks (
|
|
315
|
+
project TEXT PRIMARY KEY,
|
|
316
|
+
holder TEXT NOT NULL,
|
|
317
|
+
prUrl TEXT NOT NULL,
|
|
318
|
+
at INTEGER NOT NULL
|
|
319
|
+
);
|
|
186
320
|
`;
|
|
187
321
|
|
|
188
322
|
/**
|
|
@@ -192,9 +326,41 @@ CREATE TABLE IF NOT EXISTS friction_surfaces (
|
|
|
192
326
|
function toSql(value: unknown): SqlValue {
|
|
193
327
|
if (value === undefined || value === null) return null;
|
|
194
328
|
if (typeof value === "boolean") return value ? 1 : 0;
|
|
329
|
+
// `RunRecord.settlementFlags` is the only structured field on a run row, and
|
|
330
|
+
// it is JSON in one TEXT column rather than a table of its own: the flags are
|
|
331
|
+
// 1:1 with the run, written once at settlement, and never read except beside
|
|
332
|
+
// the row they belong to. A join per `status` render would buy a query nobody
|
|
333
|
+
// makes (#128).
|
|
334
|
+
if (Array.isArray(value)) return JSON.stringify(value);
|
|
195
335
|
return value as SqlValue;
|
|
196
336
|
}
|
|
197
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Settlement flags out of their column, or undefined for anything that is not
|
|
340
|
+
* an array of flag-shaped objects.
|
|
341
|
+
*
|
|
342
|
+
* Same posture as {@link toDispatchSummary}: this is advisory evidence, so a
|
|
343
|
+
* row hand-edited or written by a future version drops its flags rather than
|
|
344
|
+
* throwing and taking `status` down with it.
|
|
345
|
+
*/
|
|
346
|
+
function toSettlementFlags(text: string): SettlementFlag[] | undefined {
|
|
347
|
+
try {
|
|
348
|
+
const value: unknown = JSON.parse(text);
|
|
349
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
350
|
+
const flags = value.filter(
|
|
351
|
+
(flag): flag is SettlementFlag =>
|
|
352
|
+
typeof flag === "object" &&
|
|
353
|
+
flag !== null &&
|
|
354
|
+
typeof (flag as SettlementFlag).kind === "string" &&
|
|
355
|
+
typeof (flag as SettlementFlag).file === "string" &&
|
|
356
|
+
typeof (flag as SettlementFlag).detail === "string",
|
|
357
|
+
);
|
|
358
|
+
return flags.length === 0 ? undefined : flags;
|
|
359
|
+
} catch {
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
198
364
|
/**
|
|
199
365
|
* A NULL column becomes an absent property rather than an `undefined` one, so
|
|
200
366
|
* a record read back out of the store deep-equals the one that went in.
|
|
@@ -217,11 +383,87 @@ function toRecord(row: RunRow): RunRecord {
|
|
|
217
383
|
if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
|
|
218
384
|
if (row.prUrl !== null) record.prUrl = row.prUrl;
|
|
219
385
|
if (row.headSha !== null) record.headSha = row.headSha;
|
|
386
|
+
if (row.salvageSha !== null) record.salvageSha = row.salvageSha;
|
|
387
|
+
if (row.salvageError !== null) record.salvageError = row.salvageError;
|
|
388
|
+
if (row.salvageAckAt !== null) record.salvageAckAt = row.salvageAckAt;
|
|
220
389
|
if (row.endedAt !== null) record.endedAt = row.endedAt;
|
|
221
390
|
if (row.lastError !== null) record.lastError = row.lastError;
|
|
391
|
+
if (row.settlementFlags !== null) {
|
|
392
|
+
const flags = toSettlementFlags(row.settlementFlags);
|
|
393
|
+
if (flags !== undefined) record.settlementFlags = flags;
|
|
394
|
+
}
|
|
395
|
+
return record;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* NULL columns become absent properties, matching {@link toRecord}: a report
|
|
400
|
+
* read back out of the store deep-equals the one that went in.
|
|
401
|
+
*/
|
|
402
|
+
function toReport(row: ReportRow): ReportRecord {
|
|
403
|
+
const record: ReportRecord = {
|
|
404
|
+
id: row.id,
|
|
405
|
+
project: row.project,
|
|
406
|
+
kind: row.kind as ReportKind,
|
|
407
|
+
body: row.body,
|
|
408
|
+
state: row.state as ReportDeliveryState,
|
|
409
|
+
attempts: row.attempts,
|
|
410
|
+
ambiguous: row.ambiguous !== 0,
|
|
411
|
+
createdAt: row.createdAt,
|
|
412
|
+
updatedAt: row.updatedAt,
|
|
413
|
+
nextAttemptAt: row.nextAttemptAt,
|
|
414
|
+
};
|
|
415
|
+
if (row.attemptId !== null) record.attemptId = row.attemptId;
|
|
416
|
+
if (row.dedupeKey !== null) record.dedupeKey = row.dedupeKey;
|
|
417
|
+
if (row.messageId !== null) record.messageId = row.messageId;
|
|
418
|
+
if (row.deliveredAt !== null) record.deliveredAt = row.deliveredAt;
|
|
419
|
+
if (row.lastError !== null) record.lastError = row.lastError;
|
|
222
420
|
return record;
|
|
223
421
|
}
|
|
224
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Long enough that a collision is not a thing anyone has to handle, short
|
|
425
|
+
* enough that an operator can compare it against the id printed in a Telegram
|
|
426
|
+
* message by eye — which is the only reconciliation this transport allows.
|
|
427
|
+
*/
|
|
428
|
+
function newReportId(): string {
|
|
429
|
+
return crypto.randomUUID().replaceAll("-", "").slice(0, 12);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* A ledger row, with the same NULL-becomes-absent posture as {@link toReport}.
|
|
434
|
+
*
|
|
435
|
+
* `args` is stored as JSON and read back defensively: a row whose payload no
|
|
436
|
+
* longer parses is still a row that says which verb was called and how it was
|
|
437
|
+
* decided, and losing the whole entry to a malformed blob would erase exactly
|
|
438
|
+
* the call somebody is trying to audit.
|
|
439
|
+
*/
|
|
440
|
+
function toVerbLedgerEntry(row: VerbLedgerRow): VerbLedgerEntry {
|
|
441
|
+
let args: Record<string, unknown> = {};
|
|
442
|
+
try {
|
|
443
|
+
const parsed: unknown = JSON.parse(row.args);
|
|
444
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
445
|
+
args = parsed as Record<string, unknown>;
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
args = { "(unreadable)": row.args };
|
|
449
|
+
}
|
|
450
|
+
const entry: VerbLedgerEntry = {
|
|
451
|
+
id: row.id,
|
|
452
|
+
project: row.project,
|
|
453
|
+
verb: row.verb as VerbName,
|
|
454
|
+
role: row.role as SessionRole,
|
|
455
|
+
args,
|
|
456
|
+
decision: row.decision as VerbDecision,
|
|
457
|
+
detail: row.detail,
|
|
458
|
+
at: row.at,
|
|
459
|
+
};
|
|
460
|
+
if (row.runId !== null) entry.runId = row.runId;
|
|
461
|
+
if (row.issue !== null) entry.issue = row.issue;
|
|
462
|
+
if (row.refusal !== null) entry.refusal = row.refusal as VerbRefusal;
|
|
463
|
+
if (row.sha !== null) entry.sha = row.sha;
|
|
464
|
+
return entry;
|
|
465
|
+
}
|
|
466
|
+
|
|
225
467
|
function toDispatchSummary(text: string): DispatchSummary | undefined {
|
|
226
468
|
try {
|
|
227
469
|
const value = JSON.parse(text) as DispatchSummary;
|
|
@@ -324,12 +566,34 @@ export function openStore(dbPath: string): Store {
|
|
|
324
566
|
if (!columns.some((column) => column.name === "headSha")) {
|
|
325
567
|
db.exec("ALTER TABLE runs ADD COLUMN headSha TEXT");
|
|
326
568
|
}
|
|
569
|
+
// v0.3.22 and earlier removed a blocked run's dirty tree without saving it
|
|
570
|
+
// (#118), so no row before this release has anywhere to record where the
|
|
571
|
+
// work went. A NULL `salvageSha` on a historical row therefore means "never
|
|
572
|
+
// asked", which reads the same as "clean tree" — the honest reading for a
|
|
573
|
+
// release that never salvaged one.
|
|
574
|
+
for (const [name, type] of [
|
|
575
|
+
["salvageSha", "TEXT"],
|
|
576
|
+
["salvageError", "TEXT"],
|
|
577
|
+
["salvageAckAt", "INTEGER"],
|
|
578
|
+
] as const) {
|
|
579
|
+
if (!columns.some((column) => column.name === name)) {
|
|
580
|
+
db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
// Rows written before the settlement audit existed (#128) were never audited,
|
|
584
|
+
// so a NULL here means "no audit ran" and reads the same as "found nothing".
|
|
585
|
+
// That conflation is deliberate and harmless: the flags are advisory, and no
|
|
586
|
+
// decision anywhere is taken on their absence.
|
|
587
|
+
if (!columns.some((column) => column.name === "settlementFlags")) {
|
|
588
|
+
db.exec("ALTER TABLE runs ADD COLUMN settlementFlags TEXT");
|
|
589
|
+
}
|
|
327
590
|
|
|
328
591
|
const insertRun = db.query<unknown, SqlValue[]>(
|
|
329
592
|
`INSERT INTO runs (
|
|
330
593
|
id, project, issue, repo, branch, worktree, state, attempt, turns,
|
|
331
|
-
maxTurns, spendUsd, sessionFile, prUrl, headSha,
|
|
332
|
-
|
|
594
|
+
maxTurns, spendUsd, sessionFile, prUrl, headSha, salvageSha, salvageError,
|
|
595
|
+
salvageAckAt, startedAt, endedAt, lastError, settlementFlags
|
|
596
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
333
597
|
);
|
|
334
598
|
const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
|
|
335
599
|
const selectActive = db.query<RunRow, SqlValue[]>(
|
|
@@ -370,6 +634,21 @@ export function openStore(dbPath: string): Store {
|
|
|
370
634
|
`SELECT COUNT(*) AS n FROM runs
|
|
371
635
|
WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')`,
|
|
372
636
|
);
|
|
637
|
+
// Salvage state that still describes an issue's present, so an operator is
|
|
638
|
+
// shown a preserved WIP tip exactly while it is the thing a re-claim would
|
|
639
|
+
// build on — and an unsalvaged tree for as long as it is the only copy.
|
|
640
|
+
// Newest-attempt-only for the same reason the board is: a later attempt has
|
|
641
|
+
// already consumed or superseded whatever an older one left behind.
|
|
642
|
+
const selectSalvaged = db.query<RunRow, [string]>(
|
|
643
|
+
`SELECT * FROM runs
|
|
644
|
+
WHERE rowid IN (
|
|
645
|
+
SELECT MAX(rowid) FROM runs
|
|
646
|
+
WHERE project = ?
|
|
647
|
+
GROUP BY issue
|
|
648
|
+
)
|
|
649
|
+
AND (salvageSha IS NOT NULL OR salvageError IS NOT NULL)
|
|
650
|
+
ORDER BY issue ASC`,
|
|
651
|
+
);
|
|
373
652
|
// Newest attempt for one issue. `startedAt` is millisecond-resolution and two
|
|
374
653
|
// attempts could in principle share one, so rowid breaks the tie by insertion
|
|
375
654
|
// order — a `tail` that attached to the older of two same-millisecond attempts
|
|
@@ -432,6 +711,106 @@ export function openStore(dbPath: string): Store {
|
|
|
432
711
|
ON CONFLICT(project, kind) DO UPDATE SET at = excluded.at`,
|
|
433
712
|
);
|
|
434
713
|
|
|
714
|
+
// The report outbox (#123). Every terminal transition names the attempt it is
|
|
715
|
+
// settling, because a request can come back *after* the stale-`sending` sweep
|
|
716
|
+
// has already reclaimed its row — and a late answer must not overwrite a
|
|
717
|
+
// newer attempt's outcome. That predicate is also what stops one report being
|
|
718
|
+
// concurrently in flight twice: only one claim can move a `pending` row.
|
|
719
|
+
const insertReport = db.query<unknown, SqlValue[]>(
|
|
720
|
+
`INSERT OR IGNORE INTO reports (
|
|
721
|
+
id, project, kind, body, state, attempts, attemptId, ambiguous, dedupeKey,
|
|
722
|
+
createdAt, updatedAt, nextAttemptAt, messageId, deliveredAt, lastError
|
|
723
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
724
|
+
);
|
|
725
|
+
const selectReport = db.query<ReportRow, [string]>(`SELECT * FROM reports WHERE id = ?`);
|
|
726
|
+
const selectReportByDedupe = db.query<ReportRow, [string, string]>(
|
|
727
|
+
`SELECT * FROM reports WHERE project = ? AND dedupeKey = ?`,
|
|
728
|
+
);
|
|
729
|
+
const selectDueReports = db.query<ReportRow, [string, number, number]>(
|
|
730
|
+
`SELECT * FROM reports
|
|
731
|
+
WHERE project = ? AND state = 'pending' AND nextAttemptAt <= ?
|
|
732
|
+
ORDER BY createdAt ASC, rowid ASC
|
|
733
|
+
LIMIT ?`,
|
|
734
|
+
);
|
|
735
|
+
const claimReportRow = db.query<unknown, [string, number, string, number]>(
|
|
736
|
+
`UPDATE reports
|
|
737
|
+
SET state = 'sending', attemptId = ?, attempts = attempts + 1, updatedAt = ?
|
|
738
|
+
WHERE id = ? AND state = 'pending' AND nextAttemptAt <= ?`,
|
|
739
|
+
);
|
|
740
|
+
const deliverReportRow = db.query<unknown, SqlValue[]>(
|
|
741
|
+
`UPDATE reports
|
|
742
|
+
SET state = 'delivered', attemptId = NULL, messageId = ?, deliveredAt = ?,
|
|
743
|
+
updatedAt = ?, lastError = NULL
|
|
744
|
+
WHERE id = ? AND state = 'sending' AND attemptId = ?`,
|
|
745
|
+
);
|
|
746
|
+
const requeueReportRow = db.query<unknown, [number, string, number, string, string]>(
|
|
747
|
+
`UPDATE reports
|
|
748
|
+
SET state = 'pending', attemptId = NULL, nextAttemptAt = ?, lastError = ?, updatedAt = ?
|
|
749
|
+
WHERE id = ? AND state = 'sending' AND attemptId = ?`,
|
|
750
|
+
);
|
|
751
|
+
// Stays `sending` and keeps its attempt id: the request is over but its
|
|
752
|
+
// outcome is not known, which is precisely what `sending` means. `updatedAt`
|
|
753
|
+
// is left alone so the stale window keeps running from when the request left.
|
|
754
|
+
const uncertainReportRow = db.query<unknown, [string, string, string]>(
|
|
755
|
+
`UPDATE reports
|
|
756
|
+
SET ambiguous = 1, lastError = ?
|
|
757
|
+
WHERE id = ? AND state = 'sending' AND attemptId = ?`,
|
|
758
|
+
);
|
|
759
|
+
const failReportRow = db.query<unknown, [string, number, string, string]>(
|
|
760
|
+
`UPDATE reports
|
|
761
|
+
SET state = 'failed', attemptId = NULL, lastError = ?, updatedAt = ?
|
|
762
|
+
WHERE id = ? AND state = 'sending' AND attemptId = ?`,
|
|
763
|
+
);
|
|
764
|
+
const selectStaleSending = db.query<ReportRow, [string, number]>(
|
|
765
|
+
`SELECT * FROM reports
|
|
766
|
+
WHERE project = ? AND state = 'sending' AND updatedAt <= ?
|
|
767
|
+
ORDER BY createdAt ASC, rowid ASC`,
|
|
768
|
+
);
|
|
769
|
+
const recoverSendingRows = db.query<unknown, [number, number, string, number]>(
|
|
770
|
+
`UPDATE reports
|
|
771
|
+
SET state = 'pending', attemptId = NULL, ambiguous = 1, nextAttemptAt = ?, updatedAt = ?
|
|
772
|
+
WHERE project = ? AND state = 'sending' AND updatedAt <= ?`,
|
|
773
|
+
);
|
|
774
|
+
const selectOpenReports = db.query<ReportRow, [string]>(
|
|
775
|
+
`SELECT * FROM reports
|
|
776
|
+
WHERE project = ? AND state IN ('pending', 'sending', 'failed')
|
|
777
|
+
ORDER BY createdAt ASC, rowid ASC`,
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
const insertVerbLedger = db.query<unknown, SqlValue[]>(
|
|
781
|
+
`INSERT INTO verb_ledger
|
|
782
|
+
(id, project, runId, issue, verb, role, args, decision, refusal, detail, sha, at)
|
|
783
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
784
|
+
);
|
|
785
|
+
const selectVerbLedger = db.query<VerbLedgerRow, [string, string, string, number, number, number]>(
|
|
786
|
+
`SELECT * FROM verb_ledger
|
|
787
|
+
WHERE project = ?
|
|
788
|
+
AND (? = '' OR runId = ?)
|
|
789
|
+
AND (? = 0 OR issue = ?)
|
|
790
|
+
ORDER BY at DESC, rowid DESC
|
|
791
|
+
LIMIT ?`,
|
|
792
|
+
);
|
|
793
|
+
// Breaking a stale claim and taking it must be one transaction, or two
|
|
794
|
+
// daemons both see the stale row, both delete it, and both insert.
|
|
795
|
+
const breakStaleMergeLock = db.query<unknown, [string, number]>(
|
|
796
|
+
`DELETE FROM merge_locks WHERE project = ? AND at <= ?`,
|
|
797
|
+
);
|
|
798
|
+
const claimMergeLock = db.query<unknown, [string, string, string, number]>(
|
|
799
|
+
`INSERT OR IGNORE INTO merge_locks (project, holder, prUrl, at) VALUES (?, ?, ?, ?)`,
|
|
800
|
+
);
|
|
801
|
+
const dropMergeLock = db.query<unknown, [string, string]>(
|
|
802
|
+
`DELETE FROM merge_locks WHERE project = ? AND holder = ?`,
|
|
803
|
+
);
|
|
804
|
+
const selectMergeLock = db.query<MergeLockRow, [string]>(
|
|
805
|
+
`SELECT * FROM merge_locks WHERE project = ?`,
|
|
806
|
+
);
|
|
807
|
+
const takeMergeLock = db.transaction(
|
|
808
|
+
(project: string, holder: string, prUrl: string, now: number, staleAt: number): boolean => {
|
|
809
|
+
breakStaleMergeLock.run(project, staleAt);
|
|
810
|
+
return claimMergeLock.run(project, holder, prUrl, now).changes > 0;
|
|
811
|
+
},
|
|
812
|
+
);
|
|
813
|
+
|
|
435
814
|
const recordFriction = (project: string, observation: FrictionObservation): void => {
|
|
436
815
|
if (
|
|
437
816
|
!Number.isSafeInteger(observation.occurrences) ||
|
|
@@ -487,9 +866,13 @@ export function openStore(dbPath: string): Store {
|
|
|
487
866
|
toSql(record.sessionFile),
|
|
488
867
|
toSql(record.prUrl),
|
|
489
868
|
toSql(record.headSha),
|
|
869
|
+
toSql(record.salvageSha),
|
|
870
|
+
toSql(record.salvageError),
|
|
871
|
+
toSql(record.salvageAckAt),
|
|
490
872
|
record.startedAt,
|
|
491
873
|
toSql(record.endedAt),
|
|
492
874
|
toSql(record.lastError),
|
|
875
|
+
toSql(record.settlementFlags),
|
|
493
876
|
);
|
|
494
877
|
return record;
|
|
495
878
|
},
|
|
@@ -532,6 +915,10 @@ export function openStore(dbPath: string): Store {
|
|
|
532
915
|
return selectRecentRuns.all(project, mergedSinceEpochMs).map(toRecord);
|
|
533
916
|
},
|
|
534
917
|
|
|
918
|
+
salvagedRuns(project: string): RunRecord[] {
|
|
919
|
+
return selectSalvaged.all(project).map(toRecord);
|
|
920
|
+
},
|
|
921
|
+
|
|
535
922
|
attemptsFor(project: string, issue: number): number {
|
|
536
923
|
return countAttempts.get(project, issue)?.n ?? 0;
|
|
537
924
|
},
|
|
@@ -653,6 +1040,171 @@ export function openStore(dbPath: string): Store {
|
|
|
653
1040
|
for (const kind of new Set(kinds)) upsertFrictionSurface.run(project, kind, at);
|
|
654
1041
|
},
|
|
655
1042
|
|
|
1043
|
+
enqueueReport(draft: ReportDraft): ReportEnqueue {
|
|
1044
|
+
const report: ReportRecord = {
|
|
1045
|
+
id: newReportId(),
|
|
1046
|
+
project: draft.project,
|
|
1047
|
+
kind: draft.kind,
|
|
1048
|
+
body: draft.body,
|
|
1049
|
+
state: "pending",
|
|
1050
|
+
attempts: 0,
|
|
1051
|
+
ambiguous: false,
|
|
1052
|
+
...(draft.dedupeKey === undefined ? {} : { dedupeKey: draft.dedupeKey }),
|
|
1053
|
+
createdAt: draft.at,
|
|
1054
|
+
updatedAt: draft.at,
|
|
1055
|
+
// Due immediately. A backoff nobody has earned yet would delay the
|
|
1056
|
+
// first attempt at a report an operator is already waiting for.
|
|
1057
|
+
nextAttemptAt: draft.at,
|
|
1058
|
+
};
|
|
1059
|
+
const inserted = insertReport.run(
|
|
1060
|
+
report.id,
|
|
1061
|
+
report.project,
|
|
1062
|
+
report.kind,
|
|
1063
|
+
report.body,
|
|
1064
|
+
report.state,
|
|
1065
|
+
report.attempts,
|
|
1066
|
+
null,
|
|
1067
|
+
0,
|
|
1068
|
+
toSql(draft.dedupeKey),
|
|
1069
|
+
report.createdAt,
|
|
1070
|
+
report.updatedAt,
|
|
1071
|
+
report.nextAttemptAt,
|
|
1072
|
+
null,
|
|
1073
|
+
null,
|
|
1074
|
+
null,
|
|
1075
|
+
);
|
|
1076
|
+
if (inserted.changes > 0) return { report, deduped: false };
|
|
1077
|
+
// `INSERT OR IGNORE` swallowed a conflict, and the only conflict this
|
|
1078
|
+
// table has is the digest guard — a 48-bit id collision is not a thing
|
|
1079
|
+
// anyone needs to handle. Return the row that won rather than the one
|
|
1080
|
+
// that lost, so whoever handed the digest over is told the truth about
|
|
1081
|
+
// which report their operator will actually receive.
|
|
1082
|
+
const existing =
|
|
1083
|
+
draft.dedupeKey === undefined ? null : selectReportByDedupe.get(draft.project, draft.dedupeKey);
|
|
1084
|
+
if (existing === null) {
|
|
1085
|
+
throw new Error(`report ${report.id} could not be enqueued for ${draft.project}`);
|
|
1086
|
+
}
|
|
1087
|
+
return { report: toReport(existing), deduped: true };
|
|
1088
|
+
},
|
|
1089
|
+
|
|
1090
|
+
getReport(id: string): ReportRecord | undefined {
|
|
1091
|
+
const row = selectReport.get(id);
|
|
1092
|
+
return row === null ? undefined : toReport(row);
|
|
1093
|
+
},
|
|
1094
|
+
|
|
1095
|
+
dueReports(project: string, now: number, limit: number): ReportRecord[] {
|
|
1096
|
+
return selectDueReports.all(project, now, limit).map(toReport);
|
|
1097
|
+
},
|
|
1098
|
+
|
|
1099
|
+
claimReport(id: string, attemptId: string, at: number): ReportRecord | undefined {
|
|
1100
|
+
// The row is written `sending` *before* the caller's request leaves, so a
|
|
1101
|
+
// crash in the window leaves an explicitly ambiguous row rather than a
|
|
1102
|
+
// silently lost one. Losing the race is normal, not an error: it means
|
|
1103
|
+
// another pass already owns this attempt.
|
|
1104
|
+
if (claimReportRow.run(attemptId, at, id, at).changes === 0) return undefined;
|
|
1105
|
+
const row = selectReport.get(id);
|
|
1106
|
+
return row === null ? undefined : toReport(row);
|
|
1107
|
+
},
|
|
1108
|
+
|
|
1109
|
+
markReportDelivered(
|
|
1110
|
+
id: string,
|
|
1111
|
+
attemptId: string,
|
|
1112
|
+
messageId: number | undefined,
|
|
1113
|
+
at: number,
|
|
1114
|
+
): boolean {
|
|
1115
|
+
return (
|
|
1116
|
+
deliverReportRow.run(toSql(messageId), at, at, id, attemptId).changes > 0
|
|
1117
|
+
);
|
|
1118
|
+
},
|
|
1119
|
+
|
|
1120
|
+
markReportUncertain(id: string, attemptId: string, error: string): boolean {
|
|
1121
|
+
return uncertainReportRow.run(error, id, attemptId).changes > 0;
|
|
1122
|
+
},
|
|
1123
|
+
|
|
1124
|
+
markReportPending(
|
|
1125
|
+
id: string,
|
|
1126
|
+
attemptId: string,
|
|
1127
|
+
nextAttemptAt: number,
|
|
1128
|
+
error: string,
|
|
1129
|
+
at: number,
|
|
1130
|
+
): boolean {
|
|
1131
|
+
return requeueReportRow.run(nextAttemptAt, error, at, id, attemptId).changes > 0;
|
|
1132
|
+
},
|
|
1133
|
+
|
|
1134
|
+
markReportFailed(id: string, attemptId: string, error: string, at: number): boolean {
|
|
1135
|
+
return failReportRow.run(error, at, id, attemptId).changes > 0;
|
|
1136
|
+
},
|
|
1137
|
+
|
|
1138
|
+
recoverSendingReports(project: string, staleAt: number, at: number): ReportRecord[] {
|
|
1139
|
+
const stale = selectStaleSending.all(project, staleAt);
|
|
1140
|
+
if (stale.length === 0) return [];
|
|
1141
|
+
recoverSendingRows.run(at, at, project, staleAt);
|
|
1142
|
+
// Re-read rather than patching the pre-update rows: the caller logs and
|
|
1143
|
+
// renders these, and a row that says `sending` while the ledger says
|
|
1144
|
+
// `pending` is exactly the kind of quiet disagreement #123 is about.
|
|
1145
|
+
return stale
|
|
1146
|
+
.map((row) => selectReport.get(row.id))
|
|
1147
|
+
.filter((row): row is ReportRow => row !== null)
|
|
1148
|
+
.map(toReport);
|
|
1149
|
+
},
|
|
1150
|
+
|
|
1151
|
+
openReports(project: string): ReportRecord[] {
|
|
1152
|
+
return selectOpenReports.all(project).map(toReport);
|
|
1153
|
+
},
|
|
1154
|
+
|
|
1155
|
+
appendVerbLedger(draft: VerbLedgerDraft): VerbLedgerEntry {
|
|
1156
|
+
const entry: VerbLedgerEntry = { ...draft, id: crypto.randomUUID(), at: Date.now() };
|
|
1157
|
+
insertVerbLedger.run(
|
|
1158
|
+
entry.id,
|
|
1159
|
+
entry.project,
|
|
1160
|
+
toSql(entry.runId),
|
|
1161
|
+
toSql(entry.issue),
|
|
1162
|
+
entry.verb,
|
|
1163
|
+
entry.role,
|
|
1164
|
+
JSON.stringify(entry.args),
|
|
1165
|
+
entry.decision,
|
|
1166
|
+
toSql(entry.refusal),
|
|
1167
|
+
entry.detail,
|
|
1168
|
+
toSql(entry.sha),
|
|
1169
|
+
entry.at,
|
|
1170
|
+
);
|
|
1171
|
+
return entry;
|
|
1172
|
+
},
|
|
1173
|
+
|
|
1174
|
+
verbLedger(
|
|
1175
|
+
project: string,
|
|
1176
|
+
opts: { runId?: string; issue?: number; limit?: number } = {},
|
|
1177
|
+
): VerbLedgerEntry[] {
|
|
1178
|
+
// Sentinels rather than a composed SQL string: the filters are optional,
|
|
1179
|
+
// and building the WHERE clause by concatenation is how a project name
|
|
1180
|
+
// ends up interpolated into a query in a package that otherwise binds
|
|
1181
|
+
// every value (see `UPDATABLE_COLUMNS` for the one place that cannot).
|
|
1182
|
+
const runId = opts.runId ?? "";
|
|
1183
|
+
const issue = opts.issue ?? 0;
|
|
1184
|
+
const limit = opts.limit ?? 50;
|
|
1185
|
+
return selectVerbLedger.all(project, runId, runId, issue, issue, limit).map(toVerbLedgerEntry);
|
|
1186
|
+
},
|
|
1187
|
+
|
|
1188
|
+
acquireMergeLock(
|
|
1189
|
+
project: string,
|
|
1190
|
+
holder: string,
|
|
1191
|
+
prUrl: string,
|
|
1192
|
+
now: number,
|
|
1193
|
+
staleAfterMs: number,
|
|
1194
|
+
): MergeLock | undefined {
|
|
1195
|
+
if (!takeMergeLock(project, holder, prUrl, now, now - staleAfterMs)) return undefined;
|
|
1196
|
+
return { project, holder, prUrl, at: now };
|
|
1197
|
+
},
|
|
1198
|
+
|
|
1199
|
+
releaseMergeLock(project: string, holder: string): void {
|
|
1200
|
+
dropMergeLock.run(project, holder);
|
|
1201
|
+
},
|
|
1202
|
+
|
|
1203
|
+
mergeLock(project: string): MergeLock | undefined {
|
|
1204
|
+
const row = selectMergeLock.get(project);
|
|
1205
|
+
return row === null ? undefined : { ...row };
|
|
1206
|
+
},
|
|
1207
|
+
|
|
656
1208
|
close(): void {
|
|
657
1209
|
db.close(false);
|
|
658
1210
|
},
|