omp-conductor 0.18.0 → 0.18.1
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 +34 -0
- package/REFERENCE.md +60 -10
- package/agents/to-spec.md +90 -0
- package/package.json +2 -1
- package/schema/config.schema.json +29 -0
- package/src/admission.ts +204 -75
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +42 -14
- package/src/briefs/to-spec.md +84 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +2 -0
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +22 -0
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +50 -2
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +24 -0
- package/src/config.ts +42 -1
- package/src/daemon.ts +965 -36
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +235 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +52 -0
- package/src/escalate.ts +9 -3
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +146 -22
- 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 +567 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp.ts +227 -20
- package/src/orchestrator-tick.ts +1386 -15
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +99 -5
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1205 -6
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +13 -2
- package/src/setup.ts +29 -13
- package/src/shell.ts +15 -0
- package/src/status-render.ts +78 -11
- package/src/store.ts +443 -42
- package/src/to-spec.ts +387 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +343 -13
- 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 +730 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +25 -2
- package/src/worktree.ts +29 -12
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.ts
CHANGED
|
@@ -8,17 +8,23 @@
|
|
|
8
8
|
* `createAgentSession`, `subscribe` or `abort`, exactly one file breaks.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
* Harness package name. Held in a variable and cast to `string` at the call
|
|
13
|
-
* site on purpose: a non-literal specifier stops `tsc` from trying to resolve
|
|
14
|
-
* the module, which is the whole reason this shim exists.
|
|
15
|
-
*/
|
|
16
|
-
import { chmodSync, chownSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { chmodSync, chownSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
17
12
|
import { createServer, type Server, type Socket } from "node:net";
|
|
18
13
|
import { tmpdir } from "node:os";
|
|
19
14
|
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
20
16
|
|
|
17
|
+
import {
|
|
18
|
+
OMP_HARNESS_PACKAGE,
|
|
19
|
+
packageNodeModulesRoot,
|
|
20
|
+
workerHarnessPath,
|
|
21
|
+
workerLaunchArgv,
|
|
22
|
+
workerSessionEnv,
|
|
23
|
+
type WorkerIdentity,
|
|
24
|
+
} from "./host.ts";
|
|
25
|
+
import { harnessVersion, resolveHarnessEntry } from "./harness-loader.ts";
|
|
21
26
|
import { readOnlySession, worktreeConfinement } from "./confinement.ts";
|
|
27
|
+
import { observeGraphTools } from "./graph.ts";
|
|
22
28
|
import { releasePolicyTripwire, type GateShape, type ReleaseBlockContext } from "./release-policy.ts";
|
|
23
29
|
import type {
|
|
24
30
|
HostToParent,
|
|
@@ -26,11 +32,53 @@ import type {
|
|
|
26
32
|
SessionHostSpec,
|
|
27
33
|
} from "./session-host.ts";
|
|
28
34
|
import { decodeFrames, encodeFrame } from "./session-host.ts";
|
|
29
|
-
import type { ResolvedGrants, SessionRole } from "./types.ts";
|
|
35
|
+
import type { GraphToolsObservation, ResolvedGrants, SessionRole } from "./types.ts";
|
|
30
36
|
import { SESSION_ROLE_ENV } from "./types.ts";
|
|
31
37
|
import { conductorVerbs } from "./verbs/client.ts";
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Harness package name. Held in a variable and cast to `string` at the call
|
|
41
|
+
* site on purpose: a non-literal specifier stops `tsc` from trying to resolve
|
|
42
|
+
* the module, which is the whole reason this shim exists.
|
|
43
|
+
*/
|
|
44
|
+
const OMP_PACKAGE = OMP_HARNESS_PACKAGE;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Why the explicitly anchored harness entry is not the one installed beside
|
|
48
|
+
* omp-conductor, or `undefined` when it is — or when there is nothing to
|
|
49
|
+
* compare, because this package is running from a source checkout with no
|
|
50
|
+
* install root of its own.
|
|
51
|
+
*
|
|
52
|
+
* The failure it names is silent otherwise (#828): Bun's ambient bare-package
|
|
53
|
+
* resolution can choose its install cache even when the worker binding is
|
|
54
|
+
* mounted and inode-current. The production resolver is therefore
|
|
55
|
+
* {@link resolveHarnessEntry}, rooted at this module's directory; this check
|
|
56
|
+
* remains the fail-closed attestation that the resolved absolute path stayed
|
|
57
|
+
* inside the same install.
|
|
58
|
+
*/
|
|
59
|
+
export function harnessOutsideInstall(
|
|
60
|
+
moduleDir: string = import.meta.dir,
|
|
61
|
+
resolveSpecifier: (specifier: string) => string = () => resolveHarnessEntry(moduleDir),
|
|
62
|
+
): string | undefined {
|
|
63
|
+
const root = packageNodeModulesRoot(moduleDir);
|
|
64
|
+
if (root === undefined) return undefined;
|
|
65
|
+
let resolved: string;
|
|
66
|
+
try {
|
|
67
|
+
const result = resolveSpecifier(OMP_PACKAGE);
|
|
68
|
+
resolved = result.startsWith("file:") ? fileURLToPath(result) : result;
|
|
69
|
+
} catch {
|
|
70
|
+
// Nothing resolved at all. The import below reports that far better than a
|
|
71
|
+
// guess here could, and reporting it twice in two spellings is worse.
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
if (resolved.startsWith(`${root}/`)) return undefined;
|
|
75
|
+
return (
|
|
76
|
+
`omp-conductor could not load its peer dependency ${OMP_PACKAGE} from its own installation: it resolves to ` +
|
|
77
|
+
`${resolved}, outside ${root}. Refusing Bun's ambient auto-install cache prevents a different harness build ` +
|
|
78
|
+
"and native addon from standing in for the operator's install. Run `omp-conductor setup host` to verify the " +
|
|
79
|
+
"worker binding, or install the harness alongside omp-conductor."
|
|
80
|
+
);
|
|
81
|
+
}
|
|
34
82
|
|
|
35
83
|
/**
|
|
36
84
|
* The only session surface the dispatcher is allowed to know about: send one
|
|
@@ -73,6 +121,39 @@ export interface AgentSessionLike {
|
|
|
73
121
|
* downgrade, instead of it vanishing and a run just reading dumber.
|
|
74
122
|
*/
|
|
75
123
|
modelFallbackMessage?: string;
|
|
124
|
+
/**
|
|
125
|
+
* The code-graph session observation (#726): whether the graph MCP tools
|
|
126
|
+
* were in this session's registry, as a one-shot async fact. The harness
|
|
127
|
+
* tool registry settles late in a session's life (MCP wiring finalises
|
|
128
|
+
* after creation), so the in-process session under {@link createLocalSession}
|
|
129
|
+
* exposes this promise; the session-host child relays the resolved value
|
|
130
|
+
* over the wire, and the proxy session {@link createSession} exposes the
|
|
131
|
+
* settled observation as {@link graphTools}. Resolving `undefined` means no
|
|
132
|
+
* observation could be recorded (surface absent or never readable) — never
|
|
133
|
+
* "graph tools absent", which is the `present: false` truth value.
|
|
134
|
+
*/
|
|
135
|
+
graphToolsReady?: Promise<GraphToolsObservation | undefined>;
|
|
136
|
+
/**
|
|
137
|
+
* The code-graph session observation (#726): whether the graph MCP tools
|
|
138
|
+
* were in this session's registry. Set on the proxy session once the child's
|
|
139
|
+
* relayed observation lands. Absent means no observation was recorded — never
|
|
140
|
+
* "graph tools absent", which is the `present: false` truth value.
|
|
141
|
+
*/
|
|
142
|
+
graphTools?: GraphToolsObservation;
|
|
143
|
+
/**
|
|
144
|
+
* The version of the omp-conductor module this session process actually
|
|
145
|
+
* LOADED, attested by the live process at session start (#832): the module
|
|
146
|
+
* tree this session's tools execute out of, read from the package this
|
|
147
|
+
* process resolved — never from the package files on disk after the fact.
|
|
148
|
+
* Absent only when the version cannot be read (a hand-built fake, a
|
|
149
|
+
* packaging oddity) — absence is not "the newest", and an upgrade that
|
|
150
|
+
* cannot see the loaded version must not claim the session reloaded.
|
|
151
|
+
*/
|
|
152
|
+
extensionVersion?: string;
|
|
153
|
+
/** Absolute entry path of the harness module this session process loaded. */
|
|
154
|
+
harnessPath?: string;
|
|
155
|
+
/** Version read from the package owning {@link harnessPath}. */
|
|
156
|
+
harnessVersion?: string;
|
|
76
157
|
}
|
|
77
158
|
|
|
78
159
|
/**
|
|
@@ -216,15 +297,28 @@ export async function createLocalSession(opts: {
|
|
|
216
297
|
*/
|
|
217
298
|
requireYieldTool?: boolean;
|
|
218
299
|
}): Promise<AgentSessionLike> {
|
|
300
|
+
let harnessPath: string;
|
|
301
|
+
try {
|
|
302
|
+
// An explicit Bun-supported root is the resolver contract. A bare import
|
|
303
|
+
// from this same bound module was observed selecting the worker's ambient
|
|
304
|
+
// auto-install cache even while the peer beside it was readable (#828).
|
|
305
|
+
harnessPath = resolveHarnessEntry(import.meta.dir);
|
|
306
|
+
} catch (cause) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`omp-conductor could not resolve its peer dependency ${OMP_PACKAGE} from ${import.meta.dir}. Install it alongside omp-conductor (it is deliberately not bundled, so the dispatcher runs the same harness build as the operator).`,
|
|
309
|
+
{ cause },
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
const stray = harnessOutsideInstall(import.meta.dir, () => harnessPath);
|
|
313
|
+
if (stray !== undefined) throw new Error(stray);
|
|
219
314
|
let loaded: unknown;
|
|
220
315
|
try {
|
|
221
|
-
// Dynamic import is load-bearing
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
loaded = await import(OMP_PACKAGE as string);
|
|
316
|
+
// Dynamic import is load-bearing: the runtime-selected absolute path pins
|
|
317
|
+
// the operator's peer without making it a build-time dependency.
|
|
318
|
+
loaded = await import(harnessPath);
|
|
225
319
|
} catch (cause) {
|
|
226
320
|
throw new Error(
|
|
227
|
-
`omp-conductor could not load its peer dependency ${OMP_PACKAGE}. Install it alongside omp-conductor (it is deliberately not bundled, so the dispatcher runs the same harness build as the operator).`,
|
|
321
|
+
`omp-conductor could not load its peer dependency ${OMP_PACKAGE} from ${harnessPath}. Install it alongside omp-conductor (it is deliberately not bundled, so the dispatcher runs the same harness build as the operator).`,
|
|
228
322
|
{ cause },
|
|
229
323
|
);
|
|
230
324
|
}
|
|
@@ -329,6 +423,19 @@ export async function createLocalSession(opts: {
|
|
|
329
423
|
const modelFallbackMessage =
|
|
330
424
|
typeof fallback === "string" && fallback !== "" ? fallback : undefined;
|
|
331
425
|
|
|
426
|
+
// The code-graph session observation (#726): whether the graph MCP tools are
|
|
427
|
+
// in this session's registry. The harness tool registry is structural and
|
|
428
|
+
// settles late — MCP wiring finalises through a deferred discovery pass after
|
|
429
|
+
// `createAgentSession` resolves, so the read can throw in the creation
|
|
430
|
+
// window, and the graph tools surface under the *enabled* names, never the
|
|
431
|
+
// active ones. `observeGraphTools` polls that surface until it stops
|
|
432
|
+
// throwing, bounded; an absent surface (older harness build) or an
|
|
433
|
+
// unreadable one records no observation — a registry answer is never
|
|
434
|
+
// guessed. The observation resolves asynchronously so the session handshake
|
|
435
|
+
// never waits for it.
|
|
436
|
+
const sessionTools = raw as { getEnabledToolNames?: () => string[] };
|
|
437
|
+
const graphToolsReady = observeGraphTools(sessionTools.getEnabledToolNames?.bind(sessionTools));
|
|
438
|
+
|
|
332
439
|
// One real subscription fanned out per event type, so N `on()` calls cost one
|
|
333
440
|
// listener on the harness stream and unknown event types cost nothing.
|
|
334
441
|
const handlers = new Map<string, ((e: unknown) => void)[]>();
|
|
@@ -339,6 +446,7 @@ export async function createLocalSession(opts: {
|
|
|
339
446
|
for (const cb of handlers.get("*") ?? []) cb(event);
|
|
340
447
|
});
|
|
341
448
|
|
|
449
|
+
const loadedHarnessVersion = harnessVersion(harnessPath);
|
|
342
450
|
const session: AgentSessionLike = {
|
|
343
451
|
prompt: (text, promptOpts) => raw.prompt(text, promptOpts),
|
|
344
452
|
on(event, cb) {
|
|
@@ -354,6 +462,12 @@ export async function createLocalSession(opts: {
|
|
|
354
462
|
raw.abort({ goalReason: "interrupted", reason: "operator pause" }),
|
|
355
463
|
).then(() => undefined);
|
|
356
464
|
},
|
|
465
|
+
// The loaded-module attestation (#832): this session process executes the
|
|
466
|
+
// conductor module it resolved at start; the version is a property of this
|
|
467
|
+
// process's loaded tree, deliberately not of what is on disk now.
|
|
468
|
+
...(conductorModuleVersion() === undefined ? {} : { extensionVersion: conductorModuleVersion() }),
|
|
469
|
+
harnessPath,
|
|
470
|
+
...(loadedHarnessVersion === undefined ? {} : { harnessVersion: loadedHarnessVersion }),
|
|
357
471
|
// The path the session actually opened, never one we asked for: the
|
|
358
472
|
// arm/monitor tooling reads this file as proof of activity, so a path
|
|
359
473
|
// nothing ever writes to is worse than no path at all.
|
|
@@ -361,6 +475,7 @@ export async function createLocalSession(opts: {
|
|
|
361
475
|
return raw.sessionFile;
|
|
362
476
|
},
|
|
363
477
|
...(modelFallbackMessage === undefined ? {} : { modelFallbackMessage }),
|
|
478
|
+
graphToolsReady,
|
|
364
479
|
};
|
|
365
480
|
|
|
366
481
|
disposers.set(session, async () => {
|
|
@@ -439,6 +554,28 @@ function asRawSession(created: unknown): RawSession {
|
|
|
439
554
|
/** Absolute path of the child entrypoint, resolved against this package. */
|
|
440
555
|
export const SESSION_HOST = join(import.meta.dir, "session-host.ts");
|
|
441
556
|
|
|
557
|
+
/**
|
|
558
|
+
* The version of the omp-conductor package this *process* loaded — the module
|
|
559
|
+
* tree this process's session machinery actually executes, read from the
|
|
560
|
+
* package.json next to `import.meta.dir` (#832). Read once per process and
|
|
561
|
+
* cached: the upgrade's session-reload proof compares this against the target
|
|
562
|
+
* release to tell a genuinely reloaded OMP process from a package install the
|
|
563
|
+
* live session never picked up. `undefined` when the file cannot be read —
|
|
564
|
+
* absence is a missing attestation, never "the newest".
|
|
565
|
+
*/
|
|
566
|
+
let loadedConductorVersion: string | undefined;
|
|
567
|
+
export function conductorModuleVersion(): string | undefined {
|
|
568
|
+
if (loadedConductorVersion !== undefined) return loadedConductorVersion;
|
|
569
|
+
try {
|
|
570
|
+
const raw = readFileSync(join(import.meta.dir, "..", "package.json"), "utf8");
|
|
571
|
+
const parsed = JSON.parse(raw) as { version?: unknown };
|
|
572
|
+
loadedConductorVersion = typeof parsed.version === "string" ? parsed.version : undefined;
|
|
573
|
+
} catch {
|
|
574
|
+
loadedConductorVersion = undefined;
|
|
575
|
+
}
|
|
576
|
+
return loadedConductorVersion;
|
|
577
|
+
}
|
|
578
|
+
|
|
442
579
|
/**
|
|
443
580
|
* How long the parent waits for a child to connect and report `ready`.
|
|
444
581
|
*
|
|
@@ -478,6 +615,18 @@ export interface CreateSessionOptions {
|
|
|
478
615
|
model?: string;
|
|
479
616
|
resume?: boolean;
|
|
480
617
|
role: SessionRole;
|
|
618
|
+
/**
|
|
619
|
+
* The worker identity this session must run under (#798): when set, the
|
|
620
|
+
* child is launched through {@link workerLaunchArgv}'s identity transition
|
|
621
|
+
* (setpriv) before any of its code runs, its environment is re-pointed at
|
|
622
|
+
* the worker's own home/agent path, the control socket is granted to that
|
|
623
|
+
* uid, and the child refuses to build a session unless its kernel uid/gid
|
|
624
|
+
* match. The daemon resolves this identity once and threads it through every
|
|
625
|
+
* worker launch; a worker session is never launched without it. Absent, the
|
|
626
|
+
* session runs as this process's own identity — the orchestrator and every
|
|
627
|
+
* test surface.
|
|
628
|
+
*/
|
|
629
|
+
identity?: WorkerIdentity;
|
|
481
630
|
releaseGrants?: ResolvedGrants;
|
|
482
631
|
onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
|
|
483
632
|
/**
|
|
@@ -533,6 +682,12 @@ export interface CreateSessionOptions {
|
|
|
533
682
|
* without a live harness or a model bill.
|
|
534
683
|
*/
|
|
535
684
|
hostModule?: string;
|
|
685
|
+
/**
|
|
686
|
+
* Test-only worker binding root. Production always uses
|
|
687
|
+
* `WORKER_HARNESS_NODE_MODULES`; the Linux privilege regression supplies an
|
|
688
|
+
* inode-identical temporary binding so it never mutates the live host mount.
|
|
689
|
+
*/
|
|
690
|
+
harnessNodeModules?: string;
|
|
536
691
|
/**
|
|
537
692
|
* Deny every tool but reading and searching (#307).
|
|
538
693
|
*
|
|
@@ -626,13 +781,22 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
626
781
|
);
|
|
627
782
|
}
|
|
628
783
|
// The socket is the run's own channel, and the daemon's own uid is the only
|
|
629
|
-
// one that speaks on it
|
|
784
|
+
// one that speaks on it — unless this session runs under the worker
|
|
785
|
+
// identity, in which case the child (and only the child) is the worker uid,
|
|
786
|
+
// and the socket must be granted to it or the very first connect fails
|
|
787
|
+
// before the identity could matter.
|
|
630
788
|
chmodSync(socketPath, 0o600);
|
|
789
|
+
if (opts.identity !== undefined) {
|
|
790
|
+
chownSync(socketPath, opts.identity.uid, opts.identity.gid);
|
|
791
|
+
}
|
|
631
792
|
|
|
632
793
|
const spec: SessionHostSpec = {
|
|
633
794
|
socket: socketPath,
|
|
634
795
|
cwd: opts.cwd,
|
|
635
796
|
role: opts.role,
|
|
797
|
+
...(opts.identity === undefined
|
|
798
|
+
? {}
|
|
799
|
+
: { identity: { uid: opts.identity.uid, gid: opts.identity.gid } }),
|
|
636
800
|
...(opts.sessionDir === undefined ? {} : { sessionDir: opts.sessionDir }),
|
|
637
801
|
...(opts.model === undefined ? {} : { model: opts.model }),
|
|
638
802
|
...(opts.resume === undefined ? {} : { resume: opts.resume }),
|
|
@@ -646,14 +810,35 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
646
810
|
};
|
|
647
811
|
|
|
648
812
|
const log = opts.onChildLog ?? ((line: string) => process.stderr.write(`${line}\n`));
|
|
649
|
-
const
|
|
813
|
+
const hostModule = opts.hostModule ?? SESSION_HOST;
|
|
814
|
+
// Under the worker identity the child launches from the read-only binding of
|
|
815
|
+
// the operator's install (#828). The child then resolves the harness from an
|
|
816
|
+
// explicit root and runs with `--no-install`; the entry remap and resolver
|
|
817
|
+
// anchor therefore name the same tree, while Bun has no ambient-cache
|
|
818
|
+
// fallback for either the peer or its transitive/native imports.
|
|
819
|
+
const packageRoot = packageNodeModulesRoot(hostModule);
|
|
820
|
+
const boundEntry =
|
|
821
|
+
opts.harnessNodeModules === undefined
|
|
822
|
+
? workerHarnessPath(hostModule, packageRoot)
|
|
823
|
+
: workerHarnessPath(hostModule, packageRoot, opts.harnessNodeModules);
|
|
824
|
+
const entryModule = opts.identity === undefined ? hostModule : boundEntry ?? hostModule;
|
|
825
|
+
const payloadArgv = [process.execPath, "--no-install", entryModule, JSON.stringify(spec)];
|
|
826
|
+
const argv = workerLaunchArgv(payloadArgv, opts.identity);
|
|
827
|
+
// The session's own role is stamped on the child, so a process the agent
|
|
828
|
+
// runs — `omp-conductor report` from its sandbox — can tell a worker session
|
|
829
|
+
// from the operator's shell. Direct CLI runs outside a spawned session
|
|
830
|
+
// inherit nothing and stay the orchestrator surface. Under the worker
|
|
831
|
+
// identity, the child's HOME and agent/state roots are re-pointed at the
|
|
832
|
+
// worker account's own, so the harness it boots writes worker state, never
|
|
833
|
+
// the daemon's.
|
|
834
|
+
let env: Record<string, string | undefined> | undefined;
|
|
835
|
+
if (opts.role !== undefined) {
|
|
836
|
+
env = { ...process.env, [SESSION_ROLE_ENV]: opts.role };
|
|
837
|
+
if (opts.identity !== undefined) env = workerSessionEnv(env, opts.identity);
|
|
838
|
+
}
|
|
650
839
|
const child = Bun.spawn(argv, {
|
|
651
840
|
cwd: opts.cwd,
|
|
652
|
-
|
|
653
|
-
// `omp-conductor report` from its sandbox — can tell a worker session from
|
|
654
|
-
// the operator's shell. Direct CLI runs outside a spawned session inherit
|
|
655
|
-
// nothing and stay the orchestrator surface.
|
|
656
|
-
env: opts.role === undefined ? undefined : { ...process.env, [SESSION_ROLE_ENV]: opts.role },
|
|
841
|
+
env,
|
|
657
842
|
stdin: "ignore",
|
|
658
843
|
// The harness writes progress to stdout; both streams are the daemon's log,
|
|
659
844
|
// never the protocol. The protocol has its own socket precisely so a chatty
|
|
@@ -783,6 +968,10 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
783
968
|
const pending = new Map<number, { resolve: () => void; reject: (err: Error) => void }>();
|
|
784
969
|
let sessionFile: string | undefined;
|
|
785
970
|
let modelFallbackMessage: string | undefined;
|
|
971
|
+
let graphTools: GraphToolsObservation | undefined;
|
|
972
|
+
let extensionVersion: string | undefined;
|
|
973
|
+
let loadedHarnessPath: string | undefined;
|
|
974
|
+
let loadedHarnessVersion: string | undefined;
|
|
786
975
|
let promptSeq = 0;
|
|
787
976
|
|
|
788
977
|
const { promise: ready, resolve: onReady, reject: onReadyFail } = Promise.withResolvers<void>();
|
|
@@ -886,8 +1075,14 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
886
1075
|
case "ready":
|
|
887
1076
|
sessionFile = message.sessionFile;
|
|
888
1077
|
modelFallbackMessage = message.modelFallbackMessage;
|
|
1078
|
+
extensionVersion = message.extensionVersion;
|
|
1079
|
+
loadedHarnessPath = message.harnessPath;
|
|
1080
|
+
loadedHarnessVersion = message.harnessVersion;
|
|
889
1081
|
onReady();
|
|
890
1082
|
break;
|
|
1083
|
+
case "graph-tools":
|
|
1084
|
+
graphTools = message.graphTools;
|
|
1085
|
+
break;
|
|
891
1086
|
case "start-error":
|
|
892
1087
|
fail(message.message);
|
|
893
1088
|
break;
|
|
@@ -981,6 +1176,18 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
981
1176
|
get modelFallbackMessage() {
|
|
982
1177
|
return modelFallbackMessage;
|
|
983
1178
|
},
|
|
1179
|
+
get graphTools() {
|
|
1180
|
+
return graphTools;
|
|
1181
|
+
},
|
|
1182
|
+
get extensionVersion() {
|
|
1183
|
+
return extensionVersion;
|
|
1184
|
+
},
|
|
1185
|
+
get harnessPath() {
|
|
1186
|
+
return loadedHarnessPath;
|
|
1187
|
+
},
|
|
1188
|
+
get harnessVersion() {
|
|
1189
|
+
return loadedHarnessVersion;
|
|
1190
|
+
},
|
|
984
1191
|
};
|
|
985
1192
|
|
|
986
1193
|
disposers.set(session, async () => {
|