Skip to content

One workspace daemon shared by every frontend - #105

Merged
JesseHerrick merged 6 commits into
mainfrom
workspace-daemon
Sep 22, 2026
Merged

JesseHerrick merged 6 commits into
mainfrom
workspace-daemon

Conversation

@JesseHerrick

@JesseHerrick JesseHerrick commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Refs #100 and #84. Issue #100 asks for CLI parity with the LSP and floats a background daemon; this is that daemon, and the CLI now shares one index with the editor. The remaining half of #100 — name-based CLI answers that use the LSP's resolution, not just the store — is the follow-up below. PR #84's MCP server is the other frontend this was built for: after it is rebased, it should attach through daemon.Ensure and the control-method registries instead of owning its own store, watchers, and initial index pass (docs/daemon.md maps the ten tools onto the recommended daemon seams).

Every dexter frontend — the editor, the CLI, and future MCP clients — now attaches to one per-workspace daemon that owns the SQLite index, the file and Git watchers, the mutation queue, and the language caches. The daemon belongs to the workspace rather than to whichever frontend started it; dexter lsp is a raw stdio proxy onto it, and lookup, references, reindex, and stop are control clients.

Each frontend used to open its own store handle, run its own watchers and caches, and index the same tree twice. A shell reindex could race the editor's writer, each editor session duplicated the warm caches, and an editor restart lost them. There is no in-process editor mode any more: one workspace has one owner, so every frontend sees the same index and the same answers.

Highlights

  • Ownership is an advisory kernel lock held for the process lifetime, so a crash releases it at once. The socket binds before the first index pass, so opening an editor never waits on a cold build, and the daemon exits after 15 minutes with no clients (DEXTER_DAEMON_IDLE_TIMEOUT, 0 keeps it).
  • The handshake carries a single contract version, bumped together with IndexVersion only when an older daemon must not keep serving. A newer frontend replaces the daemon — which exits itself under the contract, or is signaled by the pid in its refusal — and a newer daemon refuses an older frontend with a message to restart it. No client process is ever killed by the daemon, because an editor or agent owns those.
  • dexter stop [path] [--force] ends a workspace daemon by workspace, not by pid. Plain stop goes through the protocol and refuses while clients are attached; --force skips the handshake, locates the process by command line (uid-filtered), and escalates SIGTERM to SIGKILL after a grace period.
  • --root/-C on every command runs it as if started in that directory, so agents, scripts, and editor wrappers can target a project from anywhere. Relative paths, a reindex target included, resolve from the named root.
  • A guard refuses to treat a directory with no mix.exs, .git, or .dexter as a workspace unless -y/--yes is passed, so a mistyped command in the home directory cannot build an index over everything. lsp warns and serves instead, because an editor is authoritative about what the user opened.

Resilience

  • One unwatchable directory no longer disables the whole native watcher; the runtime adds a periodic reconciliation whenever watching is missing or partial, which keeps a daemon-only workspace fresh without an editor.
  • Runtime files prefer $XDG_RUNTIME_DIR/dexter, then /tmp/dexter-<uid>, then $TMPDIR/dexter-<uid>. $TMPDIR is deliberately last: it is per process, so preferring it could split the workspace lock between two frontends. Candidates that are not real directories owned by this user, are symlinks, or make the socket path too long for sockaddr_un are skipped.
  • Elixir and mix detection also searches the standard mise, asdf, and Homebrew locations and falls back to a login shell, so an editor-launched daemon with a stripped PATH no longer disables stdlib indexing or formatting.
  • Spawned daemons are reaped; a panic in one connection is contained; a daemon whose socket disappears exits so a replacement can start; transient accept errors back off instead of taking the daemon down; CLI control calls are bounded so an unavailable filesystem cannot hang a shell forever.

Follow-ups

  • The CLI's name-based lookup remains store-level. Editor-grade resolution (BEAM-generated functions, use-chain helpers) is available through the daemon's LSP endpoint; wiring workspace/lookup to the shared language services is the next step, alongside the MCP control methods sketched in docs/daemon.md.
  • Version and IndexVersion are intentionally not bumped here; the release PR bumps them together with daemon.ContractVersion.

Testing

go test ./..., go test -race ./..., golangci-lint, and gofmt are clean. New coverage includes the daemon transport and registries, ownership and symlink identity, contract-mismatch directions and replacement, forced stop with SIGKILL escalation, the non-project guard, socket-disappearance exit, connection-panic isolation, watch degradation and the periodic fallback, and end-to-end CLI/LSP runs against real daemons.


Note

High Risk
This is a major architectural change to process ownership, indexing, and LSP startup; regressions could affect index correctness, multi-editor sessions, upgrades, and shutdown/replacement behavior across the whole toolchain.

Overview
Introduces a per-workspace daemon that is the sole owner of the SQLite index, file/Git watchers, mutation queue, and shared language caches. Every frontend attaches to it: dexter lsp becomes a raw stdio proxy (bytes copied, not re-parsed), while lookup, references, and reindex call the daemon over a multiplexed control protocol instead of opening .dexter/dexter.db directly. dexter init still builds the index locally but takes the same advisory lock and asks an idle daemon to exit.

New CLI surface: global --root/-C, dexter stop (polite shutdown vs --force), --wait/--quiet on queries, and a project-root guard (mix.exs, .git, or .dexter; -y to override; LSP warns only). Runtime lives under /tmp/dexter-<uid> with contract versioning (ContractVersion + handshake) to replace incompatible daemons on upgrade.

Adds internal/daemon (transport, ownership, client/server) and internal/workspace (shared runtime), fsnotify/FSEvents watching with degraded-path recovery, plus docs/daemon.md and broad integration/unit tests.

Reviewed by Cursor Bugbot for commit f106ab2. Bugbot is set up for automated code reviews on this repo. Configure here.

Refs #100 and #84. Issue #100 asks for CLI parity with the LSP and floats a background daemon; this is that daemon, and the CLI now shares one index with the editor. The remaining half of #100 — name-based CLI answers that use the LSP's resolution, not just the store — is the follow-up below. PR #84's MCP server is the other frontend this was built for: after it is rebased, it should attach through `daemon.Ensure` and the control-method registries instead of owning its own store, watchers, and initial index pass (`docs/daemon.md` maps the ten tools onto the recommended daemon seams).

Every dexter frontend — the editor, the CLI, and future MCP clients — now attaches to one per-workspace daemon that owns the SQLite index, the file and Git watchers, the mutation queue, and the language caches. The daemon belongs to the workspace rather than to whichever frontend started it; `dexter lsp` is a raw stdio proxy onto it, and `lookup`, `references`, `reindex`, and `stop` are control clients.

Each frontend used to open its own store handle, run its own watchers and caches, and index the same tree twice. A shell reindex could race the editor's writer, each editor session duplicated the warm caches, and an editor restart lost them. There is no in-process editor mode any more: one workspace has one owner, so every frontend sees the same index and the same answers.

## Highlights

- Ownership is an advisory kernel lock held for the process lifetime, so a crash releases it at once. The socket binds before the first index pass, so opening an editor never waits on a cold build, and the daemon exits after 15 minutes with no clients (`DEXTER_DAEMON_IDLE_TIMEOUT`, `0` keeps it).
- The handshake carries a single contract version, bumped together with `IndexVersion` only when an older daemon must not keep serving. A newer frontend replaces the daemon — which exits itself under the contract, or is signaled by the pid in its refusal — and a newer daemon refuses an older frontend with a message to restart it. No client process is ever killed by the daemon, because an editor or agent owns those.
- `dexter stop [path] [--force]` ends a workspace daemon by workspace, not by pid. Plain `stop` goes through the protocol and refuses while clients are attached; `--force` skips the handshake, locates the process by command line (uid-filtered), and escalates SIGTERM to SIGKILL after a grace period.
- `--root`/`-C` on every command runs it as if started in that directory, so agents, scripts, and editor wrappers can target a project from anywhere. Relative paths, a `reindex` target included, resolve from the named root.
- A guard refuses to treat a directory with no `mix.exs`, `.git`, or `.dexter` as a workspace unless `-y/--yes` is passed, so a mistyped command in the home directory cannot build an index over everything. `lsp` warns and serves instead, because an editor is authoritative about what the user opened.

## Resilience

- One unwatchable directory no longer disables the whole native watcher; the runtime adds a periodic reconciliation whenever watching is missing or partial, which keeps a daemon-only workspace fresh without an editor.
- Runtime files prefer `$XDG_RUNTIME_DIR/dexter`, then `/tmp/dexter-<uid>`, then `$TMPDIR/dexter-<uid>`. `$TMPDIR` is deliberately last: it is per process, so preferring it could split the workspace lock between two frontends. Candidates that are not real directories owned by this user, are symlinks, or make the socket path too long for `sockaddr_un` are skipped.
- Elixir and mix detection also searches the standard mise, asdf, and Homebrew locations and falls back to a login shell, so an editor-launched daemon with a stripped PATH no longer disables stdlib indexing or formatting.
- Spawned daemons are reaped; a panic in one connection is contained; a daemon whose socket disappears exits so a replacement can start; transient accept errors back off instead of taking the daemon down; CLI control calls are bounded so an unavailable filesystem cannot hang a shell forever.

## Follow-ups

- The CLI's name-based `lookup` remains store-level. Editor-grade resolution (BEAM-generated functions, use-chain helpers) is available through the daemon's LSP endpoint; wiring `workspace/lookup` to the shared language services is the next step, alongside the MCP control methods sketched in `docs/daemon.md`.
- `Version` and `IndexVersion` are intentionally not bumped here; the release PR bumps them together with `daemon.ContractVersion`.

## Testing

`go test ./...`, `go test -race ./...`, `golangci-lint`, and `gofmt` are clean. New coverage includes the daemon transport and registries, ownership and symlink identity, contract-mismatch directions and replacement, forced stop with SIGKILL escalation, the non-project guard, socket-disappearance exit, connection-panic isolation, watch degradation and the periodic fallback, and end-to-end CLI/LSP runs against real daemons.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread cmd/main.go
Comment thread cmd/main.go
CI caught that `stop --force` could report success while the target process
was still alive: the escalation waited for the ownership lock to be released,
and a process that never held it (a wedged daemon whose lock file was removed,
or the test's trap-TERM helper) made that wait look satisfied immediately, so
no SIGKILL was sent. The wait now polls the process itself, with the lock as a
zombie-safe fallback at the end, which is both the correct condition and
deterministic under Linux timing.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread internal/workspace/watch.go Outdated
Comment thread internal/lsp/server.go

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 27f4c84. Configure here.

Comment thread cmd/main.go
@JesseHerrick
JesseHerrick merged commit d1c361f into main Sep 22, 2026
5 checks passed
@JesseHerrick
JesseHerrick deleted the workspace-daemon branch September 22, 2026 21:08
JesseHerrick added a commit that referenced this pull request Oct 4, 2026
Main now has one workspace daemon for every frontend (#105) and one name
navigation for the CLI and the editor (#107). This merge makes `dexter mcp`
a frontend of that daemon, like `dexter lsp` and the CLI:

- The frontend keeps the MCP protocol and the root negotiation. It opens no
  store and starts no watcher. For each root it holds one control
  connection to the daemon (daemon.Ensure), and sends each tool call as the
  new control method "mcp/tool".
- The tool bodies run in the daemon, against its store and its headless
  language service. Definitions and references use LookupName and
  ReferenceNames, the same navigation as the editor and `dexter lookup`.
- A tool call waits for a cold index for a short time, then answers with a
  note when the index is still building or has a warning or an error
  condition (#114). The rename tool refuses until the index is complete.
- dexter_reindex uses the daemon's reindex barrier.
- Negotiated and fallback roots resolve with the CLI's project-root search.
  A launch directory that is not a project is refused as a fallback.

Removed because the daemon now does this work: the MCP file watcher, the
per-root store bindings, the git HEAD watch stop, the blocking reindex,
attached mode (`dexter lsp --mcp-listen`, which needs an in-process LSP),
deliverEdits and its text-edit applier, CollectReferences, and Serve.

The rename tools keep the editor rename machinery. RenameFunction and
RenameModule run it on the headless language service, report the files
changed, moved, and not written, and return when the index shows the
rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JesseHerrick added a commit that referenced this pull request Oct 4, 2026
## What

A built-in MCP server, modeled on [gopls
mcp](https://go.dev/gopls/features/mcp), so AI agents can navigate
Elixir codebases through dexter's index instead of grep:

```sh
claude mcp add dexter -- dexter mcp
```

Ten tools, deliberately coarse and agent-oriented rather than 1:1 LSP
methods, and addressed by module/function name rather than file+position
(Elixir modules are not tied to files, which makes name-based addressing
the natural fit for agents):

| Tool | What it does |
|---|---|
| `dexter_workspace` | project layout, index state and active
conditions, stdlib status |
| `dexter_search` | fuzzy workspace symbol search |
| `dexter_definition` | definition with `@doc`/`@spec` and source
snippet; follows defdelegate chains; at most 20 clauses shown |
| `dexter_references` | references including use-chain injected call
sites |
| `dexter_module_api` | moduledoc, public functions with signatures and
doc summaries, delegates, types, callbacks, submodules |
| `dexter_file_outline` | modules/functions a file defines (fresh
parse); only regular files inside the project, up to 10 MB |
| `dexter_implementations` | behaviour implementors and protocol
defimpls |
| `dexter_call_hierarchy` | incoming/outgoing calls |
| `dexter_reindex` | forces a reindex through the daemon (the index also
updates automatically via the daemon's file watcher) |
| `dexter_rename_symbol` | workspace-wide rename of a module or function
with the editor rename machinery: writes changes, moves
convention-following files, reports every file touched, returns when the
index shows the rename |

Transports: stdio (`dexter mcp`) and streamable HTTP (`dexter mcp
--listen ADDR`). `dexter mcp --instructions` prints an agent-facing
guide covering Elixir-specific behavior (modules vs files, defdelegate
following, use-chain injection, behaviours vs protocols).

Uses the official `github.com/modelcontextprotocol/go-sdk` (v1.6.1,
stable), the same SDK gopls uses. Tool input schemas are inferred from
Go param structs.

## Why a built-in MCP server rather than an LSP bridge?

Agent frontends can already drive `dexter lsp` through a generic LSP
bridge (Claude Code's LSP tool, for example), so the real question is
what built-in tools add over bridging.

A bridge inherits LSP's request shapes. Apart from workspace symbol
search, every operation is position-based: the agent must find the file,
locate the exact line and column, make the call, then open each returned
location. Every step is a round trip, and a wrong position silently
returns nothing. A bridge also cannot expose anything the protocol does
not define.

Built-in tools have neither limit:

- Name-based: `dexter_definition {module: MyApp.Accounts, function:
fetch_user}` answers directly.
- Coarse: `dexter_module_api` summarizes a whole module in one call;
references include source lines, so no second pass.
- Beyond LSP's surface: workspace overview and explicit reindexing have
no LSP method to bridge.
- Elixir-aware: server instructions cover defdelegate, use-chain
injection, and modules vs files.
- Client-agnostic: one-line registration in anything that speaks MCP;
bridges exist only in some clients.

Editors keep the LSP; both share the same index.

## How it works: a frontend of the workspace daemon

`dexter mcp` is a frontend of the shared workspace daemon (#105), like
`dexter lsp` and the CLI.

- **No workspace state in the MCP process.** It opens no index and
starts no watcher. For each workspace root it holds one control
connection to the daemon (`daemon.Ensure`), which also keeps the daemon
alive while the session is open. Freshness comes from the daemon's file
watcher and git HEAD poll. When the daemon goes away, the frontend
reconnects once per call; a rename is never sent twice.
- **Tool bodies run in the daemon.** Each tool call goes to the daemon
as one control method, `mcp/tool`, with the tool name and its arguments.
The daemon runs the tool against its store and its headless language
service. One method (instead of one per tool) keeps each tool's
parameter type in one place, shared by the input schema and the body.
- **Cancellation.** A canceled tool call sends `$/cancel` to the daemon,
which cancels that request's context and frees its slot. A frontend runs
at most 32 calls at a time per workspace, so one agent cannot fill the
daemon's request limit that editors share. The daemon `ContractVersion`
is now 4: a newer frontend replaces an older daemon, and an older
frontend gets a clear upgrade error from a newer daemon.
- **Shared navigation.** `dexter_definition` and `dexter_references` use
`LookupName` and `ReferenceNames`, the same navigation as
go-to-definition, find-references, and `dexter lookup` (use chains,
injected aliases, generated functions and their declaring lines).
- **Index state in the answers (#114).** A tool call waits up to 30 s
for a cold index (bounded by the call's context), then answers from what
is indexed. An answer from an index that is still building, or that has
an active warning or error condition, ends with a note. The rename tool
refuses until the index is complete.
- **Unsaved editor buffers.** Tools that show source text use an
editor's buffer for a file only when that buffer has unsaved edits and
the file on disk is not newer than those edits. Otherwise they read the
disk; when both changed, the answer says that an editor also has unsaved
changes. Index lines are mapped into the buffer with a bounded line
diff, and the answer names the files that came from unsaved buffers.
- **Roots.** Client roots and the fallback root resolve with the CLI's
project-root search, so every frontend reaches the same daemon. A root
that is not a project (for example a home directory) is refused, for
client roots and for the launch directory. When a daemon already serves
the directory through another spelling, the frontend uses the daemon's
root. A session serves the first usable root.
- **HTTP mode.** `--listen` accepts only loopback addresses unless
`--listen-unsafe` is given (which logs a warning). The handler refuses
cross-origin requests and keeps the SDK's localhost Host check. Request
bodies are limited to 4 MB (413), and an idle HTTP session ends after 30
minutes.
- **Rename.** Renames from every frontend run one at a time
(`renameSerial`), so two parallel renames cannot lose each other's
edits. A canceled rename stops if it has not started to write; one
canceled during its writes completes, and the agent is told to check
`git status`. The MCP rename does not yet apply the shared rule for
buffers that an editor has open (never write over unsaved editor work);
the README asks users to save before an agent renames, and the rename
tool adopts that rule when it lands in `internal/lsp`.
- **Recovery hints** say `dexter stop --force`, which works while
frontends are attached.

Co-authored-by: Jesse Herrick <jesse@remote.com>
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