fix(daemon_pool): support CPython 3.14 ThreadPoolExecutor internals - #65182
fix(daemon_pool): support CPython 3.14 ThreadPoolExecutor internals#65182TheNeuralVault wants to merge 1 commit into
Conversation
DaemonThreadPoolExecutor._adjust_thread_count mirrored CPython's private worker-spawn code for 3.8-3.13, reaching into ThreadPoolExecutor internals that 3.14 refactored. On 3.14 the `_worker` target signature changed from (executor_ref, work_queue, initializer, initargs) to (executor_ref, ctx, work_queue) where ctx = self._create_worker_context(), and the `_initializer`/`_initargs` attributes were removed. Result on 3.14: every worker spawn raised `AttributeError: 'DaemonThreadPoolExecutor' object has no attribute '_initializer'`, breaking every consumer of this pool — concurrent tool execution (agent/tool_executor.py), skill catalog fan-out (tools/skills_hub.py), background memory sync (agent/memory_manager.py), and subagent timeout wrappers (tools/delegate_tool.py, async_delegation.py). Fix: make _adjust_thread_count version-adaptive. Detect the 3.14+ contract via hasattr(self, "_create_worker_context") and build the correct `_worker` args tuple for each; use getattr for the legacy initializer/initargs so the 3.8-3.13 branch stays type-clean. Single class, both contracts, no behavior change on older interpreters. Verified on Python 3.14.6 (Termux/Android): - python -m pytest tests/tools/test_daemon_pool.py -> 4 passed (daemon threads, results+initializer parity, idle reuse, wedged-worker-does-not-block-exit) - ruff check tools/daemon_pool.py -> All checks passed!
Related work: #57459 addresses the same CPython 3.14 compatibility area, but it retains the four-argument |
|
Thanks for addressing the actual CPython contract change. Current main still passes Problems
Suggested changes
Automated hermes-sweeper review. |
|
Pasted a regression test that covers the exact gap the sweeper flagged: there was no coverage for the new 3.14 branch, and CI can't exercise it because pyproject still caps Python at <3.14. This adds a mocked branch test that needs no real 3.14 interpreter.
Both pass against this branch's This doesn't touch the cap in pyproject, so it slots in without waiting on the 3.14 support decision. Happy to fold it into the diff directly if you'd rather not carry a separate file. |
|
Closing #72955 in favour of this one (same mechanism, this PR is older). Three pieces of evidence from that work that are relevant here. 1. #57459 is not a viable fallback, and it is worse than the current crash. The sweeper review and the triage note both describe #57459 as "retains a four-argument Probe: two 2. The The Homebrew formula builds the app against The agent reports this as 3. A regression test that complements the mocked branch test already offered above.
Extracting them into a helper keeps that testable: def _worker_args(self, executor_ref: weakref.ref) -> tuple:
create_context = getattr(self, "_create_worker_context", None)
if create_context is not None:
return (executor_ref, create_context(), self._work_queue)
return (
executor_ref,
self._work_queue,
getattr(self, "_initializer", None),
getattr(self, "_initargs", ()),
)with def test_worker_args_match_stdlib_worker_signature():
"""CPython changes _worker's signature between versions (3.14 moved
initializer/initargs into a worker context), so the mirrored spawn
path must adapt instead of passing a stale argument tuple."""
import inspect
import weakref
from concurrent.futures.thread import _worker
expected = len(inspect.signature(_worker).parameters)
pool = DaemonThreadPoolExecutor(max_workers=1)
try:
assert len(pool._worker_args(weakref.ref(pool))) == expected
finally:
pool.shutdown(wait=True)Passes on 3.11.15 (4 arguments, legacy branch) and 3.14.6 (3 arguments, context branch). Fold it in or drop it as you prefer; either way this PR's direction is the correct one and #57459 should not be merged as an alternative. |
SummaryEighteen PRs address the Python 3.14 Related pull requests
Duplicates#57459, #58598, #58699, #59897, #60061, #63780, #65182, #69108, #69209, #69311, #72955, #74452, #76212, #76756, and #76817 substantially implement the same WorkerContext-aware spawn repair; #59157 and #63777 duplicate the incomplete four-argument fallback. #61224 overlaps only in Suggested consolidationKeep #65182 open with a salvage path to add the mocked WorkerContext tuple/ Complex graphflowchart TD
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
I58596(["issue #58596 (open)"])
I59896(["issue #59896 (open)"])
I63769(["issue #63769 (open)"])
I69359(["issue #69359 (open)"])
I76621(["issue #76621 (open)"])
subgraph Dup57459 ["PRs duplicating each other"]
P57459["PR #57459 (open)"]
P58598["PR #58598 (closed)"]
P58699["PR #58699 (open)"]
P59157["PR #59157 (closed)"]
P59897["PR #59897 (closed)"]
P60061["PR #60061 (open)"]
P61224["PR #61224 (open)"]
P63777["PR #63777 (closed)"]
P63780["PR #63780 (closed)"]
P65182["PR #65182 (open)"]
P69108["PR #69108 (open)"]
P69209["PR #69209 (open)"]
P69311["PR #69311 (open)"]
P72955["PR #72955 (closed)"]
P74452["PR #74452 (open)"]
P76212["PR #76212 (closed)"]
P76756["PR #76756 (closed)"]
P76817["PR #76817 (open)"]
end
P65182 -->|fixes| I58596
P65182 -->|fixes| I59896
P65182 -->|fixes| I63769
P65182 -->|best fix| I69359
P65182 -->|fixes| I76621
class I58596 open
class I59896 open
class I63769 open
class I69359 open
class I76621 open
class P57459 open
class P58598 closed
class P58699 open
class P59157 closed
class P59897 closed
class P60061 open
class P61224 open
class P63777 closed
class P63780 closed
class P65182 open
class P69108 open
class P69209 open
class P69311 open
class P72955 closed
class P74452 open
class P76212 closed
class P76756 closed
class P76817 open
class P58699 best
class P58699 best
class P58699 best
class P58699 best
class P58699 best
class P65182 best
class P65182 target
click I58596 "https://github.com/NousResearch/hermes-agent/issues/58596"
click I59896 "https://github.com/NousResearch/hermes-agent/issues/59896"
click I63769 "https://github.com/NousResearch/hermes-agent/issues/63769"
click I69359 "https://github.com/NousResearch/hermes-agent/issues/69359"
click I76621 "https://github.com/NousResearch/hermes-agent/issues/76621"
click P57459 "https://github.com/NousResearch/hermes-agent/pull/57459"
click P58598 "https://github.com/NousResearch/hermes-agent/pull/58598"
click P58699 "https://github.com/NousResearch/hermes-agent/pull/58699"
click P59157 "https://github.com/NousResearch/hermes-agent/pull/59157"
click P59897 "https://github.com/NousResearch/hermes-agent/pull/59897"
click P60061 "https://github.com/NousResearch/hermes-agent/pull/60061"
click P61224 "https://github.com/NousResearch/hermes-agent/pull/61224"
click P63777 "https://github.com/NousResearch/hermes-agent/pull/63777"
click P63780 "https://github.com/NousResearch/hermes-agent/pull/63780"
click P65182 "https://github.com/NousResearch/hermes-agent/pull/65182"
click P69108 "https://github.com/NousResearch/hermes-agent/pull/69108"
click P69209 "https://github.com/NousResearch/hermes-agent/pull/69209"
click P69311 "https://github.com/NousResearch/hermes-agent/pull/69311"
click P72955 "https://github.com/NousResearch/hermes-agent/pull/72955"
click P74452 "https://github.com/NousResearch/hermes-agent/pull/74452"
click P76212 "https://github.com/NousResearch/hermes-agent/pull/76212"
click P76756 "https://github.com/NousResearch/hermes-agent/pull/76756"
click P76817 "https://github.com/NousResearch/hermes-agent/pull/76817"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label). Cross-PR triage: Reviewed 18 pull requests and 5 issues in this complex. Each diff was read against this issue; Assessment working set: 70 kB of PR diffs, 52 kB of issue/PR text, 40 kB of discussion (59 comments), 115 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch. |
|
+1 on this fix — posting to add blast-radius evidence, since the P3/'niche forward-compat' triage premise understates where this bites. This is the default macOS install path, not an edge case. The Homebrew formula ( What's dead on that path: concurrent tool-call segments (any batch of parallel-safe tools crashes the turn's tool execution) and 100% of The honest framing: I realize pyproject.toml caps at We've been carrying the workaround downstream for weeks (serialize all tool batches, no delegation). Happy to drop it the day this merges. |
|
Gentle bump on this — we've been carrying this exact patch in production since 2026-08-08 and can confirm it fully resolves the 3.14 break. For visibility on blast radius: this isn't an edge-case config. Anyone installing via Homebrew today hits it on the default path — the brew formula hard-requires
Post-patch, live-verified on our running gateway (2026-08-09): parallel 2× Given the brew/pyproject version gap means the default macOS install path has had dead subagents since the formula moved to 3.14, would love to see this merged — happy to rebase or adjust if anything's needed. |
Ufonik88
left a comment
There was a problem hiding this comment.
Independent producer verification of this diff (worth 10 points cause nothing merges otherwise):
Environment: production Hermes gateway, Linux, CPython 3.14.4 (linuxbrew python@3.14.4). An equivalent patch with the exact same structure has been in daily service here since 2026-08-11, re-applied by a systemd watchdog after every hermes update, with zero regressions. The current gateway process restarted onto the patched code today (2026-08-19 07:47).
I probed the live interpreter this morning; the diff is correct for the installed CPython:
_adjust_thread_count()in 3.14.4 buildsargs=(weakref.ref(self, cb), self._create_worker_context(), self._work_queue)and_workertakes exactly 3 positional args (3.14) vs 4 (3.13 and earlier). The install'shasattr(executor, _create_worker_context)gate is the right discriminator._create_worker_contextis an instance method on 3.14; checkinghasattr(self, ...)on the executor is correct (a class-level check would wrongly report False, which is a trap I'd flag in other variants).- The legacy branch here uses
getattr(self, "_initializer", None)/getattr(self, "_initargs", ())defaults, so it is safe on both contracts — no AttributeError either way.
This PR matches the verified patch exactly and is merge-ready from a correctness standpoint. The only thing the keep_open sweeper verdict asks for is a mocked regression test pinning the 3-element tuple and daemon=True; node-ready proposed tests were posted in this thread (2026-07-18). Folding one such test in would clear the stated gate and let this land.
+1 from a production 3.14 gateway that runs this code every day.
Symptom
On CPython 3.14, every task that spins up a
DaemonThreadPoolExecutorworker crashes with:Because this pool backs concurrent tool execution, skill-catalog fan-out, background memory sync, and subagent timeout wrappers, the failure surfaces broadly at runtime — e.g.
skill_view/ concurrent tool batches intermittently returningError during OpenAI-compatible API call: 'DaemonThreadPoolExecutor' object has no attribute '_initializer'.Root cause
tools/daemon_pool.py::DaemonThreadPoolExecutor._adjust_thread_countmirrors CPython's private worker-spawn code (to setdaemon=Trueand skip_threads_queuesregistration). It was written for the 3.8–3.13 internals. CPython 3.14 refactored those internals:_workertarget signature changed from(executor_ref, work_queue, initializer, initargs)to(executor_ref, ctx, work_queue), wherectx = self._create_worker_context()._initializer/_initargsattributes were removed.So on 3.14 the old arg tuple references a now-nonexistent
self._initializer→AttributeErroron every worker spawn.Verified against the live 3.14 stdlib:
Fix
Make
_adjust_thread_countversion-adaptive: detect the 3.14+ contract viahasattr(self, "_create_worker_context")and build the correct_workerargs for each interpreter. The legacy branch usesgetattrfor_initializer/_initargsso it stays type-clean. Single class, both contracts, no behavior change on 3.8–3.13.This fixes the whole bug class (all consumers spawn workers through this one overridden method) rather than any single call site.
Verification (Python 3.14.6, Termux/Android, against current
main—daemon_pool.pyis byte-identical to upstream)The existing
test_results_and_initializer_work_like_stdlibexercises the initializer path (the one that crashed) and the wedged-worker test confirms the daemon/no-_threads_queuesinvariant still holds.Scope / footprint
One file, one method. No new tools, no config, no env vars, no core-surface change; prompt caching and message alternation untouched.