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:
2026-09-06 15:18:06 +08:00
parent a44fd6949b
commit 79c4171c9d
40 changed files with 3369 additions and 116 deletions

View File

@ -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 里