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.
Files changed (65) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +71 -17
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +53 -1
  6. package/src/admission.ts +308 -76
  7. package/src/ask.ts +307 -10
  8. package/src/backups.ts +2 -2
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +43 -14
  11. package/src/briefs/to-spec.md +84 -0
  12. package/src/briefs/worker.md +37 -19
  13. package/src/cli.ts +2 -0
  14. package/src/command-help.ts +19 -1
  15. package/src/command-manifest.ts +27 -2
  16. package/src/commands/context.ts +1 -0
  17. package/src/commands/drain.ts +176 -0
  18. package/src/commands/extend.ts +6 -10
  19. package/src/commands/status.ts +5 -1
  20. package/src/commands/watch.ts +110 -3
  21. package/src/commands/worker.ts +9 -10
  22. package/src/config-schema.ts +57 -0
  23. package/src/config.ts +102 -2
  24. package/src/daemon.ts +1220 -1517
  25. package/src/dashboard/app.js +4 -1
  26. package/src/dashboard/server.ts +5 -2
  27. package/src/decisions.ts +279 -16
  28. package/src/depends-on.ts +261 -1
  29. package/src/diff-flags.ts +425 -1
  30. package/src/digest-schedule.ts +37 -0
  31. package/src/doctor.ts +52 -0
  32. package/src/escalate.ts +9 -3
  33. package/src/failure-class.ts +43 -4
  34. package/src/fleet.ts +166 -24
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +55 -8
  37. package/src/graph.ts +379 -69
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +567 -2
  40. package/src/lifecycle.ts +158 -6
  41. package/src/omp.ts +269 -20
  42. package/src/orchestrator-tick.ts +1489 -26
  43. package/src/orchestrator.ts +12 -0
  44. package/src/privileged.ts +1 -4
  45. package/src/release-policy.ts +503 -9
  46. package/src/routing.ts +11 -3
  47. package/src/session-host.ts +115 -5
  48. package/src/settlement.ts +1780 -0
  49. package/src/setup-host.ts +1205 -6
  50. package/src/setup-install.ts +119 -30
  51. package/src/setup-wizard.ts +88 -2
  52. package/src/setup.ts +119 -13
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +100 -11
  55. package/src/store.ts +519 -45
  56. package/src/to-spec.ts +387 -0
  57. package/src/tracker/github.ts +150 -14
  58. package/src/types.ts +470 -16
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +770 -40
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +239 -9
  65. package/src/worktree.ts +142 -18
@@ -21,7 +21,9 @@
21
21
 
22
22
  import { connect } from "node:net";
23
23
 
24
+ import { identityMismatch } from "./host.ts";
24
25
  import { createLocalSession, disposeSession, type AgentSessionLike } from "./omp.ts";
26
+ import type { GraphToolsObservation } from "./types.ts";
25
27
  import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
26
28
  import type { ResolvedGrants, SessionRole } from "./types.ts";
27
29
 
@@ -34,6 +36,16 @@ export interface SessionHostSpec {
34
36
  socket: string;
35
37
  cwd: string;
36
38
  role: SessionRole;
39
+ /**
40
+ * The kernel identity this session was launched under (#798): the uid/gid
41
+ * setpriv dropped to before this process started. The child asserts
42
+ * `process.getuid()/getgid()` against it as its first act, and refuses to
43
+ * build a session when they differ — a worker session whose kernel identity
44
+ * is not the one its parent established must not run worker code. Absent,
45
+ * the session runs as the daemon's own uid, which is what every
46
+ * non-worker surface (orchestrator, tests) wants.
47
+ */
48
+ identity?: { uid: number; gid: number };
37
49
  sessionDir?: string;
38
50
  model?: string;
39
51
  resume?: boolean;
@@ -55,6 +67,19 @@ export interface SessionHostSpec {
55
67
  * Carried as a plain scalar like the other fields; absent, nothing is staged
56
68
  * on the far side either. */
57
69
  ompSettingsFile?: string;
70
+ /**
71
+ * The structured-settlement contract (#540): a JSON Schema the session's
72
+ * `yield` tool validates its `data` payload against. Carried as plain JSON
73
+ * through the spec, like everything else; absent, the far side passes nothing
74
+ * to the harness.
75
+ */
76
+ outputSchema?: unknown;
77
+ /** Enforcement policy for {@link outputSchema} — the worker contract is
78
+ * `"permissive"`: a schema violation settles with whatever the worker
79
+ * produced, never a lost report. */
80
+ outputSchemaMode?: "permissive" | "strict";
81
+ /** Force the hidden `yield` tool into this session's toolset (#540). */
82
+ requireYieldTool?: boolean;
58
83
  }
59
84
 
60
85
  /** Parent → child. */
@@ -66,7 +91,35 @@ export type ParentToHost =
66
91
 
67
92
  /** Child → parent. */
68
93
  export type HostToParent =
69
- | { t: "ready"; sessionFile?: string; modelFallbackMessage?: string }
94
+ | {
95
+ t: "ready";
96
+ sessionFile?: string;
97
+ modelFallbackMessage?: string;
98
+ /**
99
+ * The omp-conductor version this child process loaded, attested at the
100
+ * ready handshake (#832): the session host's `ready` is the exact-session
101
+ * handoff the upgrade verifies against, so the live process's own loaded
102
+ * module version travels with it instead of being read off disk later.
103
+ * Absent only when the version cannot be read — absence is "not
104
+ * attested", never "the newest".
105
+ */
106
+ extensionVersion?: string;
107
+ /** Exact peer entry and package version loaded by this child (#828). */
108
+ harnessPath?: string;
109
+ harnessVersion?: string;
110
+ }
111
+ | {
112
+ t: "graph-tools";
113
+ /**
114
+ * The code-graph session observation (#726): whether the graph MCP tools
115
+ * were in this session's registry. Sent once the session's tool registry
116
+ * settles (it finalises after creation, so the ready handshake never
117
+ * waits for it). Absent value never travels — no message means no
118
+ * observation was recorded, never "graph tools absent", which is the
119
+ * `present: false` truth value.
120
+ */
121
+ graphTools: GraphToolsObservation;
122
+ }
70
123
  | { t: "start-error"; message: string }
71
124
  | { t: "event"; event: unknown }
72
125
  | { t: "session-file"; path: string }
@@ -159,11 +212,17 @@ export interface SessionHostDeps {
159
212
  * into the same `Error` the in-process path would have raised, so a missing
160
213
  * peer dependency still reads as a deployment mistake rather than as an
161
214
  * unexplained child exit.
215
+ *
216
+ * Returns `false` when the session refused to start because the identity it
217
+ * was launched for does not match the kernel identity this process actually
218
+ * runs under — that refusal is the fail-closed arm of the worker boundary,
219
+ * and the caller turns it into a non-zero exit so nothing downstream can read
220
+ * it as a clean session.
162
221
  */
163
222
  export async function runSessionHost(
164
223
  spec: SessionHostSpec,
165
224
  deps: SessionHostDeps = { createSession: createLocalSession },
166
- ): Promise<void> {
225
+ ): Promise<boolean> {
167
226
 
168
227
  const socket = connect(spec.socket);
169
228
  socket.setNoDelay(true);
@@ -171,6 +230,26 @@ export async function runSessionHost(
171
230
  if (!socket.writableEnded) socket.write(encodeFrame(message));
172
231
  };
173
232
 
233
+ // The kernel identity check comes as early as there is a socket to report
234
+ // over and before the harness (`createSession`) has run a single byte of
235
+ // worker-controlled code. A child whose uid/gid are not the ones its parent
236
+ // launched it for proves the boundary did not hold — the daemon would be
237
+ // supervising a session that is not what it thinks it is.
238
+ const violation = identityMismatch(spec.identity);
239
+ if (violation !== undefined) {
240
+ send({ t: "start-error", message: violation });
241
+ // The frame must reach the parent before this process exits: `process.exit`
242
+ // does not flush the socket, and a parent that only observes the exit used
243
+ // to report a bare exit code instead of the child's own words. The parent
244
+ // resolves the run as failed with no live child — fail closed.
245
+ await new Promise<void>((resolve) => {
246
+ socket.once("finish", () => resolve());
247
+ socket.once("error", () => resolve());
248
+ socket.end();
249
+ });
250
+ return false;
251
+ }
252
+
174
253
  const { promise: connected, resolve: onConnect, reject: onConnectFail } = Promise.withResolvers<void>();
175
254
  socket.once("connect", () => {
176
255
  onConnect();
@@ -192,6 +271,9 @@ export async function runSessionHost(
192
271
  ...(spec.verbSocketPath === undefined ? {} : { verbSocketPath: spec.verbSocketPath }),
193
272
  ...(spec.readOnly === undefined ? {} : { readOnly: spec.readOnly }),
194
273
  ...(spec.ompSettingsFile === undefined ? {} : { ompSettingsFile: spec.ompSettingsFile }),
274
+ ...(spec.outputSchema === undefined ? {} : { outputSchema: spec.outputSchema }),
275
+ ...(spec.outputSchemaMode === undefined ? {} : { outputSchemaMode: spec.outputSchemaMode }),
276
+ ...(spec.requireYieldTool === undefined ? {} : { requireYieldTool: spec.requireYieldTool }),
195
277
  // The release audit lives in the daemon's state directory, which this
196
278
  // process may not be able to write and must not be trusted to. It
197
279
  // becomes a message; the parent performs the durable write.
@@ -204,12 +286,15 @@ export async function runSessionHost(
204
286
  // The frame must reach the parent before this process exits: `process.exit`
205
287
  // does not flush the socket, and a parent that only observes the exit used
206
288
  // to report a bare exit code instead of the child's own words.
289
+ // This is a failed start, not an identity refusal: the process still walks
290
+ // away with a clean exit code, because the parent's verdict comes from the
291
+ // frame, not the status — 91 is reserved for the boundary refusal above.
207
292
  await new Promise<void>((resolve) => {
208
293
  socket.once("finish", () => resolve());
209
294
  socket.once("error", () => resolve());
210
295
  socket.end();
211
296
  });
212
- return;
297
+ return true;
213
298
  }
214
299
 
215
300
  const live = session;
@@ -232,8 +317,28 @@ export async function runSessionHost(
232
317
  ...(live.modelFallbackMessage === undefined
233
318
  ? {}
234
319
  : { modelFallbackMessage: live.modelFallbackMessage }),
320
+ // The loaded-module attestation (#832): what THIS process's session
321
+ // machinery actually executes, carried in the exact-session handoff the
322
+ // upgrade verifies against.
323
+ ...(live.extensionVersion === undefined ? {} : { extensionVersion: live.extensionVersion }),
324
+ ...(live.harnessPath === undefined ? {} : { harnessPath: live.harnessPath }),
325
+ ...(live.harnessVersion === undefined ? {} : { harnessVersion: live.harnessVersion }),
235
326
  });
236
327
 
328
+ // The code-graph session observation (#726) rides its own frame: the factory
329
+ // reads the session's tool registry asynchronously (the harness finalises
330
+ // MCP wiring after creation), so the ready handshake never waits for it. The
331
+ // child is the session's own process — this is the runtime observation the
332
+ // config check in `resolvePrereqs` deliberately is not, and an observation
333
+ // that never resolves simply never travels: no message is "not recorded",
334
+ // never "graph tools absent".
335
+ const graphToolsReady = live.graphToolsReady;
336
+ if (graphToolsReady !== undefined) {
337
+ void graphToolsReady.then((graphTools) => {
338
+ if (graphTools !== undefined) send({ t: "graph-tools", graphTools });
339
+ });
340
+ }
341
+
237
342
  const { promise: finished, resolve: finish } = Promise.withResolvers<void>();
238
343
  let buffer = "";
239
344
  socket.on("data", (chunk: Buffer) => {
@@ -300,6 +405,7 @@ export async function runSessionHost(
300
405
  // Teardown noise must not become the run's outcome.
301
406
  }
302
407
  socket.end();
408
+ return true;
303
409
  }
304
410
 
305
411
  /** Reads the spec the parent handed over. Argv, not stdin: stdin is the harness's. */
@@ -315,6 +421,10 @@ if (import.meta.main) {
315
421
  // Exit code is the parent's only signal when the socket never opened, so a
316
422
  // spec that will not parse fails loudly here rather than as a silent hang.
317
423
  const spec = specFromArgv(process.argv);
318
- await runSessionHost(spec);
319
- process.exit(0);
424
+ const started = await runSessionHost(spec);
425
+ // A refused start — the launched identity did not match this process's
426
+ // kernel identity — exits 91 (the same code the rejected cgroup-boundary
427
+ // launcher used) so an operator reading `ps` or the exit status sees a
428
+ // boundary refusal, never a clean exit.
429
+ process.exit(started ? 0 : 91);
320
430
  }