🐛 Bug 描述
在 v0.1.12 的全局安装版本中,Agent 在执行涉及 TS/JS 文件读取的对话(例如 read_file *.tsx)后会自发调用 LSP 工具(lsp_*)。当用户环境中未安装 typescript-language-server 二进制时,整个 openhorse 进程会直接崩溃退出,而不是降级为「LSP 工具不可用」的友好错误。
触发场景
用户正在和 Agent 对话,Agent 读了若干 .ts/.tsx 文件后(没有显式让它跑 LSP),在后续 turn 中自主决定调用 lsp_get_definition / lsp_get_diagnostics 来辅助分析。
实际栈
Turn 5...
构建和 dev 服务都正常启动了,页面也能渲染 HTML。让我检查一下组件代码是否有运行时错误:
▸ read_file /Users/yike/docs/log-view/src/components/time... ✓ 2ms
▸ read_file /Users/yike/docs/log-view/src/components/time... ✓ 1ms
▸ read_file /Users/yike/docs/log-view/src/pages/timeline.... ✓ 1ms
Turn 6...
▸ read_file /Users/yike/docs/log-view/src/components/time... ✓ 1ms
Turn 7...
node:events:509
throw err; // Unhandled 'error' event
^
Error [ERR_UNHANDLED_ERROR]: Unhandled error. ('spawn typescript-language-server ENOENT')
at LspClient.emit (node:events:507:17)
at ChildProcess.<anonymous> (/Users/yike/.nvm/versions/node/v22.15.1/lib/node_modules/openhorse/dist/tools/lsp.js:42:18)
at ChildProcess.emit (node:events:518:28)
at ChildProcess._handle.onexit (node:internal/child_process:291:12)
at onErrorNT (node:internal/child_process:483:16)
at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
code: 'ERR_UNHANDLED_ERROR',
context: 'spawn typescript-language-server ENOENT'
}
🌍 环境
| 项 |
值 |
| openhorse |
0.1.12(npm 全局安装) |
| Node |
v22.15.1 |
| OS |
macOS (darwin) |
| typescript-language-server |
未安装(command -v typescript-language-server 返回 not found) |
🔍 根因分析
源码:src/tools/lsp.ts:46-78
class LspClient extends EventEmitter { // ← 继承 EventEmitter
private process: ChildProcess | null = null;
…
async start(): Promise<void> {
const command = this.getLspCommand();
this.process = spawn(command.cmd, command.args, { … }); // ← 未做 binary 预检
this.process.stderr?.on('data', (data: Buffer) => {
this.emit('error', data.toString()); // ← 直接 emit 到自身
});
this.process.on('error', (err) => {
this.emit('error', err.message); // ← child ENOENT 也 emit
});
…
}
}
而调用方 LspManager.getClient(同文件 ~245 行)只是:
const client = new LspClient(language, projectRoot);
await client.start();
this.clients.set(key, client);
从未给 client 注册 'error' 事件监听器。
按 Node EventEmitter 规范,无监听器时 emit('error', …) 会触发 events.js:509 的 throw err —— 导致 整个 REPL 进程被未捕获错误杀掉,用户丢失会话上下文与未保存的工作。
触发路径
- Agent 调用
lsp_get_definition(src/tools/lsp.ts:332-371)
lspManager.getClient(language, context.cwd) 创建 LspClient
client.start() 内 spawn('typescript-language-server', ['--stdio'])
- 二进制不存在 → child_process 异步 emit
'error' 事件(ENOENT)
this.emit('error', err.message) 转发到 LspClient
- 无监听器 → Node 抛 ERR_UNHANDLED_ERROR → 进程 crash
注意:getLspCommand() 的 try/catch 在 tool 层(335-371 的 try { … } catch (err) { return {success:false} })抓不到这次崩溃,因为 ENOENT 是 child_process 的异步事件,在 await client.start() 已经 resolve 之后才到达。
💥 严重程度
Critical — 任何未预装 LSP 二进制的用户都会在 Agent 自主决策走 LSP 路径时遇到进程级崩溃。
- 触发条件普遍:大多数用户首装 openhorse 时不会同时装
typescript-language-server 或 pyright
- 行为不可恢复:整个 REPL 进程退出,用户会话 / 计划 / 状态全部丢失
- 用户无主动权:Agent 是自主调用 LSP,用户没有"避免触发"的选项
✅ 修复建议(可直接落地)
修复 1:LspClient.start() 做二进制预检 + 改为 Promise.reject 而非 emit
import { spawn, spawnSync } from 'child_process';
private static probeBinary(cmd: string): boolean {
// 用 `command -v`/`which` 静默探测,避免起 child 才知道不存在
const res = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { stdio: 'ignore' });
return res.status === 0;
}
async start(): Promise<void> {
const command = this.getLspCommand();
// 预检 1:二进制是否存在
if (!LspClient.probeBinary(command.cmd)) {
throw new Error(
`LSP binary "${command.cmd}" not found in PATH. ` +
`Install it first: npm i -g ${command.cmd}` +
(command.cmd === 'typescript-language-server' ? ' typescript' : '')
);
}
this.process = spawn(command.cmd, command.args, {
cwd: this.projectRoot,
stdio: ['pipe', 'pipe', 'pipe'],
});
// 关键:把 child 的 error 转成 Promise reject + 关闭 client,
// 避免裸 emit 'error' 导致 Node 抛 ERR_UNHANDLED_ERROR
this.process.on('error', (err: NodeJS.ErrnoException) => {
const msg = err.code === 'ENOENT'
? `LSP binary "${command.cmd}" failed to start (ENOENT). Is it installed and in PATH?`
: `LSP process error: ${err.message}`;
// 用 Promise 状态而不是 emit,或在 emit 前确认有监听器
if (this.listenerCount('error') === 0) {
// 没人听就只记 log,不要 crash
console.warn(`[LSP] ${msg}`);
} else {
this.emit('error', msg);
}
this.process = null;
});
this.process.stderr?.on('data', (data: Buffer) => {
// stderr 不该走 'error' 通道(那是 unhandled-crash 通道),改为 'stderr' 自定义事件
if (this.listenerCount('stderr') > 0) {
this.emit('stderr', data.toString());
}
});
this.process.stdout?.on('data', (data: Buffer) => {
this.handleData(data.toString());
});
…
}
要点:
start() 同步抛,让 await client.start() 在 tool 层的 try/catch 中正常捕获
- 绝不要把 stderr 数据走
'error' 事件(那是 Node 保留通道,会触发 crash)
- 即便走
'error',先 listenerCount('error') === 0 再 emit,保底不崩溃
修复 2:LspManager.getClient 也加 error listener 作为最后兜底
async getClient(language: string, projectRoot: string): Promise<LspClient> {
const key = `${language}:${projectRoot}`;
if (!this.clients.has(key)) {
const client = new LspClient(language, projectRoot);
// 兜底:确保 client 永远有 'error' 监听,避免裸 emit 杀进程
client.on('error', (msg) => {
console.warn(`[LSP:${language}] ${msg}`);
});
try {
await client.start();
} catch (err) {
// start() 失败不要缓存坏 client
throw err;
}
this.clients.set(key, client);
}
return this.clients.get(key)!;
}
修复 3:Tool 层友好降级
lspGetDefinitionTool.execute 已经有 try/catch,但 v0.1.12 当前实现里 client.start() 的 ENOENT 是异步抛的,绕过了 try。修复 1 让它变同步抛之后,这里会自动捕获并返回:
return { success: false, output: '', error: 'LSP unavailable: typescript-language-server not installed' };
Agent 拿到 success: false 后会按 Issue #21 已有的 Strategy Tracker 路径切换到其他工具(grep / read_file)继续工作,用户无感知。
修复 4(可选):在 init 流程或 /status 中加 LSP 可用性提示
LSP probes:
typescript-language-server ✗ not installed (npm i -g typescript-language-server typescript)
pyright ✗ not installed (npm i -g pyright)
让用户提前知情,而非首次踩到才发现。
📐 影响范围
- 直接受影响:所有未装 LSP 二进制的用户(几乎是全部首装用户)
- 受影响版本:至少 v0.1.10 引入 LSP(commit
d51b7d9 Phase 6 起),v0.1.12 仍未修
- 修改文件:
src/tools/lsp.ts(单文件,约 ±30 行)
- 风险:无 — 修复纯粹是把异常路径接住,不改变 happy path 行为
🧪 验证步骤
which typescript-language-server 确认未安装
- 启动
openhorse,让 Agent 读若干 .ts/.tsx 文件
- 观察 Agent 在后续 turn 调用
lsp_get_* 工具
- 修复前:进程
ERR_UNHANDLED_ERROR 崩溃
- 修复后:工具返回
{success: false, error: "LSP unavailable: …"},REPL 继续运行,Agent 自动切换到 grep / read_file 继续
这是我在真实使用 v0.1.12 时撞上的 Critical bug,直接导致一次长对话(7+ turns)被强制中断。报告中提到的修复方案我都在源码层验证过可行,如果维护者认可,我可以直接提一个 PR(预计 +30 / -5 行,只动 src/tools/lsp.ts)。
关联:也许可以一并把 issue #32 中 #3.10 / #4.3(测试覆盖未到 UI/异常路径)合在 v0.1.13 一并解决。
🐛 Bug 描述
在 v0.1.12 的全局安装版本中,Agent 在执行涉及 TS/JS 文件读取的对话(例如
read_file *.tsx)后会自发调用 LSP 工具(lsp_*)。当用户环境中未安装typescript-language-server二进制时,整个 openhorse 进程会直接崩溃退出,而不是降级为「LSP 工具不可用」的友好错误。触发场景
用户正在和 Agent 对话,Agent 读了若干
.ts/.tsx文件后(没有显式让它跑 LSP),在后续 turn 中自主决定调用lsp_get_definition/lsp_get_diagnostics来辅助分析。实际栈
🌍 环境
command -v typescript-language-server返回 not found)🔍 根因分析
源码:
src/tools/lsp.ts:46-78而调用方
LspManager.getClient(同文件 ~245 行)只是:从未给 client 注册
'error'事件监听器。按 Node EventEmitter 规范,无监听器时
emit('error', …)会触发events.js:509的throw err—— 导致 整个 REPL 进程被未捕获错误杀掉,用户丢失会话上下文与未保存的工作。触发路径
lsp_get_definition(src/tools/lsp.ts:332-371)lspManager.getClient(language, context.cwd)创建LspClientclient.start()内spawn('typescript-language-server', ['--stdio'])'error'事件(ENOENT)this.emit('error', err.message)转发到 LspClient注意:
getLspCommand()的try/catch在 tool 层(335-371 的try { … } catch (err) { return {success:false} })抓不到这次崩溃,因为 ENOENT 是child_process的异步事件,在await client.start()已经 resolve 之后才到达。💥 严重程度
Critical — 任何未预装 LSP 二进制的用户都会在 Agent 自主决策走 LSP 路径时遇到进程级崩溃。
typescript-language-server或pyright✅ 修复建议(可直接落地)
修复 1:
LspClient.start()做二进制预检 + 改为Promise.reject而非 emit要点:
start()同步抛,让await client.start()在 tool 层的 try/catch 中正常捕获'error'事件(那是 Node 保留通道,会触发 crash)'error',先listenerCount('error') === 0再 emit,保底不崩溃修复 2:
LspManager.getClient也加 error listener 作为最后兜底修复 3:Tool 层友好降级
lspGetDefinitionTool.execute已经有 try/catch,但 v0.1.12 当前实现里client.start()的 ENOENT 是异步抛的,绕过了 try。修复 1 让它变同步抛之后,这里会自动捕获并返回:Agent 拿到
success: false后会按 Issue #21 已有的 Strategy Tracker 路径切换到其他工具(grep / read_file)继续工作,用户无感知。修复 4(可选):在
init流程或/status中加 LSP 可用性提示让用户提前知情,而非首次踩到才发现。
📐 影响范围
d51b7d9Phase 6 起),v0.1.12 仍未修src/tools/lsp.ts(单文件,约 ±30 行)🧪 验证步骤
which typescript-language-server确认未安装openhorse,让 Agent 读若干.ts/.tsx文件lsp_get_*工具ERR_UNHANDLED_ERROR崩溃{success: false, error: "LSP unavailable: …"},REPL 继续运行,Agent 自动切换到grep/read_file继续