omp-conductor 0.17.1 → 0.18.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/README.md +34 -0
- package/REFERENCE.md +71 -17
- package/agents/to-spec.md +90 -0
- package/package.json +2 -1
- package/schema/config.schema.json +53 -1
- package/src/admission.ts +308 -76
- package/src/ask.ts +307 -10
- package/src/backups.ts +2 -2
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +43 -14
- package/src/briefs/to-spec.md +84 -0
- package/src/briefs/worker.md +37 -19
- package/src/cli.ts +2 -0
- package/src/command-help.ts +19 -1
- package/src/command-manifest.ts +27 -2
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +110 -3
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +57 -0
- package/src/config.ts +102 -2
- package/src/daemon.ts +1220 -1517
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +279 -16
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +425 -1
- package/src/digest-schedule.ts +37 -0
- package/src/doctor.ts +52 -0
- package/src/escalate.ts +9 -3
- package/src/failure-class.ts +43 -4
- package/src/fleet.ts +166 -24
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +55 -8
- package/src/graph.ts +379 -69
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +567 -2
- package/src/lifecycle.ts +158 -6
- package/src/omp.ts +269 -20
- package/src/orchestrator-tick.ts +1489 -26
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/routing.ts +11 -3
- package/src/session-host.ts +115 -5
- package/src/settlement.ts +1780 -0
- package/src/setup-host.ts +1205 -6
- package/src/setup-install.ts +119 -30
- package/src/setup-wizard.ts +88 -2
- package/src/setup.ts +119 -13
- package/src/shell.ts +15 -0
- package/src/status-render.ts +100 -11
- package/src/store.ts +519 -45
- package/src/to-spec.ts +387 -0
- package/src/tracker/github.ts +150 -14
- package/src/types.ts +470 -16
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +770 -40
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +239 -9
- package/src/worktree.ts +142 -18
package/src/admission.ts
CHANGED
|
@@ -18,9 +18,13 @@ import { join } from "node:path";
|
|
|
18
18
|
import { log, errText, safeEscalate } from "./log.ts";
|
|
19
19
|
import type {
|
|
20
20
|
Caps,
|
|
21
|
+
EffectiveModel,
|
|
21
22
|
Escalation,
|
|
23
|
+
FileLane,
|
|
22
24
|
IssueComment,
|
|
23
25
|
IssueSnapshot,
|
|
26
|
+
LaneDeclaration,
|
|
27
|
+
ModelDeclaration,
|
|
24
28
|
OpenCloser,
|
|
25
29
|
ProjectConfig,
|
|
26
30
|
RunRecord,
|
|
@@ -32,7 +36,8 @@ import { readPlanUsage, type PlanUsageStatus, type UsageSource } from "./usage.t
|
|
|
32
36
|
import type { CriticalBaseProbe, CriticalBaseVerdict, LaneFile, LaneSource, RunLaneProbe } from "./gitops.ts";
|
|
33
37
|
import { repoSlugFor } from "./gitops.ts";
|
|
34
38
|
import { branchName, type Routed } from "./routing.ts";
|
|
35
|
-
import { parseDependsOn } from "./depends-on.ts";
|
|
39
|
+
import { parseDependsOn, buildDependencyCycles } from "./depends-on.ts";
|
|
40
|
+
import type { DependencyCycle, DependencyNodeReader } from "./depends-on.ts";
|
|
36
41
|
|
|
37
42
|
/** Fleet-wide escalations still need an issue number in the payload; 0 is the
|
|
38
43
|
* sentinel that reads as "no issue" in every renderer. */
|
|
@@ -64,6 +69,16 @@ export interface AdmissionDeps {
|
|
|
64
69
|
* prerequisite closed too — the daemon always wires it.
|
|
65
70
|
*/
|
|
66
71
|
probeIssueIn?: (repo: string, issue: number) => Promise<IssueSnapshot | undefined>;
|
|
72
|
+
/**
|
|
73
|
+
* Reads one issue's BODY in a repository the admission tracker is NOT bound
|
|
74
|
+
* to — the cross-repo half of the dependency-graph cycle pass (#421). The
|
|
75
|
+
* tracker bound to `project.tracker.repo` already answers same-repo bodies
|
|
76
|
+
* via {@link Tracker.issueBody}; a reachable prerequisite body in a routed
|
|
77
|
+
* repo goes through this. Undefined means "could not tell"; unset, a routed
|
|
78
|
+
* reachable body fails that branch closed (no false cycle), exactly like
|
|
79
|
+
* {@link Tracker.issueBody}. The daemon always wires it.
|
|
80
|
+
*/
|
|
81
|
+
probeBodyIn?: (repo: string, issue: number) => Promise<string | undefined>;
|
|
67
82
|
}
|
|
68
83
|
/** `stops` are the operational ends that each require one resume. */
|
|
69
84
|
export function hasContinuationBudget(stops: number, maxContinuations: number): boolean {
|
|
@@ -83,11 +98,16 @@ export function hasFailedAttemptBudget(failures: number, maxAttempts: number): b
|
|
|
83
98
|
* (#608): the exact snapshot the overlap gate enforced. Dispatch renders this
|
|
84
99
|
* value into the worker brief, so a changed or failed second comment read can
|
|
85
100
|
* neither hide nor reword the lane admission held — the gate and the
|
|
86
|
-
* worker-visible brief are one value, not two reads of the same thread.
|
|
101
|
+
* worker-visible brief are one value, not two reads of the same thread.
|
|
102
|
+
* `model` is the effective `Model:` declaration resolved from the same read
|
|
103
|
+
* (#535): not a gate, but the same one-value contract — dispatch launches on
|
|
104
|
+
* this selector and the brief echoes it, so a second read can neither hide
|
|
105
|
+
* the model admission held nor drift from it. */
|
|
87
106
|
export interface Admission {
|
|
88
107
|
r: Routed;
|
|
89
108
|
attempt: number;
|
|
90
109
|
lane?: FileLane;
|
|
110
|
+
model?: EffectiveModel;
|
|
91
111
|
}
|
|
92
112
|
|
|
93
113
|
export interface AdmissionHold {
|
|
@@ -181,20 +201,42 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
|
|
|
181
201
|
* files an issue declares it will touch (#555).
|
|
182
202
|
*
|
|
183
203
|
* The orchestrator already writes exactly this list into every promotion brief
|
|
184
|
-
* in prose; this parses that same
|
|
185
|
-
* pre-dispatch comment, which is the same
|
|
204
|
+
* in prose; this parses that same declaration out of the issue body — or out of
|
|
205
|
+
* a pre-dispatch comment, which is the same declaration on a supported brief
|
|
186
206
|
* surface since #517 — so the interlock is load-bearing rather than advisory.
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
* (
|
|
207
|
+
* Two spellings are accepted, and both work on every surface:
|
|
208
|
+
*
|
|
209
|
+
* - The inline line: a line beginning with "file lane" (case-insensitive,
|
|
210
|
+
* optional bold/heading markers) whose rest carries the paths as
|
|
211
|
+
* backtick-delimited spans (the brief form), with a bare
|
|
212
|
+
* comma/space-separated fallback that keeps tokens that look like relative
|
|
213
|
+
* paths.
|
|
214
|
+
* - The write-lane section (#825): a markdown heading "Exact write lane" (or
|
|
215
|
+
* "Write lane" / "write-lane") whose immediately following bullet items
|
|
216
|
+
* carry the paths — the package-floor decomposition format, where a groomed
|
|
217
|
+
* child names the files it writes as one backticked path per bullet. Only
|
|
218
|
+
* the contiguous bullet run under that heading is read: a following
|
|
219
|
+
* paragraph (like a "Read only:" caveat) or a later section ends it, so
|
|
220
|
+
* read-only entry points, proof commands and acceptance bullets elsewhere in
|
|
221
|
+
* the issue are never captured.
|
|
222
|
+
*
|
|
223
|
+
* Inline wins when both are present — the machine-shaped sentence has held
|
|
224
|
+
* since #555, and the mediated label echo shows exactly which declaration
|
|
225
|
+
* admission enforces. A line or section whose tokens are not pathlike (like
|
|
226
|
+
* `File lane: none`) is not a declaration. Anything else — including an
|
|
192
227
|
* absent declaration, which is the default — is an empty lane: the issue is
|
|
193
228
|
* admitted exactly as today (`fail open`), and the gate never refuses work for
|
|
194
|
-
* wanting a lane.
|
|
195
|
-
*
|
|
229
|
+
* wanting a lane. A clearly delimited write-lane section that parsed nothing
|
|
230
|
+
* is NOT an absent declaration: the promotion verb refuses it with an
|
|
231
|
+
* actionable syntax error ({@link writeLaneSectionHeading}) rather than
|
|
232
|
+
* echoing a fail-open the section's own heading contradicts.
|
|
196
233
|
*/
|
|
197
234
|
export function laneDeclaration(text: string): LaneDeclaration | undefined {
|
|
235
|
+
return inlineLaneDeclaration(text) ?? sectionLaneDeclaration(text);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The inline `File lane:`/`File-lane=` sentence grammar (see {@link laneDeclaration}). */
|
|
239
|
+
function inlineLaneDeclaration(text: string): LaneDeclaration | undefined {
|
|
198
240
|
const match = text.match(
|
|
199
241
|
/^\s*(?:[#>*-]\s*)*file[- ]lane\s*[:=]\s*([^\n]*)$/im,
|
|
200
242
|
);
|
|
@@ -211,34 +253,58 @@ export function laneDeclaration(text: string): LaneDeclaration | undefined {
|
|
|
211
253
|
return { files, source: match[0].trim() };
|
|
212
254
|
}
|
|
213
255
|
|
|
214
|
-
/** One parsed `File lane:` declaration: the paths and the verbatim source
|
|
215
|
-
* line, so a renderer can reproduce the declaration itself rather than a
|
|
216
|
-
* summary of it. Exported for the brief's guarantee that the gate's effective
|
|
217
|
-
* lane is always visible to the worker (#608). */
|
|
218
|
-
export interface LaneDeclaration {
|
|
219
|
-
files: string[];
|
|
220
|
-
/** The declaration line verbatim, as written on the surface it came from. */
|
|
221
|
-
source: string;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
256
|
/**
|
|
225
|
-
*
|
|
226
|
-
* (#
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* the
|
|
230
|
-
*
|
|
231
|
-
* worker. `at` records which surface won (`"body"`, or the 0-based comment
|
|
232
|
-
* index), letting the brief reproduce the declaration verbatim when the
|
|
233
|
-
* winning comment sits beyond its rendered discussion budget.
|
|
257
|
+
* A markdown heading that opens a clearly delimited write-lane section
|
|
258
|
+
* (#825): `## Exact write lane`, `## Write lane`, or a hyphenated spelling,
|
|
259
|
+
* optional trailing colon. Returns the heading line verbatim, or undefined.
|
|
260
|
+
* The promotion verb uses this to refuse a section that parsed to nothing —
|
|
261
|
+
* the echo must never claim fail-open for a heading that plainly tried to
|
|
262
|
+
* declare one.
|
|
234
263
|
*/
|
|
235
|
-
export
|
|
236
|
-
|
|
264
|
+
export function writeLaneSectionHeading(text: string): string | undefined {
|
|
265
|
+
const match = text.match(
|
|
266
|
+
/^[ \t]*#{1,6}[ \t]+(?:exact[ \t]+)?write[-\s]lane[ \t]*[:.]?[ \t]*$/im,
|
|
267
|
+
);
|
|
268
|
+
return match === null ? undefined : match[0].trim();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The write-lane section grammar (see {@link laneDeclaration}): the heading
|
|
272
|
+
* plus the contiguous bullet run beneath it. A non-bullet line ends the run,
|
|
273
|
+
* so a caveat paragraph or the next section bounds the declaration. */
|
|
274
|
+
function sectionLaneDeclaration(text: string): LaneDeclaration | undefined {
|
|
275
|
+
const lines = text.split("\n");
|
|
276
|
+
let start = -1;
|
|
277
|
+
for (let i = 0; i < lines.length; i++) {
|
|
278
|
+
if (writeLaneSectionHeading(lines[i]!) !== undefined) {
|
|
279
|
+
start = i;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (start < 0) return undefined;
|
|
284
|
+
const files: string[] = [];
|
|
285
|
+
const bullets: string[] = [];
|
|
286
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
287
|
+
const line = lines[i]!;
|
|
288
|
+
if (line.trim() === "") continue;
|
|
289
|
+
// A markdown bullet marker (`- `, `* `, `+ `, `1. `), with an optional
|
|
290
|
+
// task checkbox. Anything else ends the section's bullet run.
|
|
291
|
+
const marker = line.match(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/);
|
|
292
|
+
if (marker === null) break;
|
|
293
|
+
const rest = line.slice(marker[0].length);
|
|
294
|
+
const backticked = [...rest.matchAll(/`([^`]+)`/g)]
|
|
295
|
+
.map((m) => m[1]!.trim())
|
|
296
|
+
.filter(isPathLike);
|
|
297
|
+
files.push(...(backticked.length > 0 ? backticked : rest.split(/[,\s]+/).map((s) => s.trim()).filter(isPathLike)));
|
|
298
|
+
bullets.push(line.trim());
|
|
299
|
+
}
|
|
300
|
+
if (files.length === 0) return undefined;
|
|
301
|
+
return { files: [...new Set(files)], source: [lines[start]!.trim(), ...bullets].join("\n") };
|
|
237
302
|
}
|
|
238
303
|
|
|
239
304
|
/** Who holds one file's lane this admission pass, and which read proved it.
|
|
240
|
-
* `"declared"`
|
|
241
|
-
*
|
|
305
|
+
* `"declared"` covers both occupancy halves: an admitted candidate's own
|
|
306
|
+
* lane for the rest of its pass, and — since #744 — a live run's persisted
|
|
307
|
+
* declaration on every later pass, without any probe read. */
|
|
242
308
|
export type LaneHolder = { issue: number; source: LaneSource | "declared" };
|
|
243
309
|
|
|
244
310
|
/** Resolves the effective lane across the body and the whole comment thread. */
|
|
@@ -259,6 +325,53 @@ export function declaredLane(body: string): string[] {
|
|
|
259
325
|
return laneDeclaration(body)?.files ?? [];
|
|
260
326
|
}
|
|
261
327
|
|
|
328
|
+
/**
|
|
329
|
+
* The model half of a promotion brief (#535): the selector an issue declares
|
|
330
|
+
* it will dispatch on, read from the body or a pre-dispatch comment — the
|
|
331
|
+
* same two surfaces `File lane:` reads. Conductor names a selector and omp
|
|
332
|
+
* resolves what it means, so the declaration is one token in omp's own model
|
|
333
|
+
* syntax — a role alias (`@slow`) or a concrete `provider/model`, either
|
|
334
|
+
* optionally `:thinking`-suffixed — passed through verbatim as the session's
|
|
335
|
+
* `modelPattern`. A line whose rest is not exactly one selector (blank,
|
|
336
|
+
* `none`, multi-word prose) is not a declaration: dispatch fails open to the
|
|
337
|
+
* project's `workerModel`, exactly as a lane-less issue is admitted today.
|
|
338
|
+
* This is the one grammar for all surfaces; there is no second comment-only
|
|
339
|
+
* spelling.
|
|
340
|
+
*/
|
|
341
|
+
export function modelDeclaration(text: string): ModelDeclaration | undefined {
|
|
342
|
+
const match = text.match(
|
|
343
|
+
/^\s*(?:[#>*-]\s*)*model\s*[:=]\s*([^\n]*)$/im,
|
|
344
|
+
);
|
|
345
|
+
if (match === null) return undefined;
|
|
346
|
+
const rest = (match[1] ?? "").trim().replace(/^[*`\s]+|[*`\s]+$/g, "").trim();
|
|
347
|
+
if (rest === "" || /^none$/i.test(rest) || /\s/.test(rest)) return undefined;
|
|
348
|
+
return { model: rest, source: match[0].trim() };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Resolves the effective model across the body and the whole comment thread. */
|
|
352
|
+
export function effectiveModel(body: string, comments: IssueComment[]): EffectiveModel | undefined {
|
|
353
|
+
let current: EffectiveModel | undefined;
|
|
354
|
+
const bodyDecl = modelDeclaration(body);
|
|
355
|
+
if (bodyDecl !== undefined) current = { ...bodyDecl, at: "body" };
|
|
356
|
+
for (let i = 0; i < comments.length; i++) {
|
|
357
|
+
const decl = modelDeclaration(comments[i]!.body);
|
|
358
|
+
if (decl !== undefined) current = { ...decl, at: i };
|
|
359
|
+
}
|
|
360
|
+
return current;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The file lane as one line, for surfaces where a human asserts the claim
|
|
365
|
+
* (#724): the parsed file list exactly as admission will enforce it, or the
|
|
366
|
+
* explicit fail-open note when nothing parsed. Verbatim parser output only —
|
|
367
|
+
* a paraphrase would hide the very over-parse (#720) or under-parse (#684)
|
|
368
|
+
* this echo exists to surface.
|
|
369
|
+
*/
|
|
370
|
+
export function laneEcho(lane: LaneDeclaration | undefined): string {
|
|
371
|
+
if (lane === undefined) return "no lane declared (fail open)";
|
|
372
|
+
return lane.files.join(", ");
|
|
373
|
+
}
|
|
374
|
+
|
|
262
375
|
/** A plausible relative path: it has a `.` extension or a directory separator. */
|
|
263
376
|
function isPathLike(token: string): boolean {
|
|
264
377
|
return token !== "" && !/\s/.test(token) && (token.includes("/") || /\.[A-Za-z0-9]{1,10}$/.test(token));
|
|
@@ -325,43 +438,61 @@ export async function admitCandidates(
|
|
|
325
438
|
};
|
|
326
439
|
|
|
327
440
|
// The file-lane interlock (#555): repo -> file -> the issue holding that file.
|
|
328
|
-
// Seeded from
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
441
|
+
// Seeded from what a live run actually *occupies*, never from its issue body:
|
|
442
|
+
// the probed worktrees/branches of active runs (#288) unioned — since #744 —
|
|
443
|
+
// with the lane declaration admission persisted on the row at dispatch, which
|
|
444
|
+
// is the exact enforced snapshot, not a re-parse. The declared half is what
|
|
445
|
+
// survives across passes: a run admitted with a lane it has not yet written
|
|
446
|
+
// still occupies until it reaches a terminal state and leaves the active set,
|
|
447
|
+
// and the declared lanes are extended as this pass admits candidates, so two
|
|
448
|
+
// overlapping candidates cannot both clear the gate in one tick. Keyed by
|
|
449
|
+
// repo because a path only collides within its own checkout: a `daemon.ts` on
|
|
450
|
+
// the api repo and one on the web repo are different files. Built lazily and
|
|
451
|
+
// once, only when the first candidate that declares a lane reaches the gate,
|
|
452
|
+
// so a queue of laneless issues pays nothing for it.
|
|
336
453
|
let laneOccupancy: Map<string, Map<string, LaneHolder>> | undefined;
|
|
337
454
|
const ensureLaneOccupancy = async (): Promise<Map<string, Map<string, LaneHolder>>> => {
|
|
338
455
|
if (laneOccupancy !== undefined) return laneOccupancy;
|
|
339
456
|
const occupied = new Map<string, Map<string, LaneHolder>>();
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
mirror: join(project.mirrorRoot, `${run.repo}.git`),
|
|
350
|
-
});
|
|
351
|
-
} catch {
|
|
352
|
-
// Fail open, like an unreadable lane: the gate never refuses a
|
|
353
|
-
// well-formed issue because one probe could not be answered.
|
|
354
|
-
files = [];
|
|
457
|
+
// Shared per-file first-read-wins: a file a run both declares and has
|
|
458
|
+
// actually touched is tagged by the concrete probe read, and across runs
|
|
459
|
+
// whichever live run comes first in the active set is the holder named —
|
|
460
|
+
// both are legitimate for the gate, which holds on any overlap.
|
|
461
|
+
const occupy = (run: RunRecord, file: string, source: LaneSource | "declared"): void => {
|
|
462
|
+
let perRepo = occupied.get(run.repo);
|
|
463
|
+
if (perRepo === undefined) {
|
|
464
|
+
perRepo = new Map();
|
|
465
|
+
occupied.set(run.repo, perRepo);
|
|
355
466
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
467
|
+
if (!perRepo.has(file)) perRepo.set(file, { issue: run.issue, source });
|
|
468
|
+
};
|
|
469
|
+
for (const run of activeRuns) {
|
|
470
|
+
// The probed actual half first, so the concrete read outnames the
|
|
471
|
+
// declaration for a file the run has already written.
|
|
472
|
+
if (d.probeWorktreeLane !== undefined) {
|
|
473
|
+
const base = project.routing.repos[run.repo]?.defaultBranch ?? "main";
|
|
474
|
+
let files: LaneFile[];
|
|
475
|
+
try {
|
|
476
|
+
files = await d.probeWorktreeLane({
|
|
477
|
+
worktree: run.worktree,
|
|
478
|
+
baseRef: `refs/remotes/origin/${base}`,
|
|
479
|
+
...(run.branch === "" ? {} : { branchRef: `refs/heads/${run.branch}` }),
|
|
480
|
+
mirror: join(project.mirrorRoot, `${run.repo}.git`),
|
|
481
|
+
});
|
|
482
|
+
} catch {
|
|
483
|
+
// Fail open, like an unreadable lane: the gate never refuses a
|
|
484
|
+
// well-formed issue because one probe could not be answered.
|
|
485
|
+
files = [];
|
|
361
486
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
487
|
+
for (const { file, source } of files) occupy(run, file, source);
|
|
488
|
+
}
|
|
489
|
+
// The declared durable half (#744): the lane admission persisted at
|
|
490
|
+
// dispatch. Occupies even before the run has written anything, on every
|
|
491
|
+
// pass — the declaration lives on the row, not in a per-pass memo.
|
|
492
|
+
// Released by the active-set filter exactly like probed occupancy: a
|
|
493
|
+
// terminal run leaves `activeRuns` and its files stop occupying here.
|
|
494
|
+
if (run.lane !== undefined && run.lane.files.length > 0) {
|
|
495
|
+
for (const file of run.lane.files) occupy(run, file, "declared");
|
|
365
496
|
}
|
|
366
497
|
}
|
|
367
498
|
laneOccupancy = occupied;
|
|
@@ -385,31 +516,45 @@ export async function admitCandidates(
|
|
|
385
516
|
// the worker therefore agree on one lane, which is the #608 defect's shape.
|
|
386
517
|
//
|
|
387
518
|
// Comments are read at most once per issue per pass — the lane feeds both
|
|
388
|
-
// the gate and the same-pass sibling occupancy below,
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
519
|
+
// the gate and the same-pass sibling occupancy below, and the model rides
|
|
520
|
+
// the same read for its one-value contract with dispatch (#535), so the
|
|
521
|
+
// cache is what stops one candidate costing two comment reads. Unreadable
|
|
522
|
+
// comments fail open to the body declarations (a body lane stays
|
|
523
|
+
// load-bearing, and a body model stays the one dispatch follows), and with
|
|
524
|
+
// no readable declaration at all the #555 fail-open admission is unchanged.
|
|
392
525
|
//
|
|
393
526
|
// The resolved `FileLane` — not just its paths — is what an admitted
|
|
394
527
|
// candidate carries into dispatch (#608): the gate and the brief must agree
|
|
395
528
|
// on the *same declaration* (paths, verbatim source line, and which surface
|
|
396
529
|
// it came from), so dispatch renders this cached value rather than reading
|
|
397
|
-
// the thread a second time and hoping it did not change.
|
|
530
|
+
// the thread a second time and hoping it did not change. The resolved
|
|
531
|
+
// `EffectiveModel` is carried the same way and for the same reason: dispatch
|
|
532
|
+
// launches on exactly this selector and the brief echoes it.
|
|
398
533
|
const laneCache = new Map<number, FileLane | undefined>();
|
|
399
|
-
const
|
|
534
|
+
const modelCache = new Map<number, EffectiveModel | undefined>();
|
|
535
|
+
const declarationsFor = async (r: Routed): Promise<void> => {
|
|
400
536
|
const issue = r.issue.number;
|
|
401
|
-
if (laneCache.has(issue)) return
|
|
537
|
+
if (laneCache.has(issue)) return;
|
|
402
538
|
let lane: FileLane | undefined;
|
|
539
|
+
let model: EffectiveModel | undefined;
|
|
403
540
|
try {
|
|
404
|
-
|
|
541
|
+
const comments = await tracker.listComments(issue);
|
|
542
|
+
lane = effectiveLane(r.issue.body, comments);
|
|
543
|
+
model = effectiveModel(r.issue.body, comments);
|
|
405
544
|
} catch (err) {
|
|
406
545
|
const bodyDecl = laneDeclaration(r.issue.body);
|
|
407
546
|
lane = bodyDecl === undefined ? undefined : { ...bodyDecl, at: "body" };
|
|
408
|
-
|
|
547
|
+
const bodyModel = modelDeclaration(r.issue.body);
|
|
548
|
+
model = bodyModel === undefined ? undefined : { ...bodyModel, at: "body" };
|
|
549
|
+
log(`#${issue} comments unreadable at admission; the body's declarations stand: ${errText(err)}`);
|
|
409
550
|
}
|
|
410
551
|
laneCache.set(issue, lane);
|
|
411
|
-
|
|
552
|
+
modelCache.set(issue, model);
|
|
412
553
|
};
|
|
554
|
+
const laneFor = (r: Routed): Promise<FileLane | undefined> =>
|
|
555
|
+
declarationsFor(r).then(() => laneCache.get(r.issue.number));
|
|
556
|
+
const modelFor = (r: Routed): Promise<EffectiveModel | undefined> =>
|
|
557
|
+
declarationsFor(r).then(() => modelCache.get(r.issue.number));
|
|
413
558
|
|
|
414
559
|
// The plan allowance is a fleet-wide question, so it is asked once per pass
|
|
415
560
|
// and answers for every candidate — unlike every gate below it, which is
|
|
@@ -427,6 +572,9 @@ export async function admitCandidates(
|
|
|
427
572
|
log(`plan usage gate holding ${String(routed.length)} candidate(s): ${plan.detail}`);
|
|
428
573
|
const escalation = planUsageEscalation(project.name, plan);
|
|
429
574
|
if (escalation !== undefined) await safeEscalate(d, escalation);
|
|
575
|
+
// Every pass exit reconciles the durable blocked inventory against this
|
|
576
|
+
// pass's holds, so a hold that stopped applying clears itself (#735).
|
|
577
|
+
store.reconcileGrooming(project.name, holds);
|
|
430
578
|
return { admitted: [], holds };
|
|
431
579
|
}
|
|
432
580
|
|
|
@@ -469,8 +617,79 @@ export async function admitCandidates(
|
|
|
469
617
|
}
|
|
470
618
|
|
|
471
619
|
const admitted: Admission[] = [];
|
|
620
|
+
|
|
621
|
+
// Phase A — dependency-graph cycle pass (#421). Built ONCE per pass, before
|
|
622
|
+
// any per-candidate open-prerequisite hold, so a candidate sitting on a cycle
|
|
623
|
+
// is held as `dependency-cycle` instead of having an open first edge mask the
|
|
624
|
+
// group. Readers follow the same tracker/probe seams as the per-candidate
|
|
625
|
+
// interlock: same-repo refs via `tracker`, routed refs via `probeIssueIn` /
|
|
626
|
+
// `probeBodyIn`. Traversal is bounded to the tracker/routed repos and every
|
|
627
|
+
// node is read at most once; a tracker failure fails closed (no edge) rather
|
|
628
|
+
// than ever synthesising a cycle.
|
|
629
|
+
const readDepNode: DependencyNodeReader = async (repo, issue) => {
|
|
630
|
+
const snap = await (async () => {
|
|
631
|
+
try {
|
|
632
|
+
if (repo === project.tracker.repo) return await tracker.issueSnapshot(issue);
|
|
633
|
+
return d.probeIssueIn === undefined ? undefined : await d.probeIssueIn(repo, issue);
|
|
634
|
+
} catch {
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
637
|
+
})();
|
|
638
|
+
if (snap === undefined) return undefined;
|
|
639
|
+
if (snap.state === "closed") return { state: "closed", body: "" };
|
|
640
|
+
const body = await (async () => {
|
|
641
|
+
try {
|
|
642
|
+
if (repo === project.tracker.repo) return await tracker.issueBody(issue);
|
|
643
|
+
return d.probeBodyIn === undefined ? undefined : await d.probeBodyIn(repo, issue);
|
|
644
|
+
} catch {
|
|
645
|
+
return undefined;
|
|
646
|
+
}
|
|
647
|
+
})();
|
|
648
|
+
return body === undefined ? undefined : { state: "open", body };
|
|
649
|
+
};
|
|
650
|
+
const graph = await buildDependencyCycles(
|
|
651
|
+
routed.map((r) => ({ repo: project.tracker.repo, issue: r.issue.number, body: r.issue.body })),
|
|
652
|
+
project.tracker.repo,
|
|
653
|
+
(ownerRepo) => crossRepoTarget(project, ownerRepo),
|
|
654
|
+
readDepNode,
|
|
655
|
+
);
|
|
656
|
+
// candidate issue -> the cycle path that holds it, for status/digest.
|
|
657
|
+
const memberCycle = new Map<number, string>();
|
|
658
|
+
for (const cycle of graph.cycles) {
|
|
659
|
+
for (const member of cycle.members) {
|
|
660
|
+
const [repo, issueStr] = member.split("#") as [string, string];
|
|
661
|
+
if (repo !== project.tracker.repo) continue;
|
|
662
|
+
const n = Number(issueStr);
|
|
663
|
+
// Prefer the cycle anchored at this member (its "own" cycle) when a
|
|
664
|
+
// candidate sits on more than one.
|
|
665
|
+
if (!memberCycle.has(n) || cycle.anchorIssue === n) memberCycle.set(n, cycle.display);
|
|
666
|
+
}
|
|
667
|
+
// One material event per stable cycle identity: the escalation ledger
|
|
668
|
+
// dedupes on project/issue/tier/summary, and the summary carries the
|
|
669
|
+
// canonical path, so a stable cycle pages once and a changed cycle emits a
|
|
670
|
+
// new event (the rotated/queue-ordered spelling is canonicalised away).
|
|
671
|
+
await safeEscalate(d, {
|
|
672
|
+
tier: 1,
|
|
673
|
+
project: project.name,
|
|
674
|
+
issue: cycle.anchorIssue,
|
|
675
|
+
summary: `Dependency cycle detected: ${cycle.display}`,
|
|
676
|
+
detail: [
|
|
677
|
+
"None of the issues on this cycle can be dispatched until one edge is broken.",
|
|
678
|
+
`Cycle: ${cycle.display}`,
|
|
679
|
+
"Break it by closing or reordering a `Depends-on:` reference, and the affected",
|
|
680
|
+
"candidates become claimable again on the next tick.",
|
|
681
|
+
].join("\n"),
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
472
685
|
for (const r of routed) {
|
|
473
686
|
const issue = r.issue.number;
|
|
687
|
+
const cyclePath = memberCycle.get(issue);
|
|
688
|
+
if (cyclePath !== undefined) {
|
|
689
|
+
hold(issue, "dependency-cycle", cyclePath);
|
|
690
|
+
log(`#${issue} held (dependency-cycle): ${cyclePath}`);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
474
693
|
if (admitted.length >= slots) {
|
|
475
694
|
hold(issue, "capacity");
|
|
476
695
|
continue;
|
|
@@ -761,6 +980,15 @@ export async function admitCandidates(
|
|
|
761
980
|
log(`#${issue} skipped: queue label ${project.queueLabel} was removed (search index lag)`);
|
|
762
981
|
continue;
|
|
763
982
|
}
|
|
983
|
+
// Park beats queue here too (#734): the candidate list was fetched from
|
|
984
|
+
// the search index before the operator parked the issue, so the fresh
|
|
985
|
+
// snapshot is the last gate. A park never touches a live run — it only
|
|
986
|
+
// stops a claim.
|
|
987
|
+
if (snapshot.labels.includes(project.stateLabels.backlog)) {
|
|
988
|
+
hold(issue, "issue-parked");
|
|
989
|
+
log(`#${issue} skipped: park label ${project.stateLabels.backlog} was applied since listing`);
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
764
992
|
|
|
765
993
|
// The Depends-on interlock (#419/#420): a candidate declares the issues it
|
|
766
994
|
// must not be dispatched before — same-repo (`#123`) or cross-repo
|
|
@@ -932,7 +1160,7 @@ export async function admitCandidates(
|
|
|
932
1160
|
if (blocked) continue;
|
|
933
1161
|
}
|
|
934
1162
|
|
|
935
|
-
admitted.push({ r, attempt: priorRuns + 1, lane });
|
|
1163
|
+
admitted.push({ r, attempt: priorRuns + 1, lane, model: await modelFor(r) });
|
|
936
1164
|
liveByRepo.set(r.repo.name, (liveByRepo.get(r.repo.name) ?? 0) + 1);
|
|
937
1165
|
// Same-pass sibling occupancy for the file-lane gate: once admitted, a
|
|
938
1166
|
// candidate's declared lane occupies for the rest of the pass, so a later
|
|
@@ -961,5 +1189,9 @@ export async function admitCandidates(
|
|
|
961
1189
|
}
|
|
962
1190
|
}
|
|
963
1191
|
|
|
1192
|
+
// Every pass exit reconciles the durable blocked inventory against this
|
|
1193
|
+
// pass's holds, so a lane/dependency hold that stopped applying clears
|
|
1194
|
+
// itself on the very next pass (#735).
|
|
1195
|
+
store.reconcileGrooming(project.name, holds);
|
|
964
1196
|
return { admitted, holds };
|
|
965
1197
|
}
|