Repository navigation
Conversation
| this.stopWatcher() | ||
| } finally { | ||
| this._isProcessing = false | ||
| await writer.release() |
There was a problem hiding this comment.
WARNING: The writer lock is released here while this session's file watcher is still writing the store.
_runScan() calls fileWatcher.setCollecting(true) (line 398) after the scan, so the watcher's debounced batches call upsertPoints() / deletePointsByMultipleFilePaths() (see processors/file-watcher.ts) with no lock held. A second session can acquire the now-free lock, start its own scan, and clear/reindex while this session's watcher keeps committing points — the same concurrent-writer corruption this lock is meant to prevent. Consider keeping the lock for as long as the watcher writes, or acquiring it around watcher batches.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| release: async () => { | ||
| clearInterval(timer) | ||
| const current = await read(path.join(lockdir, "meta.json")) | ||
| if (current && current.token !== token) { |
There was a problem hiding this comment.
WARNING: release() can delete a lock it no longer owns.
The guard only refuses when meta.json is readable and holds a different token. If read() returns undefined (file missing, mid-write, or corrupt), current is falsy, the check is skipped, and the fs.rm(lockdir, ...) below deletes whatever directory is there. A session that reclaimed this workspace is between claim() (line 114) and writeFile(meta.json) (line 122) during that window, so its fresh lock gets removed and both sessions become writers. Treat "cannot confirm ownership" as "do not delete" — e.g. skip the rm when meta.json is absent.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| return undefined | ||
| } | ||
| log.info("reclaiming stale writer lock", { workspacePath }) | ||
| await fs.rm(lockdir, { recursive: true, force: true }) |
There was a problem hiding this comment.
WARNING: Stale-lock reclaim is not atomic (fs.rm + claim).
Two sessions can both observe the same stale lock, both call fs.rm(lockdir, { recursive: true }), and then both claim() succeed because each removed the other's freshly created directory. Both then return a Writer for the same workspace and write concurrently. Make takeover atomic — rename the stale directory to a unique path before claiming, or re-verify the directory identity after claim() and back out if it changed.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| await fs.writeFile(path.join(lockdir, "meta.json"), JSON.stringify(owner)) | ||
| await fs.writeFile(path.join(lockdir, "heartbeat"), "") | ||
|
|
||
| const timer = setInterval(() => { |
There was a problem hiding this comment.
WARNING: The heartbeat keeps beating after this lock has been taken over.
The interval closes over lockdir, not a token check, so if another session reclaims the workspace the displaced owner's timer keeps utimes-ing the new owner's heartbeat file. That masks the new owner's death (its lock never goes stale) and the displaced owner still believes it holds the lock and may resume writing. Re-read the token before each beat and stop the timer once ownership is lost.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| } | ||
|
|
||
| public async clearIndexData(): Promise<void> { | ||
| const writer = await this._acquire() |
There was a problem hiding this comment.
WARNING: A user-requested clear is silently dropped when the lock is held.
When _acquire() fails, clearIndexData() returns after setting Standby / "Another session is indexing this workspace." and never deletes the collection or cache. The lock is not reentrant and _isProcessing is not checked, so if this same session's runIndexing() holds the lock, the clear also overwrites a live "Indexing" state with Standby while the scan continues. Surface a distinct error/state rather than silently no-oping the user's clear.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| new Watcher() as unknown as IFileWatcher, | ||
| ) | ||
|
|
||
| await orchestrator.startIndexing("background") |
There was a problem hiding this comment.
SUGGESTION: No test exercises the real lock lifecycle through the orchestrator.
Every success-path test passes a fake CacheManager with no dir, so _acquire() returns the unlocked no-op writer (orchestrator.ts:160-164), and this standby test never lets a scan run under a real lock. A regression where a successful run fails to call writer.release() (permanently locking other sessions out) would still pass. Add a test that acquires through the orchestrator, runs a scan, and asserts the lock is released/re-acquirable afterward.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
Code Review SummaryStatus: 1 Issue Found | Recommendation: Address before merge Incremental review of Overview
Issue Details (click to expand)WARNING
Files Reviewed (2 files)
Previous Review Summaries (9 snapshots, latest commit e00de4c)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit e00de4c)Status: 2 Issues Found | Recommendation: Address before merge Incremental review of Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (2 files)
Fix these issues in Kilo Cloud Previous review (commit 915e655)Status: 4 Issues Found | Recommendation: Address before merge Incremental review of Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (2 files)
Fix these issues in Kilo Cloud Previous review (commit 1342f58)Status: 4 Issues Found | Recommendation: Address before merge Incremental review of Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (3 files)
Fix these issues in Kilo Cloud Previous review (commit 715dd3b)Status: 5 Issues Found | Recommendation: Address before merge Incremental review of Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (2 files)
Previous review (commit 014a2b0)Status: 4 Issues Found | Recommendation: Address before merge Incremental review of Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (2 files)
Fix these issues in Kilo Cloud Previous review (commit 8e832b6)Status: 3 Issues Found | Recommendation: Address before merge Incremental review of Overview
Issue Details (click to expand)WARNING
Files Reviewed (5 files)
Previous review (commit 7be0b6e)Status: 3 Issues Found | Recommendation: Address before merge Incremental review of Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (4 files)
Fix these issues in Kilo Cloud Previous review (commit c5709ad)Status: 3 Issues Found | Recommendation: Address before merge This incremental review covers the changes since Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (6 files)
Fix these issues in Kilo Cloud Previous review (commit af61ae3)Status: 7 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (13 files)
Fix these issues in Kilo Cloud Additional previous summary content was truncated to keep this comment within platform limits. Reviewed by deepseek-flash-latest · Input: 38.4K · Output: 5.2K · Cached: 288K Review guidance: REVIEW.md from base branch |
79eef42 to
c6e4a02
Compare
maphew
left a comment
There was a problem hiding this comment.
Changeset bumped to minor: IVectorStore interface change (hasIndexedData -> state()) is a breaking API change for external implementers, so the release type should be minor rather than patch.
| await fs.mkdir(root, { recursive: true }) | ||
| const made = await claim(lockdir) | ||
| if (!made) { | ||
| if (!(await reclaimable(lockdir))) { |
There was a problem hiding this comment.
WARNING: reclaimable() reports a vanished lock directory as held. If the current holder calls release() (which fs.rms the directory) after this session's claim() fails with EEXIST but before reclaimable() stats the files, every age()/read() gets ENOENT and returns undefined, so reclaimable() returns false and acquire() returns undefined even though the lock is now free. The caller then parks in Standby. A missing lockdir should be treated as reclaimable (retry claim), not held.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| if (beatAge !== undefined) return beatAge > STALE_MS | ||
|
|
||
| const owner = await read(meta) | ||
| if (owner && owner.host === os.hostname() && !alive(owner.pid)) { |
There was a problem hiding this comment.
SUGGESTION: This dead-local-owner check is effectively unreachable. reclaimable() returns at line 81 whenever the heartbeat file exists, and acquire() always creates the heartbeat (line 123), so once a session has ever held the lock the heartbeat is present and a crashed owner's heartbeat is still fresh for up to STALE_MS. The pid-liveness path then only runs in the tiny window between writing meta.json and heartbeat, contradicting the docstring above. Checking liveness before the heartbeat age would let a crashed local owner be reclaimed immediately instead of after the 60s window.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
|
|
||
| this._cancelRequested = false | ||
|
|
||
| const writer = await this._acquire() |
There was a problem hiding this comment.
WARNING: The writer is acquired before the try/finally (release at line 304), so any throw between here and line 217 skips release(). The statements in between include setSystemState, whose progress emitter is fired synchronously by Emitter.fire without a guard, so a throwing host listener would propagate and leave the heartbeat interval (writer-lock.ts:125) running, keeping the lock fresh forever and locking every other session out for the life of the process. Consider acquiring the writer inside the try, or wrapping the gap so release() always runs.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
|
|
||
| const writer = await this._acquire() | ||
| if (!writer) { | ||
| this.stateManager.setSystemState("Standby", "Another session is indexing this workspace.") |
There was a problem hiding this comment.
WARNING: This contention path parks the session in Standby and returns without emitting an error. manager.handleTelemetry only schedules recovery for type === "error" events, and runRecovery treats a non-error result as success, so nothing re-checks the lock when the holder finishes. The losing session then stays in Standby until an unrelated trigger (settings change, manual start, worktree baseline change) calls startIndexing again. A bounded retry/backoff or a re-check when the lock frees would let the second session eventually index.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| } | ||
| async state(): Promise<StoreState> { | ||
| const collectionInfo = await this.client.getCollection(this.collectionName) | ||
| const points = collectionInfo.points_count ?? 0 |
There was a problem hiding this comment.
SUGGESTION: points_count includes the metadata marker point, because markIndexingComplete/markIndexingIncomplete upsert a real point (METADATA_ID) into the same collection (lines 714-731 / 745-762). Once any scan has written its marker this check can never yield empty: a collection with zero code points reports partial or complete. LanceDB avoids this since its marker lives in a separate metadata table, so the two backends disagree on the same logical state and the new empty classification is unreachable for Qdrant. Consider excluding the marker point from the count.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
287df9f to
52b14c0
Compare
| @@ -0,0 +1,5 @@ | |||
| --- | |||
| "@kilocode/kilo-indexing": minor | |||
There was a problem hiding this comment.
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.
|
There are still various warnings and suggestions from kilobot and I'd love to have all of them addressed before counter-verifying this review, thank you! |
52b14c0 to
e2c21a4
Compare
maphew
left a comment
There was a problem hiding this comment.
Adversarial review complete. Rebased onto main (defcd7d) — clean, no conflicts. Verification performed:
- Typecheck: passes (tsgo --noEmit, kilo-indexing package)
- Tests: 553 pass, 0 fail (all kilo-indexing tests including new writer-lock.test.ts and updated orchestrator.test.ts)
- CI guards: check-opencode-annotations ok, check-workflows ok (38 workflows)
- All status checks on the PR are green
Diff review findings — all addressed by the author, no issues raised:
- writer-lock.ts: lease design is sound. Heartbeat every 20s, stale threshold 60s, reclaim also checks local pid liveness (process.kill pid 0, EPERM-tolerant), and release refuses to rm a lock whose token changed after takeover. No leaked secrets, no unhandled errors (all fs failures either map to ENOENT/EPERM or are logged).
- IVectorStore.hasIndexedData() -> state(): breaking API change, changeset already bumped to minor by the author (commit e2c21a4). Both implementers (lancedb, qdrant) updated.
- Orchestrator: only clears an empty store; partial/complete stores are preserved. Stands by (Standby state) while another session holds the writer lock. writer.release() runs in finally on both the scan path and clearIndexData().
- CacheManager.cacheDirectory -> dir is now public readonly; only used internally by the orchestrator for the lock root, no external breakage found.
PR is ready for merge. Requesting review from a maintainer to approve (author cannot self-approve).
maphew
left a comment
There was a problem hiding this comment.
Automated sweep review: read the full diff — per-workspace writer lock (mkdir claim, 20s heartbeat, 60s stale reclaim, dead-pid takeover), hasIndexedData() -> state() (empty/partial/complete), orchestrator only clears empty stores and releases the lock in finally, both stores implement state(), and the new tests cover partial-index preservation and lock contention. CI is green and 18/18 indexing tests pass locally. Rebase onto main applies cleanly with no conflicts.
Blocker: base branch policy prohibits the merge — an approving review is required, no reviewer is currently requested, and the author cannot self-approve. A maintainer approval is needed before this can merge.
Non-blocking nits for a follow-up: .changeset/single-writer-codebase-index.md lacks a trailing newline; writer.release() runs in a finally block, so an fs.rm failure there could mask the original indexing error.
_auto-free on behalf of @maphew
e2c21a4 to
11c1899
Compare
|
|
||
| const token = randomUUID() | ||
| const owner = { token, pid: process.pid, host: os.hostname(), at: new Date().toISOString() } | ||
| await fs.writeFile(path.join(lockdir, "meta.json"), JSON.stringify(owner)) |
There was a problem hiding this comment.
WARNING: acquire() is not exception-safe after it wins the lock.
claim() at line 106 has already created lockdir, but the two writeFile calls here and at line 123 run with no try/catch. If either rejects (disk full, EACCES, etc.), acquire() throws while the directory is present and freshly created. Because reclaimable() only treats a directory as reclaimable once its heartbeat/meta/dir mtime exceeds STALE_MS, every other session is locked out for up to 60s, and if meta.json was written before the failure the recorded pid is this still-live process, so the liveness check also refuses to reclaim. In addition, _acquire() (orchestrator.ts:158-170) has no try/catch and is awaited before the try at orchestrator.ts:204, so this rejection escapes runIndexing() unhandled and never sets the session to Error.
Consider wrapping the post-claim() writes so that a failure removes the directory before rethrowing (or returning undefined), so a failed acquisition does not leave a lock behind or abort indexing with no user-visible error.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
maphew
left a comment
There was a problem hiding this comment.
Daily sweep review complete. Rebased onto main (fb7e96e) and verified locally: kilo-indexing suite 544 pass / 0 fail, tsgo --noEmit clean, and all required checks green on the new head (the two visual-regression checks skip by path filter since this PR touches no webview/UI files).
Full adversarial review found no security, secret-leak, error-handling, or correctness issues; the writer lock (claim/reclaim/token-checked release), the empty-vs-partial store states, and the orchestrator lock lifecycle (acquire before any store mutation, release in finally) all check out, with coverage in the 10 new tests. One minor finding fixed on the branch (11c1899): both stores kept the stale hasIndexedData() doc comment claiming a boolean return on the new state().
Merge is blocked solely by base-branch policy: 1 approving review plus extra approval for unattributed changes (author association is CONTRIBUTOR, and authors cannot approve their own PR). Auto-merge needs repo permissions the fork author lacks. A maintainer approval is the only remaining step to land this.
Signed: _kilo-auto/free on behalf of maphew
| this.stopWatcher() | ||
| } finally { | ||
| this._isProcessing = false | ||
| await writer?.release() |
There was a problem hiding this comment.
WARNING: The writer lock is released here at the end of runIndexing, but this session's file watcher keeps writing the store afterwards. _runScan() calls fileWatcher.setCollecting(true) (line 415) immediately before returning, so once runIndexing finishes the watcher continues issuing upsert/delete batches with no lock held. A second session can then acquire the now-free lock and scan/clear concurrently — the concurrent-writer corruption this lock exists to prevent. Note the new comment at lines 411-414 claims the lock "stays held until shutdown or clearIndexData", which contradicts this finally. Either hold the lock for the watcher's lifetime or acquire it around watcher batches.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| const stale = `${lockdir}.stale.${process.pid}.${Date.now()}` | ||
| try { | ||
| await fs.rename(lockdir, stale) | ||
| } catch (err) { |
There was a problem hiding this comment.
WARNING: reclaim() treats every rename failure as a lost race, but ENOENT means the lock directory is already gone — i.e. the lock is free. reclaimable() now returns true for a vanished directory (line 102), and acquire() then calls reclaim() (line 127); the rename fails with ENOENT, this return false makes acquire() log "lost the race" and resolve undefined, even though it could simply have claimed the free lock. That is the exact holdout the docstring at lines 76-79 claims this path fixes, and the previous fs.rm(..., { force: true }) + claim() did acquire here. When the rename fails because the directory is missing, fall through to claim(lockdir) instead of returning false.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| // _active runs runIndexing, whose finally releases the lock, so closing | ||
| // here hands the next session a closed connection rather than one whose | ||
| // close is still in flight. | ||
| await this.vectorStore.close?.() |
There was a problem hiding this comment.
SUGGESTION: Closing the store before await this._active opens a use-after-close window. shutdown() cancels the scanner but the runIndexing scan can still be in flight; its next getDb()/getTable() call lazily reconnects (lancedb-vector-store.ts:88), so the store can be open again by the time _active's finally releases the writer lock — defeating the "closed before the lock releases" goal and leaving a re-opened connection that is never closed. Closing inside runIndexing's own finally, right before writer.release(), would keep the guarantee without racing the scan.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| // The completion marker is itself a point, so subtract it before judging | ||
| // emptiness: otherwise a scanned-then-cleared collection reports | ||
| // points_count 1 and `empty` is unreachable after any scan. | ||
| const marker = (await this.getMetadataPayload()) ? 1 : 0 |
There was a problem hiding this comment.
SUGGESTION: getMetadataPayload() performs a client.retrieve round-trip. This line calls it to compute marker and then line 696 calls it again for payload — two identical network retrievals on every state() call. Cache the first result (e.g. const payload = await this.getMetadataPayload() then const marker = payload ? 1 : 0) and reuse it below.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| // A user-requested clear must not vanish silently when another session | ||
| // holds the lock: record contention as an error event so the manager can | ||
| // retry or display it, instead of reporting Standby success. | ||
| this.emitError( |
There was a problem hiding this comment.
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.
…indexes Multiple sessions on the same workspace each ran their own scan: each treated the other's in-progress index as unusable, cleared it, and restarted from zero, leaving both sessions committing to the same LanceDB table (Kilo-Org#14544). An interrupted index was likewise discarded instead of resumed (Kilo-Org#14083). - writer-lock.ts: per-workspace lease lock under the indexing cache directory, heartbeat every 20s, reclaimed after 60s without a heartbeat or when the recorded local pid is gone - IVectorStore.state(): replaces hasIndexedData() with empty/partial/complete so an in-progress index is no longer indistinguishable from an absent one - orchestrator: take the writer lock before touching the store and clear the collection only when the store is empty; clearIndexData() takes the same lock - lancedb and qdrant stores implement state() Signed: _auto-free on behalf of @matt
The state() method kept the hasIndexedData() doc comment claiming a boolean return; it returns StoreState. Signed: _kilo-auto/free on behalf of maphew
ae1d938 to
dc3a7b2
Compare
|
Rebased onto latest main. All checks passing. Tests for modified packages pass. Ready for review/merge. |
|
Review findings addressed in 8e832b6:
Also raised the Windows subprocess timeout in |
| // 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 |
There was a problem hiding this comment.
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.
| 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() |
There was a problem hiding this comment.
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.
| // after dispose. This is a one-shot probe, so exit explicitly instead | ||
| // of waiting for the event loop to drain; otherwise the parent sees a | ||
| // live child and times out while all assertions have already passed. | ||
| process.exit(0) |
There was a problem hiding this comment.
SUGGESTION: Exiting explicitly hides a real resource leak instead of verifying it.
The comment documents that CodeIndexManager can leave a native watcher handle alive after dispose(). That is a product-side leak (in a long-lived CLI it would keep the process from exiting naturally), not just a test artifact, and exiting here also discards any error raised after dispose() — e.g. a rejected background flush from the dangling watcher — so the probe can no longer catch it. If fixing the leak is out of scope for this PR, consider referencing a follow-up issue or unref'ing/closing the handle in dispose(), rather than working around it only in the probe.
|
Follow-up findings addressed in 715dd3b:
The |
| // method is about to release (shutdown() waits the same way). | ||
| this._cancelRequested = true | ||
| this.scanner.cancel() | ||
| await this._active |
There was a problem hiding this comment.
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.
| private async _verifyWriter(): Promise<void> { | ||
| const writer = this._writer | ||
| if (!writer) return | ||
| if (await writer.owned()) return |
There was a problem hiding this comment.
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.
| await writeFile(path.join(slot, "heartbeat"), "") | ||
|
|
||
| watcher.onDidStartBatchProcessing.fire(["src/a.ts"]) | ||
| await Bun.sleep(20) |
There was a problem hiding this comment.
SUGGESTION: This asserts on the result of a fire-and-forget async check after a fixed 20 ms sleep.
_verifyWriter() is invoked with void and performs real fs reads (and, on loss, state updates plus stopWatcher()), so under a loaded CI runner 20 ms is not guaranteed to be enough and the test can flake. Consider polling watcher.disposed / orchestrator.state until the condition holds (with a timeout) instead of a fixed sleep.
… reading lock metadata
|
Remaining findings addressed in 1342f58:
Two responses remain:
|
| // 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() |
There was a problem hiding this comment.
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.
|
Fixed in 915e655: after The session-lifetime hold remains a deliberate rebuttal: the file watcher writes continuously after the scan, so the lock cannot be released per run without reintroducing the concurrent-store corruption this PR fixes. Other sessions read the shared index and retry when the holder exits; recovering by running a second writer is exactly what the lock prevents. |
| // The scan's error path releases the writer lock. Re-acquire before | ||
| // deleting: otherwise another session can take the lock between that | ||
| // release and this clear, and we would delete its collection unlocked. | ||
| if (!this._writer) { |
There was a problem hiding this comment.
WARNING: Re-acquiring only when _writer is unset does not re-verify ownership.
await this._active can run for minutes on a large workspace. If this session's heartbeat lapses past STALE_MS during that wait, another session reclaims the lock and starts its own scan, yet this._writer is still set: _verifyWriter() only runs on watcher batches, and the watcher is in drain-only mode until a scan finalizes. The guard then skips _acquire() and deleteCollection() runs under a lock another session now owns — the concurrent-writer corruption this lock exists to prevent.
_acquire() already covers this case: it re-checks owned() on the cached writer, clears it on loss and re-acquires. Call it unconditionally here and inspect its return value instead of gating on the truthiness of _writer.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
|
Addressed the remaining lock-ownership finding in e00de4c:
Two findings remain deliberate rebuttals:
|
|
|
||
| await orchestrator.startIndexing("background") | ||
| const clearing = orchestrator.clearIndexData() | ||
| await Bun.sleep(20) |
There was a problem hiding this comment.
SUGGESTION: This fixed 20 ms sleep gates the assertions on async work that must finish first, so the test can flake.
clearIndexData() performs two real owned() metadata reads (and the lock check) before it reaches await fileWatcher.shutdown(), so on a loaded CI runner watcher.shutdowns can still be 0 here and the test fails even though the drain ordering is correct. The takeover test above already replaced its fixed sleep with polling for this reason. Consider polling until watcher.shutdowns === 1 (with a timeout) before asserting store.deleteCount === 0.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
…x/index-single-writer
What & why
Every session on a workspace runs its own indexer, which caused two defects with one root:
initialize()+optimize()races can corrupt the store — the reporter saw dangling refs in the latest manifest and 460 commits in 10 minutes.hasIndexedData()returnsfalsefor an in-progress index, so the orchestrator callsclearCollection()+clearCacheFile()and restarts from zero. With several sessions, each new session destroys the previous one's in-progress index — the restart loop that produced the churn on large workspaces.Changes
src/indexing/writer-lock.ts(new): per-workspace lease under<cacheDir>/locks/<sha256-16>/— heartbeat every 20s, stale after 60s, reclaimable when the heartbeat stops or the recorded local pid is gone, token-checked release so a session can never delete another session's lock.IVectorStore.state()replaceshasIndexedData()withempty/partial/complete; both stores implement it. Qdrant keeps its no-marker backward-compat path, which can only have come from a finished scan.empty, and releases it infinally. A session that cannot acquire the lock goes to Standby with "Another session is indexing this workspace." instead of scanning.clearIndexData()takes the same lock so a clear cannot land mid-scan in another session.Relationship to #14706
Complements rather than overlaps: #14706 fixes the retention policy and in-process serialization (fix 2 of #14544); this adds the cross-process lock (fix 1) and the resume-instead-of-restart semantics (#14083). Both touch
lancedb-vector-store.tsin different regions, so either merge order works.Testing
bun test(kilo-indexing): 515 pass / 0 fail, including 8 new writer-lock tests (contention, release, stale-heartbeat takeover, dead-owner reclaim, token-checked release) and 2 new orchestrator cases (partial index not cleared; standby while the lock is held).tsgo --noEmitclean for kilo-indexing and opencode; 36 opencode indexing tests pass.oxlinton changed files: 0 errors;check:architectureandcheck:duplicationclean.Known limitation, by design: a session that loses the race stands by rather than serving searches from the partial index; it picks up on a later trigger.
Signed: kilo-auto-free on behalf of maphew