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:
@ -36,11 +36,13 @@
|
||||
import { mkdirSync, openSync, closeSync, unlinkSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { ModelRuntime } from '@earendil-works/pi-coding-agent';
|
||||
import { ModelRuntime, getAgentDir } from '@earendil-works/pi-coding-agent';
|
||||
|
||||
import { GatewayClient, readLocalKey, generateLocalKey, saveConfig } from './gateway.mjs';
|
||||
import { createWorkerPool } from './pool.mjs';
|
||||
import { createSessionScanner } from './session-scan.mjs';
|
||||
import { describeError } from './turn.mjs';
|
||||
import { BoundedSet, MAX_TRACKED_MAILS } from '../lib/bounded.js';
|
||||
import { snapshotPiModels } from '../lib/model-scope.js';
|
||||
import { snapshotPiSessions } from '../lib/session-snapshot.js';
|
||||
import { selectCatchup } from '../lib/catchup.js';
|
||||
@ -91,12 +93,18 @@ const log = (...args) => console.error('[pi-mail-bridge]', ...args);
|
||||
// 会话映射、权限授权、命名指纹都下沉到 pool 里按邮件会话存 —— 主进程不再持有
|
||||
// AgentSession 对象(那东西跨不了进程边界)。
|
||||
|
||||
const deliveredMails = new Set(); // 已投过的 mail_id(SSE 与补拉共用,B-7.3)
|
||||
// 已投过的 mail_id(SSE 与补拉共用,B-7.3)。
|
||||
//
|
||||
// 有界:桥是守护进程,跑几十天下来这里会攒下每一封处理过的邮件 id 而永远
|
||||
// 没有出口。淘汰是安全的 —— 它防的两种重复(心跳与 SSE 建连之间的窗口、
|
||||
// SSE 断线重放)都发生在秒到分钟级,几千封之前的 id 不可能再来。
|
||||
const deliveredMails = new BoundedSet(MAX_TRACKED_MAILS);
|
||||
|
||||
let allowedModels = [];
|
||||
let modelRuntime = null;
|
||||
let client = null;
|
||||
let pool = null;
|
||||
let sessionScanner = null;
|
||||
let heartbeatTimer = null;
|
||||
let shuttingDown = false;
|
||||
|
||||
@ -172,14 +180,15 @@ function handlePermissionDecision(data) {
|
||||
|
||||
async function reportSessions() {
|
||||
try {
|
||||
const { SessionManager } = await import('@earendil-works/pi-coding-agent');
|
||||
// 不传参数:`listAll(dir)` 把字符串当**自定义会话目录**,传 getAgentDir()
|
||||
// 会去 ~/.pi/agent 下直接找 .jsonl(那里没有),得到空列表。
|
||||
// 不传时它用默认的 ~/.pi/agent/sessions,逐个 cwd 子目录扫。
|
||||
// **不用 `SessionManager.listAll()`**:它为了拿 id/cwd/name/modified 四个
|
||||
// 字段,把 ~/.pi/agent/sessions 下每个 .jsonl 的每一行都读进来并 JSON.parse,
|
||||
// 还把所有消息正文拼成一个 allMessagesText 大字符串。本机实测(115 个文件 /
|
||||
// 145MB)单次 1431ms、堆里瞬时 240MB —— 而这 282MB 每 30 秒分配一次随即
|
||||
// 变成垃圾,且那 1.4 秒是同步解析,跑在事件循环上(SSE 读循环那期间停着)。
|
||||
//
|
||||
// 用 listAll 而不是 list(cwd):桥的进程 cwd 与会话 cwd 无关,
|
||||
// 按前者过滤会漏掉所有真正在干活的会话。
|
||||
const all = await SessionManager.listAll();
|
||||
// sessionScanner 只读 header 的首行 + 增量扫尾部找 session_info:
|
||||
// 稳态下未变化的文件一个字节都不读(实测 3ms / 0 字节)。
|
||||
const all = await sessionScanner.scan();
|
||||
const driven = pool.mailDrivenIDs();
|
||||
return snapshotPiSessions(all, (id) => driven.has(id));
|
||||
} catch (e) {
|
||||
@ -255,6 +264,16 @@ async function main() {
|
||||
const runtimeErr = modelRuntime.getError?.();
|
||||
if (runtimeErr) log(`模型运行时告警: ${runtimeErr}`);
|
||||
|
||||
// 会话目录扫描器。**必须建一次并复用** —— 它的省内存全靠跨拍存活的
|
||||
// size 缓存(稳态下未变化的文件一个字节都不读)。每拍新建一个等于
|
||||
// 每拍都冷启动,退回 listAll 那种全量读的开销。
|
||||
//
|
||||
// 路径自己拼而不是 import getSessionsDir:SDK 只导出 getAgentDir,
|
||||
// getSessionsDir 是内部函数(dist/config.js 里 `join(getAgentDir(), "sessions")`)。
|
||||
sessionScanner = createSessionScanner({
|
||||
sessionsDir: join(getAgentDir(), 'sessions'),
|
||||
});
|
||||
|
||||
// 工作进程池。config() 每次派活时取一次 —— allowedModels 随心跳变,
|
||||
// 取快照会让 worker 用上一轮的模型范围。
|
||||
pool = createWorkerPool({
|
||||
@ -339,6 +358,14 @@ function handleSSEEvent(type, data) {
|
||||
handlePermissionDecision(data);
|
||||
return;
|
||||
}
|
||||
if (type === 'session_archived') {
|
||||
// 会话归档 = 那条会话再也不会收信(别名 404),pool 里的 sessionState
|
||||
// 可以确定性地清掉,不必等上限淘汰去猜。
|
||||
if (pool?.forget(data?.session_id || '')) {
|
||||
log(`会话 ${data.session_id} 已归档,清除本地状态`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (type !== 'new_mail') return;
|
||||
if (data?.role && data.role !== 'to' && data.role !== 'cc') return;
|
||||
const id = data?.mail_id;
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { replyInstruction, inboundHeadline } from '../lib/relay-policy.js';
|
||||
import { clampRelayKey } from '../lib/relay-key.js';
|
||||
|
||||
/** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 叠加。 */
|
||||
export function stripRe(subject) {
|
||||
@ -180,7 +181,10 @@ export function buildMailPrompt({ agentName, data, kind, reused }) {
|
||||
* @returns {string}
|
||||
*/
|
||||
export function relayKeyFor(piSessionId, leafId) {
|
||||
return `${piSessionId || 'unknown'}:${leafId || 'noleaf'}`;
|
||||
// clampRelayKey 收尾:会话 id 与 leafId 平常都短,但不能假定——
|
||||
// 同一个假定在权限询问那边已经坏过一次(toolCallId 被拼了思考签名,
|
||||
// 437 ~ 13601 字节)。超限时才改写,所以合规的键不受影响。
|
||||
return clampRelayKey(`${piSessionId || 'unknown'}:${leafId || 'noleaf'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user