cloudagent-daemon 0.5.4__tar.gz → 0.5.6__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.6}/CHANGELOG.md +49 -0
  2. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/PKG-INFO +1 -1
  3. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/pyproject.toml +1 -1
  4. cloudagent_daemon-0.5.6/src/cloudagent_daemon/__init__.py +1 -0
  5. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/claude_runner.py +128 -52
  6. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_claude_runner.py +231 -3
  7. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/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.6}/.gitignore +0 -0
  10. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/LICENSE +0 -0
  11. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/README.md +0 -0
  12. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/scripts/release.sh +0 -0
  13. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/__main__.py +0 -0
  14. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/agent_download.py +0 -0
  15. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/agent_upload.py +0 -0
  16. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/agent_upload_denylist.py +0 -0
  17. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/auth_preflight.py +0 -0
  18. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/cli.py +0 -0
  19. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/config.py +0 -0
  20. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/context_fetch.py +0 -0
  21. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/delegation_report.py +0 -0
  22. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/doctor.py +0 -0
  23. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/frame_dispatch.py +0 -0
  24. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/http_outbound.py +0 -0
  25. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/injection_state.py +0 -0
  26. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/jsonl_logging.py +0 -0
  27. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/login.py +0 -0
  28. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/main_loop.py +0 -0
  29. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/mcp_bridge.py +0 -0
  30. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/metrics.py +0 -0
  31. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/metrics_server.py +0 -0
  32. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/outbound_queue.py +0 -0
  33. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/paths.py +0 -0
  34. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/protocol.py +0 -0
  35. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/qoder_credentials.py +0 -0
  36. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/readonly_bash_guard.py +0 -0
  37. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/runtime_config.py +0 -0
  38. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/runtime_detect.py +0 -0
  39. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/service.py +0 -0
  40. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/spawn_outbox.py +0 -0
  41. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/spawn_queue.py +0 -0
  42. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/sse_client.py +0 -0
  43. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/transport.py +0 -0
  44. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/unix_socket.py +0 -0
  45. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/workspace.py +0 -0
  46. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/ws_client.py +0 -0
  47. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/__init__.py +0 -0
  48. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/_fake_sse_server.py +0 -0
  49. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/conftest.py +0 -0
  50. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_e2e_deliver_to_spawn.py +0 -0
  51. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_e2e_sse.py +0 -0
  52. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_full_e2e.py +0 -0
  53. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_mcp_bridge_round_trip.py +0 -0
  54. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_one_shot_cli.py +0 -0
  55. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_spawn_mcp_bridge_e2e.py +0 -0
  56. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_sse_handshake.py +0 -0
  57. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_sse_watchdog.py +0 -0
  58. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_ws_handshake.py +0 -0
  59. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_ws_reconnect.py +0 -0
  60. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/conftest.py +0 -0
  61. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_agent_download.py +0 -0
  62. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_agent_upload.py +0 -0
  63. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_auth_preflight.py +0 -0
  64. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_claude_runner_codex_contract.py +0 -0
  65. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_claude_runner_routing_contract.py +0 -0
  66. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_claude_runner_w3.py +0 -0
  67. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_cli_multi_profile.py +0 -0
  68. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_cli_smoke.py +0 -0
  69. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_config.py +0 -0
  70. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_context_fetch.py +0 -0
  71. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_delegation_report.py +0 -0
  72. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_doctor.py +0 -0
  73. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_frame_dispatch.py +0 -0
  74. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_http_outbound.py +0 -0
  75. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_injection_state.py +0 -0
  76. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_jsonl_logging.py +0 -0
  77. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_login.py +0 -0
  78. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_main_loop_assign.py +0 -0
  79. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_main_loop_preflight.py +0 -0
  80. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_mcp_bridge_agent_retrieval.py +0 -0
  81. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_mcp_bridge_send_message.py +0 -0
  82. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_metrics.py +0 -0
  83. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_metrics_server.py +0 -0
  84. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_multi_tool_wake.py +0 -0
  85. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_outbound_queue.py +0 -0
  86. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
  87. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_packaging_mcp_pin.py +0 -0
  88. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_paths.py +0 -0
  89. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_protocol.py +0 -0
  90. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_qoder_credentials.py +0 -0
  91. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_readonly_bash_guard.py +0 -0
  92. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_runtime_detect.py +0 -0
  93. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_service.py +0 -0
  94. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_service_multi_profile.py +0 -0
  95. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_spawn_outbox.py +0 -0
  96. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_spawn_queue.py +0 -0
  97. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_sse_active_watchdog.py +0 -0
  98. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_start_guard.py +0 -0
  99. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_status_stop.py +0 -0
  100. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_workspace.py +0 -0
  101. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_workspace_root_task113.py +0 -0
  102. {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_ws_client_insecure.py +0 -0
@@ -4,6 +4,55 @@ 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.6] — 2026-08-30
8
+
9
+ ### Changed
10
+ - Every claude_code wake now passes `--autocompact 200k`. A lane is re-read in
11
+ full on every wake, so an unbounded one charges its whole history each time —
12
+ one agent's lane climbed to 1.07M cached tokens and was replaying all of it 96
13
+ times a day to add a few thousand tokens of new content. An explicit size is
14
+ used rather than `auto` because `auto` tracks the model's own window (1M on
15
+ opus), which is precisely the runaway being bounded. This prevents what
16
+ `_is_context_exhausted` (0.5.x, #114) only cures after a wake has already
17
+ failed and its session been discarded.
18
+ - A timer-fired wake (`message_id="remind:<id>:<n>"`) now runs in a throwaway
19
+ session: it does not resume a lane, does not record one, and its failure may
20
+ not clear one. A reminder tick is a stateless patrol — what it must decide
21
+ comes from the room's current state, which it reads live, not from what it
22
+ said on the previous tick.
23
+
24
+ ### Notes
25
+ - Recording a patrol's session as the room's lane was the same bleed as #95,
26
+ arriving by another door: the next human to speak in that room inherited the
27
+ timer's accumulated history. Ordinary wakes are unaffected — a human message
28
+ in that same room still resumes the room's lane and still records its session.
29
+ - Hosts on 0.5.5 will see supervision-style agents start each tick fresh. That
30
+ is the change, not a regression: such an agent should be reading the room, not
31
+ replaying itself.
32
+
33
+ ## [0.5.5] — 2026-08-27
34
+
35
+ ### Changed
36
+ - Every engine now gets one runtime session per conversation, not one per agent.
37
+ An agent resumed the same CLI session no matter which channel woke it, so
38
+ #debug, the DM and #SSS all shared a single context: the per-channel prompt
39
+ leaked between rooms, and each wake replayed the union of every room's
40
+ history — muddled context and a token bill that grew without bound. Lanes
41
+ already fixed this for qoder; the bleed was never qoder-specific, so
42
+ claude_code and codex now resume from the same per-conversation lane, keyed
43
+ on the waking conversation (channel, DM and thread each carry their own).
44
+ Wakes with no conversation — timers, spawn chains — keep the global lane.
45
+
46
+ ### Notes
47
+ - The lane file is now `.meta/sessions.json`. `qoder-sessions.json` is still
48
+ read so an upgraded host keeps its lanes; the first write folds it into the
49
+ new file and removes it, or a cleared lane would come back to life from the
50
+ stale value left behind.
51
+ - Agents carrying a global session into this version start fresh on their first
52
+ wake in each conversation. That is the fix rather than a side effect —
53
+ inheriting it is precisely the bleed being removed — but expect an agent to
54
+ look like it "forgot" an in-flight thread once, right after the upgrade.
55
+
7
56
  ## [0.5.4] — 2026-08-24
8
57
 
9
58
  ### 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.6
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.6"
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.6"
@@ -474,6 +474,16 @@ class ClaudeRunner:
474
474
  "no rollout found for thread",
475
475
  )
476
476
 
477
+ #: #114 followup — ``--autocompact`` window for claude_code wakes.
478
+ #:
479
+ #: An explicit size rather than ``auto``: ``auto`` tracks the model's own
480
+ #: window (1M on opus), which is the runaway this exists to bound — a lane
481
+ #: is replayed in full on every wake, so "compacts just before overflow"
482
+ #: still means every wake pays for a near-window-sized history. 200k keeps
483
+ #: days of one room's working context while cutting per-wake replay ~5x.
484
+ #: The CLI accepts ``auto`` or 100k–1M (``claude --help``).
485
+ AUTOCOMPACT_WINDOW: str = "200k"
486
+
477
487
  #: #114 — 上下文窗口撑满。这是**永久性**前提失败,不是瞬时错误:退避几秒
478
488
  #: 再把同一份历史送一次,必然再次溢出。codex 自己给出的处置就是 "Start a
479
489
  #: new thread or clear earlier history",与 stale-session 的补救动作完全
@@ -680,11 +690,10 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
680
690
  stale_conv = (
681
691
  str(payload["conversation_id"]) if payload.get("conversation_id") else None
682
692
  )
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
- )
693
+ # #95 — the lane IS the session this wake resumed from (the
694
+ # global last_session only when there is no conversation), so
695
+ # it is also the one to drop once it proves unusable.
696
+ stale_lane_sid = self._lane_session(ws, stale_conv)
688
697
  # #114 — 上下文撑满与 stale-session 是同一类问题:**当前会话已经
689
698
  # 不能再用了**,区别只在于一个是引擎不认识它、另一个是它太大。补救
690
699
  # 动作完全相同(清掉会话、免 resume 重跑一次),所以走同一条路径,
@@ -693,6 +702,10 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
693
702
  context_exhausted = self._is_context_exhausted(attempt)
694
703
  if (
695
704
  self._run_mode_for(payload) != "read_analyze"
705
+ # A periodic wake resumed nothing, so a stale/exhausted session
706
+ # is not its to drop — clearing here would evict the lane the
707
+ # room's humans are still using.
708
+ and not self._is_periodic_wake(payload)
696
709
  and attempt.exit_code != 0
697
710
  and (
698
711
  any(
@@ -701,12 +714,12 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
701
714
  )
702
715
  or context_exhausted
703
716
  )
704
- and (ws.last_session_id() is not None or qoder_lane_sid is not None)
717
+ and stale_lane_sid is not None
705
718
  ):
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)
719
+ stale_sid = stale_lane_sid
720
+ # #95 — 过期会话就在 lane 里,清 lane 才能真正脱离它
721
+ if stale_conv:
722
+ self._set_lane(ws, stale_conv, None)
710
723
  logger.warning(
711
724
  "claude_runner: dropping unusable session_id=%s "
712
725
  "(cause=%s, detail=%r). Clearing and retrying "
@@ -782,21 +795,24 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
782
795
  # 防外人注入的上下文渗进 owner 后续 full 会话。仍上报 session_start
783
796
  # 供实时展示,但不落 last-spawn(下次 full 唤醒仍 resume 干净的 owner 轨)。
784
797
  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),不会被别的频道的会话覆盖。
798
+ # #114 followup — a periodic wake ran in a throwaway session (it never
799
+ # resumed one). Recording it would hand the room's humans a lane whose
800
+ # history is a patrol tick, which is the bleed we just removed.
801
+ is_periodic = self._is_periodic_wake(payload)
802
+ # #95 — 所有引擎都按 conversation_id 分轨存会话;全局 last_session 仍写
803
+ # (last-spawn.json 可观测性 + 非平台唤醒兜底),但平台唤醒的 resume
804
+ # 只读 lane(见 _build_cmd),不会被别的频道的会话覆盖。
788
805
  conv_for_lane = (
789
806
  str(payload["conversation_id"]) if payload.get("conversation_id") else None
790
807
  )
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():
799
- if not is_read_analyze:
808
+ # 比较基准取**本轨**上一个会话,不是全局 last_session:分轨后全局会被
809
+ # 最后说话的频道盖掉,拿它比会让 A/B 频道交替唤醒每轮都像是新会话,
810
+ # 把 session_start 刷成噪音。必须在 _set_lane 之前读。
811
+ prev_lane_session = self._lane_session(ws, conv_for_lane)
812
+ if captured_session and captured_session != prev_lane_session:
813
+ if not is_read_analyze and not is_periodic:
814
+ if conv_for_lane:
815
+ self._set_lane(ws, conv_for_lane, captured_session)
800
816
  ws.set_last_session_id(captured_session)
801
817
  await self.outbound.send_agent_session_start(
802
818
  agent_id, captured_session
@@ -1382,6 +1398,7 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1382
1398
  model, reasoning_effort = self._model_and_effort_for(ws, payload)
1383
1399
  runtime_env = self._runtime_env_for(ws, payload)
1384
1400
  run_mode = self._run_mode_for(payload)
1401
+ periodic = self._is_periodic_wake(payload)
1385
1402
  scratch_dir = (
1386
1403
  self._readonly_scratch_dir(ws, payload)
1387
1404
  if run_mode == "read_analyze"
@@ -1397,6 +1414,7 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1397
1414
  reasoning_effort=reasoning_effort,
1398
1415
  run_mode=run_mode,
1399
1416
  scratch_dir=scratch_dir,
1417
+ periodic=periodic,
1400
1418
  conversation_id=(
1401
1419
  str(payload["conversation_id"])
1402
1420
  if payload.get("conversation_id") else None
@@ -1920,6 +1938,27 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
1920
1938
  raw = payload.get("run_mode")
1921
1939
  return "read_analyze" if raw == "read_analyze" else "full"
1922
1940
 
1941
+ @staticmethod
1942
+ def _is_periodic_wake(payload: dict) -> bool:
1943
+ """True for a timer-fired wake — one occurrence of a reminder.
1944
+
1945
+ Backend stamps these with the synthetic message id
1946
+ ``remind:<reminder_id>:<occurrence>``.
1947
+
1948
+ A periodic wake is a *stateless patrol*: what it has to decide comes
1949
+ from the room's CURRENT state, which it reads live (platform_read),
1950
+ not from what it said on the previous tick. So it neither resumes a
1951
+ lane nor writes one back — it runs in its own throwaway session.
1952
+
1953
+ Before this, a 15-minute supervision timer resumed its channel's lane
1954
+ 96 times a day, replaying an ever-growing history to contribute a few
1955
+ thousand tokens of new content — the replay reached ~1M tokens per
1956
+ wake — and left that history behind for the humans in the room to
1957
+ inherit on their next message.
1958
+ """
1959
+ mid = payload.get("message_id")
1960
+ return isinstance(mid, str) and mid.startswith("remind:")
1961
+
1923
1962
  def _readonly_scratch_dir(
1924
1963
  self, ws: AgentWorkspaceLike, payload: dict
1925
1964
  ) -> "Path":
@@ -2098,53 +2137,79 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2098
2137
  )
2099
2138
 
2100
2139
  # ------------------------------------------------------------------
2101
- # #95 — qoder runtime-session lanes, one per conversation.
2140
+ # #95 — runtime-session lanes, one per conversation.
2102
2141
  #
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}.
2142
+ # A single global last_session shared by DM/#debug/#SSS made every room
2143
+ # bleed into every other one: the agent resumed the same CLI session no
2144
+ # matter which channel woke it, so #89's per-channel prompt leaked
2145
+ # across rooms and each wake replayed the union of all rooms' history
2146
+ # (context noise + token burn). Each conversation therefore gets its own
2147
+ # lane: ``.meta/sessions.json`` = {conversation_id: session_id}.
2108
2148
  # Non-platform wakes (no conversation_id) keep the legacy global lane.
2149
+ #
2150
+ # Lanes started out qoder-only (qodercli ``--resume`` additionally
2151
+ # refuses to adopt a new ``--append-system-prompt``), but the bleed is
2152
+ # engine-independent — claude_code and codex resumed one global session
2153
+ # too. All engines now share this path; the superseded qoder file is
2154
+ # still read so an upgraded host keeps its existing lanes.
2109
2155
  # ------------------------------------------------------------------
2110
2156
 
2157
+ #: Superseded lane file — read-only, for hosts upgraded from the
2158
+ #: qoder-only era. Writes always land in ``sessions.json``.
2159
+ LEGACY_QODER_LANES_FILENAME = "qoder-sessions.json"
2160
+
2111
2161
  @staticmethod
2112
- def _qoder_lanes_path(ws: AgentWorkspaceLike) -> "Path":
2113
- return ws.path / ".meta" / "qoder-sessions.json"
2162
+ def _lanes_path(ws: AgentWorkspaceLike) -> "Path":
2163
+ return ws.path / ".meta" / "sessions.json"
2164
+
2165
+ @classmethod
2166
+ def _read_lanes(cls, ws: AgentWorkspaceLike) -> dict:
2167
+ """Current lane file layered over the superseded qoder one."""
2168
+ merged: dict = {}
2169
+ for path in (
2170
+ ws.path / ".meta" / cls.LEGACY_QODER_LANES_FILENAME,
2171
+ cls._lanes_path(ws),
2172
+ ):
2173
+ try:
2174
+ data = json.loads(path.read_text(encoding="utf-8"))
2175
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
2176
+ continue
2177
+ if isinstance(data, dict):
2178
+ merged.update(data)
2179
+ return merged
2114
2180
 
2115
- def _qoder_lane_session(
2181
+ def _lane_session(
2116
2182
  self, ws: AgentWorkspaceLike, conversation_id: str | None
2117
2183
  ) -> str | None:
2118
2184
  if not conversation_id:
2119
2185
  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
2186
+ # Deliberately NO fallback to the global last_session: the first
2187
+ # wake in a conversation must start fresh, or it would inherit
2188
+ # whatever room spoke last — the exact bleed lanes exist to remove.
2189
+ sid = self._read_lanes(ws).get(conversation_id)
2125
2190
  return sid if isinstance(sid, str) and sid else None
2126
2191
 
2127
- def _qoder_set_lane(
2192
+ def _set_lane(
2128
2193
  self, ws: AgentWorkspaceLike, conversation_id: str, session_id: str | None
2129
2194
  ) -> None:
2130
2195
  """Set (or clear with None) the lane. Best-effort; never raises."""
2131
- p = self._qoder_lanes_path(ws)
2196
+ p = self._lanes_path(ws)
2197
+ legacy = ws.path / ".meta" / self.LEGACY_QODER_LANES_FILENAME
2132
2198
  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 = {}
2199
+ data = self._read_lanes(ws)
2139
2200
  if session_id:
2140
2201
  data[conversation_id] = session_id
2141
2202
  else:
2142
2203
  data.pop(conversation_id, None)
2143
2204
  p.parent.mkdir(parents=True, exist_ok=True)
2144
2205
  p.write_text(json.dumps(data, indent=2), encoding="utf-8")
2206
+ # The merge above already carried every legacy entry across, so
2207
+ # drop the old file — otherwise a *cleared* lane would come back
2208
+ # to life on the next read from the stale value left behind.
2209
+ legacy.unlink(missing_ok=True)
2145
2210
  except OSError:
2146
2211
  logger.warning(
2147
- "claude_runner: qoder lane write failed conv=%s", conversation_id,
2212
+ "claude_runner: lane write failed conv=%s", conversation_id,
2148
2213
  exc_info=True,
2149
2214
  )
2150
2215
 
@@ -2193,6 +2258,7 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2193
2258
  run_mode: str = "full",
2194
2259
  scratch_dir: "Path | None" = None,
2195
2260
  conversation_id: str | None = None,
2261
+ periodic: bool = False,
2196
2262
  ) -> list[str]:
2197
2263
  """Build the ``claude`` argv list for one wake.
2198
2264
 
@@ -2214,8 +2280,12 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2214
2280
  故此模式下 ``last_session`` 恒为 None。
2215
2281
  """
2216
2282
  read_analyze = run_mode == "read_analyze"
2283
+ # #95 — 每个 conversation 一条轨(无 conversation_id 的非平台唤醒落回
2284
+ # 全局轨)。原先这里直接读 ws.last_session_id(),于是同一个 agent 无论
2285
+ # 在哪个频道被唤醒都 --resume 同一条会话。
2217
2286
  last_session = (
2218
- ws.last_session_id() if (with_resume and not read_analyze) else None
2287
+ self._lane_session(ws, conversation_id)
2288
+ if (with_resume and not read_analyze and not periodic) else None
2219
2289
  )
2220
2290
  if runtime_engine == "codex":
2221
2291
  binary = self.runtime_binaries.get("codex") or "codex"
@@ -2290,14 +2360,10 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2290
2360
  # 输出走既有 stream_json 消费器(联调时如发现事件形状偏差再适配)。
2291
2361
  # 认证:QODER_PERSONAL_ACCESS_TOKEN 环境变量或 qodercli login。
2292
2362
  binary = self.runtime_binaries.get("qoder") or "qodercli"
2293
- # #95 — 会话按 conversation_id 分轨(DM/各频道各自续聊),不再共用
2294
- # 全局 last_session;无 conversation_id 的非平台唤醒沿用全局轨。
2363
+ # #95 — 分轨已上提到 last_session(所有引擎共用),这里直接复用。
2295
2364
  # #107 — read_analyze 与 claude/codex 一样绝不 resume owner 轨,
2296
2365
  # 防外部指挥上下文渗进后续 full 会话。
2297
- lane_session = (
2298
- self._qoder_lane_session(ws, conversation_id)
2299
- if with_resume and not read_analyze else None
2300
- )
2366
+ lane_session = last_session
2301
2367
  cmd = [binary]
2302
2368
  # r6 同款:模型 + 推理强度由 chairman 在编辑页选(qoder 档位
2303
2369
  # low|medium|high|xhigh|max,与 claude_code 同集)。
@@ -2354,6 +2420,16 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2354
2420
  cmd += ["--model", model]
2355
2421
  if reasoning_effort:
2356
2422
  cmd += ["--effort", reasoning_effort]
2423
+ # #114 followup — cap the context BEFORE it overflows.
2424
+ #
2425
+ # ``_is_context_exhausted`` is the cure and it is expensive: the wake
2426
+ # has already failed, and the remedy throws the whole session away.
2427
+ # ``--autocompact`` is the prevention — the CLI folds older turns into
2428
+ # a summary at the threshold, so a long-lived lane keeps its thread
2429
+ # instead of being discarded whole. It also bounds what every wake
2430
+ # REPLAYS, which is the larger bill: a lane is re-read in full on each
2431
+ # wake, so an unbounded one charges its entire history every time.
2432
+ cmd += ["--autocompact", self.AUTOCOMPACT_WINDOW]
2357
2433
  if last_session:
2358
2434
  cmd += ["--resume", last_session]
2359
2435
  # 1i Bug F — inject operational contract via --append-system-prompt.
@@ -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
  # ---------------------------------------------------------------------------
@@ -3728,3 +3846,113 @@ async def test_no_conversation_id_means_no_notice_but_never_a_crash_of_the_wake(
3728
3846
  if inv.get("tool_name") == "platform_send"
3729
3847
  ]
3730
3848
  assert not sends
3849
+
3850
+
3851
+ # ---------------------------------------------------------------------------
3852
+ # #114 followup — bound what a wake replays (A: --autocompact), and keep
3853
+ # timers off the room's thread (B: a periodic wake is a throwaway session).
3854
+ #
3855
+ # Both target the same bill from opposite ends. A lane is re-read IN FULL on
3856
+ # every wake, so an unbounded one charges its whole history each time; a
3857
+ # 15-minute timer made that 96 times a day while contributing a few thousand
3858
+ # tokens of new content, and left the accumulated patrol history behind for
3859
+ # whoever spoke in the room next.
3860
+ # ---------------------------------------------------------------------------
3861
+
3862
+
3863
+ def _reminder_payload(reminder_id: str = "rem_1", occurrence: int = 0, **extra) -> dict:
3864
+ """A timer wake as the backend stamps it: ``remind:<reminder_id>:<n>``."""
3865
+ return {
3866
+ **_make_payload(message_id=f"remind:{reminder_id}:{occurrence}"),
3867
+ **extra,
3868
+ }
3869
+
3870
+
3871
+ async def test_every_claude_wake_caps_its_context_window(
3872
+ stateful_fake_claude, tmp_path
3873
+ ):
3874
+ """A — the cap is on every wake, not only the ones that already grew."""
3875
+ mcp_cfg = tmp_path / "mcp.json"; mcp_cfg.write_text("{}")
3876
+ ws = FakeWorkspace(FakeAgentWorkspace(mcp_path=mcp_cfg, last_session=None))
3877
+ runner = _build_runner(
3878
+ stateful_fake_claude([_claude_ok("s1")]), ws, FakeOutbound()
3879
+ )
3880
+
3881
+ await runner.run_one("agent-A", _make_payload())
3882
+
3883
+ argv = _read_argv_n(tmp_path, 0)
3884
+ assert argv[argv.index("--autocompact") + 1] == ClaudeRunner.AUTOCOMPACT_WINDOW
3885
+
3886
+
3887
+ async def test_periodic_wake_does_not_resume_the_rooms_lane(
3888
+ stateful_fake_claude, tmp_path
3889
+ ):
3890
+ """B — a patrol reads the room's CURRENT state; it must not replay its past."""
3891
+ mcp_cfg = tmp_path / "mcp.json"; mcp_cfg.write_text("{}")
3892
+ (tmp_path / ".meta").mkdir(exist_ok=True)
3893
+ (tmp_path / ".meta" / "sessions.json").write_text(
3894
+ json.dumps({"conv_room": "human_thread"})
3895
+ )
3896
+ ws = FakeWorkspace(FakeAgentWorkspace(mcp_path=mcp_cfg, last_session=None))
3897
+ runner = _build_runner(
3898
+ stateful_fake_claude([_claude_ok("patrol_thread")]), ws, FakeOutbound()
3899
+ )
3900
+
3901
+ await runner.run_one("agent-A", _reminder_payload(conversation_id="conv_room"))
3902
+
3903
+ assert "--resume" not in _read_argv_n(tmp_path, 0)
3904
+
3905
+
3906
+ async def test_periodic_wake_leaves_the_rooms_lane_untouched(
3907
+ stateful_fake_claude, tmp_path
3908
+ ):
3909
+ """B — and it writes nothing back: neither the room's lane nor the global.
3910
+
3911
+ This is the regression itself. A timer that records its throwaway session
3912
+ hands its patrol history to the next human who speaks in that room.
3913
+ """
3914
+ mcp_cfg = tmp_path / "mcp.json"; mcp_cfg.write_text("{}")
3915
+ (tmp_path / ".meta").mkdir(exist_ok=True)
3916
+ (tmp_path / ".meta" / "sessions.json").write_text(
3917
+ json.dumps({"conv_room": "human_thread"})
3918
+ )
3919
+ agent_ws = FakeAgentWorkspace(mcp_path=mcp_cfg, last_session=None)
3920
+ runner = _build_runner(
3921
+ stateful_fake_claude([_claude_ok("patrol_thread")]),
3922
+ FakeWorkspace(agent_ws),
3923
+ FakeOutbound(),
3924
+ )
3925
+
3926
+ await runner.run_one("agent-A", _reminder_payload(conversation_id="conv_room"))
3927
+
3928
+ lanes = json.loads((tmp_path / ".meta" / "sessions.json").read_text())
3929
+ assert lanes == {"conv_room": "human_thread"} # patrol_thread did NOT land
3930
+ assert agent_ws.set_calls == [] # nor did the global lane move
3931
+
3932
+
3933
+ async def test_ordinary_wake_in_that_room_still_resumes_and_records(
3934
+ stateful_fake_claude, tmp_path
3935
+ ):
3936
+ """B control — only TIMER wakes are throwaway. A human message in the same
3937
+ room still resumes that room's lane and still records its session."""
3938
+ mcp_cfg = tmp_path / "mcp.json"; mcp_cfg.write_text("{}")
3939
+ (tmp_path / ".meta").mkdir(exist_ok=True)
3940
+ (tmp_path / ".meta" / "sessions.json").write_text(
3941
+ json.dumps({"conv_room": "human_thread"})
3942
+ )
3943
+ agent_ws = FakeAgentWorkspace(mcp_path=mcp_cfg, last_session=None)
3944
+ runner = _build_runner(
3945
+ stateful_fake_claude([_claude_ok("human_thread_2")]),
3946
+ FakeWorkspace(agent_ws),
3947
+ FakeOutbound(),
3948
+ )
3949
+
3950
+ await runner.run_one(
3951
+ "agent-A", {**_make_payload(), "conversation_id": "conv_room"}
3952
+ )
3953
+
3954
+ argv = _read_argv_n(tmp_path, 0)
3955
+ assert argv[argv.index("--resume") + 1] == "human_thread"
3956
+ lanes = json.loads((tmp_path / ".meta" / "sessions.json").read_text())
3957
+ assert lanes == {"conv_room": "human_thread_2"}
3958
+ assert agent_ws.set_calls == ["human_thread_2"]
@@ -122,7 +122,7 @@ wheels = [
122
122
 
123
123
  [[package]]
124
124
  name = "cloudagent-daemon"
125
- version = "0.5.4"
125
+ version = "0.5.6"
126
126
  source = { editable = "." }
127
127
  dependencies = [
128
128
  { name = "h2" },
@@ -1 +0,0 @@
1
- __version__ = "0.5.4"