taskflow

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):

  1. Shared Context Tree (def.contextSharing === true or any closure member has shareContext === true): a sharing phase can read sibling blackboard writes outside its declared deps, so the static closure under-approximates real reads.
  2. flow phase in the closure (type === "flow"): a flow phase's sub-structure is resolved at runtime (inline def) or from a saved flow (use) and is not statically visible here.
  3. join: "any" phase (phase.join === "any"): validation exempts it from the {steps.X}-must-be-in-dependsOn check, 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 when ref 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 until convergence 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 scout
why-stale output
why-stale — run abc123 · flow "review-changes"

Assuming changed: scout

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

The 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 tokens

The 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. summary reads {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 (flow phase's use or inline def)
  • 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:

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 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 (or action: "cache-clear") removes all entries.

The incremental loop

The full incremental workflow is: compile → inspect → explain → recompute.

1. Compile the flow

/tf ir review-changes

Output:

/tf ir output
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 abc123
/tf provenance output
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. Explain staleness

A week later, the summary looks stale. Check why:

/tf why-stale abc123 scout
/tf why-stale output
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. Recompute minimally

Dry-run first to see what would change:

/tf recompute abc123 scout
/tf recompute output (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]

Apply the recompute:

/tf recompute abc123 scout --apply
/tf recompute output (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]

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:

1. Author and verify a flow
/tf verify review-changes

Verify catches a cycle and a missing dependsOn before any tokens are spent. Fix the JSON.

2. Run the flow
/tf run review-changes dir=src

The 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).
3. Inspect the compiled IR
/tf ir review-changes

Check the hash and declared dependencies. The hash is stable across runs of the same flow definition.

4. Inspect provenance after the run
/tf runs  # Find the runId
/tf provenance <runId>

See what each phase actually read. This is the ground truth for dependency tracking.

5. A week later: check staleness
/tf why-stale <runId> scout

The scout phase read src/ files; those files changed. The stale frontier includes scout and everything that transitively reads it.

6. Dry-run recompute
/tf recompute <runId> scout

See what would change without spending tokens. The report shows which phases would be rerun vs. reused vs. cut off.

7. Apply the recompute
/tf recompute <runId> scout --apply

Only 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.

8. Clear the cache (optional)
/tf cache-clear

Remove all cross-run cache entries. Use this when you want to force a fresh run (e.g. after a model upgrade).

Command reference

CommandDescription
/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> --applyApply minimal recompute. Spends tokens on rerun phases only.
/tf cache-clearClear the cross-run memoization cache. Zero tokens.

Next

Last updated on

Was this helpful?

Help us improve the docs or ask a question in the community.

On this page