首页
/ claude-mem 会话生命周期修复实战:Stop Hook 端口冲突、Windows 路径损坏与上下文压缩崩溃

claude-mem 会话生命周期修复实战:Stop Hook 端口冲突、Windows 路径损坏与上下文压缩崩溃

2026-09-04 23:12:52作者:卓炯娓

本文基于 claude-mem 仓库中的修复 Playbook TRIAGE-03-Stop-Hook-Session-Lifecycle,深入讲解 Stop Hook(会话结束钩子)三类高影响缺陷的根因与修复方案:多会话并发启动时的端口冲突、Windows 下 Hook 命令反斜杠路径导致的 MODULE_NOT_FOUND,以及上下文压缩(compaction)后转录文件消失引发的 Stop Hook 崩溃。读完本文,你将掌握 claude-mem 钩子链路的幂等启动、跨平台路径安全与优雅降级(graceful degradation)设计,并能对照仓库源码验证这些机制的实际实现。

背景:为什么 Stop Hook 是最高危的故障点

claude-mem 的核心机制是在 Agent 会话的关键节点挂载 Hook:会话开始时拉起 worker 服务并注入上下文,工具调用后捕获 observation,会话结束时(Stop Hook)请求生成会话摘要。这些 Hook 定义在 plugin/hooks/hooks.json 中,其中 Stop 事件执行 hook claude-code summarize 命令(timeout 120 秒,异步执行):

"Stop": [
  {
    "hooks": [
      {
        "type": "command",
        "shell": "bash",
        "command": "... node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook claude-code summarize",
        "timeout": 120,
        "async": true
      }
    ]
  }
]

Playbook 开篇将 Stop Hook 故障定性为"第二高影响的缺陷类别"(second most impactful bug class),原因有三:

  1. 错误直接可见于用户:Stop Hook 运行在会话边界,正是用户注意力集中的时刻,任何报错都会形成"错误循环"体验;
  2. 阻断摘要生成:Stop Hook 是触发 summarize 的唯一入口,一旦失败,会话记忆压缩与持久化整条链路断裂;
  3. Windows 上可能整体崩溃:路径分隔符问题会让钩子脚本连加载都做不到。

该 Playbook 针对三个 Issue:#1346(端口冲突)、#1281(Windows MODULE_NOT_FOUND)、#1274(压缩后转录缺失崩溃),并以 Phase 01 的合并(PR #1330、#1291、#1326)为前置条件。下面逐一展开。

修复一:并发会话的端口冲突——worker 启动幂等化(Issue #1346)

问题现象

当两个 Claude Code 会话几乎同时启动时,第二个会话的 worker 启动尝试会失败,报出 port 37777 in use 错误。从源码结构看,worker 默认绑定固定端口(37777),两个 SessionStart Hook 并发触发时,后启动的一方尝试 bind 已被占用的端口,从而把"健康 worker 已存在"这一正常状态误判为错误并向用户暴露。

修复策略:先探测、再决定,绝不向健康 worker 报障

Playbook 给出的修复准则是"让 worker 启动幂等",核心决策树为:

  1. 在尝试 spawn 新 worker 之前,调用 isPortInUse() 探测端口;
  2. 若端口被占用 健康检查通过(/api/health 返回 200)——静默退出,退出码 0,因为 worker 本来就在运行;
  3. 若端口被占用但健康检查失败——才进入清理与重启流程。

关键行为准则一句话:只要存在健康的 worker,第二个会话永远不应看到错误。

源码印证

isPortInUse() 的实现位于 src/services/infrastructure/HealthMonitor.ts,其设计本身就体现了上述决策树的双层探测思想:

export async function isPortInUse(port: number): Promise<boolean> {
  if (process.platform === 'win32') {
    // Fast path: HTTP health check. A live claude-mem worker responds to
    // /api/health, so this is the cheapest non-disruptive probe for the
    // common case (worker is running and healthy).
    try {
      const response = await fetch(`http://${formatHostForUrl(getWorkerHost())}:${port}/api/health`);
      if (response.ok) return true;
      // Non-ok response: port is reachable but the worker is unhealthy.
      // Fall through to the net.createServer check below ...
    } catch (error) {
      // fetch threw (ECONNREFUSED, timeout, etc.): the port may still be in
      // use by a non-HTTP process (zombie worker, foreign service, etc.).
      // Fall through to the net.createServer probe ...
    }
  }

  return new Promise((resolve) => {
    const server = net.createServer();
    server.once('error', (err: NodeJS.ErrnoException) => {
      if (err.code === 'EADDRINUSE') resolve(true);
      else resolve(false);
    });
    server.once('listening', () => server.close(() => resolve(false)));
    server.listen(port, workerHost);
  });
}

两个值得注意的细节:

  • Windows 快速路径:在 win32 上先做 /api/health HTTP 探测。因为 Windows 上 socket 状态查询手段受限,一个能应答 health 端点的进程几乎可以断定就是存活的 worker;HTTP 探测不确定时(超时、非 200),再退化到 net.createServer 的 bind 探测作为权威判定——只有"尝试绑定失败(EADDRINUSE)"才能确证端口被占用。
  • host 格式化formatHostForUrl() 会把 CLAUDE_MEM_WORKER_HOST 中的 IPv6 字面量(如 ::1)括起来生成合法的 http://[::1]:port URL,避免 URL 构造错误造成误判。

同一文件还配套导出了 waitForHealth() / waitForReadiness()(轮询 /api/health/api/readiness,默认 30 秒超时,500ms 间隔),供启动后确认服务就绪。

worker 侧对进程内 EADDRINUSE 的兜底在 src/services/worker-service.ts

worker.start().catch(async (error) => {
  const isPortConflict = error instanceof Error && (
    (error as NodeJS.ErrnoException).code === 'EADDRINUSE' ||
    /port.*in use|address.*in use/i.test(error.message)
  );
  if (isPortConflict && await waitForHealth(port, 3000)) {
    logger.info('SYSTEM', 'Duplicate daemon exiting — another worker already claimed port', { port });
    process.exit(0);
  }
  // ... removePidFileIfOwner(process.pid); process.exit(1);
});

这里实现了 Playbook 中"修复剩余边界情况"的描述:当进程内 bind 直接撞上 EADDRINUSE 时,先 waitForHealth(port, 3000) 确认占用者是一个健康的 worker,是则退出码 0 静默退出(转而通过 HTTP 复用已有 worker),而不是退出 1 把失败暴露给调用方。PID 文件清理也采用 owner-or-dead 守卫(removePidFileIfOwner)——输掉端口竞争的一方绝不清掉赢家的 PID 文件,防止把健康 worker 的元数据误删。

Playbook 记录的最终状态:Phase 01 的 PR #1341 已带入多层端口保护(PID 检查、端口检查、daemon 守卫),Phase 03 补齐了上述进程内 EADDRINUSE 回退路径,并把"端口占用"日志从 ERROR 降级为 INFO(这是正常去重而非错误),新增 12 个端口冲突测试,全部 1144 个测试通过。

修复二:Windows Stop Hook MODULE_NOT_FOUND——路径分隔符双保险(Issue #1281)

问题现象

在 Windows 上,Hook 命令中的脚本路径若包含反斜杠(Windows 原生分隔符),命令解析时会发生"反斜杠路径损坏"(backslash path corruption),导致 stop hook 脚本以 MODULE_NOT_FOUND 失败。根因在于:hook command 是一段 shell 文本,路径在其中被二次插值;Unix 风格的 shell 转义规则会把 \s\p 之类的序列吞掉或转义掉,路径就此变形。

修复方案:两层防御

Playbook 记录的修复分两层:

第一层:hooks.json shell 前言(preamble)规范化。 在插值路径进命令之前,先用 POSIX 安全的 printf | tr 管道把 CLAUDE_PLUGIN_ROOT 中的反斜杠统一转换成正斜杠。这一层保证无论环境变量来自哪个平台、携带哪种分隔符,进入后续路径拼接的都是正斜杠形式。

第二层:执行器从 Unix-only 脚本切换到跨平台 Node 包装器。 hooks.json 的命令从原先的 bun-exec-runner.sh(纯 Unix shell 脚本,Windows 无对应 shell 时直接不可用)切换为 node bun-runner.js。跨平台路径规范化逻辑内置在 plugin/scripts/bun-runner.js 第 111-117 行附近——由 Node.js 而非 shell 负责解析与执行脚本路径,从根本上绕开 shell 转义差异。

验证手段

修复的验收标准是:hooks.json 的输出在所有平台上只含正斜杠路径。当前仓库中 plugin/hooks/hooks.json 的 8 个 hook command 全部形如 node "$_P/scripts/bun-runner.js" ...,配合命令前段的 cygpath -w 调用(Git Bash 环境下把正斜杠路径转回 Windows 形式,仅在 command -v cygpath 存在时执行)形成闭环:shell 文本层面恒用正斜杠,真正执行时由 cygpath/Node 做平台适配。Playbook 记录该修复后 25 个 plugin-distribution 测试与全部 90 个 hook 相关测试通过。

修复三:上下文压缩后 Stop Hook 崩溃——转录缺失优雅降级(Issue #1274)

问题现象

Claude Code 在会话中途压缩上下文(context compaction)时,转录(transcript)文件会移动或消失。此时 Stop Hook 仍会触发,处理代码尝试读取旧路径的转录文件,抛出 "Transcript path missing" 错误,并沿 transcript-parser.tshook-command.ts 一路传播,最终以退出码 2(BLOCKING_ERROR)结束——Stop Hook 崩溃,用户会话结束时看到报错。

修复方案:永不因缺失转录而抛错

Playbook 的修复准则:

  1. 读取转录前加 existsSync(transcriptPath) 守卫;
  2. 文件缺失时记录警告并按降级模式继续——用最后一条已知的 assistant 消息代替完整转录生成摘要;
  3. 同时检查 CLAUDE_CONVERSATION 环境变量回退路径——压缩可能改写该变量,回退逻辑应同时尝试原始路径与压缩后路径;
  4. 硬性约束:handler 不得在转录缺失时 throw,必须优雅降级

源码印证:summarize handler 的降级实现

当前 src/cli/handlers/summarize.ts 中的转录提取正是按"绝不外抛"的原则实现的:

try {
  // One read of the transcript yields both the text and the model.
  const turn = extractLastAssistantTurn(transcriptPath, true);
  lastAssistantMessage = stripMemoryTags(turn.text);
  observedModel = turn.model;
} catch (err) {
  logger.warn('HOOK', `Stop hook: failed to extract last assistant message for session ${sessionId}: ...`);
  return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}

转录文件缺失、为空或不可读时,extractLastAssistantTurn 的异常被就地捕获,记录 warning 后以退出码 0 返回 { continue: true }——会话照常结束,不再向用户抛出 BLOCKING_ERROR。这与 Playbook 描述的演进路径一致:修复前 transcript-parser.ts 的错误会传播到 src/cli/hook-command.ts 的兜底 catch 分支,触发 emitBlockingError 并以 HOOK_EXIT_CODES.BLOCKING_ERROR(2)退出;修复后该路径被降级为"跳过摘要"而非"崩溃"。

值得注意的是,当前实现的错误路由还有一整套更细粒度的分类,见 src/cli/hook-command.ts

  • isNonBlockingHookInputError()(L77-L83):消息含 "transcript path" 且 "missing/does not exist" 时,视为非阻断输入错误——记 warn、发 no-op 结果、退出码 0;
  • isWorkerUnavailableError()(L43-L75):ECONNREFUSED、fetch failed、5xx/429 等传输层错误——worker 不可用属于瞬态,退出码 0,交给 recordWorkerUnreachable() 的失败计数达到阈值后才对外发声;
  • 只有上述都不匹配的真实编程错误,才走 emitBlockingError 退出码 2 的路径。

这套分类保证了 Stop Hook 的退出码语义清晰:只有真正需要阻断的钩子错误才会以 2 退出并把信息回灌给模型,环境性、瞬态性、输入缺失类问题一律静默放行。

修复后的摘要请求流程

即便降级,会话清理仍应发生。summarize handler 提取(或获得降级后的)lastAssistantMessage 后,通过 executeWithWorkerFallback('/api/sessions/summarize', 'POST', ...) 把摘要请求投递给 worker(见 src/cli/handlers/summarize.ts);若运行在 server 运行时,则走 summarizeViaServer,通过 startSessionrecordEventendSession 三步把最后一条 assistant 消息落入生成流水线。也就是说,"转录缺失"只影响摘要的素材完整度,不会中断 worker 接收摘要请求这条主链。

验证与回归:Phase 03 的验收基线

Playbook 将验证固化为四步 checklist,这也是对同类钩子问题回归测试的可复用模板:

  1. 全量测试通过npm test——Phase 03 完成时 1149 个测试 0 失败(3 skipped),其中针对本阶段新增了:
    • 12 个端口冲突测试(并发启动、EADDRINUSE 回退、日志级别);
    • 5 个转录缺失测试,覆盖:文件缺失、空文件、合法转录、无路径、以及警告日志断言;
  2. 构建与分发npm run build-and-sync——worker service、MCP server、context generator 构建并同步到 marketplace;
  3. hooks.json 路径格式检查:读取 plugin/hooks/hooks.json,断言所有 command 值使用 node "$_P/scripts/bun-runner.js" 前缀且全为正斜杠,任何 command value 中不得出现反斜杠;
  4. 关键行为断言:健康 worker 存在时,第二个会话启动路径必须退出码 0 且无用户可见错误。

对应仓库中可继续深入验证的测试包括 tests/infrastructure/health-monitor.test.tstests/infrastructure/plugin-distribution.test.tstests/hook-command.test.ts

小结:会话边界健壮性的三条设计准则

从 TRIAGE-03 的修复中可以提炼出 claude-mem 钩子体系处理的三条通用准则,对任何"宿主 IDE/CLI + 后台服务 + 生命周期钩子"的架构都适用:

  1. 启动幂等先于启动尝试:任何端口绑定前做"占用 + 健康"双层探测,占用且健康即静默复用,杜绝把正常去重当错误报给用户(HealthMonitor.ts 的双层探测、worker-service.ts 的 EADDRINUSE 回退);
  2. shell 文本中的路径必须平台无关:跨平台钩子命令在 shell 层恒用正斜杠 + 前置 tr 规范化,执行层交给 Node/cygpath 适配,避免 shell 转义吞噬反斜杠(hooks.jsonbun-runner.js);
  3. 钩子 handler 是纯函数,错误分级路由:handler 只返回结果不直接写 IO/退出(见 summarize.ts 头部注释的 IO discipline),由 hook-command.ts 统一把"输入缺失 / worker 瞬态不可用 / 真实错误"分流到退出码 0 / 0 / 2,保证会话边界永不因环境异常向用户报错。
登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
980
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384