LEARN · PHASE 15 · LESSON 104

Failure Propagation — What Happens When One Node Dies

You can say, per node, what counts as failure and who is affected — so one dead

Type
Learn
Prerequisites
Lesson 101 (Claiming); Phase 14, Lesson 95 (Verification Loops)
Time
~35 minutes

Learning Objectives

  • Define failure for a node: exit, output, verification, or timeout
  • Decide what a failure does downstream — propagate, skip, or compensate
  • Bounds a retry and say why it must be idempotent
  • Detect an orphaned node whose owner died

The Problem

A deploy pipeline had a test node between build and release. The test node exited non-zero. The runner logged the error and moved on.

The release node ran anyway. It built on an artifact whose tests had failed, and the deploy went out. The error was sitting in the log the whole time — visible, and ignored, because the runner treated "error" as a note rather than a state.

The inverse happens just as often. A runner treats a failed node as fatal for everything: one docs typo kills the whole build, and three unrelated branches that were fine never ship. Both are the same missing decision. The graph has no answer to "what does a failure mean here", so the runner invents one — per node, by accident.


The Concept

Failure is not a property of an agent. It is a property of the graph, and it has to be decided per node, before the run. Four questions:

1. What counts as failure? A node has failed when:

SignalFail when…
Exit codeThe process returned non-zero.
Empty outputIt produced nothing, or less than the minimum it promised.
VerificationA checker — a test, a schema validation, a linter — rejected the output.
TimeoutIt exceeded its allotted time and was stopped.

A node with none of these is a node that cannot fail, which usually means it cannot be verified either. "Exit 0" is the weakest signal; a verifier is the strongest. (Turning a verifier into a structural check is Phase 14, Lesson 95.)

2. What does it do to the graph? A failure does not travel along every edge — it travels along dependency edges, downstream. A failed node taints its descendants: every node that needed its output is now blocked-by-failure, not merely not-ready. But its siblings — nodes that share a parent but not this node — are untouched. So is every subgraph the failure is not an ancestor of.

flowchart LR
    B[Build] --> T[Tests]
    B --> D[Docs]
    T --> R[Release]
    T -.->|fails| X["✗ failed"]
    R -.->|tainted| RB["blocked-by-failure"]
    D --> pub[Publish docs]
    style X fill:#fdd
    style RB fill:#fdd
Mermaid source — renders as a diagram where mermaid.js is available

Build failed a test, so Release is tainted. Docs never depended on the tests, so Publish docs runs. The failure pruned one branch of the graph and left the rest alone. That is what "propagates" means — not "everything stops".

A node where propagation matters most is the join (Lesson 103). If an all-of join is missing a child, it fails; if the join is a quorum, it may proceed — but it must know one child failed, or it will report success over a hole.

3. Retry, and idempotency. A failure may be transient. Retry it — bounded. An unbounded retry is a node that never completes and never fails, which strands its descendants. A bound of two or three attempts is a decision, and it must be written down.

A retry is only safe if the node is idempotent (Lesson 101): re-running must not double-apply a side effect. A node that appends to a file, or charges a card, must check what already happened before it acts. Retrying a non-idempotent node compounds the failure instead of fixing it.

4. Orphans. A node whose claimant died mid-work is an orphan. Its claim's lease expires (Lesson 101), the node returns to ready, and it is retried or failed on purpose. Without orphan detection, that node is neither running nor failed — it is lost, and its descendants wait forever.

Fail-open versus fail-closed

The last decision is the one with the most consequence: when a node fails and there is no retry left, does the graph proceed anyway or stop?

  • Fail-closed: the failure blocks the run. Correct for irreversible or dangerous steps — a deploy, a migration, a payment.
  • Fail-open: the run continues, marked, with the failure recorded. Correct for advisory steps — a lint, an optional enrichment, a nice-to-have summary.

Neither is "better". The failure comes from leaving the choice implicit, so the default flips depending on which node happened to be written carelessly. Decide it per node, and make the irreversible nodes fail closed by construction.

The reframe: a failure does not kill the graph — it taints the failed node's

descendants, while everything not downstream of it keeps running; the bug is a

runner that treats "error" the same as "done".

The graph semantics are stable; retry defaults, timeouts, and capsule limits are version-gated. Check the changelog and Run agents in parallel before quoting a number.


Build It

Annotate the graph you have (tasks.json, plus decision nodes from Lesson 102).

1. Give every node a failure definition and a policy

Add three fields per node: what fails it, whether it retries, and whether the graph fails closed.

{
  "id": "tests",
  "needs": ["api"],
  "failsOn": ["nonzero-exit", "verifier"],
  "retry": { "attempts": 2, "idempotent": true },
  "onFinalFailure": "fail-closed"
}

✅ You pass when no node says "onFinalFailure": "whatever" and every irreversible node (deploy, migrate, publish) says fail-closed.

2. Force a failure and watch what propagates

Use the join from Lesson 103. Make its upstream node throw.

✅ You pass when the failed node's descendants are marked blocked-by-failure, its siblings still complete, and the run's summary names the failed node rather than reporting blanket success.

3. Bound a retry and prove idempotency

Give the flaky node attempts: 2, make it fail once and succeed second time, and log each attempt. Then make it succeed on the first try and re-run the whole node.

✅ You pass when the retry count stops at the bound and the re-run does not double-apply any side effect.

4. Detect an orphan

Claim a node, then kill the claimant mid-work (Ctrl-C the process) with a short lease. Re-run the runner.

✅ You pass when the expired claim is reclaimed and the orphaned node is retried or failed — not left invisible.


Use It

SituationMove
Deciding what "failed" meansA verifier beats an exit code. Exit 0 with no output is not success.
A node failed and something needs its outputMark descendants blocked-by-failure. Do not run them on partial input.
A node failed with no dependentsLog it and let the rest of the graph finish.
A transient failureRetry, bounded, and only if the node is idempotent.
An irreversible node (deploy, migrate, pay)Fail-closed. Never proceed on error.
An advisory node (lint, optional summary)Fail-open, recorded — the run continues and the gap is visible.
A claimed node whose owner diedLet the lease expire; retry or fail the orphan on purpose.
An all-of join missing a childFail the join, or emit a gap marker. Never report success.

Exercises

  1. Add a failure policy to every node in a real graph, naming what fails it and whether it fails open or closed. ✅ You pass when every irreversible node is fail-closed and at least one advisory node is explicitly fail-open with a recorded marker.
  1. Force a mid-graph node to fail and show the split: descendants tainted, siblings complete. ✅ You pass when the run output names the failed node, lists its blocked descendants, and lists at least one sibling that still finished.
  1. Kill a claimant with a live lease and show the node recovered without a manual cleanup. ✅ You pass when a new attempt runs after the lease lapses and the node is neither lost nor double-applied.

Key Terms

TermWhat people sayWhat it actually means
failure propagation"it crashed the build"A failure taints the failed node's descendants, not unrelated branches.
taint / blocked-by-failure"skipped"A downstream node whose ancestor failed. Distinct from "not ready yet".
verifier"the tests"A structural check that rejects bad output. A stronger failure signal than an exit code.
bounded retry"just retry"A fixed number of attempts. Unbounded retry strands descendants forever.
idempotent"safe to re-run"A retry does not double-apply its side effect.
orphan"stuck"A node whose claimant died; freed when its lease expires.
fail-open / fail-closed"keep going" / "stop"Proceed-with-a-marker versus block-the-run, chosen per node.
compensation"undo"A deliberate action that reverses a partial effect when a node cannot complete.

What this lesson skipped

Verification design. What a good verifier checks, and how to build one, is Phase 14, Lesson 95.

Compensation in depth. Sagas and rollbacks across many nodes are beyond a local-first course; this lesson names the concept and stops there.

Runtime caps. How a runner limits total agents, time, and fan-out is Phase 12, Lesson 81.

Error message catalogue. Specific failure strings are version-gated; the four failure signals above are not.

Check yourself

4 questions. Answer before you reveal — recognising the right option is not the same as being able to produce it.

  1. Graph: build → tests → release, and build → docs. The tests node fails. Which nodes can still run, and which are tainted?

    1. Everything stops; a failure is fatal to the graph
    2. Docs and its descendants can still run; release is tainted because tests is its ancestor
    3. Release can run, because only tests failed
    4. Nothing can run until tests is retried to success
    Show answer

    B. Docs and its descendants can still run; release is tainted because tests is its ancestor

    A failure taints the failed node's descendants (release), not sibling branches that never depended on it (docs). Option A is the tempting "fail the whole run" reflex and throws away independent work. Option C ignores that release consumes tests' output. Option D blocks independent branches on a node they do not need.

  2. A node charges a customer and then, on a transient network error, retries three times. The customer is charged three times. What property was missing?

    1. A longer lease on the claim
    2. Idempotency — a retry must not double-apply its side effect
    3. A soft edge between charge and retry
    4. Fail-open, so the run continues past the error
    Show answer

    B. Idempotency — a retry must not double-apply its side effect

    Bounded retries are only safe if re-running a node does not repeat its side effect; a charge needs a dedupe key or a check-before-write. Option A addresses who owns the node, not repeated effects. Option C is an edge-classification idea and unrelated. Option D would proceed on error, which here means shipping a triple charge.

  3. A lint node fails. A team decides this should stop the whole release. Identify the mistake and the fix.

    1. No mistake — lint failures should always block
    2. A harmless advisory node is fail-closed and is gating work it should not; make it fail-open with a recorded marker unless the lint is a hard gate
    3. The lint node should have no failure definition at all
    4. The lint node needs a longer retry bound
    Show answer

    B. A harmless advisory node is fail-closed and is gating work it should not; make it fail-open with a recorded marker unless the lint is a hard gate

    Fail-open versus fail-closed is chosen per node for its consequence; a lint is advisory unless it enforces a rule the team treats as blocking. Option A is the tempting "stricter is safer," which serialises shipping on a cosmetic check. Option C removes verification entirely. Option D retries a deterministic failure, which cannot fix it.

  4. A worker is killed after claiming a node but before finishing. The runner reports the graph complete, yet the node's descendant never ran. What two mechanisms were missing?

    1. A broader fan-out and a smaller lease
    2. Orphan detection via lease expiry, and propagation so the descendants are marked rather than silently skipped
    3. A soft edge and an interface
    4. A decision node and a spike
    Show answer

    B. Orphan detection via lease expiry, and propagation so the descendants are marked rather than silently skipped

    The claim's lease must expire so the node is retried or failed, and the failure must propagate so the descendant is not quietly omitted. Option A widens the problem without adding recovery or reporting. Options C and D are concepts from Lessons 100 and 102 that do not address a dead owner or a lost descendant.