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.
Files changed (61) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +60 -10
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +29 -0
  6. package/src/admission.ts +204 -75
  7. package/src/ask.ts +268 -7
  8. package/src/board.ts +17 -3
  9. package/src/briefs/orchestrator.md +42 -14
  10. package/src/briefs/to-spec.md +84 -0
  11. package/src/briefs/worker.md +2 -1
  12. package/src/cli.ts +2 -0
  13. package/src/command-help.ts +11 -0
  14. package/src/command-manifest.ts +22 -0
  15. package/src/commands/context.ts +1 -0
  16. package/src/commands/drain.ts +176 -0
  17. package/src/commands/extend.ts +6 -10
  18. package/src/commands/status.ts +5 -1
  19. package/src/commands/watch.ts +50 -2
  20. package/src/commands/worker.ts +9 -10
  21. package/src/config-schema.ts +24 -0
  22. package/src/config.ts +42 -1
  23. package/src/daemon.ts +965 -36
  24. package/src/dashboard/app.js +4 -1
  25. package/src/dashboard/server.ts +5 -2
  26. package/src/decisions.ts +235 -17
  27. package/src/diff-flags.ts +75 -1
  28. package/src/doctor.ts +52 -0
  29. package/src/escalate.ts +9 -3
  30. package/src/failure-class.ts +28 -2
  31. package/src/fleet.ts +146 -22
  32. package/src/gitops.ts +188 -81
  33. package/src/graph-health.ts +35 -1
  34. package/src/graph.ts +66 -1
  35. package/src/harness-loader.ts +59 -0
  36. package/src/host.ts +567 -2
  37. package/src/lifecycle.ts +122 -1
  38. package/src/omp.ts +227 -20
  39. package/src/orchestrator-tick.ts +1386 -15
  40. package/src/orchestrator.ts +12 -0
  41. package/src/privileged.ts +1 -4
  42. package/src/release-policy.ts +503 -9
  43. package/src/session-host.ts +99 -5
  44. package/src/settlement.ts +69 -17
  45. package/src/setup-host.ts +1205 -6
  46. package/src/setup-install.ts +28 -0
  47. package/src/setup-wizard.ts +13 -2
  48. package/src/setup.ts +29 -13
  49. package/src/shell.ts +15 -0
  50. package/src/status-render.ts +78 -11
  51. package/src/store.ts +443 -42
  52. package/src/to-spec.ts +387 -0
  53. package/src/tracker/github.ts +104 -14
  54. package/src/types.ts +343 -13
  55. package/src/upgrade-verify.ts +209 -2
  56. package/src/upgrade.ts +175 -1
  57. package/src/verbs/protocol.ts +39 -0
  58. package/src/verbs/server.ts +730 -56
  59. package/src/verbs/socket.ts +24 -5
  60. package/src/worker.ts +25 -2
  61. package/src/worktree.ts +29 -12
@@ -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;
@@ -79,7 +91,35 @@ export type ParentToHost =
79
91
 
80
92
  /** Child → parent. */
81
93
  export type HostToParent =
82
- | { 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
+ }
83
123
  | { t: "start-error"; message: string }
84
124
  | { t: "event"; event: unknown }
85
125
  | { t: "session-file"; path: string }
@@ -172,11 +212,17 @@ export interface SessionHostDeps {
172
212
  * into the same `Error` the in-process path would have raised, so a missing
173
213
  * peer dependency still reads as a deployment mistake rather than as an
174
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.
175
221
  */
176
222
  export async function runSessionHost(
177
223
  spec: SessionHostSpec,
178
224
  deps: SessionHostDeps = { createSession: createLocalSession },
179
- ): Promise<void> {
225
+ ): Promise<boolean> {
180
226
 
181
227
  const socket = connect(spec.socket);
182
228
  socket.setNoDelay(true);
@@ -184,6 +230,26 @@ export async function runSessionHost(
184
230
  if (!socket.writableEnded) socket.write(encodeFrame(message));
185
231
  };
186
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
+
187
253
  const { promise: connected, resolve: onConnect, reject: onConnectFail } = Promise.withResolvers<void>();
188
254
  socket.once("connect", () => {
189
255
  onConnect();
@@ -220,12 +286,15 @@ export async function runSessionHost(
220
286
  // The frame must reach the parent before this process exits: `process.exit`
221
287
  // does not flush the socket, and a parent that only observes the exit used
222
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.
223
292
  await new Promise<void>((resolve) => {
224
293
  socket.once("finish", () => resolve());
225
294
  socket.once("error", () => resolve());
226
295
  socket.end();
227
296
  });
228
- return;
297
+ return true;
229
298
  }
230
299
 
231
300
  const live = session;
@@ -248,8 +317,28 @@ export async function runSessionHost(
248
317
  ...(live.modelFallbackMessage === undefined
249
318
  ? {}
250
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 }),
251
326
  });
252
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
+
253
342
  const { promise: finished, resolve: finish } = Promise.withResolvers<void>();
254
343
  let buffer = "";
255
344
  socket.on("data", (chunk: Buffer) => {
@@ -316,6 +405,7 @@ export async function runSessionHost(
316
405
  // Teardown noise must not become the run's outcome.
317
406
  }
318
407
  socket.end();
408
+ return true;
319
409
  }
320
410
 
321
411
  /** Reads the spec the parent handed over. Argv, not stdin: stdin is the harness's. */
@@ -331,6 +421,10 @@ if (import.meta.main) {
331
421
  // Exit code is the parent's only signal when the socket never opened, so a
332
422
  // spec that will not parse fails loudly here rather than as a silent hang.
333
423
  const spec = specFromArgv(process.argv);
334
- await runSessionHost(spec);
335
- 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);
336
430
  }
package/src/settlement.ts CHANGED
@@ -23,8 +23,9 @@ import {
23
23
  deriveChangedLine,
24
24
  } from "./diff-flags.ts";
25
25
  import { classifyRun, normalise, type ClassifyFacts } from "./failure-class.ts";
26
+ import { GhPrMissingError } from "./tracker/github.ts";
26
27
  import { formatModelsTried, modelsTried } from "./model-fallback.ts";
27
- import { PR_LOOKUP_WINDOW_MS } from "./verbs/server.ts";
28
+ import { PR_LOOKUP_WINDOW_MS } from "./decisions.ts";
28
29
  import {
29
30
  removeWorktree,
30
31
  salvageWip,
@@ -35,6 +36,7 @@ import type {
35
36
  Caps,
36
37
  Escalation,
37
38
  FailureClass,
39
+ FileLane,
38
40
  MergedPrInfo,
39
41
  OpenCloser,
40
42
  PrState,
@@ -107,7 +109,7 @@ export interface SettlementAuditResult {
107
109
  */
108
110
  export async function collectSettlementFlags(
109
111
  tracker: Pick<Tracker, "prDiff" | "prBody">,
110
- claim: { prUrl?: string; issueText: string; sessionFile?: string },
112
+ claim: { prUrl?: string; issueText: string; sessionFile?: string; lane?: FileLane },
111
113
  ): Promise<SettlementAuditResult> {
112
114
  if (claim.prUrl === undefined) return { flags: [UNREADABLE_TREE_FLAG], truncated: false };
113
115
  // The diff and the claimed-proof body read in parallel; both are advisory,
@@ -135,6 +137,7 @@ export async function collectSettlementFlags(
135
137
  diff,
136
138
  ...(prBody === undefined ? {} : { prBody }),
137
139
  ...(transcript === undefined ? {} : { transcript }),
140
+ ...(claim.lane === undefined ? {} : { lane: claim.lane }),
138
141
  }),
139
142
  truncated: diff.truncated,
140
143
  changedLine: deriveChangedLine(diff),
@@ -174,14 +177,24 @@ export interface Settlement {
174
177
  * `returned-for-revision` at settlement. A review decision asks for another
175
178
  * implementation pass, not a failure, so it consumes the continuation budget
176
179
  * instead of the failed-attempt budget.
180
+ * - `missing` — the claimed PR definitively does not exist (a definitive 404
181
+ * from the tracker, never a transient outage). Work was pushed but no PR
182
+ * ever appeared, so the honest terminal is the same as `closed`: `failed`,
183
+ * work preserved on the branch and the issue returned to the queue as a
184
+ * continuation. Nothing retrying can recover — a missing PR does not grow
185
+ * back (#779).
177
186
  * - `open`, and undefined — nothing changes. Undefined is "could not tell": a
178
- * flaky network, a revoked token, a deleted PR. Settling on it would record a
179
- * merge that never happened, and the next tick asks again for free. An
180
- * ambiguous answer must never settle a row.
187
+ * flaky network, a revoked token. Settling on it would record a merge that
188
+ * never happened, and the next tick asks again for free. An ambiguous answer
189
+ * must never settle a row.
181
190
  */
182
- export function settlementFor(pr: PrState | undefined, prUrl: string): Settlement | undefined {
191
+ export function settlementFor(
192
+ pr: PrState | "missing" | undefined,
193
+ prUrl: string,
194
+ ): Settlement | undefined {
183
195
  if (pr === "merged") return { state: "merged", reason: `${prUrl} merged` };
184
196
  if (pr === "closed") return { state: "failed", reason: `${prUrl} closed without merging` };
197
+ if (pr === "missing") return { state: "failed", reason: `PR ${prUrl} does not exist` };
185
198
  return undefined;
186
199
  }
187
200
 
@@ -311,16 +324,27 @@ export async function settlePushedGreen(
311
324
  // must not buy a `gh` call every five minutes forever.
312
325
  if (run.prUrl === undefined) continue;
313
326
 
314
- let pr: PrState | undefined;
327
+ let pr: PrState | "missing" | undefined;
315
328
  try {
316
329
  pr = await tracker.prState(run.prUrl);
317
330
  } catch (err) {
318
- // Per row, like admission's held candidate. The GitHub adapter already
319
- // answers undefined instead of throwing, so this catch is the port's
320
- // contract rather than that adapter's behaviour and a tracker that does
321
- // throw must cost its own row, not the whole sweep.
322
- log(`#${run.issue} not settled: PR state lookup failed (${errText(err)}) retrying next tick`);
323
- continue;
331
+ // The adapter throws exactly one classified error: `GhPrMissingError`,
332
+ // an individual REST 404 the adapter has corroborated with a
333
+ // same-repository pulls-list read, so the claimed PR definitively does
334
+ // not exist (#779). That is not "could not tell" retrying can never
335
+ // conjure the PR so the row is settled as a missing PR through the
336
+ // same mapping as a closed one: work preserved on the branch, busy guard
337
+ // released, issue back on the queue. A raw 404 that was never
338
+ // corroborated (a hidden repository, a lost token scope) stays "could
339
+ // not tell": the instance check is the corroboration, and every other
340
+ // failure retries next tick, per row — a tracker that throws
341
+ // unclassified must cost its own row, not the whole sweep.
342
+ if (err instanceof GhPrMissingError) {
343
+ pr = "missing";
344
+ } else {
345
+ log(`#${run.issue} not settled: PR state lookup failed (${errText(err)}) — retrying next tick`);
346
+ continue;
347
+ }
324
348
  }
325
349
 
326
350
  const settlement = settlementFor(pr, run.prUrl);
@@ -1297,10 +1321,23 @@ export async function classifyAndRecover(d: SettlementDeps): Promise<number> {
1297
1321
  }
1298
1322
  }
1299
1323
  } catch (err) {
1300
- // Per row, like every other sweep here: one unreachable PR must not stop
1301
- // the rest from being classified. The next tick asks again for free.
1302
- log(`#${run.issue} not classified: fact gathering failed (${errText(err)}) retrying next tick`);
1303
- continue;
1324
+ // The tracker throws exactly one classified error: `GhPrMissingError`,
1325
+ // an individual REST 404 the adapter corroborated with a
1326
+ // same-repository pulls-list read, so the claimed PR definitively does
1327
+ // not exist (#779). That is a fact, not "could not tell" — retrying can
1328
+ // never conjure the PR — so it is recorded as the absent-PR fact and
1329
+ // classification proceeds instead of leaving the row in
1330
+ // `selectUnclassified` to be offered forever. Everything else (a 5xx, a
1331
+ // revoked token, an opaque 404) is genuinely unreadable and the next
1332
+ // tick asks again for free.
1333
+ if (err instanceof GhPrMissingError) {
1334
+ facts.pr = "missing";
1335
+ } else {
1336
+ // Per row, like every other sweep here: one unreachable PR must not stop
1337
+ // the rest from being classified. The next tick asks again for free.
1338
+ log(`#${run.issue} not classified: fact gathering failed (${errText(err)}) — retrying next tick`);
1339
+ continue;
1340
+ }
1304
1341
  }
1305
1342
 
1306
1343
  const { cls, recovery, evidence } = classifyRun(classifiedRun, facts, caps);
@@ -1310,6 +1347,21 @@ export async function classifyAndRecover(d: SettlementDeps): Promise<number> {
1310
1347
  // base does move under it.
1311
1348
  if (run.state === "pushed-green" && cls === "unknown") continue;
1312
1349
 
1350
+ // A recovery of `none` is the classifier naming no failure class for the
1351
+ // row at all: a terminal `failed` row whose PR is open and every check is
1352
+ // green is the strongest mechanical success evidence there is short of
1353
+ // merge, so it is not a failed attempt. Restore it to `pushed-green` — the
1354
+ // settle sweep owns the merge decision from there, `failuresFor` stops
1355
+ // counting it, and no `[unknown]` escalation is persisted. Nothing else is
1356
+ // written, so the row stays in the normal `pushed-green` lifecycle
1357
+ // (merge-conflict on a moved base, settle on a merged or closed PR) rather
1358
+ // than being pinned by a class it never had (#766).
1359
+ if (recovery === "none") {
1360
+ store.updateRun(run.id, { state: "pushed-green" });
1361
+ log(`#${run.issue} restored to pushed-green: ${evidence}`);
1362
+ continue;
1363
+ }
1364
+
1313
1365
  const retry = run.failureClass !== undefined;
1314
1366
  store.updateRun(run.id, { failureClass: cls, recoveryAction: recovery });
1315
1367
  log(