omp-conductor 0.19.7 → 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 +10 -1
- 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 -7923
- 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/failure-class.ts +75 -1
- package/src/fleet.ts +290 -164
- 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 +604 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +416 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- package/src/worker.ts +456 -1
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon process itself: what `omp-conductor daemon` actually runs.
|
|
3
|
+
*
|
|
4
|
+
* Everything in this directory above it is a pure-ish pass over a `Deps`.
|
|
5
|
+
* This is where the real ones are built — the tracker, the store, the verb
|
|
6
|
+
* socket, the orchestrator session, the pool and the registries — and where the
|
|
7
|
+
* only long-lived state in the dispatcher lives: `Bun.serve`, the timers, the
|
|
8
|
+
* integrity baseline recorded at boot, and the shutdown that has to drain them
|
|
9
|
+
* all in the right order.
|
|
10
|
+
*
|
|
11
|
+
* Keeping it last and separate is what lets every pass be tested without a
|
|
12
|
+
* process. Nothing here is called by a pass; the arrow runs one way, from the
|
|
13
|
+
* runtime into the passes, which is also why `runDaemon` is the only place a
|
|
14
|
+
* dep is wired to its production implementation.
|
|
15
|
+
*/
|
|
16
|
+
import { mkdirSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { findProject, loadConfig, resolveCaps, resolveReleaseGrants, resolveSharedInstallAuthority, stateDir } from "../config.ts";
|
|
19
|
+
import { probeCredentialClass } from "../credential-class.ts";
|
|
20
|
+
import { createEscalator, escalationIssueRef } from "../escalate.ts";
|
|
21
|
+
import { releaseOrphanedWorkerPane } from "../fleet.ts";
|
|
22
|
+
import { probeCriticalBase, probeRunLane, pushRunBranch } from "../gitops.ts";
|
|
23
|
+
import { pendingCodeGraph, probeCodeGraph, type CodeGraphHealth } from "../graph-health.ts";
|
|
24
|
+
import { ensureHttpToken } from "../http-token.ts";
|
|
25
|
+
import { acquireOnceLease, livingDaemon } from "../lifecycle.ts";
|
|
26
|
+
import { errText, log } from "../log.ts";
|
|
27
|
+
import { reconcileOrchestratorDown } from "../orchestrator-down.ts";
|
|
28
|
+
import { startOrchestrator, type OrchestratorHandle } from "../orchestrator.ts";
|
|
29
|
+
import { isPaused } from "../pause.ts";
|
|
30
|
+
import { recordReleaseBlock } from "../release-policy.ts";
|
|
31
|
+
import { createReportOutbox, type ReportOutbox } from "../reports.ts";
|
|
32
|
+
import { reconcileOrphanedRuns } from "../settlement.ts";
|
|
33
|
+
import { dbPath, openStore, utcDay } from "../store.ts";
|
|
34
|
+
import { checkTelegramFreshness } from "../telegram-freshness.ts";
|
|
35
|
+
import { GraphqlBreaker, makeTracker } from "../tracker/github.ts";
|
|
36
|
+
import { RELEASE_SHAPES, type ProjectConfig, type ResolvedGrants, type RunRecord } from "../types.ts";
|
|
37
|
+
import { runCommand } from "../upgrade-verify.ts";
|
|
38
|
+
import { inspectSurfaces } from "../upgrade.ts";
|
|
39
|
+
import { sharedUsageSource } from "../usage.ts";
|
|
40
|
+
import { githubVerbActions } from "../verbs/actions.ts";
|
|
41
|
+
import { listenVerbChannel, type VerbListener } from "../verbs/server.ts";
|
|
42
|
+
import { ensureVerbSocketDir, peerCredentialReader, transportBanner, verbSocketPath } from "../verbs/socket.ts";
|
|
43
|
+
import type { RunPublisher } from "../worktree.ts";
|
|
44
|
+
import { createWorkerPool } from "./admission-pass.ts";
|
|
45
|
+
import { DEFAULT_PORT, GRAPH_HEALTH_INTERVAL_MS, PACKAGE_SRC_DIR, REPORT_DELIVERY_INTERVAL_MS, TICK_INTERVAL_MS, verbDeps, type DaemonOpts, type Deps, type DrainSignal, type IntegrityGate } from "./deps.ts";
|
|
46
|
+
import { createTurnLimitRegistry, createWorkerControlRegistry } from "./dispatch.ts";
|
|
47
|
+
import { daemonHttpResponse } from "./http.ts";
|
|
48
|
+
import { packageManifest } from "./integrity.ts";
|
|
49
|
+
import { workspaceOwnership } from "./panes.ts";
|
|
50
|
+
import { reconcileCrashedReviewRevisions } from "./review.ts";
|
|
51
|
+
import { reconcileHistoricalInfra } from "./settle-pass.ts";
|
|
52
|
+
import { tick } from "./tick.ts";
|
|
53
|
+
import { daemonHealth, daemonHealthSnapshot } from "./views.ts";
|
|
54
|
+
|
|
55
|
+
export interface ProjectRuntime {
|
|
56
|
+
d: Deps;
|
|
57
|
+
outbox: ReportOutbox;
|
|
58
|
+
orchestrator?: OrchestratorHandle;
|
|
59
|
+
orchestratorVerbs?: VerbListener;
|
|
60
|
+
codeGraph: CodeGraphHealth;
|
|
61
|
+
graphProbe?: Promise<void>;
|
|
62
|
+
reportPass?: Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function orchestratorStandingOrders(
|
|
66
|
+
project: ProjectConfig,
|
|
67
|
+
projects: readonly ProjectConfig[],
|
|
68
|
+
): {
|
|
69
|
+
brief: string;
|
|
70
|
+
releaseGrants: ResolvedGrants;
|
|
71
|
+
} {
|
|
72
|
+
const installAuthority = resolveSharedInstallAuthority(projects);
|
|
73
|
+
const releaseGrants = {
|
|
74
|
+
...resolveReleaseGrants(project),
|
|
75
|
+
install: installAuthority.holder ?? "human",
|
|
76
|
+
};
|
|
77
|
+
const grantedShapes = RELEASE_SHAPES.filter((shape) => releaseGrants[shape] === "orchestrator");
|
|
78
|
+
const deniedShapes = RELEASE_SHAPES.filter((shape) => releaseGrants[shape] !== "orchestrator");
|
|
79
|
+
return {
|
|
80
|
+
releaseGrants,
|
|
81
|
+
brief: [
|
|
82
|
+
`You are the omp-conductor orchestrator for project "${project.name}".`,
|
|
83
|
+
`Tracker: ${project.tracker.repo}. Pass --repo ${project.tracker.repo} to every gh command:`,
|
|
84
|
+
"this working directory is the conductor's state directory, not a checkout.",
|
|
85
|
+
`Labels: queue=${project.queueLabel}, running=${project.stateLabels.inProgress}, ` +
|
|
86
|
+
`blocked=${project.stateLabels.blocked}, failed=${project.stateLabels.failed}.`,
|
|
87
|
+
"The dispatcher claims queue-labelled issues, runs one worker session per attempt in its own",
|
|
88
|
+
"worktree under hard turn/wallclock/spend caps, and escalates to you when a worker blocks or",
|
|
89
|
+
"fails twice, its gates stay red, its branch conflicts, or a tripwire fires.",
|
|
90
|
+
"Your job when that happens: re-brief the issue (comment what the next worker must do",
|
|
91
|
+
`differently, then put ${project.queueLabel} back on it), file follow-up issues, or promote to`,
|
|
92
|
+
"tier 2 and let the human decide.",
|
|
93
|
+
project.authority.merge === "orchestrator"
|
|
94
|
+
? "You never edit product code or push a branch — a worker session does that. Merging is yours: one PR at " +
|
|
95
|
+
"a time, freshness-checked against the base branch, per the Releases section of your POLICY.md."
|
|
96
|
+
: "You never edit product code, push a branch, or merge a PR — a worker session edits and pushes, and a " +
|
|
97
|
+
"human merges.",
|
|
98
|
+
`Release tool gate: ${
|
|
99
|
+
grantedShapes.length === 0
|
|
100
|
+
? "every release and deploy shape is mechanically blocked for you"
|
|
101
|
+
: `you may invoke ${grantedShapes.join(", ")} — and only by the procedure in your POLICY.md`
|
|
102
|
+
}.` + (deniedShapes.length === 0 ? "" : ` Blocked: ${deniedShapes.join(", ")}.`),
|
|
103
|
+
...(installAuthority.holder === undefined
|
|
104
|
+
? [
|
|
105
|
+
`Host-global install authority conflicts: ${installAuthority.entries
|
|
106
|
+
.map((entry) => `${entry.project}=${entry.holder}`)
|
|
107
|
+
.join(", ")}. Install is blocked.`,
|
|
108
|
+
]
|
|
109
|
+
: []),
|
|
110
|
+
"Handle each escalation below before the next one.",
|
|
111
|
+
].join("\n"),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pacing for the resident dispatch loop, interruptible so an operator action —
|
|
117
|
+
* `resume` — does not wait out the full interval (#380).
|
|
118
|
+
*
|
|
119
|
+
* `requestWake` is sticky until a pass absorbs it: while the long sleep is in
|
|
120
|
+
* flight it is cut short, and when a pass is mid-flight the loop skips the
|
|
121
|
+
* *following* sleep. Either way the next pass starts without the interval
|
|
122
|
+
* wait. The single boolean coalesces any number of near-simultaneous wakes
|
|
123
|
+
* into one prompt pass, and the strictly sequential loop means a wake can
|
|
124
|
+
* never start a pass on top of one already running.
|
|
125
|
+
*/
|
|
126
|
+
export interface DispatchPace {
|
|
127
|
+
/** Ask for a prompt pass; cuts an in-flight sleep short. Coalesces. */
|
|
128
|
+
requestWake(): void;
|
|
129
|
+
/** Absorb a pending request — true when the pass that just finished should
|
|
130
|
+
* skip its sleep. */
|
|
131
|
+
takeWake(): boolean;
|
|
132
|
+
/** The long interval sleep; resolves early when {@link requestWake} fires. */
|
|
133
|
+
sleep(ms: number): Promise<void>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function createDispatchPace(): DispatchPace {
|
|
137
|
+
let pendingWake = false;
|
|
138
|
+
let interruptSleep: (() => void) | undefined;
|
|
139
|
+
return {
|
|
140
|
+
requestWake() {
|
|
141
|
+
pendingWake = true;
|
|
142
|
+
interruptSleep?.();
|
|
143
|
+
},
|
|
144
|
+
takeWake() {
|
|
145
|
+
const pending = pendingWake;
|
|
146
|
+
pendingWake = false;
|
|
147
|
+
return pending;
|
|
148
|
+
},
|
|
149
|
+
async sleep(ms) {
|
|
150
|
+
// A wake that landed in the gap between the loop's takeWake check and
|
|
151
|
+
// this call is absorbed here: skip the interval, and consume the flag
|
|
152
|
+
// so the pass that follows it is the only prompt one.
|
|
153
|
+
if (pendingWake) {
|
|
154
|
+
pendingWake = false;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
await new Promise<void>((resolve) => {
|
|
158
|
+
const timer = setTimeout(resolve, ms);
|
|
159
|
+
interruptSleep = () => {
|
|
160
|
+
// The interruption *is* the absorption: the pass that follows the
|
|
161
|
+
// sleep is the prompt pass this wake asked for, so the loop sleeps
|
|
162
|
+
// normally after it instead of running a second immediate pass.
|
|
163
|
+
pendingWake = false;
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
resolve();
|
|
166
|
+
};
|
|
167
|
+
// A wake can land between the loop's takeWake check and this
|
|
168
|
+
// constructor; resolve immediately instead of letting it wait out
|
|
169
|
+
// the full interval.
|
|
170
|
+
if (pendingWake) interruptSleep();
|
|
171
|
+
});
|
|
172
|
+
interruptSleep = undefined;
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface DispatchLoopOptions {
|
|
178
|
+
/** One dispatch pass over every configured project runtime. */
|
|
179
|
+
tick(): Promise<void>;
|
|
180
|
+
/** True once a stop is requested; checked between passes. */
|
|
181
|
+
shouldStop(): boolean;
|
|
182
|
+
pace: DispatchPace;
|
|
183
|
+
/** The scheduled interval between passes (`TICK_INTERVAL_MS` in production). */
|
|
184
|
+
tickIntervalMs: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The resident daemon's dispatch loop: one pass immediately, then a
|
|
189
|
+
* `tickIntervalMs` sleep the {@link DispatchPace} can cut short. Exported so
|
|
190
|
+
* the resume-wake behaviour can be pinned deterministically without a live
|
|
191
|
+
* daemon (#380).
|
|
192
|
+
*/
|
|
193
|
+
export async function runDispatchLoop(o: DispatchLoopOptions): Promise<void> {
|
|
194
|
+
const { tick, shouldStop, pace, tickIntervalMs } = o;
|
|
195
|
+
while (!shouldStop()) {
|
|
196
|
+
await tick();
|
|
197
|
+
if (shouldStop()) break;
|
|
198
|
+
// A wake that landed while the pass was in flight skips the following
|
|
199
|
+
// sleep: that pass made its admission decisions against pre-resume state,
|
|
200
|
+
// and the resumed project must not wait a full interval for the next one.
|
|
201
|
+
if (pace.takeWake()) continue;
|
|
202
|
+
await pace.sleep(tickIntervalMs);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
207
|
+
// A `--once` tick is still a dispatcher: it settles rows, projects labels,
|
|
208
|
+
// admits and launches workers, so it must hold the same exclusive daemon/
|
|
209
|
+
// state ownership as the long-running dispatcher for its whole lifecycle
|
|
210
|
+
// (#431). This is the one choke point every once tick passes through (both
|
|
211
|
+
// `daemon --once` and the `setup host` smoke), so ownership is acquired here
|
|
212
|
+
// — before any settlement, label, admission, worktree or worker mutation —
|
|
213
|
+
// and refused (having done none of those) while another live daemon owns the
|
|
214
|
+
// project or another live `--once` holds the lease.
|
|
215
|
+
const onceLease = o.once ? acquireOnceLease({ port: o.port, project: o.project }) : undefined;
|
|
216
|
+
if (onceLease !== undefined && !onceLease.ok) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`another daemon is alive (pid ${onceLease.pid}); stop it first — a --once tick refuses to run beside it`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const cfg = loadConfig();
|
|
222
|
+
const projects = o.project === undefined ? cfg.projects : [findProject(cfg, o.project)];
|
|
223
|
+
const store = openStore(dbPath());
|
|
224
|
+
const verbPeerReader = peerCredentialReader();
|
|
225
|
+
const verbDir = ensureVerbSocketDir(stateDir());
|
|
226
|
+
const integrity: IntegrityGate = { baseline: packageManifest(), paged: false };
|
|
227
|
+
const usage = sharedUsageSource();
|
|
228
|
+
const turnLimits = createTurnLimitRegistry((runId, maxTurns) => {
|
|
229
|
+
store.updateRun(runId, { maxTurns });
|
|
230
|
+
});
|
|
231
|
+
const workerControls = createWorkerControlRegistry();
|
|
232
|
+
// Created before the pool so a worker's completion can poke the loop it
|
|
233
|
+
// shares with the interval sleep (#878).
|
|
234
|
+
const pace = createDispatchPace();
|
|
235
|
+
const workers = createWorkerPool(() => {
|
|
236
|
+
// Sticky and coalescing by construction: several workers settling together
|
|
237
|
+
// produce one pass, and a pass already running absorbs the request rather
|
|
238
|
+
// than overlapping. No second owner is created and no gate is skipped.
|
|
239
|
+
pace.requestWake();
|
|
240
|
+
});
|
|
241
|
+
const alive = livingDaemon();
|
|
242
|
+
const runtimes: ProjectRuntime[] = [];
|
|
243
|
+
|
|
244
|
+
// The stop fence (#374): one signal shared by every project's tick.
|
|
245
|
+
// `stopping` gates the run loop between whole ticks; `drain.draining` is
|
|
246
|
+
// the same event visible *inside* a tick already in flight, so a pass that
|
|
247
|
+
// was mid-admission when the stop landed re-checks it before claiming or
|
|
248
|
+
// launching. Created here, before the loop, so `stop` below and every
|
|
249
|
+
// project's `Deps` reference the same object.
|
|
250
|
+
const drain: DrainSignal = { draining: false };
|
|
251
|
+
|
|
252
|
+
log(`verb transport: ${transportBanner(verbDir, verbPeerReader)}`);
|
|
253
|
+
log(`package integrity baseline: ${integrity.baseline.size} files under ${PACKAGE_SRC_DIR}`);
|
|
254
|
+
|
|
255
|
+
for (const project of projects) {
|
|
256
|
+
const projectLog = (message: string): void => {
|
|
257
|
+
log(projects.length === 1 ? message : `[${project.name}] ${message}`);
|
|
258
|
+
};
|
|
259
|
+
const caps = resolveCaps(project, cfg.defaults);
|
|
260
|
+
const host = cfg.host;
|
|
261
|
+
// One transient-server-error breaker per project (#642): admission's
|
|
262
|
+
// GraphQL checks and the orchestrator mutation commands share it, so a 503
|
|
263
|
+
// observed by either side gates both instead of one provider outage being
|
|
264
|
+
// re-asked by every candidate AND every mutation.
|
|
265
|
+
const graphqlBreaker = new GraphqlBreaker();
|
|
266
|
+
const tracker = makeTracker(project, undefined, {
|
|
267
|
+
onCall: () => store.bumpGhCalls?.(utcDay(), "daemon"),
|
|
268
|
+
onRefusal: (at) => store.recordGhRefusal?.(at),
|
|
269
|
+
onNotModified: () => store.bumpGhCalls?.(utcDay(), "daemon-304"),
|
|
270
|
+
}, { graphqlBreaker });
|
|
271
|
+
const verbActions = githubVerbActions(project, undefined, undefined, graphqlBreaker);
|
|
272
|
+
|
|
273
|
+
if (alive === undefined || alive.pid === process.pid) {
|
|
274
|
+
const orphanPublisher = (run: RunRecord): RunPublisher => {
|
|
275
|
+
const repo = project.routing.repos[run.repo];
|
|
276
|
+
return async (branch) =>
|
|
277
|
+
repo === undefined
|
|
278
|
+
? {
|
|
279
|
+
ok: false,
|
|
280
|
+
stderr:
|
|
281
|
+
`run ${String(run.id)} names repo "${run.repo}", ` +
|
|
282
|
+
`which project "${project.name}" no longer routes`,
|
|
283
|
+
}
|
|
284
|
+
: pushRunBranch(project, { repo, runRepoPath: run.worktree, branch });
|
|
285
|
+
};
|
|
286
|
+
for (const run of await reconcileOrphanedRuns(store, project.name, orphanPublisher)) {
|
|
287
|
+
// The workspace outlives the process, so a restart inherits panes whose
|
|
288
|
+
// workers are gone (#842). A session-host child dies with the daemon that
|
|
289
|
+
// owned its socket, so an orphaned row's pane is authoritatively dead
|
|
290
|
+
// whatever its recorded pid says. Ownership is proven before the close
|
|
291
|
+
// (#1035 review): pane ids are reused after a Herdr restart, so only a
|
|
292
|
+
// pane inside a workspace conductor owns — token-marked or store-recorded,
|
|
293
|
+
// which is what survives Herdr dropping tokens on restore — whose
|
|
294
|
+
// reported identity still agrees with this row is retired. An unowned
|
|
295
|
+
// hit closes nothing and says so.
|
|
296
|
+
const orphanPane = releaseOrphanedWorkerPane(
|
|
297
|
+
{ paneId: run.paneId, paneLabel: run.paneLabel, runId: run.id, project: run.project },
|
|
298
|
+
{ ownership: workspaceOwnership(store) },
|
|
299
|
+
);
|
|
300
|
+
if (orphanPane.kind === "released") {
|
|
301
|
+
projectLog(
|
|
302
|
+
`#${run.issue} herdr pane ${orphanPane.paneId} retired: its worker died with the previous daemon`,
|
|
303
|
+
);
|
|
304
|
+
} else if (orphanPane.kind === "unowned") {
|
|
305
|
+
projectLog(`#${run.issue} herdr pane ${orphanPane.paneId} left alone: ${orphanPane.reason}`);
|
|
306
|
+
} else if (orphanPane.kind === "failed") {
|
|
307
|
+
projectLog(`#${run.issue} herdr pane ${orphanPane.paneId} release failed: ${orphanPane.reason}`);
|
|
308
|
+
}
|
|
309
|
+
projectLog(
|
|
310
|
+
`#${run.issue} orphaned by a previous daemon (attempt ${run.attempt}, was ${run.state}, ` +
|
|
311
|
+
`worktree ${run.worktree}) — slot freed; the ${project.stateLabels.inProgress} label stays ` +
|
|
312
|
+
"until the orchestrator triages what the worker left",
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
} else if (runtimes.length === 0) {
|
|
316
|
+
log(`skipping orphan reconciliation: daemon pid ${alive.pid} is alive and owns the active runs`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const { brief, releaseGrants } = orchestratorStandingOrders(project, cfg.projects);
|
|
320
|
+
let orchestrator: OrchestratorHandle | undefined;
|
|
321
|
+
let orchestratorVerbs: VerbListener | undefined;
|
|
322
|
+
/** First start-failure cause, surfaced by the orchestrator-down incident (#288). */
|
|
323
|
+
let orchestratorStartError: string | undefined;
|
|
324
|
+
if (project.escalation.orchestrator === "external") {
|
|
325
|
+
projectLog(
|
|
326
|
+
"orchestrator: external — tier-1 escalations post as issue comments for the external session's drain duty",
|
|
327
|
+
);
|
|
328
|
+
} else {
|
|
329
|
+
try {
|
|
330
|
+
const orchCwd = join(
|
|
331
|
+
stateDir(),
|
|
332
|
+
projects.length === 1 ? "orchestrator" : `orchestrator-${project.name}`,
|
|
333
|
+
);
|
|
334
|
+
mkdirSync(orchCwd, { recursive: true });
|
|
335
|
+
orchestratorVerbs = await listenVerbChannel(
|
|
336
|
+
verbDeps({ project, store, tracker, verbActions }),
|
|
337
|
+
{
|
|
338
|
+
kind: "orchestrator",
|
|
339
|
+
path: verbSocketPath(verbDir, `orchestrator-${project.name}`),
|
|
340
|
+
project: project.name,
|
|
341
|
+
role: "orchestrator",
|
|
342
|
+
},
|
|
343
|
+
{ ...(verbPeerReader === undefined ? {} : { peerReader: verbPeerReader }) },
|
|
344
|
+
);
|
|
345
|
+
orchestrator = await startOrchestrator({
|
|
346
|
+
cwd: orchCwd,
|
|
347
|
+
brief,
|
|
348
|
+
releaseGrants,
|
|
349
|
+
socketPath: join(orchCwd, "ipc.sock"),
|
|
350
|
+
verbSocketPath: orchestratorVerbs.path,
|
|
351
|
+
onSpawn: (pid) => {
|
|
352
|
+
orchestratorVerbs?.bindPid(pid);
|
|
353
|
+
},
|
|
354
|
+
onChildLog: (line) => {
|
|
355
|
+
projectLog(`orchestrator ${line}`);
|
|
356
|
+
},
|
|
357
|
+
onReleaseBlocked: (shape, context) =>
|
|
358
|
+
recordReleaseBlock(project.name, "orchestrator", shape, context),
|
|
359
|
+
});
|
|
360
|
+
const transcript = orchestrator.sessionFile();
|
|
361
|
+
const loaded = orchestrator.extensionVersion();
|
|
362
|
+
projectLog(
|
|
363
|
+
`orchestrator session ready${transcript === undefined ? "" : ` · ${transcript}`}` +
|
|
364
|
+
`${loaded === undefined ? "" : ` · loaded omp-conductor ${loaded}`}`,
|
|
365
|
+
);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
orchestratorStartError = errText(err);
|
|
368
|
+
projectLog(
|
|
369
|
+
"WARNING: orchestrator session failed to start; tier-1 escalations will fall back to issue " +
|
|
370
|
+
`comments: ${orchestratorStartError}`,
|
|
371
|
+
);
|
|
372
|
+
await orchestratorVerbs?.close();
|
|
373
|
+
orchestratorVerbs = undefined;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let runtimeDeps: Deps | undefined;
|
|
378
|
+
const currentProject = (): ProjectConfig => runtimeDeps?.project ?? project;
|
|
379
|
+
const deliveryPolicyValid = (): boolean => runtimeDeps?.deliveryPolicyValid === true;
|
|
380
|
+
const escalator = createEscalator(
|
|
381
|
+
currentProject,
|
|
382
|
+
tracker,
|
|
383
|
+
store,
|
|
384
|
+
orchestrator,
|
|
385
|
+
Date.now,
|
|
386
|
+
deliveryPolicyValid,
|
|
387
|
+
(e) => {
|
|
388
|
+
// Every tier-1 escalation that lands on the issue-comment fallback was
|
|
389
|
+
// diverted from the orchestrator. Count it durably on the open incident
|
|
390
|
+
// (a no-op when none is open), so the page and status name how much
|
|
391
|
+
// the outage diverted (#288).
|
|
392
|
+
store.bumpOrchestratorDiverted(project.name, 1);
|
|
393
|
+
projectLog(
|
|
394
|
+
`orchestrator: tier-1 escalation on ${escalationIssueRef(e.issue)} diverted to issue comments ` +
|
|
395
|
+
`while the orchestrator was down`,
|
|
396
|
+
);
|
|
397
|
+
},
|
|
398
|
+
);
|
|
399
|
+
const outbox = createReportOutbox({
|
|
400
|
+
project: currentProject,
|
|
401
|
+
store,
|
|
402
|
+
escalate: (event) => escalator.escalate(event),
|
|
403
|
+
log: projectLog,
|
|
404
|
+
deliveryAllowed: deliveryPolicyValid,
|
|
405
|
+
});
|
|
406
|
+
if (alive === undefined || alive.pid === process.pid) {
|
|
407
|
+
for (const report of outbox.recover(Date.now())) {
|
|
408
|
+
projectLog(
|
|
409
|
+
`report ${report.id} was left mid-send by a previous daemon (attempt ${report.attempts}) — ` +
|
|
410
|
+
"retrying; its message will say it may be a repeat",
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const d: Deps = {
|
|
416
|
+
project,
|
|
417
|
+
caps,
|
|
418
|
+
host,
|
|
419
|
+
tracker,
|
|
420
|
+
store,
|
|
421
|
+
drain,
|
|
422
|
+
deliveryPolicyValid: false,
|
|
423
|
+
usage,
|
|
424
|
+
escalate: (event) => escalator.escalate(event),
|
|
425
|
+
turnLimits,
|
|
426
|
+
workerControls,
|
|
427
|
+
integrity,
|
|
428
|
+
stall: { paged: false },
|
|
429
|
+
...(orchestrator === undefined ? {} : { orchestrator }),
|
|
430
|
+
cleanup: { next: 0 },
|
|
431
|
+
probeCriticalBase: (repo, markers, branch) =>
|
|
432
|
+
probeCriticalBase(project, repo, branch, markers),
|
|
433
|
+
// The same seam `doctor` and `upgrade` read (#904/#919), never a second
|
|
434
|
+
// implementation. `readHerdr` stays on: a host that runs herdr is the
|
|
435
|
+
// case where a stale recovery pin matters, and a host without it answers
|
|
436
|
+
// "absent", which no cheap surface treats as a fault.
|
|
437
|
+
probeInstallSurfaces: () => inspectSurfaces({ run: runCommand, log, env: process.env }),
|
|
438
|
+
probeTelegramFreshness: () =>
|
|
439
|
+
// The module's own reader, so the daemon, `doctor` and `status` cannot
|
|
440
|
+
// come to disagree about what is installed (#961).
|
|
441
|
+
checkTelegramFreshness({
|
|
442
|
+
run: async (cmd, args) => {
|
|
443
|
+
const r = await runCommand(cmd, args);
|
|
444
|
+
return { code: r.code, stdout: r.stdout };
|
|
445
|
+
},
|
|
446
|
+
}),
|
|
447
|
+
probeWorktreeLane: (input) => probeRunLane(input),
|
|
448
|
+
// The credential-class fence's reader (#852): the runnable probe module,
|
|
449
|
+
// spawned against the live credential store. Wired once here so the
|
|
450
|
+
// admission gate and the launch fence ask the same question through the
|
|
451
|
+
// same transport — two readers would eventually disagree, and the one that
|
|
452
|
+
// disagreed by passing is the expensive one.
|
|
453
|
+
probeCredentialClass: (provider) => probeCredentialClass(provider),
|
|
454
|
+
// A cross-repo Depends-on prerequisite reads through the same GitHub
|
|
455
|
+
// credential/accounting seams as the project tracker — a fresh tracker
|
|
456
|
+
// scoped to the referenced repo, reusing the daemon's gh hooks so API
|
|
457
|
+
// spend and refusals are counted exactly as the main tracker's are.
|
|
458
|
+
probeIssueIn: (ownerRepo, issue) =>
|
|
459
|
+
makeTracker({ ...project, tracker: { kind: "github", repo: ownerRepo } }, undefined, {
|
|
460
|
+
onCall: () => store.bumpGhCalls?.(utcDay(), "daemon"),
|
|
461
|
+
onRefusal: (at) => store.recordGhRefusal?.(at),
|
|
462
|
+
}).issueSnapshot(issue),
|
|
463
|
+
// The body twin of `probeIssueIn`, for the dependency-graph cycle pass
|
|
464
|
+
// (#421): reads a reachable routed prerequisite's body through the same
|
|
465
|
+
// per-repo tracker/credential/accounting seams.
|
|
466
|
+
probeBodyIn: (ownerRepo, issue) =>
|
|
467
|
+
makeTracker({ ...project, tracker: { kind: "github", repo: ownerRepo } }, undefined, {
|
|
468
|
+
onCall: () => store.bumpGhCalls?.(utcDay(), "daemon"),
|
|
469
|
+
onRefusal: (at) => store.recordGhRefusal?.(at),
|
|
470
|
+
}).issueBody(issue),
|
|
471
|
+
...(verbPeerReader === undefined ? {} : { verbPeerReader }),
|
|
472
|
+
verbActions,
|
|
473
|
+
};
|
|
474
|
+
runtimeDeps = d;
|
|
475
|
+
// Review-revision restart recovery (#692): a revision the previous daemon
|
|
476
|
+
// claimed and lost — its run is `running`/`orphaned` by the orphan sweep
|
|
477
|
+
// above — is restored to `pushed-green` and re-queued so this process's
|
|
478
|
+
// first dispatch pass resumes the exact session. Runs under the same
|
|
479
|
+
// dead-daemon guard as `reconcileOrphanedRuns`, and after it, so the
|
|
480
|
+
// revision's worktree has already been salvaged to the branch before it is
|
|
481
|
+
// cleared for a fresh reattach. A round that cannot be restored was
|
|
482
|
+
// settled and escalated by the reconcile itself.
|
|
483
|
+
if (alive === undefined || alive.pid === process.pid) {
|
|
484
|
+
try {
|
|
485
|
+
for (const revision of await reconcileCrashedReviewRevisions(d)) {
|
|
486
|
+
projectLog(
|
|
487
|
+
`#${revision.issue} review round ${revision.round} recovered across restart — ` +
|
|
488
|
+
`run ${revision.runId} is pushed-green again and awaits its next dispatch pass`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
} catch (err) {
|
|
492
|
+
projectLog(`review revision restart recovery failed: ${errText(err)}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// Adjudication restart recovery (#932). An adjudication is `running` only
|
|
496
|
+
// while a session it launched is alive, and that session died with the
|
|
497
|
+
// previous process — nothing resumes it, because an adjudicator has no
|
|
498
|
+
// branch, no worktree and no transcript worth continuing: its whole output
|
|
499
|
+
// is a verdict it never produced.
|
|
500
|
+
//
|
|
501
|
+
// So it settles `failed`, which is honest and terminal, rather than being
|
|
502
|
+
// left `running` forever (a row nothing would ever touch again) or silently
|
|
503
|
+
// re-queued (a second launch for one head, which the one-shot exists to
|
|
504
|
+
// prevent). The one shot for that head is spent, and #876's disposition path
|
|
505
|
+
// reads `failed` exactly as it reads any other non-clear verdict.
|
|
506
|
+
if (alive === undefined || alive.pid === process.pid) {
|
|
507
|
+
for (const adjudication of store.openReviewAdjudications(project.name)) {
|
|
508
|
+
if (adjudication.state !== "running") continue;
|
|
509
|
+
store.settleReviewAdjudication(
|
|
510
|
+
adjudication.id,
|
|
511
|
+
"failed",
|
|
512
|
+
"the daemon restarted while this adjudication was running; no verdict was produced",
|
|
513
|
+
Date.now(),
|
|
514
|
+
);
|
|
515
|
+
projectLog(
|
|
516
|
+
`#${adjudication.issue} adjudication failed across restart — its session died with the previous daemon`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// Startup reconciliation: close an incident carried over from a previous
|
|
521
|
+
// process when the orchestrator is up (one recovery notice), or open one
|
|
522
|
+
// when it failed to start (one down page). A daemon restarted while still
|
|
523
|
+
// down rediscovers the open incident and does not re-page it.
|
|
524
|
+
await reconcileOrchestratorDown({
|
|
525
|
+
project,
|
|
526
|
+
store,
|
|
527
|
+
orchestrator,
|
|
528
|
+
escalate: (event) => d.escalate(event),
|
|
529
|
+
...(orchestratorStartError === undefined ? {} : { startCause: orchestratorStartError }),
|
|
530
|
+
log: projectLog,
|
|
531
|
+
});
|
|
532
|
+
runtimes.push({
|
|
533
|
+
d,
|
|
534
|
+
outbox,
|
|
535
|
+
...(orchestrator === undefined ? {} : { orchestrator }),
|
|
536
|
+
...(orchestratorVerbs === undefined ? {} : { orchestratorVerbs }),
|
|
537
|
+
codeGraph: pendingCodeGraph(project),
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Historical budget repair (#638): one bounded pass per daemon process,
|
|
542
|
+
// before the first tick, so admission and `unblock` read the repaired counts
|
|
543
|
+
// from the moment the daemon starts. A classifier fix changes future
|
|
544
|
+
// verdicts but not rows already settled under the old one; re-fetching their
|
|
545
|
+
// failed check logs and reclassifying the exact closed infrastructure
|
|
546
|
+
// signature returns those attempts without re-animating the runs. Best-effort
|
|
547
|
+
// per project — one unreachable project must not stop the rest from booting.
|
|
548
|
+
for (const runtime of runtimes) {
|
|
549
|
+
try {
|
|
550
|
+
await reconcileHistoricalInfra(runtime.d);
|
|
551
|
+
} catch (err) {
|
|
552
|
+
log(`historical infra reconciliation failed: ${errText(err)}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (o.once) {
|
|
557
|
+
try {
|
|
558
|
+
for (const runtime of runtimes) await tick(runtime.d, workers);
|
|
559
|
+
await workers.drain();
|
|
560
|
+
for (const runtime of runtimes) await runtime.outbox.deliverDue();
|
|
561
|
+
} finally {
|
|
562
|
+
// Drop exclusivity only while it is still ours; a manager that claimed
|
|
563
|
+
// the record mid-tick is left alone. A lease acquired but never released
|
|
564
|
+
// (config/lease failure before this branch) is stale recovery's job.
|
|
565
|
+
if (onceLease !== undefined && onceLease.ok) onceLease.lease.release();
|
|
566
|
+
for (const runtime of runtimes.toReversed()) {
|
|
567
|
+
await runtime.orchestrator?.dispose();
|
|
568
|
+
await runtime.orchestratorVerbs?.close();
|
|
569
|
+
}
|
|
570
|
+
store.close();
|
|
571
|
+
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const refreshCodeGraph = (runtime: ProjectRuntime): void => {
|
|
576
|
+
if (runtime.graphProbe !== undefined) return;
|
|
577
|
+
runtime.graphProbe = probeCodeGraph(runtime.d.project)
|
|
578
|
+
.then((health) => {
|
|
579
|
+
runtime.codeGraph = health;
|
|
580
|
+
})
|
|
581
|
+
.catch(() => {
|
|
582
|
+
log(
|
|
583
|
+
`[${runtime.d.project.name}] code-graph health probe failed unexpectedly; ` +
|
|
584
|
+
"retaining the previous bounded result",
|
|
585
|
+
);
|
|
586
|
+
})
|
|
587
|
+
.finally(() => {
|
|
588
|
+
runtime.graphProbe = undefined;
|
|
589
|
+
});
|
|
590
|
+
};
|
|
591
|
+
for (const runtime of runtimes) refreshCodeGraph(runtime);
|
|
592
|
+
const graphTimer = setInterval(() => {
|
|
593
|
+
for (const runtime of runtimes) refreshCodeGraph(runtime);
|
|
594
|
+
}, GRAPH_HEALTH_INTERVAL_MS);
|
|
595
|
+
|
|
596
|
+
const drainReports = (runtime: ProjectRuntime): void => {
|
|
597
|
+
if (runtime.reportPass !== undefined) return;
|
|
598
|
+
runtime.reportPass = runtime.outbox
|
|
599
|
+
.deliverDue()
|
|
600
|
+
.then(() => {})
|
|
601
|
+
.catch((err: unknown) => {
|
|
602
|
+
log(`[${runtime.d.project.name}] report delivery pass failed: ${errText(err)}`);
|
|
603
|
+
})
|
|
604
|
+
.finally(() => {
|
|
605
|
+
runtime.reportPass = undefined;
|
|
606
|
+
});
|
|
607
|
+
};
|
|
608
|
+
for (const runtime of runtimes) drainReports(runtime);
|
|
609
|
+
const reportTimer = setInterval(() => {
|
|
610
|
+
for (const runtime of runtimes) drainReports(runtime);
|
|
611
|
+
}, REPORT_DELIVERY_INTERVAL_MS);
|
|
612
|
+
|
|
613
|
+
let stopping = false;
|
|
614
|
+
// The wake surface (#380): `resume` POSTs /wake, `stop` interrupts the same
|
|
615
|
+
// sleep. A wake request is sticky and coalescing — several resumes in quick
|
|
616
|
+
// succession produce one prompt pass, never an overlapping one.
|
|
617
|
+
const stop = (): void => {
|
|
618
|
+
if (stopping) return;
|
|
619
|
+
stopping = true;
|
|
620
|
+
// Close the admission fence before the loop is told, so a tick already in
|
|
621
|
+
// flight — parked at an await when the signal landed — sees `draining`
|
|
622
|
+
// when it resumes and cannot create the work this shutdown is about to
|
|
623
|
+
// wait for (#374).
|
|
624
|
+
drain.draining = true;
|
|
625
|
+
log("shutdown requested — draining dispatch; live worker sessions are not waited for");
|
|
626
|
+
pace.requestWake();
|
|
627
|
+
};
|
|
628
|
+
process.on("SIGINT", stop);
|
|
629
|
+
process.on("SIGTERM", stop);
|
|
630
|
+
|
|
631
|
+
// This loopback surface exposes liveness (open) and bounded live-run controls
|
|
632
|
+
// (authenticated). Repository and tracker mutations stay on credentialled verb
|
|
633
|
+
// sockets, where project and role come from the channel rather than a payload.
|
|
634
|
+
//
|
|
635
|
+
// The mutating half used to be open, on the reasoning that binding 127.0.0.1
|
|
636
|
+
// is itself the boundary. It is not, on a shared host: every local process has
|
|
637
|
+
// the same loopback — including the worker sessions this daemon launches — so
|
|
638
|
+
// anything running here could pause, stop or re-ceiling any run, or wake
|
|
639
|
+
// dispatch in a loop. Phase 4 closes that with a bearer token minted here,
|
|
640
|
+
// before the server exists, so no request can ever be served against a token
|
|
641
|
+
// that had not been decided yet. `GET /healthz` deliberately stays open; the
|
|
642
|
+
// reason is at {@link daemonHttpResponse} and it is load-bearing for
|
|
643
|
+
// `requireDaemonControl`.
|
|
644
|
+
const httpToken = ensureHttpToken();
|
|
645
|
+
const server = Bun.serve({
|
|
646
|
+
hostname: "127.0.0.1",
|
|
647
|
+
port: o.port ?? DEFAULT_PORT,
|
|
648
|
+
fetch: (req) =>
|
|
649
|
+
daemonHttpResponse(req, {
|
|
650
|
+
token: httpToken,
|
|
651
|
+
projects: runtimes.map((runtime) => ({
|
|
652
|
+
project: runtime.d.project.name,
|
|
653
|
+
store,
|
|
654
|
+
caps: () => runtime.d.caps,
|
|
655
|
+
// The same project prefix the runtime's own journal lines carry, so
|
|
656
|
+
// a pause audit reads like every other daemon line (#997).
|
|
657
|
+
log: (line: string) =>
|
|
658
|
+
log(runtimes.length === 1 ? line : `[${runtime.d.project.name}] ${line}`),
|
|
659
|
+
})),
|
|
660
|
+
turnLimits,
|
|
661
|
+
workerControls,
|
|
662
|
+
wake: () => {
|
|
663
|
+
log("dispatch wake requested — an immediate pass is starting");
|
|
664
|
+
pace.requestWake();
|
|
665
|
+
},
|
|
666
|
+
health: () =>
|
|
667
|
+
daemonHealth(
|
|
668
|
+
runtimes.map((runtime) => {
|
|
669
|
+
const orch = runtime.orchestrator;
|
|
670
|
+
const mode = runtime.d.project.escalation.orchestrator;
|
|
671
|
+
return daemonHealthSnapshot(
|
|
672
|
+
store,
|
|
673
|
+
runtime.d.project.name,
|
|
674
|
+
isPaused(runtime.d.project.name),
|
|
675
|
+
runtime.codeGraph,
|
|
676
|
+
workerControls,
|
|
677
|
+
orch === undefined
|
|
678
|
+
? // External mode is the pane's session: the daemon hosts no
|
|
679
|
+
// child to attest. An embedded orchestrator that failed to
|
|
680
|
+
// start is a live outage of the surface the upgrade must
|
|
681
|
+
// promise reloaded (#832) — say so out loud.
|
|
682
|
+
{ mode: mode === "external" ? "external" : "failed" }
|
|
683
|
+
: {
|
|
684
|
+
mode: "embedded",
|
|
685
|
+
...(orch.extensionVersion() === undefined
|
|
686
|
+
? {}
|
|
687
|
+
: { loaded: orch.extensionVersion() }),
|
|
688
|
+
...(orch.sessionFile() === undefined ? {} : { sessionFile: orch.sessionFile() }),
|
|
689
|
+
alive: orch.alive(),
|
|
690
|
+
},
|
|
691
|
+
);
|
|
692
|
+
}),
|
|
693
|
+
),
|
|
694
|
+
}),
|
|
695
|
+
});
|
|
696
|
+
log(
|
|
697
|
+
projects.length === 1
|
|
698
|
+
? `serving /healthz on :${server.port}, project ${projects[0]!.name}`
|
|
699
|
+
: `serving /healthz on :${server.port}, projects ${projects.map(({ name }) => name).join(", ")}`,
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
try {
|
|
703
|
+
await runDispatchLoop({
|
|
704
|
+
tick: async () => {
|
|
705
|
+
for (const runtime of runtimes) {
|
|
706
|
+
try {
|
|
707
|
+
await tick(runtime.d, workers);
|
|
708
|
+
} catch (err) {
|
|
709
|
+
log(
|
|
710
|
+
`${projects.length === 1 ? "" : `[${runtime.d.project.name}] `}` +
|
|
711
|
+
`tick failed: ${errText(err)}`,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
shouldStop: () => stopping,
|
|
717
|
+
pace,
|
|
718
|
+
tickIntervalMs: TICK_INTERVAL_MS,
|
|
719
|
+
});
|
|
720
|
+
} finally {
|
|
721
|
+
process.off("SIGINT", stop);
|
|
722
|
+
process.off("SIGTERM", stop);
|
|
723
|
+
clearInterval(graphTimer);
|
|
724
|
+
clearInterval(reportTimer);
|
|
725
|
+
for (const runtime of runtimes) await runtime.reportPass;
|
|
726
|
+
await workers.drain();
|
|
727
|
+
await server.stop(true);
|
|
728
|
+
for (const runtime of runtimes.toReversed()) {
|
|
729
|
+
await runtime.orchestrator?.dispose();
|
|
730
|
+
await runtime.orchestratorVerbs?.close();
|
|
731
|
+
}
|
|
732
|
+
store.close();
|
|
733
|
+
log("stopped");
|
|
734
|
+
process.exitCode = 0;
|
|
735
|
+
}
|
|
736
|
+
}
|