query-worker.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. /**
  2. * Query worker thread — issue: concurrent MCP tool calls starve the daemon.
  3. *
  4. * The shared daemon serves every session on ONE event loop with synchronous
  5. * `node:sqlite`. `codegraph_explore` is CPU-heavy (FTS + RWR/personalized-
  6. * PageRank + impact + output building) stitched together by microtask `await`s,
  7. * so N concurrent explores keep the microtask queue continuously full and
  8. * starve the macrotask phases — timers AND socket I/O. The transport freezes:
  9. * no response flushes, no request is read, until the whole batch drains. With
  10. * ~10 subagents that routinely exceeds the MCP client's request timeout.
  11. *
  12. * This worker moves the heavy read-tool dispatch OFF the daemon's main loop.
  13. * Each worker owns its OWN read connection (node:sqlite WAL allows N concurrent
  14. * readers across connections — verified: a worker reader sees the main writer's
  15. * committed catch-up/watcher writes), so {@link QueryPool} runs N tool calls in
  16. * true parallel up to core count while the main loop stays free for the MCP
  17. * transport. The worker runs {@link ToolHandler.executeReadTool} — validation +
  18. * dispatch + error classification — and returns the raw {@link ToolResult}; the
  19. * MAIN thread keeps the catch-up gate, the watcher-state notices (staleness /
  20. * worktree), `codegraph_status`, and telemetry, none of which a watcher-less
  21. * read connection can answer.
  22. */
  23. import { parentPort, workerData } from 'worker_threads';
  24. import type { ToolResult } from './tools';
  25. interface WorkerInit {
  26. root: string;
  27. }
  28. interface CallMessage {
  29. type: 'call';
  30. id: number;
  31. toolName: string;
  32. args: Record<string, unknown>;
  33. }
  34. // Mirror the engine's lazy-require of the heavy CodeGraph + tools chain. This
  35. // module is only ever loaded as a Worker, so the require runs once on spawn.
  36. const loadCodeGraph = (): typeof import('../index').default =>
  37. (require('../index') as typeof import('../index')).default;
  38. const loadToolHandler = (): typeof import('./tools').ToolHandler =>
  39. (require('./tools') as typeof import('./tools')).ToolHandler;
  40. if (parentPort) {
  41. const port = parentPort;
  42. const { root } = workerData as WorkerInit;
  43. // Open the default project's READ connection once, at spawn. Other repos are
  44. // opened lazily on first cross-project (projectPath) call by the ToolHandler's
  45. // own per-handler cache. openSync does not start a watcher — workers are pure
  46. // readers; the single watcher/writer stays on the daemon's main thread.
  47. let handler: InstanceType<typeof import('./tools').ToolHandler> | null = null;
  48. let initError: string | null = null;
  49. try {
  50. const cg = loadCodeGraph().openSync(root);
  51. handler = new (loadToolHandler())(cg);
  52. } catch (err) {
  53. initError = err instanceof Error ? err.message : String(err);
  54. }
  55. // Tell the pool we're up. `ok:false` lets the pool count a hard open failure
  56. // against its crash budget (→ fall back to in-process) without hanging.
  57. port.postMessage({ type: 'ready', ok: initError === null, error: initError });
  58. port.on('message', (msg: CallMessage) => {
  59. if (!msg || msg.type !== 'call') return;
  60. void serve(msg);
  61. });
  62. const serve = async (msg: CallMessage): Promise<void> => {
  63. // Test-only crash hook so the pool's worker-recovery path is exercisable
  64. // deterministically. Gated behind an env flag only the suite sets — inert in
  65. // normal operation (and `__test_crash__` isn't a real tool name anyway).
  66. if (msg.toolName === '__test_crash__' && process.env.CODEGRAPH_QUERY_WORKER_ALLOW_TEST_CRASH === '1') {
  67. process.exit(13);
  68. }
  69. if (!handler) {
  70. port.postMessage({
  71. type: 'result',
  72. id: msg.id,
  73. result: errorResult(`codegraph worker could not open the project: ${initError}`),
  74. });
  75. return;
  76. }
  77. try {
  78. // executeReadTool already classifies NotIndexed/PathRefusal/internal errors
  79. // into a ToolResult and never throws — the catch is belt-and-suspenders.
  80. const result: ToolResult = await handler.executeReadTool(msg.toolName, msg.args);
  81. port.postMessage({ type: 'result', id: msg.id, result });
  82. } catch (err) {
  83. port.postMessage({
  84. type: 'result',
  85. id: msg.id,
  86. result: errorResult(err instanceof Error ? err.message : String(err)),
  87. });
  88. }
  89. };
  90. }
  91. function errorResult(text: string): ToolResult {
  92. return { isError: true, content: [{ type: 'text', text }] };
  93. }