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:
@ -114,6 +114,9 @@ export class GatewayClient {
|
||||
if (!res.ok) {
|
||||
const err = new Error(data?.error || `POST ${path} 失败: HTTP ${res.status}`);
|
||||
err.status = res.status;
|
||||
// 响应体也带上:服务端对 409 会给 detail/suggestion,
|
||||
// 那些文字要原文转给模型(它据此决定换什么做法)。
|
||||
err.body = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
|
||||
@ -34,6 +34,7 @@ import { modelAttemptOrder, renderFailureReport, snapshotPiModels } from '../lib
|
||||
import { snapshotPiSessions } from '../lib/session-snapshot.js';
|
||||
import { selectCatchup } from '../lib/catchup.js';
|
||||
import { explicitSends, shouldSkipAutoRelay } from '../lib/relay-dedup.js';
|
||||
import { createGrantStore, isApproval } from '../lib/permission-grants.js';
|
||||
|
||||
// ─── 配置 ───
|
||||
|
||||
@ -60,6 +61,8 @@ const mailContexts = new Map(); // agentmail session_id -> { replyTo, subject,
|
||||
const relayedSummaries = new Map(); // pi session id -> 已转发过的 relay_key
|
||||
const syncedNames = new Map(); // pi session id -> 上次提交给 Gateway 的名字
|
||||
const pendingPermissions = new Map(); // relay_key -> { resolve, piSessionId }
|
||||
// 人点过「一直同意」的 (会话, 工具)。作用域与清理语义见 lib/permission-grants.js。
|
||||
const permissionGrants = createGrantStore();
|
||||
const deliveredMails = new Set(); // 已投过的 mail_id(SSE 与补拉共用,B-7.3)
|
||||
|
||||
let allowedModels = [];
|
||||
@ -140,23 +143,66 @@ function permissionExtension(getMailContext) {
|
||||
// 占着钩子不放会让人在 TUI 里干活时每一步都卡住等邮件。
|
||||
if (!mailSessionId) return;
|
||||
|
||||
// 人对这条会话的这个工具点过「一直同意」→ 直接放行,不再发邮件。
|
||||
// 这一步必须在 POST 之前:否则每条命令都生成一封邮件,人点过的
|
||||
// 「一直同意」形同虚设(实测同一条会话被问了 15 次 bash)。
|
||||
if (permissionGrants.isGranted(piSessionId, event.toolName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// relay_key 用 pi 给的 toolCallId(B-8.1):服务端会随决策事件回传它,
|
||||
// 桥重启丢了 pendingPermissions 也能对上(B-4.2)。自造随机 id 做不到。
|
||||
const relayKey = `${piSessionId}:${event.toolCallId}`;
|
||||
const ctxInfo = getMailContext(mailSessionId);
|
||||
|
||||
try {
|
||||
// **不传 `to`**(这里曾经传 `ctxInfo.replyTo`,那是个死锁 bug)。
|
||||
//
|
||||
// replyTo 是来信人的名字,而来信人可能是另一个 Agent —— Agent 把任务
|
||||
// 分派给自己的另一条会话时(pi→pi),权限邮件就发给了 `pi` 自己。
|
||||
// 后果是死锁而不是报错:Agent 不可能在 Web 界面上点「同意」,
|
||||
// 服务端的 SendToUser 又投进一个不存在的用户通道(没有任何人被提醒),
|
||||
// 于是下面那个 await 永不 resolve —— 会话永久挂死,没有超时、没有日志。
|
||||
//
|
||||
// 决策人交给服务端定:它按 会话 owner → 线索里最近的人类 → 无人可问则
|
||||
// 409 的顺序解析,那是唯一能看到整条线索的地方。插件只有本地那点上下文,
|
||||
// 猜不出「这条 Agent 链最初是谁派的活」。
|
||||
await client.post('/permission/request', {
|
||||
question: `是否允许执行 ${event.toolName}?`,
|
||||
options: ['同意', '一直同意', '拒绝'],
|
||||
context: describeToolCall(event),
|
||||
// 带上触发这次询问的来信(B-8.4)。决策人未必是这条会话的参与者 ——
|
||||
// Agent 转派出来的会话,人从没见过它,只给一句「是否允许执行 bash」
|
||||
// 是无从判断的:得知道这活是谁派的、为的什么事。
|
||||
context: [
|
||||
describeToolCall(event),
|
||||
ctxInfo?.subject ? `\n触发任务:${ctxInfo.subject}` : '',
|
||||
ctxInfo?.replyTo ? `任务来自:${ctxInfo.replyTo}` : '',
|
||||
].filter(Boolean).join('\n'),
|
||||
session_id: mailSessionId,
|
||||
to: ctxInfo?.replyTo || '',
|
||||
relay_key: relayKey,
|
||||
});
|
||||
} catch (e) {
|
||||
// 转发失败 → 让位给 pi 本地 UI(B-8.2)。返回 undefined 表示
|
||||
// 「这个钩子不表态」,pi 会走它自己的批准流程。
|
||||
// 409 = 服务端已判定这条任务链上没有人类,永远不会有人来点头。
|
||||
//
|
||||
// 不能「让位给本地 UI」:邮件驱动的会话没有 TUI,让位之后 pi 按默认
|
||||
// 策略处置,而默认策略在没有交互端时就是等 —— 又一次无声挂死。
|
||||
//
|
||||
// 直接 block 并把服务端的建议原文当作 reason:模型从工具报错里
|
||||
// 看到「这条链上没人可问,换不需要权限的方式」才能自己改道,
|
||||
// 而挂死时它连重试的机会都没有。
|
||||
if (e?.status === 409) {
|
||||
const b = e.body || {};
|
||||
const reason = [
|
||||
b.error || '权限询问无法送达:这条任务链上没有人类用户',
|
||||
b.detail || '',
|
||||
b.suggestion || '',
|
||||
].filter(Boolean).join('\n');
|
||||
log(`权限询问无人可投,当场拒绝 ${relayKey}:${b.error || ''}`);
|
||||
return { block: true, reason };
|
||||
}
|
||||
|
||||
// 其余失败(网络抖动、Gateway 重启)是暂时的 → 让位给 pi 本地 UI(B-8.2)。
|
||||
// 返回 undefined 表示「这个钩子不表态」,pi 会走它自己的批准流程。
|
||||
log(`权限转发失败,让位给本地决策: ${describeError(e)}`);
|
||||
return;
|
||||
}
|
||||
@ -168,7 +214,13 @@ function permissionExtension(getMailContext) {
|
||||
|
||||
// fail closed(B-9.2 / N-9):只有明确的同意才放行。
|
||||
// 关停时 shutdown() 会用 'shutdown' 唤醒所有等待者,落到这里的 else。
|
||||
if (/^(同意|一直同意|allow|approve|always|yes)/i.test(decision)) {
|
||||
if (isApproval(decision)) {
|
||||
// 「一直同意」要真的记住,否则这个选项是在骗人:人点了它,
|
||||
// 下一条命令照样来一封邮件。grant() 内部只认精确的 always 文本 ——
|
||||
// 「同意」是单次授权,把它当 always 会放行人没看过的后续命令。
|
||||
if (permissionGrants.grant(piSessionId, event.toolName, decision)) {
|
||||
log(`本会话的 ${event.toolName} 已获「一直同意」,后续不再询问`);
|
||||
}
|
||||
log(`权限 ${relayKey} 获批(${decision}),放行 ${event.toolName}`);
|
||||
return;
|
||||
}
|
||||
@ -483,6 +535,9 @@ async function deliverMail(data, kind, mailTools) {
|
||||
function rebind(mailSessionID, oldPiId, opened, cwd) {
|
||||
reverseMap.delete(oldPiId);
|
||||
mailDriven.delete(oldPiId);
|
||||
// 免批授权跟着旧的 pi 会话作废:它是人对**那次**上下文的判断,
|
||||
// 换模型意味着重开一条会话、重跑一遍提示,不该继承上一条的授权。
|
||||
permissionGrants.revokeSession(oldPiId);
|
||||
const piSessionId = opened.session.sessionId;
|
||||
// cwd 由调用方传:AgentSession 上没有 cwd getter(只有 sessionId /
|
||||
// sessionFile / sessionName),从 sessionManager.getCwd() 也行,
|
||||
@ -610,7 +665,18 @@ async function main() {
|
||||
const runtimeErr = modelRuntime.getError?.();
|
||||
if (runtimeErr) log(`模型运行时告警: ${runtimeErr}`);
|
||||
|
||||
const mailTools = createMailTools({ client, log, agentName: AGENT_NAME });
|
||||
// onReconnect:connect_to_server 换了 Gateway 地址/密钥后,旧 SSE 长连仍连着
|
||||
// 旧地址(或已被旧密钥打断),必须在这里重建,模型调完工具才真正「切过去」。
|
||||
// reconfigure 已清空 lastEventID,所以 startSSE 会以「首次连接」姿态
|
||||
// (不带 Last-Event-ID,N-11)连上新地址 —— 拿旧序号问新 Gateway 只会
|
||||
// 命中一段无关的历史。
|
||||
const mailTools = createMailTools({
|
||||
client, log, agentName: AGENT_NAME,
|
||||
onReconnect: () => {
|
||||
client.stopSSE();
|
||||
client.startSSE((type, data) => handleSSEEvent(type, data, mailTools), log);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await client.register(); // B-1.2
|
||||
@ -644,23 +710,31 @@ async function main() {
|
||||
await beat(); // B-1.3:不等第一个 30 秒周期
|
||||
heartbeatTimer = setInterval(beat, 30_000); // B-1.5
|
||||
|
||||
client.startSSE((type, data) => { // B-1.4:首次不带 Last-Event-ID
|
||||
if (type === 'permission_decision') {
|
||||
handlePermissionDecision(data, mailTools).catch((e) =>
|
||||
log(`权限决策处理失败: ${describeError(e)}`));
|
||||
return;
|
||||
}
|
||||
if (type !== 'new_mail') return;
|
||||
if (data?.role && data.role !== 'to' && data.role !== 'cc') return;
|
||||
const id = data?.mail_id;
|
||||
if (!id || deliveredMails.has(id)) return; // B-3 第 1 步:去重
|
||||
deliveredMails.add(id);
|
||||
deliverMail(data, 'mail', mailTools).catch((e) => log(`投递 ${id} 失败: ${describeError(e)}`));
|
||||
}, log);
|
||||
client.startSSE((type, data) => handleSSEEvent(type, data, mailTools), log);
|
||||
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => shutdown(sig));
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 事件分派。
|
||||
*
|
||||
* 提成命名函数是因为 connect_to_server 换地址后要用同一个处理器重建长连 ——
|
||||
* 内联箭头函数在那里拿不到,只能复制一遍,而复制出来的两份迟早会分叉。
|
||||
*/
|
||||
function handleSSEEvent(type, data, mailTools) {
|
||||
if (type === 'permission_decision') {
|
||||
handlePermissionDecision(data, mailTools).catch((e) =>
|
||||
log(`权限决策处理失败: ${describeError(e)}`));
|
||||
return;
|
||||
}
|
||||
if (type !== 'new_mail') return;
|
||||
if (data?.role && data.role !== 'to' && data.role !== 'cc') return;
|
||||
const id = data?.mail_id;
|
||||
if (!id || deliveredMails.has(id)) return; // B-3 第 1 步:去重
|
||||
deliveredMails.add(id);
|
||||
deliverMail(data, 'mail', mailTools).catch((e) => log(`投递 ${id} 失败: ${describeError(e)}`));
|
||||
}
|
||||
|
||||
function shutdown(reason) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
|
||||
Reference in New Issue
Block a user