所有权树: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);
},

两种等待策略:

策略恢复条件失败时
allSettledon 里所有任务完成等全部结束,再决定
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 }] };
    },
});

三个设计点:

  1. replay: "safe" + 按 ownerTaskId 查找已存在的子对话,保证崩溃重跑不会创建第二个 subagent;
  2. requestId: subagent:${api.taskId},保证同一次工具调用只提交一次输入;
  3. 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。

这条规则解决的是「孤儿任务」:没有它,一个重启后再也没人等待的任务会一直占着资源,或者更糟——在无人观察的情况下继续产生副作用。

七、设计原则

所有权树背后只有三条原则:

  1. 创建者负责。 谁创建了工作,谁就负责它的生命周期。
  2. 取消向下传播。 abort 先子后父,每层只清理自己的副作用。
  3. 边界显式化。 想活过当前一轮,就声明 background: true;想被 Esc 停止,就保持前台。

这三条原则让「多 Agent」不再是散落的对象引用,而是一棵可计算、可观察、可中止的树。harness.taskGraph() 可以把这棵树直接画出来,用于任务面板或调试。

小结

  • 所有权是生命周期归属,不是普通引用;
  • abort 自底向上,父任务在子任务全部停止后才清理;
  • 前台任务属于当前工作,后台任务用 background: true 划出边界;
  • 子任务用 allSettled 或 failFast 等待,completing 保证结果与收尾一致;
  • subagent 就是一个由工具任务拥有的普通对话,前台与后台只差一个锚点任务;
  • abandonOnRestart 清理无人续等的孤儿任务。

下一篇转向外部世界:客户端如何观察一个正在工作的 Agent,晚加入、断线重连和多人协作分别怎么处理。