crewai - ✅(Solved) Fix [BUG] async_execution=True loses ContextVar state — threading.Thread not using copy_context() [2 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
crewAIInc/crewAI#4822Fetched 2026-04-08 00:40:11
View on GitHub
Comments
1
Participants
2
Timeline
13
Reactions
0
Timeline (top)
referenced ×4cross-referenced ×2mentioned ×2subscribed ×2

Summary

Tasks with async_execution=True are executed via threading.Thread() without copying the current contextvars.Context. This causes any ContextVar set on the calling thread to silently reset to its default value inside the worker thread.

Related to #4168 (token tracking race condition) — same root cause, different symptom.

Environment

  • crewai: 1.7.2 but checked 1.10.0.1
  • Python: 3.12

Root Cause

execute_async() in task.py spawns the worker thread without a context copy:

def execute_async(self, agent, context, tools) -> Future[TaskOutput]:
    future: Future[TaskOutput] = Future()
    threading.Thread(
        daemon=True,
        target=self._execute_task_async,
        args=(agent, context, tools, future),
    ).start()
    return future

threading.Thread() does not inherit contextvars.Context from the spawning thread.
asyncio.to_thread() and loop.run_in_executor() do, which is why the kickoff_async()
path doesn't have this problem.

Impact

Libraries that rely on ContextVar for request-scoped state are silently broken for tasks using async_execution=True:

  • OpenTelemetry — trace context and baggage are lost. LLM calls inside these tasks appear as orphaned traces with no parent span.
  • Langfuse and other tracing SDKs — session context set on the calling thread is not visible in the worker.
  • Any custom ContextVar state — resets to default with no error raised.

Error Message

  • Any custom ContextVar state — resets to default with no error raised. values inside the worker thread. No exception is raised — the failure is silent.

Root Cause

Related to #4168 (token tracking race condition) — same root cause, different symptom.

Fix Action

Fix / Workaround

Workaround

PR fix notes

PR #4823: fix: propagate contextvars.Context in execute_async() thread

Description (problem / solution / changelog)

fix: propagate contextvars.Context in execute_async() thread

Summary

Fixes #4822 — Task.execute_async() spawns a threading.Thread without copying the caller's contextvars.Context, causing all ContextVar values (used by OpenTelemetry, Langfuse, and other tracing/observability libraries) to silently reset to their defaults inside the worker thread.

The fix uses contextvars.copy_context() to snapshot the calling thread's context, then runs the worker via ctx.run() — the same pattern CPython uses internally in asyncio.to_thread().

Before:

threading.Thread(
    target=self._execute_task_async,
    args=(agent, context, tools, future),
).start()

After:

ctx = contextvars.copy_context()
threading.Thread(
    target=ctx.run,
    args=(self._execute_task_async, agent, context, tools, future),
).start()

Four new tests added in TestAsyncContextVarPropagation:

  1. Single ContextVar is preserved in worker thread
  2. Multiple ContextVars are all preserved
  3. Worker thread changes are isolated (don't leak back to parent)
  4. Normal execution still works when no ContextVars are set

Review & Testing Checklist for Human

  • Verify ctx.run(callable, *args) signature correctness — Context.run() passes positional args through to the callable, so args=(self._execute_task_async, agent, context, tools, future) should work correctly
  • Consider whether other threading.Thread callsites in the codebase (e.g. lancedb_storage.py, event_bus.py, streaming.py) also need context propagation — this PR only addresses task.py
  • Run the new tests (pytest lib/crewai/tests/task/test_async_task.py::TestAsyncContextVarPropagation -v) and confirm they pass
  • Optionally test with a real OpenTelemetry/Langfuse setup using async_execution=True tasks to verify trace context propagates end-to-end

Notes

<!-- CURSOR_SUMMARY -->

[!NOTE] Low Risk Small, localized change to async task thread startup that should only affect contextvars propagation; main risk is subtle regressions in threaded execution if ctx.run() invocation behaves differently across Python versions.

Overview Ensures Task.execute_async() preserves the caller’s contextvars.Context when spawning a worker threading.Thread by snapshotting with contextvars.copy_context() and running the worker via ctx.run().

Adds a focused test suite (TestAsyncContextVarPropagation) validating single/multiple ContextVar propagation, isolation between parent/worker contexts, and that execution still works when no context vars are set.

<sup>Written by Cursor Bugbot for commit 00e963aeb34eb96b202c19cbc932cacf326e4638. This will update automatically on new commits. Configure here.</sup>

<!-- /CURSOR_SUMMARY -->

Changed files

  • lib/crewai/src/crewai/task.py (modified, +4/-2)
  • lib/crewai/tests/task/test_async_task.py (modified, +137/-1)

PR #4834: fix: propagate ContextVar state to async_execution worker threads

Description (problem / solution / changelog)

Task.execute_async() spawns a new threading.Thread for background execution, but threading.Thread does not inherit the parent's contextvars.Context. This causes ContextVar values (e.g. tracing spans, tenant IDs, session state) to be lost in the worker thread. Use contextvars.copy_context() to snapshot the current context and run the worker function inside it via ctx.run(). Fixes #4822

<!-- CURSOR_SUMMARY -->

[!NOTE] Medium Risk Touches core async task execution by changing how worker threads are started, which could affect any code relying on thread startup semantics. The change is small but impacts tracing/tenant context propagation across all Task.execute_async calls.

Overview Fixes lost ContextVar state in async task execution. Task.execute_async() now snapshots the current contextvars context and starts the background thread via ctx.run(...), ensuring context-local values (e.g., tracing/tenant/session data) propagate into the worker thread.

<sup>Written by Cursor Bugbot for commit 06973d3b0ab71fbeedcc38e03db025266c90c54c. This will update automatically on new commits. Configure here.</sup>

<!-- /CURSOR_SUMMARY -->

Changed files

  • lib/crewai/src/crewai/task.py (modified, +6/-2)

Code Example

def execute_async(self, agent, context, tools) -> Future[TaskOutput]:
      future: Future[TaskOutput] = Future()
      threading.Thread(
          daemon=True,
          target=self._execute_task_async,
          args=(agent, context, tools, future),
      ).start()
      return future

  threading.Thread() does not inherit contextvars.Context from the spawning thread.
  asyncio.to_thread() and loop.run_in_executor() do, which is why the kickoff_async()
  path doesn't have this problem.

---

import contextvars, threading

  my_var = contextvars.ContextVar("my_var", default=None)
  my_var.set("hello")

  threading.Thread(target=lambda: print(my_var.get())).start()
  # prints: None

  vs. asyncio.to_thread():

  import asyncio, contextvars

  my_var = contextvars.ContextVar("my_var", default=None)
  my_var.set("hello")

  async def main():
      await asyncio.to_thread(lambda: print(my_var.get()))
      # prints: hello

  asyncio.run(main())

---

import contextvars, threading                                                                               
                                                                                                              
  my_var = contextvars.ContextVar("my_var", default=None)                                                     
  my_var.set("hello")

---

threading.Thread(target=lambda: print(my_var.get())).start()
  # prints: None

---

import asyncio
  async def main():
      await asyncio.to_thread(lambda: print(my_var.get()))
  asyncio.run(main())
  # prints: hello

---

import contextvars

  def execute_async(self, agent, context, tools) -> Future[TaskOutput]:
      future: Future[TaskOutput] = Future()
      ctx = contextvars.copy_context()
      threading.Thread(
          daemon=True,
          target=ctx.run,
          args=(self._execute_task_async, agent, context, tools, future),
      ).start()
      return future
RAW_BUFFERClick to expand / collapse

Description

Summary

Tasks with async_execution=True are executed via threading.Thread() without copying the current contextvars.Context. This causes any ContextVar set on the calling thread to silently reset to its default value inside the worker thread.

Related to #4168 (token tracking race condition) — same root cause, different symptom.

Environment

  • crewai: 1.7.2 but checked 1.10.0.1
  • Python: 3.12

Root Cause

execute_async() in task.py spawns the worker thread without a context copy:

def execute_async(self, agent, context, tools) -> Future[TaskOutput]:
    future: Future[TaskOutput] = Future()
    threading.Thread(
        daemon=True,
        target=self._execute_task_async,
        args=(agent, context, tools, future),
    ).start()
    return future

threading.Thread() does not inherit contextvars.Context from the spawning thread.
asyncio.to_thread() and loop.run_in_executor() do, which is why the kickoff_async()
path doesn't have this problem.

Impact

Libraries that rely on ContextVar for request-scoped state are silently broken for tasks using async_execution=True:

  • OpenTelemetry — trace context and baggage are lost. LLM calls inside these tasks appear as orphaned traces with no parent span.
  • Langfuse and other tracing SDKs — session context set on the calling thread is not visible in the worker.
  • Any custom ContextVar state — resets to default with no error raised.

Steps to Reproduce

  import contextvars, threading

  my_var = contextvars.ContextVar("my_var", default=None)
  my_var.set("hello")

  threading.Thread(target=lambda: print(my_var.get())).start()
  # prints: None

  vs. asyncio.to_thread():

  import asyncio, contextvars

  my_var = contextvars.ContextVar("my_var", default=None)
  my_var.set("hello")

  async def main():
      await asyncio.to_thread(lambda: print(my_var.get()))
      # prints: hello

  asyncio.run(main())

Expected behavior

ContextVar values set on the calling thread should be accessible inside the worker
thread spawned by async_execution=True, consistent with how asyncio.to_thread()
and run_in_executor() behave.

Screenshots/Code snippets

  import contextvars, threading                                                                               
                                                                                                              
  my_var = contextvars.ContextVar("my_var", default=None)                                                     
  my_var.set("hello")

Current behaviour — context is lost

  threading.Thread(target=lambda: print(my_var.get())).start()
  # prints: None

Expected behaviour — context is preserved

  import asyncio
  async def main():
      await asyncio.to_thread(lambda: print(my_var.get()))
  asyncio.run(main())
  # prints: hello

Operating System

Other (specify in additional context)

Python Version

3.12

crewAI Version

1.7.2

crewAI Tools Version

1.7.2

Virtual Environment

Venv

Evidence

Evidence: Verified by inspecting task.py in crewai 1.10.0.1:

  def execute_async(self, agent, context, tools) -> Future[TaskOutput]:                                   
      future: Future[TaskOutput] = Future()
      threading.Thread(
          daemon=True,
          target=self._execute_task_async,
          args=(agent, context, tools, future),
      ).start()
      return future

threading.Thread() does not copy contextvars.Context from the spawning thread. ContextVar values set before execute_async() is called read as their default values inside the worker thread. No exception is raised — the failure is silent.

This affects OpenTelemetry (trace context lost, spans appear orphaned), Langfuse (session context lost), and any library using ContextVar for request-scoped state.

Related: #4168 (token tracking race condition, same root cause), #4286 (open PR).

Possible Solution

  import contextvars

  def execute_async(self, agent, context, tools) -> Future[TaskOutput]:
      future: Future[TaskOutput] = Future()
      ctx = contextvars.copy_context()
      threading.Thread(
          daemon=True,
          target=ctx.run,
          args=(self._execute_task_async, agent, context, tools, future),
      ).start()
      return future

Workaround

Use async def @listen with await crew.kickoff_async() inside asyncio.gather() instead of async_execution=True in sync crews — the async path propagates context correctly.

Additional context

References

  • #4168 — token tracking race condition in async_execution (same root cause)
  • #4286 — open PR for #4168
  • Python docs: contextvars.copy_context()
  • CPython: asyncio.to_thread implementation

extent analysis

Fix Plan

To fix the issue, we need to copy the current contextvars.Context when spawning a new thread using threading.Thread(). We can achieve this by using the contextvars.copy_context() function.

Here are the steps to fix the issue:

  • Import the contextvars module.
  • Create a copy of the current context using contextvars.copy_context().
  • Use the ctx.run() method to run the target function with the copied context.

Example code:

import contextvars

def execute_async(self, agent, context, tools) -> Future[TaskOutput]:
    future: Future[TaskOutput] = Future()
    ctx = contextvars.copy_context()
    threading.Thread(
        daemon=True,
        target=ctx.run,
        args=(self._execute_task_async, agent, context, tools, future),
    ).start()
    return future

Verification

To verify that the fix worked, you can test the execute_async() method with a ContextVar set on the calling thread. The ContextVar value should be accessible inside the worker thread spawned by async_execution=True.

Example test code:

import contextvars
import threading

my_var = contextvars.ContextVar("my_var", default=None)
my_var.set("hello")

def test_execute_async():
    # Call execute_async() with my_var set
    future = execute_async(agent, context, tools)
    # Check if my_var is accessible inside the worker thread
    def check_my_var():
        print(my_var.get())  # Should print "hello"
    threading.Thread(target=check_my_var).start()

test_execute_async()

Extra Tips

  • When using threading.Thread(), always copy the current contextvars.Context to ensure that ContextVar values are propagated to the new thread.
  • Consider using asyncio.to_thread() or loop.run_in_executor() instead of threading.Thread() to avoid context propagation issues.
  • Refer to the Python documentation for contextvars.copy_context() and the CPython implementation of asyncio.to_thread() for more information.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

ContextVar values set on the calling thread should be accessible inside the worker
thread spawned by async_execution=True, consistent with how asyncio.to_thread()
and run_in_executor() behave.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

crewai - ✅(Solved) Fix [BUG] async_execution=True loses ContextVar state — threading.Thread not using copy_context() [2 pull requests, 1 comments, 2 participants]