cloudagent-daemon 0.5.4__tar.gz → 0.5.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/CHANGELOG.md +23 -0
  2. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/PKG-INFO +1 -1
  3. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/pyproject.toml +1 -1
  4. cloudagent_daemon-0.5.5/src/cloudagent_daemon/__init__.py +1 -0
  5. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/claude_runner.py +75 -51
  6. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_claude_runner.py +121 -3
  7. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/uv.lock +1 -1
  8. cloudagent_daemon-0.5.4/src/cloudagent_daemon/__init__.py +0 -1
  9. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/.gitignore +0 -0
  10. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/LICENSE +0 -0
  11. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/README.md +0 -0
  12. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/scripts/release.sh +0 -0
  13. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/__main__.py +0 -0
  14. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/agent_download.py +0 -0
  15. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/agent_upload.py +0 -0
  16. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/agent_upload_denylist.py +0 -0
  17. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/auth_preflight.py +0 -0
  18. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/cli.py +0 -0
  19. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/config.py +0 -0
  20. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/context_fetch.py +0 -0
  21. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/delegation_report.py +0 -0
  22. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/doctor.py +0 -0
  23. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/frame_dispatch.py +0 -0
  24. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/http_outbound.py +0 -0
  25. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/injection_state.py +0 -0
  26. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/jsonl_logging.py +0 -0
  27. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/login.py +0 -0
  28. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/main_loop.py +0 -0
  29. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/mcp_bridge.py +0 -0
  30. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/metrics.py +0 -0
  31. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/metrics_server.py +0 -0
  32. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/outbound_queue.py +0 -0
  33. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/paths.py +0 -0
  34. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/protocol.py +0 -0
  35. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/qoder_credentials.py +0 -0
  36. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/readonly_bash_guard.py +0 -0
  37. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/runtime_config.py +0 -0
  38. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/runtime_detect.py +0 -0
  39. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/service.py +0 -0
  40. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/spawn_outbox.py +0 -0
  41. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/spawn_queue.py +0 -0
  42. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/sse_client.py +0 -0
  43. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/transport.py +0 -0
  44. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/unix_socket.py +0 -0
  45. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/workspace.py +0 -0
  46. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/ws_client.py +0 -0
  47. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/integration/__init__.py +0 -0
  48. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/integration/_fake_sse_server.py +0 -0
  49. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/integration/conftest.py +0 -0
  50. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/integration/test_e2e_deliver_to_spawn.py +0 -0
  51. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/integration/test_e2e_sse.py +0 -0
  52. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/integration/test_full_e2e.py +0 -0
  53. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/integration/test_mcp_bridge_round_trip.py +0 -0
  54. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/integration/test_one_shot_cli.py +0 -0
  55. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/integration/test_spawn_mcp_bridge_e2e.py +0 -0
  56. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/integration/test_sse_handshake.py +0 -0
  57. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/integration/test_sse_watchdog.py +0 -0
  58. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/integration/test_ws_handshake.py +0 -0
  59. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/integration/test_ws_reconnect.py +0 -0
  60. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/conftest.py +0 -0
  61. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_agent_download.py +0 -0
  62. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_agent_upload.py +0 -0
  63. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_auth_preflight.py +0 -0
  64. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_claude_runner_codex_contract.py +0 -0
  65. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_claude_runner_routing_contract.py +0 -0
  66. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_claude_runner_w3.py +0 -0
  67. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_cli_multi_profile.py +0 -0
  68. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_cli_smoke.py +0 -0
  69. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_config.py +0 -0
  70. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_context_fetch.py +0 -0
  71. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_delegation_report.py +0 -0
  72. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_doctor.py +0 -0
  73. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_frame_dispatch.py +0 -0
  74. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_http_outbound.py +0 -0
  75. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_injection_state.py +0 -0
  76. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_jsonl_logging.py +0 -0
  77. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_login.py +0 -0
  78. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_main_loop_assign.py +0 -0
  79. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_main_loop_preflight.py +0 -0
  80. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_mcp_bridge_agent_retrieval.py +0 -0
  81. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_mcp_bridge_send_message.py +0 -0
  82. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_metrics.py +0 -0
  83. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_metrics_server.py +0 -0
  84. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_multi_tool_wake.py +0 -0
  85. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_outbound_queue.py +0 -0
  86. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
  87. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_packaging_mcp_pin.py +0 -0
  88. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_paths.py +0 -0
  89. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_protocol.py +0 -0
  90. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_qoder_credentials.py +0 -0
  91. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_readonly_bash_guard.py +0 -0
  92. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_runtime_detect.py +0 -0
  93. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_service.py +0 -0
  94. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_service_multi_profile.py +0 -0
  95. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_spawn_outbox.py +0 -0
  96. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_spawn_queue.py +0 -0
  97. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_sse_active_watchdog.py +0 -0
  98. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_start_guard.py +0 -0
  99. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_status_stop.py +0 -0
  100. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_workspace.py +0 -0
  101. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_workspace_root_task113.py +0 -0
  102. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.5}/tests/unit/test_ws_client_insecure.py +0 -0
@@ -4,6 +4,29 @@ All notable changes to the local daemon. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/); versions are
5
5
  [PEP 440](https://peps.python.org/pep-0440/). Release with `scripts/release.sh`.
6
6
 
7
+ ## [0.5.5] — 2026-08-27
8
+
9
+ ### Changed
10
+ - Every engine now gets one runtime session per conversation, not one per agent.
11
+ An agent resumed the same CLI session no matter which channel woke it, so
12
+ #debug, the DM and #SSS all shared a single context: the per-channel prompt
13
+ leaked between rooms, and each wake replayed the union of every room's
14
+ history — muddled context and a token bill that grew without bound. Lanes
15
+ already fixed this for qoder; the bleed was never qoder-specific, so
16
+ claude_code and codex now resume from the same per-conversation lane, keyed
17
+ on the waking conversation (channel, DM and thread each carry their own).
18
+ Wakes with no conversation — timers, spawn chains — keep the global lane.
19
+
20
+ ### Notes
21
+ - The lane file is now `.meta/sessions.json`. `qoder-sessions.json` is still
22
+ read so an upgraded host keeps its lanes; the first write folds it into the
23
+ new file and removes it, or a cleared lane would come back to life from the
24
+ stale value left behind.
25
+ - Agents carrying a global session into this version start fresh on their first
26
+ wake in each conversation. That is the fix rather than a side effect —
27
+ inheriting it is precisely the bleed being removed — but expect an agent to
28
+ look like it "forgot" an in-flight thread once, right after the upgrade.
29
+
7
30
  ## [0.5.4] — 2026-08-24
8
31
 
9
32
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cloudagent-daemon
3
- Version: 0.5.4
3
+ Version: 0.5.5
4
4
  Summary: Local daemon for cloudagent — bridges Claude Code, Codex, and Qoder CLI agents to the cloudagent backend.
5
5
  Project-URL: Homepage, https://cloudagent.chat
6
6
  Project-URL: Repository, https://codeup.aliyun.com/69bd0a4d29ad98af4065ce71/cloudagent
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cloudagent-daemon"
7
- version = "0.5.4"
7
+ version = "0.5.5"
8
8
  description = "Local daemon for cloudagent — bridges Claude Code, Codex, and Qoder CLI agents to the cloudagent backend."
9
9
  authors = [{name = "chaoyin", email = "tudoucyx@gmail.com"}]
10
10
  readme = "README.md"
@@ -0,0 +1 @@
1
+ __version__ = "0.5.5"
@@ -680,11 +680,10 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
680
680
  stale_conv = (
681
681
  str(payload["conversation_id"]) if payload.get("conversation_id") else None
682
682
  )
683
- stale_is_qoder = self._runtime_engine_for(ws, payload) == "qoder"
684
- qoder_lane_sid = (
685
- self._qoder_lane_session(ws, stale_conv)
686
- if stale_is_qoder and stale_conv else None
687
- )
683
+ # #95 — the lane IS the session this wake resumed from (the
684
+ # global last_session only when there is no conversation), so
685
+ # it is also the one to drop once it proves unusable.
686
+ stale_lane_sid = self._lane_session(ws, stale_conv)
688
687
  # #114 — 上下文撑满与 stale-session 是同一类问题:**当前会话已经
689
688
  # 不能再用了**,区别只在于一个是引擎不认识它、另一个是它太大。补救
690
689
  # 动作完全相同(清掉会话、免 resume 重跑一次),所以走同一条路径,
@@ -701,12 +700,12 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
701
700
  )
702
701
  or context_exhausted
703
702
  )
704
- and (ws.last_session_id() is not None or qoder_lane_sid is not None)
703
+ and stale_lane_sid is not None
705
704
  ):
706
- stale_sid = qoder_lane_sid or ws.last_session_id()
707
- # #95 — qoder 的过期会话在 lane 里,清 lane 才能真正脱离它
708
- if stale_is_qoder and stale_conv:
709
- self._qoder_set_lane(ws, stale_conv, None)
705
+ stale_sid = stale_lane_sid
706
+ # #95 — 过期会话就在 lane 里,清 lane 才能真正脱离它
707
+ if stale_conv:
708
+ self._set_lane(ws, stale_conv, None)
710
709
  logger.warning(
711
710
  "claude_runner: dropping unusable session_id=%s "
712
711
  "(cause=%s, detail=%r). Clearing and retrying "
@@ -782,21 +781,20 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
782
781
  # 防外人注入的上下文渗进 owner 后续 full 会话。仍上报 session_start
783
782
  # 供实时展示,但不落 last-spawn(下次 full 唤醒仍 resume 干净的 owner 轨)。
784
783
  is_read_analyze = self._run_mode_for(payload) == "read_analyze"
785
- # #95 — qoder 按 conversation_id 分轨存会话;全局 last_session 仍写
786
- # (last-spawn.json 可观测性 + 非平台唤醒兜底),但 qoder 平台唤醒的
787
- # resume 只读 lane(见 _build_cmd),不会被别的频道的会话覆盖。
784
+ # #95 — 所有引擎都按 conversation_id 分轨存会话;全局 last_session 仍写
785
+ # (last-spawn.json 可观测性 + 非平台唤醒兜底),但平台唤醒的 resume
786
+ # 只读 lane(见 _build_cmd),不会被别的频道的会话覆盖。
788
787
  conv_for_lane = (
789
788
  str(payload["conversation_id"]) if payload.get("conversation_id") else None
790
789
  )
791
- if (
792
- captured_session
793
- and not is_read_analyze
794
- and conv_for_lane
795
- and self._runtime_engine_for(ws, payload) == "qoder"
796
- ):
797
- self._qoder_set_lane(ws, conv_for_lane, captured_session)
798
- if captured_session and captured_session != ws.last_session_id():
790
+ # 比较基准取**本轨**上一个会话,不是全局 last_session:分轨后全局会被
791
+ # 最后说话的频道盖掉,拿它比会让 A/B 频道交替唤醒每轮都像是新会话,
792
+ # 把 session_start 刷成噪音。必须在 _set_lane 之前读。
793
+ prev_lane_session = self._lane_session(ws, conv_for_lane)
794
+ if captured_session and captured_session != prev_lane_session:
799
795
  if not is_read_analyze:
796
+ if conv_for_lane:
797
+ self._set_lane(ws, conv_for_lane, captured_session)
800
798
  ws.set_last_session_id(captured_session)
801
799
  await self.outbound.send_agent_session_start(
802
800
  agent_id, captured_session
@@ -2098,53 +2096,79 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2098
2096
  )
2099
2097
 
2100
2098
  # ------------------------------------------------------------------
2101
- # #95 — qoder runtime-session lanes, one per conversation.
2099
+ # #95 — runtime-session lanes, one per conversation.
2102
2100
  #
2103
- # qodercli ``--resume`` does not reliably adopt a new
2104
- # ``--append-system-prompt`` on a resumed session, and a single global
2105
- # last_session shared by DM/#debug/#SSS made the per-channel prompt
2106
- # (#89) bleed across rooms. Each conversation therefore gets its own
2107
- # lane: ``.meta/qoder-sessions.json`` = {conversation_id: session_id}.
2101
+ # A single global last_session shared by DM/#debug/#SSS made every room
2102
+ # bleed into every other one: the agent resumed the same CLI session no
2103
+ # matter which channel woke it, so #89's per-channel prompt leaked
2104
+ # across rooms and each wake replayed the union of all rooms' history
2105
+ # (context noise + token burn). Each conversation therefore gets its own
2106
+ # lane: ``.meta/sessions.json`` = {conversation_id: session_id}.
2108
2107
  # Non-platform wakes (no conversation_id) keep the legacy global lane.
2108
+ #
2109
+ # Lanes started out qoder-only (qodercli ``--resume`` additionally
2110
+ # refuses to adopt a new ``--append-system-prompt``), but the bleed is
2111
+ # engine-independent — claude_code and codex resumed one global session
2112
+ # too. All engines now share this path; the superseded qoder file is
2113
+ # still read so an upgraded host keeps its existing lanes.
2109
2114
  # ------------------------------------------------------------------
2110
2115
 
2116
+ #: Superseded lane file — read-only, for hosts upgraded from the
2117
+ #: qoder-only era. Writes always land in ``sessions.json``.
2118
+ LEGACY_QODER_LANES_FILENAME = "qoder-sessions.json"
2119
+
2111
2120
  @staticmethod
2112
- def _qoder_lanes_path(ws: AgentWorkspaceLike) -> "Path":
2113
- return ws.path / ".meta" / "qoder-sessions.json"
2121
+ def _lanes_path(ws: AgentWorkspaceLike) -> "Path":
2122
+ return ws.path / ".meta" / "sessions.json"
2123
+
2124
+ @classmethod
2125
+ def _read_lanes(cls, ws: AgentWorkspaceLike) -> dict:
2126
+ """Current lane file layered over the superseded qoder one."""
2127
+ merged: dict = {}
2128
+ for path in (
2129
+ ws.path / ".meta" / cls.LEGACY_QODER_LANES_FILENAME,
2130
+ cls._lanes_path(ws),
2131
+ ):
2132
+ try:
2133
+ data = json.loads(path.read_text(encoding="utf-8"))
2134
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
2135
+ continue
2136
+ if isinstance(data, dict):
2137
+ merged.update(data)
2138
+ return merged
2114
2139
 
2115
- def _qoder_lane_session(
2140
+ def _lane_session(
2116
2141
  self, ws: AgentWorkspaceLike, conversation_id: str | None
2117
2142
  ) -> str | None:
2118
2143
  if not conversation_id:
2119
2144
  return ws.last_session_id()
2120
- try:
2121
- data = json.loads(self._qoder_lanes_path(ws).read_text(encoding="utf-8"))
2122
- except (FileNotFoundError, json.JSONDecodeError, OSError):
2123
- return None
2124
- sid = data.get(conversation_id) if isinstance(data, dict) else None
2145
+ # Deliberately NO fallback to the global last_session: the first
2146
+ # wake in a conversation must start fresh, or it would inherit
2147
+ # whatever room spoke last — the exact bleed lanes exist to remove.
2148
+ sid = self._read_lanes(ws).get(conversation_id)
2125
2149
  return sid if isinstance(sid, str) and sid else None
2126
2150
 
2127
- def _qoder_set_lane(
2151
+ def _set_lane(
2128
2152
  self, ws: AgentWorkspaceLike, conversation_id: str, session_id: str | None
2129
2153
  ) -> None:
2130
2154
  """Set (or clear with None) the lane. Best-effort; never raises."""
2131
- p = self._qoder_lanes_path(ws)
2155
+ p = self._lanes_path(ws)
2156
+ legacy = ws.path / ".meta" / self.LEGACY_QODER_LANES_FILENAME
2132
2157
  try:
2133
- try:
2134
- data = json.loads(p.read_text(encoding="utf-8"))
2135
- if not isinstance(data, dict):
2136
- data = {}
2137
- except (FileNotFoundError, json.JSONDecodeError, OSError):
2138
- data = {}
2158
+ data = self._read_lanes(ws)
2139
2159
  if session_id:
2140
2160
  data[conversation_id] = session_id
2141
2161
  else:
2142
2162
  data.pop(conversation_id, None)
2143
2163
  p.parent.mkdir(parents=True, exist_ok=True)
2144
2164
  p.write_text(json.dumps(data, indent=2), encoding="utf-8")
2165
+ # The merge above already carried every legacy entry across, so
2166
+ # drop the old file — otherwise a *cleared* lane would come back
2167
+ # to life on the next read from the stale value left behind.
2168
+ legacy.unlink(missing_ok=True)
2145
2169
  except OSError:
2146
2170
  logger.warning(
2147
- "claude_runner: qoder lane write failed conv=%s", conversation_id,
2171
+ "claude_runner: lane write failed conv=%s", conversation_id,
2148
2172
  exc_info=True,
2149
2173
  )
2150
2174
 
@@ -2214,8 +2238,12 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2214
2238
  故此模式下 ``last_session`` 恒为 None。
2215
2239
  """
2216
2240
  read_analyze = run_mode == "read_analyze"
2241
+ # #95 — 每个 conversation 一条轨(无 conversation_id 的非平台唤醒落回
2242
+ # 全局轨)。原先这里直接读 ws.last_session_id(),于是同一个 agent 无论
2243
+ # 在哪个频道被唤醒都 --resume 同一条会话。
2217
2244
  last_session = (
2218
- ws.last_session_id() if (with_resume and not read_analyze) else None
2245
+ self._lane_session(ws, conversation_id)
2246
+ if (with_resume and not read_analyze) else None
2219
2247
  )
2220
2248
  if runtime_engine == "codex":
2221
2249
  binary = self.runtime_binaries.get("codex") or "codex"
@@ -2290,14 +2318,10 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2290
2318
  # 输出走既有 stream_json 消费器(联调时如发现事件形状偏差再适配)。
2291
2319
  # 认证:QODER_PERSONAL_ACCESS_TOKEN 环境变量或 qodercli login。
2292
2320
  binary = self.runtime_binaries.get("qoder") or "qodercli"
2293
- # #95 — 会话按 conversation_id 分轨(DM/各频道各自续聊),不再共用
2294
- # 全局 last_session;无 conversation_id 的非平台唤醒沿用全局轨。
2321
+ # #95 — 分轨已上提到 last_session(所有引擎共用),这里直接复用。
2295
2322
  # #107 — read_analyze 与 claude/codex 一样绝不 resume owner 轨,
2296
2323
  # 防外部指挥上下文渗进后续 full 会话。
2297
- lane_session = (
2298
- self._qoder_lane_session(ws, conversation_id)
2299
- if with_resume and not read_analyze else None
2300
- )
2324
+ lane_session = last_session
2301
2325
  cmd = [binary]
2302
2326
  # r6 同款:模型 + 推理强度由 chairman 在编辑页选(qoder 档位
2303
2327
  # low|medium|high|xhigh|max,与 claude_code 同集)。
@@ -3293,14 +3293,18 @@ async def test_qoder_sessions_are_isolated_per_conversation(
3293
3293
  a3 = _read_argv_n(tmp_path, 3)
3294
3294
  assert a3[a3.index("--resume") + 1] == "sess_dm"
3295
3295
 
3296
- lanes = json.loads((tmp_path / ".meta" / "qoder-sessions.json").read_text())
3296
+ lanes = json.loads((tmp_path / ".meta" / "sessions.json").read_text())
3297
3297
  assert lanes == {"conv_debug": "sess_debug", "conv_dm": "sess_dm"}
3298
3298
 
3299
3299
 
3300
3300
  async def test_qoder_stale_resume_clears_only_that_lane(
3301
3301
  stateful_fake_claude, tmp_path
3302
3302
  ):
3303
- """lane 里的过期会话被 CLI 拒绝 → 清该 lane 并免 resume 重试;别的 lane 不动。"""
3303
+ """lane 里的过期会话被 CLI 拒绝 → 清该 lane 并免 resume 重试;别的 lane 不动。
3304
+
3305
+ seed 故意写在**旧** ``qoder-sessions.json`` 上,于是这条同时验收升级路径:
3306
+ 旧 lane 仍可 resume,首次写入把两条都搬进 ``sessions.json`` 并删掉旧文件。
3307
+ """
3304
3308
  mcp_cfg = tmp_path / "mcp.json"; mcp_cfg.write_text("{}")
3305
3309
  ws = FakeWorkspace(FakeAgentWorkspace(mcp_path=mcp_cfg, last_session=None))
3306
3310
  (tmp_path / ".meta").mkdir(parents=True, exist_ok=True)
@@ -3319,8 +3323,122 @@ async def test_qoder_stale_resume_clears_only_that_lane(
3319
3323
  a0 = _read_argv_n(tmp_path, 0); a1 = _read_argv_n(tmp_path, 1)
3320
3324
  assert a0[a0.index("--resume") + 1] == "old_A"
3321
3325
  assert "--resume" not in a1
3322
- lanes = json.loads((tmp_path / ".meta" / "qoder-sessions.json").read_text())
3326
+ lanes = json.loads((tmp_path / ".meta" / "sessions.json").read_text())
3323
3327
  assert lanes == {"conv_A": "new_A", "conv_B": "keep_B"}
3328
+ # 迁移后旧文件必须消失,否则被清掉的 lane 会从残留值里复活
3329
+ assert not (tmp_path / ".meta" / "qoder-sessions.json").exists()
3330
+
3331
+
3332
+ # ---------------------------------------------------------------------------
3333
+ # #95 — lanes are engine-independent: claude_code / codex get them too
3334
+ # ---------------------------------------------------------------------------
3335
+
3336
+
3337
+ def _claude_ok(sid: str) -> dict:
3338
+ return {
3339
+ "stdout": [
3340
+ json.dumps({"type": "system", "subtype": "init", "session_id": sid}),
3341
+ json.dumps({"type": "result", "subtype": "success", "result": "ok"}),
3342
+ ],
3343
+ "exit_code": 0,
3344
+ }
3345
+
3346
+
3347
+ async def test_claude_code_sessions_are_isolated_per_conversation(
3348
+ stateful_fake_claude, tmp_path
3349
+ ):
3350
+ """claude_code 同样按 conversation 分轨:频道/DM 各自续聊,互不串味。
3351
+
3352
+ 这条是本次改动的本体 —— 分轨此前只有 qoder 走,claude_code 不管在哪个
3353
+ 频道被唤醒都 --resume 同一条全局会话。
3354
+ """
3355
+ mcp_cfg = tmp_path / "mcp.json"; mcp_cfg.write_text("{}")
3356
+ ws = FakeWorkspace(FakeAgentWorkspace(mcp_path=mcp_cfg, last_session=None))
3357
+ fake = stateful_fake_claude([
3358
+ _claude_ok("sess_debug"), # 0: #debug 首轮
3359
+ _claude_ok("sess_dm"), # 1: DM 首轮 —— 不得继承 #debug 的会话
3360
+ _claude_ok("sess_debug"), # 2: 回 #debug —— resume 自己的轨
3361
+ _claude_ok("sess_dm"), # 3: 回 DM —— resume DM 的轨
3362
+ ])
3363
+ runner = _build_runner(fake, ws, FakeOutbound())
3364
+ dbg = {**_make_payload(), "conversation_id": "conv_debug"}
3365
+ dm = {**_make_payload(), "conversation_id": "conv_dm"}
3366
+
3367
+ await runner.run_one("agent-A", dbg)
3368
+ assert "--resume" not in _read_argv_n(tmp_path, 0)
3369
+ await runner.run_one("agent-A", dm)
3370
+ assert "--resume" not in _read_argv_n(tmp_path, 1)
3371
+ await runner.run_one("agent-A", dbg)
3372
+ a2 = _read_argv_n(tmp_path, 2)
3373
+ assert a2[a2.index("--resume") + 1] == "sess_debug"
3374
+ await runner.run_one("agent-A", dm)
3375
+ a3 = _read_argv_n(tmp_path, 3)
3376
+ assert a3[a3.index("--resume") + 1] == "sess_dm"
3377
+
3378
+ lanes = json.loads((tmp_path / ".meta" / "sessions.json").read_text())
3379
+ assert lanes == {"conv_debug": "sess_debug", "conv_dm": "sess_dm"}
3380
+
3381
+
3382
+ async def test_new_conversation_never_inherits_the_global_session(
3383
+ stateful_fake_claude, tmp_path
3384
+ ):
3385
+ """升级现场的回归闸:老 agent 带着全局 last_session 进入本版本,新频道
3386
+ 首轮必须从零开始 —— 继承它正是分轨要消灭的那条串味路径。"""
3387
+ mcp_cfg = tmp_path / "mcp.json"; mcp_cfg.write_text("{}")
3388
+ ws = FakeWorkspace(
3389
+ FakeAgentWorkspace(mcp_path=mcp_cfg, last_session="global_old")
3390
+ )
3391
+ fake = stateful_fake_claude([_claude_ok("sess_fresh")])
3392
+ runner = _build_runner(fake, ws, FakeOutbound())
3393
+
3394
+ await runner.run_one(
3395
+ "agent-A", {**_make_payload(), "conversation_id": "conv_new"}
3396
+ )
3397
+ assert "--resume" not in _read_argv_n(tmp_path, 0)
3398
+
3399
+
3400
+ async def test_non_platform_wake_still_uses_the_global_lane(
3401
+ stateful_fake_claude, tmp_path
3402
+ ):
3403
+ """无 conversation_id 的唤醒(定时器 / spawn 链)沿用全局轨,不受分轨影响。"""
3404
+ mcp_cfg = tmp_path / "mcp.json"; mcp_cfg.write_text("{}")
3405
+ ws = FakeWorkspace(
3406
+ FakeAgentWorkspace(mcp_path=mcp_cfg, last_session="global_old")
3407
+ )
3408
+ fake = stateful_fake_claude([_claude_ok("global_old")])
3409
+ runner = _build_runner(fake, ws, FakeOutbound())
3410
+
3411
+ await runner.run_one("agent-A", _make_payload())
3412
+ a0 = _read_argv_n(tmp_path, 0)
3413
+ assert a0[a0.index("--resume") + 1] == "global_old"
3414
+
3415
+
3416
+ async def test_codex_sessions_are_isolated_per_conversation(
3417
+ stateful_fake_claude, tmp_path
3418
+ ):
3419
+ """codex 分轨;resume 形态是 ``exec resume … <thread_id> -``。"""
3420
+ mcp_cfg = tmp_path / "mcp.json"; mcp_cfg.write_text("{}")
3421
+ ws = FakeWorkspace(FakeAgentWorkspace(mcp_path=mcp_cfg, last_session=None))
3422
+ fake = stateful_fake_claude([
3423
+ {"stdout": _codex_ok("thread_a"), "exit_code": 0}, # 0: conv_A 首轮
3424
+ {"stdout": _codex_ok("thread_b"), "exit_code": 0}, # 1: conv_B 首轮
3425
+ {"stdout": _codex_ok("thread_a"), "exit_code": 0}, # 2: 回 conv_A
3426
+ ])
3427
+ runner = _codex_runner(fake, ws, FakeOutbound())
3428
+ a = {**_make_payload(), "runtime_engine": "codex", "conversation_id": "conv_A"}
3429
+ b = {**_make_payload(), "runtime_engine": "codex", "conversation_id": "conv_B"}
3430
+
3431
+ await runner.run_one("agent-A", a)
3432
+ assert "resume" not in _read_argv_n(tmp_path, 0)
3433
+ await runner.run_one("agent-A", b)
3434
+ assert "resume" not in _read_argv_n(tmp_path, 1)
3435
+ await runner.run_one("agent-A", a)
3436
+ a2 = _read_argv_n(tmp_path, 2)
3437
+ assert "resume" in a2
3438
+ assert a2[-2] == "thread_a" # …<thread_id> "-"
3439
+
3440
+ lanes = json.loads((tmp_path / ".meta" / "sessions.json").read_text())
3441
+ assert lanes == {"conv_A": "thread_a", "conv_B": "thread_b"}
3324
3442
 
3325
3443
 
3326
3444
  # ---------------------------------------------------------------------------
@@ -122,7 +122,7 @@ wheels = [
122
122
 
123
123
  [[package]]
124
124
  name = "cloudagent-daemon"
125
- version = "0.5.4"
125
+ version = "0.5.5"
126
126
  source = { editable = "." }
127
127
  dependencies = [
128
128
  { name = "h2" },
@@ -1 +0,0 @@
1
- __version__ = "0.5.4"