feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分
L0 核心: - 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点 - DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields - 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag) - 日历 status 校验(create 补字段 + update 拦非法值) - 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例 A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按 解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了 A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*), SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查, 48h 年龄下限兜上传窗口。已接进每小时 sweep 循环 C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human (EXISTS users 判定),models.Mail 加 ToHuman。前端判据从 workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart 从 session_workspace 取 path(修 dsh@dsh 拼接 bug) 契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/ permission_mode/permission_enforcement),B-5 加 B-5.6 (Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement + unknown_fields,demo 死链修复 + from_human 检查 验收清单加 Agent→Agent 负向对照项
This commit is contained in:
@ -54,6 +54,7 @@ import { explicitSends, shouldSkipAutoRelay } from '../lib/relay-dedup.js';
|
||||
import { autoRelayDecision } from '../lib/relay-policy.js';
|
||||
import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js';
|
||||
import { isApproval, isAlwaysDecision } from '../lib/permission-grants.js';
|
||||
import { clampRelayKey, isPermanentFailure } from '../lib/relay-key.js';
|
||||
|
||||
// ─── 与主进程的通道 ───
|
||||
|
||||
@ -121,7 +122,12 @@ function permissionExtension() {
|
||||
if (grants.has(event.toolName)) return;
|
||||
|
||||
// relay_key 用 pi 给的 toolCallId(B-8.1):服务端随决策事件回传它。
|
||||
const relayKey = `${sid || piSessionId}:${event.toolCallId}`;
|
||||
//
|
||||
// clampRelayKey 不是防御性冗余:启用 extended thinking 时 Bedrock 把
|
||||
// 思考签名拼进 toolCallId,实测长到 437 ~ 13601 字节,键直接超服务端
|
||||
// 160 字节列宽 → 400。同一条会话里长短两种形态混着出现,于是权限询问
|
||||
// 随机成功随机失败(生产日志 21:54:02 失败、21:54:24 同会话成功)。
|
||||
const relayKey = clampRelayKey(`${sid || piSessionId}:${event.toolCallId}`);
|
||||
|
||||
try {
|
||||
// 不传 `to`:决策人由服务端按 会话 owner → 线索里最近的人类 → 409
|
||||
@ -151,8 +157,31 @@ function permissionExtension() {
|
||||
log(`权限询问无人可投,当场拒绝 ${relayKey}:${b.error || ''}`);
|
||||
return { block: true, reason };
|
||||
}
|
||||
// 其余失败是暂时的 → 让位给 pi 本地决策(B-8.2)。
|
||||
log(`权限转发失败,让位给本地决策: ${describeError(e)}`);
|
||||
|
||||
// 其余 4xx(400 / 401 / 403 / 404 / 422…)同样永远不会因重试成功。
|
||||
//
|
||||
// 这里原来一律「让位给本地决策」,而邮件驱动的 worker 没有 TUI ——
|
||||
// 让位等于守卫消失。生产实测:relay_key 过长报 400 被当暂时失败,
|
||||
// 那次 bash 在无人批准的情况下执行了(21:54:02 让位,同会话
|
||||
// 21:54:24 另一次 key 正常,于是权限询问被随机吞掉)。
|
||||
//
|
||||
// fail closed:宁可让模型看到「权限系统坏了」并自己改道,
|
||||
// 也不能悄悄放行一条没人看过的命令。
|
||||
if (isPermanentFailure(e)) {
|
||||
const detail = describeError(e);
|
||||
log(`权限转发遇到永久失败(HTTP ${e?.status}),当场拒绝:${detail}`);
|
||||
return {
|
||||
block: true,
|
||||
reason: [
|
||||
`无法把 ${event.toolName} 的授权请求送达给人类:${detail}`,
|
||||
'这是一个不会因重试而改变的失败(请求本身被服务端拒绝)。',
|
||||
'请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
// 暂时失败(5xx / 408 / 429 / 网络层)→ 让位给 pi 本地决策(B-8.2)。
|
||||
log(`权限转发暂时失败,让位给本地决策: ${describeError(e)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -231,9 +260,18 @@ async function loadSession(mailTools) {
|
||||
|
||||
const adoptID = adoptedSessionID(data);
|
||||
if (adoptID) {
|
||||
const { SessionManager } = await import('@earendil-works/pi-coding-agent');
|
||||
// listAll 而不是 list(cwd):worker 的进程 cwd 与会话 cwd 无关。
|
||||
const all = await SessionManager.listAll();
|
||||
// 用 sessionScanner 而不是 `SessionManager.listAll()`:这里只要 id → path,
|
||||
// 而那两个字段全在会话文件的**首行** header 里。listAll 为了拿它们会把
|
||||
// 每个 .jsonl 的每一行读进来并 JSON.parse,还把所有消息正文拼成一个大字符串
|
||||
// (本机 115 个文件 / 145MB 实测:1431ms、堆里瞬时 240MB)。
|
||||
//
|
||||
// worker 是短命进程,拿不到跨拍缓存的好处,但冷启动也依然便宜得多:
|
||||
// 没有任何一行巨大的 message 被 materialize(实测单行最长 2.63MB)。
|
||||
const { createSessionScanner } = await import('./session-scan.mjs');
|
||||
const { getAgentDir } = await import('@earendil-works/pi-coding-agent');
|
||||
const all = await createSessionScanner({
|
||||
sessionsDir: join(getAgentDir(), 'sessions'),
|
||||
}).scan();
|
||||
const info = all.find((e) => e?.id === adoptID);
|
||||
if (!info?.path) {
|
||||
// 镜像是快照,可以过期。**不能**退回「新建一条」—— 那会让人在 TUI 里
|
||||
|
||||
Reference in New Issue
Block a user