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