Skip to content

Dispatch nested routines to the pool and implement a Wool-picklable contextvars.Token wrapper — Closes #278, #231#282

Draft
conradbzura wants to merge 8 commits into
wool-labs:masterfrom
conradbzura:278-dispatch-nested-routines-to-pool
Draft

Dispatch nested routines to the pool and implement a Wool-picklable contextvars.Token wrapper — Closes #278, #231#282
conradbzura wants to merge 8 commits into
wool-labs:masterfrom
conradbzura:278-dispatch-nested-routines-to-pool

Conversation

@conradbzura

Copy link
Copy Markdown
Contributor

Summary

Fix nested routine dispatch and make wool.Token survive it. Routine dispatch shipped the raw undecorated function to workers, so nested @wool.routine calls ran in-process on the worker instead of dispatching across the pool. Fixing that exposed the missing half of the story: the stdlib contextvars.Token returned by wool.ContextVar.set cannot pickle, so the downstream-reset pattern — set on the caller, reset on a worker — silently dropped the token at the serialization boundary.

Introduce a first-class wool.Token that crosses the wire through the dispatch pickler while reproducing stdlib reset semantics end to end: single use enforced across processes, per-variable token binding, and per-context ownership with stdlib's canonical error types and messages. Vanilla pickle, cloudpickle, and copy remain rejected — the token serializes only through a dispatch. Guiding principle throughout: stdlib parity, with an awaited routine behaving like an awaited local coroutine.

Trade-offs worth noting: the wire manifest carries two new bookkeeping sets (consumed token ids per var, live token ids per chain) shipped in full on every frame; diff-encoding is deferred. Context ownership is decided receiver-side at mount time rather than at serialize time, because a worker pickles its result in the gRPC handler outside the routine's context — the manifest, captured inside the right context, is the reliable witness.

Closes #278
Closes #231

Proposed changes

Dispatch the routine wrapper so nested routines reach the pool

Put the decorated wrapper in the Task callable instead of the raw function. On the worker the wrapper runs under do_dispatch(False), takes its local-execution branch, and _execute/_stream re-enable do_dispatch(True) around the body so nested calls dispatch as intended. The wrapper also pickles by reference for importable routines, shrinking the serialized callable.

Carry token bookkeeping on the wire chain manifest

Add external_used (consumed token ids, per ContextVar entry) and live_token_ids (unconsumed token ids minted in the snapshotted context, per manifest) to the ChainManifest wire schema and its decoded counterpart. Both ride every request and response frame so consumption propagates in both directions and the receiver can decide which wire tokens belong to the context it continues. A process-wide registry backs the consumed ledger; encoding stays byte-deterministic via sorted emission.

Make wool.Token survive dispatch with stdlib reset semantics

wool.Token wraps the native stdlib token locally and serializes only through the dispatch pickler via a wool-reduce hook, with a reduce guard rejecting vanilla pickle, cloudpickle, and copy. Reset reproduces the stdlib contract and check order: an already-used token raises RuntimeError (used-first, consulting the cross-process ledger so a token consumed on a worker is dead everywhere, including for the caller's original object), a foreign variable's token raises ValueError, and a token minted in a context the receiver does not continue raises the stdlib different-Context ValueError.

A wire token earns a reset anchor on the receiver only when its id appears in the mounted manifest's live_token_ids. Reconstituted tokens queue by chain id and Chain.mount drains the queue inside the target context, minting an anchor stdlib token that delegates same-context validation to stdlib machinery. A token that fails the gate arrives as an orphan: freely holdable, returnable, and re-dispatchable, failing only at an actual reset attempt. Because live ids ride the manifest, the ownership decision composes across hops — a token passed through one worker to another anchors wherever the continuing context reaches, matching awaited-call semantics.

Frame.mount no longer short-circuits a manifest whose only state is token bookkeeping, since ledger ingest and anchor drain live inside the mount.

Test cases

# Test Suite Given When Then Coverage Target
1 TestToken A Token produced by set() on a wool.ContextVar repr() is called It names the variable and namespace in stdlib-shaped form Token repr
2 TestToken A ContextVar in an armed context set() mints a Token and var is read token.var is the wool ContextVar itself Stdlib var-identity parity
3 TestToken A previously-unset ContextVar set() mints a Token and old_value is read It is the Undefined sentinel old_value for unset vars
4 TestToken A ContextVar already set to a value A second set mints a Token old_value equals the first value old_value for nested sets
5 TestToken A live Token and a vanilla channel (pickle, cloudpickle, copy, deepcopy) The channel is invoked outside a dispatch TypeError pointing at the dispatch path Vanilla serialization guard
6 TestToken A live Token Serialized and deserialized through the Wool serializer Reconstructs with the same id naming the same variable Dispatch transport
7 (module) A Token held with no other reference to its variable Garbage collection runs token.var still resolves with the original identity Registry pinning
8 TestContextVar A ContextVar in an armed context set is called A wool.Token is returned whose var is the variable set return type
9 TestContextVar An armed ContextVar and a non-token argument reset() is called TypeError naming Token, matching stdlib Reset type guard
10 TestContextVar Two ContextVars, each with a set value reset() is called with the other's token ValueError naming a different ContextVar, matching stdlib Per-variable binding
11 TestContextVarPropagation A caller-minted token dispatched to a worker that resets it The caller resets the same token afterward RuntimeError already-used — consumption propagated back Cross-process single use
12 TestContextVarPropagation A token minted in a sibling stdlib context, passed to a routine that only inspects it The token travels to the worker and back No error anywhere in transport; only an explicit reset raises Lossless orphan transport
13 TestContextVarPropagation A sibling-context token sharing the dispatching chain id A worker resets it Stdlib different-Context ValueError Context ownership over chain identity
14 TestContextVarPropagation A routine that sets a var on the worker and returns the minted token The awaiting caller resets the returned token The set propagated back and the reset restores the pre-call value Worker-minted token, awaited-call parity

Routine dispatch put the raw undecorated function in the Task callable,
so a worker deserialized and ran the function body directly under
do_dispatch(False). Nested routine calls then took the wrapper's
local-execution branch and ran in-process on the same worker instead of
dispatching across the pool. The raw function also could not pickle by
reference, since its qualname resolves to the wrapper, forcing its code
to serialize by value on every dispatch.

Dispatch the routine wrapper instead of the raw function. On the worker
the wrapper runs under do_dispatch(False), takes its else branch, and
_execute re-enables do_dispatch(True) around the body so nested calls
dispatch as intended. The wrapper also pickles by reference for
importable routines, shrinking the serialized callable.

Claude-Session: https://claude.ai/code/session_01YV7ftABBRGQFCitMqLydP1
Add two fields to the ChainManifest wire schema and its decoded
counterpart. Each ContextVar entry gains external_used, the hex ids of
wool.Token instances for that var consumed by a reset somewhere in the
chain; the manifest gains live_token_ids, the ids of unconsumed tokens
minted in the context the manifest snapshots. Both ride every request
and response frame so consumption propagates in both directions and the
receiver can decide which wire tokens belong to the context it
continues.

A process-wide external_used registry keyed by var identity backs the
consumed ledger, guarded by the existing registry lock. It lives in the
registry module rather than the token module so chain and manifest can
consult it without an import cycle.

Encoding stays byte-deterministic: both id sets emit sorted, and
consumed ids piggyback on the var entry that already exists for the
value or reset the consuming reset produced.

Claude-Session: https://claude.ai/code/session_01YV7ftABBRGQFCitMqLydP1
wool.Token was an alias for the stdlib contextvars.Token, which cannot
pickle, so a token passed to a routine as an argument or nested in a
propagated ContextVar value was silently dropped from the dispatch.
With nested routines now genuinely dispatching, the downstream-reset
pattern — set on the caller, reset on a worker — was broken end to end.

Introduce a first-class wool.Token returned by wool.ContextVar.set. It
wraps the native stdlib token locally and serializes only through the
dispatch pickler via a wool-reduce hook; vanilla pickle, cloudpickle,
and copy are rejected with a TypeError pointing at the dispatch path.
Reset reproduces the stdlib contract: single use enforced used-first
across processes through the consumed-token ledger, per-variable token
binding, and per-context ownership with the canonical error types and
messages.

Context ownership follows the exact minting context, not the chain. A
wire token earns a reset anchor on the receiver only when its id is in
the mounted manifest's live_token_ids — the snapshot the sender's
to_manifest captured inside the context it ships. The decision cannot
be made at serialize time: a worker pickles its result in the gRPC
handler, outside the routine's context, so the ambient chain there is
not a reliable witness. Reconstituted tokens queue by chain id and
Chain.mount drains the queue inside the target context, minting an
anchor stdlib token that delegates same-context validation to stdlib
machinery. A token that fails the gate arrives as an orphan: freely
holdable, returnable, and re-dispatchable, failing only at an actual
reset attempt with the stdlib different-Context error.

Frame.mount no longer short-circuits a manifest whose only state is
token bookkeeping, since ledger ingest and anchor drain live inside the
mount.

Claude-Session: https://claude.ai/code/session_01YV7ftABBRGQFCitMqLydP1
Rewrite the token unit tests for the first-class wool.Token: repr names
the wool variable, var restores the stdlib var identity parity the
stdlib alias never had, old_value reports Undefined for a previously
unset variable, vanilla pickle, cloudpickle, copy, and deepcopy are
rejected, and the wool serializer roundtrips a token preserving its id
and variable identity.

Update the var tests for the new reset contract: set returns a
wool.Token whose var is the wool variable, and the reset error surface
matches stdlib byte for byte — expected an instance of Token for a
non-token argument and created by a different ContextVar for a foreign
token.

Claude-Session: https://claude.ai/code/session_01YV7ftABBRGQFCitMqLydP1
Add integration coverage for the token wire semantics over a real
worker pool: a token reset on a worker is consumed for the caller too,
so the caller's second reset raises the already-used RuntimeError; an
orphaned token minted in a sibling context transports losslessly to the
worker and back, failing only on an explicit reset; a worker-side reset
of that orphan raises the different-Context ValueError even though the
sibling shares the dispatching chain id; and a token minted on the
worker returns to the awaiting caller resettable, restoring the
pre-call value with stdlib awaited-coroutine parity.

Four routines back the tests: reset a passed token, describe a token
without resetting, attempt an orphan reset and report the error, and
set-then-return a fresh token.

Claude-Session: https://claude.ai/code/session_01YV7ftABBRGQFCitMqLydP1
@conradbzura conradbzura self-assigned this Jul 3, 2026
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.

1 participant