cloudagent-daemon 0.3.0__tar.gz → 0.3.1__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.3.0 → cloudagent_daemon-0.3.1}/.gitignore +3 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/PKG-INFO +1 -1
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/pyproject.toml +1 -1
- cloudagent_daemon-0.3.1/src/cloudagent_daemon/__init__.py +1 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/claude_runner.py +308 -8
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/runtime_detect.py +1 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_claude_runner.py +457 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_runtime_detect.py +24 -1
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/uv.lock +2 -2
- cloudagent_daemon-0.3.0/src/cloudagent_daemon/__init__.py +0 -1
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/LICENSE +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/README.md +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/__main__.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/cli.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/config.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/context_fetch.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/delegation_report.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/doctor.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/frame_dispatch.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/http_outbound.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/injection_state.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/jsonl_logging.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/login.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/main_loop.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/mcp_bridge.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/metrics.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/metrics_server.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/outbound_queue.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/paths.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/protocol.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/service.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/spawn_outbox.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/spawn_queue.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/sse_client.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/transport.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/unix_socket.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/workspace.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/ws_client.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/__init__.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/_fake_sse_server.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/conftest.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_e2e_deliver_to_spawn.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_e2e_sse.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_full_e2e.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_mcp_bridge_round_trip.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_one_shot_cli.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_spawn_mcp_bridge_e2e.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_sse_handshake.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_sse_watchdog.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_ws_handshake.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_ws_reconnect.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/conftest.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_claude_runner_routing_contract.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_claude_runner_w3.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_cli_multi_profile.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_cli_smoke.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_config.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_context_fetch.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_delegation_report.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_doctor.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_frame_dispatch.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_http_outbound.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_injection_state.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_jsonl_logging.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_login.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_main_loop_assign.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_mcp_bridge_send_message.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_metrics.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_metrics_server.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_multi_tool_wake.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_outbound_queue.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_paths.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_protocol.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_service.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_service_multi_profile.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_spawn_outbox.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_spawn_queue.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_sse_active_watchdog.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_status_stop.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_workspace.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_workspace_root_task113.py +0 -0
- {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_ws_client_insecure.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudagent-daemon
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Local daemon for cloudagent — bridges Claude Code subprocesses to the cloudagent backend over WebSocket.
|
|
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.3.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Local daemon for cloudagent — bridges Claude Code subprocesses to the cloudagent backend over WebSocket."
|
|
9
9
|
authors = [{name = "chaoyin", email = "tudoucyx@gmail.com"}]
|
|
10
10
|
readme = "README.md"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.1"
|
|
@@ -33,6 +33,9 @@ from __future__ import annotations
|
|
|
33
33
|
import asyncio
|
|
34
34
|
import json
|
|
35
35
|
import logging
|
|
36
|
+
import os
|
|
37
|
+
import platform
|
|
38
|
+
import subprocess
|
|
36
39
|
import time
|
|
37
40
|
from dataclasses import dataclass
|
|
38
41
|
from pathlib import Path
|
|
@@ -60,6 +63,165 @@ PartialPublisher = Callable[[str, str, str], Awaitable[None]]
|
|
|
60
63
|
logger = logging.getLogger(__name__)
|
|
61
64
|
|
|
62
65
|
|
|
66
|
+
_RUNTIME_PROXY_URL_ENV = "CLOUDAGENT_RUNTIME_PROXY_URL"
|
|
67
|
+
_RUNTIME_NO_PROXY_ENV = "CLOUDAGENT_RUNTIME_NO_PROXY"
|
|
68
|
+
_DISABLE_RUNTIME_PROXY_ENV = "CLOUDAGENT_DISABLE_RUNTIME_PROXY"
|
|
69
|
+
_DEFAULT_RUNTIME_NO_PROXY = "localhost,127.0.0.1,::1"
|
|
70
|
+
_PROXY_ENV_VARS = (
|
|
71
|
+
"HTTP_PROXY",
|
|
72
|
+
"HTTPS_PROXY",
|
|
73
|
+
"ALL_PROXY",
|
|
74
|
+
"http_proxy",
|
|
75
|
+
"https_proxy",
|
|
76
|
+
"all_proxy",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _truthy_env(value: str | None) -> bool:
|
|
81
|
+
return value is not None and value.strip().lower() in {
|
|
82
|
+
"1",
|
|
83
|
+
"true",
|
|
84
|
+
"yes",
|
|
85
|
+
"on",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _disabled_proxy_value(value: str | None) -> bool:
|
|
90
|
+
return value is not None and value.strip().lower() in {
|
|
91
|
+
"0",
|
|
92
|
+
"false",
|
|
93
|
+
"no",
|
|
94
|
+
"off",
|
|
95
|
+
"none",
|
|
96
|
+
"direct",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_scutil_proxy_output(text: str) -> dict[str, str]:
|
|
101
|
+
out: dict[str, str] = {}
|
|
102
|
+
for raw_line in text.splitlines():
|
|
103
|
+
line = raw_line.strip()
|
|
104
|
+
if " : " not in line:
|
|
105
|
+
continue
|
|
106
|
+
key, value = line.split(" : ", 1)
|
|
107
|
+
out[key.strip()] = value.strip()
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _proxy_url_from_parts(
|
|
112
|
+
scheme: str, host: str | None, port: str | None
|
|
113
|
+
) -> str | None:
|
|
114
|
+
host = (host or "").strip()
|
|
115
|
+
port = (port or "").strip()
|
|
116
|
+
if not host or not port:
|
|
117
|
+
return None
|
|
118
|
+
if "://" in host:
|
|
119
|
+
return host
|
|
120
|
+
return f"{scheme}://{host}:{port}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _detect_macos_system_proxy_url() -> str | None:
|
|
124
|
+
"""Return the active macOS proxy URL for runtime subprocesses.
|
|
125
|
+
|
|
126
|
+
launchd jobs do not inherit Network Settings proxy configuration as
|
|
127
|
+
environment variables. Codex/Claude child runtimes, however, rely on
|
|
128
|
+
standard proxy env vars when they need to reach model providers from
|
|
129
|
+
restricted networks. Read the system proxy at spawn time so existing
|
|
130
|
+
installed daemons start working after upgrade + restart.
|
|
131
|
+
"""
|
|
132
|
+
if platform.system().lower() != "darwin":
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
result = subprocess.run(
|
|
136
|
+
["/usr/sbin/scutil", "--proxy"],
|
|
137
|
+
capture_output=True,
|
|
138
|
+
text=True,
|
|
139
|
+
timeout=2.0,
|
|
140
|
+
check=False,
|
|
141
|
+
)
|
|
142
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
143
|
+
return None
|
|
144
|
+
if result.returncode != 0:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
values = _parse_scutil_proxy_output(result.stdout)
|
|
148
|
+
if values.get("HTTPSEnable") == "1":
|
|
149
|
+
proxy = _proxy_url_from_parts(
|
|
150
|
+
"http", values.get("HTTPSProxy"), values.get("HTTPSPort")
|
|
151
|
+
)
|
|
152
|
+
if proxy:
|
|
153
|
+
return proxy
|
|
154
|
+
if values.get("HTTPEnable") == "1":
|
|
155
|
+
proxy = _proxy_url_from_parts(
|
|
156
|
+
"http", values.get("HTTPProxy"), values.get("HTTPPort")
|
|
157
|
+
)
|
|
158
|
+
if proxy:
|
|
159
|
+
return proxy
|
|
160
|
+
if values.get("SOCKSEnable") == "1":
|
|
161
|
+
return _proxy_url_from_parts(
|
|
162
|
+
"socks5h", values.get("SOCKSProxy"), values.get("SOCKSPort")
|
|
163
|
+
)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _runtime_proxy_url(env: dict[str, str]) -> str | None:
|
|
168
|
+
explicit = env.get(_RUNTIME_PROXY_URL_ENV)
|
|
169
|
+
if _disabled_proxy_value(explicit):
|
|
170
|
+
return None
|
|
171
|
+
if explicit and explicit.strip():
|
|
172
|
+
return explicit.strip()
|
|
173
|
+
for name in (
|
|
174
|
+
"HTTPS_PROXY",
|
|
175
|
+
"https_proxy",
|
|
176
|
+
"HTTP_PROXY",
|
|
177
|
+
"http_proxy",
|
|
178
|
+
"ALL_PROXY",
|
|
179
|
+
"all_proxy",
|
|
180
|
+
):
|
|
181
|
+
value = env.get(name)
|
|
182
|
+
if value and value.strip():
|
|
183
|
+
return value.strip()
|
|
184
|
+
return _detect_macos_system_proxy_url()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _runtime_subprocess_env(
|
|
188
|
+
base_env: dict[str, str] | None = None,
|
|
189
|
+
) -> dict[str, str] | None:
|
|
190
|
+
"""Build env for Codex/Claude subprocesses.
|
|
191
|
+
|
|
192
|
+
The daemon itself deliberately bypasses ambient system proxies when it
|
|
193
|
+
connects to CloudAgent. Runtime children are different: they often must
|
|
194
|
+
reach external model-provider endpoints such as chatgpt.com or
|
|
195
|
+
Anthropic. If a runtime proxy is configured or discoverable from macOS
|
|
196
|
+
Network Settings, inject proxy env vars for the child only and narrow
|
|
197
|
+
``NO_PROXY=*`` so the runtime does not accidentally bypass the proxy.
|
|
198
|
+
"""
|
|
199
|
+
env = dict(base_env or os.environ)
|
|
200
|
+
if _truthy_env(env.get(_DISABLE_RUNTIME_PROXY_ENV)):
|
|
201
|
+
return env
|
|
202
|
+
|
|
203
|
+
proxy_url = _runtime_proxy_url(env)
|
|
204
|
+
if not proxy_url:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
for name in _PROXY_ENV_VARS:
|
|
208
|
+
env[name] = proxy_url
|
|
209
|
+
|
|
210
|
+
runtime_no_proxy = env.get(_RUNTIME_NO_PROXY_ENV)
|
|
211
|
+
if runtime_no_proxy is not None:
|
|
212
|
+
no_proxy = runtime_no_proxy
|
|
213
|
+
else:
|
|
214
|
+
upper = env.get("NO_PROXY")
|
|
215
|
+
lower = env.get("no_proxy")
|
|
216
|
+
if upper in (None, "", "*") or lower in (None, "", "*"):
|
|
217
|
+
no_proxy = _DEFAULT_RUNTIME_NO_PROXY
|
|
218
|
+
else:
|
|
219
|
+
no_proxy = upper or lower or _DEFAULT_RUNTIME_NO_PROXY
|
|
220
|
+
env["NO_PROXY"] = no_proxy
|
|
221
|
+
env["no_proxy"] = no_proxy
|
|
222
|
+
return env
|
|
223
|
+
|
|
224
|
+
|
|
63
225
|
# ---------------------------------------------------------------------------
|
|
64
226
|
# Workspace protocol — concrete impl arrives in Task 11
|
|
65
227
|
# ---------------------------------------------------------------------------
|
|
@@ -142,6 +304,15 @@ class _SpawnAttempt:
|
|
|
142
304
|
event). Both feed the Layer-B daemon-side fallback that POSTs a
|
|
143
305
|
``send_message`` on claude's behalf when claude generated a reply
|
|
144
306
|
but never invoked the tool.
|
|
307
|
+
|
|
308
|
+
M8 — additionally tracks ``had_api_error``: True iff claude emitted
|
|
309
|
+
an ``isApiErrorMessage:true`` synthetic assistant event during the
|
|
310
|
+
wake. Claude Code's CLI handles upstream Anthropic/Bedrock API
|
|
311
|
+
errors by printing a synthetic ``assistant`` message (model=
|
|
312
|
+
``<synthetic>``, ``isApiErrorMessage:true``) and exiting rc=0
|
|
313
|
+
*without* a terminating ``type=result`` event — so neither rc nor
|
|
314
|
+
``final_result_text`` reflects the failure. This flag lets the
|
|
315
|
+
runner promote the wake to ``crashed`` instead of ``idle``.
|
|
145
316
|
"""
|
|
146
317
|
|
|
147
318
|
exit_code: int
|
|
@@ -154,6 +325,9 @@ class _SpawnAttempt:
|
|
|
154
325
|
# ``type=result`` event (normalised to ``_INITIAL_USAGE`` shape).
|
|
155
326
|
# ``None`` when the result event carried no ``usage`` blob.
|
|
156
327
|
result_usage: dict | None
|
|
328
|
+
# M8 — set True when claude emitted an ``isApiErrorMessage:true``
|
|
329
|
+
# event during the wake (upstream API error swallowed by the CLI).
|
|
330
|
+
had_api_error: bool = False
|
|
157
331
|
|
|
158
332
|
|
|
159
333
|
def _build_lazy_identity(agent_id: str, payload: dict) -> dict:
|
|
@@ -512,6 +686,14 @@ The daemon discards stdout — only the mcp tool call reaches the user.
|
|
|
512
686
|
|
|
513
687
|
if timeout_hit:
|
|
514
688
|
final_status = "timeout"
|
|
689
|
+
elif attempt.had_api_error:
|
|
690
|
+
# M8 — Claude Code swallows upstream Anthropic/Bedrock API
|
|
691
|
+
# errors: it prints a synthetic ``isApiErrorMessage:true``
|
|
692
|
+
# event and exits rc=0 *without* a terminating ``type=
|
|
693
|
+
# result``. Without this branch the wake would be reported
|
|
694
|
+
# as ``idle`` (cleanly completed) — leaving the dispatching
|
|
695
|
+
# chief silently waiting for a reply that will never come.
|
|
696
|
+
final_status = "crashed"
|
|
515
697
|
elif rc == 0:
|
|
516
698
|
final_status = "idle"
|
|
517
699
|
else:
|
|
@@ -534,6 +716,76 @@ The daemon discards stdout — only the mcp tool call reaches the user.
|
|
|
534
716
|
exc_info=True,
|
|
535
717
|
)
|
|
536
718
|
|
|
719
|
+
# M8 — wake_aborted escalation. When the wake ends in a non-idle
|
|
720
|
+
# terminal state AND the inbound message was one whose sender is
|
|
721
|
+
# waiting on a reply (kind in WAIT_FOR_REPLIES_VALID_PARENT_KINDS:
|
|
722
|
+
# task / peer_request / question) AND claude never managed to
|
|
723
|
+
# call send_message, post a system-marked reply on its behalf so
|
|
724
|
+
# the chief's wait_for_replies satisfies early instead of timing
|
|
725
|
+
# out 80+ minutes later. This is NOT a fake reply: the body is
|
|
726
|
+
# explicitly tagged ``[system: ...]`` so the receiving agent can
|
|
727
|
+
# distinguish daemon-emitted abort signals from real staff work.
|
|
728
|
+
# Best-effort: any failure here is logged but never crashes the
|
|
729
|
+
# wake (the wake's user-facing contract is already satisfied by
|
|
730
|
+
# the agent_status frame below).
|
|
731
|
+
WAKE_REPLY_EXPECTED_KINDS = ("task", "peer_request", "question")
|
|
732
|
+
inbound_kind = payload.get("kind")
|
|
733
|
+
parent_for_abort = (
|
|
734
|
+
payload.get("parent_msg_id") or payload.get("message_id")
|
|
735
|
+
)
|
|
736
|
+
if (
|
|
737
|
+
final_status in ("crashed", "timeout")
|
|
738
|
+
and not called_send_message
|
|
739
|
+
and isinstance(fallback_session, str)
|
|
740
|
+
and fallback_session
|
|
741
|
+
and inbound_kind in WAKE_REPLY_EXPECTED_KINDS
|
|
742
|
+
and parent_for_abort not in (None, "")
|
|
743
|
+
):
|
|
744
|
+
if attempt.had_api_error:
|
|
745
|
+
reason = "upstream_api_error"
|
|
746
|
+
elif timeout_hit:
|
|
747
|
+
reason = "wake_timeout"
|
|
748
|
+
else:
|
|
749
|
+
reason = "claude_crashed"
|
|
750
|
+
abort_body = (
|
|
751
|
+
f"[system: wake_aborted reason={reason} — the dispatched "
|
|
752
|
+
f"agent did not produce a reply (claude exit_code={rc}). "
|
|
753
|
+
f"This is a daemon-emitted system signal, not staff "
|
|
754
|
+
f"output. Re-dispatch or escalate as needed.]"
|
|
755
|
+
)
|
|
756
|
+
logger.warning(
|
|
757
|
+
"claude_runner: wake_aborted — POSTing system reply "
|
|
758
|
+
"(agent=%s session=%s reason=%s parent_msg_id=%s)",
|
|
759
|
+
agent_id,
|
|
760
|
+
fallback_session,
|
|
761
|
+
reason,
|
|
762
|
+
parent_for_abort,
|
|
763
|
+
)
|
|
764
|
+
try:
|
|
765
|
+
await self.outbound.send_tool_call(
|
|
766
|
+
agent_id=agent_id,
|
|
767
|
+
session_id=fallback_session,
|
|
768
|
+
tool_name="send_message",
|
|
769
|
+
args=self._fallback_reply_args(payload, abort_body),
|
|
770
|
+
timeout=15.0,
|
|
771
|
+
)
|
|
772
|
+
try:
|
|
773
|
+
from cloudagent_daemon.metrics import get_default as _md
|
|
774
|
+
await _md().inc_counter(
|
|
775
|
+
"cloudagent_daemon_wake_aborted_emitted_total",
|
|
776
|
+
labels={"reason": reason},
|
|
777
|
+
)
|
|
778
|
+
except Exception: # noqa: BLE001
|
|
779
|
+
pass
|
|
780
|
+
except Exception: # pragma: no cover — best-effort
|
|
781
|
+
logger.exception(
|
|
782
|
+
"claude_runner: wake_aborted POST FAILED "
|
|
783
|
+
"(chief will time out) agent=%s session=%s reason=%s",
|
|
784
|
+
agent_id,
|
|
785
|
+
fallback_session,
|
|
786
|
+
reason,
|
|
787
|
+
)
|
|
788
|
+
|
|
537
789
|
await self.outbound.send_agent_status(
|
|
538
790
|
agent_id, final_status, exit_code=rc
|
|
539
791
|
)
|
|
@@ -763,12 +1015,22 @@ The daemon discards stdout — only the mcp tool call reaches the user.
|
|
|
763
1015
|
)
|
|
764
1016
|
|
|
765
1017
|
logger.debug("ClaudeRunner: spawning %s", cmd)
|
|
1018
|
+
spawn_env = _runtime_subprocess_env()
|
|
1019
|
+
if spawn_env is not None:
|
|
1020
|
+
logger.info(
|
|
1021
|
+
"claude_runner: applying runtime proxy env to subprocess "
|
|
1022
|
+
"agent=%s runtime=%s no_proxy=%s",
|
|
1023
|
+
agent_id,
|
|
1024
|
+
runtime_engine,
|
|
1025
|
+
spawn_env.get("NO_PROXY", ""),
|
|
1026
|
+
)
|
|
766
1027
|
proc = await asyncio.create_subprocess_exec(
|
|
767
1028
|
*cmd,
|
|
768
1029
|
stdin=asyncio.subprocess.PIPE,
|
|
769
1030
|
stdout=asyncio.subprocess.PIPE,
|
|
770
1031
|
stderr=asyncio.subprocess.PIPE,
|
|
771
1032
|
cwd=str(spawn_cwd),
|
|
1033
|
+
env=spawn_env,
|
|
772
1034
|
# asyncio StreamReader 默认 limit=64KB;claude CLI 单条 JSON
|
|
773
1035
|
# event 可超出(tool_result 含大型 Read 文件 / Anthropic 返回
|
|
774
1036
|
# body / partial_messages 累积 chunk 等)。2026-05-14 实战:
|
|
@@ -819,6 +1081,7 @@ The daemon discards stdout — only the mcp tool call reaches the user.
|
|
|
819
1081
|
tool_uses,
|
|
820
1082
|
final_text,
|
|
821
1083
|
result_usage,
|
|
1084
|
+
had_api_error,
|
|
822
1085
|
) = await self._consume_stdout(
|
|
823
1086
|
proc,
|
|
824
1087
|
parse_mode=(
|
|
@@ -848,6 +1111,7 @@ The daemon discards stdout — only the mcp tool call reaches the user.
|
|
|
848
1111
|
tool_uses=tool_uses,
|
|
849
1112
|
final_result_text=final_text,
|
|
850
1113
|
result_usage=result_usage,
|
|
1114
|
+
had_api_error=had_api_error,
|
|
851
1115
|
)
|
|
852
1116
|
|
|
853
1117
|
# ------------------------------------------------------------------
|
|
@@ -994,7 +1258,9 @@ Available cloudagent tools:
|
|
|
994
1258
|
identity = {}
|
|
995
1259
|
raw = identity.get("runtime_engine")
|
|
996
1260
|
engine = raw.strip() if isinstance(raw, str) else ""
|
|
997
|
-
|
|
1261
|
+
if engine in {"codex", "mulerun"}:
|
|
1262
|
+
return engine
|
|
1263
|
+
return "claude_code"
|
|
998
1264
|
|
|
999
1265
|
def _fallback_reply_args(self, payload: dict, body: str) -> dict:
|
|
1000
1266
|
"""Build daemon fallback ``send_message`` args.
|
|
@@ -1086,7 +1352,10 @@ Available cloudagent tools:
|
|
|
1086
1352
|
]
|
|
1087
1353
|
return cmd
|
|
1088
1354
|
|
|
1089
|
-
|
|
1355
|
+
if runtime_engine == "mulerun":
|
|
1356
|
+
cmd = [self.runtime_binaries.get("mulerun") or "mulerun", "code", "--"]
|
|
1357
|
+
else:
|
|
1358
|
+
cmd = [self.runtime_binaries.get("claude_code") or self.claude_binary]
|
|
1090
1359
|
if last_session:
|
|
1091
1360
|
cmd += ["--resume", last_session]
|
|
1092
1361
|
# 1i Bug F — inject operational contract via --append-system-prompt.
|
|
@@ -1135,7 +1404,7 @@ Available cloudagent tools:
|
|
|
1135
1404
|
*,
|
|
1136
1405
|
partial_session_id: str | None = None,
|
|
1137
1406
|
partial_agent_id: str | None = None,
|
|
1138
|
-
) -> tuple[str | None, list[str], str | None, dict | None]:
|
|
1407
|
+
) -> tuple[str | None, list[str], str | None, dict | None, bool]:
|
|
1139
1408
|
"""Consume ``codex exec --json`` NDJSON.
|
|
1140
1409
|
|
|
1141
1410
|
Codex emits ``thread.started`` with ``thread_id`` and one or more
|
|
@@ -1216,7 +1485,9 @@ Available cloudagent tools:
|
|
|
1216
1485
|
logger.warning("CodexRunner: stdout error event=%s msg=%r", evt_type, msg)
|
|
1217
1486
|
|
|
1218
1487
|
final_text = "\n\n".join(final_chunks) if final_chunks else None
|
|
1219
|
-
|
|
1488
|
+
# Codex doesn't surface upstream API errors as a synthetic event
|
|
1489
|
+
# the way Claude Code does — leave had_api_error=False here.
|
|
1490
|
+
return captured, [], final_text, result_usage, False
|
|
1220
1491
|
|
|
1221
1492
|
async def _consume_stdout(
|
|
1222
1493
|
self,
|
|
@@ -1225,8 +1496,8 @@ Available cloudagent tools:
|
|
|
1225
1496
|
parse_mode: str = "stream_json",
|
|
1226
1497
|
partial_session_id: str | None = None,
|
|
1227
1498
|
partial_agent_id: str | None = None,
|
|
1228
|
-
) -> tuple[str | None, list[str], str | None, dict | None]:
|
|
1229
|
-
"""Stream NDJSON
|
|
1499
|
+
) -> tuple[str | None, list[str], str | None, dict | None, bool]:
|
|
1500
|
+
"""Stream NDJSON; return ``(session_id, tool_uses, result_text, result_usage, had_api_error)``.
|
|
1230
1501
|
|
|
1231
1502
|
Accepts BOTH session-init event shapes for forward/backward
|
|
1232
1503
|
compatibility:
|
|
@@ -1274,6 +1545,9 @@ Available cloudagent tools:
|
|
|
1274
1545
|
final_text: str | None = None
|
|
1275
1546
|
# M7 W3 — whole-turn usage from the terminating ``result`` event.
|
|
1276
1547
|
result_usage: dict | None = None
|
|
1548
|
+
# M8 — set True when claude emits a synthetic isApiErrorMessage
|
|
1549
|
+
# assistant event (upstream API error swallowed by the CLI).
|
|
1550
|
+
had_api_error: bool = False
|
|
1277
1551
|
# 1k74 — running buffer for token-level streaming. claude emits
|
|
1278
1552
|
# one ``stream_event`` per content_block_delta (~80 chars at a
|
|
1279
1553
|
# time); we concatenate them and forward the running total to
|
|
@@ -1290,7 +1564,7 @@ Available cloudagent tools:
|
|
|
1290
1564
|
_cache_read_tokens: int = 0
|
|
1291
1565
|
_cache_creation_tokens: int = 0
|
|
1292
1566
|
if proc.stdout is None:
|
|
1293
|
-
return None, tool_uses, final_text, result_usage
|
|
1567
|
+
return None, tool_uses, final_text, result_usage, had_api_error
|
|
1294
1568
|
|
|
1295
1569
|
async for line_b in proc.stdout:
|
|
1296
1570
|
line = line_b.decode("utf-8", errors="replace").strip()
|
|
@@ -1384,6 +1658,32 @@ Available cloudagent tools:
|
|
|
1384
1658
|
# {"type":"tool_use","name":"mcp__cloudagent__send_message",...}
|
|
1385
1659
|
# ]}}
|
|
1386
1660
|
if evt_type == "assistant":
|
|
1661
|
+
# M8 — detect synthetic API error events. Claude Code's
|
|
1662
|
+
# CLI flags upstream Anthropic/Bedrock errors with
|
|
1663
|
+
# ``isApiErrorMessage:true`` (model=``<synthetic>``) and
|
|
1664
|
+
# then exits rc=0 *without* a terminating ``type=result``
|
|
1665
|
+
# event. Without this signal the runner would mark the
|
|
1666
|
+
# wake ``idle`` and the dispatcher would never know the
|
|
1667
|
+
# reply was lost.
|
|
1668
|
+
if evt.get("isApiErrorMessage") is True:
|
|
1669
|
+
had_api_error = True
|
|
1670
|
+
err_text = ""
|
|
1671
|
+
msg_x = evt.get("message")
|
|
1672
|
+
if isinstance(msg_x, dict):
|
|
1673
|
+
for blk in msg_x.get("content") or []:
|
|
1674
|
+
if (
|
|
1675
|
+
isinstance(blk, dict)
|
|
1676
|
+
and blk.get("type") == "text"
|
|
1677
|
+
):
|
|
1678
|
+
t = blk.get("text")
|
|
1679
|
+
if isinstance(t, str):
|
|
1680
|
+
err_text = t[:200]
|
|
1681
|
+
break
|
|
1682
|
+
logger.warning(
|
|
1683
|
+
"ClaudeRunner: stdout isApiErrorMessage event — "
|
|
1684
|
+
"wake will be marked crashed (text=%r)",
|
|
1685
|
+
err_text,
|
|
1686
|
+
)
|
|
1387
1687
|
msg = evt.get("message")
|
|
1388
1688
|
content = (
|
|
1389
1689
|
msg.get("content")
|
|
@@ -1470,7 +1770,7 @@ Available cloudagent tools:
|
|
|
1470
1770
|
"claude_runner: cache metrics publish failed", exc_info=True
|
|
1471
1771
|
)
|
|
1472
1772
|
|
|
1473
|
-
return captured, tool_uses, final_text, result_usage
|
|
1773
|
+
return captured, tool_uses, final_text, result_usage, had_api_error
|
|
1474
1774
|
|
|
1475
1775
|
async def _wait_with_timeout(
|
|
1476
1776
|
self, proc: asyncio.subprocess.Process
|
|
@@ -34,6 +34,7 @@ class DetectedRuntime:
|
|
|
34
34
|
# (canonical_name, [binary candidates in priority order], [version-args])
|
|
35
35
|
_PROBES: list[tuple[str, list[str], list[str]]] = [
|
|
36
36
|
("claude_code", ["claude", "claude-code"], ["--version"]),
|
|
37
|
+
("mulerun", ["mulerun"], ["--version"]),
|
|
37
38
|
("codex", ["codex"], ["--version"]),
|
|
38
39
|
("opencode", ["opencode", "open-code"], ["version"]),
|
|
39
40
|
("aider", ["aider"], ["--version"]),
|