fix(plugins): 409 时当场表态 + DSH 补投按会话串行

**409 = 永远不会成功**(没有人类可路由)。原来三个插件都在失败时让位给
平台本地 UI —— 但邮件驱动的会话**没有 TUI**,让位之后 waterfall 跑到尾
依旧无人应答,仍是无声挂死。

HTTP 客户端必须把 err.status 与 err.body 挂到 error 上:只看 message
字符串分不出「暂时失败(502,该重试)」与「永远不会成功(409)」,
两种都会被当成前者,而前者会永久挂住会话。

三平台表态方式不同但语义统一:
- opencode: output.status = "deny" + output.reason 带服务端原文
- dsh: return 'rejected'(ApprovalOutcome 只认 allowed-once/rejected/
  cancelled,写 'denied' 不报错而是被当未知值静默失效)
- pi: return { block: true, reason }

其余失败(502 等)保持原行为,让位本地 UI。

---

**DSH 补投并发**(同一文件,故并入本次提交)

生产日志:`补投 5 封(共 16 封未读)`,9 秒后三封失败
`message "undefined" is already pending`。串行 for...of 并未真正串行 ——
awaitFirstTurn 在**首个 token** 就放行,turn 尚未结束下一封已 followup。

新增 waitForTurnEnd(等 turn/end 而非首 chunk)与 sessionLocks/locked()
按会话串行化。live-agent 路径原来直接 followup 就返回,现在也进锁。
120s 超时兜底,模型完全无响应时不会把后续邮件永久卡住。

权限场景下锁会持有到人类决策完 —— 这是正确行为:两封都需要授权时
第二封排队,比同时弹两个授权请求更合理。

顺带把 rename-proposal 纳入 check-shared-libs.sh 的同源校验。
This commit is contained in:
2026-09-03 21:10:48 +08:00
parent edb4bd94fd
commit 2996f9af9c
16 changed files with 1156 additions and 81 deletions

View File

@ -34,6 +34,9 @@ import {
shouldSkipAutoRelay,
} from "./lib/relay-dedup.js";
import { appendRenameProposal, renameProposalNote } from "./lib/rename-proposal.js";
// opencode 原生支持三态权限免批由它自己记response:"always"
// 所以这里只借用决策文本的判定,不需要 createGrantStore。
import { isAlwaysDecision, isApproval } from "./lib/permission-grants.js";
const GATEWAY_URL = process.env.AGENTMAIL_GATEWAY_URL || "http://127.0.0.1:8180";
const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || "opencode";
@ -124,7 +127,16 @@ async function apiPost(path, body) {
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `POST ${path} failed: ${res.status}`);
if (!res.ok) {
// 把状态码与响应体挂在 error 上:调用方需要区分「暂时失败」与
// 「永远不会成功」。具体例子:权限询问碰到 409任务链上没有人类
// 必须当场 deny而 502 应该保持 ask 等重试 —— 只看 message 字符串
// 分不出这两种,于是两种都会被当成后者,而前者会永久挂住会话。
const err = new Error(data.error || `POST ${path} failed: ${res.status}`);
err.status = res.status;
err.body = data;
throw err;
}
return data;
}
@ -460,6 +472,11 @@ const readMailTool = {
//
// relay_key 用 opencode 的 permission.id 做幂等键permission.updated 会重复触发,
// 插件重连也会重放,没有它同一次询问会生成好几封邮件。
//
// **不传 `to`**:决策人由服务端定(会话 owner → 线索里最近的人类 → 无人可问则 409
// 插件若把来信人当决策人Agent 之间转派任务时权限邮件会发给 Agent 自己 ——
// Agent 不可能在界面上点「同意」,服务端的用户推送也投进一个不存在的通道,
// 那条会话于是无声挂死pi 侧真实发生过)。
async function relayPermission({ question, options, context, relayKey }) {
return apiPost("/permission/request", {
question,
@ -745,9 +762,15 @@ async function replyPermission(client, directory, data) {
}
const decision = String(data.decision || "");
const response =
decision === "一直同意" || decision === "always" ? "always" :
decision === "拒绝" || decision === "reject" ? "reject" : "once";
// 三态映射,且**认不出的一律 reject**fail closedN-9
//
// 这里原先是 `... : "once"` —— 兜底落在放行一侧。那意味着任何意外文本
// (空串、历史数据里的旧选项、将来服务端新增的选项)都会放行一次
// 没人批准的危险操作。判断顺序也重要always 必须先判,因为 isApproval
// 对「一直同意」同样为真。
const response = isAlwaysDecision(decision) ? "always"
: isApproval(decision) ? "once"
: "reject";
await client.postSessionIdPermissionsPermissionId({
path: { id: sessionID, permissionID: permID },
@ -1107,9 +1130,31 @@ export default async function mailBridge(input) {
});
console.error(`[mail-bridge] 权限询问已转邮件 ${input.id}${input.type}`);
} catch (e) {
// 转不出去就别让 opencode 挂在那儿等:保持 ask 让本地机制接管TUI 弹窗)
console.error("[mail-bridge] 权限询问转发失败:", e?.message || e);
pendingPermissions.delete(input.id);
// 409 = 这条任务链上没有人类,永远不会有人来点头。
//
// 必须当场 deny保持 "ask" 等于把会话交给本地 TUI 弹窗,
// 而邮件驱动的会话根本没有 TUI —— 模型会永久挂在那里。
// 这正是生产事故的形状pi 把任务派给自己的另一条会话,
// 那条会话要跑 bash权限邮件无人可投整条线索卡死。
//
// deny 的同时把服务端的建议原文带给模型,它才知道下一步该换什么做法。
if (e?.status === 409 && e?.body?.suggestion) {
console.error(`[mail-bridge] 权限询问无人可投,当场拒绝 ${input.id}${e.body.error || ""}`);
output.status = "deny";
// opencode 把 reason 作为工具报错回给模型
output.reason = [
e.body.error || "权限询问无法送达:该任务链上没有人类用户",
e.body.detail || "",
e.body.suggestion || "",
].filter(Boolean).join("\n");
return;
}
// 其余失败网络抖动、Gateway 重启)保持 ask那些是暂时的
// 人仍可能在本地看到弹窗,不该把一次抖动当成永久拒绝。
console.error("[mail-bridge] 权限询问转发失败:", e?.message || e);
return;
}
output.status = "ask";