@agentunion/kite 1.0.7 → 1.2.0

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.
Files changed (77) hide show
  1. package/core/event_hub/entry.py +305 -26
  2. package/core/event_hub/hub.py +8 -0
  3. package/core/event_hub/server.py +80 -17
  4. package/core/kite_log.py +241 -0
  5. package/core/launcher/entry.py +978 -284
  6. package/core/launcher/process_manager.py +456 -46
  7. package/core/registry/entry.py +272 -3
  8. package/core/registry/server.py +339 -289
  9. package/core/registry/store.py +10 -4
  10. package/extensions/agents/__init__.py +1 -0
  11. package/extensions/agents/assistant/__init__.py +1 -0
  12. package/extensions/agents/assistant/entry.py +380 -0
  13. package/extensions/agents/assistant/module.md +22 -0
  14. package/extensions/agents/assistant/server.py +236 -0
  15. package/extensions/channels/__init__.py +1 -0
  16. package/extensions/channels/acp_channel/__init__.py +1 -0
  17. package/extensions/channels/acp_channel/entry.py +380 -0
  18. package/extensions/channels/acp_channel/module.md +22 -0
  19. package/extensions/channels/acp_channel/server.py +236 -0
  20. package/extensions/event_hub_bench/entry.py +664 -379
  21. package/extensions/event_hub_bench/module.md +2 -1
  22. package/extensions/services/backup/__init__.py +1 -0
  23. package/extensions/services/backup/entry.py +380 -0
  24. package/extensions/services/backup/module.md +22 -0
  25. package/extensions/services/backup/server.py +244 -0
  26. package/extensions/services/model_service/__init__.py +1 -0
  27. package/extensions/services/model_service/entry.py +380 -0
  28. package/extensions/services/model_service/module.md +22 -0
  29. package/extensions/services/model_service/server.py +236 -0
  30. package/extensions/services/watchdog/entry.py +460 -147
  31. package/extensions/services/watchdog/module.md +3 -0
  32. package/extensions/services/watchdog/monitor.py +128 -13
  33. package/extensions/services/watchdog/server.py +75 -13
  34. package/extensions/services/web/__init__.py +1 -0
  35. package/extensions/services/web/config.yaml +149 -0
  36. package/extensions/services/web/entry.py +487 -0
  37. package/extensions/services/web/module.md +24 -0
  38. package/extensions/services/web/routes/__init__.py +1 -0
  39. package/extensions/services/web/routes/routes_call.py +189 -0
  40. package/extensions/services/web/routes/routes_config.py +512 -0
  41. package/extensions/services/web/routes/routes_contacts.py +98 -0
  42. package/extensions/services/web/routes/routes_devlog.py +99 -0
  43. package/extensions/services/web/routes/routes_phone.py +81 -0
  44. package/extensions/services/web/routes/routes_sms.py +48 -0
  45. package/extensions/services/web/routes/routes_stats.py +17 -0
  46. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  47. package/extensions/services/web/routes/schemas.py +216 -0
  48. package/extensions/services/web/server.py +332 -0
  49. package/extensions/services/web/static/css/style.css +1064 -0
  50. package/extensions/services/web/static/index.html +1445 -0
  51. package/extensions/services/web/static/js/app.js +4671 -0
  52. package/extensions/services/web/vendor/__init__.py +1 -0
  53. package/extensions/services/web/vendor/bluetooth/__init__.py +0 -0
  54. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  55. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  56. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  57. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  58. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  59. package/extensions/services/web/vendor/config.py +139 -0
  60. package/extensions/services/web/vendor/conversation/__init__.py +0 -0
  61. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  62. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  63. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  64. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  65. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  66. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  67. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  68. package/extensions/services/web/vendor/storage/identity.py +312 -0
  69. package/extensions/services/web/vendor/storage/store.py +507 -0
  70. package/extensions/services/web/vendor/task/__init__.py +0 -0
  71. package/extensions/services/web/vendor/task/manager.py +864 -0
  72. package/extensions/services/web/vendor/task/models.py +45 -0
  73. package/extensions/services/web/vendor/task/webhook.py +263 -0
  74. package/extensions/services/web/vendor/tools/__init__.py +0 -0
  75. package/extensions/services/web/vendor/tools/registry.py +321 -0
  76. package/main.py +230 -90
  77. package/package.json +1 -1
@@ -1,20 +1,240 @@
1
1
  """
2
2
  Event Hub entry point.
3
- Reads token from stdin boot_info, reads launcher_ws_token from stdin second line,
4
- starts FastAPI server, outputs ws_endpoint via stdout, waits for Launcher to connect,
5
- then registers to Registry after stdio disconnect.
3
+ Reads token from stdin boot_info, then reads launcher_ws_token + registry_port from
4
+ stdin kite messages (in any order), starts FastAPI server, outputs ws_endpoint via
5
+ stdout, waits for Launcher to connect, then registers to Registry after stdio disconnect.
6
6
  """
7
7
 
8
+ import builtins
8
9
  import json
9
10
  import os
10
11
  import re
11
12
  import socket
12
13
  import sys
13
14
  import threading
15
+ import time
16
+ import traceback
17
+ from datetime import datetime, timezone
14
18
 
15
19
  import uvicorn
16
20
 
17
- # Ensure project root is on sys.path (set by main.py or cli.js)
21
+
22
+ # ── Module configuration ──
23
+ MODULE_NAME = "event_hub"
24
+
25
+
26
+ def _fmt_elapsed(t0: float) -> str:
27
+ """Format elapsed time since t0: <1s → 'NNNms', >=1s → 'N.Ns', >=10s → 'NNs'."""
28
+ d = time.monotonic() - t0
29
+ if d < 1:
30
+ return f"{d * 1000:.0f}ms"
31
+ if d < 10:
32
+ return f"{d:.1f}s"
33
+ return f"{d:.0f}s"
34
+
35
+
36
+ # ── Safe stdout/stderr: ignore BrokenPipeError after Launcher closes stdio ──
37
+
38
+ class _SafeWriter:
39
+ """Wraps a stream to silently swallow BrokenPipeError on write/flush."""
40
+ def __init__(self, stream):
41
+ self._stream = stream
42
+
43
+ def write(self, s):
44
+ try:
45
+ self._stream.write(s)
46
+ except (BrokenPipeError, OSError):
47
+ pass
48
+
49
+ def flush(self):
50
+ try:
51
+ self._stream.flush()
52
+ except (BrokenPipeError, OSError):
53
+ pass
54
+
55
+ def __getattr__(self, name):
56
+ return getattr(self._stream, name)
57
+
58
+ sys.stdout = _SafeWriter(sys.stdout)
59
+ sys.stderr = _SafeWriter(sys.stderr)
60
+
61
+
62
+ # ── Timestamped print + log file writer ──
63
+ # Independent implementation per module (no shared code dependency)
64
+
65
+ _builtin_print = builtins.print
66
+ _start_ts = time.monotonic()
67
+ _last_ts = time.monotonic()
68
+ _ANSI_RE = re.compile(r"\033\[[0-9;]*m")
69
+ _log_lock = threading.Lock()
70
+ _log_latest_path = None
71
+ _log_daily_path = None
72
+ _log_daily_date = ""
73
+ _log_dir = None
74
+ _crash_log_path = None
75
+
76
+ def _strip_ansi(s: str) -> str:
77
+ return _ANSI_RE.sub("", s)
78
+
79
+ def _resolve_daily_log_path():
80
+ """Resolve daily log path based on current date."""
81
+ global _log_daily_path, _log_daily_date
82
+ if not _log_dir:
83
+ return
84
+ today = datetime.now().strftime("%Y-%m-%d")
85
+ if today == _log_daily_date and _log_daily_path:
86
+ return
87
+ month_dir = os.path.join(_log_dir, today[:7])
88
+ os.makedirs(month_dir, exist_ok=True)
89
+ _log_daily_path = os.path.join(month_dir, f"{today}.log")
90
+ _log_daily_date = today
91
+
92
+ def _write_log(plain_line: str):
93
+ """Write a plain-text line to both latest.log and daily log."""
94
+ with _log_lock:
95
+ if _log_latest_path:
96
+ try:
97
+ with open(_log_latest_path, "a", encoding="utf-8") as f:
98
+ f.write(plain_line)
99
+ except Exception:
100
+ pass
101
+ _resolve_daily_log_path()
102
+ if _log_daily_path:
103
+ try:
104
+ with open(_log_daily_path, "a", encoding="utf-8") as f:
105
+ f.write(plain_line)
106
+ except Exception:
107
+ pass
108
+
109
+ def _write_crash(exc_type, exc_value, exc_tb, thread_name=None, severity="critical", handled=False):
110
+ """Write crash record to crashes.jsonl + daily crash archive."""
111
+ record = {
112
+ "timestamp": datetime.now(timezone.utc).isoformat(),
113
+ "module": MODULE_NAME,
114
+ "thread": thread_name or threading.current_thread().name,
115
+ "exception_type": exc_type.__name__ if exc_type else "Unknown",
116
+ "exception_message": str(exc_value),
117
+ "traceback": "".join(traceback.format_exception(exc_type, exc_value, exc_tb)),
118
+ "severity": severity,
119
+ "handled": handled,
120
+ "process_id": os.getpid(),
121
+ "platform": sys.platform,
122
+ "runtime_version": f"Python {sys.version.split()[0]}",
123
+ }
124
+
125
+ if exc_tb:
126
+ tb_entries = traceback.extract_tb(exc_tb)
127
+ if tb_entries:
128
+ last = tb_entries[-1]
129
+ record["context"] = {
130
+ "function": last.name,
131
+ "file": os.path.basename(last.filename),
132
+ "line": last.lineno,
133
+ }
134
+
135
+ line = json.dumps(record, ensure_ascii=False) + "\n"
136
+
137
+ if _crash_log_path:
138
+ try:
139
+ with open(_crash_log_path, "a", encoding="utf-8") as f:
140
+ f.write(line)
141
+ except Exception:
142
+ pass
143
+
144
+ if _log_dir:
145
+ try:
146
+ today = datetime.now().strftime("%Y-%m-%d")
147
+ archive_dir = os.path.join(_log_dir, "crashes", today[:7])
148
+ os.makedirs(archive_dir, exist_ok=True)
149
+ archive_path = os.path.join(archive_dir, f"{today}.jsonl")
150
+ with open(archive_path, "a", encoding="utf-8") as f:
151
+ f.write(line)
152
+ except Exception:
153
+ pass
154
+
155
+ def _print_crash_summary(exc_type, exc_tb, thread_name=None):
156
+ """Print crash summary to console (red highlight)."""
157
+ RED = "\033[91m"
158
+ RESET = "\033[0m"
159
+
160
+ if exc_tb:
161
+ tb_entries = traceback.extract_tb(exc_tb)
162
+ if tb_entries:
163
+ last = tb_entries[-1]
164
+ location = f"{os.path.basename(last.filename)}:{last.lineno}"
165
+ else:
166
+ location = "unknown"
167
+ else:
168
+ location = "unknown"
169
+
170
+ prefix = f"[{MODULE_NAME}]"
171
+ if thread_name:
172
+ _builtin_print(f"{prefix} {RED}线程 {thread_name} 崩溃: "
173
+ f"{exc_type.__name__} in {location}{RESET}")
174
+ else:
175
+ _builtin_print(f"{prefix} {RED}崩溃: {exc_type.__name__} in {location}{RESET}")
176
+ if _crash_log_path:
177
+ _builtin_print(f"{prefix} 崩溃日志: {_crash_log_path}")
178
+
179
+ def _setup_exception_hooks():
180
+ """Set up global exception hooks."""
181
+ _orig_excepthook = sys.excepthook
182
+
183
+ def _excepthook(exc_type, exc_value, exc_tb):
184
+ _write_crash(exc_type, exc_value, exc_tb, severity="critical", handled=False)
185
+ _print_crash_summary(exc_type, exc_tb)
186
+ _orig_excepthook(exc_type, exc_value, exc_tb)
187
+
188
+ sys.excepthook = _excepthook
189
+
190
+ if hasattr(threading, "excepthook"):
191
+ def _thread_excepthook(args):
192
+ _write_crash(args.exc_type, args.exc_value, args.exc_traceback,
193
+ thread_name=args.thread.name if args.thread else "unknown",
194
+ severity="error", handled=False)
195
+ _print_crash_summary(args.exc_type, args.exc_traceback,
196
+ thread_name=args.thread.name if args.thread else None)
197
+
198
+ threading.excepthook = _thread_excepthook
199
+
200
+ def _tprint(*args, **kwargs):
201
+ """Timestamped print that adds [timestamp] HH:MM:SS.mmm +delta prefix."""
202
+ global _last_ts
203
+ now = time.monotonic()
204
+ elapsed = now - _start_ts
205
+ delta = now - _last_ts
206
+ _last_ts = now
207
+
208
+ if elapsed < 1:
209
+ elapsed_str = f"{elapsed * 1000:.0f}ms"
210
+ elif elapsed < 100:
211
+ elapsed_str = f"{elapsed:.1f}s"
212
+ else:
213
+ elapsed_str = f"{elapsed:.0f}s"
214
+
215
+ if delta < 0.001:
216
+ delta_str = ""
217
+ elif delta < 1:
218
+ delta_str = f"+{delta * 1000:.0f}ms"
219
+ elif delta < 100:
220
+ delta_str = f"+{delta:.1f}s"
221
+ else:
222
+ delta_str = f"+{delta:.0f}s"
223
+
224
+ ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
225
+
226
+ _builtin_print(*args, **kwargs)
227
+
228
+ if _log_latest_path or _log_daily_path:
229
+ sep = kwargs.get("sep", " ")
230
+ end = kwargs.get("end", "\n")
231
+ text = sep.join(str(a) for a in args)
232
+ prefix = f"[{elapsed_str:>6}] {ts} {delta_str:>8} "
233
+ _write_log(prefix + _strip_ansi(text) + end)
234
+
235
+ builtins.print = _tprint
236
+
237
+ # Ensure project root is on sys.path
18
238
  _this_dir = os.path.dirname(os.path.abspath(__file__))
19
239
  _project_root = os.environ.get("KITE_PROJECT") or os.path.dirname(os.path.dirname(_this_dir))
20
240
  if _project_root not in sys.path:
@@ -59,29 +279,68 @@ def _bind_port(preferred: int, host: str) -> int:
59
279
  return s.getsockname()[1]
60
280
 
61
281
 
62
- def _read_stdin_kite_message() -> dict | None:
63
- """Read a structured kite message from stdin (second line after boot_info).
64
- Uses a short timeout thread to avoid blocking forever if Launcher doesn't send.
282
+ def _read_stdin_kite_messages(expected: set[str], timeout: float = 10) -> dict[str, dict]:
283
+ """Read multiple structured kite messages from stdin until all expected types received.
284
+
285
+ Args:
286
+ expected: set of kite message types to wait for (e.g. {"launcher_ws_token", "registry_port"})
287
+ timeout: total timeout in seconds
288
+
289
+ Returns:
290
+ dict mapping kite type -> message dict. Missing types are absent from the result.
65
291
  """
66
- result = [None]
292
+ collected: dict[str, dict] = {}
293
+ remaining = set(expected)
67
294
 
68
295
  def _read():
69
- try:
70
- line = sys.stdin.readline().strip()
71
- if line:
296
+ while remaining:
297
+ try:
298
+ line = sys.stdin.readline().strip()
299
+ if not line:
300
+ return # stdin closed
72
301
  msg = json.loads(line)
73
302
  if isinstance(msg, dict) and "kite" in msg:
74
- result[0] = msg
75
- except Exception:
76
- pass
303
+ kite_type = msg["kite"]
304
+ collected[kite_type] = msg
305
+ remaining.discard(kite_type)
306
+ except Exception:
307
+ return # parse error or stdin closed
77
308
 
78
309
  t = threading.Thread(target=_read, daemon=True)
79
310
  t.start()
80
- t.join(timeout=5)
81
- return result[0]
311
+ t.join(timeout=timeout)
312
+ return collected
82
313
 
83
314
 
84
315
  def main():
316
+ # Initialize log file paths
317
+ global _log_dir, _log_latest_path, _crash_log_path
318
+ module_data = os.environ.get("KITE_MODULE_DATA")
319
+ if module_data:
320
+ _log_dir = os.path.join(module_data, "log")
321
+ os.makedirs(_log_dir, exist_ok=True)
322
+ suffix = os.environ.get("KITE_INSTANCE_SUFFIX", "")
323
+
324
+ _log_latest_path = os.path.join(_log_dir, f"latest{suffix}.log")
325
+ try:
326
+ with open(_log_latest_path, "w", encoding="utf-8") as f:
327
+ pass
328
+ except Exception:
329
+ _log_latest_path = None
330
+
331
+ _crash_log_path = os.path.join(_log_dir, f"crashes{suffix}.jsonl")
332
+ try:
333
+ with open(_crash_log_path, "w", encoding="utf-8") as f:
334
+ pass
335
+ except Exception:
336
+ _crash_log_path = None
337
+
338
+ _resolve_daily_log_path()
339
+
340
+ _setup_exception_hooks()
341
+
342
+ _t0 = time.monotonic()
343
+
85
344
  # Kite environment
86
345
  kite_instance = os.environ.get("KITE_INSTANCE", "")
87
346
  is_debug = os.environ.get("KITE_DEBUG") == "1"
@@ -100,24 +359,36 @@ def main():
100
359
  print("[event_hub] 错误: boot_info 中缺少令牌")
101
360
  sys.exit(1)
102
361
 
103
- # Step 2: Read launcher_ws_token from stdin (second line, structured kite message)
362
+ # Step 2: Check env for registry_port first (fast path for restart / Phase 3.5+ scenarios)
363
+ registry_port = int(os.environ.get("KITE_REGISTRY_PORT", "0"))
364
+
365
+ # Determine which stdin messages to wait for
366
+ stdin_expected = {"launcher_ws_token"}
367
+ if not registry_port:
368
+ stdin_expected.add("registry_port")
369
+
370
+ kite_msgs = _read_stdin_kite_messages(stdin_expected, timeout=10)
371
+
104
372
  launcher_ws_token = ""
105
- kite_msg = _read_stdin_kite_message()
106
- if kite_msg and kite_msg.get("kite") == "launcher_ws_token":
107
- launcher_ws_token = kite_msg.get("launcher_ws_token", "")
373
+ ws_msg = kite_msgs.get("launcher_ws_token")
374
+ if ws_msg:
375
+ launcher_ws_token = ws_msg.get("launcher_ws_token", "")
108
376
 
109
377
  if launcher_ws_token:
110
378
  print(f"[event_hub] 已收到启动器 WS 令牌 ({len(launcher_ws_token)} 字符)")
111
379
  else:
112
380
  print("[event_hub] 警告: 未收到 launcher_ws_token,启动器引导认证已禁用")
113
381
 
114
- # Step 3: Read registry_port from environment variable
115
- registry_port = int(os.environ.get("KITE_REGISTRY_PORT", "0"))
382
+ # Step 3: registry_port — env already had it, or read from stdin
383
+ if not registry_port:
384
+ port_msg = kite_msgs.get("registry_port")
385
+ if port_msg:
386
+ registry_port = int(port_msg.get("registry_port", 0))
116
387
  if not registry_port:
117
- print("[event_hub] 错误: KITE_REGISTRY_PORT 未设置")
388
+ print("[event_hub] 错误: 未收到 registry_port(stdin 和环境变量均无)")
118
389
  sys.exit(1)
119
390
 
120
- print(f"[event_hub] 已收到令牌 ({len(token)} 字符),Registry 端口: {registry_port}")
391
+ print(f"[event_hub] 已收到令牌 ({len(token)} 字符),Registry 端口: {registry_port} ({_fmt_elapsed(_t0)})")
121
392
 
122
393
  # Step 4: Read config from own module.md
123
394
  md_config = _read_module_md()
@@ -149,8 +420,16 @@ def main():
149
420
  # Step 7: Start HTTP + WS server
150
421
  # Launcher will connect with launcher_ws_token → Event Hub sends module.ready → stdio disconnect
151
422
  # After stdio disconnect, Event Hub registers to Registry (done by server on_launcher_connected callback)
152
- print(f"[event_hub] 启动中 {bind_host}:{port}")
153
- uvicorn.run(server.app, host=bind_host, port=port, log_level="warning")
423
+ print(f"[event_hub] 启动中 {bind_host}:{port} ({_fmt_elapsed(_t0)})")
424
+ try:
425
+ config = uvicorn.Config(server.app, host=bind_host, port=port, log_level="warning")
426
+ uvi_server = uvicorn.Server(config)
427
+ server._uvicorn_server = uvi_server
428
+ uvi_server.run()
429
+ except Exception as e:
430
+ _write_crash(type(e), e, e.__traceback__, severity="critical", handled=True)
431
+ _print_crash_summary(type(e), e.__traceback__)
432
+ sys.exit(1)
154
433
 
155
434
 
156
435
  if __name__ == "__main__":
@@ -55,6 +55,8 @@ class EventHub:
55
55
  self._cnt_dedup = 0
56
56
  self._cnt_errors = 0
57
57
  self._start_time = time.time()
58
+ # Shutdown callback: set by EventHubServer to handle own shutdown
59
+ self._shutdown_cb = None
58
60
 
59
61
  # ── Connection lifecycle ──
60
62
 
@@ -186,6 +188,12 @@ class EventHub:
186
188
  await self._route_event(module_id, msg)
187
189
  await self._send_ack(ws, event_id)
188
190
 
191
+ # Check if this is a shutdown event targeting event_hub
192
+ if event_type == "module.shutdown" and self._shutdown_cb:
193
+ data = msg.get("data", {})
194
+ if data.get("module_id") == "event_hub":
195
+ asyncio.create_task(self._shutdown_cb(data))
196
+
189
197
  # ── Routing ──
190
198
 
191
199
  async def _route_event(self, sender_id: str, msg: dict):
@@ -5,7 +5,7 @@ FastAPI app: /ws (WebSocket), /health, /stats.
5
5
 
6
6
  Launcher bootstrap sequence:
7
7
  Launcher connects with launcher_ws_token → Event Hub verifies locally →
8
- sends module.readyregisters to Registry.
8
+ registers to Registry sends module.ready.
9
9
  """
10
10
 
11
11
  import asyncio
@@ -35,7 +35,12 @@ class EventHubServer:
35
35
  self._timer_task: asyncio.Task | None = None
36
36
  self._launcher_connected = False
37
37
  self._registered_to_registry = False
38
+ self._uvicorn_server = None # set by entry.py for graceful shutdown
39
+ self._launcher_ws: WebSocket | None = None # reference to Launcher's WS
40
+ self._http_client: httpx.AsyncClient | None = None # reused for Registry calls
38
41
  self.app = self._create_app()
42
+ # Register shutdown callback on hub
43
+ self.hub._shutdown_cb = self._handle_shutdown
39
44
 
40
45
  # ── Token verification ──
41
46
 
@@ -49,18 +54,22 @@ class EventHubServer:
49
54
  if self.launcher_ws_token and token == self.launcher_ws_token:
50
55
  return "launcher"
51
56
  # Normal verification via Registry
57
+ import time as _time
58
+ _t0 = _time.monotonic()
52
59
  try:
53
- async with httpx.AsyncClient() as client:
54
- resp = await client.post(
55
- f"{self.registry_url}/verify",
56
- json={"token": token},
57
- headers={"Authorization": f"Bearer {self.own_token}"},
58
- timeout=5,
59
- )
60
- if resp.status_code == 200:
61
- body = resp.json()
62
- if body.get("ok"):
63
- return body.get("module_id")
60
+ resp = await self._http_client.post(
61
+ f"{self.registry_url}/verify",
62
+ json={"token": token},
63
+ headers={"Authorization": f"Bearer {self.own_token}"},
64
+ timeout=5,
65
+ )
66
+ _t1 = _time.monotonic()
67
+ if resp.status_code == 200:
68
+ body = resp.json()
69
+ if body.get("ok"):
70
+ mid = body.get("module_id")
71
+ print(f"[event_hub] _verify_token({mid}): {(_t1-_t0)*1000:.0f}ms")
72
+ return mid
64
73
  except Exception as e:
65
74
  print(f"[event_hub] Token verification failed: {e}")
66
75
  return None
@@ -68,8 +77,13 @@ class EventHubServer:
68
77
  # ── Launcher bootstrap ──
69
78
 
70
79
  async def _on_launcher_connected(self, ws: WebSocket):
71
- """Called on first Launcher WS connect. Sends module.ready, then registers to Registry."""
80
+ """Called on first Launcher WS connect. Registers to Registry first, then sends module.ready.
81
+ This order ensures all startup-phase prints are done before Launcher calls close_stdio."""
72
82
  self._launcher_connected = True
83
+ self._launcher_ws = ws
84
+ # Step 1: Register to Registry (may print log lines)
85
+ await self._register_to_registry()
86
+ # Step 2: Send module.ready (Launcher will close_stdio after receiving this)
73
87
  msg = {
74
88
  "type": "event",
75
89
  "event_id": str(uuid.uuid4()),
@@ -79,6 +93,7 @@ class EventHubServer:
79
93
  "data": {
80
94
  "module_id": "event_hub",
81
95
  "ws_endpoint": f"ws://{self.advertise_ip}:{self.port}/ws",
96
+ "graceful_shutdown": True,
82
97
  },
83
98
  }
84
99
  try:
@@ -86,8 +101,48 @@ class EventHubServer:
86
101
  print("[event_hub] Sent module.ready to Launcher")
87
102
  except Exception as e:
88
103
  print(f"[event_hub] Failed to send module.ready: {e}")
89
- # Register to Registry in background
90
- asyncio.create_task(self._register_to_registry())
104
+
105
+ async def _handle_shutdown(self, data: dict):
106
+ """Handle module.shutdown event targeting event_hub."""
107
+ print("[event_hub] Received shutdown request")
108
+ # Send ack and ready via Launcher's WS connection
109
+ ws = self._launcher_ws
110
+ if ws:
111
+ try:
112
+ ack_msg = {
113
+ "type": "event",
114
+ "event_id": str(uuid.uuid4()),
115
+ "event": "module.shutdown.ack",
116
+ "source": "event_hub",
117
+ "timestamp": datetime.now(timezone.utc).isoformat(),
118
+ "data": {"module_id": "event_hub", "estimated_cleanup": 2},
119
+ }
120
+ await ws.send_text(json.dumps(ack_msg))
121
+ except Exception as e:
122
+ print(f"[event_hub] Failed to send shutdown ack: {e}")
123
+ # Cleanup
124
+ if self._timer_task:
125
+ self._timer_task.cancel()
126
+ if self._http_client:
127
+ await self._http_client.aclose()
128
+ # Send ready
129
+ if ws:
130
+ try:
131
+ ready_msg = {
132
+ "type": "event",
133
+ "event_id": str(uuid.uuid4()),
134
+ "event": "module.shutdown.ready",
135
+ "source": "event_hub",
136
+ "timestamp": datetime.now(timezone.utc).isoformat(),
137
+ "data": {"module_id": "event_hub"},
138
+ }
139
+ await ws.send_text(json.dumps(ready_msg))
140
+ except Exception as e:
141
+ print(f"[event_hub] Failed to send shutdown ready: {e}")
142
+ print("[event_hub] Shutdown ready, exiting")
143
+ # Trigger uvicorn exit
144
+ if self._uvicorn_server:
145
+ self._uvicorn_server.should_exit = True
91
146
 
92
147
  async def _register_to_registry(self):
93
148
  """Register to Registry. Triggered after Launcher connects."""
@@ -129,6 +184,8 @@ class EventHubServer:
129
184
 
130
185
  @app.on_event("startup")
131
186
  async def _startup():
187
+ # Initialize HTTP client early to avoid blocking first token verification
188
+ server._http_client = httpx.AsyncClient()
132
189
  server._timer_task = asyncio.create_task(server._timer_loop())
133
190
 
134
191
  @app.on_event("shutdown")
@@ -148,7 +205,10 @@ class EventHubServer:
148
205
  # causing websockets 15.x clients to get "no close frame received or sent" errors.
149
206
  await ws.accept()
150
207
  print(f"[event_hub] 认证失败: token={token[:8]}... hint={mid_hint}")
151
- await ws.close(code=4001, reason="Authentication failed")
208
+ try:
209
+ await ws.close(code=4001, reason="Authentication failed")
210
+ except Exception:
211
+ pass
152
212
  return
153
213
 
154
214
  await ws.accept()
@@ -168,7 +228,10 @@ class EventHubServer:
168
228
  except WebSocketDisconnect:
169
229
  pass
170
230
  except Exception as e:
171
- print(f"[event_hub] WebSocket error for {module_id}: {e}")
231
+ # Connection-state errors are normal during shutdown
232
+ err = str(e).lower()
233
+ if "not connected" not in err and "closed" not in err:
234
+ print(f"[event_hub] WebSocket error for {module_id}: {e}")
172
235
  finally:
173
236
  server.hub.remove_connection(module_id)
174
237