Incremental Recompute
FlowIR compilation, observed read-sets, staleness detection, and minimal recompute.
taskflow's incremental recompute is a cost-asymmetric reactivity system: the cheap effects (figuring out what would be invalidated) run for free; the expensive effects (actually re-running an LLM phase) are gated behind explicit confirmation. This page is the exact reference for how it works.
The system has four layers: FlowIR compilation (content-address the flow definition), dependency tracking (declared + observed read-sets), staleness detection (transitive frontier computation), and minimal recompute (force-rerun seeds, reuse everything else). Each layer is pure where possible, never throws into the run, and degrades safely when its preconditions aren't met.
The entire recompute toolkit — /tf ir, /tf provenance, /tf why-stale, /tf recompute — costs zero tokens. Only recompute --apply spends tokens, and even then only on the phases whose inputs actually changed.
FlowIR compilation
Every run begins by compiling the flow definition into a content-addressed intermediate representation (FlowIR). This compilation is the foundation of cross-run memoization: two structurally identical flows produce the same hash, so a phase from one run can be reused in another.
The compile seam
The compilation is routed through compileTaskflowToIR(def), which returns a 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)
}The compilation is pure + async (uses Web Crypto for hashing) and never throws — a hash failure leaves hash unset and the cache degrades to the legacy flowName-only key (cross-run disabled for that run).
Content addressing
The hash is currently produced by flowDefHash(def), which serializes the desugared definition to canonical JSON (recursively key-sorted, no whitespace, undefined dropped) and hashes it with SHA-256 (first 16 bytes, lowercase hex). This is the vendored overstory hashing contract — byte-identical to overstory's hashIR algorithm.
// 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 is always true in the current stub: the hash is the definition fingerprint, not the IR-canonical hash. It flips to false only once the genuine overstory compiler is vendored. Callers must never mistake a stub hash for a canonical one.
Per-phase sub-fingerprints
phaseFingerprint(def, phaseId) produces a structural sub-fingerprint of only the phase + its transitive dependency closure. Folding this into the cache key (instead of the whole-flow hash) means editing phase B invalidates only B and its transitive dependents — independent sibling phase A keeps its cache hit.
The fingerprint strips policy fields before hashing: cache, retry, concurrency, and final do not affect a phase's subagent output, only execution mechanics or result selection. Including them would cause false cache invalidation on a no-op config change.
Soundness gates
Per-phase invalidation is only sound when a phase's real dependencies are fully captured by the static dependsOn ∪ from closure. Three cases break that guarantee, so phaseFingerprint returns undefined for them and the caller falls back to the whole-flow flowDefHash (safe, = pre-M6 behavior):
- Shared Context Tree (
def.contextSharing === trueor any closure member hasshareContext === true): a sharing phase can read sibling blackboard writes outside its declared deps, so the static closure under-approximates real reads. flowphase in the closure (type === "flow"): aflowphase's sub-structure is resolved at runtime (inlinedef) or from a saved flow (use) and is not statically visible here.join: "any"phase (phase.join === "any"): validation exempts it from the{steps.X}-must-be-in-dependsOncheck, so it may read phases outside its static closure.
Compile-time declared dependencies
The compilation also synthesizes a declared dependency plane (M2): for each phase, collectRefs(phase) scans the task, when, until, eval, score.target, score.judge.task, branches[].task, with.*, and context[] fields for {steps.X.*} interpolation refs. These become the phase's declared reads; its writes are [phase.id] (1:1 projection).
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)
}The declared plane is persisted on RunState.declaredDeps for audit/provenance, but recompute derives it fresh from def so old runs (pre-H1, no persisted declaredDeps) also get union semantics.
Dependency tracking
taskflow tracks dependencies on two planes: the declared plane (compile-time, static) and the observed plane (runtime, dynamic). Staleness detection uses their union so a declared-but-unobserved edge (e.g. a when ref that never fired) still propagates staleness.
Observed read-sets (M3)
Every completed phase records an observed readSet — not what it declared to depend on (dependsOn), but what it truly read at interpolation time. Each entry carries the version (= the read phase's inputHash) it consumed, so a later staleness check can tell whether the upstream has moved.
interface PhaseState {
// ...
reads?: Array<{
stepId: string; // The upstream phase id
version?: string; // The upstream's inputHash when this phase read it
}>;
}This is the overstory "observed readSet@version" moat: no other orchestrator records what a result actually depended on. A phase that interpolates {steps.scout.output} and {steps.analyst.output} has two read entries; a phase with no interpolation refs has none.
Use /tf provenance <runId> to inspect a run's observed read-sets. This is the ground truth for "what did this phase actually consume?"
Declared read-sets (M2)
The declared plane is derived from collectRefs(phase) at compile time. It captures every {steps.X.*} ref the phase could read, including refs inside when guards that may never fire (because the condition is false) and refs inside until convergence checks that may only be evaluated on later iterations.
// 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;A ReadMap is Map<phaseId, readonly string[]> — phaseId to the upstream stepIds it reads. Both observed and declared planes use this shape.
Union semantics
When declared is provided to computeStaleFrontier or dependentsOf, the dependent set is the union of observed dependents (from reads) and declared dependents (from declared). This ensures:
- A
whenref that never fired (because the condition was false) still counts as a dependency — if the referenced phase changes, the guarded phase is marked stale even though it didn't observe the read. - An
untilconvergence check that only evaluated on iteration 3 still propagates staleness from its refs, even though iterations 1 and 2 didn't observe them. - Old runs (pre-H1, no persisted declaredDeps) get union semantics too, because recompute derives the declared plane fresh from
def.
Staleness detection
/tf why-stale explains why a cached run is stale: which phase changed, and what else is transitively invalidated. The computation is pure, deterministic, and zero-token — it reads the persisted RunState and walks the dependency graph.
The stale frontier
computeStaleFrontier(reads, seeds, declared?) returns the transitive closure of phases that are stale if seeds change. A reader is stale if ANY phase it (observed- or declared-)reads is stale (union semantics: when in doubt, assume dependency). The frontier includes the seeds themselves.
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>;The algorithm is BFS over the read graph: enqueue the seeds, then for each seed find its dependents (via dependentsOf), enqueue those, repeat. A phase is enqueued at most once (visited set), so cycles in a pathological read graph terminate. Complexity is O(phases + read-edges).
Explaining staleness
formatWhyStale(runId, flowName, reads, seeds, declared?) renders a human-readable explanation:
- No seeds: shows the full observed dependency graph (who reads what), with declared-only edges annotated
(declared). - With seeds: shows the stale frontier, with each stale phase listing the stale upstreams that caused it (its "why"). Seeds are marked
(changed — seed); downstream phases list their stale reads.
/tf why-stale abc123 scoutwhy-stale — run abc123 · flow "review-changes"
Assuming changed: scout
Stale frontier (transitive, 3 phases):
■ scout (changed — seed)
■ analyst ← reads scout
■ summary ← reads analystThe frontier is conservative: it marks phases stale if they might be affected. The actual recompute (with --apply) checks each phase's inputHash — if the upstream's new output happens to equal the old, the phase hits its cache (early cutoff) and is reused.
Minimal recompute
/tf recompute <runId> <phaseId> minimally recomputes a stale phase: force-rerun the seed, then walk its stale frontier in topological order. The cache provides early cutoff for free — a downstream whose inputHash didn't move (because the seed's new output happened to equal the old) hits its prior result and is reused rather than re-executed.
Dry-run default
Recompute is fail-safe by default: without --apply, it computes the worst-case frontier without spending a token and returns a RecomputeReport describing what would happen.
/tf recompute abc123 scout # Dry-run: what would change?
/tf recompute abc123 scout --apply # Real recompute: spend tokensThe dry-run report explains each phase's outcome:
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
}The decisions array is the "explainable reactivity" layer — like React DevTools telling you why a component re-rendered. Use it to understand why a phase was rerun vs. cut off vs. reused.
Early cutoff
Early cutoff is the prize: phases in the stale frontier whose inputHash did not move after their upstreams re-ran. This happens when:
- The seed's new output is byte-identical to the old (e.g. a refactor that didn't change the rendered docs).
- The seed's output changed, but the downstream phase doesn't interpolate the changed part (e.g.
summaryreads{steps.analyst.output}but only uses the first paragraph, which didn't change).
Early cutoff is what makes recompute cheaper than a full re-run: you pay for the seed and any downstream whose inputs actually moved; everything else is free.
Topological order
Real recompute (--apply) walks the frontier in topological order respecting dependsOn ∪ observed reads ∪ declared reads (M2 union). This ensures a downstream always sees its (already-refreshed) upstreams when it re-evaluates its cache key — no false early-cutoff from evaluating against stale upstream state.
Self-edges are filtered: a loop until checking {steps.thisId.output} must not create a self-loop in the scheduling graph (topoLayers would deadlock).
Safety guards
Recompute with --apply is gated for flows with unobserved dependencies. The observed readSet only tracks {steps.X.*} interpolation refs; it is blind to:
- Shared Context Tree (
shareContext,contextSharing,ctx_read/ctx_write) - Sub-flow internals (
flowphase'suseor inlinedef) - Context file pre-reads (
context: ["src/**/*.ts"]) - Non-steps interpolation (
{previous.output},{args.*},{item.*})
Recomputing such a run with --apply could silently skip phases whose deps changed outside the observed frontier and then persist a corrupted run over the original. The runtime throws:
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.If your flow uses any of the above, --apply is blocked. Use dryRun:true to inspect the frontier, then either (a) change the upstream phase and re-run the whole flow, or (b) refactor to remove the unobserved dependency.
Cross-run caching
The cross-run cache (CacheStore) memoizes phase results across runs. A phase with the same inputHash (interpolated task + flow definition hash + fingerprint) reuses the prior result for $0.00.
Fingerprint resolution
Phases can declare a cache.fingerprint list to fold external state into the cache key. Each entry is resolved to a deterministic string:
| Prefix | Resolution | Example |
|---|---|---|
git:<ref> | git rev-parse <ref> → SHA | git:HEAD → git:HEAD=a1b2c3d... |
glob:<pattern> | File list + size + mtime digest | glob:src/**/*.ts → glob:src/**/*.ts=<digest> |
glob!:<pattern> | File list + content SHA digest | glob!:src/**/*.ts → glob!:src/**/*.ts=<digest> |
file:<path> | File content SHA | file:package.json → file:package.json=<sha> |
env:<name> | Environment variable value | env:NODE_ENV → env:NODE_ENV=production |
Unknown prefixes are rejected at validation time; defensively encoded as <unknown> at runtime. Missing files, non-git repos, and unreadable paths resolve to stable sentinels (<missing>, <no-git>, <skip>) so the key stays deterministic — a later appearance of the resource simply changes the key → cache miss (safe direction).
function resolveFingerprint(entries: string[] | undefined, cwd: string): string;Use glob!: (content mode) for source files — a file whose mtime changed but content didn't (e.g. touch) won't invalidate the cache. Use glob: (stat mode) for generated files where mtime matters.
Cache entry shape
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;
}The full PhaseState is stored (not just output/json) so cross-run reuse is semantically equivalent to within-run resume — storing only the output would drop gate, approval, reads, loop, tournament, warnings, etc., breaking recompute soundness and gate-block detection.
Cache lifecycle
- TTL: entries expire after
cache.ttl(parsed to ms). Default: no TTL (entries persist until evicted). - Max age: hard backstop — entries older than 90 days are dropped regardless of TTL.
- Max entries: 1000 entries per cache dir; oldest evicted when exceeded.
- Clear:
/tf cache-clear(oraction: "cache-clear") removes all entries.
The incremental loop
The full incremental workflow is: compile → inspect → explain → recompute.
1. Compile the flow
/tf ir review-changesOutput:
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. Inspect provenance
After a run, inspect what each phase actually read:
/tf provenance abc123Provenance — 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: 4d5e6f78901234563. Explain staleness
A week later, the summary looks stale. Check why:
/tf why-stale abc123 scoutwhy-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, gate4. Recompute minimally
Dry-run first to see what would change:
/tf recompute abc123 scoutRecompute 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]Apply the recompute:
/tf recompute abc123 scout --applyRecompute 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]The gate phase hit early cutoff: its upstream (analyst) re-ran, but the analyst's output (the list of issues) didn't change, so the gate's inputHash was unchanged and it reused its prior result. You paid for 3 phases, not 4.
Concrete walkthrough
A realistic session from first run to incremental recompute:
/tf verify review-changesVerify catches a cycle and a missing dependsOn before any tokens are spent. Fix the JSON.
/tf run review-changes dir=srcThe run completes. The runtime:
- Compiles the flow to FlowIR (content-addressed hash).
- Executes phases in topological order.
- Captures observed read-sets at interpolation time.
- Persists the run state (including
flowDefHash,declaredDeps,reads).
/tf ir review-changesCheck the hash and declared dependencies. The hash is stable across runs of the same flow definition.
/tf runs # Find the runId
/tf provenance <runId>See what each phase actually read. This is the ground truth for dependency tracking.
/tf why-stale <runId> scoutThe scout phase read src/ files; those files changed. The stale frontier includes scout and everything that transitively reads it.
/tf recompute <runId> scoutSee what would change without spending tokens. The report shows which phases would be rerun vs. reused vs. cut off.
/tf recompute <runId> scout --applyOnly the seed and downstream phases whose inputs actually moved are re-executed. Early cutoff saves tokens for phases whose upstreams re-ran but produced identical output.
/tf cache-clearRemove all cross-run cache entries. Use this when you want to force a fresh run (e.g. after a model upgrade).
Command reference
| Command | Description |
|---|---|
/tf ir <name> | Compile to FlowIR + content hash. Zero tokens. |
/tf provenance <runId> | Show observed read-set provenance for a run. Zero tokens. |
/tf why-stale <runId> [phaseId] | Explain why a cached run is stale. Zero tokens. |
/tf recompute <runId> <phaseId> | Dry-run minimal recompute. Zero tokens. |
/tf recompute <runId> <phaseId> --apply | Apply minimal recompute. Spends tokens on rerun phases only. |
/tf cache-clear | Clear the cross-run memoization cache. Zero tokens. |
Next
Commands
Full command reference for Pi and MCP hosts.
Caching
Cross-run memoization syntax: cache scope, TTL, fingerprint.
Flow Definition
The full phases DAG that FlowIR compiles from.
Last updated on
Was this helpful?
Help us improve the docs or ask a question in the community.