Skip to content

Workflow graph integrity: a workflow executes exactly the graph it declares - #185

Merged
yellowman merged 16 commits into
mainfrom
claude/new-session-hafb2u-7jzpi5
Aug 27, 2026
Merged

Workflow graph integrity: a workflow executes exactly the graph it declares#185
yellowman merged 16 commits into
mainfrom
claude/new-session-hafb2u-7jzpi5

Conversation

@yellowman

@yellowman yellowman commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Reference validation, tranche 1. Scope is graph-internal integrity and the runtime that executes it — not external reference resolution, which is tranche 2.

The invariant

A workflow executes exactly the graph it declares. Every node id is unique, every explicitly named entrypoint resolves, and every graph edge resolves to a declared node. Invalid persisted graphs fail closed at execution rather than being repaired or truncated silently.

Every defect below is a violation reported as success. The engine did not crash on any of them; it ran a different workflow from the published one and said it worked.

What was wrong, measured

Each row was reproduced by running the engine before anything changed.

declared what actually executed
entrypoint: "nowhere" next(iter(node_map)) — whatever node came first
next: "nowhere" if not node: continue; the continuation vanished
two nodes sharing an id the node_map comprehension kept the last
on_error: recover, breaker open tool -> normal, the success edge, status: "ok"
end with next: "side" nothing; end stops the run
switch with next only branches[].next
tool_call with after only next / on_error
parallel with on_error only next / after
type: "swich" admitted with 201, then invoked the model as a tool call
parallel -> switch -> side fan -> choose -> join; side never ran
parallel -> end -> after the node named end ended nothing
101-node chain 100 nodes ran, success, content "No response generated."
next: ["leaf"] * 150, 3 nodes 150 concurrent tool invocations against a budget of 16, success

Streaming was a second copy of all of it: its own entrypoint fallback, its own if not node: continue, and — because it streams three tools without calling the blocking executor — no circuit-breaker preflight and no on_error handoff at all.

What changed

liminallm/service/workflow_graph.py (new) — one pure validator, returning every problem rather than the first, so each caller raises in its own vocabulary.

The edge fields were measured from the executor, not read off the kind schema. The executor consumes five; after and on_error are not in that schema at all, so a validator written from it would have covered three of five and looked complete. Cardinality was measured the same way: next is the only field read as either a string or a list.

_NODE_EDGES is a per-node-type table, because which fields a node reads is decided by its type, and the field set checked against it is derived from the table — adding a field to one type asks every other type whether it reads it.

_parallel_child_problems covers a third dimension: not what a node reads, but how it was reached. _execute_parallel_nodes discards a child's successor list and reads only "error" out of its status, so a parallel child is a leaf tool_call — SPEC §9.1's "fan-out to multiple nodes, then join", with termination on the after continuation where the ordinary loop can see it.

Two validation altitudes. Admission stops new invalid graphs; the engine checks again before building node_map, because a row can predate the check or arrive by import. The node type is an enum in the kind schema and a semantic check, for that reason. SPEC §9.1 already wrote it as an enum.

One tool-node control plane. _circuit_open_result (the breaker preflight) and _successors (the next-versus-on_error chooser) are shared by the blocking executor, its circuit-open branch, and streaming. Token production stays streaming-specific — that is why the path exists. A streamed failure takes on_error only if it failed before producing a token: once a token has reached the client, recovery would append a second answer to the same bubble, which is the boundary _stream_agent_files_node already keeps.

ExecutionBudget — one object per run, in workflow_limits with the other shared limits, held by the driving loop and the fan-out it dispatches. The reservation sits beside the gather it bounds and is taken before the tasks are built, so an over-budget batch never begins any of it. Each entry in parallel.next costs one, a repeated id included. Both exhaustion mechanisms now fail closed and name which budget ran out.

Evidence

41 mutations, 41 killed, no survivors. Three were retired rather than left surviving, each because it changed no behaviour — a mutation with no effect says the code it adds is dead, not that the tests are weak.

Several rules earned a complementary pair, because losing a rule and over-applying it are both wrong and only one fails loudly:

  • extending the parallel-child rule to the after target kills the control that says the rule is about the context, not the node;
  • marking every streamed node as having emitted a token kills the zero-token recovery witnesses, which "never recover" would otherwise satisfy;
  • reserving one execution for a whole fan-out kills the same four witnesses as making children free — it would satisfy a naive "the budget is consulted" check while leaving fan-out unbounded.

Sixteen anchors went stale across seven passes and the driver reported each as unmeasured rather than as a survivor.

Mutations found three of my own bad witnesses, which is the honest part of this record:

  • a control that ended with pending already empty, so it never exercised the case it was named for;
  • a cycle fixture with two nodes that was witnessing the step budget, not the visit guard — a two-node cycle reaches its eighth visit at step 15 and the step budget stops it at 14;
  • a positive control whose parallel fanned into an end, which under the new rule would have passed on the child rule rather than the field it was named for.

The permanent VALID fixture was the same shape: its parallel children each declared next: "join" while the parallel declared after: "join", so it looked like it exercised a child's next when after was doing all the work.

One witness pins a premise rather than a rule: it runs the refused parallel-child graph with validation disabled and asserts the successor never executes. If parallel ever becomes a recursive subgraph executor, that test fails and says the rule needs revisiting.

Full lane: 3092 passed, 27 skipped. CI's lint selection clean.

Fixture corrections

The node-type enum found 15 existing fixtures declaring llm_call or respond — node types this engine has never executed, in tests that never run the graph. Corrected to a real tool_call rather than loosening the rule.

Not in this PR

Three findings are recorded in docs/ISSUES.md and deliberately left for their own tranches: the PoolClosed teardown race in the xdist lane, streamed failures never incrementing the circuit breaker (SPEC §18 accounting, not §9 graph fidelity), and a refused fan-out leaving its parent out of workflow_trace.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA


Generated by Claude Code


Note

Overview
Workflows are no longer silently repaired or truncated at runtime. A new graph_problems validator (used at artifact admission and again before run / run_streaming) rejects dangling entrypoints/edges, duplicate or empty ids, wrong edge cardinality, node types and edges a type never reads, and invalid parallel children—matching what the executor actually consumes, including after and on_error fields absent from JSON Schema.

The workflow kind schema is tightened (four node type values, minLength on ids, scalar branches[].next). Blocking and streaming now share _circuit_open_result and _successors, so open breakers and tool failures follow on_error instead of the success path; streaming only recovers on on_error when no token was already emitted.

ExecutionBudget charges parallel fan-out before gather, and step/revisit exhaustion returns status: error (or a streaming error event) instead of a placeholder success. Tests and fixtures switch obsolete node types to real tool_call graphs; docs/ISSUES.md records the campaign and follow-ups left out of this tranche.

Reviewed by Cursor Bugbot for commit 186616b. Bugbot is set up for automated code reviews on this repo. Configure here.

claude added 16 commits August 26, 2026 13:48
Three parts of one rule: node ids are unique, an explicitly named entrypoint
resolves, and every graph edge resolves to a declared node. The engine did
none of them, and each failure is silent:

  entrypoint names nowhere   ran node `first` instead
  next names nowhere         continuation vanished at `if not node: continue`
  two nodes share an id      one replaced the other in the node_map dict

The edge fields are measured rather than read off the schema. The executor
consumes five — entrypoint, next as scalar or list, branches[].next, after,
and on_error — and the last two are not in the artifact kind schema at all,
so a validator written from the schema would have covered three of five and
looked finished.

Two altitudes on purpose. Admission stops new invalid graphs; the engine
checks again before it builds node_map, because a row can predate the check
or arrive by import, and repairing one silently at execution is the defect
rather than the fallback.

Controls carry weight here: the valid fixture exercises all five edge kinds,
an absent entrypoint stays legal because starting at the first node is the
documented behaviour, a shapeless graph must not crash the validator on its
way to saying so, and the workflows this system synthesises for itself must
still run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
workflow_graph.graph_problems is pure and returns every problem rather than
the first, so each caller raises in its own vocabulary: admission gives
ArtifactValidationError and the engine BadRequestError. The engine asks
before it builds node_map, because that is where two of these stop being
visible — duplicate ids collapse into one key, and a dangling entrypoint used
to be replaced by whatever node came first.

The edge set was measured from the executor rather than read off the kind
schema. It consumes five fields, and `after` and `on_error` are not in that
schema at all, so a validator written from the schema would have covered
three of five and looked finished. `on_error` is the one that matters most:
it is the transition a workflow takes exactly when it can least afford to
stop silently.

Both altitudes are load-bearing here, unlike the operand rule in the patch
tranche: the mutation removing each kills only its own witnesses. The two
mutations that drop `after` and `on_error` from the edge set likewise kill
only their own, so the schema-invisible pair is separated rather than counted
twice.

Eight mutations, all killed. A ninth was retired rather than left surviving:
re-adding the engine's old entrypoint repair cannot fire once the check above
it has refused such an entrypoint, so it changed no behaviour, which says the
code it adds is dead rather than that the tests are weak.

One control was measuring the harness. It asserted the workflows this system
synthesises still run; they do not under those mocks, with the change stashed
as well. It now asserts they are not refused, and a second witness checks both
built-in graphs against the rule with no harness in the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Two gaps in the tranche, plus a sibling, all reproduced.

Streaming is a separate graph execution path with its own copy of the repair
semantics — the same entrypoint fallback, the same `if not node: continue` —
and it never calls graph_problems. So the pre-existing or imported row this
tranche exists to protect fails closed in blocking chat and still silently
runs a different graph in streaming chat. The engine witnesses only drove
run(), so nothing said so.

Cardinality. graph_problems checks that a reference resolves and not that it
has the shape the executor reads. The executor takes a list only for `next`:
`after` is inserted as one pending node id and `on_error` is wrapped as one
next-node id, so a list in either position reaches node_map.get() as a list.
Neither field is in the artifact kind schema, so JSON Schema does not reject
the shape either. Measured: {"after": ["join"]} and {"on_error": ["join"]}
both pass admission with zero problems and fail at execution.

That is the second half of the lesson from measuring fields the schema does
not know about — their cardinality is unpinned for the same reason their
targets were.

The sibling: `id` has no minLength, graph_problems skips falsy ids, and
node_map drops them, so a declared node with an empty id disappears
unreported — the same silent-removal shape as a duplicate. An explicitly
empty entrypoint is likewise treated as though it were omitted, when omitted
means "start at the first node" and written-empty means the operator named
something that is not a node.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Streaming carried its own copy of the repair semantics — the same entrypoint
fallback, the same `if not node: continue` — and never asked the rule, so an
invalid row failed closed in blocking chat and ran a different graph in
streaming chat. It asks now, before node_map, and keeps its own vocabulary:
blocking raises, streaming emits validation_error and stops before a token or
a trace reaches anyone. The witness asserts that ordering rather than merely
that an error appears somewhere in the stream.

Cardinality. The executor reads a list only for `next`; it inserts `after` as
one pending node id and wraps `on_error` as one next-node id, so a list in
either position arrives at node_map.get() as a list. Measured, both passed
admission with zero problems and failed at execution. _EDGE_FIELDS is a
mapping now, naming per field whether a list is legal there.

branches[].next was a live contradiction: the kind schema advertised
string-or-array while the switch executor appends one value and never
flattens. SPEC 9 gives fan-out to parallel, so the schema was narrowed to
match execution rather than the other way round.

An id that cannot name a node is reported rather than skipped, and carries
minLength 1 in the kind schema — the schema does not reach a row that
predates it. An explicitly empty entrypoint is told apart from an omitted
one: absent means start at the first node, written-empty means the operator
named something that is not a node.

Fifteen mutations, all killed. Two retired rather than left surviving, both
because they added unreachable code. Four anchors went stale across this
tranche's two passes and the driver reported each one instead of a survivor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Recorded after one sighting during the patch tranche and marked not
reproduced. It recurred during the graph tranche, so it is intermittent
rather than a one-off.

The second trace widens the shape. The pool closes underneath a live request
— POST /v1/files/upload — and two different call sites reach for a connection
afterwards: contexts_covering_path during publish, and hold_live_user from
the idempotency guard's exit. So it is not only a fire-and-forget write
outliving its session; request work is still running when the pool goes.

Also worth knowing for whoever picks it up: the test passes in isolation and
logs the unhandled exception, so it can fail the lane without failing its own
assertion, and grepping for the assertion will not find it.

Unrelated to the patch and graph tranches by reachability, and the lane is
green on a re-run. On the reviewer's standing instruction this now deserves
its own concurrency/lifetime red rather than another observation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
The ordinary tool tail swaps `next` for `on_error` on an error result. The
circuit-open branch builds its own error result, reads `next`, and returns
before reaching that swap. Measured on a graph declaring
`tool -> normal` / `on_error: recover`, with the breaker forced open:

  expected  tool -> recover
  actual    tool -> normal

So an open breaker sends the turn down the *success* path, into nodes that
assume outputs the failed node never produced.

Same class as the rest of this file one level in: the declared graph says one
thing and the runtime does another. The validator cannot see it, because the
graph is valid — what is wrong is which edge execution chose. My own
docstring for `on_error` asserts the behaviour this path does not have, which
is what a comment is worth as evidence.

The control matters as much: routing every tool node to `on_error` would pass
the witness above and break every successful turn, so a closed circuit still
taking `next` is asserted beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
The circuit-open branch had its own copy of "where does this node go next".
It read `next`, never looked at `on_error`, and returned before the tail that
does the swap — so a graph declaring `tool -> recover` on failure ran
`tool -> normal` whenever the breaker was open, into nodes that assume
outputs the failed node never produced.

_successors is that decision, once, and both callers use it. A failure is a
failure however it arose.

A second witness came out of the mutations rather than review: removing
on_error from the chooser killed only the circuit-open case, which meant the
primary path — a tool that simply fails — had no witness of its own and was
resting on the breaker case to notice. It has one now, and the two mutations
separate: restoring the early-return copy kills one, removing the rule kills
both.

The control matters as much as either: routing every tool node to on_error
would satisfy both refusals and break every successful turn, so a closed
circuit still taking `next` is asserted beside them.

Seventeen mutations, all killed. Full lane 3042 passed, 27 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
…type

Two seams left in "a workflow executes exactly the graph it declares", both
verified by running the code before anything changed.

`run_streaming` streams `llm.generic`, `llm.generic_chat_v1` and
`agent.files_v1` without calling `_execute_node_with_retry`, so neither
decision the blocking path makes around a tool call happens there. With the
breaker forced open, `generate_stream` was still called and the run traced
`['tool', 'normal']` with `status: ok` — an open breaker did not stop the
call, and the declared `on_error: recover` was never taken. With the backend
raising before the first token, the run traced nothing at all and ended on an
error event, again ignoring `recover`. The same graph on the blocking path
takes `recover` in both cases.

`graph_problems` asks which edges resolve, but not whether the declaring node
reads them. Measured, all five of these reported no problems:

    {"id": "stop",   "type": "end",       "next": "side"}
    {"id": "choose", "type": "switch",    "next": "side", "branches": [...]}
    {"id": "t",      "type": "tool_call", "after": "side"}
    {"id": "fan",    "type": "parallel",  "on_error": "side"}
    {"id": "x",      "type": "swich",     "tool": "llm.generic"}

The first is the sharpest: publish `end -> side`, validation confirms the
edge resolves, execution stops at `end`. The last is the same shape one level
up — SPEC §9 names four node types and writes them as an enum, the kind
schema accepted any string, and `_execute_node` runs anything it does not
recognise as a tool call. The typo was accepted at admission and traced
`{"node": "x", "status": "ok"}`: it invoked the LLM.

14 failing witnesses. The controls pass already, which is what a control is
for: a legal shape per node type, a node with no type at all (the executor
defaults that to `tool_call`, so this altitude does too), a successful stream
still taking `next` with its tokens intact, and a streamed failure with no
error edge still ending the turn rather than being routed down `next`.

Also retargets the two list-cardinality witnesses onto the node types that
read those fields, so they keep measuring cardinality rather than passing for
the new reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Closes the two seams in "a workflow executes exactly the graph it declares".

Streaming shares the tool-node control plane instead of carrying its own.
`_circuit_open_result` is the breaker preflight as a method, asked by both
`_execute_node` and the streaming branch, so an open breaker now stops a
streamed LLM call — it did not stop one at all before, for the three tools
every ordinary chat turn uses. A streamed failure goes through the same
`_successors` chooser as every other tool failure, so a graph declaring
`tool -> recover` takes `recover` whether the call was refused by the breaker
or the backend raised before the first token. Token production stays
streaming-specific: that is why the path exists.

One deliberate asymmetry, with its own control and its own mutation: a
streamed failure whose node declares no `on_error` still ends the stream as
it always did. The chooser answers `next` when no error edge exists, so
handing every failure to it would send a failed node down the success path.

`_NODE_EDGES` replaces the global edge set with a per-node-type table, and the
field set is derived from it, so adding a field to one type also asks every
other type whether it reads it. An edge on a node whose type never reads it is
now a problem, and so is `branches` anywhere but a switch. The node type is the
same shape one level up: SPEC §9's four are an enum in the kind schema and a
semantic check in `graph_problems`, because the schema does not reach a row
that predates it. An absent type is read as `tool_call`, which is what
`_execute_node` does with it.

The rule found fifteen existing fixtures declaring `llm_call` or `respond` —
node types this engine has never executed, in tests that never run the graph.
Corrected to a real tool_call node rather than loosening the rule.

26 mutations, 26 killed, no survivors. The two streaming mutations separate as
intended: bypassing the preflight dies only on the open-breaker witnesses,
removing the handoff only on the `on_error` ones. Reverting the schema enum
alone left the end-to-end admission test green, so the enum got a witness that
exercises the JSON Schema validator by itself rather than being explained away.

Full lane: 3065 passed, 27 skipped. CI's lint selection clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
…ond answer

Both verified by running the code before anything changed.

`_execute_parallel_nodes` calls `_execute_node_with_retry` and throws the
successor list away (`result, _ = await ...`), so how a node was reached
decides whether its declared edges mean anything. On this graph:

    fan:    parallel next=["choose"] after="join"
    choose: switch true -> side
    side:   end
    join:   end

`graph_problems` returned `[]` and the run traced `['fan', 'join']`. `choose`
executed, returned `['side']`, and `side` never ran. The same shape applies to
a `tool_call` child's `next`/`on_error` and to a nested parallel's children
and `after`.

The permanent `VALID` fixture was a warning sign for this: its parallel fanned
into `work` and `other`, both declaring `next: "join"`, while the parallel
itself declared `after: "join"`. The fixture looked like it exercised a
child's `next` when `after` was doing all the work — a witness at the wrong
altitude. Its children now declare nothing, which is what the executor
supports, and `work` keeps `next`/`on_error` on the path that reads them.

The second finding is one the previous commit introduced. Its own test said
recovery after partial output was a separate question; the implementation
answered it by accident. A node that streams a token and then fails still
takes `on_error`, so the client received `['PARTIAL ', 'RECOVERED ANSWER']`
in one bubble and the run traced `['tool', 'recover', 'fin']`. The correct
precedent is one function away in the same file: the attachment agent tracks
`emitted_tokens` for exactly this reason.

8 failing witnesses. The controls pass already: a parallel child that declares
no control flow, the identical node reached by `after` instead, and — pinning
the premise the new rule is derived from rather than the rule itself — the
executor really does discard a child's successor, measured with the graph
check disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
…ry window

`_discarded_by_parallel` closes the third dimension of the graph invariant:
not what a node type reads, but how the node was reached. A node named by
`parallel.next` runs once and its successor list is discarded, so declaring
`next`, `on_error`, `branches[].next` or nested children there publishes
control flow that resolves at validation and executes as nothing. The check
derives from the same `_NODE_EDGES` table — whatever the child's own type
would read is what the parallel throws away — and it reads a scalar `next` as
one child, because `_execute_node` does.

This is the narrow reading, and the one SPEC §9 supports: "fan-out to multiple
nodes, then join" means `parallel.next` names children and `after` owns the
continuation. Making `parallel` a recursive subgraph executor is a
specification decision rather than a bug fix, so this refuses the graphs that
would need one instead of inventing the semantics. A witness pins that premise
directly, running the refused graph with the check disabled: if the executor
ever stops discarding, that test fails and says so.

The streamed `on_error` handoff gains the boundary the last commit walked
past. `emitted_tokens` is tracked per node, and recovery runs only when the
node failed before producing anything; one token or more and the stream
terminates as it always did. This is the policy `_stream_agent_files_node`
already keeps, one function away in the same file, for the same reason — a
token that has been yielded is on the reader's screen and nothing downstream
can take it back.

The `VALID` fixture is restructured. Its parallel children each declared
`next: "join"` while the parallel declared `after: "join"`, so the fixture
looked like it exercised a child's `next` when `after` was producing that
continuation alone. A control that passes for a reason other than the one it
claims is a witness at the wrong altitude.

31 mutations, 31 killed, no survivors. Two rules gained complementary pairs,
because losing a rule and over-applying it are both wrong and only one fails
loudly: extending the parallel rule to the `after` target kills the control
that says the rule is about the context, and marking every streamed node as
having emitted kills the zero-token recovery witnesses that "never recover"
would otherwise satisfy.

Full lane: 3076 passed, 27 skipped. CI's lint selection clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
…cess

Both measured against the running engine first.

`_discarded_by_parallel` asks what edges a child declares, and `end` declares
none — `_NODE_EDGES["end"]` is empty, so it passes cleanly. But `end` carries
control semantics without an edge: its meaning is its status. On the ordinary
path `status == "end"` stops the workflow; `_execute_parallel_nodes` reads
only `"error"` out of a child's status, so `"end"` is an ordinary successful
child. Measured:

    fan:  parallel next=["stop"] after="side"
    stop: end
    side: end

    graph_problems   []
    runtime          traced ['fan', 'side'] — the node named `end` ended nothing

SPEC §9.1 calls `end` the node that produces the final response, and
termination belongs on the `after` continuation where the ordinary loop can
see it.

Two execution budgets bound a run, and on exhaustion both fell through to the
ordinary result. Nothing pins node count at admission, so this is reachable
with a valid acyclic graph:

    101-node switch chain   100 nodes ran, `n100` never did, status None,
                            content "No response generated."
    2-node cycle            14 steps, "workflow_loop_detected" logged,
                            status None, same placeholder content
    streaming, both         message_done, no error event

The configuration says more work remains, the runtime silently omits it, and
the caller is told it worked — this tranche's sentence, with the runtime
rather than the graph on the wrong side of it.

5 failing witnesses. The controls pass already: a chain inside the budget
completes in both paths, an `end` reached by `after` is accepted, and — the
narrow one that keeps the step-budget rule honest — an `end` reached while
siblings are still queued is a legitimate completion, not an exhausted budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
… error

`_parallel_child_problems` states the rule positively now: `parallel.next`
names leaf `tool_call` nodes. The edge check that preceded it could not see
`end`, because `end` declares no edge — its meaning is its status, and
`_execute_parallel_nodes` reads only `"error"` out of a child's status, so an
`end` child was an ordinary success the parent walked past. SPEC §9.1 gives
`end` the final response and `parallel` a fan-out that joins, so a child does
work and termination lives on the `after` continuation where the ordinary
loop can see it.

Both execution budgets fail closed. The step budget is the `while` condition
and the visit budget is a `break`, and each fell through to the ordinary
result — so a valid 101-node chain ran 100 nodes and returned a success whose
content was the placeholder. Blocking now returns `status: "error"` naming the
budget; streaming emits an error event instead of `message_done`. Reaching an
`end` with siblings still queued stays a completion: the two are told apart by
which one happened, not by whether work remains.

Two of my own witnesses were vacuous and mutations found both. The control for
that last distinction ended with `pending` already empty, so it exercised
nothing — it uses a list `next` now and asserts the trace. The cycle fixture
had two nodes and was witnessing the step budget, not the visit guard:
computed, a two-node cycle reaches its eighth visit at step 15 and the step
budget stops it at 14, while three nodes puts the guard at step 13 of 16.
Asserting which budget ran out is what said so, and that assertion stays.

One positive control needed the same repair for the same reason: its parallel
fanned into an `end`, so under the new rule it would have passed on the child
rule rather than on the field it was named for.

39 mutations, 39 killed, no survivors. The two halves of the parallel-child
rule separate — admitting `end` kills only the `end` witness, dropping the
edge loop kills only the tool_call children carrying successors — and so do
the two budgets, which is what pinning the reason bought.

Full lane: 3086 passed, 27 skipped. CI's lint selection clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Measured against the running engine, with the tool call itself stubbed so the
invocation count is the only variable.

`visited` is incremented only in the driving loop. A parallel child runs
inside `_execute_parallel_nodes`, which touches neither `visited` nor
`visited_nodes`, and which builds every child task before awaiting one
`asyncio.gather`. The validator permits any number of leaf `tool_call`
children and nothing caps fan-out:

    graph                        max_steps   tool invocations   result
    152 nodes, 150 children      100         150                success
    3 nodes, "next": [leaf]*150  16          150                success

The second is the sharper one. Three nodes, a budget of sixteen, one repeated
child id — each occurrence is an execution, so a graph naming a single node
began a hundred and fifty concurrent tool calls and the outer loop recorded
two visits. These are real worker invocations, so this is an availability
finding rather than a bookkeeping one.

4 failing witnesses across both execution paths, including one that separates
the two halves of the rule: a fan-out of 40 that fits, followed by a chain
that only exceeds the budget once the children are charged for. Both controls
pass — a small fan-out still runs every child, blocking and streaming — so
"refuse every parallel" cannot satisfy the refusals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
`ExecutionBudget` is one object per run, held by the driving loop and by the
fan-out it dispatches. It lives in `workflow_limits` with the other shared
limits, because `workflow_streaming` cannot import from `workflow` without a
cycle and the two paths must not hold different limits.

The reservation sits inside `_execute_parallel_nodes`, beside the `gather` it
bounds rather than in the two callers, so a third caller cannot forget it. It
is taken before the tasks are built: a batch this run cannot afford never
begins any of it, rather than being cut off partway through. Each entry in
`parallel.next` costs one, a repeated id included — each occurrence is an
execution, whatever it is named.

A fan-out constant would not have repaired the claim that `max_steps` bounds
node executions; it would have replaced one unchecked number with another.
Charging the children is what bounds the rest of the run, and that half has
its own witness: a fan-out of forty that fits, followed by a chain that
exceeds the budget only once the children are counted.

The reservation also replaced an inference, which is a simplification. The
loop used to conclude after the fact that leftover pending work meant the step
budget had run out; now the reason is recorded where the refusal happens, so
`reached_end` is gone and so is the mutation that probed it.

41 mutations, 41 killed. The two fan-out mutations kill exactly the four
fan-out witnesses and neither control: making children free again, and
reserving one execution for the whole batch — the second would satisfy a naive
"the budget is consulted" check while leaving fan-out unbounded.

Two anchors went stale in the rename and the driver reported them rather than
counting survivors. It now accepts mutation names on the command line, so a
retarget is re-measured in seconds.

Full lane: 3092 passed, 27 skipped. CI's lint selection clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
A refused fan-out leaves its parent `parallel` node out of `workflow_trace`:
the node executed — it produced the child list — but the caller breaks on
`budget_exhausted` before appending it. The run fails closed and the reported
failure is honest, so this is ledger completeness, not correctness, and it
belongs wherever `workflow_trace` is qualified as a complete execution ledger.

And one witness is written to expire.
`test_an_ordinary_tool_failure_also_takes_on_error` manufactures a runtime
failure with `no.such.tool.v1` because tool names are not reference-validated
yet. Once they are, that graph is refused before it executes and the witness
becomes invalid by design. The replacement is a real resolvable tool forced to
fail — not a weakened reference rule. Recorded rather than pre-emptively
changed, because it measures the right thing today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
@yellowman
yellowman merged commit 3b12ef5 into main Aug 27, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants