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.
@@ -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
+ }