Skip to content

fix(daemon_pool): support CPython 3.14 ThreadPoolExecutor internals - #65182

Open
TheNeuralVault wants to merge 1 commit into
NousResearch:mainfrom
TheNeuralVault:fix/daemon-pool-py314
Open

fix(daemon_pool): support CPython 3.14 ThreadPoolExecutor internals#65182
TheNeuralVault wants to merge 1 commit into
NousResearch:mainfrom
TheNeuralVault:fix/daemon-pool-py314

Conversation

@TheNeuralVault

Copy link
Copy Markdown

Symptom

On CPython 3.14, every task that spins up a DaemonThreadPoolExecutor worker crashes with:

AttributeError: 'DaemonThreadPoolExecutor' object has no attribute '_initializer'

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 returning Error during OpenAI-compatible API call: 'DaemonThreadPoolExecutor' object has no attribute '_initializer'.

Root cause

tools/daemon_pool.py::DaemonThreadPoolExecutor._adjust_thread_count mirrors CPython's private worker-spawn code (to set daemon=True and skip _threads_queues registration). It was written for the 3.8–3.13 internals. CPython 3.14 refactored those internals:

  • _worker target signature changed from (executor_ref, work_queue, initializer, initargs) to (executor_ref, ctx, work_queue), where ctx = self._create_worker_context().
  • The _initializer / _initargs attributes were removed.

So on 3.14 the old arg tuple references a now-nonexistent self._initializerAttributeError on every worker spawn.

Verified against the live 3.14 stdlib:

>>> [a for a in vars(ThreadPoolExecutor(1)) if 'init' in a.lower()]
[]                      # no _initializer/_initargs
>>> inspect.signature(concurrent.futures.thread._worker)
(executor_reference, ctx, work_queue)

Fix

Make _adjust_thread_count version-adaptive: detect the 3.14+ contract via hasattr(self, "_create_worker_context") and build the correct _worker args for each interpreter. The legacy branch uses getattr for _initializer/_initargs so 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 maindaemon_pool.py is byte-identical to upstream)

$ python -m pytest tests/tools/test_daemon_pool.py -v
tests/tools/test_daemon_pool.py::test_workers_are_daemon_threads PASSED
tests/tools/test_daemon_pool.py::test_results_and_initializer_work_like_stdlib PASSED
tests/tools/test_daemon_pool.py::test_idle_worker_reuse PASSED
tests/tools/test_daemon_pool.py::test_wedged_worker_does_not_block_interpreter_exit PASSED
4 passed in 1.59s

$ ruff check tools/daemon_pool.py
All checks passed!

The existing test_results_and_initializer_work_like_stdlib exercises the initializer path (the one that crashed) and the wedged-worker test confirms the daemon/no-_threads_queues invariant 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.

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!
@alt-glitch alt-glitch added type/bug Something isn't working comp/tools Tool registry, model_tools, toolsets P3 Low — cosmetic, nice to have duplicate This issue or pull request already exists labels Jul 15, 2026
@alt-glitch

alt-glitch commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related work: #57459 addresses the same CPython 3.14 compatibility area, but it retains the four-argument _worker invocation. This PR uses CPython 3.14's three-argument WorkerContext contract; maintainers should compare the implementations and choose the repair.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for addressing the actual CPython contract change. Current main still passes self._initializer and self._initargs in tools/daemon_pool.py:55-60, while CPython 3.14 builds _worker(executor_ref, self._create_worker_context(), work_queue) (Lib/concurrent/futures/thread.py:185-187,233-236). The new tuple at PR line 58 is therefore the correct fix direction.

Problems

  • The PR adds no regression coverage for its new branch. The project explicitly caps supported Python at <3.14 in pyproject.toml:13-20, so existing daemon-pool tests cannot exercise this branch in normal CI.

Suggested changes

  • Add a mocked branch test that verifies _adjust_thread_count() passes the exact three-element CPython 3.14 tuple and keeps daemon=True, without requiring a 3.14 CI interpreter.
  • The linked fix(tools): Python 3.14 compatibility for DaemonThreadPoolExecutor #57459 is not an equivalent fallback: its diff retains a four-argument _worker call, whereas CPython 3.14's worker takes three arguments.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 16, 2026
@indigokarasu

Copy link
Copy Markdown
Contributor

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.

tests/tools/test_daemon_pool.py:

  • test_adjust_thread_count_314_tuple_and_daemon injects _create_worker_context onto the instance and reads what _adjust_thread_count hands to threading.Thread. It asserts the 3-element tuple (executor weakref, WorkerContext, work queue) and daemon=True.
  • test_submit_runs_on_current_interpreter is an end to end check that a real submit returns a result on whatever interpreter runs the suite, so the legacy path stays honest too.

Both pass against this branch's daemon_pool.py on CPython 3.14.4:

test_daemon_pool.py::test_adjust_thread_count_314_tuple_and_daemon PASSED
test_daemon_pool.py::test_submit_runs_on_current_interpreter PASSED
2 passed in 0.16s

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.

@mrwogu

mrwogu commented Jul 28, 2026

Copy link
Copy Markdown

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 _worker call". Measured side by side on 3.14.6, that four-argument call does not just fail to fix the bug, it converts a loud failure into a silent hang: TypeError kills the worker thread after submit() returns, so the future never resolves.

python 3.14.6
_worker params: 3
has _create_worker_context: True
Exception in thread probe_0:
TypeError: _worker() takes 3 positional arguments but 4 were given
getattr patch (#57459): HUNG: future never completed (worker thread died)
worker-context patch:   result=42

python 3.11.15
_worker params: 4
has _create_worker_context: False
getattr patch (#57459): result=42
worker-context patch:   result=42

Probe: two ThreadPoolExecutor subclasses, one with #57459's argument shape and one with this PR's, each submitting a single task with result(timeout=5). So the choice between the two open patches is not a style preference.

2. The requires-python = ">=3.11,<3.14" cap does not protect installs, which is worth noting because #50077 was closed as unreproducible on that basis.

The Homebrew formula builds the app against python@3.14, so the interpreter that runs hermes is 3.14.6 regardless of the cap. Live traceback from hermes-agent/2026.6.5 on macOS, triggered by an ordinary parallel tool batch:

File "run_agent.py", line 6309, in _execute_tool_calls_concurrent
  return execute_tool_calls_concurrent(self, assistant_message, messages, effective_task_id, api_call_count)
File "agent/tool_executor.py", line 691, in execute_tool_calls_concurrent
  f = executor.submit(
File ".../python3.14/concurrent/futures/thread.py", line 215, in submit
  self._adjust_thread_count()
File "tools/daemon_pool.py", line 58, in _adjust_thread_count
  self._initializer,
AttributeError: 'DaemonThreadPoolExecutor' object has no attribute '_initializer'

The agent reports this as Error during OpenAI-compatible API call #N, so the outer loop burns retries on what is a local spawn failure. That mislabeling is why the bug reads as a provider problem in user reports.

3. A regression test that complements the mocked branch test already offered above.

test_adjust_thread_count_314_tuple_and_daemon pins the current 3.14 shape, which is the right check for today. It will not notice the next signature change, because it asserts the shape it injects. Tying the argument count to the live stdlib signature covers that on whatever interpreter runs the suite, including 3.11 CI, and it needs the spawn arguments to be reachable without starting a thread.

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 _adjust_thread_count calling args=self._worker_args(weakref.ref(self, weakref_cb)), and the test:

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.

@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown

This was generated by AI during triage.

Summary

Eighteen PRs address the Python 3.14 DaemonThreadPoolExecutor failure across the five duplicate issue threads: sixteen visible diffs construct the required three-element WorkerContext tuple, while #59157 and #63777 retain the incompatible four-argument _worker call. #61224 overlaps the executor repair but also contains distinct gateway-liveness and dotenv-reload changes.

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 tools/daemon_pool.py, because its gateway and environment changes are distinct.

Suggested consolidation

Keep #65182 open with a salvage path to add the mocked WorkerContext tuple/daemon=True regression required by its maintainer-bot verdict, and author action on recorded best fix #58699: rebase onto main or split out the part that can merge while preserving its broader tests. Close #57459, #60061, #69108, #69209, #69311, #74452, and #76817 as duplicates through #65182/#58699 despite their keep_open reviews because their visible production diffs cover the same cause with less regression coverage or weaker detection; keep the already-closed duplicates closed, and ask #61224's author to split its distinct gateway-liveness and dotenv-retry work from the duplicate daemon-pool portion.

Complex graph

flowchart 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"
Loading

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.

@menhguin

menhguin commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

+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 (homebrew-core/Formula/h/hermes-agent.rb) hard-requires python@3.14 and the keg's libexec/bin/python3 symlinks to it. A stock brew install hermes-agent therefore runs an interpreter where DaemonThreadPoolExecutor.submit() always raises AttributeError: 'DaemonThreadPoolExecutor' object has no attribute '_initializer' (daemon_pool.py:58) — 3.14 removed _initializer/_initargs from ThreadPoolExecutor (worker context moved to _create_worker_context).

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 delegate_task/subagent spawns. Reproduced today on v2026.8.3 + Python 3.14.6 (brew bottle): pool instantiates fine, first submit() crashes.

The honest framing: I realize pyproject.toml caps at >=3.11,<3.14, so this is technically an unsupported interpreter — the <3.15 in the installed METADATA is a bottle build artifact, not upstream intent. But the practical effect is that the only macOS package-manager channel ships a configuration where concurrency and delegation are broken out of the box. Either raising the cap + this fix, or the formula pinning python@3.13, resolves it; this PR is the smallest piece.

We've been carrying the workaround downstream for weeks (serialize all tool batches, no delegation). Happy to drop it the day this merges.

@alt-glitch alt-glitch removed the needs-decision Awaiting maintainer decision before any implementation label Aug 8, 2026
@menhguin

Copy link
Copy Markdown
Contributor

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 python@3.14 while upstream pyproject caps <3.14, so brew users are on 3.14 whether they asked or not. For us (macOS, brew hermes-agent 2026.8.3, Python 3.14.6) the pre-patch symptoms were:

  • delegate_task / subagents 100% dead — child never spawns, result reports api_calls: 0, duration: 0.0s. Compounding it: the surfaced error message ("background delegation pool at capacity, raise max_concurrent_children") is a red herring — the real AttributeError: ... '_initializer' at daemon_pool.py:58 gets swallowed, so users chase a config knob that does nothing.
  • Parallel tool-call batches fail wholesale on the dead pool (only batches that happen to route through the serial planner path survive).

Post-patch, live-verified on our running gateway (2026-08-09): parallel 2×web_search batch returns both results, delegate_task leaf spawns and returns real results. One week in production since, zero regressions.

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 Ufonik88 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 builds args=(weakref.ref(self, cb), self._create_worker_context(), self._work_queue) and _worker takes exactly 3 positional args (3.14) vs 4 (3.13 and earlier). The install's hasattr(executor, _create_worker_context) gate is the right discriminator.
  • _create_worker_context is an instance method on 3.14; checking hasattr(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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tools Tool registry, model_tools, toolsets P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants