omp-conductor 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +35 -1
  2. package/REFERENCE.md +61 -11
  3. package/agents/to-spec.md +94 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +35 -1
  6. package/src/admission.ts +204 -75
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +268 -7
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +62 -21
  11. package/src/briefs/to-spec.md +88 -0
  12. package/src/briefs/worker.md +2 -1
  13. package/src/cli.ts +124 -1
  14. package/src/command-help.ts +11 -0
  15. package/src/command-manifest.ts +38 -5
  16. package/src/commands/arm.ts +1 -1
  17. package/src/commands/context.ts +1 -0
  18. package/src/commands/drain.ts +176 -0
  19. package/src/commands/extend.ts +6 -10
  20. package/src/commands/intake.ts +4 -19
  21. package/src/commands/status.ts +5 -1
  22. package/src/commands/watch.ts +51 -16
  23. package/src/commands/worker.ts +9 -10
  24. package/src/config-schema.ts +43 -6
  25. package/src/config.ts +65 -9
  26. package/src/daemon.ts +879 -41
  27. package/src/dashboard/app.js +4 -1
  28. package/src/dashboard/server.ts +5 -2
  29. package/src/decisions.ts +243 -17
  30. package/src/diff-flags.ts +75 -1
  31. package/src/doctor.ts +60 -82
  32. package/src/escalate.ts +31 -14
  33. package/src/failure-class.ts +28 -2
  34. package/src/fleet.ts +239 -240
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +35 -1
  37. package/src/graph.ts +66 -1
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +242 -2
  40. package/src/lifecycle.ts +122 -1
  41. package/src/omp-settings.ts +19 -0
  42. package/src/omp.ts +183 -21
  43. package/src/orchestrator-tick.ts +1591 -32
  44. package/src/orchestrator.ts +12 -0
  45. package/src/privileged.ts +1 -4
  46. package/src/release-policy.ts +503 -9
  47. package/src/session-host.ts +65 -6
  48. package/src/settlement.ts +69 -17
  49. package/src/setup-host.ts +1225 -9
  50. package/src/setup-install.ts +28 -0
  51. package/src/setup-wizard.ts +154 -3
  52. package/src/setup.ts +83 -17
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +216 -12
  55. package/src/store.ts +443 -42
  56. package/src/to-spec.ts +408 -0
  57. package/src/tracker/github.ts +104 -14
  58. package/src/types.ts +405 -19
  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 +765 -56
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +12 -2
  65. package/src/worktree.ts +29 -12
package/src/gitops.ts CHANGED
@@ -17,7 +17,8 @@
17
17
  * branch cannot disagree.
18
18
  */
19
19
 
20
- import { existsSync } from "node:fs";
20
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
21
+ import { homedir, tmpdir } from "node:os";
21
22
  import { join } from "node:path";
22
23
 
23
24
  import { parseChainSource, type ChainEntry } from "./chain-check.ts";
@@ -75,6 +76,98 @@ export function credentialedEnv(
75
76
  return { ...env, ...extra };
76
77
  }
77
78
 
79
+ /**
80
+ * The exact per-run dubious-ownership exemption for a daemon git call against
81
+ * a worker-owned run repository (#816).
82
+ *
83
+ * Every daemon-side invocation whose cwd (or opening) is the run repository
84
+ * carries the run's own path as a command-line `-c safe.directory=<path>` —
85
+ * never a wildcard, and never an entry in the daemon's global config, which
86
+ * would grow one line per run forever. Git 2.35+ refuses to open a repository
87
+ * owned by another uid ("dubious ownership"); the run repository was handed
88
+ * to the worker identity at dispatch, so the daemon's own `rev-parse`,
89
+ * salvage, lane probe and cleanup calls are exactly that shape.
90
+ */
91
+ export function runRepoSafeDirectoryExemption(runRepoPath: string): [string, string] {
92
+ return ["-c", `safe.directory=${runRepoPath}`];
93
+ }
94
+
95
+ /**
96
+ * A daemon-owned, per-call global git config granting the *source-side*
97
+ * ownership exemption for a local-path fetch out of one worker-owned run
98
+ * repository.
99
+ *
100
+ * `git fetch <runRepo>` from the mirror is the one daemon git call whose
101
+ * *source* is worker-owned, and the one shape command-line config cannot
102
+ * reach: git spawns `upload-pack` against the source — `.git` dubious-ownership
103
+ * check included — and that subprocess sees none of the destination process's
104
+ * `-c` options. The only real config file the subprocess reads that this side
105
+ * controls is the global one, so the run's exemption is materialised there,
106
+ * for exactly this call: a temp file (0700, daemon's own) naming precisely the
107
+ * run's repo path and its gitdir, never a wildcard, with the daemon's existing
108
+ * global config replayed through `include.path` so nothing else about the
109
+ * caller's git behavior changes. The file is removed when the call is done.
110
+ */
111
+ export interface ScopedSafeDirectory {
112
+ /** The env additions for one git call; the caller's own use of it is what
113
+ * scopes the exemption to that call. */
114
+ env: Record<string, string>;
115
+ /** Remove the daemon-owned temp file. The call must not outlive it. */
116
+ close: () => void;
117
+ }
118
+
119
+ /** A config value: git's quoting accepts C escapes in double quotes. */
120
+ function quoteGitConfigValue(value: string): string {
121
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
122
+ }
123
+
124
+ export function scopedSafeDirectoryEnv(...runRepos: readonly string[]): ScopedSafeDirectory {
125
+ const dir = mkdtempSync(join(tmpdir(), "omp-conductor-safe-"));
126
+ try {
127
+ // The global-config chain the daemon normally reads, replayed: setting
128
+ // GIT_CONFIG_GLOBAL would otherwise replace those files rather than
129
+ // augmenting them, dropping e.g. credential helpers and `insteadOf` rules
130
+ // for the duration of this one call.
131
+ const globals: string[] = [];
132
+ const globalOverride = process.env["GIT_CONFIG_GLOBAL"];
133
+ if (globalOverride !== undefined && globalOverride !== "") {
134
+ globals.push(globalOverride);
135
+ } else {
136
+ const xdgHome = process.env["XDG_CONFIG_HOME"];
137
+ const xdgConfig = xdgHome === undefined || xdgHome === ""
138
+ ? join(homedir(), ".config", "git", "config")
139
+ : join(xdgHome, "git", "config");
140
+ if (existsSync(xdgConfig)) globals.push(xdgConfig);
141
+ const home = homedir();
142
+ if (home !== "") {
143
+ const gitconfig = join(home, ".gitconfig");
144
+ if (existsSync(gitconfig)) globals.push(gitconfig);
145
+ }
146
+ }
147
+ const lines = [
148
+ ...globals.flatMap((file) => ["[include]", `\tpath = ${quoteGitConfigValue(file)}`]),
149
+ "[safe]",
150
+ // Both spellings git actually checks (2.43 and later use the gitdir
151
+ // path for a local-path source; a direct open names the worktree path).
152
+ // Both are exact, never a wildcard.
153
+ ...runRepos.flatMap((repo) => [
154
+ `\tdirectory = ${quoteGitConfigValue(repo)}`,
155
+ `\tdirectory = ${quoteGitConfigValue(join(repo, ".git"))}`,
156
+ ]),
157
+ "",
158
+ ];
159
+ const file = join(dir, "gitconfig");
160
+ writeFileSync(file, lines.join("\n"));
161
+ return {
162
+ env: { ...credentialedEnv(), GIT_CONFIG_GLOBAL: file },
163
+ close: () => rmSync(dir, { recursive: true, force: true }),
164
+ };
165
+ } catch (err) {
166
+ rmSync(dir, { recursive: true, force: true });
167
+ throw err;
168
+ }
169
+ }
170
+
78
171
  // ------------------------------------------------- the privileged publish path
79
172
 
80
173
  /** One run's own repository, as the daemon addresses it. */
@@ -163,7 +256,11 @@ export async function probeRunLane(
163
256
  for (const line of stdout.split("\n")) add(line.trim(), source);
164
257
  };
165
258
  if (input.worktree !== "") {
166
- const status = await exec(["git", "-C", input.worktree, "status", "--porcelain"], {});
259
+ // The live run's worktree is owned by the worker identity (#798), so
260
+ // every daemon-side read of it carries the exact per-run exemption — no
261
+ // wildcard, no global safe.directory (#816).
262
+ const exemption = runRepoSafeDirectoryExemption(input.worktree);
263
+ const status = await exec(["git", ...exemption, "-C", input.worktree, "status", "--porcelain"], {});
167
264
  if (status.code === 0) {
168
265
  const { untracked, tracked } = parsePorcelain(status.stdout);
169
266
  // Untracked files are authored by construction: a merge stages the files
@@ -183,12 +280,12 @@ export async function probeRunLane(
183
280
  // resolution. An unreadable divergence read fails open — no tracked
184
281
  // occupancy is claimed while the merge makes the read ambiguous.
185
282
  const merge = await exec(
186
- ["git", "-C", input.worktree, "rev-parse", "-q", "--verify", "MERGE_HEAD"],
283
+ ["git", ...exemption, "-C", input.worktree, "rev-parse", "-q", "--verify", "MERGE_HEAD"],
187
284
  {},
188
285
  );
189
286
  if (merge.code === 0) {
190
287
  const diverged = await exec(
191
- ["git", "-C", input.worktree, "diff", "--name-only", input.baseRef],
288
+ ["git", ...exemption, "-C", input.worktree, "diff", "--name-only", input.baseRef],
192
289
  {},
193
290
  );
194
291
  if (diverged.code === 0) {
@@ -203,7 +300,7 @@ export async function probeRunLane(
203
300
  }
204
301
  }
205
302
  const diff = await exec(
206
- ["git", "-C", input.worktree, "diff", "--name-only", `${input.baseRef}...HEAD`],
303
+ ["git", ...exemption, "-C", input.worktree, "diff", "--name-only", `${input.baseRef}...HEAD`],
207
304
  {},
208
305
  );
209
306
  if (diff.code === 0) addDiff(diff.stdout, "branch");
@@ -311,91 +408,101 @@ export async function pushRunBranch(
311
408
  const ref = `refs/heads/${run.branch}`;
312
409
  const tracked = `refs/remotes/origin/${run.branch}`;
313
410
  const env = credentialedEnv();
411
+ // The run repository is worker-owned (#798): the direct read below carries
412
+ // the exact-path exemption on the command line, and the local-path fetch's
413
+ // source (upload-pack) needs it as a scoped global config for the one call
414
+ // (#816). Both are exact per-run entries, never a wildcard, and the scoped
415
+ // file dies with the call.
416
+ const scoped = scopedSafeDirectoryEnv(run.runRepoPath);
314
417
 
315
- // Copy the run's branch into the mirror, fast-forward only.
316
- const fetched = await exec(["git", "-C", mirror, "fetch", "--no-tags", run.runRepoPath, `${ref}:${ref}`], { env });
418
+ try {
419
+ // Copy the run's branch into the mirror, fast-forward only.
420
+ const fetched = await exec(["git", ...runRepoSafeDirectoryExemption(run.runRepoPath), "-C", mirror, "fetch", "--no-tags", run.runRepoPath, `${ref}:${ref}`], { env: scoped.env });
421
+
422
+ // The proposed head is read from the run repo itself: when the copy above
423
+ // was refused, the mirror's copy of the branch is exactly the stale one.
424
+ const proposedRun = await exec(["git", ...runRepoSafeDirectoryExemption(run.runRepoPath), "-C", run.runRepoPath, "rev-parse", ref], { env: scoped.env });
425
+ if (proposedRun.code !== 0) {
426
+ return { ok: false, stderr: scrubUserinfo(proposedRun.stderr.trim() || `git rev-parse ${ref} exited ${String(proposedRun.code)}`) };
427
+ }
428
+ const proposed = proposedRun.stdout.trim();
429
+
430
+ // Reconcile with the live remote before enforcing anything: the ancestry
431
+ // that decides a fast-forward is the live remote's, not the mirror's copy's
432
+ // (see the docstring above). ls-remote answers "is the branch published at
433
+ // all" and "what does GitHub hold" in one call.
434
+ const liveListed = await exec(["git", "-C", mirror, "ls-remote", "origin", ref], { env });
435
+ if (liveListed.code !== 0) {
436
+ return { ok: false, stderr: scrubUserinfo(liveListed.stderr.trim() || `git ls-remote origin ${ref} exited ${String(liveListed.code)}`) };
437
+ }
438
+ const live = liveListed.stdout
439
+ .split("\n")
440
+ .map((line) => line.trimEnd())
441
+ .find((line) => line.endsWith(`\t${ref}`))
442
+ ?.split(/\s+/, 1)[0];
443
+
444
+ if (live !== undefined && live !== proposed) {
445
+ // The branch is published and the run proposes something different. Pull
446
+ // the live branch's history into the mirror once (which also keeps the
447
+ // tracked ref a reattach reads fresh), then prove the fast-forward.
448
+ const reconciled = await exec(["git", "-C", mirror, "fetch", "--no-tags", "origin", `+${ref}:${tracked}`], { env });
449
+ if (reconciled.code !== 0) {
450
+ return { ok: false, stderr: scrubUserinfo(reconciled.stderr.trim() || reconciled.stdout.trim() || `git fetch origin exited ${String(reconciled.code)}`) };
451
+ }
317
452
 
318
- // The proposed head is read from the run repo itself: when the copy above
319
- // was refused, the mirror's copy of the branch is exactly the stale one.
320
- const proposedRun = await exec(["git", "-C", run.runRepoPath, "rev-parse", ref], { env });
321
- if (proposedRun.code !== 0) {
322
- return { ok: false, stderr: scrubUserinfo(proposedRun.stderr.trim() || `git rev-parse ${ref} exited ${String(proposedRun.code)}`) };
323
- }
324
- const proposed = proposedRun.stdout.trim();
325
-
326
- // Reconcile with the live remote before enforcing anything: the ancestry
327
- // that decides a fast-forward is the live remote's, not the mirror's copy's
328
- // (see the docstring above). ls-remote answers "is the branch published at
329
- // all" and "what does GitHub hold" in one call.
330
- const liveListed = await exec(["git", "-C", mirror, "ls-remote", "origin", ref], { env });
331
- if (liveListed.code !== 0) {
332
- return { ok: false, stderr: scrubUserinfo(liveListed.stderr.trim() || `git ls-remote origin ${ref} exited ${String(liveListed.code)}`) };
333
- }
334
- const live = liveListed.stdout
335
- .split("\n")
336
- .map((line) => line.trimEnd())
337
- .find((line) => line.endsWith(`\t${ref}`))
338
- ?.split(/\s+/, 1)[0];
339
-
340
- if (live !== undefined && live !== proposed) {
341
- // The branch is published and the run proposes something different. Pull
342
- // the live branch's history into the mirror once (which also keeps the
343
- // tracked ref a reattach reads fresh), then prove the fast-forward.
344
- const reconciled = await exec(["git", "-C", mirror, "fetch", "--no-tags", "origin", `+${ref}:${tracked}`], { env });
345
- if (reconciled.code !== 0) {
346
- return { ok: false, stderr: scrubUserinfo(reconciled.stderr.trim() || reconciled.stdout.trim() || `git fetch origin exited ${String(reconciled.code)}`) };
453
+ const isAncestor = await exec(["git", "-C", mirror, "merge-base", "--is-ancestor", live, proposed], { env });
454
+ if (isAncestor.code === 128) {
455
+ // git could not perform the check at all (an object it was asked to
456
+ // resolve is missing, not merely unrelated). That is a failed
457
+ // verification, not a divergence verdict: refuse with git's own words,
458
+ // still naming both SHAs so the report carries the mismatch.
459
+ return {
460
+ ok: false,
461
+ stderr: scrubUserinfo(
462
+ isAncestor.stderr.trim() ||
463
+ isAncestor.stdout.trim() ||
464
+ `git merge-base --is-ancestor ${live} ${proposed} exited ${String(isAncestor.code)}`,
465
+ ),
466
+ };
467
+ }
468
+ if (isAncestor.code !== 0) {
469
+ // A real divergence: no fast-forward exists, and the mirror is left
470
+ // exactly where it was. Both SHAs are named so the mismatch is
471
+ // diagnosable instead of a bare "non-fast-forward".
472
+ return {
473
+ ok: false,
474
+ stderr:
475
+ `refusing non-fast-forward push of ${run.branch}: the live remote tip ${live} is not an ancestor of ` +
476
+ `the proposed head ${proposed}. Fetch the live branch and rebase or merge it before pushing again.`,
477
+ };
478
+ }
347
479
  }
348
480
 
349
- const isAncestor = await exec(["git", "-C", mirror, "merge-base", "--is-ancestor", live, proposed], { env });
350
- if (isAncestor.code === 128) {
351
- // git could not perform the check at all (an object it was asked to
352
- // resolve is missing, not merely unrelated). That is a failed
353
- // verification, not a divergence verdict: refuse with git's own words,
354
- // still naming both SHAs so the report carries the mismatch.
355
- return {
356
- ok: false,
357
- stderr: scrubUserinfo(
358
- isAncestor.stderr.trim() ||
359
- isAncestor.stdout.trim() ||
360
- `git merge-base --is-ancestor ${live} ${proposed} exited ${String(isAncestor.code)}`,
361
- ),
362
- };
481
+ if (fetched.code !== 0 && live === undefined) {
482
+ // The mirror refused the plain copy and there is no published branch to
483
+ // validate the run's head against: a rewritten branch that was never
484
+ // published must not rewrite the mirror either. Refuse with git's words.
485
+ return { ok: false, stderr: scrubUserinfo(fetched.stderr.trim() || fetched.stdout.trim() || `git fetch exited ${String(fetched.code)}`) };
363
486
  }
364
- if (isAncestor.code !== 0) {
365
- // A real divergence: no fast-forward exists, and the mirror is left
366
- // exactly where it was. Both SHAs are named so the mismatch is
367
- // diagnosable instead of a bare "non-fast-forward".
368
- return {
369
- ok: false,
370
- stderr:
371
- `refusing non-fast-forward push of ${run.branch}: the live remote tip ${live} is not an ancestor of ` +
372
- `the proposed head ${proposed}. Fetch the live branch and rebase or merge it before pushing again.`,
373
- };
487
+ if (fetched.code !== 0) {
488
+ // The proposed head is a legitimate fast-forward over the live remote (it
489
+ // equals the live head or the ancestor test above passed), so the only
490
+ // thing the plain copy refused on was the mirror's own stale copy.
491
+ // Refresh it; the push below still re-checks against the live remote.
492
+ const refreshed = await exec(["git", ...runRepoSafeDirectoryExemption(run.runRepoPath), "-C", mirror, "fetch", "--no-tags", run.runRepoPath, `+${ref}:${ref}`], { env: scoped.env });
493
+ if (refreshed.code !== 0) {
494
+ return { ok: false, stderr: scrubUserinfo(refreshed.stderr.trim() || refreshed.stdout.trim() || `git fetch exited ${String(refreshed.code)}`) };
495
+ }
374
496
  }
375
- }
376
497
 
377
- if (fetched.code !== 0 && live === undefined) {
378
- // The mirror refused the plain copy and there is no published branch to
379
- // validate the run's head against: a rewritten branch that was never
380
- // published must not rewrite the mirror either. Refuse with git's words.
381
- return { ok: false, stderr: scrubUserinfo(fetched.stderr.trim() || fetched.stdout.trim() || `git fetch exited ${String(fetched.code)}`) };
382
- }
383
- if (fetched.code !== 0) {
384
- // The proposed head is a legitimate fast-forward over the live remote (it
385
- // equals the live head or the ancestor test above passed), so the only
386
- // thing the plain copy refused on was the mirror's own stale copy.
387
- // Refresh it; the push below still re-checks against the live remote.
388
- const refreshed = await exec(["git", "-C", mirror, "fetch", "--no-tags", run.runRepoPath, `+${ref}:${ref}`], { env });
389
- if (refreshed.code !== 0) {
390
- return { ok: false, stderr: scrubUserinfo(refreshed.stderr.trim() || refreshed.stdout.trim() || `git fetch exited ${String(refreshed.code)}`) };
498
+ const pushed = await exec(["git", "-C", mirror, "push", "origin", `${ref}:${ref}`], { env });
499
+ if (pushed.code !== 0) {
500
+ return { ok: false, stderr: scrubUserinfo(pushed.stderr.trim() || pushed.stdout.trim() || `git push exited ${String(pushed.code)}`) };
391
501
  }
502
+ return { ok: true, sha: proposed };
503
+ } finally {
504
+ scoped.close();
392
505
  }
393
-
394
- const pushed = await exec(["git", "-C", mirror, "push", "origin", `${ref}:${ref}`], { env });
395
- if (pushed.code !== 0) {
396
- return { ok: false, stderr: scrubUserinfo(pushed.stderr.trim() || pushed.stdout.trim() || `git push exited ${String(pushed.code)}`) };
397
- }
398
- return { ok: true, sha: proposed };
399
506
  }
400
507
 
401
508
  /**
@@ -33,6 +33,19 @@ export type CodeGraphHealth =
33
33
  indexer: CheckState;
34
34
  mcpMount: McpMountState;
35
35
  };
36
+ /**
37
+ * Session-recorded graph-tools observations over this project's runs
38
+ * (#726): what dispatched sessions' registries actually held at start.
39
+ * `recorded: 0` means no run has ever recorded the fact — "observed" is
40
+ * never claimed without one, which is the whole honesty contract of the
41
+ * state. This is the runtime counterpart to `prerequisites.mcpMount`,
42
+ * which is deliberately a *config* finding.
43
+ */
44
+ session: {
45
+ recorded: number;
46
+ present: number;
47
+ absent: number;
48
+ };
36
49
  repos: CodeGraphRepoHealth[];
37
50
  timer: {
38
51
  enabled: "enabled" | "disabled" | "unknown";
@@ -61,6 +74,7 @@ export function pendingCodeGraph(project: ProjectConfig, now = Date.now()): Code
61
74
  status: "unknown",
62
75
  checkedAt: new Date(now).toISOString(),
63
76
  prerequisites: { indexer: "unknown", mcpMount: "unknown" },
77
+ session: { recorded: 0, present: 0, absent: 0 },
64
78
  repos: repos.map((repo) => ({
65
79
  name: repo.name,
66
80
  path: repo.graphProject,
@@ -78,6 +92,12 @@ export interface CodeGraphProbeDeps {
78
92
  exists(path: string): boolean;
79
93
  run(command: string, args: readonly string[]): Promise<ReadOnlyCommandResult>;
80
94
  now(): number;
95
+ /**
96
+ * Session-recorded graph-tools observations for the probed project (#726).
97
+ * Absent, the probe reports no observation — which is exactly what a caller
98
+ * that has no store must report, and never a claim that tools were absent.
99
+ */
100
+ graphToolsObservations?(): { recorded: number; present: number; absent: number };
81
101
  }
82
102
 
83
103
  async function runReadOnly(command: string, args: readonly string[]): Promise<ReadOnlyCommandResult> {
@@ -104,7 +124,7 @@ async function runReadOnly(command: string, args: readonly string[]): Promise<Re
104
124
  }
105
125
  }
106
126
 
107
- const DEFAULT_DEPS: CodeGraphProbeDeps = {
127
+ export const DEFAULT_DEPS: CodeGraphProbeDeps = {
108
128
  prereqs: resolvePrereqs,
109
129
  exists: existsSync,
110
130
  run: runReadOnly,
@@ -241,6 +261,19 @@ export async function probeCodeGraph(
241
261
  if (indexer === "missing") reasons.push("indexer is not present on PATH");
242
262
  if (mcpMount === "unconfigured") reasons.push("worker MCP configuration does not mount the indexer");
243
263
 
264
+ // The runtime half (#726): what dispatched sessions actually had in their
265
+ // registries. `recorded: 0` (no run ever recorded an observation, or no
266
+ // store-backed reader) contributes nothing — "observed" is only ever claimed
267
+ // from a run that recorded it. An observed absence is the signal this
268
+ // feature exists to surface: config says "mounted" while sessions ran
269
+ // without the tools, which is exactly the blind spot `mcp.json` alone had.
270
+ const session = deps.graphToolsObservations?.() ?? { recorded: 0, present: 0, absent: 0 };
271
+ if (session.recorded > 0 && session.absent > 0) {
272
+ reasons.push(
273
+ `graph tools observed absent from the session registry in ${session.absent} of ${session.recorded} runs`,
274
+ );
275
+ }
276
+
244
277
  // The units are per project (`cbm-reindex-<project>.*`): probing the shared
245
278
  // stem would report project B's health against project A's timer, and with
246
279
  // both installed the answer would be whichever the probe guessed (#720).
@@ -301,6 +334,7 @@ export async function probeCodeGraph(
301
334
  status: uncertain ? "unknown" : reasons.length === 0 ? "healthy" : "degraded",
302
335
  checkedAt,
303
336
  prerequisites: { indexer, mcpMount },
337
+ session,
304
338
  repos,
305
339
  timer: { enabled, active },
306
340
  refresh: refresh.health,
package/src/graph.ts CHANGED
@@ -29,7 +29,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n
29
29
  import { homedir, userInfo } from "node:os";
30
30
  import { dirname, join } from "node:path";
31
31
  import { expandHome, stateDir } from "./config.ts";
32
- import type { ProjectConfig, RepoTarget } from "./types.ts";
32
+ import type { GraphToolsObservation, ProjectConfig, RepoTarget } from "./types.ts";
33
33
 
34
34
  /**
35
35
  * The indexer's own CLI, invoked by name rather than by path so the generated
@@ -38,6 +38,71 @@ import type { ProjectConfig, RepoTarget } from "./types.ts";
38
38
  */
39
39
  const INDEXER = "codebase-memory-mcp";
40
40
 
41
+ /**
42
+ * The MCP server name the graph tools arrive under in a session's registry,
43
+ * in the spellings the harness prefixes tools with (#726). `graphToolsPresent`
44
+ * matches against these, so the runtime observation and `resolvePrereqs`'s
45
+ * config check share one identity and cannot drift apart. The underscore form
46
+ * is the harness's normalised spelling for a server name; the verbatim form
47
+ * covers servers that keep their hyphens.
48
+ */
49
+ export const GRAPH_MCP_SERVER_NAMES: readonly string[] = [INDEXER, INDEXER.replace(/-/g, "_")];
50
+
51
+ /**
52
+ * Whether the code-graph tools are in a live session's registry (#726).
53
+ *
54
+ * Takes the session's own enabled tool names and matches the harness's MCP
55
+ * mounting convention (`mcp__<server>_<tool>`). This is the runtime
56
+ * observation: unlike {@link resolvePrereqs}'s `mounted` (which reads
57
+ * `mcp.json` and can only say what a session should mount), it answers what
58
+ * the session actually had, so "the model ignored a tool it had" and "the
59
+ * tool was missing" stop looking identical from outside the session.
60
+ */
61
+ export function graphToolsPresent(toolNames: readonly string[]): boolean {
62
+ return toolNames.some(
63
+ (name) =>
64
+ name.startsWith("mcp__") &&
65
+ GRAPH_MCP_SERVER_NAMES.some((server) => name.startsWith(`mcp__${server}_`)),
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Poll a session's registry for the code-graph observation (#726).
71
+ *
72
+ * Measured against the real harness rather than assumed: MCP wiring finalises
73
+ * after `createAgentSession` resolves (a deferred discovery pass), so the
74
+ * registry read can throw in the window right after session creation, and the
75
+ * graph tools surface under the *enabled* names — the active names stop at
76
+ * the core tools. This therefore polls the enabled surface until the read
77
+ * stops throwing, bounded by `deadlineMs`; a surface that never becomes
78
+ * readable (or a build that does not expose one — `getEnabledToolNames` is
79
+ * `undefined`) records *no* observation — never "graph tools absent", which
80
+ * is the `present: false` truth value of a surface that was read.
81
+ *
82
+ * @param getEnabledToolNames the session's `getEnabledToolNames` read, already
83
+ * bound to its session (the SDK method dereferences private state), or
84
+ * `undefined` when the harness surface is absent
85
+ */
86
+ export async function observeGraphTools(
87
+ getEnabledToolNames: (() => string[]) | undefined,
88
+ options: { intervalMs?: number; deadlineMs?: number } = {},
89
+ ): Promise<GraphToolsObservation | undefined> {
90
+ if (getEnabledToolNames === undefined) return undefined;
91
+ const intervalMs = options.intervalMs ?? 250;
92
+ const deadlineMs = options.deadlineMs ?? 15_000;
93
+ const start = Date.now();
94
+ for (;;) {
95
+ try {
96
+ return { present: graphToolsPresent(getEnabledToolNames()), at: Date.now() };
97
+ } catch {
98
+ if (Date.now() - start >= deadlineMs) return undefined;
99
+ const { promise, resolve } = Promise.withResolvers<void>();
100
+ setTimeout(resolve, intervalMs);
101
+ await promise;
102
+ }
103
+ }
104
+ }
105
+
41
106
  /** The shared prefix of every reindex artefact; `cbm` is the indexer's own prefix. */
42
107
  export const REINDEX_UNIT = "cbm-reindex";
43
108
 
@@ -0,0 +1,59 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ import {
5
+ OMP_HARNESS_PACKAGE,
6
+ OMP_NATIVES_PACKAGE,
7
+ packageNodeModulesRoot,
8
+ } from "./host.ts";
9
+
10
+ export interface HarnessAttestation {
11
+ path: string;
12
+ version: string;
13
+ nativePath: string;
14
+ }
15
+
16
+ /** Resolve the installed peer from this package's directory, never Bun's ambient cache. */
17
+ export function resolveHarnessEntry(moduleDir: string = import.meta.dir): string {
18
+ return Bun.resolveSync(OMP_HARNESS_PACKAGE, moduleDir);
19
+ }
20
+
21
+ /** The installed peer version belonging to a resolved harness entry. */
22
+ export function harnessVersion(entry: string): string | undefined {
23
+ const root = packageNodeModulesRoot(entry);
24
+ if (root === undefined) return undefined;
25
+ try {
26
+ const parsed: unknown = JSON.parse(
27
+ readFileSync(join(root, OMP_HARNESS_PACKAGE, "package.json"), "utf8"),
28
+ );
29
+ if (parsed === null || typeof parsed !== "object") return undefined;
30
+ const version = Reflect.get(parsed, "version");
31
+ return typeof version === "string" && version !== "" ? version : undefined;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Exercise the worker's real import contract: explicitly anchored peer and
39
+ * native-addon resolution, with the caller responsible for passing
40
+ * `--no-install` so neither import can fall through to Bun's package cache.
41
+ */
42
+ export async function probeHarness(moduleDir: string = import.meta.dir): Promise<HarnessAttestation> {
43
+ const path = resolveHarnessEntry(moduleDir);
44
+ await import(path);
45
+ const version = harnessVersion(path);
46
+ if (version === undefined) throw new Error(`cannot read the harness version for ${path}`);
47
+ const nativePath = Bun.resolveSync(OMP_NATIVES_PACKAGE, dirname(path));
48
+ await import(nativePath);
49
+ return { path, version, nativePath };
50
+ }
51
+
52
+ if (import.meta.main) {
53
+ try {
54
+ process.stdout.write(`${JSON.stringify(await probeHarness())}\n`);
55
+ } catch (cause) {
56
+ process.stderr.write(`${cause instanceof Error ? cause.stack ?? cause.message : String(cause)}\n`);
57
+ process.exitCode = 1;
58
+ }
59
+ }