跳到主要内容

心脏:worker 守护进程与持久 Agent SDK 观察者会话

30 秒导读: 你在终端里正常和 Claude 干活(这是"主会话")。claude-mem 在旁边再养了第二个 Claude——一个专职做记忆的"观察者"。你的每一次工具调用都会被钩子悄悄抄送给一个常驻后台的 worker 守护进程;worker 把这些事件排进队列,喂给那个观察者会话,让它在后台慢慢把"刚才发生了什么"消化成结构化记忆。关键在于:主会话跑得飞快,从不等观察者。这一章讲清楚"为什么要派第二个 Claude、它是怎么被喂养的"。


1. 这是什么(零基础也能懂)

1.1 先讲清楚"两个 Claude"

claude-mem 要解决的问题是:让 Claude 跨会话记住东西。要记住,就得有人观察并总结你和主 Claude 的每一步。

一个天真的做法是:让主会话自己边干活边写记忆。但那样会拖慢你、污染主会话的上下文、还抢主会话的 token。

claude-mem 的选择是分工:

角色是谁干什么谁在等它
主会话你终端里的 Claude Code帮你写代码、答问题你(它必须快)
观察者(observer)后台第二个 Claude,用 Agent SDK 起读主会话的工具事件,压成记忆没有人(它可以慢)

这条异步解耦的主线贯穿全章:主会话每做一步只是"发一封信"就继续跑;观察者在后台自己拆信、慢慢读、慢慢写。

1.2 谁在中间收发信 —— worker 守护进程

主会话和观察者不直接说话。中间有一个常驻的后台进程worker:

  • 它是一个 Bun 写的 HTTP 守护进程,监听本机一个端口。
  • 钩子(见 01 章)每次拦到一个工具调用,就往 worker POST 一条 /api/sessions/observations
  • worker 收下,排进那个会话专属的内存队列,然后确保观察者会话正在后台运行、正在把队列里的事件一条条喂给第二个 Claude。

一句话直觉:worker 是邮局,内存队列是信箱,观察者是那个慢慢读信、慢慢写回忆录的人。 你(主会话)只管把信投进邮局就走。

1.3 用起来什么样

你什么都感觉不到——这正是设计目标。但在底下,一次工具调用大致触发这样一条 HTTP:

POST http://127.0.0.1:<port>/api/sessions/observations
{
"contentSessionId": "abc-123", # 主会话 id
"tool_name": "Edit",
"tool_input": { "file_path": "...", ... },
"tool_response": { ... },
"cwd": "/Users/you/project",
"tool_use_id": "toolu_..." # 用于去重
}
→ 200 { "status": "queued" } # 立刻返回,主会话继续跑

注意返回是 queued(排队了),不是 done(做完了)。worker 把它收进信箱就回话了,真正的消化在后台慢慢发生。


2. 顶层全景(它大概怎么转)

2.1 一张图看懂喂养回路

怎么读这张图:从左到右是一条工具事件的旅程。左边"快",右边"慢";两者被中间的内存缓冲解耦。

你的主会话 worker 守护进程 (Bun HTTP, 常驻) 第二个 Claude
(Claude Code) ┌──────────────────────────────────────────────┐ (观察者会话)
│ │ │ │
工具调用 │ SessionRoutes SessionManager │ │
│ HTTP POST │ ┌───────────────┐ ┌──────────────────┐│ │
├────────────▶│ │/observations │─queue─▶│ per-session 缓冲 ││ │
│ (立即返回) │ │/summarize │ │ (SessionMessage- ││ │
│◀────────────┤ │/init │ │ Buffer, 内存) ││ │
继续干活 │ └──────┬────────┘ └────────┬─────────┘│ │
(不等) │ │ ensureGeneratorRunning │ drain │ │
⋮ │ └──────────┐ │(async gen)│ query() 流式 │
⋮ │ ClaudeProvider ◀─────────┘ ├──喂 prompt──▶│
⋮ │ .startSession() ──── 持久会话 ─────── │◀─ 压缩结果 ──┤
⋮ │ │ 解析落库 → SQLite / Chroma │ │
└─────────────┴─────────────────┴──────────────────────────────┘ ▼
(03 章讲压缩与解析)

2.2 部件一句话职责

部件干什么在哪个文件
WorkerService守护进程本体:装配所有部件、开 HTTP、管生命周期src/services/worker-service.ts:192
SessionRoutes三个入口端点;收事件、入队、拉起观察者src/services/worker/http/routes/SessionRoutes.ts:55
SessionManager管每个主会话的 ActiveSession 状态 + 队列src/services/worker/SessionManager.ts:9
SessionMessageBufferper-session 内存队列:去重、drain、claim/confirmsrc/services/worker/SessionMessageBuffer.ts:42
ClaudeProvider用 Agent SDK query() 起并维持那个持久观察者会话src/services/worker/ClaudeProvider.ts:166

2.3 主线走一遍(高层)

  1. 钩子 POST /api/sessions/observationsSessionRoutes 收下。
  2. 事件被 SessionManager.queueObservation 塞进该会话的内存缓冲
  3. ensureGeneratorRunning 确认这个会话的观察者会话在跑;没跑就 ClaudeProvider.startSession 起一个。
  4. ClaudeProvider 里的异步生成器从缓冲 drain 出事件,拼成 prompt,流式喂给第二个 Claude 的 query() 会话。
  5. 第二个 Claude 吐回压缩结果,processAgentResponse 解析落库(细节交给 03 章)。

关键:第 1 步 HTTP 立刻返回;第 3-5 步在后台自己转。主会话与观察者的唯一接触点,是第 2 步那个内存缓冲。


3. worker 守护进程:常驻的邮局

3.1 为什么要"常驻"

观察者会话是有状态的——第二个 Claude 记着"这个主会话之前发生过什么"。如果每来一个事件就冷启动一次,既慢又丢上下文。所以 claude-mem 让 worker 作为一个后台守护进程一直活着,把有状态的观察者会话缓存在内存里。

它是一个 Bun HTTP 服务start() 起 supervisor、监听端口、写 PID 文件,然后把重活推到后台异步初始化:

// 示意,非源码:WorkerService.start() 的骨架
await startSupervisor();
await this.server.listen(port, host); // 先开始收 HTTP
writePidFile({ pid, port, startedAt });
this.initializeBackground(); // DB/搜索在后台慢慢起,不 await

真实实现见 worker-service.ts:396(start)与 worker-service.ts:440(initializeBackground)。注意 initializeBackground() 没有被 await——HTTP 端口先开,数据库和搜索在后台就绪;就绪前 /api 请求会被中间件挡回 503 Service initializing(worker-service.ts:335)。

3.2 端口:不是写死的 37777

worker 监听的端口按当前用户 uid 派生,而不是固定值:

// src/shared/SettingsDefaultsManager.ts:94
CLAUDE_MEM_WORKER_PORT: String(37700 + ((process.getuid?.() ?? 77) % 100)),

于是每个用户各占 37700–37799 之间的一个端口(uid 末两位为 77 的用户恰好是 37777,这也是文档里常举的例子)。取端口的入口是 getWorkerPort()(src/shared/worker-utils.ts:126,带进程内缓存)。这样同机多用户不会抢同一端口。

3.3 装配:一个构造函数把心脏接起来

WorkerService 的构造函数是整台机器的接线图——它 new 出所有核心部件并互相注入:

// src/services/worker-service.ts:236 起(节选)
this.dbManager = new DatabaseManager();
this.sessionManager = new SessionManager(this.dbManager);
this.sdkAgent = new ClaudeProvider(this.dbManager, this.sessionManager);

三者的关系是本章的骨架:

部件依赖谁为什么
DatabaseManager落库的门面(SQLite + Chroma 镜像,见 04 章)
SessionManagerDatabaseManager管会话状态、从库里读会话行
ClaudeProvider两者都要喂养观察者时要读会话状态、写回记忆

SearchManager 不在构造函数里,而是在 initializeBackground() 里等 DB 就绪后才装配(worker-service.ts:508),然后注册检索路由——检索是回路的另一半,交给 05 章

3.4 生命周期命令:start / stop / restart / status

worker 既是守护进程,也是自己的 CLI。parseWorkerServiceCommand(worker-service.ts:800)把 argv 分派到四个生命周期分支:

命令干什么关键设计
start确保 worker 起来走 spawn gate,避免并发重复拉起
stop先抓死掉进程的 pid,HTTP 关停,等端口释放PID 文件清理只删自己或死进程的,绝不误删继任者(Phase 5)
restart将死的 worker 自己 spawn 继任者端口一空继任者 ~200ms 内重绑;CLI 只做验证,不抢着 spawn
statusGET /api/health 为准PID 文件只作诊断,绝不让 status 撒谎

restart 尤其精巧:主路径是将死进程自我替换(worker-shutdown.ts 的 handoff),CLI 只 verifyRestartedWorker——确认应答健康检查的是新 pid、新版本;只有 handoff 没能产出可验证的继任者,CLI 才通过 spawn gate 兜底自己拉起(worker-service.ts:1034 起的 restart case)。


4. 收信与入队:SessionRoutes 与 per-session 缓冲

4.1 三个入口端点

SessionRoutes.setupRoutes(SessionRoutes.ts:262)注册了喂养回路的三扇门:

端点谁来敲干什么
POST /api/sessions/init用户发新 prompt 时建会话行、存 user prompt、拉起观察者(source='init')
POST /api/sessions/observations每次工具调用后把工具事件入队(source='observation')
POST /api/sessions/summarize会话收尾时排一条 summarize 请求(source='summarize')

三者都遵守同一节奏:校验 → 落一点库 → 入队 → ensureGeneratorRunning → 立刻回 queued。以 observations 为例,真正的入队委托给 ingestObservation(src/services/worker/http/shared.ts:57),它做隐私/排除过滤后调 sessionManager.queueObservation(...),再 ensureGeneratorRunning(sessionDbId, 'observation')(shared.ts:121shared.ts:138)。

4.2 队列长什么样:per-session 内存缓冲

每个主会话有自己的一条内存队列,由 SessionManager 上唯一的 SessionMessageBuffersessionDbId 分桶管理(SessionManager.ts:13)。入队就是往桶里 push:

// src/services/worker/SessionManager.ts:151 queueObservation(节选)
const message: PendingMessage = {
type: 'observation',
tool_name: data.tool_name,
tool_input: data.tool_input,
tool_response: data.tool_response,
toolUseId: data.toolUseId,
...
};
const messageId = this.buffer.enqueue(sessionDbId, message);

queueSummarize(SessionManager.ts:183)同理,只是塞一条 type: 'summarize'

4.3 为什么队列是"内存"而不是"持久"的

这是 claude-mem 一个刻意的、带血泪史的决定,写在 SessionMessageBuffer 顶部的注释里(SessionMessageBuffer.ts:21 起):

一条缓冲消息是喂给一个有状态、非确定的 reducer(观察者会话)的一个工具片段。老版本用持久的 SQLite 队列,但它只存了片段、丢了 reducer 的会话状态;崩溃后"重放"pending 行会生成不同/重复的观察,甚至无限循环——这就是当年的重试风暴

所以现在的设计是:队列只在 worker 进程生命周期内存在,崩了就没了。真正的持久真源是 Claude Code 的 transcript JSONL,崩溃后的恢复路径是重放 transcript,而不是重放队列。

一句话精华:别把喂给有状态 AI 的中间片段做持久化——持久化的应该是能重放的原始事实,不是半消化的碎片。

4.4 去重:同一个 tool_use 只喂一次

钩子可能把同一个工具调用送来两次。缓冲用 toolUseId 去重——这镜像了老版本 UNIQUE(content_session_id, tool_use_id) 的部分唯一索引:

// src/services/worker/SessionMessageBuffer.ts:56 enqueue(节选)
const toolUseId = message.toolUseId;
if (toolUseId) {
const seen = this.getSeen(sessionDbId);
if (seen.has(toolUseId)) {
return 0; // 0 = 被当成重复压掉
}
seen.add(toolUseId);
}

返回 0 表示"重复,已压掉"(调用方据此打 DUP_SUPPRESSED 日志,SessionManager.ts:172)。clear() 会连带清掉这个 seen 集合(SessionMessageBuffer.ts:105),否则清空后再来一个旧 toolUseId 会被静默吞掉。


5. 喂养:drain 与 claim/confirm/reset 语义

5.1 观察者怎么"取"信

观察者会话不是被 push 事件,而是自己拉——通过一个 async 生成器 drain(SessionMessageBuffer.ts:152)。它的循环很简单:有信就 claim 一条 yield 出去,没信就在 per-session 的 EventEmitter 上等,直到:新信来了、abort 触发、或空闲超时

// src/services/worker/SessionMessageBuffer.ts:152 drain(骨架示意)
while (!signal.aborted) {
const claimed = this.claimNext(sessionDbId); // 标记 claimed=true 并取出
if (claimed) { yield { ...claimed.message, _persistentId, _originalTimestamp }; continue; }
const received = await this.waitForMessage(...); // 空转等信号/abort/超时
// 超时则 onIdleTimeout() 并 return,结束迭代器
}

5.2 claim / confirm / reset —— 三个动词的意思

这三个词只是单个活着的生成器 pass 内的进程内控制流,从不跨进程(SessionMessageBuffer.ts:37 注释强调)。含义如下:

动词谁调意思
claimdrain 内部 claimNext"我正在处理这条"——标记 claimed=true,防止重复 yield
confirm观察者成功落库后"这条我消化完了"——从缓冲里删掉(buffer.confirm,SessionMessageBuffer.ts:74)
reset每次生成器 pass 开始时"上一轮 claim 了没 confirm 的,重新算待处理"(resetClaimed,SessionMessageBuffer.ts:87)

SessionManager 在这三者外面包了一层记账:getMessageIterator 每次开跑先 resetProcessingToPending(把上一轮悬空的 claim 复位,SessionManager.ts:350),然后一边 yield 一边把 id 记进 session.claimedMessageIds(SessionManager.ts:362);观察者存好一批后调 confirmClaimedMessages 逐个 confirm(SessionManager.ts:219)。

为什么需要 reset?因为一批事件可能在 memorySessionId 还没捕获到时暂时存不了——那就先不 confirm,下一轮 reset 后重新 yield,不丢事件。

5.3 空闲超时:让第二个 Claude 睡觉

观察者会话是个真实的子进程,常驻很贵。所以缓冲带一个 3 分钟空闲超时(IDLE_TIMEOUT_MS,SessionMessageBuffer.ts:5)。空转超过阈值,drain 触发 onIdleTimeout,而这个回调会abort 整个会话,从而杀掉 SDK 子进程:

// src/services/worker/SessionManager.ts:352 getMessageIterator 里的 onIdleTimeout
onIdleTimeout: () => {
session.idleTimedOut = true;
session.abortReason = 'idle';
session.abortController.abort(); // 结束迭代器 → 生成器退出 → 子进程被杀
}

下次再来事件时,ensureGeneratorRunning 会重新起一个观察者会话。这就是"该忙时忙、闲时释放"的关键。

5.4 ActiveSession:每个会话的状态卡

worker 为每个主会话维护一个 ActiveSession(结构在 src/services/worker-types.ts:9),它是观察者"记得这个会话"的全部内存状态。挑几个本章相关的字段:

字段作用
contentSessionId主会话 id(你终端那个)
memorySessionId观察者会话自己的 SDK session id(下节的主角)
abortController空闲/关停/配额时用它杀子进程
generatorPromise当前观察者 pass 的 Promise,null 表示没在跑
lastPromptNumber决定这次是 INIT 还是续接
claimedMessageIds本轮 claim 了、待 confirm 的 id
conversationHistory喂给观察者的对话历史(provider 间共享,可切换)

6. 心脏的心脏:ClaudeProvider 与持久 observer 会话

6.1 用 Agent SDK 起一个持久会话

ClaudeProvider.startSession(ClaudeProvider.ts:175)是"第二个 Claude"真正被点起来的地方。它用 @anthropic-ai/claude-agent-sdkquery(),把一个异步消息生成器当 prompt 传进去:

// src/services/worker/ClaudeProvider.ts:241 (节选)
const queryResult = query({
prompt: messageGenerator, // ← 关键:prompt 是个 async 生成器
options: buildHardenedSdkOptions({
source: 'Observer',
model: modelId,
env: isolatedEnv, // 用 ~/.claude-mem/.env 的独立凭证
abortController: session.abortController,
...(shouldResume && session.memorySessionId ? { resume: session.memorySessionId } : {}),
...
}),
});
for await (const message of queryResult) { /* 处理回复,见 6.4 */ }

prompt 传的是生成器而非字符串——这正是"流式喂养"的实现:SDK 会持续从这个生成器下一条消息,拉多久都行。这个观察者会话被 buildHardenedSdkOptions 强隔离(allowedTools: []permissionMode: 'dontAsk'settingSources: []mcpServers: {}),它只被允许读事件、写记忆文本,不能真去动你的系统(src/sdk/hardened-options.ts:90)。

6.2 生成器怎么把缓冲流式喂进 SDK

createMessageGenerator(ClaudeProvider.ts:451)是那个异步生成器。它先 yield 一条 init/续接 prompt,然后挂到 SessionManager.getMessageIterator,把缓冲里 drain 出来的每条事件翻译成 prompt 再 yield:

// src/services/worker/ClaudeProvider.ts:485 起(骨架示意)
yield { type: 'user', message: { role: 'user', content: initPrompt }, ... }; // 开场

for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) {
if (message.type === 'observation') {
const obsPrompt = buildObservationPrompt({ tool_name, tool_input, tool_output, cwd, ... });
yield { type: 'user', message: { role: 'user', content: obsPrompt }, ... };
} else if (message.type === 'summarize') {
const summaryPrompt = buildSummaryPrompt({ ... }, mode);
yield { type: 'user', message: { role: 'user', content: summaryPrompt }, ... };
}
}

于是数据流是一条完整的管子:缓冲 → getMessageIterator(claim)→ 生成器(拼 prompt)→ query()(喂 SDK)→ 第二个 Claude。生成器一旦跑到 getMessageIterator 没有更多事件、且空闲超时,迭代器结束,生成器返回,query() 也就自然收尾。(prompt 长什么样、mode 怎么选,交给 03 章。)

6.3 resume 决策与 memorySessionId 对齐 [ALIGNMENT]

这是全章最烧脑、也最关键的一处:观察者会话是同一个主会话跨多个 prompt 的连续会话,所以第二次及以后要 resume 回上次那个 SDK 会话,而不是每次都从头。

两个 id 千万别混:

id是什么谁给的
contentSessionId主会话(你的 Claude Code)的 idClaude Code
memorySessionId观察者自己那个 SDK 会话的 id第一次 query() 的响应里带回来

是否 resume 的判断(ClaudeProvider.ts:201):

const hasRealMemorySessionId = !!session.memorySessionId;
const shouldResume = hasRealMemorySessionId
&& session.lastPromptNumber > 1
&& !session.forceInit;

第一次跑没有 memorySessionId,于是从头起;SDK 回复里第一次出现 session_id 时,ClaudeProvider 捕获它并写库对齐:

// src/services/worker/ClaudeProvider.ts:289 起(节选)
if (message.session_id && message.session_id !== session.memorySessionId) {
session.memorySessionId = message.session_id;
this.dbManager.getSessionStore()
.ensureMemorySessionIdRegistered(session.sessionDbId, message.session_id);
// 立刻回读校验,dbVerified=false 时打 MEMORY_ID_MISMATCH error
}

整条决策链都会打 [ALIGNMENT] 调试日志(ClaudeProvider.ts:231:234:311),把 contentSessionId → memorySessionId | prompt# | shouldResume | resumeWith 串起来,方便追"这次到底 resume 了没、resume 的是哪个"。

6.4 Issue #817:丢弃陈旧的 memory_session_id

worker 一旦重启,内存里那个观察者 SDK 会话就没了——但数据库里可能还留着上次的 memory_session_id。如果傻乎乎拿它去 resume,会 resume 到一个SDK 侧已不存在的会话,行为错乱。

所以 SessionManager.initializeSession 在从库里恢复会话时,明确把它丢掉:

// src/services/worker/SessionManager.ts:78 起(节选)
if (dbSession.memory_session_id) {
logger.warn('SESSION',
'Discarding stale memory_session_id from previous worker instance (Issue #817)', {
staleMemorySessionId: dbSession.memory_session_id,
reason: 'SDK context lost on worker restart - will capture new ID'
});
}
// ...新建 session 时:
memorySessionId: null, // 永远从头,让 SDK 重新给一个新 id

对应地,startSession 里对 INIT prompt 也再兜一层:即便手上有 memorySessionId,首个 prompt 也 START_FRESH,并 warn"SDK 上下文已丢失(worker 重启/崩溃恢复)"(ClaudeProvider.ts:233)。

6.5 错误分类:哪些能重试,哪些是死路

观察者会话会撞上各种错误。classifyClaudeError(ClaudeProvider.ts:54)把它们归成有限几类,决定"重试还是放弃":

kind触发处置
setup_required找不到 Claude 可执行文件 / ENOENT不可恢复,记依赖健康,停
auth_invalid401/403、Invalid API key认证问题,停
transientOverloadedError/529、5xx、未知可短暂重试(默认归这类)
rate_limit429限流
quota_exhausted消息含 quota exceeded配额用尽
unrecoverablePrompt is too long、HTTP 400永久性配置/上下文错,停

其中 HTTP 400 分支专治 Issue #2357:当 CLAUDE_CODE_EFFORT_LEVEL 从宿主环境泄漏进 SDK 子进程、SDK 把 effort 参数转发给不支持它的模型(Haiku/Sonnet 4.5)时,API 会以 400 拒绝;没有这个分支,默认的 transient 分类会无限重试(ClaudeProvider.ts:116 起)。这也解释了为什么 CLI/spawn 各处都在 sanitizeEnv 里剥掉 CLAUDE_CODE_*(worker-service.ts:863)。

6.6 token 计量与配额 wall-clock 守护 (#2234)

观察者在后台默默烧 token/额度,必须有护栏防止它把你交互会话的配额也吃光。

计量:每条 assistant 消息带 usage,ClaudeProvider 累加进 cumulativeInputTokens/OutputTokens,并把"真实单次用量"(含 cache 读写)记进 session.lastUsage 供遥测(ClaudeProvider.ts:324 起)。注意:流式 assistant 消息里的 output_tokens 只是早期占位,真正的 per-turn 用量与成本要等 result 消息(ClaudeProvider.ts:385 起,total_cost_usd 是累计值,单次成本取相邻两次 result 的差)。

wall-clock 配额守护:SDK 会推 system / subtype: 'rate_limit' 事件,携带订阅配额的实时状态。ClaudeProvider 在消费循环里截获它,喂给 shouldAbortForQuota;一旦越过某个时间窗的利用率阈值(或某窗即将在宽限期内重置),就主动 abort、退出循环,把配额留给交互会话:

// src/services/worker/ClaudeProvider.ts:258 起(骨架示意)
for await (const message of queryResult) {
if (message?.type === 'system' && message?.subtype === 'rate_limit') {
globalRateLimitStore.set(message.rate_limit_info);
const decision = shouldAbortForQuota(authMethod, globalRateLimitStore);
if (decision.abort) {
session.abortReason = `quota:${decision.window ?? 'unknown'}`;
session.abortController.abort();
break;
}
}
// ...否则照常捕获 memorySessionId / 处理 assistant / result
}

API key 用户豁免——他们已授权按调用付费,不受订阅窗阈值约束(shouldAbortForQuotaisApiKeyAuth 判定,src/services/worker/RateLimitStore.ts:128 起)。

此外还有一层并发闸:起会话前 waitForSlot(maxConcurrent, signal)(ClaudeProvider.ts:214;实现 src/supervisor/process-registry.ts:510)确保后台同时活着的观察者子进程不超过设定值(默认 2),并有一个更硬的 TOTAL_PROCESS_HARD_CAP 兜底,防止 worker fork 出失控的进程军团。


7. 观察者的一生:从拉起到退出

把 4-6 节连起来,一个观察者会话的生命周期由 SessionRoutes 编排:

事件入队 ──▶ ensureGeneratorRunning(sessionDbId, source)
│ (SessionRoutes.ts:76)
├─ 已有 generatorPromise? ──是──▶ 什么都不做(复用现有会话)
│ └─(provider 变了?留言,等本轮自然结束再切)
└─ 否 ──▶ startGeneratorWithProvider (SessionRoutes.ts:136)

├─ agent.startSession(session) ← query() 持久会话开跑
│ (SessionRoutes.ts:172)

├─ .catch(...) ← 分类错误、记遥测、按需 abort

└─ .finally(...) ← 消费 abortReason、记 outcome、
handleGeneratorExit 清理

三个要点:

  • 单飞:session.generatorPromise 非空就绝不再起第二个,新事件只是被现有生成器 drain 出来——一个会话同一时刻只有一个观察者在跑
  • 切 provider 不打断:选中的 provider(claude/gemini/openrouter)变了,不粗暴中断当前 pass,而是让它自然收尾,下一轮用新 provider;conversationHistory 在 provider 间共享,上下文不丢(SessionRoutes.ts:124)。
  • 退出即记账:finally 里消费 abortReason(idle/shutdown/overflow/quota),发一条归一化的 session_compressed 遥测,再 handleGeneratorExit 收尾(SessionRoutes.ts:227 起)。

8. 巧妙之处(可带走的精华)

  1. 异步解耦是整章的魂。 HTTP 端点收下事件即回 queued,主会话零等待;观察者在后台自成节奏。接触面收窄到"一个内存缓冲",两侧速度彻底脱钩。(SessionRoutes.ts:344)

  2. 把 prompt 做成 async 生成器,会话就能"永远流式喂"。 query({ prompt: messageGenerator }) 让第二个 Claude 变成一根持续拉取的管子,而不是一问一答。(ClaudeProvider.ts:241:451)

  3. 别持久化喂给有状态 AI 的中间碎片。 队列只活在进程内;真源是可重放的 transcript。这是从"重试风暴"里学来的教训。(SessionMessageBuffer.ts:21 注释)

  4. 两个会话 id 要严格对齐并可审计。 contentSessionIdmemorySessionId 分离,[ALIGNMENT] 日志把每次 resume 决策串起来,落库后立刻回读校验。(ClaudeProvider.ts:289)

  5. 重启即失忆,主动丢弃陈旧 SDK 会话 id。 worker 一重启,内存里的观察者会话没了,库里的 memory_session_id 就成了会指向"幽灵会话"的陷阱——直接丢掉,重新捕获。(SessionManager.ts:78,Issue #817)

  6. 后台工作必须给交互会话让路。 wall-clock 配额守护 + 空闲超时 + 并发闸,三条护栏保证"第二个 Claude"永远是仆人,不会反客为主吃光你的额度或进程。(ClaudeProvider.ts:258SessionMessageBuffer.ts:5process-registry.ts:510)


9. 边界与局限(诚实)

  • 崩溃丢队列。 缓冲是纯内存的,worker 崩了、进程内未 confirm 的事件就没了;恢复靠 transcript 重放,不靠队列。这是设计取舍,不是 bug。(SessionMessageBuffer.ts:33)
  • 观察是"尽力而为",不保证实时。 空闲超时会主动杀观察者子进程;配额/限流会中途 abort。所以记忆的生成可能滞后甚至在极端配额下被跳过。
  • 强依赖能找到并跑起 Claude 可执行文件。 找不到会被分类为 setup_required 并进入冷却期跳过 Claude 生成(SessionRoutes.ts:84);此时观察者根本不启动。
  • 本章不覆盖"消化之后"。 观察者吐回的文本长什么样、怎么解析落库,是 03 章;存进哪、怎么建索引,是 04 章;怎么把记忆检索回来注入新会话,是 05 章

10. 代码地图(导航索引)

主题文件符号
守护进程装配 / 生命周期src/services/worker-service.tsWorkerServicestartinitializeBackgroundshutdown
CLI 命令分派src/services/worker-service.tsparseWorkerServiceCommandmain(start/stop/restart/status)
端口派生src/shared/SettingsDefaultsManager.tssrc/shared/worker-utils.tsCLAUDE_MEM_WORKER_PORTgetWorkerPort
三个入口端点src/services/worker/http/routes/SessionRoutes.tssetupRouteshandleObservationsByClaudeIdhandleSummarizeByClaudeIdhandleSessionInitByClaudeId
观察者拉起 / 生命周期src/services/worker/http/routes/SessionRoutes.tsensureGeneratorRunningstartGeneratorWithProviderapplyTierRouting
事件入队src/services/worker/http/shared.tssrc/services/worker/SessionManager.tsingestObservationqueueObservationqueueSummarize
会话状态src/services/worker/SessionManager.tssrc/services/worker-types.tsinitializeSessiongetMessageIteratorconfirmClaimedMessagesActiveSession
内存缓冲 / 去重 / drainsrc/services/worker/SessionMessageBuffer.tsSessionMessageBufferenqueuedrainclaimNextconfirmresetClaimedIDLE_TIMEOUT_MS
持久 observer 会话src/services/worker/ClaudeProvider.tsClaudeProviderstartSessioncreateMessageGeneratorgetModelId
resume / id 对齐 / #817src/services/worker/ClaudeProvider.tssrc/services/worker/SessionManager.tsshouldResumememorySessionId 捕获、ensureMemorySessionIdRegistered[ALIGNMENT] 日志
错误分类src/services/worker/ClaudeProvider.tsclassifyClaudeErrorClassifiedProviderError
配额守护 / 并发闸src/services/worker/RateLimitStore.tssrc/supervisor/process-registry.tsshouldAbortForQuotaglobalRateLimitStoreisApiKeyAuthwaitForSlot
SDK 隔离选项src/sdk/hardened-options.tsbuildHardenedSdkOptions