Skip to content
Open
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
6fa7d04
fix(indexing): serialize workspace index writes and preserve partial …
maphew Oct 5, 2026
bce5b0a
chore(changeset): mark single-writer change as minor for breaking int…
maphew Oct 6, 2026
48f5dba
docs(indexing): correct state() doc comment on vector stores
maphew Oct 10, 2026
dc3a7b2
fix(indexing): harden single-writer lock lifecycle and contention
maphew Oct 11, 2026
ee50689
Merge remote-tracking branch 'upstream/main' into fix/index-single-wr…
maphew Oct 16, 2026
af61ae3
chore(changeset): add trailing newline
maphew Oct 16, 2026
729b1ec
Merge remote-tracking branch 'upstream/main' into fix/index-single-wr…
maphew Oct 17, 2026
c5709ad
fix(indexing): hold writer lock for session lifetime, drain close races
maphew Oct 17, 2026
8645197
Merge remote-tracking branch 'upstream/main' into fix/index-single-wr…
maphew Oct 18, 2026
5561125
Merge remote-tracking branch 'upstream/main' into fix/index-single-wr…
maphew Oct 19, 2026
ff78873
Merge remote-tracking branch 'upstream/main' into fix/index-single-wr…
maphew Oct 19, 2026
7b20620
Merge branch 'fix/index-single-writer' of https://github.com/maphew/k…
maphew Oct 19, 2026
7be0b6e
chore(changeset): add trailing newline
maphew Oct 21, 2026
c76c878
Merge upstream/main into fix/index-single-writer
maphew Oct 22, 2026
2f589b9
Merge remote-tracking branch 'upstream/main' into fix/index-single-wr…
maphew Oct 22, 2026
8e832b6
fix(indexing): verify cached writer ownership and release lock on clear
maphew Oct 22, 2026
014a2b0
test(indexing): exit the keyless probe after dispose on Windows
maphew Oct 22, 2026
9a462ec
Merge remote-tracking branch 'upstream/main' into fix/index-single-wr…
maphew Oct 22, 2026
715dd3b
fix(indexing): verify lock ownership per watcher batch and drain scan…
maphew Oct 22, 2026
1342f58
fix(indexing): drain watcher batches before clear and tolerate a blip…
maphew Oct 22, 2026
915e655
fix(indexing): re-acquire the writer lock before clearing after a fai…
maphew Oct 22, 2026
ceb01eb
Merge remote-tracking branch 'upstream/main' into fix/index-single-wr…
maphew Oct 22, 2026
ee2c8a0
Merge remote-tracking branch 'upstream/main' into fix/index-single-wr…
maphew Oct 23, 2026
cc1ccc9
Merge remote-tracking branch 'upstream/main' into fix/index-single-wr…
maphew Oct 23, 2026
e00de4c
fix(indexing): verify cached writer ownership before clearing
maphew Oct 23, 2026
7dda113
Merge branch 'main' into fix/index-single-writer
maphew Oct 23, 2026
e632b58
Merge remote-tracking branch 'upstream/main' into fix/index-single-wr…
maphew Oct 24, 2026
eca8073
Merge remote-tracking branch 'origin/fix/index-single-writer' into fi…
maphew Oct 24, 2026
58be068
test(indexing): poll for cancel and drain instead of fixed sleeps
maphew Oct 24, 2026
907e4fa
Merge remote-tracking branch 'upstream/main' into fix/index-single-wr…
maphew Oct 26, 2026
c60db52
Merge remote-tracking branch 'upstream/main' into fix/index-single-wr…
maphew Oct 27, 2026
9c999e0
Merge remote-tracking branch 'upstream/main' into fix/index-single-wr…
maphew Oct 27, 2026
2a126d7
Merge remote-tracking branch 'upstream/main' into fix/index-single-wr…
maphew Oct 27, 2026
1f9c145
Merge upstream/main into fix/index-single-writer
maphew Oct 27, 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
6 changes: 6 additions & 0 deletions .changeset/single-writer-codebase-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@kilocode/kilo-indexing": minor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SUGGESTION: This is a breaking change to a published package, but the changeset marks it minor.

The PR replaces the exported IVectorStore.hasIndexedData() method with state() and changes the IVectorStore public interface (packages/kilo-indexing/src/indexing/interfaces/vector-store.ts). IVectorStore is re-exported from the package entry (packages/kilo-indexing/src/indexing/index.ts:24) and @kilocode/kilo-indexing is at 7.8.3, so removing a public method is a major bump under the repo's changeset convention (AGENTS.md: patch for fixes, minor for features, major for breaking changes). If any external consumer implements IVectorStore, a minor release would break them without a major version signal. Consider major unless the maintainers have deliberately decided this interface is internal-only.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

---

Keep codebase indexing intact when several sessions share a workspace. Only one session now writes a workspace's index, and a session that finds a partially built index no longer discards it and starts over.

4 changes: 2 additions & 2 deletions packages/kilo-indexing/src/indexing/cache-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ export class CacheManager implements ICacheManager {
private saveTask = Promise.resolve()

constructor(
private readonly cacheDirectory: string,
public readonly dir: string,
private readonly workspacePath: string,
) {
const hash = createHash("sha256").update(workspacePath).digest("hex")
this.cachePath = path.join(cacheDirectory, `roo-index-cache-${hash}.json`)
this.cachePath = path.join(dir, `roo-index-cache-${hash}.json`)
}

async initialize(): Promise<void> {
Expand Down
16 changes: 13 additions & 3 deletions packages/kilo-indexing/src/indexing/interfaces/vector-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,13 @@ export interface IVectorStore {
collectionExists(): Promise<boolean>

/**
* Checks if the collection exists and has indexed points
* @returns Promise resolving to boolean indicating if the collection exists and has points
* Reports how far along the store is. `partial` means points exist but no
* successful scan finished, which is what a store owned by another live
* session looks like. Callers must treat `partial` as usable-but-untrusted
* and must never clear it on behalf of a session that does not own the
* writer lock.
*/
hasIndexedData(): Promise<boolean>
state(): Promise<StoreState>

/**
* Marks the indexing process as complete by storing metadata
Expand All @@ -94,6 +97,13 @@ export interface VectorStoreSearchResult {
payload?: Payload | null
}

export type StoreStatus = "empty" | "partial" | "complete"

export type StoreState = {
status: StoreStatus
points?: number
}

export interface Payload {
filePath: string
fileHash?: string
Expand Down
7 changes: 7 additions & 0 deletions packages/kilo-indexing/src/indexing/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@ export class CodeIndexManager {
if (!this.isFeatureEnabled || !this.isFeatureConfigured) return
if (this._retryTask || this._isRecoveringFromError) return

// Lock contention is not failure: another live session holds the writer
// lock, so retrying with backoff only spins while the holder watches.
// The second session retries on its own triggers (settings, restart,
// watcher events); a retry here would also recreate services, tearing
// down the loser's search path for no gain.
if (event.error.includes("Another session is indexing this workspace")) return

if (this._retryAttempt >= this._retryMaxAttempts) {
log.warn("indexing recovery retries exhausted", {
workspacePath: this.workspacePath,
Expand Down
190 changes: 180 additions & 10 deletions packages/kilo-indexing/src/indexing/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,30 @@ import type {
IndexingTelemetryTrigger,
} from "./interfaces/telemetry"
import type { IVectorStore } from "./interfaces/vector-store"
import type { StoreState } from "./interfaces"
import { DirectoryScanner } from "./processors"
import type { CacheManager } from "./cache-manager"
import type { Disposable } from "./runtime"
import { Log } from "../util/log"
import { sanitizeErrorMessage } from "./shared/validation-helpers"
import { DEFAULT_VECTOR_STORE } from "./constants"
import type { WorktreeOverlay } from "./worktree-overlay"
import { acquire, type Writer } from "./writer-lock"

const log = Log.create({ service: "indexing-orchestrator" })

const EMPTY: StoreState = { status: "empty" }

export class CodeIndexOrchestrator {
private _fileWatcherSubscriptions: Disposable[] = []
private _isProcessing = false
private _cancelRequested = false
private _active?: Promise<void>
// The session's writer lock, held from the first successful run until
// shutdown (or release on error/clear). The file watcher keeps writing the
// store after every scan, so releasing per-run would let a second session
// acquire the lock and scan concurrently — the corruption the lock targets.
private _writer?: Writer

constructor(
private readonly configManager: CodeIndexConfigManager,
Expand Down Expand Up @@ -97,6 +106,11 @@ export class CodeIndexOrchestrator {
workspacePath: this.workspacePath,
filesInBatch: paths.length,
})
// The watcher writes the store on its own after the scan, outside
// runIndexing. Re-check ownership before every batch: a heartbeat can
// lapse (event loop stall) long enough for another session to reclaim
// the lock, and this session must not keep committing without it.
void this._verifyWriter()
if (this.stateManager.state !== "Indexing") {
this.stateManager.setSystemState("Indexing", "Processing file changes...")
}
Expand Down Expand Up @@ -145,6 +159,68 @@ export class CodeIndexOrchestrator {
return task
}

/**
* Only one session may write a workspace's index. Without this, a second
* session starts a scan against a store the first one is still filling,
* clears it as unusable, and leaves both sessions committing to the same
* table. The lock is session-held (see _writer): once acquired, later runs
* reuse it instead of contending with themselves.
*/
private async _acquire(): Promise<Writer | undefined> {
if (this._writer) {
// The session caches its writer across runs. Verify ownership before
// reusing it: a heartbeat that saw another session's token stops beating,
// but only a fresh check here stops this orchestrator from writing the
// store after it has lost the lock.
if (await this._writer.owned()) return this._writer

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

WARNING: Ownership is only verified at acquisition; watcher batches still write the store after a takeover.

owned() now guards _acquire(), so a later runIndexing/clearIndexData notices a lost lock. But once the scan finishes, _runScan() hands writing to the file watcher (setCollecting(true), line 444) and those batches call upsertPoints() / deletePointsByMultipleFilePaths() directly with no ownership check. If this session's heartbeat lapses (e.g. the event loop is blocked past STALE_MS on a large workspace) and another session reclaims the lock, this session's watcher keeps committing to a store the new owner is scanning — the concurrent-writer corruption this lock exists to prevent. Consider stopping the watcher (or gating each batch) when ownership is lost.

log.warn("lost the writer lock since the last run; reacquiring", {
workspacePath: this.workspacePath,
})
this._writer = undefined
}
const dir = this.cacheManager.dir
if (!dir) {
log.warn("no cache directory for the writer lock, proceeding unlocked", {
workspacePath: this.workspacePath,
})
return { release: async () => {}, owned: async () => true }
}
const writer = await acquire(dir, this.workspacePath)
if (writer) {
this._writer = writer
return writer
}
log.info("writer lock is held elsewhere", { workspacePath: this.workspacePath })
return undefined
}

private async _releaseWriter(): Promise<void> {
const writer = this._writer
this._writer = undefined
await writer?.release()
}

/**
* Stops the watcher when this session no longer owns the writer lock.
*
* The heartbeat stops beating after a takeover, but the watcher keeps
* receiving file events and writing the store. Checking once per batch
* bounds how much can be written after ownership is lost; shutting the
* watcher down turns a silent split-brain into a visible Error state.
*/
private async _verifyWriter(): Promise<void> {
const writer = this._writer
if (!writer) return
if (await writer.owned()) return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SUGGESTION: Treating an unreadable meta.json as lost ownership stops the session permanently.

owned() is read(meta.json)?.token === token, and read() swallows every error and returns undefined, so a transient fs error (or a partially written file during a takeover) reads as "different owner". Unlike _acquire(), which retries, this path disposes the watcher and parks the session in Error with no retry, so one failed read ends file watching for the rest of the session. Consider distinguishing "cannot read" from "read a different token" — e.g. retry once, or only stop when a readable token differs.

if (this._writer !== writer) return
this._writer = undefined
log.error("writer lock lost; stopping the file watcher to avoid unlocked writes", {
workspacePath: this.workspacePath,
})
this.stateManager.setSystemState("Error", "Lost the indexing writer lock to another session. File watching stopped.")
this.stopWatcher()
}

private async runIndexing(trigger: IndexingTelemetryTrigger): Promise<void> {
log.info("indexing start requested", {
workspacePath: this.workspacePath,
Expand Down Expand Up @@ -176,6 +252,7 @@ export class CodeIndexOrchestrator {
}

this._cancelRequested = false

this._isProcessing = true
this.stateManager.setSystemState("Indexing", "Initializing services...")

Expand All @@ -184,6 +261,27 @@ export class CodeIndexOrchestrator {
let mode: IndexingTelemetryMode | undefined

try {
// Acquired inside the try and session-held (see _writer): a throw between
// acquire and the body (e.g. from state setup below) releases below and
// must not leave the lock fresh for STALE_MS, blocking other sessions.
// The lock is NOT released on success: the file watcher keeps writing the
// store after the scan, so it stays held until shutdown or clearIndexData.
const writer = await this._acquire()
if (!writer) {
// Contention is worth observing, not a silent park: the manager
// surfaces start events as activity, so emit an error event the manager
// can retry or display rather than leaving the session in Standby with
// no record of the missed run.
this.emitError(
"orchestrator:startIndexing",
new Error("Another session is indexing this workspace."),
"scan",
trigger,
)
this.stateManager.setSystemState("Standby", "Another session is indexing this workspace.")
return
}

this.overlay?.prepare()
await this._startWatcher()

Expand Down Expand Up @@ -214,8 +312,11 @@ export class CodeIndexOrchestrator {
})
}

const hasExistingData = this.overlay ? false : await this.vectorStore.hasIndexedData()
if (!this.overlay && !hasExistingData) {
const index = this.overlay ? EMPTY : await this.vectorStore.state()
// Only an empty store is safe to clear. A partial one belongs to a run
// that died or is still going, and clearing it throws away every point
// it managed to embed.
if (!this.overlay && index.status === "empty") {
if (!collectionCreated) await this.vectorStore.clearCollection()
await this.cacheManager.clearCacheFile()
log.info("cleared indexing cache before full scan", {
Expand All @@ -225,7 +326,7 @@ export class CodeIndexOrchestrator {
}
log.info("checked vector store indexed data", {
workspacePath: this.workspacePath,
hasExistingData,
status: index.status,
collectionCreated,
})

Expand All @@ -234,7 +335,7 @@ export class CodeIndexOrchestrator {
return
}

mode = hasExistingData && !collectionCreated ? "incremental" : "full"
mode = index.status === "complete" && !collectionCreated ? "incremental" : "full"

if (mode === "incremental") {
log.info("collection has existing data, running incremental scan")
Expand All @@ -245,7 +346,7 @@ export class CodeIndexOrchestrator {
} else {
log.info("running full scan", {
workspacePath: this.workspacePath,
hasExistingData,
status: index.status,
collectionCreated,
})
this.stateManager.setSystemState("Indexing", "Services ready. Starting workspace scan...")
Expand All @@ -265,6 +366,9 @@ export class CodeIndexOrchestrator {
const msg = err instanceof Error ? err.message : "Unknown error"
this.stateManager.setSystemState("Error", `Failed during initial scan: ${msg}`)
this.stopWatcher()
// The watcher is stopped, so this session no longer writes the store:
// hand the lock back instead of holding it while wedged in Error.
await this._releaseWriter()
} finally {
this._isProcessing = false
log.info("indexing start flow finished", {
Expand Down Expand Up @@ -359,7 +463,10 @@ export class CodeIndexOrchestrator {
this.overlay?.reconcile(this.cacheManager.getAllHashes())
await this.cacheManager.flush?.()
await this.vectorStore.markIndexingComplete()
await this.vectorStore.close?.()
// The scan finished, but this session's file watcher keeps writing the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

WARNING: Holding the writer lock for the whole session lifetime starves every other session on the workspace.

Because the success path no longer releases the lock (this comment), _writer stays set until shutdown()/dispose() or a failed run, and clearIndexData() never releases it either (line 551 only releases when the lock was freshly acquired). The manager suppresses recovery for the contention message (manager.ts:168), so a second session on the same workspace stays in Standby for as long as this process lives — not just until the current scan finishes. Since _acquire() now short-circuits on _writer (line 165), no later trigger can ever succeed while this session is open, so the "picks up on a later trigger" rationale does not hold.

Consider releasing the lock when the session goes idle (no watcher batches pending) or otherwise bounding the hold, instead of for the entire process lifetime.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

// store for every file change, so the session-held writer lock stays
// acquired until shutdown or clearIndexData (see _writer). Releasing here
// would let a second session acquire it and scan concurrently.
this.fileWatcher.setCollecting(true)
this.stateManager.setSystemState("Indexed", "File watcher started. Index up-to-date.")
log.info("workspace scan finalized", {
Expand All @@ -385,13 +492,23 @@ export class CodeIndexOrchestrator {
public async shutdown(): Promise<void> {
this._cancelRequested = true
this.scanner.cancel()
// Stop the session's own watcher writes before releasing the writer lock:
// the lock releases below, so the store must be quiet by then rather than
// still receiving file-watcher batches.
if (this.fileWatcher.shutdown) await this.fileWatcher.shutdown()
else this.fileWatcher.dispose()
this.fileWatcher.setCollecting(false)
// Wait out the in-flight scan before closing the store: a scan that is
// still running would lazily reconnect on its next getDb()/getTable() call,
// reopening a connection close() just tore down and leaking it. Closing
// after _active settles keeps the guarantee without racing the scan. The
// next session cannot interleave here: it can only acquire the writer lock
// once _releaseWriter runs below.
await this._active
await this.vectorStore.close?.()
await this._releaseWriter()
for (const sub of this._fileWatcherSubscriptions) sub.dispose()
this._fileWatcherSubscriptions = []
if (this.fileWatcher.shutdown) await this.fileWatcher.shutdown()
else this.fileWatcher.dispose()
await this.vectorStore.close?.()
this._isProcessing = false
}

Expand All @@ -413,17 +530,67 @@ export class CodeIndexOrchestrator {
log.info("cancelling indexing", { workspacePath: this.workspacePath })
this._cancelRequested = true
this.scanner.cancel()
this.stopWatcher()
// Keep the file watcher alive: cancel only aborts the scan, and the
// session still holds the writer lock, so watcher batches continue to own
// the store. Disposing here (via stopWatcher) would strand _active's
// finally with a disposed watcher while file changes keep arriving.
this.stateManager.setSystemState("Standby", "Indexing cancelled.")
this._isProcessing = false
log.info("indexing cancelled", { workspacePath: this.workspacePath })
}

public async clearIndexData(): Promise<void> {
// Reuse the session-held lock when this session owns it: acquiring again

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SUGGESTION: The stated reason for keeping the session-held lock is stale.

clearIndexData() calls this.stopWatcher() at line 531, which disposes the file watcher, so there are no "watcher's ongoing writes" left to protect here. The lock is retained purely to keep ownership for a subsequent reindex — reword the comment (or reconsider whether an owned clear should release like the fresh-lock path does).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

// would contend with ourselves. Whatever the source, the watcher is
// stopped below, so no writes remain and the lock is released afterwards.
const writer = await this._acquire()
if (!writer) {
// A user-requested clear must not vanish silently when another session
// holds the lock: surface contention as an error event with a
// clear-specific location. The manager only auto-retries startIndexing
// locations, so this stays terminal: the user sees the failure and can
// retry the clear once the other session finishes.
this.emitError(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SUGGESTION: This comment says the error event lets the manager "retry or display it", but manager.ts:168 now suppresses recovery for any error whose message contains "Another session is indexing this workspace" — the same text this and the start path emit. A contended clear therefore leaves the session in Error with no automatic retry, and it reports location: "orchestrator:startIndexing" for a clear operation. Consider a distinct location/message for the clear path, or reword the comment to match the no-retry behavior.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

"orchestrator:clearIndexData",
new Error("Another session is indexing this workspace; index data was not cleared."),
"scan",
)
this.stateManager.setSystemState("Error", "Another session is indexing this workspace; index data was not cleared.")
return
}

this._isProcessing = true
log.info("clearing index data", { workspacePath: this.workspacePath })

try {
// A clear must not race an in-flight scan. Cancel it and wait for the
// scan to settle before deleting the collection: otherwise batches still
// running can re-add points after the clear and write under a lock this
// method is about to release (shutdown() waits the same way).
this._cancelRequested = true
this.scanner.cancel()
await this._active

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

WARNING: Waiting for _active covers the scan but not the in-flight watcher batch.

stopWatcher() only calls fileWatcher.dispose(), which does not await drainTask (processors/file-watcher.ts:333-349; shutdown() does at line 329), and processBatch() keeps its upsert/delete phases running once started. A watcher batch already in progress when the user clears can therefore call upsertPoints() / deletePointsByMultipleFilePaths() after deleteCollection() (line 577) and after _releaseWriter() (line 595) hands the lock to another session — re-creating points the clear was meant to remove and writing under a lock this session no longer owns. The writer is also acquired before this wait (line 546), so if the awaited run fails and releases the lock in runIndexing's catch (line 371), the clear proceeds without it. Consider awaiting fileWatcher.shutdown() (or the drain task) and re-checking ownership after the wait before deleting.

// Verify ownership before deleting even when a writer is still cached:
// a heartbeat lapse during the awaited scan can let another session
// reclaim the lock while _writer stays set. _acquire() re-checks owned()
// and reuses the cached writer only when this session still holds it.
const retry = await this._acquire()
if (!retry) {
this.emitError(
"orchestrator:clearIndexData",
new Error("Another session is indexing this workspace; index data was not cleared."),
"scan",
)
this.stateManager.setSystemState(
"Error",
"Another session is indexing this workspace; index data was not cleared.",
)
return
}
// The watcher's drainTask is separate from _active: shutdown() waits for
// it before returning, so a batch already writing cannot upsert after
// deleteCollection() or after the lock is released below.
if (this.fileWatcher.shutdown) await this.fileWatcher.shutdown()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SUGGESTION: The new drain wait is not exercised by any test.

This await fileWatcher.shutdown() is the only thing keeping an in-flight watcher batch from calling upsertPoints() / deletePointsByMultipleFilePaths() after deleteCollection(), but the Watcher fake in orchestrator.test.ts only implements dispose() (line 131), so this.fileWatcher.shutdown is undefined and this branch is skipped in every orchestrator test. The existing "clearing waits for an in-flight scan" case covers _active only. A fake shutdown() that blocks on a deferred and asserts store.deleteCount === 0 until it resolves would lock in the ordering this line provides.

this.stopWatcher()

try {
Expand All @@ -444,6 +611,9 @@ export class CodeIndexOrchestrator {
}
} finally {
this._isProcessing = false
// stopWatcher() above means this session no longer writes the store, so
// a session-held lock must be handed back rather than kept until exit.
await this._releaseWriter()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

WARNING: Releasing the lock here does not wait for an in-flight scan, so a clear can hand the store to another session while this one is still writing.

clearIndexData() can run while runIndexing() is active (_isProcessing is not checked and manager.clearIndexData() does not await _active). In that case stopWatcher() only calls scanner.cancel(), which is cooperative: a batch already past its cancellation check can still complete its upsertPoints()/cache flush after deleteCollection() and after this release, so the "Index data cleared successfully" state can be wrong and the residual write lands under a lock another session now holds. shutdown() avoids exactly this by awaiting _active before _releaseWriter() (line 481). Await _active here too (or defer the release while a scan is running) before handing the lock back.

log.info("finished clearing index data", {
workspacePath: this.workspacePath,
state: this.stateManager.state,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,10 @@ export class FileWatcher implements IFileWatcher {
*/
async shutdown(): Promise<void> {
this.collecting = false
if (this.batchProcessDebounceTimer) clearTimeout(this.batchProcessDebounceTimer)
this.batchProcessDebounceTimer = undefined
if (this.batchProcessDebounceTimer) {
clearTimeout(this.batchProcessDebounceTimer)
this.batchProcessDebounceTimer = undefined
}
const subscription = this.subscription
this.subscription = undefined
await subscription
Expand Down
Loading
Loading