dsh 源码解析
第四段 · 编排与对外 · 第 11 章

编排:子代理、工作流、后台任务、目标

四个互不依赖的家族;竞品的 agent 可以是你的一个 provider

本章基准:dsh 0.1.1-rc.2,commit b150a551b8d465e31e418e1b2eaf5e79bbb7d28e

主要源码:packages/subagent/(11 包)、packages/workflow/(4 包)、packages/jobs/(3 包)、packages/goal/(4 包)

前十章讲的是一个 agent 怎么跑一轮。这一章讲怎么跑多个、跑很久、跑到人不在的时候。

dsh 把这件事拆成了四个互不依赖的家族,而且它们的关系值得先看清:

家族 解决什么 有没有 Service 谁是权威状态
subagent 把活派给子 agent ctx.subagents 子会话自己的日志
workflow 让模型写一段编排脚本 ctx.workflowEngine 无(不做检查点)
jobs 长跑工具的后台协议 ctx.jobs 进程内注册表
goal 同一会话内持续推进一个目标 ctx.goals 会话日志的 goal/change

关键一点:agent-loop 里没有"子代理模式"、没有"fresh-agent 循环"、没有"目标模式"。 四个家族都是第 8 章那种 capability seam 加上第 4/7 章那些扩展点拼出来的普通插件。tool-ralph 的 README 把这条说得最直白:

no Ralph mode or fresh-agent loop is added to agent-loop, and the same-session goal domain remains independent.

全章图示:assets/ch11-orchestration.svg

四个编排家族与两种子代理生命周期


1. 子代理 seam:一个服务,七种 provider

ctx.subagentspackages/subagent/subagent/src/index.ts)背后可以同时挂多个具名 provider:

provider 子代理跑在哪 看得到父对话吗
spawn 本进程,全新会话
fork 本进程,用父的已完成历史做种子
acp 另一进程,走 ACP 协议
codex 真实的 Codex app-server
claude-code 官方 Claude Agent SDK
dsh-sdk 另一个 harness 进程,走 TS SDK

同一个模型可见的 subagent 工具,换 provider 配置就换了执行世界——包括换成 Claude Code 或 Codex。这是第 8 章那个 seam 模式最激进的一次应用:竞品的 agent 是本 harness 的一个 provider。

tool-subagent 的绑定方式很克制:

Each plugin instance binds one provider to one toolName; the model receives no provider selector. Load another distinctly named instance to expose another transport.

模型不选传输方式。 想暴露两条路就装两个实例、给两个工具名。

1.1 fork 的种子边界:为什么不能直接拷日志

function completedTurnPrefix(parent: Agent): SessionEvent[] {
  const events = parent.session.events
  const lastEnd = events.findLast(e => e.type === 'turn/end')
  if (lastEnd === undefined) return []
  // seq === array index (the append contract), so slice up to and including it.
  return events.slice(0, lastEnd.seq + 1)
}

subagent-fork-in-process/src/index.ts:49

README 解释了这个函数存在的原因:

The parent's current tool-calling turn is still open when a subagent starts: its log contains the assistant tool call but not the matching tool result or turn/end. Copying that raw log would give the child an invalid, unbalanced session.

派生子代理这个动作本身发生在一个未完成的工具调用里。 那条 assistant/message 里有 tool-call,配对的 tool/result 还不存在——直接拷过去,子会话第一眼就是一个孤儿调用(第 7 章的配对不变式)。

切到最后一个 turn/end,子代理看到所有完成的轮次、看不到进行中那一轮。父还没跑完一轮时种子为空,行为退化成 spawn

注意那句注释的重要性:seq === array index (the append contract)——第 5 章那个"seq 从 0 连续递增"的约定在这里被直接当作切片下标用。 一个看起来很小的不变式,在这里省掉了一次查找。

而种子的语义边界也划清了:

The seed transfers conversation history only. The child still receives a fresh flat registration scope; it does not inherit the parent's tool restrictions or authority.

看到历史 ≠ 继承权限。 inheritsParentContext 这个 flag 在 seam 里被明确标注为 descriptive rather than enforceable

1.2 委派边界固定权限,而且钉在子日志里

export function captureDelegatedPolicyOverrides(parent: Agent): DelegatedPolicyOverrides {
  return {
    sandboxMode: parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session),
    approvalPolicy: parent.ctx.get('approval') === undefined ? undefined : 'never',
  }
}

subagent/src/child-agent.ts:199

两个决定:

审批策略无条件钉成 'never'——regardless of the parent's own policy。理由是:

so a delegated child acts only within its inherited sandbox scope and every ask (for example a sandbox_permissions escalation) is rejected deterministically instead of waiting on a prompt no one is watching.

没人在看的审批提示不该等。 第 9 章那条 never 必须由 ApprovalService 自己在 waterfall 之前判定的规则,在这里兑现:子代理的升级请求确定性地被拒,不会挂在一个永远不会有人回答的问题上。

沙箱只复制显式覆盖,不复制部署默认值:

The sandbox deployment default is never copied: an unswitched parent stamps no sandbox/mode and its child follows the deployment default dynamically.

父没切过模式,子就动态跟随部署默认值。复制默认值会把一个"当前恰好是这样"的值冻结成一个"永久就是这样"的事实。

而写入方式是第 5 章那套:

childSession.append('sandbox/mode', { mode: overrides.sandboxMode, source: 'delegation' })
childSession.append('approval/policy', { policy: overrides.approvalPolicy, source: 'delegation' })

child-agent.ts:215

时机很关键——Appends land **after any fork seed**, so fresh policy wins stale seed state。fork 的种子里可能带着父当时的 sandbox/mode 事件;委派策略写在种子之后,靠 last-wins 覆盖它。

于是有一条性质:子代理的有效策略只从它自己的日志就能重建。 冷恢复不需要去问父在哪、父现在什么策略。README 也点明了对应的推论:

a cold resume replays the persisted delegation events instead of re-capturing the parent, so a parent switch after creation never retroactively changes a durable child.

父后来改了策略,不会追溯改变已经存在的子代理。

1.3 子代理知道自己不能升级

每个进程内子代理的 runtime context 里多一句(第 6 章的 scoped context,第 9 章那两句之后):

You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the job needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it.

"不要重试,把限制写进回复,让派活的那个 agent 处理。" 对比第 9 章顶层 agent 的指导("被拒了就在同一轮里用 sandbox_permissions 重试")——同一个机制,两种角色,两套明确相反的指导,都是通过 scoped context 注入的。

这也是全书里 scope 机制最漂亮的一次体现:权限差异不是靠模型推断出来的,是被显式告知的。

1.4 一次调用组装子代理,而且必须传父

applyChildComposition(childCtx, parent, composition)

child-agent.ts:163

它先 join 父的 agent-preset composition,再套子自己的 persona 和 tool filter。README 里那句设计说明值得完整看:

The join is what gives the child its capabilities: with every model-facing row on the agent plane, a child that joined nothing would reach the model with an empty tool registry. Taking the parent as a parameter is deliberate — it makes composing a child WITHOUT that join unrepresentable at the call sites, which is the defect the one call exists to prevent.

把父作为必填参数,是为了让"忘记 join"在语法上不可能写出来。 这不是文档约定,是签名设计。

childSessionMeta()child-agent.ts:102)把 join 后的 preset id 写进子的 durable header,理由和顶层会话一样:preset 决定了模型看到哪些工具 schema 和 prompt section,冷读历史必须重建那个组合,不能用部署默认值。

而且它从父的活 scope 链读,不从父的 header 读:

because a parent that switched preset while blank runs on the newer composition while its header still names the older one.

父在空闲时换了 preset,它跑的是新的、header 上写的是旧的。 要记的是子真正用了什么。

1.5 能力靠 provider 声明,而不是靠试

provider.capabilities  // { outputSchema, depthLimit, toolFilter, persona }

Start-time features are advertised in provider.capabilities because the service must reject an unsupported one-shot request before child creation.

必须在创建子代理之前拒绝。 一个"先建再发现不支持结构化输出"的实现要负责回滚一个已经启动的子会话。

而 continuable 能力的检查方式不同——方法存在与否就是能力声明

Continuable creation is the optional SubagentProvider.prepareContinuable?() method: its presence is the capability check.

它的返回值也被刻意限死:

The method returns only a detached ContinuableCreateSpec ({ seed? }) — data, never a capability: it carries no Agent, AgentHandle, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation.

provider 只负责回答"种子是什么",一切生命周期归 manager。 一个返回 handle 的 provider 接口会让每个 provider 都要正确实现一遍所有权和退让规则。

1.6 委派深度:持久化的单调下界

AgentOptions.subagentDepth
assertSubagentMaxDepth(...)
delegationDepthOf(agent)

The persisted SessionHeader.delegationDepth is authoritative and monotone — runtime options may deepen the count but never lower it, so a resumed child cannot be re-counted as top-level.

单调性是为了防止"恢复一个子会话,深度归零"这种绕过。 深度限制如果只存在运行时选项里,冷恢复就是一次免费的重置。

maxDepth 的默认是 30 表示禁止委派。工具在到达上限时仍然可见

The tool stays visible at the cap; each attempted start checks the calling agent's current depth and returns an errored tool result when rejected.

到上限不隐藏工具,而是让调用失败并解释。 这和第 9 章"拒绝结果里带升级提示"是同一个思路:让模型从结果里学到边界,而不是让它从工具是否存在里猜。

还有一个值:'provider-managed' 表示"不发送上限,预算属于对面那个 harness"——跨进程时不假装自己能管别人的预算。


2. 两种子代理生命周期

这是本章最需要讲清的分岔。

2.1 one-shot:一次交付、一个结果、必须 dispose

start(name, request) → SubagentRun { id, localAgent, result, dispose() }

provider.start()所有权转移边界

Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call dispose() on every path.

结果契约里一条很重要:

Child-level failures resolve with a non-completed reason; only an infrastructure fault that the seam cannot represent may reject.

"子代理跑失败了"是一个正常的 resolve,不是异常。 只有 seam 表达不了的基础设施故障才 reject。这让调用方的错误处理路径干净:result 永远给你一个可读的 stopReason

diagnostic 字段的约束值得抄:

A provider may add a safe diagnostic to a non-completed result after removing tool inputs, file contents, environment values, credentials, and raw protocol payloads and limiting the complete text to 4096 UTF-8 bytes. … The field is not assistant output: consumers present it separately, and it does not enter subagent/end.lastAssistantMessage.

一个跨越信任边界的诊断字段必须显式列出要剥掉什么。 "别放敏感信息"是没用的指导;"剥掉工具输入、文件内容、环境值、凭据、原始协议载荷,并截到 4096 字节"是可以执行的。

而且诊断不算助手输出——否则一段被截断的答案会被当成子代理说的话。

2.2 continuable:一个持久会话 + 至多一个 Activation

A continuable child has one durable Session and at most one process-local Activation — one residency epoch for a reconstructed child Agent, not a request, result, cancellation, or Task boundary.

Activation 是"这个子代理此刻住在本进程里"的一段驻留期。 它不是请求、不是结果、不是取消、不是 Task。

而 continuable 路径完全不创建 Task 和结果承诺

The Agent inbox is the only turn queue, so the continuation manager owns residency while the Agent loop owns all turn ordering and execution.

排队用第 4 章那个 inbox,不另建队列。 这是全书反复出现的手法:不要为了新场景造第二个状态机。

三个驻留状态也是推导出来的,不是维护出来的:

The manager derives three internal residency conditions from Agent quiescence and the owned-child set rather than maintaining a second state machine: running(有活跃准入、打开的 turn,或唤醒中的 inbox 工作)、waiting(安静但仍拥有至少一个未 dispose 的子)、settled(安静且所有子已 dispose → dispose handle、移除 Activation)。

路由规则只看驻留状态:

running  → 入队
waiting  → 唤醒同一个 Agent
无 Activation → 冷恢复出一个新的

冷恢复不走 provider:

Cold resume never dispatches through a provider because the persisted Session already holds the initial prefix and the folded descriptor is the whole reconstruction input.

持久会话 + 折叠出来的 descriptor 就是重建的全部输入。 provider 在子代理第一次创建之后就不再被需要了——甚至可以被卸载。

2.3 权限:三种,各自的理由不同

操作 需要什么权限 为什么
followup() 精确的活的直接父 Agent 投递内容
reportFrom() 精确的活的子 投递内容;收件人从 durable parentSession 推导
interrupt() 活的直接父 人类持久父地址 任意活的祖先 只停一轮,不投递内容

Interrupt authority is deliberately wider than delivery authority: a human presents the durable direct-parent address so a live child stays stoppable while its parent Agent is offline, and any exact live ancestor recorded in the Activation's materialization lineage may stop its descendant, because stopping a turn is idempotent and delivers no content.

"停止"和"投递"是两种权限,因为它们的风险完全不同。 停一轮是幂等的、不传内容;投递会让子代理相信父说了某句话。

source 字段被明确剥离了权威性:

The source on a follow-up records who supplied the delivered message and grants no authority.

记录发送者 ≠ 授权发送者。 这两件事在很多系统里被混在一个字段里。

interrupt() 还有一条清晰的保留语义:

it issues Agent.cancel(cause, { keepInbox: true }) … Unclaimed pending inbox work, the Activation, and published descendants are preserved; work already claimed into the interrupted turn is not requeued.

对比 drainContinuableChildren()

This is teardown, so unlike interrupt() it does not preserve pending inbox work.

同一个 seam 里两个操作对 inbox 的处置相反,因为一个是"停这一轮",一个是"拆掉它"。

2.4 结算通知:两条排序规则

子代理的 Activation 结束时,manager 在父自己的 turn 流里告诉父"这个子不会再产出了"。投递是无条件的

it does not consider whether the child called report, because the endings that most need an account — a token ceiling, a model failure, cancellation, teardown — are exactly the ones where the child never got to choose.

最需要交代的结局,恰好是子代理没机会自己交代的那些。

而且来源被区分开:

durable provenance { kind: 'subagent-settled', form: 'notice', senderSessionId: <child-id> }a different source kind from a child-authored subagent-report, so a transcript never credits the child with words the runtime wrote.

运行时写的话不能记在子代理名下。

两条排序规则解释了为什么这件事必须归 manager,而不能是一个外部的 subagent/end 监听器:

First, the send happens before the child's ownership release, while the parent still counts the child and is therefore structurally unable to be judged settled. Second, a parent that is itself a resident Activation receives the message through the same waking-admission accounting as a report, so the window between the synchronous send and the microtask that admits it is not mistaken for quiescence.

没有这两条,父可能在通知还躺在 inbox 里时就被 dispose,而 cancel() 会清掉它——静默丢失。

投递方式按父的状态分三种(对应第 4 章那三个输入原语):

父的状态 用什么 为什么
idle followup — 一个普通的新轮 正常路径
busy steer — 挤进最近的 step 边界 几个子同时结算只花一个 step,而不是一个 turn;而且 steer 能被"读状态到发送之间退休的 driver"接住
自己的谱系正在拆除 inject — 完全不唤醒 唤醒一个宿主马上要 dispose 的 Agent 等于白花一次模型请求,而且会逐层向上传染

第三行是全章最细的一处推理:

Agent.followup() on a quiescent parent starts a turn and cancel() does not arm against a later one, so waking during teardown would spend a model request on an Agent its host is about to dispose — once per tree layer, since each layer's notice then wakes the layer above it.

拆除期唤醒会沿树逐层放大。 而注入的通知也不保证活过父自己的 dispose——AgentHandle.dispose()keepInbox: false 的取消,会持久地取消掉未领取的通知。这个后果被接受了,因为有替代路径:

A resumed parent therefore has no pending notice to read: list_agents tells it which children exist and whether each is live or stored, while the outcome itself stays in the child's own Session, which a send_message reaches by resuming that child.

丢的是通知,不是事实。 事实在子会话的日志里。

最后:Delivery never blocks or fails teardown — a rejected send is logged, because retaining a child to retry a notice would **pin its whole ancestry in waiting forever**.

为了重试一个通知而保留子代理,会让整条祖先链永久卡在 waiting。

2.5 durable descriptor:显式字段,不用可扩展对象

每个本地会话支撑的子代理启动时 append 一个 subagent/descriptor(log-only,无 surfaceOp,压缩也留着)。continuable 的那份还记录解析后的 provider/model 和可选 persona/toolFilter

These are explicit fields, never the merge-extensible AgentOptions object, so an unrelated extension value cannot break continuation.

冷恢复依赖的字段必须是显式列举的。 存一个可扩展对象意味着任何插件加的一个字段都可能让恢复失败。

而 descriptor 刻意不存两样东西:

  • subagentDepth——因为 SessionHeader.delegationDepth 已经是单调下界(一个事实一个归属);
  • outputSchema——因为它是 Activation 的结果契约,不是身份。

3. 枚举:一个只读的、不碰运行时的目录

listChildren() / listDescendants() 的自我限定非常严:

It never consults the continuation manager, Agent registrations, Activations, or providers.

它读的是活会话存储 + 可选的会话持久化的 live-preferred 合并,身份来自注册在 ctx.sessionProjections 上的 subagent projection 单元。

而分类权威只有一处:

The projection fold is the single classification authority; listing parses no descriptor itself.

四种结果各有明确含义:

情况 结果
fold 给出了身份 一个正常的子条目
已结算但 fold 没给出身份 corrupt 诊断
持久化读取失败 unavailable(瞬时,下次列举重试)
还在跑但还没有身份 省略(创建窗口,descriptor 还没 append)

"还没写 descriptor 的运行中子代理"被省略而不是报错——那是一个正常的短暂窗口。而"已经结束了却没有身份"是真的坏了。

缓存的处理也很讲究:

A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold.

派生数据出错不产生判断,回退到权威路径。


4. 后台任务:ctx.jobs

长跑工具(bash 后台命令、后台子代理…)共用一个协议:观察、取消、等待、完成通知、清理。

4.1 所有者围栏是唯一边界

Owned access compares the job's SessionId with the caller's. Ids such as bash-1 are predictable, so this fence is the boundary.

id 是可预测的,所以不能靠 id 保密。 bash-1 谁都猜得到——真正的边界是"这个 job 的 SessionId 是不是你的"。

而三种注册全部是相对于所有者的:

All three registrations are owner-relative, because one registry serves every composition in the process. A controller or listener registered from an unscoped context serves every owner; one registered under an agent composition's scope serves exactly the agents composed under it. So a composition that loads no controller cannot start background work on the strength of another composition's controls.

这是第 2 章 scope 机制在安全上的直接应用:没装控制器的组合不能借别人的控制器开后台工作。

4.2 提交点必须是不可失败的

start(spec) validates the attached controller, spec, exact live owner, optional positive outputLimitBytes, and any provider-owned admission policy before calling the producer's run() once. A preflight rejection or starter throw leaves no job id or registered work; successful return commits without another failable step.

成功返回之后不再有可能失败的步骤。 否则会出现"有了 id 但没有工作"或者反过来的状态。

同样的思路在 kill() 上:

kill() invokes producer cancellation before changing status. A cancellation throw leaves the job running;success changes it to stopping.

取消失败就还在跑——状态不能撒谎。

4.3 两个事件承载完全不同的意义

onJobDone 观察每条终态记录。onJobsChanged 观察可见集合的变化,而 README 明确否定了一个自然的猜测:

It is owner-granular because removal is a change no per-job record can express, and it is not a superset of onJobDone: it carries no delivery meaning and marks nothing reported.

"某个 job 被移除了"这件事没法用某个 job 的记录来表达——所以需要一个集合级事件。而它明确不承载投递语义,不标记"已上报"。

还有一个 scope 细节:The registration binds to the calling fiber, so an observer mounted **outside** the registry still sees the disposal emptying.

4.4 承认的限制

  • 流式输出只有一个消费游标——独立观察者需要另外的 API;
  • 前台工作不能提升为后台——producer 开始前就得选;
  • 契约是进程内的——JobStart.run() 传的是回调和精确的 Agent 对象,跨进程后端必须重塑身份、重启、所有权和观察语义。

第三条是诚实的边界声明:这个 seam 不假装自己能跨进程。


5. 工作流:让模型写编排脚本

ctx.workflowEngine 执行一段模型写的编排脚本,脚本可以扇出子代理。

5.1 结果永不 reject

WorkflowEngine.start(request) validates enough synchronously to reject a malformed meta block, unparseable script, unavailable provider route, or unsupported per-run limit before a run exists. Once returned, WorkflowRun.result never rejects: execution failures resolve with stopReason: 'error', and cancellation resolves with cancelled within the engine's bounded grace.

要么在 run 存在之前同步失败,要么之后一定 resolve。 中间态不存在。

5.2 worker thread 明确不是安全边界

这段自我否定值得完整引用:

Workflow scripts are model-written and have the same trust premise as the model's existing bash access. node:vm inside a worker is an API-shaping mechanism, not a security boundary: an escaped script can recover Node capabilities with the host process's privileges.

然后列出它真正提供的东西:

  • 脚本的 CPU 工作和同步自旋不占宿主事件循环;
  • worker.terminate() 给 dispose 一个真正的终止手段;
  • worker 以空环境启动,所以环境里的凭据不会通过 process.env 漏过去;
  • 宿主/worker 之间用 structured-clone 数据,脚本边界做纯 JSON 校验。

A genuinely untrusted-script sandbox would require a different engine behind the same workflow seam.

这是一个技术文档能做的最有价值的事:明确说清一个隔离机制不是安全边界,然后列出它实际值多少钱。 一个含糊地说"我们在沙箱里跑脚本"的文档会直接导致误用。

(注意"和模型现有的 bash 访问同一个信任前提"这句——它把工作流的风险锚定在一个已知量上,而不是留给读者估。)

5.3 脚本的四个钩子

agent(prompt, { label, phase, schema, model })  // 启一个宿主侧子代理;有 schema 返结构化值,否则返最终文本;普通失败返 null
parallel(thunks)                                // 在配置的并发上限下跑
pipeline(items, ...stages)                      // 传 (previous, item, index),无跨阶段栅栏
phase(title) / log(message)                     // 观察者叙述

一条边界画得很准:

A child that resolves normally with a non-completed stop reason is not an infrastructure exception: agent() returns null, allowing the script to handle an ordinary child failure.

普通的子代理失败是 null,脚本自己处理;基础设施故障是 fatal error,穿透 parallel()pipeline() 两类失败不混在一个返回通道里。

而路由决策对脚本不可见

subagentProvider optionally routes every child in that run without exposing provider choice to the script. maxTotalAgents optionally lowers the engine's deployment ceiling for one run and is likewise invisible to the script.

模型写的脚本不能选 provider、不能改上限。 这是"模型写代码"这件事必须配的约束:给它表达力,不给它权限。

5.4 事件只带 info,不带 run

Workflow events are observe-only. They carry WorkflowRunInfo (id plus meta) rather than the live run, so listeners cannot acquire cancellation or disposal authority.

观察者拿不到取消和 dispose 的能力——因为事件载荷里根本没有那个对象。这比"文档要求你别调"可靠得多。

5.5 启动时序:追踪未发布的启动

agent() 一次调用的宿主侧时序里,第 3 和第 5 步最值得看:

If start rejects, the host sends child-start-error; provider startup has already reached quiescence and no child lifecycle event is emitted.

Provider starts are tracked separately from published children. If cancellation, worker death, or normal workflow settlement closes admission while a start is pending, the shared signal aborts it. A provider that nevertheless fulfills after closure is disposed by the host and never announced to the worker.

"正在启动但还没发布"是一个必须单独追踪的状态。 否则一个在关闭准入之后才成功的 provider 启动就变成了泄漏的子代理。

还有一个防竞态的握手:A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice.——worker 启动和取消信号赛跑时,别让脚本的第一个同步片段跑起来。

5.6 承认的限制

  • 只有前台收集——调用方持有一个活的 run 并等它;
  • 没有日志和恢复——脚本、子代理进度、中间值都不做检查点,进程重启不能续跑
  • 不能嵌套——脚本拿不到 workflow() 钩子;
  • 没有 token 预算词汇——引擎限并发、限条目、限子代理数,但请求和结果都不核算跨子代理的 token;
  • run 是持有者所有,不是服务追踪的——卸载引擎不会发现独立的活 handle。

第二条是很重的限制,但它诚实:dsh 的工作流是"跑一次"的编排,不是可恢复的作业系统。

5.7 Ralph:一个把固定策略写成插件的示范

tool-ralph 给同一个不变的目标喂一串全新的子代理:

Each child receives only the immutable objective, its current Ralph round and cap, a shared-workspace-as-authority instruction, and the previous structured handoff. The workspace is long-term memory; parent conversation and prior child sessions are not seeded.

工作区是长期记忆。 上下文不累积——这是绕过上下文窗口的另一条路(对比第 10 章那条压缩的路)。

它对 provider 的要求是硬性的:must exist, support structured output, and report inheritsParentContext: false``。

而报告的校验方式值得注意:

Invalid, missing, or oversized reports fail the workflow instead of being truncated or mistaken for cap exhaustion.

坏报告要报错,不能截断,更不能被误认成"轮次用尽"。 后者会把一个 bug 伪装成正常结局。

还有一处措辞上的克制:

completion and blocker labels in its Native renderer explicitly say that a worker reported the outcome, not independent certification.

"某个 worker 报告说完成了"和"完成了"是两句话。 渲染层不能把前者说成后者。


6. 目标:同一会话内的持续推进

ctx.goals 是第四条路:不派子代理、不写脚本,就在同一个会话里一轮一轮推进一个目标。

6.1 事件溯源 + CAS 围栏

get() returns a detached GoalView; mutations use a GoalRef { id, revision } compare-and-set fence and reject stale refs.

每次变更 append 一个 goal/change载荷是变更后的完整快照

Goal state therefore does not depend on inbox placement, claim, admission, or discard. The session log is the only durable authority.

目标状态不依赖 inbox 的任何细节。 消息可能被丢弃、被重排、被取消——目标状态在日志里,和这些无关。

6.2 严格重放:坏了就停在那里

Strict replay derives lifecycle mutations only from goal/change and rejects malformed shapes, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential admitted goal rounds. Incremental replay retains its cursor at the first corrupt event.

五类拒绝,而且增量重放把游标停在第一个坏事件上——不跳过、不猜。

时钟回拨也被处理了:Mutation timestamps **clamp** against the preceding goal update when wall time moves backward.

./invariant companion 维护一个独立的 fold,在候选事件进入持久日志之前就拒绝它。这正是仓库规则里那条"runtime invariant 应检查事件流之间的关系"的实例。

6.3 活性从不持久化

这是整个目标域最关键的一条设计:

Activation is never persisted. A fresh cache and every agent/session-start edge disarm it even when replay finds an active durable phase.

恢复、fork、换 driver 都保留目标、阶段、修订号和已推进轮数,但都不会自己开始干活。 想继续必须有一次显式的 resume 变更。

"这个会话有一个未完成的目标"和"这个会话现在应该自己动起来"是两件事。 把后者持久化的系统,会在每次恢复时自动开始烧钱。

6.4 一个阶段承载所有停止原因

A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states.

provider 限流、预算耗尽、执行错误、需要人介入——全都是 blocked 加一个 code。 不为每种原因造一个生命周期状态。理由在第 5 章讲过:闭合的小词汇表比开放的大词汇表容易正确处理。

6.5 round driver:预留、双重校验、耐久义务

goal-round-driver 把"活跃且已武装的目标"变成顺序的 goal round:

the driver first checkpoints pending goal mutations, then reserves roundsStarted + 1 for the current { goalId, revision }. It queues one <goal_round> prompt with GoalMessageSource. The agent/pre-step listener verifies the complete claimed record and current goal both before and after downstream listeners; only an entered user/message increments roundsStarted.

三个细节:

在下游监听器之前和之后都校验一次。 第 4 章那个 agent/pre-step 是 serial 的——下游监听器可能改变一切(比如第 10 章的压缩就挂在那里)。

只有真正进入的 user/message 才增加计数。 A reservation rejected as stale **does not consume the round number**.

人类消息不消耗目标预算。 Human messages do not consume the goal cap. 而且混批时自动工作让路:If human work enters the inbox before a reservation or joins its pending batch, automatic work **yields** until the agent becomes idle.

耐久义务写得很硬:

goal/changed creates a durability obligation. Before queuing work, the driver awaits ctx.sessions.flush() and rechecks both the goal revision and competing input after the await. A flush failure arriving through agent/error disarms continuation before another round can start.

排队之前必须先落盘,落盘后必须重新检查。 一个"轮次已推进但没落盘"的状态在崩溃后会重复消耗预算。

取消的处理是保守的:

At the next idle checkpoint the driver pauses a goal with a reserved or admitted attempt so cancellation cannot auto-restart it; cancellation unrelated to a goal attempt only disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming.

用户按了取消,目标不能在下一个空闲点自动重启。 而且暂停变更失败时退化成解除武装——两条路都通向"不自动继续"。

6.6 不做的事:不把前一轮的结果解释成目标结局

The driver does not classify the preceding activity by correlating the goal message with turn/end, so provider errors and token limits are not prompt-level goal outcomes.

驱动器不去猜"上一轮算成功还是失败"。 它只在空闲检查点看持久的目标阶段和修订号。目标的阶段只能被显式的变更改变(模型调 complete/block,或人下命令)。

一个从 turn 结果里推断目标状态的实现,会把一次 provider 500 解释成任务失败。


7. 四个家族怎么组合

它们刻意不互相依赖,但在实践中形成几条路径:

模型要并行探索三个方案      → tool-subagent(one-shot × 3,各自独立上下文)
模型要一个能持续对话的助手   → tool-subagent(continuable)+ send_message + list_agents
模型要写一段自定义编排      → tool-workflow(脚本 + agent/parallel/pipeline 钩子)
固定的"一个目标喂一串新 agent" → tool-ralph(workflow + subagent 的固定策略)
一个目标在同一会话里推进      → ctx.goals + goal-round-driver
一条长跑命令                → ctx.jobs(bash 后台)+ tool-jobs

tool-ralph 是这套组合能力的证明:它是一个普通插件,用 ctx.workflowEngine + ctx.subagents 拼出一个专门的编排策略,agent-loop 一行没改。


8. 设计代价

四套编排机制意味着四份心智模型。 一个新读者要理解 one-shot vs continuable、Activation vs Task vs Job、workflow run vs goal round 的区别。文档很详尽,但概念数量是真实的。

subagent 契约是进程内的。 同进程的请求、descriptor、结果、事件载荷是"借用的不可变值"——不克隆、不冻结。跨进程 provider 必须自己在边界上做序列化和敌意输入校验。

Activation 是进程本地的。 两个 harness 进程不协调驻留和所有权图;共享一个持久化存储需要持久邮箱和跨进程租约协议——明确未实现。

没有持久的 report 邮箱。 report 需要一个活的直接父,提供的是"接受身份",不是恰好一次投递或读取回执。

接受但未记录的消息不会重放。 崩溃可能丢掉一个已被接受、还没进日志的初始 prompt 或 follow-up。可以冷恢复子代理,但那条消息不会自动重放。

取消收敛期有唤醒缺口。 中断信号已发出、活跃 driver 还没变 idle 之间接受的唤醒式 follow-up 会一直排着,直到下一次唤醒式发送。这个缺口和第 4 章那个 wake latch 是同一个 issue。

ACP 子代理只能 one-shot,而且不可枚举。 没有本地子会话,就不在父的会话语料里。

工作流不做检查点,进程重启不能续跑。 而且不能嵌套、不核算 token。

worker thread 不是安全边界。 明说了,但这意味着模型写的脚本拥有宿主进程的权限。

目标域只有一个当前目标。 想并行推进多个目标要靠子代理,不是目标域。

生命周期事件只能观察。 一个能影响 run 的 subagent/end 延续或决策 API 在等一个具体的消费者。


9. 可迁移的经验

1. 竞品可以是你的一个 provider。 subagent-claude-codesubagent-codex 走的是和 spawn 完全一样的 seam。前提是契约足够薄——结果只有 { output, structured?, diagnostic?, stopReason }

2. 派生历史时要切到最近的完成边界。 直接拷贝正在进行的日志会得到一个不平衡的会话(未配对的 tool-call)。而"seq 等于数组下标"这种小不变式会在这种地方省掉一次查找。

3. 看到历史 ≠ 继承权限。 把这两件事在文档和类型里分开;inheritsParentContext 被明确标注为描述性而非可执行。

4. 在委派边界固定权限,并把它写进子的日志。 于是子的有效策略只从它自己的日志就能重建,父后来的改动不追溯影响它。

5. 只复制显式覆盖,不复制默认值。 复制默认值会把"当前恰好是这样"冻结成"永久就是这样"。

6. 没人在看的审批必须确定性地拒绝,不能等。 子代理的审批策略无条件钉成 never

7. 用 scoped context 给不同角色下相反的指导。 顶层 agent 被告知"被拒了就在同一轮里升级重试",子代理被告知"不要重试,把限制写进回复"。同一个机制,因为 scope 不同而说反话。

8. 把必需的参数做成必填,让错误的组合写不出来。 applyChildComposition(childCtx, parent, composition) 收父作为参数,就是为了让"忘记 join 父的组合"在调用点上不可表达。

9. 能力要在启动之前声明,而不是在启动之后试。 一个"先创建再发现不支持"的实现要负责回滚。可选方法的存在本身可以是能力声明。

10. 深度/预算这类限制必须持久化并单调。 只存在运行时选项里的上限,冷恢复就是一次免费重置。

11. 到上限时不要隐藏工具,让调用失败并解释。 让模型从结果里学到边界,而不是从工具是否存在里猜。

12. 跨进程时不假装能管别人的预算。 'provider-managed' 明确表示"不发上限"。

13. "失败"和"异常"要分开。 子代理跑失败是一个正常的 resolve(带 stopReason),只有 seam 表达不了的基础设施故障才 reject。同理工作流里普通子代理失败返 null,fatal error 才穿透。

14. 跨信任边界的诊断字段必须显式列出要剥掉什么,并给出字节上限。 "别放敏感信息"不是可执行的指导。

15. 不要为新场景造第二个队列或第二个状态机。 continuable 子代理用 Agent 自己的 inbox 做唯一队列,三个驻留状态从 Agent 静默性和拥有的子集合推导出来。

16. "停止"和"投递"是两种权限。 停一轮幂等、不传内容,所以权限可以更宽(人类的持久地址、任意活祖先);投递内容必须是精确的活的直接父。

17. 记录发送者 ≠ 授权发送者。 把这两件事放在不同的字段里,并在文档里明说。

18. 运行时写的话不能记在模型名下。 结算通知用一个不同的 source kind。

19. 最需要交代的结局,恰好是当事人没机会自己交代的那些。 所以结算通知无条件投递,不看子代理有没有自己 report 过。

20. 通知投递要看接收方状态选原语。 idle → 新轮;busy → steer 挤进最近的 step 边界(几个同时结算只花一个 step);正在拆除 → 纯注入不唤醒(唤醒会沿树逐层放大)。

21. 丢通知可以接受,丢事实不行。 事实留在子会话的日志里,加一个列举工具和一个恢复通道就够了。

22. 为了重试一个通知而保留资源,会把整条祖先链永久钉住。 投递失败就记日志。

23. 冷恢复依赖的字段必须显式列举,不能是可扩展对象。 否则任意插件加的字段都可能让恢复失败。

24. 可预测的 id 不是边界。 bash-1 谁都猜得到——边界是所有者比对。

25. 权限相关的注册要相对于所有者。 没装控制器的组合不能借别人的控制器开后台工作。

26. 提交点之后不能再有可失败的步骤。 否则会出现"有 id 没工作"这类不一致状态。取消也一样:取消失败就还在跑,状态不能撒谎。

27. 集合级变化需要集合级事件。 "某项被移除"这件事没法用某项的记录表达。而这个事件明确不承载投递语义。

28. 明确说清一个隔离机制不是安全边界,然后列出它实际值多少钱。 worker thread:CPU 隔离、真正的终止手段、空环境、structured-clone 边界——但不是沙箱。含糊地说"在沙箱里跑"会直接导致误用。而把风险锚定到一个已知量("和模型现有的 bash 访问同一个信任前提")比留给读者估要好。

29. 给模型表达力,不给它权限。 模型写的工作流脚本不能选 provider、不能改上限、拿不到定时器和文件系统 API。

30. 观察者拿不到的能力不需要靠文档约束。 事件载荷里只放 { id, meta },不放活的 run。

31. "正在启动但还没发布"是一个必须单独追踪的状态。 否则关闭准入之后才成功的启动就是泄漏。

32. 坏输入要报错,不能截断,更不能被误认成正常结局。 把一个畸形报告当成"轮次用尽"会把 bug 伪装成正常路径。

33. "某个 worker 报告说完成了"和"完成了"是两句话。 渲染层不能替下游做认证。

34. 持久状态和"现在该动起来"要分开。 目标的活性从不持久化——恢复、fork、换 driver 都保留目标,但都不自动开工。把后者持久化的系统会在每次恢复时自动烧钱。

35. 一个 blocked 阶段 + 一个 code,好过五个生命周期状态。 provider 限流、预算、执行错误、需要人介入全走同一个阶段。

36. 严格重放要停在第一个坏事件上,并且把不变式检查放在写入之前。 不跳过、不猜;候选事件进日志前就被独立的 fold 拒绝。

37. 自动工作要给人类让路,而且不消耗人类的额度。 混批时自动 prompt 被拒绝并在检查点后重新预留。

38. 排队之前先落盘,落盘之后重新检查。 一个"轮次已推进但没落盘"的状态在崩溃后会重复消耗预算。

39. 用户取消之后不能自动重启。 而且两条路(暂停失败 → 退化成解除武装)都要通向"不自动继续"。

40. 不要从上一轮的结果推断长期状态。 阶段只能被显式变更改变,否则一次 provider 500 会被解释成任务失败。

41. 派生数据出错不产生判断。 缓存读取抛错就静默回退到权威重算,而不是给出一个错误的结论。


10. 本章源码位置

位置 内容
packages/subagent/README.md 家族地图:11 个包、7 个 provider、3 个模型可见工具
packages/subagent/subagent/src/index.ts:212 startContinuable()
packages/subagent/subagent/src/index.ts:304 drainContinuableDescendants():关闭准入、子先释放
packages/subagent/subagent/src/index.ts:355 listChildren():只读枚举,不碰 manager / Activation / provider
packages/subagent/subagent/src/index.ts:385 registerProvider():effect 作用域、重名 fail loud
packages/subagent/subagent/src/child-agent.ts:102 childSessionMeta():把 join 后的 preset id 写进子 header
packages/subagent/subagent/src/child-agent.ts:163 applyChildComposition():父作为必填参数
packages/subagent/subagent/src/child-agent.ts:199 captureDelegatedPolicyOverrides():审批钉 never,只取显式沙箱覆盖
packages/subagent/subagent/src/child-agent.ts:215 appendDelegatedPolicyOverrides():写在 fork 种子之后
packages/subagent/subagent/src/descriptor.ts subagent/descriptor:版本化、显式字段、log-only
packages/subagent/subagent/README.md Activation、结算通知的两条排序规则、枚举的四种结果、限制清单
packages/subagent/subagent-fork-in-process/src/index.ts:49 completedTurnPrefix():切到最后一个 turn/end
packages/subagent/tool-subagent/README.md 一实例一 provider 一工具名;backgroundModemaxDepth'provider-managed'
packages/jobs/jobs/README.md 所有者围栏、相对所有者的注册、不可失败的提交点、两个事件的区别
packages/workflow/workflow/README.md result 永不 reject、事件只带 WorkflowRunInfoWorkflowErrorfatal
packages/workflow/workflow-worker-thread/README.md worker 不是安全边界、四个脚本钩子、启动时序与未发布启动的追踪
packages/workflow/tool-ralph/README.md 工作区即长期记忆;坏报告 fail 而非截断;"worker 报告"而非认证
packages/goal/goal/README.md goal/change 全量快照、CAS 围栏、严格重放五类拒绝、活性从不持久化
packages/goal/goal-round-driver/README.md 预留 + 双重校验、人类优先、落盘义务、取消后不自动重启
docs/subsystems/subagent.md 子系统参考:启动请求、结果、活跃 run、provider 契约
docs/subsystems/jobs.md job id 方案、所有者围栏契约、快照
docs/subsystems/workflow.md 启动请求、WorkflowMeta、结果、workflow/* 事件
docs/subsystems/goal.md 目标身份、生命周期快照、激活、变更记录