Skip to content

Commit 770973e

Browse files
Kowserclaude
andcommitted
Make framework workers spawn-safe: GraphWorkerEntry + PassthroughWorkerEntry
Group C (final) of the idea-5 spawn-safety design — the 9 framework worker closures (6 langgraph graph-structure workers + 3 passthroughs). - GraphWorkerEntry: picklable wrapper carrying (factory name, FunctionRef to the user node/router function, plain-data args); the langgraph make_* factory runs once per worker process inside the entry — its nested worker never crosses a process boundary. The LLM/subgraph objects were never captured anyway (only their variable NAMES; the spawn child rebuilds them by re-importing the node function's module). - PassthroughWorkerEntry: same deferred-factory pattern for the claude / langchain / langgraph passthroughs. Claude options travel as a plain- config dict (ClaudeCodeOptions is never picklable as-is — debug_stderr defaults to sys.stderr) via new claude_options_to_plain_config / make_claude_agent_sdk_worker_from_config; hooks / can_use_tool callables and in-process MCP instances raise SpawnSafetyError naming the field. Langchain executors / compiled langgraph graphs hold live clients and locks — the registration probe (group "framework", now enabled) fails fast with an actionable message instead of an opaque PicklingError. - Passthrough registration tests updated to the deferred-factory contract (invoke the entry, assert the factory call); the claude test uses a real ClaudeCodeOptions instead of a Mock. Group D note: check_approval_worker and the _tool_registry dispatch write are retained (test consumers + upstream-parity tracking), not deleted. Tests: GraphWorkerEntry across a real spawn boundary; passthrough payload pickling + fail-fast; claude config extraction/rejection/ round-trip. tests/unit: 2523 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 373f868 commit 770973e

6 files changed

Lines changed: 302 additions & 43 deletions

File tree

src/conductor/ai/agents/frameworks/claude_agent_sdk.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,66 @@ def agent_to_claude_code_options(agent: Any) -> Any:
155155
)
156156

157157

158+
def claude_options_to_plain_config(options: Any) -> Dict[str, Any]:
159+
"""Extract the picklable plain-data fields of a ClaudeCodeOptions.
160+
161+
Spawn-safe transport for passthrough workers: ``ClaudeCodeOptions`` is
162+
never picklable as-is (``debug_stderr`` defaults to ``sys.stderr``), so
163+
the worker entry carries this config dict and rebuilds the options in the
164+
child. ``debug_stderr`` is skipped (the child re-defaults to its own
165+
stderr); any other unpicklable field (``hooks`` / ``can_use_tool``
166+
callables, in-process MCP server instances) raises ``SpawnSafetyError``
167+
naming the field — those objects cannot cross a process boundary.
168+
"""
169+
import dataclasses
170+
import pickle
171+
172+
from conductor.ai.agents.runtime._worker_entries import SpawnSafetyError
173+
174+
if not dataclasses.is_dataclass(options):
175+
raise SpawnSafetyError(
176+
f"expected a ClaudeCodeOptions dataclass, got {type(options).__name__!r}"
177+
)
178+
179+
config: Dict[str, Any] = {}
180+
for f in dataclasses.fields(options):
181+
if f.name == "debug_stderr":
182+
continue
183+
value = getattr(options, f.name)
184+
if value is not None:
185+
try:
186+
pickle.dumps(value)
187+
except Exception as e:
188+
raise SpawnSafetyError(
189+
f"ClaudeCodeOptions.{f.name} is not picklable and cannot "
190+
f"cross the spawn worker boundary ({e!r}). Remove it or "
191+
f"replace it with plain data."
192+
) from e
193+
config[f.name] = value
194+
return config
195+
196+
197+
def make_claude_agent_sdk_worker_from_config(
198+
config: Dict[str, Any],
199+
name: str,
200+
server_url: str,
201+
auth_key: str,
202+
auth_secret: str,
203+
credential_names: Optional[List[str]] = None,
204+
) -> Any:
205+
"""Rebuild ClaudeCodeOptions from a plain config and create the worker.
206+
207+
Runs in the worker child process (invoked by PassthroughWorkerEntry).
208+
"""
209+
from claude_code_sdk import ClaudeCodeOptions
210+
211+
options = ClaudeCodeOptions(**config)
212+
return make_claude_agent_sdk_worker(
213+
options, name, server_url, auth_key, auth_secret,
214+
credential_names=credential_names,
215+
)
216+
217+
158218
# ---------------------------------------------------------------------------
159219
# Passthrough worker
160220
# ---------------------------------------------------------------------------

src/conductor/ai/agents/runtime/_worker_entries.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,74 @@ async def __call__(self, human_output: object = None) -> object:
602602
return {"selected": str(human_output)}
603603

604604

605+
# ── Framework worker entries (Group C) ───────────────────────────────────
606+
607+
608+
class GraphWorkerEntry:
609+
"""Picklable langgraph graph-structure worker (node/router/llm/subgraph).
610+
611+
Carries the factory name, a transported user function, and plain-data
612+
factory args; rebuilds the real worker via the langgraph ``make_*``
613+
factory once per process. The LLM/subgraph objects are never pickled —
614+
those workers reference them by variable NAME in the resolved function's
615+
module globals, which the spawn child rebuilds by re-import.
616+
"""
617+
618+
def __init__(self, factory: str, fn, *args, **kwargs):
619+
self.factory = factory
620+
self.fn_t = wrap_callable(fn)
621+
self.args = args
622+
self.kwargs = kwargs
623+
self._worker = None # per-process memo, dropped on pickle
624+
625+
def __getstate__(self):
626+
state = self.__dict__.copy()
627+
state["_worker"] = None
628+
return state
629+
630+
def __call__(self, task):
631+
if self._worker is None:
632+
from conductor.ai.agents.frameworks import langgraph as _lg
633+
634+
self._worker = getattr(_lg, self.factory)(
635+
unwrap_callable(self.fn_t), *self.args, **self.kwargs
636+
)
637+
return self._worker(task)
638+
639+
640+
class PassthroughWorkerEntry:
641+
"""Picklable-by-value passthrough worker (claude / langchain / langgraph).
642+
643+
Carries the payload the factory needs. For claude the payload is a
644+
plain-config dict (rebuilt into options in the child); for langchain /
645+
langgraph it is the live executor/compiled graph — those typically hold
646+
live clients and locks, so the registration probe fails fast under spawn
647+
with an actionable message instead of an opaque PicklingError.
648+
Rebuilds the real worker via the named factory once per process.
649+
"""
650+
651+
def __init__(self, module: str, factory: str, payload, *args, **kwargs):
652+
self.module = module
653+
self.factory = factory
654+
self.payload = payload
655+
self.args = args
656+
self.kwargs = kwargs
657+
self._worker = None # per-process memo, dropped on pickle
658+
659+
def __getstate__(self):
660+
state = self.__dict__.copy()
661+
state["_worker"] = None
662+
return state
663+
664+
def __call__(self, task):
665+
if self._worker is None:
666+
factory_mod = importlib.import_module(self.module)
667+
self._worker = getattr(factory_mod, self.factory)(
668+
self.payload, *self.args, **self.kwargs
669+
)
670+
return self._worker(task)
671+
672+
605673
# ── Registration-time spawn probe ────────────────────────────────────────
606674
#
607675
# Groups are enabled stage-by-stage as each worker family is converted to a
@@ -611,7 +679,10 @@ async def __call__(self, human_output: object = None) -> object:
611679
# framework-extracted) — converted in Stage 2.
612680
# - "system": AgentRuntime._register_* control workers (guardrails, callbacks,
613681
# termination, transfer, router, handoff, selection) — converted in Stage 3.
614-
_ENABLED_PROBE_GROUPS: FrozenSet[str] = frozenset({"tools", "system"})
682+
# - "framework": langgraph graph workers + passthrough workers (claude /
683+
# langchain / langgraph) — converted in Stage 4. Passthrough payloads that
684+
# hold live clients/graphs fail here, by design, with a named offender.
685+
_ENABLED_PROBE_GROUPS: FrozenSet[str] = frozenset({"tools", "system", "framework"})
615686

616687

617688
def _spawn_probe_active(group: str) -> bool:

src/conductor/ai/agents/runtime/runtime.py

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3110,11 +3110,13 @@ def _register_passthrough_worker(self, worker: Any) -> None:
31103110
worker.func is already a pre-wrapped tool_worker(task) -> TaskResult closure.
31113111
Uses _passthrough_task_def (600s timeout) instead of _default_task_def (10s).
31123112
"""
3113+
from conductor.ai.agents.runtime._worker_entries import probe_spawn_safety
31133114
from conductor.client.worker.worker_task import worker_task
31143115

31153116
# Add minimal annotations so the Conductor SDK can introspect the function
31163117
worker.func.__annotations__ = {"task": object, "return": object}
31173118

3119+
probe_spawn_safety(worker.func, worker.name, group="framework")
31183120
worker_task(
31193121
task_definition_name=worker.name,
31203122
task_def=_passthrough_task_def(worker.name),
@@ -3150,13 +3152,9 @@ def _register_graph_workers(self, raw_config: dict, workers: list) -> None:
31503152
if not workers:
31513153
return
31523154

3153-
from conductor.ai.agents.frameworks.langgraph import (
3154-
make_llm_finish_worker,
3155-
make_llm_prep_worker,
3156-
make_node_worker,
3157-
make_router_worker,
3158-
make_subgraph_finish_worker,
3159-
make_subgraph_prep_worker,
3155+
from conductor.ai.agents.runtime._worker_entries import (
3156+
GraphWorkerEntry,
3157+
probe_spawn_safety,
31603158
)
31613159
from conductor.client.worker.worker_task import worker_task
31623160

@@ -3175,20 +3173,32 @@ def _register_graph_workers(self, raw_config: dict, workers: list) -> None:
31753173
subgraph_role = extra.get("subgraph_role")
31763174
subgraph_var_name = extra.get("subgraph_var_name")
31773175

3176+
# Picklable entries (idea-5 spawn safety): the langgraph make_*
3177+
# factory runs per process inside the entry; the user function
3178+
# travels as a FunctionRef when module-level.
31783179
if subgraph_role == "prep" and subgraph_var_name:
3179-
wrapped = make_subgraph_prep_worker(w.func, w.name, subgraph_var_name)
3180+
wrapped = GraphWorkerEntry(
3181+
"make_subgraph_prep_worker", w.func, w.name, subgraph_var_name
3182+
)
31803183
elif subgraph_role == "finish" and subgraph_var_name:
3181-
wrapped = make_subgraph_finish_worker(w.func, w.name, subgraph_var_name)
3184+
wrapped = GraphWorkerEntry(
3185+
"make_subgraph_finish_worker", w.func, w.name, subgraph_var_name
3186+
)
31823187
elif llm_role == "prep" and llm_var_name:
3183-
wrapped = make_llm_prep_worker(w.func, w.name, llm_var_name)
3188+
wrapped = GraphWorkerEntry("make_llm_prep_worker", w.func, w.name, llm_var_name)
31843189
elif llm_role == "finish" and llm_var_name:
3185-
wrapped = make_llm_finish_worker(w.func, w.name, llm_var_name)
3190+
wrapped = GraphWorkerEntry(
3191+
"make_llm_finish_worker", w.func, w.name, llm_var_name
3192+
)
31863193
elif w.name in router_refs:
31873194
is_dynamic = extra.get("is_dynamic_fanout", False)
3188-
wrapped = make_router_worker(w.func, w.name, is_dynamic_fanout=is_dynamic)
3195+
wrapped = GraphWorkerEntry(
3196+
"make_router_worker", w.func, w.name, is_dynamic_fanout=is_dynamic
3197+
)
31893198
else:
3190-
wrapped = make_node_worker(w.func, w.name)
3199+
wrapped = GraphWorkerEntry("make_node_worker", w.func, w.name)
31913200

3201+
probe_spawn_safety(wrapped, w.name, group="framework")
31923202
worker_task(
31933203
task_definition_name=w.name,
31943204
task_def=_default_task_def(w.name),
@@ -3223,10 +3233,18 @@ def _build_passthrough_func(
32233233
auth_key = self._config.auth_key or ""
32243234
auth_secret = self._config.auth_secret or ""
32253235

3226-
if framework == "langgraph":
3227-
from conductor.ai.agents.frameworks.langgraph import make_langgraph_worker
3236+
# All three return a picklable PassthroughWorkerEntry (idea-5 spawn
3237+
# safety) that rebuilds the real worker per process. For langgraph/
3238+
# langchain the payload is the live graph/executor — typically
3239+
# unpicklable, in which case the registration probe fails fast with
3240+
# an actionable message. For claude the payload is a plain-config
3241+
# dict, so that path is fully spawn-safe.
3242+
from conductor.ai.agents.runtime._worker_entries import PassthroughWorkerEntry
32283243

3229-
return make_langgraph_worker(
3244+
if framework == "langgraph":
3245+
return PassthroughWorkerEntry(
3246+
"conductor.ai.agents.frameworks.langgraph",
3247+
"make_langgraph_worker",
32303248
agent_obj,
32313249
name,
32323250
server_url,
@@ -3235,9 +3253,9 @@ def _build_passthrough_func(
32353253
credential_names=credentials,
32363254
)
32373255
elif framework == "langchain":
3238-
from conductor.ai.agents.frameworks.langchain import make_langchain_worker
3239-
3240-
return make_langchain_worker(
3256+
return PassthroughWorkerEntry(
3257+
"conductor.ai.agents.frameworks.langchain",
3258+
"make_langchain_worker",
32413259
agent_obj,
32423260
name,
32433261
server_url,
@@ -3249,7 +3267,7 @@ def _build_passthrough_func(
32493267
from conductor.ai.agents.agent import Agent as AgentClass
32503268
from conductor.ai.agents.frameworks.claude_agent_sdk import (
32513269
agent_to_claude_code_options,
3252-
make_claude_agent_sdk_worker,
3270+
claude_options_to_plain_config,
32533271
)
32543272

32553273
# CRITICAL: convert Agent → ClaudeCodeOptions before passing to worker
@@ -3258,8 +3276,10 @@ def _build_passthrough_func(
32583276
else:
32593277
options = agent_obj # Already ClaudeCodeOptions
32603278

3261-
return make_claude_agent_sdk_worker(
3262-
options,
3279+
return PassthroughWorkerEntry(
3280+
"conductor.ai.agents.frameworks.claude_agent_sdk",
3281+
"make_claude_agent_sdk_worker_from_config",
3282+
claude_options_to_plain_config(options),
32633283
name,
32643284
server_url,
32653285
auth_key,

tests/unit/ai/test_passthrough_registration.py

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,17 @@ def test_build_passthrough_func_passes_auth_to_langgraph_worker(self):
9999
graph = MagicMock()
100100
type(graph).__name__ = "CompiledStateGraph"
101101

102+
# Build a minimal runtime just to call _build_passthrough_func. The
103+
# factory is now deferred into a picklable PassthroughWorkerEntry
104+
# (idea-5 spawn safety) and runs on first task — invoke the entry to
105+
# verify the full propagation.
106+
runtime = AgentRuntime.__new__(AgentRuntime)
107+
runtime._config = config
108+
entry = runtime._build_passthrough_func(graph, "langgraph", "test_graph")
109+
102110
with patch("conductor.ai.agents.frameworks.langgraph.make_langgraph_worker") as mock_worker:
103111
mock_worker.return_value = MagicMock()
104-
# Build a minimal runtime just to call _build_passthrough_func
105-
runtime = AgentRuntime.__new__(AgentRuntime)
106-
runtime._config = config
107-
runtime._build_passthrough_func(graph, "langgraph", "test_graph")
112+
entry(MagicMock())
108113

109114
mock_worker.assert_called_once_with(
110115
graph,
@@ -129,16 +134,18 @@ def test_build_passthrough_func_passes_credentials_to_langgraph_worker(self):
129134
graph = MagicMock()
130135
type(graph).__name__ = "CompiledStateGraph"
131136

137+
runtime = AgentRuntime.__new__(AgentRuntime)
138+
runtime._config = config
139+
entry = runtime._build_passthrough_func(
140+
graph,
141+
"langgraph",
142+
"test_graph",
143+
credentials=["GITHUB_TOKEN"],
144+
)
145+
132146
with patch("conductor.ai.agents.frameworks.langgraph.make_langgraph_worker") as mock_worker:
133147
mock_worker.return_value = MagicMock()
134-
runtime = AgentRuntime.__new__(AgentRuntime)
135-
runtime._config = config
136-
runtime._build_passthrough_func(
137-
graph,
138-
"langgraph",
139-
"test_graph",
140-
credentials=["GITHUB_TOKEN"],
141-
)
148+
entry(MagicMock())
142149

143150
mock_worker.assert_called_once_with(
144151
graph,
@@ -159,25 +166,33 @@ def test_build_passthrough_func_passes_auth_to_claude_agent_sdk_worker(self):
159166
auth_secret="my_secret",
160167
)
161168

162-
options = MagicMock()
163-
type(options).__name__ = "ClaudeCodeOptions"
169+
claude_sdk = pytest.importorskip("claude_code_sdk")
170+
options = claude_sdk.ClaudeCodeOptions(system_prompt="hello", max_turns=4)
171+
172+
runtime = AgentRuntime.__new__(AgentRuntime)
173+
runtime._config = config
174+
# Options travel as a plain-config dict inside a PassthroughWorkerEntry
175+
# (ClaudeCodeOptions is never picklable as-is — debug_stderr) and are
176+
# rebuilt in the worker process; invoke the entry to verify the chain.
177+
entry = runtime._build_passthrough_func(options, "claude_agent_sdk", "test_agent")
164178

165179
with patch(
166180
"conductor.ai.agents.frameworks.claude_agent_sdk.make_claude_agent_sdk_worker"
167181
) as mock_worker:
168182
mock_worker.return_value = MagicMock()
169-
runtime = AgentRuntime.__new__(AgentRuntime)
170-
runtime._config = config
171-
runtime._build_passthrough_func(options, "claude_agent_sdk", "test_agent")
183+
entry(MagicMock())
172184

173-
mock_worker.assert_called_once_with(
174-
options,
185+
mock_worker.assert_called_once()
186+
called_options = mock_worker.call_args.args[0]
187+
assert called_options.system_prompt == "hello"
188+
assert called_options.max_turns == 4
189+
assert mock_worker.call_args.args[1:] == (
175190
"test_agent",
176191
"http://testserver:8080/api",
177192
"my_key",
178193
"my_secret",
179-
credential_names=None,
180194
)
195+
assert mock_worker.call_args.kwargs == {"credential_names": None}
181196

182197

183198
def _make_fake_task(workflow_instance_id="wf-123", prompt="test prompt"):

0 commit comments

Comments
 (0)