Add `tractor.to_actor` one-shot task API subpkg

First cut at the `to_thread`/`to_process`-style "run it over there"
wrapper layer from issue #477: a single-remote-task invocation API
decoupled from the `ActorNursery` spawn machinery, composed purely
from the lower level daemon-actor + portal primitives,

- `to_actor.run(fn, **fn_kwargs)` spawns a subactor via
  `ActorNursery.start_actor()`, schedules `fn` as its lone task
  with `Portal.run()` and ALWAYS reaps it via a `finally`-scoped
  `Portal.cancel_actor()` (whose bounded cancel-req wait is
  internally shielded so the reap also runs under caller-scope
  cancellation).
- remote errors raise directly in the caller's task as boxed
  `RemoteActorError`s, moving error collection/propagation up into
  whatever local `trio` scope encloses the call.
- "placement" opts: `portal=` reuses a running actor (no
  spawn/reap), `an=` spawns from a caller-managed actor-nursery,
  neither opens a private call-scoped `open_nursery()` (implicitly
  booting the runtime, tunable via pass-through `runtime_kwargs`).
- fail-fast validation BEFORE any spawn: non-streaming async fn
  only (same constraint as `Portal.run()`), `portal=`/`an=` mutual
  exclusion and no `runtime_kwargs` alongside a placement opt.

Also,
- x-ref the successor API from `.run_in_actor()`'s deprecation TODO
  + docstring; emitting a formal `DeprecationWarning` waits on
  migrating in-repo usage.
- log prompt-io provenance per NLNet policy incl. the driver prompt
  file.

Prompt-IO: ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.md

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
wkt/to_actor_subpkg
Gud Boi 2026-07-02 12:10:19 -04:00
parent 65bf9df5ba
commit 63df5534bb
7 changed files with 458 additions and 6 deletions

View File

@ -0,0 +1,79 @@
---
model: claude-fable-5
service: claude
session: f6c84722-471a-4458-9a80-e453fea9029f
timestamp: 2026-07-02T15:42:55Z
git_ref: 65bf9df5
scope: code
substantive: true
raw_file: 20260702T154255Z_65bf9df5_prompt_io.raw.md
---
## Prompt
Driver prompt file `ai/prompt-io/prompts/issue_477.md`:
> attempt to resolve
> https://github.com/goodboy/tractor/issues/477
> do it with /open-wkt.
(plus a hard stop-for-human-review deadline of 12:50PM
EST the same day)
Issue #477 asks to factor `ActorNursery.run_in_actor()`
(and possibly `Portal.run()`) out of the nursery
internals into a new `tractor.to_actor` wrapper
subpackage of "higher level one shot" single-remote-task
APIs, adopting the `trio.to_thread`/`anyio.to_process`
parlance, so that error collection/propagation moves up
into the caller's local `trio` scope and the nursery's
spawn machinery can eventually drop the
`._ria_nursery` coupling.
## Response summary
First-cut `tractor.to_actor` subpkg delivering the
one-shot API composed purely from the existing
daemon-spawn + portal primitives (`start_actor()` +
`Portal.run()` + `Portal.cancel_actor()`), leaving the
legacy `.run_in_actor()` machinery untouched (formal
deprecation deferred until in-repo usage migrates):
- `to_actor.run(fn, **fn_kwargs) -> Any`: spawn a
subactor, schedule `fn` as its lone remote task, wait
on and return its result, ALWAYS reaping the subactor
(shield-safe `finally`). Remote errors raise in the
caller's task as boxed `RemoteActorError`s.
- placement variants: `portal=` reuses a running actor
(no spawn/reap), `an=` spawns from a caller-managed
actor-nursery, neither opens a call-scoped private
`open_nursery()` (implicitly booting the runtime,
configurable via `runtime_kwargs`).
- fail-fast validation before any spawn: non-streaming
async fn required; `portal=`/`an=` mutually
exclusive; `runtime_kwargs` rejected alongside any
placement opt.
- `run_in_actor()` TODO/docstring now cross-reference
the successor API.
## Files changed
- `tractor/to_actor/__init__.py` — new subpkg,
re-exports `run`
- `tractor/to_actor/_api.py``run()` +
`_invoke_in_subactor()` + `_validate_one_shot_fn()`
- `tractor/__init__.py` — top-level `to_actor`
re-export
- `tractor/runtime/_supervise.py` — comment/docstring
pointers from `run_in_actor()` to the successor
- `tests/test_to_actor.py` — 11-test suite covering
all placement variants, error relay, the concurrent
worker-pool-ish pattern and arg validation
- `examples/parallelism/to_actor_one_shots.py`
runnable demo (auto-collected by
`test_docs_examples.py`)
## Human edits
None yet — pending human review (work paused before the
12:50PM EST deadline per the driver prompt).

View File

@ -0,0 +1,100 @@
---
model: claude-fable-5
service: claude
timestamp: 2026-07-02T15:42:55Z
git_ref: 65bf9df5
diff_cmd: git diff main..wkt/to_actor_subpkg
---
# Raw AI output (diff-ref mode)
All generated code is committed on the
`wkt/to_actor_subpkg` branch; per diff-ref mode each
file's verbatim content is reachable via the pointers
below rather than duplicated here.
## Generated files
> `git diff main..wkt/to_actor_subpkg -- tractor/to_actor/__init__.py`
New subpackage init: module docstring establishing the
`trio.to_thread`/`anyio.to_process` "run it over there"
parlance for actors, plus the single public re-export
`run as run` from `._api`.
> `git diff main..wkt/to_actor_subpkg -- tractor/to_actor/_api.py`
The one-shot invocation impl, composed entirely from the
lower level daemon-spawn + portal primitives as
prescribed by issue #477:
- `_validate_one_shot_fn()`: the `Portal.run()`
non-streaming-async-fn constraint checked up-front,
before any subactor is spawned.
- `_invoke_in_subactor()`: `an.start_actor()` ->
`Portal.run()` -> always-reap via
`Portal.cancel_actor()` in a `finally` (the cancel
req's bounded wait is internally shielded so the reap
also runs under caller-scope cancellation).
- `run()`: the public API. Placement options:
`portal=` (reuse a running actor, no spawn/reap),
`an=` (spawn from a caller-managed nursery), or
neither (private `open_nursery()` scoped to the call,
implicitly booting the runtime when needed, tunable
via pass-through `runtime_kwargs`). Spawn opts mirror
`ActorNursery.start_actor()`; `**fn_kwargs` are
relayed to the remote task. Errors raise in the
caller's task as boxed `RemoteActorError`s.
`runtime_kwargs` alongside any placement opt is a
hard `ValueError`, never silently ignored.
> `git diff main..wkt/to_actor_subpkg -- tractor/__init__.py`
Top-level `from . import to_actor as to_actor`
re-export.
> `git diff main..wkt/to_actor_subpkg -- tractor/runtime/_supervise.py`
Comment/docstring-only: the `run_in_actor()` deprecation
TODO now points at the implemented `.to_actor.run()`
successor (checkbox ticked) and the method docstring
gains a NOTE steering users to the new API; remaining
TODO items are the `DeprecationWarning` emission +
in-repo usage migration.
> `git diff main..wkt/to_actor_subpkg -- tests/test_to_actor.py`
11-test suite: private-nursery one-shot, implicit
runtime boot via `runtime_kwargs`, remote-error relay to
the caller's task (bare + caller-managed nursery),
caller-nursery spawn, portal reuse w/o implicit reap,
the concurrent worker-pool-ish pattern (local `trio`
nursery x shared `an`), and the four validation
rejections (sync fn, async-gen fn, `portal`+`an`
combo, `runtime_kwargs`+placement combo).
> `git diff main..wkt/to_actor_subpkg -- examples/parallelism/to_actor_one_shots.py`
Runnable example (auto-collected by
`test_docs_examples.py`): the fully-implicit one-shot
plus the concurrent worker-pool-ish prime-check pattern
against a shared caller-managed actor-nursery.
## Test runs (verbatim)
```
tests/test_to_actor.py .......... [100%]
============= 10 passed in 4.29s =============
```
Regression subset for touched modules
(`test_local.py test_rpc.py test_spawning.py
test_cancellation.py`):
```
38 passed, 1 xfailed, 24 warnings in 80.71s (0:01:20)
```
(warnings are pre-existing stdlib `os.fork()`
DeprecationWarnings from the mp spawn backends, not
introduced by this change)

View File

@ -0,0 +1,7 @@
NOTE: you MUST pause this work at 12:50PM EST (BEFORE your weekly
limit reset) for review by a human!
---
attempt to resolve https://github.com/goodboy/tractor/issues/477
do it with /open-wkt.

View File

@ -62,6 +62,7 @@ from .devx import (
post_mortem as post_mortem, post_mortem as post_mortem,
) )
from . import msg as msg from . import msg as msg
from . import to_actor as to_actor
from ._root import ( from ._root import (
run_daemon as run_daemon, run_daemon as run_daemon,
open_root_actor as open_root_actor, open_root_actor as open_root_actor,

View File

@ -383,12 +383,13 @@ class ActorNursery:
) )
# TODO: DEPRECATE THIS: # TODO: DEPRECATE THIS:
# -[ ] impl instead as a hilevel wrapper on # -[x] impl instead as a hilevel wrapper on top of
# top of a `@context` style invocation. # the lower level daemon-spawn + portal APIs
# |_ dynamic @context decoration on child side # |_ see `.to_actor.run()` (issue #477) which does
# |_ implicit `Portal.open_context() as (ctx, first):` # `.start_actor()` + `Portal.run()` + a one-shot
# and `return first` on parent side. # reap via `Portal.cancel_actor()`.
# |_ mention how it's similar to `trio-parallel` API? # -[ ] emit a `DeprecationWarning` here (requires
# migrating all in-repo usage first!)
# -[ ] use @api_frame on the wrapper # -[ ] use @api_frame on the wrapper
async def run_in_actor( async def run_in_actor(
self, self,
@ -416,6 +417,11 @@ class ActorNursery:
until the task spawned by executing ``fn`` completes at which point until the task spawned by executing ``fn`` completes at which point
the actor is terminated. the actor is terminated.
NOTE: prefer the (eventual) replacement API
`tractor.to_actor.run()` which delivers the same
one-shot semantics decoupled from this nursery's
internal spawn machinery; see issue #477.
''' '''
__runtimeframe__: int = 1 # noqa __runtimeframe__: int = 1 # noqa
mod_path: str = fn.__module__ mod_path: str = fn.__module__

View File

@ -0,0 +1,33 @@
# tractor: distributed structured concurrency.
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
`tractor.to_actor`: high-level "one-shot" remote-task APIs.
Adopts the "run it over there" parlance from analogous
(sibling-library) APIs like `trio.to_thread` and
`anyio.to_process` but for SC-supervised actors: spawn (or
reuse) a subactor, schedule a single remote task, wait on
its result and (when the call owns the subactor) reap it.
The "spiritual successor" to (and eventual replacement of)
the `ActorNursery.run_in_actor()` API; see
https://github.com/goodboy/tractor/issues/477
'''
from ._api import (
run as run,
)

View File

@ -0,0 +1,226 @@
# tractor: distributed structured concurrency.
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
One-shot remote-task invocation built on spawn-and-portal
primitives.
Implemented (as prescribed by #477) entirely "on top of"
the lower level daemon-actor spawn + portal APIs,
- `ActorNursery.start_actor()` for (daemon-style) subactor
spawning,
- `Portal.run()` for scheduling the lone remote task and
waiting on its result,
- `Portal.cancel_actor()` for reaping the subactor once
that result (or error) arrives,
such that error collection and propagation happens in the
*caller's task* (and thus whatever `trio` nursery/scope
encloses it) instead of inside the actor-nursery's
spawn-machinery nurseries as with the (to be deprecated)
`ActorNursery.run_in_actor()` API.
'''
from __future__ import annotations
import inspect
from typing import (
Any,
Callable,
TYPE_CHECKING,
)
from ..runtime._supervise import (
ActorNursery,
open_nursery,
)
if TYPE_CHECKING:
from ..discovery._addr import UnwrappedAddress
from ..runtime._portal import Portal
def _validate_one_shot_fn(
fn: Callable,
) -> None:
'''
Ensure `fn` is a non-streaming async function, raise
a `TypeError` otherwise.
The same constraint enforced by `Portal.run()` but
checked up-front, BEFORE any subactor is spawned.
'''
if not (
inspect.iscoroutinefunction(fn)
and
not getattr(
fn,
'_tractor_stream_function',
False,
)
):
raise TypeError(
f'{fn!r} must be a non-streaming async '
f'function!'
)
async def _invoke_in_subactor(
an: ActorNursery,
fn: Callable,
name: str,
spawn_kwargs: dict[str, Any],
fn_kwargs: dict[str, Any],
) -> Any:
'''
Spawn a (daemon) subactor via `an.start_actor()`,
schedule `fn` as its lone remote task via
`Portal.run()` and, ALWAYS, reap the subactor once
that task's result (or error) has been delivered.
'''
portal: Portal = await an.start_actor(
name,
**spawn_kwargs,
)
try:
return await portal.run(
fn,
**fn_kwargs,
)
finally:
# one-shot semantics: the subactor's lifetime is
# bound to its lone task's completion; the
# cancel-req's bounded wait is shielded
# internally (see `Portal.cancel_actor()`) so
# this reap also runs when the caller's scope
# was itself cancelled.
await portal.cancel_actor()
async def run(
fn: Callable,
*,
# actor "placement": reuse an already-running peer
# via its `portal`, spawn a fresh subactor from
# a caller-managed `an: ActorNursery`, or, when
# neither is provided, open a private actor-nursery
# (implicitly booting the actor-runtime as needed)
# scoped to just this call.
portal: Portal|None = None,
an: ActorNursery|None = None,
# subactor spawn opts passed (mostly) verbatim to
# `ActorNursery.start_actor()`; unused when `portal`
# is provided.
name: str|None = None,
bind_addrs: list[UnwrappedAddress]|None = None,
enable_modules: list[str]|None = None,
loglevel: str|None = None,
debug_mode: bool|None = None,
infect_asyncio: bool = False,
inherit_parent_main: bool = True,
proc_kwargs: dict[str, Any]|None = None,
# passed verbatim to the private `open_nursery()`
# (and in turn any implicit `open_root_actor()`)
# when NO `an`/`portal` is provided.
runtime_kwargs: dict[str, Any]|None = None,
**fn_kwargs, # explicit (keyword) args to `fn`
) -> Any:
'''
Run the async `fn` as the lone task in a (new)
subactor, block waiting on its result and return it;
the distributed-parallelism equivalent of
`trio.to_thread.run_sync()`.
Unlike `ActorNursery.run_in_actor()` (which returns
a `Portal` whose result is only collected at
actor-nursery teardown) this is a plain "call and
wait" primitive: any remote error is raised HERE, in
the caller's task. Concurrency is composed the usual
`trio` way by scheduling multiple `run()` calls in
a local task nursery, ideally against a shared
caller-managed `an: ActorNursery` (see the test
suite for the canonical worker-pool-ish pattern).
'''
__runtimeframe__: int = 1 # noqa
_validate_one_shot_fn(fn)
if (
runtime_kwargs
and
(
an is not None
or
portal is not None
)
):
raise ValueError(
'`runtime_kwargs` only applies when this '
'call opens its own private actor-nursery '
'(no `an`/`portal` provided)!'
)
if portal is not None:
if an is not None:
raise ValueError(
'Pass at most ONE of `portal` or `an`, '
'not both!'
)
return await portal.run(
fn,
**fn_kwargs,
)
name: str = name or fn.__name__
spawn_kwargs: dict[str, Any] = dict(
enable_modules=(
[fn.__module__]
+
(enable_modules or [])
),
bind_addrs=bind_addrs,
loglevel=loglevel,
debug_mode=debug_mode,
infect_asyncio=infect_asyncio,
inherit_parent_main=inherit_parent_main,
proc_kwargs=proc_kwargs,
)
if an is not None:
return await _invoke_in_subactor(
an,
fn,
name,
spawn_kwargs,
fn_kwargs,
)
async with open_nursery(
**(runtime_kwargs or {}),
) as an:
return await _invoke_in_subactor(
an,
fn,
name,
spawn_kwargs,
fn_kwargs,
)