所有权树:Subagent、Abort 与后台任务
系列第 6 篇,共 8 篇。
多 Agent 系统最容易出问题的地方,不是「怎么创建子 Agent」,而是「谁负责收尾」。
一个 subagent 被 abort,它开着的容器、写了一半的文件、还没提交的子任务怎么办?父对话按 Esc,应该停掉正在跑的审查,还是连后台的定时任务一起取消?Pi Durable 用一个明确的结构回答这些问题:所有权树。
一、树的结构
每个任务和对话都属于某个所有者:
- 对话可以由某个任务拥有(subagent 模式);
- 任务可以由对话拥有,也可以由另一个任务拥有(子任务模式);
ownerless对话不属于任何人,独立存活。
flowchart TD
R["根对话"] --> T1["工具任务:triage"]
T1 --> C1["子对话:triage worker"]
C1 --> T2["generation 任务"]
T1 --> T3["子任务:payment A"]
T1 --> T4["子任务:payment B"]
R --> T5["后台任务:reminder"]
T5 --> C2["后台 subagent"]
所有权不是「引用关系」,而是生命周期归属:
- 父级 abort 时,它拥有的全部工作先被 abort;
- 父级只有在拥有的工作完成后才 idle;
- 子级失败时,父级可以决定如何处理,但不会自动被拖垮。
二、Abort 自底向上
这是整套设计里最关键的一条规则:
sequenceDiagram
autonumber
participant P as 父任务
participant C1 as 子任务 A
participant C2 as 子任务 B
participant H as Harness
H->>P: abort()
P->>C1: abort()
P->>C2: abort()
C1->>C1: 清理自己的副作用
C2->>C2: 清理自己的副作用
C1-->>P: 已中止
C2-->>P: 已中止
P->>P: 运行自己的 abort handler
P-->>H: terminal(aborted)
顺序不能反过来。如果父任务先清理,子任务可能还在写文件、还在扣款,父任务所谓的「已清理」就是假的。自底向上保证每一层只撤销自己的副作用,并且在所有子级停止之后才发生。
abort handler 是任务定义的一部分:
const Payment = defineTask<{ card: string }, { phase: "charge" }, string>({
name: "shop.payment",
version: 1,
initial: () => ({ phase: "charge" }),
phases: {
charge: async (task, runtime, context) => {
const charge = await bank.charge(task.input.card, `payment-${task.id}`);
await runtime.commit(
() => ({
status: "terminal",
outcome: charge.ok
? { status: "completed", result: charge.receipt }
: { status: "failed", error: { message: charge.error } },
}),
context,
);
},
},
abort: async (task, runtime, context) => {
await bank.refund(`payment-${task.id}`);
await runtime.commit(
() => ({ status: "terminal", outcome: { status: "aborted" } }),
context,
);
},
});
bank.charge 使用 payment-${task.id} 作为幂等键,abort 用同一个键退款。即使重跑或重复 abort,银行侧也只会看到一次有效操作。
三、前台任务与后台任务
任务默认是前台的:它属于对话当前的工作。前台任务全部完成,对话才 idle;abort 对话(例如用户按 Esc)会 abort 它们及其拥有的一切。
后台任务属于该对话,但不属于它当前的工作:
- 它运行期间,对话可以 idle;
- 普通的 abort 不会碰它及其拥有的东西;
root.abort(context, { background: true })会把它一起 abort。
flowchart TD
A["对话"] --> F["前台任务<br/>属于当前工作"]
A --> B["后台任务<br/>background: true"]
F -->|"Esc / abort()"| FA["被 abort"]
B -->|"普通 abort"| BA["继续运行"]
B -->|"abort({ background: true })"| BB["被 abort"]
适用场景:
| 任务 | 选择 | 原因 |
|---|---|---|
| 当前回答里的工具调用 | 前台 | 用户按 Esc 应该停下 |
| 审查代码的 subagent | 前台 | 它是这一轮工作的一部分 |
| 明早触发的提醒 | 后台 | 应比启动它的那一轮活得更久 |
| 长期驻留的 Slack 监听 | 后台 | 不应让对话一直 busy |
// 属于当前工作:Esc 会 abort 它,对话会等它
await api.createTask(Checkout, input, {
ownership: { kind: "task", taskId: api.taskId },
}, context);
// 侧边工作:它运行期间对话 idle,Esc 不碰它
await api.createTask(Reminder, input, {
ownership: { kind: "conversation" },
background: true,
}, context);
四、子任务:等待、失败与收尾
一个任务可以创建多个子任务,然后进入 waiting:
pay: async (task, runtime, context) => {
await runtime.commit(async (tx) => {
const payments: TaskId<string>[] = [];
for (const card of task.input.cards) {
payments.push(await tx.createTask(Payment, { card }, {
ownership: { kind: "task", taskId: task.id },
}));
}
return {
status: "waiting",
checkpoint: { phase: "decide", payments },
on: payments,
policy: "failFast",
};
}, context);
},
两种等待策略:
| 策略 | 恢复条件 | 失败时 |
|---|---|---|
allSettled | on 里所有任务完成 | 等全部结束,再决定 |
failFast | 第一个失败立即恢复 | 同时 abort 其他子任务 |
stateDiagram-v2
[*] --> running
running --> waiting: 创建子任务并等待
waiting --> decide: allSettled 全部完成
waiting --> abortOthers: failFast 第一个失败
abortOthers --> decide: 其他子任务已中止
decide --> terminal: 提交 outcome
terminal --> [*]
failFast 的实际语义不是「失败就结束」,而是「失败就先撤销其他分支,再统一结算」。这让「一张卡被拒,其他卡退款」成为框架层可表达的模式,而不是业务代码里的补丁。
4.1 completing 状态
一个任务可以在自己拥有的工作还在跑时结束,状态是 completing:
- 结果已定;
- 但只有那项工作做完才变 terminal;
waitForTask()才返回。
如果结果是 failed 或 aborted,会先 abort 那项工作。这避免了「父任务已经返回失败,子任务还在后台写数据」的窗口。
五、Subagent 的两种模式
Pi Durable 没有内置 subagent,但用几行代码就能构建。核心是:子对话由启动它的工具任务拥有。
5.1 前台 subagent
工具创建子对话、等待答案、把答案返回给模型:
const subagent = defineTool({
name: "subagent",
description: "Delegate a self-contained task to a subagent and get its answer back.",
parameters: Type.Object({ task: Type.String() }),
replay: "safe",
execute: async (args, api, context) => {
const child = await api.commit(async (tx) => {
const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
if (existing !== undefined) return existing.id;
const created = await tx.createConversation({
ownership: { kind: "task", taskId: api.taskId },
});
await configure(tx, created.id, {
model: haiku,
extensions: { remove: [Subagent] },
});
return created.id;
}, context);
await api.details({ conversationId: child }, context);
const request = {
type: "input",
content: args.task,
requestId: `subagent:${api.taskId}`,
} as const;
const settled = await (await (await api.conversation(child, context))!.submit(request, context)).wait(context);
return { output: [{ type: "text", text: settled.status }] };
},
});
三个设计点:
replay: "safe"+ 按ownerTaskId查找已存在的子对话,保证崩溃重跑不会创建第二个 subagent;requestId: subagent:${api.taskId},保证同一次工具调用只提交一次输入;ownership: { kind: "task" },保证 abort 这次工具调用会 abort 子对话。
5.2 后台 subagent
后台模式需要一个后台锚点任务拥有子对话,否则父级 Esc 会把它一起带走:
flowchart TD
P["父对话"] --> A["锚点任务(background: true)"]
A --> C["subagent 对话"]
C --> M1["消息 1"]
C --> M2["消息 2"]
P -.->|"Esc 不传播"| A
A -.->|"abort({ background: true })"| C
23-subagent-background.ts 展示了完整产品化实现:subagent 工具负责 spawn、发消息(steer 或 follow-up)、等待、停止、列出;每条消息由一个后台 reporter task 投递,答案到达后作为 follow-up 回帖给父级;requestId 保证重启不会把消息或回报发两次。
六、重启时会被放弃的任务
有一类任务只在内存里被 await,重启后不会有人再来续。它们可以用 abandonOnRestart: true 创建:
- 后来启动的 Harness 开始调度、发现它仍是 live 时,会在它再次运行任何东西之前,以
abortReason: "restart"abort 它连同它拥有的一切; - 非 replay-safe 工具的嵌套调用自动获得这一待遇;
- 以这种方式被放弃、但其定义缺失的任务,会等到其扩展被安装,这样它的 abort handler 仍能清理;
- 它的调用者与对话会跟着等;
harness.inspect()把它显示为 blocked。
这条规则解决的是「孤儿任务」:没有它,一个重启后再也没人等待的任务会一直占着资源,或者更糟——在无人观察的情况下继续产生副作用。
七、设计原则
所有权树背后只有三条原则:
- 创建者负责。 谁创建了工作,谁就负责它的生命周期。
- 取消向下传播。 abort 先子后父,每层只清理自己的副作用。
- 边界显式化。 想活过当前一轮,就声明
background: true;想被 Esc 停止,就保持前台。
这三条原则让「多 Agent」不再是散落的对象引用,而是一棵可计算、可观察、可中止的树。harness.taskGraph() 可以把这棵树直接画出来,用于任务面板或调试。
小结
- 所有权是生命周期归属,不是普通引用;
- abort 自底向上,父任务在子任务全部停止后才清理;
- 前台任务属于当前工作,后台任务用
background: true划出边界; - 子任务用
allSettled或failFast等待,completing保证结果与收尾一致; - subagent 就是一个由工具任务拥有的普通对话,前台与后台只差一个锚点任务;
abandonOnRestart清理无人续等的孤儿任务。
下一篇转向外部世界:客户端如何观察一个正在工作的 Agent,晚加入、断线重连和多人协作分别怎么处理。
