上下文隔离与并发:六种方案与限流
多智能体最难的两个工程决策:子代理要不要继承父对话,以及并发怎么限。这篇按源码拆开六种上下文隔离方案、四种 worktree 实现、四种限流机制,讲清嵌套为什么必须独立预算,以及死锁是怎么推理出来的。
上下文隔离与并发:六种方案与限流
上一篇把 pi-agent-core 的 Agent 类、十个事件、权限穿透讲完了,留了两个没答的问题:子代理的上下文从哪来?并发扇出怎么限?
这两个问题看起来是两个,实际是一个:隔离强度和并发上限必须一起设计。 隔离越强,每个子代理占的资源越多,能并发的数量就越少;并发开得越大,隔离方案要能兜住的冲突就越多。
生态里已经有四套成熟的组合方式,但它们在两个关键点上分岔:worktree 创建失败时是降级还是拒绝,以及嵌套派生是排队还是立即失败。这两个选择决定了你的系统是"偶尔出错"还是"卡死"。
前置知识:读过本模块前两篇(十三个原语、Agent 生命周期与事件),以及第五章(会话树与上下文裁剪)。
六种上下文隔离方案
"上下文成本"不是 token 账单,是你要额外为隔离付出的输入。 共享不需要额外输入,但主会话上下文随循环无限膨胀——pi-ralph-wiggum 自称"a flat version without subagents",就是这个代价。fork 继承父的完整历史,父上下文 50KB 就多付 50KB,但可能命中父的 prompt cache;fresh 只带一段任务简报,输入最小。而"fork 能命中 prompt cache"是官方生态包明确点出的优势(pi-subagents docs/workflows.md:22):
A forked child sends the history it inherited exactly as the parent sent it, so its first request can reuse the parent's prompt cache; it still cannot run
subagent.
"exactly as the parent sent it" 是关键:历史是原样转发的,所以缓存前缀的哈希对得上。fresh 会话做不到这一点——它的历史是新拼的,前缀哈希完全不同。
fresh 的默认策略
pi-subagents 给三类内置角色设了不同默认值(docs/workflows.md:13):
Packaged
workerdefaults to fresh context so implementation starts from its assigned brief instead of the parent's unfinished conversation. Packagedoracleandadvisordefault to forked context; if the parent has no persisted session file or current leaf yet, that implicit default falls back tofresh.
worker 默认 fresh(实现者应从任务简报开始,不该被父会话的半成品讨论带偏),oracle 和 advisor 默认 fork(顾问需要知道"我们讨论过什么")。注意最后那个 fallback:父会话没有持久化的 session 文件、还没有 current leaf 时,fork 隐式默认退化成 fresh——fork 依赖父历史,父历史不存在时不能崩。这组默认值本身就是一份可抄的决策规则:实现者用 fresh,顾问用 fork,而多数自建多智能体系统的第一个错误就是给所有子代理都用 fork,白白把父上下文的体积乘以 N。
决策路径:三个问题
六种方案不需要凭感觉选。按顺序问三个问题,答案唯一确定方案。
第二问的杀伤力最大。 pi-subagents 没有任何妥协空间(docs/workflows.md:476):
Before a materialized
runs.runorruns.allgroup dispatches fresh children, isolated sources must be Git repositories with clean working trees (excluding.pi/subagents/runtime state). A rejected group dispatches no children and spends no fan-out slots or child output claims; ...
"A rejected group dispatches no children and spends no fan-out slots" 这半句是重点。 拒绝发生在扇出之前,一个脏工作区不会浪费任何并发额度——不会先派 8 个子代理再发现源不干净。
每种隔离的实现要点
fork 怎么省 prompt cache
实现要点不是"拷贝历史",而是**"原样转发历史"**。三个必要条件:子会话拿到的是父 transcript 的同一份内容;provider session identity 必须是新的(否则缓存串味,durable README :710:"it keeps the parent's agent as of that entry but receives a fresh provider session identity");上下文过滤只删父专属的编排痕迹,不能改写普通对话内容。
第三条是 pi-subagents 做得最细的地方(docs/workflows.md:22):
Child context filtering removes parent-only subagent artifacts (hidden orchestration-instruction messages, slash/status/control messages, and
subagenttool-call/tool-result history) while preserving ordinary prose and unrelated tool calls/results.
过滤掉的是"编排指令类"消息,保留的是"普通散文和无关工具调用"。 如果过滤时动了用户的正常发言,前缀哈希就变了,prompt cache 直接失效。"删掉 subagent 工具调用历史"还有个副作用:fork 出来的子代理看不到父之前的子代理调用记录——这既是隔离,也是防递归(它不知道父派过谁)。fresh 则相反:只带简报——该有任务目标、验收标准、工作目录、输出格式;不该带父会话的完整讨论、父的中间尝试、失败的路径,带过去是纯成本,还会把子代理带进父的思维定式。
子进程怎么传系统提示
官方示例(examples/extensions/subagent/index.ts)的参数数组:
const args: string[] = ["--mode", "json", "-p", "--no-session"];--mode json 输出 JSONL 事件流让父进程结构化消费,-p 是 print 模式,--no-session 不写 session 文件。然后按需追加 --model、--thinking、--tools。--tools 是子进程方案唯一能用的权限机制——独立进程,父会话的 beforeToolCall 钩子管不到它,只能白名单不能黑名单。
系统提示词走临时文件,不走命令行参数:
async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
const safeName = agentName.replace(/[^\w.-]+/g, "_");
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
return { dir: tmpDir, filePath };
}追加时 args.push("--append-system-prompt", tmpPromptPath)。绕这一圈有三个原因:命令行长度限制、转义地狱(提示词里有引号、换行、反斜杠)、以及注入风险(内容直接拼进 argv,任何含 shell 元字符的内容都可能出问题)。注意文件权限 0o600——系统提示词可能包含内部规则。
子进程的中止不是即时的。 官方示例的做法是:收到 abort 先 proc.kill("SIGTERM"),再起一个 5 秒的 setTimeout 兜底 proc.kill("SIGKILL")。宽限期让子进程有机会清理,代价是父以为中止了,实际可能还有 5 秒窗口期。 写验收逻辑时必须考虑——父的"已完成"不代表子进程真的停了。
worktree 怎么保 monorepo 子目录
这是四个 worktree 实现里最容易被忽略的坑。@tintinweb 的 src/worktree.ts:85-118 真实代码:
export async function createWorktree(
pi: ExtensionAPI,
cwd: string,
agentId: string,
): Promise<WorktreeInfo | undefined> {
// Verify we're in a git repo with at least one commit (HEAD must exist)
let baseSha: string;
let subdir: string;
try {
await git(pi, cwd, ["rev-parse", "--is-inside-work-tree"], 5000);
baseSha = await git(pi, cwd, ["rev-parse", "HEAD"], 5000);
// Where cwd sits inside the repo ("" at the root): the agent must work at
// the same subdirectory inside the copy, or a monorepo-package cwd would
// silently widen to the whole repo. realpath both sides — git emits
// resolved paths while cwd may arrive through a symlink (macOS /tmp).
const topLevel = await git(pi, cwd, ["rev-parse", "--show-toplevel"], 5000);
subdir = relative(realpathSync(topLevel), realpathSync(cwd));
} catch {
return undefined;
}
const branch = `pi-agent-${agentId}`;
const suffix = randomUUID().slice(0, 8);
const worktreePath = join(tmpdir(), `pi-agent-${agentId}-${suffix}`);
try {
// Create detached worktree at HEAD
await git(pi, cwd, ["worktree", "add", "--detach", worktreePath, "HEAD"], 30000);
return { path: worktreePath, branch, baseSha, workPath: subdir ? join(worktreePath, subdir) : worktreePath };
} catch {
// If worktree creation fails, return undefined (agent runs in normal cwd)
return undefined;
}
}三个值得逐行看的决策。
第一,subdir 的计算。 注释说得很清楚:"the agent must work at the same subdirectory inside the copy, or a monorepo-package cwd would silently widen to the whole repo."
场景:cwd 是 repo/packages/service/,仓库根是 repo/。git worktree add 复制的是整个仓库,但 agent 应该只在 packages/service/ 里干活。不计算 subdir、直接返回 worktree 根路径,agent 的工作目录就悄悄变成了整个仓库——它能看到、修改所有 package 的文件。这是一个静默的权限扩大,不是报错。 在 monorepo 里做并行写代码,这会表现为"agent 改了我没让它改的文件"。第二,realpathSync 两侧都调,原因写在注释里:"git emits resolved paths while cwd may arrive through a symlink (macOS /tmp)"——macOS 上 /tmp 是 /private/tmp 的符号链接,git 返回解析后的真实路径,cwd 可能带着符号链接,不 realpath 则 relative() 会算出完全错误的相对路径。第三,--detach + 事后建分支:分支名 pi-agent-${agentId} 记录在返回值里,由调用方决定何时创建——好处是 worktree 创建和分支创建是两步,失败时不会留下半成品分支。
worktree 的四种实现差异
降级 vs 拒绝:最重要的分岔
降级派(@tintinweb):创建失败 → return undefined → agent 在普通 cwd 里跑。好处是系统不会因为一个 git 问题完全停摆;坏处是隔离静默消失。
这个"静默"有多危险? 想象三个 agent 并行写代码,第二个的 worktree 创建失败(磁盘满、git 锁冲突),它退回主工作区开始改文件——而第一个 agent 正在同一个目录里改文件。没有报错,没有告警,就是两个 agent 同时写同一批文件。**拒绝派(pi-subagents)**则是:源不是 clean 的 git 仓库 → 整组拒绝,一个子代理都不派。拒绝派多付出的是"可用性",换来的是"确定性"。 并行写代码选拒绝,只读调研可接受降级,无人值守的 CI 必须拒绝——因为降级之后的结果不可预测,而不可预测的并行写代码会产出损坏的提交。
pi-crew 的 TOCTOU 防护
pi-crew 处理的不是"创建失败",而是更隐蔽的一类:worker 有 worktree 的写权限,它能改 worktree 里的 .git 指针文件。
src/worktree/worktree-manager.ts:55-66:
function git(cwd: string, args: string[]): string {
// SECURITY: PI_* and PI_CREW_* wildcards removed — they could match secret vars like PI_PASSWORD.
// Git operations do not need PI_CREW_* execution-control vars.
// MED-1: hardened — config-injection overrides via hardenGitArgs (git-hardening.ts).
// R-A3: re-verify the gitdir pointer before EVERY command whose cwd sits
// inside a linked worktree (TOCTOU: a live worker can flip the `.git`
// pointer after an entry-level check passed). The assert throws OUTSIDE the
// execFile try so callers land in their "worktree corrupted" handling.
assertWorktreePointerTrusted(cwd);
return execFileSync("git", hardenGitArgs(args), {"re-verify the gitdir pointer before EVERY command" 是重点。 不是入口查一次,是每条 git 命令前都查。
为什么必须每条都查? 这是 TOCTOU 竞态:worker 在你检查完、执行 git 之前,把 .git 指针改指向它自己构造的目录。那目录里可以放一个带 core.hooksPath 的 config,任何后续 git 命令都会执行 worker 指定的脚本——在编排器进程的权限上下文中。git-hardening.ts 的威胁模型注释列了可利用的 config 项:core.fsmonitor(status/add/commit/diff 时触发)、core.hooksPath(pre/post-commit)、diff.external / diff.<drv>.textconv(git diff 时触发)、filter.<x>.clean/smudge(add/status/diff/checkout 时触发)。这是多智能体系统特有的攻击面:worker 和编排器共享文件系统,而 worker 能写。 单 agent 场景下没有这个问题——编排器调用 git 时必须假设工作区里的任何文件都可能是敌意的。
taskplane 的 lane 分配与文件亲和性
taskplane 按"lane"分配 worktree,每个 lane 一个。分支命名(extensions/taskplane/worktree.ts:42):
opId 是操作者标识,注释说明是为了"collision resistance across concurrent operators in the same repository"。基础路径有两种模式(worktree_location 配置):sibling 放在仓库旁边,subdirectory 放在仓库内的 .worktrees。
taskplane 真正的亮点是文件范围亲和性——文件范围有重叠的任务,分配到同一条 lane 串行跑,从而减少需要的 worktree 数量。实现用并查集(Union-Find):两两检查两个任务的 file scope 是否重叠(支持精确路径和 glob 前缀),有重叠就 union,每个连通分量就是一个亲和组。亲和组的意义:并行度降低了,但冲突消除了,worktree 数量也少了。"亲和组过大时整组串行"是明确的取舍——正确性优先于并行度,源码注释写得很清楚:"Oversized groups (> maxLanes): group stays together on one lane (serial fallback — correctness over parallelism)"。
并行写代码的三条纪律
纪律一:文件系统层隔离。 每个写任务一个 worktree,没有例外。靠"任务之间文件不重叠"来避免冲突是不够的——重叠判定是静态的,实际写哪些文件是动态的。纪律二:文件范围亲和性。 能串行的就串行,亲和性把"看起来无关"的任务识别出来——它们可能都改 package.json 或同一个类型定义文件。第一道防线是 worktree(物理隔离),第二道是亲和性(逻辑串行)。
纪律三:acceptance 检查。 worktree 隔离之后,必须有验收机制确认每个 worktree 的改动是对的。pi-subagents 的做法(docs/tool-reference.md:409-431):
Checked writers reject staged files by default. When a parent intentionally starts a single writer with reviewed staged content, opt in with
acceptance: { level: "checked", preserveStagedIndex: true }. The host captures the repository-wide index tree immediately before each launch (including each retained resume) and accepts only ifgit write-treeproduces the same tree at completion. Working-tree-only fixes are allowed; child-created staging is rejected. Capture or terminal Git failures, including an unavailable or unmerged index, fail closed.
为什么拒绝 staged 文件? 父编排器需要精确控制"哪些改动被接受"。如果子代理可以自由 git add,它就能把不该提交的东西混进暂存区,父的验收和后续的 patch 应用都会失真。"fail closed"是硬要求:捕获失败或终止性 git 失败(index 不可用、未合并)一律拒绝——和"拒绝而非降级"同一个道理,失败时选择保守的那一边。
并发限流的四种机制
官方示例的两个常量保护的是完全不同的资源。 MAX_PARALLEL_TASKS = 8 是入队上限,防模型一次派 100 个任务撑爆主会话上下文;MAX_CONCURRENCY = 4 是执行上限,防同时 100 个进程压垮机器。设成一样是常见的偷懒。 @tintinweb 是同样的双层结构:maxConcurrent 默认 10(后台 agent),maxConcurrentForeground 默认 0(= 不限)。
全局信号量是 pi-crew 的实现(src/runtime/scheduling/global-worker-cap.ts:33-51):
function resolveCapacity(): number {
const env = getCrewEnv("PI_CREW_MAX_WORKERS");
if (env !== undefined && env !== "") {
const parsed = Number.parseInt(env, 10);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
// Invalid env value → fall back to default (don't silently accept 0/negative).
}
const cpus = os.cpus().length;
return Math.max(2, cpus - 2);
}默认容量是 max(2, cpus - 2)——按 CPU 数推导,不是固定值,8 核机器上默认 6 个 worker。注释里那句 "Invalid env value → fall back to default (don't silently accept 0/negative)" 是个值得学的防御:一个手滑的 PI_CREW_MAX_WORKERS=0 如果被接受,信号量容量变成 0,所有 worker 永久阻塞,回退到默认值至少系统还能跑。FIFO 公平则保证先到的任务不会饿死。而"等待本身可取消"是信号量最容易漏掉的能力(global-worker-cap.ts:63-70):
RR-014 / F15: an optional AbortSignal makes the WAIT itself cancellable — a waiter whose signal fires is removed from the queue eagerly and the acquire REJECTS (SemaphoreAbortedError) without consuming a slot. Without a signal the semantics are unchanged.
三个关键细节:"removed from the queue eagerly"(被取消的等待者立即从队列移除,不占位)、"the acquire REJECTS"(抛 SemaphoreAbortedError,不是静默返回)、"without consuming a slot"(取消不消耗槽位,槽位还给池子)。最后这条最容易实现错——很多信号量实现在取消时会把槽位标记为已占用但永不释放,结果槽位慢慢泄漏干净,池子永久卡死。没有 signal 的时候语义完全不变,向后兼容。
嵌套为什么必须用独立预算
多层派生(worker 再派 worker)如果和顶层共用一个信号量,必然死锁。
nested-slots.ts:3-16 的原文:
Grandchildren NEVER ride the global worker semaphore — a waiting parent worker keeps holding its own global slot, so ride-along spawn is the permanent-deadlock shape the repo already records for the judge (...). Instead the root-side delegate handler draws from THIS separate budget:
max = max(1, floor(globalSem / 2)) (or an explicit config override)
FAIL-FAST, NEVER QUEUE: when the budget is exhausted,
tryAcquirereturns false immediately — the caller rejects the delegate with a policy message ("nested spawn budget exhausted; N/M in flight"). No waiting, no queueing.
"a waiting parent worker keeps holding its own global slot" 是死锁的核心机制。 展开说(全局容量 = 2):
Worker A 拿到槽位 1,派子代理 C → C 要申请全局槽位 → 排队
Worker B 拿到槽位 2,派子代理 D → D 要申请全局槽位 → 排队
现在:A 等 C,B 等 D;C 和 D 都在等槽位
槽位被 A、B 持有,而 A、B 都在等自己的子代理完成
→ A 永远不会释放槽位(它要等 C)
→ C 永远拿不到槽位(池子满了)
→ 死锁"ride-along spawn"(搭便车式派生)这个说法很精准:子代理搭着父的槽位一起等。父等子、子等槽位、槽位等父——闭环形成。 解决方式不是"加大信号量",而是"给嵌套单独一份预算":公式 max(1, floor(globalSem / 2)),全局 2 → 嵌套 1,全局 8 → 嵌套 4。取一半是"给顶层留余量":嵌套最多吃掉一半容量,顶层永远有槽位可用。 而预算耗尽时的行为是立即失败,不是排队——因为排队会重新引入死锁:排队者持有父的槽位,等一个永远不会释放的槽位。 fail-fast 把它变成一次正常的工具错误(nested spawn budget exhausted; 2/2 in flight),这个错误会回到模型手里,模型可以换路子(少派一个、换个方案、告诉用户)。死锁则是挂起——模型永远等不到回复,在 agent 系统里"失败"和"挂起"是本质区别:前者能恢复,后者不能。
死锁推理:为什么 judge 不走信号量
上面讲的是"嵌套必须独立预算"。但还有一类角色结论相反:judge 不该受任何并发限制。
global-worker-cap.ts:11-24 的完整原文:
─── WHY THE GOAL-JUDGE IS EXEMPT (RFC MAJ#3) ───
Do NOT route goal-judge spawns through this cap. The judge is naturally bounded — exactly 1 judge per turn, maxTurns:3, no tools (it emits a short JSON verdict, not long agentic loops). A goal cannot spawn many judges, so the judge is NOT a fork-storm vector. Routing the judge through the cap would risk DEADLOCK under contention: a judge could wait on a worker slot that never frees (e.g. all slots held by workers waiting on the judge's verdict). Bounding WORKERS alone bounds the real DoS surface; the exempt judge cannot starve them.
四步推理,每步都必要。
第一步:judge 天然有界。 三个硬约束——每轮恰好 1 个 judge、maxTurns: 3、无工具。"no tools" 最关键:judge 不跑命令、不读文件、不做任何 agentic 循环,只输出一段短 JSON。没有工具就没有"跑很久"的可能。第二步:judge 不是 fork-storm 向量。 "A goal cannot spawn many judges"——每轮一个,轮数有限,不构成资源耗尽攻击面。第三步(关键):走信号量反而会死锁。
全局信号量容量 = 2
Worker A 拿到槽位 1,跑完工作,进入"等 judge 裁决"
Worker B 拿到槽位 2,跑完工作,进入"等 judge 裁决"
现在槽位被 A、B 持有,A、B 都在等 judge
而 judge 要申请槽位才能启动 → judge 排队,队列里没有空闲槽位
→ A、B 等 judge,judge 等槽位,槽位等 A、B 释放
→ 死锁这个死锁的形状和嵌套那个一模一样,但方向相反:不是"父等子",而是"所有 worker 都在等同一个 judge,而 judge 在等 worker 释放的槽位"。第四步:只约束 WORKERS 就够了。 "Bounding WORKERS alone bounds the real DoS surface; the exempt judge cannot starve them."——要防的攻击面是 fork-storm(由受限的 worker 承担);judge 饿死 worker 的风险则由豁免消除,因为豁免的 judge 无法消耗 worker 的槽位。 "the exempt judge cannot starve them" 是整段推理的收口:豁免一个角色是有风险的,但 judge 的自然边界(1 个 / 3 轮 / 无工具)让这个风险不存在。这一段是通用工程智慧,不限于 Pi:
限流规则要按"谁能派生谁"来设计,而不是按"谁看起来更危险"。 派生者受限、被派生者豁免——因为死锁来自派生关系,不来自角色本身。
这条推理反过来也解释了嵌套预算为什么必须独立:如果 judge 可以无限派生,它就是 fork-storm 向量,必须受限;如果 judge 天然有界,它就该豁免。 同一个问题的两面。
codemode 的 2 worker 上限
packages/codemode/docs/codemode.md:224-231 的限制对多智能体并发有直接约束:
设想一个编排:父 Agent 起 8 个子 Agent,每个子 Agent 内部都跑 codemode 做并行工具调用。worker 池只有 2 个槽,其余全在排队;排队超过 60 秒就失败,失败的子代理直接报错,不执行任何工具调用。
所以 codemode 编排有一个硬性并发上限:单进程内并行跑 codemode 的任务数 ≤ 2。 想提高吞吐,要么调 PI_CODEMODE_MAX_WORKERS,要么把编排拆到多个 pi 进程。"Scripts cannot start other codemode scripts" 和 durable 的 extensions: { remove: [Subagent] } 是同一个思路:不是靠计数防递归,是靠能力不存在——计数器可以被绕过,但"这个工具没有"是物理性的。
一个可运行的并发扇出实现
上面所有机制的最小合集:固定并发、FIFO 公平、等待可取消、失败即失败:
/** FIFO-fair counting semaphore with cancellable waits.
* Mirrors pi-crew's global-worker-cap.ts: waiters are served in arrival order,
* and an aborted waiter leaves the queue WITHOUT consuming a slot. */
export class Semaphore {
private available: number;
private readonly waiters: Array<{ resolve: () => void; reject: (e: Error) => void; onAbort?: () => void }> = [];
constructor(private readonly capacity: number) {
this.available = Math.max(1, capacity);
}
async acquire(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) throw new Error("semaphore acquire aborted");
if (this.available > 0) {
this.available -= 1;
return;
}
return new Promise<void>((resolve, reject) => {
const waiter = { resolve, reject };
if (signal) {
waiter.onAbort = () => {
// Eager removal: an aborted waiter consumes NO slot.
const i = this.waiters.indexOf(waiter);
if (i >= 0) this.waiters.splice(i, 1);
reject(new Error("semaphore acquire aborted"));
};
signal.addEventListener("abort", waiter.onAbort, { once: true });
}
this.waiters.push(waiter);
});
}
release(): void {
const next = this.waiters.shift();
if (next) {
next.resolve();
return;
}
this.available = Math.min(this.capacity, this.available + 1);
}
}
/** Fan out over items with a bounded number of concurrent workers.
* Each item settles independently, so one failure never leaks the others. */
export async function mapWithConcurrency<TIn, TOut>(
items: TIn[],
concurrency: number,
semaphore: Semaphore,
fn: (item: TIn, index: number) => Promise<TOut>,
signal?: AbortSignal,
): Promise<Array<{ status: "fulfilled"; value: TOut } | { status: "rejected"; reason: unknown }>> {
const limit = Math.max(1, Math.min(concurrency, items.length));
const results = new Array(items.length);
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (!signal?.aborted) {
const current = nextIndex++;
if (current >= items.length) return;
try {
await semaphore.acquire(signal);
} catch {
return; // aborted while waiting for a slot
}
try {
results[current] = { status: "fulfilled", value: await fn(items[current], current) };
} catch (reason) {
results[current] = { status: "rejected", reason };
} finally {
semaphore.release();
}
}
};
await Promise.all(new Array(limit).fill(null).map(worker));
return results;
}与官方示例的三点差异:官方用 Promise.all(一个抛错就整体 reject),这里逐项 settle——一个子代理失败不该让其它子代理的进程泄漏;官方没有取消,这里 signal 同时取消等待和运行(官方示例的子进程中止是 SIGTERM + 5 秒 SIGKILL,不是即时的);官方靠外部保证释放槽位,这里用 finally 里 release——抛异常时不释放就是永久泄漏一个槽位。 pi-crew 的 withWorkerSlot 也是 ALWAYS release,包括 fn reject 的时候。
谁负责什么
三种场景的可观察结果
场景一:派了 8 个调研子代理,主会话上下文暴涨到吃不下。 检查子代理用的上下文模式。可观察结果是主会话 transcript 里塞满了子代理读的每一个文件——说明用了 fork 或共享。切到 fresh,父只保留每段的结论。
场景二:三个 agent 并行改代码,其中一个 agent 改到了另一个 agent 正在改的文件。 检查 worktree 隔离。可观察结果是 git status 里出现了本该隔离的改动混在一起,或者 agent 报告自己"看到了另一个 agent 的改动"。降级是这个症状最常见的成因——检查 worktree 创建失败时是不是静默退回了普通 cwd。
场景三:派了 4 个子代理,其中 2 个说"我完成了"但工作区没有任何变化。 检查是不是命中了 worktree 的静默降级路径,或者子代理用 fresh 上下文但简报里没给工作目录。可观察结果是 git worktree list 里少了一个 worktree——创建失败了但没人报错。
你现在能做什么
- 拿一个真实任务,分别用 fresh 和 fork 各派一个子代理,对比输入 token 量和 prompt cache 命中率。
- 在 monorepo 的子目录(比如
packages/foo/)里跑一次@tintinweb的 worktree 创建,验证 agent 的工作目录是worktree/packages/foo/而不是 worktree 根。 - 手动把
PI_CREW_MAX_WORKERS设成0和abc,观察resolveCapacity()回退到max(2, cpus - 2)而不是变成 0。 - 用上面那份
Semaphore+mapWithConcurrency实现,造一个"槽位被占满 + 一个等待者被 abort"的场景,验证取消不消耗槽位。 - 复现死锁:把嵌套预算改成和全局信号量共用,跑一个"worker 派子代理"的场景观察挂起;换成独立预算后观察正常的 fail-fast 错误信息。
- 统计你的编排里有几个 codemode 脚本会同时跑——超过 2 个就该调
PI_CODEMODE_MAX_WORKERS或拆进程。
排障速查
参考资料
- 官方文档:How Pi Works
- 官方文档:Sessions
- 官方文档:Codemode
- 官方文档:Extensions
- 官方文档:Pi Packages
- 官方文档:RPC Mode
- 官方 subagent 扩展示例(并发扇出、子进程隔离)
- pi-agent-core 源码
- pi-durable README(fork、ownership)
- pi-codemode 文档(资源限制)
- pi-subagents 仓库(默认上下文、acceptance gates、isolation 拒绝策略、child-safety)
- @tintinweb/pi-subagents 仓库(worktree 实现、workflow resume)
- pi-crew 仓库(全局信号量、嵌套槽位、judge 豁免推理)
- taskplane 仓库(依赖图、波次计算、文件范围亲和性)