taskflow

增量重算

FlowIR 编译、观测读集、过期检测与最小重算。

taskflow 的增量重算是一个成本非对称的响应式系统:廉价的效果(计算哪些内容失效)几乎免费运行;昂贵的效果(真正重新运行一个 LLM 阶段)则被显式确认所把关。本页是其工作原理的精确参考。

该系统分为四层:FlowIR 编译(对 flow 定义做内容寻址)、依赖追踪(声明读集 + 观测读集)、过期检测(传递性前沿计算)、最小重算(强制重跑种子,其余全部复用)。每一层在可能时都是纯函数,绝不向运行过程抛出异常,并在其前置条件不满足时安全降级。

整个重算工具集 —— /tf ir/tf provenance/tf why-stale/tf recompute —— 都不消耗任何 token。只有 recompute --apply 会消耗 token,而且即便如此,也只花在输入确实发生变化的那些阶段上。

FlowIR 编译

每次运行都以将 flow 定义编译为内容寻址的中间表示(FlowIR)作为开端。这次编译是跨运行记忆化的基础:两个结构相同的 flow 会产生相同的哈希,因此一次运行中的某个阶段可以在另一次运行中被复用。

编译接缝

编译通过 compileTaskflowToIR(def) 路由,它返回一个 TaskflowIR

interface TaskflowIR {
  ir?: FlowIR;                    // The compiled IR (nodes with inject/emits)
  meta: TaskflowIRMeta;           // Compile-time metadata (declared deps, sidecar)
  hash?: string;                  // Content fingerprint (32-hex SHA-256)
  warnings: CompileWarning[];     // Non-fatal advisories
  errors: CompileError[];         // Hard compile errors (none in the stub)
  usedFallbackHash: boolean;      // True when the hash is flowDefHash (not IR-canonical)
}

编译是纯函数 + 异步的(使用 Web Crypto 做哈希),且永不抛出异常 —— 哈希失败时 hash 保持未设置,缓存降级为仅基于 flowName 的旧式键(该次运行的跨运行复用被禁用)。

内容寻址

hash 当前由 flowDefHash(def) 生成,它将脱糖后的定义序列化为规范 JSON(递归按键排序、无空白、丢弃 undefined),并用 SHA-256(前 16 字节,小写十六进制)对其哈希。这是 vendored 的 overstory 哈希契约 —— 与 overstory 的 hashIR 算法逐字节一致。

// Canonical JSON: keys sorted by UTF-16 code units, arrays preserve order
function canonicalJson(value: unknown): string;

// SHA-256 of canonical serialization, first 16 bytes hex
async function hashCanonical(canonical: string): Promise<string>;

// Content fingerprint of the desugared Taskflow definition
async function flowDefHash(def: Taskflow): Promise<string>;

在当前的桩实现中,usedFallbackHash 始终为 true:该哈希是定义指纹,而非 IR 规范哈希。只有当真正的 overstory 编译器被 vendored 之后,它才会翻转为 false。调用方绝不能把桩哈希误当成规范哈希。

阶段级子指纹

phaseFingerprint(def, phaseId) 只对阶段本身 + 其传递依赖闭包生成一个结构性子指纹。把它折进缓存键(而不是整 flow 哈希)意味着:编辑阶段 B 只会让 B 及其传递依赖者失效 —— 相互独立的兄弟阶段 A 仍然命中缓存。

该指纹在哈希前会剥离策略字段cacheretryconcurrencyfinal 不影响阶段的 subagent 输出,只影响执行机制或结果选择。把它们算进去会在无实质改动的配置变更上造成虚假的缓存失效。

可靠性门控

阶段级失效只有在某个阶段的真实依赖被静态 dependsOn ∪ from 闭包完整捕获时才是可靠的。有三种情况会破坏这一保证,因此 phaseFingerprint 对它们返回 undefined,调用方退回到整 flow 的 flowDefHash(安全,= pre-M6 行为):

  1. Shared Context Treedef.contextSharing === true 或闭包中任一成员 shareContext === true):共享阶段可以读取其声明依赖之外的兄弟黑板写入,因此静态闭包欠近似真实读集。
  2. 闭包中的 flow 阶段type === "flow"):flow 阶段的子结构在运行时才解析(内联 def)或从已保存的 flow(use)读取,这里无法静态可见。
  3. join: "any" 阶段phase.join === "any"):校验豁免它“{steps.X} 必须出现在 dependsOn 中”的检查,因此它可能读取其静态闭包之外的阶段。

编译期声明依赖

编译还会合成一个声明依赖平面(M2):对每个阶段,collectRefs(phase) 扫描 taskwhenuntilevalscore.targetscore.judge.taskbranches[].taskwith.*context[] 字段中的 {steps.X.*} 插值引用。这些构成该阶段的声明读集;其写集为 [phase.id](1:1 投影)。

interface DeclaredDeps {
  reads: string[];  // Upstream step ids statically referenced
  writes: string[]; // Step ids this phase emits (currently [phase.id])
}

interface TaskflowIRMeta {
  sourceFlowName: string;
  declaredDeps: Record<string, DeclaredDeps>;  // Per-phase declared footprint
  sidecar: Record<string, unknown>;             // Pi-taskflow-specific fields (round-trip)
}

声明平面会持久化到 RunState.declaredDeps 以便审计/溯源,但重算会从 def 重新派生它,因此旧运行(H1 之前、没有持久化 declaredDeps)也能获得 union 语义。

依赖追踪

taskflow 在两个平面上追踪依赖:声明平面(编译期、静态)和观测平面(运行期、动态)。过期检测使用它们的并集,因此一个“声明但未被观测到”的边(例如一个从未触发的 when 引用)仍然会传播过期。

观测读集(M3)

每个已完成阶段都记录一个观测 readSet —— 不是它声明依赖的内容(dependsOn),而是它在插值时刻真正读取的内容。每个条目都带有它消费时对应的版本(= 被读阶段的 inputHash),这样后续的过期检查就能判断上游是否已经移动。

interface PhaseState {
  // ...
  reads?: Array<{
    stepId: string;      // The upstream phase id
    version?: string;    // The upstream's inputHash when this phase read it
  }>;
}

这就是 overstory “observed readSet@version” 的护城河:没有其他编排器记录一个结果真正依赖了什么。一个插值 {steps.scout.output}{steps.analyst.output} 的阶段有两条读记录;一个没有插值引用的阶段则没有。

使用 /tf provenance <runId> 查看一次运行的观测读集。这是“这个阶段到底消费了什么”的真实依据。

声明读集(M2)

声明平面在编译期由 collectRefs(phase) 派生。它捕获阶段可能读取的每一个 {steps.X.*} 引用,包括可能永不触发(因为条件为假)的 when 守卫里的引用,以及可能只在后续迭代才求值的 until 收敛检查里的引用。

// Fold a run's PhaseStates into a read map (drops phases with no reads)
function readMapOf(phases: Record<string, PhaseState>): ReadMap;

// Build a declared ReadMap from a flow definition (collectRefs per phase)
function declaredReadMapOfDef(def: Taskflow): ReadMap;

ReadMapMap<phaseId, readonly string[]> —— 阶段 id 到它读取的上游 stepId 列表。观测平面和声明平面都使用这个形状。

Union 语义

当把 declared 提供给 computeStaleFrontierdependentsOf 时,依赖者集合是观测依赖者(来自 reads)与声明依赖者(来自 declared)的并集。这确保:

  • 一个从未触发(因为条件为假)的 when 引用仍然算作依赖 —— 如果被引用的阶段变化,被守卫的阶段即便没有观测到这次读取,也会被标记为过期。
  • 一个只在第 3 次迭代才求值的 until 收敛检查,仍会从其引用传播过期,即使第 1、2 次迭代并未观测到它们。
  • 旧运行(H1 之前、没有持久化 declaredDeps)同样获得 union 语义,因为重算会从 def 重新派生声明平面。

过期检测

/tf why-stale 解释一个被缓存的运行为何过期:哪个阶段变了,以及还有什么被传递性失效。该计算是纯的、确定性的、零 token 的 —— 它读取持久化的 RunState 并遍历依赖图。

过期前沿

computeStaleFrontier(reads, seeds, declared?) 返回当 seeds 变化时过期的阶段的传递闭包。一个读者只要(观测地或声明地)读取了任意一个过期阶段,它就过期(union 语义:存疑时按有依赖处理)。前沿包含种子本身。

function computeStaleFrontier(
  reads: ReadMap,           // Observed read map
  seeds: Iterable<string>,  // Phases assumed to have changed
  declared?: ReadMap        // Declared read map (union when provided)
): Set<string>;

算法是对读图的 BFS:把种子入队,然后对每个种子找出它的依赖者(通过 dependentsOf),把它们入队,循环往复。每个阶段至多入队一次(visited 集合),因此病态读图中的环也能终止。复杂度为 O(phases + read-edges)。

解释过期

formatWhyStale(runId, flowName, reads, seeds, declared?) 渲染人类可读的解释:

  • 无种子:展示完整的观测依赖图(谁读了谁),仅声明的边标注 (declared)
  • 有种子:展示过期前沿,每个过期阶段列出导致它过期的上游(即它的“为什么”)。种子标记为 (changed — seed);下游阶段列出其过期读取。
/tf why-stale abc123 scout
why-stale 输出
why-stale — run abc123 · flow "review-changes"

Assuming changed: scout

Stale frontier (transitive, 3 phases):
  ■ scout  (changed — seed)
  ■ analyst  ← reads scout
  ■ summary  ← reads analyst

前沿是保守的:只要阶段可能受影响,就标记为过期。真正的重算(带 --apply)会检查每个阶段的 inputHash —— 如果上游的新输出恰好与旧输出相等,该阶段命中缓存(early cutoff)并被复用。

最小重算

/tf recompute <runId> <phaseId> 最小化地重算一个过期阶段:强制重跑种子,然后按拓扑序遍历其过期前沿。缓存免费提供 early cutoff —— 一个 inputHash 未移动的下游(因为种子的新输出恰好与旧输出相等)会命中其先前结果并被复用,而不是重新执行。

默认干跑

重算默认故障安全:不带 --apply 时,它不花一个 token 就算出最坏情况下的前沿,并返回一份描述将会发生什么的 RecomputeReport

/tf recompute abc123 scout        # Dry-run: what would change?
/tf recompute abc123 scout --apply  # Real recompute: spend tokens

干跑报告解释每个阶段的结局:

interface RecomputeReport {
  dryRun: boolean;
  aborted: boolean;                // True if the user cancelled mid-recompute
  seeds: readonly string[];        // The phase(s) forced to re-run
  rerun: readonly string[];        // Phases that were (would be) re-executed
  reused: readonly string[];       // Phases outside the frontier (untouched)
  cutoff: readonly string[];       // Frontier phases whose inputHash didn't move
  decisions: readonly RecomputeDecision[];  // Per-phase WHY
}

interface RecomputeDecision {
  phaseId: string;
  outcome: "rerun" | "cutoff" | "reused" | "failed";
  reason: string;                  // Human-readable cause
  causedBy?: readonly string[];    // Upstream phases that caused this outcome
}

decisions 数组是“可解释的响应式”层 —— 就像 React DevTools 告诉你某个组件为何重渲染。用它来理解一个阶段为何被重跑、被截断还是被复用。

Early cutoff

Early cutoff 是关键收益:过期前沿中那些在上游重跑后 inputHash 移动的阶段。这发生在:

  • 种子的新输出与旧输出逐字节相同(例如一次未改变渲染文档的重构)。
  • 种子的输出变了,但下游阶段不插值变化的部分(例如 summary 读取 {steps.analyst.output} 但只用了第一段,而第一段没变)。

Early cutoff 正是让重算比完整重跑更便宜的原因:你只为种子和任何输入确实移动的下游付费;其余全部免费。

拓扑序

真正的重算(--apply)按拓扑序遍历前沿,并尊重 dependsOn ∪ observed reads ∪ declared reads(M2 union)。这确保下游在重新评估其缓存键时,总能看到(已刷新的)上游 —— 不会因为基于陈旧上游状态评估而产生虚假的 early cutoff。

自环会被过滤:一个检查 {steps.thisId.output}loop until 不得在调度图中创建自环(topoLayers 会死锁)。

安全守卫

对含有未观测依赖的 flow,带 --apply 的重算是被门控的。观测 readSet 只追踪 {steps.X.*} 插值引用;它对以下情况是盲的:

  • Shared Context TreeshareContextcontextSharingctx_read/ctx_write
  • 子 flow 内部flow 阶段的 use 或内联 def
  • 上下文文件预读context: ["src/**/*.ts"]
  • 非 steps 插值{previous.output}{args.*}{item.*}

对这样的运行用 --apply 重算,可能会静默跳过那些依赖在观测前沿之外发生变化的阶段,然后把一个损坏的运行覆盖回原始运行。运行时会抛出:

recompute dryRun:false is unsafe for this run: it contains dependencies
(shareContext, flow/ctx_spawn, context: files, {previous.output}, {args.*}, or {item.*})
that are not tracked by the observed readSet. Use dryRun:true to inspect
the frontier, or change the upstream phase and re-run the whole flow.

如果你的 flow 使用了上述任一特性,--apply 会被阻止。请用 dryRun:true 检查前沿,然后 (a) 改变上游阶段并重跑整个 flow,或 (b) 重构以移除未观测的依赖。

跨运行缓存

跨运行缓存(CacheStore)跨运行记忆化阶段结果。一个具有相同 inputHash(插值后的 task + flow 定义哈希 + 指纹)的阶段会以 $0.00 复用先前结果。

指纹解析

阶段可以声明一个 cache.fingerprint 列表,把外部状态折进缓存键。每个条目都被解析为一个确定性字符串:

PrefixResolutionExample
git:<ref>git rev-parse <ref> → SHAgit:HEADgit:HEAD=a1b2c3d...
glob:<pattern>File list + size + mtime digestglob:src/**/*.tsglob:src/**/*.ts=<digest>
glob!:<pattern>File list + content SHA digestglob!:src/**/*.tsglob!:src/**/*.ts=<digest>
file:<path>File content SHAfile:package.jsonfile:package.json=<sha>
env:<name>Environment variable valueenv:NODE_ENVenv:NODE_ENV=production

未知前缀在校验期被拒绝;运行期防御性编码为 <unknown>。缺失的文件、非 git 仓库、不可读路径都会解析为稳定的哨兵值(<missing><no-git><skip>),以保证键的确定性 —— 资源后续出现只会改变键 → 缓存未命中(安全方向)。

function resolveFingerprint(entries: string[] | undefined, cwd: string): string;

对源代码文件使用 glob!:(内容模式)—— 一个 mtime 变了但内容没变的文件(例如 touch)不会让缓存失效。对 mtime 有意义的生成文件使用 glob:(stat 模式)。

缓存条目形状

interface CacheEntry {
  key: string;              // The full cache key (inputHash)
  createdAt: number;        // Unix timestamp
  output?: string;          // Trimmed phase output
  json?: unknown;           // Parsed JSON (for output: "json" phases)
  model?: string;           // Model that produced this result
  state?: PhaseState;       // Full PhaseState (gate, approval, reads, loop, etc.)
  flowName?: string;        // Provenance
  phaseId?: string;
  runId?: string;
}

完整的 PhaseState 被存储(而不只是 output/json),这样跨运行复用在语义上等价于运行内恢复 —— 只存输出会丢掉 gateapprovalreadslooptournamentwarnings 等,破坏重算的可靠性和 gate-block 检测。

缓存生命周期

  • TTL:条目在 cache.ttl(解析为毫秒)后过期。默认:无 TTL(条目一直保留直到被驱逐)。
  • Max age:硬性兜底 —— 超过 90 天的条目无论 TTL 如何都会被丢弃。
  • Max entries:每个缓存目录 1000 条;超出时驱逐最旧的。
  • Clear/tf cache-clear(或 action: "cache-clear")移除所有条目。

增量闭环

完整的增量工作流是:编译 → 检查 → 解释 → 重算

1. 编译 flow

/tf ir review-changes

输出:

/tf ir 输出
FlowIR — flow "review-changes"
  hash: a1b2c3d4e5f6789012345678 (usedFallbackHash: true)
  nodes: 4
    ■ scout       kind: agent    inject: []              emits: [scout]
    ■ analyst     kind: agent    inject: [scout]         emits: [analyst]
    ■ gate        kind: gate     inject: [analyst]       emits: [gate]       when: {steps.analyst.json.issues}
    ■ summary     kind: agent    inject: [analyst, gate] emits: [summary]
  declaredDeps:
    scout:     reads: []               writes: [scout]
    analyst:   reads: [scout]          writes: [analyst]
    gate:      reads: [analyst]        writes: [gate]
    summary:   reads: [analyst, gate]  writes: [summary]

2. 检查溯源

运行后,检查每个阶段实际读取了什么:

/tf provenance abc123
/tf provenance 输出
Provenance — run abc123 · flow "review-changes"

  ■ scout
    reads: (none — root phase)
    inputHash: 1a2b3c4d5e6f7890

  ■ analyst
    reads:
      - scout (version: 1a2b3c4d5e6f7890)
    inputHash: 2b3c4d5e6f789012

  ■ gate
    reads:
      - analyst (version: 2b3c4d5e6f789012)
    inputHash: 3c4d5e6f78901234

  ■ summary
    reads:
      - analyst (version: 2b3c4d5e6f789012)
      - gate (version: 3c4d5e6f78901234)
    inputHash: 4d5e6f7890123456

3. 解释过期

一周后,summary 看起来过期了。查一下原因:

/tf why-stale abc123 scout
/tf why-stale 输出
why-stale — run abc123 · flow "review-changes"

Assuming changed: scout

Stale frontier (transitive, 4 phases):
  ■ scout  (changed — seed)
  ■ analyst  ← reads scout
  ■ gate  ← reads analyst
  ■ summary  ← reads analyst, gate

4. 最小重算

先干跑看看会改变什么:

/tf recompute abc123 scout
/tf recompute 输出 (dry-run)
Recompute report (dry-run) — run abc123 · flow "review-changes"
  seeds: [scout]
  rerun: [scout, analyst, gate, summary]
  reused: []
  cutoff: []
  decisions:
    ■ scout     outcome: rerun    reason: forced by recompute request (seed)
    ■ analyst   outcome: rerun    reason: reads a phase in the stale frontier; may re-run if that upstream's output moves
                                   causedBy: [scout]
    ■ gate      outcome: rerun    reason: reads a phase in the stale frontier; may re-run if that upstream's output moves
                                   causedBy: [analyst]
    ■ summary   outcome: rerun    reason: reads a phase in the stale frontier; may re-run if that upstream's output moves
                                   causedBy: [analyst, gate]

应用重算:

/tf recompute abc123 scout --apply
/tf recompute 输出 (applied)
Recompute report — run abc123 · flow "review-changes"
  seeds: [scout]
  rerun: [scout, analyst, summary]
  reused: []
  cutoff: [gate]
  decisions:
    ■ scout     outcome: rerun    reason: forced by recompute request (seed)
    ■ analyst   outcome: rerun    reason: input changed — an upstream's output moved
                                   causedBy: [scout]
    ■ gate      outcome: cutoff   reason: input unchanged — upstream(s) re-ran but produced identical output (early cutoff)
                                   causedBy: [analyst]
    ■ summary   outcome: rerun    reason: input changed — an upstream's output moved
                                   causedBy: [analyst, gate]

gate 阶段命中了 early cutoff:它的上游(analyst)重跑了,但 analyst 的输出(问题列表)没变,所以 gate 的 inputHash 未变,复用了先前结果。你只为 3 个阶段付费,而不是 4 个。

具体走查

一个从首次运行到增量重算的真实会话:

1. 编写并校验一个 flow
/tf verify review-changes

verify 在花任何 token 之前就捕获了一个环和一个缺失的 dependsOn。修正 JSON。

2. 运行 flow
/tf run review-changes dir=src

运行完成。运行时会:

  • 把 flow 编译为 FlowIR(内容寻址哈希)。
  • 按拓扑序执行阶段。
  • 在插值时刻捕获观测读集。
  • 持久化运行状态(包括 flowDefHashdeclaredDepsreads)。
3. 检查编译后的 IR
/tf ir review-changes

检查哈希和声明依赖。同一 flow 定义下哈希跨运行稳定。

4. 运行后检查溯源
/tf runs  # 找到 runId
/tf provenance <runId>

查看每个阶段实际读取了什么。这是依赖追踪的真实依据。

5. 一周后:检查过期
/tf why-stale <runId> scout

scout 阶段读取了 src/ 文件;这些文件变了。过期前沿包含 scout 及所有传递性读取它的阶段。

6. 干跑重算
/tf recompute <runId> scout

不花 token 看看会改变什么。报告展示哪些阶段会被重跑、复用或截断。

7. 应用重算
/tf recompute <runId> scout --apply

只有种子以及输入确实移动的下游阶段会被重新执行。Early cutoff 为那些上游重跑但产出相同结果的阶段节省 token。

8. 清理缓存(可选)
/tf cache-clear

移除所有跨运行缓存条目。当你想强制全新运行时使用(例如模型升级之后)。

命令参考

CommandDescription
/tf ir <name>编译为 FlowIR + 内容哈希。零 token。
/tf provenance <runId>展示一次运行的观测读集溯源。零 token。
/tf why-stale <runId> [phaseId]解释一个被缓存的运行为何过期。零 token。
/tf recompute <runId> <phaseId>干跑最小重算。零 token。
/tf recompute <runId> <phaseId> --apply应用最小重算。只对重跑阶段消耗 token。
/tf cache-clear清理跨运行记忆化缓存。零 token。

接下来

Last updated on

这页内容对你有帮助吗?

帮助我们改进文档,或在社区中提问。

On this page