跳到主内容
创建时间:最后更新:作者:白川

上下文隔离与并发:六种方案与限流

多智能体最难的两个工程决策:子代理要不要继承父对话,以及并发怎么限。这篇按源码拆开六种上下文隔离方案、四种 worktree 实现、四种限流机制,讲清嵌套为什么必须独立预算,以及死锁是怎么推理出来的。

上下文隔离与并发:六种方案与限流

上一篇把 pi-agent-core 的 Agent 类、十个事件、权限穿透讲完了,留了两个没答的问题:子代理的上下文从哪来?并发扇出怎么限?

这两个问题看起来是两个,实际是一个:隔离强度和并发上限必须一起设计。 隔离越强,每个子代理占的资源越多,能并发的数量就越少;并发开得越大,隔离方案要能兜住的冲突就越多。

生态里已经有四套成熟的组合方式,但它们在两个关键点上分岔:worktree 创建失败时是降级还是拒绝,以及嵌套派生是排队还是立即失败。这两个选择决定了你的系统是"偶尔出错"还是"卡死"。

前置知识:读过本模块前两篇(十三个原语、Agent 生命周期与事件),以及第五章(会话树与上下文裁剪)。

六种上下文隔离方案

方案隔离强度上下文成本实现者适用场景
共享(同 session 同上下文)
无
最低
@tmustier/pi-ralph-wiggum
单任务长循环,靠 compaction 撑住
fork 子会话
中(继承父历史)
中(可命中父 prompt cache)
pi-subagents、tintinweb
需要"知道讨论过什么"的顾问
fresh 子会话
高
低(只带任务简报)
pi-subagents(worker 默认)
实现者、scout
子进程(独立 pi 进程)
最高
低 + 200-500ms 启动开销
pi-crew、feynman、官方示例
真并行、真沙箱
worktree 隔离
高(文件系统层)
中
tintinweb、crew、taskplane
并行写代码
各自 session + durable
最高(含持久化)
中 + 存储
pi-durable
崩溃恢复、跨进程、任务图

"上下文成本"不是 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 worker defaults to fresh context so implementation starts from its assigned brief instead of the parent's unfinished conversation. Packaged oracle and advisor default to forked context; if the parent has no persisted session file or current leaf yet, that implicit default falls back to fresh.

worker 默认 fresh(实现者应从任务简报开始,不该被父会话的半成品讨论带偏),oracle 和 advisor 默认 fork(顾问需要知道"我们讨论过什么")。注意最后那个 fallback:父会话没有持久化的 session 文件、还没有 current leaf 时,fork 隐式默认退化成 fresh——fork 依赖父历史,父历史不存在时不能崩。这组默认值本身就是一份可抄的决策规则:实现者用 fresh,顾问用 fork,而多数自建多智能体系统的第一个错误就是给所有子代理都用 fork,白白把父上下文的体积乘以 N。

决策路径:三个问题

六种方案不需要凭感觉选。按顺序问三个问题,答案唯一确定方案。

问题选项落点
要不要知道父对话?
不要,只执行一件明确的事
fresh
要,得理解前面的讨论
fork
要不要同时改文件?
否
上下文隔离就够,不需要 worktree
是,但文件不重叠
靠文件范围亲和性分配,减少 worktree 数
是,且可能重叠
必须 worktree 或严格串行
要不要真并行?
不,串行更省事
共享或 fresh
要,且父要等子跑完
durable + worktree

第二问的杀伤力最大。 pi-subagents 没有任何妥协空间(docs/workflows.md:476):

Before a materialized runs.run or runs.all group 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 subagent tool-call/tool-result history) while preserving ordinary prose and unrelated tool calls/results.

过滤掉的是"编排指令类"消息,保留的是"普通散文和无关工具调用"。 如果过滤时动了用户的正常发言,前缀哈希就变了,prompt cache 直接失效。"删掉 subagent 工具调用历史"还有个副作用:fork 出来的子代理看不到父之前的子代理调用记录——这既是隔离,也是防递归(它不知道父派过谁)。fresh 则相反:只带简报——该有任务目标、验收标准、工作目录、输出格式;不该带父会话的完整讨论、父的中间尝试、失败的路径,带过去是纯成本,还会把子代理带进父的思维定式。

子进程怎么传系统提示

官方示例(examples/extensions/subagent/index.ts)的参数数组:

typescript
const args: string[] = ["--mode", "json", "-p", "--no-session"];

--mode json 输出 JSONL 事件流让父进程结构化消费,-p 是 print 模式,--no-session 不写 session 文件。然后按需追加 --model、--thinking、--tools。--tools 是子进程方案唯一能用的权限机制——独立进程,父会话的 beforeToolCall 钩子管不到它,只能白名单不能黑名单。

系统提示词走临时文件,不走命令行参数:

typescript
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 真实代码:

typescript
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 的四种实现差异

实现分支策略路径失败行为
@tintinweb
git worktree add --detach <path> HEAD,事后建 pi-agent-<id> 分支
系统 tmpdir
创建失败 → 返回 undefined,退回普通 cwd(src/worktree.ts:114-117)
pi-crew
pi-crew/{runId}/{taskId}
项目 .pi 下的 worktrees 子目录
git 操作前每次都重验 gitdir 指针(TOCTOU 防护,src/worktree/worktree-manager.ts:59-66)
taskplane
task/{opId}-lane-{N}-{batchId}
仓库旁或 .worktrees
—
pi-subagents
从 clean HEAD 分支,启动前记录 ownership,捕获 patch + handoff manifest
可配置
拒绝而非降级:源必须是 clean 的 git 仓库

降级 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:

typescript
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 if git write-tree produces 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.

时刻动作拦住了什么
启动前
拍下 index tree 快照
建立基线
完成时
git write-tree 生成当前 index 树
—
对比
不一致就拒绝
child 偷偷 git add 了东西

为什么拒绝 staged 文件? 父编排器需要精确控制"哪些改动被接受"。如果子代理可以自由 git add,它就能把不该提交的东西混进暂存区,父的验收和后续的 patch 应用都会失真。"fail closed"是硬要求:捕获失败或终止性 git 失败(index 不可用、未合并)一律拒绝——和"拒绝而非降级"同一个道理,失败时选择保守的那一边。

并发限流的四种机制

机制位置语义
固定并发 + 队列
官方 subagent 示例(MAX_PARALLEL_TASKS=8、MAX_CONCURRENCY=4)
worker pool 模式
全局 worker 信号量
pi-crew global-worker-cap.ts
FIFO 公平信号量,默认 max(2, cpus - 2),env PI_CREW_MAX_WORKERS 覆盖
嵌套槽位预算
pi-crew nested-slots.ts
max(1, floor(globalSem / 2)),fail-fast 永不排队
队列深度
@tintinweb(默认并发 10)
排队等待槽位

官方示例的两个常量保护的是完全不同的资源。 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):

typescript
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, tryAcquire returns 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):

plaintext
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 系统里"失败"和"挂起"是本质区别:前者能恢复,后者不能。

层满了怎么办为什么
顶层 worker
排队(FIFO)
排队者不持有别人的槽位,队列最终会流动
嵌套孙代理
立即失败
排队者持有父的槽位,排队即死锁

死锁推理:为什么 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"——每轮一个,轮数有限,不构成资源耗尽攻击面。第三步(关键):走信号量反而会死锁。

plaintext
全局信号量容量 = 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 的限制对多智能体并发有直接约束:

限制数值对多智能体的影响
脚本 VM 内存
256MB
超了抛 InternalError: out of memory
一个 pi 进程内同时存活的 codemode worker
2 个(PI_CODEMODE_MAX_WORKERS 可改)
满容量时新脚本按到达顺序排队
拿不到 worker 的超时
60 秒
超时就失败,不运行
脚本能否启动其它 codemode 脚本
不能
防递归的另一道墙

设想一个编排:父 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 公平、等待可取消、失败即失败:

typescript
/** 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 的时候。

谁负责什么

职责由谁承担
提供 Agent 运行时、事件、钩子、runToolCall() 权限穿透
Pi 官方(pi-agent-core)
决定子代理要不要继承父历史、选哪种隔离形态
你
决定 worktree 失败时降级还是拒绝
你
并发上限
你——官方示例是 8 入队 / 4 执行
嵌套派生的预算
你——但必须独立于顶层,且 fail-fast
信号量的公平性与可取消性
你——框架不提供信号量
文件范围亲和性
你——或者用 taskplane 的实现
递归防护
你 + 你选的隔离形态
崩溃恢复
durable 提供机制,你决定覆盖范围

三种场景的可观察结果

场景一:派了 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 或拆进程。

排障速查

现象检查点处理
主会话上下文暴涨
上下文模式
实现者用 fresh,顾问用 fork;fork 只在确实需要父讨论时用
fork 之后 prompt cache 没命中
上下文过滤
过滤只能删父专属编排消息,不能改写普通对话内容
agent 改了不该改的文件(monorepo)
worktree 的 subdir
worktree 必须保 monorepo 子目录;relative(realpath(top), realpath(cwd))
并行写代码互相覆盖
worktree 隔离
检查是不是失败了静默降级;改用"拒绝而非降级"
git 命令执行了不该执行的脚本
TOCTOU
worker 能改 .git 指针;每条 git 命令前重验指针(assertWorktreePointerTrusted)
并发派发撑爆上下文
入队上限
MAX_PARALLEL_TASKS 和 MAX_CONCURRENCY 是两回事,别设一样
设了 PI_CREW_MAX_WORKERS=0 后全卡死
env 解析
无效值要回退默认,不能接受 0/负数
嵌套派生挂起不返回
共用信号量
嵌套必须独立预算(floor(globalSem / 2)),且 fail-fast 永不排队
worker 抛异常后槽位永久泄漏
release 时机
finally 里 release,withWorkerSlot 同理
judge 判定时全体 worker 卡住
judge 走了信号量
judge 必须豁免:每轮 1 个、maxTurns:3、无工具
codemode 脚本报超时
worker 池
一个 pi 进程最多 2 个 worker;调 PI_CODEMODE_MAX_WORKERS

参考资料