@agentunion/kite 1.0.5 → 1.0.7

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 (49) hide show
  1. package/cli.js +127 -25
  2. package/core/event_hub/entry.py +105 -61
  3. package/core/event_hub/module.md +0 -1
  4. package/core/event_hub/server.py +96 -28
  5. package/core/launcher/entry.py +477 -290
  6. package/core/launcher/module_scanner.py +10 -9
  7. package/core/launcher/process_manager.py +120 -96
  8. package/core/registry/entry.py +66 -30
  9. package/core/registry/server.py +47 -14
  10. package/core/registry/store.py +6 -1
  11. package/{core → extensions}/event_hub_bench/entry.py +17 -9
  12. package/{core → extensions}/event_hub_bench/module.md +2 -1
  13. package/extensions/services/watchdog/entry.py +11 -7
  14. package/extensions/services/watchdog/server.py +1 -1
  15. package/main.py +204 -4
  16. package/package.json +11 -2
  17. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  18. package/core/__pycache__/data_dir.cpython-313.pyc +0 -0
  19. package/core/data_dir.py +0 -62
  20. package/core/event_hub/__pycache__/__init__.cpython-313.pyc +0 -0
  21. package/core/event_hub/__pycache__/bench.cpython-313.pyc +0 -0
  22. package/core/event_hub/__pycache__/bench_perf.cpython-313.pyc +0 -0
  23. package/core/event_hub/__pycache__/dedup.cpython-313.pyc +0 -0
  24. package/core/event_hub/__pycache__/entry.cpython-313.pyc +0 -0
  25. package/core/event_hub/__pycache__/hub.cpython-313.pyc +0 -0
  26. package/core/event_hub/__pycache__/router.cpython-313.pyc +0 -0
  27. package/core/event_hub/__pycache__/server.cpython-313.pyc +0 -0
  28. package/core/event_hub/bench_results/.gitkeep +0 -0
  29. package/core/event_hub/bench_results/2026-02-28_13-26-48.json +0 -51
  30. package/core/event_hub/bench_results/2026-02-28_13-44-45.json +0 -51
  31. package/core/event_hub/bench_results/2026-02-28_13-45-39.json +0 -51
  32. package/core/launcher/__pycache__/__init__.cpython-313.pyc +0 -0
  33. package/core/launcher/__pycache__/entry.cpython-313.pyc +0 -0
  34. package/core/launcher/__pycache__/module_scanner.cpython-313.pyc +0 -0
  35. package/core/launcher/__pycache__/process_manager.cpython-313.pyc +0 -0
  36. package/core/launcher/data/log/lifecycle.jsonl +0 -1158
  37. package/core/launcher/data/token.txt +0 -1
  38. package/core/registry/__pycache__/__init__.cpython-313.pyc +0 -0
  39. package/core/registry/__pycache__/entry.cpython-313.pyc +0 -0
  40. package/core/registry/__pycache__/server.cpython-313.pyc +0 -0
  41. package/core/registry/__pycache__/store.cpython-313.pyc +0 -0
  42. package/core/registry/data/port.txt +0 -1
  43. package/core/registry/data/port_484.txt +0 -1
  44. package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
  45. package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
  46. package/extensions/services/watchdog/__pycache__/__init__.cpython-313.pyc +0 -0
  47. package/extensions/services/watchdog/__pycache__/entry.cpython-313.pyc +0 -0
  48. package/extensions/services/watchdog/__pycache__/monitor.cpython-313.pyc +0 -0
  49. package/extensions/services/watchdog/__pycache__/server.cpython-313.pyc +0 -0
@@ -34,9 +34,9 @@ class ModuleInfo:
34
34
  module_dir: str = "" # absolute path to the module directory
35
35
  launch: LaunchConfig = field(default_factory=LaunchConfig)
36
36
 
37
- def is_core(self, project_root: str) -> bool:
38
- """Core modules live directly under {project_root}/core/."""
39
- core_dir = os.path.join(project_root, "core")
37
+ def is_core(self) -> bool:
38
+ """Core modules live directly under {KITE_PROJECT}/core/."""
39
+ core_dir = os.path.join(os.environ["KITE_PROJECT"], "core")
40
40
  return os.path.normcase(self.module_dir).startswith(os.path.normcase(core_dir + os.sep))
41
41
 
42
42
 
@@ -89,18 +89,19 @@ def _parse_frontmatter(text: str) -> dict:
89
89
  class ModuleScanner:
90
90
  """Discover modules via configurable sources."""
91
91
 
92
- def __init__(self, project_root: str, discovery: dict = None, launcher_dir: str = ""):
93
- self.project_root = project_root
92
+ def __init__(self, discovery: dict = None, launcher_dir: str = ""):
94
93
  self.discovery = discovery
94
+ project_root = os.environ["KITE_PROJECT"]
95
95
  self.launcher_dir = launcher_dir or os.path.join(project_root, "core", "launcher")
96
96
 
97
97
  def scan(self) -> dict[str, ModuleInfo]:
98
98
  """Return dict of {module_name: ModuleInfo}. Duplicate names are skipped."""
99
99
  modules = {}
100
+ project_root = os.environ["KITE_PROJECT"]
100
101
 
101
102
  # Built-in: always scan core/ (depth 1) and extensions/ (depth 2)
102
- self._scan_dir(os.path.join(self.project_root, "core"), 1, modules)
103
- self._scan_dir(os.path.join(self.project_root, "extensions"), 2, modules)
103
+ self._scan_dir(os.path.join(project_root, "core"), 1, modules)
104
+ self._scan_dir(os.path.join(project_root, "extensions"), 2, modules)
104
105
 
105
106
  # Extra sources from discovery config
106
107
  if self.discovery:
@@ -118,7 +119,7 @@ class ModuleScanner:
118
119
  if not path:
119
120
  continue
120
121
  if not os.path.isabs(path):
121
- path = os.path.join(self.project_root, path)
122
+ path = os.path.join(project_root, path)
122
123
 
123
124
  if src_type == "scan_dir":
124
125
  max_depth = int(src.get("max_depth", 2))
@@ -156,7 +157,7 @@ class ModuleScanner:
156
157
  if not line or line.startswith("#"):
157
158
  continue
158
159
  if not os.path.isabs(line):
159
- line = os.path.join(self.project_root, line)
160
+ line = os.path.join(os.environ["KITE_PROJECT"], line)
160
161
  self._add_module(line, modules)
161
162
  except Exception as e:
162
163
  print(f"[launcher] WARNING: failed to read module list {list_path}: {e}")
@@ -1,6 +1,11 @@
1
1
  """
2
2
  Manage child process lifecycle: start, stop, monitor, cleanup leftovers.
3
3
  Cross-platform support for Windows, Linux, and macOS.
4
+
5
+ stdio lifecycle:
6
+ - stdin: write boot_info → keep open (Launcher may send more messages) → close after module.ready
7
+ - stdout: read structured messages + log lines → close after module.ready
8
+ - Structured messages: JSON lines with "kite" field, dispatched via callback
4
9
  """
5
10
 
6
11
  import json
@@ -10,6 +15,7 @@ import sys
10
15
  import threading
11
16
  import time
12
17
  from dataclasses import dataclass, asdict
18
+ from typing import Callable
13
19
 
14
20
 
15
21
  IS_WINDOWS = sys.platform == "win32"
@@ -28,15 +34,19 @@ class ProcessRecord:
28
34
  class ProcessManager:
29
35
  """Manage child processes for all Kite modules."""
30
36
 
31
- def __init__(self, project_root: str, kite_token: str, instance_id: str = ""):
32
- self.project_root = project_root
37
+ def __init__(self, kite_token: str, instance_id: str = "",
38
+ on_kite_message: Callable[[str, dict], None] | None = None):
39
+ """
40
+ Args:
41
+ on_kite_message: callback(module_name, msg_dict) for structured stdout messages.
42
+ Called from stdout reader thread — must be thread-safe.
43
+ """
33
44
  self.kite_token = kite_token
34
45
  self.instance_id = instance_id or str(os.getpid())
35
- # Use centralized data directory management
36
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
37
- from data_dir import get_launcher_data_dir
38
- self.data_dir = get_launcher_data_dir()
39
- self.records_path = os.path.join(self.data_dir, f"processes_{self.instance_id}.json")
46
+ self._on_kite_message = on_kite_message
47
+ # Use KITE_MODULE_DATA (set by Launcher for itself)
48
+ self.data_dir = os.environ.get("KITE_MODULE_DATA") or os.path.join(os.environ["KITE_INSTANCE_DIR"], "launcher")
49
+ self.records_path = os.path.join(self.data_dir, "processes.json")
40
50
  self._processes: dict[str, subprocess.Popen] = {} # name -> Popen
41
51
  self._records: dict[str, ProcessRecord] = {} # name -> record
42
52
  os.makedirs(self.data_dir, exist_ok=True)
@@ -44,77 +54,36 @@ class ProcessManager:
44
54
  # ── Leftover cleanup ──
45
55
 
46
56
  def cleanup_leftovers(self):
47
- """Scan all processes_*.json files, kill leftovers from dead instances, preserve live ones."""
48
- import glob
49
-
50
- # Also clean legacy processes.json (pre-multi-instance)
51
- legacy = os.path.join(self.data_dir, "processes.json")
52
- patterns = [
53
- os.path.join(self.data_dir, "processes_*.json"),
54
- ]
55
- files = []
56
- for p in patterns:
57
- files.extend(glob.glob(p))
58
- if os.path.isfile(legacy):
59
- files.append(legacy)
60
-
61
- registry_data_dir = os.path.join(self.project_root, "core", "registry", "data")
62
- # Also check user home registry data dir
63
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
64
- from data_dir import get_registry_data_dir
65
- registry_data_dir_alt = get_registry_data_dir()
66
-
67
- for path in files:
68
- if path == self.records_path:
69
- continue # skip our own instance
70
-
71
- # If this is an instance file, check if that Launcher is still alive
72
- basename = os.path.basename(path)
73
- if basename.startswith("processes_") and basename.endswith(".json"):
74
- inst_id = basename[len("processes_"):-len(".json")]
75
- try:
76
- if self._is_pid_alive(int(inst_id)):
77
- continue # that Launcher is still running, skip
78
- except (ValueError, TypeError):
79
- pass
57
+ """Read processes.json in current instance dir, kill leftovers from previous runs."""
58
+ if not os.path.isfile(self.records_path):
59
+ return
80
60
 
61
+ try:
62
+ with open(self.records_path, "r", encoding="utf-8") as f:
63
+ saved = json.load(f)
64
+ except Exception:
81
65
  try:
82
- with open(path, "r", encoding="utf-8") as f:
83
- saved = json.load(f)
84
- except Exception:
85
- os.remove(path)
66
+ os.remove(self.records_path)
67
+ except OSError:
68
+ pass
69
+ return
70
+
71
+ for entry in saved:
72
+ pid = entry.get("pid", 0)
73
+ cmd = entry.get("cmd", [])
74
+ name = entry.get("name", "?")
75
+ if not pid:
86
76
  continue
77
+ if self._is_pid_alive(pid) and self._cmd_matches(pid, cmd):
78
+ print(f"[launcher] 正在清理残留进程: {name} (PID {pid})")
79
+ self._force_kill(pid)
80
+ else:
81
+ print(f"[launcher] 残留进程 {name} (PID {pid}) 已不存在")
87
82
 
88
- for entry in saved:
89
- pid = entry.get("pid", 0)
90
- cmd = entry.get("cmd", [])
91
- name = entry.get("name", "?")
92
- if not pid:
93
- continue
94
- if self._is_pid_alive(pid) and self._cmd_matches(pid, cmd):
95
- print(f"[launcher] Killing leftover process: {name} (PID {pid})")
96
- self._force_kill(pid)
97
- else:
98
- print(f"[launcher] Leftover {name} (PID {pid}) already gone")
99
-
100
- os.remove(path)
101
-
102
- # Remove corresponding port file
103
- basename = os.path.basename(path)
104
- if basename.startswith("processes_") and basename.endswith(".json"):
105
- inst_id = basename[len("processes_"):-len(".json")]
106
- port_file = os.path.join(registry_data_dir, f"port_{inst_id}.txt")
107
- if os.path.isfile(port_file):
108
- os.remove(port_file)
109
- # Also check alt location
110
- port_file_alt = os.path.join(registry_data_dir_alt, f"port_{inst_id}.txt")
111
- if os.path.isfile(port_file_alt):
112
- os.remove(port_file_alt)
113
-
114
- # Also clean legacy port.txt
115
- legacy_port = os.path.join(registry_data_dir, "port.txt")
116
- if os.path.isfile(legacy_port):
117
- os.remove(legacy_port)
83
+ try:
84
+ os.remove(self.records_path)
85
+ except OSError:
86
+ pass
118
87
 
119
88
  def _is_pid_alive(self, pid: int) -> bool:
120
89
  """Check if a process with given PID exists."""
@@ -229,13 +198,14 @@ class ProcessManager:
229
198
  import signal
230
199
  os.kill(pid, signal.SIGKILL)
231
200
  except Exception as e:
232
- print(f"[launcher] WARNING: failed to kill PID {pid}: {e}")
201
+ print(f"[launcher] 警告: 强制终止 PID {pid} 失败: {e}")
233
202
 
234
203
  # ── Start module ──
235
204
 
236
205
  def start_module(self, info, boot_info: dict = None) -> bool:
237
206
  """Start a module as a subprocess. Returns True on success.
238
207
  If boot_info is provided, it is written as JSON to the child's stdin pipe.
208
+ stdin is kept open after boot_info for Launcher to send additional messages.
239
209
  """
240
210
  from .module_scanner import ModuleInfo
241
211
  info: ModuleInfo
@@ -243,17 +213,18 @@ class ProcessManager:
243
213
  if info.name in self._processes:
244
214
  proc = self._processes[info.name]
245
215
  if proc.poll() is None:
246
- print(f"[launcher] {info.name} already running (PID {proc.pid})")
216
+ print(f"[launcher] {info.name} 已在运行 (PID {proc.pid})")
247
217
  return True
248
218
 
249
219
  cmd = self._build_cmd(info)
250
220
  if not cmd:
251
- print(f"[launcher] ERROR: cannot build command for {info.name}")
221
+ print(f"[launcher] 错误: 无法构建 {info.name} 的启动命令")
252
222
  return False
253
223
 
254
224
  env = os.environ.copy()
255
225
  env["PYTHONUTF8"] = "1" # Force UTF-8 in child Python processes
256
226
  env["PYTHONUNBUFFERED"] = "1" # Disable output buffering for real-time logs
227
+ env["KITE_MODULE_DATA"] = os.path.join(os.environ["KITE_INSTANCE_DIR"], info.name)
257
228
  if info.launch.env:
258
229
  env.update(info.launch.env)
259
230
 
@@ -278,17 +249,16 @@ class ProcessManager:
278
249
  creationflags=creation_flags,
279
250
  )
280
251
  except Exception as e:
281
- print(f"[launcher] ERROR: failed to start {info.name}: {e}")
252
+ print(f"[launcher] 错误: 启动 {info.name} 失败: {e}")
282
253
  return False
283
254
 
284
- # Write boot_info to stdin then close
255
+ # Write boot_info to stdin keep stdin open for additional messages
285
256
  if use_stdin and proc.stdin:
286
257
  try:
287
258
  proc.stdin.write(json.dumps(boot_info).encode() + b"\n")
288
259
  proc.stdin.flush()
289
- proc.stdin.close()
290
260
  except Exception as e:
291
- print(f"[launcher] WARNING: failed to write boot_info to {info.name}: {e}")
261
+ print(f"[launcher] 警告: {info.name} 写入 boot_info 失败: {e}")
292
262
 
293
263
  self._processes[info.name] = proc
294
264
  self._records[info.name] = ProcessRecord(
@@ -299,16 +269,50 @@ class ProcessManager:
299
269
  started_at=time.time(),
300
270
  )
301
271
 
302
- # Daemon thread to read stdout and prefix with module name
272
+ # Daemon thread to read stdout: structured messages + log lines
303
273
  t = threading.Thread(
304
274
  target=self._read_stdout, args=(info.name, proc),
305
275
  daemon=True,
306
276
  )
307
277
  t.start()
308
278
 
309
- print(f"[launcher] Started {info.name} (PID {proc.pid})")
279
+ print(f"[launcher] 已启动 {info.name} (PID {proc.pid})")
310
280
  return True
311
281
 
282
+ def write_stdin(self, name: str, data: dict) -> bool:
283
+ """Write a structured JSON message to a module's stdin.
284
+ Used by Launcher to send additional messages after boot_info (e.g. launcher_ws_token).
285
+ Returns True on success.
286
+ """
287
+ proc = self._processes.get(name)
288
+ if not proc or not proc.stdin or proc.stdin.closed:
289
+ return False
290
+ try:
291
+ proc.stdin.write(json.dumps(data).encode() + b"\n")
292
+ proc.stdin.flush()
293
+ return True
294
+ except Exception as e:
295
+ print(f"[launcher] 警告: 向 {name} 写入 stdin 失败: {e}")
296
+ return False
297
+
298
+ def close_stdio(self, name: str):
299
+ """Close stdin and stdout for a module. Called by Launcher after module.ready → module.started.
300
+ stdout reader thread will exit naturally when stdout is closed.
301
+ """
302
+ proc = self._processes.get(name)
303
+ if not proc:
304
+ return
305
+ if proc.stdin and not proc.stdin.closed:
306
+ try:
307
+ proc.stdin.close()
308
+ except Exception:
309
+ pass
310
+ if proc.stdout and not proc.stdout.closed:
311
+ try:
312
+ proc.stdout.close()
313
+ except Exception:
314
+ pass
315
+
312
316
  def _build_cmd(self, info) -> list:
313
317
  """Build the command list based on module runtime.
314
318
 
@@ -329,16 +333,32 @@ class ProcessManager:
329
333
  elif info.runtime == "binary":
330
334
  return [os.path.join(info.module_dir, info.entry)]
331
335
  else:
332
- print(f"[launcher] WARNING: unknown runtime '{info.runtime}' for {info.name}")
336
+ print(f"[launcher] 警告: 未知运行时 '{info.runtime}',模块 {info.name}")
333
337
  return []
334
338
 
335
- @staticmethod
336
- def _read_stdout(name: str, proc: subprocess.Popen):
337
- """Read subprocess stdout line by line, prefix with module name."""
339
+ def _read_stdout(self, name: str, proc: subprocess.Popen):
340
+ """Read subprocess stdout line by line.
341
+ Lines containing a JSON object with "kite" field are dispatched as structured messages.
342
+ All other lines are printed as log output with module name prefix.
343
+ """
338
344
  try:
339
345
  for line in iter(proc.stdout.readline, b""):
340
346
  text = line.decode("utf-8", errors="replace").rstrip()
341
- if text:
347
+ if not text:
348
+ continue
349
+ # Try to parse as structured kite message
350
+ if self._on_kite_message and text.startswith("{"):
351
+ try:
352
+ msg = json.loads(text)
353
+ if isinstance(msg, dict) and "kite" in msg:
354
+ self._on_kite_message(name, msg)
355
+ continue
356
+ except (json.JSONDecodeError, KeyError):
357
+ pass
358
+ # Regular log line — avoid double prefix if module already added [name]
359
+ if text.startswith(f"[{name}]"):
360
+ print(text)
361
+ else:
342
362
  print(f"[{name}] {text}")
343
363
  except Exception:
344
364
  pass
@@ -353,12 +373,15 @@ class ProcessManager:
353
373
  self._records.pop(name, None)
354
374
  return True
355
375
 
356
- print(f"[launcher] Stopping {name} (PID {proc.pid})...")
376
+ # Close stdio before terminating
377
+ self.close_stdio(name)
378
+
379
+ print(f"[launcher] 正在停止 {name} (PID {proc.pid})...")
357
380
  proc.terminate()
358
381
  try:
359
382
  proc.wait(timeout=timeout)
360
383
  except subprocess.TimeoutExpired:
361
- print(f"[launcher] {name} did not exit in {timeout}s, force killing")
384
+ print(f"[launcher] {name} {timeout}s 内未退出,强制终止")
362
385
  proc.kill()
363
386
  try:
364
387
  proc.wait(timeout=3)
@@ -367,7 +390,7 @@ class ProcessManager:
367
390
 
368
391
  self._processes.pop(name, None)
369
392
  self._records.pop(name, None)
370
- print(f"[launcher] Stopped {name}")
393
+ print(f"[launcher] 已停止 {name}")
371
394
  return True
372
395
 
373
396
  def stop_all(self, timeout: float = 10):
@@ -376,10 +399,11 @@ class ProcessManager:
376
399
  return
377
400
 
378
401
  names = list(self._processes.keys())
379
- print(f"[launcher] Stopping all modules: {', '.join(names)}")
402
+ print(f"[launcher] 正在停止所有模块: {', '.join(names)}")
380
403
 
381
- # Phase 1: send terminate to all
404
+ # Phase 1: close stdio and send terminate to all
382
405
  for name in names:
406
+ self.close_stdio(name)
383
407
  proc = self._processes.get(name)
384
408
  if proc and proc.poll() is None:
385
409
  proc.terminate()
@@ -400,7 +424,7 @@ class ProcessManager:
400
424
  for name in names:
401
425
  proc = self._processes.get(name)
402
426
  if proc and proc.poll() is None:
403
- print(f"[launcher] Force killing {name} (PID {proc.pid})")
427
+ print(f"[launcher] 强制终止 {name} (PID {proc.pid})")
404
428
  proc.kill()
405
429
  try:
406
430
  proc.wait(timeout=3)
@@ -409,7 +433,7 @@ class ProcessManager:
409
433
 
410
434
  self._processes.clear()
411
435
  self._records.clear()
412
- print("[launcher] All modules stopped")
436
+ print("[launcher] 所有模块已停止")
413
437
 
414
438
  # ── Persistence & monitoring ──
415
439
 
@@ -443,4 +467,4 @@ class ProcessManager:
443
467
  with open(self.records_path, "w", encoding="utf-8") as f:
444
468
  json.dump(data, f, indent=2)
445
469
  except Exception as e:
446
- print(f"[launcher] WARNING: failed to write processes.json: {e}")
470
+ print(f"[launcher] 警告: 写入 processes.json 失败: {e}")
@@ -1,17 +1,19 @@
1
1
  """
2
2
  Registry entry point.
3
- Reads launcher_token, starts HTTP server on dynamic port, writes port.txt.
3
+ Reads token from stdin boot_info, starts HTTP server on dynamic port,
4
+ outputs port via stdout structured message, waits for Event Hub to trigger module.ready.
4
5
  """
5
6
 
7
+ import json
6
8
  import os
7
9
  import sys
8
10
  import socket
9
11
 
10
12
  import uvicorn
11
13
 
12
- # Ensure project root is on sys.path
14
+ # Ensure project root is on sys.path (set by main.py or cli.js)
13
15
  _this_dir = os.path.dirname(os.path.abspath(__file__))
14
- _project_root = os.path.dirname(os.path.dirname(_this_dir))
16
+ _project_root = os.environ.get("KITE_PROJECT") or os.path.dirname(os.path.dirname(_this_dir))
15
17
  if _project_root not in sys.path:
16
18
  sys.path.insert(0, _project_root)
17
19
 
@@ -19,54 +21,88 @@ from core.registry.store import RegistryStore
19
21
  from core.registry.server import RegistryServer
20
22
 
21
23
 
22
- def _get_free_port() -> int:
24
+ def _read_module_md() -> dict:
25
+ """Read preferred_port and advertise_ip from own module.md."""
26
+ md_path = os.path.join(_this_dir, "module.md")
27
+ result = {"preferred_port": 0, "advertise_ip": "127.0.0.1"}
28
+ try:
29
+ with open(md_path, "r", encoding="utf-8") as f:
30
+ text = f.read()
31
+ import re
32
+ m = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL)
33
+ if m:
34
+ try:
35
+ import yaml
36
+ fm = yaml.safe_load(m.group(1)) or {}
37
+ except ImportError:
38
+ fm = {}
39
+ result["preferred_port"] = int(fm.get("preferred_port", 0))
40
+ result["advertise_ip"] = fm.get("advertise_ip", "127.0.0.1")
41
+ except Exception:
42
+ pass
43
+ return result
44
+
45
+
46
+ def _bind_port(preferred: int, host: str) -> int:
47
+ """Try preferred port first, fall back to OS-assigned."""
48
+ if preferred:
49
+ try:
50
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
51
+ s.bind((host, preferred))
52
+ return preferred
53
+ except OSError:
54
+ pass
23
55
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
24
- s.bind(("127.0.0.1", 0))
56
+ s.bind((host, 0))
25
57
  return s.getsockname()[1]
26
58
 
27
59
 
28
- def _write_port_file(port: int, instance_id: str = ""):
29
- from core.data_dir import get_registry_data_dir
30
- data_dir = get_registry_data_dir()
31
- os.makedirs(data_dir, exist_ok=True)
32
- filename = f"port_{instance_id}.txt" if instance_id else "port.txt"
33
- port_file = os.path.join(data_dir, filename)
34
- with open(port_file, "w") as f:
35
- f.write(str(port))
36
- print(f"[registry] Port written to {port_file}")
37
-
38
-
39
60
  def main():
40
- # Read launcher_token and config from stdin (boot_info JSON pipe)
61
+ # Kite environment
62
+ kite_instance = os.environ.get("KITE_INSTANCE", "")
63
+ is_debug = os.environ.get("KITE_DEBUG") == "1"
64
+
65
+ # Step 1: Read token from stdin boot_info (only token)
41
66
  launcher_token = ""
42
- bind_host = "127.0.0.1"
43
- instance_id = ""
44
67
  try:
45
- import json
46
68
  line = sys.stdin.readline().strip()
47
69
  if line:
48
70
  boot_info = json.loads(line)
49
71
  launcher_token = boot_info.get("token", "")
50
- bind_host = boot_info.get("bind", "127.0.0.1")
51
- instance_id = boot_info.get("instance_id", "")
52
72
  except Exception:
53
73
  pass
54
74
 
55
75
  if not launcher_token:
56
- print("[registry] ERROR: No launcher token provided via stdin boot_info")
76
+ print("[registry] 错误: 未通过 stdin boot_info 提供启动器令牌")
57
77
  sys.exit(1)
58
78
 
59
- print(f"[registry] Launcher token received ({len(launcher_token)} chars)")
79
+ print(f"[registry] 已收到启动器令牌 ({len(launcher_token)} 字符)")
60
80
 
61
- # Create store and server
81
+ if is_debug:
82
+ print("[registry] 调试模式已启用 (KITE_DEBUG=1),接受所有令牌")
83
+
84
+ # Step 2: Read config from own module.md
85
+ md_config = _read_module_md()
86
+ advertise_ip = md_config["advertise_ip"]
87
+ preferred_port = md_config["preferred_port"]
88
+
89
+ # Step 3: Create store and server
62
90
  store = RegistryStore(launcher_token)
63
- server = RegistryServer(store, launcher_token=launcher_token)
91
+ server = RegistryServer(store, launcher_token=launcher_token, advertise_ip=advertise_ip)
92
+
93
+ # Step 4: Bind port
94
+ bind_host = advertise_ip
95
+ port = _bind_port(preferred_port, bind_host)
96
+
97
+ # Step 5: Output port via stdout structured message (Launcher reads this)
98
+ # This message proves HTTP is about to start — Launcher uses it as readiness signal
99
+ print(json.dumps({"kite": "port", "port": port}), flush=True)
100
+
101
+ print(f"[registry] 启动中 {bind_host}:{port}")
64
102
 
65
- # Bind to dynamic port
66
- port = _get_free_port()
67
- _write_port_file(port, instance_id)
103
+ # Store port and advertise_ip for module.ready event later
104
+ server.port = port
68
105
 
69
- print(f"[registry] Starting on {bind_host}:{port}")
70
106
  uvicorn.run(server.app, host=bind_host, port=port, log_level="warning")
71
107
 
72
108