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:
@ -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 closed,N-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";
|
||||
|
||||
Reference in New Issue
Block a user