fix(dsh-bridge): 新开会话登记工作区,workspaceOf 加淘汰兜底

read_inbox 在**新开会话**里恒 400("缺少 workspace"),而 read_mail /
read_thread / list_contacts / session_participants 都不受影响。

## 根因

`workspaceOf()` 取不到工作区就返空,URL 不带 `&workspace=`,服务端 400
(`server/internal/handler/mail.go:626`,用户裁定:不带 workspace 是错误
发件格式)。而 `sessionWorkspace` 只有两个写入点 —— adopt(bindAdopted)
与确定性 id 恢复 —— **新开会话分支漏了**。

漏了之后只有两种情况能发现:那条会话后来恰好走过另外两条绑定路径,
或者重启后 `sessionWorkspace`(500 条上限)把它淘汰掉。

## 改法

1. 新开会话分支补 `sessionWorkspace.set(attemptSessionId, sessionCwd)`。

   ★ 取值必须是 `header.cwd`,**不是那个 `cwd`**:后者来自
   `resolveWorkspaceCwd`,`to_workspace` 不可用时回退到
   `mailSessionFallback` → `~/.dsh/mail-sessions/mail-<uuid>`,每次邮件
   都不同。拿它登记会把收件箱收窄到一个**永远读不到信**的目录 ——
   比 400 更坏,因为 400 至少是可见的错误。同一文件下方工作区注册那段
   (`actualCwd`)也是从 header 取,两处保持同源。

2. `workspaceOf()` 读侧兜底:反查 `sessionMap` 的 `directory`。

   补在读侧而不是写侧,因为淘汰是**事后**发生的:补写站点只能覆盖
   「我这次走过」,被淘汰的键下次谁来读都读不到。反查的 `directory`
   在两条绑定路径上都是真实 cwd(adopt 走 `persistedCwd` 读磁盘
   header),不是兜底目录。

## 影响面

`read_inbox` 的两维收窄(session_id + workspace)是**防越界读**的机制
(2026-09-14 用户报的越界)。这个改动让更多会话能拿到 workspace,因此
**可见范围确实变宽**了 —— 这是有意的(原本是读不到,不是读得少),
但复核时应当盯住这一条。

## 测试

新增 test/workspace-of-fallback.test.mjs(7 项)。其中 5 项把
`workspaceOf` 的真函数体抠出来在真 BoundedMap 上跑(不复制一份实现,
否则源文件改了测试还在绿)。已做变异验证:删掉读侧兜底 → 3 项红;
删掉写入点 → 2 项红。

dsh 桥 414 项全绿(改前 407,无回归)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 09:26:15 +08:00
parent c2796eaa52
commit 9985c41b13
2 changed files with 205 additions and 4 deletions

View File

@ -937,12 +937,32 @@ export function apply(ctx: any, config: PluginConfig): void {
* 取不到返回空串(= 退回整个 Agent 的收件箱)。宁可退回旧行为,也不猜 ——
* 猜错会把别人会话的未读标掉,那是静默丢信。
*/
/** 这次调用所属会话的工作区(与 mailSessionOf 同一把键)。拿不到返回空串 —— 那时
* 收件箱请求会 400,是刻意的:错误可见,好过静默跨工作区拿到别处的信。 */
/**
* 这次调用所属会话的工作区(与 mailSessionOf 同一把键)。拿不到返回空串 —— 那时
* 收件箱请求会 400,是刻意的:错误可见,好过静默跨工作区拿到别处的信。
*
* ★ 兜底:反查 `sessionMap` 里的 `directory`。
*
* `sessionWorkspace` 与 `sessionMap` 是**两张独立有界表**(各 500 条,
* `MAX_TRACKED_SESSIONS`),淘汰的进度也就各走各的。写站点少一处的后果
* 是**可复现**的:`sessionMap` 命中 ⇒ 走复用分支 ⇒ `sessionWorkspace` 永远
* 不会被这条路径补上,于是收件箱恒 400。
*
* 补在**读侧**而不是写侧,是因为淘汰是**事后**发生的:补写站点只能覆盖
* 「我这次走过」,被淘汰的键下次谁来读都读不到。反查的 `directory` 在两条
* 绑定路径上都是真实 cwd(adopt 走 `persistedCwd` 读磁盘 header,新开走
* 会话 header),不是 `resolveWorkspaceCwd` 的兜底目录 —— 那是下面
* 那个 `mail-sessions/` 陷阱,见新开会话分支的注释。
*/
function workspaceOf(exec: any): string {
const dshSessionId = exec?.agent?.id ?? exec?.agent ?? '';
const dshSessionId = String(exec?.agent?.id ?? exec?.agent ?? '');
if (!dshSessionId) return '';
return sessionWorkspace.get(String(dshSessionId)) ?? '';
const direct = sessionWorkspace.get(dshSessionId);
if (direct) return direct;
for (const bound of sessionMap.values()) {
if (bound.dshSessionId === dshSessionId && bound.directory) return bound.directory;
}
return '';
}
function mailSessionOf(exec: any): string {
@ -1235,6 +1255,23 @@ export function apply(ctx: any, config: PluginConfig): void {
sessionMap.set(mailSessionID, { dshSessionId: attemptSessionId, directory: cwd });
reverseMap.set(attemptSessionId, mailSessionID);
mailDrivenSessions.add(attemptSessionId);
/* 会话自己的 cwd 就是它的工作区 —— read_inbox 的收窄要用它。
*
* 这一处原先**漏了**,后果是新开会话里 `read_inbox` 恒 400
* (`workspaceOf` 返空 ⇒ URL 不带 workspace ⇒ 服务端 400)。
* 漏了之后只有两条路能发现:重启后 `sessionWorkspace` 被淘汰,
* 或者那条会话恰好走过 adopt / 确定性 id 恢复分支。
*
* ★ 取值必须是 `header.cwd`,**不能用上面那个 `cwd`**:后者是
* `resolveWorkspaceCwd` 的结果,`to_workspace` 不可用时是兜底目录
* (`mailSessionFallback` → `~/.dsh/mail-sessions/mail-<uuid>`),
* 每次邮件都不同。拿它登记会把收件箱收窄到一个**永远读不到信**的
* 目录 —— 比 400 更坏,因为 400 至少是可见的错误。
* 与下面工作区注册那段同源,两处必须一致。 */
const sessionCwd = String(handle.agent?.session?.header?.cwd ?? '');
if (sessionCwd) sessionWorkspace.set(attemptSessionId, sessionCwd);
mailContexts.set(mailSessionID, {
replyTo: data.from_name || '',
subject: data.subject || '',