feat: 权限档位体系(三档 plan/workspace/full + 四桥 from_session_id)
L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列 (sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置, homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id 供 relay 去重与会话回溯。 FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
This commit is contained in:
@ -35,6 +35,8 @@ import {
|
||||
} from "./lib/relay-dedup.js";
|
||||
import { adoptedSessionID, adoptMissingMessage } from "./lib/adopt.js";
|
||||
import { autoRelayDecision, replyInstruction, inboundHeadline } from "./lib/relay-policy.js";
|
||||
import { clampRelayKey, isPermanentFailure } from "./lib/relay-key.js";
|
||||
import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from "./lib/bounded.js";
|
||||
import { appendRenameProposal, renameProposalNote } from "./lib/rename-proposal.js";
|
||||
// opencode 原生支持三态权限,免批由它自己记(response:"always"),
|
||||
// 所以这里只借用决策文本的判定,不需要 createGrantStore。
|
||||
@ -199,6 +201,7 @@ const sendMailTool = {
|
||||
reply_to: args.reply_to || "",
|
||||
session_alias: args.session_alias || "",
|
||||
attachment_ids: args.attachment_ids || [],
|
||||
from_session_id: context?.sessionID || "",
|
||||
});
|
||||
|
||||
// 发成功后才记:失败的调用不该压掉自动转发 ——
|
||||
@ -585,9 +588,13 @@ function startSSE(onEvent) {
|
||||
// AgentMail 会话 ↔ opencode 会话的绑定。
|
||||
// 网关已经根据三维地址的 session 位完成了「复用默认 / 新建 / 具名必须存在」的判定,
|
||||
// 推送过来的 session_id 就是那个判定结果;插件只负责忠实映射,不自己决定开不开新会话。
|
||||
const sessionMap = new Map(); // agentmail session_id -> opencode session id
|
||||
const reverseMap = new Map(); // opencode session id -> agentmail session_id(供 event 钩子回写命名)
|
||||
const syncedTitles = new Map(); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏)
|
||||
// 这几张表都是**常驻进程里只增不减**的形态:键来自邮件事件流,会话数随时间
|
||||
// 单调增长。两条出口 —— `forgetSession()`(会话归档,确定性)与 Bounded* 的
|
||||
// 上限淘汰(兜底)。没有它们,插件跑几周之后表里躺着几十万条再也不会被查到的
|
||||
// 条目,而 GC 收不掉(还被强引用着)。
|
||||
const sessionMap = new BoundedMap(MAX_TRACKED_SESSIONS); // agentmail session_id -> opencode session id
|
||||
const reverseMap = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> agentmail session_id(供 event 钩子回写命名)
|
||||
const syncedTitles = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏)
|
||||
|
||||
// 权限询问的双向定位。
|
||||
//
|
||||
@ -595,11 +602,15 @@ const syncedTitles = new Map(); // opencode session id -> 已回写过的标题
|
||||
// 转出去时要记住 permission 属于哪个会话(回信地址从那里来),
|
||||
// 人类决策回来时要用 permission.id 去回复 opencode。
|
||||
// 服务端会把 relay_key 随决策事件回传,所以插件重启丢了内存映射也能续上。
|
||||
//
|
||||
// **这张表不设上界**(与上面几张不同):它装的是「还在等结果的东西」。
|
||||
// 静默淘汰一条会让 opencode 侧那次工具调用永远等不到回答 —— 而它有确定的
|
||||
// 清理路径(决策到达 / 询问被取消),不需要靠猜。
|
||||
const pendingPermissions = new Map(); // permission.id -> { sessionID, callID }
|
||||
|
||||
// 每个会话「上一次转出去的最后一条 assistant 消息」,避免 session.idle 重复触发时重发。
|
||||
// 服务端另有 relay_key 幂等兜底,这里只是少打一次网关。
|
||||
const relayedSummaries = new Map(); // opencode session id -> assistant message id
|
||||
const relayedSummaries = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> assistant message id
|
||||
|
||||
// 哪些会话参与邮件往来,idle 时要把总结转回去。
|
||||
//
|
||||
@ -608,7 +619,7 @@ const relayedSummaries = new Map(); // opencode session id -> assistant message
|
||||
// 却永远没有回音,发件人只看到信发出去后再无音讯。
|
||||
//
|
||||
// 没有邮件投进来的 TUI 会话不在这里,它们不该被搬进邮件系统。
|
||||
const mailDrivenSessions = new Set(); // opencode session id
|
||||
const mailDrivenSessions = new BoundedSet(MAX_TRACKED_SESSIONS); // opencode session id
|
||||
|
||||
// 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。
|
||||
// 空数组 = 不限定,回退到环境变量或平台默认。
|
||||
@ -771,7 +782,7 @@ async function relaySummary(client, directory, sessionID) {
|
||||
reply_to: ctx.mailID || "",
|
||||
// relay + relay_key:走免配额通道,并以 assistant message id 保证只转一次
|
||||
relay: "summary",
|
||||
relay_key: last.id,
|
||||
relay_key: clampRelayKey(last.id),
|
||||
});
|
||||
relayedSummaries.set(sessionID, last.id);
|
||||
explicitSends.delete(sessionID); // 一轮结束,窗口关闭
|
||||
@ -785,7 +796,31 @@ function stripRe(subject) {
|
||||
|
||||
// 每个 AgentMail 会话最近一封来信的上下文,用于决定总结回给谁。
|
||||
// 一个会话里可能来过多封信,回最近那封(reply_to 指向它,回信才落回同一线索)。
|
||||
const mailContexts = new Map(); // agentmail session_id -> { replyTo, subject, mailID }
|
||||
const mailContexts = new BoundedMap(MAX_TRACKED_SESSIONS); // agentmail session_id -> { replyTo, subject, mailID }
|
||||
|
||||
/**
|
||||
* 会话归档 → 忘掉它的全部映射。
|
||||
*
|
||||
* 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404),也不会再有新
|
||||
* 邮件投进来,`session.idle` 也不该再把总结转回去(会话已经收不了信)。
|
||||
* 留着这些条目只是占内存,而上限淘汰是「猜」—— 能确切知道该删的时候就不该靠猜。
|
||||
*
|
||||
* opencode 侧那条会话**不删**:人可能还在 TUI 里看它。这里只解除邮件绑定。
|
||||
*
|
||||
* @param {string} mailSessionID
|
||||
*/
|
||||
function forgetSession(mailSessionID) {
|
||||
if (!mailSessionID) return;
|
||||
// peek 而不是 get:这是清理路径,不该把即将删掉的条目刷成「最近活跃」。
|
||||
const opencodeID = sessionMap.peek(mailSessionID);
|
||||
mailContexts.delete(mailSessionID);
|
||||
sessionMap.delete(mailSessionID);
|
||||
if (!opencodeID) return;
|
||||
reverseMap.delete(opencodeID);
|
||||
mailDrivenSessions.delete(opencodeID);
|
||||
relayedSummaries.delete(opencodeID);
|
||||
syncedTitles.delete(opencodeID);
|
||||
}
|
||||
|
||||
// relaySummary 需要 client/directory,而 event 钩子拿不到它们
|
||||
// (只在插件初始化时给一次)。插件启动时把它们闭包进来。
|
||||
@ -941,7 +976,7 @@ async function deliverMail(client, directory, data, kind) {
|
||||
body: renderFailureReport(failures, data.subject),
|
||||
reply_to: data.mail_id || "",
|
||||
relay: "summary",
|
||||
relay_key: `model-failure:${data.mail_id || sessionID}`,
|
||||
relay_key: clampRelayKey(`model-failure:${data.mail_id || sessionID}`),
|
||||
});
|
||||
console.error(`[mail-bridge] 已回报模型调用失败给 ${data.from_name}`);
|
||||
} catch (e) {
|
||||
@ -1073,7 +1108,10 @@ export default async function mailBridge(input) {
|
||||
|
||||
// 已经投过的 mail_id。心跳与 SSE 建连之间有个窗口:那期间到的邮件
|
||||
// 既在 pending_mails 里、也会被 SSE 推一次 —— 不去重就会投两遍。
|
||||
const deliveredMails = new Set();
|
||||
//
|
||||
// 有界:插件跟着 opencode serve 一起长期活着,这里会攒下每一封处理过的
|
||||
// 邮件 id 而永远没有出口。淘汰是安全的 —— 它防的两种重复都发生在秒到分钟级。
|
||||
const deliveredMails = new BoundedSet(MAX_TRACKED_MAILS);
|
||||
|
||||
/**
|
||||
* 补投离线期间积压的未读邮件。
|
||||
@ -1145,6 +1183,12 @@ export default async function mailBridge(input) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "session_archived") {
|
||||
// 会话归档 = 那条会话再也不会收信,映射可以确定性地清掉(不必等上限淘汰)。
|
||||
forgetSession(data?.session_id || "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (type !== "new_mail") return;
|
||||
if (data?.mail_id) deliveredMails.add(data.mail_id);
|
||||
deliverMail(client, directory, data, "mail")
|
||||
@ -1194,35 +1238,42 @@ export default async function mailBridge(input) {
|
||||
? "\n```json\n" + JSON.stringify(input.metadata, null, 2) + "\n```"
|
||||
: "",
|
||||
].filter(Boolean).join("\n"),
|
||||
relayKey: input.id,
|
||||
relayKey: clampRelayKey(input.id),
|
||||
});
|
||||
console.error(`[mail-bridge] 权限询问已转邮件 ${input.id}(${input.type})`);
|
||||
} catch (e) {
|
||||
pendingPermissions.delete(input.id);
|
||||
|
||||
// 409 = 这条任务链上没有人类,永远不会有人来点头。
|
||||
// 4xx = 请求本身被服务端拒绝,重试一万次也是同一个结果。
|
||||
//
|
||||
// 必须当场 deny:保持 "ask" 等于把会话交给本地 TUI 弹窗,
|
||||
// 而邮件驱动的会话根本没有 TUI —— 模型会永久挂在那里。
|
||||
// 这正是生产事故的形状:pi 把任务派给自己的另一条会话,
|
||||
// 那条会话要跑 bash,权限邮件无人可投,整条线索卡死。
|
||||
//
|
||||
// deny 的同时把服务端的建议原文带给模型,它才知道下一步该换什么做法。
|
||||
if (e?.status === 409 && e?.body?.suggestion) {
|
||||
console.error(`[mail-bridge] 权限询问无人可投,当场拒绝 ${input.id}:${e.body.error || ""}`);
|
||||
// 两个真实事故都是这个形状:
|
||||
// 409:pi 把任务派给自己另一条会话,那条要跑 bash,
|
||||
// 权限邮件无人可投,整条线索卡死
|
||||
// 400:relay_key 超长(extended thinking 把思考签名拼进了 toolCallId),
|
||||
// 被当暂时失败让位 → 那条命令无人批准就执行了
|
||||
//
|
||||
// deny 的同时把原因带给模型(opencode 把 reason 作为工具报错回去),
|
||||
// 它才知道下一步该换什么做法。
|
||||
if (isPermanentFailure(e)) {
|
||||
const b = e?.body || {};
|
||||
console.error(
|
||||
`[mail-bridge] 权限询问遇到永久失败(HTTP ${e?.status}),当场拒绝 ${input.id}:${b.error || e?.message || ""}`,
|
||||
);
|
||||
output.status = "deny";
|
||||
// opencode 把 reason 作为工具报错回给模型
|
||||
output.reason = [
|
||||
e.body.error || "权限询问无法送达:该任务链上没有人类用户",
|
||||
e.body.detail || "",
|
||||
e.body.suggestion || "",
|
||||
b.error || `无法把授权请求送达给人类(HTTP ${e?.status})`,
|
||||
b.detail || "",
|
||||
b.suggestion || "这是一个不会因重试而改变的失败。请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。",
|
||||
].filter(Boolean).join("\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// 其余失败(网络抖动、Gateway 重启)保持 ask:那些是暂时的,
|
||||
// 人仍可能在本地看到弹窗,不该把一次抖动当成永久拒绝。
|
||||
console.error("[mail-bridge] 权限询问转发失败:", e?.message || e);
|
||||
// 暂时失败(5xx / 408 / 429 / 网络拖动)保持 ask:那些真的可能下一次就好,
|
||||
// 人也仍可能在本地看到弹窗,不该把一次抖动当成永久拒绝。
|
||||
console.error("[mail-bridge] 权限询问转发暂时失败(保持 ask):", e?.message || e);
|
||||
return;
|
||||
}
|
||||
output.status = "ask";
|
||||
|
||||
Reference in New Issue
Block a user