Skip to content

feat(mcp): lock-elected single-writer file watcher (oxcode_watch); release 0.4.0 - #30

Merged
snowmead merged 1 commit into
mainfrom
feat/lock-elected-watcher
Jun 17, 2026
Merged

feat(mcp): lock-elected single-writer file watcher (oxcode_watch); release 0.4.0#30
snowmead merged 1 commit into
mainfrom
feat/lock-elected-watcher

Conversation

@snowmead

Copy link
Copy Markdown
Contributor

What & why

oxcode mcp is run by many agent processes at once, started and torn down at
any time, often pointed at one repo. The old model (a watcher started at server
launch, queries auto-building the index) meant every process would redundantly
re-index the same .oxcode/index.oxgdb and collide on writes.

This replaces the manual oxcode_index tool with oxcode_watch, and elects a
single writer per folder via a cross-process file lock so exactly one process
watches and re-indexes while the rest serve reads.

How it works

  • oxcode_watch (optional path, taskSupport: "optional") opens
    <root>/.oxcode/watch.lock and try_lock()s it (std::fs::File::try_lock
    advisory flock, no new dependency; mirrors oxgraph-db's own WriterLock).
    • Lock acquired → writer: builds the index, runs a debounced recursive
      watcher, re-indexes on change.
    • WouldBlockstandby: serves reads and runs a failover poll; when the
      writer process exits the kernel frees the lock and a standby takes over.
  • Reads stay read-only. The writer caches + evicts its reader on each reindex;
    every other process opens fresh per query (so it sees the writer's latest
    committed snapshot). A query before any oxcode_watch, with no index, returns a
    clear "call oxcode_watch" hint — queries never write, keeping the
    single-writer invariant airtight.
  • Removed: the --no-watch flag and the startup warm-build.

The election is not a consensus protocol — the kernel is the arbiter. At most
one process holds the exclusive lock at any instant (so no split-brain, even
during failover), and the lock auto-releases on process death (so no lease
renewal or stale-lock cleanup). Scope is same-machine, which fits the use case.

Observability

  • oxcode_status now reports this instance's role (writer/standby/reader),
    watching, and reindexes count.
  • oxcode_watch returns its elected role.
  • Informative stderr (elected as writer, re-indexed (#n), promoted to writer…); stdout stays the JSON-RPC channel.
  • Watcher intervals are tunable via OXCODE_WATCH_DEBOUNCE_MS /
    OXCODE_WATCH_POLL_MS.

Testing

  • In-process unit tests (src/mcp.rs): a sub-ms flock exclusivity probe,
    writer election, standby, "query without watch errors", and a real
    watcher-reindex test verified by an exact-named search match + the reindexes
    counter (which caught — and fixed — a query-echo false positive the previous
    watcher test masked).
  • Real multi-process E2E (tests/multiprocess.rs): spawns 3 actual
    oxcode mcp processes
    driven over stdio via rmcp's TokioChildProcess, all
    on one repo. Asserts — entirely over MCP — single-writer election, all three
    reading, only the writer's reindex counter advancing while standbys stay 0,
    all three seeing the change, and failover: it tears down the writer and
    verifies a standby is promoted and re-indexes a fresh edit.
  • just ci green (fmt, taplo, clippy -D warnings, cargo deny, all suites).

Version bumps (0.4.0)

Feature + breaking MCP-surface change on a 0.x project → minor bump, aligned:

  • crates oxcode-model / oxcode-core / oxcode-cli: 0.3.0 → 0.4.0 (inter-crate pins + Cargo.lock)
  • npm/package.json and server.json (MCP Registry): 0.2.0 → 0.4.0 (CI also stamps these from the crate version at release)
  • claude-plugin/.claude-plugin/plugin.json: already 0.4.0; refreshed its stale description (oxcode_indexoxcode_watch)

Note: when an MCP process is launched outside the project folder, the agent
must pass path to the tools (the E2E exercises exactly this).

🤖 Generated with Claude Code

Replace the manual oxcode_index tool with oxcode_watch. Across many
`oxcode mcp` processes pointed at one folder, a `.oxcode/watch.lock`
advisory file lock (std `File::try_lock`) elects exactly one writer that
runs a debounced recursive file watcher and re-indexes on change; the
rest serve reads, and a standby takes over via a failover poll if the
writer exits (the kernel frees the lock on process death).

- oxcode_watch (taskSupport optional) acquires the lock, builds the
  index, and watches; lock-losers become standbys running a failover loop.
- index_for is role-aware: the writer caches + evicts its reader on each
  reindex, every other process opens fresh per query (so it observes the
  writer's latest commit); a query with no index returns a hint to call
  oxcode_watch — queries never write.
- oxcode_status reports this instance's role/watching/reindexes;
  watcher intervals via OXCODE_WATCH_DEBOUNCE_MS / OXCODE_WATCH_POLL_MS.
- Remove the --no-watch flag and the startup warm-build.
- tests/multiprocess.rs: real 3-process end-to-end test proving
  single-writer election, reader reads, a single reindexer, and failover.

Bump oxcode-model/core/cli to 0.4.0 (inter-crate pins + Cargo.lock); the
npm launcher (npm/package.json) and MCP Registry manifest (server.json)
to 0.4.0; refresh the Claude plugin description (oxcode_index -> oxcode_watch).
@snowmead
snowmead merged commit fcb5ec2 into main Jun 17, 2026
5 checks passed
@snowmead
snowmead deleted the feat/lock-elected-watcher branch June 17, 2026 02:01
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