omp-conductor 0.15.13 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/REFERENCE.md +72 -2
- package/package.json +2 -1
- package/schema/config.schema.json +6 -0
- package/src/admission.ts +745 -0
- package/src/ask.ts +47 -0
- package/src/backups.ts +19 -7
- package/src/board.ts +1 -2
- package/src/briefs/orchestrator.md +62 -4
- package/src/cli.ts +26 -0
- package/src/commands/context.ts +3 -0
- package/src/commands/decision.ts +10 -1
- package/src/commands/doctor.ts +2 -0
- package/src/commands/message.ts +8 -1
- package/src/commands/restart.ts +15 -3
- package/src/commands/restore-db.ts +146 -0
- package/src/commands/stop.ts +24 -15
- package/src/commands/unfreeze.ts +56 -0
- package/src/commands/watch.ts +77 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +24 -0
- package/src/daemon.ts +239 -530
- package/src/dashboard/server.ts +2 -1
- package/src/decisions.ts +32 -7
- package/src/depends-on.ts +73 -0
- package/src/doctor.ts +178 -5
- package/src/escalate.ts +114 -15
- package/src/failure-class.ts +47 -0
- package/src/fleet.ts +41 -410
- package/src/gitops.ts +86 -1
- package/src/log.ts +40 -0
- package/src/model-fallback.ts +3 -2
- package/src/omp-settings.ts +114 -0
- package/src/omp.ts +39 -0
- package/src/orchestrator-tick.ts +7 -1
- package/src/reports.ts +124 -12
- package/src/session-host.ts +6 -0
- package/src/setup-wizard.ts +36 -0
- package/src/setup.ts +58 -1
- package/src/status-render.ts +445 -0
- package/src/stop-provenance.ts +53 -0
- package/src/store.ts +352 -11
- package/src/types.ts +187 -4
- package/src/unblock.ts +1 -1
- package/src/upgrade-verify.ts +1 -1
- package/src/upgrade.ts +1 -2
- package/src/verbs/server.ts +25 -0
- package/src/worker.ts +162 -10
package/src/admission.ts
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The admission half of the dispatcher (`daemon.ts` composes; this owns the
|
|
3
|
+
* gates).
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `daemon.ts` so the most contested region — who gets a worker
|
|
6
|
+
* this tick — is a lane of its own: #283, #419, #420, #421, #507 and #555 all
|
|
7
|
+
* live here, and a diff touching only admission must not have to touch the
|
|
8
|
+
* composition root. Behavior is deliberately untouched by the move; a later
|
|
9
|
+
* slice reworks the seams.
|
|
10
|
+
*
|
|
11
|
+
* What admission is allowed to consult is the point of the module. It reads a
|
|
12
|
+
* structural slice of the daemon's deps — never the whole `Deps` and never the
|
|
13
|
+
* module that owns them, which would be the circular import the split exists
|
|
14
|
+
* to rule out. Adding a field to the daemon's deps must not break these tests.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { log, errText, safeEscalate } from "./log.ts";
|
|
19
|
+
import type {
|
|
20
|
+
Caps,
|
|
21
|
+
Escalation,
|
|
22
|
+
IssueSnapshot,
|
|
23
|
+
OpenCloser,
|
|
24
|
+
ProjectConfig,
|
|
25
|
+
RunRecord,
|
|
26
|
+
Tracker,
|
|
27
|
+
Store,
|
|
28
|
+
AdmissionHoldReason,
|
|
29
|
+
} from "./types.ts";
|
|
30
|
+
import { readPlanUsage, type PlanUsageStatus, type UsageSource } from "./usage.ts";
|
|
31
|
+
import type { CriticalBaseProbe, CriticalBaseVerdict, RunLaneProbe } from "./gitops.ts";
|
|
32
|
+
import { branchName, type Routed } from "./routing.ts";
|
|
33
|
+
import { parseDependsOn } from "./depends-on.ts";
|
|
34
|
+
|
|
35
|
+
/** Fleet-wide escalations still need an issue number in the payload; 0 is the
|
|
36
|
+
* sentinel that reads as "no issue" in every renderer. */
|
|
37
|
+
const NO_ISSUE = 0;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The slice of the daemon's `Deps` admission reads. Defined here rather than
|
|
41
|
+
* imported from `daemon.ts` (that would be the cycle this module exists to
|
|
42
|
+
* avoid); every `daemon.test.ts` fake already satisfies it, so the seam costs
|
|
43
|
+
* nothing.
|
|
44
|
+
*/
|
|
45
|
+
export interface AdmissionDeps {
|
|
46
|
+
project: ProjectConfig;
|
|
47
|
+
caps: Caps;
|
|
48
|
+
tracker: Tracker;
|
|
49
|
+
store: Store;
|
|
50
|
+
usage: UsageSource;
|
|
51
|
+
escalate(e: Escalation): Promise<void>;
|
|
52
|
+
probeCriticalBase?: CriticalBaseProbe;
|
|
53
|
+
probeWorktreeLane?: RunLaneProbe;
|
|
54
|
+
}
|
|
55
|
+
/** `stops` are the operational ends that each require one resume. */
|
|
56
|
+
export function hasContinuationBudget(stops: number, maxContinuations: number): boolean {
|
|
57
|
+
return stops <= maxContinuations;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True while unspent failed-implementation attempts remain. This is the
|
|
61
|
+
* dispatcher's admission gate: once every `maxAttemptsPerIssue` slot is
|
|
62
|
+
* spent, the issue is held as `failed-attempts` forever, and the `unblock`
|
|
63
|
+
* verb withholds the queue label on the same predicate (#348). */
|
|
64
|
+
export function hasFailedAttemptBudget(failures: number, maxAttempts: number): boolean {
|
|
65
|
+
return failures < maxAttempts;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A candidate cleared for dispatch, with the attempt number it will run as. */
|
|
69
|
+
export interface Admission {
|
|
70
|
+
r: Routed;
|
|
71
|
+
attempt: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface AdmissionHold {
|
|
75
|
+
issue: number;
|
|
76
|
+
reason: AdmissionHoldReason;
|
|
77
|
+
/** Human-readable "what" for the surface the reason group alone cannot say —
|
|
78
|
+
* the overlapping file and the run holding it for `file-lane`. Survives only
|
|
79
|
+
* far enough to be rendered; it never leaves the current pass's summary. */
|
|
80
|
+
detail?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface AdmissionPass {
|
|
84
|
+
admitted: Admission[];
|
|
85
|
+
holds: AdmissionHold[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What a held plan-usage gate says to a human, if anything.
|
|
91
|
+
*
|
|
92
|
+
* Three different problems hide behind one hold, and they want different
|
|
93
|
+
* tiers. Reaching the threshold is the guard *working*: tier 1, because the
|
|
94
|
+
* fleet resumes on its own at the provider's reset and nobody needs to get
|
|
95
|
+
* out of bed. Everything else — a window nothing reports, a window that
|
|
96
|
+
* resolves to two allowances, a meter that has been unreadable for half an
|
|
97
|
+
* hour — is dispatch stopped with no self-recovery, which is tier 2.
|
|
98
|
+
*
|
|
99
|
+
* Each summary carries the fact that will change when the situation does (the
|
|
100
|
+
* reset instant, the configured id, the date), because the escalation ledger
|
|
101
|
+
* dedupes on the summary: a stable one pages once and then goes quiet, which
|
|
102
|
+
* is right for a repeated tick and wrong for the next window.
|
|
103
|
+
*/
|
|
104
|
+
function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation | undefined {
|
|
105
|
+
const base = { project, issue: NO_ISSUE };
|
|
106
|
+
if (plan.state === "at-cap") {
|
|
107
|
+
const window = plan.window?.id ?? plan.cap?.windowId ?? "the configured window";
|
|
108
|
+
const resets =
|
|
109
|
+
plan.resetsAt === undefined
|
|
110
|
+
? new Date().toISOString().slice(0, 10)
|
|
111
|
+
: new Date(plan.resetsAt).toISOString();
|
|
112
|
+
return {
|
|
113
|
+
...base,
|
|
114
|
+
tier: 1,
|
|
115
|
+
summary: `Plan allowance cap reached on ${window} — ${project} is not claiming new work (window ${resets})`,
|
|
116
|
+
detail: [
|
|
117
|
+
plan.detail,
|
|
118
|
+
"Running workers finish normally; only new claims are held.",
|
|
119
|
+
"Dispatch resumes by itself once the provider reports the window reset or usage below the threshold —",
|
|
120
|
+
"no `resume` needed. Raise `caps.planUsage.maxUsedFraction` only if you mean to spend the rest.",
|
|
121
|
+
].join("\n"),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (plan.state === "blind") {
|
|
125
|
+
return {
|
|
126
|
+
...base,
|
|
127
|
+
tier: 2,
|
|
128
|
+
category: "fleet-stopped",
|
|
129
|
+
// Dated: a meter that breaks again next month is a new incident, not a
|
|
130
|
+
// repeat of this one.
|
|
131
|
+
summary: `Plan usage source unreadable — ${project} is not claiming new work (${new Date().toISOString().slice(0, 10)})`,
|
|
132
|
+
detail: [
|
|
133
|
+
plan.detail,
|
|
134
|
+
"The guard admitted work while the failure looked transient and has now stopped.",
|
|
135
|
+
"Check `omp usage --json` on the fleet host, or set `caps.planUsage` to null if this fleet is unmetered.",
|
|
136
|
+
].join("\n"),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
plan.state === "window-missing" ||
|
|
141
|
+
plan.state === "window-ambiguous" ||
|
|
142
|
+
plan.state === "window-uncomparable"
|
|
143
|
+
) {
|
|
144
|
+
return {
|
|
145
|
+
...base,
|
|
146
|
+
tier: 2,
|
|
147
|
+
category: "fleet-stopped",
|
|
148
|
+
summary: `Plan usage cap names an unusable window "${plan.cap?.windowId ?? "?"}" — ${project} is not claiming new work`,
|
|
149
|
+
detail: [
|
|
150
|
+
plan.detail,
|
|
151
|
+
"Run `omp usage --json` and copy an allowance `id` into `caps.planUsage.windowId`,",
|
|
152
|
+
"or set `caps.planUsage` to null if this fleet is unmetered.",
|
|
153
|
+
].join("\n"),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The candidate half of the file-lane interlock: the machine-readable list of
|
|
162
|
+
* files an issue declares it will touch (#555).
|
|
163
|
+
*
|
|
164
|
+
* The orchestrator already writes exactly this list into every promotion brief
|
|
165
|
+
* in prose; this parses that same sentence out of the issue body so the
|
|
166
|
+
* interlock is load-bearing rather than advisory. A body line beginning with
|
|
167
|
+
* "file lane" (case-insensitive, optional bold/heading markers) is accepted,
|
|
168
|
+
* and paths are read as backtick-delimited spans (the brief form) with a bare
|
|
169
|
+
* comma/space-separated fallback that keeps tokens that look like relative
|
|
170
|
+
* paths. Anything else — including an absent line, which is the default — is
|
|
171
|
+
* an empty lane: the issue is admitted exactly as today (`fail open`), and the
|
|
172
|
+
* gate never refuse work for wanting a lane. Exported so the format is pinned
|
|
173
|
+
* independent of admission.
|
|
174
|
+
*/
|
|
175
|
+
export function declaredLane(body: string): string[] {
|
|
176
|
+
const match = body.match(
|
|
177
|
+
/^\s*(?:[#>*-]\s*)*file[- ]lane\s*[:=]\s*([^\n]*)$/im,
|
|
178
|
+
);
|
|
179
|
+
if (match === null) return [];
|
|
180
|
+
const rest = match[1] ?? "";
|
|
181
|
+
const backticked = [...rest.matchAll(/`([^`]+)`/g)]
|
|
182
|
+
.map((m) => m[1]!.trim())
|
|
183
|
+
.filter(isPathLike);
|
|
184
|
+
if (backticked.length > 0) return [...new Set(backticked)];
|
|
185
|
+
return [...new Set(rest.split(/[,\s]+/).map((s) => s.trim()).filter(isPathLike))];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A plausible relative path: it has a `.` extension or a directory separator. */
|
|
189
|
+
function isPathLike(token: string): boolean {
|
|
190
|
+
return token !== "" && !/\s/.test(token) && (token.includes("/") || /\.[A-Za-z0-9]{1,10}$/.test(token));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Which routed candidates get a worker this tick — in queue order, never more
|
|
195
|
+
* than `slots` of them. Every non-admission receives a stable reason code.
|
|
196
|
+
*
|
|
197
|
+
* Exported so the admission rules can be pinned without spawning a worker.
|
|
198
|
+
* Every one of them exists because of a live incident, and each guards a
|
|
199
|
+
* different way the same issue gets worked twice — including epic siblings
|
|
200
|
+
* racing onto the same files (#48).
|
|
201
|
+
*
|
|
202
|
+
* Takes the slice of `Deps` it actually reads rather than the whole thing: what
|
|
203
|
+
* admission is allowed to consult is the point of the function, and a `Deps`
|
|
204
|
+
* that grows a field has no business breaking these tests.
|
|
205
|
+
*/
|
|
206
|
+
export async function admitCandidates(
|
|
207
|
+
d: AdmissionDeps,
|
|
208
|
+
routed: Routed[],
|
|
209
|
+
slots: number,
|
|
210
|
+
): Promise<AdmissionPass> {
|
|
211
|
+
const { project, caps, tracker, store } = d;
|
|
212
|
+
const activeRuns = store.activeRuns(project.name);
|
|
213
|
+
const busyIssues = activeRuns.map((r) => r.issue);
|
|
214
|
+
const busy = new Set(busyIssues);
|
|
215
|
+
// issue -> its active run rows, for the pushed-green admission bypass (#175):
|
|
216
|
+
// only a worker-free pushed-green row may be bypassed, and only when *every*
|
|
217
|
+
// active run for the issue is worker-free. A live (claimed/running) row still
|
|
218
|
+
// holds unconditionally.
|
|
219
|
+
const activeByIssue = new Map<number, RunRecord[]>();
|
|
220
|
+
for (const run of activeRuns) {
|
|
221
|
+
const list = activeByIssue.get(run.issue);
|
|
222
|
+
if (list === undefined) activeByIssue.set(run.issue, [run]);
|
|
223
|
+
else list.push(run);
|
|
224
|
+
}
|
|
225
|
+
// Live worker count per repo, seeded from live runs and incremented as this
|
|
226
|
+
// same pass admits — so two same-repo candidates can never both clear the
|
|
227
|
+
// per-repo cap in one tick (#186).
|
|
228
|
+
const liveByRepo = new Map<string, number>();
|
|
229
|
+
for (const run of store.liveRuns(project.name)) {
|
|
230
|
+
liveByRepo.set(run.repo, (liveByRepo.get(run.repo) ?? 0) + 1);
|
|
231
|
+
}
|
|
232
|
+
const holds: AdmissionHold[] = [];
|
|
233
|
+
const hold = (issue: number, reason: AdmissionHoldReason, detail?: string): void => {
|
|
234
|
+
holds.push({ issue, reason, ...(detail === undefined ? {} : { detail }) });
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// The file-lane interlock (#555): repo -> file -> the issue holding that file.
|
|
238
|
+
// Seeded from the *actual* worktrees/branches of active runs — never from
|
|
239
|
+
// their issue bodies, which is precisely how #288 collided — and extended as
|
|
240
|
+
// this pass admits candidates with declared lanes, so two overlapping
|
|
241
|
+
// candidates cannot both clear the gate in one tick. Keyed by repo because a
|
|
242
|
+
// path only collides within its own checkout: a `daemon.ts` on the api repo
|
|
243
|
+
// and one on the web repo are different files. Built lazily and once, only
|
|
244
|
+
// when the first candidate that declares a lane reaches the gate, so a queue
|
|
245
|
+
// of laneless issues pays nothing for it.
|
|
246
|
+
let laneOccupancy: Map<string, Map<string, number>> | undefined;
|
|
247
|
+
const ensureLaneOccupancy = async (): Promise<Map<string, Map<string, number>>> => {
|
|
248
|
+
if (laneOccupancy !== undefined) return laneOccupancy;
|
|
249
|
+
const occupied = new Map<string, Map<string, number>>();
|
|
250
|
+
for (const run of activeRuns) {
|
|
251
|
+
if (d.probeWorktreeLane === undefined) break;
|
|
252
|
+
const base = project.routing.repos[run.repo]?.defaultBranch ?? "main";
|
|
253
|
+
let files: string[];
|
|
254
|
+
try {
|
|
255
|
+
files = await d.probeWorktreeLane({
|
|
256
|
+
worktree: run.worktree,
|
|
257
|
+
baseRef: `refs/remotes/origin/${base}`,
|
|
258
|
+
...(run.branch === "" ? {} : { branchRef: `refs/heads/${run.branch}` }),
|
|
259
|
+
mirror: join(project.mirrorRoot, `${run.repo}.git`),
|
|
260
|
+
});
|
|
261
|
+
} catch {
|
|
262
|
+
// Fail open, like an unreadable lane: the gate never refuses a
|
|
263
|
+
// well-formed issue because one probe could not be answered.
|
|
264
|
+
files = [];
|
|
265
|
+
}
|
|
266
|
+
for (const file of files) {
|
|
267
|
+
if (occupied.get(run.repo)?.has(file) === true) continue;
|
|
268
|
+
let perRepo = occupied.get(run.repo);
|
|
269
|
+
if (perRepo === undefined) {
|
|
270
|
+
perRepo = new Map();
|
|
271
|
+
occupied.set(run.repo, perRepo);
|
|
272
|
+
}
|
|
273
|
+
perRepo.set(file, run.issue);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
laneOccupancy = occupied;
|
|
277
|
+
return occupied;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// The plan allowance is a fleet-wide question, so it is asked once per pass
|
|
281
|
+
// and answers for every candidate — unlike every gate below it, which is
|
|
282
|
+
// per-issue. It sits here rather than beside the spend cap in `tick` for one
|
|
283
|
+
// reason: the spend cap *pauses the daemon* and waits for a human, and a
|
|
284
|
+
// weekly plan window resets by itself. A guard that demanded `resume` after
|
|
285
|
+
// every rollover would cost more operator attention than the guard saves
|
|
286
|
+
// (#110). Already-running workers are untouched and settle normally.
|
|
287
|
+
//
|
|
288
|
+
// Placed after the cheap local busy-set read and before the first tracker
|
|
289
|
+
// call, so a held fleet spends no GitHub API budget discovering it is held.
|
|
290
|
+
const plan = await readPlanUsage(caps.planUsage, d.usage);
|
|
291
|
+
if (plan.blocking) {
|
|
292
|
+
for (const r of routed) hold(r.issue.number, "plan-usage-cap");
|
|
293
|
+
log(`plan usage gate holding ${String(routed.length)} candidate(s): ${plan.detail}`);
|
|
294
|
+
const escalation = planUsageEscalation(project.name, plan);
|
|
295
|
+
if (escalation !== undefined) await safeEscalate(d, escalation);
|
|
296
|
+
return { admitted: [], holds };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// parent -> repo name -> blocking issue. Seeded from active runs (including
|
|
300
|
+
// pushed-green), then extended by candidates admitted earlier in this same
|
|
301
|
+
// pass so two siblings of one epic never both clear the gate in one tick.
|
|
302
|
+
// A busy issue whose run row cannot be resolved occupies the sentinel repo
|
|
303
|
+
// "" — treated as matching every repo, failing toward holding (#197).
|
|
304
|
+
const occupiedParents = new Map<number, Map<string, number>>();
|
|
305
|
+
const parentCache = new Map<number, number | undefined>();
|
|
306
|
+
|
|
307
|
+
const resolveParent = async (issue: number): Promise<number | undefined> => {
|
|
308
|
+
if (parentCache.has(issue)) return parentCache.get(issue);
|
|
309
|
+
const parent = await tracker.parentOf(issue);
|
|
310
|
+
parentCache.set(issue, parent);
|
|
311
|
+
return parent;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Bounded by concurrent workers, not queue depth. A failed lookup here cannot
|
|
315
|
+
// mark an epic occupied; candidates still fail closed on their own parentOf.
|
|
316
|
+
for (const issue of busyIssues) {
|
|
317
|
+
try {
|
|
318
|
+
const parent = await resolveParent(issue);
|
|
319
|
+
if (parent === undefined) continue;
|
|
320
|
+
// The runs table records which repo each attempt worked in, and sibling
|
|
321
|
+
// holds are now per-repo, so a busy child only occupies its epic under
|
|
322
|
+
// that repo's name (same spelling as `createRun` writes from
|
|
323
|
+
// `r.repo.name`). A busy issue with no resolvable run row occupies the
|
|
324
|
+
// sentinel "" instead — matching every repo, failing toward holding.
|
|
325
|
+
const repo = store.latestRun(project.name, issue)?.repo ?? "";
|
|
326
|
+
const siblings = occupiedParents.get(parent);
|
|
327
|
+
if (siblings === undefined) {
|
|
328
|
+
occupiedParents.set(parent, new Map([[repo, issue]]));
|
|
329
|
+
} else if (!siblings.has(repo) && !siblings.has("")) {
|
|
330
|
+
siblings.set(repo, issue);
|
|
331
|
+
}
|
|
332
|
+
} catch (err) {
|
|
333
|
+
log(`#${issue} parent lookup failed while seeding epic occupancy (${errText(err)})`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const admitted: Admission[] = [];
|
|
338
|
+
for (const r of routed) {
|
|
339
|
+
const issue = r.issue.number;
|
|
340
|
+
if (admitted.length >= slots) {
|
|
341
|
+
hold(issue, "capacity");
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (busy.has(issue)) {
|
|
345
|
+
// A pushed-green row is worker-free by definition (it is not in
|
|
346
|
+
// LIVE_STATES): its PR is live but no process is writing to its branch.
|
|
347
|
+
// So an issue whose active runs are ALL pushed-green is not actually
|
|
348
|
+
// occupied — the corrective attempt the operator unblocked may be
|
|
349
|
+
// admitted as a continuation of that PR, and the open-PR gate below
|
|
350
|
+
// decides the identity. Any live row still holds (#175).
|
|
351
|
+
const allWorkerFree = (activeByIssue.get(issue) ?? []).every((r) => r.state === "pushed-green");
|
|
352
|
+
if (!allWorkerFree) {
|
|
353
|
+
hold(issue, "issue-active");
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Per-repo concurrency: the mirror, branch-protection staleness and shared
|
|
359
|
+
// CI egress are all per-repo collision domains, so extra slots should land
|
|
360
|
+
// on other repos rather than stacking workers into the same one (#186).
|
|
361
|
+
const liveInRepo = liveByRepo.get(r.repo.name) ?? 0;
|
|
362
|
+
if (liveInRepo >= caps.maxConcurrentWorkersPerRepo) {
|
|
363
|
+
hold(issue, "repo-active");
|
|
364
|
+
log(`#${issue} skipped: ${liveInRepo} live worker(s) already in ${r.repo.name} (cap ${caps.maxConcurrentWorkersPerRepo})`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const priorRuns = store.attemptsFor(project.name, issue);
|
|
369
|
+
const failures = store.failuresFor(project.name, issue);
|
|
370
|
+
if (!hasFailedAttemptBudget(failures, caps.maxAttemptsPerIssue)) {
|
|
371
|
+
hold(issue, "failed-attempts");
|
|
372
|
+
await safeEscalate(d, {
|
|
373
|
+
tier: 1,
|
|
374
|
+
project: project.name,
|
|
375
|
+
issue,
|
|
376
|
+
summary: `#${issue} has used all ${caps.maxAttemptsPerIssue} failed attempts`,
|
|
377
|
+
detail: [
|
|
378
|
+
r.issue.title,
|
|
379
|
+
r.issue.url,
|
|
380
|
+
"Another implementation attempt almost always means the issue itself is underspecified.",
|
|
381
|
+
"Rewrite the acceptance criteria, or take it off the queue.",
|
|
382
|
+
].join("\n"),
|
|
383
|
+
});
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const continuations = store.continuationsFor(project.name, issue);
|
|
388
|
+
if (!hasContinuationBudget(continuations, caps.maxContinuationsPerIssue)) {
|
|
389
|
+
hold(issue, "continuations");
|
|
390
|
+
await safeEscalate(d, {
|
|
391
|
+
tier: 1,
|
|
392
|
+
project: project.name,
|
|
393
|
+
issue,
|
|
394
|
+
summary: `#${issue} exceeded its ${caps.maxContinuationsPerIssue}-continuation budget`,
|
|
395
|
+
detail: [
|
|
396
|
+
r.issue.title,
|
|
397
|
+
r.issue.url,
|
|
398
|
+
"Repeated cap kills, daemon orphans, or answered blocks need an operator to inspect progress.",
|
|
399
|
+
].join("\n"),
|
|
400
|
+
});
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Fail closed on work that exists only in a run repo. `addRunRepo` clears
|
|
405
|
+
// the tree at <workspaceRoot>/<issue> before it provisions, so admitting
|
|
406
|
+
// this issue is what finally destroys the copy the salvage could not save
|
|
407
|
+
// (#118). Nothing here can recover it — git already refused once — so the
|
|
408
|
+
// only safe move is to refuse the claim and keep saying why until an
|
|
409
|
+
// operator has looked and run `unblock --force`.
|
|
410
|
+
const newest = store.latestRun(project.name, issue);
|
|
411
|
+
if (newest?.salvageError !== undefined && newest.salvageAckAt === undefined) {
|
|
412
|
+
hold(issue, "unsalvaged-wip");
|
|
413
|
+
await safeEscalate(d, {
|
|
414
|
+
tier: 1,
|
|
415
|
+
project: project.name,
|
|
416
|
+
issue,
|
|
417
|
+
summary: `#${issue} is holding unsalvaged work and will not be re-claimed`,
|
|
418
|
+
detail: [
|
|
419
|
+
r.issue.title,
|
|
420
|
+
r.issue.url,
|
|
421
|
+
`Attempt ${newest.attempt} could not commit its uncommitted changes: ${newest.salvageError}`,
|
|
422
|
+
`The only copy is the worktree ${newest.worktree === "" ? "(path not recorded)" : newest.worktree}.`,
|
|
423
|
+
"Dispatch is held because claiming this issue removes that tree.",
|
|
424
|
+
"Recover it by hand, then `omp-conductor unblock <n> --force` to release the hold.",
|
|
425
|
+
].join("\n"),
|
|
426
|
+
});
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// #428 half (a): a preserved continuation that predates a configured
|
|
431
|
+
// critical-base/safety marker must not be reattached. A base safety fix
|
|
432
|
+
// protects only branches forked after it landed — a continuation forked
|
|
433
|
+
// before it still carries the dangerous test/runtime code, and re-running
|
|
434
|
+
// it on the shared host is what SIGTERMed the production daemon. Fail
|
|
435
|
+
// closed: only a probe that proves every marker is in the reattach
|
|
436
|
+
// source's ancestry admits, and a project that names a marker but has no
|
|
437
|
+
// probe wired (never happens outside tests) holds. Both the hold and the
|
|
438
|
+
// escalation are durable across restart and orphan recovery because this
|
|
439
|
+
// gate runs every admission pass; the branch is re-admitted automatically
|
|
440
|
+
// once the operator updates it to contain the marker, without losing work.
|
|
441
|
+
const markers = project.criticalBase ?? [];
|
|
442
|
+
if (markers.length > 0) {
|
|
443
|
+
const branch = branchName(r.issue);
|
|
444
|
+
let verdict: CriticalBaseVerdict;
|
|
445
|
+
if (d.probeCriticalBase === undefined) {
|
|
446
|
+
verdict = { state: "unknown", error: "no critical-base probe is wired in this deployment" };
|
|
447
|
+
} else {
|
|
448
|
+
try {
|
|
449
|
+
verdict = await d.probeCriticalBase(r.repo, markers, branch);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
verdict = { state: "unknown", error: errText(err) };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (verdict.state === "stale") {
|
|
455
|
+
hold(issue, "stale-base");
|
|
456
|
+
log(
|
|
457
|
+
`#${issue} held (stale-base): continuation branch ${branch} predates critical-base marker ${verdict.marker}`,
|
|
458
|
+
);
|
|
459
|
+
await safeEscalate(d, {
|
|
460
|
+
tier: 1,
|
|
461
|
+
project: project.name,
|
|
462
|
+
issue,
|
|
463
|
+
summary: `#${issue} continuation branch predates a critical base safety commit and is held (stale-base)`,
|
|
464
|
+
detail: [
|
|
465
|
+
r.issue.title,
|
|
466
|
+
r.issue.url,
|
|
467
|
+
`The retained branch ${branch} does not contain critical-base marker ${verdict.marker}.`,
|
|
468
|
+
...(verdict.range.length > 0
|
|
469
|
+
? [`Base commits the branch is missing: ${verdict.range.join(", ")}`]
|
|
470
|
+
: []),
|
|
471
|
+
"Recovery: merge current base into the branch so it contains the marker, and the next",
|
|
472
|
+
"admission pass re-admits it automatically without losing the branch's work; or review",
|
|
473
|
+
"the branch by hand and clear the hold once the fix is present.",
|
|
474
|
+
].join("\n"),
|
|
475
|
+
});
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (verdict.state === "unknown") {
|
|
479
|
+
// Fail closed: a branch that cannot be *proven* to contain the marker
|
|
480
|
+
// is refused, and the reason names the unverifiable marker so the
|
|
481
|
+
// operator can fix the fetch or the marker rather than guess.
|
|
482
|
+
hold(issue, "stale-base");
|
|
483
|
+
log(
|
|
484
|
+
`#${issue} held (stale-base): continuation branch ${branch} could not be verified ` +
|
|
485
|
+
`against critical-base marker(s) ${markers.join(", ")} (${verdict.error})`,
|
|
486
|
+
);
|
|
487
|
+
await safeEscalate(d, {
|
|
488
|
+
tier: 1,
|
|
489
|
+
project: project.name,
|
|
490
|
+
issue,
|
|
491
|
+
summary: `#${issue} continuation branch could not be verified against a critical base safety commit and is held (stale-base)`,
|
|
492
|
+
detail: [
|
|
493
|
+
r.issue.title,
|
|
494
|
+
r.issue.url,
|
|
495
|
+
`The retained branch ${branch} could not be verified against critical-base marker(s) ${markers.join(", ")}: ${verdict.error}`,
|
|
496
|
+
"Recovery: merge current base into the branch so it contains the marker, and the next",
|
|
497
|
+
"admission pass re-admits it automatically without losing the branch's work; or review",
|
|
498
|
+
"the branch by hand and clear the hold once the fix is present.",
|
|
499
|
+
].join("\n"),
|
|
500
|
+
});
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Soft concurrency per epic, per repository: at most one in-flight child of
|
|
506
|
+
// a given parent in each repo. Children of one epic in *different* repos
|
|
507
|
+
// parallelise freely — `repo-active` / `maxConcurrentWorkersPerRepo` owns
|
|
508
|
+
// the same-repo collision domain (#197). The "" sentinel matches every
|
|
509
|
+
// repo. No parent means today's concurrent admission. Cheap local filters
|
|
510
|
+
// already ran; this sits before the open-PR API call so a held sibling
|
|
511
|
+
// frees the slot for unrelated work without spending a closers query.
|
|
512
|
+
let parent: number | undefined;
|
|
513
|
+
try {
|
|
514
|
+
parent = await resolveParent(issue);
|
|
515
|
+
} catch (err) {
|
|
516
|
+
hold(issue, "parent-lookup-error");
|
|
517
|
+
log(`#${issue} held: parent check failed (${errText(err)}) — retrying next tick`);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (parent !== undefined) {
|
|
521
|
+
const occupied = occupiedParents.get(parent);
|
|
522
|
+
const blocker = occupied?.get(r.repo.name) ?? occupied?.get("");
|
|
523
|
+
// The gate serializes siblings under one epic: a held candidate must not
|
|
524
|
+
// proceed while a *different* child of the parent is occupied. But a
|
|
525
|
+
// candidate's own worker-free pushed-green row is exactly the work it is
|
|
526
|
+
// continuing, not a rival — the unblocked continuation of that same
|
|
527
|
+
// issue must not be rejected by its own occupancy, or the retained
|
|
528
|
+
// continuation deadlocks forever with the PR open.
|
|
529
|
+
if (blocker !== undefined && blocker !== issue) {
|
|
530
|
+
hold(issue, "sibling-active");
|
|
531
|
+
log(`#${issue} skipped: sibling #${blocker} in flight under epic #${parent} in ${r.repo.name}`);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// The busy set is built from run rows, so it can only speak for work this
|
|
537
|
+
// database recorded. Work pushed before this store existed — a migration, a
|
|
538
|
+
// wiped or relocated state dir, a restore onto a new host — looks exactly
|
|
539
|
+
// like fresh work, and a worker sent at it re-implements a finished PR. The
|
|
540
|
+
// tracker is the only party that remembers, so it is asked. The cost is
|
|
541
|
+
// bounded by free slots, not by queue depth: the call sits behind the two
|
|
542
|
+
// cheap local filters and candidates beyond capacity skip it.
|
|
543
|
+
let closer: OpenCloser | undefined;
|
|
544
|
+
try {
|
|
545
|
+
closer = await tracker.openCloserFor(issue);
|
|
546
|
+
} catch (err) {
|
|
547
|
+
// Fail closed, per candidate. An API error means "unknown whether
|
|
548
|
+
// finished work exists", and admitting on unknown recreates precisely the
|
|
549
|
+
// duplicate-work failure this guard exists to kill: the worst case of
|
|
550
|
+
// holding is a five-minute delay, the worst case of admitting is a burned
|
|
551
|
+
// attempt and a second PR on the same issue. Holding one candidate rather
|
|
552
|
+
// than aborting the loop keeps a transient GitHub failure from deadlocking
|
|
553
|
+
// the whole dispatcher; the next tick retries by itself.
|
|
554
|
+
hold(issue, "open-pr-lookup-error");
|
|
555
|
+
log(`#${issue} held: open-PR check failed (${errText(err)}) — retrying next tick`);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (closer !== undefined) {
|
|
559
|
+
const latest = store.latestRun(project.name, issue);
|
|
560
|
+
// Terminality is the first half of the test and is not negotiable: while a
|
|
561
|
+
// run is live its worker is still pushing to that branch, and a second
|
|
562
|
+
// worker sent at the same PR is exactly the duplicate-work failure this
|
|
563
|
+
// guard exists to kill. Only a run that has stopped can be continued.
|
|
564
|
+
const retained =
|
|
565
|
+
latest?.state === "blocked" ||
|
|
566
|
+
latest?.state === "failed" ||
|
|
567
|
+
latest?.state === "killed" ||
|
|
568
|
+
latest?.state === "orphaned" ||
|
|
569
|
+
latest?.state === "pushed-green"
|
|
570
|
+
? latest
|
|
571
|
+
: undefined;
|
|
572
|
+
// The second half asks "is this open PR our retained work", and accepts
|
|
573
|
+
// two identities for it, because the branch is the durable artefact of a
|
|
574
|
+
// retained run and the PR is not. A cap kill can end a run before any PR
|
|
575
|
+
// exists: veltro#324 attempt 1 was killed at the turns cap on
|
|
576
|
+
// 2026-08-09T00:47Z before its worker opened one, so the row kept `branch`
|
|
577
|
+
// and `prUrl` stayed NULL. chad#438 was opened from that exact branch
|
|
578
|
+
// afterwards, and URL equality — the only test 0.3.20 had — can never match
|
|
579
|
+
// a URL the terminal run never recorded, so every tick held #324 as
|
|
580
|
+
// `open-pr` until an operator closed recoverable work to free the branch
|
|
581
|
+
// (#50). An ordinary issue whose open PR is unrelated still fails both
|
|
582
|
+
// identities and stays ineligible, and an empty `headRefName` (a reply that
|
|
583
|
+
// did not carry the field) is never a match: unknown is not identity.
|
|
584
|
+
let resume: string | undefined;
|
|
585
|
+
if (retained !== undefined) {
|
|
586
|
+
if (retained.prUrl === closer.url) {
|
|
587
|
+
resume = `from ${retained.state} run (matched recorded PR URL)`;
|
|
588
|
+
} else if (closer.headRefName !== "" && retained.branch === closer.headRefName) {
|
|
589
|
+
resume = `from ${retained.state} run (matched retained branch ${closer.headRefName})`;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (resume === undefined) {
|
|
593
|
+
hold(issue, "open-pr");
|
|
594
|
+
log(`#${issue} skipped: open PR ${closer.url} already closes it`);
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
log(`#${issue} continuing retained PR ${closer.url} ${resume}`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// The queue comes from GitHub's eventually-consistent search index. Re-read
|
|
601
|
+
// state and labels directly at the last possible moment so a just-closed or
|
|
602
|
+
// explicitly dequeued issue cannot turn a stale candidate into another
|
|
603
|
+
// attempt (#247).
|
|
604
|
+
let snapshot: IssueSnapshot | undefined;
|
|
605
|
+
try {
|
|
606
|
+
snapshot = await tracker.issueSnapshot(issue);
|
|
607
|
+
} catch {
|
|
608
|
+
snapshot = undefined;
|
|
609
|
+
}
|
|
610
|
+
if (snapshot === undefined) {
|
|
611
|
+
hold(issue, "issue-state-lookup-error");
|
|
612
|
+
log(`#${issue} held: issue snapshot check failed — retrying next tick`);
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
if (snapshot.state === "closed") {
|
|
616
|
+
hold(issue, "issue-closed");
|
|
617
|
+
log(`#${issue} skipped: issue is closed (search index lag)`);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (!snapshot.labels.includes(project.queueLabel)) {
|
|
621
|
+
hold(issue, "issue-dequeued");
|
|
622
|
+
log(`#${issue} skipped: queue label ${project.queueLabel} was removed (search index lag)`);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// The Depends-on interlock (#419): a candidate declares the same-repo
|
|
627
|
+
// issues it must not be dispatched before, and any *open* prerequisite
|
|
628
|
+
// holds it until every referenced issue is closed. Prerequisite state is
|
|
629
|
+
// read fresh from the tracker at claim time, every pass — never cached
|
|
630
|
+
// across ticks — so a prerequisite that reopens re-holds on the next tick.
|
|
631
|
+
// Only same-repo `#<n>` references are resolved here; cross-repo
|
|
632
|
+
// references and graph cycles are later slices (#420/#421).
|
|
633
|
+
const dependsOn = parseDependsOn(r.issue.body);
|
|
634
|
+
if (dependsOn.malformed.length > 0) {
|
|
635
|
+
// A marker line with no strict `#<n>` reference (e.g. `Depends-on:
|
|
636
|
+
// #abc`) is ignored as a gate — it never holds, and it never crashes —
|
|
637
|
+
// but it is flagged so the issue is never *silently* dispatched with a
|
|
638
|
+
// dependency nobody read. Emitted once: the escalation ledger dedupes on
|
|
639
|
+
// the stable project/issue/tier/summary key, so repeated ticks do not
|
|
640
|
+
// re-page the orchestrator for the same malformed body.
|
|
641
|
+
await safeEscalate(d, {
|
|
642
|
+
tier: 1,
|
|
643
|
+
project: project.name,
|
|
644
|
+
issue,
|
|
645
|
+
summary: `#${issue} has a malformed Depends-on declaration and its undeclared prerequisite was not held`,
|
|
646
|
+
detail: [
|
|
647
|
+
r.issue.title,
|
|
648
|
+
r.issue.url,
|
|
649
|
+
...dependsOn.malformed.map((line) => `Malformed declaration: ${line}`),
|
|
650
|
+
"Depends-on references must be bare issue numbers (`#<n>`). Groom the body",
|
|
651
|
+
"so the dependency is honoured, or the issue is dispatched without it.",
|
|
652
|
+
].join("\n"),
|
|
653
|
+
});
|
|
654
|
+
log(`#${issue} flagged: malformed depends-on declaration(s): ${dependsOn.malformed.join("; ")}`);
|
|
655
|
+
}
|
|
656
|
+
if (dependsOn.refs.length > 0) {
|
|
657
|
+
let blocking: number | undefined;
|
|
658
|
+
let unreadable: number | undefined;
|
|
659
|
+
for (const ref of dependsOn.refs) {
|
|
660
|
+
let state: IssueSnapshot | undefined;
|
|
661
|
+
try {
|
|
662
|
+
state = await tracker.issueSnapshot(ref);
|
|
663
|
+
} catch {
|
|
664
|
+
state = undefined;
|
|
665
|
+
}
|
|
666
|
+
if (state?.state === "open") {
|
|
667
|
+
blocking = ref;
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
if (state === undefined) {
|
|
671
|
+
unreadable = ref;
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
// Fail closed on an unreadable prerequisite: admitting on unknown would
|
|
676
|
+
// dispatch work whose prerequisite may still be open, and the next tick
|
|
677
|
+
// rereads the tracker itself, so a transient failure just defers.
|
|
678
|
+
if (blocking !== undefined) {
|
|
679
|
+
hold(issue, "depends-on", `blocked by #${blocking}`);
|
|
680
|
+
log(`#${issue} held (depends-on): prerequisite #${blocking} is open`);
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
if (unreadable !== undefined) {
|
|
684
|
+
hold(issue, "depends-on", `prerequisite #${unreadable} state unreadable`);
|
|
685
|
+
log(`#${issue} held (depends-on): prerequisite #${unreadable} state unreadable`);
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// The file-lane interlock (#555): a candidate whose declared lane overlaps
|
|
691
|
+
// a live run's *actual* lane is held until that run's work has merged, so
|
|
692
|
+
// no second worker is sent at files another worker is still writing. The
|
|
693
|
+
// gate is the mechanical version of the prose rule that failed three times
|
|
694
|
+
// in one day. Only the machine-readable lane participates: a candidate
|
|
695
|
+
// without one is admitted exactly as today (fail open), and a candidate's
|
|
696
|
+
// own retained run never holds it — that is the continuation it continues.
|
|
697
|
+
const lane = declaredLane(r.issue.body);
|
|
698
|
+
if (lane.length > 0) {
|
|
699
|
+
const occupied = await ensureLaneOccupancy();
|
|
700
|
+
const perRepo = occupied.get(r.repo.name);
|
|
701
|
+
let blocked = false;
|
|
702
|
+
for (const file of lane) {
|
|
703
|
+
const holder = perRepo?.get(file);
|
|
704
|
+
if (holder !== undefined && holder !== issue) {
|
|
705
|
+
const detail = `${file} held by run #${holder}`;
|
|
706
|
+
hold(issue, "file-lane", detail);
|
|
707
|
+
log(`#${issue} held (file-lane): ${detail}`);
|
|
708
|
+
blocked = true;
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (blocked) continue;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
admitted.push({ r, attempt: priorRuns + 1 });
|
|
716
|
+
liveByRepo.set(r.repo.name, (liveByRepo.get(r.repo.name) ?? 0) + 1);
|
|
717
|
+
// Same-pass sibling occupancy for the file-lane gate: once admitted, a
|
|
718
|
+
// candidate's declared lane occupies for the rest of the pass, so a later
|
|
719
|
+
// overlapping candidate is held rather than both clearing in one tick.
|
|
720
|
+
if (lane.length > 0) {
|
|
721
|
+
const occupied = await ensureLaneOccupancy();
|
|
722
|
+
let perRepo = occupied.get(r.repo.name);
|
|
723
|
+
if (perRepo === undefined) {
|
|
724
|
+
perRepo = new Map();
|
|
725
|
+
occupied.set(r.repo.name, perRepo);
|
|
726
|
+
}
|
|
727
|
+
for (const file of lane) {
|
|
728
|
+
if (!perRepo.has(file)) perRepo.set(file, issue);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (parent !== undefined) {
|
|
732
|
+
// Extend the epic's occupancy under this repo (slot empty by construction
|
|
733
|
+
// here — the gate above would have held the candidate otherwise) so a
|
|
734
|
+
// same-repo sibling later in this pass does not clear the gate (#197).
|
|
735
|
+
let siblings = occupiedParents.get(parent);
|
|
736
|
+
if (siblings === undefined) {
|
|
737
|
+
siblings = new Map();
|
|
738
|
+
occupiedParents.set(parent, siblings);
|
|
739
|
+
}
|
|
740
|
+
if (!siblings.has(r.repo.name)) siblings.set(r.repo.name, issue);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return { admitted, holds };
|
|
745
|
+
}
|