Workflow runs gain `run_in_background` over the job registry
workflow 运行经任务注册表获得 `run_in_background`
A `workflow` call blocked the parent turn until the whole script settled: a long orchestration (an audit fanning out over hundreds of files) held the model hostage for its entire wall-clock, with no way to keep working, no live progress for a human, and cancellation as the only exit. Every other long-running execution surface — bash, pwsh, one-shot subagents — already had a `run_in_background` route into `ctx.jobs`.
Source compatibility: Chinese title uses a compatible non-canonical header
English
Problem
A workflow call blocked the parent turn until the whole script settled: a long orchestration (an audit fanning out over hundreds of files) held the model hostage for its entire wall-clock, with no way to keep working, no live progress for a human, and cancellation as the only exit. Every other long-running execution surface — bash, pwsh, one-shot subagents — already had a run_in_background route into ctx.jobs. The record merge also removed the foreground workflow's activity mirror on the explicit promise that live workflow narration would return as a background record job.
Decision
The workflow tool gains run_in_background: true (exposed and accepted while the enableRunInBackground config holds, default on): the call registers the run as an owned kind: 'workflow' job with an observation record and returns { kind: 'background', jobId, runId } immediately.
- The run belongs to the job, not the tool step. The engine run starts inside the job starter with no
exec.signal;job_kill, the job list's stop control, and owner teardown are the cancellation paths, each forwarding its reason intorun.cancel. A synchronous engine rejection (meta/parse failure) propagates out of the starter, so nothing registers and the model sees the ordinary correctable error. - Settlement is the job's settlement.
donechains fromrun.result: dispose (a disposal failure warns and never rejects into the registry), stop the mirrors, then map the stop reason —completedcarries the same rendered return value as the foreground path inJobOutcome.output(so the completion notice andjob_outputdeliver it),cancelledsettleskilledand leaves the detail to the registry's kill-reason merge (the forwarded cancel reason is the same string),errorsettlesfailedwith the script's failure message. - The record mirror replaces the deleted activity mirror.
src/record.tssubscribesworkflow/phase,workflow/log, and member lifecycle events once per plugin and routes them into tracked runs'JobHandlefaces as the same text lines the activity mirror wrote, on thelogchannel so the model'sjob_outputnever renders them;updateProgresstracks the current phase. There is no contained-error wrapper:append/updateProgressare non-throwing surfaces (post-settlement appends drop inside the registry), and a straggling event finds no tracked run. - The output schema becomes a
kind-discriminated union (background|foreground), matching bash's shape; the foreground envelope gainskind: 'foreground'for symmetry. The durable run-start/member/run-end session recording keeps itsexec.parent === undefinedgate on both paths, with the background run-end written from the job'sdonechain. workflowjoinsJobKindMapby declaration merging from the tool package, likepwshandpty.
Alternatives considered
- A start/poll API on the tool itself (a
workflow_statuscompanion): duplicatesjob_output/job_killfor one producer and leaves the run outside owner teardown and the session-header list; the job registry already is the start/poll surface. - Streaming intermediate values to the model (partial results through
readOutput): a workflow's value is the script's single return; per-agent intermediates are script-internal (log()narrates them for humans through the record). A consuming model cursor would invite polling loops against an inherently final-value producer. - Bridging
exec.signalinto the background run: the returning tool step's abort (turn cancellation) would kill work the model just intentionally detached; bash's background route set the precedent that only the registry cancels.
Consequences
The model can fire long orchestrations and keep working; humans watch phases, logs, and member lifecycles stream in the session-header job list and can stop the run there. A background run's value arrives only at settlement (job_output before that returns status alone). The snapshot tree pins the schema, prompt, and PTC stub changes; a recorded-session scenario replaying a full background run is deferred and tracked in the package README's limitations.
中文
问题
workflow 调用会阻塞父级轮次直到整个脚本结算:一次长编排(扇出到数百个文件的审计)把模型按在原地耗完全部墙钟时间,模型无法继续工作,人类看不到实时进度,取消是唯一出口。其他每个长时执行面——bash、pwsh、一次性 subagent——都早已有通往 ctx.jobs 的 run_in_background 路径。record 合并删掉前台 workflow 的 activity 镜像时,也明确承诺实时 workflow 叙述会以后台 record 任务的形式回归。
决策
workflow 工具获得 run_in_background: true(在 enableRunInBackground 配置生效时公开并受理,默认开启):调用把运行注册为自有的 kind: 'workflow' 任务并声明观察 record,立刻返回 { kind: 'background', jobId, runId }。
- 运行属于任务,不属于工具步骤。 引擎运行在任务 starter 内启动且不带
exec.signal;取消路径是job_kill、任务列表的停止控件与 owner 释放,各自把原因转发进run.cancel。引擎的同步拒绝(meta/解析失败)从 starter 传播出去,因此什么都不注册,模型看到的是普通的可修正错误。 - 结算即任务结算。
done从run.result链起:先 dispose(释放失败只告警,绝不 reject 进注册表),再停掉镜像,最后映射结束原因——completed在JobOutcome.output里携带与前台路径相同的渲染返回值(完成播报与job_output由此送达它),cancelled结算为killed并把 detail 留给注册表的 kill 原因合并(转发的取消原因就是同一字符串),error以脚本失败消息结算为failed。 - record 镜像顶替被删的 activity 镜像。
src/record.ts按插件订阅一次workflow/phase、workflow/log与成员生命周期事件,并把它们以 activity 镜像当年写下的同样文本行以log通道路由进被跟踪运行的JobHandle面(模型的job_output从不渲染它们);updateProgress跟随当前 phase。这里没有收容错误的包装层:append/updateProgress是不抛出的表面(结算后的 append 在注册表内丢弃),迟到的事件找不到被跟踪的运行。 - 输出 schema 变为
kind判别联合(background|foreground),与 bash 的形态一致;前台包络为对称获得kind: 'foreground'。持久的 run-start/成员/run-end 会话记录在两条路径上都保留exec.parent === undefined门槛,后台的 run-end 从任务的done链写出。 workflow进入JobKindMap,由工具包声明合并,与pwsh、pty相同。
曾考虑的替代方案
- 工具自带 start/poll 接口(一个
workflow_status伴生工具):为单一生产者复制job_output/job_kill,还把运行留在 owner 释放与会话头部列表之外;任务注册表本身就是 start/poll 表面。 - 向模型流式中间值(经
readOutput给部分结果):workflow 的值就是脚本的单一 return;每个 agent 的中间结果是脚本内部事务(log()经 record 向人类叙述它们)。给天然只有终值的生产者一个消费型模型游标,只会招来轮询循环。 - 把
exec.signal桥接进后台运行: 已返回的工具步骤的中止(轮次取消)会杀掉模型刚刚有意脱管的工作;bash 的后台路径已立下先例——只有注册表取消它。
后果
模型可以点燃长编排后继续工作;人类在会话头部任务列表里看着 phase、log 与成员生命周期流入,并能就地停止运行。后台运行的值只在结算时抵达(此前的 job_output 只返回状态)。快照树钉住 schema、提示词与 PTC 桩的变化;回放完整后台运行的 recorded-session 场景延期,记录在包 README 的限制一节。