Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ If the durable ingestion state is invalid or inconsistent with the current confi
ignores it and starts a new safe run. It does not resume from untrusted table names or staged
manifest paths.

## Windows reports `EPERM`, `EBUSY`, or `ENOTEMPTY`

Antivirus scanners, search indexing, and sync clients such as OneDrive briefly hold new files on
Windows. Ragmir retries the atomic renames and directory removals they block for about two
seconds. If the error persists, keep `.ragmir/` out of synced folders or exclude it from real-time
scanning, then run `rgr ingest` again; the active index stays searchable meanwhile.

## The activation manifest was recovered

If `rgr doctor` reports that the canonical activation manifest is missing or invalid, Ragmir uses
Expand Down
5 changes: 3 additions & 2 deletions packages/ragmir-core/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { isRagmirError, type RagmirError } from "./errors.js"
import { initProject } from "./init.js"
import { search } from "./query.js"
import { INDEX_READ_DIAGNOSTICS_CHANNEL, type IndexReadDiagnosticsEvent } from "./store.js"
import { integrationTimeout } from "./test-support/timeouts.js"
import { workloadSnapshot } from "./workload.js"

const tempDirs: string[] = []
Expand All @@ -32,7 +33,7 @@ async function projectWithEvidence(prefix: string): Promise<string> {
return root
}

describe("RagmirClient", () => {
describe("RagmirClient", { timeout: integrationTimeout(10_000) }, () => {
it("should reuse one client for ingestion and cited retrieval", async () => {
const root = await projectWithEvidence("ragmir-client-")
const client = await createRagmirClient({ cwd: root })
Expand Down Expand Up @@ -169,7 +170,7 @@ describe("RagmirClient", () => {
const config = await loadConfig(root)
expect(workloadSnapshot(config, "search")).toMatchObject({ active: 0, queued: 0 })
expect(workloadSnapshot(config, "embedding")).toMatchObject({ active: 0, queued: 0 })
}, 10_000)
})

it("should reject a 100-request burst at the configured queue boundary", async () => {
const root = await projectWithEvidence("ragmir-client-overload-")
Expand Down
7 changes: 4 additions & 3 deletions packages/ragmir-core/src/config-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ingest } from "./ingest.js"
import { initProject } from "./init.js"
import { expandCitation, search } from "./query.js"
import { readIndexManifest, writeIndexManifest } from "./store.js"
import { integrationTimeout } from "./test-support/timeouts.js"
import { inspectUpgrade, upgradeProject } from "./upgrade.js"

const roots: string[] = []
Expand All @@ -20,7 +21,7 @@ async function fixture() {
return { root, configPath: path.join(root, ".ragmir/config.json") }
}

describe("source-preserving migration", () => {
describe("source-preserving migration", { timeout: integrationTimeout(20_000) }, () => {
it("should refuse silent removal when a legacy config requests masking", async () => {
const { root, configPath } = await fixture()
const previous = JSON.stringify({ redaction: { enabled: true }, privacyProfile: "private" })
Expand Down Expand Up @@ -74,7 +75,7 @@ describe("source-preserving migration", () => {
removedKeys: [],
backupPath: null,
})
}, 20_000)
})

it("should keep formerly blocked external operations disabled when migrating strict mode", async () => {
const { root, configPath } = await fixture()
Expand All @@ -99,5 +100,5 @@ describe("source-preserving migration", () => {
mcpMaxTopK: 5,
mcpMaxOutputBytes: 16_384,
})
}, 20_000)
})
})
7 changes: 4 additions & 3 deletions packages/ragmir-core/src/context-resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"
import { getKnowledgeBaseContext, getKnowledgeBaseSourceCatalog } from "./context-resources.js"
import { ingest } from "./ingest.js"
import { initProject } from "./init.js"
import { integrationTimeout } from "./test-support/timeouts.js"

const tempDirs: string[] = []

Expand All @@ -14,7 +15,7 @@ afterEach(async () => {
}
})

describe("context resources", () => {
describe("context resources", { timeout: integrationTimeout(15_000) }, () => {
it("should stop context diagnostics when the signal is already aborted", async () => {
const controller = new AbortController()
controller.abort("cancelled by caller")
Expand Down Expand Up @@ -73,7 +74,7 @@ describe("context resources", () => {
expect((await getKnowledgeBaseContext(secondRoot)).corpusFingerprint).not.toBe(
first.corpusFingerprint,
)
}, 15_000)
})

it("should cap source details while preserving complete totals", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-source-resource-"))
Expand Down Expand Up @@ -104,5 +105,5 @@ describe("context resources", () => {
expect(secondPage.indexedFiles).toHaveLength(5)
expect(secondPage.indexedFiles[0]?.source).toBe(".ragmir/raw/source-50.md")
expect(secondPage.page).toEqual({ offset: 50, limit: 10, nextOffset: null })
}, 15_000)
})
})
5 changes: 3 additions & 2 deletions packages/ragmir-core/src/destroy.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { existsSync } from "node:fs"
import { realpath, rm } from "node:fs/promises"
import { realpath } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { loadConfig } from "./config.js"
import { INDEX_MANIFEST_FILENAME } from "./defaults.js"
import { disposeTransformersCache } from "./embeddings.js"
import { removeTree } from "./fs-retry.js"
import { withIndexWriteLock } from "./index-write-lock.js"
import { readIngestionState } from "./ingestion-state.js"
import { operationSignal } from "./operation.js"
Expand Down Expand Up @@ -35,7 +36,7 @@ export async function destroyIndex(
hasIngestionState,
hasFingerprintCache,
)
await rm(config.storageDir, { recursive: true, force: true })
await removeTree(config.storageDir)
// Release any cached Transformers.js pipelines so a subsequent re-ingest with
// a different embedding config does not pin stale ONNX weights in memory.
await disposeTransformersCache()
Expand Down
9 changes: 5 additions & 4 deletions packages/ragmir-core/src/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { doctor } from "./doctor.js"
import { ingest } from "./ingest.js"
import { initProject } from "./init.js"
import { installAgentSkills, installSkill } from "./skill.js"
import { integrationTimeout } from "./test-support/timeouts.js"

const tempDirs: string[] = []

Expand All @@ -16,7 +17,7 @@ afterEach(async () => {
}
})

describe("doctor", () => {
describe("doctor", { timeout: integrationTimeout(30_000) }, () => {
it("should stop diagnostics when the signal is already aborted", async () => {
const controller = new AbortController()
controller.abort("cancelled by caller")
Expand Down Expand Up @@ -71,7 +72,7 @@ describe("doctor", () => {
expect(withEvidence.nextSteps).toContain(
"Run `pnpm exec rgr doctor --fix` to rebuild stale or missing index data.",
)
}, 15_000)
})

it("reports files added or edited after ingestion instead of trusting the ingest snapshot", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-doctor-freshness-"))
Expand Down Expand Up @@ -106,7 +107,7 @@ describe("doctor", () => {
await ingest({ cwd: root })
const reindexed = await doctor(root)
expect(reindexed.sourceFreshness).toMatchObject({ missingFromIndex: 0, staleInIndex: 0 })
}, 30_000)
})

it("recommends the semantic setup shortcut after the index is ready", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-doctor-"))
Expand Down Expand Up @@ -153,7 +154,7 @@ describe("doctor", () => {
const integrated = await doctor(root)
expect(integrated.agentIntegration.projectAgents).toContain("codex")
expect(integrated.agentIntegration.ready).toBe(integrated.agentIntegration.runnerReady)
}, 15_000)
})

it("does not report complete coverage when a supported file yields no text", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-doctor-empty-"))
Expand Down
5 changes: 3 additions & 2 deletions packages/ragmir-core/src/durable-file.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { AsyncLocalStorage } from "node:async_hooks"
import { randomUUID } from "node:crypto"
import type { FileHandle } from "node:fs/promises"
import { open, rename, rm } from "node:fs/promises"
import { open, rm } from "node:fs/promises"
import { renameWithRetry } from "./fs-retry.js"
import { ensurePrivateDirectory, hardenPrivateFile } from "./permissions.js"

export type DurableWritePhase = "before-write" | "before-sync" | "before-rename" | "after-rename"
Expand Down Expand Up @@ -41,7 +42,7 @@ export async function writePrivateFileAtomic(
await handle.close()
handle = undefined
await notifyPhase(targetPath, temporaryPath, "before-rename")
await rename(temporaryPath, targetPath)
await renameWithRetry(temporaryPath, targetPath)
await syncDirectory(directory)
await notifyPhase(targetPath, temporaryPath, "after-rename")
} finally {
Expand Down
13 changes: 7 additions & 6 deletions packages/ragmir-core/src/embeddings.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { createHash, randomUUID } from "node:crypto"
import { createReadStream } from "node:fs"
import { mkdir, mkdtemp, readdir, rename, rm, stat } from "node:fs/promises"
import { mkdir, mkdtemp, readdir, stat } from "node:fs/promises"
import path from "node:path"
import { MAX_EMBEDDING_BATCH_SIZE } from "./defaults.js"
import { removeTree, renameWithRetry } from "./fs-retry.js"
import { throwIfAborted } from "./operation.js"
import { ensurePrivateDirectory } from "./permissions.js"
import { tokenize } from "./text.js"
Expand Down Expand Up @@ -145,7 +146,7 @@ export async function pullEmbeddingModel(config: Config): Promise<PullEmbeddingM
embeddingModelPath: config.embeddingModelPath,
}
} finally {
await rm(downloadRoot, { recursive: true, force: true })
await removeTree(downloadRoot)
}
}

Expand Down Expand Up @@ -556,18 +557,18 @@ async function replaceDirectory(target: string, replacement: string): Promise<vo
() => false,
)
if (hadPrevious) {
await rename(target, previous)
await renameWithRetry(target, previous)
}
try {
await rename(replacement, target)
await renameWithRetry(replacement, target)
} catch (error) {
if (hadPrevious) {
await rename(previous, target)
await renameWithRetry(previous, target)
}
throw error
}
if (hadPrevious) {
await rm(previous, { recursive: true, force: true })
await removeTree(previous)
}
}

Expand Down
5 changes: 3 additions & 2 deletions packages/ragmir-core/src/evaluate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
readIndexManifest,
writeIndexManifest,
} from "./store.js"
import { integrationTimeout } from "./test-support/timeouts.js"

const tempDirs: string[] = []
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
Expand All @@ -33,7 +34,7 @@ afterEach(async () => {
}
})

describe("evaluateGoldenQueries", () => {
describe("evaluateGoldenQueries", { timeout: integrationTimeout(15_000) }, () => {
it("should stop before reading a golden file when the signal is already aborted", async () => {
const controller = new AbortController()
controller.abort("cancelled by caller")
Expand Down Expand Up @@ -233,7 +234,7 @@ describe("evaluateGoldenQueries", () => {
} finally {
unsubscribe(INDEX_READ_DIAGNOSTICS_CHANNEL, onDiagnostic)
}
}, 15_000)
})

it("caps query topK when a caller provides a maximum", async () => {
const parent = await mkdtemp(path.join(os.tmpdir(), "ragmir-evaluate-cap-"))
Expand Down
90 changes: 90 additions & 0 deletions packages/ragmir-core/src/fs-retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { afterEach, describe, expect, it } from "vitest"
import { removeTree, renameWithRetry } from "./fs-retry.js"

const tempDirs: string[] = []

afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await rm(dir, { recursive: true, force: true })
}
})

function systemError(code: string): NodeJS.ErrnoException {
return Object.assign(new Error(`${code}: simulated`), { code })
}

describe("Windows-tolerant file operations", () => {
it("retries a rename that Windows refuses while another process holds the file", async () => {
const failures = ["EPERM", "EBUSY", "EACCES"]
const attempts: string[] = []

await renameWithRetry("source", "target", {
platform: "win32",
delaysMs: [0, 0, 0, 0],
renameFile: async () => {
const code = failures.shift()
attempts.push(code ?? "renamed")
if (code) {
throw systemError(code)
}
},
})

expect(attempts).toEqual(["EPERM", "EBUSY", "EACCES", "renamed"])
})

it("fails at once on other platforms and errors, and after the last Windows retry", async () => {
let calls = 0
const failWith = (code: string) => async (): Promise<void> => {
calls += 1
throw systemError(code)
}

await expect(
renameWithRetry("a", "b", { platform: "linux", renameFile: failWith("EPERM") }),
).rejects.toMatchObject({ code: "EPERM" })
expect(calls).toBe(1)

calls = 0
await expect(
renameWithRetry("a", "b", {
platform: "win32",
delaysMs: [0],
renameFile: failWith("ENOENT"),
}),
).rejects.toMatchObject({ code: "ENOENT" })
expect(calls).toBe(1)

calls = 0
await expect(
renameWithRetry("a", "b", {
platform: "win32",
delaysMs: [0, 0],
renameFile: failWith("EBUSY"),
}),
).rejects.toMatchObject({ code: "EBUSY" })
expect(calls).toBe(3)
})

it("renames real files and removes directory trees", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-fs-retry-"))
tempDirs.push(root)
const source = path.join(root, "state.json.tmp")
const target = path.join(root, "state.json")
await writeFile(target, "old\n")
await writeFile(source, "new\n")

await renameWithRetry(source, target)

expect(await readFile(target, "utf8")).toBe("new\n")
const tree = path.join(root, "generation", "nested")
await mkdir(tree, { recursive: true })
await writeFile(path.join(tree, "fragment.lance"), "data")
await removeTree(path.join(root, "generation"))
await removeTree(path.join(root, "missing"))
await expect(stat(path.join(root, "generation"))).rejects.toMatchObject({ code: "ENOENT" })
})
})
Loading
Loading