omp-conductor 0.17.1 → 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 +71 -17
- package/agents/to-spec.md +90 -0
- package/package.json +2 -1
- package/schema/config.schema.json +53 -1
- package/src/admission.ts +308 -76
- package/src/ask.ts +307 -10
- package/src/backups.ts +2 -2
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +43 -14
- package/src/briefs/to-spec.md +84 -0
- package/src/briefs/worker.md +37 -19
- package/src/cli.ts +2 -0
- package/src/command-help.ts +19 -1
- package/src/command-manifest.ts +27 -2
- 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 +110 -3
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +57 -0
- package/src/config.ts +102 -2
- package/src/daemon.ts +1220 -1517
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +279 -16
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +425 -1
- package/src/digest-schedule.ts +37 -0
- package/src/doctor.ts +52 -0
- package/src/escalate.ts +9 -3
- package/src/failure-class.ts +43 -4
- package/src/fleet.ts +166 -24
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +55 -8
- package/src/graph.ts +379 -69
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +567 -2
- package/src/lifecycle.ts +158 -6
- package/src/omp.ts +269 -20
- package/src/orchestrator-tick.ts +1489 -26
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/routing.ts +11 -3
- package/src/session-host.ts +115 -5
- package/src/settlement.ts +1780 -0
- package/src/setup-host.ts +1205 -6
- package/src/setup-install.ts +119 -30
- package/src/setup-wizard.ts +88 -2
- package/src/setup.ts +119 -13
- package/src/shell.ts +15 -0
- package/src/status-render.ts +100 -11
- package/src/store.ts +519 -45
- package/src/to-spec.ts +387 -0
- package/src/tracker/github.ts +150 -14
- package/src/types.ts +470 -16
- 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 +770 -40
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +239 -9
- package/src/worktree.ts +142 -18
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;
|
|
@@ -340,21 +340,52 @@ export function acquireOnceLease(
|
|
|
340
340
|
throw new Error("could not acquire the daemon lease — a stale holder kept reappearing");
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Why a health probe came back negative. A timeout means the process is up
|
|
345
|
+
* but not answering (wedged or overloaded); a refusal means nothing is
|
|
346
|
+
* listening on the port at all. Everything else — a 500, a torn response —
|
|
347
|
+
* is `other`, a health answer rather than a probe verdict.
|
|
348
|
+
*/
|
|
349
|
+
export type HealthCheckFailure = "timeout" | "refused" | "other";
|
|
350
|
+
|
|
351
|
+
export type HealthCheckResult =
|
|
352
|
+
| { ok: true; body?: string }
|
|
353
|
+
| { ok: false; failure: HealthCheckFailure; body?: string };
|
|
354
|
+
|
|
343
355
|
/**
|
|
344
356
|
* Probes the daemon's own health endpoint. Never throws: a refused connection,
|
|
345
357
|
* a DNS-less host, a hung socket and a 500 are all just "not healthy", and the
|
|
346
|
-
* callers of this are the ones responsible for saying so nicely.
|
|
358
|
+
* callers of this are the ones responsible for saying so nicely. The `failure`
|
|
359
|
+
* kind lets a caller tell "up but slow" (timeout) from "nothing listening"
|
|
360
|
+
* (refused) instead of collapsing both into `{ ok: false }` (#716).
|
|
347
361
|
*/
|
|
348
|
-
export async function healthCheck(port: number): Promise<
|
|
362
|
+
export async function healthCheck(port: number): Promise<HealthCheckResult> {
|
|
349
363
|
try {
|
|
350
364
|
const res = await fetch(`http://127.0.0.1:${port}/healthz`, {
|
|
351
365
|
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
|
352
366
|
});
|
|
353
367
|
const body = (await res.text()).trim();
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
368
|
+
if (!res.ok) {
|
|
369
|
+
return { ok: false, failure: "other", ...(body.length > 0 ? { body } : {}) };
|
|
370
|
+
}
|
|
371
|
+
return { ok: true, ...(body.length > 0 ? { body } : {}) };
|
|
372
|
+
} catch (err) {
|
|
373
|
+
return { ok: false, failure: healthFailureKind(err) };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Classify a fetch rejection: the timeout signal raises `TimeoutError`; a
|
|
379
|
+
* refused connection is `ConnectionRefused` under Bun and `ECONNREFUSED`
|
|
380
|
+
* under undici. Anything else is an unclassified probe failure.
|
|
381
|
+
*/
|
|
382
|
+
function healthFailureKind(err: unknown): HealthCheckFailure {
|
|
383
|
+
if (err instanceof Error && err.name === "TimeoutError") return "timeout";
|
|
384
|
+
const code = (err as { code?: unknown } | null)?.code;
|
|
385
|
+
if (code === "ConnectionRefused" || code === "ECONNREFUSED" || code === "ECONNRESET") {
|
|
386
|
+
return "refused";
|
|
357
387
|
}
|
|
388
|
+
return "other";
|
|
358
389
|
}
|
|
359
390
|
|
|
360
391
|
/** Whether a `/healthz` body names `project` as served: the daemon's health
|
|
@@ -932,6 +963,127 @@ export function systemdMainPid(unit = SYSTEMD_UNIT): number | undefined {
|
|
|
932
963
|
return ownership.kind === "active" ? ownership.pid : undefined;
|
|
933
964
|
}
|
|
934
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
|
+
|
|
935
1087
|
// ---------------------------------------------------------------------------
|
|
936
1088
|
// internals
|
|
937
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
|
/**
|
|
@@ -197,16 +278,47 @@ export async function createLocalSession(opts: {
|
|
|
197
278
|
* session has no route to a mutation to scope in the first place.
|
|
198
279
|
*/
|
|
199
280
|
readOnly?: boolean;
|
|
281
|
+
/**
|
|
282
|
+
* The structured-settlement contract (#540): a JSON Schema the session's
|
|
283
|
+
* `yield` tool validates its `data` payload against. The worker yields its
|
|
284
|
+
* settlement as structured output instead of writing prose for the caller to
|
|
285
|
+
* regex. A subagent-oriented harness option, forwarded as-is.
|
|
286
|
+
*/
|
|
287
|
+
outputSchema?: unknown;
|
|
288
|
+
/**
|
|
289
|
+
* Enforcement policy for {@link outputSchema}. The worker contract is
|
|
290
|
+
* `"permissive"`: a schema violation must still settle with whatever the
|
|
291
|
+
* worker produced, never discard the report.
|
|
292
|
+
*/
|
|
293
|
+
outputSchemaMode?: "permissive" | "strict";
|
|
294
|
+
/**
|
|
295
|
+
* Force the hidden `yield` tool into this session's toolset (#540). Without
|
|
296
|
+
* it the model has no sanctioned way to submit structured output.
|
|
297
|
+
*/
|
|
298
|
+
requireYieldTool?: boolean;
|
|
200
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);
|
|
201
314
|
let loaded: unknown;
|
|
202
315
|
try {
|
|
203
|
-
// Dynamic import is load-bearing
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
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);
|
|
207
319
|
} catch (cause) {
|
|
208
320
|
throw new Error(
|
|
209
|
-
`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).`,
|
|
210
322
|
{ cause },
|
|
211
323
|
);
|
|
212
324
|
}
|
|
@@ -290,6 +402,13 @@ export async function createLocalSession(opts: {
|
|
|
290
402
|
// agentDir (~/.omp/agent/mcp.json) — without this, workers grep-only and
|
|
291
403
|
// burn the turns cap on discovery (#29).
|
|
292
404
|
enableMCP: true,
|
|
405
|
+
// The structured-settlement contract (#540): subagent-oriented options
|
|
406
|
+
// threaded to the harness so the worker can yield its report as a schema-
|
|
407
|
+
// validated object instead of prose. Absent for every other session kind —
|
|
408
|
+
// probes and orchestrator sessions never yield.
|
|
409
|
+
...(opts.outputSchema === undefined ? {} : { outputSchema: opts.outputSchema }),
|
|
410
|
+
...(opts.outputSchemaMode === undefined ? {} : { outputSchemaMode: opts.outputSchemaMode }),
|
|
411
|
+
...(opts.requireYieldTool === undefined ? {} : { requireYieldTool: opts.requireYieldTool }),
|
|
293
412
|
// Mechanical tool gates: confinement prevents structured worktree I/O from
|
|
294
413
|
// escaping cwd; release policy blocks release/deploy calls when configured.
|
|
295
414
|
...(extensions.length === 0 ? {} : { extensions }),
|
|
@@ -304,6 +423,19 @@ export async function createLocalSession(opts: {
|
|
|
304
423
|
const modelFallbackMessage =
|
|
305
424
|
typeof fallback === "string" && fallback !== "" ? fallback : undefined;
|
|
306
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
|
+
|
|
307
439
|
// One real subscription fanned out per event type, so N `on()` calls cost one
|
|
308
440
|
// listener on the harness stream and unknown event types cost nothing.
|
|
309
441
|
const handlers = new Map<string, ((e: unknown) => void)[]>();
|
|
@@ -314,6 +446,7 @@ export async function createLocalSession(opts: {
|
|
|
314
446
|
for (const cb of handlers.get("*") ?? []) cb(event);
|
|
315
447
|
});
|
|
316
448
|
|
|
449
|
+
const loadedHarnessVersion = harnessVersion(harnessPath);
|
|
317
450
|
const session: AgentSessionLike = {
|
|
318
451
|
prompt: (text, promptOpts) => raw.prompt(text, promptOpts),
|
|
319
452
|
on(event, cb) {
|
|
@@ -329,6 +462,12 @@ export async function createLocalSession(opts: {
|
|
|
329
462
|
raw.abort({ goalReason: "interrupted", reason: "operator pause" }),
|
|
330
463
|
).then(() => undefined);
|
|
331
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 }),
|
|
332
471
|
// The path the session actually opened, never one we asked for: the
|
|
333
472
|
// arm/monitor tooling reads this file as proof of activity, so a path
|
|
334
473
|
// nothing ever writes to is worse than no path at all.
|
|
@@ -336,6 +475,7 @@ export async function createLocalSession(opts: {
|
|
|
336
475
|
return raw.sessionFile;
|
|
337
476
|
},
|
|
338
477
|
...(modelFallbackMessage === undefined ? {} : { modelFallbackMessage }),
|
|
478
|
+
graphToolsReady,
|
|
339
479
|
};
|
|
340
480
|
|
|
341
481
|
disposers.set(session, async () => {
|
|
@@ -414,6 +554,28 @@ function asRawSession(created: unknown): RawSession {
|
|
|
414
554
|
/** Absolute path of the child entrypoint, resolved against this package. */
|
|
415
555
|
export const SESSION_HOST = join(import.meta.dir, "session-host.ts");
|
|
416
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
|
+
|
|
417
579
|
/**
|
|
418
580
|
* How long the parent waits for a child to connect and report `ready`.
|
|
419
581
|
*
|
|
@@ -453,6 +615,18 @@ export interface CreateSessionOptions {
|
|
|
453
615
|
model?: string;
|
|
454
616
|
resume?: boolean;
|
|
455
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;
|
|
456
630
|
releaseGrants?: ResolvedGrants;
|
|
457
631
|
onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
|
|
458
632
|
/**
|
|
@@ -508,6 +682,12 @@ export interface CreateSessionOptions {
|
|
|
508
682
|
* without a live harness or a model bill.
|
|
509
683
|
*/
|
|
510
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;
|
|
511
691
|
/**
|
|
512
692
|
* Deny every tool but reading and searching (#307).
|
|
513
693
|
*
|
|
@@ -515,6 +695,20 @@ export interface CreateSessionOptions {
|
|
|
515
695
|
* gate — actually lives.
|
|
516
696
|
*/
|
|
517
697
|
readOnly?: boolean;
|
|
698
|
+
/**
|
|
699
|
+
* The structured-settlement contract (#540): a JSON Schema the session's
|
|
700
|
+
* `yield` tool validates its `data` payload against. Forwarded to the child
|
|
701
|
+
* through the host spec, exactly like {@link ompSettingsFile}.
|
|
702
|
+
*/
|
|
703
|
+
outputSchema?: unknown;
|
|
704
|
+
/**
|
|
705
|
+
* Enforcement policy for {@link outputSchema} — the worker contract is
|
|
706
|
+
* `"permissive"`, so a schema violation settles with whatever the worker
|
|
707
|
+
* produced instead of losing the report.
|
|
708
|
+
*/
|
|
709
|
+
outputSchemaMode?: "permissive" | "strict";
|
|
710
|
+
/** Force the hidden `yield` tool into this session's toolset (#540). */
|
|
711
|
+
requireYieldTool?: boolean;
|
|
518
712
|
}
|
|
519
713
|
|
|
520
714
|
/**
|
|
@@ -587,13 +781,22 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
587
781
|
);
|
|
588
782
|
}
|
|
589
783
|
// The socket is the run's own channel, and the daemon's own uid is the only
|
|
590
|
-
// 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.
|
|
591
788
|
chmodSync(socketPath, 0o600);
|
|
789
|
+
if (opts.identity !== undefined) {
|
|
790
|
+
chownSync(socketPath, opts.identity.uid, opts.identity.gid);
|
|
791
|
+
}
|
|
592
792
|
|
|
593
793
|
const spec: SessionHostSpec = {
|
|
594
794
|
socket: socketPath,
|
|
595
795
|
cwd: opts.cwd,
|
|
596
796
|
role: opts.role,
|
|
797
|
+
...(opts.identity === undefined
|
|
798
|
+
? {}
|
|
799
|
+
: { identity: { uid: opts.identity.uid, gid: opts.identity.gid } }),
|
|
597
800
|
...(opts.sessionDir === undefined ? {} : { sessionDir: opts.sessionDir }),
|
|
598
801
|
...(opts.model === undefined ? {} : { model: opts.model }),
|
|
599
802
|
...(opts.resume === undefined ? {} : { resume: opts.resume }),
|
|
@@ -601,17 +804,41 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
601
804
|
...(opts.verbSocketPath === undefined ? {} : { verbSocketPath: opts.verbSocketPath }),
|
|
602
805
|
...(opts.readOnly === undefined ? {} : { readOnly: opts.readOnly }),
|
|
603
806
|
...(opts.ompSettingsFile === undefined ? {} : { ompSettingsFile: opts.ompSettingsFile }),
|
|
807
|
+
...(opts.outputSchema === undefined ? {} : { outputSchema: opts.outputSchema }),
|
|
808
|
+
...(opts.outputSchemaMode === undefined ? {} : { outputSchemaMode: opts.outputSchemaMode }),
|
|
809
|
+
...(opts.requireYieldTool === undefined ? {} : { requireYieldTool: opts.requireYieldTool }),
|
|
604
810
|
};
|
|
605
811
|
|
|
606
812
|
const log = opts.onChildLog ?? ((line: string) => process.stderr.write(`${line}\n`));
|
|
607
|
-
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
|
+
}
|
|
608
839
|
const child = Bun.spawn(argv, {
|
|
609
840
|
cwd: opts.cwd,
|
|
610
|
-
|
|
611
|
-
// `omp-conductor report` from its sandbox — can tell a worker session from
|
|
612
|
-
// the operator's shell. Direct CLI runs outside a spawned session inherit
|
|
613
|
-
// nothing and stay the orchestrator surface.
|
|
614
|
-
env: opts.role === undefined ? undefined : { ...process.env, [SESSION_ROLE_ENV]: opts.role },
|
|
841
|
+
env,
|
|
615
842
|
stdin: "ignore",
|
|
616
843
|
// The harness writes progress to stdout; both streams are the daemon's log,
|
|
617
844
|
// never the protocol. The protocol has its own socket precisely so a chatty
|
|
@@ -741,6 +968,10 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
741
968
|
const pending = new Map<number, { resolve: () => void; reject: (err: Error) => void }>();
|
|
742
969
|
let sessionFile: string | undefined;
|
|
743
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;
|
|
744
975
|
let promptSeq = 0;
|
|
745
976
|
|
|
746
977
|
const { promise: ready, resolve: onReady, reject: onReadyFail } = Promise.withResolvers<void>();
|
|
@@ -844,8 +1075,14 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
844
1075
|
case "ready":
|
|
845
1076
|
sessionFile = message.sessionFile;
|
|
846
1077
|
modelFallbackMessage = message.modelFallbackMessage;
|
|
1078
|
+
extensionVersion = message.extensionVersion;
|
|
1079
|
+
loadedHarnessPath = message.harnessPath;
|
|
1080
|
+
loadedHarnessVersion = message.harnessVersion;
|
|
847
1081
|
onReady();
|
|
848
1082
|
break;
|
|
1083
|
+
case "graph-tools":
|
|
1084
|
+
graphTools = message.graphTools;
|
|
1085
|
+
break;
|
|
849
1086
|
case "start-error":
|
|
850
1087
|
fail(message.message);
|
|
851
1088
|
break;
|
|
@@ -939,6 +1176,18 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
939
1176
|
get modelFallbackMessage() {
|
|
940
1177
|
return modelFallbackMessage;
|
|
941
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
|
+
},
|
|
942
1191
|
};
|
|
943
1192
|
|
|
944
1193
|
disposers.set(session, async () => {
|