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:
2026-09-06 15:16:49 +08:00
parent 13fcb00acc
commit a44fd6949b
32 changed files with 3462 additions and 177 deletions

View File

@ -47,11 +47,20 @@
* `{type:'name_synced', signature}` 命名指纹,防下一个 worker 重复 sync
* `{type:'reconfigure', url, agentKey}` connect_to_server 换了坐标
* `{type:'done', ok, error}` 这封处理完了
*
* # 内存边界
*
* `sessionState` 与 `retired` 是**跨 worker 长期存活**的两张表,键来自邮件会话流
* —— 会话数随时间单调增长。两条出口:`forget()`(会话归档,确定性)与
* `BoundedMap`/`BoundedSet` 的上限淘汰(兜底)。缺了它们这里就是常驻进程里
* 一处只增不减的结构。
*/
import { fork } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { BoundedMap, BoundedSet, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url));
/**
@ -81,14 +90,14 @@ export function createWorkerPool({
* 这是 worker 一封一进程之后仍需在主进程留存的全部东西 —— 下一封邮件靠
* sessionFile 接着谈,靠 grants 不重复问已经「一直同意」过的工具。
*/
const sessionState = new Map();
const sessionState = new BoundedMap(MAX_TRACKED_SESSIONS);
/**
* 被模型降级换掉的旧 pi 会话 id。
*
* 仍要计入 mail_driven它们已经参与过邮件往来而磁盘上的会话文件
* 不会因为换模型而消失 —— 心跳快照仍会上报它们。
*/
const retired = new Set();
const retired = new BoundedSet(MAX_TRACKED_SESSIONS);
let stopped = false;
/**
@ -235,12 +244,38 @@ export function createWorkerPool({
/** 这条邮件会话有 worker 在跑吗B-4.2 判断降级路径用)。 */
const hasSession = (mailSessionID) => sessionState.has(mailSessionID);
/**
* 忘掉一条已归档会话的全部状态。
*
* 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404也不会再有
* 新邮件投进来。把它的 sessionState 留着只是占内存,而上限淘汰是「猜」——
* 能确切知道该删的时候就不该依赖猜。
*
* 正在跑的 worker **不杀**:归档不是中止指令,模型可能正在写文件;它自己跑完
* 就退,只是那一轮的回信会因为会话已归档而被服务端拦下。
*
* @param {string} mailSessionID
* @returns {boolean} 是否真的删掉了东西
*/
function forget(mailSessionID) {
if (!mailSessionID) return false;
// peek 而不是 get这是清理路径不该把即将删掉的条目刷成「最近活跃」。
const state = sessionState.peek(mailSessionID);
// 已归档会话的 pi 会话 id 也不必再报 mail_driven那个标记的用途是让人在
// 补全里看到「这条在跑邮件」,而已归档的会话不在补全候选里。
if (state?.piSessionId) retired.delete(state.piSessionId);
return sessionState.delete(mailSessionID);
}
/**
* 邮件驱动过的 pi 会话 id喂给心跳快照的 `mail_driven` 标记。
*
* 不随 worker 退出而清worker 退了不代表那条会话不再参与邮件往来 ——
* 下一封邮件还会接着谈,而人在补全里需要看到它带着这个标记。
* 重启丢是已知取舍(契约第六节)。
* 重启丢是已知取舍(契约第六节);确定性的清理时机是归档(见 forget
*
* 返回普通 Set 而不是 BoundedSet调用方只拿它做一轮 has 查询就丢,
* 没有长期持有,不需要上界。
*/
const mailDrivenIDs = () => {
const out = new Set(retired);
@ -268,10 +303,13 @@ export function createWorkerPool({
running: running.size,
queued: queue.length,
sessions: sessionState.size,
// 淘汰计数持续增长说明上限设得太小 —— 那意味着会话上下文在被白白丢掉,
// 而症状是「这条会话怎么突然不记得前面说过什么了」。
evictedSessions: sessionState.evicted,
workers: [...running.values()].map((e) => ({
pid: e.child.pid, mailID: e.mailID, ageMs: Date.now() - e.startedAt,
})),
});
return { submit, routePermission, hasSession, mailDrivenIDs, stop, stats };
return { submit, routePermission, hasSession, forget, mailDrivenIDs, stop, stats };
}