omp-conductor 0.19.6 → 0.20.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 +27 -2
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/arm-challenge.ts +204 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +253 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +113 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +412 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +736 -0
- package/src/daemon/settle-pass.ts +589 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7832
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +24 -3
- package/src/doctor.ts +17 -12
- package/src/escalate.ts +39 -21
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +1218 -304
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +428 -1681
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +72 -6
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +158 -7
- package/src/store.ts +646 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +435 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +384 -12
- package/src/wake.ts +19 -2
- package/src/worker.ts +456 -1
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One tick: the periodic pass that sequences every other pass in this
|
|
3
|
+
* directory.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here decides anything on its own. `tick` reads the config afresh at
|
|
6
|
+
* its own boundary, builds the one `Deps` the pass will use, and then calls the
|
|
7
|
+
* modules beside it in the order their side effects require — supervision before
|
|
8
|
+
* dispatch so a wedged orchestrator is known about before work is claimed,
|
|
9
|
+
* settlement sweeps after dispatch so a run that just ended is swept on the same
|
|
10
|
+
* pass, grooming last so a queue refilled by promotion is picked up by the next
|
|
11
|
+
* tick rather than mid-pass. That ordering is the only thing this module owns,
|
|
12
|
+
* and keeping it a call sequence rather than a set of registered hooks is what
|
|
13
|
+
* makes it readable in one screen.
|
|
14
|
+
*
|
|
15
|
+
* The three probes that ride along — install surfaces, upgrade verification,
|
|
16
|
+
* the DB snapshot cadence — live here rather than with their subjects because
|
|
17
|
+
* they have no subject beyond "once in a while", and their schedule is the
|
|
18
|
+
* tick's business.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { admitCandidates, startOfToday, type AdmissionHold } from "../admission.ts";
|
|
22
|
+
import { dbBackupDirFor, findProject, loadConfig, resolveCaps, stateDir } from "../config.ts";
|
|
23
|
+
import { evaluateDecisionConditions, probeNpmVersion, probeRateLimitReset } from "../decisions.ts";
|
|
24
|
+
import { dbSnapshotDue, dbSnapshotMarkerKey, localDayKey } from "../digest-schedule.ts";
|
|
25
|
+
import { runDoctor } from "../doctor.ts";
|
|
26
|
+
import { fleetLayers, herdrPaneOmpStarts, resolveHerdrSession } from "../fleet.ts";
|
|
27
|
+
import { projectLabels } from "../label-projection.ts";
|
|
28
|
+
import { healthCheck } from "../lifecycle.ts";
|
|
29
|
+
import { errText, log, safeEscalate } from "../log.ts";
|
|
30
|
+
import { DEFAULT_MINING_WINDOW_MS, mineSignals, type MinedSignal } from "../mining.ts";
|
|
31
|
+
import { reconcileOrchestratorDown } from "../orchestrator-down.ts";
|
|
32
|
+
import { clearPauseIfUnchanged, isPaused, pauseInstance, pauseInstanceAt, pausedPath, setPaused } from "../pause.ts";
|
|
33
|
+
import { enqueueAvailableHeldNotices } from "../reports.ts";
|
|
34
|
+
import { effectiveLabels, isEligible, route } from "../routing.ts";
|
|
35
|
+
import { adoptSalvagedPrs, classifyAndRecover, reconcileGroomingClosures, reconcileStaleLabels, settlePushedGreen } from "../settlement.ts";
|
|
36
|
+
import { ACTIVE_STATES, DB_SNAPSHOT_RETENTION, dbPath, pruneDbSnapshots, snapshotDb } from "../store.ts";
|
|
37
|
+
import { DEFAULT_REPORT_POLICY, type ConductorConfig, type IssueSnapshot, type ReportingPolicy, type Store } from "../types.ts";
|
|
38
|
+
import { appendJournal, launchTransientUnit, readUpgradeJournal } from "../upgrade-journal.ts";
|
|
39
|
+
import { processStartTimeMs, runCommand, verifyPendingUpgrade, type UpgradeVerifyDeps } from "../upgrade-verify.ts";
|
|
40
|
+
import { inspectSurfaces } from "../upgrade.ts";
|
|
41
|
+
import { writeAdmissionAck } from "./ack.ts";
|
|
42
|
+
import { dispatchAdmissions, summarizeDispatch, summarizeHeldPass, type WorkerPool } from "./admission-pass.ts";
|
|
43
|
+
import { NO_ISSUE, PACKAGE_SRC_DIR, UNROUTABLE_TEXT, type Deps } from "./deps.ts";
|
|
44
|
+
import { handleIssue } from "./dispatch.ts";
|
|
45
|
+
import { cancelDrain, consumeDrain } from "./drain.ts";
|
|
46
|
+
import { dispatchToSpecGrooming } from "./groom-pass.ts";
|
|
47
|
+
import { INTEGRITY_SAMPLE, checkIntegrity, markPaged, packageManifest } from "./integrity.ts";
|
|
48
|
+
import { reconcilePanes } from "./panes.ts";
|
|
49
|
+
import { applyAdjudicationDispositions, dispatchReviewAdjudications, dispatchReviewRevisions } from "./review.ts";
|
|
50
|
+
import { cleanupRetainedRuns, watchBaseHealth, watchMergedBase } from "./settle-pass.ts";
|
|
51
|
+
import { wakeOrchestratorForMetConditions, watchOrchestrator } from "./supervision.ts";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Record what this host has installed, once per dispatch pass (#919).
|
|
55
|
+
*
|
|
56
|
+
* Here rather than at render time, and this is the whole design of the slice:
|
|
57
|
+
* the read costs three subprocesses, and calling it from the tick took the
|
|
58
|
+
* tick's own suite from 8.4s to 83.4s while spawning three children every
|
|
59
|
+
* fifteen minutes to answer a question that changes only when someone installs
|
|
60
|
+
* something. The dispatch pass is already async and already spawns `gh`, so one
|
|
61
|
+
* read per pass is free by comparison, and every cheap surface then reads a row.
|
|
62
|
+
*
|
|
63
|
+
* Advisory throughout: a probe that throws (no `herdr` on PATH, a `$PATH`
|
|
64
|
+
* without `omp`) leaves the previous observation in place and logs. A stale
|
|
65
|
+
* observation is still the truth about the last time anyone could look, and
|
|
66
|
+
* losing a dispatch pass over a version string would be absurd.
|
|
67
|
+
*/
|
|
68
|
+
export async function recordInstallSurfaces(d: Deps): Promise<void> {
|
|
69
|
+
if (d.probeInstallSurfaces === undefined) return;
|
|
70
|
+
try {
|
|
71
|
+
const surfaces = await d.probeInstallSurfaces();
|
|
72
|
+
// The telegram peer's versions ride along on the same pass (#961). Read
|
|
73
|
+
// here rather than in `status` because `status` runs every tick and this
|
|
74
|
+
// touches the npm registry: one periodic read, rendered from the store as
|
|
75
|
+
// often as anyone looks. A read that throws leaves the fields absent, which
|
|
76
|
+
// renders as unverified — never as agreement.
|
|
77
|
+
const telegram =
|
|
78
|
+
d.probeTelegramFreshness === undefined ? undefined : await d.probeTelegramFreshness();
|
|
79
|
+
const tgInstalled = telegram?.surfaces.installed;
|
|
80
|
+
const tgDaemon = telegram?.surfaces.daemon;
|
|
81
|
+
const tgPublished = telegram?.surfaces.published;
|
|
82
|
+
d.store.recordInstallSurfaces({
|
|
83
|
+
at: Date.now(),
|
|
84
|
+
cliVersion: surfaces.cliVersion,
|
|
85
|
+
...(surfaces.ompVersion === undefined ? {} : { ompVersion: surfaces.ompVersion }),
|
|
86
|
+
...(surfaces.herdrSource === undefined ? {} : { herdrSource: surfaces.herdrSource }),
|
|
87
|
+
...(tgInstalled?.kind === "version" ? { telegramInstalled: tgInstalled.version } : {}),
|
|
88
|
+
...(tgDaemon?.kind === "version" ? { telegramDaemon: tgDaemon.version } : {}),
|
|
89
|
+
...(tgPublished?.kind === "version" ? { telegramPublished: tgPublished.version } : {}),
|
|
90
|
+
});
|
|
91
|
+
} catch (err) {
|
|
92
|
+
log(`install-surface read skipped: ${errText(err)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ==================================================== hourly signal mining
|
|
97
|
+
// (Phase 4.) The fleet already records every fact worth grooming — settlement
|
|
98
|
+
// flags that name a weakened test, failure classes that keep recurring — and
|
|
99
|
+
// then leaves them in `runs`, where only somebody reading stats would ever meet
|
|
100
|
+
// them. Mining turns them into intake items, which is the one queue grooming
|
|
101
|
+
// already reads.
|
|
102
|
+
//
|
|
103
|
+
// Three properties make this safe to run unattended:
|
|
104
|
+
//
|
|
105
|
+
// - **Never auto-queued.** A mined item is `pending` intake, exactly like an
|
|
106
|
+
// idea an operator typed. It reaches a worker only after grooming specs it
|
|
107
|
+
// and the ready gate passes it. Mining proposes; it does not decide.
|
|
108
|
+
// - **Idempotent by construction.** Each signal carries a stable `source`, and
|
|
109
|
+
// Lane A derives the intake id from `project + source` with INSERT OR IGNORE.
|
|
110
|
+
// An unresolved signal therefore does not multiply hourly — the tenth pass
|
|
111
|
+
// over the same weakened test files nothing.
|
|
112
|
+
// - **Bounded to what the store already holds.** `mineSignals` is pure and
|
|
113
|
+
// takes pre-read rows; no network, no new table, no second clock.
|
|
114
|
+
|
|
115
|
+
// The window is `mining.ts`'s own `DEFAULT_MINING_WINDOW_MS`, imported rather
|
|
116
|
+
// than restated: the miner's occurrence thresholds are calibrated against it, so
|
|
117
|
+
// a second copy here would be a second answer to "what counts as recent".
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Run the mining pass at most once per wall-clock hour (Phase 4).
|
|
121
|
+
*
|
|
122
|
+
* The cadence rides the five-minute tick rather than adding a timer: a second
|
|
123
|
+
* scheduler for a pass whose whole job is reading rows it already has would be
|
|
124
|
+
* one more thing to reason about at shutdown. The hour is a bucket key in the
|
|
125
|
+
* `notifications` ledger — the same once-per-key mechanism the daily db snapshot
|
|
126
|
+
* uses — which makes it durable across restarts, where an in-memory `lastRun`
|
|
127
|
+
* would let a flapping daemon mine on every boot.
|
|
128
|
+
*
|
|
129
|
+
* The key is claimed BEFORE the work, so the bound is "one attempt per hour".
|
|
130
|
+
* That is the right way round: a miner that throws would otherwise retry every
|
|
131
|
+
* five minutes and log every time, and losing one hour costs nothing at all —
|
|
132
|
+
* the signals are derived from stored rows, so next hour sees the same ones.
|
|
133
|
+
*/
|
|
134
|
+
export function mineIntakeSignals(d: Pick<Deps, "project" | "store">, now = Date.now()): void {
|
|
135
|
+
// Hour bucket, not a rolling 60 minutes: a pass at :59 and one at :01 are two
|
|
136
|
+
// buckets and both run. That is the dbSnapshot cadence's own shape, and it is
|
|
137
|
+
// harmless here precisely because filing is idempotent.
|
|
138
|
+
const key = `signal-mining:${d.project.name}:${new Date(now).toISOString().slice(0, 13)}`;
|
|
139
|
+
if (d.store.wasNotified(key)) return;
|
|
140
|
+
d.store.markNotified(key);
|
|
141
|
+
|
|
142
|
+
const since = now - DEFAULT_MINING_WINDOW_MS;
|
|
143
|
+
let signals: readonly MinedSignal[];
|
|
144
|
+
try {
|
|
145
|
+
signals = mineSignals({
|
|
146
|
+
project: d.project.name,
|
|
147
|
+
now,
|
|
148
|
+
runs: d.store.statsRuns(d.project.name, since),
|
|
149
|
+
windowMs: DEFAULT_MINING_WINDOW_MS,
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
log(`signal mining skipped: ${errText(err)}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (signals.length === 0) return;
|
|
156
|
+
|
|
157
|
+
const filed: string[] = [];
|
|
158
|
+
let known = 0;
|
|
159
|
+
let dismissed = 0;
|
|
160
|
+
for (const signal of signals) {
|
|
161
|
+
try {
|
|
162
|
+
const item = d.store.recordIntake({
|
|
163
|
+
project: d.project.name,
|
|
164
|
+
text: signal.text,
|
|
165
|
+
at: now,
|
|
166
|
+
source: signal.source,
|
|
167
|
+
});
|
|
168
|
+
// The store returns the row that is NOW in the table, so a `createdAt`
|
|
169
|
+
// older than this call proves the signal was already filed — the ordinary
|
|
170
|
+
// case for anything not yet groomed, and it must read as "already known"
|
|
171
|
+
// rather than as a tenth duplicate.
|
|
172
|
+
if (item.createdAt === now) filed.push(signal.source);
|
|
173
|
+
// A signal the operator dismissed stays dismissed: INSERT OR IGNORE cannot
|
|
174
|
+
// resurrect it, and counting it as merely "known" would hide the fact that
|
|
175
|
+
// the fleet keeps producing a signal somebody has already judged
|
|
176
|
+
// uninteresting — which is itself worth seeing in the journal.
|
|
177
|
+
else if (item.state !== "pending") dismissed += 1;
|
|
178
|
+
else known += 1;
|
|
179
|
+
} catch (err) {
|
|
180
|
+
log(`intake for ${signal.source} not filed: ${errText(err)}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
log(
|
|
184
|
+
`signal mining: ${filed.length} filed, ${known} already pending, ${dismissed} previously dismissed ` +
|
|
185
|
+
`(${signals.length} signal(s) over ${Math.round(DEFAULT_MINING_WINDOW_MS / 86_400_000)}d)` +
|
|
186
|
+
(filed.length === 0 ? "" : ` — ${filed.join(", ")}`),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The first-tick verification for a fleet-initiated upgrade (#486).
|
|
192
|
+
*
|
|
193
|
+
* The detached install unit journals its progress and stops, leaving dispatch
|
|
194
|
+
* paused; whichever daemon comes back after the restart runs this on its
|
|
195
|
+
* first tick — above every gate that a pause or an integrity mismatch could
|
|
196
|
+
* close, because the install's own pause is the thing under verification.
|
|
197
|
+
* A day with no journal costs one file read; only a pending request pays for
|
|
198
|
+
* the version/health/ticks/pane/doctor checks and the outbox rows.
|
|
199
|
+
*/
|
|
200
|
+
export function upgradeVerifyDepsFor(d: Pick<Deps, "project" | "store">): UpgradeVerifyDeps {
|
|
201
|
+
return {
|
|
202
|
+
projectName: d.project.name,
|
|
203
|
+
journal: (entry) => appendJournal(stateDir(), entry),
|
|
204
|
+
run: runCommand,
|
|
205
|
+
layers: (project) => fleetLayers(project),
|
|
206
|
+
health: healthCheck,
|
|
207
|
+
doctor: (name) => runDoctor(name),
|
|
208
|
+
surfaces: () => inspectSurfaces({ run: runCommand, log, env: process.env }),
|
|
209
|
+
enqueue: (draft) => {
|
|
210
|
+
try {
|
|
211
|
+
d.store.enqueueReport(draft);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
log(`upgrade verification report could not be queued: ${errText(err)}`);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
pause: pauseInstance,
|
|
217
|
+
resume: (project) => setPaused(false, undefined, project),
|
|
218
|
+
holdGlobal: () => {
|
|
219
|
+
if (pauseInstance() === undefined) {
|
|
220
|
+
setPaused(true, { source: "upgrade-recovery", reason: "upgrade recovery verification required" });
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
releaseGlobalRecovery: (expectedSince) => {
|
|
224
|
+
const pause = pauseInstance();
|
|
225
|
+
if (
|
|
226
|
+
pause?.source === "upgrade-recovery" &&
|
|
227
|
+
expectedSince !== undefined &&
|
|
228
|
+
pause.since === expectedSince
|
|
229
|
+
) {
|
|
230
|
+
setPaused(false);
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
launchRollback: async (version) => {
|
|
234
|
+
const launched = await launchTransientUnit(
|
|
235
|
+
(argv) => runCommand(argv[0]!, argv.slice(1)),
|
|
236
|
+
process.env,
|
|
237
|
+
{ kind: "rollback", version },
|
|
238
|
+
);
|
|
239
|
+
return launched.ok === true ? { ok: true, unit: launched.unit } : { ok: false, stderr: launched.stderr };
|
|
240
|
+
},
|
|
241
|
+
herdrSession: resolveHerdrSession(process.env),
|
|
242
|
+
// The durable pane probe for the post-restart verifier (#832): every omp
|
|
243
|
+
// process the fleet pane currently claims by herdr, resolved to its start
|
|
244
|
+
// time — the evidence an external (pane-owned) orchestrator actually
|
|
245
|
+
// restarted after the install began, through the same shared probe as the
|
|
246
|
+
// in-process upgrade.
|
|
247
|
+
probePaneOmp: (session) => herdrPaneOmpStarts(runCommand, session, processStartTimeMs),
|
|
248
|
+
log,
|
|
249
|
+
now: () => Date.now(),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function verifyPendingUpgradeTick(
|
|
254
|
+
d: Deps,
|
|
255
|
+
run = verifyPendingUpgrade,
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
// Cheap pre-test: no journal, no pending request, nothing to touch.
|
|
258
|
+
if (readUpgradeJournal(stateDir()).length === 0) return;
|
|
259
|
+
const verifyDeps = upgradeVerifyDepsFor(d);
|
|
260
|
+
try {
|
|
261
|
+
const outcome = await run(verifyDeps);
|
|
262
|
+
if (
|
|
263
|
+
outcome.handled !== "none" &&
|
|
264
|
+
outcome.handled !== "already-closed" &&
|
|
265
|
+
outcome.handled !== "recovery-owed"
|
|
266
|
+
) {
|
|
267
|
+
log(
|
|
268
|
+
`upgrade journal handled: ${outcome.handled}` +
|
|
269
|
+
`${outcome.version === undefined ? "" : ` for omp-conductor@${outcome.version}`}` +
|
|
270
|
+
`${outcome.detail === undefined ? "" : ` — ${outcome.detail}`}`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
} catch (err) {
|
|
274
|
+
// A torn journal must not take the tick down: the operator still reads it
|
|
275
|
+
// through `status`, and the next tick retries the same pass.
|
|
276
|
+
log(`upgrade verification pass failed: ${errText(err)}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The daemon's conductor.db snapshot cadence (#289) — the ledger's only
|
|
282
|
+
* durable copy, taken on the digest-aligned {@link dbSnapshotDue} window:
|
|
283
|
+
* once per local day, at/after the digest's configured `at` when it has one.
|
|
284
|
+
*
|
|
285
|
+
* Host-global, like the store itself: one snapshot per day for every project,
|
|
286
|
+
* so whichever project's daemon wins the day first publishes it and the
|
|
287
|
+
* others no-op on the same durable marker. The marker is written through the
|
|
288
|
+
* store's existing notification ledger (a one-row idempotence guard, the same
|
|
289
|
+
* primitive escalations use not to act twice) *after* the snapshot is
|
|
290
|
+
* published — a crash between publish and mark re-snapshots the next tick
|
|
291
|
+
* instead of skipping the day, and a restored store that predates today's
|
|
292
|
+
* marker takes a fresh snapshot on the next tick.
|
|
293
|
+
*
|
|
294
|
+
* Returns true when a snapshot was published. A store that does not exist
|
|
295
|
+
* yet is a silent no-op: there is nothing to back up, and `doctor`'s
|
|
296
|
+
* `db-backup` probe already treats that as a pass.
|
|
297
|
+
*/
|
|
298
|
+
export function runDbSnapshotCadence(args: {
|
|
299
|
+
digestPolicy: ReportingPolicy["digest"];
|
|
300
|
+
store: Pick<Store, "wasNotified" | "markNotified">;
|
|
301
|
+
source: string;
|
|
302
|
+
backupDir: string;
|
|
303
|
+
now?: number;
|
|
304
|
+
keep?: number;
|
|
305
|
+
log?: (line: string) => void;
|
|
306
|
+
}): boolean {
|
|
307
|
+
const { digestPolicy, store, source, backupDir } = args;
|
|
308
|
+
// No store yet (first boot before any run persisted) → nothing to back up;
|
|
309
|
+
// `doctor`'s `db-backup` probe already treats that as a pass.
|
|
310
|
+
if (!existsSync(source)) return false;
|
|
311
|
+
const now = args.now ?? Date.now();
|
|
312
|
+
const today = localDayKey(now, digestPolicy.timezone);
|
|
313
|
+
const alreadyToday = store.wasNotified(dbSnapshotMarkerKey(today));
|
|
314
|
+
if (!dbSnapshotDue({ digest: digestPolicy }, alreadyToday ? today : undefined, now)) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
const published = snapshotDb(source, backupDir, now);
|
|
318
|
+
(args.log ?? log)(`conductor.db snapshot published: ${published}`);
|
|
319
|
+
store.markNotified(dbSnapshotMarkerKey(today));
|
|
320
|
+
try {
|
|
321
|
+
const removed = pruneDbSnapshots(backupDir, args.keep ?? DB_SNAPSHOT_RETENTION);
|
|
322
|
+
if (removed > 0) {
|
|
323
|
+
(args.log ?? log)(`db snapshot retention pruned ${removed} file(s) beyond the retained ${args.keep ?? DB_SNAPSHOT_RETENTION}`);
|
|
324
|
+
}
|
|
325
|
+
} catch (err) {
|
|
326
|
+
// The snapshot is already published and marked; an over-bound directory
|
|
327
|
+
// costs disk until the next due day retries the prune, never the backup.
|
|
328
|
+
(args.log ?? log)(`db snapshot retention prune failed: ${errText(err)}`);
|
|
329
|
+
}
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
334
|
+
// A config edit takes effect on the next tick, not the next daemon restart
|
|
335
|
+
// (#170). Re-resolve the project and its caps at the tick boundary so a tick
|
|
336
|
+
// and every run it admits see one consistent snapshot; a failed read keeps
|
|
337
|
+
// the boot values rather than wedging the tick, and the next tick tries
|
|
338
|
+
// again. The same reloaded config feeds the db-snapshot backup dir: when it
|
|
339
|
+
// is unreadable the cadence falls back to the state-root default, matching
|
|
340
|
+
// `doctor`'s probe of an absent field.
|
|
341
|
+
let reloadedConfig: ConductorConfig | undefined;
|
|
342
|
+
try {
|
|
343
|
+
const cfg = loadConfig();
|
|
344
|
+
reloadedConfig = cfg;
|
|
345
|
+
const fresh = findProject(cfg, d.project.name);
|
|
346
|
+
const freshCaps = resolveCaps(fresh, cfg.defaults);
|
|
347
|
+
if (JSON.stringify(fresh) !== JSON.stringify(d.project) || JSON.stringify(freshCaps) !== JSON.stringify(d.caps)) {
|
|
348
|
+
log(`config reloaded: project ${d.project.name} changed on disk — applying from this tick`);
|
|
349
|
+
}
|
|
350
|
+
d.project = fresh;
|
|
351
|
+
d.caps = freshCaps;
|
|
352
|
+
d.host = cfg.host;
|
|
353
|
+
d.deliveryPolicyValid = true;
|
|
354
|
+
} catch (err) {
|
|
355
|
+
log(`config reload failed (${errText(err)}) — retaining boot values but blocking autonomous delivery`);
|
|
356
|
+
d.deliveryPolicyValid = false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The fleet-installs-itself transaction (#486): the detached install unit
|
|
360
|
+
// paused dispatch and stopped; the first tick after the restart verifies
|
|
361
|
+
// against its journal and closes or rolls back. Above the pause gate on
|
|
362
|
+
// purpose — the install's own pause is exactly the state being verified.
|
|
363
|
+
try {
|
|
364
|
+
await (d.upgradeVerifier ?? verifyPendingUpgradeTick)(d);
|
|
365
|
+
} catch (err) {
|
|
366
|
+
log(`upgrade journal pass failed: ${errText(err)}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Availability-held notices are already durable. Once the freshly reloaded
|
|
370
|
+
// policy opens (or newly allows their category), atomically hand a bounded
|
|
371
|
+
// batch to the report outbox. Pauses do not suppress delivery.
|
|
372
|
+
if (d.deliveryPolicyValid !== false) {
|
|
373
|
+
try {
|
|
374
|
+
const catchUp = enqueueAvailableHeldNotices(d.project, d.store, Date.now());
|
|
375
|
+
if (catchUp !== undefined && !catchUp.deduped) {
|
|
376
|
+
log(`availability catch-up ${catchUp.report.id} queued for ${d.project.name}`);
|
|
377
|
+
}
|
|
378
|
+
} catch (err) {
|
|
379
|
+
// The daily digest may have claimed the same rows from another process
|
|
380
|
+
// between selection and association. Either way the ledger still owns them.
|
|
381
|
+
log(`availability catch-up handoff deferred (${errText(err)}) — retrying next tick`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Before the pause check, deliberately. This one is not about dispatch: the
|
|
386
|
+
// orchestrator is a different process, and it can be wedged while this fleet
|
|
387
|
+
// is paused — which is exactly the state the reference fleet was in when the
|
|
388
|
+
// failure happened. A pause silences claiming, not the operator's right to
|
|
389
|
+
// know their supervising session stopped reading its queue.
|
|
390
|
+
await watchOrchestrator(d);
|
|
391
|
+
// The down incident is reconciled the same place and for the same reason: a
|
|
392
|
+
// session that has actually died is as much the operator's concern as one
|
|
393
|
+
// that is wedged, and restarting it is the daemon's restart either way. This
|
|
394
|
+
// is what turns a crashed orchestrator into one page ("down since <t>") plus
|
|
395
|
+
// a diverting count, instead of a warning only in daemon.log.
|
|
396
|
+
await reconcileOrchestratorDown({
|
|
397
|
+
project: d.project,
|
|
398
|
+
store: d.store,
|
|
399
|
+
orchestrator: d.orchestrator,
|
|
400
|
+
escalate: (event) => d.escalate(event),
|
|
401
|
+
log,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Beside the two orchestrator watches, and above the pause gate, for the same
|
|
405
|
+
// reason they are: mining reads rows this store already holds and files intake
|
|
406
|
+
// for a human to groom. It claims nothing, so a paused fleet has no cause to
|
|
407
|
+
// stop noticing that a test was weakened three times this week. Its own hour
|
|
408
|
+
// bucket bounds it; this call site just supplies the clock.
|
|
409
|
+
try {
|
|
410
|
+
mineIntakeSignals(d);
|
|
411
|
+
} catch (err) {
|
|
412
|
+
log(`signal mining pass failed: ${errText(err)}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Settlement is maintenance, not dispatch. Run it before every gate that can
|
|
416
|
+
// stop claiming — pause, integrity, spend, and capacity — so status converges
|
|
417
|
+
// while the fleet is parked or workers are still active. Resident workers run
|
|
418
|
+
// through the pool without blocking this five-minute tick. The return value
|
|
419
|
+
// is what this pass settled; a held pass records it so the paused state reads
|
|
420
|
+
// as work done, not just a clock that moved (#497).
|
|
421
|
+
let settled = await settlePushedGreen(d);
|
|
422
|
+
try {
|
|
423
|
+
await watchMergedBase(d);
|
|
424
|
+
} catch (err) {
|
|
425
|
+
log(`base-branch check sweep failed: ${errText(err)}`);
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
await watchBaseHealth(d);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
log(`current base-health sweep failed: ${errText(err)}`);
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
await adoptSalvagedPrs(d);
|
|
434
|
+
} catch (err) {
|
|
435
|
+
log(`salvaged PR adoption sweep failed: ${errText(err)}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// The conductor.db snapshot cadence (#289) is durability, not dispatch:
|
|
439
|
+
// the verb ledger, the decision rows and the run history have no other
|
|
440
|
+
// copy, so they are snapshotted once per day on the digest-aligned window.
|
|
441
|
+
// Above the pause gate on purpose — a parked fleet still accumulates ledger
|
|
442
|
+
// rows and still deserves a backup — and a failure costs the day's snapshot
|
|
443
|
+
// placeholder, never the tick: failed cadence steps are logged and retried
|
|
444
|
+
// by the five-minute loop, exactly like the sweeps above it.
|
|
445
|
+
try {
|
|
446
|
+
runDbSnapshotCadence({
|
|
447
|
+
digestPolicy: (d.project.reporting ?? DEFAULT_REPORT_POLICY).digest,
|
|
448
|
+
store: d.store,
|
|
449
|
+
source: dbPath(),
|
|
450
|
+
backupDir: dbBackupDirFor(reloadedConfig),
|
|
451
|
+
log,
|
|
452
|
+
});
|
|
453
|
+
} catch (err) {
|
|
454
|
+
log(`db snapshot cadence failed: ${errText(err)}`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Immediately after settlement and before any routing, so a class is on the
|
|
458
|
+
// row before the next dispatch decision reads its budgets (#132). Above the
|
|
459
|
+
// pause gate deliberately: classification and label reconciliation are
|
|
460
|
+
// maintenance, and the four phantom `agent:failed` issues this exists to clear
|
|
461
|
+
// are exactly as misleading on a parked fleet as on a busy one.
|
|
462
|
+
//
|
|
463
|
+
// Each is guarded on its own: a tracker that fails mid-classification must not
|
|
464
|
+
// stop the label reconcile, and neither may stop the tick.
|
|
465
|
+
try {
|
|
466
|
+
settled += await classifyAndRecover({
|
|
467
|
+
project: d.project,
|
|
468
|
+
caps: d.caps,
|
|
469
|
+
tracker: d.tracker,
|
|
470
|
+
store: d.store,
|
|
471
|
+
escalate: (e) => d.escalate(e),
|
|
472
|
+
isPaused,
|
|
473
|
+
setPaused,
|
|
474
|
+
});
|
|
475
|
+
} catch (err) {
|
|
476
|
+
log(`classification sweep failed: ${errText(err)}`);
|
|
477
|
+
}
|
|
478
|
+
try {
|
|
479
|
+
await reconcileStaleLabels(d);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
log(`label reconcile failed: ${errText(err)}`);
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
// Same phase, same reason as the label reconcile above: a durable row the
|
|
485
|
+
// tracker has moved past (#964). A separate call because that one iterates
|
|
486
|
+
// labelled issues, and a promotable issue carries no label.
|
|
487
|
+
await reconcileGroomingClosures(d);
|
|
488
|
+
} catch (err) {
|
|
489
|
+
log(`grooming closure reconcile failed: ${errText(err)}`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Drain the label projection outbox (#201). The maintenance phases above may
|
|
493
|
+
// have enqueued ops (settlement releases, recovery requeues, reconciles);
|
|
494
|
+
// each due op is applied now — or deferred with backoff for the next tick —
|
|
495
|
+
// before the queue is read, so dispatch sees labels converging on what the
|
|
496
|
+
// store decided. A refusing tracker defers ops instead of taking the tick
|
|
497
|
+
// down.
|
|
498
|
+
try {
|
|
499
|
+
await projectLabels(d.store, d.tracker, d.project);
|
|
500
|
+
} catch (err) {
|
|
501
|
+
log(`label projection failed: ${errText(err)}`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Ledger maintenance, above the pause gate for the same reason the stall watch
|
|
505
|
+
// is: a paused fleet still owes its operator the questions it asked, and a
|
|
506
|
+
// condition that came true while dispatch was parked is exactly the thing the
|
|
507
|
+
// orchestrator has to see promptly (#329).
|
|
508
|
+
//
|
|
509
|
+
// Expiry is synchronous (one UPDATE); condition evaluation is fire-and-forget
|
|
510
|
+
// because it shells out to `gh` and `npm`, and a registry that hangs must cost
|
|
511
|
+
// one unevaluated condition rather than the tick. A false→true transition
|
|
512
|
+
// writes the same immediate-tick poke recover uses so the heartbeat does not
|
|
513
|
+
// wait a full interval; repeated sweeps while the condition stays true never
|
|
514
|
+
// re-enter `met` (store mark is idempotent).
|
|
515
|
+
for (const expired of d.store.expireDueDecisions(d.project.name, Date.now())) {
|
|
516
|
+
log(`decision ${expired.id} expired unanswered after seven days: ${expired.question}`);
|
|
517
|
+
}
|
|
518
|
+
void evaluateDecisionConditions(
|
|
519
|
+
d.store,
|
|
520
|
+
d.project.name,
|
|
521
|
+
d.tracker,
|
|
522
|
+
{ npm: probeNpmVersion, rateLimit: probeRateLimitReset },
|
|
523
|
+
Date.now,
|
|
524
|
+
)
|
|
525
|
+
.then((met) => {
|
|
526
|
+
wakeOrchestratorForMetConditions(d.project.name, met);
|
|
527
|
+
})
|
|
528
|
+
.catch((err: unknown) => {
|
|
529
|
+
log(`decision condition pass failed: ${errText(err)}`);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// A spend-cap pause is self-expiring (#780). The spend gate below persists a
|
|
533
|
+
// pause once today's spend reaches the cap, but once that latch is on disk
|
|
534
|
+
// every later pass returns at the pause gate and never reaches the spend
|
|
535
|
+
// check again — so the fleet stayed stopped after the rolling window reset
|
|
536
|
+
// until an operator ran `resume`. The same measurement that closed the gate
|
|
537
|
+
// reopens it: clear a per-project spend-cap pause when the current
|
|
538
|
+
// rolling-window spend is below the cap (or no cap is configured at all —
|
|
539
|
+
// the gate that justified the pause is gone). Two guards keep the clear from
|
|
540
|
+
// ever overriding another stop: only the exact instance that was read is
|
|
541
|
+
// removed (a hold that replaced the sentinel meanwhile is untouched, review
|
|
542
|
+
// #780), and only the per-project sentinel is ever considered — the legacy
|
|
543
|
+
// global sentinel, which may carry an operator/integrity/setup hold, is
|
|
544
|
+
// never removed, so the pause gate below still observes it.
|
|
545
|
+
const spendPausePath = pausedPath(d.project.name);
|
|
546
|
+
const spendPause = pauseInstanceAt(spendPausePath);
|
|
547
|
+
if (spendPause?.source === "spend-cap") {
|
|
548
|
+
const spent = d.store.spendSince(d.project.name, startOfToday());
|
|
549
|
+
if (
|
|
550
|
+
(d.caps.dailySpendUsd === null || spent < d.caps.dailySpendUsd) &&
|
|
551
|
+
clearPauseIfUnchanged(spendPausePath, spendPause)
|
|
552
|
+
) {
|
|
553
|
+
log(
|
|
554
|
+
`spend-cap pause cleared: $${spent.toFixed(2)} ` +
|
|
555
|
+
(d.caps.dailySpendUsd === null
|
|
556
|
+
? "(no daily cap configured)"
|
|
557
|
+
: `below the $${d.caps.dailySpendUsd.toFixed(2)} daily cap`) +
|
|
558
|
+
" — dispatch resumes",
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// A paused fleet claims nothing. Checked first so pausing takes effect on the
|
|
564
|
+
// next tick without signalling the process. But the pass still ran, and the
|
|
565
|
+
// operator has to be able to see it: record it as a held pass — the work the
|
|
566
|
+
// maintenance sweeps settled above this gate, and nothing admitted below it —
|
|
567
|
+
// so `last dispatch` keeps moving while the fleet is deliberately parked. A
|
|
568
|
+
// frozen clock is then a stall report, not a hold (#497).
|
|
569
|
+
if (isPaused(d.project.name)) {
|
|
570
|
+
// The held pass is the daemon-side admission acknowledgement: the daemon
|
|
571
|
+
// has reached its admission boundary under the fence and claims nothing.
|
|
572
|
+
// Written durably so the setup barrier can prove the fence was observed
|
|
573
|
+
// (#651 review #3) — a pause cannot acknowledge itself.
|
|
574
|
+
try {
|
|
575
|
+
writeAdmissionAck(d.project.name);
|
|
576
|
+
} catch (err) {
|
|
577
|
+
log(`admission acknowledgement write failed: ${errText(err)}`);
|
|
578
|
+
}
|
|
579
|
+
d.store.recordDispatch(d.project.name, summarizeHeldPass(settled));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// The project drain is the self-expiring sibling of the pause fence (#484):
|
|
584
|
+
// the same admission boundary — settlement above it, nothing claimed below —
|
|
585
|
+
// but the intent is durable (it survives orchestrator loss) and bounded (the
|
|
586
|
+
// record carries an absolute deadline, so a crash can never strand
|
|
587
|
+
// admission). Three shapes, three behaviours:
|
|
588
|
+
// - fresh drain with live runs → a held pass, exactly like a pause;
|
|
589
|
+
// - fresh drain with nothing left to wait for → the drain is satisfied
|
|
590
|
+
// and clears itself, so a completed drain never needs a second operator
|
|
591
|
+
// action and this pass proceeds normally;
|
|
592
|
+
// - malformed record → fails closed for THIS pass (it might be a fresh
|
|
593
|
+
// fence we cannot read), and the same consume removed it, so it can
|
|
594
|
+
// never become an unbounded permanent drain.
|
|
595
|
+
const drain = consumeDrain(d.project.name);
|
|
596
|
+
if (drain.kind === "active") {
|
|
597
|
+
// Completion is the ACTIVE set, not the live-worker set: pushed-pending
|
|
598
|
+
// and pushed-green PRs still make the `runs-settled` release gate fail, so
|
|
599
|
+
// a drain that cleared while one remained would admit work on top of a
|
|
600
|
+
// batch the releases still see as unfinished (#776 review #2).
|
|
601
|
+
if (d.store.activeRuns(d.project.name).length === 0) {
|
|
602
|
+
cancelDrain(d.project.name);
|
|
603
|
+
log("project drain completed: no active runs remain — drain cleared");
|
|
604
|
+
} else {
|
|
605
|
+
d.store.recordDispatch(d.project.name, summarizeHeldPass(settled));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
} else if (drain.kind === "error") {
|
|
609
|
+
log(`project drain record invalid (${drain.problem}) — removed; this pass admits nothing`);
|
|
610
|
+
d.store.recordDispatch(d.project.name, summarizeHeldPass(settled));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const { project, caps, store } = d;
|
|
615
|
+
|
|
616
|
+
// "Nobody patches the running conductor" is a hard boundary in both briefs —
|
|
617
|
+
// which makes it prompt text, and prompt text is a request. This is the half
|
|
618
|
+
// that does not negotiate: the package that dispatched the last worker must
|
|
619
|
+
// still be the package on disk, or nothing else this tick does is
|
|
620
|
+
// attributable. A legitimate deploy never trips it, because installing a new
|
|
621
|
+
// build and restarting the unit re-records the baseline from the new files;
|
|
622
|
+
// only an edit *underneath* a live daemon diverges from it.
|
|
623
|
+
//
|
|
624
|
+
// Below the pause gate on purpose, unlike the stall watch above. The property
|
|
625
|
+
// being defended is that no work is dispatched under a package the operator
|
|
626
|
+
// did not install — and a paused fleet dispatches nothing, so nothing needs
|
|
627
|
+
// attributing yet. Tampering during a pause is not missed, only deferred: the
|
|
628
|
+
// baseline is boot's, so the first tick after `resume` compares against it and
|
|
629
|
+
// pauses again before claiming anything. Checking above the gate instead would
|
|
630
|
+
// page on every legitimate build an operator deploys into a parked fleet,
|
|
631
|
+
// which is exactly when they deploy them.
|
|
632
|
+
const integrity = checkIntegrity(d.integrity, packageManifest());
|
|
633
|
+
if (integrity.pause) {
|
|
634
|
+
const shown = integrity.diff.slice(0, INTEGRITY_SAMPLE);
|
|
635
|
+
log(
|
|
636
|
+
`ERROR: the installed conductor changed under this daemon — ${integrity.diff.length} file(s) differ ` +
|
|
637
|
+
`(${shown.join(", ")}${integrity.diff.length > shown.length ? ", …" : ""}) — pausing`,
|
|
638
|
+
);
|
|
639
|
+
setPaused(
|
|
640
|
+
true,
|
|
641
|
+
{ source: "integrity", reason: "installed package changed under the daemon" },
|
|
642
|
+
project.name,
|
|
643
|
+
);
|
|
644
|
+
if (integrity.page) {
|
|
645
|
+
const delivered = await safeEscalate(d, {
|
|
646
|
+
tier: 2,
|
|
647
|
+
category: "fleet-stopped",
|
|
648
|
+
project: project.name,
|
|
649
|
+
issue: NO_ISSUE,
|
|
650
|
+
// Dated for the same reason the spend cap is: the dedup key is the
|
|
651
|
+
// summary, and a second tamper months later must not be swallowed as a
|
|
652
|
+
// repeat of the first.
|
|
653
|
+
summary:
|
|
654
|
+
`Installed conductor changed under a running daemon on ${new Date().toISOString().slice(0, 10)}: ` +
|
|
655
|
+
`${integrity.diff.length} file(s) differ (first: ${integrity.diff[0]}) — ${project.name} is paused`,
|
|
656
|
+
detail: [
|
|
657
|
+
`Package root: ${PACKAGE_SRC_DIR}`,
|
|
658
|
+
...shown,
|
|
659
|
+
...(integrity.diff.length > shown.length ? [`… and ${integrity.diff.length - shown.length} more`] : []),
|
|
660
|
+
"",
|
|
661
|
+
"If you deployed a new build, restart the daemon — the restart re-records the baseline.",
|
|
662
|
+
"If you did not, the host edited itself while it was dispatching work: treat every run since",
|
|
663
|
+
"the last known-good restart as unattributable before resuming.",
|
|
664
|
+
"`omp-conductor resume` alone will not hold — the next tick re-pauses while the files differ.",
|
|
665
|
+
].join("\n"),
|
|
666
|
+
});
|
|
667
|
+
markPaged(d.integrity, delivered);
|
|
668
|
+
}
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Review revisions are dispatch, not admission (#677): the orchestrator's
|
|
673
|
+
// verb recorded them durably and the run row already occupies its issue, so
|
|
674
|
+
// they bypass the ready queue and the claim path entirely — this pass wakes
|
|
675
|
+
// them (bounded by the same worker slots) BEFORE the queue's capacity gate
|
|
676
|
+
// reads live runs, so a claimed revision counts against capacity exactly like
|
|
677
|
+
// the worker it is about to spawn.
|
|
678
|
+
try {
|
|
679
|
+
await dispatchReviewRevisions(d, workers);
|
|
680
|
+
} catch (err) {
|
|
681
|
+
log(`review revision dispatch failed: ${errText(err)}`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Adjudications are dispatch too (#932), and their own pass: an adjudication
|
|
685
|
+
// occupies no worker slot and no issue — it opens a fresh read-only session
|
|
686
|
+
// about a PR whose run has already settled — so it is neither bounded by the
|
|
687
|
+
// worker cap nor allowed to consume a review round. Placed after revisions so
|
|
688
|
+
// a PR with a live revision is never adjudicated in the same tick it is being
|
|
689
|
+
// corrected in; the head re-read inside the pass is the durable guard.
|
|
690
|
+
try {
|
|
691
|
+
await dispatchReviewAdjudications(d, workers);
|
|
692
|
+
} catch (err) {
|
|
693
|
+
log(`adjudication dispatch failed: ${errText(err)}`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// And what the verdicts imply (#876). Its own pass, after dispatch, because a
|
|
697
|
+
// disposition acts on a SETTLED adjudication: running it before dispatch would
|
|
698
|
+
// simply be a tick late, and running it inside dispatch would tie a tracker
|
|
699
|
+
// mutation to the launch that produced the verdict.
|
|
700
|
+
try {
|
|
701
|
+
await applyAdjudicationDispositions(d);
|
|
702
|
+
} catch (err) {
|
|
703
|
+
log(`adjudication dispositions failed: ${errText(err)}`);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// route() filters the queue through isEligible() itself, so anything already
|
|
707
|
+
// carrying a state label is gone before it gets here.
|
|
708
|
+
const ready = await d.tracker.listReady();
|
|
709
|
+
await cleanupRetainedRuns(
|
|
710
|
+
d,
|
|
711
|
+
new Set(ready.map((issue) => issue.number)),
|
|
712
|
+
d.cleanup ?? { next: 0 },
|
|
713
|
+
);
|
|
714
|
+
// Label-projection overlay (#201): an issue whose outbox ops have not
|
|
715
|
+
// reached GitHub yet is judged on what its labels *will* be. A pending
|
|
716
|
+
// state-label removal stops a stale GitHub label from blocking redispatch,
|
|
717
|
+
// and a pending queue-label removal drops the issue out of eligibility even
|
|
718
|
+
// though the label is still physically present. isEligible stays pure; the
|
|
719
|
+
// overlay happens here.
|
|
720
|
+
const effective = ready.map((issue) => {
|
|
721
|
+
const pending = store.pendingLabelOpsFor(project.name, issue.number);
|
|
722
|
+
return pending.length === 0 ? issue : { ...issue, labels: effectiveLabels(issue.labels, pending) };
|
|
723
|
+
});
|
|
724
|
+
// Post-unblock stale-list revalidation (#891). `listReady` is a
|
|
725
|
+
// label-FILTERED search, and GitHub's search index is eventually consistent:
|
|
726
|
+
// measured 2026-08-22T09:18Z, an `unblock` cleared `agent:failed` and
|
|
727
|
+
// restored the queue label, `gh issue view` returned the clean label set
|
|
728
|
+
// immediately, and the very next pass still saw the stale labels, held #889
|
|
729
|
+
// as `stale-lifecycle` and left the fleet at 0/5 with four spare slots. The
|
|
730
|
+
// next scheduled pass usually recovers — this is throughput, not correctness
|
|
731
|
+
// — but an operator-driven unblock that cannot refill the fleet has not done
|
|
732
|
+
// what it says.
|
|
733
|
+
//
|
|
734
|
+
// So a lifecycle label read from the LIST is a suspicion, not a verdict: the
|
|
735
|
+
// exact per-issue read decides. Bounded to candidates that would otherwise be
|
|
736
|
+
// dropped for a lifecycle label — never the whole queue — and further to those
|
|
737
|
+
// whose newest run is not active (a genuinely in-flight issue needs no
|
|
738
|
+
// revalidation; its own row is the authority) and that are not operator-parked
|
|
739
|
+
// (a park is a decision, not a stale label).
|
|
740
|
+
//
|
|
741
|
+
// An unreadable exact read keeps the stale labels, so the issue stays out of
|
|
742
|
+
// this pass, and is held as `issue-state-lookup-error` rather than
|
|
743
|
+
// `stale-lifecycle`: "the tracker could not say" is not evidence of a
|
|
744
|
+
// residual label, and it must not summon Duty 1 to reconcile a label nobody
|
|
745
|
+
// has read.
|
|
746
|
+
const stateLabelSet = new Set(Object.values(project.stateLabels));
|
|
747
|
+
const suspect = effective.filter(
|
|
748
|
+
(issue) =>
|
|
749
|
+
!isEligible(issue, project) &&
|
|
750
|
+
issue.labels.some((l) => stateLabelSet.has(l)) &&
|
|
751
|
+
!issue.labels.includes(project.stateLabels.backlog) &&
|
|
752
|
+
!ACTIVE_STATES.includes(store.latestRun(project.name, issue.number)?.state ?? "merged"),
|
|
753
|
+
);
|
|
754
|
+
const revalidated = new Map<number, readonly string[]>();
|
|
755
|
+
const unreadable = new Set<number>();
|
|
756
|
+
for (const issue of suspect) {
|
|
757
|
+
let snapshot: IssueSnapshot | undefined;
|
|
758
|
+
try {
|
|
759
|
+
snapshot = await d.tracker.issueSnapshot(issue.number);
|
|
760
|
+
} catch {
|
|
761
|
+
snapshot = undefined;
|
|
762
|
+
}
|
|
763
|
+
if (snapshot === undefined) {
|
|
764
|
+
unreadable.add(issue.number);
|
|
765
|
+
log(`#${issue.number} lifecycle labels could not be revalidated — holding this pass`);
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
// A closed issue is nobody's candidate; leave the list labels alone and let
|
|
769
|
+
// the ordinary gates speak.
|
|
770
|
+
if (snapshot.state === "closed") continue;
|
|
771
|
+
if (snapshot.labels.join("\u0000") === issue.labels.join("\u0000")) continue;
|
|
772
|
+
revalidated.set(issue.number, snapshot.labels);
|
|
773
|
+
log(`#${issue.number} lifecycle labels revalidated: the queue list was stale`);
|
|
774
|
+
}
|
|
775
|
+
const verified =
|
|
776
|
+
revalidated.size === 0
|
|
777
|
+
? effective
|
|
778
|
+
: effective.map((issue) => {
|
|
779
|
+
const live = revalidated.get(issue.number);
|
|
780
|
+
return live === undefined ? issue : { ...issue, labels: [...live] };
|
|
781
|
+
});
|
|
782
|
+
const { routed, unroutable } = route(verified, project);
|
|
783
|
+
// route() drops a candidate for two reasons, only one of which is a claim
|
|
784
|
+
// question. A lifecycle state label (agent:in-progress/blocked/failed)
|
|
785
|
+
// marks a run-owned issue: it is genuinely in flight while its newest run
|
|
786
|
+
// is live or settling, and a terminal newest run — or no run at all — means
|
|
787
|
+
// the label is residual, the footprint of a settled run nobody cleared,
|
|
788
|
+
// which is Duty 1 reconciliation work, not occupied capacity. A missing
|
|
789
|
+
// queue label instead marks an issue the outbox is withdrawing (a pending
|
|
790
|
+
// queue-label removal, e.g. releaseQueueLabel after a merge the PR did not
|
|
791
|
+
// close), which belongs in none of the three populations. So `claimed` is
|
|
792
|
+
// defined from actual ownership over genuinely lifecycle-labelled candidates
|
|
793
|
+
// rather than as the residual of routing (#228, #611).
|
|
794
|
+
// Every population below reads the VERIFIED list, so a candidate the exact
|
|
795
|
+
// read cleared is not simultaneously admitted and counted as held (#891).
|
|
796
|
+
const dropped = verified.filter(
|
|
797
|
+
(issue) => !isEligible(issue, project) && issue.labels.some((l) => stateLabelSet.has(l)),
|
|
798
|
+
);
|
|
799
|
+
// The operator's park label is not a stale lifecycle label: the queue query
|
|
800
|
+
// still returns a parked-and-queued issue, route() drops it as ineligible,
|
|
801
|
+
// and the claim gate holds it as `issue-parked` when the park lands mid-pass
|
|
802
|
+
// (#734). Counting it as stale-lifecycle below would summon the orchestrator
|
|
803
|
+
// to reconcile a deliberate decision. Parked is its own population, derived
|
|
804
|
+
// from the same isEligible read the gate uses, so the number status renders
|
|
805
|
+
// cannot disagree with what admission would hold (#507).
|
|
806
|
+
const parkedCandidates = verified.filter(
|
|
807
|
+
(issue) => !isEligible(issue, project) && issue.labels.includes(project.stateLabels.backlog),
|
|
808
|
+
);
|
|
809
|
+
const parkedNumbers = new Set(parkedCandidates.map((issue) => issue.number));
|
|
810
|
+
let claimed = 0;
|
|
811
|
+
let parked = 0;
|
|
812
|
+
const lifecycleHolds: AdmissionHold[] = [];
|
|
813
|
+
for (const issue of dropped) {
|
|
814
|
+
const newest = store.latestRun(project.name, issue.number);
|
|
815
|
+
if (newest !== undefined && ACTIVE_STATES.includes(newest.state)) {
|
|
816
|
+
claimed += 1;
|
|
817
|
+
} else if (parkedNumbers.has(issue.number)) {
|
|
818
|
+
// A park never kills a live run; a parked issue with no run is inventory
|
|
819
|
+
// the operator is deliberately holding, not reconciliation work.
|
|
820
|
+
parked += 1;
|
|
821
|
+
} else if (unreadable.has(issue.number)) {
|
|
822
|
+
// Fail closed, and say which read failed: a tracker that could not answer
|
|
823
|
+
// is not evidence of a residual label (#891).
|
|
824
|
+
lifecycleHolds.push({
|
|
825
|
+
issue: issue.number,
|
|
826
|
+
reason: "issue-state-lookup-error",
|
|
827
|
+
detail: "lifecycle labels could not be revalidated against the exact issue read",
|
|
828
|
+
});
|
|
829
|
+
} else {
|
|
830
|
+
lifecycleHolds.push({ issue: issue.number, reason: "stale-lifecycle" });
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
const routingHolds: AdmissionHold[] = [
|
|
834
|
+
...unroutable.map(
|
|
835
|
+
(u): AdmissionHold => ({ issue: u.issue.number, reason: `unroutable:${u.reason}` }),
|
|
836
|
+
),
|
|
837
|
+
...lifecycleHolds,
|
|
838
|
+
];
|
|
839
|
+
const recordDispatch = (admitted: number, holds: readonly AdmissionHold[]): void => {
|
|
840
|
+
store.recordDispatch(
|
|
841
|
+
project.name,
|
|
842
|
+
summarizeDispatch(ready.length, routed.length, claimed, admitted, holds, Date.now(), settled, parked),
|
|
843
|
+
);
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
// An issue nobody can route never reaches a worker: guessing the target repo
|
|
847
|
+
// is exactly the kind of improvisation this system exists to prevent. The
|
|
848
|
+
// summary is stable so a queue left unfixed pages once, not every tick.
|
|
849
|
+
for (const u of unroutable) {
|
|
850
|
+
await safeEscalate(d, {
|
|
851
|
+
tier: 1,
|
|
852
|
+
project: project.name,
|
|
853
|
+
issue: u.issue.number,
|
|
854
|
+
summary: `#${u.issue.number} cannot be routed: ${UNROUTABLE_TEXT[u.reason]}`,
|
|
855
|
+
detail: [
|
|
856
|
+
u.issue.title,
|
|
857
|
+
u.issue.url,
|
|
858
|
+
`Repo labels seen: ${u.labels.length > 0 ? u.labels.join(", ") : "(none)"}`,
|
|
859
|
+
`Configured repos: ${Object.keys(project.routing.repos).join(", ") || "(none)"}`,
|
|
860
|
+
`Fix: put exactly one \`${project.routing.labelPrefix}<repo>\` label on the issue.`,
|
|
861
|
+
].join("\n"),
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const since = startOfToday();
|
|
866
|
+
|
|
867
|
+
// Spend is the one cap that stops the fleet instead of merely deferring work.
|
|
868
|
+
// A loop that is burning money has to halt itself; waiting for a human to
|
|
869
|
+
// notice tomorrow is how a runaway becomes expensive. `null` means the
|
|
870
|
+
// operator opted out — turns and wall-clock still brake every run (#46).
|
|
871
|
+
const spent = store.spendSince(project.name, since);
|
|
872
|
+
if (caps.dailySpendUsd !== null && spent >= caps.dailySpendUsd) {
|
|
873
|
+
setPaused(
|
|
874
|
+
true,
|
|
875
|
+
{ source: "spend-cap", reason: `daily spend reached $${caps.dailySpendUsd}` },
|
|
876
|
+
project.name,
|
|
877
|
+
);
|
|
878
|
+
await safeEscalate(d, {
|
|
879
|
+
tier: 2,
|
|
880
|
+
category: "fleet-stopped",
|
|
881
|
+
project: project.name,
|
|
882
|
+
issue: NO_ISSUE,
|
|
883
|
+
// Dated so the same cap pages again tomorrow, but only once per day.
|
|
884
|
+
summary: `Daily spend cap reached on ${new Date().toISOString().slice(0, 10)} — ${project.name} is paused`,
|
|
885
|
+
detail: [
|
|
886
|
+
`Spent $${spent.toFixed(2)} of the $${caps.dailySpendUsd.toFixed(2)} daily cap.`,
|
|
887
|
+
"Dispatch stays paused until today's spend falls below the cap, then resumes",
|
|
888
|
+
"automatically on the next pass — `omp-conductor resume` only speeds that up.",
|
|
889
|
+
].join("\n"),
|
|
890
|
+
});
|
|
891
|
+
recordDispatch(0, [
|
|
892
|
+
...routingHolds,
|
|
893
|
+
...routed.map((r) => ({ issue: r.issue.number, reason: "daily-spend-cap" as const })),
|
|
894
|
+
]);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Grooming, from here on, is the daemon's own duty (#1041). Its place in the
|
|
899
|
+
// tick is the argument for it: BELOW the daily spend cap, because a batch of
|
|
900
|
+
// scouts is real money and a fleet that has stopped spending must stop
|
|
901
|
+
// spending on grooming too; ABOVE the capacity gate, because a full fleet
|
|
902
|
+
// with a draining queue is precisely when the next specs have to be written —
|
|
903
|
+
// the queue those workers will pull from next is empty right now, and waiting
|
|
904
|
+
// for a free slot to notice is how the loop stalls between batches.
|
|
905
|
+
//
|
|
906
|
+
// It reads this pass's own routable count, so the drought and the response
|
|
907
|
+
// happen in one pass instead of one apiece, and it never blocks: the launches
|
|
908
|
+
// ride the worker pool exactly like adjudications.
|
|
909
|
+
try {
|
|
910
|
+
await dispatchToSpecGrooming(d, routed.length, workers);
|
|
911
|
+
} catch (err) {
|
|
912
|
+
log(`grooming pass failed: ${errText(err)}`);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Two different questions, deliberately two queries. Capacity counts worker
|
|
916
|
+
// *processes*, so a green PR awaiting a human merge must not consume a slot —
|
|
917
|
+
// two of those would otherwise stop the fleet. That same PR's *issue* must
|
|
918
|
+
// still be occupied, which is what `admitCandidates`' busy set is for.
|
|
919
|
+
const live = store.liveRuns(project.name);
|
|
920
|
+
const slots = caps.maxConcurrentWorkers - live.length;
|
|
921
|
+
if (slots <= 0) {
|
|
922
|
+
log(`at capacity: ${live.length}/${caps.maxConcurrentWorkers} workers`);
|
|
923
|
+
recordDispatch(0, [
|
|
924
|
+
...routingHolds,
|
|
925
|
+
...routed.map((r) => ({ issue: r.issue.number, reason: "capacity" as const })),
|
|
926
|
+
]);
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const pass = await admitCandidates(d, routed, slots);
|
|
931
|
+
|
|
932
|
+
// The stop fence (#374): the run loop only re-reads `stopping` between
|
|
933
|
+
// whole ticks, so a pass already in flight when SIGTERM/SIGINT landed must
|
|
934
|
+
// re-check the drain signal here — after candidate admission, before any
|
|
935
|
+
// claim or launch — or the shutdown path admits exactly the work it is
|
|
936
|
+
// about to wait for and then loses to the stop timeout. The candidates keep
|
|
937
|
+
// their queue labels; the next daemon start re-dispatches them.
|
|
938
|
+
if (d.drain?.draining === true) {
|
|
939
|
+
recordDispatch(0, [
|
|
940
|
+
...routingHolds,
|
|
941
|
+
...pass.holds,
|
|
942
|
+
...pass.admitted.map((a) => ({ issue: a.r.issue.number, reason: "shutting-down" as const })),
|
|
943
|
+
]);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
recordDispatch(pass.admitted.length, [...routingHolds, ...pass.holds]);
|
|
947
|
+
await recordInstallSurfaces(d);
|
|
948
|
+
reconcilePanes(d, project.name, log);
|
|
949
|
+
|
|
950
|
+
if (pass.admitted.length === 0) return;
|
|
951
|
+
|
|
952
|
+
log(`dispatching ${pass.admitted.map((a) => `#${a.r.issue.number}`).join(" ")}`);
|
|
953
|
+
await dispatchAdmissions(
|
|
954
|
+
pass.admitted,
|
|
955
|
+
(a) => handleIssue(d, a.r, a.attempt, a.lane, a.model),
|
|
956
|
+
workers,
|
|
957
|
+
);
|
|
958
|
+
|
|
959
|
+
// Post-admission flush: freshly claimed runs enqueued their in-progress
|
|
960
|
+
// label inside `handleIssue`; applying it now means the guard label lands on
|
|
961
|
+
// GitHub within the same tick on the healthy path, not five minutes later
|
|
962
|
+
// (#201).
|
|
963
|
+
try {
|
|
964
|
+
await projectLabels(store, d.tracker, project);
|
|
965
|
+
} catch (err) {
|
|
966
|
+
log(`label projection failed: ${errText(err)}`);
|
|
967
|
+
}
|
|
968
|
+
}
|