Skip to content

Commit 7481aed

Browse files
authored
feat(automator): CONDUCTOR_WORKER_ISOLATION=thread worker isolation mode #436
feat(automator): CONDUCTOR_WORKER_ISOLATION=thread worker isolation mode
2 parents e5ff5cd + 01eee8a commit 7481aed

6 files changed

Lines changed: 411 additions & 76 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Worker isolation mode: `CONDUCTOR_WORKER_ISOLATION=thread` runs every worker as a thread instead of a `multiprocessing.Process` (default `process` is unchanged — `spawn` remains the start method). For environments where multiprocessing's fork+exec bootstraps (spawn children, `resource_tracker`) fail, e.g. Firecracker microVM guests. Thread-mode tradeoffs: no per-worker force-kill (shutdown is cooperative), CPU-bound workers share the GIL, and `signal.signal` becomes a no-op off the main thread. Implementation: the Windows-only Process→Thread shim moved to `conductor.client.automator.worker_isolation` (the private `worker_manager._patch_conductor_use_threads_on_windows` helper is removed — the Windows gate calls `apply_thread_isolation()` directly) and now also swaps the logging-relay `Queue` for a plain `queue.Queue`
13+
1214
- Canonical metrics mode: opt-in harmonized metric surface via `WORKER_CANONICAL_METRICS=true` -- [details](METRICS.md#detailed-technical-notes--unreleased)
1315
- `MetricsSettings` gains `clean_directory` and `clean_dead_pids` for opt-in stale `.db` file cleanup (both default to `False`)
1416
- `SchedulerClient` now carries the schedule lifecycle operations itself: `pause(reason=)`, `resume`, `delete`, `run_now`, `preview_next`, `reconcile` (declarative tri-state sync) — with typed errors (`ScheduleNotFound`, `InvalidCronExpression`, ...). `pause_schedule` gains an optional `reason=` (stored by OSS Conductor servers; ignored by Orkes servers)

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

Lines changed: 6 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -16,78 +16,9 @@
1616
import threading
1717
from typing import TYPE_CHECKING, Any, Optional
1818

19-
logger = logging.getLogger("conductor.ai.agents.worker_manager")
20-
21-
22-
def _patch_conductor_use_threads_on_windows() -> None:
23-
"""On Windows, replace multiprocessing.Process with threading.Thread for Conductor workers.
24-
25-
Windows multiprocessing uses 'spawn' which requires all objects passed to
26-
child processes to be picklable. Conductor workers hold threading locks
27-
and closures that are not picklable. Using threads instead of processes
28-
sidesteps the entire issue: threads share the parent's memory so no
29-
pickling is needed, and tool functions are typically I/O-bound so the GIL
30-
is not a bottleneck.
31-
32-
Also patches the worker target functions to skip signal.signal() calls,
33-
which are forbidden in non-main threads.
34-
"""
35-
try:
36-
from conductor.client.automator import task_handler as _th_module
37-
except ImportError:
38-
return
39-
40-
if getattr(_th_module, "_agentspan_thread_patched", False):
41-
return
42-
43-
# ── Thread shim ──────────────────────────────────────────────────────────
44-
45-
class _ThreadAsProcess(threading.Thread):
46-
"""threading.Thread shim that satisfies the multiprocessing.Process interface."""
47-
48-
def __init__(
49-
self, group=None, target=None, name=None, args=(), kwargs=None, *, daemon=None
50-
):
51-
super().__init__(
52-
group=group, target=target, name=name, args=args, kwargs=kwargs or {}, daemon=daemon
53-
)
54-
self.exitcode: Any = None
55-
56-
def terminate(self) -> None:
57-
pass
19+
from conductor.client.automator.worker_isolation import apply_thread_isolation
5820

59-
def kill(self) -> None:
60-
pass
61-
62-
@property
63-
def pid(self) -> None:
64-
return None
65-
66-
_th_module.Process = _ThreadAsProcess # type: ignore[attr-defined]
67-
68-
# ── Patch worker targets to skip signal.signal() in threads ──────────────
69-
# The conductor process targets call signal.signal(SIGINT, SIG_IGN) at the
70-
# top — valid in a real child process but raises ValueError in a thread.
71-
72-
import signal as _signal
73-
74-
# ── Patch signal.signal to be a no-op in non-main threads ────────────────
75-
# The conductor worker/logger process targets call signal.signal(SIGINT,
76-
# SIG_IGN) at startup — valid in a child process but raises ValueError
77-
# when called from a non-main thread. We monkey-patch signal.signal to
78-
# silently skip the call when not in the main thread.
79-
import signal as _signal_mod
80-
81-
_orig_signal_fn = _signal_mod.signal
82-
83-
def _thread_safe_signal(signalnum, handler):
84-
if threading.current_thread() is threading.main_thread():
85-
return _orig_signal_fn(signalnum, handler)
86-
# Non-main thread: skip silently
87-
88-
_signal_mod.signal = _thread_safe_signal # type: ignore[attr-defined]
89-
90-
_th_module._agentspan_thread_patched = True # type: ignore[attr-defined]
21+
logger = logging.getLogger("conductor.ai.agents.worker_manager")
9122

9223

9324
if TYPE_CHECKING:
@@ -147,8 +78,11 @@ def start(self) -> None:
14778
"""
14879
from conductor.client.automator.task_handler import TaskHandler
14980

81+
# Windows always needs thread isolation (spawn-pickling of worker
82+
# closures fails there); other environments opt in via
83+
# CONDUCTOR_WORKER_ISOLATION=thread, read by TaskHandler itself.
15084
if platform.system() == "Windows":
151-
_patch_conductor_use_threads_on_windows()
85+
apply_thread_isolation()
15286

15387
with self._lock:
15488
if self._task_handler is None:

src/conductor/client/automator/task_handler.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from conductor.client.automator.task_runner import TaskRunner
1616
from conductor.client.automator.async_task_runner import AsyncTaskRunner
17+
from conductor.client.automator import worker_isolation
1718
from conductor.client.configuration.configuration import Configuration
1819
from conductor.client.configuration.settings.metrics_settings import MetricsSettings
1920
from conductor.client.event.task_runner_events import TaskRunnerEvent
@@ -34,12 +35,12 @@
3435
)
3536

3637
_decorated_functions = {}
37-
_VALID_MP_START_METHODS = {"spawn", "fork", "forkserver"}
38-
_mp_fork_set = False
39-
if not _mp_fork_set:
38+
39+
_mp_spawn_set = False
40+
if not _mp_spawn_set:
4041
try:
4142
set_start_method("spawn")
42-
_mp_fork_set = True
43+
_mp_spawn_set = True
4344
except Exception as e:
4445
logger.info("error when setting multiprocessing.set_start_method - maybe the context is set %s", e.args)
4546
if platform == "darwin":
@@ -227,6 +228,11 @@ def __init__(
227228
restart_backoff_max_seconds: float = 60.0,
228229
restart_max_attempts: int = 0
229230
):
231+
# Thread isolation must be applied before _setup_logging_queue():
232+
# it creates the multiprocessing Queue and starts the logger Process —
233+
# the primitives CONDUCTOR_WORKER_ISOLATION=thread replaces.
234+
if worker_isolation.isolation_mode() == worker_isolation.ISOLATION_THREAD:
235+
worker_isolation.apply_thread_isolation()
230236
workers = workers or []
231237
self.logger_process, self.queue = _setup_logging_queue(configuration)
232238
self._configuration = configuration
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Copyright (c) 2025 Agentspan
2+
# Licensed under the MIT License. See LICENSE file in the project root for details.
3+
4+
"""Worker isolation mode — ``process`` (default) or ``thread``.
5+
6+
``CONDUCTOR_WORKER_ISOLATION=thread`` replaces :class:`TaskHandler`'s
7+
multiprocessing primitives (``Process``, ``Queue``) with thread equivalents.
8+
For environments where multiprocessing's fork+exec bootstraps (spawn
9+
children, ``resource_tracker``) fail — e.g. minimal microVM guests. Threads
10+
share the parent's memory, so the log relay needs no IPC and worker payloads
11+
need no pickling.
12+
13+
The default (``process``, or the variable unset) leaves every existing code
14+
path untouched: ``multiprocessing.Process`` under the ``spawn`` start method.
15+
16+
Tradeoffs (thread mode only): no per-worker force-kill (``terminate``/``kill``
17+
are no-ops; shutdown is cooperative), CPU-bound workers share the GIL, and
18+
``signal.signal`` becomes a no-op off the main thread instead of raising
19+
``ValueError``.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import logging
25+
import os
26+
import queue
27+
import threading
28+
from typing import Any
29+
30+
logger = logging.getLogger(__name__)
31+
32+
WORKER_ISOLATION_ENV = "CONDUCTOR_WORKER_ISOLATION"
33+
ISOLATION_PROCESS = "process"
34+
ISOLATION_THREAD = "thread"
35+
_VALID_MODES = frozenset({ISOLATION_PROCESS, ISOLATION_THREAD})
36+
37+
_invalid_mode_warned = False
38+
39+
40+
def isolation_mode() -> str:
41+
"""Return the configured worker isolation mode.
42+
43+
Reads ``CONDUCTOR_WORKER_ISOLATION`` (case-insensitive, whitespace
44+
trimmed). Invalid values warn once and fall back to ``process`` — failing
45+
open to the default behavior rather than bricking every worker on a typo.
46+
"""
47+
global _invalid_mode_warned
48+
raw = os.environ.get(WORKER_ISOLATION_ENV, "")
49+
mode = raw.strip().lower() or ISOLATION_PROCESS
50+
if mode not in _VALID_MODES:
51+
if not _invalid_mode_warned:
52+
logger.warning(
53+
"Ignoring invalid %s=%r; valid values are %s; using %r",
54+
WORKER_ISOLATION_ENV,
55+
raw,
56+
sorted(_VALID_MODES),
57+
ISOLATION_PROCESS,
58+
)
59+
_invalid_mode_warned = True
60+
mode = ISOLATION_PROCESS
61+
return mode
62+
63+
64+
class _ThreadAsProcess(threading.Thread):
65+
"""threading.Thread shim that satisfies the multiprocessing.Process interface."""
66+
67+
def __init__(
68+
self, group=None, target=None, name=None, args=(), kwargs=None, *, daemon=None
69+
):
70+
super().__init__(
71+
group=group, target=target, name=name, args=args, kwargs=kwargs or {}, daemon=daemon
72+
)
73+
self.exitcode: Any = None
74+
75+
def terminate(self) -> None:
76+
pass
77+
78+
def kill(self) -> None:
79+
pass
80+
81+
@property
82+
def pid(self) -> None:
83+
return None
84+
85+
86+
def apply_thread_isolation() -> None:
87+
"""Swap ``task_handler``'s multiprocessing primitives for thread equivalents.
88+
89+
Idempotent. Must run before the first :class:`TaskHandler` is constructed:
90+
its ``__init__`` creates the logging ``Queue`` and starts the logger
91+
``Process`` immediately.
92+
"""
93+
from conductor.client.automator import task_handler as _th_module # lazy: no import cycle
94+
95+
if getattr(_th_module, "_thread_isolation_applied", False):
96+
return
97+
98+
_th_module.Process = _ThreadAsProcess # type: ignore[attr-defined]
99+
100+
# With every worker a thread, the log relay is same-process: a plain
101+
# thread-safe queue replaces the IPC Queue, whose SemLock would drag in
102+
# the resource_tracker bootstrap this mode exists to avoid.
103+
_th_module.Queue = queue.Queue # type: ignore[attr-defined]
104+
105+
# The worker/logger process targets call signal.signal(SIGINT, SIG_IGN)
106+
# at startup — valid in a child process but a ValueError in a non-main
107+
# thread. Make signal.signal skip silently off the main thread.
108+
import signal as _signal_mod
109+
110+
_orig_signal_fn = _signal_mod.signal
111+
112+
def _thread_safe_signal(signalnum, handler):
113+
if threading.current_thread() is threading.main_thread():
114+
return _orig_signal_fn(signalnum, handler)
115+
# Non-main thread: skip silently
116+
117+
_signal_mod.signal = _thread_safe_signal # type: ignore[assignment]
118+
119+
_th_module._thread_isolation_applied = True # type: ignore[attr-defined]

tests/unit/ai/test_worker_manager.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,3 +356,39 @@ def test_filter_installed_on_conductor_logger(self):
356356
# Clean up
357357
for f in schema_filters:
358358
conductor_logger.removeFilter(f)
359+
360+
361+
class TestWindowsThreadIsolation:
362+
"""On Windows the gate applies thread isolation via worker_isolation directly."""
363+
364+
def test_windows_gate_applies_thread_isolation(self):
365+
config = MagicMock()
366+
manager = WorkerManager(configuration=config)
367+
with patch(
368+
"conductor.ai.agents.runtime.worker_manager.platform.system",
369+
return_value="Windows",
370+
), patch(
371+
"conductor.ai.agents.runtime.worker_manager.apply_thread_isolation"
372+
) as mock_apply, patch(
373+
"conductor.client.automator.task_handler.TaskHandler"
374+
) as mock_th:
375+
mock_th.return_value.task_runner_processes = []
376+
mock_th.return_value.metrics_provider_process = None
377+
manager.start()
378+
mock_apply.assert_called_once_with()
379+
380+
def test_non_windows_gate_does_not_apply(self):
381+
config = MagicMock()
382+
manager = WorkerManager(configuration=config)
383+
with patch(
384+
"conductor.ai.agents.runtime.worker_manager.platform.system",
385+
return_value="Linux",
386+
), patch(
387+
"conductor.ai.agents.runtime.worker_manager.apply_thread_isolation"
388+
) as mock_apply, patch(
389+
"conductor.client.automator.task_handler.TaskHandler"
390+
) as mock_th:
391+
mock_th.return_value.task_runner_processes = []
392+
mock_th.return_value.metrics_provider_process = None
393+
manager.start()
394+
mock_apply.assert_not_called()

0 commit comments

Comments
 (0)