Execution Semantics¶
This page describes how a run proceeds. It is where The Living Graph either holds up or doesn't.
Design, not implementation — Phase 3
The semantics below are decided. src/swarmplane/execution/ is still a package
skeleton, so nothing here has been proven by a running system.
The loop¶
while the run is unfinished:
launch eligible nodes, up to the worker and budget limits
wait for the FIRST outcome to arrive
append the outcome to the log
ask the expansion policy for a disposition
append the disposition, and whatever it justified
Two details in that sketch carry most of the weight.
The outcome is appended before it may create work. A result that has happened but is not yet durable cannot justify anything. This single ordering prevents the two ugliest crash bugs — losing a result that already happened, and launching its follow-up twice.
There is no wave-wide barrier. The loop waits for the first outcome, not for a wave
to finish. If three researchers run concurrently and the first reveals that the run needs a
currency normalizer, the normalizer launches while the other two are still working.
asyncio.gather over a fixed list cannot express this, because both the list and the join
were fixed before any result existed.
Eligibility¶
A node is eligible for expansion when all of these hold:
- it is terminal — it has produced an outcome, success or failure
- it has no recorded disposition
- at least one declared obligation it was created to satisfy is still open
- it is claimable — no other scheduler holds it
- budget and circuit-breaker authorization permit evaluation
Failure is not special. A failed node is eligible on exactly the same terms as a successful one, which is why retry needs no dedicated machinery: a retry is what expansion happens to produce when the parent's obligation is still open.
Dispositions¶
Expansion returns one immutable disposition, which is recorded whether or not it produced children.
| Disposition | Meaning |
|---|---|
Continue(children, satisfies) |
the outcome justified new work |
Complete(evidence) |
this branch satisfied its obligation |
Stop(reason) |
this branch is finished and did not satisfy it |
Waiting(external_ref) |
the next event must come from outside this process |
BudgetBlocked(reason) |
expansion was authorized nowhere |
Recording the disposition — rather than inferring "expanded" from the presence of children — is what lets a branch retire and stay retired, and what preserves why it ended.
It is also the idempotency key. A disposition already recorded against a node is never recorded twice, so replaying an event after a crash cannot duplicate its consequences, and two schedulers cannot both expand the same node.
Waiting¶
Waiting is not failure, and it is not a slow coroutine. It means the runtime has
deliberately released the worker because the next event must come from outside: a human
approval, a webhook, an A2A push.
A waiting node holds no worker, no coroutine and no open request. It is durable graph state carrying an external correlation key, and it returns to eligible when a matching external event is appended.
That distinction is what lets an agent wait six hours without anything running for six hours — see GCP Deployment for why the alternative has a hard ceiling.
A correlation key binds which node resumes. It does not bind what was agreed to, and those are not the same guarantee. So a waiting node also records the exact parameters it intends to proceed with, and an otherwise-matching event whose parameters differ is refused rather than applied — compared after canonicalisation, so that reordering a field cannot carry a change past the check. Whoever approved one action cannot be made to authorise a larger one by anything that happens between the approval and the resume.
The expansion policy is two-tier¶
A deterministic policy owns the invariants — what must precede what, what evidence an answer requires — and is always a legal fallback.
A constrained model policy may propose expansions, but only in a fenced form: it may emit registered node kinds only, edges only to nodes that already exist, and no widened scope. Unknown kinds, duplicate identities, invalid edges and malformed output are rejected, and the rejection is itself recorded as an event rather than silently retried. When the model cannot produce a legal disposition, the deterministic policy continues the run.
The model decides what is useful. Code decides what is possible.
Cancellation is derived, never proposed¶
The model has no cancel verb.
When a disposition records satisfies=[...], any in-flight node whose sole justification
was one of those obligations is cancelled as a derived consequence, computed by code.
Obligation satisfaction is global to the run: a sibling may satisfy an obligation another
node left open.
A cancelled node's late result, if one arrives, is recorded as an outcome and justifies nothing. Cancellation is a fact about obligations, and obligations are declared on the task spec rather than inferred from a goal.
Core semantics, not configuration
A growing graph has no natural stopping condition. The frontier empties when every branch declines to expand, and most runs reach that — but nothing guarantees it, because a language model can always propose one more thing to try.
Budget and circuit-breaking are therefore not safety features bolted to the side. They are the only termination guarantee the system has, and they belong in the type system. An expansion path that can reach a model call without passing a budget check is a severity-one bug, not a missing config option.
What is not settled¶
Budget reservation and settlement. The target is atomic durable reservation, an authorized-call capability, then settlement of actual cost — but concurrent reservation and crash recovery are unresolved, which makes budget the weakest guarantee in a system where it is also the only termination guarantee. This is the most important open problem here. An effect whose outcome is unknown after a crash makes it slightly worse: its cost is unknown too, so the reservation can be neither settled nor released, and which of those to do anyway is undecided.
OpenTelemetry mapping. A span has zero or one parent; a node at a join has several causal parents. Whether one becomes the span parent with the rest as links is undecided, so "every node maps 1:1 to a span" is not yet fully specified.