cloudagent-daemon 0.2.1__py3-none-any.whl
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/__init__.py +1 -0
- cloudagent_daemon/__main__.py +4 -0
- cloudagent_daemon/claude_runner.py +1068 -0
- cloudagent_daemon/cli.py +833 -0
- cloudagent_daemon/config.py +197 -0
- cloudagent_daemon/doctor.py +534 -0
- cloudagent_daemon/frame_dispatch.py +216 -0
- cloudagent_daemon/http_outbound.py +80 -0
- cloudagent_daemon/jsonl_logging.py +98 -0
- cloudagent_daemon/login.py +34 -0
- cloudagent_daemon/main_loop.py +891 -0
- cloudagent_daemon/mcp_bridge.py +441 -0
- cloudagent_daemon/metrics.py +315 -0
- cloudagent_daemon/metrics_server.py +149 -0
- cloudagent_daemon/outbound_queue.py +289 -0
- cloudagent_daemon/paths.py +204 -0
- cloudagent_daemon/protocol.py +141 -0
- cloudagent_daemon/runtime_detect.py +135 -0
- cloudagent_daemon/service.py +355 -0
- cloudagent_daemon/spawn_outbox.py +443 -0
- cloudagent_daemon/spawn_queue.py +200 -0
- cloudagent_daemon/sse_client.py +439 -0
- cloudagent_daemon/transport.py +105 -0
- cloudagent_daemon/unix_socket.py +278 -0
- cloudagent_daemon/workspace.py +441 -0
- cloudagent_daemon/ws_client.py +431 -0
- cloudagent_daemon-0.2.1.dist-info/METADATA +322 -0
- cloudagent_daemon-0.2.1.dist-info/RECORD +31 -0
- cloudagent_daemon-0.2.1.dist-info/WHEEL +4 -0
- cloudagent_daemon-0.2.1.dist-info/entry_points.txt +2 -0
- cloudagent_daemon-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
"""ClaudeRunner: spawn the ``claude`` binary, drive its stdin/stdout and emit
|
|
2
|
+
status frames for one wake-up.
|
|
3
|
+
|
|
4
|
+
Spec §6.5–§6.7. The runner is invoked once per delivered message by the
|
|
5
|
+
SpawnQueue drain loop (see Task 8). It is *not* the queue itself.
|
|
6
|
+
|
|
7
|
+
Design contract
|
|
8
|
+
---------------
|
|
9
|
+
``run_one(agent_id, payload)`` performs the following sequence:
|
|
10
|
+
|
|
11
|
+
1. Build the ``claude`` argv (`--resume` only on a pre-existing session,
|
|
12
|
+
plus ``--mcp-config <path> --print --output-format stream-json``).
|
|
13
|
+
2. Spawn it with stdin/stdout/stderr pipes.
|
|
14
|
+
3. Write ``payload["body"]`` to stdin and close it.
|
|
15
|
+
4. Emit ``delivery_ack(status="started")`` for the message and an
|
|
16
|
+
``agent_status(running)`` frame.
|
|
17
|
+
5. Stream stdout NDJSON events; remember any ``session_id`` carried by a
|
|
18
|
+
``session_init`` event.
|
|
19
|
+
6. Wait for exit (90-min single-wake timeout per spec §6.7). On timeout,
|
|
20
|
+
terminate then kill the process.
|
|
21
|
+
7. Read stderr tail, persist `last-spawn.json`, delete the per-agent
|
|
22
|
+
one-shot token file.
|
|
23
|
+
8. If the captured session_id is new, persist it on the workspace and
|
|
24
|
+
emit ``agent_session_start``.
|
|
25
|
+
9. Emit a final ``agent_status`` (``idle``/``crashed``/``timeout``) and
|
|
26
|
+
return a :class:`SpawnResult`.
|
|
27
|
+
|
|
28
|
+
Workspace is referenced via :class:`WorkspaceLike` / :class:`AgentWorkspaceLike`
|
|
29
|
+
``Protocol`` types; the real implementation lands in Task 11.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
import time
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import TYPE_CHECKING, Awaitable, Callable, Protocol
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from cloudagent_daemon.outbound_queue import OutboundQueue
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# 1k74 — token-level streaming partial-publish hook.
|
|
46
|
+
#
|
|
47
|
+
# The runner invokes this callable each time claude emits a text_delta
|
|
48
|
+
# stream event during a run. ``accumulated_text`` is everything claude
|
|
49
|
+
# has emitted so far (the runner concatenates chunks before calling
|
|
50
|
+
# the publisher) so the receiver can render it as the in-progress
|
|
51
|
+
# assistant bubble without having to track its own running buffer.
|
|
52
|
+
#
|
|
53
|
+
# Implementations should be fire-and-forget: a failed publish must not
|
|
54
|
+
# tear down the claude wake. Network errors / backend hiccups are
|
|
55
|
+
# logged at INFO; the final ``send_message`` MCP call is the canonical
|
|
56
|
+
# source of truth and will land regardless.
|
|
57
|
+
PartialPublisher = Callable[[str, str, str], Awaitable[None]]
|
|
58
|
+
"""(session_id, agent_id, accumulated_text) -> None"""
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Workspace protocol — concrete impl arrives in Task 11
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AgentWorkspaceLike(Protocol):
|
|
69
|
+
"""Per-agent workspace surface used by :class:`ClaudeRunner`."""
|
|
70
|
+
|
|
71
|
+
path: Path
|
|
72
|
+
# Task #118 (2026-05-12) — claude subprocess cwd. Decoupled from
|
|
73
|
+
# ``path`` (the metadata root) so a user-supplied
|
|
74
|
+
# ``agent.workspace_root`` doesn't pollute the platform's
|
|
75
|
+
# ``~/.cloudagent/agents/<id>/`` directory.
|
|
76
|
+
cwd: Path
|
|
77
|
+
|
|
78
|
+
def last_session_id(self) -> str | None: ...
|
|
79
|
+
|
|
80
|
+
def set_last_session_id(self, sid: str) -> None: ...
|
|
81
|
+
|
|
82
|
+
# 1k followup — clear stored session_id when ``claude --resume`` reports
|
|
83
|
+
# "No conversation found" so the next wake starts a fresh conversation
|
|
84
|
+
# instead of looping on the stale id forever.
|
|
85
|
+
def clear_last_session_id(self) -> None: ...
|
|
86
|
+
|
|
87
|
+
def mcp_config_path(self) -> Path: ...
|
|
88
|
+
|
|
89
|
+
def write_last_spawn(
|
|
90
|
+
self, exit_code: int, session_id: str | None, stderr_tail: str
|
|
91
|
+
) -> None: ...
|
|
92
|
+
|
|
93
|
+
def delete_token_file(self) -> None: ...
|
|
94
|
+
|
|
95
|
+
# 1k hotfix — per-spawn current-session marker; see workspace.py.
|
|
96
|
+
def write_current_session(self, session_id: str | None) -> None: ...
|
|
97
|
+
|
|
98
|
+
def delete_current_session(self) -> None: ...
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class WorkspaceLike(Protocol):
|
|
102
|
+
def for_agent(self, agent_id: str) -> AgentWorkspaceLike: ...
|
|
103
|
+
|
|
104
|
+
# 1h Part C — auto-create on first deliver if no workspace exists yet.
|
|
105
|
+
# The server may push a deliver for an agent the daemon has never
|
|
106
|
+
# seen (lazy registration); falling back to a sensible identity
|
|
107
|
+
# avoids a hard crash and lets the user run ``claude`` without a
|
|
108
|
+
# manual init step.
|
|
109
|
+
def ensure(
|
|
110
|
+
self, agent_id: str, identity: dict
|
|
111
|
+
) -> AgentWorkspaceLike: ...
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Result type
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class SpawnResult:
|
|
121
|
+
exit_code: int
|
|
122
|
+
captured_session_id: str | None
|
|
123
|
+
stderr_tail: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class _SpawnAttempt:
|
|
128
|
+
"""Outcome of a single ``claude`` subprocess invocation.
|
|
129
|
+
|
|
130
|
+
1k followup — internal helper returned by
|
|
131
|
+
:meth:`ClaudeRunner._spawn_attempt`. Distinct from the public
|
|
132
|
+
:class:`SpawnResult` because we additionally need ``timeout_hit`` to
|
|
133
|
+
pick the final ``agent_status`` (timeout vs crashed) — that field is
|
|
134
|
+
not part of the runner's external contract.
|
|
135
|
+
|
|
136
|
+
1k #63 — additionally tracks ``tool_uses`` (the names of every
|
|
137
|
+
tool_use block emitted by claude during the wake) and
|
|
138
|
+
``final_result_text`` (the text from the terminating ``type=result``
|
|
139
|
+
event). Both feed the Layer-B daemon-side fallback that POSTs a
|
|
140
|
+
``send_message`` on claude's behalf when claude generated a reply
|
|
141
|
+
but never invoked the tool.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
exit_code: int
|
|
145
|
+
captured_session_id: str | None
|
|
146
|
+
stderr_tail: str
|
|
147
|
+
timeout_hit: bool
|
|
148
|
+
tool_uses: list[str]
|
|
149
|
+
final_result_text: str | None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _build_lazy_identity(agent_id: str, payload: dict) -> dict:
|
|
153
|
+
"""Construct the minimum-viable identity dict for ``Workspace.ensure``.
|
|
154
|
+
|
|
155
|
+
1h Part C: the daemon may receive a deliver for an agent it doesn't
|
|
156
|
+
have a registered workspace for yet (e.g. after a fresh install,
|
|
157
|
+
or when the user creates the agent on the server while the daemon
|
|
158
|
+
is offline). In that case we derive a safe identity from whatever
|
|
159
|
+
the deliver payload carries, falling back to ``agent_id`` for any
|
|
160
|
+
missing fields.
|
|
161
|
+
|
|
162
|
+
``is_chief`` defaults to ``False`` — that is a security guarantee:
|
|
163
|
+
daemons must NOT auto-promote unknown agents to chief (which gates
|
|
164
|
+
``delegate_to_staff`` access). A future server-pushed ``assign``
|
|
165
|
+
frame will carry the correct kind / role.
|
|
166
|
+
"""
|
|
167
|
+
return {
|
|
168
|
+
"agent_id": agent_id,
|
|
169
|
+
"agent_name": str(payload.get("agent_name") or agent_id),
|
|
170
|
+
"is_chief": False,
|
|
171
|
+
"role": str(payload.get("role") or "staff"),
|
|
172
|
+
"workspace_slug": str(payload.get("workspace_slug") or "default"),
|
|
173
|
+
"reports_to": "(unset)",
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# Runner
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ClaudeRunner:
|
|
183
|
+
"""Run one wake of the ``claude`` CLI for a delivered message."""
|
|
184
|
+
|
|
185
|
+
#: Spec §6.7 — 90-minute single-wake timeout.
|
|
186
|
+
SINGLE_WAKE_TIMEOUT_SECONDS: float = 90 * 60
|
|
187
|
+
#: Wait this long after SIGTERM before SIGKILL on timeout.
|
|
188
|
+
TERMINATE_GRACE_SECONDS: float = 10.0
|
|
189
|
+
#: How many bytes of stderr to retain in last-spawn.json.
|
|
190
|
+
STDERR_TAIL_BYTES: int = 2000
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
outbound: OutboundQueue,
|
|
196
|
+
workspace: WorkspaceLike,
|
|
197
|
+
claude_binary: str = "claude",
|
|
198
|
+
single_wake_timeout: float | None = None,
|
|
199
|
+
partial_publisher: PartialPublisher | None = None,
|
|
200
|
+
agent_to_session: "dict[str, str | None] | None" = None,
|
|
201
|
+
) -> None:
|
|
202
|
+
self.outbound = outbound
|
|
203
|
+
self.workspace = workspace
|
|
204
|
+
self.claude_binary = claude_binary
|
|
205
|
+
# Allow tests to override the 90-min timeout without monkey-patching
|
|
206
|
+
# a class attribute. Defaults to spec value.
|
|
207
|
+
self._single_wake_timeout: float = (
|
|
208
|
+
single_wake_timeout
|
|
209
|
+
if single_wake_timeout is not None
|
|
210
|
+
else self.SINGLE_WAKE_TIMEOUT_SECONDS
|
|
211
|
+
)
|
|
212
|
+
# 1k74 — optional callback for token-level streaming. main_loop
|
|
213
|
+
# wires this to an HTTP POST against the backend's partial-
|
|
214
|
+
# publish endpoint. Absent in tests, where we don't care about
|
|
215
|
+
# the stream and want to keep claude_runner exercises hermetic.
|
|
216
|
+
self._partial_publisher = partial_publisher
|
|
217
|
+
# (c) IPC architecture upgrade — shared agent→session memory map.
|
|
218
|
+
# main_loop passes its own dict; mcp_bridge queries it via the
|
|
219
|
+
# unix socket ``get_current_session`` op instead of reading a file.
|
|
220
|
+
# None means feature disabled (legacy / test path).
|
|
221
|
+
self._agent_to_session: dict[str, str | None] | None = agent_to_session
|
|
222
|
+
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
# Public API
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
#: 1k followup — substring used to detect the stale-session crash
|
|
228
|
+
#: emitted by ``claude --resume <sid>`` when the session is no longer
|
|
229
|
+
#: present in ``~/.claude/sessions/``. ``--print`` mode does not
|
|
230
|
+
#: always persist sessions, so chaining wakes via ``--resume`` is
|
|
231
|
+
#: fragile. When we observe this we clear ``last_session_id`` and
|
|
232
|
+
#: retry once without ``--resume``.
|
|
233
|
+
STALE_SESSION_STDERR_MARKER: str = "No conversation found"
|
|
234
|
+
|
|
235
|
+
#: 1k #63 — stdin wrapper template (Layer A defense).
|
|
236
|
+
#:
|
|
237
|
+
#: ``--append-system-prompt`` text is treated by claude as a soft
|
|
238
|
+
#: guideline: simple prompts ("你好" / "你是谁") still get answered
|
|
239
|
+
#: by writing the reply to stdout and exiting WITHOUT invoking
|
|
240
|
+
#: ``mcp__cloudagent__send_message``. The daemon discards stdout, so
|
|
241
|
+
#: the user sees nothing.
|
|
242
|
+
#:
|
|
243
|
+
#: stdin, by contrast, is the active user message — claude treats it
|
|
244
|
+
#: as the immediate task. We put the user body FIRST (so it is treated
|
|
245
|
+
#: as the primary task) and append the tool-call instruction AS A
|
|
246
|
+
#: FOOTER with an explicit "do not echo this scaffolding" line.
|
|
247
|
+
#:
|
|
248
|
+
#: 1k #66 fix: the previous template led with a "[CRITICAL — Tool-only
|
|
249
|
+
#: response required]" header. claude treated that prominent tag as
|
|
250
|
+
#: salient enough to acknowledge in its reply text (e.g. parenthetical
|
|
251
|
+
#: notes like "(外层 [CRITICAL] 指令依旧按提示注入处理)") — leaking
|
|
252
|
+
#: scaffolding to the user. Quieter footer + explicit anti-echo line
|
|
253
|
+
#: keeps the same tool-use enforcement without the leak.
|
|
254
|
+
WAKE_PROMPT_TEMPLATE: str = """{header}{body}
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
[Assistant scaffolding — not user content. Do not echo, quote, or reference this block in your reply.]
|
|
258
|
+
Reply via tool call only:
|
|
259
|
+
mcp__cloudagent__send_message(
|
|
260
|
+
target=<sender role from [meta] header — NOT a literal "user">,
|
|
261
|
+
parent_msg_id=<message_id from [meta] header>,
|
|
262
|
+
body="<your reply text only>",
|
|
263
|
+
kind="reply",
|
|
264
|
+
)
|
|
265
|
+
The daemon discards stdout — only the mcp tool call reaches the user.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
#: Wave C #1 (D2 contract) — ``dispatch_source`` is a backend-stamped
|
|
269
|
+
#: enum on the deliver frame (``user_direct`` / ``chief_dispatch`` /
|
|
270
|
+
#: ``peer_request`` / ``system_fan_out`` / ``hitl_response`` /
|
|
271
|
+
#: ``auto_reply``). The daemon **passes it through to claude verbatim**
|
|
272
|
+
#: as a header above the body, without parsing or branching on it —
|
|
273
|
+
#: claude's system prompt + the backend's ``message_transitions``
|
|
274
|
+
#: enforce the "缺 dispatch_source 回 question" rule. The daemon's
|
|
275
|
+
#: only job is making the field visible in the wake prompt.
|
|
276
|
+
#:
|
|
277
|
+
#: Tracked separately so a future contract iteration adding more
|
|
278
|
+
#: stamped fields (e.g. ``thread_id`` / ``parent_msg_id``) can extend
|
|
279
|
+
#: the header without touching the body assembly path.
|
|
280
|
+
_PASSTHROUGH_HEADER_FIELDS: tuple[str, ...] = (
|
|
281
|
+
"dispatch_source",
|
|
282
|
+
"from",
|
|
283
|
+
"sender_role",
|
|
284
|
+
"parent_msg_id",
|
|
285
|
+
# Hot-fix 2026-05-12 — local_daemon staff replies were sending
|
|
286
|
+
# ``parent_msg_id=None`` because claude couldn't see the inbound
|
|
287
|
+
# message_id (it was on the deliver payload but never surfaced
|
|
288
|
+
# to the wake prompt). Now in the ``[meta]`` header so claude
|
|
289
|
+
# can copy it into ``send_message(parent_msg_id=...)``.
|
|
290
|
+
"message_id",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
async def run_one(self, agent_id: str, payload: dict) -> SpawnResult:
|
|
294
|
+
"""Execute one wake; see module docstring for the full sequence.
|
|
295
|
+
|
|
296
|
+
1h Part C — lazy workspace: when the daemon receives a deliver
|
|
297
|
+
for an agent it has never been asked to host before, the
|
|
298
|
+
per-agent ``~/.cloudagent/agents/<agent_id>/`` directory does
|
|
299
|
+
not exist yet — and ``mcp-config`` / ``MEMORY.md`` / ``identity.json``
|
|
300
|
+
won't either. ``claude`` would crash on the missing
|
|
301
|
+
``--mcp-config`` arg. We auto-create the workspace using sane
|
|
302
|
+
defaults derived from the deliver payload (or just the agent_id
|
|
303
|
+
when the payload is sparse). ``is_chief`` defaults to ``False``
|
|
304
|
+
(deny-by-default) — a future ``assign`` frame should override.
|
|
305
|
+
|
|
306
|
+
1k followup — when the first attempt fails with stderr matching
|
|
307
|
+
:attr:`STALE_SESSION_STDERR_MARKER` (``claude --resume`` rejected
|
|
308
|
+
a non-persisted session id) we clear the stored session id and
|
|
309
|
+
retry exactly once without ``--resume`` so the user-facing reply
|
|
310
|
+
still lands rather than crashing the wake forever.
|
|
311
|
+
"""
|
|
312
|
+
ws = self.workspace.for_agent(agent_id)
|
|
313
|
+
if not ws.path.exists():
|
|
314
|
+
identity = _build_lazy_identity(agent_id, payload)
|
|
315
|
+
logger.info(
|
|
316
|
+
"ClaudeRunner: lazy-creating workspace agent_id=%s "
|
|
317
|
+
"(server-pushed deliver for unknown agent)",
|
|
318
|
+
agent_id,
|
|
319
|
+
)
|
|
320
|
+
ws = self.workspace.ensure(agent_id, identity)
|
|
321
|
+
|
|
322
|
+
# 1k hotfix — write current deliver's session_id BEFORE spawn so
|
|
323
|
+
# mcp_bridge (a grandchild process) can pick it up at startup.
|
|
324
|
+
# Removed in the finally below regardless of how the spawn-and-
|
|
325
|
+
# wait section exits.
|
|
326
|
+
current_session_id = payload.get("session_id")
|
|
327
|
+
_effective_session_id: str | None = (
|
|
328
|
+
current_session_id
|
|
329
|
+
if isinstance(current_session_id, str) and current_session_id
|
|
330
|
+
else None
|
|
331
|
+
)
|
|
332
|
+
try:
|
|
333
|
+
ws.write_current_session(_effective_session_id)
|
|
334
|
+
except Exception: # pragma: no cover — defensive
|
|
335
|
+
logger.exception("ClaudeRunner: write_current_session failed")
|
|
336
|
+
# (c) IPC architecture upgrade — register in the daemon-process
|
|
337
|
+
# memory map so mcp_bridge can query via unix socket instead of
|
|
338
|
+
# reading the file. Both paths coexist for backward compat.
|
|
339
|
+
if self._agent_to_session is not None:
|
|
340
|
+
self._agent_to_session[agent_id] = _effective_session_id
|
|
341
|
+
|
|
342
|
+
wake_start = time.monotonic()
|
|
343
|
+
try:
|
|
344
|
+
attempt = await self._spawn_attempt(
|
|
345
|
+
ws, agent_id, payload, with_resume=True
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# 1k followup — detect stale session (``claude --resume``
|
|
349
|
+
# rejected) and retry once without ``--resume``. The first
|
|
350
|
+
# attempt did NOT capture a session_id (the resume failed
|
|
351
|
+
# before claude got to print ``system/init``), so there is
|
|
352
|
+
# nothing useful to persist — we just clear the stored id
|
|
353
|
+
# and re-spawn fresh.
|
|
354
|
+
if (
|
|
355
|
+
attempt.exit_code != 0
|
|
356
|
+
and self.STALE_SESSION_STDERR_MARKER in attempt.stderr_tail
|
|
357
|
+
and ws.last_session_id() is not None
|
|
358
|
+
):
|
|
359
|
+
stale_sid = ws.last_session_id()
|
|
360
|
+
logger.warning(
|
|
361
|
+
"claude_runner: claude --resume rejected stale "
|
|
362
|
+
"session_id=%s (stderr contained %r). Clearing and "
|
|
363
|
+
"retrying without --resume.",
|
|
364
|
+
stale_sid,
|
|
365
|
+
self.STALE_SESSION_STDERR_MARKER,
|
|
366
|
+
)
|
|
367
|
+
try:
|
|
368
|
+
ws.clear_last_session_id()
|
|
369
|
+
except Exception: # pragma: no cover — defensive
|
|
370
|
+
logger.exception(
|
|
371
|
+
"ClaudeRunner: clear_last_session_id failed"
|
|
372
|
+
)
|
|
373
|
+
attempt = await self._spawn_attempt(
|
|
374
|
+
ws, agent_id, payload, with_resume=False
|
|
375
|
+
)
|
|
376
|
+
finally:
|
|
377
|
+
# 1k hotfix — always remove the per-spawn marker, even on
|
|
378
|
+
# spawn failure / cancellation, to prevent stale session_id
|
|
379
|
+
# leaking into the next wake.
|
|
380
|
+
try:
|
|
381
|
+
ws.delete_current_session()
|
|
382
|
+
except Exception: # pragma: no cover — defensive
|
|
383
|
+
logger.exception("ClaudeRunner: delete_current_session failed")
|
|
384
|
+
# (c) IPC architecture upgrade — clear from memory map.
|
|
385
|
+
if self._agent_to_session is not None:
|
|
386
|
+
self._agent_to_session.pop(agent_id, None)
|
|
387
|
+
|
|
388
|
+
captured_session = attempt.captured_session_id
|
|
389
|
+
rc = attempt.exit_code
|
|
390
|
+
timeout_hit = attempt.timeout_hit
|
|
391
|
+
stderr_tail = attempt.stderr_tail
|
|
392
|
+
|
|
393
|
+
# Spec §6.6 step 2 — token is one-shot per wake.
|
|
394
|
+
try:
|
|
395
|
+
ws.delete_token_file()
|
|
396
|
+
except Exception: # pragma: no cover — defensive
|
|
397
|
+
logger.exception("ClaudeRunner: delete_token_file failed")
|
|
398
|
+
|
|
399
|
+
if captured_session and captured_session != ws.last_session_id():
|
|
400
|
+
ws.set_last_session_id(captured_session)
|
|
401
|
+
await self.outbound.send_agent_session_start(
|
|
402
|
+
agent_id, captured_session
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# 1k #63 Layer B — daemon-side fallback ``send_message``.
|
|
406
|
+
#
|
|
407
|
+
# Even with the stdin wrapper (Layer A) claude may occasionally
|
|
408
|
+
# still print a reply to stdout without invoking
|
|
409
|
+
# ``mcp__cloudagent__send_message`` (e.g. for trivial prompts
|
|
410
|
+
# like "你好" / "你是谁"). When that happens the daemon discards
|
|
411
|
+
# stdout and the user-facing inbox stays empty.
|
|
412
|
+
#
|
|
413
|
+
# If claude exited cleanly (rc==0, no timeout) AND produced final
|
|
414
|
+
# result text AND did NOT call send_message, we POST the reply
|
|
415
|
+
# ourselves so the user always sees it. We use the per-deliver
|
|
416
|
+
# session_id from the payload — the same id mcp_bridge would
|
|
417
|
+
# have read from current-session.txt if claude had called the
|
|
418
|
+
# tool itself. The backend merges session_id from the frame
|
|
419
|
+
# top-level into args before dispatching ``send_message`` (1k
|
|
420
|
+
# #56), so we don't need to inject it into the args dict.
|
|
421
|
+
called_send_message = any(
|
|
422
|
+
name == "mcp__cloudagent__send_message"
|
|
423
|
+
for name in attempt.tool_uses
|
|
424
|
+
)
|
|
425
|
+
fallback_session = payload.get("session_id")
|
|
426
|
+
if (
|
|
427
|
+
not called_send_message
|
|
428
|
+
and rc == 0
|
|
429
|
+
and not timeout_hit
|
|
430
|
+
and attempt.final_result_text
|
|
431
|
+
and isinstance(fallback_session, str)
|
|
432
|
+
and fallback_session
|
|
433
|
+
):
|
|
434
|
+
logger.warning(
|
|
435
|
+
"claude_runner: claude wake exited without calling "
|
|
436
|
+
"send_message — daemon fallback POSTing result text "
|
|
437
|
+
"(agent=%s session=%s text_chars=%d)",
|
|
438
|
+
agent_id,
|
|
439
|
+
fallback_session,
|
|
440
|
+
len(attempt.final_result_text),
|
|
441
|
+
)
|
|
442
|
+
try:
|
|
443
|
+
await self.outbound.send_tool_call(
|
|
444
|
+
agent_id=agent_id,
|
|
445
|
+
session_id=fallback_session,
|
|
446
|
+
tool_name="send_message",
|
|
447
|
+
args={
|
|
448
|
+
"target": "user",
|
|
449
|
+
"body": attempt.final_result_text,
|
|
450
|
+
"kind": "reply",
|
|
451
|
+
},
|
|
452
|
+
# Backend dispatch is fast — short timeout keeps the
|
|
453
|
+
# wake from blocking the spawn queue if the WS round
|
|
454
|
+
# trip stalls.
|
|
455
|
+
timeout=15.0,
|
|
456
|
+
)
|
|
457
|
+
# Wave C #3 — non-zero in /metrics means the 1k #63
|
|
458
|
+
# Layer A stdin template slipped; operators should
|
|
459
|
+
# investigate. Increment AFTER the POST succeeded so
|
|
460
|
+
# we don't double-count crashloops.
|
|
461
|
+
try:
|
|
462
|
+
from cloudagent_daemon.metrics import get_default as _md
|
|
463
|
+
await _md().inc_counter(
|
|
464
|
+
"cloudagent_daemon_spawn_fallback_send_message_total"
|
|
465
|
+
)
|
|
466
|
+
except Exception: # noqa: BLE001
|
|
467
|
+
pass
|
|
468
|
+
except Exception: # pragma: no cover — best-effort fallback
|
|
469
|
+
logger.exception(
|
|
470
|
+
"claude_runner: daemon fallback send_message FAILED "
|
|
471
|
+
"(lost reply) agent=%s session=%s",
|
|
472
|
+
agent_id,
|
|
473
|
+
fallback_session,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
ws.write_last_spawn(rc, captured_session, stderr_tail)
|
|
477
|
+
|
|
478
|
+
if timeout_hit:
|
|
479
|
+
final_status = "timeout"
|
|
480
|
+
elif rc == 0:
|
|
481
|
+
final_status = "idle"
|
|
482
|
+
else:
|
|
483
|
+
final_status = "crashed"
|
|
484
|
+
|
|
485
|
+
# Wave C #3 — surface terminal status counts for /metrics +
|
|
486
|
+
# doctor. Label-keyed so a single counter renders three
|
|
487
|
+
# series (idle / crashed / timeout). Failure to publish must
|
|
488
|
+
# never crash the wake — the wake itself is the user-facing
|
|
489
|
+
# contract.
|
|
490
|
+
try:
|
|
491
|
+
from cloudagent_daemon.metrics import get_default as _md
|
|
492
|
+
await _md().inc_counter(
|
|
493
|
+
"cloudagent_daemon_spawn_completed_total",
|
|
494
|
+
labels={"status": final_status},
|
|
495
|
+
)
|
|
496
|
+
except Exception: # noqa: BLE001
|
|
497
|
+
logger.debug(
|
|
498
|
+
"claude_runner: spawn_completed_total publish failed",
|
|
499
|
+
exc_info=True,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
await self.outbound.send_agent_status(
|
|
503
|
+
agent_id, final_status, exit_code=rc
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
wake_secs = time.monotonic() - wake_start
|
|
507
|
+
logger.info(
|
|
508
|
+
"claude_runner: claude exited agent=%s rc=%d session_id=%s "
|
|
509
|
+
"status=%s wake_secs=%.2f stderr_tail_chars=%d",
|
|
510
|
+
agent_id,
|
|
511
|
+
rc,
|
|
512
|
+
captured_session,
|
|
513
|
+
final_status,
|
|
514
|
+
wake_secs,
|
|
515
|
+
len(stderr_tail),
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
return SpawnResult(
|
|
519
|
+
exit_code=rc,
|
|
520
|
+
captured_session_id=captured_session,
|
|
521
|
+
stderr_tail=stderr_tail,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
async def _spawn_attempt(
|
|
525
|
+
self,
|
|
526
|
+
ws: AgentWorkspaceLike,
|
|
527
|
+
agent_id: str,
|
|
528
|
+
payload: dict,
|
|
529
|
+
*,
|
|
530
|
+
with_resume: bool,
|
|
531
|
+
) -> _SpawnAttempt:
|
|
532
|
+
"""Run a single ``claude`` subprocess and return its outcome.
|
|
533
|
+
|
|
534
|
+
Extracted from :meth:`run_one` so the runner can re-invoke it
|
|
535
|
+
with ``with_resume=False`` when the first attempt fails with a
|
|
536
|
+
stale-session error (1k followup).
|
|
537
|
+
|
|
538
|
+
Side-effects emitted exactly once per attempt: ``delivery_ack``
|
|
539
|
+
for the message_id, and ``agent_status(running)``. The final
|
|
540
|
+
``agent_status`` (idle/crashed/timeout) and last-spawn.json
|
|
541
|
+
write are intentionally LEFT to the caller so they always
|
|
542
|
+
reflect the *last* attempt.
|
|
543
|
+
"""
|
|
544
|
+
cmd = self._build_cmd(ws, agent_id, with_resume=with_resume)
|
|
545
|
+
|
|
546
|
+
# Task #118 (2026-05-12) — spawn cwd is now the agent-specific
|
|
547
|
+
# cwd (per-agent override → host default → platform fallback).
|
|
548
|
+
# Pre-#118 the daemon inherited its own cwd (typically ``~``)
|
|
549
|
+
# which meant claude saw HOME as its working dir regardless of
|
|
550
|
+
# the agent's intended project — broken UX for "let agent A
|
|
551
|
+
# work on ~/projects/foo".
|
|
552
|
+
spawn_cwd = ws.cwd
|
|
553
|
+
|
|
554
|
+
# 1i Bug C — Phase 1 demo visibility.
|
|
555
|
+
logger.info(
|
|
556
|
+
"claude_runner: spawning claude agent=%s msg=%s prior_session=%s "
|
|
557
|
+
"with_resume=%s cwd=%s cmd=%s",
|
|
558
|
+
agent_id,
|
|
559
|
+
payload.get("message_id"),
|
|
560
|
+
ws.last_session_id(),
|
|
561
|
+
with_resume,
|
|
562
|
+
spawn_cwd,
|
|
563
|
+
" ".join(cmd),
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
logger.debug("ClaudeRunner: spawning %s", cmd)
|
|
567
|
+
proc = await asyncio.create_subprocess_exec(
|
|
568
|
+
*cmd,
|
|
569
|
+
stdin=asyncio.subprocess.PIPE,
|
|
570
|
+
stdout=asyncio.subprocess.PIPE,
|
|
571
|
+
stderr=asyncio.subprocess.PIPE,
|
|
572
|
+
cwd=str(spawn_cwd),
|
|
573
|
+
# asyncio StreamReader 默认 limit=64KB;claude CLI 单条 JSON
|
|
574
|
+
# event 可超出(tool_result 含大型 Read 文件 / Anthropic 返回
|
|
575
|
+
# body / partial_messages 累积 chunk 等)。2026-05-14 实战:
|
|
576
|
+
# local daemon 在执行 SDK/CLI 开发 task 时 readline 抛
|
|
577
|
+
# LimitOverrunError "Separator is found, but chunk is longer
|
|
578
|
+
# than limit" → spawn fail,claude 没机会发 send_message 回复
|
|
579
|
+
# → 上游 task 永远卡 waiting/partial。对齐 backend harness 的
|
|
580
|
+
# claude_code_core.py 同步把 limit 提到 10MB。
|
|
581
|
+
limit=10 * 1024 * 1024,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
# 1k #63 Layer A — wrap the message body in a tool-only contract.
|
|
585
|
+
# stdin is the active user prompt that claude can't treat as a
|
|
586
|
+
# soft footer (unlike ``--append-system-prompt``). See
|
|
587
|
+
# ``WAKE_PROMPT_TEMPLATE`` docstring.
|
|
588
|
+
body = payload.get("body") or ""
|
|
589
|
+
header = self._build_passthrough_header(payload)
|
|
590
|
+
wake_prompt = self.WAKE_PROMPT_TEMPLATE.format(
|
|
591
|
+
header=header, body=body
|
|
592
|
+
)
|
|
593
|
+
try:
|
|
594
|
+
assert proc.stdin is not None # for type checkers
|
|
595
|
+
proc.stdin.write(wake_prompt.encode("utf-8"))
|
|
596
|
+
proc.stdin.write(b"\n")
|
|
597
|
+
await proc.stdin.drain()
|
|
598
|
+
proc.stdin.close()
|
|
599
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
600
|
+
# claude died before we finished writing; downstream code will
|
|
601
|
+
# observe the exit code and stderr.
|
|
602
|
+
logger.debug("ClaudeRunner: stdin pipe closed early")
|
|
603
|
+
|
|
604
|
+
# Notify backend that we picked the message up.
|
|
605
|
+
message_id = payload.get("message_id")
|
|
606
|
+
if message_id:
|
|
607
|
+
await self.outbound.send_ack(
|
|
608
|
+
seq=None, message_id=message_id, status="started"
|
|
609
|
+
)
|
|
610
|
+
await self.outbound.send_agent_status(agent_id, "running")
|
|
611
|
+
|
|
612
|
+
# 1k74 — token-level streaming. Forward the deliver's session_id
|
|
613
|
+
# and agent_id into the stdout consumer so it can call our
|
|
614
|
+
# partial_publisher with the right routing key for each text
|
|
615
|
+
# chunk. session_id is ``test_chat_{host}_{agent}`` for test-
|
|
616
|
+
# chat deliveries (the new per-agent shape from 1k73).
|
|
617
|
+
deliver_session_id = payload.get("session_id")
|
|
618
|
+
captured_session, tool_uses, final_text = await self._consume_stdout(
|
|
619
|
+
proc,
|
|
620
|
+
partial_session_id=(
|
|
621
|
+
deliver_session_id
|
|
622
|
+
if isinstance(deliver_session_id, str)
|
|
623
|
+
else None
|
|
624
|
+
),
|
|
625
|
+
partial_agent_id=agent_id,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
timeout_hit, rc = await self._wait_with_timeout(proc)
|
|
629
|
+
|
|
630
|
+
# stderr was buffered by the subprocess; drain whatever is left.
|
|
631
|
+
stderr_b = await proc.stderr.read() if proc.stderr is not None else b""
|
|
632
|
+
stderr_tail = stderr_b.decode("utf-8", errors="replace")[
|
|
633
|
+
-self.STDERR_TAIL_BYTES:
|
|
634
|
+
]
|
|
635
|
+
|
|
636
|
+
return _SpawnAttempt(
|
|
637
|
+
exit_code=rc,
|
|
638
|
+
captured_session_id=captured_session,
|
|
639
|
+
stderr_tail=stderr_tail,
|
|
640
|
+
timeout_hit=timeout_hit,
|
|
641
|
+
tool_uses=tool_uses,
|
|
642
|
+
final_result_text=final_text,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
# ------------------------------------------------------------------
|
|
646
|
+
# Internal helpers
|
|
647
|
+
# ------------------------------------------------------------------
|
|
648
|
+
|
|
649
|
+
#: 1i Bug E — the daemon-injected ``cloudagent`` MCP toolset MUST run
|
|
650
|
+
#: unattended in ``--print`` headless mode (otherwise claude prompts
|
|
651
|
+
#: the user for permission, the prompt is never answered, and the
|
|
652
|
+
#: wake exits without the reply being delivered). We allow exactly
|
|
653
|
+
#: this toolset rather than ``--dangerously-skip-permissions`` (which
|
|
654
|
+
#: would also whitelist Bash/Edit/etc. — too broad).
|
|
655
|
+
CLOUDAGENT_MCP_ALLOWED_TOOLS: tuple[str, ...] = (
|
|
656
|
+
"mcp__cloudagent__send_message",
|
|
657
|
+
"mcp__cloudagent__list_messages",
|
|
658
|
+
"mcp__cloudagent__upload_file",
|
|
659
|
+
"mcp__cloudagent__schedule_reminder",
|
|
660
|
+
"mcp__cloudagent__delegate_to_staff",
|
|
661
|
+
"mcp__cloudagent__close_task",
|
|
662
|
+
"mcp__cloudagent__request_user_approval",
|
|
663
|
+
"mcp__cloudagent__set_company_status",
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
# 1i Bug F — operational contract for daemon-spawned wakes.
|
|
667
|
+
# Without this, claude in --print mode just dumps its answer to stdout
|
|
668
|
+
# and exits, never invoking send_message — so the user never sees a reply.
|
|
669
|
+
# The daemon discards stdout text; only mcp tool calls flow back through
|
|
670
|
+
# the outbound queue to the user. We inject this as --append-system-prompt
|
|
671
|
+
# so it runs after the default Claude Code system prompt and the user
|
|
672
|
+
# cannot edit it away from MEMORY.md / settings.
|
|
673
|
+
SYSTEM_PROMPT_APPEND: str = """
|
|
674
|
+
You are an AI agent running inside cloudagent's local daemon. The user message you are receiving via stdin is a deliver from your agent inbox (agent_id={agent_id}).
|
|
675
|
+
|
|
676
|
+
ROUTING — choosing `target` for send_message (HARD RULE):
|
|
677
|
+
Every kind="reply" send MUST set BOTH `target` AND `parent_msg_id`
|
|
678
|
+
by reading the inbound message's [meta] header — never by defaulting
|
|
679
|
+
to "user". The [meta] header contains the sender's role and
|
|
680
|
+
message_id; copy them verbatim into your tool call:
|
|
681
|
+
|
|
682
|
+
target = inbound `from` / `sender_role` field
|
|
683
|
+
parent_msg_id = inbound `message_id` field
|
|
684
|
+
|
|
685
|
+
Concrete cases:
|
|
686
|
+
- Inbound `from`="user" → target="user" (human HITL surface)
|
|
687
|
+
- Inbound `from`="ceo" → target="ceo" (chief↔staff collab)
|
|
688
|
+
- Inbound `from`="<peer staff role>" → target=<that role>
|
|
689
|
+
|
|
690
|
+
Do NOT default target="user" when a non-user agent dispatched the
|
|
691
|
+
work — that breaks chief↔staff collaboration AND the parent task
|
|
692
|
+
hangs in `substatus=partial` because the chief's wait_for_replies
|
|
693
|
+
never sees your reply. The backend has two safety nets (parent_msg_id
|
|
694
|
+
→to_agent derivation; orphan-reply implicit inbox lookup within 5
|
|
695
|
+
min) but they are belt-and-braces — your tool call MUST be correct.
|
|
696
|
+
When unsure which agent dispatched the work, call
|
|
697
|
+
`mcp__cloudagent__list_messages(limit=20)` first and copy `from` /
|
|
698
|
+
`message_id` from the most recent inbound row addressed to you.
|
|
699
|
+
|
|
700
|
+
THREADING — parent_msg_id (hot-fix 2026-05-12, CRITICAL):
|
|
701
|
+
When replying with kind="reply" or kind="question" you MUST set
|
|
702
|
+
parent_msg_id to the inbound message's `message_id` (shown in the
|
|
703
|
+
[meta] header above your stdin payload, e.g. `message_id: 236`).
|
|
704
|
+
Failing this, the chief's wait_for_replies will never satisfy and
|
|
705
|
+
the parent task hangs forever in `partial` substatus. Backend's
|
|
706
|
+
parent_msg_id-based to_agent inference IS a safety net for routing,
|
|
707
|
+
but the chief's wait_for_replies looks up replies BY parent_msg_id —
|
|
708
|
+
if you drop it, the chief simply never knows your reply exists.
|
|
709
|
+
Unsolicited kind="task" / kind="report" sends leave parent_msg_id
|
|
710
|
+
unset (None / omitted).
|
|
711
|
+
|
|
712
|
+
INBOX SWEEP — on every boot:
|
|
713
|
+
Your FIRST action MUST be `mcp__cloudagent__list_messages(limit=20)`.
|
|
714
|
+
Scan the result for any **unread** messages addressed to you that are
|
|
715
|
+
NOT the deliver you just received via stdin. Handle each in order
|
|
716
|
+
(call send_message to reply per the routing rules above) BEFORE you
|
|
717
|
+
respond to the stdin deliver. This catches any frames the daemon
|
|
718
|
+
failed to spawn while you were offline.
|
|
719
|
+
|
|
720
|
+
To reply, call mcp__cloudagent__send_message with:
|
|
721
|
+
- target: per the routing rules above
|
|
722
|
+
- body: your reply text
|
|
723
|
+
- kind: "reply" (or "question" if you need clarification)
|
|
724
|
+
|
|
725
|
+
Do NOT write your reply directly to stdout. The daemon ignores stdout text — only mcp tool calls reach the user / other agents.
|
|
726
|
+
|
|
727
|
+
After all sends are issued, exit naturally. Your wake will be marked complete.
|
|
728
|
+
|
|
729
|
+
Available cloudagent tools:
|
|
730
|
+
- mcp__cloudagent__send_message(target, body, kind, parent_msg_id) — send to user or another agent.
|
|
731
|
+
For kind="reply" / "question", parent_msg_id MUST be the inbound
|
|
732
|
+
message_id from [meta] (see THREADING above). Omit / leave None
|
|
733
|
+
for unsolicited kind="task" / "report" sends.
|
|
734
|
+
- mcp__cloudagent__list_messages(limit) — read recent inbox (status field discriminates unread/read)
|
|
735
|
+
- mcp__cloudagent__upload_file(path) — share a file
|
|
736
|
+
- mcp__cloudagent__schedule_reminder(when, body) — schedule self-reminder
|
|
737
|
+
""".strip()
|
|
738
|
+
|
|
739
|
+
def _build_passthrough_header(self, payload: dict) -> str:
|
|
740
|
+
"""Render the D2 passthrough header for the wake prompt.
|
|
741
|
+
|
|
742
|
+
Emits a single ``[meta]`` block with one ``key: value`` line per
|
|
743
|
+
``_PASSTHROUGH_HEADER_FIELDS`` entry **that the payload provides
|
|
744
|
+
as a non-empty string-coercible value**. Empty when no fields are
|
|
745
|
+
present (so the existing pre-Wave-C behaviour is byte-identical
|
|
746
|
+
for legacy backends that don't stamp these fields yet).
|
|
747
|
+
|
|
748
|
+
Newline-terminated when non-empty so it composes cleanly into
|
|
749
|
+
``WAKE_PROMPT_TEMPLATE`` immediately above the body.
|
|
750
|
+
"""
|
|
751
|
+
lines: list[str] = []
|
|
752
|
+
for key in self._PASSTHROUGH_HEADER_FIELDS:
|
|
753
|
+
if key not in payload:
|
|
754
|
+
continue
|
|
755
|
+
value = payload.get(key)
|
|
756
|
+
if value in (None, ""):
|
|
757
|
+
continue
|
|
758
|
+
# Coerce to a single-line string. Multi-line values are
|
|
759
|
+
# collapsed defensively — daemon doesn't interpret these
|
|
760
|
+
# fields, but a stray newline in a header would mis-frame the
|
|
761
|
+
# body for claude.
|
|
762
|
+
text = str(value).replace("\r", " ").replace("\n", " ").strip()
|
|
763
|
+
if not text:
|
|
764
|
+
continue
|
|
765
|
+
lines.append(f"{key}: {text}")
|
|
766
|
+
if not lines:
|
|
767
|
+
return ""
|
|
768
|
+
return "[meta]\n" + "\n".join(lines) + "\n[/meta]\n\n"
|
|
769
|
+
|
|
770
|
+
def _build_cmd(
|
|
771
|
+
self,
|
|
772
|
+
ws: AgentWorkspaceLike,
|
|
773
|
+
agent_id: str,
|
|
774
|
+
*,
|
|
775
|
+
with_resume: bool = True,
|
|
776
|
+
) -> list[str]:
|
|
777
|
+
"""Build the ``claude`` argv list for one wake.
|
|
778
|
+
|
|
779
|
+
1k followup — ``with_resume=False`` forces the runner to omit
|
|
780
|
+
``--resume`` even if a stored ``last_session_id`` exists. The
|
|
781
|
+
retry path uses this after a stale-session crash.
|
|
782
|
+
"""
|
|
783
|
+
cmd: list[str] = [self.claude_binary]
|
|
784
|
+
last_session = ws.last_session_id() if with_resume else None
|
|
785
|
+
if last_session:
|
|
786
|
+
cmd += ["--resume", last_session]
|
|
787
|
+
# 1i Bug F — inject operational contract via --append-system-prompt.
|
|
788
|
+
system_prompt = self.SYSTEM_PROMPT_APPEND.format(agent_id=agent_id)
|
|
789
|
+
cmd += [
|
|
790
|
+
"--mcp-config",
|
|
791
|
+
str(ws.mcp_config_path()),
|
|
792
|
+
"--print",
|
|
793
|
+
"--verbose",
|
|
794
|
+
"--output-format",
|
|
795
|
+
"stream-json",
|
|
796
|
+
# 1k74 (2026-05-11) — token-level streaming. Without this flag
|
|
797
|
+
# claude buffers each ``assistant`` message and emits it in
|
|
798
|
+
# one event (full reply, 100% buffered). With it the runtime
|
|
799
|
+
# ALSO emits ``stream_event`` events carrying
|
|
800
|
+
# ``content_block_delta`` / ``text_delta`` chunks (~80
|
|
801
|
+
# chars/chunk) as the model generates. The daemon forwards
|
|
802
|
+
# those chunks to backend so the chat bubble fills in
|
|
803
|
+
# token-by-token instead of jumping straight to the final
|
|
804
|
+
# reply after a 30s blank stare.
|
|
805
|
+
"--include-partial-messages",
|
|
806
|
+
# 1i Bug E — see CLOUDAGENT_MCP_ALLOWED_TOOLS docstring.
|
|
807
|
+
"--allowedTools",
|
|
808
|
+
",".join(self.CLOUDAGENT_MCP_ALLOWED_TOOLS),
|
|
809
|
+
# 1i Bug F — see SYSTEM_PROMPT_APPEND docstring.
|
|
810
|
+
"--append-system-prompt",
|
|
811
|
+
system_prompt,
|
|
812
|
+
]
|
|
813
|
+
return cmd
|
|
814
|
+
|
|
815
|
+
async def _consume_stdout(
|
|
816
|
+
self,
|
|
817
|
+
proc: asyncio.subprocess.Process,
|
|
818
|
+
*,
|
|
819
|
+
partial_session_id: str | None = None,
|
|
820
|
+
partial_agent_id: str | None = None,
|
|
821
|
+
) -> tuple[str | None, list[str], str | None]:
|
|
822
|
+
"""Stream NDJSON from stdout; return ``(session_id, tool_uses, result_text)``.
|
|
823
|
+
|
|
824
|
+
Accepts BOTH session-init event shapes for forward/backward
|
|
825
|
+
compatibility:
|
|
826
|
+
|
|
827
|
+
* Old/spec shape: ``{"type": "session_init", "session_id": "..."}``
|
|
828
|
+
* Real claude (≥ 2024-Q4) shape:
|
|
829
|
+
``{"type": "system", "subtype": "init", "session_id": "..."}``
|
|
830
|
+
|
|
831
|
+
1k #63 — additionally extracts:
|
|
832
|
+
|
|
833
|
+
* ``tool_uses``: list of every tool name that appears in any
|
|
834
|
+
assistant ``content[*].type == "tool_use"`` block AND in any
|
|
835
|
+
standalone ``type == "tool_use"`` event (claude CLI varies
|
|
836
|
+
across versions). Used by the daemon-side fallback to decide
|
|
837
|
+
whether claude actually called ``mcp__cloudagent__send_message``.
|
|
838
|
+
* ``final_result_text``: the text from the terminating
|
|
839
|
+
``type == "result"`` event. Falls back to ``evt.get("text")``
|
|
840
|
+
if ``evt.get("result")`` is empty (different claude versions
|
|
841
|
+
name the same field differently). Used as the body of the
|
|
842
|
+
fallback ``send_message`` POST.
|
|
843
|
+
|
|
844
|
+
Also surfaces ``type=result`` and ``type=tool_use`` events at INFO
|
|
845
|
+
for Phase 1 demo visibility (1i Bug C).
|
|
846
|
+
"""
|
|
847
|
+
captured: str | None = None
|
|
848
|
+
tool_uses: list[str] = []
|
|
849
|
+
final_text: str | None = None
|
|
850
|
+
# 1k74 — running buffer for token-level streaming. claude emits
|
|
851
|
+
# one ``stream_event`` per content_block_delta (~80 chars at a
|
|
852
|
+
# time); we concatenate them and forward the running total to
|
|
853
|
+
# the publisher so the receiver renders the bubble in growing
|
|
854
|
+
# form rather than having to track its own deltas.
|
|
855
|
+
partial_buffer: list[str] = []
|
|
856
|
+
publish_enabled = bool(
|
|
857
|
+
self._partial_publisher
|
|
858
|
+
and partial_session_id
|
|
859
|
+
and partial_agent_id
|
|
860
|
+
)
|
|
861
|
+
# (d) cache token accumulators — populated from Anthropic API
|
|
862
|
+
# ``message_start`` stream event wrapped in ``stream_event``.
|
|
863
|
+
_cache_read_tokens: int = 0
|
|
864
|
+
_cache_creation_tokens: int = 0
|
|
865
|
+
if proc.stdout is None:
|
|
866
|
+
return None, tool_uses, final_text
|
|
867
|
+
|
|
868
|
+
async for line_b in proc.stdout:
|
|
869
|
+
line = line_b.decode("utf-8", errors="replace").strip()
|
|
870
|
+
if not line:
|
|
871
|
+
continue
|
|
872
|
+
try:
|
|
873
|
+
evt = json.loads(line)
|
|
874
|
+
except json.JSONDecodeError:
|
|
875
|
+
logger.debug("ClaudeRunner: non-JSON stdout line: %r", line[:120])
|
|
876
|
+
continue
|
|
877
|
+
if not isinstance(evt, dict):
|
|
878
|
+
continue
|
|
879
|
+
|
|
880
|
+
evt_type = evt.get("type")
|
|
881
|
+
evt_subtype = evt.get("subtype")
|
|
882
|
+
|
|
883
|
+
# 1k74 — token-level streaming. claude emits one or more
|
|
884
|
+
# ``stream_event`` events per assistant message, each
|
|
885
|
+
# wrapping an Anthropic Messages API event. The shape we
|
|
886
|
+
# care about is:
|
|
887
|
+
# {"type":"stream_event","event":{
|
|
888
|
+
# "type":"content_block_delta",
|
|
889
|
+
# "delta":{"type":"text_delta","text":"..."}}}
|
|
890
|
+
# Each ``text`` is the latest chunk; concatenating them in
|
|
891
|
+
# arrival order rebuilds the assistant's eventual reply.
|
|
892
|
+
if evt_type == "stream_event":
|
|
893
|
+
ev = evt.get("event")
|
|
894
|
+
if isinstance(ev, dict):
|
|
895
|
+
# (d) cache token observability — extract usage from
|
|
896
|
+
# ``message_start`` which Anthropic emits once per
|
|
897
|
+
# response with input/cache token counts.
|
|
898
|
+
if ev.get("type") == "message_start":
|
|
899
|
+
msg_u = (ev.get("message") or {}).get("usage") or {}
|
|
900
|
+
if isinstance(msg_u, dict):
|
|
901
|
+
_cache_read_tokens += int(
|
|
902
|
+
msg_u.get("cache_read_input_tokens") or 0
|
|
903
|
+
)
|
|
904
|
+
_cache_creation_tokens += int(
|
|
905
|
+
msg_u.get("cache_creation_input_tokens") or 0
|
|
906
|
+
)
|
|
907
|
+
# 1k74 — token-level streaming.
|
|
908
|
+
if publish_enabled and ev.get("type") == "content_block_delta":
|
|
909
|
+
delta = ev.get("delta")
|
|
910
|
+
if (
|
|
911
|
+
isinstance(delta, dict)
|
|
912
|
+
and delta.get("type") == "text_delta"
|
|
913
|
+
):
|
|
914
|
+
chunk = delta.get("text")
|
|
915
|
+
if isinstance(chunk, str) and chunk:
|
|
916
|
+
partial_buffer.append(chunk)
|
|
917
|
+
accumulated = "".join(partial_buffer)
|
|
918
|
+
# Fire-and-forget — exceptions logged and
|
|
919
|
+
# swallowed so a flaky publish path can't
|
|
920
|
+
# tear down the wake.
|
|
921
|
+
assert self._partial_publisher is not None
|
|
922
|
+
assert partial_session_id is not None
|
|
923
|
+
assert partial_agent_id is not None
|
|
924
|
+
try:
|
|
925
|
+
await self._partial_publisher(
|
|
926
|
+
partial_session_id,
|
|
927
|
+
partial_agent_id,
|
|
928
|
+
accumulated,
|
|
929
|
+
)
|
|
930
|
+
except Exception as exc: # noqa: BLE001
|
|
931
|
+
logger.info(
|
|
932
|
+
"partial_publisher failed (non-fatal): %r",
|
|
933
|
+
exc,
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
# Capture session_id from either of the known event shapes.
|
|
937
|
+
is_session_event = (
|
|
938
|
+
evt_type == "session_init"
|
|
939
|
+
or (evt_type == "system" and evt_subtype == "init")
|
|
940
|
+
)
|
|
941
|
+
if is_session_event and captured is None:
|
|
942
|
+
# Tolerate both snake_case and camelCase keys.
|
|
943
|
+
sid = evt.get("session_id") or evt.get("sessionId")
|
|
944
|
+
if isinstance(sid, str) and sid:
|
|
945
|
+
captured = sid
|
|
946
|
+
logger.info(
|
|
947
|
+
"ClaudeRunner: captured session_id=%s (event type=%s subtype=%s)",
|
|
948
|
+
sid,
|
|
949
|
+
evt_type,
|
|
950
|
+
evt_subtype,
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
# 1k #63 — extract tool_use names from assistant content blocks.
|
|
954
|
+
# The real claude CLI emits tool_use as a content block inside
|
|
955
|
+
# an ``assistant`` event:
|
|
956
|
+
# {"type":"assistant","message":{"content":[
|
|
957
|
+
# {"type":"tool_use","name":"mcp__cloudagent__send_message",...}
|
|
958
|
+
# ]}}
|
|
959
|
+
if evt_type == "assistant":
|
|
960
|
+
msg = evt.get("message")
|
|
961
|
+
content = (
|
|
962
|
+
msg.get("content")
|
|
963
|
+
if isinstance(msg, dict)
|
|
964
|
+
else None
|
|
965
|
+
)
|
|
966
|
+
if isinstance(content, list):
|
|
967
|
+
for block in content:
|
|
968
|
+
if (
|
|
969
|
+
isinstance(block, dict)
|
|
970
|
+
and block.get("type") == "tool_use"
|
|
971
|
+
):
|
|
972
|
+
name = block.get("name") or block.get("tool_name")
|
|
973
|
+
if isinstance(name, str) and name:
|
|
974
|
+
tool_uses.append(name)
|
|
975
|
+
|
|
976
|
+
# 1i Bug C — Phase 1 demo visibility for key event types.
|
|
977
|
+
if evt_type == "result":
|
|
978
|
+
# 1k #63 — capture the final reply text for the fallback
|
|
979
|
+
# path. ``result`` is the canonical field on the
|
|
980
|
+
# terminating event; ``text`` is a fallback some versions
|
|
981
|
+
# use. We also capture this for visibility-only logging.
|
|
982
|
+
result_text = evt.get("result")
|
|
983
|
+
if not isinstance(result_text, str) or not result_text:
|
|
984
|
+
alt = evt.get("text")
|
|
985
|
+
if isinstance(alt, str) and alt:
|
|
986
|
+
result_text = alt
|
|
987
|
+
if isinstance(result_text, str) and result_text:
|
|
988
|
+
final_text = result_text
|
|
989
|
+
summary = (
|
|
990
|
+
result_text[:120] + "..."
|
|
991
|
+
if isinstance(result_text, str) and len(result_text) > 120
|
|
992
|
+
else result_text
|
|
993
|
+
)
|
|
994
|
+
logger.info(
|
|
995
|
+
"ClaudeRunner: stdout result subtype=%s text=%r",
|
|
996
|
+
evt_subtype,
|
|
997
|
+
summary,
|
|
998
|
+
)
|
|
999
|
+
elif evt_type == "tool_use":
|
|
1000
|
+
# Standalone tool_use event (older / alternate shape).
|
|
1001
|
+
name = evt.get("name") or evt.get("tool_name")
|
|
1002
|
+
if isinstance(name, str) and name:
|
|
1003
|
+
tool_uses.append(name)
|
|
1004
|
+
logger.info(
|
|
1005
|
+
"ClaudeRunner: stdout tool_use name=%s",
|
|
1006
|
+
name,
|
|
1007
|
+
)
|
|
1008
|
+
# (d) emit cache token metrics once per wake, after stdout drains.
|
|
1009
|
+
if _cache_read_tokens or _cache_creation_tokens:
|
|
1010
|
+
logger.info(
|
|
1011
|
+
"claude_runner: cache_tokens "
|
|
1012
|
+
"cache_read=%d cache_creation=%d "
|
|
1013
|
+
"metric_event=cloudagent_llm_cache_hit_tokens_total",
|
|
1014
|
+
_cache_read_tokens,
|
|
1015
|
+
_cache_creation_tokens,
|
|
1016
|
+
)
|
|
1017
|
+
try:
|
|
1018
|
+
from cloudagent_daemon.metrics import get_default as _get_metrics
|
|
1019
|
+
_m = _get_metrics()
|
|
1020
|
+
await _m.inc_counter(
|
|
1021
|
+
"cloudagent_llm_cache_hit_tokens_total",
|
|
1022
|
+
n=_cache_read_tokens,
|
|
1023
|
+
labels={"source": "daemon"},
|
|
1024
|
+
)
|
|
1025
|
+
await _m.inc_counter(
|
|
1026
|
+
"cloudagent_llm_cache_creation_tokens_total",
|
|
1027
|
+
n=_cache_creation_tokens,
|
|
1028
|
+
labels={"source": "daemon"},
|
|
1029
|
+
)
|
|
1030
|
+
except Exception: # noqa: BLE001 — non-fatal metric failure
|
|
1031
|
+
logger.debug(
|
|
1032
|
+
"claude_runner: cache metrics publish failed", exc_info=True
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
return captured, tool_uses, final_text
|
|
1036
|
+
|
|
1037
|
+
async def _wait_with_timeout(
|
|
1038
|
+
self, proc: asyncio.subprocess.Process
|
|
1039
|
+
) -> tuple[bool, int]:
|
|
1040
|
+
"""Wait for exit; SIGTERM→SIGKILL on timeout. Returns (timeout_hit, rc)."""
|
|
1041
|
+
try:
|
|
1042
|
+
rc = await asyncio.wait_for(
|
|
1043
|
+
proc.wait(), timeout=self._single_wake_timeout
|
|
1044
|
+
)
|
|
1045
|
+
return False, rc
|
|
1046
|
+
except asyncio.TimeoutError:
|
|
1047
|
+
logger.warning("ClaudeRunner: 90-min wake timeout, terminating")
|
|
1048
|
+
|
|
1049
|
+
# First polite SIGTERM.
|
|
1050
|
+
try:
|
|
1051
|
+
proc.terminate()
|
|
1052
|
+
except ProcessLookupError: # pragma: no cover — already gone
|
|
1053
|
+
pass
|
|
1054
|
+
try:
|
|
1055
|
+
rc = await asyncio.wait_for(
|
|
1056
|
+
proc.wait(), timeout=self.TERMINATE_GRACE_SECONDS
|
|
1057
|
+
)
|
|
1058
|
+
return True, rc
|
|
1059
|
+
except asyncio.TimeoutError:
|
|
1060
|
+
pass
|
|
1061
|
+
|
|
1062
|
+
# Hard kill.
|
|
1063
|
+
try:
|
|
1064
|
+
proc.kill()
|
|
1065
|
+
except ProcessLookupError: # pragma: no cover
|
|
1066
|
+
pass
|
|
1067
|
+
rc = await proc.wait()
|
|
1068
|
+
return True, rc
|