omp-conductor 0.15.12 → 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 +81 -6
- 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 +93 -54
- package/src/commands/restore-db.ts +146 -0
- package/src/commands/stop.ts +66 -34
- 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 +485 -577
- package/src/dashboard/server.ts +2 -1
- package/src/decisions.ts +32 -7
- package/src/depends-on.ts +73 -0
- package/src/doctor.ts +418 -8
- package/src/escalate.ts +122 -15
- package/src/failure-class.ts +47 -0
- package/src/fleet.ts +55 -377
- package/src/gitops.ts +86 -1
- package/src/lifecycle.ts +113 -2
- package/src/log.ts +40 -0
- package/src/model-fallback.ts +3 -2
- package/src/omp-settings.ts +114 -0
- package/src/omp.ts +63 -0
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +14 -1
- package/src/orchestrator.ts +14 -0
- package/src/release-policy.ts +163 -18
- package/src/reports.ts +124 -12
- package/src/session-host.ts +6 -0
- package/src/setup-host.ts +386 -17
- package/src/setup-install.ts +40 -2
- package/src/setup-wizard.ts +314 -113
- package/src/setup.ts +58 -1
- package/src/status-render.ts +445 -0
- package/src/stop-provenance.ts +119 -0
- package/src/store.ts +533 -11
- package/src/types.ts +298 -4
- package/src/unblock.ts +1 -1
- package/src/upgrade-verify.ts +1 -1
- package/src/upgrade.ts +27 -8
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +52 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +183 -10
package/src/daemon.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* here and enforced before anything is claimed.
|
|
9
9
|
*/
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
|
-
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { dirname, join, relative } from "node:path";
|
|
13
13
|
import {
|
|
14
14
|
configPath,
|
|
@@ -39,10 +39,15 @@ import { runCommand, verifyPendingUpgrade, type UpgradeVerifyDeps } from "./upgr
|
|
|
39
39
|
import { fleetLayers } from "./fleet.ts";
|
|
40
40
|
import { startOrchestrator } from "./orchestrator.ts";
|
|
41
41
|
import type { OrchestratorHandle } from "./orchestrator.ts";
|
|
42
|
+
import {
|
|
43
|
+
formatOrchestratorDown,
|
|
44
|
+
reconcileOrchestratorDown,
|
|
45
|
+
} from "./orchestrator-down.ts";
|
|
42
46
|
import {
|
|
43
47
|
createReportOutbox,
|
|
44
48
|
enqueueAvailableHeldNotices,
|
|
45
49
|
formatOpenReports,
|
|
50
|
+
reliabilitySettlementLine,
|
|
46
51
|
type ReportOutbox,
|
|
47
52
|
} from "./reports.ts";
|
|
48
53
|
import {
|
|
@@ -52,6 +57,10 @@ import {
|
|
|
52
57
|
} from "./release-policy.ts";
|
|
53
58
|
import { branchName, effectiveLabels, route } from "./routing.ts";
|
|
54
59
|
import type { Routed, UnroutableReason } from "./routing.ts";
|
|
60
|
+
import { admitCandidates, hasContinuationBudget, hasFailedAttemptBudget } from "./admission.ts";
|
|
61
|
+
import type { Admission, AdmissionHold } from "./admission.ts";
|
|
62
|
+
import { log, errText, safeEscalate } from "./log.ts";
|
|
63
|
+
import { materializeOmpSettings } from "./omp-settings.ts";
|
|
55
64
|
import { evaluateDecisionConditions, probeNpmVersion, probeRateLimitReset } from "./decisions.ts";
|
|
56
65
|
import { classifyRun, providerCreditRefusal, providerTransientFault, type ClassifyFacts } from "./failure-class.ts";
|
|
57
66
|
import {
|
|
@@ -67,6 +76,7 @@ import { dbPath, LIVE_STATES, openStore, utcDay } from "./store.ts";
|
|
|
67
76
|
import { makeTracker, type RateLimitStatus } from "./tracker/github.ts";
|
|
68
77
|
import { DEFAULT_REPORT_POLICY, RELEASE_SHAPES } from "./types.ts";
|
|
69
78
|
import type {
|
|
79
|
+
BaseFreeze,
|
|
70
80
|
BaseHealth,
|
|
71
81
|
AdmissionHoldReason,
|
|
72
82
|
Caps,
|
|
@@ -78,6 +88,7 @@ import type {
|
|
|
78
88
|
MergedPrInfo,
|
|
79
89
|
OpenCloser,
|
|
80
90
|
ReleaseShape,
|
|
91
|
+
OrchestratorIncident,
|
|
81
92
|
PrState,
|
|
82
93
|
ProjectConfig,
|
|
83
94
|
ReadyIssue,
|
|
@@ -100,6 +111,7 @@ import {
|
|
|
100
111
|
type WorkerPausePhase,
|
|
101
112
|
type WorkerResult,
|
|
102
113
|
type RunWorkerDeps,
|
|
114
|
+
ORPHAN_RESUME_PROMPT,
|
|
103
115
|
renderBrief,
|
|
104
116
|
runWorker,
|
|
105
117
|
} from "./worker.ts";
|
|
@@ -134,10 +146,12 @@ import { homedir } from "node:os";
|
|
|
134
146
|
|
|
135
147
|
import {
|
|
136
148
|
probeCriticalBase,
|
|
149
|
+
probeRunLane,
|
|
137
150
|
pushRunBranch,
|
|
138
151
|
readBaseChain,
|
|
139
152
|
type CriticalBaseProbe,
|
|
140
153
|
type CriticalBaseVerdict,
|
|
154
|
+
type RunLaneProbe,
|
|
141
155
|
type RunRepoRef,
|
|
142
156
|
} from "./gitops.ts";
|
|
143
157
|
import {
|
|
@@ -166,6 +180,13 @@ const DISPATCH_INFRA_MAX_STRIKES = 3;
|
|
|
166
180
|
* provider itself is degraded, not unlucky, and the sweep escalates to a
|
|
167
181
|
* human instead of requeueing into a down provider forever (#220). */
|
|
168
182
|
const PROVIDER_TRANSIENT_MAX_STRIKES = 3;
|
|
183
|
+
/** A provider-capacity requeue (sustained in-session rate limiting) is retried,
|
|
184
|
+
* but only a bounded number of times: three throttled runs for one issue mean
|
|
185
|
+
* the provider is at capacity, not unlucky, and the sweep escalates to a human
|
|
186
|
+
* instead of requeueing into a throttled provider forever (#573). The issue's
|
|
187
|
+
* own chain moves onto its next model per strike (via {@link FAILOVER_CLASSES}),
|
|
188
|
+
* so a bounded chain is exhaustible; this caps the unbounded no-chain case. */
|
|
189
|
+
const PROVIDER_CAPACITY_MAX_STRIKES = 3;
|
|
169
190
|
/** Terminal rows inspected for a salvaged PR per tick. GitHub provenance reads
|
|
170
191
|
* are maintenance, but a backlog must not turn one tick into an API burst. */
|
|
171
192
|
const SALVAGED_PR_ADOPTION_BATCH = 10;
|
|
@@ -227,6 +248,13 @@ interface Deps {
|
|
|
227
248
|
workerDeps?: RunWorkerDeps;
|
|
228
249
|
integrity: IntegrityGate;
|
|
229
250
|
stall: StallGate;
|
|
251
|
+
/**
|
|
252
|
+
* The embedded orchestrator session handle when one started; absent when it
|
|
253
|
+
* failed to start or the project uses an external orchestrator. Feeds the
|
|
254
|
+
* orchestrator-down reconcile ({@link reconcileOrchestratorDown}) so a
|
|
255
|
+
* crashed session pages once per incident instead of degrading quietly.
|
|
256
|
+
*/
|
|
257
|
+
orchestrator?: OrchestratorHandle;
|
|
230
258
|
cleanup?: RetainedCleanupCursor;
|
|
231
259
|
/**
|
|
232
260
|
* Reads the connecting uid off a verb socket (#126). Resolved once at startup
|
|
@@ -258,6 +286,16 @@ interface Deps {
|
|
|
258
286
|
* a marker (a safety interlock must not silently weaken).
|
|
259
287
|
*/
|
|
260
288
|
probeCriticalBase?: CriticalBaseProbe;
|
|
289
|
+
/**
|
|
290
|
+
* Reads one active run's file lane for the admission file-lane interlock
|
|
291
|
+
* (#555): the union of its uncommitted worktree changes and its branch-vs-base
|
|
292
|
+
* diff. Wired by `runDaemon` to the mirror/worktree-backed
|
|
293
|
+
* {@link probeRunLane}; a test injects a fake. Absent, the interlock is inert
|
|
294
|
+
* (no lane is ever known occupied), which is the issue's "fail open": the
|
|
295
|
+
* gate adds holds, it never refuses a well-formed issue for lack of this
|
|
296
|
+
* probe the way `criticalBase` does.
|
|
297
|
+
*/
|
|
298
|
+
probeWorktreeLane?: RunLaneProbe;
|
|
261
299
|
}
|
|
262
300
|
|
|
263
301
|
/**
|
|
@@ -531,6 +569,19 @@ export function pauseInstance(
|
|
|
531
569
|
}
|
|
532
570
|
}
|
|
533
571
|
|
|
572
|
+
/**
|
|
573
|
+
* The pause sentinel's `source=` token, proven from a verb. The sentinel's
|
|
574
|
+
* source line is read back as a single `\S+` token (see {@link pauseInstance}
|
|
575
|
+
* and {@link pauseProvenance}), so a verb that contains a space (`setup host`)
|
|
576
|
+
* is unrepresentable verbatim and must be encoded before it reaches disk —
|
|
577
|
+
* otherwise the fence cannot prove its own pause and refuses forever (#552).
|
|
578
|
+
* Spaces become `-`; the human-readable verb is preserved in the sentinel's
|
|
579
|
+
* `reason=` instead.
|
|
580
|
+
*/
|
|
581
|
+
export function pauseSourceToken(verb: string): string {
|
|
582
|
+
return verb.trim().replace(/\s+/g, "-");
|
|
583
|
+
}
|
|
584
|
+
|
|
534
585
|
export function setPaused(
|
|
535
586
|
v: boolean,
|
|
536
587
|
why?: { source: string; reason?: string },
|
|
@@ -655,14 +706,6 @@ export function markPaged(
|
|
|
655
706
|
|
|
656
707
|
// ---------------------------------------------------------------------- helpers
|
|
657
708
|
|
|
658
|
-
function log(msg: string): void {
|
|
659
|
-
process.stderr.write(`[conductor ${new Date().toISOString()}] ${msg}\n`);
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
function errText(e: unknown): string {
|
|
663
|
-
return e instanceof Error ? (e.stack ?? e.message) : String(e);
|
|
664
|
-
}
|
|
665
|
-
|
|
666
709
|
/**
|
|
667
710
|
* Local midnight, matching how a human reads "today".
|
|
668
711
|
*
|
|
@@ -815,15 +858,6 @@ export function recordOperatorStop(
|
|
|
815
858
|
* gate on the attempt turns one failed delivery into permanent silence about a
|
|
816
859
|
* condition that is still true.
|
|
817
860
|
*/
|
|
818
|
-
async function safeEscalate(d: Pick<Deps, "escalate">, e: Escalation): Promise<boolean> {
|
|
819
|
-
try {
|
|
820
|
-
await d.escalate(e);
|
|
821
|
-
return true;
|
|
822
|
-
} catch (err) {
|
|
823
|
-
log(`escalation for ${escalationIssueRef(e.issue)} could not be delivered: ${errText(err)}`);
|
|
824
|
-
return false;
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
861
|
|
|
828
862
|
async function reactToProviderCredit(
|
|
829
863
|
d: Deps,
|
|
@@ -1082,18 +1116,6 @@ export async function buildBrief(
|
|
|
1082
1116
|
|
|
1083
1117
|
// ------------------------------------------------------------------- one issue
|
|
1084
1118
|
|
|
1085
|
-
/** `stops` are the operational ends that each require one resume. */
|
|
1086
|
-
export function hasContinuationBudget(stops: number, maxContinuations: number): boolean {
|
|
1087
|
-
return stops <= maxContinuations;
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
/** True while unspent failed-implementation attempts remain. This is the
|
|
1091
|
-
* dispatcher's admission gate: once every `maxAttemptsPerIssue` slot is
|
|
1092
|
-
* spent, the issue is held as `failed-attempts` forever, and the `unblock`
|
|
1093
|
-
* verb withholds the queue label on the same predicate (#348). */
|
|
1094
|
-
export function hasFailedAttemptBudget(failures: number, maxAttempts: number): boolean {
|
|
1095
|
-
return failures < maxAttempts;
|
|
1096
|
-
}
|
|
1097
1119
|
|
|
1098
1120
|
/** The failure classes `countContinuations` deliberately does not charge — the
|
|
1099
1121
|
* inverted copy of its exclusions, kept beside the breakdown that consumes it
|
|
@@ -1107,6 +1129,7 @@ const NON_CONTINUATION_CLASSES: Partial<Record<FailureClass, true>> = {
|
|
|
1107
1129
|
"dispatch-infra": true,
|
|
1108
1130
|
"provider-credit": true,
|
|
1109
1131
|
"provider-transient": true,
|
|
1132
|
+
"provider-capacity": true,
|
|
1110
1133
|
};
|
|
1111
1134
|
|
|
1112
1135
|
/** How one issue spent its continuation budget, grouped by failure class —
|
|
@@ -1607,6 +1630,50 @@ export async function inheritedPrForContinuation(
|
|
|
1607
1630
|
return { prUrl: prior.prUrl, ...(prior.headSha === undefined ? {} : { headSha: prior.headSha }) };
|
|
1608
1631
|
}
|
|
1609
1632
|
|
|
1633
|
+
/**
|
|
1634
|
+
* #536: whether an orphan-clean requeue may resume the interrupted session
|
|
1635
|
+
* instead of dispatching fresh.
|
|
1636
|
+
*
|
|
1637
|
+
* `reconcileOrphanedRuns` keeps the worktree (salvage commit included) and the
|
|
1638
|
+
* transcript is file-backed, so a daemon restart can hand the next attempt
|
|
1639
|
+
* back its own memory: the same worktree, the same session directory, and
|
|
1640
|
+
* `resume: true` at the harness. The old dispatch built a fresh
|
|
1641
|
+
* `run-<uuid>` session and re-read the repo from zero — the exact rediscovery
|
|
1642
|
+
* orphan-clean spent turns on in #535.
|
|
1643
|
+
*
|
|
1644
|
+
* Only `orphan-clean` resumes. A cap-killed or otherwise failed worker was
|
|
1645
|
+
* killed for cause, and orphan-dirty is held precisely because the worktree is
|
|
1646
|
+
* the only copy. The checks here are what the daemon can prove cheaply before
|
|
1647
|
+
* the claim (the transcript is present and non-empty, the worktree is
|
|
1648
|
+
* present); the harness's own `continueRecent` is the backstop, and a corrupt
|
|
1649
|
+
* transcript it silently falls back from is surfaced loudly by the
|
|
1650
|
+
* `sessionFile` lineage compare at the dispatch site rather than left quiet.
|
|
1651
|
+
*/
|
|
1652
|
+
function orphanResumeVerdict(
|
|
1653
|
+
prior: RunRecord | undefined,
|
|
1654
|
+
): { kind: "resume"; prior: RunRecord } | { kind: "fresh"; reason?: string } {
|
|
1655
|
+
if (prior === undefined || prior.state !== "orphaned" || prior.failureClass !== "orphan-clean") {
|
|
1656
|
+
return { kind: "fresh" };
|
|
1657
|
+
}
|
|
1658
|
+
if (prior.sessionFile === undefined) {
|
|
1659
|
+
return { kind: "fresh", reason: "the orphaned attempt recorded no transcript" };
|
|
1660
|
+
}
|
|
1661
|
+
try {
|
|
1662
|
+
if (!existsSync(prior.sessionFile)) {
|
|
1663
|
+
return { kind: "fresh", reason: `transcript ${prior.sessionFile} is gone` };
|
|
1664
|
+
}
|
|
1665
|
+
if (statSync(prior.sessionFile).size === 0) {
|
|
1666
|
+
return { kind: "fresh", reason: `transcript ${prior.sessionFile} is empty` };
|
|
1667
|
+
}
|
|
1668
|
+
} catch (err) {
|
|
1669
|
+
return { kind: "fresh", reason: `transcript ${prior.sessionFile} is unreadable (${errText(err)})` };
|
|
1670
|
+
}
|
|
1671
|
+
if (prior.worktree === "" || !existsSync(prior.worktree)) {
|
|
1672
|
+
return { kind: "fresh", reason: `worktree ${prior.worktree} is gone` };
|
|
1673
|
+
}
|
|
1674
|
+
return { kind: "resume", prior };
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1610
1677
|
export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
1611
1678
|
const { project, caps, tracker, store } = d;
|
|
1612
1679
|
const issue = r.issue.number;
|
|
@@ -1770,6 +1837,49 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
1770
1837
|
// fresh issue always starts on the primary (#286). Read before the claim
|
|
1771
1838
|
// writes this attempt's row, which would otherwise break the streak.
|
|
1772
1839
|
const chainFacts = providerFailureFacts(store.runsForIssue(project.name, issue));
|
|
1840
|
+
// Resolved before the claim, not after provisioning: the model is also
|
|
1841
|
+
// part of the #536 resume decision, which has to be made before the
|
|
1842
|
+
// dispatch shape (fresh provision vs kept worktree) is chosen. With no
|
|
1843
|
+
// `modelFallbacks` configured this is the primary model — or none, for an
|
|
1844
|
+
// unconfigured project — and today's dispatch is byte for byte what it
|
|
1845
|
+
// has always been.
|
|
1846
|
+
const chainConfigured = (project.modelFallbacks?.length ?? 0) > 0;
|
|
1847
|
+
const choice = resolveDispatchModel({
|
|
1848
|
+
workerModel: project.workerModel,
|
|
1849
|
+
modelFallbacks: project.modelFallbacks,
|
|
1850
|
+
threshold: project.modelFallbackThreshold ?? DEFAULT_MODEL_FALLBACK_THRESHOLD,
|
|
1851
|
+
streak: chainFacts.streak,
|
|
1852
|
+
});
|
|
1853
|
+
const clause = fallbackClause(choice, chainFacts, project.workerModel);
|
|
1854
|
+
|
|
1855
|
+
// #536: an orphan-clean requeue resumes the interrupted session instead of
|
|
1856
|
+
// re-reading the repo from zero. Decided here, before this attempt's row
|
|
1857
|
+
// exists, because `prior` is still the attempt whose work this one
|
|
1858
|
+
// inherits and the whole dispatch shape follows the verdict.
|
|
1859
|
+
let resuming: RunRecord | undefined;
|
|
1860
|
+
const verdict = orphanResumeVerdict(prior);
|
|
1861
|
+
if (verdict.kind === "resume") {
|
|
1862
|
+
const resumePrior = verdict.prior;
|
|
1863
|
+
// The continuation must stay on the model the interrupted session was
|
|
1864
|
+
// using; a chain that now resolves differently dispatches fresh rather
|
|
1865
|
+
// than quietly continuing on another model, which would smear one
|
|
1866
|
+
// attempt's work across two models (#286 attribution).
|
|
1867
|
+
if (resumePrior.model !== undefined && choice.model !== undefined && resumePrior.model !== choice.model) {
|
|
1868
|
+
log(
|
|
1869
|
+
`#${issue} attempt ${attempt} not resumed: attempt ${resumePrior.attempt} ran on ${resumePrior.model} but dispatch ` +
|
|
1870
|
+
`now resolves ${choice.model} — fresh dispatch`,
|
|
1871
|
+
);
|
|
1872
|
+
} else {
|
|
1873
|
+
resuming = resumePrior;
|
|
1874
|
+
log(
|
|
1875
|
+
`#${issue} attempt ${attempt} continuing session of attempt ${resuming.attempt} → ` +
|
|
1876
|
+
`transcript ${resuming.sessionFile}, worktree kept`,
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1879
|
+
} else if (verdict.reason !== undefined) {
|
|
1880
|
+
log(`#${issue} attempt ${attempt} not resumed: ${verdict.reason} — fresh dispatch`);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1773
1883
|
run = store.createRun({
|
|
1774
1884
|
project: project.name,
|
|
1775
1885
|
issue,
|
|
@@ -1822,22 +1932,30 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
1822
1932
|
|
|
1823
1933
|
// A run's tree is <workspaceRoot>/<issue> and addRunRepo refuses to reuse
|
|
1824
1934
|
// an existing path, so a retry — or a tree kept from a failed attempt — has
|
|
1825
|
-
// to be cleared first. Both helpers are pure path math and removeWorktree
|
|
1826
|
-
// tolerates a mirror or tree that is not there yet
|
|
1827
|
-
//
|
|
1828
|
-
//
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
if (
|
|
1832
|
-
|
|
1833
|
-
r.repo,
|
|
1834
|
-
|
|
1835
|
-
project.workspaceRoot,
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1935
|
+
// to be cleared first. Both helpers are pure path math, and removeWorktree
|
|
1936
|
+
// tolerates a mirror or tree that is not there yet. An attempted resume
|
|
1937
|
+
// skips the whole dance: the orphaned run's tree is the work to continue
|
|
1938
|
+
// (its salvage commit is already on the branch), and re-cloning it from
|
|
1939
|
+
// the mirror would be exactly the rediscovery this feature exists to skip.
|
|
1940
|
+
let provisioned: Awaited<ReturnType<typeof addRunRepo>> | undefined;
|
|
1941
|
+
if (resuming !== undefined) {
|
|
1942
|
+
worktreePath = resuming.worktree;
|
|
1943
|
+
runRepo = { repo: r.repo, runRepoPath: worktreePath, branch };
|
|
1944
|
+
} else {
|
|
1945
|
+
await removeWorktree(mirrorPath, worktreePathFor(project.workspaceRoot, issue));
|
|
1946
|
+
if (await settleStopBeforeSession()) return;
|
|
1947
|
+
if (await settleDrainBeforeSession()) return;
|
|
1948
|
+
const provisionedTree = await addRunRepo(
|
|
1949
|
+
r.repo,
|
|
1950
|
+
project.mirrorRoot,
|
|
1951
|
+
project.workspaceRoot,
|
|
1952
|
+
issue,
|
|
1953
|
+
branch,
|
|
1954
|
+
);
|
|
1955
|
+
provisioned = provisionedTree;
|
|
1956
|
+
worktreePath = provisionedTree.path;
|
|
1957
|
+
runRepo = { repo: r.repo, runRepoPath: worktreePath, branch };
|
|
1958
|
+
}
|
|
1841
1959
|
if (await settleStopBeforeSession()) return;
|
|
1842
1960
|
if (await settleDrainBeforeSession()) return;
|
|
1843
1961
|
|
|
@@ -1846,11 +1964,27 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
1846
1964
|
// here would put a file that never gets written into an escalation.
|
|
1847
1965
|
//
|
|
1848
1966
|
// Per run rather than one shared directory, so one run's transcript cannot
|
|
1849
|
-
// be truncated or replaced by the next
|
|
1967
|
+
// be truncated or replaced by the next — except for a resumed attempt,
|
|
1968
|
+
// which deliberately reuses the interrupted session's directory so the
|
|
1969
|
+
// SDK's `continueRecent(cwd, dir)` picks up that transcript and keeps
|
|
1970
|
+
// writing it.
|
|
1850
1971
|
const runTreeRoot = stateDir();
|
|
1851
|
-
const sessionDir =
|
|
1972
|
+
const sessionDir =
|
|
1973
|
+
resuming === undefined
|
|
1974
|
+
? join(runTreeRoot, "sessions", `run-${String(runId)}`)
|
|
1975
|
+
: dirname(resuming.sessionFile!);
|
|
1852
1976
|
mkdirSync(sessionDir, { recursive: true });
|
|
1853
1977
|
|
|
1978
|
+
// The fleet-owned omp settings overlay (#537): the project's `ompSettings`
|
|
1979
|
+
// map (plus the retry keys derived from `modelFallbacks`, #539's staging
|
|
1980
|
+
// half) materialised to YAML under the run's session directory — never
|
|
1981
|
+
// inside the worktree, whose diff is the PR a worker ships. Rewritten on
|
|
1982
|
+
// every attempt, so a config edit takes effect on the next dispatch and a
|
|
1983
|
+
// resumed attempt reuses the kept session dir with the *current* config.
|
|
1984
|
+
// Absent `ompSettings` and `modelFallbacks`, no file is written, nothing
|
|
1985
|
+
// is passed, and dispatch is byte-for-byte today's.
|
|
1986
|
+
const ompSettingsFile = materializeOmpSettings(project, sessionDir);
|
|
1987
|
+
|
|
1854
1988
|
// ---- the run's mutation channel (#126) -------------------------------
|
|
1855
1989
|
// A shared, daemon-owned 0711 parent with one 0600 socket per run, never a
|
|
1856
1990
|
// per-run *directory*: a directory owned by the run principal would hand
|
|
@@ -1884,50 +2018,51 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
1884
2018
|
store.updateRun(runId, { worktree: worktreePath, state: "running" });
|
|
1885
2019
|
|
|
1886
2020
|
// Where this attempt goes, and (when the failover fired) the clause that
|
|
1887
|
-
// makes it attributable
|
|
1888
|
-
//
|
|
1889
|
-
//
|
|
1890
|
-
const chainConfigured = (project.modelFallbacks?.length ?? 0) > 0;
|
|
1891
|
-
const choice = resolveDispatchModel({
|
|
1892
|
-
workerModel: project.workerModel,
|
|
1893
|
-
modelFallbacks: project.modelFallbacks,
|
|
1894
|
-
threshold: project.modelFallbackThreshold ?? DEFAULT_MODEL_FALLBACK_THRESHOLD,
|
|
1895
|
-
streak: chainFacts.streak,
|
|
1896
|
-
});
|
|
2021
|
+
// makes it attributable. `chainConfigured`/`choice`/`clause` were resolved
|
|
2022
|
+
// before the claim — the resume verdict had to be made before the dispatch
|
|
2023
|
+
// shape was chosen (#536) — so only the record write lives here.
|
|
1897
2024
|
// Recorded before the launch, so even a run killed mid-flight leaves the
|
|
1898
2025
|
// model it chose on its row. Only a chain-configured project writes the
|
|
1899
2026
|
// column: absent `modelFallbacks` must preserve today's rows byte for byte.
|
|
1900
2027
|
if (chainConfigured && choice.model !== undefined) {
|
|
1901
2028
|
store.updateRun(runId, { model: choice.model });
|
|
1902
2029
|
}
|
|
1903
|
-
const clause = fallbackClause(choice, chainFacts, project.workerModel);
|
|
1904
2030
|
|
|
1905
2031
|
log(
|
|
1906
2032
|
`#${issue} attempt ${attempt}${clause === undefined ? "" : ` ${clause}`} → ${r.repo.name} ${branch}` +
|
|
1907
|
-
(provisioned
|
|
2033
|
+
(provisioned?.reattached ? " (continuation: reattached existing branch)" : ""),
|
|
1908
2034
|
);
|
|
1909
2035
|
|
|
1910
|
-
// The
|
|
1911
|
-
//
|
|
1912
|
-
//
|
|
1913
|
-
//
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
2036
|
+
// The continuation notice replaces the brief for a resumed attempt (#536).
|
|
2037
|
+
// The original brief is already in the resumed transcript; re-sending it is
|
|
2038
|
+
// how a resumed worker ends up re-doing the work it just did. Everything
|
|
2039
|
+
// below the brief is fresh-dispatch-only, exactly as today.
|
|
2040
|
+
let brief: string;
|
|
2041
|
+
if (resuming !== undefined) {
|
|
2042
|
+
brief = ORPHAN_RESUME_PROMPT;
|
|
2043
|
+
} else {
|
|
2044
|
+
// The discussion is rendered at dispatch so a worker never depends on a
|
|
2045
|
+
// runtime `gh` read to see the orchestrator's grooming (#517). The read is
|
|
2046
|
+
// best-effort, but its failure is not silent: an unreadable tracker names
|
|
2047
|
+
// itself in the brief's Discussion section instead of reading as "no
|
|
2048
|
+
// comments" — the exact confusion this fix removes.
|
|
2049
|
+
let comments: IssueComment[] | "unread";
|
|
2050
|
+
try {
|
|
2051
|
+
comments = await tracker.listComments(issue);
|
|
2052
|
+
} catch (err) {
|
|
2053
|
+
log(`#${issue} issue comments unreadable at dispatch; the brief will say so: ${errText(err)}`);
|
|
2054
|
+
comments = "unread";
|
|
2055
|
+
}
|
|
1922
2056
|
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
2057
|
+
brief = await buildBrief(project, r, branch, worktreePath, {
|
|
2058
|
+
continuation: provisioned?.reattached === true,
|
|
2059
|
+
defaultBranch: r.repo.defaultBranch,
|
|
2060
|
+
...(provisioned?.reattached === true && priorSalvage !== undefined
|
|
2061
|
+
? { salvagedSha: priorSalvage }
|
|
2062
|
+
: {}),
|
|
2063
|
+
comments,
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
1931
2066
|
if (await settleStopBeforeSession()) return;
|
|
1932
2067
|
if (await settleDrainBeforeSession()) return;
|
|
1933
2068
|
|
|
@@ -1946,6 +2081,7 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
1946
2081
|
workerControl?.install(control);
|
|
1947
2082
|
},
|
|
1948
2083
|
sessionDir,
|
|
2084
|
+
...(resuming === undefined ? {} : { resume: true }),
|
|
1949
2085
|
// The session's control socket, under the daemon's own state directory —
|
|
1950
2086
|
// a child process of the daemon reaches it directly.
|
|
1951
2087
|
socketPath: join(sessionDir, "ipc.sock"),
|
|
@@ -1962,6 +2098,12 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
1962
2098
|
log(`#${issue} ${line}`);
|
|
1963
2099
|
},
|
|
1964
2100
|
...(choice.model === undefined ? {} : { model: choice.model }),
|
|
2101
|
+
// The fleet-owned omp settings overlay (#537): the staged YAML the
|
|
2102
|
+
// session loads through `Settings.init({ configFiles: [<path>] })` —
|
|
2103
|
+
// the project's `ompSettings` map plus the within-run failover keys
|
|
2104
|
+
// #581 staged directly (the project's own chain, not an empty default).
|
|
2105
|
+
// Absent both, nothing is staged, today's dispatch byte for byte.
|
|
2106
|
+
...(ompSettingsFile === undefined ? {} : { ompSettingsFile }),
|
|
1965
2107
|
releaseGrants: resolveReleaseGrants(project),
|
|
1966
2108
|
onReleaseBlocked: workerReleaseBlockRecorder(project.name, issue, runId),
|
|
1967
2109
|
onTurn: (n) => store.updateRun(runId, { turns: n }),
|
|
@@ -1974,7 +2116,21 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
1974
2116
|
// ends: `omp-conductor tail` resolves an issue to a file through this row,
|
|
1975
2117
|
// and a path written at completion is a path nobody can follow live. The
|
|
1976
2118
|
// completion-time update below writes the same value again, harmlessly.
|
|
1977
|
-
onSessionFile: (f) =>
|
|
2119
|
+
onSessionFile: (f) => {
|
|
2120
|
+
store.updateRun(runId, { sessionFile: f });
|
|
2121
|
+
// #536: the harness's own `continueRecent` can still fall back to a
|
|
2122
|
+
// blank session (corrupt transcript, nothing to continue) — and a
|
|
2123
|
+
// blank session in the "resumed" worktree is indistinguishable from
|
|
2124
|
+
// today's dispatch unless the downgrade is named. Same-file lineage
|
|
2125
|
+
// is the proof the resume happened: the resumed run must keep
|
|
2126
|
+
// writing the orphaned attempt's transcript.
|
|
2127
|
+
if (resuming !== undefined && f !== resuming.sessionFile) {
|
|
2128
|
+
log(
|
|
2129
|
+
`#${issue} attempt ${attempt} resume fell back to a fresh session: opened ${f} ` +
|
|
2130
|
+
`instead of the orphaned attempt's ${resuming.sessionFile}`,
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
},
|
|
1978
2134
|
// The last fence (#374): every pre-launch settle check above has
|
|
1979
2135
|
// passed, but the stop can still land while the session socket is
|
|
1980
2136
|
// binding inside `createSession`. This gate is re-checked there,
|
|
@@ -2034,10 +2190,27 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
2034
2190
|
// worker's, the disclosure becomes the diff's (#488). A diff that could
|
|
2035
2191
|
// not be read leaves the worker's text untouched and the audit's
|
|
2036
2192
|
// `changed-line-missing` flag says so.
|
|
2037
|
-
|
|
2193
|
+
// The within-run reliability sentence, appended where a human reads it
|
|
2194
|
+
// (#584): which model the run finished on and whether it swapped
|
|
2195
|
+
// mid-flight. Empty (undefined) for a clean run, so a run that never
|
|
2196
|
+
// retried, swapped or compacted keeps today's settlement report byte for
|
|
2197
|
+
// byte — the "additive" claim asserted rather than assumed.
|
|
2198
|
+
const reliabilityLine = reliabilitySettlementLine({
|
|
2199
|
+
resolvedModel: result.model,
|
|
2200
|
+
resolvedProvider: result.provider,
|
|
2201
|
+
retryFallbacks: result.retryFallbacks,
|
|
2202
|
+
retryFallbackSucceeded: result.retryFallbackSucceeded,
|
|
2203
|
+
modelRecoveries: result.modelRecoveries,
|
|
2204
|
+
autoRetryCount: result.autoRetryCount,
|
|
2205
|
+
autoCompactionCount: result.autoCompactionCount,
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
const settlementReport = [
|
|
2038
2209
|
audit?.changedLine === undefined
|
|
2039
2210
|
? result.report
|
|
2040
|
-
: withDerivedChangedLine(result.report, audit.changedLine)
|
|
2211
|
+
: withDerivedChangedLine(result.report, audit.changedLine),
|
|
2212
|
+
...(reliabilityLine === undefined ? [] : ["", reliabilityLine]),
|
|
2213
|
+
].join("\n");
|
|
2041
2214
|
|
|
2042
2215
|
const finalReport = [
|
|
2043
2216
|
...(verified.reason === undefined ? [] : [verified.reason, ""]),
|
|
@@ -2087,6 +2260,24 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
2087
2260
|
endedAt: Date.now(),
|
|
2088
2261
|
turns: result.turns,
|
|
2089
2262
|
spendUsd: result.spendUsd,
|
|
2263
|
+
// The count of in-session provider 429s the worker metered live, so a
|
|
2264
|
+
// run the provider throttled into the ground carries its own diagnosis
|
|
2265
|
+
// instead of landing `unknown` — the classifier reads it straight off
|
|
2266
|
+
// this column (#573).
|
|
2267
|
+
provider429Count: result.provider429Count,
|
|
2268
|
+
// The within-run harness reliability surface #581 collected, persisted
|
|
2269
|
+
// now that settlement owns the row (#584). The resolved model/provider
|
|
2270
|
+
// only travel when some assistant message carried them (the run recorded
|
|
2271
|
+
// no model, or the worker never established one); the count fields always
|
|
2272
|
+
// travel, 0 for a clean run, so an absent column can never be read as a
|
|
2273
|
+
// quiet fleet. Written for every terminal state, clean or not.
|
|
2274
|
+
...(result.model === undefined ? {} : { resolvedModel: result.model }),
|
|
2275
|
+
...(result.provider === undefined ? {} : { resolvedProvider: result.provider }),
|
|
2276
|
+
retryFallbacks: result.retryFallbacks,
|
|
2277
|
+
retryFallbackSucceeded: result.retryFallbackSucceeded,
|
|
2278
|
+
modelRecoveries: result.modelRecoveries,
|
|
2279
|
+
autoRetryCount: result.autoRetryCount,
|
|
2280
|
+
autoCompactionCount: result.autoCompactionCount,
|
|
2090
2281
|
// The worker only reports these when it actually established them; a kill
|
|
2091
2282
|
// or a settle whose report named no PR must not wipe what a verb recorded
|
|
2092
2283
|
// earlier in the same run (#468). The sink in `updateRun` skips undefined
|
|
@@ -2129,6 +2320,22 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
2129
2320
|
|
|
2130
2321
|
const salvaged = settlement?.lines ?? [];
|
|
2131
2322
|
|
|
2323
|
+
// The run's reliability news, surfaced where the tick digest reads it
|
|
2324
|
+
// (#584): the digest is model-authored but consumes the store's material
|
|
2325
|
+
// ledger, so a run that swapped mid-flight or rode out a throttled
|
|
2326
|
+
// provider lands one event the digest can name. Clean runs record nothing
|
|
2327
|
+
// here, so a quiet fleet's digest is unchanged.
|
|
2328
|
+
if (reliabilityLine !== undefined) {
|
|
2329
|
+
store.recordMaterialEvent({
|
|
2330
|
+
project: project.name,
|
|
2331
|
+
category: "reliability",
|
|
2332
|
+
summary: `#${issue} ${reliabilityLine}`,
|
|
2333
|
+
evidence: `${r.issue.title}\n${r.issue.url}\n\n${reliabilityLine}`,
|
|
2334
|
+
occurredAt: Date.now(),
|
|
2335
|
+
recordedAt: Date.now(),
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2132
2339
|
if (state === "stopped") {
|
|
2133
2340
|
log(`#${issue} stopped by operator on attempt ${attempt}: ${result.stoppedReason}`);
|
|
2134
2341
|
} else if (state === "blocked") {
|
|
@@ -2550,6 +2757,31 @@ export async function watchMergedBase(d: Pick<Deps, "project" | "tracker" | "sto
|
|
|
2550
2757
|
`${failed.name} failed at ${run.mergeSha} — ${failed.url}` +
|
|
2551
2758
|
(preexisting ? " (already red before this merge)" : "");
|
|
2552
2759
|
const flag: SettlementFlag = { kind: "base-branch-red", file: "(base branch)", detail };
|
|
2760
|
+
// Freeze merges to this repo while the base it merged into is red (#283).
|
|
2761
|
+
// The freeze is repo-scoped and sets independently of escalation delivery:
|
|
2762
|
+
// a merge that broke the base must not be followed by another merge onto
|
|
2763
|
+
// the same red base, even if paging the operator fails.
|
|
2764
|
+
if (
|
|
2765
|
+
d.store.setBaseFreeze(d.project.name, {
|
|
2766
|
+
repo: run.repo,
|
|
2767
|
+
culpritSha: run.mergeSha,
|
|
2768
|
+
detail,
|
|
2769
|
+
setAt: now,
|
|
2770
|
+
})
|
|
2771
|
+
) {
|
|
2772
|
+
d.store.recordMaterialEvent({
|
|
2773
|
+
project: d.project.name,
|
|
2774
|
+
category: "base-red-freeze",
|
|
2775
|
+
summary: `merges to ${run.repo} frozen — base ${run.baseRef} red at ${run.mergeSha.slice(0, 8)}`,
|
|
2776
|
+
evidence:
|
|
2777
|
+
`${detail} This freeze names ${run.mergeSha.slice(0, 8)} as the suspected culprit merge. ` +
|
|
2778
|
+
"Merges to this repo are refused until the base is green again; reverting the culprit is the " +
|
|
2779
|
+
"likely remedy. The freeze lifts automatically on a green re-observation, or the operator can " +
|
|
2780
|
+
"override it with `omp-conductor unfreeze <repo>`.",
|
|
2781
|
+
occurredAt: now,
|
|
2782
|
+
recordedAt: now,
|
|
2783
|
+
});
|
|
2784
|
+
}
|
|
2553
2785
|
const delivered = await safeEscalate(d, {
|
|
2554
2786
|
tier: 1,
|
|
2555
2787
|
project: d.project.name,
|
|
@@ -2608,7 +2840,16 @@ export async function watchBaseHealth(
|
|
|
2608
2840
|
}
|
|
2609
2841
|
|
|
2610
2842
|
const previous = previousByRepo.get(repo);
|
|
2843
|
+
const freeze = d.store.baseFreeze(d.project.name, repo);
|
|
2844
|
+
const frozen = freeze !== undefined && freeze.clearedAt === undefined;
|
|
2845
|
+
// The same-head/age shortcut exists to avoid re-querying GitHub when a
|
|
2846
|
+
// terminal verdict has not moved. It must NOT skip a frozen repo: a freeze
|
|
2847
|
+
// keyed on one red observation has to keep re-evaluating the same head so a
|
|
2848
|
+
// green rerun clears it without operator action (tonight's evidence — a
|
|
2849
|
+
// red/unknown read two minutes after the same SHA's CI succeeded — is why
|
|
2850
|
+
// it cannot be trusted as terminal).
|
|
2611
2851
|
if (
|
|
2852
|
+
!frozen &&
|
|
2612
2853
|
previous?.branch === branch &&
|
|
2613
2854
|
previous.headSha === head &&
|
|
2614
2855
|
(previous.verdict === "green" || previous.verdict === "red")
|
|
@@ -2668,6 +2909,43 @@ export async function watchBaseHealth(
|
|
|
2668
2909
|
};
|
|
2669
2910
|
d.store.upsertBaseHealth(d.project.name, health);
|
|
2670
2911
|
previousByRepo.set(repo, health);
|
|
2912
|
+
|
|
2913
|
+
// The freeze follows the live base verdict: red arms (or re-arms) the
|
|
2914
|
+
// repo-scoped freeze, green lifts it. pending/unknown leave it untouched —
|
|
2915
|
+
// a stale or in-flight reading must neither create a freeze nor clear one.
|
|
2916
|
+
if (verdict === "green") {
|
|
2917
|
+
if (d.store.clearBaseFreeze(d.project.name, repo, "daemon", "base-green", now)) {
|
|
2918
|
+
d.store.recordMaterialEvent({
|
|
2919
|
+
project: d.project.name,
|
|
2920
|
+
category: "base-recovered",
|
|
2921
|
+
summary: `merges to ${repo} unfrozen — base ${branch} observed green at ${head.slice(0, 8)}`,
|
|
2922
|
+
evidence: `The base-red freeze on ${repo} lifted automatically: ${branch} is green at ${head.slice(0, 8)}. Merges resume.`,
|
|
2923
|
+
occurredAt: now,
|
|
2924
|
+
recordedAt: now,
|
|
2925
|
+
});
|
|
2926
|
+
}
|
|
2927
|
+
} else if (verdict === "red") {
|
|
2928
|
+
if (
|
|
2929
|
+
d.store.setBaseFreeze(d.project.name, {
|
|
2930
|
+
repo,
|
|
2931
|
+
culpritSha: head,
|
|
2932
|
+
detail,
|
|
2933
|
+
setAt: now,
|
|
2934
|
+
})
|
|
2935
|
+
) {
|
|
2936
|
+
d.store.recordMaterialEvent({
|
|
2937
|
+
project: d.project.name,
|
|
2938
|
+
category: "base-red-freeze",
|
|
2939
|
+
summary: `merges to ${repo} frozen — base ${branch} red at ${head.slice(0, 8)}`,
|
|
2940
|
+
evidence:
|
|
2941
|
+
`${detail ?? `workflow failed at ${head.slice(0, 8)}`} This freeze names ` +
|
|
2942
|
+
`${head.slice(0, 8)} as the suspected culprit. Reverting it is the likely remedy; the freeze ` +
|
|
2943
|
+
"lifts automatically on green or with `omp-conductor unfreeze <repo>`.",
|
|
2944
|
+
occurredAt: now,
|
|
2945
|
+
recordedAt: now,
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2671
2949
|
}
|
|
2672
2950
|
}
|
|
2673
2951
|
|
|
@@ -2987,22 +3265,6 @@ export async function cleanupRetainedRuns(
|
|
|
2987
3265
|
|
|
2988
3266
|
// -------------------------------------------------------------------- admission
|
|
2989
3267
|
|
|
2990
|
-
/** A candidate cleared for dispatch, with the attempt number it will run as. */
|
|
2991
|
-
export interface Admission {
|
|
2992
|
-
r: Routed;
|
|
2993
|
-
attempt: number;
|
|
2994
|
-
}
|
|
2995
|
-
|
|
2996
|
-
export interface AdmissionHold {
|
|
2997
|
-
issue: number;
|
|
2998
|
-
reason: AdmissionHoldReason;
|
|
2999
|
-
}
|
|
3000
|
-
|
|
3001
|
-
export interface AdmissionPass {
|
|
3002
|
-
admitted: Admission[];
|
|
3003
|
-
holds: AdmissionHold[];
|
|
3004
|
-
}
|
|
3005
|
-
|
|
3006
3268
|
const HOLD_SAMPLE_SIZE = 5;
|
|
3007
3269
|
const DEGRADED_HOLDS: ReadonlySet<AdmissionHoldReason> = new Set([
|
|
3008
3270
|
"parent-lookup-error",
|
|
@@ -3020,11 +3282,16 @@ export function summarizeDispatch(
|
|
|
3020
3282
|
completedAt = Date.now(),
|
|
3021
3283
|
settled = 0,
|
|
3022
3284
|
): DispatchSummary {
|
|
3023
|
-
const groups = new Map<AdmissionHoldReason, { count: number; issues: number[] }>();
|
|
3285
|
+
const groups = new Map<AdmissionHoldReason, { count: number; issues: number[]; details: string[] }>();
|
|
3024
3286
|
for (const hold of holds) {
|
|
3025
|
-
const group = groups.get(hold.reason) ?? { count: 0, issues: [] };
|
|
3287
|
+
const group = groups.get(hold.reason) ?? { count: 0, issues: [], details: [] };
|
|
3026
3288
|
group.count += 1;
|
|
3027
|
-
if (group.issues.length < HOLD_SAMPLE_SIZE)
|
|
3289
|
+
if (group.issues.length < HOLD_SAMPLE_SIZE) {
|
|
3290
|
+
// `issues` and `details` share the sample: the detail is only kept when
|
|
3291
|
+
// the issue it explains is, so the arrays stay index-aligned.
|
|
3292
|
+
group.issues.push(hold.issue);
|
|
3293
|
+
if (hold.detail !== undefined) group.details.push(hold.detail);
|
|
3294
|
+
}
|
|
3028
3295
|
groups.set(hold.reason, group);
|
|
3029
3296
|
}
|
|
3030
3297
|
return {
|
|
@@ -3036,7 +3303,12 @@ export function summarizeDispatch(
|
|
|
3036
3303
|
degraded: holds.some((hold) => DEGRADED_HOLDS.has(hold.reason)),
|
|
3037
3304
|
holds: [...groups]
|
|
3038
3305
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
3039
|
-
.map(([reason, group]) => ({
|
|
3306
|
+
.map(([reason, group]) => ({
|
|
3307
|
+
reason,
|
|
3308
|
+
count: group.count,
|
|
3309
|
+
issues: group.issues,
|
|
3310
|
+
...(group.details.length === 0 ? {} : { details: group.details }),
|
|
3311
|
+
})),
|
|
3040
3312
|
settled,
|
|
3041
3313
|
};
|
|
3042
3314
|
}
|
|
@@ -3061,478 +3333,6 @@ export function summarizeHeldPass(settled: number, completedAt = Date.now()): Di
|
|
|
3061
3333
|
};
|
|
3062
3334
|
}
|
|
3063
3335
|
|
|
3064
|
-
/**
|
|
3065
|
-
* What a held plan-usage gate says to a human, if anything.
|
|
3066
|
-
*
|
|
3067
|
-
* Three different problems hide behind one hold, and they want different
|
|
3068
|
-
* tiers. Reaching the threshold is the guard *working*: tier 1, because the
|
|
3069
|
-
* fleet resumes on its own at the provider's reset and nobody needs to get
|
|
3070
|
-
* out of bed. Everything else — a window nothing reports, a window that
|
|
3071
|
-
* resolves to two allowances, a meter that has been unreadable for half an
|
|
3072
|
-
* hour — is dispatch stopped with no self-recovery, which is tier 2.
|
|
3073
|
-
*
|
|
3074
|
-
* Each summary carries the fact that will change when the situation does (the
|
|
3075
|
-
* reset instant, the configured id, the date), because the escalation ledger
|
|
3076
|
-
* dedupes on the summary: a stable one pages once and then goes quiet, which
|
|
3077
|
-
* is right for a repeated tick and wrong for the next window.
|
|
3078
|
-
*/
|
|
3079
|
-
function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation | undefined {
|
|
3080
|
-
const base = { project, issue: NO_ISSUE };
|
|
3081
|
-
if (plan.state === "at-cap") {
|
|
3082
|
-
const window = plan.window?.id ?? plan.cap?.windowId ?? "the configured window";
|
|
3083
|
-
const resets =
|
|
3084
|
-
plan.resetsAt === undefined
|
|
3085
|
-
? new Date().toISOString().slice(0, 10)
|
|
3086
|
-
: new Date(plan.resetsAt).toISOString();
|
|
3087
|
-
return {
|
|
3088
|
-
...base,
|
|
3089
|
-
tier: 1,
|
|
3090
|
-
summary: `Plan allowance cap reached on ${window} — ${project} is not claiming new work (window ${resets})`,
|
|
3091
|
-
detail: [
|
|
3092
|
-
plan.detail,
|
|
3093
|
-
"Running workers finish normally; only new claims are held.",
|
|
3094
|
-
"Dispatch resumes by itself once the provider reports the window reset or usage below the threshold —",
|
|
3095
|
-
"no `resume` needed. Raise `caps.planUsage.maxUsedFraction` only if you mean to spend the rest.",
|
|
3096
|
-
].join("\n"),
|
|
3097
|
-
};
|
|
3098
|
-
}
|
|
3099
|
-
if (plan.state === "blind") {
|
|
3100
|
-
return {
|
|
3101
|
-
...base,
|
|
3102
|
-
tier: 2,
|
|
3103
|
-
category: "fleet-stopped",
|
|
3104
|
-
// Dated: a meter that breaks again next month is a new incident, not a
|
|
3105
|
-
// repeat of this one.
|
|
3106
|
-
summary: `Plan usage source unreadable — ${project} is not claiming new work (${new Date().toISOString().slice(0, 10)})`,
|
|
3107
|
-
detail: [
|
|
3108
|
-
plan.detail,
|
|
3109
|
-
"The guard admitted work while the failure looked transient and has now stopped.",
|
|
3110
|
-
"Check `omp usage --json` on the fleet host, or set `caps.planUsage` to null if this fleet is unmetered.",
|
|
3111
|
-
].join("\n"),
|
|
3112
|
-
};
|
|
3113
|
-
}
|
|
3114
|
-
if (
|
|
3115
|
-
plan.state === "window-missing" ||
|
|
3116
|
-
plan.state === "window-ambiguous" ||
|
|
3117
|
-
plan.state === "window-uncomparable"
|
|
3118
|
-
) {
|
|
3119
|
-
return {
|
|
3120
|
-
...base,
|
|
3121
|
-
tier: 2,
|
|
3122
|
-
category: "fleet-stopped",
|
|
3123
|
-
summary: `Plan usage cap names an unusable window "${plan.cap?.windowId ?? "?"}" — ${project} is not claiming new work`,
|
|
3124
|
-
detail: [
|
|
3125
|
-
plan.detail,
|
|
3126
|
-
"Run `omp usage --json` and copy an allowance `id` into `caps.planUsage.windowId`,",
|
|
3127
|
-
"or set `caps.planUsage` to null if this fleet is unmetered.",
|
|
3128
|
-
].join("\n"),
|
|
3129
|
-
};
|
|
3130
|
-
}
|
|
3131
|
-
return undefined;
|
|
3132
|
-
}
|
|
3133
|
-
|
|
3134
|
-
/**
|
|
3135
|
-
* Which routed candidates get a worker this tick — in queue order, never more
|
|
3136
|
-
* than `slots` of them. Every non-admission receives a stable reason code.
|
|
3137
|
-
*
|
|
3138
|
-
* Exported so the admission rules can be pinned without spawning a worker.
|
|
3139
|
-
* Every one of them exists because of a live incident, and each guards a
|
|
3140
|
-
* different way the same issue gets worked twice — including epic siblings
|
|
3141
|
-
* racing onto the same files (#48).
|
|
3142
|
-
*
|
|
3143
|
-
* Takes the slice of `Deps` it actually reads rather than the whole thing: what
|
|
3144
|
-
* admission is allowed to consult is the point of the function, and a `Deps`
|
|
3145
|
-
* that grows a field has no business breaking these tests.
|
|
3146
|
-
*/
|
|
3147
|
-
export async function admitCandidates(
|
|
3148
|
-
d: Pick<Deps, "project" | "caps" | "tracker" | "store" | "escalate" | "usage" | "probeCriticalBase">,
|
|
3149
|
-
routed: Routed[],
|
|
3150
|
-
slots: number,
|
|
3151
|
-
): Promise<AdmissionPass> {
|
|
3152
|
-
const { project, caps, tracker, store } = d;
|
|
3153
|
-
const activeRuns = store.activeRuns(project.name);
|
|
3154
|
-
const busyIssues = activeRuns.map((r) => r.issue);
|
|
3155
|
-
const busy = new Set(busyIssues);
|
|
3156
|
-
// issue -> its active run rows, for the pushed-green admission bypass (#175):
|
|
3157
|
-
// only a worker-free pushed-green row may be bypassed, and only when *every*
|
|
3158
|
-
// active run for the issue is worker-free. A live (claimed/running) row still
|
|
3159
|
-
// holds unconditionally.
|
|
3160
|
-
const activeByIssue = new Map<number, RunRecord[]>();
|
|
3161
|
-
for (const run of activeRuns) {
|
|
3162
|
-
const list = activeByIssue.get(run.issue);
|
|
3163
|
-
if (list === undefined) activeByIssue.set(run.issue, [run]);
|
|
3164
|
-
else list.push(run);
|
|
3165
|
-
}
|
|
3166
|
-
// Live worker count per repo, seeded from live runs and incremented as this
|
|
3167
|
-
// same pass admits — so two same-repo candidates can never both clear the
|
|
3168
|
-
// per-repo cap in one tick (#186).
|
|
3169
|
-
const liveByRepo = new Map<string, number>();
|
|
3170
|
-
for (const run of store.liveRuns(project.name)) {
|
|
3171
|
-
liveByRepo.set(run.repo, (liveByRepo.get(run.repo) ?? 0) + 1);
|
|
3172
|
-
}
|
|
3173
|
-
const holds: AdmissionHold[] = [];
|
|
3174
|
-
const hold = (issue: number, reason: AdmissionHoldReason): void => {
|
|
3175
|
-
holds.push({ issue, reason });
|
|
3176
|
-
};
|
|
3177
|
-
|
|
3178
|
-
// The plan allowance is a fleet-wide question, so it is asked once per pass
|
|
3179
|
-
// and answers for every candidate — unlike every gate below it, which is
|
|
3180
|
-
// per-issue. It sits here rather than beside the spend cap in `tick` for one
|
|
3181
|
-
// reason: the spend cap *pauses the daemon* and waits for a human, and a
|
|
3182
|
-
// weekly plan window resets by itself. A guard that demanded `resume` after
|
|
3183
|
-
// every rollover would cost more operator attention than the guard saves
|
|
3184
|
-
// (#110). Already-running workers are untouched and settle normally.
|
|
3185
|
-
//
|
|
3186
|
-
// Placed after the cheap local busy-set read and before the first tracker
|
|
3187
|
-
// call, so a held fleet spends no GitHub API budget discovering it is held.
|
|
3188
|
-
const plan = await readPlanUsage(caps.planUsage, d.usage);
|
|
3189
|
-
if (plan.blocking) {
|
|
3190
|
-
for (const r of routed) hold(r.issue.number, "plan-usage-cap");
|
|
3191
|
-
log(`plan usage gate holding ${String(routed.length)} candidate(s): ${plan.detail}`);
|
|
3192
|
-
const escalation = planUsageEscalation(project.name, plan);
|
|
3193
|
-
if (escalation !== undefined) await safeEscalate(d, escalation);
|
|
3194
|
-
return { admitted: [], holds };
|
|
3195
|
-
}
|
|
3196
|
-
|
|
3197
|
-
// parent -> repo name -> blocking issue. Seeded from active runs (including
|
|
3198
|
-
// pushed-green), then extended by candidates admitted earlier in this same
|
|
3199
|
-
// pass so two siblings of one epic never both clear the gate in one tick.
|
|
3200
|
-
// A busy issue whose run row cannot be resolved occupies the sentinel repo
|
|
3201
|
-
// "" — treated as matching every repo, failing toward holding (#197).
|
|
3202
|
-
const occupiedParents = new Map<number, Map<string, number>>();
|
|
3203
|
-
const parentCache = new Map<number, number | undefined>();
|
|
3204
|
-
|
|
3205
|
-
const resolveParent = async (issue: number): Promise<number | undefined> => {
|
|
3206
|
-
if (parentCache.has(issue)) return parentCache.get(issue);
|
|
3207
|
-
const parent = await tracker.parentOf(issue);
|
|
3208
|
-
parentCache.set(issue, parent);
|
|
3209
|
-
return parent;
|
|
3210
|
-
};
|
|
3211
|
-
|
|
3212
|
-
// Bounded by concurrent workers, not queue depth. A failed lookup here cannot
|
|
3213
|
-
// mark an epic occupied; candidates still fail closed on their own parentOf.
|
|
3214
|
-
for (const issue of busyIssues) {
|
|
3215
|
-
try {
|
|
3216
|
-
const parent = await resolveParent(issue);
|
|
3217
|
-
if (parent === undefined) continue;
|
|
3218
|
-
// The runs table records which repo each attempt worked in, and sibling
|
|
3219
|
-
// holds are now per-repo, so a busy child only occupies its epic under
|
|
3220
|
-
// that repo's name (same spelling as `createRun` writes from
|
|
3221
|
-
// `r.repo.name`). A busy issue with no resolvable run row occupies the
|
|
3222
|
-
// sentinel "" instead — matching every repo, failing toward holding.
|
|
3223
|
-
const repo = store.latestRun(project.name, issue)?.repo ?? "";
|
|
3224
|
-
const siblings = occupiedParents.get(parent);
|
|
3225
|
-
if (siblings === undefined) {
|
|
3226
|
-
occupiedParents.set(parent, new Map([[repo, issue]]));
|
|
3227
|
-
} else if (!siblings.has(repo) && !siblings.has("")) {
|
|
3228
|
-
siblings.set(repo, issue);
|
|
3229
|
-
}
|
|
3230
|
-
} catch (err) {
|
|
3231
|
-
log(`#${issue} parent lookup failed while seeding epic occupancy (${errText(err)})`);
|
|
3232
|
-
}
|
|
3233
|
-
}
|
|
3234
|
-
|
|
3235
|
-
const admitted: Admission[] = [];
|
|
3236
|
-
for (const r of routed) {
|
|
3237
|
-
const issue = r.issue.number;
|
|
3238
|
-
if (admitted.length >= slots) {
|
|
3239
|
-
hold(issue, "capacity");
|
|
3240
|
-
continue;
|
|
3241
|
-
}
|
|
3242
|
-
if (busy.has(issue)) {
|
|
3243
|
-
// A pushed-green row is worker-free by definition (it is not in
|
|
3244
|
-
// LIVE_STATES): its PR is live but no process is writing to its branch.
|
|
3245
|
-
// So an issue whose active runs are ALL pushed-green is not actually
|
|
3246
|
-
// occupied — the corrective attempt the operator unblocked may be
|
|
3247
|
-
// admitted as a continuation of that PR, and the open-PR gate below
|
|
3248
|
-
// decides the identity. Any live row still holds (#175).
|
|
3249
|
-
const allWorkerFree = (activeByIssue.get(issue) ?? []).every((r) => r.state === "pushed-green");
|
|
3250
|
-
if (!allWorkerFree) {
|
|
3251
|
-
hold(issue, "issue-active");
|
|
3252
|
-
continue;
|
|
3253
|
-
}
|
|
3254
|
-
}
|
|
3255
|
-
|
|
3256
|
-
// Per-repo concurrency: the mirror, branch-protection staleness and shared
|
|
3257
|
-
// CI egress are all per-repo collision domains, so extra slots should land
|
|
3258
|
-
// on other repos rather than stacking workers into the same one (#186).
|
|
3259
|
-
const liveInRepo = liveByRepo.get(r.repo.name) ?? 0;
|
|
3260
|
-
if (liveInRepo >= caps.maxConcurrentWorkersPerRepo) {
|
|
3261
|
-
hold(issue, "repo-active");
|
|
3262
|
-
log(`#${issue} skipped: ${liveInRepo} live worker(s) already in ${r.repo.name} (cap ${caps.maxConcurrentWorkersPerRepo})`);
|
|
3263
|
-
continue;
|
|
3264
|
-
}
|
|
3265
|
-
|
|
3266
|
-
const priorRuns = store.attemptsFor(project.name, issue);
|
|
3267
|
-
const failures = store.failuresFor(project.name, issue);
|
|
3268
|
-
if (!hasFailedAttemptBudget(failures, caps.maxAttemptsPerIssue)) {
|
|
3269
|
-
hold(issue, "failed-attempts");
|
|
3270
|
-
await safeEscalate(d, {
|
|
3271
|
-
tier: 1,
|
|
3272
|
-
project: project.name,
|
|
3273
|
-
issue,
|
|
3274
|
-
summary: `#${issue} has used all ${caps.maxAttemptsPerIssue} failed attempts`,
|
|
3275
|
-
detail: [
|
|
3276
|
-
r.issue.title,
|
|
3277
|
-
r.issue.url,
|
|
3278
|
-
"Another implementation attempt almost always means the issue itself is underspecified.",
|
|
3279
|
-
"Rewrite the acceptance criteria, or take it off the queue.",
|
|
3280
|
-
].join("\n"),
|
|
3281
|
-
});
|
|
3282
|
-
continue;
|
|
3283
|
-
}
|
|
3284
|
-
|
|
3285
|
-
const continuations = store.continuationsFor(project.name, issue);
|
|
3286
|
-
if (!hasContinuationBudget(continuations, caps.maxContinuationsPerIssue)) {
|
|
3287
|
-
hold(issue, "continuations");
|
|
3288
|
-
await safeEscalate(d, {
|
|
3289
|
-
tier: 1,
|
|
3290
|
-
project: project.name,
|
|
3291
|
-
issue,
|
|
3292
|
-
summary: `#${issue} exceeded its ${caps.maxContinuationsPerIssue}-continuation budget`,
|
|
3293
|
-
detail: [
|
|
3294
|
-
r.issue.title,
|
|
3295
|
-
r.issue.url,
|
|
3296
|
-
"Repeated cap kills, daemon orphans, or answered blocks need an operator to inspect progress.",
|
|
3297
|
-
].join("\n"),
|
|
3298
|
-
});
|
|
3299
|
-
continue;
|
|
3300
|
-
}
|
|
3301
|
-
|
|
3302
|
-
// Fail closed on work that exists only in a run repo. `addRunRepo` clears
|
|
3303
|
-
// the tree at <workspaceRoot>/<issue> before it provisions, so admitting
|
|
3304
|
-
// this issue is what finally destroys the copy the salvage could not save
|
|
3305
|
-
// (#118). Nothing here can recover it — git already refused once — so the
|
|
3306
|
-
// only safe move is to refuse the claim and keep saying why until an
|
|
3307
|
-
// operator has looked and run `unblock --force`.
|
|
3308
|
-
const newest = store.latestRun(project.name, issue);
|
|
3309
|
-
if (newest?.salvageError !== undefined && newest.salvageAckAt === undefined) {
|
|
3310
|
-
hold(issue, "unsalvaged-wip");
|
|
3311
|
-
await safeEscalate(d, {
|
|
3312
|
-
tier: 1,
|
|
3313
|
-
project: project.name,
|
|
3314
|
-
issue,
|
|
3315
|
-
summary: `#${issue} is holding unsalvaged work and will not be re-claimed`,
|
|
3316
|
-
detail: [
|
|
3317
|
-
r.issue.title,
|
|
3318
|
-
r.issue.url,
|
|
3319
|
-
`Attempt ${newest.attempt} could not commit its uncommitted changes: ${newest.salvageError}`,
|
|
3320
|
-
`The only copy is the worktree ${newest.worktree === "" ? "(path not recorded)" : newest.worktree}.`,
|
|
3321
|
-
"Dispatch is held because claiming this issue removes that tree.",
|
|
3322
|
-
"Recover it by hand, then `omp-conductor unblock <n> --force` to release the hold.",
|
|
3323
|
-
].join("\n"),
|
|
3324
|
-
});
|
|
3325
|
-
continue;
|
|
3326
|
-
}
|
|
3327
|
-
|
|
3328
|
-
// #428 half (a): a preserved continuation that predates a configured
|
|
3329
|
-
// critical-base/safety marker must not be reattached. A base safety fix
|
|
3330
|
-
// protects only branches forked after it landed — a continuation forked
|
|
3331
|
-
// before it still carries the dangerous test/runtime code, and re-running
|
|
3332
|
-
// it on the shared host is what SIGTERMed the production daemon. Fail
|
|
3333
|
-
// closed: only a probe that proves every marker is in the reattach
|
|
3334
|
-
// source's ancestry admits, and a project that names a marker but has no
|
|
3335
|
-
// probe wired (never happens outside tests) holds. Both the hold and the
|
|
3336
|
-
// escalation are durable across restart and orphan recovery because this
|
|
3337
|
-
// gate runs every admission pass; the branch is re-admitted automatically
|
|
3338
|
-
// once the operator updates it to contain the marker, without losing work.
|
|
3339
|
-
const markers = project.criticalBase ?? [];
|
|
3340
|
-
if (markers.length > 0) {
|
|
3341
|
-
const branch = branchName(r.issue);
|
|
3342
|
-
let verdict: CriticalBaseVerdict;
|
|
3343
|
-
if (d.probeCriticalBase === undefined) {
|
|
3344
|
-
verdict = { state: "unknown", error: "no critical-base probe is wired in this deployment" };
|
|
3345
|
-
} else {
|
|
3346
|
-
try {
|
|
3347
|
-
verdict = await d.probeCriticalBase(r.repo, markers, branch);
|
|
3348
|
-
} catch (err) {
|
|
3349
|
-
verdict = { state: "unknown", error: errText(err) };
|
|
3350
|
-
}
|
|
3351
|
-
}
|
|
3352
|
-
if (verdict.state === "stale") {
|
|
3353
|
-
hold(issue, "stale-base");
|
|
3354
|
-
log(
|
|
3355
|
-
`#${issue} held (stale-base): continuation branch ${branch} predates critical-base marker ${verdict.marker}`,
|
|
3356
|
-
);
|
|
3357
|
-
await safeEscalate(d, {
|
|
3358
|
-
tier: 1,
|
|
3359
|
-
project: project.name,
|
|
3360
|
-
issue,
|
|
3361
|
-
summary: `#${issue} continuation branch predates a critical base safety commit and is held (stale-base)`,
|
|
3362
|
-
detail: [
|
|
3363
|
-
r.issue.title,
|
|
3364
|
-
r.issue.url,
|
|
3365
|
-
`The retained branch ${branch} does not contain critical-base marker ${verdict.marker}.`,
|
|
3366
|
-
...(verdict.range.length > 0
|
|
3367
|
-
? [`Base commits the branch is missing: ${verdict.range.join(", ")}`]
|
|
3368
|
-
: []),
|
|
3369
|
-
"Recovery: merge current base into the branch so it contains the marker, and the next",
|
|
3370
|
-
"admission pass re-admits it automatically without losing the branch's work; or review",
|
|
3371
|
-
"the branch by hand and clear the hold once the fix is present.",
|
|
3372
|
-
].join("\n"),
|
|
3373
|
-
});
|
|
3374
|
-
continue;
|
|
3375
|
-
}
|
|
3376
|
-
if (verdict.state === "unknown") {
|
|
3377
|
-
// Fail closed: a branch that cannot be *proven* to contain the marker
|
|
3378
|
-
// is refused, and the reason names the unverifiable marker so the
|
|
3379
|
-
// operator can fix the fetch or the marker rather than guess.
|
|
3380
|
-
hold(issue, "stale-base");
|
|
3381
|
-
log(
|
|
3382
|
-
`#${issue} held (stale-base): continuation branch ${branch} could not be verified ` +
|
|
3383
|
-
`against critical-base marker(s) ${markers.join(", ")} (${verdict.error})`,
|
|
3384
|
-
);
|
|
3385
|
-
await safeEscalate(d, {
|
|
3386
|
-
tier: 1,
|
|
3387
|
-
project: project.name,
|
|
3388
|
-
issue,
|
|
3389
|
-
summary: `#${issue} continuation branch could not be verified against a critical base safety commit and is held (stale-base)`,
|
|
3390
|
-
detail: [
|
|
3391
|
-
r.issue.title,
|
|
3392
|
-
r.issue.url,
|
|
3393
|
-
`The retained branch ${branch} could not be verified against critical-base marker(s) ${markers.join(", ")}: ${verdict.error}`,
|
|
3394
|
-
"Recovery: merge current base into the branch so it contains the marker, and the next",
|
|
3395
|
-
"admission pass re-admits it automatically without losing the branch's work; or review",
|
|
3396
|
-
"the branch by hand and clear the hold once the fix is present.",
|
|
3397
|
-
].join("\n"),
|
|
3398
|
-
});
|
|
3399
|
-
continue;
|
|
3400
|
-
}
|
|
3401
|
-
}
|
|
3402
|
-
|
|
3403
|
-
// Soft concurrency per epic, per repository: at most one in-flight child of
|
|
3404
|
-
// a given parent in each repo. Children of one epic in *different* repos
|
|
3405
|
-
// parallelise freely — `repo-active` / `maxConcurrentWorkersPerRepo` owns
|
|
3406
|
-
// the same-repo collision domain (#197). The "" sentinel matches every
|
|
3407
|
-
// repo. No parent means today's concurrent admission. Cheap local filters
|
|
3408
|
-
// already ran; this sits before the open-PR API call so a held sibling
|
|
3409
|
-
// frees the slot for unrelated work without spending a closers query.
|
|
3410
|
-
let parent: number | undefined;
|
|
3411
|
-
try {
|
|
3412
|
-
parent = await resolveParent(issue);
|
|
3413
|
-
} catch (err) {
|
|
3414
|
-
hold(issue, "parent-lookup-error");
|
|
3415
|
-
log(`#${issue} held: parent check failed (${errText(err)}) — retrying next tick`);
|
|
3416
|
-
continue;
|
|
3417
|
-
}
|
|
3418
|
-
if (parent !== undefined) {
|
|
3419
|
-
const occupied = occupiedParents.get(parent);
|
|
3420
|
-
const blocker = occupied?.get(r.repo.name) ?? occupied?.get("");
|
|
3421
|
-
if (blocker !== undefined) {
|
|
3422
|
-
hold(issue, "sibling-active");
|
|
3423
|
-
log(`#${issue} skipped: sibling #${blocker} in flight under epic #${parent} in ${r.repo.name}`);
|
|
3424
|
-
continue;
|
|
3425
|
-
}
|
|
3426
|
-
}
|
|
3427
|
-
|
|
3428
|
-
// The busy set is built from run rows, so it can only speak for work this
|
|
3429
|
-
// database recorded. Work pushed before this store existed — a migration, a
|
|
3430
|
-
// wiped or relocated state dir, a restore onto a new host — looks exactly
|
|
3431
|
-
// like fresh work, and a worker sent at it re-implements a finished PR. The
|
|
3432
|
-
// tracker is the only party that remembers, so it is asked. The cost is
|
|
3433
|
-
// bounded by free slots, not by queue depth: the call sits behind the two
|
|
3434
|
-
// cheap local filters and candidates beyond capacity skip it.
|
|
3435
|
-
let closer: OpenCloser | undefined;
|
|
3436
|
-
try {
|
|
3437
|
-
closer = await tracker.openCloserFor(issue);
|
|
3438
|
-
} catch (err) {
|
|
3439
|
-
// Fail closed, per candidate. An API error means "unknown whether
|
|
3440
|
-
// finished work exists", and admitting on unknown recreates precisely the
|
|
3441
|
-
// duplicate-work failure this guard exists to kill: the worst case of
|
|
3442
|
-
// holding is a five-minute delay, the worst case of admitting is a burned
|
|
3443
|
-
// attempt and a second PR on the same issue. Holding one candidate rather
|
|
3444
|
-
// than aborting the loop keeps a transient GitHub failure from deadlocking
|
|
3445
|
-
// the whole dispatcher; the next tick retries by itself.
|
|
3446
|
-
hold(issue, "open-pr-lookup-error");
|
|
3447
|
-
log(`#${issue} held: open-PR check failed (${errText(err)}) — retrying next tick`);
|
|
3448
|
-
continue;
|
|
3449
|
-
}
|
|
3450
|
-
if (closer !== undefined) {
|
|
3451
|
-
const latest = store.latestRun(project.name, issue);
|
|
3452
|
-
// Terminality is the first half of the test and is not negotiable: while a
|
|
3453
|
-
// run is live its worker is still pushing to that branch, and a second
|
|
3454
|
-
// worker sent at the same PR is exactly the duplicate-work failure this
|
|
3455
|
-
// guard exists to kill. Only a run that has stopped can be continued.
|
|
3456
|
-
const retained =
|
|
3457
|
-
latest?.state === "blocked" ||
|
|
3458
|
-
latest?.state === "failed" ||
|
|
3459
|
-
latest?.state === "killed" ||
|
|
3460
|
-
latest?.state === "orphaned" ||
|
|
3461
|
-
latest?.state === "pushed-green"
|
|
3462
|
-
? latest
|
|
3463
|
-
: undefined;
|
|
3464
|
-
// The second half asks "is this open PR our retained work", and accepts
|
|
3465
|
-
// two identities for it, because the branch is the durable artefact of a
|
|
3466
|
-
// retained run and the PR is not. A cap kill can end a run before any PR
|
|
3467
|
-
// exists: veltro#324 attempt 1 was killed at the turns cap on
|
|
3468
|
-
// 2026-08-09T00:47Z before its worker opened one, so the row kept `branch`
|
|
3469
|
-
// and `prUrl` stayed NULL. chad#438 was opened from that exact branch
|
|
3470
|
-
// afterwards, and URL equality — the only test 0.3.20 had — can never match
|
|
3471
|
-
// a URL the terminal run never recorded, so every tick held #324 as
|
|
3472
|
-
// `open-pr` until an operator closed recoverable work to free the branch
|
|
3473
|
-
// (#50). An ordinary issue whose open PR is unrelated still fails both
|
|
3474
|
-
// identities and stays ineligible, and an empty `headRefName` (a reply that
|
|
3475
|
-
// did not carry the field) is never a match: unknown is not identity.
|
|
3476
|
-
let resume: string | undefined;
|
|
3477
|
-
if (retained !== undefined) {
|
|
3478
|
-
if (retained.prUrl === closer.url) {
|
|
3479
|
-
resume = `from ${retained.state} run (matched recorded PR URL)`;
|
|
3480
|
-
} else if (closer.headRefName !== "" && retained.branch === closer.headRefName) {
|
|
3481
|
-
resume = `from ${retained.state} run (matched retained branch ${closer.headRefName})`;
|
|
3482
|
-
}
|
|
3483
|
-
}
|
|
3484
|
-
if (resume === undefined) {
|
|
3485
|
-
hold(issue, "open-pr");
|
|
3486
|
-
log(`#${issue} skipped: open PR ${closer.url} already closes it`);
|
|
3487
|
-
continue;
|
|
3488
|
-
}
|
|
3489
|
-
log(`#${issue} continuing retained PR ${closer.url} ${resume}`);
|
|
3490
|
-
}
|
|
3491
|
-
|
|
3492
|
-
// The queue comes from GitHub's eventually-consistent search index. Re-read
|
|
3493
|
-
// state and labels directly at the last possible moment so a just-closed or
|
|
3494
|
-
// explicitly dequeued issue cannot turn a stale candidate into another
|
|
3495
|
-
// attempt (#247).
|
|
3496
|
-
let snapshot: IssueSnapshot | undefined;
|
|
3497
|
-
try {
|
|
3498
|
-
snapshot = await tracker.issueSnapshot(issue);
|
|
3499
|
-
} catch {
|
|
3500
|
-
snapshot = undefined;
|
|
3501
|
-
}
|
|
3502
|
-
if (snapshot === undefined) {
|
|
3503
|
-
hold(issue, "issue-state-lookup-error");
|
|
3504
|
-
log(`#${issue} held: issue snapshot check failed — retrying next tick`);
|
|
3505
|
-
continue;
|
|
3506
|
-
}
|
|
3507
|
-
if (snapshot.state === "closed") {
|
|
3508
|
-
hold(issue, "issue-closed");
|
|
3509
|
-
log(`#${issue} skipped: issue is closed (search index lag)`);
|
|
3510
|
-
continue;
|
|
3511
|
-
}
|
|
3512
|
-
if (!snapshot.labels.includes(project.queueLabel)) {
|
|
3513
|
-
hold(issue, "issue-dequeued");
|
|
3514
|
-
log(`#${issue} skipped: queue label ${project.queueLabel} was removed (search index lag)`);
|
|
3515
|
-
continue;
|
|
3516
|
-
}
|
|
3517
|
-
|
|
3518
|
-
admitted.push({ r, attempt: priorRuns + 1 });
|
|
3519
|
-
liveByRepo.set(r.repo.name, (liveByRepo.get(r.repo.name) ?? 0) + 1);
|
|
3520
|
-
if (parent !== undefined) {
|
|
3521
|
-
// Extend the epic's occupancy under this repo (slot empty by construction
|
|
3522
|
-
// here — the gate above would have held the candidate otherwise) so a
|
|
3523
|
-
// same-repo sibling later in this pass does not clear the gate (#197).
|
|
3524
|
-
let siblings = occupiedParents.get(parent);
|
|
3525
|
-
if (siblings === undefined) {
|
|
3526
|
-
siblings = new Map();
|
|
3527
|
-
occupiedParents.set(parent, siblings);
|
|
3528
|
-
}
|
|
3529
|
-
if (!siblings.has(r.repo.name)) siblings.set(r.repo.name, issue);
|
|
3530
|
-
}
|
|
3531
|
-
}
|
|
3532
|
-
|
|
3533
|
-
return { admitted, holds };
|
|
3534
|
-
}
|
|
3535
|
-
|
|
3536
3336
|
export interface WorkerPool {
|
|
3537
3337
|
launch(work: Promise<void>): void;
|
|
3538
3338
|
activeCount(): number;
|
|
@@ -3723,6 +3523,18 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
3723
3523
|
// failure happened. A pause silences claiming, not the operator's right to
|
|
3724
3524
|
// know their supervising session stopped reading its queue.
|
|
3725
3525
|
await watchOrchestrator(d);
|
|
3526
|
+
// The down incident is reconciled the same place and for the same reason: a
|
|
3527
|
+
// session that has actually died is as much the operator's concern as one
|
|
3528
|
+
// that is wedged, and restarting it is the daemon's restart either way. This
|
|
3529
|
+
// is what turns a crashed orchestrator into one page ("down since <t>") plus
|
|
3530
|
+
// a diverting count, instead of a warning only in daemon.log.
|
|
3531
|
+
await reconcileOrchestratorDown({
|
|
3532
|
+
project: d.project,
|
|
3533
|
+
store: d.store,
|
|
3534
|
+
orchestrator: d.orchestrator,
|
|
3535
|
+
escalate: (event) => d.escalate(event),
|
|
3536
|
+
log,
|
|
3537
|
+
});
|
|
3726
3538
|
|
|
3727
3539
|
// Settlement is maintenance, not dispatch. Run it before every gate that can
|
|
3728
3540
|
// stop claiming — pause, integrity, spend, and capacity — so status converges
|
|
@@ -4415,6 +4227,8 @@ export interface StatusSnapshot {
|
|
|
4415
4227
|
*/
|
|
4416
4228
|
/** Current live-head push-workflow verdict per recently merged repository. */
|
|
4417
4229
|
baseHealth: BaseHealth[];
|
|
4230
|
+
/** Per-repo base-red merge freezes, active first (#283). */
|
|
4231
|
+
freezes: BaseFreeze[];
|
|
4418
4232
|
verbLedger: VerbLedgerEntry[];
|
|
4419
4233
|
/** Runs backed by a worker process — the number capacity compares against. */
|
|
4420
4234
|
liveWorkers: number;
|
|
@@ -4450,6 +4264,13 @@ export interface StatusSnapshot {
|
|
|
4450
4264
|
* see rather than a silent gap.
|
|
4451
4265
|
*/
|
|
4452
4266
|
labelOps?: { pending: number; oldestAgeMs: number };
|
|
4267
|
+
/**
|
|
4268
|
+
* The orchestrator-down incident, when the embedded orchestrator is down:
|
|
4269
|
+
* mode, since-moment and the tier-1 escalations diverted to issue comments
|
|
4270
|
+
* so far. Absent when the orchestrator is healthy (or external), so recovery
|
|
4271
|
+
* drops the degrade row from `status` (#288).
|
|
4272
|
+
*/
|
|
4273
|
+
orchestratorDown?: OrchestratorIncident;
|
|
4453
4274
|
}
|
|
4454
4275
|
|
|
4455
4276
|
/** Builds a status reading from an already-open store. Long-lived operator
|
|
@@ -4469,6 +4290,8 @@ export function statusSnapshotFromStore(
|
|
|
4469
4290
|
const dispatch = store.latestDispatch(p.name);
|
|
4470
4291
|
const labelOpsPending = store.countPendingLabelOps(p.name);
|
|
4471
4292
|
const oldestLabelOpAt = store.oldestPendingLabelOpAt(p.name);
|
|
4293
|
+
// Read once: `status` renders the degrade row off this while it is down.
|
|
4294
|
+
const orchestratorDown = store.orchestratorIncident(p.name);
|
|
4472
4295
|
// Read once: the provenance read touches the filesystem, and the renderer
|
|
4473
4296
|
// should never pay for it twice per status.
|
|
4474
4297
|
const reason = pauseProvenance(p.name)?.reason;
|
|
@@ -4501,6 +4324,8 @@ export function statusSnapshotFromStore(
|
|
|
4501
4324
|
? {}
|
|
4502
4325
|
: { labelOps: { pending: labelOpsPending, oldestAgeMs: now - oldestLabelOpAt } }),
|
|
4503
4326
|
baseHealth: store.baseHealth(p.name),
|
|
4327
|
+
freezes: store.freezes(p.name),
|
|
4328
|
+
...(orchestratorDown === undefined ? {} : { orchestratorDown }),
|
|
4504
4329
|
};
|
|
4505
4330
|
}
|
|
4506
4331
|
|
|
@@ -4543,6 +4368,11 @@ export function formatDispatchSummary(summary?: DispatchSummary): string {
|
|
|
4543
4368
|
? ""
|
|
4544
4369
|
: ` (#${hold.issues.join(", #")}${hold.count > hold.issues.length ? ", …" : ""})`;
|
|
4545
4370
|
lines.push(` ${hold.reason} ${hold.count}${sample}`);
|
|
4371
|
+
// A `file-lane` hold groups several issues, each blocked by a different
|
|
4372
|
+
// file and holder; the grouped line says how many, this says which.
|
|
4373
|
+
if ((hold.details?.length ?? 0) > 0) {
|
|
4374
|
+
lines.push(` ${hold.details!.join(" | ")}`);
|
|
4375
|
+
}
|
|
4546
4376
|
}
|
|
4547
4377
|
}
|
|
4548
4378
|
}
|
|
@@ -4612,6 +4442,25 @@ export function formatBaseHealth(rows: readonly BaseHealth[]): string[] {
|
|
|
4612
4442
|
});
|
|
4613
4443
|
}
|
|
4614
4444
|
|
|
4445
|
+
/**
|
|
4446
|
+
* The active base-red freezes as status lines — merges refused until the base
|
|
4447
|
+
* is green again or the operator overrides. Active freezes only: a cleared
|
|
4448
|
+
* freeze is history the ledger and digest already told an operator about.
|
|
4449
|
+
*/
|
|
4450
|
+
export function formatFreezes(freezes: readonly BaseFreeze[]): string[] {
|
|
4451
|
+
const active = freezes.filter((f) => f.clearedAt === undefined);
|
|
4452
|
+
if (active.length === 0) return [];
|
|
4453
|
+
return [
|
|
4454
|
+
"frozen repos (merges refused until base green)",
|
|
4455
|
+
...active.map(
|
|
4456
|
+
(f) =>
|
|
4457
|
+
` ${f.repo} base red at ${f.culpritSha.slice(0, 8)}` +
|
|
4458
|
+
(f.detail === undefined ? "" : ` ${f.detail}`) +
|
|
4459
|
+
` — override: omp-conductor unfreeze ${f.repo}`,
|
|
4460
|
+
),
|
|
4461
|
+
];
|
|
4462
|
+
}
|
|
4463
|
+
|
|
4615
4464
|
|
|
4616
4465
|
export function formatStatus(s: StatusSnapshot): string {
|
|
4617
4466
|
const lines = [
|
|
@@ -4619,6 +4468,7 @@ export function formatStatus(s: StatusSnapshot): string {
|
|
|
4619
4468
|
`config ${s.configPath}`,
|
|
4620
4469
|
`state ${s.stateDir}`,
|
|
4621
4470
|
"",
|
|
4471
|
+
...(s.orchestratorDown === undefined ? [] : formatOrchestratorDown(s.orchestratorDown)),
|
|
4622
4472
|
"caps",
|
|
4623
4473
|
` workers ${s.liveWorkers} / ${s.caps.maxConcurrentWorkers}`,
|
|
4624
4474
|
` issues today ${s.runsToday}`,
|
|
@@ -4660,6 +4510,7 @@ export function formatStatus(s: StatusSnapshot): string {
|
|
|
4660
4510
|
}
|
|
4661
4511
|
}
|
|
4662
4512
|
lines.push(...formatBaseHealth(s.baseHealth));
|
|
4513
|
+
lines.push(...formatFreezes(s.freezes));
|
|
4663
4514
|
lines.push(...formatSalvagedRuns(s.salvagedRuns));
|
|
4664
4515
|
lines.push(...formatOpenReports(s.openReports));
|
|
4665
4516
|
lines.push(...formatVerbLedger(s.verbLedger));
|
|
@@ -5095,6 +4946,35 @@ async function recoverRun(
|
|
|
5095
4946
|
log(`#${run.issue} escalated from persistent ${cls}: ${evidence}`);
|
|
5096
4947
|
return;
|
|
5097
4948
|
}
|
|
4949
|
+
// Same bound for provider-capacity: a run the provider throttled into the
|
|
4950
|
+
// ground is requeued free (no attempt charged) — but a provider that
|
|
4951
|
+
// throttles the same issue three times is at capacity, and a human has to
|
|
4952
|
+
// check its status before hand-requeueing (#573). On a chain-configured
|
|
4953
|
+
// project each requeue already moved the next attempt to the next chain
|
|
4954
|
+
// model, so this escalation is what catches the no-chain case and the
|
|
4955
|
+
// exhausted chain; it names every model the chain tried.
|
|
4956
|
+
if (
|
|
4957
|
+
cls === "provider-capacity" &&
|
|
4958
|
+
store.classCountFor(project.name, run.issue, "provider-capacity") >= PROVIDER_CAPACITY_MAX_STRIKES
|
|
4959
|
+
) {
|
|
4960
|
+
const tried = formatModelsTried(modelsTried(store.runsForIssue(project.name, run.issue)));
|
|
4961
|
+
await safeEscalate(d, {
|
|
4962
|
+
tier: 1,
|
|
4963
|
+
project: project.name,
|
|
4964
|
+
issue: run.issue,
|
|
4965
|
+
summary: `[provider-capacity] #${run.issue}: the model provider is throttling this run into the ground — ${evidence}`,
|
|
4966
|
+
detail: [
|
|
4967
|
+
`The provider answered #${run.issue} with sustained in-session rate limits ${PROVIDER_CAPACITY_MAX_STRIKES} times in a row; the harness retried each and was exhausted.`,
|
|
4968
|
+
...(tried === ""
|
|
4969
|
+
? []
|
|
4970
|
+
: [`Models tried: ${tried}.`]),
|
|
4971
|
+
"Check the provider's rate-limit status (and its throughput-oriented routes) before requeueing by hand.",
|
|
4972
|
+
].join("\n"),
|
|
4973
|
+
});
|
|
4974
|
+
store.updateRun(run.id, { recoveredAt: Date.now() });
|
|
4975
|
+
log(`#${run.issue} escalated from persistent ${cls}: ${evidence}`);
|
|
4976
|
+
return;
|
|
4977
|
+
}
|
|
5098
4978
|
// Only when the tracker still shows this issue as ours to hand back. An
|
|
5099
4979
|
// issue that is closed, or has no state label, was resolved by another route
|
|
5100
4980
|
// and requeueing it would dispatch work nobody asked for.
|
|
@@ -5597,6 +5477,8 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
5597
5477
|
const { brief, releaseGrants } = orchestratorStandingOrders(project);
|
|
5598
5478
|
let orchestrator: OrchestratorHandle | undefined;
|
|
5599
5479
|
let orchestratorVerbs: VerbListener | undefined;
|
|
5480
|
+
/** First start-failure cause, surfaced by the orchestrator-down incident (#288). */
|
|
5481
|
+
let orchestratorStartError: string | undefined;
|
|
5600
5482
|
if (project.escalation.orchestrator === "external") {
|
|
5601
5483
|
projectLog(
|
|
5602
5484
|
"orchestrator: external — tier-1 escalations post as issue comments for the external session's drain duty",
|
|
@@ -5636,9 +5518,10 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
5636
5518
|
const transcript = orchestrator.sessionFile();
|
|
5637
5519
|
projectLog(`orchestrator session ready${transcript === undefined ? "" : ` · ${transcript}`}`);
|
|
5638
5520
|
} catch (err) {
|
|
5521
|
+
orchestratorStartError = errText(err);
|
|
5639
5522
|
projectLog(
|
|
5640
5523
|
"WARNING: orchestrator session failed to start; tier-1 escalations will fall back to issue " +
|
|
5641
|
-
`comments: ${
|
|
5524
|
+
`comments: ${orchestratorStartError}`,
|
|
5642
5525
|
);
|
|
5643
5526
|
await orchestratorVerbs?.close();
|
|
5644
5527
|
orchestratorVerbs = undefined;
|
|
@@ -5655,6 +5538,17 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
5655
5538
|
orchestrator,
|
|
5656
5539
|
Date.now,
|
|
5657
5540
|
deliveryPolicyValid,
|
|
5541
|
+
(e) => {
|
|
5542
|
+
// Every tier-1 escalation that lands on the issue-comment fallback was
|
|
5543
|
+
// diverted from the orchestrator. Count it durably on the open incident
|
|
5544
|
+
// (a no-op when none is open), so the page and status name how much
|
|
5545
|
+
// the outage diverted (#288).
|
|
5546
|
+
store.bumpOrchestratorDiverted(project.name, 1);
|
|
5547
|
+
projectLog(
|
|
5548
|
+
`orchestrator: tier-1 escalation on ${escalationIssueRef(e.issue)} diverted to issue comments ` +
|
|
5549
|
+
`while the orchestrator was down`,
|
|
5550
|
+
);
|
|
5551
|
+
},
|
|
5658
5552
|
);
|
|
5659
5553
|
const outbox = createReportOutbox({
|
|
5660
5554
|
project: currentProject,
|
|
@@ -5685,13 +5579,27 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
5685
5579
|
workerControls,
|
|
5686
5580
|
integrity,
|
|
5687
5581
|
stall: { paged: false },
|
|
5582
|
+
...(orchestrator === undefined ? {} : { orchestrator }),
|
|
5688
5583
|
cleanup: { next: 0 },
|
|
5689
5584
|
probeCriticalBase: (repo, markers, branch) =>
|
|
5690
5585
|
probeCriticalBase(project, repo, branch, markers),
|
|
5586
|
+
probeWorktreeLane: (input) => probeRunLane(input),
|
|
5691
5587
|
...(verbPeerReader === undefined ? {} : { verbPeerReader }),
|
|
5692
5588
|
verbActions,
|
|
5693
5589
|
};
|
|
5694
5590
|
runtimeDeps = d;
|
|
5591
|
+
// Startup reconciliation: close an incident carried over from a previous
|
|
5592
|
+
// process when the orchestrator is up (one recovery notice), or open one
|
|
5593
|
+
// when it failed to start (one down page). A daemon restarted while still
|
|
5594
|
+
// down rediscovers the open incident and does not re-page it.
|
|
5595
|
+
await reconcileOrchestratorDown({
|
|
5596
|
+
project,
|
|
5597
|
+
store,
|
|
5598
|
+
orchestrator,
|
|
5599
|
+
escalate: (event) => d.escalate(event),
|
|
5600
|
+
...(orchestratorStartError === undefined ? {} : { startCause: orchestratorStartError }),
|
|
5601
|
+
log: projectLog,
|
|
5602
|
+
});
|
|
5695
5603
|
runtimes.push({
|
|
5696
5604
|
d,
|
|
5697
5605
|
outbox,
|
|
@@ -5771,7 +5679,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
5771
5679
|
// when it resumes and cannot create the work this shutdown is about to
|
|
5772
5680
|
// wait for (#374).
|
|
5773
5681
|
drain.draining = true;
|
|
5774
|
-
log("
|
|
5682
|
+
log("shutdown requested — draining dispatch; live worker sessions are not waited for");
|
|
5775
5683
|
pace.requestWake();
|
|
5776
5684
|
};
|
|
5777
5685
|
process.on("SIGINT", stop);
|