omp-conductor 0.18.2 → 0.19.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/README.md +105 -40
- package/REFERENCE.md +865 -30
- package/package.json +1 -1
- package/schema/config.schema.json +26 -0
- package/src/admission.ts +212 -26
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +6 -5
- package/src/cli.ts +5 -1
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +36 -3
- package/src/commands/arm.ts +5 -1
- package/src/commands/context.ts +2 -0
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/config-schema.ts +19 -0
- package/src/config.ts +80 -0
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1218 -288
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/doctor.ts +377 -20
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +497 -15
- package/src/host.ts +6 -130
- package/src/omp.ts +29 -0
- package/src/orchestrator-tick.ts +343 -88
- package/src/pause.ts +233 -0
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +321 -1155
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +111 -50
- package/src/setup.ts +33 -0
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +348 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +27 -0
- package/src/types.ts +697 -4
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +299 -12
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +447 -8
- package/src/wake.ts +48 -0
- package/src/worker.ts +403 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
|
@@ -35,6 +35,18 @@
|
|
|
35
35
|
],
|
|
36
36
|
"description": "Rolling-day spend ceiling; null means no spend gate (default 25)"
|
|
37
37
|
},
|
|
38
|
+
"maxRunSpendUsd": {
|
|
39
|
+
"anyOf": [
|
|
40
|
+
{
|
|
41
|
+
"type": "number",
|
|
42
|
+
"exclusiveMinimum": 0
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"type": "null"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"description": "Ceiling on one run, reserved from the daily budget before it launches; null derives it from dailySpendUsd. Must not exceed dailySpendUsd"
|
|
49
|
+
},
|
|
38
50
|
"planUsage": {
|
|
39
51
|
"anyOf": [
|
|
40
52
|
{
|
|
@@ -272,6 +284,18 @@
|
|
|
272
284
|
],
|
|
273
285
|
"description": "Rolling-day spend ceiling; null means no spend gate (default 25)"
|
|
274
286
|
},
|
|
287
|
+
"maxRunSpendUsd": {
|
|
288
|
+
"anyOf": [
|
|
289
|
+
{
|
|
290
|
+
"type": "number",
|
|
291
|
+
"exclusiveMinimum": 0
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
"type": "null"
|
|
295
|
+
}
|
|
296
|
+
],
|
|
297
|
+
"description": "Ceiling on one run, reserved from the daily budget before it launches; null derives it from dailySpendUsd. Must not exceed dailySpendUsd"
|
|
298
|
+
},
|
|
275
299
|
"planUsage": {
|
|
276
300
|
"anyOf": [
|
|
277
301
|
{
|
|
@@ -334,6 +358,8 @@
|
|
|
334
358
|
"workerModel": {},
|
|
335
359
|
"modelFallbacks": {},
|
|
336
360
|
"modelFallbackThreshold": {},
|
|
361
|
+
"workerEscalationModel": {},
|
|
362
|
+
"requireOauthProviders": {},
|
|
337
363
|
"ompSettings": {},
|
|
338
364
|
"workerAdvisor": {},
|
|
339
365
|
"escalation": {
|
package/src/admission.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { log, errText, safeEscalate } from "./log.ts";
|
|
19
|
+
import { oauthFenceVerdict, type CredentialClassProbeResult } from "./credential-class.ts";
|
|
19
20
|
import type {
|
|
20
21
|
Caps,
|
|
21
22
|
EffectiveModel,
|
|
@@ -35,6 +36,7 @@ import type {
|
|
|
35
36
|
import { readPlanUsage, type PlanUsageStatus, type UsageSource } from "./usage.ts";
|
|
36
37
|
import type { CriticalBaseProbe, CriticalBaseVerdict, LaneFile, LaneSource, RunLaneProbe } from "./gitops.ts";
|
|
37
38
|
import { repoSlugFor } from "./gitops.ts";
|
|
39
|
+
import { LIVE_STATES } from "./store.ts";
|
|
38
40
|
import { branchName, type Routed } from "./routing.ts";
|
|
39
41
|
import { parseDependsOn, buildDependencyCycles } from "./depends-on.ts";
|
|
40
42
|
import type { DependencyCycle, DependencyNodeReader } from "./depends-on.ts";
|
|
@@ -79,6 +81,18 @@ export interface AdmissionDeps {
|
|
|
79
81
|
* {@link Tracker.issueBody}. The daemon always wires it.
|
|
80
82
|
*/
|
|
81
83
|
probeBodyIn?: (repo: string, issue: number) => Promise<string | undefined>;
|
|
84
|
+
/**
|
|
85
|
+
* Reads one provider's current credential class from the harness (#852).
|
|
86
|
+
*
|
|
87
|
+
* Injected because the production reader spawns a subprocess against the live
|
|
88
|
+
* credential store, and admission's tests must be able to drive every verdict
|
|
89
|
+
* — including "the probe could not answer" — without one. Unset, the fence
|
|
90
|
+
* cannot verify anything, so a project that declares
|
|
91
|
+
* `requireOauthProviders` is held rather than dispatched: the whole point is
|
|
92
|
+
* that unverified and wrong cost the same. A project that declares nothing
|
|
93
|
+
* never calls it.
|
|
94
|
+
*/
|
|
95
|
+
probeCredentialClass?: (provider: string) => Promise<CredentialClassProbeResult>;
|
|
82
96
|
}
|
|
83
97
|
/** `stops` are the operational ends that each require one resume. */
|
|
84
98
|
export function hasContinuationBudget(stops: number, maxContinuations: number): boolean {
|
|
@@ -93,6 +107,52 @@ export function hasFailedAttemptBudget(failures: number, maxAttempts: number): b
|
|
|
93
107
|
return failures < maxAttempts;
|
|
94
108
|
}
|
|
95
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Local midnight, matching how a human reads "today".
|
|
112
|
+
*
|
|
113
|
+
* ponytail: a rolling 24h window would be fairer to a run that started at
|
|
114
|
+
* 23:50, but midnight is what someone checking a morning spend report expects.
|
|
115
|
+
* Upgrade path is a `capWindow: "day" | "rolling24h"` config key.
|
|
116
|
+
*
|
|
117
|
+
* Lives here, with the spend gates that read it, and is imported by the
|
|
118
|
+
* composition root rather than the other way round: one definition of "today"
|
|
119
|
+
* for the fleet-stop check, the reservation gate and the status line, because
|
|
120
|
+
* three midnights would eventually disagree.
|
|
121
|
+
*/
|
|
122
|
+
export function startOfToday(): number {
|
|
123
|
+
const d = new Date();
|
|
124
|
+
d.setHours(0, 0, 0, 0);
|
|
125
|
+
return d.getTime();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What one run is allowed to spend, and therefore what admission reserves for
|
|
130
|
+
* it before launching anything (#851).
|
|
131
|
+
*
|
|
132
|
+
* `null` means unbounded: no daily cap and no explicit per-run ceiling, so
|
|
133
|
+
* there is nothing to reserve and nothing to enforce.
|
|
134
|
+
*
|
|
135
|
+
* An explicit `maxRunSpendUsd` always wins. Otherwise the allowance is derived
|
|
136
|
+
* as the daily cap divided by the worker cap — the largest per-run reservation
|
|
137
|
+
* that still lets the fleet run at its configured concurrency. Reserving the
|
|
138
|
+
* WHOLE daily cap per run would bound overshoot just as well, but it would
|
|
139
|
+
* also serialise every fleet on defaults (a $25 cap and two workers would only
|
|
140
|
+
* ever admit one run), turning a spend guard into a concurrency change nobody
|
|
141
|
+
* asked for. Dividing keeps both promises: the day's total is still bounded by
|
|
142
|
+
* the cap, because N reservations of cap/N are the cap.
|
|
143
|
+
*
|
|
144
|
+
* A zero worker cap has no runs to allow, and is guarded rather than divided
|
|
145
|
+
* by.
|
|
146
|
+
*/
|
|
147
|
+
export function runSpendAllowanceUsd(
|
|
148
|
+
caps: Pick<Caps, "dailySpendUsd" | "maxRunSpendUsd" | "maxConcurrentWorkers">,
|
|
149
|
+
): number | null {
|
|
150
|
+
if (caps.maxRunSpendUsd !== null) return caps.maxRunSpendUsd;
|
|
151
|
+
if (caps.dailySpendUsd === null) return null;
|
|
152
|
+
const workers = Math.max(1, caps.maxConcurrentWorkers);
|
|
153
|
+
return caps.dailySpendUsd / workers;
|
|
154
|
+
}
|
|
155
|
+
|
|
96
156
|
/** A candidate cleared for dispatch, with the attempt number it will run as.
|
|
97
157
|
* `lane` is the effective file-lane declaration admission resolved for it
|
|
98
158
|
* (#608): the exact snapshot the overlap gate enforced. Dispatch renders this
|
|
@@ -437,13 +497,58 @@ export async function admitCandidates(
|
|
|
437
497
|
holds.push({ issue, reason, ...(detail === undefined ? {} : { detail }) });
|
|
438
498
|
};
|
|
439
499
|
|
|
500
|
+
// Runs holding an ACTIVE MUTATION LEASE (#899): the only rows whose files a
|
|
501
|
+
// candidate must not touch, because they are the only rows something is
|
|
502
|
+
// currently writing through.
|
|
503
|
+
//
|
|
504
|
+
// Deliberately narrower than `activeRuns`, and this is the whole of the fix.
|
|
505
|
+
// A `pushed-green` / `pushed-pending` row is worker-free by construction —
|
|
506
|
+
// its PR is live but no process owns its branch — and seeding lane occupancy
|
|
507
|
+
// from it meant a settled artifact held its entire diff forever: measured on
|
|
508
|
+
// this fleet, worker-free #782 / PR #870 held `omp/src/daemon.ts` and idled
|
|
509
|
+
// slots against unrelated candidates that merely overlapped it.
|
|
510
|
+
//
|
|
511
|
+
// What does NOT change: `activeRuns` still answers same-issue ownership, the
|
|
512
|
+
// sibling/parent occupancy and the open-PR gate, so releasing the *lane*
|
|
513
|
+
// never lets the artifact's own issue be re-implemented (the named silent
|
|
514
|
+
// fake of #899). Only file occupancy narrows.
|
|
515
|
+
//
|
|
516
|
+
// A dispatched, unsettled review revision is a lease too. Its run row is
|
|
517
|
+
// `running` the moment `claimRunForReview` fires, so it is normally already
|
|
518
|
+
// in `LIVE_STATES`; it is unioned here anyway because the claim and the
|
|
519
|
+
// launch are not the same instant, and a lane the gate briefly forgets is a
|
|
520
|
+
// lane two writers can share.
|
|
521
|
+
const leasedRuns = store.leasedRuns(project.name);
|
|
522
|
+
|
|
523
|
+
// One run's actual lane: the files it has touched relative to base, read from
|
|
524
|
+
// its worktree and/or its branch in the mirror. Fails OPEN by construction —
|
|
525
|
+
// an unreadable probe yields no files — because this side of the interlock is
|
|
526
|
+
// automatic and continuous, and a gate that refused well-formed work whenever
|
|
527
|
+
// a git read hiccuped would idle the fleet on a transient. (The deliberate
|
|
528
|
+
// counterpart is `conductor_pr_recover`, which is a single human-initiated
|
|
529
|
+
// call with a retryable refusal, and therefore fails closed — #925.)
|
|
530
|
+
const probeRunFiles = async (run: RunRecord): Promise<LaneFile[]> => {
|
|
531
|
+
if (d.probeWorktreeLane === undefined) return [];
|
|
532
|
+
const base = project.routing.repos[run.repo]?.defaultBranch ?? "main";
|
|
533
|
+
try {
|
|
534
|
+
return await d.probeWorktreeLane({
|
|
535
|
+
worktree: run.worktree,
|
|
536
|
+
baseRef: `refs/remotes/origin/${base}`,
|
|
537
|
+
...(run.branch === "" ? {} : { branchRef: `refs/heads/${run.branch}` }),
|
|
538
|
+
mirror: join(project.mirrorRoot, `${run.repo}.git`),
|
|
539
|
+
});
|
|
540
|
+
} catch {
|
|
541
|
+
return [];
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
|
|
440
545
|
// The file-lane interlock (#555): repo -> file -> the issue holding that file.
|
|
441
|
-
// Seeded from what a
|
|
442
|
-
// the probed worktrees/branches of
|
|
443
|
-
//
|
|
444
|
-
// is the exact enforced snapshot, not a
|
|
445
|
-
// survives across passes: a run admitted
|
|
446
|
-
//
|
|
546
|
+
// Seeded from what a leaseholder actually *occupies*, never from its issue
|
|
547
|
+
// body: the probed worktrees/branches of runs holding a mutation lease (#288,
|
|
548
|
+
// #899) unioned — since #744 — with the lane declaration admission persisted
|
|
549
|
+
// on the row at dispatch, which is the exact enforced snapshot, not a
|
|
550
|
+
// re-parse. The declared half is what survives across passes: a run admitted
|
|
551
|
+
// with a lane it has not yet written still occupies while it holds its lease,
|
|
447
552
|
// and the declared lanes are extended as this pass admits candidates, so two
|
|
448
553
|
// overlapping candidates cannot both clear the gate in one tick. Keyed by
|
|
449
554
|
// repo because a path only collides within its own checkout: a `daemon.ts` on
|
|
@@ -466,31 +571,16 @@ export async function admitCandidates(
|
|
|
466
571
|
}
|
|
467
572
|
if (!perRepo.has(file)) perRepo.set(file, { issue: run.issue, source });
|
|
468
573
|
};
|
|
469
|
-
for (const run of
|
|
574
|
+
for (const run of leasedRuns) {
|
|
470
575
|
// The probed actual half first, so the concrete read outnames the
|
|
471
576
|
// declaration for a file the run has already written.
|
|
472
|
-
|
|
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 = [];
|
|
486
|
-
}
|
|
487
|
-
for (const { file, source } of files) occupy(run, file, source);
|
|
488
|
-
}
|
|
577
|
+
for (const { file, source } of await probeRunFiles(run)) occupy(run, file, source);
|
|
489
578
|
// The declared durable half (#744): the lane admission persisted at
|
|
490
579
|
// dispatch. Occupies even before the run has written anything, on every
|
|
491
580
|
// pass — the declaration lives on the row, not in a per-pass memo.
|
|
492
|
-
// Released by the
|
|
493
|
-
// terminal
|
|
581
|
+
// Released by the lease filter exactly like probed occupancy: a run that
|
|
582
|
+
// settles or is terminal stops holding a lease and its files stop
|
|
583
|
+
// occupying here (#899).
|
|
494
584
|
if (run.lane !== undefined && run.lane.files.length > 0) {
|
|
495
585
|
for (const file of run.lane.files) occupy(run, file, "declared");
|
|
496
586
|
}
|
|
@@ -566,6 +656,36 @@ export async function admitCandidates(
|
|
|
566
656
|
//
|
|
567
657
|
// Placed after the cheap local busy-set read and before the first tracker
|
|
568
658
|
// call, so a held fleet spends no GitHub API budget discovering it is held.
|
|
659
|
+
// The credential-class fence (#852), asked once per pass for the same reason
|
|
660
|
+
// the plan allowance is: it is a property of the fleet's credentials, not of
|
|
661
|
+
// any candidate, and its answer is identical for all of them. Placed
|
|
662
|
+
// immediately before the plan gate so both fleet-wide questions sit together,
|
|
663
|
+
// and so a fleet billing to the wrong credential spends no tracker budget
|
|
664
|
+
// discovering it is held.
|
|
665
|
+
//
|
|
666
|
+
// It clears by itself: the operator re-authenticates and the next pass passes.
|
|
667
|
+
// No `resume` is required and no escalation is raised here — a held candidate
|
|
668
|
+
// renders its reason in `status`, and inventing a page for a condition an
|
|
669
|
+
// operator fixes in a minute is how a guard costs more attention than it saves
|
|
670
|
+
// (the #110 reasoning, applied to the same shape of gate).
|
|
671
|
+
const requiredOauth = project.requireOauthProviders ?? [];
|
|
672
|
+
if (requiredOauth.length > 0 && routed.length > 0) {
|
|
673
|
+
const fence = await oauthFenceVerdict(
|
|
674
|
+
requiredOauth,
|
|
675
|
+
d.probeCredentialClass ??
|
|
676
|
+
(async (provider) => ({
|
|
677
|
+
ok: false,
|
|
678
|
+
reason: `no credential probe is wired for ${provider}`,
|
|
679
|
+
})),
|
|
680
|
+
);
|
|
681
|
+
if (!fence.ok) {
|
|
682
|
+
for (const r of routed) hold(r.issue.number, "credential-class", fence.reason);
|
|
683
|
+
log(`credential-class gate holding ${String(routed.length)} candidate(s): ${fence.reason}`);
|
|
684
|
+
store.reconcileGrooming(project.name, holds);
|
|
685
|
+
return { admitted: [], holds };
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
569
689
|
const plan = await readPlanUsage(caps.planUsage, d.usage);
|
|
570
690
|
if (plan.blocking) {
|
|
571
691
|
for (const r of routed) hold(r.issue.number, "plan-usage-cap");
|
|
@@ -682,6 +802,23 @@ export async function admitCandidates(
|
|
|
682
802
|
});
|
|
683
803
|
}
|
|
684
804
|
|
|
805
|
+
// The spend reservation (#851). Read once per pass: what today has already
|
|
806
|
+
// cost, plus what every live run has committed but not yet spent. The daily
|
|
807
|
+
// cap on its own is a post-spend stop — it compares spend-to-date at
|
|
808
|
+
// admission and pauses the fleet on the NEXT pass — so one expensive run
|
|
809
|
+
// could cross it by its entire cost before anything objected. On 2026-08-21
|
|
810
|
+
// one `@slow` run spent an estimated $25.16 against a $25.00 cap and the
|
|
811
|
+
// fleet paused only after it had settled.
|
|
812
|
+
const runAllowanceUsd = runSpendAllowanceUsd(caps);
|
|
813
|
+
const dailyCapUsd = caps.dailySpendUsd;
|
|
814
|
+
const budgeted = runAllowanceUsd !== null && dailyCapUsd !== null;
|
|
815
|
+
const spentTodayUsd = budgeted ? store.spendSince(project.name, startOfToday()) : 0;
|
|
816
|
+
const liveReservedUsd = budgeted ? store.reservedSpendUsd(project.name) : 0;
|
|
817
|
+
// Candidates admitted earlier in THIS pass have no run row yet (the claim
|
|
818
|
+
// happens in dispatch), so the pass counts its own reservations or it would
|
|
819
|
+
// admit a whole slot's worth of runs against one slot's budget.
|
|
820
|
+
let passReservedUsd = 0;
|
|
821
|
+
|
|
685
822
|
for (const r of routed) {
|
|
686
823
|
const issue = r.issue.number;
|
|
687
824
|
const cyclePath = memberCycle.get(issue);
|
|
@@ -718,6 +855,24 @@ export async function admitCandidates(
|
|
|
718
855
|
continue;
|
|
719
856
|
}
|
|
720
857
|
|
|
858
|
+
// Reserve before launching, never after settling: the whole allowance is
|
|
859
|
+
// taken out of the day's budget up front, so the worst case is bounded by
|
|
860
|
+
// what was reserved rather than by what a run turns out to cost. A run
|
|
861
|
+
// that settles for less releases the remainder simply by leaving the live
|
|
862
|
+
// set, which is why the next pass can admit again without any release
|
|
863
|
+
// bookkeeping. Distinct from the fleet-stopping `daily-spend-cap`: this
|
|
864
|
+
// holds one candidate while admitted work is still running.
|
|
865
|
+
if (budgeted && spentTodayUsd + liveReservedUsd + passReservedUsd + runAllowanceUsd > dailyCapUsd) {
|
|
866
|
+
const remaining = dailyCapUsd - spentTodayUsd - liveReservedUsd - passReservedUsd;
|
|
867
|
+
const detail =
|
|
868
|
+
`reserving $${runAllowanceUsd.toFixed(2)} for this run would exceed today's $${dailyCapUsd.toFixed(2)} ` +
|
|
869
|
+
`budget: $${spentTodayUsd.toFixed(2)} spent, $${(liveReservedUsd + passReservedUsd).toFixed(2)} reserved by ` +
|
|
870
|
+
`runs in flight, $${Math.max(0, remaining).toFixed(2)} left`;
|
|
871
|
+
hold(issue, "spend-reservation", detail);
|
|
872
|
+
log(`#${issue} held (spend-reservation): ${detail}`);
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
|
|
721
876
|
const priorRuns = store.attemptsFor(project.name, issue);
|
|
722
877
|
const failures = store.failuresFor(project.name, issue);
|
|
723
878
|
if (!hasFailedAttemptBudget(failures, caps.maxAttemptsPerIssue)) {
|
|
@@ -952,6 +1107,36 @@ export async function admitCandidates(
|
|
|
952
1107
|
log(`#${issue} skipped: open PR ${closer.url} already closes it`);
|
|
953
1108
|
continue;
|
|
954
1109
|
}
|
|
1110
|
+
// The continuation reacquire (#925). A continuation resumes writing the
|
|
1111
|
+
// retained branch, so the files that matter are the ones that branch
|
|
1112
|
+
// ACTUALLY carries — not the lane its issue declares. The declared gate
|
|
1113
|
+
// below cannot stand in for this: a continuation's declaration may be
|
|
1114
|
+
// narrower than its predecessor's diff (or absent entirely), and the
|
|
1115
|
+
// whole point of #899 is that the retained branch's files were released
|
|
1116
|
+
// the moment its worker finished, so an unrelated worker may legitimately
|
|
1117
|
+
// hold them by the time the continuation is admitted.
|
|
1118
|
+
//
|
|
1119
|
+
// Its own predecessor can never block it: a retained row is terminal or
|
|
1120
|
+
// `pushed-*`, so it holds no lease, and the holder check ignores this
|
|
1121
|
+
// issue's own rows in any case.
|
|
1122
|
+
if (retained !== undefined) {
|
|
1123
|
+
const carried = await probeRunFiles(retained);
|
|
1124
|
+
if (carried.length > 0) {
|
|
1125
|
+
const occupied = await ensureLaneOccupancy();
|
|
1126
|
+
const perRepo = occupied.get(r.repo.name);
|
|
1127
|
+
const collision = carried
|
|
1128
|
+
.map(({ file }) => ({ file, holder: perRepo?.get(file) }))
|
|
1129
|
+
.find(({ holder }) => holder !== undefined && holder.issue !== issue);
|
|
1130
|
+
if (collision !== undefined) {
|
|
1131
|
+
const detail =
|
|
1132
|
+
`${collision.file} held by run #${collision.holder!.issue} (${collision.holder!.source})` +
|
|
1133
|
+
` — carried by retained branch ${retained.branch}`;
|
|
1134
|
+
hold(issue, "file-lane", detail);
|
|
1135
|
+
log(`#${issue} held (file-lane): ${detail}`);
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
955
1140
|
log(`#${issue} continuing retained PR ${closer.url} ${resume}`);
|
|
956
1141
|
}
|
|
957
1142
|
|
|
@@ -1161,6 +1346,7 @@ export async function admitCandidates(
|
|
|
1161
1346
|
}
|
|
1162
1347
|
|
|
1163
1348
|
admitted.push({ r, attempt: priorRuns + 1, lane, model: await modelFor(r) });
|
|
1349
|
+
if (budgeted) passReservedUsd += runAllowanceUsd;
|
|
1164
1350
|
liveByRepo.set(r.repo.name, (liveByRepo.get(r.repo.name) ?? 0) + 1);
|
|
1165
1351
|
// Same-pass sibling occupancy for the file-lane gate: once admitted, a
|
|
1166
1352
|
// candidate's declared lane occupies for the rest of the pass, so a later
|