Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
cb3e1e9
fix(search): index vault changes that land while the server starts
aliasunder Oct 16, 2026
dbbff20
test(search): cover the held rescan, real chokidar and server wiring;…
aliasunder Oct 16, 2026
9b1bed5
test(search): make the deadline and embedding-failure tests fail on s…
aliasunder Oct 16, 2026
b10d8be
test(search): assert exact results instead of lengths and call-log re…
aliasunder Oct 16, 2026
5169bea
fix(review): go live and resolve startup when the held-change batch t…
aliasunder Oct 16, 2026
d1f7fd5
style(search): split indexChangedFile, model the hold as a state unio…
aliasunder Oct 16, 2026
57efaa9
test(search): cover the startup batch's folder, stat and rebuild-fail…
aliasunder Oct 16, 2026
a820697
refactor(search): share one file-read bound between the rebuild and t…
aliasunder Oct 16, 2026
ea411a9
fix(search): check every folder on a held path so a folder case-renam…
aliasunder Oct 16, 2026
cdd4249
fix(search): extract held PDFs at the rebuild's lower bound during th…
aliasunder Oct 16, 2026
00081dd
fix(search): hold back only the held PDFs whose text is extracted
aliasunder Oct 16, 2026
f0ae4b4
fix(search): bound the startup replay's reads and close the watcher w…
aliasunder Oct 16, 2026
b458e62
docs(deploy): list the startup tasks in their new order
aliasunder Oct 16, 2026
6768746
fix(search): keep the rebuild's error when closing the watcher fails
aliasunder Oct 16, 2026
d177e51
refactor(search): explain the rebuild's orphan sweep, second link cle…
aliasunder Oct 16, 2026
6c1746f
fix(search): skip replay reads that a later deletion or event replace…
aliasunder Oct 16, 2026
86aee05
fix(search): skip a closed watcher's scheduled folder rescan and pin …
aliasunder Oct 16, 2026
97a82da
test(search): assert the watcher's chokidar options and asset upsert …
aliasunder Oct 16, 2026
955b8d3
fix(search): log a failure to start the startup replay instead of rej…
aliasunder Oct 16, 2026
b009c80
docs(search): state why the rebuild clears links a second time
aliasunder Oct 16, 2026
98c753a
test(search): pin that a held change after a held deletion is replayed
aliasunder Oct 16, 2026
14b7072
refactor(search): take named params and the caller's logger in watchV…
aliasunder Oct 17, 2026
eade0a4
refactor(file-watcher): apply held startup changes in one live batch
aliasunder Oct 19, 2026
71b6910
docs(architecture): describe the live startup batch
aliasunder Oct 19, 2026
5339e2f
style(file-watcher): define held paths and event tokens where first u…
aliasunder Oct 19, 2026
c6b8072
test(file-watcher): pin that a held change before a deletion does not…
aliasunder Oct 19, 2026
5c723f8
fix(file-watcher): describe the startup batch's superseded reads as r…
aliasunder Oct 19, 2026
2a9fd8f
test(file-watcher): pin removals before reads and the vanished read l…
aliasunder Oct 19, 2026
48e5ee8
style(file-watcher): name the old path and the batch's reads in the r…
aliasunder Oct 19, 2026
5c14538
test(file-watcher): pin that the startup batch completes when a held …
aliasunder Oct 19, 2026
98dc559
docs(server): name the embedding promise startup leaves unawaited
aliasunder Oct 19, 2026
3cb8498
docs(server): say startup serves requests while the embedding pass runs
aliasunder Oct 19, 2026
568a32d
docs: say search finds a note by keyword only until its vectors exist
aliasunder Oct 19, 2026
bc4aeea
docs(deploy): keep the first-start embedding bullet one sentence like…
aliasunder Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,9 @@ root logger (src/logger.ts)
- Data-layer functions (`vault-filesystem`, `vault-patcher`,
`note-mover`, `memory-store`, `search-index`) take the logger as a
**required** second argument (two-arg pattern: `(params, logger)`)
- Background callers (file-watcher, startup) use the root logger
directly — no request context available
- Background modules (`file-watcher`) take the logger the same way,
through their entry point. Startup wiring in `server.ts` has no
request context, so it passes the root logger

**Two-arg `(params, logger)` pattern:**

Expand Down
20 changes: 16 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,11 +492,22 @@ When no embedder is configured (`EMBEDDING_ENABLED=false`), no vectors are index

### Indexing

**Indexing flow:** `rebuildFromVault` runs three passes, then returns so the server can start accepting requests:
**Indexing flow:** `rebuildFromVault` runs passes 1 and 2, then returns while pass 3 runs in the background:

1. **Pass 1** — index notes (FTS5 + metadata)
2. **Pass 2** — extract links (with the complete path list for resolution), then index file content (canvas, PDF, text → FTS5)
3. **Pass 3 (background)** — embed notes, then file content. Search works with FTS-only until vectors are ready
3. **Pass 3 (background)** — embed notes, then file content. Until a note's vectors exist, search finds it by keyword only

**Startup order:** `watchVaultAndBuildIndex` starts the file watcher before the rebuild, so a change that lands while the rebuild reads the vault is neither missed nor replaced by the rebuild's older read:

1. The watcher starts holding: from then on, it records each path chokidar reports as changed instead of indexing it. Startup waits for chokidar's initial scan to finish.
2. `rebuildFromVault` runs. The watcher is the index's only other writer, because a tool's vault write reaches the index as a watcher event. The watcher is holding, so nothing else writes while the rebuild reads.
3. The watcher goes live. In the same step, it removes each held path whose latest event was a deletion, and marks every held path so that a live change to it takes over from the step 4 batch.
4. One batch checks each held path against the disk, `INDEXING_FILE_READ_CONCURRENCY` (16) at a time. Paths gone from disk are removed first. The rest are read again within the rebuild's limits: other files 16 at a time, then the PDFs whose text is extracted, `PDF_EXTRACTION_CONCURRENCY` (4) at a time.

The server starts accepting requests once the step 4 batch has finished writing to the index, or after 10 s if a Sync backlog makes the batch longer; the batch then finishes in the background. A held path that a live change takes over is indexed by that change, which can finish just after the server starts.

A file that is created after step 1 and deleted within chokidar's 2 s write-stability window emits no event, so if the rebuild read it, it stays searchable until the next restart.

**Startup cleanup:** before resetting the source tables, the rebuild removes vectors whose parent chunk or memory-entry row is missing and retains vectors with surviving parents.

Expand Down Expand Up @@ -928,8 +939,9 @@ graph LR
both processes read/write the shared `/vault` volume. On startup: bootstraps
memory templates if the memory folder
doesn't exist, `MEMORY_ENABLED` is not `false`, and the server is not in
`READONLY_MODE`, builds the FTS5 search index including those templates,
then starts the file watcher.
`READONLY_MODE`, starts the file watcher, builds the FTS5 search index
including those templates, then applies the changes the watcher saw while
the index was built (see **Startup order** under [Indexing](#indexing)).

`svc-vault-mcp` declares `svc-obsidian-sync` in its `dependencies.d`, so the
MCP server starts only after the full init chain has finished and the sync
Expand Down
9 changes: 6 additions & 3 deletions deploy/local/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ What happens on first start:
- The image is pulled (~150MB)
- The keyword index builds in seconds — search works right away
- The semantic (embedding) index builds in the background — expect a few
minutes on a large vault, with search served keyword-only until it finishes
minutes on a large vault; keyword search works meanwhile, and semantic
matches appear as each note is embedded

> **On Windows?** Set `WINDOWS_MODE=true` in your `.env` — then `VAULT_PATH` can point
> at a normal Windows path like `C:\Users\you\MyVault`. With the CLI, edit the
Expand Down Expand Up @@ -192,8 +193,10 @@ The server runs these startup tasks on every boot:

1. Create memory template files if the memory folder doesn't exist (skipped
when `MEMORY_ENABLED=false` or `READONLY_MODE=true`).
2. Rebuild the search index, including any new memory template files.
3. Start the file watcher.
2. Start the file watcher, which holds the changes it sees until step 4.
3. Rebuild the search index, including any new memory template files.
4. Apply the changes the watcher held, so search reflects notes added, edited
or deleted while the server started.

Restarting the container re-runs this flow (useful when testing bootstrap
behavior). The command is the same for both setup methods,
Expand Down
6 changes: 4 additions & 2 deletions deploy/remote/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,10 @@ starts. The server then runs these startup tasks:

1. Create memory template files if the memory folder doesn't exist (skipped
when `MEMORY_ENABLED=false` or `READONLY_MODE=true`).
2. Rebuild the search index, including any new memory template files.
3. Start the file watcher.
2. Start the file watcher, which holds the changes it sees until step 4.
3. Rebuild the search index, including any new memory template files.
4. Apply the changes the watcher held, so search reflects notes added, edited
or deleted while the server started.

Restarting the container re-runs this flow (useful when testing bootstrap
behavior). The command is the same for every setup
Expand Down
35 changes: 35 additions & 0 deletions src/__tests__/integration/server-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,41 @@ it("indexes newly bootstrapped memory templates before accepting requests", asyn
)
})

// Startup holds file-watcher events until its index batch ends, so a server
// that never stopped holding would never index this note.
it("indexes a note written after startup once the file watcher reports it", async () => {
const server = await startServer(await freePort())
onTestFinished(server.cleanup)
const client = await createTestClient(server.port)
onTestFinished(() => client.close())

const writeResult = await callTool({
client,
name: "vault_write_note",
args: { path: "watcher-smoke.md", body: "watchersmokeopal\n" },
})
expect(writeResult.isError).not.toBe(true)

// Polls without throwing, so a server that never indexes the note fails on
// the exact assertion below rather than on a timeout.
const pollDeadline = DateTime.now().plus({ seconds: 10 })
const searchUntilFound = async (): Promise<string[]> => {
const searchResult = await callTool({
client,
name: "vault_search",
args: { query: "watchersmokeopal" },
})
const foundPaths = JSON.parse(textContent(searchResult)).results.map(
(entry: { path: string }) => entry.path,
)

if (foundPaths.length > 0 || DateTime.now() > pollDeadline) return foundPaths
await new Promise((resolve) => setTimeout(resolve, 250))
return searchUntilFound()
}
expect(await searchUntilFound()).toEqual(["watcher-smoke.md"])
}, 30_000)

/** Extract joined text from a prompt result's messages. */
const promptText = (result: Awaited<ReturnType<Client["getPrompt"]>>): string => {
return result.messages
Expand Down
Loading
Loading