@agentunion/kite 1.0.7 → 1.3.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/CHANGELOG.md +208 -0
- package/README.md +48 -0
- package/cli.js +1 -1
- package/extensions/agents/__init__.py +1 -0
- package/extensions/agents/assistant/__init__.py +1 -0
- package/extensions/agents/assistant/entry.py +329 -0
- package/extensions/agents/assistant/module.md +22 -0
- package/extensions/agents/assistant/server.py +197 -0
- package/extensions/channels/__init__.py +1 -0
- package/extensions/channels/acp_channel/__init__.py +1 -0
- package/extensions/channels/acp_channel/entry.py +329 -0
- package/extensions/channels/acp_channel/module.md +22 -0
- package/extensions/channels/acp_channel/server.py +197 -0
- package/extensions/event_hub_bench/entry.py +624 -379
- package/extensions/event_hub_bench/module.md +2 -1
- package/extensions/services/backup/__init__.py +1 -0
- package/extensions/services/backup/entry.py +508 -0
- package/extensions/services/backup/module.md +22 -0
- package/extensions/services/model_service/__init__.py +1 -0
- package/extensions/services/model_service/entry.py +508 -0
- package/extensions/services/model_service/module.md +22 -0
- package/extensions/services/watchdog/entry.py +468 -102
- package/extensions/services/watchdog/module.md +3 -0
- package/extensions/services/watchdog/monitor.py +170 -69
- package/extensions/services/web/__init__.py +1 -0
- package/extensions/services/web/config.yaml +149 -0
- package/extensions/services/web/entry.py +390 -0
- package/extensions/services/web/module.md +24 -0
- package/extensions/services/web/routes/__init__.py +1 -0
- package/extensions/services/web/routes/routes_call.py +189 -0
- package/extensions/services/web/routes/routes_config.py +512 -0
- package/extensions/services/web/routes/routes_contacts.py +98 -0
- package/extensions/services/web/routes/routes_devlog.py +99 -0
- package/extensions/services/web/routes/routes_phone.py +81 -0
- package/extensions/services/web/routes/routes_sms.py +48 -0
- package/extensions/services/web/routes/routes_stats.py +17 -0
- package/extensions/services/web/routes/routes_voicechat.py +554 -0
- package/extensions/services/web/routes/schemas.py +216 -0
- package/extensions/services/web/server.py +375 -0
- package/extensions/services/web/static/css/style.css +1064 -0
- package/extensions/services/web/static/index.html +1445 -0
- package/extensions/services/web/static/js/app.js +4671 -0
- package/extensions/services/web/vendor/__init__.py +1 -0
- package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
- package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
- package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
- package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
- package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
- package/extensions/services/web/vendor/config.py +139 -0
- package/extensions/services/web/vendor/conversation/asr.py +936 -0
- package/extensions/services/web/vendor/conversation/engine.py +548 -0
- package/extensions/services/web/vendor/conversation/llm.py +534 -0
- package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
- package/extensions/services/web/vendor/conversation/tts.py +322 -0
- package/extensions/services/web/vendor/conversation/vad.py +138 -0
- package/extensions/services/web/vendor/storage/__init__.py +1 -0
- package/extensions/services/web/vendor/storage/identity.py +312 -0
- package/extensions/services/web/vendor/storage/store.py +507 -0
- package/extensions/services/web/vendor/task/manager.py +864 -0
- package/extensions/services/web/vendor/task/models.py +45 -0
- package/extensions/services/web/vendor/task/webhook.py +263 -0
- package/extensions/services/web/vendor/tools/registry.py +321 -0
- package/kernel/__init__.py +0 -0
- package/kernel/entry.py +407 -0
- package/{core/event_hub/hub.py → kernel/event_hub.py} +62 -74
- package/kernel/module.md +33 -0
- package/{core/registry/store.py → kernel/registry_store.py} +23 -8
- package/kernel/rpc_router.py +388 -0
- package/kernel/server.py +267 -0
- package/launcher/__init__.py +10 -0
- package/launcher/__main__.py +6 -0
- package/launcher/count_lines.py +258 -0
- package/launcher/entry.py +1778 -0
- package/launcher/logging_setup.py +289 -0
- package/{core/launcher → launcher}/module_scanner.py +11 -6
- package/launcher/process_manager.py +880 -0
- package/main.py +11 -210
- package/package.json +6 -9
- package/__init__.py +0 -1
- package/__main__.py +0 -15
- package/core/event_hub/BENCHMARK.md +0 -94
- package/core/event_hub/bench.py +0 -459
- package/core/event_hub/bench_extreme.py +0 -308
- package/core/event_hub/bench_perf.py +0 -350
- package/core/event_hub/entry.py +0 -157
- package/core/event_hub/module.md +0 -20
- package/core/event_hub/server.py +0 -206
- package/core/launcher/entry.py +0 -1158
- package/core/launcher/process_manager.py +0 -470
- package/core/registry/entry.py +0 -110
- package/core/registry/module.md +0 -30
- package/core/registry/server.py +0 -289
- package/extensions/services/watchdog/server.py +0 -167
- /package/{core → extensions/services/web/vendor/bluetooth}/__init__.py +0 -0
- /package/{core/event_hub → extensions/services/web/vendor/conversation}/__init__.py +0 -0
- /package/{core/launcher → extensions/services/web/vendor/task}/__init__.py +0 -0
- /package/{core/registry → extensions/services/web/vendor/tools}/__init__.py +0 -0
- /package/{core/event_hub → kernel}/dedup.py +0 -0
- /package/{core/event_hub → kernel}/router.py +0 -0
- /package/{core/launcher → launcher}/module.md +0 -0
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Manage child process lifecycle: start, stop, monitor, cleanup leftovers.
|
|
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
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import glob
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass, asdict
|
|
20
|
+
from typing import Callable
|
|
21
|
+
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
import ctypes
|
|
24
|
+
from ctypes import wintypes
|
|
25
|
+
|
|
26
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
27
|
+
IS_MACOS = sys.platform == "darwin"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ProcessRecord:
|
|
32
|
+
name: str
|
|
33
|
+
pid: int
|
|
34
|
+
cmd: list
|
|
35
|
+
module_dir: str
|
|
36
|
+
started_at: float
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ProcessManager:
|
|
40
|
+
"""Manage child processes for all Kite modules."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, kite_token: str, instance_id: str = "",
|
|
43
|
+
on_kite_message: Callable[[str, dict], None] | None = None):
|
|
44
|
+
"""
|
|
45
|
+
Args:
|
|
46
|
+
on_kite_message: callback(module_name, msg_dict) for structured stdout messages.
|
|
47
|
+
Called from stdout reader thread — must be thread-safe.
|
|
48
|
+
"""
|
|
49
|
+
self.kite_token = kite_token
|
|
50
|
+
self.instance_id = instance_id or str(os.getpid())
|
|
51
|
+
self._on_kite_message = on_kite_message
|
|
52
|
+
# Use KITE_MODULE_DATA (set by Launcher for itself)
|
|
53
|
+
launcher_dir = os.environ.get("KITE_MODULE_DATA") or os.path.join(os.environ["KITE_INSTANCE_DIR"], "launcher")
|
|
54
|
+
self.data_dir = os.path.join(launcher_dir, "state")
|
|
55
|
+
os.makedirs(self.data_dir, exist_ok=True)
|
|
56
|
+
self._instance_num = self._allocate_instance_num()
|
|
57
|
+
suffix = "" if self._instance_num == 1 else f"~{self._instance_num}"
|
|
58
|
+
self._instance_suffix = suffix
|
|
59
|
+
self.records_path = os.path.join(self.data_dir, f"processes{suffix}.json")
|
|
60
|
+
self._processes: dict[str, subprocess.Popen] = {} # name -> Popen
|
|
61
|
+
self._records: dict[str, ProcessRecord] = {} # name -> record
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def instance_suffix(self) -> str:
|
|
65
|
+
"""Return the instance suffix (empty string for instance 1, '~N' for N>1)."""
|
|
66
|
+
return self._instance_suffix
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def instance_num(self) -> int:
|
|
70
|
+
"""Return the instance number (1-based)."""
|
|
71
|
+
return self._instance_num
|
|
72
|
+
|
|
73
|
+
def get_alive_instances(self) -> list[dict]:
|
|
74
|
+
"""Scan processes*.json and return info about all alive instances (including self).
|
|
75
|
+
|
|
76
|
+
Returns list of dicts: {"num": int, "launcher_pid": int, "module_count": int, "is_self": bool, "debug": bool, "cwd": str}.
|
|
77
|
+
"""
|
|
78
|
+
instances = []
|
|
79
|
+
pattern = os.path.join(self.data_dir, "processes*.json")
|
|
80
|
+
for filepath in glob.glob(pattern):
|
|
81
|
+
basename = os.path.basename(filepath)
|
|
82
|
+
num = self._parse_instance_num(basename)
|
|
83
|
+
if num is None:
|
|
84
|
+
continue
|
|
85
|
+
pid = self._read_launcher_pid(filepath)
|
|
86
|
+
is_self = (pid == os.getpid())
|
|
87
|
+
if not is_self and (not pid or not self._is_pid_alive(pid)):
|
|
88
|
+
continue
|
|
89
|
+
# Read module count, debug flag, and cwd
|
|
90
|
+
module_count = 0
|
|
91
|
+
debug = False
|
|
92
|
+
cwd = ""
|
|
93
|
+
try:
|
|
94
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
95
|
+
data = json.load(f)
|
|
96
|
+
if isinstance(data, dict):
|
|
97
|
+
module_count = len(data.get("records", []))
|
|
98
|
+
debug = data.get("debug", False)
|
|
99
|
+
cwd = data.get("cwd", "")
|
|
100
|
+
elif isinstance(data, list):
|
|
101
|
+
module_count = len(data)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
instances.append({
|
|
105
|
+
"num": num,
|
|
106
|
+
"launcher_pid": pid or 0,
|
|
107
|
+
"module_count": module_count,
|
|
108
|
+
"is_self": is_self,
|
|
109
|
+
"debug": debug,
|
|
110
|
+
"cwd": cwd,
|
|
111
|
+
})
|
|
112
|
+
instances.sort(key=lambda x: x["num"])
|
|
113
|
+
return instances
|
|
114
|
+
|
|
115
|
+
def get_global_instances(self) -> list[dict]:
|
|
116
|
+
"""Scan all instance dirs under KITE_WORKSPACE for alive launcher instances.
|
|
117
|
+
|
|
118
|
+
Returns list of dicts:
|
|
119
|
+
{"instance_dir": str, "cwd": str, "num": int, "launcher_pid": int,
|
|
120
|
+
"module_count": int, "is_self": bool, "debug": bool}
|
|
121
|
+
"""
|
|
122
|
+
workspace = os.environ.get("KITE_WORKSPACE", "")
|
|
123
|
+
if not workspace or not os.path.isdir(workspace):
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
my_pid = os.getpid()
|
|
127
|
+
my_instance_dir = os.environ.get("KITE_INSTANCE_DIR", "")
|
|
128
|
+
results = []
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
entries = os.listdir(workspace)
|
|
132
|
+
except OSError:
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
for entry in entries:
|
|
136
|
+
inst_path = os.path.join(workspace, entry)
|
|
137
|
+
if not os.path.isdir(inst_path):
|
|
138
|
+
continue
|
|
139
|
+
state_dir = os.path.join(inst_path, "launcher", "state")
|
|
140
|
+
if not os.path.isdir(state_dir):
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# Read .cwd for display (fallback if processes.json lacks cwd)
|
|
144
|
+
cwd_from_file = ""
|
|
145
|
+
cwd_file = os.path.join(inst_path, ".cwd")
|
|
146
|
+
try:
|
|
147
|
+
with open(cwd_file, "r", encoding="utf-8") as f:
|
|
148
|
+
cwd_from_file = f.read().strip()
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
# Scan processes*.json in this instance's state dir
|
|
153
|
+
pattern = os.path.join(state_dir, "processes*.json")
|
|
154
|
+
for filepath in glob.glob(pattern):
|
|
155
|
+
basename = os.path.basename(filepath)
|
|
156
|
+
num = self._parse_instance_num(basename)
|
|
157
|
+
if num is None:
|
|
158
|
+
continue
|
|
159
|
+
pid = self._read_launcher_pid(filepath)
|
|
160
|
+
is_self_dir = (os.path.normcase(os.path.normpath(inst_path))
|
|
161
|
+
== os.path.normcase(os.path.normpath(my_instance_dir)))
|
|
162
|
+
is_self = (pid == my_pid and is_self_dir)
|
|
163
|
+
if not is_self and (not pid or not self._is_pid_alive(pid)):
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
# Read metadata
|
|
167
|
+
module_count = 0
|
|
168
|
+
debug = False
|
|
169
|
+
cwd = ""
|
|
170
|
+
try:
|
|
171
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
172
|
+
data = json.load(f)
|
|
173
|
+
if isinstance(data, dict):
|
|
174
|
+
module_count = len(data.get("records", []))
|
|
175
|
+
debug = data.get("debug", False)
|
|
176
|
+
cwd = data.get("cwd", "")
|
|
177
|
+
elif isinstance(data, list):
|
|
178
|
+
module_count = len(data)
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
results.append({
|
|
183
|
+
"instance_dir": entry, # basename of instance dir
|
|
184
|
+
"cwd": cwd or cwd_from_file,
|
|
185
|
+
"num": num,
|
|
186
|
+
"launcher_pid": pid or 0,
|
|
187
|
+
"module_count": module_count,
|
|
188
|
+
"is_self": is_self,
|
|
189
|
+
"debug": debug,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
results.sort(key=lambda x: (x["instance_dir"], x["num"]))
|
|
193
|
+
return results
|
|
194
|
+
|
|
195
|
+
def _allocate_instance_num(self) -> int:
|
|
196
|
+
"""Scan processes*.json in data_dir and allocate the smallest available instance number.
|
|
197
|
+
|
|
198
|
+
An instance number is "available" if:
|
|
199
|
+
- No corresponding processes file exists, OR
|
|
200
|
+
- The file exists but its launcher_pid is dead (stale file from a crashed instance).
|
|
201
|
+
"""
|
|
202
|
+
# Collect occupied instance numbers (whose launcher is still alive)
|
|
203
|
+
occupied: set[int] = set()
|
|
204
|
+
pattern = os.path.join(self.data_dir, "processes*.json")
|
|
205
|
+
for filepath in glob.glob(pattern):
|
|
206
|
+
basename = os.path.basename(filepath)
|
|
207
|
+
num = self._parse_instance_num(basename)
|
|
208
|
+
if num is None:
|
|
209
|
+
continue
|
|
210
|
+
pid = self._read_launcher_pid(filepath)
|
|
211
|
+
if pid and pid != os.getpid() and self._is_pid_alive(pid):
|
|
212
|
+
occupied.add(num)
|
|
213
|
+
|
|
214
|
+
# Allocate smallest available number starting from 1
|
|
215
|
+
num = 1
|
|
216
|
+
while num in occupied:
|
|
217
|
+
num += 1
|
|
218
|
+
return num
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _parse_instance_num(basename: str) -> int | None:
|
|
222
|
+
"""Extract instance number from a processes filename.
|
|
223
|
+
|
|
224
|
+
'processes.json' -> 1, 'processes~3.json' -> 3, other -> None.
|
|
225
|
+
"""
|
|
226
|
+
if basename == "processes.json":
|
|
227
|
+
return 1
|
|
228
|
+
m = re.match(r"^processes~(\d+)\.json$", basename)
|
|
229
|
+
return int(m.group(1)) if m else None
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _read_launcher_pid(filepath: str) -> int | None:
|
|
233
|
+
"""Read launcher_pid from a processes JSON file. Returns None on error or old format."""
|
|
234
|
+
try:
|
|
235
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
236
|
+
data = json.load(f)
|
|
237
|
+
if isinstance(data, dict):
|
|
238
|
+
return data.get("launcher_pid")
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
# ── Leftover cleanup ──
|
|
244
|
+
|
|
245
|
+
def cleanup_leftovers(self) -> dict[str, int]:
|
|
246
|
+
"""Scan all processes*.json in data_dir (current instance dir only), clean up dead instances' child processes.
|
|
247
|
+
|
|
248
|
+
Returns dict: {instance_dir_basename: killed_count}.
|
|
249
|
+
"""
|
|
250
|
+
my_pid = os.getpid()
|
|
251
|
+
inst_name = os.path.basename(os.environ.get("KITE_INSTANCE_DIR", "")) or "unknown"
|
|
252
|
+
killed = 0
|
|
253
|
+
pattern = os.path.join(self.data_dir, "processes*.json")
|
|
254
|
+
for filepath in glob.glob(pattern):
|
|
255
|
+
killed += self._cleanup_one_leftover_file(filepath, my_pid)
|
|
256
|
+
return {inst_name: killed} if killed else {}
|
|
257
|
+
|
|
258
|
+
def cleanup_global_leftovers(self) -> dict[str, int]:
|
|
259
|
+
"""Scan all instance dirs under KITE_WORKSPACE for dead launcher files.
|
|
260
|
+
|
|
261
|
+
Same logic as cleanup_leftovers() but across all workspace instances.
|
|
262
|
+
Designed to run via run_in_executor (synchronous, blocking I/O).
|
|
263
|
+
Returns dict: {instance_dir_basename: killed_count}.
|
|
264
|
+
"""
|
|
265
|
+
workspace = os.environ.get("KITE_WORKSPACE", "")
|
|
266
|
+
if not workspace or not os.path.isdir(workspace):
|
|
267
|
+
return {}
|
|
268
|
+
|
|
269
|
+
my_pid = os.getpid()
|
|
270
|
+
result: dict[str, int] = {}
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
entries = os.listdir(workspace)
|
|
274
|
+
except OSError:
|
|
275
|
+
return {}
|
|
276
|
+
|
|
277
|
+
for entry in entries:
|
|
278
|
+
state_dir = os.path.join(workspace, entry, "launcher", "state")
|
|
279
|
+
if not os.path.isdir(state_dir):
|
|
280
|
+
continue
|
|
281
|
+
pattern = os.path.join(state_dir, "processes*.json")
|
|
282
|
+
for filepath in glob.glob(pattern):
|
|
283
|
+
killed = self._cleanup_one_leftover_file(filepath, my_pid)
|
|
284
|
+
if killed:
|
|
285
|
+
result[entry] = result.get(entry, 0) + killed
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
def _cleanup_one_leftover_file(self, filepath: str, my_pid: int) -> int:
|
|
289
|
+
"""Process a single processes*.json file for leftover cleanup.
|
|
290
|
+
|
|
291
|
+
- Skip if launcher_pid matches my_pid (our own records).
|
|
292
|
+
- Skip if launcher_pid is still alive (parallel instance).
|
|
293
|
+
- Otherwise clean up child processes and delete the file.
|
|
294
|
+
Returns number of processes killed.
|
|
295
|
+
"""
|
|
296
|
+
try:
|
|
297
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
298
|
+
saved = json.load(f)
|
|
299
|
+
except Exception:
|
|
300
|
+
try:
|
|
301
|
+
os.remove(filepath)
|
|
302
|
+
except OSError:
|
|
303
|
+
pass
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
# New format: {"launcher_pid": N, "records": [...]}
|
|
307
|
+
# Old format: plain array [...]
|
|
308
|
+
if isinstance(saved, dict):
|
|
309
|
+
launcher_pid = saved.get("launcher_pid")
|
|
310
|
+
records = saved.get("records", [])
|
|
311
|
+
elif isinstance(saved, list):
|
|
312
|
+
launcher_pid = None
|
|
313
|
+
records = saved
|
|
314
|
+
else:
|
|
315
|
+
try:
|
|
316
|
+
os.remove(filepath)
|
|
317
|
+
except OSError:
|
|
318
|
+
pass
|
|
319
|
+
return 0
|
|
320
|
+
|
|
321
|
+
# Skip our own records (written by current process)
|
|
322
|
+
if launcher_pid == my_pid:
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
# If the owning launcher is still alive, skip this file (parallel instance)
|
|
326
|
+
if launcher_pid and self._is_pid_alive(launcher_pid):
|
|
327
|
+
return 0
|
|
328
|
+
|
|
329
|
+
# Dead launcher (or old format) — clean up its child processes
|
|
330
|
+
killed = 0
|
|
331
|
+
for entry in records:
|
|
332
|
+
pid = entry.get("pid", 0)
|
|
333
|
+
cmd = entry.get("cmd", [])
|
|
334
|
+
name = entry.get("name", "?")
|
|
335
|
+
if not pid:
|
|
336
|
+
continue
|
|
337
|
+
if self._is_pid_alive(pid) and self._cmd_matches(pid, cmd):
|
|
338
|
+
print(f"[launcher] 正在清理残留进程: {name} (PID {pid}) [{os.path.basename(filepath)}]")
|
|
339
|
+
self._force_kill(pid)
|
|
340
|
+
killed += 1
|
|
341
|
+
else:
|
|
342
|
+
print(f"[launcher] 残留进程 {name} (PID {pid}) 已不存在")
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
os.remove(filepath)
|
|
346
|
+
except OSError:
|
|
347
|
+
pass
|
|
348
|
+
return killed
|
|
349
|
+
|
|
350
|
+
def _is_pid_alive(self, pid: int) -> bool:
|
|
351
|
+
"""Check if a process with given PID exists.
|
|
352
|
+
|
|
353
|
+
Windows: Uses OpenProcess API via ctypes (fast, ~0.1ms per check).
|
|
354
|
+
Linux/macOS: Uses os.kill(pid, 0) signal check.
|
|
355
|
+
"""
|
|
356
|
+
if IS_WINDOWS:
|
|
357
|
+
return self._win_is_pid_alive_ctypes(pid)
|
|
358
|
+
else:
|
|
359
|
+
try:
|
|
360
|
+
os.kill(pid, 0)
|
|
361
|
+
return True
|
|
362
|
+
except OSError:
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
def _win_get_all_alive_pids(self) -> set:
|
|
366
|
+
"""Windows: Get all alive PIDs in one batch call (fallback optimization).
|
|
367
|
+
|
|
368
|
+
Returns set of PIDs. Returns empty set on failure.
|
|
369
|
+
"""
|
|
370
|
+
if not IS_WINDOWS:
|
|
371
|
+
return set()
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
result = subprocess.run(
|
|
375
|
+
["tasklist", "/FO", "CSV", "/NH"],
|
|
376
|
+
capture_output=True, timeout=10,
|
|
377
|
+
)
|
|
378
|
+
# Use errors='replace' to handle any encoding (GBK, UTF-8, etc.)
|
|
379
|
+
stdout = result.stdout.decode(errors='replace')
|
|
380
|
+
pids = set()
|
|
381
|
+
for line in stdout.strip().split('\n'):
|
|
382
|
+
if not line:
|
|
383
|
+
continue
|
|
384
|
+
# CSV format: "image_name","pid","session_name","session#","mem_usage"
|
|
385
|
+
parts = line.split(',')
|
|
386
|
+
if len(parts) >= 2:
|
|
387
|
+
try:
|
|
388
|
+
pid_str = parts[1].strip('" \r')
|
|
389
|
+
pids.add(int(pid_str))
|
|
390
|
+
except (ValueError, IndexError):
|
|
391
|
+
continue
|
|
392
|
+
return pids
|
|
393
|
+
except Exception:
|
|
394
|
+
return set()
|
|
395
|
+
|
|
396
|
+
def _win_is_pid_alive_ctypes(self, pid: int) -> bool:
|
|
397
|
+
"""Windows: Check if PID exists and is still running using OpenProcess API.
|
|
398
|
+
|
|
399
|
+
Returns True only if process exists AND has not exited.
|
|
400
|
+
Handles zombie processes (exited but handle still open) correctly
|
|
401
|
+
by checking GetExitCodeProcess after OpenProcess succeeds.
|
|
402
|
+
|
|
403
|
+
Falls back to batch tasklist on ctypes failure (rare).
|
|
404
|
+
"""
|
|
405
|
+
if not IS_WINDOWS:
|
|
406
|
+
return False
|
|
407
|
+
|
|
408
|
+
# Constants from Windows API
|
|
409
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
410
|
+
ERROR_INVALID_PARAMETER = 87
|
|
411
|
+
ERROR_ACCESS_DENIED = 5
|
|
412
|
+
STILL_ACTIVE = 259
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
# Try to open the process with minimal permissions
|
|
416
|
+
handle = ctypes.windll.kernel32.OpenProcess(
|
|
417
|
+
PROCESS_QUERY_LIMITED_INFORMATION,
|
|
418
|
+
False, # bInheritHandle
|
|
419
|
+
pid
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
if handle:
|
|
423
|
+
try:
|
|
424
|
+
# OpenProcess succeeds even for zombie processes (exited but handle
|
|
425
|
+
# not yet closed by parent). Check actual exit status.
|
|
426
|
+
exit_code = wintypes.DWORD()
|
|
427
|
+
if ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
428
|
+
return exit_code.value == STILL_ACTIVE
|
|
429
|
+
# GetExitCodeProcess failed — assume alive to be safe
|
|
430
|
+
return True
|
|
431
|
+
finally:
|
|
432
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
433
|
+
|
|
434
|
+
# OpenProcess failed, check why
|
|
435
|
+
error_code = ctypes.windll.kernel32.GetLastError()
|
|
436
|
+
|
|
437
|
+
if error_code == ERROR_ACCESS_DENIED:
|
|
438
|
+
# Process exists but we don't have permission (e.g., SYSTEM process)
|
|
439
|
+
# For our use case, this means it's NOT our child process
|
|
440
|
+
return False
|
|
441
|
+
elif error_code == ERROR_INVALID_PARAMETER:
|
|
442
|
+
# Invalid PID (process doesn't exist)
|
|
443
|
+
return False
|
|
444
|
+
else:
|
|
445
|
+
# Other errors (e.g., process exited between check and open)
|
|
446
|
+
return False
|
|
447
|
+
|
|
448
|
+
except Exception:
|
|
449
|
+
# Fallback: batch tasklist (fetch once, cache for subsequent calls)
|
|
450
|
+
if not hasattr(self, '_alive_pids_cache'):
|
|
451
|
+
self._alive_pids_cache = self._win_get_all_alive_pids()
|
|
452
|
+
return pid in self._alive_pids_cache
|
|
453
|
+
|
|
454
|
+
def _cmd_matches(self, pid: int, expected_cmd: list) -> bool:
|
|
455
|
+
"""Verify the process command line matches what we expect (prevent killing recycled PIDs).
|
|
456
|
+
Uses multi-strategy fallback per platform for robustness.
|
|
457
|
+
"""
|
|
458
|
+
if not expected_cmd:
|
|
459
|
+
return False
|
|
460
|
+
cmdline = self._get_process_cmdline(pid)
|
|
461
|
+
if cmdline:
|
|
462
|
+
return expected_cmd[-1] in cmdline
|
|
463
|
+
# Degraded fallback: match executable image name only
|
|
464
|
+
return self._exe_name_matches(pid, expected_cmd)
|
|
465
|
+
|
|
466
|
+
def _get_process_cmdline(self, pid: int) -> str:
|
|
467
|
+
"""Get full command line of a process. Returns empty string on failure."""
|
|
468
|
+
if IS_WINDOWS:
|
|
469
|
+
for method in (self._win_powershell_cmdline, self._win_wmic_cmdline):
|
|
470
|
+
result = method(pid)
|
|
471
|
+
if result:
|
|
472
|
+
return result
|
|
473
|
+
return ""
|
|
474
|
+
elif IS_MACOS:
|
|
475
|
+
try:
|
|
476
|
+
r = subprocess.run(["ps", "-p", str(pid), "-o", "command="],
|
|
477
|
+
capture_output=True, text=True, timeout=5)
|
|
478
|
+
return r.stdout.strip()
|
|
479
|
+
except Exception:
|
|
480
|
+
return ""
|
|
481
|
+
else:
|
|
482
|
+
# Linux: /proc first, ps fallback
|
|
483
|
+
try:
|
|
484
|
+
with open(f"/proc/{pid}/cmdline", "r") as f:
|
|
485
|
+
return f.read()
|
|
486
|
+
except Exception:
|
|
487
|
+
pass
|
|
488
|
+
try:
|
|
489
|
+
r = subprocess.run(["ps", "-p", str(pid), "-o", "args="],
|
|
490
|
+
capture_output=True, text=True, timeout=5)
|
|
491
|
+
return r.stdout.strip()
|
|
492
|
+
except Exception:
|
|
493
|
+
return ""
|
|
494
|
+
|
|
495
|
+
def _win_powershell_cmdline(self, pid: int) -> str:
|
|
496
|
+
"""Windows: Get cmdline via PowerShell Get-CimInstance (modern, replaces wmic)."""
|
|
497
|
+
try:
|
|
498
|
+
r = subprocess.run(
|
|
499
|
+
["powershell", "-NoProfile", "-Command",
|
|
500
|
+
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine"],
|
|
501
|
+
capture_output=True, timeout=8,
|
|
502
|
+
)
|
|
503
|
+
return r.stdout.decode(errors='replace').strip()
|
|
504
|
+
except Exception:
|
|
505
|
+
return ""
|
|
506
|
+
|
|
507
|
+
def _win_wmic_cmdline(self, pid: int) -> str:
|
|
508
|
+
"""Windows: Get cmdline via wmic (legacy fallback, deprecated but widely available)."""
|
|
509
|
+
try:
|
|
510
|
+
r = subprocess.run(
|
|
511
|
+
["wmic", "process", "where", f"ProcessId={pid}",
|
|
512
|
+
"get", "CommandLine", "/value"],
|
|
513
|
+
capture_output=True, timeout=5,
|
|
514
|
+
)
|
|
515
|
+
return r.stdout.decode(errors='replace').strip()
|
|
516
|
+
except Exception:
|
|
517
|
+
return ""
|
|
518
|
+
|
|
519
|
+
def _exe_name_matches(self, pid: int, expected_cmd: list) -> bool:
|
|
520
|
+
"""Last resort: check if process image name matches expected executable.
|
|
521
|
+
|
|
522
|
+
Windows: Uses OpenProcess + QueryFullProcessImageName for fast check.
|
|
523
|
+
Falls back to tasklist if ctypes fails.
|
|
524
|
+
"""
|
|
525
|
+
exe = os.path.basename(expected_cmd[0]).lower()
|
|
526
|
+
if exe.endswith(".exe"):
|
|
527
|
+
exe = exe[:-4]
|
|
528
|
+
if IS_WINDOWS:
|
|
529
|
+
# Try fast ctypes method first
|
|
530
|
+
image_name = self._win_get_image_name_ctypes(pid)
|
|
531
|
+
if image_name:
|
|
532
|
+
return exe in image_name.lower()
|
|
533
|
+
# Fallback to tasklist
|
|
534
|
+
try:
|
|
535
|
+
r = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
|
|
536
|
+
capture_output=True, timeout=5)
|
|
537
|
+
return exe in r.stdout.decode(errors='replace').lower()
|
|
538
|
+
except Exception:
|
|
539
|
+
return False
|
|
540
|
+
else:
|
|
541
|
+
try:
|
|
542
|
+
r = subprocess.run(["ps", "-p", str(pid), "-o", "comm="],
|
|
543
|
+
capture_output=True, text=True, timeout=5)
|
|
544
|
+
return exe in r.stdout.lower()
|
|
545
|
+
except Exception:
|
|
546
|
+
return False
|
|
547
|
+
|
|
548
|
+
def _win_get_image_name_ctypes(self, pid: int) -> str:
|
|
549
|
+
"""Windows: Get process image name using OpenProcess + QueryFullProcessImageName.
|
|
550
|
+
|
|
551
|
+
Returns empty string on failure.
|
|
552
|
+
"""
|
|
553
|
+
if not IS_WINDOWS:
|
|
554
|
+
return ""
|
|
555
|
+
|
|
556
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
557
|
+
|
|
558
|
+
try:
|
|
559
|
+
handle = ctypes.windll.kernel32.OpenProcess(
|
|
560
|
+
PROCESS_QUERY_LIMITED_INFORMATION,
|
|
561
|
+
False,
|
|
562
|
+
pid
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
if not handle:
|
|
566
|
+
return ""
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
# Buffer for image name (MAX_PATH = 260)
|
|
570
|
+
buffer_size = wintypes.DWORD(260)
|
|
571
|
+
buffer = ctypes.create_unicode_buffer(buffer_size.value)
|
|
572
|
+
|
|
573
|
+
# QueryFullProcessImageNameW
|
|
574
|
+
success = ctypes.windll.kernel32.QueryFullProcessImageNameW(
|
|
575
|
+
handle,
|
|
576
|
+
0, # dwFlags (0 = Win32 path format)
|
|
577
|
+
buffer,
|
|
578
|
+
ctypes.byref(buffer_size)
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
if success:
|
|
582
|
+
return os.path.basename(buffer.value)
|
|
583
|
+
else:
|
|
584
|
+
return ""
|
|
585
|
+
|
|
586
|
+
finally:
|
|
587
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
588
|
+
|
|
589
|
+
except Exception:
|
|
590
|
+
return ""
|
|
591
|
+
|
|
592
|
+
def _force_kill(self, pid: int):
|
|
593
|
+
"""Force kill a process by PID."""
|
|
594
|
+
try:
|
|
595
|
+
if IS_WINDOWS:
|
|
596
|
+
subprocess.run(["taskkill", "/F", "/PID", str(pid)],
|
|
597
|
+
capture_output=True, timeout=5)
|
|
598
|
+
else:
|
|
599
|
+
import signal
|
|
600
|
+
os.kill(pid, signal.SIGKILL)
|
|
601
|
+
except Exception as e:
|
|
602
|
+
print(f"[launcher] 警告: 强制终止 PID {pid} 失败: {e}")
|
|
603
|
+
|
|
604
|
+
# ── Start module ──
|
|
605
|
+
|
|
606
|
+
def start_module(self, info, boot_info: dict = None) -> bool:
|
|
607
|
+
"""Start a module as a subprocess. Returns True on success.
|
|
608
|
+
If boot_info is provided, it is written as JSON to the child's stdin pipe.
|
|
609
|
+
stdin is kept open after boot_info for Launcher to send additional messages.
|
|
610
|
+
"""
|
|
611
|
+
from .module_scanner import ModuleInfo
|
|
612
|
+
info: ModuleInfo
|
|
613
|
+
|
|
614
|
+
if info.name in self._processes:
|
|
615
|
+
proc = self._processes[info.name]
|
|
616
|
+
if proc.poll() is None:
|
|
617
|
+
print(f"[launcher] {info.name} 已在运行 (PID {proc.pid})")
|
|
618
|
+
return True
|
|
619
|
+
|
|
620
|
+
cmd = self._build_cmd(info)
|
|
621
|
+
if not cmd:
|
|
622
|
+
print(f"[launcher] 错误: 无法构建 {info.name} 的启动命令")
|
|
623
|
+
return False
|
|
624
|
+
|
|
625
|
+
env = os.environ.copy()
|
|
626
|
+
env["PYTHONUTF8"] = "1" # Force UTF-8 in child Python processes
|
|
627
|
+
env["PYTHONUNBUFFERED"] = "1" # Disable output buffering for real-time logs
|
|
628
|
+
env["KITE_MODULE_DATA"] = os.path.join(os.environ["KITE_INSTANCE_DIR"], info.name)
|
|
629
|
+
if info.launch.env:
|
|
630
|
+
env.update(info.launch.env)
|
|
631
|
+
|
|
632
|
+
creation_flags = 0
|
|
633
|
+
if IS_WINDOWS:
|
|
634
|
+
creation_flags = subprocess.CREATE_NO_WINDOW
|
|
635
|
+
|
|
636
|
+
use_stdin = boot_info is not None and info.launch.boot_stdin
|
|
637
|
+
|
|
638
|
+
cwd = info.module_dir
|
|
639
|
+
if info.launch.cwd:
|
|
640
|
+
cwd = os.path.normpath(os.path.join(info.module_dir, info.launch.cwd))
|
|
641
|
+
|
|
642
|
+
try:
|
|
643
|
+
proc = subprocess.Popen(
|
|
644
|
+
cmd,
|
|
645
|
+
cwd=cwd,
|
|
646
|
+
env=env,
|
|
647
|
+
stdin=subprocess.PIPE if use_stdin else None,
|
|
648
|
+
stdout=subprocess.PIPE,
|
|
649
|
+
stderr=subprocess.STDOUT,
|
|
650
|
+
creationflags=creation_flags,
|
|
651
|
+
)
|
|
652
|
+
except Exception as e:
|
|
653
|
+
print(f"[launcher] 错误: 启动 {info.name} 失败: {e}")
|
|
654
|
+
return False
|
|
655
|
+
|
|
656
|
+
# Write boot_info to stdin — keep stdin open for additional messages
|
|
657
|
+
if use_stdin and proc.stdin:
|
|
658
|
+
try:
|
|
659
|
+
proc.stdin.write(json.dumps(boot_info).encode() + b"\n")
|
|
660
|
+
proc.stdin.flush()
|
|
661
|
+
except Exception as e:
|
|
662
|
+
print(f"[launcher] 警告: 向 {info.name} 写入 boot_info 失败: {e}")
|
|
663
|
+
|
|
664
|
+
self._processes[info.name] = proc
|
|
665
|
+
self._records[info.name] = ProcessRecord(
|
|
666
|
+
name=info.name,
|
|
667
|
+
pid=proc.pid,
|
|
668
|
+
cmd=cmd,
|
|
669
|
+
module_dir=info.module_dir,
|
|
670
|
+
started_at=time.time(),
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
# Daemon thread to read stdout: structured messages + log lines
|
|
674
|
+
t = threading.Thread(
|
|
675
|
+
target=self._read_stdout, args=(info.name, proc),
|
|
676
|
+
daemon=True,
|
|
677
|
+
)
|
|
678
|
+
t.start()
|
|
679
|
+
|
|
680
|
+
print(f"[launcher] 已启动 {info.name} (PID {proc.pid})")
|
|
681
|
+
return True
|
|
682
|
+
|
|
683
|
+
def write_stdin(self, name: str, data: dict) -> bool:
|
|
684
|
+
"""Write a structured JSON message to a module's stdin.
|
|
685
|
+
Used by Launcher to send additional messages after boot_info (e.g. launcher_ws_token).
|
|
686
|
+
Returns True on success.
|
|
687
|
+
"""
|
|
688
|
+
proc = self._processes.get(name)
|
|
689
|
+
if not proc or not proc.stdin or proc.stdin.closed:
|
|
690
|
+
return False
|
|
691
|
+
try:
|
|
692
|
+
proc.stdin.write(json.dumps(data).encode() + b"\n")
|
|
693
|
+
proc.stdin.flush()
|
|
694
|
+
return True
|
|
695
|
+
except Exception as e:
|
|
696
|
+
print(f"[launcher] 警告: 向 {name} 写入 stdin 失败: {e}")
|
|
697
|
+
return False
|
|
698
|
+
|
|
699
|
+
def close_stdio(self, name: str):
|
|
700
|
+
"""Close stdin for a module. Called by Launcher after module.ready → module.started.
|
|
701
|
+
Only closes stdin (no more messages to send). stdout is left open so the reader
|
|
702
|
+
thread can continue relaying logs — it exits naturally when the process dies.
|
|
703
|
+
On Windows, closing stdout while a reader thread is blocked on readline() can deadlock.
|
|
704
|
+
"""
|
|
705
|
+
proc = self._processes.get(name)
|
|
706
|
+
if not proc:
|
|
707
|
+
return
|
|
708
|
+
if proc.stdin and not proc.stdin.closed:
|
|
709
|
+
try:
|
|
710
|
+
proc.stdin.close()
|
|
711
|
+
except Exception:
|
|
712
|
+
pass
|
|
713
|
+
|
|
714
|
+
def _build_cmd(self, info) -> list:
|
|
715
|
+
"""Build the command list based on module runtime.
|
|
716
|
+
|
|
717
|
+
For python/node: if entry starts with '-m ', use package mode (e.g. python -m pkg).
|
|
718
|
+
Otherwise use script file mode (e.g. python entry.py).
|
|
719
|
+
"""
|
|
720
|
+
if info.launch.cmd:
|
|
721
|
+
return list(info.launch.cmd)
|
|
722
|
+
|
|
723
|
+
if info.runtime == "python":
|
|
724
|
+
if info.entry.startswith("-m "):
|
|
725
|
+
return [sys.executable] + info.entry.split()
|
|
726
|
+
return [sys.executable, os.path.join(info.module_dir, info.entry)]
|
|
727
|
+
elif info.runtime == "node":
|
|
728
|
+
if info.entry.startswith("-e ") or info.entry.startswith("--eval "):
|
|
729
|
+
return ["node"] + info.entry.split()
|
|
730
|
+
return ["node", os.path.join(info.module_dir, info.entry)]
|
|
731
|
+
elif info.runtime == "binary":
|
|
732
|
+
return [os.path.join(info.module_dir, info.entry)]
|
|
733
|
+
else:
|
|
734
|
+
print(f"[launcher] 警告: 未知运行时 '{info.runtime}',模块 {info.name}")
|
|
735
|
+
return []
|
|
736
|
+
|
|
737
|
+
def _read_stdout(self, name: str, proc: subprocess.Popen):
|
|
738
|
+
"""Read subprocess stdout line by line.
|
|
739
|
+
Lines containing a JSON object with "kite" field are dispatched as structured messages.
|
|
740
|
+
All other lines are printed as log output with module name prefix.
|
|
741
|
+
"""
|
|
742
|
+
try:
|
|
743
|
+
for line in iter(proc.stdout.readline, b""):
|
|
744
|
+
text = line.decode("utf-8", errors="replace").rstrip()
|
|
745
|
+
if not text:
|
|
746
|
+
continue
|
|
747
|
+
# Try to parse as structured kite message
|
|
748
|
+
if self._on_kite_message and text.startswith("{"):
|
|
749
|
+
try:
|
|
750
|
+
msg = json.loads(text)
|
|
751
|
+
if isinstance(msg, dict) and "kite" in msg:
|
|
752
|
+
self._on_kite_message(name, msg)
|
|
753
|
+
continue
|
|
754
|
+
except (json.JSONDecodeError, KeyError):
|
|
755
|
+
pass
|
|
756
|
+
# Regular log line — avoid double prefix if module already added [name]
|
|
757
|
+
if text.startswith(f"[{name}]"):
|
|
758
|
+
print(text)
|
|
759
|
+
else:
|
|
760
|
+
print(f"[{name}] {text}")
|
|
761
|
+
except Exception:
|
|
762
|
+
pass
|
|
763
|
+
|
|
764
|
+
# ── Stop module ──
|
|
765
|
+
|
|
766
|
+
def stop_module(self, name: str, timeout: float = 5) -> bool:
|
|
767
|
+
"""Stop a module by name. Returns True if stopped successfully."""
|
|
768
|
+
proc = self._processes.get(name)
|
|
769
|
+
if not proc or proc.poll() is not None:
|
|
770
|
+
self._processes.pop(name, None)
|
|
771
|
+
self._records.pop(name, None)
|
|
772
|
+
return True
|
|
773
|
+
|
|
774
|
+
# Close stdio before terminating
|
|
775
|
+
self.close_stdio(name)
|
|
776
|
+
|
|
777
|
+
print(f"[launcher] 正在停止 {name} (PID {proc.pid})...")
|
|
778
|
+
proc.terminate()
|
|
779
|
+
try:
|
|
780
|
+
proc.wait(timeout=timeout)
|
|
781
|
+
except subprocess.TimeoutExpired:
|
|
782
|
+
print(f"[launcher] {name} 在 {timeout}s 内未退出,强制终止")
|
|
783
|
+
proc.kill()
|
|
784
|
+
try:
|
|
785
|
+
proc.wait(timeout=3)
|
|
786
|
+
except subprocess.TimeoutExpired:
|
|
787
|
+
self._force_kill(proc.pid)
|
|
788
|
+
|
|
789
|
+
self._processes.pop(name, None)
|
|
790
|
+
self._records.pop(name, None)
|
|
791
|
+
print(f"[launcher] 已停止 {name}")
|
|
792
|
+
return True
|
|
793
|
+
|
|
794
|
+
def stop_all(self, timeout: float = 10):
|
|
795
|
+
"""Stop all managed processes. Terminate first, force kill after timeout."""
|
|
796
|
+
if not self._processes:
|
|
797
|
+
return
|
|
798
|
+
|
|
799
|
+
# Filter to only still-running processes
|
|
800
|
+
alive = [n for n, p in self._processes.items() if p.poll() is None]
|
|
801
|
+
if not alive:
|
|
802
|
+
# All already exited — just clean up bookkeeping
|
|
803
|
+
self._processes.clear()
|
|
804
|
+
self._records.clear()
|
|
805
|
+
return
|
|
806
|
+
|
|
807
|
+
print(f"[launcher] 正在停止所有模块: {', '.join(alive)}")
|
|
808
|
+
|
|
809
|
+
# Phase 1: close stdio and send terminate to all
|
|
810
|
+
for name in alive:
|
|
811
|
+
self.close_stdio(name)
|
|
812
|
+
proc = self._processes.get(name)
|
|
813
|
+
if proc and proc.poll() is None:
|
|
814
|
+
proc.terminate()
|
|
815
|
+
|
|
816
|
+
# Phase 2: wait for all to exit
|
|
817
|
+
deadline = time.time() + timeout
|
|
818
|
+
for name in alive:
|
|
819
|
+
proc = self._processes.get(name)
|
|
820
|
+
if not proc:
|
|
821
|
+
continue
|
|
822
|
+
remaining = max(0.1, deadline - time.time())
|
|
823
|
+
try:
|
|
824
|
+
proc.wait(timeout=remaining)
|
|
825
|
+
except subprocess.TimeoutExpired:
|
|
826
|
+
pass
|
|
827
|
+
|
|
828
|
+
# Phase 3: force kill survivors
|
|
829
|
+
for name in alive:
|
|
830
|
+
proc = self._processes.get(name)
|
|
831
|
+
if proc and proc.poll() is None:
|
|
832
|
+
print(f"[launcher] 强制终止 {name} (PID {proc.pid})")
|
|
833
|
+
proc.kill()
|
|
834
|
+
try:
|
|
835
|
+
proc.wait(timeout=3)
|
|
836
|
+
except subprocess.TimeoutExpired:
|
|
837
|
+
self._force_kill(proc.pid)
|
|
838
|
+
|
|
839
|
+
self._processes.clear()
|
|
840
|
+
self._records.clear()
|
|
841
|
+
print("[launcher] 所有模块已停止")
|
|
842
|
+
|
|
843
|
+
# ── Persistence & monitoring ──
|
|
844
|
+
|
|
845
|
+
def persist_records(self):
|
|
846
|
+
"""Write current process records to processes.json (with launcher_pid for multi-instance)."""
|
|
847
|
+
data = {
|
|
848
|
+
"launcher_pid": os.getpid(),
|
|
849
|
+
"debug": os.environ.get("KITE_DEBUG") == "1",
|
|
850
|
+
"cwd": os.environ.get("KITE_CWD", ""),
|
|
851
|
+
"records": [asdict(r) for r in self._records.values()],
|
|
852
|
+
}
|
|
853
|
+
self._write_records_file(data)
|
|
854
|
+
|
|
855
|
+
def check_exited(self) -> list[tuple[str, int]]:
|
|
856
|
+
"""Non-blocking check for exited child processes. Returns [(name, returncode)]."""
|
|
857
|
+
exited = []
|
|
858
|
+
for name in list(self._processes.keys()):
|
|
859
|
+
proc = self._processes[name]
|
|
860
|
+
rc = proc.poll()
|
|
861
|
+
if rc is not None:
|
|
862
|
+
exited.append((name, rc))
|
|
863
|
+
self._processes.pop(name, None)
|
|
864
|
+
self._records.pop(name, None)
|
|
865
|
+
return exited
|
|
866
|
+
|
|
867
|
+
def get_record(self, name: str) -> ProcessRecord | None:
|
|
868
|
+
return self._records.get(name)
|
|
869
|
+
|
|
870
|
+
def is_running(self, name: str) -> bool:
|
|
871
|
+
proc = self._processes.get(name)
|
|
872
|
+
return proc is not None and proc.poll() is None
|
|
873
|
+
|
|
874
|
+
def _write_records_file(self, data):
|
|
875
|
+
"""Write data to the instance's processes JSON file."""
|
|
876
|
+
try:
|
|
877
|
+
with open(self.records_path, "w", encoding="utf-8") as f:
|
|
878
|
+
json.dump(data, f, indent=2)
|
|
879
|
+
except Exception as e:
|
|
880
|
+
print(f"[launcher] 警告: 写入 {os.path.basename(self.records_path)} 失败: {e}")
|