同一个会话,终端和浏览器同时开着会发生什么

我平时的用法是在终端里跑长任务,然后开浏览器看进度。同一个会话,两个界面同时在刷。

这件事听起来只要把事件广播出去就行,实际写起来全是边角:浏览器刷新了怎么办、点了停止为什么停不下来、一轮跑到一半崩了历史会不会断、两个界面同时发消息谁赢。

这篇写 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。

graph TD A[&#34;引擎要执行一个需要确认的操作&#34;] --> B[&#34;createDecisionWaiter<br/>挂进 waiters 表&#34;] B --> C[&#34;publish confirm_request<br/>前端画确认卡&#34;] C --> D{&#34;哪个先来&#34;} D -->|用户点允许| E[&#34;HTTP 回填 resolve<br/>引擎继续执行&#34;] D -->|用户点停止| F[&#34;signal abort 触发 reject<br/>引擎收到决策取消&#34;] D -->|30 分钟无人应答| G[&#34;定时器 reject<br/>引擎收到超时&#34;] E --> H[&#34;finish:清定时器<br/>摘 abort 监听<br/>从 waiters 移除&#34;] F --> H G --> H H --> I[&#34;引擎把结果转成工具返回值<br/>不再永久悬挂&#34;]

几个细节。

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,专门用于中止时的配对。

中止时要把工具调用的账补齐

这是最容易被忽略的一处。

graph TD A[&#34;turn_start&#34;] --> B[&#34;累积 turnText 与 turnCalls&#34;] B --> C[&#34;每个 tool_result 到达<br/>id 记入 answeredCalls&#34;] C --> D{&#34;这一轮怎么结束&#34;} D -->|正常结束| E[&#34;saveTurnAssistant<br/>写一条 assistant,带本轮 toolCalls&#34;] D -->|中止或异常| F[&#34;pairUnansweredCalls<br/>给没结果的调用补占位 tool 消息&#34;] F --> E E --> G[&#34;beginTurnIfSaved<br/>清空累计,进入下一轮&#34;]

模型输出了三个工具调用,第一个执行完,第二个执行到一半用户点了停止。此刻数据库里有什么?

一条 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。想看清楚三个端怎么共用一套事件,把这三个文件并排看最快。

相关推荐
咚咚咚小柒4 小时前
【前端】LangChain.js学习
前端·javascript·人工智能·typescript·langchain·vue·ai编程
螺丝钉tina9 小时前
[Playwright][01] 从 Puppeteer 到 Playwright:为什么选择 Playwright + TypeScript?
前端·javascript·typescript
miofly1 天前
GitHub 今日推荐|rea:用 MCP 把反编译器和 AI agent 接到一起的逆向工具
typescript·开源·github
ZoeLandia1 天前
React11:数据请求与 TanStack Query
前端·react.js·typescript·react
康小旺2 天前
AI 写 Vue 总写成同一个味道:给它装 Skills,装的不是知识是边界
vue.js·typescript
ZoeLandia2 天前
React08:自定义 Hook
开发语言·前端·react.js·typescript
aloha_7893 天前
HTML、JavaScript 与 TypeScript 基础速查
javascript·typescript·html
离歌漠4 天前
《我用 Tauri 做了一个本地开发工具箱》
rust·typescript·vue
enjoy编程4 天前
playwright学习-I
typescript·vue·nodejs·1024程序员节·playwright