后台运行
启动长时间运行的流程并立即返回提示符。通过 runId 轮询状态。
普通的 taskflow 调用会阻塞你的会话,直到所有阶段完成。一个三阶段的代码审计如果耗时八分钟,你的提示符就冻结八分钟。
后台运行解决了这个问题。设置 detach: true,MCP 工具在一秒内返回一个 runId。一个子进程接管工作,在每个阶段完成后持久化进度,并在结束时写入最终状态——或者在崩溃时写入失败状态。你可以随时从磁盘轮询运行状态来获取进度更新,也可以在中途失败后恢复它。
本指南详细说明具体的 spawn 机制、如何检查后台运行的状态、审批(approval)阶段会发生什么,以及无头运行的限制。
基本模式
在调用 taskflow 工具时设置 detach: true:
{
"action": "run",
"name": "code-audit",
"args": { "target": "src/auth" },
"detach": true
}工具立即返回:
Taskflow 'code-audit' started in background (pid: 48231). Run id: 20260706T143022-a1b2c3你的会话已释放。流程在一个分离的子进程中运行。PID 记录在运行状态上,你可以检查它是否仍然存活。
工作原理
宿主在磁盘上创建 RunState。 运行以 status: "running"、detached: true 开始,流程定义和参数被持久化。这与所有运行使用的原子写入路径相同——分离进程会把它读回来。
上下文序列化到临时文件。 /tmp/taskflow-detach-<runId>.json 的 JSON 文件携带 runId、defName、args、cwd 以及宿主适配器运行模块的绝对路径(runnerModule)。这个文件是子进程的唯一输入。
生成子进程。 宿主 fork node <detached-runner.js> <context-file>,使用 detached: true 和 stdio: ["ignore", "ignore", "pipe"]。stdout 被忽略;stderr 被管道回父进程以捕获崩溃诊断信息。子进程立即 unref()——它不会保持父进程的事件循环存活。
父进程返回 runId。 子进程的 PID 写入 RunState.pid,MCP 工具响应同时包含 PID 和 runId。父会话可以继续其他工作。
子进程启动并运行流程。 分离运行器读取上下文文件,从磁盘重新发现 agent 配置,动态导入宿主适配器的运行模块,然后调用 executeTaskflow。每个阶段完成后通过同一个 saveRun 路径持久化进度。
写入终态。 成功时,子进程持久化 status: "completed" 和所有阶段输出。失败时,顶层 catch 写入 status: "failed"。崩溃时,父进程的崩溃保护写入合成的 __detach__ 阶段和 stderr 信息。
检查后台运行状态
运行状态是磁盘上的 JSON 文件——与分离进程在每个阶段完成后更新的文件相同。读回来:
/tf runs运行面板显示运行的当前状态、阶段进度和时间。对于后台运行,面板定时轮询文件,你无需重新打开即可看到实时进度——updatedAt 在每个阶段完成后推进。
你也可以检查子进程是否仍然存活。RunState.pid 存储操作系统 PID;isProcessAlive(pid) 使用 process.kill(pid, 0)(信号 0——不发送信号,仅做存活检查)。如果进程已退出但运行仍为 "running",说明出了问题——检查 __detach__ 阶段获取错误信息。
运行状态一览
| 状态 | 含义 |
|---|---|
running | 分离进程存活并正在执行阶段。 |
completed | 所有阶段完成。finalOutput 在最后一个阶段的输出中。 |
failed | 某个阶段失败(重试已耗尽),或子进程崩溃。 |
paused | loop 阶段达到迭代上限并暂停以供审查。 |
blocked | gate 阶段返回 VERDICT: BLOCK。 |
崩溃与 __detach__ 阶段
如果子进程在写入终态之前死亡——OOM kill、段错误、运行模块导入失败——父进程的崩溃保护会触发。它在运行上写入一个合成的 __detach__ 阶段:
{
"id": "__detach__",
"status": "failed",
"error": "Detached runner exited with code 1: Failed to load runner module 'pi-taskflow/dist/runner.js'..."
}崩溃保护仅在运行仍为 "running" 且 PID 匹配时触发——它永远不会覆盖运行器已持久化的真正终态。这意味着:
- 正常退出(code 0): 运行器已持久化自己的状态。崩溃保护不执行操作。
- 非零退出: 崩溃保护检查运行状态。如果仍为
"running",则标记为"failed"并附上捕获的 stderr。 - Spawn 错误:
child.on("error")处理器用 spawn 错误信息写入相同的合成阶段。
__detach__ 阶段可轮询、可调试——你可以在 /tf runs 和运行详情视图中看到它,就像任何其他阶段一样。
审批阶段自动拒绝
审批阶段是安全边界。在正常的交互式运行中,宿主弹出提示并等待你批准、拒绝或编辑。在分离运行中,没有交互式审批者——requestApproval 回调未被注入。
当运行时遇到没有审批者的 approval 阶段时:
output: "(auto-rejected: no interactive approver available)"
approval: { decision: "reject", auto: true }
gate: { verdict: "block" }审批自动拒绝,门控阻塞。依赖审批输出的下游阶段会看到拒绝文本。运行记录该决策,你以后可以审计它。
审批门控在分离模式下永远不会被绕过。自动拒绝不是自动批准——门控阻塞,下游阶段级联失败。如果你的流程有审批门控且需要在 CI 中通过,请移除该门控或重构流程以支持无头执行。
使用场景
CI 流水线
CI 任务可以触发 taskflow、捕获 runId,并在循环中轮询运行状态。无需交互式会话。任务根据终态退出:
# CI 步骤的伪代码
RUN_ID=$(taskflow run --name code-audit --detach --json | jq -r '.runId')
while true; do
STATUS=$(taskflow status --run-id "$RUN_ID" --json | jq -r '.status')
case "$STATUS" in
completed) exit 0 ;;
failed|blocked) exit 1 ;;
running) sleep 10 ;;
esac
done审批阶段在 CI 中自动拒绝,这是正确的行为——你希望流水线在审批门控处暂停,而不是静默地盖章通过。
长时间运行的流程
一个耗时 30 分钟的 20 阶段迁移规划器不应该阻塞你的编码会话。分离它,继续工作,然后回来看:
/tf runs ← 查看进度完成后,运行历史面板显示最终输出。你可以检查各个阶段的输出、使用统计和时间——与前台运行相同的数据。
过夜批处理
在离开前排队几个分离运行。每个运行独立执行,持久化自己的状态,第二天早上可以通过 runId 轮询。运行清理策略(maxKeptRuns、maxRunAgeDays)自动修剪旧运行。
即发即忘的辅助任务
生成文档、运行 linter 扫描或生成报告的后台流程——你想完成但不需要监视的任务——是天然的分离候选。流程运行到完成(或可见地失败),不消耗你的注意力。
上下文文件格式
/tmp/taskflow-detach-<runId>.json 的临时文件携带:
{
"runId": "20260706T143022-a1b2c3",
"defName": "code-audit",
"args": { "target": "src/auth" },
"cwd": "/Users/you/project",
"runnerModule": "/Users/you/.pi/extensions/pi-taskflow/dist/runner.js",
"runnerExport": "piSubagentRunner"
}| 字段 | 用途 |
|---|---|
runId | 预分配的运行标识符。子进程通过此 id 加载其 RunState。 |
defName | 流程名称——用于从磁盘解析已保存的定义。 |
args | 调用参数,已根据流程的 args 模式解析。 |
cwd | 工作目录——agent 运行的位置以及 runsDir 的根目录。 |
runnerModule | 宿主适配器运行模块的绝对路径。子进程在运行时 import() 此模块以获取 runTask 函数。 |
runnerExport | 运行模块上的命名导出(默认为 "piSubagentRunner")。 |
文件在 spawn 时写入一次,运行期间不更新。子进程退出后可安全删除。
分离运行器入口点
detached-runner.ts 是一个仅 spawn 的入口——它不被 taskflow-core 的 barrel(index.ts)导入。它位于 taskflow-core/detached-runner,由宿主适配器通过 import.meta.resolve 解析。运行器在发布的包中已预编译为 .js,因此不需要 --experimental-strip-types 标志。
运行器的启动序列:
- 从
process.argv[2]读取并解析上下文文件。 - 通过
runId加载预保存的RunState。 - 使用
readSubagentSettings()和discoverAgents()重新发现 agent 配置。 - 动态
import(runnerModule)并提取runTask函数。 - 快速失败——如果指定了运行模块但无法加载,写入合成的
__detach__阶段并以非零退出码退出,而不是带着"未注入运行器"的存根继续跛行。 - 调用
executeTaskflow(),带有在每个阶段完成后保存状态的persist回调。 - 成功时持久化终态
RunState。崩溃时,顶层catch写入status: "failed"。
限制
后台运行以交互性换取并发性。这些限制是有意为之。
| 限制 | 原因 |
|---|---|
| 审批门控自动拒绝 | 没有交互式审批者可用。安全门控不得被静默绕过。 |
| 无实时 TUI 流式传输 | 子进程的 stdout 被忽略(stdio: "ignore")。进度仅通过文件轮询可见。 |
宿主无法发送 AbortSignal | 父进程无法在运行中途取消子进程。你可以手动 kill PID。 |
runnerModule 必须可解析 | 子进程在运行时 import() 宿主适配器的运行器。如果模块路径错误,每个阶段都会失败。快速失败保护会提前捕获此问题。 |
| 临时文件必须在子进程读取前保持存在 | /tmp/taskflow-detach-<runId>.json 的上下文文件在启动时同步读取。在子进程启动前删除它会导致立即退出。 |
无 requestApproval 回调 | 宿主无法将审批处理器注入分离进程。这是自动拒绝背后的机制。 |
失败后恢复
中途失败的分离运行是一个普通的失败运行——它有 runId、持久化的阶段状态,以及恢复机制所需的所有数据。以相同的方式恢复它:
{
"action": "resume",
"runId": "20260706T143022-a1b2c3"
}已完成的阶段被跳过(它们的输出已缓存)。只有失败的阶段及其下游依赖项重新执行。恢复本身也可以是分离的——在恢复调用上设置 detach: true 再次在后台触发它。
跨运行缓存
分离运行与前台运行共享同一个跨运行缓存。如果阶段的指纹(输入哈希 + 流程定义哈希)匹配之前缓存的结果,分离运行器会跳过它——不消耗 token。这意味着在修复一个阶段的输入后重新运行分离流程,只需为变更的工作付费。
在工具调用上设置 incremental: true 以默认让每个阶段选择跨运行缓存:
{
"action": "run",
"name": "code-audit",
"detach": true,
"incremental": true
}进程生命周期
父进程(宿主会话) 子进程(分离运行器)
│ │
├─ saveRun(state, detached:true) │
├─ 写上下文 JSON 到 /tmp │
├─ spawn(node, [runner, tmpFile]) ───► ├─ readFileSync(contextPath)
│ ├─ loadRun(runId)
├─ child.on("exit", crashGuard) ├─ import(runnerModule)
├─ child.unref() ├─ executeTaskflow(...)
├─ 返回 { runId, pid } │ ├─ 阶段 1 → saveRun
│ │ ├─ 阶段 2 → saveRun
│ ... 会话继续 ... │ ├─ 阶段 3 → saveRun
│ │ └─ 持久化终态
│ └─ exit(0)
│
├─(崩溃保护:退出码 0 → 无操作)
└─ 用户轮询 /tf runs → 看到 "completed"如果子进程在写入终态之前以非零退出码退出,崩溃保护在 exit 事件和用户下一次轮询的 saveRun 之间触发,将运行标记为 "failed" 并附上捕获的 stderr。
下一步
---
## `meta.json` update
For both `en` and `zh-cn`, insert `guides/background-runs` in the `pages` array. The relevant section becomes:
```json
"guides/tournament",
"guides/background-runs",
"guides/code-audit-case-study",
"guides/headline-tournament-case-study",
"guides/migration-planner-case-study",This places it after the tournament guide (the last "feature deep-dive" guide) and before the case studies, which is the natural reading order: users learn the feature mechanics first, then see real-world examples.
[4/5] risk-reviewer
Based on my analysis of the source files (stale.ts, flowir/index.ts, flowir/translate.ts, flowir/hash.ts, flowir/meta.ts, flowir/phasefp.ts, runtime.ts recompute section, and context-store.ts), I'll now write the two complete reference pages.
Risk Review
I'm acting as a risk-reviewer for documentation changes. Since this is a documentation task (not backend logic/DB/API contracts), this falls outside my primary domain. However, I can assess technical accuracy of the documentation against the source code.
Evidence Verification
Critical facts extracted from source:
-
FlowIR Compilation (M1) -
compileTaskflowToIR(def)inflowir/index.ts:48returnsTaskflowIRwithhash,meta.declaredDeps,usedFallbackHash(always true in stub). -
Hash Algorithm -
flowDefHashinflowir/hash.ts:64=hashCanonical(canonicalJson(def))= SHA-256 truncated to 16 bytes hex. Vendored from overstory for byte-identical contract. -
Per-Phase Fingerprinting (M6) -
phaseFingerprintinflowir/phasefp.ts:68returnsundefinedwhen soundness breaks (shareContext, flow phases, join:"any"), caller falls back to whole-flow hash. Strips policy fields (cache, retry, concurrency, final). -
Observed Read-Sets (M3) -
PhaseState.readsinstore.ts:105=Array<{stepId, version}>. Captured at runtime viaonReadcallback inbuildInterpolationContext(runtime.ts). -
Stale Frontier (M4) -
computeStaleFrontierinstale.ts:89uses union semantics (observed ∪ declared). O(phases + read-edges). Cycles terminate. -
Why-Stale -
formatWhyStaleinstale.ts:147annotates declared-only edges with(declared). -
Recompute (M5) -
recomputeTaskflowinruntime.ts:2738defaults todryRun: true(fail-safe). Guards against unobserved deps (shareContext, flow phases, context files,{previous.output},{args.*},{item.*}) inhasUnobservedDependencies. UsesstructuredCloneto avoid mutation. -
Early Cutoff - In
runtime.ts:2877: phases whoseinputHashdidn't move after upstream re-ran hit their cached result (no re-execution). -
Topological Order -
runtime.ts:2838: frontier phases executed in topological order respecting observed + declared deps (union).
Now I'll write the two .mdx files:
FILE: website/content/docs/en/reference/incremental-recompute.mdx
---
title: Incremental Recompute
description: FlowIR compilation, observed read-sets, stale frontier, and minimal re-execution.
---
When a phase's upstream changes, taskflow can **recompute only the affected phases** instead of re-running the entire flow. This is the incremental-recompute system: a compile-time dependency graph (FlowIR), runtime provenance tracking (observed read-sets), conservative transitive invalidation (stale frontier), and minimal re-execution with early cutoff (recompute).
This page is the technical reference. For the user-facing commands (`/tf why-stale`, `/tf recompute`), see [Commands](/en/docs/reference/commands). For the cross-run cache that recompute builds on, see [Caching](/en/docs/syntax/caching).
## Architecture
The incremental-recompute system has four layers:
| Layer | Milestone | Purpose |
|---|---|---|
| **FlowIR compilation** | M1 | Project the flow definition into a content-addressed IR with declared dependencies. |
| **Observed read-sets** | M3 | Track which upstream outputs a phase actually interpolated at runtime. |
| **Stale frontier** | M4 | Compute the transitive closure of phases invalidated by a changed seed. |
| **Recompute** | M5 | Re-execute only the frontier, with early cutoff for phases whose inputs didn't move. |
Two additional refinements:
- **Per-phase fingerprinting (M6)** — invalidates only the affected phase + its dependents, not independent siblings.
- **Declared-plane derivation (M2)** — synthesizes the declared read-map from the flow definition so staleness uses **union semantics** (observed ∪ declared).
## FlowIR compilation (M1)
`compileTaskflowToIR(def)` projects a desugared `Taskflow` into a `FlowIR` shape:
```typescript
interface TaskflowIR {
ir?: FlowIR; // The compiled IR (nodes with inject/emits)
meta: TaskflowIRMeta; // Compile-time metadata
hash?: string; // Content fingerprint (32 hex chars)
warnings: CompileWarning[]; // Non-fatal advisories
errors: CompileError[]; // Hard errors (none in stub)
usedFallbackHash: boolean; // true in stub (hash is flowDefHash, not IR-canonical)
}The IR projection
Each phase becomes one FlowIRNode:
interface FlowIRNode {
id: string;
kind: string; // Phase type (agent, parallel, map, gate, ...)
inject: string[]; // Declared reads: {steps.X} refs from collectRefs
emits: string[]; // [phase.id] (1:1 projection)
when?: string; // Raw when guard passthrough
}The stub is a structural mirror, not a compile to overstory's native inject/emits model (which expects explicit emit declarations pi-taskflow doesn't have). Each phase's inject is synthesized from {steps.X} interpolation refs; emits is [phase.id].
Content hashing
flowDefHash is a content fingerprint of the desugared definition:
async function flowDefHash(def: Taskflow): Promise<string> {
return hashCanonical(canonicalJson(def));
}canonicalJson— recursively key-sorted (UTF-16 code units), no whitespace,undefineddropped, arrays ordered.hashCanonical— SHA-256, first 16 bytes, lowercase hex (32 chars).- Vendored from overstory — byte-identical to
packages/core/src/ir/hash.ts(pinned commit).
The hash folds into the cross-run cache key (v3:phasefp:<subfp>) so two flows with the same name but different structures never collide.
Declared dependencies (M2)
meta.declaredDeps is the declared plane — the static dependency footprint synthesized at compile time:
interface DeclaredDeps {
reads: string[]; // Upstream step ids referenced by {steps.X} interpolation
writes: string[]; // [phase.id] (what this phase emits)
}Derived from collectRefs(phase) which scans task, when, branches, with, context, until, eval for {steps.X.*} placeholders. Self-refs are excluded (a loop until checking {steps.thisId.output} doesn't create a self-edge).
Per-phase fingerprinting (M6)
phaseFingerprint(def, phaseId) produces a structural sub-fingerprint of only the phase + its transitive dependency closure:
async function phaseFingerprint(def: Taskflow, phaseId: string): Promise<string | undefined>- Soundness gate — returns
undefinedwhen per-phase invalidation is unsound:def.contextSharing === trueor any closure member hasshareContext === true(cross-sibling reads outside declared deps)type === "flow"in the closure (sub-structure resolved at runtime)phase.join === "any"(may read phases outside declared dependsOn)
- Policy fields stripped —
cache,retry,concurrency,finalare removed before hashing (they don't affect subagent output). - Fallback — caller uses the whole-flow
flowDefHashwhenundefinedis returned.
Editing phase B invalidates only B + its transitive dependents; independent sibling A keeps its cache hit.
Stub status
usedFallbackHash is always true in the stub: the hash is flowDefHash (the definition fingerprint), not the overstory-IR-canonical hash. It flips to false once the genuine overstory compiler is vendored.
Observed read-sets (M3)
At runtime, buildInterpolationContext tracks which {steps.X.*} refs a phase actually resolves via an onRead callback:
interface PhaseState {
// ...
reads?: Array<{ stepId: string; version?: string }>;
}stepId— the upstream phase id (e.g.,"scout"from{steps.scout.output}).version— the upstream'sinputHashat the time of the read (for provenance).
Captured only for steps.* refs; args.*, item.*, previous.* are invocation/loop values, not upstream dependencies.
Read map
readMapOf(phases) folds a run's PhaseState into a dependency graph:
type ReadMap = Map<string, readonly string[]>; // phaseId → upstream stepIds
function readMapOf(phases: Record<string, PhaseState>): ReadMap {
const m: ReadMap = new Map();
for (const [id, ps] of Object.entries(phases)) {
const deps = (ps.reads ?? []).map((r) => r.stepId);
if (deps.length) m.set(id, deps);
}
return m;
}Phases with no reads are dropped (they have no upstream dependencies).
Stale frontier (M4)
computeStaleFrontier(reads, seeds, declared) returns the transitive closure of phases invalidated if seeds change:
function computeStaleFrontier(
reads: ReadMap,
seeds: Iterable<string>,
declared?: ReadMap
): Set<string>Union semantics (observed ∪ declared)
When declared is provided, the read graph is the union of:
- Observed (
reads) — what the phase actually interpolated at runtime (M3). - Declared (
declared) — what the phase statically references percollectRefs(M2).
A declared-but-unobserved edge (e.g., a when ref that never fired) still propagates staleness. This is the conservative choice: when in doubt, assume dependency.
Algorithm
BFS from seeds, following dependents (phases that read the seed):
const stale = new Set<string>();
const queue: string[] = [...seeds];
while (queue.length) {
const s = queue.shift() as string;
if (stale.has(s)) continue; // Cycle termination: each phase enqueued at most once
stale.add(s);
for (const dep of dependentsOf(reads, s, declared)) {
if (!stale.has(dep)) queue.push(dep);
}
}
return stale;- O(phases + read-edges) — linear in the size of the dependency graph.
- Cycles terminate — a correct DAG can't produce them, but a pathological one could; the
stale.has(s)guard prevents infinite loops. - Includes seeds — the changed phases themselves are in the frontier.
Declared-plane derivation
declaredReadMapOfDef(def) synthesizes the declared read-map from the flow definition:
function declaredReadMapOfDef(def: Taskflow): ReadMap {
const m: ReadMap = new Map();
for (const p of def.phases) {
const refs = collectRefs(p);
const reads = refs.steps.filter((id) => id !== p.id); // Exclude self-refs
if (reads.length) m.set(p.id, reads);
}
return m;
}Pure. Used by recomputeTaskflow so old runs (pre-H1, no persisted declaredDeps) also get union semantics.
Why-stale semantics
formatWhyStale renders the stale frontier for human consumption:
function formatWhyStale(
runId: string,
flowName: string,
reads: ReadMap,
seeds: readonly string[],
declared?: ReadMap
): stringNo seeds (dependency graph)
When seeds.length === 0, shows the full observed dependency graph:
why-stale — run abc123 · flow "review-changes"
Observed dependency graph (who reads what):
■ lint reads: scout
■ test reads: scout
■ summary reads: lint, testWith seeds (stale frontier)
When seeds are provided, shows the transitive closure with "why" annotations:
why-stale — run abc123 · flow "review-changes"
Assuming changed: scout
Stale frontier (transitive, 4 phases):
■ scout (changed — seed)
■ lint ← reads scout
■ test ← reads scout
■ summary ← reads lint, testDeclared-only edges
Edges present only in the declared plane (not observed at runtime) are annotated with (declared):
■ summary ← reads lint, test (declared)This happens when a phase statically references an upstream via {steps.X} but the interpolation never fired (e.g., a when guard evaluated to false, so the task with the ref was never executed).
Recompute (M5)
recomputeTaskflow minimally re-executes a stale run:
async function recomputeTaskflow(
state: RunState,
deps: RuntimeDeps,
seeds: readonly string[],
opts: { dryRun?: boolean } = { dryRun: true }
): Promise<{ report: RecomputeReport; state: RunState }>Fail-safe default
dryRun: true by default — computes the worst-case frontier without spending a token. A real recompute (dryRun: false) overwrites the run and spends tokens.
Dry-run report
interface RecomputeReport {
readonly dryRun: boolean;
readonly aborted: boolean;
readonly seeds: readonly string[];
readonly rerun: readonly string[]; // Phases that would be (or were) re-executed
readonly reused: readonly string[]; // Phases outside the frontier (untouched)
readonly cutoff: readonly string[]; // Phases in frontier whose inputHash didn't move (early cutoff)
readonly decisions: readonly RecomputeDecision[]; // Per-phase "why"
}
interface RecomputeDecision {
readonly phaseId: string;
readonly outcome: "rerun" | "cutoff" | "reused" | "failed";
readonly reason: string;
readonly causedBy?: readonly string[];
}Dry-run cutoff is always empty (unknowable without execution).
Unobserved dependency guard
dryRun: false throws when the flow has dependencies the observed read-set can't track:
function hasUnobservedDependencies(state: RunState): boolean {
// Scans for:
// - shareContext / contextSharing (Shared Context Tree)
// - type === "flow" (sub-flows)
// - context: [...] (file pre-reads)
// - {previous.output}, {args.*}, {item.*} (interpolation outside steps.*)
}These dependencies are invisible to the observed read-set. Recomputing with dryRun: false could silently skip phases whose deps changed outside the observed frontier, corrupting the run. Use dryRun: true to inspect the frontier, or re-run the whole flow.
Topological order
Frontier phases are executed in topological order respecting observed + declared deps (union):
const augmentedPhases = newState.def.phases.map((p) => ({
...p,
dependsOn: [...new Set([...(p.dependsOn ?? []), ...depsFor(p.id)])],
}));
const order = topoLayers(augmentedPhases)
.flat()
.map((p) => p.id)
.filter((id) => frontier.has(id));This ensures a downstream always sees its (already-refreshed) upstreams when it re-evaluates its cache key. A declared-but-unobserved edge (e.g., a when ref that never fired) still orders the reader after its upstream, preventing false early-cutoff.
Early cutoff
A phase in the frontier whose inputHash didn't move (because its upstream's new output happened to equal the old) hits its cached result — no re-execution:
const ps = await executePhase(phase, newState, deps, prior, noop, 0, execOpts);
if (isSeed || ps.inputHash !== before) {
rerun.push(id);
outputMoved.add(id);
} else {
cutoff.push(id); // Early cutoff: input unchanged, cached result reused
}This is the prize — early cutoff makes recompute cheaper than a full re-run.
Seed forcing
Seeds are always re-executed (execOpts = { forceRerun: true }), bypassing the cache:
const isSeed = seedSet.has(id);
const execOpts = isSeed ? { forceRerun: true } : undefined;Abort safety
A partial recompute is never persisted over the original run:
if (deps.signal?.aborted) {
aborted = true;
break; // Caller discards state when aborted
}State isolation
recomputeTaskflow uses structuredClone(state) to avoid mutating the caller's RunState. Recompute is a speculative replay; only the caller decides whether to persist the new state.
The incremental loop
The three commands form a diagnostic workflow:
/tf ir <flow> → inspect the FlowIR + content hash
/tf why-stale <runId> [phaseId] → inspect the stale frontier
/tf recompute <runId> <phaseId> [--apply] → minimally re-executeStep 1: Inspect the IR
/tf ir review-changesShows the compiled FlowIR, the content hash, and declared dependencies. Useful for understanding what the runtime sees as the flow's structure.
Step 2: Diagnose staleness
/tf why-stale abc123No seed → shows the full dependency graph (who reads what).
/tf why-stale abc123 scoutWith seed → shows the stale frontier if scout changed.
Step 3: Recompute
/tf recompute abc123 scoutDry-run by default — shows what would be re-executed without spending tokens.
/tf recompute abc123 scout --applyReal recompute — re-executes the frontier, with early cutoff for phases whose inputs didn't move.
Concrete walkthrough
A 4-phase flow: scout → lint → test → summary. You've run it once; everything is cached.
Scenario 1: Upstream changed
You edit the scout phase's task. Re-run the flow:
/tf run review-changesscoutre-executes (task text changed →inputHashmoved).lintreadsscout→ in the stale frontier. Re-executes, but itsinputHashmoved (scout's output changed) → no early cutoff.testreadsscout→ same aslint.summaryreadslintandtest→ in the frontier. Re-executes.
All 4 phases re-run. No early cutoff because the upstream change propagated.
Scenario 2: Mid-flow changed
You edit the lint phase's task. Re-run:
/tf run review-changesscoutunchanged → cache hit (cross-run memoization).lintre-executes (task text changed).testunchanged, but readsscout(notlint) → cache hit.summaryreadslint(changed) andtest(unchanged) → in the frontier. Re-executes, but itsinputHashmoved (lint's output changed) → no early cutoff.
3 phases re-run (lint, summary, and scout/test from cache). test is an independent sibling — not invalidated.
Scenario 3: Early cutoff
You edit scout's task, but the new task produces identical output (e.g., you added a comment). Re-run:
/tf run review-changesscoutre-executes (task text changed).lintreadsscout→ in the frontier. Re-executes, butscout's output is identical →lint'sinputHashis unchanged → early cutoff. Cache hit.testsame aslint→ early cutoff.summaryreadslintandtest→ in the frontier, but both upstreams hit early cutoff →summary'sinputHashis unchanged → early cutoff.
Only scout re-runs. The rest hit early cutoff (0 tokens).
Scenario 4: Manual recompute
You want to force scout to re-run without changing its task:
/tf recompute <runId> scout --applyscoutis the seed → forced re-execution.lint,test,summaryare in the frontier → re-execute, with early cutoff if inputs didn't move.
Useful when an external factor changed (e.g., the repo scout reads was updated outside the flow).
Cache-clear
cache-clear wipes the cross-run memoization cache:
# Pi
/tf cache-clear
# MCP
taskflow_cache_clearUse when:
- You've edited the flow definition and want to ensure a clean re-run (though
flowDefHashshould invalidate automatically). - You suspect a cache corruption (rare; the cache uses atomic writes).
- You want to measure the true cost of a flow without cache reuse.
Limitations
Unobserved dependencies
The observed read-set tracks only {steps.X.*} interpolation refs. These are invisible:
- Shared Context Tree (
shareContext,contextSharing) — phases can read sibling blackboard writes outside declared deps. - Sub-flows (
type === "flow") — sub-structure resolved at runtime or from a saved flow. - File pre-reads (
context: [...]) — files read before the phase runs. - Loop locals (
{args.*},{item.*},{previous.output}) — not upstream phase dependencies.
Flows with these dependencies cannot be safely recomputed with dryRun: false. Use dryRun: true to inspect the frontier, or re-run the whole flow.
Stub hash
The current hash (flowDefHash) is the definition fingerprint, not the overstory-IR-canonical hash. Two flows with the same structure but different when guards hash differently (even if the guards never fire). This is conservative — it over-invalidates rather than under-invalidates.
No early cutoff for seeds
Seeds are always re-executed (forceRerun: true), even if their task text is unchanged. This is by design — a seed is an explicit "re-run this" request.
Next
Commands
Pi /tf commands and MCP tools for why-stale and recompute.
Caching
Cross-run memoization and the cache key structure.
Resume
Within-run resume for interrupted flows.
Last updated on