omp-conductor 0.18.0 → 0.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -1
- package/REFERENCE.md +61 -11
- package/agents/to-spec.md +94 -0
- package/package.json +2 -1
- package/schema/config.schema.json +35 -1
- package/src/admission.ts +204 -75
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +62 -21
- package/src/briefs/to-spec.md +88 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +124 -1
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +38 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/intake.ts +4 -19
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +51 -16
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +43 -6
- package/src/config.ts +65 -9
- package/src/daemon.ts +879 -41
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +243 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +60 -82
- package/src/escalate.ts +31 -14
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +239 -240
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +242 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +183 -21
- package/src/orchestrator-tick.ts +1591 -32
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +65 -6
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1225 -9
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +154 -3
- package/src/setup.ts +83 -17
- package/src/shell.ts +15 -0
- package/src/status-render.ts +216 -12
- package/src/store.ts +443 -42
- package/src/to-spec.ts +408 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +405 -19
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +765 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +12 -2
- package/src/worktree.ts +29 -12
package/src/host.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
* or the tracker. Pure so tests pin the thresholds without a real machine.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { readFileSync } from "node:fs";
|
|
6
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
|
-
import {
|
|
8
|
+
import { availableParallelism } from "node:os";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { DEFAULT_CAPS, type HostConstraints } from "./types.ts";
|
|
9
11
|
|
|
10
12
|
/** 16 GiB — below this, two in-process omp sessions plus the orchestrator are
|
|
11
13
|
* a measured swap risk on a shared VPS (issue #51: 3–4GB peaks on 7.6GB). */
|
|
@@ -78,6 +80,86 @@ export function workerOvercommit(total: number, ram?: number | undefined): strin
|
|
|
78
80
|
: undefined;
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
/**
|
|
84
|
+
* CPU cores this process can actually use — affinity- and quota-aware, which
|
|
85
|
+
* is the load a worker would really get on a shared host. `undefined` mirrors
|
|
86
|
+
* {@link hostRamBytes}: the renderer simply omits the count.
|
|
87
|
+
*/
|
|
88
|
+
export function hostCoreCount(): number | undefined {
|
|
89
|
+
try {
|
|
90
|
+
return availableParallelism();
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What {@link hostFacts} reads off the host; injectable for render tests. */
|
|
97
|
+
export interface HostFacts {
|
|
98
|
+
cores?: number;
|
|
99
|
+
ramBytes?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The host's numbers through the module's own readers — the same
|
|
104
|
+
* {@link hostRamBytes} the capacity decision and the wizard use, plus the
|
|
105
|
+
* core count — so the rendered brief, the worker-count recommendation and
|
|
106
|
+
* the status surfaces can never disagree about the box they run on. This is
|
|
107
|
+
* the default the brief renderer reaches for; tests inject facts instead.
|
|
108
|
+
*/
|
|
109
|
+
export function hostFacts(): HostFacts {
|
|
110
|
+
return { cores: hostCoreCount(), ramBytes: hostRamBytes() };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The usable-size phrase the brief folds into the host line (`4 cores,
|
|
115
|
+
* 3.2 GB`); empty when the host will not say either number.
|
|
116
|
+
*/
|
|
117
|
+
function hostSizePhrase(facts: HostFacts): string {
|
|
118
|
+
const parts: string[] = [];
|
|
119
|
+
if (facts.cores !== undefined) parts.push(`${facts.cores} cores`);
|
|
120
|
+
if (facts.ramBytes !== undefined) parts.push(formatRss(facts.ramBytes));
|
|
121
|
+
return parts.join(", ");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The worker-brief host-constraints paragraph (#721): the typed replacement
|
|
126
|
+
* for host facts hand-written into an untracked agent context file. Cores and
|
|
127
|
+
* RAM come from {@link hostFacts} (the module's own readers) and are folded
|
|
128
|
+
* into the operator's description; the PATH and per-repo convention lines are
|
|
129
|
+
* typed config. It never names a guarded command — the shared-host notice,
|
|
130
|
+
* derived from `SHARED_HOST_SCRIPTS`, stays the only list of refused suites.
|
|
131
|
+
*
|
|
132
|
+
* Empty when nothing renders — no section, and the brief is byte-for-byte
|
|
133
|
+
* today's for a fleet that never fills the config field. `facts` is a
|
|
134
|
+
* parameter so tests pin the rendering deterministically; the dispatch default
|
|
135
|
+
* is {@link hostFacts}. The return has no trailing newline and leads with one
|
|
136
|
+
* when non-empty, so the template can render it on the line below the
|
|
137
|
+
* shared-host notice without gluing the two paragraphs.
|
|
138
|
+
*/
|
|
139
|
+
export function hostConstraintsNotice(
|
|
140
|
+
host: HostConstraints | undefined,
|
|
141
|
+
repoSlug: string,
|
|
142
|
+
facts: HostFacts = hostFacts(),
|
|
143
|
+
): string {
|
|
144
|
+
if (host === undefined) return "";
|
|
145
|
+
|
|
146
|
+
const lines: string[] = [];
|
|
147
|
+
const description = host.description?.trim();
|
|
148
|
+
if (description !== undefined && description !== "") {
|
|
149
|
+
const size = hostSizePhrase(facts);
|
|
150
|
+
lines.push(`**Host:** ${description}${size === "" ? "" : ` (${size})`}`);
|
|
151
|
+
}
|
|
152
|
+
const path = host.path?.trim();
|
|
153
|
+
if (path !== undefined && path !== "") {
|
|
154
|
+
lines.push(`**PATH:** non-interactive invocations (scripts and \`ssh host '<cmd>'\`) must export PATH="${path}".`);
|
|
155
|
+
}
|
|
156
|
+
const convention = host.conventions?.[repoSlug]?.trim();
|
|
157
|
+
if (convention !== undefined && convention !== "") {
|
|
158
|
+
lines.push(`**Convention (${repoSlug}):** ${convention}`);
|
|
159
|
+
}
|
|
160
|
+
return lines.length === 0 ? "" : `\n${lines.join("\n\n")}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
81
163
|
/** Compact binary units for status lines (`3.2 GB`, `430 MB`). */
|
|
82
164
|
export function formatRss(bytes: number): string {
|
|
83
165
|
if (!Number.isFinite(bytes) || bytes < 0) return "?";
|
|
@@ -104,3 +186,161 @@ export function rssBytesFromHealthz(body: string | undefined): number | undefine
|
|
|
104
186
|
return undefined;
|
|
105
187
|
}
|
|
106
188
|
}
|
|
189
|
+
|
|
190
|
+
// ------------------------------------------------------- worker host state (#894) --
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The dedicated worker account earlier boundary slices created on this host.
|
|
194
|
+
*
|
|
195
|
+
* Worker sessions no longer launch under it (#894 restored the fleet-account
|
|
196
|
+
* runtime v0.18.0 used): the constants below survive only so `setup host` can
|
|
197
|
+
* keep staging and recognising the units it once installed until #895 retires
|
|
198
|
+
* that host lifecycle end to end. Nothing on the session-launch path reads
|
|
199
|
+
* them.
|
|
200
|
+
*/
|
|
201
|
+
export const WORKER_ACCOUNT = "omp-worker";
|
|
202
|
+
|
|
203
|
+
/** The worker account's system home: its harness config, caches and state. */
|
|
204
|
+
export const WORKER_HOME_DIR = "/var/lib/omp-worker";
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The harness package omp-conductor loads a session from. Held here, beside
|
|
208
|
+
* the identity that has to reach it, so `omp.ts` (which imports it) and the
|
|
209
|
+
* binding checks below can never name two different packages.
|
|
210
|
+
*/
|
|
211
|
+
export const OMP_HARNESS_PACKAGE = "@oh-my-pi/pi-coding-agent";
|
|
212
|
+
|
|
213
|
+
/** Native addon package the harness imports at runtime. */
|
|
214
|
+
export const OMP_NATIVES_PACKAGE = "@oh-my-pi/pi-natives";
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Where `setup host` binds the operator's install so a worker session can
|
|
218
|
+
* resolve it (#828).
|
|
219
|
+
*
|
|
220
|
+
* Bun's node_modules resolution needs **read** permission — not merely search
|
|
221
|
+
* — on the directory that holds a `node_modules`: it enumerates the directory
|
|
222
|
+
* to decide whether the child is there. The fleet account's home is granted to
|
|
223
|
+
* the worker search-only by design (#798), so a bare `@oh-my-pi/pi-coding-agent`
|
|
224
|
+
* import from `<fleet home>/node_modules/omp-conductor` did not find the
|
|
225
|
+
* operator's install at all and fell through to Bun's auto-install, which
|
|
226
|
+
* downloaded a *different* harness version into the worker's own cache whose
|
|
227
|
+
* native addon then failed to load — every dispatch on the host stopped before
|
|
228
|
+
* session start.
|
|
229
|
+
*
|
|
230
|
+
* The fix is a read-only bind of the operator's `node_modules` at a path whose
|
|
231
|
+
* every ancestor is world-readable. Worker children are launched from it, so
|
|
232
|
+
* the entry module, the peer import and every transitive import resolve inside
|
|
233
|
+
* one tree the worker can enumerate — and, because a bind shares inodes with
|
|
234
|
+
* its source, it is the operator's exact build rather than a copy that can
|
|
235
|
+
* drift.
|
|
236
|
+
*
|
|
237
|
+
* Deliberately **not** under {@link WORKER_HOME_DIR}: the worker owns its home
|
|
238
|
+
* between setups, and a symlink planted where a root-run mount point goes would
|
|
239
|
+
* redirect that mount to an arbitrary target (#816).
|
|
240
|
+
*/
|
|
241
|
+
export const WORKER_HARNESS_DIR = "/var/lib/omp-worker-harness";
|
|
242
|
+
|
|
243
|
+
/** The bound `node_modules` itself. The leaf name is load-bearing: it is what
|
|
244
|
+
* Node/Bun resolution looks for walking up from the entry module. */
|
|
245
|
+
export const WORKER_HARNESS_NODE_MODULES = join(WORKER_HARNESS_DIR, "node_modules");
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The install root a module path sits in — its nearest ancestor named
|
|
249
|
+
* `node_modules` — or `undefined` when it has none, which is this package
|
|
250
|
+
* running from a source checkout rather than an install.
|
|
251
|
+
*/
|
|
252
|
+
export function packageNodeModulesRoot(modulePath: string): string | undefined {
|
|
253
|
+
const parts = resolve(modulePath)
|
|
254
|
+
.split("/")
|
|
255
|
+
.filter((part) => part !== "");
|
|
256
|
+
const idx = parts.lastIndexOf("node_modules");
|
|
257
|
+
return idx < 0 ? undefined : `/${parts.slice(0, idx + 1).join("/")}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* `path` as a worker session sees it through the harness binding, or
|
|
262
|
+
* `undefined` when it is not inside `packageRoot` — a test seam pointing at a
|
|
263
|
+
* file elsewhere, or a source checkout with no install root at all.
|
|
264
|
+
*
|
|
265
|
+
* Production uses {@link WORKER_HARNESS_NODE_MODULES}; an explicit binding
|
|
266
|
+
* root lets the Linux regression build the same inode-sharing tree under its
|
|
267
|
+
* private temporary directory instead of touching the live host mount.
|
|
268
|
+
*/
|
|
269
|
+
export function workerHarnessPath(
|
|
270
|
+
path: string,
|
|
271
|
+
packageRoot: string | undefined,
|
|
272
|
+
bindingRoot: string = WORKER_HARNESS_NODE_MODULES,
|
|
273
|
+
): string | undefined {
|
|
274
|
+
if (packageRoot === undefined) return undefined;
|
|
275
|
+
const absolute = resolve(path);
|
|
276
|
+
const prefix = `${packageRoot}/`;
|
|
277
|
+
if (!absolute.startsWith(prefix)) return undefined;
|
|
278
|
+
return join(bindingRoot, absolute.slice(prefix.length));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** One path's filesystem identity. Identity rather than bytes, because that is
|
|
282
|
+
* exactly what distinguishes a live bind of the operator's install (same
|
|
283
|
+
* device and inode) from an empty mount point or a copy that has drifted. */
|
|
284
|
+
function pathIdentity(path: string): string | undefined {
|
|
285
|
+
try {
|
|
286
|
+
const st = statSync(path);
|
|
287
|
+
return `${st.dev}:${st.ino}`;
|
|
288
|
+
} catch {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Read-only facts {@link harnessBindingProblem} decides on; injected by tests. */
|
|
294
|
+
export interface HarnessBindingDeps {
|
|
295
|
+
/** This module's own directory — the install root is derived from it. */
|
|
296
|
+
moduleDir?: string;
|
|
297
|
+
/** `dev:ino` of one path, or `undefined` when it cannot be stat'ed. */
|
|
298
|
+
identity?: (path: string) => string | undefined;
|
|
299
|
+
/** Alternate binding root for the isolated Linux currentness regression. */
|
|
300
|
+
bindingRoot?: string;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Why a worker session could not load the operator's harness through the
|
|
305
|
+
* binding, or `undefined` when it can.
|
|
306
|
+
*
|
|
307
|
+
* Both halves of the launch are checked, by filesystem identity: this package's
|
|
308
|
+
* own directory (the child's entry module comes from it) and the harness
|
|
309
|
+
* package directory (its peer import resolves to it). A mount point that is
|
|
310
|
+
* empty, stale, or bound to some other tree fails on the identity comparison
|
|
311
|
+
* rather than being taken on faith — which is the whole point, since the
|
|
312
|
+
* symptom this replaces was a *successful* import of the wrong build.
|
|
313
|
+
*/
|
|
314
|
+
export function harnessBindingProblem(deps: HarnessBindingDeps = {}): string | undefined {
|
|
315
|
+
const moduleDir = deps.moduleDir ?? import.meta.dir;
|
|
316
|
+
const identity = deps.identity ?? pathIdentity;
|
|
317
|
+
const bindingRoot = deps.bindingRoot ?? WORKER_HARNESS_NODE_MODULES;
|
|
318
|
+
const packageRoot = packageNodeModulesRoot(moduleDir);
|
|
319
|
+
if (packageRoot === undefined) {
|
|
320
|
+
return (
|
|
321
|
+
`omp-conductor is running from ${moduleDir}, which is not inside a node_modules install root — ` +
|
|
322
|
+
"a worker session resolves its harness through a read-only bind of that root, so worker dispatch " +
|
|
323
|
+
"needs the installed package (install omp-conductor with its harness peer, then re-run `omp-conductor setup host`)"
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
for (const dir of [moduleDir, join(packageRoot, OMP_HARNESS_PACKAGE)]) {
|
|
327
|
+
const source = identity(dir);
|
|
328
|
+
if (source === undefined) {
|
|
329
|
+
return (
|
|
330
|
+
`${dir} is missing or unreadable — ${OMP_HARNESS_PACKAGE} must be installed alongside omp-conductor ` +
|
|
331
|
+
"for a worker session to load the same harness build the operator runs"
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
// Non-null by construction: `dir` is inside `packageRoot`.
|
|
335
|
+
const bound = workerHarnessPath(dir, packageRoot, bindingRoot) ?? dir;
|
|
336
|
+
if (identity(bound) !== source) {
|
|
337
|
+
return (
|
|
338
|
+
`${bound} does not resolve to ${dir} — the worker harness binding of ${packageRoot} at ` +
|
|
339
|
+
`${bindingRoot} is missing or stale, so a worker session would resolve a different ` +
|
|
340
|
+
"harness build (or none). Run `omp-conductor setup host` to establish it"
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
|
package/src/lifecycle.ts
CHANGED
|
@@ -53,7 +53,7 @@ const READY_TIMEOUT_MS = 15_000;
|
|
|
53
53
|
const READY_POLL_MS = 250;
|
|
54
54
|
|
|
55
55
|
/** `/healthz` is a local, in-memory answer; a slow one means something is wrong. */
|
|
56
|
-
const HEALTH_TIMEOUT_MS = 1_500;
|
|
56
|
+
export const HEALTH_TIMEOUT_MS = 1_500;
|
|
57
57
|
|
|
58
58
|
/** Default grace period between `SIGTERM` and `SIGKILL`. */
|
|
59
59
|
const STOP_TIMEOUT_MS = 10_000;
|
|
@@ -963,6 +963,127 @@ export function systemdMainPid(unit = SYSTEMD_UNIT): number | undefined {
|
|
|
963
963
|
return ownership.kind === "active" ? ownership.pid : undefined;
|
|
964
964
|
}
|
|
965
965
|
|
|
966
|
+
/**
|
|
967
|
+
* The run-control endpoint the `worker` verbs may address.
|
|
968
|
+
*
|
|
969
|
+
* `record` is the pidfile's own answer: the record port is authoritative
|
|
970
|
+
* because the daemon itself wrote it, and the record's `project` (when set)
|
|
971
|
+
* rides along so callers keep refusing a record pinned to another project
|
|
972
|
+
* exactly as before. `unit` is the systemd answer for a missing or stale
|
|
973
|
+
* record: the unit is active, its MainPID is alive, and `/healthz` on the
|
|
974
|
+
* probe port proved a conductor daemon serving `expectedProject` — the port
|
|
975
|
+
* was located, not assumed. `unserved` is that same live unit when no port
|
|
976
|
+
* proved itself: the daemon is running but its control address cannot be
|
|
977
|
+
* located, so callers must refuse rather than guess. `unknown` is the
|
|
978
|
+
* manager-failure answer: the unit's ownership could not be read (bus,
|
|
979
|
+
* permission, timeout), so neither running nor stopped is provable and the
|
|
980
|
+
* callers must refuse — collapsing that into `none` is how a control verb
|
|
981
|
+
* lies "daemon is not running" while the unit is up. `none` is the confirmed
|
|
982
|
+
* negative: no record, and no unit with a live MainPID.
|
|
983
|
+
*/
|
|
984
|
+
export type DaemonControlTarget =
|
|
985
|
+
| { kind: "record"; pid: number; port: number; project?: string }
|
|
986
|
+
| { kind: "unit"; pid: number; port: number }
|
|
987
|
+
| { kind: "unserved"; pid: number; port: number }
|
|
988
|
+
| { kind: "unknown"; reason: string }
|
|
989
|
+
| { kind: "none" };
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* The port a unit-run daemon is probed on when the pidfile is absent: the
|
|
993
|
+
* generated unit always pins `--port` 8787 (`DEFAULT_PORT`), so that is the
|
|
994
|
+
* default candidate. The port is verified by `/healthz` before it is used —
|
|
995
|
+
* a listener is never trusted for being on the default port alone (#811).
|
|
996
|
+
*
|
|
997
|
+
* A test process redirects the probe with `OMP_CONDUCTOR_TEST_DAEMON_PORT`,
|
|
998
|
+
* the same seam discipline as `OMP_CONDUCTOR_TEST_SYSTEMCTL_STATE`: a CLI
|
|
999
|
+
* test cannot bind the real 8787 (the host's actual daemon may answer, the
|
|
1000
|
+
* #399 failure mode), so its fake unit daemon serves on an ephemeral port
|
|
1001
|
+
* that the state names.
|
|
1002
|
+
*/
|
|
1003
|
+
function controlProbePort(): number {
|
|
1004
|
+
if (process.env["NODE_ENV"] === "test") {
|
|
1005
|
+
const override = process.env["OMP_CONDUCTOR_TEST_DAEMON_PORT"];
|
|
1006
|
+
if (override !== undefined && override.trim() !== "") {
|
|
1007
|
+
const port = Number.parseInt(override.trim(), 10);
|
|
1008
|
+
if (Number.isInteger(port) && port > 0 && port <= 65535) return port;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return DEFAULT_PORT;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Resolve the running daemon's pid + port for the run-control verbs, or prove
|
|
1016
|
+
* the daemon is not running. The pidfile is authoritative while the process
|
|
1017
|
+
* it names is alive; when it is missing or stale, the systemd unit is the
|
|
1018
|
+
* liveness witness — the same consultation `status` performs (#716): an
|
|
1019
|
+
* active unit whose MainPID is alive proves the daemon runs even though no
|
|
1020
|
+
* record pins its port. The port is never assumed: `/healthz` on the probe
|
|
1021
|
+
* port must prove a conductor daemon answering for `expectedProject` before
|
|
1022
|
+
* that address is offered as something to operate (#811).
|
|
1023
|
+
*
|
|
1024
|
+
* Never throws — the caller decides whether `unserved` (a live unit with no
|
|
1025
|
+
* proved endpoint) or `none` (nothing running) is the answer to give.
|
|
1026
|
+
*/
|
|
1027
|
+
export async function daemonControlTarget(
|
|
1028
|
+
expectedProject?: string,
|
|
1029
|
+
): Promise<DaemonControlTarget> {
|
|
1030
|
+
const record = livingDaemon();
|
|
1031
|
+
if (record !== undefined) {
|
|
1032
|
+
return {
|
|
1033
|
+
kind: "record",
|
|
1034
|
+
pid: record.pid,
|
|
1035
|
+
port: record.port,
|
|
1036
|
+
...(record.project === undefined ? {} : { project: record.project }),
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
const ownership = probeUnit();
|
|
1040
|
+
// A manager query the manager could not answer is not "no unit": collapsing
|
|
1041
|
+
// it into `none` is exactly how a control verb reports a live unit-owned
|
|
1042
|
+
// daemon stopped. The caller must refuse on the honest indeterminacy.
|
|
1043
|
+
if (ownership.kind === "unknown") return { kind: "unknown", reason: ownership.reason };
|
|
1044
|
+
if (ownership.kind !== "active" || !isAlive(ownership.pid)) return { kind: "none" };
|
|
1045
|
+
const port = controlProbePort();
|
|
1046
|
+
const health = await healthCheck(port);
|
|
1047
|
+
if (!health.ok || !healthServesProject(health.body, expectedProject)) {
|
|
1048
|
+
return { kind: "unserved", pid: ownership.pid, port };
|
|
1049
|
+
}
|
|
1050
|
+
return { kind: "unit", pid: ownership.pid, port };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* The reachable { pid, port } a run-control verb may operate, or the refusal
|
|
1055
|
+
* that names why it cannot. The refusal language is one shared place so
|
|
1056
|
+
* `worker` and `extend` cannot drift apart on the same endpoint questions:
|
|
1057
|
+
* `none` is the plain "not running"; a record pinned to another project is
|
|
1058
|
+
* the same refusal the verbs always gave; an unprovable live unit and an
|
|
1059
|
+
* unknown manager state both fail closed instead of contacting anything or
|
|
1060
|
+
* claiming the daemon stopped.
|
|
1061
|
+
*/
|
|
1062
|
+
export async function requireDaemonControl(
|
|
1063
|
+
expectedProject: string,
|
|
1064
|
+
): Promise<{ pid: number; port: number }> {
|
|
1065
|
+
const target = await daemonControlTarget(expectedProject);
|
|
1066
|
+
if (target.kind === "none") throw new Error("daemon is not running");
|
|
1067
|
+
if (target.kind === "unknown") {
|
|
1068
|
+
throw new Error(
|
|
1069
|
+
`cannot determine whether ${SYSTEMD_UNIT} is running (${target.reason}) — ` +
|
|
1070
|
+
`refusing to send run control while liveness is unknown; ` +
|
|
1071
|
+
`retry when systemctl answers, or check \`systemctl status ${SYSTEMD_UNIT}\``,
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
if (target.kind === "record" && target.project !== undefined && target.project !== expectedProject) {
|
|
1075
|
+
throw new Error(
|
|
1076
|
+
`daemon serves project "${target.project}", not requested project "${expectedProject}"`,
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
if (target.kind === "unserved") {
|
|
1080
|
+
throw new Error(
|
|
1081
|
+
`daemon is running (${SYSTEMD_UNIT} pid ${target.pid}) but not serving /healthz for project "${expectedProject}" on port ${target.port} — no run-control endpoint could be located, so nothing was changed`,
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
return { pid: target.pid, port: target.port };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
966
1087
|
// ---------------------------------------------------------------------------
|
|
967
1088
|
// internals
|
|
968
1089
|
// ---------------------------------------------------------------------------
|
package/src/omp-settings.ts
CHANGED
|
@@ -36,6 +36,25 @@ import type { ProjectConfig } from "./types.ts";
|
|
|
36
36
|
/** The overlay filename inside a run's session directory. */
|
|
37
37
|
export const OMP_SETTINGS_FILE = "omp-settings.yml";
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The OMP model-role names one settings map declares — the keys of its
|
|
41
|
+
* `modelRoles` stanza (#875). This is the one grammar every surface that
|
|
42
|
+
* names an adjudicator role reads: omp's own `modelRoles` config, whether it
|
|
43
|
+
* sits in the daemon account's global settings, a project's overlay, or both.
|
|
44
|
+
* Anything that is not a flat string-map names no roles at all — an overlay
|
|
45
|
+
* whose `modelRoles` is, say, a YAML list is omp's to reject, and offering
|
|
46
|
+
* garbage role names here would only train the setup dialog on values OMP
|
|
47
|
+
* would refuse. `null` and any other non-object input — including an empty
|
|
48
|
+
* or comments-only settings file whose YAML parses to `null` — is zero
|
|
49
|
+
* roles, never a fault.
|
|
50
|
+
*/
|
|
51
|
+
export function modelRolesIn(settings: unknown): readonly string[] {
|
|
52
|
+
if (settings === null || typeof settings !== "object" || Array.isArray(settings)) return [];
|
|
53
|
+
const roles = (settings as Record<string, unknown>)["modelRoles"];
|
|
54
|
+
if (roles === null || typeof roles !== "object" || Array.isArray(roles)) return [];
|
|
55
|
+
return Object.keys(roles as Record<string, unknown>);
|
|
56
|
+
}
|
|
57
|
+
|
|
39
58
|
/**
|
|
40
59
|
* The effective overlay map for a project: the opaque `ompSettings` map plus
|
|
41
60
|
* the retry keys (`retry.modelFallback`, `retry.fallbackChains.default`)
|