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.
- package/REFERENCE.md +10 -1
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/admission.ts +58 -14
- package/src/arm-challenge.ts +255 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +258 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +115 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +422 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +788 -0
- package/src/daemon/settle-pass.ts +606 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7923
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +135 -9
- package/src/doctor.ts +2 -2
- package/src/failure-class.ts +257 -2
- package/src/fleet.ts +295 -176
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +689 -1670
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +107 -11
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +169 -14
- package/src/store.ts +618 -28
- package/src/to-spec.ts +426 -44
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +434 -18
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- 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
|
+
}
|