@agentunion/kite 1.0.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.
- package/__init__.py +1 -0
- package/__main__.py +15 -0
- package/cli.js +70 -0
- package/core/__init__.py +0 -0
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/event_hub/BENCHMARK.md +94 -0
- package/core/event_hub/__init__.py +0 -0
- 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.py +459 -0
- package/core/event_hub/bench_extreme.py +308 -0
- package/core/event_hub/bench_perf.py +350 -0
- package/core/event_hub/bench_results/.gitkeep +0 -0
- package/core/event_hub/bench_results/2026-02-28_13-26-48.json +51 -0
- package/core/event_hub/bench_results/2026-02-28_13-44-45.json +51 -0
- package/core/event_hub/bench_results/2026-02-28_13-45-39.json +51 -0
- package/core/event_hub/dedup.py +31 -0
- package/core/event_hub/entry.py +113 -0
- package/core/event_hub/hub.py +263 -0
- package/core/event_hub/module.md +21 -0
- package/core/event_hub/router.py +21 -0
- package/core/event_hub/server.py +138 -0
- package/core/event_hub_bench/entry.py +371 -0
- package/core/event_hub_bench/module.md +25 -0
- package/core/launcher/__init__.py +0 -0
- 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 +1045 -0
- package/core/launcher/data/processes_14752.json +32 -0
- package/core/launcher/data/token.txt +1 -0
- package/core/launcher/entry.py +965 -0
- package/core/launcher/module.md +37 -0
- package/core/launcher/module_scanner.py +253 -0
- package/core/launcher/process_manager.py +435 -0
- package/core/registry/__init__.py +0 -0
- 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 +1 -0
- package/core/registry/data/port_14752.txt +1 -0
- package/core/registry/data/port_484.txt +1 -0
- package/core/registry/entry.py +73 -0
- package/core/registry/module.md +30 -0
- package/core/registry/server.py +256 -0
- package/core/registry/store.py +232 -0
- package/extensions/__init__.py +0 -0
- package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/__init__.py +0 -0
- package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__init__.py +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
- package/extensions/services/watchdog/entry.py +143 -0
- package/extensions/services/watchdog/module.md +25 -0
- package/extensions/services/watchdog/monitor.py +420 -0
- package/extensions/services/watchdog/server.py +167 -0
- package/main.py +17 -0
- package/package.json +27 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Manage child process lifecycle: start, stop, monitor, cleanup leftovers.
|
|
3
|
+
Cross-platform support for Windows, Linux, and macOS.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, asdict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
16
|
+
IS_MACOS = sys.platform == "darwin"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ProcessRecord:
|
|
21
|
+
name: str
|
|
22
|
+
pid: int
|
|
23
|
+
cmd: list
|
|
24
|
+
module_dir: str
|
|
25
|
+
started_at: float
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProcessManager:
|
|
29
|
+
"""Manage child processes for all Kite modules."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, project_root: str, kite_token: str, instance_id: str = ""):
|
|
32
|
+
self.project_root = project_root
|
|
33
|
+
self.kite_token = kite_token
|
|
34
|
+
self.instance_id = instance_id or str(os.getpid())
|
|
35
|
+
self.data_dir = os.path.join(project_root, "core", "launcher", "data")
|
|
36
|
+
self.records_path = os.path.join(self.data_dir, f"processes_{self.instance_id}.json")
|
|
37
|
+
self._processes: dict[str, subprocess.Popen] = {} # name -> Popen
|
|
38
|
+
self._records: dict[str, ProcessRecord] = {} # name -> record
|
|
39
|
+
os.makedirs(self.data_dir, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
# ── Leftover cleanup ──
|
|
42
|
+
|
|
43
|
+
def cleanup_leftovers(self):
|
|
44
|
+
"""Scan all processes_*.json files, kill leftovers from dead instances, preserve live ones."""
|
|
45
|
+
import glob
|
|
46
|
+
|
|
47
|
+
# Also clean legacy processes.json (pre-multi-instance)
|
|
48
|
+
legacy = os.path.join(self.data_dir, "processes.json")
|
|
49
|
+
patterns = [
|
|
50
|
+
os.path.join(self.data_dir, "processes_*.json"),
|
|
51
|
+
]
|
|
52
|
+
files = []
|
|
53
|
+
for p in patterns:
|
|
54
|
+
files.extend(glob.glob(p))
|
|
55
|
+
if os.path.isfile(legacy):
|
|
56
|
+
files.append(legacy)
|
|
57
|
+
|
|
58
|
+
registry_data_dir = os.path.join(self.project_root, "core", "registry", "data")
|
|
59
|
+
|
|
60
|
+
for path in files:
|
|
61
|
+
if path == self.records_path:
|
|
62
|
+
continue # skip our own instance
|
|
63
|
+
|
|
64
|
+
# If this is an instance file, check if that Launcher is still alive
|
|
65
|
+
basename = os.path.basename(path)
|
|
66
|
+
if basename.startswith("processes_") and basename.endswith(".json"):
|
|
67
|
+
inst_id = basename[len("processes_"):-len(".json")]
|
|
68
|
+
try:
|
|
69
|
+
if self._is_pid_alive(int(inst_id)):
|
|
70
|
+
continue # that Launcher is still running, skip
|
|
71
|
+
except (ValueError, TypeError):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
76
|
+
saved = json.load(f)
|
|
77
|
+
except Exception:
|
|
78
|
+
os.remove(path)
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
for entry in saved:
|
|
82
|
+
pid = entry.get("pid", 0)
|
|
83
|
+
cmd = entry.get("cmd", [])
|
|
84
|
+
name = entry.get("name", "?")
|
|
85
|
+
if not pid:
|
|
86
|
+
continue
|
|
87
|
+
if self._is_pid_alive(pid) and self._cmd_matches(pid, cmd):
|
|
88
|
+
print(f"[launcher] Killing leftover process: {name} (PID {pid})")
|
|
89
|
+
self._force_kill(pid)
|
|
90
|
+
else:
|
|
91
|
+
print(f"[launcher] Leftover {name} (PID {pid}) already gone")
|
|
92
|
+
|
|
93
|
+
os.remove(path)
|
|
94
|
+
|
|
95
|
+
# Remove corresponding port file
|
|
96
|
+
basename = os.path.basename(path)
|
|
97
|
+
if basename.startswith("processes_") and basename.endswith(".json"):
|
|
98
|
+
inst_id = basename[len("processes_"):-len(".json")]
|
|
99
|
+
port_file = os.path.join(registry_data_dir, f"port_{inst_id}.txt")
|
|
100
|
+
if os.path.isfile(port_file):
|
|
101
|
+
os.remove(port_file)
|
|
102
|
+
|
|
103
|
+
# Also clean legacy port.txt
|
|
104
|
+
legacy_port = os.path.join(registry_data_dir, "port.txt")
|
|
105
|
+
if os.path.isfile(legacy_port):
|
|
106
|
+
os.remove(legacy_port)
|
|
107
|
+
|
|
108
|
+
def _is_pid_alive(self, pid: int) -> bool:
|
|
109
|
+
"""Check if a process with given PID exists."""
|
|
110
|
+
if IS_WINDOWS:
|
|
111
|
+
try:
|
|
112
|
+
result = subprocess.run(
|
|
113
|
+
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
|
|
114
|
+
capture_output=True, text=True, timeout=5,
|
|
115
|
+
)
|
|
116
|
+
return str(pid) in result.stdout
|
|
117
|
+
except Exception:
|
|
118
|
+
return False
|
|
119
|
+
else:
|
|
120
|
+
try:
|
|
121
|
+
os.kill(pid, 0)
|
|
122
|
+
return True
|
|
123
|
+
except OSError:
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
def _cmd_matches(self, pid: int, expected_cmd: list) -> bool:
|
|
127
|
+
"""Verify the process command line matches what we expect (prevent killing recycled PIDs).
|
|
128
|
+
Uses multi-strategy fallback per platform for robustness.
|
|
129
|
+
"""
|
|
130
|
+
if not expected_cmd:
|
|
131
|
+
return False
|
|
132
|
+
cmdline = self._get_process_cmdline(pid)
|
|
133
|
+
if cmdline:
|
|
134
|
+
return expected_cmd[-1] in cmdline
|
|
135
|
+
# Degraded fallback: match executable image name only
|
|
136
|
+
return self._exe_name_matches(pid, expected_cmd)
|
|
137
|
+
|
|
138
|
+
def _get_process_cmdline(self, pid: int) -> str:
|
|
139
|
+
"""Get full command line of a process. Returns empty string on failure."""
|
|
140
|
+
if IS_WINDOWS:
|
|
141
|
+
for method in (self._win_powershell_cmdline, self._win_wmic_cmdline):
|
|
142
|
+
result = method(pid)
|
|
143
|
+
if result:
|
|
144
|
+
return result
|
|
145
|
+
return ""
|
|
146
|
+
elif IS_MACOS:
|
|
147
|
+
try:
|
|
148
|
+
r = subprocess.run(["ps", "-p", str(pid), "-o", "command="],
|
|
149
|
+
capture_output=True, text=True, timeout=5)
|
|
150
|
+
return r.stdout.strip()
|
|
151
|
+
except Exception:
|
|
152
|
+
return ""
|
|
153
|
+
else:
|
|
154
|
+
# Linux: /proc first, ps fallback
|
|
155
|
+
try:
|
|
156
|
+
with open(f"/proc/{pid}/cmdline", "r") as f:
|
|
157
|
+
return f.read()
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
try:
|
|
161
|
+
r = subprocess.run(["ps", "-p", str(pid), "-o", "args="],
|
|
162
|
+
capture_output=True, text=True, timeout=5)
|
|
163
|
+
return r.stdout.strip()
|
|
164
|
+
except Exception:
|
|
165
|
+
return ""
|
|
166
|
+
|
|
167
|
+
def _win_powershell_cmdline(self, pid: int) -> str:
|
|
168
|
+
"""Windows: Get cmdline via PowerShell Get-CimInstance (modern, replaces wmic)."""
|
|
169
|
+
try:
|
|
170
|
+
r = subprocess.run(
|
|
171
|
+
["powershell", "-NoProfile", "-Command",
|
|
172
|
+
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine"],
|
|
173
|
+
capture_output=True, text=True, timeout=8,
|
|
174
|
+
)
|
|
175
|
+
return r.stdout.strip()
|
|
176
|
+
except Exception:
|
|
177
|
+
return ""
|
|
178
|
+
|
|
179
|
+
def _win_wmic_cmdline(self, pid: int) -> str:
|
|
180
|
+
"""Windows: Get cmdline via wmic (legacy fallback, deprecated but widely available)."""
|
|
181
|
+
try:
|
|
182
|
+
r = subprocess.run(
|
|
183
|
+
["wmic", "process", "where", f"ProcessId={pid}",
|
|
184
|
+
"get", "CommandLine", "/value"],
|
|
185
|
+
capture_output=True, text=True, timeout=5,
|
|
186
|
+
)
|
|
187
|
+
return r.stdout.strip()
|
|
188
|
+
except Exception:
|
|
189
|
+
return ""
|
|
190
|
+
|
|
191
|
+
def _exe_name_matches(self, pid: int, expected_cmd: list) -> bool:
|
|
192
|
+
"""Last resort: check if process image name matches expected executable."""
|
|
193
|
+
exe = os.path.basename(expected_cmd[0]).lower()
|
|
194
|
+
if exe.endswith(".exe"):
|
|
195
|
+
exe = exe[:-4]
|
|
196
|
+
if IS_WINDOWS:
|
|
197
|
+
try:
|
|
198
|
+
r = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
|
|
199
|
+
capture_output=True, text=True, timeout=5)
|
|
200
|
+
return exe in r.stdout.lower()
|
|
201
|
+
except Exception:
|
|
202
|
+
return False
|
|
203
|
+
else:
|
|
204
|
+
try:
|
|
205
|
+
r = subprocess.run(["ps", "-p", str(pid), "-o", "comm="],
|
|
206
|
+
capture_output=True, text=True, timeout=5)
|
|
207
|
+
return exe in r.stdout.lower()
|
|
208
|
+
except Exception:
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
def _force_kill(self, pid: int):
|
|
212
|
+
"""Force kill a process by PID."""
|
|
213
|
+
try:
|
|
214
|
+
if IS_WINDOWS:
|
|
215
|
+
subprocess.run(["taskkill", "/F", "/PID", str(pid)],
|
|
216
|
+
capture_output=True, timeout=5)
|
|
217
|
+
else:
|
|
218
|
+
import signal
|
|
219
|
+
os.kill(pid, signal.SIGKILL)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
print(f"[launcher] WARNING: failed to kill PID {pid}: {e}")
|
|
222
|
+
|
|
223
|
+
# ── Start module ──
|
|
224
|
+
|
|
225
|
+
def start_module(self, info, boot_info: dict = None) -> bool:
|
|
226
|
+
"""Start a module as a subprocess. Returns True on success.
|
|
227
|
+
If boot_info is provided, it is written as JSON to the child's stdin pipe.
|
|
228
|
+
"""
|
|
229
|
+
from .module_scanner import ModuleInfo
|
|
230
|
+
info: ModuleInfo
|
|
231
|
+
|
|
232
|
+
if info.name in self._processes:
|
|
233
|
+
proc = self._processes[info.name]
|
|
234
|
+
if proc.poll() is None:
|
|
235
|
+
print(f"[launcher] {info.name} already running (PID {proc.pid})")
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
cmd = self._build_cmd(info)
|
|
239
|
+
if not cmd:
|
|
240
|
+
print(f"[launcher] ERROR: cannot build command for {info.name}")
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
env = os.environ.copy()
|
|
244
|
+
env["PYTHONUTF8"] = "1" # Force UTF-8 in child Python processes
|
|
245
|
+
env["PYTHONUNBUFFERED"] = "1" # Disable output buffering for real-time logs
|
|
246
|
+
if info.launch.env:
|
|
247
|
+
env.update(info.launch.env)
|
|
248
|
+
|
|
249
|
+
creation_flags = 0
|
|
250
|
+
if IS_WINDOWS:
|
|
251
|
+
creation_flags = subprocess.CREATE_NO_WINDOW
|
|
252
|
+
|
|
253
|
+
use_stdin = boot_info is not None and info.launch.boot_stdin
|
|
254
|
+
|
|
255
|
+
cwd = info.module_dir
|
|
256
|
+
if info.launch.cwd:
|
|
257
|
+
cwd = os.path.normpath(os.path.join(info.module_dir, info.launch.cwd))
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
proc = subprocess.Popen(
|
|
261
|
+
cmd,
|
|
262
|
+
cwd=cwd,
|
|
263
|
+
env=env,
|
|
264
|
+
stdin=subprocess.PIPE if use_stdin else None,
|
|
265
|
+
stdout=subprocess.PIPE,
|
|
266
|
+
stderr=subprocess.STDOUT,
|
|
267
|
+
creationflags=creation_flags,
|
|
268
|
+
)
|
|
269
|
+
except Exception as e:
|
|
270
|
+
print(f"[launcher] ERROR: failed to start {info.name}: {e}")
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
# Write boot_info to stdin then close
|
|
274
|
+
if use_stdin and proc.stdin:
|
|
275
|
+
try:
|
|
276
|
+
proc.stdin.write(json.dumps(boot_info).encode() + b"\n")
|
|
277
|
+
proc.stdin.flush()
|
|
278
|
+
proc.stdin.close()
|
|
279
|
+
except Exception as e:
|
|
280
|
+
print(f"[launcher] WARNING: failed to write boot_info to {info.name}: {e}")
|
|
281
|
+
|
|
282
|
+
self._processes[info.name] = proc
|
|
283
|
+
self._records[info.name] = ProcessRecord(
|
|
284
|
+
name=info.name,
|
|
285
|
+
pid=proc.pid,
|
|
286
|
+
cmd=cmd,
|
|
287
|
+
module_dir=info.module_dir,
|
|
288
|
+
started_at=time.time(),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Daemon thread to read stdout and prefix with module name
|
|
292
|
+
t = threading.Thread(
|
|
293
|
+
target=self._read_stdout, args=(info.name, proc),
|
|
294
|
+
daemon=True,
|
|
295
|
+
)
|
|
296
|
+
t.start()
|
|
297
|
+
|
|
298
|
+
print(f"[launcher] Started {info.name} (PID {proc.pid})")
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
def _build_cmd(self, info) -> list:
|
|
302
|
+
"""Build the command list based on module runtime.
|
|
303
|
+
|
|
304
|
+
For python/node: if entry starts with '-m ', use package mode (e.g. python -m pkg).
|
|
305
|
+
Otherwise use script file mode (e.g. python entry.py).
|
|
306
|
+
"""
|
|
307
|
+
if info.launch.cmd:
|
|
308
|
+
return list(info.launch.cmd)
|
|
309
|
+
|
|
310
|
+
if info.runtime == "python":
|
|
311
|
+
if info.entry.startswith("-m "):
|
|
312
|
+
return [sys.executable] + info.entry.split()
|
|
313
|
+
return [sys.executable, os.path.join(info.module_dir, info.entry)]
|
|
314
|
+
elif info.runtime == "node":
|
|
315
|
+
if info.entry.startswith("-e ") or info.entry.startswith("--eval "):
|
|
316
|
+
return ["node"] + info.entry.split()
|
|
317
|
+
return ["node", os.path.join(info.module_dir, info.entry)]
|
|
318
|
+
elif info.runtime == "binary":
|
|
319
|
+
return [os.path.join(info.module_dir, info.entry)]
|
|
320
|
+
else:
|
|
321
|
+
print(f"[launcher] WARNING: unknown runtime '{info.runtime}' for {info.name}")
|
|
322
|
+
return []
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def _read_stdout(name: str, proc: subprocess.Popen):
|
|
326
|
+
"""Read subprocess stdout line by line, prefix with module name."""
|
|
327
|
+
try:
|
|
328
|
+
for line in iter(proc.stdout.readline, b""):
|
|
329
|
+
text = line.decode("utf-8", errors="replace").rstrip()
|
|
330
|
+
if text:
|
|
331
|
+
print(f"[{name}] {text}")
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
# ── Stop module ──
|
|
336
|
+
|
|
337
|
+
def stop_module(self, name: str, timeout: float = 5) -> bool:
|
|
338
|
+
"""Stop a module by name. Returns True if stopped successfully."""
|
|
339
|
+
proc = self._processes.get(name)
|
|
340
|
+
if not proc or proc.poll() is not None:
|
|
341
|
+
self._processes.pop(name, None)
|
|
342
|
+
self._records.pop(name, None)
|
|
343
|
+
return True
|
|
344
|
+
|
|
345
|
+
print(f"[launcher] Stopping {name} (PID {proc.pid})...")
|
|
346
|
+
proc.terminate()
|
|
347
|
+
try:
|
|
348
|
+
proc.wait(timeout=timeout)
|
|
349
|
+
except subprocess.TimeoutExpired:
|
|
350
|
+
print(f"[launcher] {name} did not exit in {timeout}s, force killing")
|
|
351
|
+
proc.kill()
|
|
352
|
+
try:
|
|
353
|
+
proc.wait(timeout=3)
|
|
354
|
+
except subprocess.TimeoutExpired:
|
|
355
|
+
self._force_kill(proc.pid)
|
|
356
|
+
|
|
357
|
+
self._processes.pop(name, None)
|
|
358
|
+
self._records.pop(name, None)
|
|
359
|
+
print(f"[launcher] Stopped {name}")
|
|
360
|
+
return True
|
|
361
|
+
|
|
362
|
+
def stop_all(self, timeout: float = 10):
|
|
363
|
+
"""Stop all managed processes. Terminate first, force kill after timeout."""
|
|
364
|
+
if not self._processes:
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
names = list(self._processes.keys())
|
|
368
|
+
print(f"[launcher] Stopping all modules: {', '.join(names)}")
|
|
369
|
+
|
|
370
|
+
# Phase 1: send terminate to all
|
|
371
|
+
for name in names:
|
|
372
|
+
proc = self._processes.get(name)
|
|
373
|
+
if proc and proc.poll() is None:
|
|
374
|
+
proc.terminate()
|
|
375
|
+
|
|
376
|
+
# Phase 2: wait for all to exit
|
|
377
|
+
deadline = time.time() + timeout
|
|
378
|
+
for name in names:
|
|
379
|
+
proc = self._processes.get(name)
|
|
380
|
+
if not proc:
|
|
381
|
+
continue
|
|
382
|
+
remaining = max(0.1, deadline - time.time())
|
|
383
|
+
try:
|
|
384
|
+
proc.wait(timeout=remaining)
|
|
385
|
+
except subprocess.TimeoutExpired:
|
|
386
|
+
pass
|
|
387
|
+
|
|
388
|
+
# Phase 3: force kill survivors
|
|
389
|
+
for name in names:
|
|
390
|
+
proc = self._processes.get(name)
|
|
391
|
+
if proc and proc.poll() is None:
|
|
392
|
+
print(f"[launcher] Force killing {name} (PID {proc.pid})")
|
|
393
|
+
proc.kill()
|
|
394
|
+
try:
|
|
395
|
+
proc.wait(timeout=3)
|
|
396
|
+
except subprocess.TimeoutExpired:
|
|
397
|
+
self._force_kill(proc.pid)
|
|
398
|
+
|
|
399
|
+
self._processes.clear()
|
|
400
|
+
self._records.clear()
|
|
401
|
+
print("[launcher] All modules stopped")
|
|
402
|
+
|
|
403
|
+
# ── Persistence & monitoring ──
|
|
404
|
+
|
|
405
|
+
def persist_records(self):
|
|
406
|
+
"""Write current process records to processes.json."""
|
|
407
|
+
data = [asdict(r) for r in self._records.values()]
|
|
408
|
+
self._write_records_file(data)
|
|
409
|
+
|
|
410
|
+
def check_exited(self) -> list[tuple[str, int]]:
|
|
411
|
+
"""Non-blocking check for exited child processes. Returns [(name, returncode)]."""
|
|
412
|
+
exited = []
|
|
413
|
+
for name in list(self._processes.keys()):
|
|
414
|
+
proc = self._processes[name]
|
|
415
|
+
rc = proc.poll()
|
|
416
|
+
if rc is not None:
|
|
417
|
+
exited.append((name, rc))
|
|
418
|
+
self._processes.pop(name, None)
|
|
419
|
+
self._records.pop(name, None)
|
|
420
|
+
return exited
|
|
421
|
+
|
|
422
|
+
def get_record(self, name: str) -> ProcessRecord | None:
|
|
423
|
+
return self._records.get(name)
|
|
424
|
+
|
|
425
|
+
def is_running(self, name: str) -> bool:
|
|
426
|
+
proc = self._processes.get(name)
|
|
427
|
+
return proc is not None and proc.poll() is None
|
|
428
|
+
|
|
429
|
+
def _write_records_file(self, data: list):
|
|
430
|
+
"""Write data list to processes.json."""
|
|
431
|
+
try:
|
|
432
|
+
with open(self.records_path, "w", encoding="utf-8") as f:
|
|
433
|
+
json.dump(data, f, indent=2)
|
|
434
|
+
except Exception as e:
|
|
435
|
+
print(f"[launcher] WARNING: failed to write processes.json: {e}")
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
51279
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
63756
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
61190
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Registry entry point.
|
|
3
|
+
Reads launcher_token, starts HTTP server on dynamic port, writes port.txt.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import socket
|
|
9
|
+
|
|
10
|
+
import uvicorn
|
|
11
|
+
|
|
12
|
+
# Ensure project root is on sys.path
|
|
13
|
+
_this_dir = os.path.dirname(os.path.abspath(__file__))
|
|
14
|
+
_project_root = os.path.dirname(os.path.dirname(_this_dir))
|
|
15
|
+
if _project_root not in sys.path:
|
|
16
|
+
sys.path.insert(0, _project_root)
|
|
17
|
+
|
|
18
|
+
from core.registry.store import RegistryStore
|
|
19
|
+
from core.registry.server import RegistryServer
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_free_port() -> int:
|
|
23
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
24
|
+
s.bind(("127.0.0.1", 0))
|
|
25
|
+
return s.getsockname()[1]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _write_port_file(port: int, instance_id: str = ""):
|
|
29
|
+
data_dir = os.path.join(_this_dir, "data")
|
|
30
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
31
|
+
filename = f"port_{instance_id}.txt" if instance_id else "port.txt"
|
|
32
|
+
port_file = os.path.join(data_dir, filename)
|
|
33
|
+
with open(port_file, "w") as f:
|
|
34
|
+
f.write(str(port))
|
|
35
|
+
print(f"[registry] Port written to {port_file}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main():
|
|
39
|
+
# Read launcher_token and config from stdin (boot_info JSON pipe)
|
|
40
|
+
launcher_token = ""
|
|
41
|
+
bind_host = "127.0.0.1"
|
|
42
|
+
instance_id = ""
|
|
43
|
+
try:
|
|
44
|
+
import json
|
|
45
|
+
line = sys.stdin.readline().strip()
|
|
46
|
+
if line:
|
|
47
|
+
boot_info = json.loads(line)
|
|
48
|
+
launcher_token = boot_info.get("token", "")
|
|
49
|
+
bind_host = boot_info.get("bind", "127.0.0.1")
|
|
50
|
+
instance_id = boot_info.get("instance_id", "")
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
if not launcher_token:
|
|
55
|
+
print("[registry] ERROR: No launcher token provided via stdin boot_info")
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
print(f"[registry] Launcher token received ({len(launcher_token)} chars)")
|
|
59
|
+
|
|
60
|
+
# Create store and server
|
|
61
|
+
store = RegistryStore(launcher_token)
|
|
62
|
+
server = RegistryServer(store, launcher_token=launcher_token)
|
|
63
|
+
|
|
64
|
+
# Bind to dynamic port
|
|
65
|
+
port = _get_free_port()
|
|
66
|
+
_write_port_file(port, instance_id)
|
|
67
|
+
|
|
68
|
+
print(f"[registry] Starting on {bind_host}:{port}")
|
|
69
|
+
uvicorn.run(server.app, host=bind_host, port=port, log_level="warning")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
main()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: registry
|
|
3
|
+
display_name: Registry
|
|
4
|
+
type: infrastructure
|
|
5
|
+
state: enabled
|
|
6
|
+
runtime: python
|
|
7
|
+
entry: entry.py
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Registry
|
|
11
|
+
|
|
12
|
+
Kite 系统注册中心,第一个启动的核心模块。
|
|
13
|
+
|
|
14
|
+
## 职责
|
|
15
|
+
|
|
16
|
+
- 服务注册与发现(所有模块的"电话簿")
|
|
17
|
+
- 能力索引(工具、Hook、事件的聚合查询)
|
|
18
|
+
- 通用字段查询(glob 模式匹配)
|
|
19
|
+
- 心跳 TTL 管理
|
|
20
|
+
|
|
21
|
+
## API
|
|
22
|
+
|
|
23
|
+
| 方法 | 路径 | 说明 |
|
|
24
|
+
|------|------|------|
|
|
25
|
+
| POST | `/modules` | 模块生命周期(register / deregister / heartbeat) |
|
|
26
|
+
| GET | `/lookup` | 通用搜索(三参数均支持 glob) |
|
|
27
|
+
| GET | `/get/{path}` | 按路径精确读取注册内容 |
|
|
28
|
+
| POST | `/tokens` | 注册 token 映射(仅 Launcher) |
|
|
29
|
+
| POST | `/query` | LLM 自然语言查询(待实现) |
|
|
30
|
+
| GET | `/health` | 健康检查(无需 token) |
|