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

@ -51,6 +51,8 @@ import {
renderThread,
} from '../lib/discovery.js';
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
// 只用 isApprovalDSH 没有 always 语义,免批授权表在这里用不上(见决策处的注释)。
import { isApproval } from '../lib/permission-grants.js';
// ─── 凭证管理 ───
@ -108,7 +110,15 @@ class GatewayClient {
body: JSON.stringify(body),
});
const data = await res.json() as any;
if (!res.ok) throw new Error(data?.error || `POST ${path} failed: ${res.status}`);
if (!res.ok) {
// 状态码与响应体挂在 error 上:调用方要区分「暂时失败」与「永远不会成功」。
// 权限询问碰到 409任务链上没有人类必须当场拒绝
// 而 502 应该重试 —— 只看 message 字符串分不出这两种。
const err: any = new Error(data?.error || `POST ${path} failed: ${res.status}`);
err.status = res.status;
err.body = data;
throw err;
}
return data;
}
@ -427,13 +437,12 @@ export function apply(ctx: any, config: PluginConfig): void {
if (event?.type === 'assistant/chunk') {
const chunk = event.data?.chunk;
if (chunk?.type === 'finish') {
// finish 是这一步的收尾,可能成功也可能失败
if (chunk.reason?.kind === 'error') {
return finish({ ok: false, error: describe(chunk.reason.failure) });
}
return; // 正常收尾,等 turn/end 定论
return; // finish 收尾,等 turn/end
}
// 其余 chunk 类型 = 模型真的在产出内容
// assistant/chunk 模型在产出内容 → 模型确实活着,算 OK
return finish({ ok: true });
}
@ -442,13 +451,47 @@ export function apply(ctx: any, config: PluginConfig): void {
if (reason?.kind === 'error') {
return finish({ ok: false, error: describe(reason.error) });
}
// 正常结束completed/canceled也算走通了 —— 有些轮次不产出 chunk
return finish({ ok: true });
}
});
});
}
/**
* 等待当前轮次结束后再投递下一封。
*
* 原来 catchUp 用 awaitFirstTurn首 token 即放行),同一会话的多封邮件在
* 补投时全部撞进同一个 turn → 「message undefined is already pending」。
* 这个函数等 turn/end且对同一会话串行化杜绝并发 followup。
*/
async function waitForTurnEnd(agent: any, timeoutMs = 120_000): Promise<void> {
await new Promise<void>((resolve) => {
let done = false;
const finish = () => { if (!done) { done = true; clearTimeout(timer); dispose?.(); resolve(); } };
const timer = setTimeout(finish, timeoutMs);
const dispose = ctx.on('session/event', (session: any, event: any) => {
if (session !== agent.session) return;
if (event?.type === 'turn/end') finish();
});
});
}
/** 按会话锁串行化 followup同一会话同一时刻只跑一轮。 */
const sessionLocks = new Map<string, Promise<void>>();
async function locked<T>(dshSessionId: string, fn: () => Promise<T>): Promise<T> {
const prev = sessionLocks.get(dshSessionId) ?? Promise.resolve();
let release!: () => void;
const next = new Promise<void>((r) => { release = r; });
sessionLocks.set(dshSessionId, next);
try {
await prev;
return await fn();
} finally {
release();
if (sessionLocks.get(dshSessionId) === next) sessionLocks.delete(dshSessionId);
}
}
// ─── 建会话(磁盘上已有则 resume───
/**
@ -534,20 +577,27 @@ export function apply(ctx: any, config: PluginConfig): void {
if (existing) {
const live = ctx.agents.get(existing.dshSessionId);
if (live) {
const promptText = kind === 'permission'
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`
: [
`本会话收到一封新邮件AgentMail 续谈)。`,
``,
`发件人:${data.from_name || 'unknown'}`,
`主题${data.subject || '(无主题)'}`,
`邮件 ID${data.mail_id || 'unknown'}`,
``,
`请先调用 read_inbox 读取完整正文,然后处理其中的请求。`,
`回信不用你自己发:把这一轮做完、把结论说出来就行`,
].join('\n');
live.followup(userMessage(promptText));
return { sessionID: existing.dshSessionId, reused: true };
return locked(existing.dshSessionId, async () => {
const promptText = kind === 'permission'
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`
: [
`本会话收到一封新邮件AgentMail 续谈)。`,
``,
`发件人${data.from_name || 'unknown'}`,
`主题${data.subject || '(无主题)'}`,
`邮件 ID${data.mail_id || 'unknown'}`,
``,
`请先调用 read_inbox 读取完整正文,然后处理其中的请求`,
`回信不用你自己发:把这一轮做完、把结论说出来就行。`,
].join('\n');
live.followup(userMessage(promptText));
// 等 turn/end 而不是立即返回:这封邮件的轮次未结束时投递下一封,
// 会让 DSH 报 "message already pending"。串行化靠 locked() 保证
// 同一时刻只有一个 followup 在跑,两个锁互斥 —— 即使 turn/end
// 未出现比如模型完全没响应120s 超时兜底不会把后续邮件永久卡住。
await waitForTurnEnd(live);
return { sessionID: existing.dshSessionId, reused: true };
});
}
}
@ -1239,26 +1289,51 @@ export function apply(ctx: any, config: PluginConfig): void {
// DSH 不给询问发 id用 (会话, 工具, callId) 做幂等键。
const relayKey = `${agentId}:${req.toolName}:${req.callId ?? 'nocall'}`;
const mctx = mailContexts.get(mailSessionID);
try {
// **不传 `to`**:决策人由服务端定(会话 owner → 线索里最近的人类 →
// 无人可问则 409。插件若把来信人当决策人Agent 之间转派任务时
// A 把活分给 B权限邮件会发给 Agent 自己 —— Agent 不可能在界面上点
// 「同意」,于是下面那个 await 永不 resolve会话无声挂死。
await client.post('/permission/request', {
question: `请求执行 ${req.toolName}`,
options: ['同意', '拒'],
options: ['同意', '拒'],
context: [
`工具:${req.toolName}`,
req.callId ? `调用 ID${req.callId}` : '',
req.reason ? `理由:${req.reason}` : '',
// 决策人未必是这条会话的参与者Agent 转派出来的会话,人从没见过它),
// 只给工具名无从判断得说明这活是谁派的、为的什么事B-8.4)。
mctx?.subject ? `触发任务:${mctx.subject}` : '',
mctx?.replyTo ? `任务来自:${mctx.replyTo}` : '',
].filter(Boolean).join('\n'),
session_id: mailSessionID,
relay_key: relayKey,
});
} catch (e: any) {
// 转不出去就别把 DSH 挂在那儿等:交给下一个 answerer本地 UI接管
ctx.logger.error(`[dsh-mail-bridge] 权限询问转发失败: ${e?.message || e}`);
// 409 = 服务端已判定这条任务链上没有人类,永远不会有人来点头
//
// 不能 `return next()`:下一个 answerer 是本地 UI而邮件驱动的会话
// 根本没有 UIwaterfall 跑到尾以后依旧无人应答 —— 这正是生产事故
// 的形状pi 把任务派给自己的另一条会话,那条要跑 bash会话永久挂死。
//
// 直接 denied 并把服务端的建议原文写进日志:模型从工具报错里看到
// 拒绝后会自己换方式,而挂死时它连重试的机会都没有。
if (e?.status === 409) {
const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion]
.filter(Boolean).join(' ');
console.error(`[dsh-mail-bridge] 权限询问无人可投,当场拒绝 ${relayKey}${hint}`);
// 用 'rejected' 而不是 'denied'DSH 的 ApprovalOutcome 只认
// allowed-once / rejected / cancelled写错了它不报错而是当成未知值处理。
return 'rejected';
}
// 其余失败网络抖动、Gateway 重启)是暂时的,交给下一个 answerer本地 UI
console.error(`[dsh-mail-bridge] 权限询问转发失败: ${e?.message || e}`);
return next();
}
ctx.logger.info(`[dsh-mail-bridge] 权限询问已转邮件 ${relayKey}`);
console.error(`[dsh-mail-bridge] 权限询问已转邮件 ${relayKey}`);
// 等人类决策DSH 撤销询问signal abort时结算为 cancelled。
return new Promise<string>((resolve) => {
@ -1277,8 +1352,15 @@ export function apply(ctx: any, config: PluginConfig): void {
pendingApprovals.delete(relayKey);
// AgentMail 的选项文本 → DSH 的 ApprovalOutcome。
// 只有“同意”才放行,其余(包括认不出的选项)一律 fail closed。
//
// **DSH 不提供「一直同意」**:它的 ApprovalOutcome 只有
// allowed-once / rejected / cancelled / unavailable没有 always 语义
// (见 @deepseek-ai/dsh-user-approval 的类型定义)。桥自己记免批的话,
// DSH 侧仍会每次调 approval/request而桥直接答 allowed-once ——
// 那等于用插件内存覆盖平台的审批策略,且这份策略没人能审计。
// 因此这里的选项只有两个(见上面的 optionsisApproval 就够用。
const decision = String(data?.decision ?? '');
const outcome = /^(同意|allow|approve|yes)/i.test(decision) ? 'allowed-once' : 'rejected';
const outcome = isApproval(decision) ? 'allowed-once' : 'rejected';
pending.resolve(outcome);
ctx.logger.info(`[dsh-mail-bridge] 权限决策 ${relayKey} -> ${outcome}`);
return;