@agentunion/kite 1.0.6 → 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.
- package/cli.js +127 -25
- package/core/event_hub/entry.py +105 -61
- package/core/event_hub/module.md +0 -1
- package/core/event_hub/server.py +96 -28
- package/core/launcher/entry.py +477 -290
- package/core/launcher/module_scanner.py +10 -9
- package/core/launcher/process_manager.py +120 -96
- package/core/registry/entry.py +66 -30
- package/core/registry/server.py +47 -14
- package/core/registry/store.py +6 -1
- package/{core → extensions}/event_hub_bench/entry.py +17 -9
- package/{core → extensions}/event_hub_bench/module.md +2 -1
- package/extensions/services/watchdog/entry.py +11 -7
- package/extensions/services/watchdog/server.py +1 -1
- package/main.py +204 -4
- package/package.json +11 -2
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/data_dir.cpython-313.pyc +0 -0
- package/core/data_dir.py +0 -62
- package/core/event_hub/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/bench.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/bench_perf.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/dedup.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/entry.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/hub.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/router.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/server.cpython-313.pyc +0 -0
- package/core/event_hub/bench_results/.gitkeep +0 -0
- package/core/event_hub/bench_results/2026-02-28_13-26-48.json +0 -51
- package/core/event_hub/bench_results/2026-02-28_13-44-45.json +0 -51
- package/core/event_hub/bench_results/2026-02-28_13-45-39.json +0 -51
- package/core/launcher/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/launcher/__pycache__/entry.cpython-313.pyc +0 -0
- package/core/launcher/__pycache__/module_scanner.cpython-313.pyc +0 -0
- package/core/launcher/__pycache__/process_manager.cpython-313.pyc +0 -0
- package/core/launcher/data/log/lifecycle.jsonl +0 -1158
- package/core/launcher/data/token.txt +0 -1
- package/core/registry/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/registry/__pycache__/entry.cpython-313.pyc +0 -0
- package/core/registry/__pycache__/server.cpython-313.pyc +0 -0
- package/core/registry/__pycache__/store.cpython-313.pyc +0 -0
- package/core/registry/data/port.txt +0 -1
- package/core/registry/data/port_484.txt +0 -1
- package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/entry.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/monitor.cpython-313.pyc +0 -0
- 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
|
|
38
|
-
"""Core modules live directly under {
|
|
39
|
-
core_dir = os.path.join(
|
|
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,
|
|
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(
|
|
103
|
-
self._scan_dir(os.path.join(
|
|
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(
|
|
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(
|
|
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,
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
self.
|
|
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
|
-
"""
|
|
48
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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]
|
|
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}
|
|
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]
|
|
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]
|
|
252
|
+
print(f"[launcher] 错误: 启动 {info.name} 失败: {e}")
|
|
282
253
|
return False
|
|
283
254
|
|
|
284
|
-
# Write boot_info to stdin
|
|
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]
|
|
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
|
|
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]
|
|
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]
|
|
336
|
+
print(f"[launcher] 警告: 未知运行时 '{info.runtime}',模块 {info.name}")
|
|
333
337
|
return []
|
|
334
338
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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}
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
470
|
+
print(f"[launcher] 警告: 写入 processes.json 失败: {e}")
|
package/core/registry/entry.py
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Registry entry point.
|
|
3
|
-
Reads
|
|
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
|
|
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((
|
|
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
|
-
#
|
|
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]
|
|
76
|
+
print("[registry] 错误: 未通过 stdin boot_info 提供启动器令牌")
|
|
57
77
|
sys.exit(1)
|
|
58
78
|
|
|
59
|
-
print(f"[registry]
|
|
79
|
+
print(f"[registry] 已收到启动器令牌 ({len(launcher_token)} 字符)")
|
|
60
80
|
|
|
61
|
-
|
|
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
|
-
#
|
|
66
|
-
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
|
|