我平时的用法是在终端里跑长任务,然后开浏览器看进度。同一个会话,两个界面同时在刷。
这件事听起来只要把事件广播出去就行,实际写起来全是边角:浏览器刷新了怎么办、点了停止为什么停不下来、一轮跑到一半崩了历史会不会断、两个界面同时发消息谁赢。
这篇写 SessionHub,也就是 CLI、Web、Desktop 三个端共用的那层会话桥。
它其实只做三件事
SessionHub 的职责可以压成三行:
scss
send(sessionId, input) 组装上下文 → 跑引擎 → 事件扇出 + 落库
subscribe(sessionId, fn) 给 SSE 挂订阅,返回退订函数
decision(...) 等人决策的等待,HTTP 端点负责回填
三个端自己不实现任何引擎逻辑,全都调 send。差别只在渲染:CLI 用 Ink 画卡片,Web 用 Vue 渲染消息流,Desktop 是套壳的 Web。
所以"同一个会话两边都在刷"是天然成立的,因为两边订阅的是同一个事件流。
typescript
subscribe(sessionId: string, fn: (ev: HubEvent) => void): () => void {
let set = this.subs.get(sessionId);
if (!set) {
set = new Set();
this.subs.set(sessionId, set);
}
set.add(fn);
return () => {
set!.delete(fn);
if (set!.size === 0) this.subs.delete(sessionId);
};
}
一个会话一个订阅者 Set,返回的退订函数会在 SSE 连接断开时调用。Set 空了就把这个 key 删掉,否则会话开得多了,这个 Map 只增不减。
扇出本身很短:
typescript
private publish(sessionId: string, ev: HubEvent): void {
for (const fn of this.subs.get(sessionId) ?? []) {
try {
fn(ev);
} catch {
// 订阅者异常不影响其他订阅者
}
}
}
关键在 try。一个订阅者的回调抛错(前端写 SSE 时序列化失败、连接已经关了还在写),不能让循环中断,否则后面所有订阅者都收不到这一条事件。
代价是错误被吞了。这个取舍我认:事件流的可用性比单个订阅者的错误可观测性重要。真要看错误,日志那边有。
点「停止」停不下来的那两天
这是这个模块里最值得说的一个 bug。
确认卡的实现最初是这样的:
typescript
// 简化后的初版,有问题
function waitForConfirm(key: string): Promise<boolean> {
return new Promise((resolve) => {
waiters.set(key, resolve);
});
}
一个只 resolve 的 Promise。用户点确认,HTTP 端点找到这个 waiter 调 resolve,流程继续。
问题出在用户不点确认、直接点停止的时候。
引擎那边拿到的是 await waitForConfirm(...),这个 await 永远不会返回。abort 信号传进来了,但没人在听。于是:
- 引擎卡在这个 await 上,
finally永远不执行 running标记清不掉- 界面上按钮变成"停止中",但一直停不下来
- 再发消息返回 409
session is already running
只能等 30 分钟后僵尸清理把它清掉。
根因是"等人决策"这个 Promise 没有失败路径。它假设了用户一定会回答,但用户直接关页面呢,直接点停止呢,网络断呢。
改完之后是 createDecisionWaiter:
typescript
export function createDecisionWaiter<T>(
kind: DecisionKind,
signal: AbortSignal,
opts: {
timeoutMs?: number;
register?: (e: DecisionWaiter) => void;
unregister?: (e: DecisionWaiter) => void;
} = {},
): Promise<T> {
return new Promise<T>((resolve, reject) => {
let settled = false;
let timer: NodeJS.Timeout | undefined;
let entry!: DecisionWaiter;
const onAbort = () => entry.reject(new Error('运行已中止,等待用户决策取消'));
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
opts.unregister?.(entry);
fn();
};
entry = {
kind,
resolve: (v) => finish(() => resolve(v as T)),
reject: (e) => finish(() => reject(e)),
};
timer = setTimeout(
() => entry.reject(new Error(`等待用户决策超时(${kind} 等待超过 ${Math.round((opts.timeoutMs ?? DECISION_TIMEOUT_MS) / 60_000)} 分钟未响应)`)),
opts.timeoutMs ?? DECISION_TIMEOUT_MS,
);
timer.unref?.(); // 超时定时器不阻止进程退出
signal.addEventListener('abort', onAbort, { once: true });
opts.register?.(entry);
});
}
三个出口:正常 resolve、超时 reject、abort reject。
几个细节。
settled 标志。三条路可能同时触发:用户点确认的同时点停止,或者超时和 abort 撞上。finish 里第一行判断,已经 settle 过就直接返回,不会重复 resolve 也不会重复 unregister。
signal.addEventListener('abort', onAbort, { once: true })。once 是必须的,不然长时间运行里 abort 监听器会一直累积。
timer.unref()。这个定时器是 30 分钟,如果不 unref,Node 进程会因为这个待定的定时器而不退出。CLI 里跑完任务进程却挂着不关,就是这个原因。
reject 之后引擎侧怎么处理?不是抛给用户看,是转成一个工具错误结果:
typescript
// 引擎收到的结果大致是
{ ok: false, code: 'decision_aborted', content: '运行已中止,等待用户决策取消' }
模型看到这个会知道"这个操作没做成",然后收尾。整条链路是通的。
僵尸运行:abort 穿透不了的地方
修完上面那个 bug,还有一类清理不掉的情况。
typescript
/**
* 僵尸运行清理:引擎可能卡死在不可中断的 await 上(如网关流挂死),
* abort 信号无法穿透 → finally 不执行 → running 永不清理 → 会话被永久锁死(发消息 409)。
* 判定:AbortController 已中止(用户点过停止但收尾未完成)或运行超 30 分钟。
*/
private zombieReap(sessionId: string): boolean {
const entry = this.running.get(sessionId);
if (!entry) return false;
const stale = entry.ac.signal.aborted || Date.now() - entry.startedAt > 30 * 60_000;
if (!stale) return false;
this.running.delete(sessionId);
try {
entry.ac.abort(); // 确保 engine 侧信号也置位
} catch {
// 已 abort 的 Controller 重复 abort 不抛错,防御性忽略
}
this.publish(sessionId, { type: 'run_error', message: '运行已超时/中止,运行状态已重置(僵尸运行清理)' });
this.publish(sessionId, { type: 'state', running: false });
return true;
}
典型场景:SSE 流挂住了。TCP 连接没断,但对面一个字节都不发,也不关。我这边有超时(前面写过三层超时),但超时逻辑本身要走一次 reader.cancel(),如果 cancel 也卡住,这个 await 就永远不返回。
abort 信号发出去了,但代码根本没走到检查信号的地方。
所以需要一个兜底:只要 Controller 已经 aborted,或者跑了超过 30 分钟,就强制认为它是僵尸,把 running 删掉。
判定放在读取处,不是定时器:
typescript
isRunning(sessionId: string): boolean {
return this.zombieReap(sessionId) ? false : this.running.has(sessionId);
}
每次问"这个会话在跑吗"的时候顺手检查一次。这样不需要额外的定时任务,也不会漏。
send 入口也是同样的处理:
typescript
const prev = this.running.get(sessionId);
if (prev) {
// 僵尸自愈:上次运行卡死未收尾(已中止或超时)时清理后继续,而不是永久 409 锁死会话
if (!this.zombieReap(sessionId)) throw new Error('session is already running');
}
僵尸自愈。用户点发送,发现上次是僵尸,清掉接着跑,不报错。这个体验比"请稍后再试"好得多。
一轮到底什么时候落库
这块是我重构过一次的地方,旧实现有个隐蔽的 bug。
typescript
// 按"轮"落库:一轮 = 一次模型响应(文本 + 本轮工具调用)。
// 旧实现跨轮累积 calls 且 done 时丢 calls,产生重复 assistant 行 / 孤立 tool 消息。
let turnText = '';
let turnCalls: ToolCall[] = [];
let turnSaved = false;
const answeredCalls = new Set<string>();
const saveTurnAssistant = () => {
if (turnSaved) return;
if (!turnText && turnCalls.length === 0) return;
service.appendMessage(sessionId, {
role: 'assistant',
content: turnText,
toolCalls: [...turnCalls],
});
turnSaved = true;
};
"一轮"的定义是:一次模型响应,包含它输出的文本和它声明的所有工具调用。
旧实现是跨轮累积 turnCalls,然后在 done 事件时一次性写一条。问题有两个:
一是 done 事件里拿到的 calls 是最后一轮的,前面几轮的丢了。结果是 assistant 消息里有 tool_calls,但对应的 tool 消息找不到归属,历史变成一堆孤立的 tool 行。
二是跨轮累积会让同一条 assistant 消息重复出现。
现在的做法是每轮结束就写,turnSaved 标记这一轮写过没有,beginTurnIfSaved 在下一轮开始时清空累计:
typescript
const beginTurnIfSaved = () => {
if (turnSaved) {
turnText = '';
turnCalls = [];
turnSaved = false;
answeredCalls.clear();
}
};
answeredCalls 记录本轮已经收到结果的工具调用 id,专门用于中止时的配对。
中止时要把工具调用的账补齐
这是最容易被忽略的一处。
模型输出了三个工具调用,第一个执行完,第二个执行到一半用户点了停止。此刻数据库里有什么?
一条 assistant 消息,tool_calls 里三个 id;一条 tool 消息,对应第一个 id。第二个和第三个没有 tool 消息。
下一次请求把这段历史发出去,网关会直接 400:tool_use 后面必须紧跟对应的 tool_result,少一个都不行。
所以中止时要补:
typescript
const pairUnansweredCalls = () => {
for (const call of turnCalls) {
if (answeredCalls.has(call.id)) continue;
answeredCalls.add(call.id);
service.appendMessage(sessionId, {
role: 'tool',
content: '(本轮执行被中止,工具调用未返回结果)',
toolCallId: call.id,
});
}
};
给没结果的调用补一条占位 tool 消息,内容是"本轮执行被中止",带正确的 toolCallId。这样历史是完整的,下一轮能正常发出去。
用户在界面上看到的是"这个工具没返回结果",而不是"会话坏了"。
这个坑我在联调阶段遇到过好几次,表现是:点停止之后再发消息,报一个莫名其妙的 400,内容大意是消息序列不合法。一开始以为是我拼历史拼错了,后来发现是中止路径根本没写配对逻辑。
用户消息必须在 try 里面
typescript
try {
// 项目根目录必须存在:无效 root 会让文件类工具全部 ENOENT、模型陷入无效重试循环
if (!fs.existsSync(project.root_path)) {
throw new Error(`项目根目录不存在:${project.root_path}(请在项目管理中移除该项目后重新添加正确路径)`);
}
...
// 用户消息立即落库(即使 Provider 解析失败也不丢输入)。
// 注意:必须位于 try 内------早期失败(DB 异常等)也要走 catch → run_error 推送,
// 否则异常会被 HTTP 层的 .catch(() => undefined) 吞掉,前端表现为"运行直接停止"。
service.appendMessage(sessionId, userMessage);
注释写得比较长,因为这件事踩过一次。
HTTP 层是 fire-and-forget 的:
typescript
void hub.send(sessionId, input, images).catch((e) => {
// run 前置失败(session/project 校验等):日志留痕,前端经 state running:false 收尾
console.error('[hub] send crashed before run_error publish:', (e as Error).message);
});
为什么要 catch:响应是 SSE,头已经发出去了,202 也回了,再抛错没地方送。
但 catch 里只打日志,前端收不到任何东西。如果 send 在 try 之前就抛了(会话被删、项目被删、数据库异常),界面上看到的就是"点了发送,转了一下,然后停了",原因只在终端日志里。
所以 try 的范围必须包住所有可能失败的操作,让错误走 catch,由 catch 统一 publish 一条 run_error,前端才有提示。
所以 try 的范围要包住所有可能失败的东西,让错误走 catch,catch 里统一 publish({ type: 'run_error' })。
用户消息写入也在 try 里,位置在 provider 解析之前。这样即使 provider 初始化失败(API key 没配、网关域名解析不了),你那句话也已经在库里了,不会因为一次失败白打一遍。
这个顺序看起来是小事,实际影响挺大:以前 provider 挂了,用户重新配好之后得重新打一遍问题。
三个端各画各的
事件类型是统一的。HubEvent 是引擎事件加桥接层自己的几种:
typescript
export type HubEvent =
| AgentEvent
| { type: 'confirm_request'; request: ConfirmRequest }
| { type: 'ask_user'; requestId: string; question: string; options?: string[] }
| { type: 'run_error'; message: string }
| { type: 'git_notice'; message: string }
| { type: 'state'; running: boolean; paused?: boolean };
AgentEvent 是引擎那头发的,二十来种,挑几个有代表性的:
typescript
| { type: 'turn_start'; turn: number }
| { type: 'text_delta'; text: string }
| { type: 'thinking_delta'; text: string }
| { type: 'tool_call_start'; call: ToolCall }
| { type: 'tool_result'; call: ToolCall; result: ToolResult; ms: number }
| { type: 'compact_start' }
| { type: 'compact_done'; beforeTokens: number; afterTokens: number }
| { type: 'budget_alert'; level: 0.5 | 0.8 | 1.0; usedTokenPct: number; usedCostPct: number }
| { type: 'plan_approval_request'; request: PlanApprovalRequest }
CLI 拿到 text_delta 就往当前行追加字符,拿到 confirm_request 就画一张卡片接管键盘。Web 拿到 text_delta 往 streamingText 里拼,等这一轮结束再落成一条正式消息。
两边共享的是"发生了什么",不是"长什么样"。这个边界划清楚之后,加第三个端(Desktop)几乎没写新逻辑,套个壳就行。
反过来说,如果有哪个事件在某一个端上表达不出来,说明事件设计有问题。比如"正在思考"这种纯 UI 状态,我没放进事件流,让它由 state: running 加本地计时器推导。放进去的话 CLI 和 Web 就得各自决定转多久,反而不一致。
没做好的
一是没有事件重放。浏览器刷新之后,SSE 重连,中间漏掉的事件就没了,只能靠重新拉一次消息列表补。运行中的流式文本会断一截,刷新后前半句不见了。
要做对得给事件编号,客户端带 Last-Event-ID 重连。协议层支持,但我没做,因为当前这个使用频率下,刷新丢半句的问题可以忍。
二是没有多端消息去重。两个浏览器窗口同时开着,都订阅同一个会话,看到的是同一份事件流,这个没问题。但如果你在一个窗口里发消息,另一个窗口的输入框不会清空,两边各留着半句话。目前靠用户自己注意。
三是 running 是内存态。进程重启,所有运行中的会话标记全丢,界面上全部变成"空闲",但实际那些任务已经死了。目前没有"重启后把上次未收尾的会话标成失败"的逻辑。
代码在 github.com/lwly/delta-...。这篇涉及:
- 会话桥、订阅扇出、僵尸清理、决策等待
packages/core/src/server/hub.ts - HTTP 端点与 SSE 长连接
packages/core/src/server/http.ts - 事件类型定义
packages/shared/src/index.ts
CLI 侧的消费在 packages/cli/src/ui/app.tsx,Web 侧在 packages/web/src/stores/chat.ts。想看清楚三个端怎么共用一套事件,把这三个文件并排看最快。