omp-conductor 0.3.18 → 0.3.20

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/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 type { RunRecord, RunState, Store } from "./types.ts";
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. `pushed-green` belongs here but not in
30
- * {@link LIVE_STATES}: its worker is finished and its worktree already removed,
31
- * so it must not consume a slot two green PRs awaiting a human merge would
32
- * otherwise stop the whole fleet — but its issue has a live PR that a second
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
  },