From f0c62b1539b8e2861203710b74006d37e5b9398b Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Wed, 11 Nov 2026 01:01:16 +0800 Subject: [PATCH 1/3] perf(server): requests during a slow content search answer in a median 5 ms instead of 2,103 ms Co-Authored-By: Claude Opus 5.5 --- apps/server/src/binMain.ts | 3 + .../src/workspace/WorkspaceEntries.test.ts | 27 +- .../workspace/WorkspaceSearchIndex.test.ts | 114 +++++-- .../src/workspace/WorkspaceSearchIndex.ts | 281 +++++++++++++++--- .../src/workspace/workspaceSearchWorker.ts | 37 +++ 5 files changed, 393 insertions(+), 69 deletions(-) create mode 100644 apps/server/src/workspace/workspaceSearchWorker.ts diff --git a/apps/server/src/binMain.ts b/apps/server/src/binMain.ts index 0dabd7387344..fc700f824a7e 100644 --- a/apps/server/src/binMain.ts +++ b/apps/server/src/binMain.ts @@ -19,6 +19,9 @@ export async function runCliCommand() { // Exit 2 identifies document rejections; OS and process failures remain retryable. process.exitCode = 2; } + } else if (command === "__workspace-search") { + const { runWorkspaceSearchWorker } = await import("./workspace/workspaceSearchWorker.ts"); + runWorkspaceSearchWorker(); } else if (command === "__managed-chatgpt-token") { const { runManagedChatGptTokenCli } = await import("./cli/managedChatGptToken.ts"); await runManagedChatGptTokenCli(process.argv.slice(3)); diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index b0633dac04cc..d673f8524689 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -15,6 +15,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; +import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); @@ -33,6 +34,30 @@ const layerTest = Layer.empty.pipe( Layer.provideMerge(NodeServices.layer), ); +// Keeps finders in this process so a test can intercept the native calls. +const layerInProcessFinders = Layer.effect( + WorkspaceEntries.WorkspaceEntries, + WorkspaceEntries.make, +).pipe( + Layer.provide(Layer.fresh(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layerNoDeps)), + Layer.provide( + Layer.succeed(WorkspaceSearchIndex.WorkspaceSearchWorker, { + create: async (options) => { + const created = FileFinder.create(options); + if (!created.ok) return created; + const finder = new Proxy(created.value, { + get: (target, method: keyof FileFinder) => + method === ("alive" as string) + ? true + : async (...args: Array) => + (target[method] as (...args: Array) => unknown)(...args), + }); + return { ok: true, value: finder as unknown as WorkspaceSearchIndex.RemoteFileFinder }; + }, + }), + ), +); + const makeTempDir = Effect.fn(function* (opts?: { prefix?: string; git?: boolean }) { const fileSystem = yield* FileSystem.FileSystem; const dir = yield* fileSystem.makeTempDirectoryScoped({ @@ -787,7 +812,7 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect(result.matches.map((match) => match.path)).toEqual(["src/notes.ts"]); // The second search ran on the rescanned live finder, not a rebuild. expect({ finders, rescans }).toEqual({ finders: 1, rescans: 1 }); - }), + }).pipe(Effect.provide(layerInProcessFinders)), ); }); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 2c41e4ed0b3d..4776cbdda0e3 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,18 +1,55 @@ -import { - FileFinder, - type FileItem, - type GrepCursor, - type GrepOptions, - type GrepResult, -} from "@ff-labs/fff-node"; +// @effect-diagnostics nodeBuiltinImport:off -- observes the real search process. +import * as NodeChildProcess from "node:child_process"; +import * as NodeEvents from "node:events"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { FileItem, GrepCursor, GrepOptions, GrepResult } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import { vi } from "vite-plus/test"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; +vi.mock("node:child_process", { spy: true }); + afterEach(() => { vi.restoreAllMocks(); + vi.mocked(NodeChildProcess.spawn).mockClear(); +}); + +const withFinder = (finder: Partial) => + Effect.provideService(WorkspaceSearchIndex.WorkspaceSearchWorker, { + create: async () => ({ + ok: true, + value: { + alive: true, + destroy: vi.fn(async () => {}), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + ...finder, + } as WorkspaceSearchIndex.RemoteFileFinder, + }), + }); + +const spawnedWorkers = () => + vi + .mocked(NodeChildProcess.spawn) + .mock.results.map((result) => result.value as NodeChildProcess.ChildProcess); + +const searchNeedle = (searchIndex: WorkspaceSearchIndex.WorkspaceSearchIndex["Service"]) => + searchIndex.searchContents({ + query: "needle", + limit: 10, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + +const makeNeedleWorkspace = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-search-worker-" }); + yield* fileSystem.writeFileString(`${cwd}/notes.ts`, "const needle = 1;\n"); + return cwd; }); function fileItem(relativePath: string): FileItem { @@ -35,7 +72,7 @@ it.effect("filters image searches before applying the result limit", () => ...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)), fileItem("public/icon.svg"), ]; - const fileSearch = vi.fn(() => ({ + const fileSearch = vi.fn(async () => ({ ok: true as const, value: { items, @@ -44,14 +81,9 @@ it.effect("filters image searches before applying the result limit", () => totalFiles: items.length, }, })); - const finder = { - destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - fileSearch, - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project").pipe( + withFinder({ fileSearch }), + ); const resultWithoutKind = yield* searchIndex.search("", 200, undefined, true); const resultWithDirectoryKind = yield* searchIndex.search("", 200, "directory", true); @@ -99,7 +131,7 @@ it.effect("continues whole-word searches after a filtered grep page", () => filteredFileCount: 1, nextCursor: cursor, }); - const grep = vi.fn((_query: string, options?: GrepOptions) => + const grep = vi.fn(async (_query: string, options?: GrepOptions) => options?.cursor ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) } : { @@ -107,14 +139,9 @@ it.effect("continues whole-word searches after a filtered grep page", () => value: grepResult("needleSuffix", [[0, 6]], nextCursor), }, ); - const finder = { - destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - grep, - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content"); + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content").pipe( + withFinder({ grep }), + ); const result = yield* searchIndex.searchContents({ query: "needle", limit: 1, @@ -139,3 +166,40 @@ it.effect("continues whole-word searches after a filtered grep page", () => }), ), ); + +it.live("rebuilds the index in a new search process after the worker exits", () => + Effect.gen(function* () { + const cwd = yield* makeNeedleWorkspace; + const searchIndex = yield* WorkspaceSearchIndex.make(cwd, "content"); + const before = yield* searchNeedle(searchIndex); + const [crashed] = spawnedWorkers(); + yield* Effect.promise(() => { + const exited = NodeEvents.EventEmitter.once(crashed!, "exit"); + crashed!.kill(); + return exited; + }); + + expect(yield* searchNeedle(searchIndex)).toEqual(before); + expect(before.matches.map((match) => match.path)).toEqual(["notes.ts"]); + expect(spawnedWorkers()).toHaveLength(2); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(WorkspaceSearchIndex.layerWorker, NodeServices.layer)), + ), +); + +it.live("ends the search process once its last index closes", () => + Effect.gen(function* () { + const cwd = yield* makeNeedleWorkspace; + yield* Effect.scoped(Effect.flatMap(WorkspaceSearchIndex.make(cwd, "content"), searchNeedle)); + const [worker] = spawnedWorkers(); + + const code = + worker!.exitCode ?? + (yield* Effect.promise(() => NodeEvents.EventEmitter.once(worker!, "exit")))[0]; + expect(code).toBe(0); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(WorkspaceSearchIndex.layerWorker, NodeServices.layer)), + ), +); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index ecb8c6ba87a7..c0358f7d457a 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -1,4 +1,6 @@ -import * as NodeModule from "node:module"; +// @effect-diagnostics nodeBuiltinImport:off -- the search process is an external runtime boundary. +import * as NodeChildProcess from "node:child_process"; +import * as NodeURL from "node:url"; import type { DirItem, @@ -6,6 +8,7 @@ import type { FileItem, FileFinder as FileFinderType, GrepCursor, + InitOptions, MixedItem, MixedSearchResult, Result, @@ -13,9 +16,13 @@ import type { } from "@ff-labs/fff-node"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as LayerMap from "effect/LayerMap"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import type { ProjectEntry, @@ -27,10 +34,6 @@ import type { } from "@t3tools/contracts"; import { isProjectFaviconPath } from "@t3tools/shared/projectFavicon"; -// fff-node's native library stays external; single-executables require disk packages instead of importing them. -const requireForFff = NodeModule.createRequire(import.meta.url); -const { FileFinder } = requireForFff("@ff-labs/fff-node") as typeof import("@ff-labs/fff-node"); - const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; @@ -284,13 +287,156 @@ function withDirectoryAncestors(entries: ReadonlyArray): ProjectEn return [...entryByPath.values()]; } +type RemoteFinderMethod = + | "destroy" + | "directorySearch" + | "fileSearch" + | "grep" + | "isScanning" + | "mixedSearch" + | "scanFiles" + | "waitForIndexReady"; + +export type RemoteFileFinder = { + readonly [Method in RemoteFinderMethod]: ( + ...args: Parameters + ) => Promise>>; +} & { + // False once the process holding the native index has exited. + readonly alive: boolean; +}; + +// One process owns every finder. Native calls already ran one at a time on the server thread, so a +// pool would add runtimes and index copies without making any single search faster. +export class WorkspaceSearchWorker extends Context.Service< + WorkspaceSearchWorker, + { + readonly create: (options: InitOptions) => Promise>; + } +>()("t3/workspace/WorkspaceSearchIndex/WorkspaceSearchWorker") {} + +const makeWorker = Effect.gen(function* () { + const executable = yield* HostProcess.ExecutablePath; + const isExecutable = yield* HostProcess.IsExecutable; + const environment = yield* HostProcess.Environment; + // In a bundle import.meta.url is a chunk beside bin.mjs; development and tests use this source module. + const entry = NodeURL.fileURLToPath( + new URL(import.meta.url.endsWith(".ts") ? "../bin.ts" : "./bin.mjs", import.meta.url), + ); + let nextId = 0; + let current: ReturnType | undefined; + + function start() { + const child = NodeChildProcess.spawn( + executable, + [...(isExecutable ? [] : [entry]), "__workspace-search"], + { + stdio: ["ignore", "ignore", "inherit", "ipc"], + serialization: "advanced", + windowsHide: true, + env: { ...environment, ELECTRON_RUN_AS_NODE: "1" }, + }, + ); + const pending = new Map & { readonly method: string }>(); + const worker = { + child, + finders: 0, + exited: false, + request: (finderId: number, method: string, args: ReadonlyArray) => { + if (worker.exited) return Promise.reject(new Error("The workspace search worker exited.")); + const id = ++nextId; + const request = { ...Promise.withResolvers(), method }; + pending.set(id, request); + child.send({ id, finderId, method, args }, (error) => { + if (error) child.kill(); + }); + return request.promise; + }, + release: () => { + if (--worker.finders > 0 || worker !== current) return; + // The last index expired; the next search starts a fresh process. + current = undefined; + child.disconnect(); + }, + }; + const exit = () => { + worker.exited = true; + if (current === worker) current = undefined; + for (const request of pending.values()) { + // The native index went with the process, so there is nothing left to destroy. + if (request.method === "destroy") request.resolve(undefined); + else request.reject(new Error("The workspace search worker exited.")); + } + pending.clear(); + }; + child.on("message", (reply: { id: number; value?: unknown; error?: unknown }) => { + const request = pending.get(reply.id); + pending.delete(reply.id); + if ("error" in reply) request?.reject(reply.error); + else request?.resolve(reply.value); + }); + child.once("exit", exit); + child.once("error", exit); + return worker; + } + + yield* Effect.addFinalizer(() => Effect.sync(() => current?.child.kill())); + + return WorkspaceSearchWorker.of({ + create: async (options) => { + const worker = (current ??= start()); + const finderId = ++nextId; + worker.finders++; + const created = (await worker + .request(finderId, "create", [options]) + .catch((error: unknown) => { + worker.release(); + throw error; + })) as Result; + if (!created.ok) { + worker.release(); + return created; + } + const call = + (method: Method) => + (...args: Parameters) => + worker.request(finderId, method, args) as Promise< + Awaited> + >; + return { + ok: true, + value: { + get alive() { + return !worker.exited; + }, + destroy: async () => { + if (worker.exited) return; + await worker.request(finderId, "destroy", []); + worker.release(); + }, + directorySearch: call("directorySearch"), + fileSearch: call("fileSearch"), + grep: call("grep"), + isScanning: call("isScanning"), + mixedSearch: call("mixedSearch"), + scanFiles: call("scanFiles"), + waitForIndexReady: call("waitForIndexReady"), + }, + }; + }, + }); +}); + +export const layerWorker = Layer.effect(WorkspaceSearchWorker, makeWorker); + const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( + worker: WorkspaceSearchWorker["Service"], cwd: string, variant: WorkspaceSearchIndexVariant, ) { - const result = yield* Effect.try({ + const result = yield* Effect.tryPromise({ try: () => - FileFinder.create({ + worker.create({ basePath: cwd, disableMmapCache: true, // Content indexing costs CPU/memory; path-only consumers use the lightweight index. @@ -315,7 +461,7 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* ( cwd: string, - finder: FileFinderType, + finder: RemoteFileFinder, onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E, ): Effect.fn.Return { const result = yield* Effect.tryPromise({ @@ -341,43 +487,81 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( cwd: string, variant: WorkspaceSearchIndexVariant = "paths", ) { - const finder = yield* Effect.acquireRelease(createFinder(cwd, variant), (finder) => - Effect.try({ - try: () => finder.destroy(), - catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }), - }).pipe(Effect.orDie), - ); + const worker = yield* WorkspaceSearchWorker; + const indexScope = yield* Effect.scope; + const reopenLock = yield* Semaphore.make(1); let initialScanTimedOut = false; - yield* waitForIndexReady( - cwd, - finder, - ({ reason, cause }) => - new WorkspaceSearchIndexCreateFailed({ + const open = Effect.fn("WorkspaceSearchIndex.open")(function* () { + const scope = yield* Scope.fork(indexScope); + return yield* Effect.gen(function* () { + const finder = yield* Effect.acquireRelease(createFinder(worker, cwd, variant), (finder) => + Effect.tryPromise({ + try: () => finder.destroy(), + catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }), + }).pipe(Effect.orDie), + ); + initialScanTimedOut = false; + yield* waitForIndexReady( cwd, - reason, - cause, - }), - ).pipe( - Effect.catchTags({ - WorkspaceSearchIndexScanTimedOut: (error) => - variant === "paths" - ? Effect.sync(() => { - initialScanTimedOut = true; - }) - : Effect.fail(error), - }), + finder, + ({ reason, cause }) => + new WorkspaceSearchIndexCreateFailed({ + cwd, + reason, + cause, + }), + ).pipe( + Effect.catchTags({ + WorkspaceSearchIndexScanTimedOut: (error) => + variant === "paths" + ? Effect.sync(() => { + initialScanTimedOut = true; + }) + : Effect.fail(error), + }), + ); + return finder; + }).pipe( + Scope.provide(scope), + Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))), + ); + }); + let finder = yield* open(); + // A search worker that exited took the native index with it; the next use rebuilds it. + const currentFinder = reopenLock.withPermits(1)( + Effect.suspend(() => + finder.alive + ? Effect.succeed(finder) + : open().pipe(Effect.map((opened) => (finder = opened))), + ), ); - const hasIncompleteInitialScan = () => initialScanTimedOut && finder.isScanning(); + const hasIncompleteInitialScan = Effect.suspend(() => + initialScanTimedOut + ? Effect.promise(() => finder.isScanning().catch(() => true)) + : Effect.succeed(false), + ); const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* ( query: string, pageSize: number, operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", - execute: () => Result, + execute: (finder: RemoteFileFinder) => Promise>, ): Effect.fn.Return { - const result = yield* Effect.try({ - try: execute, + const finder = yield* currentFinder.pipe( + Effect.mapError( + (cause) => + new WorkspaceSearchIndexSearchFailed({ + cwd, + queryLength: query.length, + pageSize, + reason: "The workspace search index could not be reopened.", + cause, + }), + ), + ); + const result = yield* Effect.tryPromise({ + try: () => execute(finder), catch: (cause) => new WorkspaceSearchIndexSearchFailed({ cwd, @@ -401,7 +585,17 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn( "WorkspaceSearchIndex.refresh", )(function* () { - const result = yield* Effect.try({ + const finder = yield* currentFinder.pipe( + Effect.mapError( + (cause) => + new WorkspaceSearchIndexRefreshFailed({ + cwd, + reason: "The workspace search index could not be reopened.", + cause, + }), + ), + ); + const result = yield* Effect.tryPromise({ try: () => finder.scanFiles(), catch: (cause) => new WorkspaceSearchIndexRefreshFailed({ @@ -432,8 +626,8 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( function* () { - const incompleteBeforeQuery = hasIncompleteInitialScan(); - const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", () => + const incompleteBeforeQuery = yield* hasIncompleteInitialScan; + const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", (finder) => finder.mixedSearch("", { pageSize: WORKSPACE_INDEX_PAGE_SIZE }), ); const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); @@ -453,22 +647,22 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( "WorkspaceSearchIndex.search", )(function* (query, limit, kind, imageOnly) { const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1); - const incompleteBeforeQuery = hasIncompleteInitialScan(); + const incompleteBeforeQuery = yield* hasIncompleteInitialScan; if (kind === "file" || imageOnly) { - const result = yield* runSearch(query, pageSize, "fileSearch", () => + const result = yield* runSearch(query, pageSize, "fileSearch", (finder) => finder.fileSearch(query, { pageSize }), ); const mapped = mapFileSearchResult(result, limit, imageOnly); return { ...mapped, truncated: incompleteBeforeQuery || mapped.truncated }; } if (kind === "directory") { - const result = yield* runSearch(query, pageSize, "directorySearch", () => + const result = yield* runSearch(query, pageSize, "directorySearch", (finder) => finder.directorySearch(query, { pageSize }), ); const mapped = mapDirectorySearchResult(result, limit); return { ...mapped, truncated: incompleteBeforeQuery || mapped.truncated }; } - const result = yield* runSearch(query, pageSize, "mixedSearch", () => + const result = yield* runSearch(query, pageSize, "mixedSearch", (finder) => finder.mixedSearch(query, { pageSize }), ); const mapped = mapMixedSearchResult(result, limit); @@ -491,7 +685,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( do { const remainingTimeBudgetMs = Math.max(1, Math.ceil(deadline - performance.now())); - const result = yield* runSearch(input.query, input.limit, "grep", () => + const result = yield* runSearch(input.query, input.limit, "grep", (finder) => finder.grep(searchQuery, { mode: regexMode ? "regex" : "plain", smartCase: !input.caseSensitive && !regexMode, @@ -567,5 +761,6 @@ export class WorkspaceSearchIndexMap extends LayerMap.Service(); + // The finders die with this process; the server closing the channel ends it. + process.on("disconnect", () => process.exit(0)); + process.on( + "message", + async (message: { + readonly id: number; + readonly finderId: number; + readonly method: "create" | "destroy" | Exclude; + readonly args: Array; + }) => { + try { + if (message.method === "create") { + const created = FileFinder.create(message.args[0] as InitOptions); + if (created.ok) finders.set(message.finderId, created.value); + process.send!({ id: message.id, value: created.ok ? { ok: true } : created }); + return; + } + const finder = finders.get(message.finderId)!; + if (message.method === "destroy") finders.delete(message.finderId); + const method = finder[message.method] as (...args: Array) => unknown; + process.send!({ id: message.id, value: await method.apply(finder, message.args) }); + } catch (error) { + process.send!({ id: message.id, error }); + } + }, + ); +} From a9e0b999168640a4352e4979fee971dfbb2a6ac0 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Wed, 11 Nov 2026 01:12:58 +0800 Subject: [PATCH 2/3] ci: rerun after a runner setup timeout Co-Authored-By: Claude Opus 5.5 From 8a1bb8cb45be19840427f85aca4e7c87e79ddb5f Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Wed, 11 Nov 2026 01:15:42 +0800 Subject: [PATCH 3/3] fix(server): report truncated results after rebuilding a timed-out index Co-Authored-By: Claude Opus 5.5 --- apps/server/src/workspace/WorkspaceSearchIndex.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index c0358f7d457a..a86aea757194 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -536,10 +536,16 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( ), ); - const hasIncompleteInitialScan = Effect.suspend(() => - initialScanTimedOut - ? Effect.promise(() => finder.isScanning().catch(() => true)) - : Effect.succeed(false), + // Reopen first, so a rebuilt index whose scan timed out still reports truncated results. + const hasIncompleteInitialScan = currentFinder.pipe( + Effect.ignore, + Effect.andThen( + Effect.suspend(() => + initialScanTimedOut + ? Effect.promise(() => finder.isScanning().catch(() => true)) + : Effect.succeed(false), + ), + ), ); const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* (