# When must an instance abandon speculative execution: branch reconciliation starvation under non-commutative side effects **Date of Record:** 2026 November 11 **Scope:** Applies to orchestrators that run multiple speculative agent branches against a shared external state (filesystem, database, message bus, payment API) and reconcile them by rollback-and-replay. Does not apply to branches whose side effects are provably commutative and idempotent, nor to pure-compute fan-out with no external writes. ## Core Distinction The common mental model treats speculative execution as a transaction: fork N branches, let them run, pick the winner, roll back the losers. The failure boundary is not "can I roll back?" but "can I *reconcile*?" Rollback is a local operation on state you own. Reconciliation is a global operation over effects you may not own, and it is where speculation dies. The non-obvious invariant: **a speculative branch becomes unrecoverable the moment it emits a non-commutative side effect whose inverse is not strictly cheaper than re-deriving the branch's result.** Once that happens, the branch is no longer speculative — it is committed, whether or not you have decided to keep it. Continuing to treat it as speculative produces reconciliation starvation: the reconciler can neither roll it back (the inverse is expensive or unavailable) nor commit it (a sibling may still win), so it parks the branch in a limbo that blocks the merge of every branch behind it. ## What Was Observed In a three-branch speculative planner (branches A, B, C sharing a task queue and a remote object store), the reconciler was designed to roll back losers by replaying a compensating action log. Branch A, the eventual winner, was fast and clean. Branch B issued a `POST /reservations` against an inventory service that had no idempotency key and no delete endpoint — only a TTL expiry of 900 seconds. Branch C depended on B's reservation ID to proceed. When A won at t=1.2s, the reconciler attempted to roll back B. The compensating action was "wait for TTL." The reconciler, correctly refusing to block, marked B `PENDING_ROLLBACK` and proceeded to merge A. But C's dependency edge on B was still live, so the merge gate held C's outputs, and C's outputs held the downstream aggregation stage. The pipeline stalled for 900 seconds while doing zero useful work, then resumed as if nothing had happened. Throughput for the window dropped 71%; no error was raised; every health check was green. The second observation: the stall was invisible in branch-level metrics. Each branch reported `SUCCESS`. Only the *aggregation* stage showed elevated latency, and it was attributed to the aggregation stage itself for two incident cycles before the dependency edge was found. ## Condition of Validity The rollback-and-replay model is valid only while every branch satisfies all of: 1. **Effect invertibility.** Every external effect has a compensating action with cost `C_inv` bounded by `C_derive`, the cost of re-running the branch from its last checkpoint. If `C_inv > C_derive`, the branch must be re-derived, not rolled back — and re-derivation requires the branch to be *restartable*, which means its inputs must still be live. 2. **Dependency acyclicity at merge time.** No surviving branch may hold a live dependency edge on a branch pending rollback. If it does, the merge gate inherits the rollback's latency. 3. **Idempotency of replayed effects.** Any effect re-emitted during replay must carry a key that the external system honors. A reservation without an idempotency key is not replayable; it is only *repeatable*, which is a different and worse property. When any of these fails, the branch is committed-on-emit. The correct response is to reclassify it immediately, not to queue it for reconciliation. ## Decision Boundary The decision to abandon speculation on a branch is not "did it fail?" It is: ``` abandon_speculation(branch) := emitted_noncommutative_effect(branch) AND NOT has_fast_inverse(branch) AND (has_dependents(branch) OR would_block_merge(branch)) ``` Note the asymmetry: a branch with a non-invertible effect but *no dependents* can be left to expire harmlessly in parallel — it costs resources but not correctness. A branch with a fast inverse but *live dependents* can be rolled back cheaply, but the dependents must be re-pointed before the merge gate opens, or the gate stalls on a dependency that no longer exists. The dangerous case is the conjunction: non-invertible effect plus live dependents. That is the point at which the branch must be promoted to committed and its dependents re-parented or killed. The boundary is therefore not about the branch's own state but about the *graph reachability* of its effects at the instant the winner is decided. A branch that looks speculative at fork time can be committed by a single downstream `await`. ## Operational Effect Enforcing the boundary changes three things: - **Fork cost rises.** Branches must declare their effect class (commutative / invertible / non-invertible) before execution, and the orchestrator must refuse to fork a dependent branch off a non-invertible parent without an explicit re-parenting plan. This is a static check that prevents the dynamic stall. - **Merge gates become graph operations.** The gate must compute reachability over live dependency edges, not just branch status. A branch in `PENDING_ROLLBACK` with live dependents is a gate-blocker and must be surfaced as such, with the dependent's identity, not the branch's. - **Metrics must be edge-aware.** Branch-level success is insufficient. The reconciler must emit `blocked_dependents_count` and `oldest_live_edge_age`; the 900-second stall above would have shown as a monotonic climb in the latter from t=1.2s. The general lesson: speculative execution is a bet on *reversibility*, and reversibility is a property of the effect graph, not of the branch. The moment a branch's effects become load-bearing for another branch, the speculation has already been resolved — by the dependency, not by the winner. ```python class UnreconcilableBranchError(RuntimeError): """Raised when a speculative branch cannot be rolled back or committed.""" def classify_branch(branch, live_edges, derive_cost, inverse_cost): if not branch.emitted_effects: return "speculative" if branch.effects_are_commutative: return "speculative" if inverse_cost <= derive_cost and not live_edges: return "rollbackable" if live_edges and inverse_cost > derive_cost: raise UnreconcilableBranchError( f"branch {branch.id} has non-invertible effects " f"(inverse={inverse_cost}s, derive={derive_cost}s) and " f"{len(live_edges)} live dependents {sorted(live_edges)}; " f"promote to committed and re-parent dependents before merge" ) return "committed_on_emit" def open_merge_gate(winner, branches): blockers = [] for b in branches: if b.id == winner.id: continue edges = b.live_dependents() try: state = classify_branch(b, edges, b.derive_cost, b.inverse_cost) except UnreconcilableBranchError as exc: blockers.append(str(exc)) continue if state == "rollbackable" and edges: blockers.append(f"{b.id}: rollbackable but {len(edges)} live edges") if blockers: raise UnreconcilableBranchError( "merge gate blocked: " + " | ".join(blockers) ) return True ```