Skip to content

fix(orchestrator): version-drift + path-hashing leading-dash strip in mcp/server.ts - #10

Open
evannadeau wants to merge 1 commit into
SpawnBox-dev:mainfrom
evannadeau:fix/mcp-server-version-drift-and-path-hashing
Open

fix(orchestrator): version-drift + path-hashing leading-dash strip in mcp/server.ts#10
evannadeau wants to merge 1 commit into
SpawnBox-dev:mainfrom
evannadeau:fix/mcp-server-version-drift-and-path-hashing

Conversation

@evannadeau

Copy link
Copy Markdown

Summary

Two trivial bug fixes in plugins/orchestrator/mcp/server.ts, plus rebuilt dist/server.js. Independent fixes, bundled because they touch the same file and have minimal blast radius.

Fix 1 — Version-drift (mcp/server.ts:655)

system_status reports a hardcoded 0.30.28 instead of the dynamically-read ${PLUGIN_VERSION}. The 0.30.31 consolidation that introduced PLUGIN_VERSION (read from package.json at module load, lines 32-37) explicitly aimed to fix this kind of drift — "the version isn't hardcoded in multiple spots and forgotten on every other version bump" per the comment — but missed this one site. Every release since (0.30.32 → 0.30.38) has shipped system_status returning 0.30.28 while the manifest, package.json, and startup banner all correctly show the current version.

Before:

lines.push(`- **Version**: orchestrator MCP server **0.30.28** (pid ${process.pid})`);

After:

lines.push(`- **Version**: orchestrator MCP server **${PLUGIN_VERSION}** (pid ${process.pid})`);

Cosmetic to user-visible, but operators reading system_status to confirm they're on the expected version are currently misled.

Fix 2 — Path-hashing leading-dash strip (mcp/server.ts:2184)

projectHash strips leading dashes after replacing path separators:

const projectHash = projectDir.replace(/[\\/:]/g, "-").replace(/^-+/, "");

On POSIX, every absolute path produces a leading dash after the replace(/[\\/:]/g, "-") (e.g., /home/user/project-home-user-project). The subsequent .replace(/^-+/, "") strips it, yielding home-user-project. But Claude Code preserves the leading dash when it names project directories: the actual directory created at ~/.claude/projects/ is -home-user-project, with the dash.

The runtime consequence: projectsHashDir resolves to a non-existent directory. listJsonlFiles() reads readdirSync(projectsHashDir) and the existsSync() guard returns false, yielding []. The agent-channel filewatcher processes zero JSONL files, never writes offsets, never parses @SA-<id8> channel addresses, and never emits routing notifications. Every multi-paragraph PA→SA dispatch on POSIX silently drops until an operator notices and symlinks the stripped path to the real one as a workaround.

Other agent-channel paths (stateDir = join(projectDir, ".orchestrator-state") for sessions.json, system_events DB, etc.) are correctly relative to cwd — only projectsHashDir, which has to match CC's directory naming, is affected.

Before:

const projectHash = projectDir.replace(/[\\/:]/g, "-").replace(/^-+/, "");

After:

// Hash the project directory the same way Claude Code does: replace path separators
// and colons with dashes, preserving any leading dash (POSIX absolute paths produce
// a leading "-home-..." form, and CC keeps the leading dash in ~/.claude/projects/).
const projectHash = projectDir.replace(/[\\/:]/g, "-");

The original strip was over-correction for the Windows drive-letter case (C:\fooC--foo) — a cosmetic double-dash, not broken routing. Removing the strip is harmless on Windows; on POSIX it's load-bearing.

Diagnostic recipe for anyone reproducing the bug:

  1. ls <project>/.orchestrator-state/agent-channel/ — absence of offsets-<id8>.json files means JSONL is not being processed.
  2. grep -c '<channel source' <project_jsonl_dir>/<sa_session_id>.jsonl — zero means no events were ever delivered to that SA.
  3. Compare paths: actual ls ~/.claude/projects/ shows -home-... with leading dash; plugin-computed projectsHashDir (instrumented via process.env.DEBUG=1 or similar) shows home-... without it.

Test plan

  • bun install (98 packages, clean).
  • bun run build against the updated source → dist/server.js 0.94 MB, 249 modules bundled. Output regenerated to match source (no stale dist).
  • bun test516 pass / 0 fail / 1207 expect() calls across 38 test files.
  • Verified PLUGIN_VERSION resolves correctly at module load (already in use at server.ts:464 for version: PLUGIN_VERSION in the MCP serverInfo and at server.ts:2900 for the startup banner — same identifier).
  • Re-read both diffs against cb40771 (current main) — line numbers verified directly against main source, not against stale notes.
  • Reviewer may want to add an explicit unit test for projectHash against POSIX vs Windows inputs — happy to follow up if requested.

Files changed

  • plugins/orchestrator/mcp/server.ts — 2 surgical edits (1 line for version, 1 line + 3 comment lines for path-hashing)
  • plugins/orchestrator/dist/server.js — regenerated via bun run build (only 4 lines changed, bundler-deterministic)

Out of scope

Two larger upstream candidates still queued from the same operator's KB:

  • Agent-channel filewatcher silently truncating PA-to-SA multi-paragraph assistant_text events at the first colon-after-blank-line — substantive change to the truncation logic, separate PR.
  • Concurrent stop-hook deadlock between parallel Claude Code sessions against the orchestrator SQLite DB — architectural fix, separate PR.

Both will ship after maintainer review of the trivial-fix bundle here.

Related

🤖 Generated with Claude Code (Admiral PA orchestrating an upstream-cleanup batch)

… mcp/server.ts

Two trivial fixes in mcp/server.ts plus rebuilt dist/server.js (bun build,
249 modules; 516/516 tests pass).

1. Version-drift (mcp/server.ts:655)
   `system_status` reported a hardcoded `0.30.28` instead of the
   dynamically-read `${PLUGIN_VERSION}` (set from package.json at module
   load, lines 32-37). The 0.30.31 consolidation that introduced
   PLUGIN_VERSION explicitly aimed to fix this kind of drift but missed
   this one site. Symptom: every release since (0.30.32 → 0.30.38) has
   shipped `system_status` reporting `0.30.28`.

2. Path-hashing leading-dash strip (mcp/server.ts:2184)
   `projectHash` stripped leading dashes after replacing path separators:
       projectDir.replace(/[\\/:]/g, "-").replace(/^-+/, "");
   On POSIX, every absolute path produces a leading dash after the
   replace, which the strip then removes. But Claude Code preserves the
   leading dash when it names project directories under
   `~/.claude/projects/-<...>`. The mismatch silently breaks the
   agent-channel filewatcher: `projectsHashDir` resolves to a
   non-existent directory, `listJsonlFiles()` returns `[]`, and every
   `@SA-<id8>` channel address gets silently dropped.

   Symlinking the stripped name to the real one restores routing (validated
   workaround); the proper fix is to stop stripping.

   On Windows, the prior leading-dash strip was over-correction for the
   drive-letter case (`C:\foo` → `C--foo`) — a cosmetic double-dash, not
   broken routing. Removing the strip is harmless there.

dist/server.js regenerated via `bun run build` against the same source
to guarantee the bundled artifact matches the source (per the long-stale
PR rebuild-after-rebase rule).
@evannadeau

Copy link
Copy Markdown
Author

Independently re-verified during a duplicate-PR cleanup that the 5 remaining 0.30.28 references in plugins/orchestrator/mcp/server.ts are intentional and should NOT be touched by this PR:

  • Lines 175, 209 — commit-history comments describing 0.30.28 launcher / per-PID write-back behavior. Touching these rewrites the comment's claim about which release introduced the behavior.
  • Lines 912, 919 — user-facing (0.30.28+) feature-version annotations on lookup (pagination + output_mode). Rewriting these would corrupt API docs.
  • Line 1298 — same pattern, (0.30.28+) feature-version annotation on note hard size limit.

PLUGIN_VERSION IIFE at mcp/server.ts:37-45 confirmed resolving correctly at module load from package.json; same identifier already in use at :464 (McpServer registration) and :2900 (startup banner).

(Companion PR #13 from me was a partial-duplicate version-drift-only fix opened before this PR's existence was noticed in the workspace KB — closing it now as subset.)

SpawnBox-dev pushed a commit that referenced this pull request Aug 9, 2026
… could not surface at all (0.49.0)

The hybrid search could not return a note the keyword leg had missed, however
good its cosine. So the chunking and model work shipped hours earlier in
0.46-0.47 was being discarded one stage downstream.

MEASURED on the live 7148-note KB, tracing one probe end to end. Target
cc1d3816, query "chatter about a topic got mistaken for the real event and
corrupted the label":

  vector similarity ......... #2 of 7148   <- second-best match in the corpus
  after RRF ................. #11
  after signal/confidence ... #19 of 24
  candidateTopK slice(0,12) . DROPPED
  final result .............. ABSENT from the top 6

TWO STRUCTURAL SUPPRESSORS, neither wrong in isolation:

1. RRF DUAL-CONTRIBUTION ASYMMETRY. reciprocalRankFusion sums 1/(k+rank) over
   both lists, so a note present in BOTH can reach ~0.033 while a vector-only
   note is capped at 1/(60+1) = 0.0164 - and only the ~18 FTS candidates are
   eligible for the bonus. No cosine score can lift a note the keyword leg
   missed above a mediocre note it found. Amplified by list-length imbalance:
   18 keyword candidates against 7148 vector-ranked notes.

2. THE SIGNAL BOOST IS AN ABSORBING STATE - the same class this lane already
   found in briefing ordering (ed316fcd entry R). signal is EARNED BY BEING
   SURFACED, so a note that has never surfaced cannot earn the multiplier that
   would let it surface. Observed while writing these tests: a filler with
   signal 50 outranked a note that won on BOTH keyword and vector.

THE FIX: reserve at most 2 result slots for the highest-cosine notes.
Displaces from the TAIL, never the head, so a note both signals agree on is
never evicted. Reserved candidates pass the same superseded / code_ref filters
as any other result - a reserve that bypassed them would be a back door around
the caller's constraints.

Direct precedent, same shape and same remedy: GLOBAL_RESERVED in recall.ts,
whose comment reads "without reserved slots, the larger project DB drowns them
out". The vector leg was the drowned minority list here.

VERIFIED LIVE: cc1d3816 now surfaces. HONEST LIMIT, stated rather than left to
be discovered: probes ranking #10, #60 and #129 by vector still do NOT surface.
A 2-slot reserve rescues the head of the semantic distribution, not its tail.

THE DEEPER REWORK IS DEFERRED ON PURPOSE, filed as 27d1da01 with five candidate
directions and none prescribed. The blocker is a LABELLED EVALUATION SET, not
implementation effort: this same session tried mean-centering - the textbook
anisotropy fix - and measured it WORSE on every probe. Five hand-written
adversarial probes can DETECT a broken ranker; they cannot TUNE one, and
changing live fusion without an eval set is a coin flip that feels like
progress.

Guards: tests/engine/semantic-reserve.test.ts, 7 tests - a zero-keyword-overlap
note surfacing against 30 lexically-matching signal-80 decoys; the reserve
capped at 2 of 6 so keyword keeps the majority; the top hybrid result not
evicted; a superseded note never promoted; no-vector queries still returning
keyword results; plus wiring assertions on the bound and on tail-displacement.
Suite 1125 pass / 0 fail.

One fixture correction recorded in the test file rather than made quietly: the
head-preservation test originally gave its decoys signal 50 and failed - not
because of the reserve, but because the pre-existing boost let a high-signal
filler outrank a note winning on both signals. Decoys are now signal 0 so the
test measures what its name claims; the boost finding went to 27d1da01.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4kPkrZLWbUxe4arhdwii
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant