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.
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/CHANGELOG.md +49 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/PKG-INFO +1 -1
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/pyproject.toml +1 -1
- cloudagent_daemon-0.5.6/src/cloudagent_daemon/__init__.py +1 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/claude_runner.py +128 -52
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_claude_runner.py +231 -3
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/uv.lock +1 -1
- cloudagent_daemon-0.5.4/src/cloudagent_daemon/__init__.py +0 -1
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/.gitignore +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/LICENSE +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/README.md +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/scripts/release.sh +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/__main__.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/agent_download.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/agent_upload.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/agent_upload_denylist.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/auth_preflight.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/cli.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/config.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/context_fetch.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/delegation_report.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/doctor.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/frame_dispatch.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/http_outbound.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/injection_state.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/jsonl_logging.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/login.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/main_loop.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/mcp_bridge.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/metrics.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/metrics_server.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/outbound_queue.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/paths.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/protocol.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/qoder_credentials.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/readonly_bash_guard.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/runtime_config.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/runtime_detect.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/service.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/spawn_outbox.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/spawn_queue.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/sse_client.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/transport.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/unix_socket.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/workspace.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/ws_client.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/__init__.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/_fake_sse_server.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/conftest.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_e2e_deliver_to_spawn.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_e2e_sse.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_full_e2e.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_mcp_bridge_round_trip.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_one_shot_cli.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_spawn_mcp_bridge_e2e.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_sse_handshake.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_sse_watchdog.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_ws_handshake.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_ws_reconnect.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/conftest.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_agent_download.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_agent_upload.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_auth_preflight.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_claude_runner_codex_contract.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_claude_runner_routing_contract.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_claude_runner_w3.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_cli_multi_profile.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_cli_smoke.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_config.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_context_fetch.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_delegation_report.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_doctor.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_frame_dispatch.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_http_outbound.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_injection_state.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_jsonl_logging.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_login.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_main_loop_assign.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_main_loop_preflight.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_mcp_bridge_agent_retrieval.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_mcp_bridge_send_message.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_metrics.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_metrics_server.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_multi_tool_wake.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_outbound_queue.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_packaging_mcp_pin.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_paths.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_protocol.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_qoder_credentials.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_readonly_bash_guard.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_runtime_detect.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_service.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_service_multi_profile.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_spawn_outbox.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_spawn_queue.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_sse_active_watchdog.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_start_guard.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_status_stop.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_workspace.py +0 -0
- {cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_workspace_root_task113.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
|
717
|
+
and stale_lane_sid is not None
|
|
705
718
|
):
|
|
706
|
-
stale_sid =
|
|
707
|
-
# #95 —
|
|
708
|
-
if
|
|
709
|
-
self.
|
|
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
|
-
# #
|
|
786
|
-
#
|
|
787
|
-
#
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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 —
|
|
2140
|
+
# #95 — runtime-session lanes, one per conversation.
|
|
2102
2141
|
#
|
|
2103
|
-
#
|
|
2104
|
-
#
|
|
2105
|
-
#
|
|
2106
|
-
#
|
|
2107
|
-
#
|
|
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
|
|
2113
|
-
return ws.path / ".meta" / "
|
|
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
|
|
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
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
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
|
|
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.
|
|
2196
|
+
p = self._lanes_path(ws)
|
|
2197
|
+
legacy = ws.path / ".meta" / self.LEGACY_QODER_LANES_FILENAME
|
|
2132
2198
|
try:
|
|
2133
|
-
|
|
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:
|
|
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
|
-
|
|
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 —
|
|
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" / "
|
|
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" / "
|
|
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"]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.5.4"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/agent_upload_denylist.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/delegation_report.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/injection_state.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/qoder_credentials.py
RENAMED
|
File without changes
|
{cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/src/cloudagent_daemon/readonly_bash_guard.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_e2e_deliver_to_spawn.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_mcp_bridge_round_trip.py
RENAMED
|
File without changes
|
|
File without changes
|
{cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/integration/test_spawn_mcp_bridge_e2e.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_claude_runner_codex_contract.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_mcp_bridge_agent_retrieval.py
RENAMED
|
File without changes
|
{cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_mcp_bridge_send_message.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_outbox_stuck_watchdog.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_service_multi_profile.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudagent_daemon-0.5.4 → cloudagent_daemon-0.5.6}/tests/unit/test_workspace_root_task113.py
RENAMED
|
File without changes
|
|
File without changes
|