feat(codegraph): per-workspace CodeGraph indexing, hardened + isolated behind a feature seam #47

Open
lz wants to merge 7 commits from feat/codegraph into main
Owner

Adds CodeGraph so the agent gets a codegraph_explore MCP tool — call paths and impact in one query instead of grep/read loops.

This PR was rewritten. The original design (first commit, kept for attribution) silently answered every session from the main branch. Review found seven defects; the six commits after it fix them and extract CodeGraph behind a generic feature seam. Review the net diff, not the first commit.

Depends on #66 (nested-session-name fixes). The ancestor guarantee below holds only because git refuses feat alongside feat/login — which #66 makes safe. Land that first.

The bug, and the one idea that fixes it

codegraph resolves its project by walking up from cwd to the first valid .codegraph/. Session worktrees live at /workspace/.nexus/worktrees/<name>under /workspace. So an index at /workspace was found by every worktree that lacked one, and the agent got main's call graph while sitting on a feature branch.

I tried every way to pin the root — serve --mcp --path, the MCP rootUri, a real .git boundary, an empty .codegraph/ placeholder. All four fail; it walks up regardless. So the fix isn't a guard, it's a placement:

  1. Every worktree gets its own valid index → the walk stops there.
  2. No index sits on any worktree's ancestor path → if (1) ever fails, there's nothing to hijack, and codegraph says so out loud: "No CodeGraph project is loaded."

The seed lives at .nexus/codegraph-seed/ — a sibling of worktrees/, not an ancestor. Every failure mode collapses from wrong answers to no answers, which is what makes backgrounding the build safe.

Defects fixed (all verified in a real container against the real binary)

was now
Wrong-branch answers /workspace index hijacked every worktree /workspace never holds an index
Torn WAL copy cp -r of a live DB — and cp exits 0 on garbage sqlite3 VACUUM INTOintegrity_check: ok
Interrupted index never rebuilt guard tested directory existence → wedged forever killed mid-build → next boot rebuilds
Boot blocked on indexing workspace unhealthy for the whole build (14.6s/374 files, minutes on a big repo) boot returns in 2s, seed builds behind it
Stale daemon lock daemon.pid/sock copied into every worktree snapshot contains only codegraph.db
Silent failures logger.debugdropped under NODE_ENV=production warn + a 60s timeout
Seed slurped every worktree duplicate symbols from every session's checkout built from a detached checkout of HEAD → 0 .nexus/ rows

A session created while the seed is still building gets a blank but valid index: it stops the walk and self-builds its own branch (verified with a branch-only canary). Correct, not merely safe.

Isolation

CodeGraph lives entirely in server/codegraph/ + worker/addons/codegraph/. Production core names the flag (codegraph_enabled — a column, a DTO field, a checkbox) and nothing else: no paths, no commands, no imports. Enforced by a grep in the plan, and the TS/shell literals are coupled by a meta-test that fails (never skips) if either side moves.

Removing CodeGraph = delete two directories, one registry line, one Dockerfile line.

Honest framing — a deliverable, not a nicety

I measured its call-edge accuracy against the TypeScript compiler over 136 exported functions: 96.5% precision, 85.7% recall. Two systematic, silent defects, each reproduced in a 6-line fixture:

  • await fn<T>(...) produces no edge at all. Not fn<T>(), not await fn() — only the combination. That's idiomatic TS: 35 call sites in this repo.
  • a.b.method() binds by bare name to any module-level function of that name, in files the caller never imports. Repo-wide, 22% of cross-file call edges have no import backing (a DB migration "calling" a Svelte component, via Kysely's col.check()).

Neither is flagged — a wrong edge looks exactly as authoritative as a right one, under a heading that reads "Blast radius — what depends on these." At 85.7% recall, an agent trusting an empty result is wrong about one time in seven.

So default-CLAUDE.md now tells the agent it is a hint, not an oracle: never read "no callers found" as proof; verify with grep or tsc before deleting or re-signing anything. Shipping the tool without this would be shipping a confident liar.

Both defects have upstream reproductions ready to file.

Upgrade note ⚠️

Workspaces spawned from a pre-hardening worker image must be recreated, not restarted. The old entrypoint rebuilds /workspace/.codegraph on every boot; createSession disarms it once, but a restart brings it back.

Also fixed on the way

  • worker/*.test.sh ran nowhere. They now run in CI — and the two needing the real binary run inside the built image, before the push, so an image can't be published before the tests that would reject it.
  • mark_degraded used > (truncate), so a second writer would clobber the secrets reason. Now appends.
  • codegraph phones home by default → CODEGRAPH_TELEMETRY=0 baked in; version pinned to 1.4.1.

Test plan

  • pnpm test — 607 pass (up from 565); lint + typecheck clean
  • all 5 worker/*.test.sh pass, on the host and inside the built image
  • docker build --target base — codegraph 1.4.1, sqlite3, blank template initialized: true, fileCount: 0
  • End-to-end in a real container: boot continues in 2s while the seed builds; a legacy poison index is removed even with codegraph disabled; snapshot integrity_check: ok, no daemon files; seed has 0 .nexus/ rows; kill mid-build → reboot rebuilds; a no-seed session self-builds its own branch
  • Root-walk guard rail drives the real binary over a real MCP handshake and pins both faces of the hazard
Adds [CodeGraph](https://github.com/colbymchenry/codegraph) so the agent gets a `codegraph_explore` MCP tool — call paths and impact in one query instead of grep/read loops. **This PR was rewritten.** The original design (first commit, kept for attribution) silently answered every session **from the main branch**. Review found seven defects; the six commits after it fix them and extract CodeGraph behind a generic feature seam. Review the net diff, not the first commit. > **Depends on #66** (nested-session-name fixes). The ancestor guarantee below holds only because git refuses `feat` alongside `feat/login` — which #66 makes safe. Land that first. ## The bug, and the one idea that fixes it codegraph resolves its project by **walking up from cwd to the first valid `.codegraph/`**. Session worktrees live at `/workspace/.nexus/worktrees/<name>` — *under* `/workspace`. So an index at `/workspace` was found by every worktree that lacked one, and the agent got **main's call graph while sitting on a feature branch**. I tried every way to pin the root — `serve --mcp --path`, the MCP `rootUri`, a real `.git` boundary, an empty `.codegraph/` placeholder. **All four fail; it walks up regardless.** So the fix isn't a guard, it's a *placement*: 1. **Every worktree gets its own valid index** → the walk stops there. 2. **No index sits on any worktree's ancestor path** → if (1) ever fails, there's nothing to hijack, and codegraph says so out loud: *"No CodeGraph project is loaded."* The seed lives at `.nexus/codegraph-seed/` — a **sibling** of `worktrees/`, not an ancestor. Every failure mode collapses from **wrong answers** to **no answers**, which is what makes backgrounding the build safe. ## Defects fixed (all verified in a real container against the real binary) | | was | now | |---|---|---| | Wrong-branch answers | `/workspace` index hijacked every worktree | `/workspace` never holds an index | | Torn WAL copy | `cp -r` of a live DB — and `cp` exits 0 on garbage | `sqlite3 VACUUM INTO` → `integrity_check: ok` | | Interrupted index never rebuilt | guard tested *directory existence* → wedged forever | killed mid-build → next boot **rebuilds** | | Boot blocked on indexing | workspace `unhealthy` for the whole build (14.6s/374 files, minutes on a big repo) | boot returns in **2s**, seed builds behind it | | Stale daemon lock | `daemon.pid`/`sock` copied into every worktree | snapshot contains **only** `codegraph.db` | | Silent failures | `logger.debug` — **dropped** under `NODE_ENV=production` | `warn` + a 60s timeout | | Seed slurped every worktree | duplicate symbols from every session's checkout | built from a detached checkout of HEAD → **0** `.nexus/` rows | A session created *while* the seed is still building gets a **blank but valid** index: it stops the walk and **self-builds its own branch** (verified with a branch-only canary). Correct, not merely safe. ## Isolation CodeGraph lives entirely in `server/codegraph/` + `worker/addons/codegraph/`. Production core names the *flag* (`codegraph_enabled` — a column, a DTO field, a checkbox) and **nothing else**: no paths, no commands, no imports. Enforced by a grep in the plan, and the TS/shell literals are coupled by a meta-test that fails (never skips) if either side moves. Removing CodeGraph = delete two directories, one registry line, one Dockerfile line. ## Honest framing — a deliverable, not a nicety I measured its call-edge accuracy against the TypeScript compiler over 136 exported functions: **96.5% precision, 85.7% recall.** Two systematic, *silent* defects, each reproduced in a 6-line fixture: - **`await fn<T>(...)` produces no edge at all.** Not `fn<T>()`, not `await fn()` — only the combination. That's idiomatic TS: **35 call sites in this repo.** - **`a.b.method()` binds by bare name** to any module-level function of that name, in files the caller never imports. Repo-wide, **22% of cross-file call edges have no import backing** (a DB migration "calling" a Svelte component, via Kysely's `col.check()`). Neither is flagged — a wrong edge looks exactly as authoritative as a right one, under a heading that reads *"Blast radius — what depends on these."* At 85.7% recall, an agent trusting an empty result is wrong about **one time in seven**. So `default-CLAUDE.md` now tells the agent it is **a hint, not an oracle**: never read "no callers found" as proof; verify with grep or `tsc` before deleting or re-signing anything. Shipping the tool without this would be shipping a confident liar. Both defects have upstream reproductions ready to file. ## Upgrade note ⚠️ Workspaces spawned from a **pre-hardening worker image must be recreated, not restarted.** The old entrypoint rebuilds `/workspace/.codegraph` on every boot; `createSession` disarms it once, but a restart brings it back. ## Also fixed on the way - `worker/*.test.sh` **ran nowhere**. They now run in CI — and the two needing the real binary run **inside the built image, before the push**, so an image can't be published before the tests that would reject it. - `mark_degraded` used `>` (truncate), so a second writer would clobber the secrets reason. Now appends. - codegraph phones home by default → `CODEGRAPH_TELEMETRY=0` baked in; version **pinned to 1.4.1**. ## Test plan - [x] `pnpm test` — 607 pass (up from 565); `lint` + `typecheck` clean - [x] all 5 `worker/*.test.sh` pass, on the host **and inside the built image** - [x] `docker build --target base` — codegraph 1.4.1, sqlite3, blank template `initialized: true, fileCount: 0` - [x] **End-to-end in a real container**: boot continues in 2s while the seed builds; a legacy poison index is removed even with codegraph *disabled*; snapshot `integrity_check: ok`, no daemon files; seed has 0 `.nexus/` rows; kill mid-build → reboot rebuilds; a no-seed session self-builds its own branch - [x] Root-walk guard rail drives the **real binary over a real MCP handshake** and pins both faces of the hazard
lz added 12 commits 2026-06-27 09:22:52 +02:00
Migration 0013 adds codegraph_enabled INTEGER NOT NULL DEFAULT 1 to the
workers table. Uses Generated<number> in the schema type so inserts may
omit it. Fix the synthetic DTO construction in spawnWorker to mirror the
existing worker_image ?? default pattern for the new Generated column.

Claude-Session: https://claude.ai/code/session_01DPMvaAf6Ay8ZtLpCRyCe8F
Claude-Session: https://claude.ai/code/session_01DPMvaAf6Ay8ZtLpCRyCe8F
Claude-Session: https://claude.ai/code/session_01DPMvaAf6Ay8ZtLpCRyCe8F
Claude-Session: https://claude.ai/code/session_01DPMvaAf6Ay8ZtLpCRyCe8F
Claude-Session: https://claude.ai/code/session_01DPMvaAf6Ay8ZtLpCRyCe8F
Add verified fact #19 describing the codegraph init/copy/MCP-register
flow, the relative-path portability guarantee, and the catchUpSync
self-correction behaviour. Add CODEGRAPH_ENABLED to the env-var table,
positioned next to the other worker per-spawn var.

Claude-Session: https://claude.ai/code/session_01DPMvaAf6Ay8ZtLpCRyCe8F
fix(codegraph): log best-effort seed-copy failure; clarify entrypoint degraded-boot gating in spec
Some checks failed
ci / nexus (pull_request) Successful in 3m12s
ci / images (./nexus, agent-nexus) (pull_request) Successful in 5m18s
ci / images (./worker, agent-nexus-worker, base) (pull_request) Successful in 3m34s
ci / images (PLAYWRIGHT_CLI_VERSION=0.1.13, ./worker, agent-nexus-worker, -playwright0.1.13, playwright) (pull_request) Failing after 7m55s
50359b08d0
lz force-pushed feat/codegraph from 50359b08d0
Some checks failed
ci / nexus (pull_request) Successful in 3m12s
ci / images (./nexus, agent-nexus) (pull_request) Successful in 5m18s
ci / images (./worker, agent-nexus-worker, base) (pull_request) Successful in 3m34s
ci / images (PLAYWRIGHT_CLI_VERSION=0.1.13, ./worker, agent-nexus-worker, -playwright0.1.13, playwright) (pull_request) Failing after 7m55s
to 6ad618e472
All checks were successful
ci / nexus (pull_request) Successful in 3m10s
ci / images (pull_request) Successful in 19m32s
2026-07-13 18:48:47 +02:00
Compare
lz changed title from feat(codegraph): per-workspace CodeGraph indexing for agent sessions to feat(codegraph): per-workspace CodeGraph indexing, hardened + isolated behind a feature seam 2026-07-13 18:49:22 +02:00
All checks were successful
ci / nexus (pull_request) Successful in 3m10s
ci / images (pull_request) Successful in 19m32s
This pull request has changes conflicting with the target branch.
  • AGENTS.md
  • nexus/src/lib/api/client.ts
  • nexus/src/lib/components/NewWorker.svelte
  • nexus/src/lib/server/sessions/service.ts
  • nexus/src/lib/server/workers/service.ts
  • nexus/src/routes/api/workers/+server.ts
  • nexus/static/default-CLAUDE.md
  • worker/Dockerfile
  • worker/entrypoint.sh
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/codegraph:feat/codegraph
git switch feat/codegraph

Merge

Merge the changes and update on Forgejo.
git switch main
git merge --no-ff feat/codegraph
git switch feat/codegraph
git rebase main
git switch main
git merge --ff-only feat/codegraph
git switch feat/codegraph
git rebase main
git switch main
git merge --no-ff feat/codegraph
git switch main
git merge --squash feat/codegraph
git switch main
git merge --ff-only feat/codegraph
git switch main
git merge feat/codegraph
git push origin main
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus!47
No description provided.