omp-conductor 0.19.7 → 0.20.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.
Files changed (71) hide show
  1. package/REFERENCE.md +10 -1
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/admission.ts +58 -14
  6. package/src/arm-challenge.ts +255 -85
  7. package/src/ask.ts +130 -615
  8. package/src/board.ts +7 -1
  9. package/src/brief-upgrade.ts +24 -0
  10. package/src/briefs/console.md +258 -0
  11. package/src/briefs/correction.md +203 -0
  12. package/src/briefs/orchestrator.md +167 -97
  13. package/src/briefs/policy.md +19 -16
  14. package/src/briefs/to-spec.md +76 -9
  15. package/src/briefs/worker.md +50 -16
  16. package/src/cli.ts +4 -0
  17. package/src/command-manifest.ts +54 -8
  18. package/src/commands/arm.ts +115 -49
  19. package/src/commands/console.ts +70 -0
  20. package/src/commands/context.ts +2 -0
  21. package/src/commands/epic.ts +132 -0
  22. package/src/commands/extend.ts +9 -1
  23. package/src/commands/intake.ts +44 -14
  24. package/src/commands/stats.ts +19 -4
  25. package/src/commands/worker.ts +9 -1
  26. package/src/config-schema.ts +13 -0
  27. package/src/config.ts +27 -0
  28. package/src/daemon/ack.ts +159 -0
  29. package/src/daemon/admission-pass.ts +135 -0
  30. package/src/daemon/brief.ts +461 -0
  31. package/src/daemon/deps.ts +539 -0
  32. package/src/daemon/dispatch.ts +1779 -0
  33. package/src/daemon/drain.ts +185 -0
  34. package/src/daemon/groom-pass.ts +422 -0
  35. package/src/daemon/http.ts +417 -0
  36. package/src/daemon/integrity.ts +108 -0
  37. package/src/daemon/panes.ts +180 -0
  38. package/src/daemon/review.ts +1888 -0
  39. package/src/daemon/runtime.ts +788 -0
  40. package/src/daemon/settle-pass.ts +606 -0
  41. package/src/daemon/supervision.ts +438 -0
  42. package/src/daemon/tick.ts +968 -0
  43. package/src/daemon/views.ts +751 -0
  44. package/src/daemon.ts +105 -7923
  45. package/src/dashboard/app.js +58 -0
  46. package/src/dashboard/controls.ts +22 -3
  47. package/src/dashboard/server.ts +4 -0
  48. package/src/diff-flags.ts +135 -9
  49. package/src/doctor.ts +2 -2
  50. package/src/failure-class.ts +257 -2
  51. package/src/fleet.ts +295 -176
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +689 -1670
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +107 -11
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +169 -14
  64. package/src/store.ts +618 -28
  65. package/src/to-spec.ts +426 -44
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +434 -18
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +330 -39
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +570 -1
@@ -0,0 +1,606 @@
1
+ /**
2
+ * The dispatcher's caller side of settlement: the periodic sweeps that watch
3
+ * what happened to work already merged or already ended.
4
+ *
5
+ * Named `settle-pass` against the top-level `settlement.ts` because they are
6
+ * two different jobs. `settlement.ts` settles *one* run — it is called at the
7
+ * end of a launch and knows a worker's outcome. Everything here is a sweep with
8
+ * a cursor and a batch size: the merged base's CI, the configured base's health,
9
+ * retained worktrees past their keep window, and the historical-infra backfill
10
+ * that reclassifies rows settled before the classifier knew about them.
11
+ *
12
+ * They belong together because they share one shape and one cost model — bounded
13
+ * GitHub reads per tick against a persisted cursor — and because none of them
14
+ * may ever block a dispatch: a sweep that fell behind must cost the next tick a
15
+ * batch, never a run.
16
+ */
17
+ import { infraLogSignature, infraSignatureVersion } from "../failure-class.ts";
18
+ import { errText, log, safeEscalate } from "../log.ts";
19
+ import { GhPrMissingError } from "../tracker/github.ts";
20
+ import type { BaseHealth, RunRecord, SettlementFlag, WorkflowRun } from "../types.ts";
21
+ import { cleanupRetainedWorktree, mirrorPathFor, type RetainedWorktreeCleanup } from "../worktree.ts";
22
+ import { githubRepo, type Deps, type RetainedCleanupCursor } from "./deps.ts";
23
+
24
+ export const BASE_CHECK_BATCH = 20;
25
+ export const BASE_CHECK_WINDOW_MS = 24 * 60 * 60 * 1_000;
26
+ export const BASE_STATUS_WINDOW_MS = 7 * 24 * 60 * 60 * 1_000;
27
+
28
+ export const SUCCESSFUL_WORKFLOW_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
29
+
30
+ export const FAILING_WORKFLOW_CONCLUSIONS = new Set([
31
+ "failure",
32
+ "cancelled",
33
+ "timed_out",
34
+ "action_required",
35
+ "startup_failure",
36
+ "stale",
37
+ ]);
38
+
39
+ export function appendSettlementFlag(run: RunRecord, flag: SettlementFlag): SettlementFlag[] {
40
+ const flags = run.settlementFlags ?? [];
41
+ return flags.some((existing) => existing.kind === flag.kind && existing.detail === flag.detail)
42
+ ? flags
43
+ : [...flags, flag];
44
+ }
45
+
46
+ /**
47
+ * Observe Actions on exact merge commits for up to one day. A running workflow
48
+ * stays quiet and pending; a failure becomes durable evidence on the merged row
49
+ * and pages exactly once because the row leaves `pending` before delivery.
50
+ */
51
+ export async function watchMergedBase(d: Pick<Deps, "project" | "tracker" | "store" | "escalate">): Promise<void> {
52
+ const now = Date.now();
53
+ for (const run of d.store.runsNeedingBaseCheck(d.project.name, BASE_CHECK_BATCH)) {
54
+ if (
55
+ run.endedAt === undefined ||
56
+ now - run.endedAt > BASE_CHECK_WINDOW_MS ||
57
+ run.mergeSha === undefined ||
58
+ run.baseRef === undefined
59
+ ) {
60
+ d.store.updateRun(run.id, { baseCheck: "unknown", baseCheckAt: now });
61
+ log(`#${run.issue} base check unknown: merge identity is absent or older than 24h`);
62
+ continue;
63
+ }
64
+
65
+ const repo = d.project.routing.repos[run.repo];
66
+ const repoIdentity = repo === undefined ? undefined : githubRepo(repo.cloneUrl);
67
+ if (repoIdentity === undefined) {
68
+ d.store.updateRun(run.id, { baseCheck: "unknown", baseCheckAt: now });
69
+ log(`#${run.issue} base check unknown: routed repository ${run.repo} has no GitHub identity`);
70
+ continue;
71
+ }
72
+
73
+ let workflows;
74
+ try {
75
+ workflows = await d.tracker.workflowRunsAt(repoIdentity, run.mergeSha, {
76
+ event: "push",
77
+ branch: run.baseRef,
78
+ });
79
+ } catch (err) {
80
+ log(`#${run.issue} base check unavailable (${errText(err)}) — retrying next tick`);
81
+ continue;
82
+ }
83
+ if (workflows === undefined) {
84
+ log(`#${run.issue} base check unavailable for ${run.mergeSha} — retrying next tick`);
85
+ continue;
86
+ }
87
+ if (workflows.length === 0) {
88
+ log(
89
+ `#${run.issue} base check: no push-triggered run yet for ${run.mergeSha} — retrying next tick`,
90
+ );
91
+ continue;
92
+ }
93
+ if (workflows.some((workflow) => workflow.status !== "completed")) continue;
94
+
95
+ const failed = workflows.find(
96
+ (workflow) =>
97
+ workflow.conclusion !== undefined &&
98
+ FAILING_WORKFLOW_CONCLUSIONS.has(workflow.conclusion),
99
+ );
100
+ if (failed === undefined) {
101
+ const unknown = workflows.find(
102
+ (workflow) =>
103
+ workflow.conclusion === undefined ||
104
+ !SUCCESSFUL_WORKFLOW_CONCLUSIONS.has(workflow.conclusion),
105
+ );
106
+ if (unknown !== undefined) {
107
+ log(
108
+ `#${run.issue} base check has unrecognised completed conclusion ` +
109
+ `${JSON.stringify(unknown.conclusion)} for ${unknown.name} — retrying next tick`,
110
+ );
111
+ continue;
112
+ }
113
+ d.store.updateRun(run.id, { baseCheck: "green", baseCheckAt: now });
114
+ log(`#${run.issue} base ${run.baseRef} green at ${run.mergeSha}`);
115
+ continue;
116
+ }
117
+
118
+ let previous;
119
+ try {
120
+ previous = await d.tracker.previousWorkflowRun(
121
+ repoIdentity,
122
+ failed.workflowId,
123
+ run.baseRef,
124
+ failed.createdAt,
125
+ );
126
+ } catch (err) {
127
+ log(`#${run.issue} previous ${failed.name} run unavailable (${errText(err)}) — retrying next tick`);
128
+ continue;
129
+ }
130
+ if (previous === undefined || (previous !== null && previous.status !== "completed")) continue;
131
+ const preexisting =
132
+ previous !== null &&
133
+ previous.conclusion !== undefined &&
134
+ FAILING_WORKFLOW_CONCLUSIONS.has(previous.conclusion);
135
+ const detail =
136
+ `${failed.name} failed at ${run.mergeSha} — ${failed.url}` +
137
+ (preexisting ? " (already red before this merge)" : "");
138
+ const flag: SettlementFlag = { kind: "base-branch-red", file: "(base branch)", detail };
139
+ // Freeze merges to this repo while the base it merged into is red (#283).
140
+ // The freeze is repo-scoped and sets independently of escalation delivery:
141
+ // a merge that broke the base must not be followed by another merge onto
142
+ // the same red base, even if paging the operator fails.
143
+ if (
144
+ d.store.setBaseFreeze(d.project.name, {
145
+ repo: run.repo,
146
+ culpritSha: run.mergeSha,
147
+ detail,
148
+ setAt: now,
149
+ })
150
+ ) {
151
+ d.store.recordMaterialEvent({
152
+ project: d.project.name,
153
+ category: "base-red-freeze",
154
+ summary: `merges to ${run.repo} frozen — base ${run.baseRef} red at ${run.mergeSha.slice(0, 8)}`,
155
+ evidence:
156
+ `${detail} This freeze names ${run.mergeSha.slice(0, 8)} as the suspected culprit merge. ` +
157
+ "Merges to this repo are refused until the base is green again; reverting the culprit is the " +
158
+ "likely remedy. The freeze lifts automatically on a green re-observation, or the operator can " +
159
+ "override it with `omp-conductor unfreeze <repo>`.",
160
+ occurredAt: now,
161
+ recordedAt: now,
162
+ });
163
+ }
164
+ const delivered = await safeEscalate(d, {
165
+ tier: 1,
166
+ project: d.project.name,
167
+ issue: run.issue,
168
+ runId: run.id,
169
+ summary: `Base branch ${run.baseRef} is red after merge`,
170
+ detail,
171
+ });
172
+ if (!delivered) continue;
173
+ d.store.updateRun(run.id, {
174
+ baseCheck: preexisting ? "red-preexisting" : "red",
175
+ baseCheckAt: now,
176
+ settlementFlags: appendSettlementFlag(run, flag),
177
+ });
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Refresh current base-branch health at each recently merged repository's live
183
+ * head. This is status and release-gate evidence only; the per-merge audit
184
+ * above remains the sole path that attributes and escalates a regression.
185
+ */
186
+ export async function watchBaseHealth(
187
+ d: Pick<Deps, "project" | "tracker" | "store">,
188
+ ): Promise<void> {
189
+ const now = Date.now();
190
+ const previousByRepo = new Map(
191
+ d.store.baseHealth(d.project.name).map((row) => [row.repo, row] as const),
192
+ );
193
+ for (const { repo, baseRef } of d.store.mergedRepoBranches(
194
+ d.project.name,
195
+ now - BASE_STATUS_WINDOW_MS,
196
+ )) {
197
+ const target = d.project.routing.repos[repo];
198
+ if (target === undefined) {
199
+ log(`base health skipped: routed repository ${repo} is no longer configured`);
200
+ continue;
201
+ }
202
+ const identity = githubRepo(target.cloneUrl);
203
+ if (identity === undefined) {
204
+ log(`base health skipped: routed repository ${repo} has no GitHub identity`);
205
+ continue;
206
+ }
207
+ const branch = baseRef ?? target.defaultBranch;
208
+
209
+ let head: string | undefined;
210
+ try {
211
+ head = await d.tracker.branchHead(identity, branch);
212
+ } catch (err) {
213
+ log(`base ${repo}/${branch} head unavailable (${errText(err)}) — keeping previous health`);
214
+ continue;
215
+ }
216
+ if (head === undefined) {
217
+ log(`base ${repo}/${branch} head unavailable — keeping previous health`);
218
+ continue;
219
+ }
220
+
221
+ const previous = previousByRepo.get(repo);
222
+ const freeze = d.store.baseFreeze(d.project.name, repo);
223
+ const frozen = freeze !== undefined && freeze.clearedAt === undefined;
224
+ // The same-head/age shortcut exists to avoid re-querying GitHub when a
225
+ // terminal verdict has not moved. It must NOT skip a frozen repo: a freeze
226
+ // keyed on one red observation has to keep re-evaluating the same head so a
227
+ // green rerun clears it without operator action (tonight's evidence — a
228
+ // red/unknown read two minutes after the same SHA's CI succeeded — is why
229
+ // it cannot be trusted as terminal).
230
+ if (
231
+ !frozen &&
232
+ previous?.branch === branch &&
233
+ previous.headSha === head &&
234
+ (previous.verdict === "green" || previous.verdict === "red")
235
+ ) {
236
+ continue;
237
+ }
238
+
239
+ let runs;
240
+ try {
241
+ runs = await d.tracker.workflowRunsAt(identity, head, { event: "push", branch });
242
+ } catch (err) {
243
+ log(`base ${repo}/${branch} workflows unavailable (${errText(err)}) — keeping previous health`);
244
+ continue;
245
+ }
246
+ if (runs === undefined) {
247
+ log(`base ${repo}/${branch} workflows unavailable — keeping previous health`);
248
+ continue;
249
+ }
250
+
251
+ let verdict: BaseHealth["verdict"];
252
+ let detail: string | undefined;
253
+ if (runs.length === 0) {
254
+ verdict = "unknown";
255
+ detail = `no push-triggered workflow run for ${head.slice(0, 8)}`;
256
+ } else if (runs.some((run) => run.status !== "completed")) {
257
+ verdict = "pending";
258
+ } else {
259
+ const failed = runs.find(
260
+ (run) =>
261
+ run.conclusion !== undefined &&
262
+ FAILING_WORKFLOW_CONCLUSIONS.has(run.conclusion),
263
+ );
264
+ if (failed !== undefined) {
265
+ verdict = "red";
266
+ detail = `${failed.name} failed at ${head.slice(0, 8)} — ${failed.url}`;
267
+ } else if (
268
+ runs.some(
269
+ (run) =>
270
+ run.conclusion === undefined ||
271
+ !SUCCESSFUL_WORKFLOW_CONCLUSIONS.has(run.conclusion),
272
+ )
273
+ ) {
274
+ verdict = "pending";
275
+ } else {
276
+ verdict = "green";
277
+ }
278
+ }
279
+
280
+ const health: BaseHealth = {
281
+ repo,
282
+ branch,
283
+ headSha: head,
284
+ verdict,
285
+ runsCount: runs.length,
286
+ checkedAt: now,
287
+ ...(detail === undefined ? {} : { detail }),
288
+ };
289
+ d.store.upsertBaseHealth(d.project.name, health);
290
+ previousByRepo.set(repo, health);
291
+
292
+ // The freeze follows the live base verdict: red arms (or re-arms) the
293
+ // repo-scoped freeze, green lifts it. pending/unknown leave it untouched —
294
+ // a stale or in-flight reading must neither create a freeze nor clear one.
295
+ if (verdict === "green") {
296
+ if (d.store.clearBaseFreeze(d.project.name, repo, "daemon", "base-green", now)) {
297
+ d.store.recordMaterialEvent({
298
+ project: d.project.name,
299
+ category: "base-recovered",
300
+ summary: `merges to ${repo} unfrozen — base ${branch} observed green at ${head.slice(0, 8)}`,
301
+ evidence: `The base-red freeze on ${repo} lifted automatically: ${branch} is green at ${head.slice(0, 8)}. Merges resume.`,
302
+ occurredAt: now,
303
+ recordedAt: now,
304
+ });
305
+ }
306
+ } else if (verdict === "red") {
307
+ if (
308
+ d.store.setBaseFreeze(d.project.name, {
309
+ repo,
310
+ culpritSha: head,
311
+ detail,
312
+ setAt: now,
313
+ })
314
+ ) {
315
+ d.store.recordMaterialEvent({
316
+ project: d.project.name,
317
+ category: "base-red-freeze",
318
+ summary: `merges to ${repo} frozen — base ${branch} red at ${head.slice(0, 8)}`,
319
+ evidence:
320
+ `${detail ?? `workflow failed at ${head.slice(0, 8)}`} This freeze names ` +
321
+ `${head.slice(0, 8)} as the suspected culprit. Reverting it is the likely remedy; the freeze ` +
322
+ "lifts automatically on green or with `omp-conductor unfreeze <repo>`.",
323
+ occurredAt: now,
324
+ recordedAt: now,
325
+ });
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ export const RETAINED_CLEANUP_BATCH = 10;
332
+
333
+ export type CleanupRetainedWorktree = (
334
+ mirrorPath: string,
335
+ worktreePath: string,
336
+ branch: string,
337
+ ) => Promise<RetainedWorktreeCleanup>;
338
+
339
+ /**
340
+ * Bounded, rotating cleanup for failure-path trees. Tracker state proves the
341
+ * run is terminal; local git state independently proves deletion cannot erase
342
+ * dirty or uniquely unpushed work.
343
+ */
344
+ export async function cleanupRetainedRuns(
345
+ d: Pick<Deps, "project" | "tracker" | "store" | "escalate">,
346
+ queuedIssues: ReadonlySet<number>,
347
+ cursor: RetainedCleanupCursor,
348
+ cleanup: CleanupRetainedWorktree = cleanupRetainedWorktree,
349
+ ): Promise<void> {
350
+ const { project, tracker, store } = d;
351
+ const candidates = store.retainedRuns(project.name);
352
+ if (candidates.length === 0) {
353
+ cursor.next = 0;
354
+ return;
355
+ }
356
+
357
+ const occupied = new Set(store.activeRuns(project.name).map((run) => run.issue));
358
+ const liveRepos = new Set(store.liveRuns(project.name).map((run) => run.repo));
359
+ const start = cursor.next % candidates.length;
360
+ const count = Math.min(RETAINED_CLEANUP_BATCH, candidates.length);
361
+ const batch = Array.from({ length: count }, (_, offset) => candidates[(start + offset) % candidates.length]!);
362
+ cursor.next = (start + count) % candidates.length;
363
+
364
+ for (const run of batch) {
365
+ if (occupied.has(run.issue) || queuedIssues.has(run.issue) || liveRepos.has(run.repo)) continue;
366
+ // Attempts reuse one physical path and deterministic branch. An older row
367
+ // cannot authorize deleting the newest failed attempt's evidence merely
368
+ // because its own PR resolved first.
369
+ const latest = store.latestRun(project.name, run.issue);
370
+ if (
371
+ latest !== undefined &&
372
+ latest.id !== run.id &&
373
+ latest.state !== "merged" &&
374
+ latest.worktree !== ""
375
+ ) {
376
+ continue;
377
+ }
378
+
379
+ let terminal = false;
380
+ try {
381
+ if (run.prUrl !== undefined) {
382
+ const pr = await tracker.prState(run.prUrl);
383
+ if (pr === undefined) continue;
384
+ if (pr === "merged" || pr === "closed") {
385
+ terminal = true;
386
+ } else {
387
+ const issue = await tracker.issueState(run.issue);
388
+ if (issue === undefined) continue;
389
+ terminal = issue === "closed";
390
+ }
391
+ } else {
392
+ const issue = await tracker.issueState(run.issue);
393
+ if (issue === undefined) continue;
394
+ terminal = issue === "closed";
395
+ }
396
+ } catch (err) {
397
+ // The tracker throws exactly one classified error: `GhPrMissingError`,
398
+ // an individual REST 404 it corroborated with a same-repository
399
+ // pulls-list read, so the claimed PR definitively does not exist
400
+ // (#779). That is a fact, not "could not tell": retrying can never
401
+ // conjure the PR, so the missing PR is terminal and the retained row
402
+ // settles in this same pass like a closed one (#1065). The row's
403
+ // phantom URL is cleared with the settlement so a repeat pass neither
404
+ // asks GitHub about it again nor logs about it again. Everything else —
405
+ // a rate limit, a 5xx, a lost network — keeps the deferred retry below,
406
+ // unchanged.
407
+ if (err instanceof GhPrMissingError) {
408
+ store.updateRun(run.id, { prUrl: null });
409
+ log(`#${run.issue} retained cleanup settled: PR ${run.prUrl} does not exist`);
410
+ terminal = true;
411
+ } else {
412
+ log(`#${run.issue} retained cleanup deferred: tracker state failed (${errText(err)})`);
413
+ continue;
414
+ }
415
+ }
416
+ if (!terminal) continue;
417
+
418
+ const repo = Object.values(project.routing.repos).find((candidate) => candidate.name === run.repo);
419
+ if (repo === undefined) {
420
+ log(`#${run.issue} retained cleanup deferred: repo ${run.repo} is no longer configured`);
421
+ continue;
422
+ }
423
+
424
+ const outcome = await cleanup(mirrorPathFor(repo, project.mirrorRoot), run.worktree, run.branch);
425
+ if (outcome.kind === "removed") {
426
+ store.updateRun(run.id, {
427
+ worktree: "",
428
+ ...(run.quarantineDetail === undefined ? {} : { quarantineDetail: null }),
429
+ });
430
+ log(`#${run.issue} retained worktree reaped: ${run.worktree} (${run.branch})`);
431
+ } else if (outcome.reason === "quarantined") {
432
+ // A tree whose object store cannot be made sound is potentially
433
+ // stranded work: the daemon refuses to fetch into it, so its commits
434
+ // cannot be verified against any remote. The row records the condition
435
+ // so the status snapshot can name the tree, and the escalation ledger
436
+ // dedupes on the stable summary below — a pass that keeps seeing the
437
+ // same broken tree reports it once, never once per dispatch pass.
438
+ store.updateRun(run.id, { quarantineDetail: outcome.detail });
439
+ await safeEscalate(d, {
440
+ tier: 1,
441
+ project: project.name,
442
+ issue: run.issue,
443
+ summary: `#${run.issue} quarantined retained worktree — potentially stranded work`,
444
+ detail: `${run.worktree} (${run.branch})\n${outcome.detail}`,
445
+ });
446
+ } else {
447
+ // Any other retained reason means the tree is back under ordinary
448
+ // retention: its alternates were repaired (or never needed it), so the
449
+ // quarantine — if one was marked — is over and the snapshot must not
450
+ // keep naming it as quarantined.
451
+ if (run.quarantineDetail !== undefined) {
452
+ store.updateRun(run.id, { quarantineDetail: null });
453
+ log(`#${run.issue} retained worktree no longer quarantined: ${outcome.detail}`);
454
+ }
455
+ log(`#${run.issue} retained worktree kept (${outcome.reason}): ${outcome.detail}`);
456
+ }
457
+ }
458
+ }
459
+
460
+ /**
461
+ * How many settled `ci-deterministic` rows one reconciliation pass may
462
+ * re-examine beyond the persisted review cursor. Each candidate costs GitHub
463
+ * calls to re-fetch the evidence its check log carried, so a fleet with a
464
+ * long misclassified history works through it over several daemon starts
465
+ * rather than spending one boot's budget on all of it (#638).
466
+ */
467
+ export const HISTORICAL_INFRA_BATCH = 20;
468
+
469
+ /** How many workflow runs for the head commit one row's evidence pass reads —
470
+ * and the ceiling past which the row is refused as undecided rather than
471
+ * decided from a prefix of its head-pinned runs (review #654). */
472
+ export const HISTORICAL_INFRA_RUNS = 3;
473
+
474
+ /** How many attempts of one workflow run may be read for the failed log — and
475
+ * the ceiling past which the run is refused as undecided rather than read as
476
+ * a prefix that could hide a later attempt's real failure (review #654). A
477
+ * failed job rerun to green leaves the failure in an earlier attempt; the
478
+ * bound keeps one pathological run from costing the whole pass. */
479
+ export const HISTORICAL_INFRA_ATTEMPTS = 5;
480
+
481
+ /**
482
+ * Repair settled `ci-deterministic` rows whose re-fetched check log carries a
483
+ * closed infrastructure signature (#638). The forward classifier now names
484
+ * the codeload setup 429 of #177 `ci-infra`, but a row already classified
485
+ * `ci-deterministic`/`escalate` never re-enters the classification sweep, so
486
+ * the old verdict charges an implementation attempt forever. Re-fetching the
487
+ * head-pinned workflow-run attempt logs through the tracker, matching the
488
+ * classifier's own closed signature list, and reclassifying `failureClass`
489
+ * alone returns the attempt without re-animating a months-old run into
490
+ * recovery.
491
+ *
492
+ * Bounded, idempotent and resumable: one batch per call, reclassifications
493
+ * only, and the store's update is guarded by the row still reading
494
+ * `ci-deterministic`, so a second pass touches nothing it already repaired.
495
+ * The batch resumes below a persisted review cursor, so each row is evaluated
496
+ * once rather than rescanning the newest non-matches forever and starving
497
+ * older repairable rows. A row whose evidence could not be read is a
498
+ * no-mutation refusal: the cursor never advances past it, so the next pass
499
+ * asks again; a row that was read and shown *not* to be infrastructure
500
+ * advances the cursor. Every repair requires *all* gathered failed logs to
501
+ * carry an infra signature — a setup 429 in one attempt must not waive a
502
+ * compile/test failure in a sibling attempt.
503
+ *
504
+ * Returns how many rows it repaired, for the boot log.
505
+ */
506
+ export async function reconcileHistoricalInfra(d: Deps): Promise<number> {
507
+ const { project, store } = d;
508
+ const version = infraSignatureVersion();
509
+ const persisted = store.historicalInfraCursor(project.name);
510
+ // A cursor stamped by an older signature list is stale: the classifier now
511
+ // recognises more evidence, so the pass restarts from the newest row rather
512
+ // than skipping past newly repairable history (#638).
513
+ const cursor =
514
+ persisted !== undefined && persisted.classifierVersion === version
515
+ ? { startedAt: persisted.startedAt, rowid: persisted.rowid }
516
+ : undefined;
517
+ const candidates = store.historicalInfraCandidates(project.name, HISTORICAL_INFRA_BATCH, cursor);
518
+ let repaired = 0;
519
+ let lastDecided: { startedAt: number; rowid: number } | undefined;
520
+ for (const run of candidates) {
521
+ const chunks = await historicalInfraEvidence(d, run);
522
+ // Unreachable evidence is undecided exactly like a fresh classifier is: a
523
+ // no-mutation refusal the next pass asks again, and the cursor stops here
524
+ // so the row is re-offered rather than being skipped past.
525
+ if (chunks === undefined) break;
526
+ lastDecided = { startedAt: run.startedAt, rowid: run.rowid };
527
+ // The head's runs were read and none holds a failing log — determinately
528
+ // not infrastructure, so an old misclassified verdict stays charged.
529
+ if (chunks.length === 0) continue;
530
+ // Mixed guard: anything gathered that is not itself a closed infra
531
+ // signature (a compile/test failure in a sibling attempt, a product 429)
532
+ // refuses the whole row. One setup 429 is not permission to waive a real
533
+ // implementation failure.
534
+ if (!chunks.every((chunk) => infraLogSignature(chunk) !== undefined)) continue;
535
+ if (store.reclassifyInfra(run.id)) {
536
+ repaired += 1;
537
+ // The every-guard above guarantees a signature and a first chunk; the
538
+ // non-null assertions make the same fact readable to the type checker.
539
+ log(
540
+ `#${run.issue} repaired historical ${run.failureClass ?? "ci-deterministic"} → ci-infra (run ${run.id}): ` +
541
+ `"${infraLogSignature(chunks[0]!)}" in the failed check log`,
542
+ );
543
+ }
544
+ }
545
+ if (lastDecided !== undefined) {
546
+ store.setHistoricalInfraCursor(
547
+ project.name,
548
+ lastDecided.startedAt,
549
+ lastDecided.rowid,
550
+ version,
551
+ );
552
+ }
553
+ return repaired;
554
+ }
555
+
556
+ /**
557
+ * The failed check logs of a settled `ci-deterministic` row, head-pinned to
558
+ * the exact commit the row ran against, or `undefined` when the evidence is
559
+ * unreachable (#638). Only the workflow-run history at `run.headSha` is
560
+ * evidence: the PR's *current* check rollup is not, because a later push's
561
+ * checks must never classify an earlier run's row. `workflowRunsAt` is itself
562
+ * head-scoped, and each run's failed attempts are read through the tracker's
563
+ * guarded runner (call/refusal accounting and the rate-limit breaker apply).
564
+ *
565
+ * Returns the per-failed-job failed logs gathered across the head's runs (one
566
+ * chunk per failed job, full and untruncated), or `undefined` when any read
567
+ * could not be made — the run register, an attempt's job register, a failed
568
+ * job's log, or the head-run list itself — or when a bounded evidence set
569
+ * exceeded its limit (more head-pinned runs than `HISTORICAL_INFRA_RUNS`, or
570
+ * more attempts than `HISTORICAL_INFRA_ATTEMPTS`). Undefined is a no-mutation
571
+ * refusal the next pass asks again; the cursor never advances past it. `[]`
572
+ * means the head's runs were read and none holds a failing log — a
573
+ * determinately non-infrastructure answer.
574
+ */
575
+ export async function historicalInfraEvidence(d: Deps, run: RunRecord): Promise<string[] | undefined> {
576
+ // Without a head SHA there is no safe way to pin evidence to this row; a
577
+ // head-less row is determinately not a repair candidate, so it advances the
578
+ // cursor rather than stalling the pass.
579
+ if (run.headSha === undefined) return [];
580
+ const target = d.project.routing.repos[run.repo];
581
+ const repoIdentity = target === undefined ? undefined : githubRepo(target.cloneUrl);
582
+ if (repoIdentity === undefined) return [];
583
+ let runs: WorkflowRun[] | undefined;
584
+ try {
585
+ runs = await d.tracker.workflowRunsAt(repoIdentity, run.headSha);
586
+ } catch {
587
+ return undefined;
588
+ }
589
+ if (runs === undefined) return undefined;
590
+ // Refuse rather than decide from the first `HISTORICAL_INFRA_RUNS`: three
591
+ // setup-429 runs must not waive a row whose fourth head-pinned run carries
592
+ // the real compile/test failure (review #654). Undecided means the cursor
593
+ // never advances past this row, so the next pass asks again.
594
+ if (runs.length > HISTORICAL_INFRA_RUNS) return undefined;
595
+ const chunks: string[] = [];
596
+ for (const wf of runs) {
597
+ const logs = await d.tracker.runFailedAttemptLogs(
598
+ repoIdentity,
599
+ wf.url,
600
+ HISTORICAL_INFRA_ATTEMPTS,
601
+ );
602
+ if (logs === undefined) return undefined;
603
+ chunks.push(...logs);
604
+ }
605
+ return chunks;
606
+ }