ymai-codeflow 0.0.0-stage → 0.3.30
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.
- package/bin/cli.js +40 -0
- package/package.json +17 -4
- package/runtime/__init__.py +0 -0
- package/runtime/chain.py +162 -0
- package/runtime/config.py +94 -0
- package/runtime/conversation.py +220 -0
- package/runtime/daemon.py +557 -0
- package/runtime/frames.py +90 -0
- package/runtime/logging_util.py +34 -0
- package/runtime/packager.py +146 -0
- package/runtime/powercontext_client.py +131 -0
- package/runtime/providers/__init__.py +0 -0
- package/runtime/providers/base.py +81 -0
- package/runtime/providers/claude_code.py +226 -0
- package/runtime/providers/mock.py +42 -0
- package/runtime/providers/text_cli.py +106 -0
- package/runtime/store.py +126 -0
- package/runtime/wsclient.py +174 -0
- package/README.md +0 -3
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"""Daemon entry point: WS session, heartbeat, dispatch worker, recovery."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import signal
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import urlencode
|
|
15
|
+
|
|
16
|
+
from . import frames
|
|
17
|
+
from .config import Config, ConfigError, load_config
|
|
18
|
+
from .logging_util import JsonLogger, log
|
|
19
|
+
from .packager import PackageError, prepare
|
|
20
|
+
from .providers.base import COLLAPSE, RunContext
|
|
21
|
+
from .providers.claude_code import write_settings_file
|
|
22
|
+
from .providers.mock import MockProvider
|
|
23
|
+
from .store import StateStore
|
|
24
|
+
from .wsclient import WsClient, WsClosed, WsIdleTimeout
|
|
25
|
+
|
|
26
|
+
PROVIDERS = {"mock": MockProvider}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def make_provider(cfg: Config):
|
|
30
|
+
if cfg.provider == "mock":
|
|
31
|
+
return MockProvider()
|
|
32
|
+
if cfg.provider == "claude":
|
|
33
|
+
from .providers.claude_code import ClaudeCodeProvider
|
|
34
|
+
return ClaudeCodeProvider(cfg.agent_cmd, cfg.agent_timeout_sec, cfg.agent_max_turns,
|
|
35
|
+
cfg.extra_env, allowed_tools=cfg.agent_allowed_tools)
|
|
36
|
+
if cfg.provider in ("gemini", "codex", "cursor"):
|
|
37
|
+
from .providers.text_cli import TextCliProvider
|
|
38
|
+
return TextCliProvider(kind=cfg.provider, cli_binary=cfg.agent_cmd,
|
|
39
|
+
timeout_sec=cfg.agent_timeout_sec)
|
|
40
|
+
raise ConfigError(f"unknown provider: {cfg.provider}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Daemon:
|
|
44
|
+
def __init__(self, cfg: Config):
|
|
45
|
+
self.cfg = cfg
|
|
46
|
+
self.store = StateStore(str(Path(cfg.data_dir) / "runtime-state.db"))
|
|
47
|
+
self.provider = make_provider(cfg)
|
|
48
|
+
self.ws: WsClient | None = None
|
|
49
|
+
self._stop = threading.Event()
|
|
50
|
+
self._ws_lock = threading.Lock()
|
|
51
|
+
self._pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="dispatch")
|
|
52
|
+
self._inflight: set[int] = set()
|
|
53
|
+
self._inflight_lock = threading.Lock()
|
|
54
|
+
# 澄清会话(BL:CONVERSATION_TURN 协议实现):独立线程池,不阻塞派发与心跳
|
|
55
|
+
self._conv_pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="conversation")
|
|
56
|
+
self._conv_inflight: set[int] = set()
|
|
57
|
+
self._conv_inflight_lock = threading.Lock()
|
|
58
|
+
self._conv_done: dict[int, dict] = {} # turn_id -> ack frame(重投幂等重放)
|
|
59
|
+
self._heartbeat_thread: threading.Thread | None = None
|
|
60
|
+
|
|
61
|
+
def _start_heartbeat_once(self) -> None:
|
|
62
|
+
"""One heartbeat thread per daemon process; leaked threads flood the server (DEF-008)."""
|
|
63
|
+
if self._heartbeat_thread is None or not self._heartbeat_thread.is_alive():
|
|
64
|
+
self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop,
|
|
65
|
+
name="heartbeat", daemon=True)
|
|
66
|
+
self._heartbeat_thread.start()
|
|
67
|
+
|
|
68
|
+
# ---- config for WS URL -------------------------------------------------
|
|
69
|
+
def _connect(self) -> WsClient:
|
|
70
|
+
query = urlencode({"executorId": self.cfg.executor_id, "token": self.cfg.executor_token})
|
|
71
|
+
hostport = self.cfg.ws_hostport
|
|
72
|
+
if ":" in hostport:
|
|
73
|
+
host, _, port_s = hostport.rpartition(":")
|
|
74
|
+
port = int(port_s)
|
|
75
|
+
else:
|
|
76
|
+
host, port = hostport, 443 if self.cfg.use_tls else 80
|
|
77
|
+
ws = WsClient(host, port, f"{self.cfg.ws_path}?{query}", use_tls=self.cfg.use_tls, timeout=60)
|
|
78
|
+
ws.connect()
|
|
79
|
+
with self._ws_lock:
|
|
80
|
+
self.ws = ws
|
|
81
|
+
return ws
|
|
82
|
+
|
|
83
|
+
def send_frame(self, frame: dict, *, dispatch_id: int | None = None, durable: bool = True) -> None:
|
|
84
|
+
if not durable:
|
|
85
|
+
self._send_now(json.dumps(frame, ensure_ascii=False))
|
|
86
|
+
return
|
|
87
|
+
outbox_id = self.store.enqueue_frame(frame["type"], frame, dispatch_id)
|
|
88
|
+
self._flush_one(outbox_id)
|
|
89
|
+
|
|
90
|
+
def _send_now(self, raw: str) -> None:
|
|
91
|
+
with self._ws_lock:
|
|
92
|
+
ws = self.ws
|
|
93
|
+
if ws is None:
|
|
94
|
+
raise WsClosed("not connected")
|
|
95
|
+
ws.send_text(raw)
|
|
96
|
+
|
|
97
|
+
def _flush_one(self, outbox_id: int | None) -> None:
|
|
98
|
+
if outbox_id is None:
|
|
99
|
+
return
|
|
100
|
+
row = self.store.conn.execute("SELECT payload FROM outbox WHERE id=?", (outbox_id,)).fetchone()
|
|
101
|
+
if not row:
|
|
102
|
+
return
|
|
103
|
+
self._send_now(row[0])
|
|
104
|
+
self.store.mark_sent(outbox_id)
|
|
105
|
+
|
|
106
|
+
def replay_pending(self) -> None:
|
|
107
|
+
for row in self.store.pending_frames():
|
|
108
|
+
frame = json.loads(row[3])
|
|
109
|
+
ftype = frame.get("type")
|
|
110
|
+
# ACK/PROGRESS are transitional; only RESULT/HANDOFF must survive reconnects.
|
|
111
|
+
if ftype in ("TASK_RESULT", "TASK_HANDOFF"):
|
|
112
|
+
with self._ws_lock:
|
|
113
|
+
ws = self.ws
|
|
114
|
+
if ws is None:
|
|
115
|
+
return
|
|
116
|
+
ws.send_text(row[3])
|
|
117
|
+
self.store.mark_sent(row[0])
|
|
118
|
+
log.info("frame.replayed", type=ftype, dispatch_id=row[1])
|
|
119
|
+
else:
|
|
120
|
+
self.store.mark_sent(row[0])
|
|
121
|
+
|
|
122
|
+
# ---- heartbeat ----------------------------------------------------------
|
|
123
|
+
def _heartbeat_loop(self):
|
|
124
|
+
while not self._stop.is_set():
|
|
125
|
+
try:
|
|
126
|
+
with self._inflight_lock:
|
|
127
|
+
inflight = sorted(self._inflight)
|
|
128
|
+
self.send_frame(frames.heartbeat(inflight, inflight, self.cfg.max_concurrent), durable=False)
|
|
129
|
+
except (WsClosed, OSError, socket.timeout) as e:
|
|
130
|
+
log.warn("heartbeat.send_failed", error=str(e))
|
|
131
|
+
self._stop.wait(self.cfg.heartbeat_sec)
|
|
132
|
+
|
|
133
|
+
# ---- PowerContext 记忆外挂(可选) ----------------------------------------
|
|
134
|
+
def _powercontext_recall(self, dispatch: dict, capsule) -> str | None:
|
|
135
|
+
"""拉取跨工单记忆;任何失败都返回 None(绝不阻断)。"""
|
|
136
|
+
if not self.cfg.powercontext_base_url:
|
|
137
|
+
return None
|
|
138
|
+
try:
|
|
139
|
+
from .powercontext_client import (PowerContextSettings, resolve_scope,
|
|
140
|
+
prepare_context)
|
|
141
|
+
st = PowerContextSettings(base_url=self.cfg.powercontext_base_url,
|
|
142
|
+
timeout_sec=self.cfg.powercontext_timeout_sec)
|
|
143
|
+
wid = int(dispatch.get("workspaceId") or self.cfg.executor_id)
|
|
144
|
+
scope_id = resolve_scope(wid, settings=st)
|
|
145
|
+
if not scope_id:
|
|
146
|
+
return None
|
|
147
|
+
title = str(capsule.workitem_md)[:400]
|
|
148
|
+
return prepare_context(scope_id, title, settings=st)
|
|
149
|
+
except Exception as e: # noqa: BLE001
|
|
150
|
+
log.warn("powercontext.recall_failed", error=repr(e))
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
def _powercontext_remember(self, dispatch: dict, capsule, summary: str) -> None:
|
|
154
|
+
"""把本次交付的关键决策写回 PowerContext;任何失败静默。"""
|
|
155
|
+
if not self.cfg.powercontext_base_url or not self.cfg.powercontext_remember:
|
|
156
|
+
return
|
|
157
|
+
try:
|
|
158
|
+
from .powercontext_client import (PowerContextSettings, resolve_scope, remember)
|
|
159
|
+
st = PowerContextSettings(base_url=self.cfg.powercontext_base_url,
|
|
160
|
+
timeout_sec=self.cfg.powercontext_timeout_sec)
|
|
161
|
+
wid = int(dispatch.get("workspaceId") or self.cfg.executor_id)
|
|
162
|
+
scope_id = resolve_scope(wid, settings=st)
|
|
163
|
+
if not scope_id:
|
|
164
|
+
return
|
|
165
|
+
title = (capsule.workitem_md or "").strip().splitlines()[0][:120]
|
|
166
|
+
text = f"[{title}] {summary[:600]}"
|
|
167
|
+
remember(wid, "decision", text, settings=st,
|
|
168
|
+
idempotency_key=f"codeflow-dispatch-{dispatch.get('dispatchId')}")
|
|
169
|
+
log.info("powercontext.remembered", dispatch_id=dispatch.get("dispatchId"))
|
|
170
|
+
except Exception as e: # noqa: BLE001
|
|
171
|
+
log.warn("powercontext.remember_error", error=repr(e))
|
|
172
|
+
|
|
173
|
+
# ---- dispatch worker ----------------------------------------------------
|
|
174
|
+
def _emit_progress(self, dispatch_id: int, run_event):
|
|
175
|
+
frame = frames.task_progress(
|
|
176
|
+
dispatch_id, run_event.type,
|
|
177
|
+
step_order=run_event.step_order, step_id=run_event.step_id,
|
|
178
|
+
step_name=run_event.step_name, message=run_event.message)
|
|
179
|
+
self.send_frame(frame, dispatch_id=dispatch_id)
|
|
180
|
+
|
|
181
|
+
def _run_dispatch(self, dispatch: dict):
|
|
182
|
+
did = int(dispatch["dispatchId"])
|
|
183
|
+
try:
|
|
184
|
+
self.send_frame(frames.task_ack(did), dispatch_id=did)
|
|
185
|
+
self.store.update_run(did, state="preparing")
|
|
186
|
+
pkg_dir, capsule = prepare(dispatch, Path(self.cfg.data_dir) / "workspaces")
|
|
187
|
+
self.store.update_run(did, state="running", capsule_dir=str(pkg_dir),
|
|
188
|
+
session_ref=self.store.get_session(did))
|
|
189
|
+
from .providers.base import SdlcStep
|
|
190
|
+
steps = capsule.sdlc.get("steps") or []
|
|
191
|
+
if steps: # 协议时序:一次 dispatch 内按 sdlc.json 逐步执行(§4)
|
|
192
|
+
plan = [SdlcStep.from_capsule_step(s, i + 1) for i, s in enumerate(steps)]
|
|
193
|
+
else:
|
|
194
|
+
plan = [SdlcStep(f"step-{did}", 1, "execute", "完成工单当前步骤")]
|
|
195
|
+
|
|
196
|
+
workdir = pkg_dir / "workspace"
|
|
197
|
+
self.provider.prepare(capsule, workdir)
|
|
198
|
+
# PowerContext 记忆外挂(可选):拉取跨工单记忆,落成注入文件供 prompt 装配
|
|
199
|
+
pc_note = self._powercontext_recall(dispatch, capsule)
|
|
200
|
+
if pc_note:
|
|
201
|
+
try:
|
|
202
|
+
(workdir / "deliverables").mkdir(parents=True, exist_ok=True)
|
|
203
|
+
(workdir / "deliverables" / "powercontext-context.md").write_text(
|
|
204
|
+
pc_note, encoding="utf-8")
|
|
205
|
+
log.info("powercontext.recalled", dispatch_id=did, bytes=len(pc_note))
|
|
206
|
+
except OSError as e:
|
|
207
|
+
log.warn("powercontext.write_failed", dispatch_id=did, error=repr(e))
|
|
208
|
+
session_ref = self.store.get_session(did)
|
|
209
|
+
artifacts, failures, summaries = [], [], []
|
|
210
|
+
steps_done = 0
|
|
211
|
+
for step in plan:
|
|
212
|
+
ctx = RunContext(dispatch_id=did, capsule=capsule,
|
|
213
|
+
workdir=type("W", (), {"root": workdir})(),
|
|
214
|
+
prev_session=session_ref,
|
|
215
|
+
emit_event=lambda ev: self._emit_progress(did, ev))
|
|
216
|
+
result = self.provider.run(step, ctx)
|
|
217
|
+
for ev in result.events:
|
|
218
|
+
self.store.add_event(did, ev.type, step_order=ev.step_order,
|
|
219
|
+
step_id=ev.step_id, step_name=ev.step_name)
|
|
220
|
+
artifacts.extend(result.artifacts)
|
|
221
|
+
summaries.append(f"[{step.name}] {result.summary}"[:400])
|
|
222
|
+
if result.session_ref:
|
|
223
|
+
session_ref = result.session_ref
|
|
224
|
+
self.store.set_session(did, session_ref)
|
|
225
|
+
if result.state != "completed":
|
|
226
|
+
failures.append(f"step {step.step_id}({step.name}): {result.state} {result.error}".strip())
|
|
227
|
+
break # SDLC 串行:失败即止,由服务端 onFail 决策
|
|
228
|
+
steps_done += 1
|
|
229
|
+
success = not failures
|
|
230
|
+
summary = ("; ".join(summaries) if success else "; ".join(failures))[:2000] or "completed"
|
|
231
|
+
self.send_frame(frames.task_result(did, success, summary, ""), dispatch_id=did)
|
|
232
|
+
self.store.mark_terminal(did, success)
|
|
233
|
+
log.info("dispatch.finished", dispatch_id=did, success=success, steps_done=steps_done)
|
|
234
|
+
if not success and self.cfg.handoff_human_on_fail:
|
|
235
|
+
try:
|
|
236
|
+
from .chain import handoff_to_human
|
|
237
|
+
handoff_to_human(self.cfg, int(dispatch["workitemId"]), self._human_id(capsule))
|
|
238
|
+
log.info("chain.handed_to_human", dispatch_id=did, workitem=dispatch["workitemId"])
|
|
239
|
+
except Exception as e: # noqa: BLE001
|
|
240
|
+
log.error("chain.human_handoff_failed", dispatch_id=did, error=repr(e))
|
|
241
|
+
if success:
|
|
242
|
+
# 数字员工交付备注(多源回写):把最终结论送回平台 → 外部工单平台(禅道等)
|
|
243
|
+
try:
|
|
244
|
+
from .chain import agent_comment
|
|
245
|
+
agent_comment(self.cfg, did, summary)
|
|
246
|
+
log.info("chain.agent_comment_posted", dispatch_id=did, workitem=dispatch["workitemId"])
|
|
247
|
+
except Exception as e: # noqa: BLE001 - 备注失败不影响交付结果
|
|
248
|
+
log.warn("chain.agent_comment_failed", dispatch_id=did, error=repr(e))
|
|
249
|
+
# PowerContext 记忆写回:本次交付的关键决策/约束(可选,失败不影响)
|
|
250
|
+
try:
|
|
251
|
+
self._powercontext_remember(dispatch, capsule, summary)
|
|
252
|
+
except Exception as e: # noqa: BLE001
|
|
253
|
+
log.warn("powercontext.remember_failed", dispatch_id=did, error=repr(e))
|
|
254
|
+
self._chain_next_role(dispatch, capsule, did)
|
|
255
|
+
except PackageError as e:
|
|
256
|
+
log.error("dispatch.package_failed", dispatch_id=did, error=str(e))
|
|
257
|
+
self.send_frame(frames.task_result(did, False, "package verification failed", str(e)), dispatch_id=did)
|
|
258
|
+
self.store.mark_terminal(did, False)
|
|
259
|
+
except Exception as e: # noqa: BLE001 - worker must never die silently
|
|
260
|
+
log.error("dispatch.crashed", dispatch_id=did, error=repr(e))
|
|
261
|
+
try:
|
|
262
|
+
self.send_frame(frames.task_result(did, False, "runtime internal error", repr(e)), dispatch_id=did)
|
|
263
|
+
self.store.mark_terminal(did, False)
|
|
264
|
+
except Exception: # noqa: BLE001
|
|
265
|
+
pass
|
|
266
|
+
if self.cfg.handoff_human_on_fail:
|
|
267
|
+
try:
|
|
268
|
+
from .chain import handoff_to_human
|
|
269
|
+
handoff_to_human(self.cfg, int(dispatch["workitemId"]),
|
|
270
|
+
self._human_id(capsule if 'capsule' in dir() else None))
|
|
271
|
+
except Exception as he: # noqa: BLE001
|
|
272
|
+
log.error("chain.human_handoff_failed", dispatch_id=did, error=repr(he))
|
|
273
|
+
finally:
|
|
274
|
+
with self._inflight_lock:
|
|
275
|
+
self._inflight.discard(did)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@staticmethod
|
|
279
|
+
def _human_id(capsule):
|
|
280
|
+
humans = (capsule.roster.get("humanTeammates") or []) if capsule else []
|
|
281
|
+
return humans[0].get("userId") if humans else None
|
|
282
|
+
|
|
283
|
+
# ---- 跨角色接棒(P0.5):架构许可的通道 = 执行器经平台 API 显式发起(SdlcDriver javadoc) ----
|
|
284
|
+
def _chain_next_role(self, dispatch: dict, capsule, did: int):
|
|
285
|
+
nxt_id = self.cfg.handoff_agent_id
|
|
286
|
+
if not nxt_id:
|
|
287
|
+
if self.cfg.chain_finalize:
|
|
288
|
+
try:
|
|
289
|
+
from .chain import finalize_to_done
|
|
290
|
+
finalize_to_done(self.cfg, int(dispatch["workitemId"]))
|
|
291
|
+
log.info("chain.finalized", dispatch_id=did, workitem=dispatch["workitemId"])
|
|
292
|
+
except Exception as e: # noqa: BLE001
|
|
293
|
+
log.error("chain.finalize_failed", dispatch_id=did, error=repr(e))
|
|
294
|
+
return
|
|
295
|
+
try:
|
|
296
|
+
from .chain import reassign_to_agent # 局部导入避免无配置时的依赖
|
|
297
|
+
role = ""
|
|
298
|
+
for tm in (capsule.roster.get("digitalTeammates") or []):
|
|
299
|
+
if str(tm.get("agentId")) == str(nxt_id):
|
|
300
|
+
role = str(tm.get("roleCode") or tm.get("roleName") or "")
|
|
301
|
+
break
|
|
302
|
+
self.send_frame(frames.task_handoff(
|
|
303
|
+
did, int(dispatch["workitemId"]), role or f"agent-{nxt_id}",
|
|
304
|
+
"AGENT", role, "auto-chain to next role"), dispatch_id=did)
|
|
305
|
+
reassign_to_agent(self.cfg, int(dispatch["workitemId"]), nxt_id)
|
|
306
|
+
log.info("chain.reassigned", dispatch_id=did, next_agent=nxt_id, role=role)
|
|
307
|
+
except Exception as e: # noqa: BLE001 - 接棒失败不改变已成功的 dispatch 终态
|
|
308
|
+
log.error("chain.failed", dispatch_id=did, error=repr(e))
|
|
309
|
+
|
|
310
|
+
def on_dispatch(self, payload: dict):
|
|
311
|
+
did = int(payload["dispatchId"])
|
|
312
|
+
if self.store.is_terminal(did):
|
|
313
|
+
log.info("dispatch.duplicate_terminal", dispatch_id=did)
|
|
314
|
+
self.send_frame(frames.task_result(did, False, "duplicate dispatch for already finished task", ""),
|
|
315
|
+
dispatch_id=did) if False else None
|
|
316
|
+
# terminal success flag decides: replay stored outcome instead of re-running
|
|
317
|
+
row = self.store.conn.execute("SELECT success FROM terminal WHERE dispatch_id=?", (did,)).fetchone()
|
|
318
|
+
success = bool(row and row[0])
|
|
319
|
+
self.send_frame(frames.task_result(did, success, "idempotent replay of terminal dispatch", ""),
|
|
320
|
+
dispatch_id=did)
|
|
321
|
+
return
|
|
322
|
+
with self._inflight_lock:
|
|
323
|
+
if did in self._inflight:
|
|
324
|
+
log.info("dispatch.duplicate_inflight", dispatch_id=did)
|
|
325
|
+
return
|
|
326
|
+
self._inflight.add(did)
|
|
327
|
+
if not self.store.create_run(payload, self.provider.name):
|
|
328
|
+
log.info("dispatch.duplicate_tracked", dispatch_id=did)
|
|
329
|
+
return
|
|
330
|
+
log.info("dispatch.accepted", dispatch_id=did, workitem=payload.get("workitemId"))
|
|
331
|
+
self._pool.submit(self._run_dispatch, payload)
|
|
332
|
+
|
|
333
|
+
# ---- main loop ----------------------------------------------------------
|
|
334
|
+
def run_forever(self):
|
|
335
|
+
backoff = 1.0
|
|
336
|
+
recovery_done = False
|
|
337
|
+
while not self._stop.is_set():
|
|
338
|
+
try:
|
|
339
|
+
ws = self._connect()
|
|
340
|
+
log.info("ws.connected", server=self.cfg.ws_hostport, executor_id=self.cfg.executor_id)
|
|
341
|
+
backoff = 1.0
|
|
342
|
+
self._start_heartbeat_once()
|
|
343
|
+
if not recovery_done:
|
|
344
|
+
# Recovery is a STARTUP operation: reconnects must never touch in-flight runs (DEF-007).
|
|
345
|
+
self._recover()
|
|
346
|
+
recovery_done = True
|
|
347
|
+
self.replay_pending()
|
|
348
|
+
while not self._stop.is_set():
|
|
349
|
+
try:
|
|
350
|
+
frame = ws.recv_json()
|
|
351
|
+
except WsIdleTimeout:
|
|
352
|
+
# 读空闲属正常(数据流以出站为主):保持连接,不做重连(否则每 60s 抖动一次)
|
|
353
|
+
log.debug("ws.idle", detail="no inbound frame within read window")
|
|
354
|
+
continue
|
|
355
|
+
if frame.get("type") == "TASK_DISPATCH":
|
|
356
|
+
data = frames.decode_dispatch(frame)
|
|
357
|
+
self.on_dispatch(data)
|
|
358
|
+
elif frame.get("type") == "CONVERSATION_TURN":
|
|
359
|
+
self.on_conversation_turn(frames.decode_conversation_turn(frame))
|
|
360
|
+
else:
|
|
361
|
+
log.debug("frame.ignored", type=frame.get("type"))
|
|
362
|
+
except WsClosed as e:
|
|
363
|
+
if isinstance(e, WsIdleTimeout):
|
|
364
|
+
# Heartbeats are outbound-only; idle reads are normal. Keep the session.
|
|
365
|
+
log.debug("ws.idle", detail="no inbound frame within read window")
|
|
366
|
+
continue
|
|
367
|
+
log.warn("ws.closed", error=str(e))
|
|
368
|
+
except (OSError, socket.timeout) as e:
|
|
369
|
+
log.warn("ws.error", error=repr(e))
|
|
370
|
+
finally:
|
|
371
|
+
with self._ws_lock:
|
|
372
|
+
self.ws = None
|
|
373
|
+
if self._stop.is_set():
|
|
374
|
+
break
|
|
375
|
+
time.sleep(backoff)
|
|
376
|
+
backoff = min(backoff * 2, 30.0)
|
|
377
|
+
|
|
378
|
+
def _recover(self):
|
|
379
|
+
"""Handle runs left active by a previous process.
|
|
380
|
+
|
|
381
|
+
本进程不认领旧派发(owned=[]),PAUSING/RUNNING 残留由服务端调和器显式收敛为失败
|
|
382
|
+
并走 SDLC onFail 重试;此处仅清本地台账,避免僵尸 run。
|
|
383
|
+
"""
|
|
384
|
+
for row in self.store.active_runs():
|
|
385
|
+
did = row["dispatch_id"]
|
|
386
|
+
if self.store.is_terminal(did):
|
|
387
|
+
continue
|
|
388
|
+
log.warn("recovery.active_run", dispatch_id=did, state=row["state"])
|
|
389
|
+
self.store.mark_terminal(did, False)
|
|
390
|
+
|
|
391
|
+
# ---- 澄清会话(CONVERSATION_TURN) ----
|
|
392
|
+
|
|
393
|
+
def on_conversation_turn(self, turn: dict) -> None:
|
|
394
|
+
turn_id = turn["turn_id"]
|
|
395
|
+
with self._conv_inflight_lock:
|
|
396
|
+
if turn_id in self._conv_inflight:
|
|
397
|
+
log.info("conversation.duplicate_inflight", turn_id=turn_id)
|
|
398
|
+
return
|
|
399
|
+
done = self._conv_done.get(turn_id)
|
|
400
|
+
if done is not None:
|
|
401
|
+
# 平台重投同一回合:重放既有 ACK(dispatchAttempt 回显为新帧值),不重跑
|
|
402
|
+
self._conv_inflight.add(turn_id)
|
|
403
|
+
self._conv_pool.submit(self._reack_conversation_turn, turn, done)
|
|
404
|
+
return
|
|
405
|
+
self._conv_inflight.add(turn_id)
|
|
406
|
+
self._conv_pool.submit(self._run_conversation_turn, turn)
|
|
407
|
+
|
|
408
|
+
def _reack_conversation_turn(self, turn: dict, done: dict) -> None:
|
|
409
|
+
try:
|
|
410
|
+
frame = dict(done)
|
|
411
|
+
if turn.get("dispatch_attempt"):
|
|
412
|
+
frame["dispatchAttempt"] = int(turn["dispatch_attempt"])
|
|
413
|
+
self.send_frame(frame, durable=False)
|
|
414
|
+
log.info("conversation.reacked", turn_id=turn["turn_id"])
|
|
415
|
+
finally:
|
|
416
|
+
with self._conv_inflight_lock:
|
|
417
|
+
self._conv_inflight.discard(turn["turn_id"])
|
|
418
|
+
|
|
419
|
+
def _run_conversation_turn(self, turn: dict) -> None:
|
|
420
|
+
from . import conversation as conv
|
|
421
|
+
turn_id = turn["turn_id"]
|
|
422
|
+
try:
|
|
423
|
+
workdir = Path(self.cfg.data_dir) / "conversations" / str(turn["conversation_id"])
|
|
424
|
+
capsule_dir = workdir / "capsule"
|
|
425
|
+
if turn.get("capability_url"):
|
|
426
|
+
zip_path = conv.download_capability(turn["capability_url"],
|
|
427
|
+
turn.get("capability_sha256") or "", capsule_dir)
|
|
428
|
+
conv.extract_capability(zip_path, capsule_dir)
|
|
429
|
+
settings_path = conv.write_conversation_settings(workdir, self.cfg.extra_env or {},
|
|
430
|
+
turn.get("mcp_token"))
|
|
431
|
+
conv.write_mcp_config(workdir, conv.ainative_mcp_server(capsule_dir, turn.get("mcp_token") or ""))
|
|
432
|
+
mcp_server = conv.ainative_mcp_server(capsule_dir, turn.get("mcp_token") or "")
|
|
433
|
+
if mcp_server and turn.get("mcp_token"):
|
|
434
|
+
conv.register_mcp_server(self.cfg.agent_cmd, workdir,
|
|
435
|
+
dict(os.environ) | (self.cfg.extra_env or {}) | {"HOME": str(workdir)},
|
|
436
|
+
mcp_server["url"], turn["mcp_token"])
|
|
437
|
+
# MCP 工具预授权:settings.permissions.allow 为权威路径,此处通配双保险
|
|
438
|
+
allowed = ",".join(t for t in (self.cfg.agent_allowed_tools or "",
|
|
439
|
+
"mcp__ainative,mcp__ainative__*" if turn.get("mcp_token") else "")
|
|
440
|
+
if t) or None
|
|
441
|
+
result = conv.run_claude_turn(
|
|
442
|
+
self.cfg.agent_cmd, workdir, turn["content"],
|
|
443
|
+
session_ref=turn.get("cli_session_ref"),
|
|
444
|
+
system_prompt=turn.get("system_prompt"),
|
|
445
|
+
settings_path=settings_path,
|
|
446
|
+
max_turns=self.cfg.agent_max_turns,
|
|
447
|
+
allowed_tools=allowed,
|
|
448
|
+
env=dict(os.environ) | (self.cfg.extra_env or {}) | {"HOME": str(workdir)},
|
|
449
|
+
)
|
|
450
|
+
if result["is_error"]:
|
|
451
|
+
ack = conv.turn_ack(turn["conversation_id"], turn_id, turn.get("dispatch_attempt"),
|
|
452
|
+
"FAILED", error=result.get("error") or "claude failed")
|
|
453
|
+
else:
|
|
454
|
+
ack = conv.turn_ack(turn["conversation_id"], turn_id, turn.get("dispatch_attempt"),
|
|
455
|
+
"SUCCESS", reply_markdown=result["text"],
|
|
456
|
+
session_id=result.get("session_id"),
|
|
457
|
+
usage=result.get("usage"), model=result.get("model"))
|
|
458
|
+
with self._conv_inflight_lock:
|
|
459
|
+
self._conv_done[turn_id] = ack # 幂等重放留底
|
|
460
|
+
while len(self._conv_done) > 200: # 只留最近回合,防无界增长
|
|
461
|
+
self._conv_done.pop(next(iter(self._conv_done)))
|
|
462
|
+
self.send_frame(ack, durable=False)
|
|
463
|
+
log.info("conversation.turned", turn_id=turn_id,
|
|
464
|
+
success=not result["is_error"], duration_ms=result.get("duration_ms"))
|
|
465
|
+
except Exception as e: # noqa: BLE001 - 会话失败必须回 ACK,不能黑洞
|
|
466
|
+
log.error("conversation.failed", turn_id=turn_id, error=repr(e))
|
|
467
|
+
try:
|
|
468
|
+
from . import conversation as conv
|
|
469
|
+
self.send_frame(conv.turn_ack(turn["conversation_id"], turn_id,
|
|
470
|
+
turn.get("dispatch_attempt"), "FAILED",
|
|
471
|
+
error=repr(e)[:800]), durable=False)
|
|
472
|
+
except Exception: # noqa: BLE001
|
|
473
|
+
pass
|
|
474
|
+
finally:
|
|
475
|
+
with self._conv_inflight_lock:
|
|
476
|
+
self._conv_inflight.discard(turn_id)
|
|
477
|
+
|
|
478
|
+
def stop(self, *_):
|
|
479
|
+
self._stop.set()
|
|
480
|
+
with self._ws_lock:
|
|
481
|
+
if self.ws is not None:
|
|
482
|
+
self.ws.close()
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def main(argv=None) -> int:
|
|
486
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
487
|
+
# 平台启动命令形态:`ainative connect --ws-url ... --token ... --executor-id ...`。
|
|
488
|
+
# `connect` 子命令后的全部 CLI 参数都会转成配置覆盖(优先级高于环境变量)。
|
|
489
|
+
if argv and argv[0] == 'connect':
|
|
490
|
+
argv = argv[1:]
|
|
491
|
+
parser = argparse.ArgumentParser(prog="ainative")
|
|
492
|
+
parser.add_argument('connect_cmd', nargs='?', choices=['connect'], help=argparse.SUPPRESS)
|
|
493
|
+
parser.add_argument("--once-smoke", action="store_true", help="connect + heartbeat once, then exit 0")
|
|
494
|
+
parser.add_argument("--check-config", action="store_true")
|
|
495
|
+
parser.add_argument("--ws-url", help="platform websocket endpoint (AINATIVE_SERVER_WS_URL)")
|
|
496
|
+
parser.add_argument("--token", help="executor token (AINATIVE_EXECUTOR_TOKEN)")
|
|
497
|
+
parser.add_argument("--executor-id", help="executor id (AINATIVE_EXECUTOR_ID)")
|
|
498
|
+
parser.add_argument("--provider", help="mock | claude | gemini | codex | cursor (AINATIVE_PROVIDER)")
|
|
499
|
+
parser.add_argument("--memory-mode", help=argparse.SUPPRESS) # 平台概念,runtime 不区分
|
|
500
|
+
parser.add_argument("--max-tasks", help="max concurrent tasks (RUNTIME_MAX_CONCURRENT)")
|
|
501
|
+
parser.add_argument("--model", help="model id passed through to the provider CLI (ANTHROPIC_MODEL)")
|
|
502
|
+
parser.add_argument("--reasoning-effort", help=argparse.SUPPRESS)
|
|
503
|
+
parser.add_argument("--context-window", help=argparse.SUPPRESS)
|
|
504
|
+
parser.add_argument("--token-aware-enable", action="store_true", help=argparse.SUPPRESS)
|
|
505
|
+
parser.add_argument("--workspace-root", help=argparse.SUPPRESS)
|
|
506
|
+
parser.add_argument("--name", help=argparse.SUPPRESS)
|
|
507
|
+
parser.add_argument("--debug", action="store_true", help="verbose runtime logging")
|
|
508
|
+
args = parser.parse_args(argv)
|
|
509
|
+
overrides: dict = {}
|
|
510
|
+
if args.ws_url:
|
|
511
|
+
overrides["AINATIVE_SERVER_WS_URL"] = args.ws_url
|
|
512
|
+
if args.token:
|
|
513
|
+
overrides["AINATIVE_EXECUTOR_TOKEN"] = args.token
|
|
514
|
+
if args.executor_id:
|
|
515
|
+
overrides["AINATIVE_EXECUTOR_ID"] = args.executor_id
|
|
516
|
+
if args.provider:
|
|
517
|
+
overrides["AINATIVE_PROVIDER"] = args.provider
|
|
518
|
+
if args.max_tasks:
|
|
519
|
+
overrides["RUNTIME_MAX_CONCURRENT"] = args.max_tasks
|
|
520
|
+
if args.model:
|
|
521
|
+
overrides["ANTHROPIC_MODEL"] = args.model
|
|
522
|
+
if args.reasoning_effort:
|
|
523
|
+
overrides["ANTHROPIC_REASONING_EFFORT"] = args.reasoning_effort
|
|
524
|
+
if args.context_window:
|
|
525
|
+
overrides["ANTHROPIC_CONTEXT_WINDOW"] = args.context_window
|
|
526
|
+
if args.debug:
|
|
527
|
+
overrides["RUNTIME_LOG_LEVEL"] = "debug"
|
|
528
|
+
try:
|
|
529
|
+
cfg = load_config(overrides)
|
|
530
|
+
except ConfigError as e:
|
|
531
|
+
log.error("config.invalid", error=str(e))
|
|
532
|
+
return 2
|
|
533
|
+
if args.check_config:
|
|
534
|
+
log.info("config.ok", provider=cfg.provider, executor_id=cfg.executor_id, server=cfg.server_ws_url)
|
|
535
|
+
return 0
|
|
536
|
+
d = Daemon(cfg)
|
|
537
|
+
signal.signal(signal.SIGINT, d.stop)
|
|
538
|
+
signal.signal(signal.SIGTERM, d.stop)
|
|
539
|
+
if args.once_smoke:
|
|
540
|
+
try:
|
|
541
|
+
d._connect()
|
|
542
|
+
d.send_frame(frames.heartbeat(), durable=False)
|
|
543
|
+
log.info("smoke.ok", executor_id=cfg.executor_id)
|
|
544
|
+
time.sleep(1.0)
|
|
545
|
+
return 0
|
|
546
|
+
except (WsClosed, OSError) as e:
|
|
547
|
+
log.error("smoke.failed", error=repr(e))
|
|
548
|
+
return 1
|
|
549
|
+
finally:
|
|
550
|
+
d.stop()
|
|
551
|
+
log.info("daemon.start", provider=cfg.provider, data_dir=cfg.data_dir)
|
|
552
|
+
d.run_forever()
|
|
553
|
+
return 0
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
if __name__ == "__main__":
|
|
557
|
+
sys.exit(main())
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Protocol frames (up: executor -> server). Field names follow docs/scheduler-executor-protocol.md verbatim."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
# CONVERSATION_DELIVERY_ACK_V1:声明后平台会话投递带 dispatchAttempt,ACK 必须回显(见 conversation.py)
|
|
7
|
+
PROTOCOL_FEATURES = ["OPEN_RUNTIME_V1", "dispatch_inventory_v1", "CONVERSATION_DELIVERY_ACK_V1"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FrameError(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def task_ack(dispatch_id: int) -> dict:
|
|
15
|
+
return {"type": "TASK_ACK", "dispatchId": dispatch_id}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def task_progress(dispatch_id: int, result_summary: str, *, step_order: int | None = None,
|
|
19
|
+
step_id: str | None = None, step_name: str | None = None,
|
|
20
|
+
message: str | None = None, error: str | None = None) -> dict:
|
|
21
|
+
f: dict[str, Any] = {"type": "TASK_PROGRESS", "dispatchId": dispatch_id, "resultSummary": result_summary}
|
|
22
|
+
if step_order is not None: f["stepOrder"] = step_order
|
|
23
|
+
if step_id is not None: f["stepId"] = step_id
|
|
24
|
+
if step_name is not None: f["stepName"] = step_name
|
|
25
|
+
if message is not None: f["message"] = message
|
|
26
|
+
if error is not None: f["error"] = error
|
|
27
|
+
return f
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def task_result(dispatch_id: int, success: bool, result_summary: str, error: str = "") -> dict:
|
|
31
|
+
return {"type": "TASK_RESULT", "dispatchId": dispatch_id, "success": success,
|
|
32
|
+
"resultSummary": result_summary, "error": error or ""}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def task_handoff(dispatch_id: int, workitem_id: int, to: str, to_type: str = "AGENT",
|
|
36
|
+
next_role: str = "", reason: str = "") -> dict:
|
|
37
|
+
return {"type": "TASK_HANDOFF", "dispatchId": dispatch_id, "workitemId": workitem_id,
|
|
38
|
+
"to": to, "toType": to_type, "nextRole": next_role, "reason": reason}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def heartbeat(owned=None, running=None, capacity=1) -> dict:
|
|
42
|
+
"""dispatch_inventory_v1 心跳:owned/running 必须为数组且 owned ⊇ running(§reconciler 契约)。"""
|
|
43
|
+
owned = sorted(int(x) for x in (owned or []))
|
|
44
|
+
running = sorted(int(x) for x in (running or []))
|
|
45
|
+
return {"type": "HEARTBEAT", "protocolFeatures": list(PROTOCOL_FEATURES),
|
|
46
|
+
"dispatchInventoryReady": True, "maxConcurrentDispatches": max(1, int(capacity)),
|
|
47
|
+
"ownedDispatchIds": owned, "runningDispatchIds": running,
|
|
48
|
+
"runningConversationTurnIds": []}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def decode_dispatch(payload: dict) -> dict:
|
|
52
|
+
"""Validate a downstream TASK_DISPATCH frame; returns normalized dict."""
|
|
53
|
+
if payload.get("type") != "TASK_DISPATCH":
|
|
54
|
+
raise FrameError("not a TASK_DISPATCH frame")
|
|
55
|
+
for key in ("dispatchId", "executorId", "tenantId", "workitemId", "attempt", "downloadUrl"):
|
|
56
|
+
if key not in payload or payload[key] in (None, ""):
|
|
57
|
+
raise FrameError(f"TASK_DISPATCH missing field: {key}")
|
|
58
|
+
return payload
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def decode_frame(raw: str) -> dict:
|
|
62
|
+
import json
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(raw)
|
|
65
|
+
except json.JSONDecodeError as e:
|
|
66
|
+
raise FrameError(f"invalid JSON frame: {e}") from e
|
|
67
|
+
if not isinstance(data, dict) or "type" not in data:
|
|
68
|
+
raise FrameError("frame missing type")
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def decode_conversation_turn(frame: dict) -> dict:
|
|
73
|
+
"""校验并提取 CONVERSATION_TURN 关键字段(缺失关键字段直接拒绝,不静默)。"""
|
|
74
|
+
if frame.get("type") != "CONVERSATION_TURN":
|
|
75
|
+
raise FrameError("not a CONVERSATION_TURN frame")
|
|
76
|
+
missing = [k for k in ("conversationId", "turnId", "content") if frame.get(k) in (None, "")]
|
|
77
|
+
if missing:
|
|
78
|
+
raise FrameError(f"CONVERSATION_TURN missing {','.join(missing)}")
|
|
79
|
+
return {
|
|
80
|
+
"conversation_id": int(frame["conversationId"]),
|
|
81
|
+
"turn_id": int(frame["turnId"]),
|
|
82
|
+
"agent_id": int(frame["agentId"]) if frame.get("agentId") else None,
|
|
83
|
+
"dispatch_attempt": int(frame["dispatchAttempt"]) if frame.get("dispatchAttempt") else 0,
|
|
84
|
+
"content": str(frame["content"]),
|
|
85
|
+
"cli_session_ref": frame.get("cliSessionRef") or None,
|
|
86
|
+
"system_prompt": frame.get("systemPrompt") or None,
|
|
87
|
+
"capability_url": frame.get("capabilityDownloadUrl") or None,
|
|
88
|
+
"capability_sha256": frame.get("capabilitySha256") or None,
|
|
89
|
+
"mcp_token": frame.get("mcpToken") or None,
|
|
90
|
+
}
|