Skip to content

第25章 收银台排队——pi-coding-agent ​

本章导读: 源码路线第 ⑦ 站(产品层)。仍在 pi-coding-agent,但聚焦 core/ 下的三个子系统——读 tools/file-mutation-queue.ts、extensions/runner.ts 和 project-trust.ts。这一站要划清三条容易混的线:同一文件的写入怎么排队、扩展加载成功不等于可信、项目授权到底拦的是什么。

启动链把会话和资源装配起来了,但小a心里还有两个疙瘩。第一个:他让 Agent 连续两次读同一个文件然后改它,忽然想到——两个写请求几乎同时到达同一个文件,会不会互相覆盖?第二个:他在项目里放了一个扩展目录,扩展确实加载成功了,但“加载成功”就等于“可以信任”吗?

“两个都是好问题,但答案都在代码里。”老z说,“一个看文件写入怎么排队,一个看扩展和项目授权怎么接入。我们不宣称这些机制构成完整沙箱。”

本章源码证据均来自 pi-mono 提交 583f153d502aa8e958eefdb9af0fbd3344e68f95(包版本 0.83.0,2026-08-01)。

内置工具通过会话进入循环 ​

小a:“那内置工具从哪来?全都塞给模型吗?”

“先看工具输出怎么控制体积。”老z打开 packages/coding-agent/src/core/tools/truncate.ts:

ts
export const DEFAULT_MAX_LINES = 2000;
export const DEFAULT_MAX_BYTES = 50 * 1024;
export function truncateHead(content: string, options = {}): TruncationResult {
  // 返回 content、truncated、totalLines、totalBytes 等元数据
}

“固定默认值是该快照中的源码事实,但不应外推为所有调用的绝对限制——函数接受 options。关键是结果包含截断元数据,让上层区分‘命令只有这些输出’和‘显示只保留这些输出’。”

“两条线是怎么算的?”

“两条独立线,谁先到谁先截。”老z说,“splitLinesForCounting 数行,Buffer.byteLength(content, 'utf-8') 数字节。maxLines 或 maxBytes 任一超限就切,truncatedBy 字段记下是 'lines' 还是 'bytes'。文件读用 truncateHead 留头部,命令输出用 truncateTail 留尾部——因为日志末尾通常是结论、文件开头通常是声明,各自的‘最有信息量’方向不同。”

“truncateHead 和 truncateTail 的边界行为也不一样?”

“对,这正是它们不可互换的原因。”老z说,“truncateHead 承诺不返回半行——如果第一行本身就超过字节上限,它直接返回空内容并置 firstLineExceedsLimit: true;truncateTail 允许例外,它从文件末尾往回攒,如果最后一行太大,会从行尾截出 maxBytes 那段并把 lastLinePartial 置位,因为 bash 输出的最后一行往往是最重要的错误消息。连‘截断从哪头算’都影响元数据语义。”

再看 OutputAccumulator.snapshot():

ts
snapshot(options = {}): OutputSnapshot {
  const truncation = truncateTail(this.getSnapshotText(), ...);
  if (options.persistIfTruncated && truncation.truncated) this.ensureTempFile();
  return { content: truncation.content, truncation, fullOutputPath: this.tempFilePath };
}

“它把滚动中的原始字节、可显示尾部和可能的完整输出路径分开。正常路径返回可放入上下文的快照;超限时仍保留截断事实;临时文件创建或写入失败属于执行层必须处理的失败,而不是模型可以忽略的文本。”

“为什么 OutputAccumulator 要单独维护 totalLines 和 totalDecodedBytes,而不是直接读 snapshot 的结果?”

“因为 snapshot 的 truncation 是对已组装文本算的,而 OutputAccumulator 累积的是流式字节——append 时一边解码一边累加 totalDecodedBytes,遇到不完整的 UTF-8 边界会暂存到下一个 chunk。”老z说,“所以 truncated 的判定用 this.totalLines > maxLines || this.totalDecodedBytes > maxBytes,跟 tailTruncation.truncated 可能不一致——前者是‘原始流超了’,后者是‘组装后文本超了’。这种分离让中间的坏字节不至于撑爆上下文。”

“那它怎么控制内存?”

“maxRollingBytes = maxBytes * 2,append 后如果 tailBytes 超过 maxRollingBytes * 2 就 trimTail()——只留尾部一块,并从 UTF-8 边界处切,尽量对齐行边界。”老z说,“shouldUseTempFile() 只要任一线超了就开始写临时文件,ensureTempFile() 把此前攒的 rawChunks 一次性回填进文件流。所以内存占用有上限,完整输出不丢——前提是临时文件能写成功,那属于执行层的失败。”

core/tools/index.ts 汇集内置工具;read.ts、write.ts、edit.ts、bash.ts、grep.ts、find.ts 与 ls.ts 分别实现具体能力,由 AgentSession 的工具配置和会话运行时接入模型循环。allToolNames 定义这七个工具名,createToolDefinition/createCodingToolDefinitions 按工具名把 schema 和执行器组装出来。

要点:收益与代价

收益是上下文不会被单个命令输出轻易占满;代价是模型看到的是删节结果,可能遗漏位于中间的关键信息。因此截断信息及完整输出位置必须作为结果的一部分,而不能伪装为完整日志。

同一文件的写入被显式排队 ​

小a:“回到我的第一个疙瘩——同一文件的并发写。”

“看 withFileMutationQueue()。”老z打开 packages/coding-agent/src/core/tools/file-mutation-queue.ts:

ts
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
const chainedQueue = currentQueue.then(() => nextQueue);
fileMutationQueues.set(key, chainedQueue);
await currentQueue;
try { return await fn(); }
finally { releaseNext(); /* 清理同一 queue */ }

“逐段解释:先登记 key 对应的前序 Promise,再安装后继 Promise,之后才等待前序,最后无论 fn 成功或抛错都释放后继。**这个 finally 是队列不会因失败永久堵塞的关键。**正常路径是同 key 的 mutation 按提交顺序执行;边界一是 key 归一化失败;边界二是 fn 抛错,后续 mutation 仍必须获得释放。”

“这个伪码省略了什么?”

“省略了 registrationQueue。”老z说,“实际代码不是直接读写 fileMutationQueues,而是把‘算 key + 读旧值 + 写新值’这一段套进 registrationQueue.then(...) 串行执行。原因是 getMutationQueueKey 是 async 的(要 realpath),如果两个调用并发进入,它们可能在 Map 上 race——A 读到空、B 也读到空,两个都装自己的后继,互相看不见。registrationQueue 把这段登记串成原子操作,保证同 key 的链不会断。”

把队列时序画出来,registrationQueue 的作用更直观:

text
两次几乎同时的 withFileMutationQueue("/x.txt", fn)(理想化时序):
  mutation A 进入                mutation B 进入
  registrationQueue.then(...)     registrationQueue.then(...)   ← 串行登记
   |   keyA = realpath → /real    |(等 A 的登记完成)
   |   查 Map → 空               |   keyB = realpath → /real
   |   建 current=A0             |   查 Map → 已是 A 的链
   |   Map[key] = A0 → nextA     |   Map[key] = nextA → nextB
   |   完成登记                   |   完成登记
   |   await current(A0)          |   await current(nextA)
   |   fn()                       |   fn()                      ← 严格串行
   |   finally: releaseNext()     |   finally: releaseNext()

“没有 registrationQueue,A 和 B 会同时读到 Map 为空、各装各的 next,后写的那次把先写的链覆盖掉。”老z说,“有了它,‘读-装-写’三步被收拢进一条串行链,Map 上的 race 在登记阶段就被消掉了。释放端也有讲究:finally 里 release 后继之后,只有 fileMutationQueues.get(key) === chainedQueue 才删 key——只有这条链仍是当前链时才清理,否则把别人刚装的链删了。”

“key 归一化具体怎么做的?”

“getMutationQueueKey 先 resolve(filePath),再 realpath。”老z说,“realpath 解符号链接——所以软链指向同一文件的两次写会落到同一个 key、被串行。但文件还不存在时 realpath 抛 ENOENT/ENOTDIR,isMissingPathError 捕获后回退用 resolvedPath。代价是:写入一个待创建的文件,符号链接等价性就丢了——/tmp/new.txt 和软链 /link/new.txt 在创建前是两个不同的 key,可能并发执行。”

“所以不同文件不共享队列?”

“对。withFileMutationQueue(filePath, fn) 先将路径解析为队列 key,再等待该 key 的当前 Promise;不同 key 不共享同一队列。”

比喻:收银台排队

同一文件像同一队收银台——同 key 依次结账;不同的队之间没有承诺。

“这是一条可核对的并发边界:它为同一规范化文件目标的 mutation 提供串行化,并不自动解决不同路径指向同一资源、外部进程修改、业务语义冲突或跨文件原子性。把‘按文件排队’描述成‘所有写入均安全’是不成立的。”

扩展加载、绑定与事件 ​

小a:“那扩展呢?‘加载成功’到底证明了什么?”

“只证明了它被发现了、读进来了,不证明它能执行、更不证明它可信。”老z打开 packages/coding-agent/src/core/extensions/loader.ts:

ts
addPaths(discoverExtensionsInDir(localExtDir));
addPaths(discoverExtensionsInDir(globalExtDir));
for (const p of configuredPaths) { /* 目录或显式文件 */ }
return loadExtensions(allPaths, resolvedCwd, eventBus);

“发现顺序是项目本地、全局、显式配置;seen 集合避免同一解析路径重复加载。发现成功不等于模块可执行:loadExtensions() 的结果还携带 errors,启动链会将其中部分错误提升为 diagnostics。”

“seen 去重用的是哪个路径?”

“path.resolve(p) 后比较。”老z说,“注意它去重的是发现阶段的路径,存进 allPaths 的是原始 p 而非 resolved——因为 loadExtensions 内部还会再 resolve 一次带 cwd 上下文。同一个文件经两条路径进来,若 resolve 后相同就只加载一次;但配置路径里指向目录的会先 resolveExtensionEntries 找 manifest,找不到再 discoverExtensionsInDir 列文件。”

“目录发现的规则具体是什么?”

“三档,不递归超过一层。”老z说,“目录下直接放 *.ts/*.js 文件算一档;子目录里有 index.ts/index.js 算一档;子目录里有带 pi.extensions 字段的 package.json 算一档,按 manifest 声明的路径加载。更复杂的包必须用 manifest——这就是为什么加载器注释里写‘no recursion beyond one level’。addPaths 拿到的就是这三档的产物,再逐条交给 loadExtensionModule 用 jiti 动态 import,工厂函数执行时才有机会 registerTool/registerCommand。”

再看 ExtensionRunner.bindCore():

ts
this.runtime.sendMessage = actions.sendMessage;
this.runtime.refreshTools = actions.refreshTools;
this.getModel = contextActions.getModel;
this.isProjectTrustedFn = contextActions.isProjectTrusted;

“绑定把扩展 API 指向会话动作和上下文读取器——**它不是进程隔离器。**Provider 注册既可能在加载时排队,也可能在绑定后直接生效;注册异常会变成 extension error,而非使错误悄悄消失。扩展 API 的事件类型位于 extensions/types.ts:会话、消息、Provider 请求、工具执行、项目授权等事件都有类型化入口和结果类型。这个结构提供拦截和订阅点;它不表示每个 handler 都被隔离执行,也不应把扩展来源自动视为安全。”

“加载阶段的 API 和绑定后的 API 一样吗?”

“接口一样,行为不一样。”老z说,“createExtensionRuntime() 里加载阶段的 action 方法是抛错 stub——sendMessage、appendEntry 这些直接 throw,只有 registerProvider/registerNativeProvider 是合法的,往 pending 数组里排队;注释写得很直白:notInitialized 会抛 ‘Extension runtime not initialized’。bindCore() 把 runtime 的 action 字段逐项替换成真实实现,上下文读取器(getModel、isProjectTrusted 等)也在这里赋值。所以一个扩展如果在加载期调 sendMessage,会立刻抛错——这不是 bug,是加载期边界被实现成了抛错。”

“bindCore 里那串赋值之后还做了什么?”

“它 flush 了加载期排队的 Provider 注册——遍历 pendingProviderRegistrations 调 providerActions.registerProvider,异常收成 diagnostic。”老z说,“还有个易错点:isProjectTrustedFn 的字段默认值是 () => true,getModel 默认 () => undefined。这是为了让 ExtensionRunner 在没绑定时也能跑(比如测试),但意味着‘未绑定’不等于‘拒绝’。真正决定信任的是 resolveProjectTrusted(),不是这个字段默认值。”

项目信任决定资源是否可直接进入运行时 ​

小a:“那项目授权呢?是不是模型提示词里加一句‘要小心’就行?”

“**不是。**项目授权是资源加载过程中的一项条件,不是提示词里的警告。”老z打开 packages/coding-agent/src/core/project-trust.ts:

ts
if (options.trustOverride !== undefined) return options.trustOverride;
if (!hasTrustRequiringProjectResources(options.cwd)) return true;
const decision = options.trustStore.get(options.cwd);
if (decision !== null) return decision;
if (!options.projectTrustContext.hasUI) return false;

“这段顺序区分三种概念:项目授权决定某些项目资源能否加载;beforeToolCall 一类 approval 决定某一次工具调用是否放行;OS 沙箱决定进程实际能访问什么。它们互补但不可互换。特别注意到最后一行——无 UI 的 ask 情况返回不信任,不能用默认允许取代人确认。”

“这个顺序里哪一步最容易被忽略?”

“扩展的 project_trust 事件。”老z说,“它在已存 trustStore 决策之前——意思是扩展可以拦截并自己回答‘信不信任’。emitProjectTrustEvent 把事件发给所有扩展,若任一返回 { result } 就用它的 trusted 字段,remember === true 时还会写进 trustStore。所以项目授权不是单纯的‘问用户’,扩展可以替你决定。代价是:一个恶意的已加载扩展能在这个事件里撒谎返回 trusted。”

“扩展事件里多个 handler 怎么算?”

“按扩展顺序逐个 handler 跑,第一个返回 yes/no 的赢,undecided 继续往下,全部 undecided 才落到 trustStore。”老z说,“单个 handler 抛错会记成 ExtensionError 并继续——错误不阻塞其他 handler,也不等于拒绝。‘扩展先于存储’的意思是:已加载扩展拥有对授权决策的先手发言权,无论它是否真正可信。”

把整条链的求值顺序和出口汇总成表:

text
resolveProjectTrusted() 顺序(583f153d):
| 第几步 | 检查 | 结果 |
| --- | --- | --- |
| 1 | trustOverride !== undefined | 直接返回 override |
| 2 | !hasTrustRequiringProjectResources(cwd) | 直接 true(无需信任资源) |
| 3 | 扩展 project_trust 事件 | 有 yes/no → 返回(可写 trustStore) |
| 4 | trustStore.get(cwd) !== null | 返回已存决策 |
| 5 | defaultProjectTrust | always→true / never→false / ask→往下 |
| 6 | projectTrustContext.hasUI 为假 | 返回 false(无 UI 默认不信任) |
| 7 | 有 UI 的 ask | 选择器,无选择也返回 false |

“第 5、6 步之间有个容易漏掉的细节:always 和 never 不需要 UI,ask 才往下走;而 hasUI 为假时 ask 直接 return false——不是弹不了框就放行,而是弹不了框就不放行。”

“那 defaultProjectTrust 的 always/never/ask 又卡在哪?”

“卡在扩展事件没给出结果、且 trustStore 没存过决策之后。”老z说,“always 直接信任、never 直接拒绝、ask 才走 UI。顺序整体是:override → 无需信任 → 扩展事件 → 已存决策 → 默认策略 → UI。这条链短路求值,前面的定了后面就不走——所以‘扩展先于存储’和‘存储先于默认’这两个相对位置是设计选择,调换会改变语义。”

要点:信任 ≠ 内容可信

项目已获信任不等于文件内容可信;项目未获信任也不等于所有风险消失。扩展代码、shell、网络和凭据仍需要各自的权限、审计和环境隔离策略。

边界责任表 ​

边界入口允许什么不保证什么
工具参数Tool schema/执行器解析并返回错误结果参数符合用户意图
文件 mutationqueue key同 key 串行跨文件原子性、外部写入
扩展loader/runner发现、加载、事件绑定扩展代码安全或兼容
项目授权resolveProjectTrusted()是否加载需信任资源模型免受注入
OS 隔离不在这些符号中无法由本章确认文件、网络、进程最小权限

core/trust-manager.ts 识别需要信任的项目配置资源,并以 ProjectTrustStore 保存决策;resolveProjectTrusted() 按顺序处理显式覆盖、无需信任的项目、扩展的 project_trust 事件、已存决策和默认策略。

小a:“所以这已经是沙箱了吗?”

“不是。”老z说,“从这些文件可确认的是发现、授权决策和调用边界;它们没有单独证明所有进程、文件系统或网络访问被操作系统隔离。安全能力必须按实际执行器和部署环境另行核验。”

失败路径与设计代价 ​

“那扩展加载失败会怎样?”

“扩展加载或 Provider 注册错误会被汇入诊断,启动入口可将 error 诊断作为失败退出,并提示以禁用扩展的方式重启。工具输出截断、扩展 handler 异常和授权拒绝也都应保留为可观察结果,不能让模型以‘已完成’覆盖它们。”

“错误诊断怎么从‘warning’升级成‘error’?”

“看来源。”老z说,“collectSettingsDiagnostics 把 settings 错误一律标 warning;resourceLoader.getExtensions().errors(模块加载失败)标 error;Provider 注册 catch 里标 error。main() 末端只检查 diagnostics.some(d => d.type === "error") 才退出 1,所以 warning 不阻断启动但会打印。这就是为什么‘扩展加载失败’会停、‘settings 字段拼错’只警告——前者影响功能、后者通常是历史兼容。”

“还有一个细节值得注意。”老z补充,“error 里混着两种不同的来源:扩展模块本身 import 失败、和注册 Provider 时抛的异常。main() 里 runtime.diagnostics.some(...message.includes("Failed to load extension")) 时还会追加一条提示——Hint: Start without extensions using "pi -ne".。也就是说,同样的退出码 1,提示文本会根据失败类别切换,给用户的下一步动作不一样。”

“这套可扩展设计的收益是核心可以在不修改主程序的情况下增加 Provider、工具、命令、界面和生命周期行为;代价是加载顺序、兼容性、可观测性和信任面都扩大。本文未覆盖所有内置工具的参数 schema、扩展包管理、具体 sandbox 实现、第三方扩展审计或用户级凭据策略。”

小结 ​

这一章把"它能做什么"和"谁把能力带进来"这两个风险集中的问题,落到了源码层面。工具层用截断和输出累积控制回填给模型的体积——默认两千行、五十 KB 的上限不是硬规则,而是可配置的边界,目的是防止单个命令输出撑爆上下文。同一文件的写入被显式排队:withFileMutationQueue 按 path 归一化的 key 串行化 mutation,finally 里释放下一项,保证队列不会因为一次失败永久堵塞。但这条边界只保护同一文件,跨文件、外部进程修改或业务语义冲突都不在它的管辖范围。扩展层负责发现、加载、绑定和事件分发,加载成功只证明模块被读进来了,不证明它能执行、更不证明它可信。

队列的细节提醒了并发边界的两面:归一化用 realpath 解软链,让指向同一文件的两次写落到同一个 key;但文件不存在时回退到 resolvedPath,创建前的软链等价性就丢了。registrationQueue 把"算 key、读旧值、写新值"收拢成原子登记,杜绝并发读 Map 的 race——释放端只在链仍是当前链时才删除 key。扩展侧的对应物是"加载期排队、绑定后落地":加载时 action 方法是抛错 stub,只有 Provider 注册能排队;bindCore 才把真实动作装进去并 flush 积压注册。加载失败、注册异常都以诊断形式保留,error 让启动停止并视类别给出 -ne 提示,warning 只打印不阻断。

项目授权决定某些本地资源能否直接参与运行时,它是资源加载过程中的一个条件,不是 prompt 里的一句警告。无 UI 时 ask 的默认结果是不信任,不能用默认允许取代人工确认。resolveProjectTrusted() 的短路链里,扩展的 project_trust 事件先于已存决策——已加载扩展对授权有先手发言权,remember: true 还能改写 trustStore,这是设计选择也是信任面扩大的来源。但这三层——工具并发、扩展加载、项目授权——减少的只是部分工程风险,它们不是对不可信代码和副作用的完整隔离证明。把"项目已信任""扩展已加载"当成"可以放心执行任意代码",是这一章要彻底纠正的误读。

源码走查 ​

先读 truncate.ts 与 OutputAccumulator.snapshot(),确认截断元数据和完整输出路径如何产生;再从 withFileMutationQueue() 跟踪等待和 finally 释放。之后比较 discoverAndLoadExtensions()、ExtensionRunner.bindCore() 和 resolveProjectTrusted(),列出扩展从发现到取得会话能力前经过的决策点,并刻意检查无 UI 的 ask 分支。