Description
__proxy__ holds the worker proxy bound for the current dispatch scope — the handle a nested routine reads to dispatch onward. It is a plain contextvars.ContextVar[WorkerProxy | None], and the proxy reaches a worker through two dedicated Task wire fields: proxy (the serialized proxy) and proxy_id (its UUID). On the worker, routine_scope deserializes that payload into a candidate proxy, uses it as a cache key into __proxy_pool__, and binds the canonical live instance to __proxy__ for the routine's lifetime.
Make __proxy__ a wool.ContextVar so the proxy propagates on the context chain like any other variable, and fold the pool lookup into reconstruction: WorkerProxy's reduce constructor, _restore_proxy, becomes pool-aware — on restore it consults __proxy_pool__ for a cached instance with the same id, reuses it when present, and constructs-and-caches it otherwise. Deserialization then yields the canonical live proxy directly, instead of a throwaway key object that routine_scope maps to the cached instance in a second step. The proxy's identity already travels inside its __wool_reduce__ payload, so the chain carries everything the dedicated fields do.
The Task.proxy and Task.proxy_id fields stay. This change is additive and wire-compatible within the major version: the dedicated fields are deprecated, not removed. Dropping them is a v1 follow-up.
Backward compatibility
A client must dispatch successfully to a worker running a newer minor version within the same major version. The chain-borne proxy is therefore additive, and both propagation paths coexist until v1:
- Old client → new worker. The old client populates only
Task.proxy / proxy_id. The new worker reads the proxy from the chain when present and falls back to the Task fields otherwise.
- New client → old worker. The old worker reads only the
Task fields. Whether the new client keeps writing them (dual-write) depends on the upgrade-ordering guarantee — resolve this as part of the design. If new-client-to-old-worker must work, the new client populates both the chain and the fields.
- New ↔ new. Prefer the chain-borne proxy.
The worker materializes the proxy from exactly one path per dispatch — chain first, fields as fallback — so a dual-written proxy never triggers two pool acquisitions. Confirm that an old worker tolerates a chain carrying the extra __proxy__ variable.
Mark Task.proxy / proxy_id deprecated in the proto and reserve their removal for v1.
The lifecycle redesign
Transport is the mechanical half. The lifecycle is the design work, because reconstruction acquires a pooled resource from inside a synchronous decode path.
- Sync reconstruction, async acquire.
_restore_proxy runs synchronously inside context-var decode, but acquiring a proxy from __proxy_pool__ is async — async with proxy_pool.get(...) leases the entry and starts the channel. Reconstruction returns a lazy, not-yet-started proxy; WorkerProxy already defers start() to first dispatch under double-checked locking. The pool gains a synchronous get-or-insert entry point for the lazy handle, separate from its async startup path.
- Lease lifetime. The lease is no longer bracketed by
routine_scope's async with. Its release point moves onto the binding's lifetime — the pool entry is reclaimed when __proxy__ is reset on frame unmount — with no leak across dispatches.
- Forward-only propagation.
__proxy__ must travel caller-to-worker only. The chain must not carry it back on yield/return checkpoints.
Motivation
The proxy is the last piece of per-dispatch state that still reaches a worker through a bespoke wire field instead of the context chain that already carries everything else. The cost shows up twice: the Task schema hauls the proxy redundantly (proxy plus proxy_id), and the materialize-from-pool step lives in routine_scope, away from the reconstruction it completes. Routing the proxy through the chain unifies propagation onto one mechanism and lets the redundant fields retire at v1.
Expected Outcome
__proxy__ is a wool.ContextVar[WorkerProxy | None]; the proxy propagates on the context chain.
_restore_proxy consults __proxy_pool__ and returns the cached instance when one exists, constructing and caching otherwise.
__proxy_pool__ exposes a synchronous get-or-insert for lazy proxy handles; proxy startup stays deferred to first dispatch.
Task.proxy and Task.proxy_id remain on the wire, marked deprecated. The worker reads the chain-borne proxy when present and falls back to the Task fields, materializing from exactly one path per dispatch.
- The pool lease is tied to the
__proxy__ binding — reclaimed on reset or frame unmount — and does not leak across dispatches.
__proxy__ does not back-propagate from worker to caller.
- A reused cached proxy resolves credentials correctly under the active credential context.
- Compatibility holds across mixed minor versions: an old-style Task (fields only) and a new-style dispatch (chain proxy) both succeed with identical behavior.
- A v1 follow-up to remove the deprecated fields and the fallback path is filed.
- Existing dispatch and nested-dispatch tests pass; the suite stays green.
Description
__proxy__holds the worker proxy bound for the current dispatch scope — the handle a nested routine reads to dispatch onward. It is a plaincontextvars.ContextVar[WorkerProxy | None], and the proxy reaches a worker through two dedicatedTaskwire fields:proxy(the serialized proxy) andproxy_id(its UUID). On the worker,routine_scopedeserializes that payload into a candidate proxy, uses it as a cache key into__proxy_pool__, and binds the canonical live instance to__proxy__for the routine's lifetime.Make
__proxy__awool.ContextVarso the proxy propagates on the context chain like any other variable, and fold the pool lookup into reconstruction:WorkerProxy's reduce constructor,_restore_proxy, becomes pool-aware — on restore it consults__proxy_pool__for a cached instance with the same id, reuses it when present, and constructs-and-caches it otherwise. Deserialization then yields the canonical live proxy directly, instead of a throwaway key object thatroutine_scopemaps to the cached instance in a second step. The proxy's identity already travels inside its__wool_reduce__payload, so the chain carries everything the dedicated fields do.The
Task.proxyandTask.proxy_idfields stay. This change is additive and wire-compatible within the major version: the dedicated fields are deprecated, not removed. Dropping them is a v1 follow-up.Backward compatibility
A client must dispatch successfully to a worker running a newer minor version within the same major version. The chain-borne proxy is therefore additive, and both propagation paths coexist until v1:
Task.proxy/proxy_id. The new worker reads the proxy from the chain when present and falls back to theTaskfields otherwise.Taskfields. Whether the new client keeps writing them (dual-write) depends on the upgrade-ordering guarantee — resolve this as part of the design. If new-client-to-old-worker must work, the new client populates both the chain and the fields.The worker materializes the proxy from exactly one path per dispatch — chain first, fields as fallback — so a dual-written proxy never triggers two pool acquisitions. Confirm that an old worker tolerates a chain carrying the extra
__proxy__variable.Mark
Task.proxy/proxy_iddeprecated in the proto and reserve their removal for v1.The lifecycle redesign
Transport is the mechanical half. The lifecycle is the design work, because reconstruction acquires a pooled resource from inside a synchronous decode path.
_restore_proxyruns synchronously inside context-var decode, but acquiring a proxy from__proxy_pool__is async —async with proxy_pool.get(...)leases the entry and starts the channel. Reconstruction returns a lazy, not-yet-started proxy;WorkerProxyalready defersstart()to first dispatch under double-checked locking. The pool gains a synchronous get-or-insert entry point for the lazy handle, separate from its async startup path.routine_scope'sasync with. Its release point moves onto the binding's lifetime — the pool entry is reclaimed when__proxy__is reset on frame unmount — with no leak across dispatches.__proxy__must travel caller-to-worker only. The chain must not carry it back on yield/return checkpoints.Motivation
The proxy is the last piece of per-dispatch state that still reaches a worker through a bespoke wire field instead of the context chain that already carries everything else. The cost shows up twice: the
Taskschema hauls the proxy redundantly (proxyplusproxy_id), and the materialize-from-pool step lives inroutine_scope, away from the reconstruction it completes. Routing the proxy through the chain unifies propagation onto one mechanism and lets the redundant fields retire at v1.Expected Outcome
__proxy__is awool.ContextVar[WorkerProxy | None]; the proxy propagates on the context chain._restore_proxyconsults__proxy_pool__and returns the cached instance when one exists, constructing and caching otherwise.__proxy_pool__exposes a synchronous get-or-insert for lazy proxy handles; proxy startup stays deferred to first dispatch.Task.proxyandTask.proxy_idremain on the wire, marked deprecated. The worker reads the chain-borne proxy when present and falls back to theTaskfields, materializing from exactly one path per dispatch.__proxy__binding — reclaimed on reset or frame unmount — and does not leak across dispatches.__proxy__does not back-propagate from worker to caller.