Skip to content

refactor(exe_types): thread the engine into the node and parameter types - #5506

Merged
cjkindel merged 35 commits into
feat/venue-exec-depsfrom
refactor/thread-the-engine-into-exe-types
Sep 21, 2026
Merged

cjkindel merged 35 commits into
feat/venue-exec-depsfrom
refactor/thread-the-engine-into-exe-types

Conversation

@cjkindel

@cjkindel cjkindel commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Split out of #5409 at review request, same shape as your #5386. It is the GriptapeNodes → injected-engine migration for exe_types and nothing else. Mostly mechanical, but not purely — three call sites change where the answer comes from, listed at the bottom.

Why here

pyproject's TID251 allowlist named exe_types as work to be done. The facade is reserved for callers that cannot be handed a reference — saved workflow files, separately-versioned libraries, process entry points — and node machinery is none of those. That allowlist entry is now deleted.

What it changes

BaseNode takes an optional keyword-only engine and falls back to the ambient one, so a library's super().__init__(name, metadata=metadata) is unaffected. Nothing in the engine passes it yet: LibraryRegistry.create_node can't without breaking every node subclass's two-arg signature. So nodes still resolve the ambient engine, and what this buys is the classification it forced, not per-node isolation.

Calls were split by what they actually ask for:

  • Questions about workflow state → requests (ListConnectionsForNode, GetConfigValue, GetSecretValue). The orchestrator is the only process holding all of it, and a request is what reaches it from a worker.
  • DAG machinery and event dispatch → self.engine.*, the sanctioned engine-internal path.

Objects that aren't nodes reach the engine through the node they hold (TrackedParameterOutputValues, BaseElement). Module-level helpers and classmethods take an engine parameter: query_model_policy, three VariableResolver staticmethods, and two private classmethods on PublicArtifactUrlParameter.

Where behaviour actually changes

The two deprecated BaseNode config helpers (get_config_value / set_config_value) now go through GetConfigValueRequest / SetConfigValueRequest rather than reading a manager directly. In the engine that is the same answer; in a worker it is the orchestrator's answer instead of the worker's own copy, which is the point — the orchestrator is the process that holds config authoritatively.

PublicArtifactUrlParameter adopts the ConfigManager-injected storage-driver construction from #5406.

Internal connection queries pass broadcast_result=False. They run on the hottest read path in the engine, and the default queued a GriptapeNodeEvent carrying the node's full connection list to the GUI on every call.

Notes

ControlFlow's delegation shim landed separately on main (#5388) and is used as-is. Tests that patched the facade now patch or inject the engine, using the engine fixture from #5385.

Full unit suite passes at this branch's tip (5863 passed).

@cjkindel
cjkindel force-pushed the refactor/thread-the-engine-into-exe-types branch from 07aebc9 to 9e605eb Compare September 8, 2026 17:56
@cjkindel
cjkindel force-pushed the refactor/thread-the-engine-into-exe-types branch from 9e605eb to 9a59b38 Compare September 8, 2026 18:24
@cjkindel
cjkindel force-pushed the refactor/thread-the-engine-into-exe-types branch from 9a59b38 to e0274a4 Compare September 8, 2026 18:37
@cjkindel
cjkindel force-pushed the refactor/thread-the-engine-into-exe-types branch from e0274a4 to db19575 Compare September 8, 2026 18:47
@cjkindel
cjkindel force-pushed the refactor/thread-the-engine-into-exe-types branch from db19575 to 23a2a42 Compare September 9, 2026 15:18
@cjkindel
cjkindel force-pushed the refactor/thread-the-engine-into-exe-types branch 2 times, most recently from 8ce0edd to 9a77f42 Compare September 9, 2026 21:04
@cjkindel
cjkindel force-pushed the refactor/thread-the-engine-into-exe-types branch from 9a77f42 to d9ba18e Compare September 9, 2026 21:24
@cjkindel
cjkindel force-pushed the refactor/thread-the-engine-into-exe-types branch from d9ba18e to 7bf0cc1 Compare September 11, 2026 15:50
@cjkindel
cjkindel force-pushed the refactor/thread-the-engine-into-exe-types branch from 7bf0cc1 to cfa2158 Compare September 18, 2026 18:37
Node machinery reached its collaborators through the process-wide GriptapeNodes
facade, which the architecture reserves for callers that cannot be handed a
reference: saved workflow files, separately-versioned libraries, and process
entry points. exe_types is none of those, and pyproject's TID251 allowlist named
it as work to be done. That entry is now deleted.

BaseNode takes an optional keyword-only engine and falls back to the ambient
one, so a library's `super().__init__(name, metadata=metadata)` is unaffected.
Nothing in the engine passes it today -- LibraryRegistry.create_node cannot
without breaking every node subclass's two-arg signature -- so nodes resolve the
ambient engine; what the migration buys is the classification it forced, not
per-node isolation. Calls were split by what they ask for:

- questions about workflow state became requests (ListConnectionsForNode,
  GetConfigValue, GetSecretValue), because the orchestrator is the only process
  that holds all of it and a request is what reaches it from a worker
- DAG machinery and event dispatch became self.engine.*, the sanctioned
  engine-internal path

Internal connection queries pass broadcast_result=False: they run on the
hottest read path in the engine, and the default queued a GriptapeNodeEvent
carrying the node's full connection list to the GUI per call.

Objects that are not nodes reach the engine through the node they hold
(TrackedParameterOutputValues, BaseElement). Module-level helpers and
classmethods take an engine parameter: query_model_policy, three
VariableResolver staticmethods, and two private classmethods on
PublicArtifactUrlParameter, which also adopts the ConfigManager-injected
storage-driver construction from #5406.

ControlFlow's delegation shim landed separately on main (#5388) and is used
as-is. Tests that patched the facade now patch or inject the engine, using the
`engine` fixture from #5385.
…cross the wire

A worker holds no authoritative state: the flow graph, connections, and node registry live in the
orchestrator. So while a worker is executing a node, its requests forward there. The question is which
ones must not, and the answer has to be derived rather than listed.

**The default is forwarding.** It used to be an allowlist, which meant every newly added request type
silently resolved against the worker's own managers -- a non-authoritative copy -- and every migration
note telling authors to "use a request instead" was wrong for any type nobody remembered to add.
Defaulting to forwarding makes that class of mistake impossible: the cost of forgetting is a round
trip, not a wrong answer.

**What stays local is derived from the cause.** Two independent reasons, each derived so a request
added later is covered without anyone remembering this file:

Filesystem work. The workspace is shared on disk, so the worker's own answer is already the
authoritative one -- and forwarding a write corrupted it, because `content` is `str | bytes` and the
wire form base64s bytes into a JSON string that cattrs resolves back to `str`. A worker's write landed
corrupted with no error anywhere. Derived from `os_events` itself, so a filesystem request added later
is covered by construction. `OpenAssociatedFileRequest` is the one exemption: it hands a path to the OS
to open in the user's default application, and that side effect belongs where the user is.

Anything that cannot survive serialization. A `MacroPath` wraps a `ParsedMacro` that will not
serialize, so the send raises and the worker blocks until the forward times out. A field declared as a
bare `type` has no cattrs structure hook, so the orchestrator's ingress raises. And two requests carry
a live Python instance -- a ResourceType, a Callable -- which `json.dumps(default=str)` turns into a
string, so the orchestrator registers a string and the worker, the process that needs it, registers
nothing, with no error on either side. Matched on the declared annotation text rather than a resolved
type, because these modules annotate under `from __future__ import annotations`.

Per-file project reads stay local for the same reason as filesystem work: a worker has already adopted
the orchestrator's project, and a project's base directory is shared on-disk state. Forwarding bought
nothing and cost a round trip on a path as hot as writing sidecar metadata for every saved file. It
also cost correctness -- `GetCurrentProjectResultSuccess` annotates `project_info` under
TYPE_CHECKING, which cattrs cannot resolve from this module, so its NameError fallback passed a raw
dict into the constructor and every sidecar write logged "'dict' object has no attribute
'project_base_dir'" and silently wrote nothing.

A library reload dispatched by a worker stays local too. The orchestrator decides when libraries
reload, and it is mid-reload already -- that is what sent the activation -- so forwarding would have
the worker dictate to it. Worse, the orchestrator's pre-reload callback terminates the very worker that
asked, mid-node, because `in_node_execution()` is a process-wide refcount rather than task-local.

Two things this rests on. `exe_types` takes an injected engine and comes off the facade allowlist, so
node base classes reach managers through their engine rather than the process-wide facade -- which is
what lets a worker's `exe_types` resolve against its own engine. And a worker's media saves returned
URLs on its own ephemeral port; they now point at the orchestrator's static server, so a saved workflow
does not reopen with dead links after the worker is evicted.

A worker-behavior suite covers the shapes above end to end, because each of them failed silently and a
unit test asserting the routing table would have passed throughout.

The suite includes a project save through ProjectFileDestination, which composes the situation lookup,
the macro resolution, the write, and the map back to macro form. It asserts the macro rather than the
bytes alone: losing the mapping leaves the save working and every stored reference an absolute path
from whichever machine ran the node.

The guard message names requests as the way out, so it states what routing actually promises: a
request reaches whichever process owns that state. Saying it is answered by the main engine was
wrong for the filesystem requests it names, which this PR makes local-only.

The event_converter diagnostic is gone from this PR. The fallback it reported on, and the result
structuring that reaches it, both predate this stack -- so warning about them is neither a defect this
change introduces nor a regression it causes. The fix that belongs here is keeping those project reads
off the wire, which is the routing policy above. A detector for the fallback wants its own PR, with a
test, and without a project type name hardcoded into a generic list.

The static-server URL handover waits for initialization to decide it rather than sampling it.
Spawning and URL resolution are both listeners on AppInitializationComplete, which fan out as
unordered concurrent tasks, so sampling was a race with a warning as the consolation prize. The
decision is a settled event covering every resolution path, including "no server here" (cloud
storage) and a resolution that raised; the wait is bounded by seconds, not the startup grace,
because resolution is a socket bind in the same fan-out.

The local-only list carries a blurb per request and is grouped by the reason that BINDS each entry,
because the reasons expire differently: loops, authoritative-here and live-object carriers are
permanent, while wire-format blockers are contingent on serialization and say so. The derivation
keeps covering requests added later, but its membership is now pinned by a test, so a new filesystem
request fails that test and forces a routing decision rather than being routed silently. Its two
module policies -- wholesale versus earned -- are named rather than fused into one boolean.

Each entry in the local-only list carries a one-or-two sentence reason, grouped by the cause that
binds it: belongs-to-this-process, authoritative-here, undeliverable-today, or carries a live object.
Only the third expires, so it says so. The derivation still covers requests added later, but a test
pins its membership, and the two module policies behind it are named rather than fused into one
boolean.
A MacroPath is a ParsedMacro plus a variables dict, and a ParsedMacro is one string: `template` is
its only init field, and `segments` carries `init=False` and is parsed from the template by
`__post_init__`. So the template is the whole value and the parsed form is derived.

Neither had a hook. cattrs has none for a NamedTuple in the JSON preset, so it handed the MacroPath
straight back and never descended into the ParsedMacro inside it -- which put the failure in
json.dumps rather than anywhere that names the type. Every request carrying one therefore could not
be sent at all.

Two hook pairs. ParsedMacro round-trips through its template, which the receiving side reparses.
MacroPath goes to a dict of its two members, registered next to the class because event_converter
cannot import project_events: project_events -> base_events -> event_converter. Payloads declare
`parsed_macro: ParsedMacro` directly as well as via MacroPath, so that pair earns its place in the
converter on its own.

Routing is deliberately unchanged here. This only removes the reason the wire could not carry these.

`test_a_macro_path_will_not_serialize` asserted the defect and is gone. In its place a sweep over
every MacroPath carrier in the payload registry round-trips each one through JSON and checks the
template, the variables, and that the segments were rebuilt -- so a carrier added later that does
not survive the wire fails here rather than at a worker's forward timeout.
Routing is identical. What changes is the stated cause, which was wrong in both directions.

`_carries_a_macro_path` and `_registers_a_python_class` read field annotations, so both said "the
wire cannot deliver this". For the five artifact_events requests that was never the binding reason:
the two registrations put a class into THIS process's provider registry, and the three preview
requests resolve a provider back out of that registry while writing into the project's previews
directory. Forwarding any of them would act in the orchestrator while the worker, the process that
needs the provider to run a node, gets nothing. That holds however well they serialize.

Reading annotations also made the routing move when serialization changed. The commit before this
one gave MacroPath a hook; on the annotation rule the three preview requests would have started
forwarding as a side effect, with no decision recorded anywhere. They are named now, so the set
moves only when someone edits it.

artifact_events is no longer swept, so the wholesale sweep is os_events alone, which is the one
module where the module-wide reason is true throughout. `_annotation_text` and the two annotation
readers are gone with it.

Three tests replace the two that asserted the old cause: the named set is pinned by name, so
dropping an entry fails rather than silently forwarding it; and everything else artifact_events
defines is checked to forward, which is 6 of its 11 request types.
One rule, applied to every way a library or node can fail to run, plus the two pieces of state that
have to survive a failure for that rule to mean anything.

**A broken execution dependency.** It used to fail registration outright: no node types, and
placeholder nodes reading "Library not found" in every workflow that used the library -- even though
nothing but the edit-time set is needed to define, draw, and edit those nodes. An artist who could not
run a node also could not open their workflow. The library now loads with real node classes and the
reason recorded on it, so the refusal happens at execution and says why. `DependencyInstallationFailedProblem`
already existed for this and was wired to nothing.

**An unmet resource requirement.** A library can declare what it needs -- platform, arch, OS version,
compute backend -- and an unmet declaration failed registration the same way. SAM3 declares cuda-only,
which is why it could not be edited on a laptop at all. Now it is FLAWED rather than UNUSABLE, editable,
and refused at execution.

That refusal has to survive the worker spawn that follows it. `get_worker_for_library` refuses on the
reason before it ever consults a worker, so an exec-dependencies library with a missing capability was
resolving and downloading a whole execution environment -- torch, gigabytes -- to serve nothing. The
spawn is skipped where the library's nodes already exist locally. A legacy worker-mode library has
none, so skipping its spawn would leave it with no node types at all; for that one the spawn still
happens, which is why clearing the reason on a fresh attempt is wrong. Clearing is right for an account
of a PREVIOUS attempt -- an evicted worker, one that never started -- and wrong for a machine
capability, because starting a worker does not give the machine a GPU. So the two conditions are
separate: whether to skip the spawn turns on the nodes being local, whether to clear the reason turns
on the requirement still being unmet.

**A node whose run fails or is cancelled goes back to UNRESOLVED.** `state` was set to RESOLVING on
dispatch and to RESOLVED on completion with nothing in between, so a node that raised -- or that was
cancelled because a sibling raised -- kept RESOLVING after the run was over, and nothing else moves it.
The editor spun on it forever. Given back before the run's maps are cleared, filtered to nodes still
mid-flight: one that finished before a sibling failed holds real outputs downstream consumers may
already have, and `make_node_unresolved` writes unconditionally.

**Parameter structure derives from parameter values.** A node's structure is a function of its values,
so it is stated as a contract, enforced on the way in, and honoured on reload rather than serialized
alongside them. Without that, a worker rebuilding a node from a wire payload could disagree with the
orchestrator about what parameters exist.

What an evicted worker leaves behind is pinned per library kind, because the two kinds differ: an
exec-dependencies library keeps its real node classes and loses only the ability to run them, while a
legacy worker-mode library's classes came from the worker and become stubs.
A library that picks its own compute device duplicates detection the engine
can do once, and gets it wrong in a worker: framework probes run against
whatever the process can see, which is not necessarily what the
orchestrator decided this library should use.

GetExecutionDeviceRequest answers it centrally, and BaseNode surfaces the
answer, so a node branches on the engine's decision rather than its own
probe. Detection failing reports cpu and logs why, because a node silently
taking its CPU path on an accelerated machine is the worse outcome.

Drops the execution-module declaration that was here. Deferred imports
already cover keeping a node module importable on the orchestrator, and the
one thing the declaration added -- letting a worker pre-import so a broken
execution environment fails at library load rather than mid-run -- is not
worth a manifest field, an engine accessor, a boundary check, and an
authoring rule to teach. Reaching heavy code with a deferred import of the
module holding it is ordinary Python and needs nothing from the engine.
CorridorKey's OCIO path reaches into the OpenEXR library. The orchestrator loaded it, the worker did
not, so selecting an OCIO colour space succeeded on the canvas and failed on run -- the exact class of
divergence the worker boundary exists to eliminate.

A worker loads only the libraries it serves, and that target list was built from the spawn request
alone. It now expands transitively through each target's declared library dependencies. Read from the
discovered manifests rather than from LibraryRegistry, so this is usable before anything has loaded.

The declaration names a REPO while the registry is keyed by library NAME:
`griptape-nodes-library-openexr` publishes itself as `OpenEXR Library`, so matching the repo name
against library names missed every library not named after its repo -- silently, since a miss only
warns and skips. Provisioning installs each download under a repo-name directory, so the path is where
the repo name actually appears.

One normalization for both callers. A declaration URL may carry an `@ref` suffix and a `.git`
extension, and both change the final path segment; the transitive resolver and the target expansion
normalized differently, so a pinned declaration resolved for one and missed for the other. Two
adjacent silences went with it: a manifest that could not be read dropped every dependency it declared
with no log at all, and the install-path fallback answered with whichever entry matched first, so a
FAILURE record beside the copy that loaded reported "not installed here" for a library that is.

The worker-reach-into-orchestrator warning is retired here, because it was about this same boundary and
was measuring it wrong. It fired on every request a worker forwarded during node execution, to warn
that fetching orchestrator-owned state per call costs a round trip and returns a view stale on arrival.
That concern is real; the rule was not measuring it. Almost everything it fired on is engine machinery
the author never wrote and cannot avoid -- `get_parameter_value` issuing `ListConnectionsForNodeRequest`
per parameter is the loudest, and it must keep forwarding, because connections are authoritative
orchestrator state. Its remediation then told you to ignore it, offering a case ("an intentional write,
e.g. publishing a parameter value") that goes through `put_event` and never reaches this code at all. A
rule that fires constantly on unavoidable machinery and hands you an excuse teaches people to ignore the
category, which costs more than the round trips it pointed at.

Requests remain the sanctioned boundary -- the engine forbids manager access during worker execution and
directs authors here -- so the guidance moves to the docs, stated once, where it is not competing with a
warning that cries wolf. Scoping the rule to library call sites was the alternative; it needs a stack
walk on every forwarded request during execution, paying a hot-path cost to warn about hot-path costs.
Forwarding behavior is unchanged.

Author-facing docs stop teaching the pattern the guard refuses. Six places told node authors to read
secrets or connections through a manager accessor, which raises once a node executes in a worker:
three in getting_started, one each in authoring_libraries and error_handling, and the connection
utilities in examples. They use the corresponding request instead. The param_components README keeps
its before/after example, annotated, since the component it advertises already uses the request.
Three sites read a declaration URL, and the registration path still
derived its pieces inline. That is the duplication the shared helper was
added for: a `@ref` suffix and a `.git` extension both change the final
path segment, so a site that normalizes differently resolves a
declaration the others miss, and a miss only logs.

The helper returned the repo name alone, which the registration path
could not use -- it also needs the normalized URL and the ref to download
a missing dependency. Widened to return all three, so every caller shares
one interpretation instead of two of them sharing part of it.

Covers the ref and normalized URL as well as the repo name: the download
reads those two, and losing the ref installs the default branch instead of
the declared one while still reporting success.
A worker loads its library's declared dependencies, so their execution
pins have to be importable here too. They were not: the expanded targets
reached the load loop through a local, while the gates that install and
splice execution environments read the instance field, so a dependency
loaded without the pins its own nodes need.

Rather than build each dependency its own .venv-exec and splice them all,
the dependency's execution set joins the depending library's single
resolution. Splicing several independently resolved environments would
reproduce between libraries the disagreement the combined edit/exec
resolution already avoids within one -- two resolutions can pick different
versions of anything they share, and whichever landed first would win --
and would have one worker writing a venv another library owns. It also
leaves the target predicate, and the isolation it enforces, untouched.

The combined set drives every decision, not just the install: a library
declaring no execution dependencies of its own would otherwise retire the
environment its dependency's pins were about to go into, since retirement
deliberately runs ahead of every gate.

The two walks over dependency declarations are now one generator, so the
target expansion and the execution-set collection cannot disagree about
the same manifest.
The existing tests patch the collection helper, so a break BETWEEN
collection and install passed all of them: reading the wrong library's
declarations, failing to resolve a repo name, or dropping the combined set
before the install would each have gone unnoticed. Drives the real path
instead, with two discovered manifests and nothing patched between the
request and the installer. Verified by reverting the install list to the
library's own set, which fails it.

Roots now arrive at the declaration walk with their manifest already read.
`install_library_dependencies_request` holds the schema it just loaded, so
collecting from a name meant reading that file a second time for every
library, in both processes, on the boot path.

Also drops the worker note from the README's "Before" example, which is
the pattern the page tells you not to copy, and states it once after the
"After" example as the reason the component works under worker execution.
… it changes

A worker only produces the same result as the orchestrator if it resolves everything the same way, and
several separate things decide that: which packages it imports, which project it holds, which workflow
it is inside, and whether it hears about a switch. Each was wrong in a different way, and each failed
silently.

**Execution dependencies reach it as PYTHONPATH.** Splicing `.venv-exec` onto a running interpreter
cannot give a library its own versions: a module already in sys.modules is never reconsidered, and a
package that probed for an optional dependency at import time has cached the answer -- so a library
shipping `safetensors` still hit `NameError` from inside huggingface_hub. PYTHONPATH is on sys.path
before the process imports anything. That requires the directory to exist before the worker starts, so
the ORCHESTRATOR builds it: it never imports from there, and the worker receiving the directory cannot
be the process that creates it. Prepended rather than assigned, because a launcher-set PYTHONPATH is
part of the environment the engine itself booted with.

**The project comes across at registration.** Registration is the one moment both processes are
guaranteed to exist, so the reply carries the project the orchestrator has committed and the worker
adopts it BEFORE loading a library. Libraries resolve against the workspace and the workspace follows
the project, so a worker that loaded first would resolve them against a different tree. Awaiting a
reply the protocol already defines makes that ordering structural rather than a race two messages
usually win.

**The workflow context is lent, not derived from.** `push_workflow` has no worker-side caller, so a
worker answered "there isn't one" to every workflow question: `workflow_dir` raised, the default
`{outputs}` situation marks the reference optional, the raise was swallowed, and the path degraded to
workspace-relative. The worker wrote real files where the orchestrator does not read, with no error --
invisible in every earlier check because the workflow sat at the workspace root, where the two paths
coincide. Lending the CONTEXT rather than any value derived from it is what stops this needing a new
handoff per value. An execution carrying no context clears the mirror, or the last workflow keeps
resolving paths for the next node.

**A switch reaches every worker, in order, before it reports success.** The fan-out was
fire-and-forget, so the switch returned while a worker was still on the old workspace. Waiting serially
and unbounded was worse: adoption runs a full library reload, so one slow worker held the rest from
even receiving the request, and `route_to_worker` argues liveness from the heartbeat, which says
nothing about a worker busy adopting. The asks run concurrently, each bounded by the startup grace.
And because a worker receives activations from two sources on different loops, they carry the
orchestrator's committed generation and only strictly newer ones are adopted -- the newest committed
switch wins regardless of arrival order.

**Routing waits for the worker to be usable.** A worker is routable the moment it registers, but
registration is deliberately early, so forwarding into that window fails node creation with "Library
not found". Execution waits on a per-library event set on every terminal outcome: loaded, spawn
refused, spawn died, worker evicted, no session available. Bounded anyway, because "every" is a claim
about code that keeps changing.

Project switching is also exposed headlessly, which is what makes any of the above testable without a
human clicking. That moves `ProjectID` to a runtime import so cattrs can structure it, which also
changes `ExportProjectRequest.destination_path` from `str` to `Path`, along with the coercion on
`ImportProjectResultSuccess.project_file_path` and two `validation` fields. All four round-trip; the
export path is the one to look at.

Building the execution environment moves behind a gate on the heavy set, so retiring one the
manifest no longer declares moves with it: the removal now sits beside the build rather than inside
_install_dependency_set, which this gate no longer reaches for a library declaring none.

The removal is wrapped: it runs on the registration path, so a directory that will not delete --
held open by a running worker, or read-only on Windows -- would otherwise cost the library its node
types over a leftover. Leaving it costs execution correctness, which is the cheaper failure.
Comment pass over this PR's additions: what survives is the constraint or
the reason an obvious alternative is wrong, and what goes is incident
history, restatement of the line below, and forward references to code
that already explains itself. Eight blocks, none shorter than six lines,
now four or fewer.

The ProjectID alias keeps its justification, rephrased: pydantic resolves
those annotations at runtime, so the import cannot be TYPE_CHECKING-only,
and events are the import leaf.

SetCurrentProjectRequest, GetCurrentProjectRequest and
LoadProjectTemplateRequest come off the MCP server's supported set. They
were added for a headless client to drive a project switch, but nothing
drives one: no unit coverage, and no e2e harness uses them. Their imports
go with them.
A worker could be told which project to run against by two different
senders: the registration reply and the switch fan-out. Ordering them meant
a generation counter on the wire and a lock on the worker, and the generation
had leaked onto CurrentProjectChanged -- protocol bookkeeping in a domain
event that clients also consume.

Registration now sends the activation instead of answering with the project,
down the same channel a mid-session switch uses. One sender, one adoption
path, and a switch landing mid-registration is just a second message rather
than a second source. The reply carries no project, and the worker waits for
the activation to settle before it loads any library.

The fan-out reads the committed pair rather than taking the id off the event,
so the id and the generation describing it come from one read. Two switches
in quick succession therefore both fan out the newest committed state and a
worker cannot be told to go backwards.

The lock stays, and its comment was wrong twice over: the two adoptions are
both on the transport loop, not different ones, and one sender does not
remove the need for it -- activation awaits internally, so two that arrive
close together interleave regardless of who sent them.
These arrived on the branch below, where the worker installed its own
execution environment. Here the orchestrator builds it -- the worker
receives that directory as PYTHONPATH and so cannot be the process that
creates it -- so they were asserting against a path this branch removes.

The behaviour under test is unchanged: a declared dependency's execution
pins resolve alongside the depending library's own, in one resolution.
Only which process does it, and which call to watch, moved.
The test arrives from the branch below, where routing waited on
wait_for_worker_library_load. Here it waits on wait_for_worker_ready, so the
mock has to match or the await fails before the case under test is reached.
…an event

Backgrounding the `.venv-exec` build to keep a torch install off the startup
path bought a coordination layer whose only job was to make an install that
had not finished look like one that had: a cancel-and-replace protocol, a
task registry keyed by venv directory, a readiness event per build, and a
spawn that waited on it. The edit-time install next to it was awaited all
along, which is the answer to why only the execution set needed any of this.

Awaited now, like its neighbour. That deletes _replace_execution_env_build,
_build_execution_env, _execution_env_builds, execution_env_ready and
wait_for_execution_env, and the spawn path simply reads the recorded reason
instead of waiting first. Holding startup is the honest behaviour; installing
less often is how to make it cheap.

The invented library name went with it -- a failed build no longer needs a
name to key an event on, so nothing falls back to "unknown".
Dropping the worker-side sys.path splice left `test_serializable_outputs_are_unaffected` on a
premise this branch removed. It set `_is_worker` BEFORE registering, because that was how the
execution venv reached sys.path; with the splice gone, worker mode installs nothing at all, so the
node module could not import even its edit-time dependency and the library loaded zero nodes.
Registration failed, and the guardrail the test is named for never ran.

The flag now flips after registering, as the sibling guardrail test above it already does: loading
a library is the orchestrator's job, and the flag only selects the execution path under test. Both
dependency sets are declared edit-time so this one process can import both, because a real worker
receives the execution set as PYTHONPATH from the spawn and no single-process test has that. The
two environments being separate on disk is pinned in test_library_execution_dependency_split.py
and is not what this test is about; the execution set stays declared so the library is still
worker-routed and the guardrail applies at all.

Caught by CI on a PR stacked on this branch. This branch's own Tests runs were cancelled, so no
completed e2e run had reached the assertion, and it fails on Linux, Windows and macOS alike.
…ds it

Building the environment before returning rather than behind an event left this test asserting the
opposite of what now happens: `.venv-exec` exists by the time registration answers, so
`assert not exec_venv.exists()` fails, and the comment explaining the absence described a
scheduled build that no longer exists.

Inverting the assertion is not enough to make it worth having. Nothing anywhere proved the
execution environment gets built -- every assertion about it was a negative one, and the test that
needs an existing environment mkdir's a fake. So this now asserts the directory exists AND that the
heavy dependency is installed inside it, which is the mechanism the whole split rests on: the
worker receives that directory as PYTHONPATH and cannot build it for itself.

What the test was really pinning is untouched and still asserted: the execution set stays absent
from the orchestrator's sys.path, and importing it here still raises. The name said "does not
install execution dependencies at all", which was never true of the directory -- only of this
process's import path -- so it now says which of the two it means.
`worker_ready` said how a library came to be executable rather than what a
caller wants to know, which is whether it may route execution there at all.
Now `library_ready` / `wait_for_library_ready`, and the docstring stops
assuming a separate process: a library that runs in this one is trivially
ready.

`project_activation_settled` becomes `worker_settled`. It gates one thing
today -- the project the orchestrator sent -- but the name is the barrier
rather than its current contents, so a second piece of startup state gates
this same event instead of adding another for the caller to wait on.

Also records #266 against the generation counters and the adoption lock,
which is where a reader meets them.
LibraryInfo carried two fields that answered questions about a PROCESS --
is a worker coming, has it loaded the library, did it die -- and WorkerManager
wrote them. A record with one owner had two writers: `_start_workers` cleared
`execution_unavailable_reason` before a spawn while `_refuse_spawn` wrote it,
and `library_ready` was created by one manager and set by the other.

Both move to WorkerManager, keyed by library name, alongside the maps it
already keeps. Callers ask rather than reading fields: `wait_until_executable`
replaces `wait_for_library_ready`, and `worker_unavailable_reason` replaces the
field read. Boot asks `wait_for_libraries` for a collective ceiling instead of
gathering events it was handed.

The other direction goes too. WorkerManager no longer reads LibraryManager to
decide whether to refuse a spawn: a library whose execution environment failed
to build is simply never asked for a worker, decided where the build happened
and the result is already known.

What stays on LibraryManager is what it knows on its own -- a declared resource
the machine lacks, an environment that would not build -- and
`get_worker_for_library` composes both owners' answers for the one message a
caller sees.
The reason and the gate are keyed by a bare library name, so they outlived
the record they describe: an evicted worker's reason survived a reload that
dropped the library's execution dependencies, and get_worker_for_library read
it for every library rather than only worker-backed ones. Both fixed --
forget_library on unload, and the read gated on executes_in_worker.

is_execution_available answered "settled", which is not the same as available;
renamed has_settled. _refuse_spawn had become a second name for
note_worker_unavailable, and the worker_manager None guards documented an
optionality the annotations deny -- the None came from a test mock describing a
shape the engine cannot be in.

Restores the eviction-reason assertion that was deleted rather than moved,
covers forget_library, and drops mocks for a method that no longer exists.
A manifest can declare legacy worker mode AND execution dependencies, and nothing rejects the pair:
`_resolve_executes_in_worker` returns True for either reason and no validator looks at the
combination. For that library the orchestrator took the WORKER_DELEGATED branch, which skipped
`install_library_dependencies_request` entirely, and inside the worker the execution install is
gated on `not self._is_worker`. So neither process built `.venv-exec`.

Nothing downstream caught it. No install ran, so no failure was recorded, so
`execution_env_failure_reason` stayed None and the spawn was not refused;
`execution_site_packages` returned None because the directory was absent, and the worker started
with no PYTHONPATH -- the raw ModuleNotFoundError at node-module import that the spawn refusal was
added to prevent, reached from the one direction the refusal cannot see.

Skipping the LOAD is not the same as skipping the environment. The orchestrator has to build
`.venv-exec` whoever loads the library, because the worker receives that directory as PYTHONPATH
and so cannot be the process that creates it.

The call now happens for both branches and only the resulting lifecycle state differs. What the
orchestrator must NOT do for such a library is build the edit-time venv, and
`_this_process_owns_the_edit_venv` is where that belongs: it returns True for every library on the
orchestrator, which its own docstring already contradicts for the worker-mode case ("nobody else
creates `.venv` and the worker must"). It reads the library's `requires_worker` now, so the
edit-time gate declines and the execution gate proceeds. An exec-install failure is recorded rather
than returned, so routing a delegated library through here cannot fail its registration.

A mutation check rather than a reading: reverting the ownership line to `return True` turns two of
the three new tests red.
Both TODOs carried a full URL into griptape-ai/internal, whose visibility is INTERNAL, so for anyone
reading this repo from outside the org the link is a 404 dressed up as an explanation.

`os_manager.py` and `path_utils.py` already cite the same tracker as `griptape-ai/internal#178`,
which reads as a reference rather than promising a page. Matching that: the reasoning those TODOs
need is in the prose above them either way.
The execution set used to be spliced after the edit-time one, so it landed at `sys.path[0]` and won
for any package in both. It now arrives as PYTHONPATH instead, which Python places at `sys.path[1]`,
and `_add_library_edit_venv_to_sys_path` then inserts the edit-time site-packages at 0 -- ahead of
it. The precedence inverted.

That matters because the two directories can hold different versions of the same package. The
edit-time install resolves `pip_dependencies` alone; the execution install resolves
`[*pip_dependencies, *execution_dependencies]`, and adding a heavy pin is exactly what makes the
combined resolver choose differently for something shared. So `process()` ran against the version
the execution resolver rejected -- the disagreement the single combined resolution exists to
prevent.

The splice is skipped when this library's execution site-packages is already on `sys.path`, which is
true only in the worker the library was spawned for. That environment is resolved over both sets, so
it provides everything the edit-time one does. A library the worker was NOT spawned for is
unaffected: its execution directory is not on the path, so its edit-time venv is still spliced, and
so is every library's in the orchestrator.

Checked by construction rather than by reading: with the execution directory on the path a later
`insert(0, edit_site_packages)` resolves a package in both to the edit-time copy.

Nothing covered this function, which is why the inversion was invisible -- the e2e suite uses one
edit-time and one execution wheel with no overlap, so no test had a package in both. Two tests now:
the execution directory stays first when it is present, and the edit-time venv is still spliced when
it is not.

The caller's docstring carries the invariant again. It said "goes on FIRST, so it wins for anything
present in both" before this stack, and losing that sentence is what let the inversion read as
tidying.
@cjkindel
cjkindel force-pushed the refactor/thread-the-engine-into-exe-types branch from cfa2158 to 5cfd901 Compare September 21, 2026 19:20
`spawn_worker` checked `_managed_worker_processes` for a duplicate, then wrote its entry 68
lines later, with the static-URL await and the fork in between. Two spawns for one library
both got past the check and both forked; only one could be recorded, leaving the other's
process untracked and holding that library's dependencies in memory until its own heartbeat
lapsed.

Two `StartWorkerRequest`s for one library name overlap whenever `_library_file_path_to_info`
carries duplicate entries for it (a FAILURE entry alongside the LOADED copy), or when
`_start_workers` runs twice before the first worker registers.

The key is now claimed in the same step as the check, and released in a `finally` so a
failed fork does not leave the library permanently unspawnable.
`_log_spawn_error` asked `task.exception()` first, which raises `CancelledError` on a
cancelled task. Raising from a done-callback surfaces as loop-level "Exception in callback"
noise and skips the `note_worker_unavailable` below it, so nothing records that no worker is coming.

Cancellation reaches this callback at loop teardown, where nothing is waiting on the worker,
so the cost today is the noise rather than a stalled run.

Note on A1 of the tracking issue, which covers the same function: the claim that the spawn
task can be garbage collected while parked does not hold. `Event.wait()` appends a future to
the event's `_waiters`, and the awaiting task installs `Task.__wakeup` as that future's
done-callback, so the task stays reachable from the manager for as long as it is parked. No
change made there.
`route_to_worker` registers a request then awaits it. Every other exit from that await removes
the entry: a response pops it in `_try_match`, eviction in `cancel_requests_by_tag`. Flow
cancellation re-raised without removing anything, so the entry stayed in `_pending_requests`
for the life of the process -- one per cancelled node execution.

Each leaked entry keeps its worker's tag, so a later `cancel_requests_by_tag` walks and
re-settles long-dead requests, and `pending_count`, the only visibility into that map, reports
a number that never comes down. Nothing fails loudly; it degrades over a long editing session.

`_cancel_request` did exactly the removal needed but was async, and awaiting inside an except
block while a cancellation is being delivered adds a suspension point in the one place it is
least wanted. Its body never awaited, so it becomes the public synchronous `discard_request`
and its five internal callers drop their `await`.
`pending_count` and `pending_request_ids` read `_pending_requests` while holding nothing, and
it is mutated from more than one loop: `_try_match` on the transport loop, `track_request` and
`cancel_requests_by_tag` from whichever loop issued the request.

`len()` only risks a stale answer, which is fine for a diagnostic. Building the id list
iterates, so a concurrent insert or pop raises "dictionary changed size during iteration" --
a diagnostic that can raise is worse than one that answers a moment out of date.

Both are diagnostics with no production caller today, so nothing holds the lock across a call
into them.
`_orchestrator_static_server_base_url` picks between a direct read and a thread hop, and
between two warnings when no URL arrives. Nothing exercised either choice: inverting the
settled check left the suite green, and so did swapping the two warnings.

Both choices matter to whoever has to act on them. Taking the hop when the answer is already
in parks an uncancellable default-executor thread that teardown joins, and blaming the settle
timeout for a resolution that raised points an operator at slow startup instead of the bind
failure that actually happened.

Five tests: the direct read, the hop with its timeout, each warning, and a cloud backend
staying quiet because there its URLs outlive the worker anyway. Each fails if the branch it
covers is inverted.
The static-URL tests set `static_server_base_url_settled`, which is a property on the real
manager, so pyright rejected the assignment even though the object is a MagicMock. Casting once
per test says what the object actually is instead of suppressing each line.
A regression in the spawn claim: `reset_workers` cleared the process registry but not the new
claim set, so a spawn suspended across a library reload kept its claim and the reload's own spawn
for that library was refused. The refusal records nothing (a worker is normally on its way when a
key is claimed) so the next run would wait out the whole startup grace and then blame the library
load. Cleared with the registry, with a test that fails without it.

`note_worker_unavailable` is deliberately still NOT called from the duplicate guard: it records a
reason and settles the library, which would falsely fail a library that already has, or is about
to have, a worker.

Comments that were wrong or that answered a reviewer are gone rather than reworded again: a claim
that `list(dict.keys())` can raise "dictionary changed size during iteration" (it cannot, the whole
build is one C loop under the GIL) and a `discard_request` docstring citing a suspension point that
never existed.

`test_an_undecided_url_is_waited_for_off_the_loop` asserted its own name only loosely; it now
records the executing thread and fails if the blocking wait runs on the event loop.
`reset_workers` drops the claims so a reload can spawn its libraries again, which leaves a spawn
suspended between its claim and the registry write resuming to find the key claimed by the
reload's spawn. Releasing by name alone freed that one, so the library was admitted again while a
spawn was genuinely in flight -- and a second fork leaves one of the two processes untracked,
which is the leak the claim exists to prevent.

Claims are now per attempt: the map holds a token identifying the holder, and the release is
conditional on the stored token still being the one this attempt took.

The test drives the interleaving: park a spawn, reset, let the reload claim the key, then release
the stale spawn with its fork failing so no registry entry shadows a wrongly-freed claim. It fails
against an unconditional release.

The comment on the claim also drops its comparison with the guard this replaced and states the
constraint instead: the registry entry is written only once the subprocess exists, and the work in
between suspends.
@cjkindel
cjkindel merged commit 7047794 into main Sep 21, 2026
27 checks passed
@cjkindel
cjkindel deleted the refactor/thread-the-engine-into-exe-types branch September 21, 2026 22:48
cjkindel added a commit that referenced this pull request Sep 21, 2026
Worker v2 landed, and it and this change had overlapping opinions about the same thing.

The guardrail goes. on_execute_node_request refused any serializable=False output in a worker,
with a message telling the author to cache it library-side and ship a descriptor the consumer
trades back -- a description of the workaround this change replaces with an engine-owned cache.
It flagged by declaration rather than by value, so it also refused a token string that ships
perfectly well. The one case the cache genuinely cannot take is a container, which has no single
object to hold and nowhere to attach a release hook, and reaching that needs an attribute the
ParameterList constructor refuses to accept. Not worth a guard, so the check and its helper are
gone and its two tests now assert what the scenario has become: the object stays and a reference
crosses. That is the feature's first coverage through the real worker path rather than a
simulated boundary.

The facade access was a genuine bug this guard caught, not over-reach. LocalObjectScope reached
the store through GriptapeNodes.ResourceManager() with a comment saying BaseNode had no engine
reference to hand down. That was true when written and went stale: #5506 added BaseNode.engine in
this same stack, and its docstring says node machinery should use it precisely so the facade stays
the surface for library code. Engine-internal code going through the facade trips a guard aimed at
node authors -- the guard's own docstring names the storage driver doing this and calls it
non-hypothetical. The scope now takes the node and reads its engine per call, following the node's
own deferred resolution rather than pinning whichever engine was ambient. One choke point, and it
fixes every worker execution.

Also: this change's rename of _instances to _capability_instances had to be carried into worker
v2's new device handler and its test, and the two teardown registrations moved out of
register_broadcast_handlers, which they had pushed past its complexity limit.
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.

2 participants