**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 的同源校验。
131 lines
5.8 KiB
JavaScript
131 lines
5.8 KiB
JavaScript
/**
|
||
* 会话改名提议 —— 所有平台插件共用。
|
||
*
|
||
* # 这是什么
|
||
*
|
||
* 模型干完活后可能觉得当前别名不贴切:会话建立时叫 `witty-planet`(平台随机 slug)
|
||
* 或 `排查登录问题`(人写的邮件主题),摸清问题后它知道这其实是
|
||
* `fix-session-cookie-leak`。改名提议就是让它把这个判断说出来。
|
||
*
|
||
* # 为什么是「提议」而不是直接改
|
||
*
|
||
* 别名是**人**的寻址入口 —— `name@path.<别名>` 里那一段。Agent 干到一半自己改掉,
|
||
* 人上一秒记住的地址下一秒就 404(`session` 位三态语义要求指向不存在的会话直接报
|
||
* 「无法送达」,不会静默新建)。所以提议入库、由人在界面上点「接受」才真正生效。
|
||
*
|
||
* 这与平台命名自动同步(`POST /sessions/{id}/sync`)互补,两者不冲突:
|
||
*
|
||
* | | 谁发起 | 何时 | 是否打扰人 |
|
||
* |---|---|---|---|
|
||
* | 自动同步 | 平台的命名机制 | 每轮结束 | 不,后台静默生效 |
|
||
* | 改名提议 | 模型的主动判断 | 它认为有必要时 | 是,界面上出提示条 |
|
||
*
|
||
* # 为什么载体是 HTML 注释
|
||
*
|
||
* `/mail/send` 没有 `propose_alias` 字段 —— 提议**搭在正文里**发出去,
|
||
* 服务端用正则摘出来再把标记从入库正文中剥掉。选 HTML 注释的三个理由:
|
||
*
|
||
* - react-markdown 默认不解析 raw HTML,万一服务端没剥掉,它在页面上也只是
|
||
* 一行不显眼的转义文本,不会破版
|
||
* - 纯文本邮件客户端里是一行不碍事的注释,不像自造标记那样显眼
|
||
* - 不与 Markdown 语法冲突,格式化工具不会改写它
|
||
*
|
||
* # 为什么必须共用
|
||
*
|
||
* 标记格式是**服务端正则的镜像**(`gateway/internal/handler/rename_proposal.go`)。
|
||
* 各平台各写一遍拼接,某一处少个空格或把双引号写成单引号,服务端匹配不上 ——
|
||
* 而失败是静默的:邮件照常发出,提议凭空消失,模型以为自己提过了。
|
||
*/
|
||
|
||
/**
|
||
* 服务端能识别的别名字符集。
|
||
*
|
||
* 与 `validateSessionAlias` 一致:`. 空白 / @` 会与三维地址解析冲突,
|
||
* `new` 是寻址保留字。这里**不做规范化**(不把非法字符替换成 `-`)——
|
||
* 规范化是服务端 `normalizeAlias` 的职责,插件擅自改写会让模型看到的
|
||
* 「我提议的名字」与实际入库的不一致。
|
||
*
|
||
* @param {string} alias
|
||
* @returns {boolean}
|
||
*/
|
||
export function isProposableAlias(alias) {
|
||
const a = String(alias ?? '').trim();
|
||
if (!a) return false;
|
||
if (a === 'new') return false;
|
||
// 双引号是标记本身的定界符,含它会截断标记
|
||
if (/[.\s/@"]/.test(a)) return false;
|
||
// 服务端 VARCHAR(128),按字节算
|
||
if (Buffer.byteLength(a, 'utf8') > 128) return false;
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 把改名提议标记追加到正文末尾。
|
||
*
|
||
* 格式必须与服务端正则逐字符对应:
|
||
* `<!-- agentmail:rename-session alias="x" reason="y" -->`
|
||
* reason 可选,为空时**整个属性都不写**(写成 `reason=""` 服务端会存一个空理由,
|
||
* 界面上的提示条就少了那句解释)。
|
||
*
|
||
* 别名不合法时**原样返回正文**,不追加标记:与其发一个服务端匹配得上却
|
||
* 被 `validateSessionAlias` 拒掉的标记,不如当它没提 —— 调用方据此告诉模型。
|
||
*
|
||
* @param {string} body 原始正文
|
||
* @param {string} [alias] 提议的别名
|
||
* @param {string} [reason] 提议理由,一句话
|
||
* @returns {{body: string, proposed: boolean}} proposed=false 表示别名不合法,未追加
|
||
*/
|
||
export function appendRenameProposal(body, alias, reason) {
|
||
const text = String(body ?? '');
|
||
if (!isProposableAlias(alias)) return { body: text, proposed: false };
|
||
|
||
const a = String(alias).trim();
|
||
// 理由里的双引号会截断标记,去掉而不是转义:HTML 注释里没有转义机制
|
||
const r = String(reason ?? '').replace(/"/g, '').trim();
|
||
const reasonAttr = r ? ` reason="${r}"` : '';
|
||
|
||
return {
|
||
body: `${text}\n\n<!-- agentmail:rename-session alias="${a}"${reasonAttr} -->`,
|
||
proposed: true,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 提议提交后回给模型的那句话。
|
||
*
|
||
* **别名取服务端回的 `rename_proposed`,不是本地提议的那个。** 服务端会跑
|
||
* `normalizeAlias` —— 非法字符换成 `-`、`new` 变 `session-new`、超长按 UTF-8
|
||
* 边界截断。回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。
|
||
*
|
||
* 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里
|
||
* 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。
|
||
*
|
||
* @param {string} [serverAlias] 服务端 `/mail/send` 响应里的 `rename_proposed`
|
||
* @param {string} [requestedAlias] 本地提议的别名,仅用于「未提交」时的说明
|
||
* @param {boolean} [proposed] appendRenameProposal 的返回值
|
||
* @returns {string} 空串表示没有需要追加的说明
|
||
*/
|
||
export function renameProposalNote(serverAlias, requestedAlias, proposed) {
|
||
const server = String(serverAlias ?? '').trim();
|
||
const wanted = String(requestedAlias ?? '').trim();
|
||
|
||
// 服务端确认收到了:用它给的最终值
|
||
if (server) {
|
||
const changed = wanted && wanted !== server
|
||
? `(你提的 "${wanted}" 被规范化成了这个)`
|
||
: '';
|
||
return `已附上改名提议 "${server}"${changed},等用户在界面上确认后生效 —— ` +
|
||
`在那之前继续用原别名寻址。`;
|
||
}
|
||
|
||
if (!wanted) return '';
|
||
|
||
// 本地就判定不合法,标记没发出去
|
||
if (!proposed) {
|
||
return `(改名提议 "${wanted}" 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)`;
|
||
}
|
||
|
||
// 标记发出去了但服务端没回 rename_proposed:它那侧的校验也拒了
|
||
return `(改名提议 "${wanted}" 未被服务端接受,会话别名不变。)`;
|
||
}
|