cloudagent-daemon 0.5.5__tar.gz → 0.5.7__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.5 → cloudagent_daemon-0.5.7}/CHANGELOG.md +46 -0
  2. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/PKG-INFO +1 -1
  3. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/pyproject.toml +1 -1
  4. cloudagent_daemon-0.5.7/src/cloudagent_daemon/__init__.py +1 -0
  5. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/claude_runner.py +60 -3
  6. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_claude_runner.py +137 -0
  7. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/uv.lock +1 -1
  8. cloudagent_daemon-0.5.5/src/cloudagent_daemon/__init__.py +0 -1
  9. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/.gitignore +0 -0
  10. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/LICENSE +0 -0
  11. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/README.md +0 -0
  12. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/scripts/release.sh +0 -0
  13. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/__main__.py +0 -0
  14. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/agent_download.py +0 -0
  15. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/agent_upload.py +0 -0
  16. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/agent_upload_denylist.py +0 -0
  17. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/auth_preflight.py +0 -0
  18. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/cli.py +0 -0
  19. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/config.py +0 -0
  20. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/context_fetch.py +0 -0
  21. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/delegation_report.py +0 -0
  22. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/doctor.py +0 -0
  23. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/frame_dispatch.py +0 -0
  24. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/http_outbound.py +0 -0
  25. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/injection_state.py +0 -0
  26. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/jsonl_logging.py +0 -0
  27. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/login.py +0 -0
  28. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/main_loop.py +0 -0
  29. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/mcp_bridge.py +0 -0
  30. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/metrics.py +0 -0
  31. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/metrics_server.py +0 -0
  32. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/outbound_queue.py +0 -0
  33. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/paths.py +0 -0
  34. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/protocol.py +0 -0
  35. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/qoder_credentials.py +0 -0
  36. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/readonly_bash_guard.py +0 -0
  37. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/runtime_config.py +0 -0
  38. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/runtime_detect.py +0 -0
  39. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/service.py +0 -0
  40. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/spawn_outbox.py +0 -0
  41. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/spawn_queue.py +0 -0
  42. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/sse_client.py +0 -0
  43. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/transport.py +0 -0
  44. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/unix_socket.py +0 -0
  45. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/workspace.py +0 -0
  46. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/src/cloudagent_daemon/ws_client.py +0 -0
  47. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/integration/__init__.py +0 -0
  48. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/integration/_fake_sse_server.py +0 -0
  49. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/integration/conftest.py +0 -0
  50. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/integration/test_e2e_deliver_to_spawn.py +0 -0
  51. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/integration/test_e2e_sse.py +0 -0
  52. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/integration/test_full_e2e.py +0 -0
  53. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/integration/test_mcp_bridge_round_trip.py +0 -0
  54. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/integration/test_one_shot_cli.py +0 -0
  55. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/integration/test_spawn_mcp_bridge_e2e.py +0 -0
  56. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/integration/test_sse_handshake.py +0 -0
  57. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/integration/test_sse_watchdog.py +0 -0
  58. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/integration/test_ws_handshake.py +0 -0
  59. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/integration/test_ws_reconnect.py +0 -0
  60. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/conftest.py +0 -0
  61. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_agent_download.py +0 -0
  62. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_agent_upload.py +0 -0
  63. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_auth_preflight.py +0 -0
  64. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_claude_runner_codex_contract.py +0 -0
  65. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_claude_runner_routing_contract.py +0 -0
  66. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_claude_runner_w3.py +0 -0
  67. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_cli_multi_profile.py +0 -0
  68. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_cli_smoke.py +0 -0
  69. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_config.py +0 -0
  70. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_context_fetch.py +0 -0
  71. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_delegation_report.py +0 -0
  72. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_doctor.py +0 -0
  73. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_frame_dispatch.py +0 -0
  74. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_http_outbound.py +0 -0
  75. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_injection_state.py +0 -0
  76. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_jsonl_logging.py +0 -0
  77. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_login.py +0 -0
  78. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_main_loop_assign.py +0 -0
  79. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_main_loop_preflight.py +0 -0
  80. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_mcp_bridge_agent_retrieval.py +0 -0
  81. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_mcp_bridge_send_message.py +0 -0
  82. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_metrics.py +0 -0
  83. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_metrics_server.py +0 -0
  84. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_multi_tool_wake.py +0 -0
  85. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_outbound_queue.py +0 -0
  86. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
  87. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_packaging_mcp_pin.py +0 -0
  88. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_paths.py +0 -0
  89. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_protocol.py +0 -0
  90. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_qoder_credentials.py +0 -0
  91. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_readonly_bash_guard.py +0 -0
  92. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_runtime_detect.py +0 -0
  93. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_service.py +0 -0
  94. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_service_multi_profile.py +0 -0
  95. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_spawn_outbox.py +0 -0
  96. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_spawn_queue.py +0 -0
  97. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_sse_active_watchdog.py +0 -0
  98. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_start_guard.py +0 -0
  99. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_status_stop.py +0 -0
  100. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_workspace.py +0 -0
  101. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_workspace_root_task113.py +0 -0
  102. {cloudagent_daemon-0.5.5 → cloudagent_daemon-0.5.7}/tests/unit/test_ws_client_insecure.py +0 -0
@@ -4,6 +4,52 @@ 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.7] — 2026-08-30
8
+
9
+ ### Fixed
10
+ - A periodic (timer) wake no longer publishes its throwaway session as the
11
+ global lane. 0.5.6 closed `set_last_session_id` but not `write_last_spawn`,
12
+ which rewrites `last-spawn.json` wholesale — and `last_session_id()` reads
13
+ that file's `session_id` back as the global lane. A patrol therefore still
14
+ left its thread there, and the next wake without a conversation resumed it:
15
+ the same bleed 0.5.6 set out to remove, one door further along. Caught on a
16
+ live host, not in review — after upgrading, the tick correctly dropped
17
+ `--resume` and left the room's lane alone, but `last-spawn.json` still moved.
18
+
19
+ ### Notes
20
+ - `read_analyze` already carried the owner's value through this call for the
21
+ same reason; periodic wakes now share that path. The spawn record still
22
+ captures the tick's rc/stderr, so operators lose no diagnostics.
23
+ - Anyone who ran 0.5.6 should check `.meta/last-spawn.json`: if its
24
+ `session_id` points at a patrol session, setting it to `null` makes the next
25
+ conversation-less wake start clean.
26
+
27
+ ## [0.5.6] — 2026-08-30
28
+
29
+ ### Changed
30
+ - Every claude_code wake now passes `--autocompact 200k`. A lane is re-read in
31
+ full on every wake, so an unbounded one charges its whole history each time —
32
+ one agent's lane climbed to 1.07M cached tokens and was replaying all of it 96
33
+ times a day to add a few thousand tokens of new content. An explicit size is
34
+ used rather than `auto` because `auto` tracks the model's own window (1M on
35
+ opus), which is precisely the runaway being bounded. This prevents what
36
+ `_is_context_exhausted` (0.5.x, #114) only cures after a wake has already
37
+ failed and its session been discarded.
38
+ - A timer-fired wake (`message_id="remind:<id>:<n>"`) now runs in a throwaway
39
+ session: it does not resume a lane, does not record one, and its failure may
40
+ not clear one. A reminder tick is a stateless patrol — what it must decide
41
+ comes from the room's current state, which it reads live, not from what it
42
+ said on the previous tick.
43
+
44
+ ### Notes
45
+ - Recording a patrol's session as the room's lane was the same bleed as #95,
46
+ arriving by another door: the next human to speak in that room inherited the
47
+ timer's accumulated history. Ordinary wakes are unaffected — a human message
48
+ in that same room still resumes the room's lane and still records its session.
49
+ - Hosts on 0.5.5 will see supervision-style agents start each tick fresh. That
50
+ is the change, not a regression: such an agent should be reading the room, not
51
+ replaying itself.
52
+
7
53
  ## [0.5.5] — 2026-08-27
8
54
 
9
55
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cloudagent-daemon
3
- Version: 0.5.5
3
+ Version: 0.5.7
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.5"
7
+ version = "0.5.7"
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.7"
@@ -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 的补救动作完全
@@ -692,6 +702,10 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
692
702
  context_exhausted = self._is_context_exhausted(attempt)
693
703
  if (
694
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)
695
709
  and attempt.exit_code != 0
696
710
  and (
697
711
  any(
@@ -781,6 +795,10 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
781
795
  # 防外人注入的上下文渗进 owner 后续 full 会话。仍上报 session_start
782
796
  # 供实时展示,但不落 last-spawn(下次 full 唤醒仍 resume 干净的 owner 轨)。
783
797
  is_read_analyze = self._run_mode_for(payload) == "read_analyze"
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)
784
802
  # #95 — 所有引擎都按 conversation_id 分轨存会话;全局 last_session 仍写
785
803
  # (last-spawn.json 可观测性 + 非平台唤醒兜底),但平台唤醒的 resume
786
804
  # 只读 lane(见 _build_cmd),不会被别的频道的会话覆盖。
@@ -792,7 +810,7 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
792
810
  # 把 session_start 刷成噪音。必须在 _set_lane 之前读。
793
811
  prev_lane_session = self._lane_session(ws, conv_for_lane)
794
812
  if captured_session and captured_session != prev_lane_session:
795
- if not is_read_analyze:
813
+ if not is_read_analyze and not is_periodic:
796
814
  if conv_for_lane:
797
815
  self._set_lane(ws, conv_for_lane, captured_session)
798
816
  ws.set_last_session_id(captured_session)
@@ -977,8 +995,13 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
977
995
  # 的读取源(下次 resume 依据)。read_analyze 若把自己的 thread 写进去就会
978
996
  # 渗进 owner 的 full 会话,故这里保留 owner 轨原有 session_id(仍记录本次
979
997
  # 的 exit_code/stderr 供调试),read_analyze 的 thread 不落任何持久轨。
998
+ # A periodic wake is throwaway for the SAME reason: it resumed nothing,
999
+ # so recording its thread here would publish it as the global lane and
1000
+ # the next wake without a conversation would resume the patrol.
980
1001
  spawn_record_session = (
981
- ws.last_session_id() if is_read_analyze else captured_session
1002
+ ws.last_session_id()
1003
+ if (is_read_analyze or is_periodic)
1004
+ else captured_session
982
1005
  )
983
1006
  ws.write_last_spawn(rc, spawn_record_session, stderr_tail)
984
1007
 
@@ -1380,6 +1403,7 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1380
1403
  model, reasoning_effort = self._model_and_effort_for(ws, payload)
1381
1404
  runtime_env = self._runtime_env_for(ws, payload)
1382
1405
  run_mode = self._run_mode_for(payload)
1406
+ periodic = self._is_periodic_wake(payload)
1383
1407
  scratch_dir = (
1384
1408
  self._readonly_scratch_dir(ws, payload)
1385
1409
  if run_mode == "read_analyze"
@@ -1395,6 +1419,7 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1395
1419
  reasoning_effort=reasoning_effort,
1396
1420
  run_mode=run_mode,
1397
1421
  scratch_dir=scratch_dir,
1422
+ periodic=periodic,
1398
1423
  conversation_id=(
1399
1424
  str(payload["conversation_id"])
1400
1425
  if payload.get("conversation_id") else None
@@ -1918,6 +1943,27 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
1918
1943
  raw = payload.get("run_mode")
1919
1944
  return "read_analyze" if raw == "read_analyze" else "full"
1920
1945
 
1946
+ @staticmethod
1947
+ def _is_periodic_wake(payload: dict) -> bool:
1948
+ """True for a timer-fired wake — one occurrence of a reminder.
1949
+
1950
+ Backend stamps these with the synthetic message id
1951
+ ``remind:<reminder_id>:<occurrence>``.
1952
+
1953
+ A periodic wake is a *stateless patrol*: what it has to decide comes
1954
+ from the room's CURRENT state, which it reads live (platform_read),
1955
+ not from what it said on the previous tick. So it neither resumes a
1956
+ lane nor writes one back — it runs in its own throwaway session.
1957
+
1958
+ Before this, a 15-minute supervision timer resumed its channel's lane
1959
+ 96 times a day, replaying an ever-growing history to contribute a few
1960
+ thousand tokens of new content — the replay reached ~1M tokens per
1961
+ wake — and left that history behind for the humans in the room to
1962
+ inherit on their next message.
1963
+ """
1964
+ mid = payload.get("message_id")
1965
+ return isinstance(mid, str) and mid.startswith("remind:")
1966
+
1921
1967
  def _readonly_scratch_dir(
1922
1968
  self, ws: AgentWorkspaceLike, payload: dict
1923
1969
  ) -> "Path":
@@ -2217,6 +2263,7 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2217
2263
  run_mode: str = "full",
2218
2264
  scratch_dir: "Path | None" = None,
2219
2265
  conversation_id: str | None = None,
2266
+ periodic: bool = False,
2220
2267
  ) -> list[str]:
2221
2268
  """Build the ``claude`` argv list for one wake.
2222
2269
 
@@ -2243,7 +2290,7 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2243
2290
  # 在哪个频道被唤醒都 --resume 同一条会话。
2244
2291
  last_session = (
2245
2292
  self._lane_session(ws, conversation_id)
2246
- if (with_resume and not read_analyze) else None
2293
+ if (with_resume and not read_analyze and not periodic) else None
2247
2294
  )
2248
2295
  if runtime_engine == "codex":
2249
2296
  binary = self.runtime_binaries.get("codex") or "codex"
@@ -2378,6 +2425,16 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2378
2425
  cmd += ["--model", model]
2379
2426
  if reasoning_effort:
2380
2427
  cmd += ["--effort", reasoning_effort]
2428
+ # #114 followup — cap the context BEFORE it overflows.
2429
+ #
2430
+ # ``_is_context_exhausted`` is the cure and it is expensive: the wake
2431
+ # has already failed, and the remedy throws the whole session away.
2432
+ # ``--autocompact`` is the prevention — the CLI folds older turns into
2433
+ # a summary at the threshold, so a long-lived lane keeps its thread
2434
+ # instead of being discarded whole. It also bounds what every wake
2435
+ # REPLAYS, which is the larger bill: a lane is re-read in full on each
2436
+ # wake, so an unbounded one charges its entire history every time.
2437
+ cmd += ["--autocompact", self.AUTOCOMPACT_WINDOW]
2381
2438
  if last_session:
2382
2439
  cmd += ["--resume", last_session]
2383
2440
  # 1i Bug F — inject operational contract via --append-system-prompt.
@@ -3846,3 +3846,140 @@ async def test_no_conversation_id_means_no_notice_but_never_a_crash_of_the_wake(
3846
3846
  if inv.get("tool_name") == "platform_send"
3847
3847
  ]
3848
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_periodic_wake_does_not_publish_itself_as_the_global_lane(
3934
+ stateful_fake_claude, tmp_path
3935
+ ):
3936
+ """B — the global lane has TWO writers; a patrol may use neither.
3937
+
3938
+ ``set_last_session_id`` is the obvious one, but ``write_last_spawn``
3939
+ rewrites last-spawn.json wholesale — and ``last_session_id()`` reads its
3940
+ ``session_id`` back as the global lane. Blocking only the first still let
3941
+ a patrol publish its throwaway thread, so the next wake with no
3942
+ conversation resumed it. read_analyze already carried the owner's value
3943
+ through here for exactly this reason; a periodic wake now does too.
3944
+ """
3945
+ mcp_cfg = tmp_path / "mcp.json"; mcp_cfg.write_text("{}")
3946
+ agent_ws = FakeAgentWorkspace(mcp_path=mcp_cfg, last_session="owner_thread")
3947
+ runner = _build_runner(
3948
+ stateful_fake_claude([_claude_ok("patrol_thread")]),
3949
+ FakeWorkspace(agent_ws),
3950
+ FakeOutbound(),
3951
+ )
3952
+
3953
+ await runner.run_one("agent-A", _reminder_payload(conversation_id="conv_room"))
3954
+
3955
+ assert agent_ws.write_last_spawn_calls, "the spawn record is still written"
3956
+ recorded_session = agent_ws.write_last_spawn_calls[-1][1]
3957
+ assert recorded_session == "owner_thread" # NOT patrol_thread
3958
+
3959
+
3960
+ async def test_ordinary_wake_in_that_room_still_resumes_and_records(
3961
+ stateful_fake_claude, tmp_path
3962
+ ):
3963
+ """B control — only TIMER wakes are throwaway. A human message in the same
3964
+ room still resumes that room's lane and still records its session."""
3965
+ mcp_cfg = tmp_path / "mcp.json"; mcp_cfg.write_text("{}")
3966
+ (tmp_path / ".meta").mkdir(exist_ok=True)
3967
+ (tmp_path / ".meta" / "sessions.json").write_text(
3968
+ json.dumps({"conv_room": "human_thread"})
3969
+ )
3970
+ agent_ws = FakeAgentWorkspace(mcp_path=mcp_cfg, last_session=None)
3971
+ runner = _build_runner(
3972
+ stateful_fake_claude([_claude_ok("human_thread_2")]),
3973
+ FakeWorkspace(agent_ws),
3974
+ FakeOutbound(),
3975
+ )
3976
+
3977
+ await runner.run_one(
3978
+ "agent-A", {**_make_payload(), "conversation_id": "conv_room"}
3979
+ )
3980
+
3981
+ argv = _read_argv_n(tmp_path, 0)
3982
+ assert argv[argv.index("--resume") + 1] == "human_thread"
3983
+ lanes = json.loads((tmp_path / ".meta" / "sessions.json").read_text())
3984
+ assert lanes == {"conv_room": "human_thread_2"}
3985
+ 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.5"
125
+ version = "0.5.7"
126
126
  source = { editable = "." }
127
127
  dependencies = [
128
128
  { name = "h2" },
@@ -1 +0,0 @@
1
- __version__ = "0.5.5"