Skip to content

fix(runner): detect stale event loop in _ensure_root_task_group to prevent cross-loop reuse - #525

Open
openjiuwen-sync-bot[bot] wants to merge 2 commits into
openJiuwen-ai:developfrom
openjiuwenai:sync/pr-2323
Open

fix(runner): detect stale event loop in _ensure_root_task_group to prevent cross-loop reuse#525
openjiuwen-sync-bot[bot] wants to merge 2 commits into
openJiuwen-ai:developfrom
openjiuwenai:sync/pr-2323

Conversation

@openjiuwen-sync-bot

@openjiuwen-sync-bot openjiuwen-sync-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Paired: GitHub #525GitCode !2323

PR 草稿:openjiuwen Runner — _ensure_root_task_group 增事件循环活性检测

提交目标:GitHub openJiuwen-ai/agent-core(真正上游),base 分支 develop
提交后由 bot1-mirror 自动镜像到 GitCode openJiuwen/agent-core(两边都能被维护者看到)。
关联 issue:GitHub #​245(= GitCode #​1444),Runner 为进程级单例、绑单一事件循环
版本:openjiuwen 0.1.16.post1(develop 分支最新)。
提交者:SmartEquipmentAssistant 团队(Team-Electric @ gitcode,基于 tdea-core 多智能体落地实践)。


查证前置(提 PR 前已做的功课)

为避免重复劳动,提交前已查证:

  1. GitHub #​245 / GitCode #​1444(open,2026-07-31 报告,13 天无进展,0 assignee 认领,1 条机器人评论,无关联 PR)——报告人 @CoreObjects 已定位到同一段根因GLOBAL_RUNNERrunner.py:683)模块级单例 + _root_task_grouprunner.py:97)单 owner 单 loop 持有 + Runner@classmethod 转发。本 PR 是该 issue 的渐进修复,区别于报告人提的"可实例化隔离 Runner"大改方案——两条路互补。
  2. GitHub #​254 / GitCode #​1447(HierarchicalTeam legacy 挂起,open)——不重叠。
  3. GitHub #​501 / GitCode #​1525(agent-teams 暂停恢复,open)——不重叠。
  4. "attached to a different loop" 报错文案:全 issue 搜索 0 命中——本 PR 的复现路径(多 app/TestClient 串行切 loop)是 #​245 未覆盖的新角度。
  5. 在途 PR:仓库近期 PR 无任何改 _ensure_root_task_group 或 runner task group 复用逻辑,不撞车。
  6. 仓库无 CONTRIBUTING.md / CLA——直接提 PR、base 到 develop 即可。

标题(Title)

fix(runner): detect stale event loop in _ensure_root_task_group to prevent cross-loop task group reuse

摘要(Summary)

GLOBAL_RUNNER 是模块级单例。当多个 FastAPI app(或 TestClient)各自的 lifespan 反复驱动同一全局 Runner、且各跑在独立 asyncio loop 上时,_ensure_root_task_group 会盲目复用一个已失效的 root task group(owner task 绑在已关闭的旧 loop 上),导致 stop()_close_root_task_groupawait ownerRuntimeError: Task ... got Future ... attached to a different loop,runner 无法干净停止,后续 lifespan teardown 冒泡成未捕获异常。

本 PR 在复用判断里加一行事件循环活性检测:owner task 绑定的 loop 已关闭时,视为失效,丢弃旧 task group 并在当前 loop 重建。是 #​245 的渐进修复(不重构单例,单 loop 场景零回归)。

问题复现(Reproduction)

环境:openjiuwen 0.1.16.post1,FastAPI + TestClient,两个测试文件分别用 session-scope 和 module-scope fixture 各自构建独立 app(lifespan 内 await Runner.start()/await Runner.stop())。

最小复现(pytest 风格):

# conftest.py
import os, sys, pytest
os.environ["AUTH_DISABLED"]="true"
from fastapi.testclient import TestClient

@pytest.fixture(scope="session")
def app_a():
    import server  # app A,lifespan 调 Runner.start()
    return server.app

@pytest.fixture
def app_b():  # 独立 app B,重新 import
    os.environ["AUTH_DISABLED"]="false"
    for m in [m for m in list(sys.modules) if m in ("config","server","auth")]:
        del sys.modules[m]
    import server as s2
    return s2.app

def test_a(app_a):
    with TestClient(app_a) as c:
        assert c.get("/health").status_code == 200

def test_b(app_b):  # 触发点:app B lifespan 又 start/stop 全局 Runner
    with TestClient(app_b) as c:
        assert c.get("/health").status_code == 200
# test_b 的 teardown(TestClient __exit__ → lifespan shutdown → Runner.stop)抛:
# RuntimeError: Task <Task ...> got Future <Task ...> attached to a different loop

关键:单跑任一 fixture 通过;跨 fixture 混跑(先 app A 再 app B)必现。

与 #​245 的区别:#​245 报的是"多线程并发"挂死(线程各自 loop 并发调 Runner.run_agent);本 PR 补的是"单线程多 app 串行切 loop"场景——根因同源(单例 + 单 loop 绑定),但复现门槛更低(单线程即可触发),且报错文案不同(attached to a different loop,#​245 未提及)。

根因(Root Cause)

openjiuwen/core/runner/runner.py:152-169

async def _ensure_root_task_group(self) -> None:
    if self._root_task_group is not None and self._root_task_group_owner is not None:
        return                      # ← 只判非 None,不判 owner 绑的 loop 是否还活着
    ...
    self._root_task_group_owner = asyncio.create_task(
        self._root_task_group_owner_loop(...)
    )                               # ← 绑在当前 loop
  • _root_task_group_ownerasyncio.create_task(...) 返回的 Task绑定创建它的 loop
  • 全局单例 GLOBAL_RUNNERrunner.py:683)跨 app/跨 loop 复用。
  • app A 在 loop A 创建 owner task;app B 切到 loop B,_ensure_root_task_group 见字段非 None 直接 return,复用 loop A 的 owner task
  • stop()_close_root_task_grouprunner.py:171-203)执行 await owner:184)——owner 绑 loop A、当前是 loop B → RuntimeError: attached to a different loop
  • _close_root_task_groupexcept Exception:192)吞成 warning,但 finally 清字段后,下一轮又复用同样失效的 owner(若时序交错),且 teardown 的 RuntimeError 在 anyio.move_on_after shield 外冒泡。

修复(Fix)

openjiuwen/core/runner/runner.py_ensure_root_task_group 复用判断加 loop 活性检测:

  async def _ensure_root_task_group(self) -> None:
-     if self._root_task_group is not None and self._root_task_group_owner is not None:
-         return
+     owner = self._root_task_group_owner
+     if self._root_task_group is not None and owner is not None:
+         # Detect a stale owner task bound to a closed/foreign event loop.
+         # GLOBAL_RUNNER is a process-wide singleton; when multiple apps (or
+         # TestClients) drive the same Runner from different asyncio loops,
+         # blindly reusing an owner created on a now-closed loop makes the
+         # subsequent stop() await an owner attached to a different loop,
+         # raising RuntimeError and leaving the task group un-closed.
+         try:
+             owner_loop = owner.get_loop()
+         except RuntimeError:
+             owner_loop = None
+         if owner_loop is not None and not owner_loop.is_closed():
+             return  # owner still alive on a live loop → safe to reuse
+         # owner's loop is closed (or we can't tell) → fall through to rebuild
+
+     # Rebuild on the current (live) loop. Null stale fields first so a
+     # rebuild failure does not leave a half-cleaned state.
+     self._root_task_group = None
+     self._root_task_group_owner = None
+     self._root_task_group_ready = None
+     self._root_task_group_stop = None
+
      # Import the manager so lower layers can schedule via manager.create_task().
      from openjiuwen.core.common.task_manager.manager import get_task_manager

      get_task_manager()
      self._root_task_group_ready = asyncio.Event()
      self._root_task_group_stop = asyncio.Event()
      self._root_task_group_owner = asyncio.create_task(
          self._root_task_group_owner_loop(
              self._root_task_group_ready,
              self._root_task_group_stop,
          )
      )
      await self._root_task_group_ready.wait()
      if self._root_task_group is None and self._root_task_group_owner.done():
          await self._root_task_group_owner

要点

  • task.get_loop()(Python 3.10+,asyncio.Task 属性)返回绑定 loop;loop.is_closed() 判断是否已关闭。
  • try/except RuntimeError 兜底极旧 Python 或已销毁 task(get_loop 在 task 被回收时可能抛)。
  • 重建前先清 4 个字段(与 _close_root_task_group 的 finally 一致),避免重建失败留下半清理状态。

兼容性(Compatibility)

  • Python 3.10+asyncio.Task.get_loop() 3.10 引入,openjiuwen 要求 3.12+,满足。
  • 单 loop 场景(绝大多数生产:一个 app 一个 loop 跑到底)行为不变——owner loop 一直活着,return 复用,零额外开销。
  • 多 loop 场景(测试、多 app)从"崩"变"自动重建",是纯增强。
  • 不引入新依赖,不改公开 API(Runner.start/stop 签名不变)。
  • 与 #​245 报告人的诉求不冲突:本 PR 不排斥未来"可实例化隔离 Runner"的大改;在那之前,单例场景也能安全跨 loop。可作为 #​245 的第一里程碑落地,大改留作后续。

验证(Verification)

自动测试:建议在 openjiuwen 侧加一个跨 loop 单测:

def test_ensure_root_task_group_rebuilds_after_loop_close():
    import asyncio
    from openjiuwen.core.runner.runner import _RunnerImpl, DEFAULT_RUNNER_CONFIG
    runner = _RunnerImpl(config=DEFAULT_RUNNER_CONFIG)

    loop1 = asyncio.new_event_loop()
    loop1.run_until_complete(runner.start())     # owner 绑 loop1
    loop1.close()                                 # 模拟 app A 下线,loop 关闭

    loop2 = asyncio.new_event_loop()             # app B 起新 loop
    loop2.run_until_complete(runner.start())     # 修复前:复用 loop1 owner
    loop2.run_until_complete(runner.stop())      # 修复前:await owner → RuntimeError
    loop2.close()
    # 修复后:上述全程无异常

集成验证(本项目 SmartEquipmentAssistant 的实测):

  • 修复前:pytest tests/test_endpoint_contract.py tests/test_rbac.pyRuntimeError: Task ... attached to a different loop(teardown)。
  • 应用等价绕过(conftest.reset_runner() 强制清字段)后:通过,耗时 160s→89s(runner 不再反复 stop/start 挣扎)。
  • 本 PR 的 loop 检测与该绕过等价,但根治在框架层,下游无需 reset_runner

关联(Related)

  • Fixes / Addresses: #​245(GitHub)= #​1444(GitCode)——渐进修复,非重复。
  • 连带但不在本 PR 范围的次要问题(建议后续单开 issue 或 PR):
    • _close_root_task_groupawait owner:184)即便 owner 失效也可更早 short-circuit(本 PR 通过避免复用,间接让该路径不再 await 失效 owner)。
    • stop():329-357except Exception 吞异常只记 warning——建议对 RuntimeError("different loop") 至少 debug 级日志,便于排查。

提交流程(给提交者自己备忘)

  1. fork GitHub openJiuwen-ai/agent-core 到自己账号。
  2. 基于最新 develop 开分支:fix/runner-ensure-root-task-group-loop-liveness
  3. openjiuwen/core/runner/runner.py_ensure_root_task_group(按上 diff)。
  4. 加单测 test_ensure_root_task_group_rebuilds_after_loop_close
  5. 跑 openjiuwen 自有测试套件确认不回归(尤其单 loop 路径)。
  6. 提 PR,base=develop,标题用上 Title,描述填本文档内容,在 PR 描述里显式写 Addresses #245(GitHub 自动关联)。
  7. 提交后由 bot1-mirror 自动镜像到 GitCode,给 @IamCandiceGuo(#​245 默认负责人)/ @seanzhang_cn / @xinyu-jiuwen 留言。
  8. 仓库无 CLA,无额外签署要求。

提交者

SmartEquipmentAssistant 团队(Team-Electric @ gitcode)。基于 tdea-core 多智能体落地实践(详见 OpenJIUWEN框架使用经验总结.md 待优化点 #​1)。

Linked Closing Issues:

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

1 similar comment
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@openjiuwen-collaboration-bot

openjiuwen-collaboration-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

head_sha: a1395a3261c11c5546377dd3f58958d6f6cdd39e

变更摘要

此 PR 修复了 _RunnerImpl._ensure_root_task_group 在全局单例 GLOBAL_RUNNER 复用时,因未检测 root task group owner 所绑定的 asyncio 事件循环是否已失效,导致跨循环复用陈旧 task 并在 stop() 时抛出 RuntimeError 的问题。修复通过在复用判断中加入 owner.get_loop().is_closed() 活性检测,当 owner 绑定的循环已关闭时丢弃旧 task group 并在当前循环重建,同时重建前先将四个相关字段置 None 以避免重建失败留下半清理状态。该修复对单循环场景零影响,仅在多 app/多 TestClient 切换循环的场景下触发自动重建。

主要改动

  • _ensure_root_task_group 复用逻辑增加循环活性检测:将原本仅判 None 的简短返回逻辑(if self._root_task_group is not None and self._root_task_group_owner is not None: return)替换为通过 owner.get_loop() 获取绑定循环并检查 is_closed(),只有 owner 存活且其循环未关闭时才复用;否则跳过复用进入重建流程。
  • 重建前置空四个相关字段防止半清理状态:在进入重建逻辑之前,显式将 _root_task_group_root_task_group_owner_root_task_group_ready_root_task_group_stop 置为 None,确保重建失败时 Runner 不会残留指向陈旧对象的字段。
  • 新增单测文件覆盖跨循环重建场景:新增 tests/test_runner_ensure_root_task_group.py,包含单循环正常启停基线测试、首个循环关闭后第二循环上重建验证、跨循环 stop() 不抛异常验证,以及同循环多次 start/stop 幂等性测试。

@openjiuwen-collaboration-bot

openjiuwen-collaboration-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

head_sha: a1395a3261c11c5546377dd3f58958d6f6cdd39e

代码审查

审查总结

文件 审查结果
openjiuwen/core/runner/runner.py 发现 1 个问题 (P2)
tests/test_runner_ensure_root_task_group.py 发现 1 个问题 (P3)

按优先级统计:P0: 0, P1: 0, P2: 1, P3: 1

整体风险评估低风险。该修复正确解决了 PR 目标场景(loop 先关闭再切新 loop 时的跨循环 RuntimeError),核心逻辑无缺陷。唯一的 P2 问题是活体检测不完整——未检测"旧 loop 存活但不同"的场景,在并发多 loop 驱动同一 Runner 时仍会触发相同的跨循环错误。但该场景本就是 #​1444 涉及的更大问题(需重构单例),本 PR 从未声称解决它,且单 loop 正常路径零回归。P3 是测试文件中一个未使用的 import pytest,不影响正确性。

类型 数量
🔴 阻塞 0
🟡 建议 1

💬 仅评论

@openjiuwen-collaboration-bot

Copy link
Copy Markdown

head_sha: a1395a3261c11c5546377dd3f58958d6f6cdd39e

任务名称 结果 日志操作
静态检查 ✅SUCCESS 点此跳转
禁用词扫描 N/A N/A
防投毒检查 ✅SUCCESS 点此跳转
开源合规检查 ✅SUCCESS 点此跳转
UT测试 ✅SUCCESS 点此跳转
ST测试 N/A N/A
build 编译包 N/A N/A
ruff codecheck ✅SUCCESS N/A

except RuntimeError:
owner_loop = None
if owner_loop is not None and not owner_loop.is_closed():
return # owner still alive on a live loop → safe to reuse

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

head_sha: a1395a3261c11c5546377dd3f58958d6f6cdd39e

🟡 Medium Priority

changed line → affected behavior/contract → failure mode → suggested fix

变更行:runner.py:165 的条件仅判断 owner_loop.is_closed(),注释(:155)明确写了 "closed/foreign event loop",但代码只检测了 "closed",未检测 "foreign"。

失效场景:当旧事件循环仍存活、但与当前运行循环不是同一个时(例如两路 app 分别在 loop1/loop2 上并发驱动同一个 GLOBAL_RUNNER,或 loop1 未关闭就切到 loop2),owner.get_loop() 返回 loop1,loop1.is_closed() 为 False,条件成立 → return,复用绑定在 loop1 上的 owner task。后续 stop()_close_root_task_groupawait owner:203)会在 loop2 上 await 一个绑定 loop1 的 task,抛出 RuntimeError: Task ... got Future ... attached to a different loop

触发条件:旧 loop 存活且不同于当前 loop。本 PR 处理了 loop1 先关闭再切 loop2 的串行场景,但未覆盖 loop1 未关闭就切 loop2 的场景(参见 #​1444 多线程并发)。

if owner_loop is not None and not owner_loop.is_closed() and owner_loop is asyncio.get_running_loop():
return

这不会影响单 loop 正常路径(此时 is 恒为 True),也不会影响已关闭 loop 的重建路径(is_closed() 先短路为 False)。

建议:在 is_closed() 检查后增加 owner_loop is asyncio.get_running_loop() 检查,确保 owner 不仅 loop 存活、而且与当前运行循环一致。

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