cloudagent-daemon 0.5.3__tar.gz → 0.5.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/CHANGELOG.md +52 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/PKG-INFO +1 -1
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/pyproject.toml +1 -1
- cloudagent_daemon-0.5.5/src/cloudagent_daemon/__init__.py +1 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/claude_runner.py +145 -60
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_claude_runner.py +264 -8
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/uv.lock +1 -1
- cloudagent_daemon-0.5.3/src/cloudagent_daemon/__init__.py +0 -1
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/.gitignore +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/LICENSE +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/README.md +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/scripts/release.sh +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/__main__.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/agent_download.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/agent_upload.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/agent_upload_denylist.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/auth_preflight.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/cli.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/config.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/context_fetch.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/delegation_report.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/doctor.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/frame_dispatch.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/http_outbound.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/injection_state.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/jsonl_logging.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/login.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/main_loop.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/mcp_bridge.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/metrics.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/metrics_server.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/outbound_queue.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/paths.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/protocol.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/qoder_credentials.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/readonly_bash_guard.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/runtime_config.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/runtime_detect.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/service.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/spawn_outbox.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/spawn_queue.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/sse_client.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/transport.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/unix_socket.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/workspace.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/ws_client.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/integration/__init__.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/integration/_fake_sse_server.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/integration/conftest.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/integration/test_e2e_deliver_to_spawn.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/integration/test_e2e_sse.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/integration/test_full_e2e.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/integration/test_mcp_bridge_round_trip.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/integration/test_one_shot_cli.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/integration/test_spawn_mcp_bridge_e2e.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/integration/test_sse_handshake.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/integration/test_sse_watchdog.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/integration/test_ws_handshake.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/integration/test_ws_reconnect.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/conftest.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_agent_download.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_agent_upload.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_auth_preflight.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_claude_runner_codex_contract.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_claude_runner_routing_contract.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_claude_runner_w3.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_cli_multi_profile.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_cli_smoke.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_config.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_context_fetch.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_delegation_report.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_doctor.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_frame_dispatch.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_http_outbound.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_injection_state.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_jsonl_logging.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_login.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_main_loop_assign.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_main_loop_preflight.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_mcp_bridge_agent_retrieval.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_mcp_bridge_send_message.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_metrics.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_metrics_server.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_multi_tool_wake.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_outbound_queue.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_packaging_mcp_pin.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_paths.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_protocol.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_qoder_credentials.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_readonly_bash_guard.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_runtime_detect.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_service.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_service_multi_profile.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_spawn_outbox.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_spawn_queue.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_sse_active_watchdog.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_start_guard.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_status_stop.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_workspace.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_workspace_root_task113.py +0 -0
- {cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/unit/test_ws_client_insecure.py +0 -0
|
@@ -4,6 +4,58 @@ All notable changes to the local daemon. Format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/); versions are
|
|
5
5
|
[PEP 440](https://peps.python.org/pep-0440/). Release with `scripts/release.sh`.
|
|
6
6
|
|
|
7
|
+
## [0.5.5] — 2026-08-27
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- Every engine now gets one runtime session per conversation, not one per agent.
|
|
11
|
+
An agent resumed the same CLI session no matter which channel woke it, so
|
|
12
|
+
#debug, the DM and #SSS all shared a single context: the per-channel prompt
|
|
13
|
+
leaked between rooms, and each wake replayed the union of every room's
|
|
14
|
+
history — muddled context and a token bill that grew without bound. Lanes
|
|
15
|
+
already fixed this for qoder; the bleed was never qoder-specific, so
|
|
16
|
+
claude_code and codex now resume from the same per-conversation lane, keyed
|
|
17
|
+
on the waking conversation (channel, DM and thread each carry their own).
|
|
18
|
+
Wakes with no conversation — timers, spawn chains — keep the global lane.
|
|
19
|
+
|
|
20
|
+
### Notes
|
|
21
|
+
- The lane file is now `.meta/sessions.json`. `qoder-sessions.json` is still
|
|
22
|
+
read so an upgraded host keeps its lanes; the first write folds it into the
|
|
23
|
+
new file and removes it, or a cleared lane would come back to life from the
|
|
24
|
+
stale value left behind.
|
|
25
|
+
- Agents carrying a global session into this version start fresh on their first
|
|
26
|
+
wake in each conversation. That is the fix rather than a side effect —
|
|
27
|
+
inheriting it is precisely the bleed being removed — but expect an agent to
|
|
28
|
+
look like it "forgot" an in-flight thread once, right after the upgrade.
|
|
29
|
+
|
|
30
|
+
## [0.5.4] — 2026-08-24
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
- A model-side error no longer counts toward the crash quota, and can no longer
|
|
34
|
+
quarantine an agent. Three consecutive `crashed` reports quarantine an agent
|
|
35
|
+
server-side, after which every wake is dropped and nobody is told; a model
|
|
36
|
+
refusing one turn is not the same as the agent being broken. This is how a
|
|
37
|
+
whole delivery line went dark twice — six hours on 2026-08-21, four on 08-23
|
|
38
|
+
(the latter from a content-safety flag raised while reviewing security
|
|
39
|
+
material). The wake now reports `idle`, which keeps the counter still.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
- A failed wake posts a notice into the originating channel carrying the
|
|
43
|
+
engine's own error text. Until now this path only wrote a log line and a
|
|
44
|
+
metric — "the silence must be VISIBLE" (U0.4) was true only for whoever reads
|
|
45
|
+
logs, and nobody does; the user found out by noticing an agent had stopped
|
|
46
|
+
answering, hours later. The notice is explicitly labelled as daemon-emitted so
|
|
47
|
+
it can never be mistaken for the agent's own answer, which is what the
|
|
48
|
+
no-fallback rule was protecting.
|
|
49
|
+
|
|
50
|
+
### Notes
|
|
51
|
+
- Status code and notification are now separate concerns: the code drives
|
|
52
|
+
counting, the notice does the telling. Model errors report `idle` AND still
|
|
53
|
+
trigger the a2a `wake_aborted` system reply — dropping the latter would leave
|
|
54
|
+
a dispatching chief waiting forever.
|
|
55
|
+
- Process-level failures (binary missing, auth file unreadable) still count.
|
|
56
|
+
Retrying those forever fixes nothing, so the protection is kept — but they now
|
|
57
|
+
post a channel notice too, so they are no longer silent either.
|
|
58
|
+
|
|
7
59
|
## [0.5.3] — 2026-08-22
|
|
8
60
|
|
|
9
61
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cloudagent-daemon
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.5
|
|
4
4
|
Summary: Local daemon for cloudagent — bridges Claude Code, Codex, and Qoder CLI agents to the cloudagent backend.
|
|
5
5
|
Project-URL: Homepage, https://cloudagent.chat
|
|
6
6
|
Project-URL: Repository, https://codeup.aliyun.com/69bd0a4d29ad98af4065ce71/cloudagent
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cloudagent-daemon"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.5"
|
|
8
8
|
description = "Local daemon for cloudagent — bridges Claude Code, Codex, and Qoder CLI agents to the cloudagent backend."
|
|
9
9
|
authors = [{name = "chaoyin", email = "tudoucyx@gmail.com"}]
|
|
10
10
|
readme = "README.md"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.5"
|
|
@@ -680,11 +680,10 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
680
680
|
stale_conv = (
|
|
681
681
|
str(payload["conversation_id"]) if payload.get("conversation_id") else None
|
|
682
682
|
)
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
)
|
|
683
|
+
# #95 — the lane IS the session this wake resumed from (the
|
|
684
|
+
# global last_session only when there is no conversation), so
|
|
685
|
+
# it is also the one to drop once it proves unusable.
|
|
686
|
+
stale_lane_sid = self._lane_session(ws, stale_conv)
|
|
688
687
|
# #114 — 上下文撑满与 stale-session 是同一类问题:**当前会话已经
|
|
689
688
|
# 不能再用了**,区别只在于一个是引擎不认识它、另一个是它太大。补救
|
|
690
689
|
# 动作完全相同(清掉会话、免 resume 重跑一次),所以走同一条路径,
|
|
@@ -701,12 +700,12 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
701
700
|
)
|
|
702
701
|
or context_exhausted
|
|
703
702
|
)
|
|
704
|
-
and
|
|
703
|
+
and stale_lane_sid is not None
|
|
705
704
|
):
|
|
706
|
-
stale_sid =
|
|
707
|
-
# #95 —
|
|
708
|
-
if
|
|
709
|
-
self.
|
|
705
|
+
stale_sid = stale_lane_sid
|
|
706
|
+
# #95 — 过期会话就在 lane 里,清 lane 才能真正脱离它
|
|
707
|
+
if stale_conv:
|
|
708
|
+
self._set_lane(ws, stale_conv, None)
|
|
710
709
|
logger.warning(
|
|
711
710
|
"claude_runner: dropping unusable session_id=%s "
|
|
712
711
|
"(cause=%s, detail=%r). Clearing and retrying "
|
|
@@ -782,21 +781,20 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
782
781
|
# 防外人注入的上下文渗进 owner 后续 full 会话。仍上报 session_start
|
|
783
782
|
# 供实时展示,但不落 last-spawn(下次 full 唤醒仍 resume 干净的 owner 轨)。
|
|
784
783
|
is_read_analyze = self._run_mode_for(payload) == "read_analyze"
|
|
785
|
-
# #95 —
|
|
786
|
-
# (last-spawn.json 可观测性 +
|
|
787
|
-
#
|
|
784
|
+
# #95 — 所有引擎都按 conversation_id 分轨存会话;全局 last_session 仍写
|
|
785
|
+
# (last-spawn.json 可观测性 + 非平台唤醒兜底),但平台唤醒的 resume
|
|
786
|
+
# 只读 lane(见 _build_cmd),不会被别的频道的会话覆盖。
|
|
788
787
|
conv_for_lane = (
|
|
789
788
|
str(payload["conversation_id"]) if payload.get("conversation_id") else None
|
|
790
789
|
)
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
):
|
|
797
|
-
self._qoder_set_lane(ws, conv_for_lane, captured_session)
|
|
798
|
-
if captured_session and captured_session != ws.last_session_id():
|
|
790
|
+
# 比较基准取**本轨**上一个会话,不是全局 last_session:分轨后全局会被
|
|
791
|
+
# 最后说话的频道盖掉,拿它比会让 A/B 频道交替唤醒每轮都像是新会话,
|
|
792
|
+
# 把 session_start 刷成噪音。必须在 _set_lane 之前读。
|
|
793
|
+
prev_lane_session = self._lane_session(ws, conv_for_lane)
|
|
794
|
+
if captured_session and captured_session != prev_lane_session:
|
|
799
795
|
if not is_read_analyze:
|
|
796
|
+
if conv_for_lane:
|
|
797
|
+
self._set_lane(ws, conv_for_lane, captured_session)
|
|
800
798
|
ws.set_last_session_id(captured_session)
|
|
801
799
|
await self.outbound.send_agent_session_start(
|
|
802
800
|
agent_id, captured_session
|
|
@@ -984,21 +982,79 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
984
982
|
)
|
|
985
983
|
ws.write_last_spawn(rc, spawn_record_session, stderr_tail)
|
|
986
984
|
|
|
985
|
+
# 主人拍板(2026-08-24):**大模型端的报错一律直接回报给人,不再计入崩溃
|
|
986
|
+
# 配额、不再据此隔离 agent。** 模型拒绝一次不等于这个 agent 坏了;把它锁死
|
|
987
|
+
# 的代价是整条交付线静默停摆——8-21 六小时、8-23 四小时,两次都是这么来的
|
|
988
|
+
# (连续 3 次模型侧失败 → quarantined → 此后所有唤醒静默丢弃,没人被告知)。
|
|
989
|
+
#
|
|
990
|
+
# 报 idle 而非 crashed:idle 不进 record_agent_crash 的计数,且会顺带解除
|
|
991
|
+
# 历史遗留的隔离(record_agent_recovery)。失败本身**不靠这个状态码传达**,
|
|
992
|
+
# 由下面的频道通知负责——状态码只有运维看得到,人看的是频道。
|
|
993
|
+
model_side_error = attempt.had_api_error
|
|
987
994
|
if timeout_hit:
|
|
988
995
|
final_status = "timeout"
|
|
989
|
-
elif
|
|
990
|
-
|
|
991
|
-
# errors: it prints a synthetic ``isApiErrorMessage:true``
|
|
992
|
-
# event and exits rc=0 *without* a terminating ``type=
|
|
993
|
-
# result``. Without this branch the wake would be reported
|
|
994
|
-
# as ``idle`` (cleanly completed) — leaving the dispatching
|
|
995
|
-
# chief silently waiting for a reply that will never come.
|
|
996
|
-
final_status = "crashed"
|
|
996
|
+
elif model_side_error:
|
|
997
|
+
final_status = "idle"
|
|
997
998
|
elif rc == 0:
|
|
998
999
|
final_status = "idle"
|
|
999
1000
|
else:
|
|
1000
1001
|
final_status = "crashed"
|
|
1001
1002
|
|
|
1003
|
+
# 主人拍板(2026-08-24):**模型侧报错必须直接回到人眼前。**
|
|
1004
|
+
#
|
|
1005
|
+
# 此前这里什么都不发:U0.4 定的口径是"频道唤醒不做 fallback,沉默必须可见
|
|
1006
|
+
# (log + metric)"。出发点对——别拿 daemon 的兜底伪造 agent 的回答——但
|
|
1007
|
+
# "可见"只可见于日志和指标,**没有人在看**。实际后果:agent 崩了,频道里
|
|
1008
|
+
# 一个字都没有,发指令的人只能靠"叫它没反应"发现,中间隔了 4 个小时。
|
|
1009
|
+
#
|
|
1010
|
+
# 所以这里补的**不是 agent 的回答,是一条明确署名的系统通知**:诚实口径不破
|
|
1011
|
+
# (不冒充 agent),可见性拿回来。已有的 platform_send 兜底只在 rc==0 且有
|
|
1012
|
+
# 输出时触发("说了话但没调工具"),失败路径正是它漏掉的那半边。
|
|
1013
|
+
failure_note: str | None = None
|
|
1014
|
+
if model_side_error:
|
|
1015
|
+
failure_note = (attempt.api_error_text or "").strip() or "模型侧返回错误(无详细信息)"
|
|
1016
|
+
elif timeout_hit:
|
|
1017
|
+
failure_note = f"本轮超时({int(self._single_wake_timeout)} 秒内未结束)"
|
|
1018
|
+
elif rc != 0:
|
|
1019
|
+
failure_note = (attempt.stderr_tail or "").strip()[-400:] or f"进程非零退出(rc={rc})"
|
|
1020
|
+
notice_conv = payload.get("conversation_id")
|
|
1021
|
+
notice_session = payload.get("session_id")
|
|
1022
|
+
if (
|
|
1023
|
+
failure_note
|
|
1024
|
+
and isinstance(notice_conv, str) and notice_conv
|
|
1025
|
+
and isinstance(notice_session, str) and notice_session
|
|
1026
|
+
):
|
|
1027
|
+
kind_label = "模型侧报错" if model_side_error else ("超时" if timeout_hit else "进程异常")
|
|
1028
|
+
body = (
|
|
1029
|
+
f"⚠️ 我这一轮没能完成:**{kind_label}**\n\n"
|
|
1030
|
+
f"```\n{failure_note[:600]}\n```\n"
|
|
1031
|
+
+ (
|
|
1032
|
+
"这是**模型端**返回的错误,不是这个 agent 坏了——它**没有被隔离**,"
|
|
1033
|
+
"下次被唤醒会照常重试。"
|
|
1034
|
+
if model_side_error
|
|
1035
|
+
else "这是运行时/进程层面的失败,重试未必能解决,可能需要看一下机器。"
|
|
1036
|
+
)
|
|
1037
|
+
+ "\n(本条由 daemon 代发的系统通知,不是 agent 的回答。)"
|
|
1038
|
+
)
|
|
1039
|
+
try:
|
|
1040
|
+
await self.outbound.send_tool_call(
|
|
1041
|
+
agent_id=agent_id,
|
|
1042
|
+
session_id=notice_session,
|
|
1043
|
+
tool_name="platform_send",
|
|
1044
|
+
args={
|
|
1045
|
+
"content": body,
|
|
1046
|
+
"conversation_id": notice_conv,
|
|
1047
|
+
"acknowledge_new_messages": True,
|
|
1048
|
+
**({"delivery_id": _effective_delivery_id} if _effective_delivery_id else {}),
|
|
1049
|
+
},
|
|
1050
|
+
timeout=15.0,
|
|
1051
|
+
)
|
|
1052
|
+
except Exception: # noqa: BLE001 — 通知失败绝不能再把这一轮拖垮
|
|
1053
|
+
logger.warning(
|
|
1054
|
+
"claude_runner: failed to post failure notice agent=%s conv=%s",
|
|
1055
|
+
agent_id, notice_conv, exc_info=True,
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1002
1058
|
# Wave C #3 — surface terminal status counts for /metrics +
|
|
1003
1059
|
# doctor. Label-keyed so a single counter renders three
|
|
1004
1060
|
# series (idle / crashed / timeout). Failure to publish must
|
|
@@ -1034,7 +1090,10 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
1034
1090
|
payload.get("parent_msg_id") or payload.get("message_id")
|
|
1035
1091
|
)
|
|
1036
1092
|
if (
|
|
1037
|
-
|
|
1093
|
+
# model_side_error 现在报 idle(不进崩溃配额),但**这条链仍要通知**:
|
|
1094
|
+
# a2a 的 chief 是在等一条回复,不给它就是永远等下去。状态码用于计数,
|
|
1095
|
+
# 这里用于告知——两件事,不能因为改了前者而丢掉后者。
|
|
1096
|
+
(final_status in ("crashed", "timeout") or model_side_error)
|
|
1038
1097
|
# This block only fires for task/peer_request/question kinds
|
|
1039
1098
|
# (WAKE_REPLY_EXPECTED_KINDS, checked below), for which
|
|
1040
1099
|
# fallback_tool is always send_message — so called_fallback_tool
|
|
@@ -2037,53 +2096,79 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
|
|
|
2037
2096
|
)
|
|
2038
2097
|
|
|
2039
2098
|
# ------------------------------------------------------------------
|
|
2040
|
-
# #95 —
|
|
2099
|
+
# #95 — runtime-session lanes, one per conversation.
|
|
2041
2100
|
#
|
|
2042
|
-
#
|
|
2043
|
-
#
|
|
2044
|
-
#
|
|
2045
|
-
#
|
|
2046
|
-
#
|
|
2101
|
+
# A single global last_session shared by DM/#debug/#SSS made every room
|
|
2102
|
+
# bleed into every other one: the agent resumed the same CLI session no
|
|
2103
|
+
# matter which channel woke it, so #89's per-channel prompt leaked
|
|
2104
|
+
# across rooms and each wake replayed the union of all rooms' history
|
|
2105
|
+
# (context noise + token burn). Each conversation therefore gets its own
|
|
2106
|
+
# lane: ``.meta/sessions.json`` = {conversation_id: session_id}.
|
|
2047
2107
|
# Non-platform wakes (no conversation_id) keep the legacy global lane.
|
|
2108
|
+
#
|
|
2109
|
+
# Lanes started out qoder-only (qodercli ``--resume`` additionally
|
|
2110
|
+
# refuses to adopt a new ``--append-system-prompt``), but the bleed is
|
|
2111
|
+
# engine-independent — claude_code and codex resumed one global session
|
|
2112
|
+
# too. All engines now share this path; the superseded qoder file is
|
|
2113
|
+
# still read so an upgraded host keeps its existing lanes.
|
|
2048
2114
|
# ------------------------------------------------------------------
|
|
2049
2115
|
|
|
2116
|
+
#: Superseded lane file — read-only, for hosts upgraded from the
|
|
2117
|
+
#: qoder-only era. Writes always land in ``sessions.json``.
|
|
2118
|
+
LEGACY_QODER_LANES_FILENAME = "qoder-sessions.json"
|
|
2119
|
+
|
|
2050
2120
|
@staticmethod
|
|
2051
|
-
def
|
|
2052
|
-
return ws.path / ".meta" / "
|
|
2121
|
+
def _lanes_path(ws: AgentWorkspaceLike) -> "Path":
|
|
2122
|
+
return ws.path / ".meta" / "sessions.json"
|
|
2123
|
+
|
|
2124
|
+
@classmethod
|
|
2125
|
+
def _read_lanes(cls, ws: AgentWorkspaceLike) -> dict:
|
|
2126
|
+
"""Current lane file layered over the superseded qoder one."""
|
|
2127
|
+
merged: dict = {}
|
|
2128
|
+
for path in (
|
|
2129
|
+
ws.path / ".meta" / cls.LEGACY_QODER_LANES_FILENAME,
|
|
2130
|
+
cls._lanes_path(ws),
|
|
2131
|
+
):
|
|
2132
|
+
try:
|
|
2133
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
2134
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
2135
|
+
continue
|
|
2136
|
+
if isinstance(data, dict):
|
|
2137
|
+
merged.update(data)
|
|
2138
|
+
return merged
|
|
2053
2139
|
|
|
2054
|
-
def
|
|
2140
|
+
def _lane_session(
|
|
2055
2141
|
self, ws: AgentWorkspaceLike, conversation_id: str | None
|
|
2056
2142
|
) -> str | None:
|
|
2057
2143
|
if not conversation_id:
|
|
2058
2144
|
return ws.last_session_id()
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
sid = data.get(conversation_id) if isinstance(data, dict) else None
|
|
2145
|
+
# Deliberately NO fallback to the global last_session: the first
|
|
2146
|
+
# wake in a conversation must start fresh, or it would inherit
|
|
2147
|
+
# whatever room spoke last — the exact bleed lanes exist to remove.
|
|
2148
|
+
sid = self._read_lanes(ws).get(conversation_id)
|
|
2064
2149
|
return sid if isinstance(sid, str) and sid else None
|
|
2065
2150
|
|
|
2066
|
-
def
|
|
2151
|
+
def _set_lane(
|
|
2067
2152
|
self, ws: AgentWorkspaceLike, conversation_id: str, session_id: str | None
|
|
2068
2153
|
) -> None:
|
|
2069
2154
|
"""Set (or clear with None) the lane. Best-effort; never raises."""
|
|
2070
|
-
p = self.
|
|
2155
|
+
p = self._lanes_path(ws)
|
|
2156
|
+
legacy = ws.path / ".meta" / self.LEGACY_QODER_LANES_FILENAME
|
|
2071
2157
|
try:
|
|
2072
|
-
|
|
2073
|
-
data = json.loads(p.read_text(encoding="utf-8"))
|
|
2074
|
-
if not isinstance(data, dict):
|
|
2075
|
-
data = {}
|
|
2076
|
-
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
2077
|
-
data = {}
|
|
2158
|
+
data = self._read_lanes(ws)
|
|
2078
2159
|
if session_id:
|
|
2079
2160
|
data[conversation_id] = session_id
|
|
2080
2161
|
else:
|
|
2081
2162
|
data.pop(conversation_id, None)
|
|
2082
2163
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
2083
2164
|
p.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
2165
|
+
# The merge above already carried every legacy entry across, so
|
|
2166
|
+
# drop the old file — otherwise a *cleared* lane would come back
|
|
2167
|
+
# to life on the next read from the stale value left behind.
|
|
2168
|
+
legacy.unlink(missing_ok=True)
|
|
2084
2169
|
except OSError:
|
|
2085
2170
|
logger.warning(
|
|
2086
|
-
"claude_runner:
|
|
2171
|
+
"claude_runner: lane write failed conv=%s", conversation_id,
|
|
2087
2172
|
exc_info=True,
|
|
2088
2173
|
)
|
|
2089
2174
|
|
|
@@ -2153,8 +2238,12 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
|
|
|
2153
2238
|
故此模式下 ``last_session`` 恒为 None。
|
|
2154
2239
|
"""
|
|
2155
2240
|
read_analyze = run_mode == "read_analyze"
|
|
2241
|
+
# #95 — 每个 conversation 一条轨(无 conversation_id 的非平台唤醒落回
|
|
2242
|
+
# 全局轨)。原先这里直接读 ws.last_session_id(),于是同一个 agent 无论
|
|
2243
|
+
# 在哪个频道被唤醒都 --resume 同一条会话。
|
|
2156
2244
|
last_session = (
|
|
2157
|
-
|
|
2245
|
+
self._lane_session(ws, conversation_id)
|
|
2246
|
+
if (with_resume and not read_analyze) else None
|
|
2158
2247
|
)
|
|
2159
2248
|
if runtime_engine == "codex":
|
|
2160
2249
|
binary = self.runtime_binaries.get("codex") or "codex"
|
|
@@ -2229,14 +2318,10 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
|
|
|
2229
2318
|
# 输出走既有 stream_json 消费器(联调时如发现事件形状偏差再适配)。
|
|
2230
2319
|
# 认证:QODER_PERSONAL_ACCESS_TOKEN 环境变量或 qodercli login。
|
|
2231
2320
|
binary = self.runtime_binaries.get("qoder") or "qodercli"
|
|
2232
|
-
# #95 —
|
|
2233
|
-
# 全局 last_session;无 conversation_id 的非平台唤醒沿用全局轨。
|
|
2321
|
+
# #95 — 分轨已上提到 last_session(所有引擎共用),这里直接复用。
|
|
2234
2322
|
# #107 — read_analyze 与 claude/codex 一样绝不 resume owner 轨,
|
|
2235
2323
|
# 防外部指挥上下文渗进后续 full 会话。
|
|
2236
|
-
lane_session =
|
|
2237
|
-
self._qoder_lane_session(ws, conversation_id)
|
|
2238
|
-
if with_resume and not read_analyze else None
|
|
2239
|
-
)
|
|
2324
|
+
lane_session = last_session
|
|
2240
2325
|
cmd = [binary]
|
|
2241
2326
|
# r6 同款:模型 + 推理强度由 chairman 在编辑页选(qoder 档位
|
|
2242
2327
|
# low|medium|high|xhigh|max,与 claude_code 同集)。
|
|
@@ -2461,11 +2461,22 @@ def _make_dispatch_payload(
|
|
|
2461
2461
|
}
|
|
2462
2462
|
|
|
2463
2463
|
|
|
2464
|
-
async def
|
|
2464
|
+
async def test_isApiErrorMessage_does_not_burn_the_crash_quota(
|
|
2465
2465
|
fake_claude, tmp_path
|
|
2466
2466
|
):
|
|
2467
|
-
"""
|
|
2468
|
-
|
|
2467
|
+
"""2026-08-24 主人拍板改写:模型侧报错**不再**记 crashed。
|
|
2468
|
+
|
|
2469
|
+
这条测试原本断言的是相反的行为(M8:``isApiErrorMessage`` + rc=0 → 报
|
|
2470
|
+
``crashed``)。改写而不是删除,因为它当初的关切依然成立、只是解法换了:
|
|
2471
|
+
|
|
2472
|
+
当初的关切 —— "别报 idle,否则派活的 chief 会以为干完了、永远等下去"。
|
|
2473
|
+
现在的解法 —— 状态码只负责**计数**,告知由 ``wake_aborted`` 的系统回复
|
|
2474
|
+
和频道通知负责(见 ``test_wake_aborted_emits_system_reply_when_api_error``
|
|
2475
|
+
与 ``test_model_side_error_is_reported_into_the_channel``)。两件事拆开了。
|
|
2476
|
+
|
|
2477
|
+
为什么必须拆:报 crashed 会累计进隔离配额,连续 3 次就把 agent 锁死,此后
|
|
2478
|
+
**所有**唤醒被静默丢弃、无人被告知。模型拒绝一次不等于这个 agent 坏了。
|
|
2479
|
+
2026-08-21 与 08-23 两次整条交付线停摆(6 小时 / 4 小时)都源于此。
|
|
2469
2480
|
"""
|
|
2470
2481
|
mcp_cfg = tmp_path / "mcp.json"
|
|
2471
2482
|
mcp_cfg.write_text("{}")
|
|
@@ -2493,8 +2504,8 @@ async def test_isApiErrorMessage_promotes_wake_to_crashed(
|
|
|
2493
2504
|
statuses = [c for c in outbound.calls if c[0] == "agent_status"]
|
|
2494
2505
|
final = [s for s in statuses if s[2] != "running"]
|
|
2495
2506
|
assert len(final) == 1
|
|
2496
|
-
assert final[0][2] == "
|
|
2497
|
-
f"
|
|
2507
|
+
assert final[0][2] == "idle", (
|
|
2508
|
+
f"模型侧报错记成了 {final[0][2]} —— 会累计进隔离配额并最终锁死 agent"
|
|
2498
2509
|
)
|
|
2499
2510
|
|
|
2500
2511
|
|
|
@@ -3282,14 +3293,18 @@ async def test_qoder_sessions_are_isolated_per_conversation(
|
|
|
3282
3293
|
a3 = _read_argv_n(tmp_path, 3)
|
|
3283
3294
|
assert a3[a3.index("--resume") + 1] == "sess_dm"
|
|
3284
3295
|
|
|
3285
|
-
lanes = json.loads((tmp_path / ".meta" / "
|
|
3296
|
+
lanes = json.loads((tmp_path / ".meta" / "sessions.json").read_text())
|
|
3286
3297
|
assert lanes == {"conv_debug": "sess_debug", "conv_dm": "sess_dm"}
|
|
3287
3298
|
|
|
3288
3299
|
|
|
3289
3300
|
async def test_qoder_stale_resume_clears_only_that_lane(
|
|
3290
3301
|
stateful_fake_claude, tmp_path
|
|
3291
3302
|
):
|
|
3292
|
-
"""lane 里的过期会话被 CLI 拒绝 → 清该 lane 并免 resume 重试;别的 lane 不动。
|
|
3303
|
+
"""lane 里的过期会话被 CLI 拒绝 → 清该 lane 并免 resume 重试;别的 lane 不动。
|
|
3304
|
+
|
|
3305
|
+
seed 故意写在**旧** ``qoder-sessions.json`` 上,于是这条同时验收升级路径:
|
|
3306
|
+
旧 lane 仍可 resume,首次写入把两条都搬进 ``sessions.json`` 并删掉旧文件。
|
|
3307
|
+
"""
|
|
3293
3308
|
mcp_cfg = tmp_path / "mcp.json"; mcp_cfg.write_text("{}")
|
|
3294
3309
|
ws = FakeWorkspace(FakeAgentWorkspace(mcp_path=mcp_cfg, last_session=None))
|
|
3295
3310
|
(tmp_path / ".meta").mkdir(parents=True, exist_ok=True)
|
|
@@ -3308,8 +3323,122 @@ async def test_qoder_stale_resume_clears_only_that_lane(
|
|
|
3308
3323
|
a0 = _read_argv_n(tmp_path, 0); a1 = _read_argv_n(tmp_path, 1)
|
|
3309
3324
|
assert a0[a0.index("--resume") + 1] == "old_A"
|
|
3310
3325
|
assert "--resume" not in a1
|
|
3311
|
-
lanes = json.loads((tmp_path / ".meta" / "
|
|
3326
|
+
lanes = json.loads((tmp_path / ".meta" / "sessions.json").read_text())
|
|
3312
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"}
|
|
3313
3442
|
|
|
3314
3443
|
|
|
3315
3444
|
# ---------------------------------------------------------------------------
|
|
@@ -3590,3 +3719,130 @@ def test_context_markers_avoid_rewritable_punctuation():
|
|
|
3590
3719
|
f"marker {marker!r} is not lowercase; _is_context_exhausted "
|
|
3591
3720
|
f"lowercases the text, so this marker can never match"
|
|
3592
3721
|
)
|
|
3722
|
+
|
|
3723
|
+
|
|
3724
|
+
# ---------------------------------------------------------------------------
|
|
3725
|
+
# 2026-08-24 主人拍板:模型侧报错直接回报给人,不计入崩溃配额、不据此隔离
|
|
3726
|
+
# ---------------------------------------------------------------------------
|
|
3727
|
+
|
|
3728
|
+
|
|
3729
|
+
def _platform_payload(**extra):
|
|
3730
|
+
"""一次频道唤醒的 payload —— 带 conversation_id 才谈得上把失败发回频道。"""
|
|
3731
|
+
return {
|
|
3732
|
+
**_make_payload(),
|
|
3733
|
+
"kind": "platform_mention",
|
|
3734
|
+
"conversation_id": "conv_test_1",
|
|
3735
|
+
"session_id": "sess_test_1",
|
|
3736
|
+
"runtime_engine": "codex",
|
|
3737
|
+
**extra,
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
|
|
3741
|
+
async def test_model_side_error_is_not_counted_as_a_crash(fake_claude, tmp_path):
|
|
3742
|
+
"""模型报错必须报 idle —— 报 crashed 就会累计进隔离配额。
|
|
3743
|
+
|
|
3744
|
+
连续 3 次 crashed 会让服务端把 agent 隔离,此后**所有**唤醒被静默丢弃、无人
|
|
3745
|
+
被告知。Dex 2026-08-23 就是这样消失了 4 小时:模型侧的内容安全拦截被当成
|
|
3746
|
+
"这个 agent 坏了"。模型拒绝一次不等于 agent 坏了。
|
|
3747
|
+
"""
|
|
3748
|
+
mcp_cfg = tmp_path / "mcp.json"
|
|
3749
|
+
mcp_cfg.write_text("{}")
|
|
3750
|
+
ws = FakeWorkspace(FakeAgentWorkspace(mcp_path=mcp_cfg, last_session=None))
|
|
3751
|
+
outbound = FakeOutbound()
|
|
3752
|
+
fake = fake_claude(
|
|
3753
|
+
stdout_lines=[_codex_turn_failed(
|
|
3754
|
+
"This content was flagged for possible cybersecurity risk"
|
|
3755
|
+
)],
|
|
3756
|
+
exit_code=1,
|
|
3757
|
+
)
|
|
3758
|
+
runner = _codex_runner(fake, ws, outbound)
|
|
3759
|
+
|
|
3760
|
+
await runner.run_one("agent-A", _platform_payload())
|
|
3761
|
+
|
|
3762
|
+
final = [c for c in outbound.calls if c[0] == "agent_status"][-1]
|
|
3763
|
+
assert final[2] == "idle", (
|
|
3764
|
+
f"模型侧报错被记成了 {final[2]} —— 会累计进隔离配额"
|
|
3765
|
+
)
|
|
3766
|
+
|
|
3767
|
+
|
|
3768
|
+
async def test_model_side_error_is_reported_into_the_channel(fake_claude, tmp_path):
|
|
3769
|
+
"""失败必须出现在人看得见的地方,而且要带原始错误文本。
|
|
3770
|
+
|
|
3771
|
+
此前这条路径只写日志和指标(U0.4 的"沉默必须可见"),而日志没有人看——
|
|
3772
|
+
使用者只能靠"叫它没反应"发现,中间隔了几个小时。
|
|
3773
|
+
"""
|
|
3774
|
+
mcp_cfg = tmp_path / "mcp.json"
|
|
3775
|
+
mcp_cfg.write_text("{}")
|
|
3776
|
+
ws = FakeWorkspace(FakeAgentWorkspace(mcp_path=mcp_cfg, last_session=None))
|
|
3777
|
+
outbound = FakeOutbound()
|
|
3778
|
+
flagged = "This content was flagged for possible cybersecurity risk"
|
|
3779
|
+
fake = fake_claude(stdout_lines=[_codex_turn_failed(flagged)], exit_code=1)
|
|
3780
|
+
runner = _codex_runner(fake, ws, outbound)
|
|
3781
|
+
|
|
3782
|
+
await runner.run_one("agent-A", _platform_payload())
|
|
3783
|
+
|
|
3784
|
+
sends = [
|
|
3785
|
+
inv for inv in outbound.tool_call_invocations
|
|
3786
|
+
if inv.get("tool_name") == "platform_send"
|
|
3787
|
+
]
|
|
3788
|
+
assert sends, "失败没有发回频道 —— 使用者无从知道它崩了"
|
|
3789
|
+
content = sends[-1]["args"]["content"]
|
|
3790
|
+
assert flagged in content, f"通知里没有原始错误文本:{content}"
|
|
3791
|
+
assert sends[-1]["args"]["conversation_id"] == "conv_test_1"
|
|
3792
|
+
# 诚实口径:这是 daemon 的系统通知,不能冒充成 agent 的回答。
|
|
3793
|
+
assert "系统通知" in content and "不是 agent 的回答" in content
|
|
3794
|
+
# 并且要说清楚它没被隔离 —— 否则人会以为又要去捞它。
|
|
3795
|
+
assert "没有被隔离" in content
|
|
3796
|
+
|
|
3797
|
+
|
|
3798
|
+
async def test_process_level_failure_still_counts_as_a_crash(fake_claude, tmp_path):
|
|
3799
|
+
"""回归护栏:放宽的只是**模型侧**报错,进程级故障的保护不能一并拆掉。
|
|
3800
|
+
|
|
3801
|
+
二进制起不来、auth 文件权限坏这类,重试多少次都一样,仍应计入崩溃配额。
|
|
3802
|
+
"""
|
|
3803
|
+
mcp_cfg = tmp_path / "mcp.json"
|
|
3804
|
+
mcp_cfg.write_text("{}")
|
|
3805
|
+
ws = FakeWorkspace(FakeAgentWorkspace(mcp_path=mcp_cfg, last_session=None))
|
|
3806
|
+
outbound = FakeOutbound()
|
|
3807
|
+
# 没有任何模型事件,只有非零退出 + stderr —— 典型的进程级失败。
|
|
3808
|
+
fake = fake_claude(stdout_lines=[], stderr_lines=["exec: not found"], exit_code=127)
|
|
3809
|
+
runner = _codex_runner(fake, ws, outbound)
|
|
3810
|
+
|
|
3811
|
+
await runner.run_one("agent-A", _platform_payload())
|
|
3812
|
+
|
|
3813
|
+
final = [c for c in outbound.calls if c[0] == "agent_status"][-1]
|
|
3814
|
+
assert final[2] == "crashed", (
|
|
3815
|
+
f"进程级失败被记成了 {final[2]} —— 保护被误拆"
|
|
3816
|
+
)
|
|
3817
|
+
# 但它同样要让人看见。
|
|
3818
|
+
sends = [
|
|
3819
|
+
inv for inv in outbound.tool_call_invocations
|
|
3820
|
+
if inv.get("tool_name") == "platform_send"
|
|
3821
|
+
]
|
|
3822
|
+
assert sends, "进程级失败也必须发回频道"
|
|
3823
|
+
assert "进程异常" in sends[-1]["args"]["content"]
|
|
3824
|
+
|
|
3825
|
+
|
|
3826
|
+
async def test_no_conversation_id_means_no_notice_but_never_a_crash_of_the_wake(
|
|
3827
|
+
fake_claude, tmp_path
|
|
3828
|
+
):
|
|
3829
|
+
"""拿不到频道就不发通知 —— 但绝不能因此把这一轮弄挂。"""
|
|
3830
|
+
mcp_cfg = tmp_path / "mcp.json"
|
|
3831
|
+
mcp_cfg.write_text("{}")
|
|
3832
|
+
ws = FakeWorkspace(FakeAgentWorkspace(mcp_path=mcp_cfg, last_session=None))
|
|
3833
|
+
outbound = FakeOutbound()
|
|
3834
|
+
fake = fake_claude(
|
|
3835
|
+
stdout_lines=[_codex_turn_failed("boom")], exit_code=1,
|
|
3836
|
+
)
|
|
3837
|
+
runner = _codex_runner(fake, ws, outbound)
|
|
3838
|
+
|
|
3839
|
+
payload = _platform_payload()
|
|
3840
|
+
payload.pop("conversation_id")
|
|
3841
|
+
result = await runner.run_one("agent-A", payload) # 不抛异常
|
|
3842
|
+
|
|
3843
|
+
assert result is not None
|
|
3844
|
+
sends = [
|
|
3845
|
+
inv for inv in outbound.tool_call_invocations
|
|
3846
|
+
if inv.get("tool_name") == "platform_send"
|
|
3847
|
+
]
|
|
3848
|
+
assert not sends
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.5.3"
|
|
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.3 → cloudagent_daemon-0.5.5}/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.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/delegation_report.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/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.3 → cloudagent_daemon-0.5.5}/src/cloudagent_daemon/qoder_credentials.py
RENAMED
|
File without changes
|
{cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/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.3 → cloudagent_daemon-0.5.5}/tests/integration/test_e2e_deliver_to_spawn.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/tests/integration/test_mcp_bridge_round_trip.py
RENAMED
|
File without changes
|
|
File without changes
|
{cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/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.3 → cloudagent_daemon-0.5.5}/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.3 → cloudagent_daemon-0.5.5}/tests/unit/test_mcp_bridge_agent_retrieval.py
RENAMED
|
File without changes
|
{cloudagent_daemon-0.5.3 → cloudagent_daemon-0.5.5}/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.3 → cloudagent_daemon-0.5.5}/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.3 → cloudagent_daemon-0.5.5}/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.3 → cloudagent_daemon-0.5.5}/tests/unit/test_workspace_root_task113.py
RENAMED
|
File without changes
|
|
File without changes
|