- Release CI on Python 3.10. Documented a known limitation: cancellation
reason strings (
task.cancel("reason")) only propagate throughasyncio.wait/task-await on Python 3.11+. On 3.10 the reason label is dropped (theCancelledErrorarrives with emptyargs); cancellation control flow — stop, drain of in-flight siblings and nested subgraph children, and snapshot/resume invariants — is unaffected. The tests that assert the reason label are now skipped below Python 3.11, and the behaviour is called out under "Known Limitations" in the README. No engine behaviour changed.
A node can now carry a complete child workflow in
config={"graph": {...}}. The child runs on its own scheduler instance —
sharing the registry, event bus, event log, and snapshot store — under the
derived run id {parent}/{node}@{visit}, with its own concurrency budget
(unlimited by default; config={"concurrency_limit": N} or a runtime
manager via Scheduler(..., child_concurrency=...)). The child's single
TERMINAL node's payload becomes the node's result, and terminal execution
ends the child run (in-flight child work is cancelled and awaited). Nesting
can never deadlock: the parent node holds one parent slot while child nodes
draw only on the child manager, and wiring an ancestor's manager into a
child is rejected. Hierarchical resume, per-visit fresh re-entry (bounded
by max_visits), black-box failure equivalence, and single-stream
observability via the namespaced-id public contract all ship with it.
If a crash lands after a child failure is persisted but before the parent
records it, resume does not retry the failed handler. Because snapshot
format 2 stores failure detail rather than the original Python exception
type, that recovered failure surfaces as a descriptive RuntimeError and
is recorded through the parent's ordinary node-failure path.
Handler names gain the qualified publisher.package/function convention
over the unchanged registry, with a transient "namespace" authoring
directive (never emitted by to_dict()) and name@version reserved.
New: UnlimitedPolicy, Scheduler(child_concurrency=..., max_subworkflow_depth=64) (inclusive positive-integer nesting backstop;
ValueError on invalid values, chain-naming RuntimeError when crossed).
Forward incompatibility (release note): a serialized definition
containing a subgraph node raises ValueError at NodeKind(...) parse
on 0.4.x and earlier — older versions reject rather than misload it.
Intentional compatibility exception — role-aware event ids continue:
every public root run now consults the configured event log at start and seeds
its counter from the highest identifier for that graph_id, even when no
snapshot remains. Private child runs consult the log only when their own
snapshot loaded or the parent identified the visit as a recovery candidate. A
fresh child visit in a non-recovered parent run does not read stale log-only
history. A recovered parent conservatively supplies the hint on each SUBGRAPH
node's first dispatch in the current run process, so that child may consult the
log even when the derived visit is new. An event-log
LookupError for an unknown id is treated as empty history only when lookup
yields no events; after any event was yielded it propagates as truncated history
whose maximum is unknowable. Actual resumes fall back to snapshot last_event_id
values when the log has no parseable ids. Resumed runs therefore continue
numbering instead of restarting at 1 and
public-root and recovery-seeded-child (graph_id, event_id) pairs stay unique
across crash/resume in append-only logs. Any composed invocation that reuses a
root graph_id without loading its parent snapshot — including crash recovery
with an event log but no snapshot store, where log-only recovery keeps the
root's own ids collision-free but not child namespaces — may re-derive retained
child ids whose engine-fresh counters restart at 1. Use a new root id to
preserve lifetime pair uniqueness. Clearing or partitioning retained child logs instead
starts a new consumer epoch; reset child cursors/deduplication state and include
that epoch or partition outside the pair.
Consumer migration: remove restart-at-1 assumptions for roots and
recovery-seeded children, and retain cursors plus (graph_id, event_id)
deduplication state across those recoveries; resumed snapshots carry the
continued last_event_id values. No
snapshot-format migration is required (format stays 2). Outside the
counter-seeding and enumerated correctness corrections below, results, event
types/order/payloads, and fresh-run state with no retained history are
byte-identical (pinned by pre-implementation 0.4.0 golden fixtures).
Correctness corrections:
EventBus.emit()accounts for every ordinary listener task and routes every ordinary listener exception through configuredon_errorbefore propagating the first callback failure. If the caller is cancelled during the initial listener wait, unfinished listeners are cancelled and fully drained; failures completed before cancellation or raised during cleanup still reachon_error(or a warning). Repeatedcancel()calls cannot replace the first caller-cancellation arguments. Listener cancellation, callback failure, and scheduler-suppressed sibling failures are warning-visible. A callback-ownedCancelledErrorsurfaces asRuntimeErrorwith that cancellation as its cause, while actual cancellation of theemit()caller propagates unchanged after listener/callback accounting. These drains absorb repeated cancel requests, so a listener oron_errorcallback that never returns blocks cancellation indefinitely — callbacks must be written to terminate. The distinction prevents callback failure from being silently misclassified, as required by Constitution Principle XV.- Inherited 0.4 first-k/partial recovery now preserves the exact fire-decision
input across a crash while the node is queued or running, including inside a
sub-workflow. When snapshot persistence is configured, every
decision-sensitive activation freezes a private,
visit-scoped envelope in the existing version-2
last_payloadfield. Every aggregate withrequired < fan-inand every multi-input partial activation emits one immediate extra snapshot. Fresh execution retains original input identity; replay receives a new isolated marker copy, so mutable fresh-handler input cannot alter the decision in a reference-retaining snapshot. Snapshot keys and format remain unchanged. Re-entered aggregates now select candidates only from current-activation upstream marks, so stale prior-visit results cannot displace the input that reopened the quorum. - Lifecycle-dispatch failures now leave coherent resumable state. A propagated
NODE_SCHEDULEDfailure closes/persists FAILED even though the handler never ran; aNODE_FAILEDcallback error cannot replace the original handler/child exception; aNODE_COMPLETEDcallback error propagates after completion and downstream readiness persist. Format-2 snapshots have no failure-origin marker, so resume treats the scheduled-listener failure as terminal; a deliberate retry requires backend-specific discard, migration, or reset of affected child and ancestor snapshots after the run is inactive, with the normal cleanup/re-execution consequences. - Cancelling a queued
PrioritySemaphorePolicyacquisition removes its heap entry and wakes peers, so later users of a shared policy cannot deadlock on a stale waiter. Plain no-SUBGRAPH roots retain 0.4 cancellation timing: their cancelledrun()may surface while in-flight handler tasks continue and later emit events or persist snapshots. Composed roots cancel and drain descendants before surfacing cancellation. - Composed definitions reject one-shot child iterators before consumption;
repeatable iterables remain accepted. Validation/canonical serialization use
iterative composition walks, so Python recursion limits do not preempt the
configurable scheduler depth guard.
Graph.to_dict()reparses embedded SUBGRAPH bodies; unparseable bodies and composition cycles raiseValueError. Canonical output drops unsupported schema-level keys while retaining arbitrary data inside supportedNode.configandEdge.metadatacontainers.
These corrections require no data migration. Operators may observe additional warning records and coherent snapshots where failing lifecycle callbacks previously interrupted persistence, plus the narrow extra save for every decision-sensitive fire decision and corrected outputs in the previously broken queued/running recovery or aggregate re-entry cases.
Reserved identifier space: run ids and node ids of workflows that use
sub-workflows must not contain / or @; in shared stores, treat
root/…@n-shaped ids as engine-owned once any run uses sub-workflows.
State retention: every visit's child state is retained; cleanup is a
backend-specific administrative action against the {root}/{node}@{visit}
prefix. Never delete state for an active or resumable root run — deleting
an unfinished child snapshot makes resume re-run that work. The core
SnapshotStore protocol gains no list/delete operations.
The scheduler now dispatches every ready node the moment it becomes ready
(eager dispatch), bounded by the configured ConcurrencyManager. Fan-out
branches genuinely run in parallel before their merge point; the capacity
limit finally contends within a run, not just across runs.
Unchanged (no migration, byte-identical behavior):
- Pipelines, switch chains, single loops, and wait-for-all merges produce identical results, event sequences, visit counts, and snapshots.
- Serialized graph definitions and version-2 snapshots load and resume without modification. The snapshot format version remains 2.
SemaphorePolicy(limit=1)reproduces pre-0.4 sequential execution exactly (at most one node in flight, never more than onerunningentry in any persisted snapshot).- Failure surface: the first uncaught handler exception propagates from
run()with its original type — never wrapped.
These bullets describe the 0.4.0 baseline. The 0.5.0 correctness section above adds one schema-preserving intermediate checkpoint for each decision-sensitive first-k/partial activation; ordinary results, events, and final snapshots remain unchanged outside the corrected recovery/re-entry cases.
New semantics (fan-out workflows):
- First-k merges: an
AGGREGATEwithconfig={"required": k}fires at its k-th input completion, exactly once per activation, with the k earliest-completing results in graph-definition order (frozen at the fire decision — capacity queuing never changes the payload).kdefaults to the full fan-in, i.e. wait-for-all. Inputs completing after the merge fired are recorded silently: no re-fire, no error. Previously, quorum merges could execute twice depending on the process hash seed; the new behavior is deterministic. - Partial-input nodes (
allow_partial_upstream=True) receive the result of the input whose completion made them ready (previously the first-in-topology upstream's result, which could be stale orNone). - Deterministic dispatch order: simultaneously-ready nodes dispatch in graph-definition order (previously seed-dependent set order).
- Edge classification: an edge lying on a cycle keeps full loop re-entry semantics, including the defensive hard-stop for back-edges into non-terminal targets; forward (acyclic) edges into an already-activated target record the completion without error.
- Failure teardown: in-flight sibling nodes are cancelled and awaited
before
run()raises; no lifecycle events or snapshot writes occur after the failure surfaces.
Observable changes to note when upgrading fan-out workflows:
- Lifecycle events of concurrent nodes interleave. Per node and per
visit,
NODE_SCHEDULEDstill always precedesNODE_COMPLETED, and exactly one pair is emitted per visit. NODE_SCHEDULEDnow means execution actually started (concurrency slot granted), not "queued".- Mid-run snapshots may contain several
runningentries; resume resets them topendingand re-executes them (at-least-once, as before). - Handlers in parallel branches can interleave at their
awaitpoints. Handlers sharing mutable state must coordinate it themselves — error tolerance likewise stays handler-owned: catch inside the branch, return the error in the payload, and enforce success thresholds in the merge handler.
See specs/003-concurrent-scheduler/ for the full specification,
behavioral contract, and design decisions.
- Activation-frontier scheduling semantics (spec 002): pending seeds from entry nodes only; unselected SWITCH branches never enter pending.
- Snapshot format version 2 with fail-fast rejection of unsupported versions.
- Initial public iterations: loop-first graph engine, event bus, pluggable concurrency policies, snapshot/event-log persistence.