@agentunion/kite 1.0.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +127 -25
- package/core/event_hub/entry.py +384 -61
- package/core/event_hub/hub.py +8 -0
- package/core/event_hub/module.md +0 -1
- package/core/event_hub/server.py +169 -38
- package/core/kite_log.py +241 -0
- package/core/launcher/entry.py +1306 -425
- package/core/launcher/module_scanner.py +10 -9
- package/core/launcher/process_manager.py +555 -121
- package/core/registry/entry.py +335 -30
- package/core/registry/server.py +339 -256
- package/core/registry/store.py +13 -2
- package/extensions/agents/__init__.py +1 -0
- package/extensions/agents/assistant/__init__.py +1 -0
- package/extensions/agents/assistant/entry.py +380 -0
- package/extensions/agents/assistant/module.md +22 -0
- package/extensions/agents/assistant/server.py +236 -0
- package/extensions/channels/__init__.py +1 -0
- package/extensions/channels/acp_channel/__init__.py +1 -0
- package/extensions/channels/acp_channel/entry.py +380 -0
- package/extensions/channels/acp_channel/module.md +22 -0
- package/extensions/channels/acp_channel/server.py +236 -0
- package/{core → extensions}/event_hub_bench/entry.py +664 -371
- package/{core → extensions}/event_hub_bench/module.md +4 -2
- package/extensions/services/backup/__init__.py +1 -0
- package/extensions/services/backup/entry.py +380 -0
- package/extensions/services/backup/module.md +22 -0
- package/extensions/services/backup/server.py +244 -0
- package/extensions/services/model_service/__init__.py +1 -0
- package/extensions/services/model_service/entry.py +380 -0
- package/extensions/services/model_service/module.md +22 -0
- package/extensions/services/model_service/server.py +236 -0
- package/extensions/services/watchdog/entry.py +460 -143
- package/extensions/services/watchdog/module.md +3 -0
- package/extensions/services/watchdog/monitor.py +128 -13
- package/extensions/services/watchdog/server.py +75 -13
- package/extensions/services/web/__init__.py +1 -0
- package/extensions/services/web/config.yaml +149 -0
- package/extensions/services/web/entry.py +487 -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 +332 -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/__init__.py +0 -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/__init__.py +0 -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/__init__.py +0 -0
- package/extensions/services/web/vendor/tools/registry.py +321 -0
- package/main.py +344 -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/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
- /package/{core/event_hub/bench_results/.gitkeep → extensions/services/web/vendor/bluetooth/__init__.py} +0 -0
|
@@ -1,16 +1,27 @@
|
|
|
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
|
|
|
11
|
+
import glob
|
|
6
12
|
import json
|
|
7
13
|
import os
|
|
14
|
+
import re
|
|
8
15
|
import subprocess
|
|
9
16
|
import sys
|
|
10
17
|
import threading
|
|
11
18
|
import time
|
|
12
19
|
from dataclasses import dataclass, asdict
|
|
20
|
+
from typing import Callable
|
|
13
21
|
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
import ctypes
|
|
24
|
+
from ctypes import wintypes
|
|
14
25
|
|
|
15
26
|
IS_WINDOWS = sys.platform == "win32"
|
|
16
27
|
IS_MACOS = sys.platform == "darwin"
|
|
@@ -28,105 +39,322 @@ class ProcessRecord:
|
|
|
28
39
|
class ProcessManager:
|
|
29
40
|
"""Manage child processes for all Kite modules."""
|
|
30
41
|
|
|
31
|
-
def __init__(self,
|
|
32
|
-
|
|
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
|
+
"""
|
|
33
49
|
self.kite_token = kite_token
|
|
34
50
|
self.instance_id = instance_id or str(os.getpid())
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
self.data_dir =
|
|
39
|
-
|
|
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")
|
|
40
60
|
self._processes: dict[str, subprocess.Popen] = {} # name -> Popen
|
|
41
61
|
self._records: dict[str, ProcessRecord] = {} # name -> record
|
|
42
|
-
os.makedirs(self.data_dir, exist_ok=True)
|
|
43
62
|
|
|
44
|
-
|
|
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
|
|
45
67
|
|
|
46
|
-
|
|
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
|
|
68
|
+
@property
|
|
69
|
+
def instance_num(self) -> int:
|
|
70
|
+
"""Return the instance number (1-based)."""
|
|
71
|
+
return self._instance_num
|
|
80
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 = ""
|
|
81
93
|
try:
|
|
82
|
-
with open(
|
|
83
|
-
|
|
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)
|
|
84
102
|
except Exception:
|
|
85
|
-
|
|
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):
|
|
86
141
|
continue
|
|
87
142
|
|
|
88
|
-
for
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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:
|
|
93
158
|
continue
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
118
349
|
|
|
119
350
|
def _is_pid_alive(self, pid: int) -> bool:
|
|
120
|
-
"""Check if a process with given PID exists.
|
|
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
|
+
"""
|
|
121
356
|
if IS_WINDOWS:
|
|
122
|
-
|
|
123
|
-
result = subprocess.run(
|
|
124
|
-
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
|
|
125
|
-
capture_output=True, text=True, timeout=5,
|
|
126
|
-
)
|
|
127
|
-
return str(pid) in result.stdout
|
|
128
|
-
except Exception:
|
|
129
|
-
return False
|
|
357
|
+
return self._win_is_pid_alive_ctypes(pid)
|
|
130
358
|
else:
|
|
131
359
|
try:
|
|
132
360
|
os.kill(pid, 0)
|
|
@@ -134,6 +362,95 @@ class ProcessManager:
|
|
|
134
362
|
except OSError:
|
|
135
363
|
return False
|
|
136
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
|
+
|
|
137
454
|
def _cmd_matches(self, pid: int, expected_cmd: list) -> bool:
|
|
138
455
|
"""Verify the process command line matches what we expect (prevent killing recycled PIDs).
|
|
139
456
|
Uses multi-strategy fallback per platform for robustness.
|
|
@@ -181,9 +498,9 @@ class ProcessManager:
|
|
|
181
498
|
r = subprocess.run(
|
|
182
499
|
["powershell", "-NoProfile", "-Command",
|
|
183
500
|
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine"],
|
|
184
|
-
capture_output=True,
|
|
501
|
+
capture_output=True, timeout=8,
|
|
185
502
|
)
|
|
186
|
-
return r.stdout.strip()
|
|
503
|
+
return r.stdout.decode(errors='replace').strip()
|
|
187
504
|
except Exception:
|
|
188
505
|
return ""
|
|
189
506
|
|
|
@@ -193,22 +510,31 @@ class ProcessManager:
|
|
|
193
510
|
r = subprocess.run(
|
|
194
511
|
["wmic", "process", "where", f"ProcessId={pid}",
|
|
195
512
|
"get", "CommandLine", "/value"],
|
|
196
|
-
capture_output=True,
|
|
513
|
+
capture_output=True, timeout=5,
|
|
197
514
|
)
|
|
198
|
-
return r.stdout.strip()
|
|
515
|
+
return r.stdout.decode(errors='replace').strip()
|
|
199
516
|
except Exception:
|
|
200
517
|
return ""
|
|
201
518
|
|
|
202
519
|
def _exe_name_matches(self, pid: int, expected_cmd: list) -> bool:
|
|
203
|
-
"""Last resort: check if process image name matches expected executable.
|
|
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
|
+
"""
|
|
204
525
|
exe = os.path.basename(expected_cmd[0]).lower()
|
|
205
526
|
if exe.endswith(".exe"):
|
|
206
527
|
exe = exe[:-4]
|
|
207
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
|
|
208
534
|
try:
|
|
209
535
|
r = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
|
|
210
|
-
capture_output=True,
|
|
211
|
-
return exe in r.stdout.lower()
|
|
536
|
+
capture_output=True, timeout=5)
|
|
537
|
+
return exe in r.stdout.decode(errors='replace').lower()
|
|
212
538
|
except Exception:
|
|
213
539
|
return False
|
|
214
540
|
else:
|
|
@@ -219,6 +545,50 @@ class ProcessManager:
|
|
|
219
545
|
except Exception:
|
|
220
546
|
return False
|
|
221
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
|
+
|
|
222
592
|
def _force_kill(self, pid: int):
|
|
223
593
|
"""Force kill a process by PID."""
|
|
224
594
|
try:
|
|
@@ -229,13 +599,14 @@ class ProcessManager:
|
|
|
229
599
|
import signal
|
|
230
600
|
os.kill(pid, signal.SIGKILL)
|
|
231
601
|
except Exception as e:
|
|
232
|
-
print(f"[launcher]
|
|
602
|
+
print(f"[launcher] 警告: 强制终止 PID {pid} 失败: {e}")
|
|
233
603
|
|
|
234
604
|
# ── Start module ──
|
|
235
605
|
|
|
236
606
|
def start_module(self, info, boot_info: dict = None) -> bool:
|
|
237
607
|
"""Start a module as a subprocess. Returns True on success.
|
|
238
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.
|
|
239
610
|
"""
|
|
240
611
|
from .module_scanner import ModuleInfo
|
|
241
612
|
info: ModuleInfo
|
|
@@ -243,17 +614,18 @@ class ProcessManager:
|
|
|
243
614
|
if info.name in self._processes:
|
|
244
615
|
proc = self._processes[info.name]
|
|
245
616
|
if proc.poll() is None:
|
|
246
|
-
print(f"[launcher] {info.name}
|
|
617
|
+
print(f"[launcher] {info.name} 已在运行 (PID {proc.pid})")
|
|
247
618
|
return True
|
|
248
619
|
|
|
249
620
|
cmd = self._build_cmd(info)
|
|
250
621
|
if not cmd:
|
|
251
|
-
print(f"[launcher]
|
|
622
|
+
print(f"[launcher] 错误: 无法构建 {info.name} 的启动命令")
|
|
252
623
|
return False
|
|
253
624
|
|
|
254
625
|
env = os.environ.copy()
|
|
255
626
|
env["PYTHONUTF8"] = "1" # Force UTF-8 in child Python processes
|
|
256
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)
|
|
257
629
|
if info.launch.env:
|
|
258
630
|
env.update(info.launch.env)
|
|
259
631
|
|
|
@@ -278,17 +650,16 @@ class ProcessManager:
|
|
|
278
650
|
creationflags=creation_flags,
|
|
279
651
|
)
|
|
280
652
|
except Exception as e:
|
|
281
|
-
print(f"[launcher]
|
|
653
|
+
print(f"[launcher] 错误: 启动 {info.name} 失败: {e}")
|
|
282
654
|
return False
|
|
283
655
|
|
|
284
|
-
# Write boot_info to stdin
|
|
656
|
+
# Write boot_info to stdin — keep stdin open for additional messages
|
|
285
657
|
if use_stdin and proc.stdin:
|
|
286
658
|
try:
|
|
287
659
|
proc.stdin.write(json.dumps(boot_info).encode() + b"\n")
|
|
288
660
|
proc.stdin.flush()
|
|
289
|
-
proc.stdin.close()
|
|
290
661
|
except Exception as e:
|
|
291
|
-
print(f"[launcher]
|
|
662
|
+
print(f"[launcher] 警告: 向 {info.name} 写入 boot_info 失败: {e}")
|
|
292
663
|
|
|
293
664
|
self._processes[info.name] = proc
|
|
294
665
|
self._records[info.name] = ProcessRecord(
|
|
@@ -299,16 +670,47 @@ class ProcessManager:
|
|
|
299
670
|
started_at=time.time(),
|
|
300
671
|
)
|
|
301
672
|
|
|
302
|
-
# Daemon thread to read stdout
|
|
673
|
+
# Daemon thread to read stdout: structured messages + log lines
|
|
303
674
|
t = threading.Thread(
|
|
304
675
|
target=self._read_stdout, args=(info.name, proc),
|
|
305
676
|
daemon=True,
|
|
306
677
|
)
|
|
307
678
|
t.start()
|
|
308
679
|
|
|
309
|
-
print(f"[launcher]
|
|
680
|
+
print(f"[launcher] 已启动 {info.name} (PID {proc.pid})")
|
|
310
681
|
return True
|
|
311
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
|
+
|
|
312
714
|
def _build_cmd(self, info) -> list:
|
|
313
715
|
"""Build the command list based on module runtime.
|
|
314
716
|
|
|
@@ -329,16 +731,32 @@ class ProcessManager:
|
|
|
329
731
|
elif info.runtime == "binary":
|
|
330
732
|
return [os.path.join(info.module_dir, info.entry)]
|
|
331
733
|
else:
|
|
332
|
-
print(f"[launcher]
|
|
734
|
+
print(f"[launcher] 警告: 未知运行时 '{info.runtime}',模块 {info.name}")
|
|
333
735
|
return []
|
|
334
736
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
"""
|
|
338
742
|
try:
|
|
339
743
|
for line in iter(proc.stdout.readline, b""):
|
|
340
744
|
text = line.decode("utf-8", errors="replace").rstrip()
|
|
341
|
-
if text:
|
|
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:
|
|
342
760
|
print(f"[{name}] {text}")
|
|
343
761
|
except Exception:
|
|
344
762
|
pass
|
|
@@ -353,12 +771,15 @@ class ProcessManager:
|
|
|
353
771
|
self._records.pop(name, None)
|
|
354
772
|
return True
|
|
355
773
|
|
|
356
|
-
|
|
774
|
+
# Close stdio before terminating
|
|
775
|
+
self.close_stdio(name)
|
|
776
|
+
|
|
777
|
+
print(f"[launcher] 正在停止 {name} (PID {proc.pid})...")
|
|
357
778
|
proc.terminate()
|
|
358
779
|
try:
|
|
359
780
|
proc.wait(timeout=timeout)
|
|
360
781
|
except subprocess.TimeoutExpired:
|
|
361
|
-
print(f"[launcher] {name}
|
|
782
|
+
print(f"[launcher] {name} 在 {timeout}s 内未退出,强制终止")
|
|
362
783
|
proc.kill()
|
|
363
784
|
try:
|
|
364
785
|
proc.wait(timeout=3)
|
|
@@ -367,7 +788,7 @@ class ProcessManager:
|
|
|
367
788
|
|
|
368
789
|
self._processes.pop(name, None)
|
|
369
790
|
self._records.pop(name, None)
|
|
370
|
-
print(f"[launcher]
|
|
791
|
+
print(f"[launcher] 已停止 {name}")
|
|
371
792
|
return True
|
|
372
793
|
|
|
373
794
|
def stop_all(self, timeout: float = 10):
|
|
@@ -375,18 +796,26 @@ class ProcessManager:
|
|
|
375
796
|
if not self._processes:
|
|
376
797
|
return
|
|
377
798
|
|
|
378
|
-
|
|
379
|
-
|
|
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)}")
|
|
380
808
|
|
|
381
|
-
# Phase 1: send terminate to all
|
|
382
|
-
for name in
|
|
809
|
+
# Phase 1: close stdio and send terminate to all
|
|
810
|
+
for name in alive:
|
|
811
|
+
self.close_stdio(name)
|
|
383
812
|
proc = self._processes.get(name)
|
|
384
813
|
if proc and proc.poll() is None:
|
|
385
814
|
proc.terminate()
|
|
386
815
|
|
|
387
816
|
# Phase 2: wait for all to exit
|
|
388
817
|
deadline = time.time() + timeout
|
|
389
|
-
for name in
|
|
818
|
+
for name in alive:
|
|
390
819
|
proc = self._processes.get(name)
|
|
391
820
|
if not proc:
|
|
392
821
|
continue
|
|
@@ -397,10 +826,10 @@ class ProcessManager:
|
|
|
397
826
|
pass
|
|
398
827
|
|
|
399
828
|
# Phase 3: force kill survivors
|
|
400
|
-
for name in
|
|
829
|
+
for name in alive:
|
|
401
830
|
proc = self._processes.get(name)
|
|
402
831
|
if proc and proc.poll() is None:
|
|
403
|
-
print(f"[launcher]
|
|
832
|
+
print(f"[launcher] 强制终止 {name} (PID {proc.pid})")
|
|
404
833
|
proc.kill()
|
|
405
834
|
try:
|
|
406
835
|
proc.wait(timeout=3)
|
|
@@ -409,13 +838,18 @@ class ProcessManager:
|
|
|
409
838
|
|
|
410
839
|
self._processes.clear()
|
|
411
840
|
self._records.clear()
|
|
412
|
-
print("[launcher]
|
|
841
|
+
print("[launcher] 所有模块已停止")
|
|
413
842
|
|
|
414
843
|
# ── Persistence & monitoring ──
|
|
415
844
|
|
|
416
845
|
def persist_records(self):
|
|
417
|
-
"""Write current process records to processes.json."""
|
|
418
|
-
data =
|
|
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
|
+
}
|
|
419
853
|
self._write_records_file(data)
|
|
420
854
|
|
|
421
855
|
def check_exited(self) -> list[tuple[str, int]]:
|
|
@@ -437,10 +871,10 @@ class ProcessManager:
|
|
|
437
871
|
proc = self._processes.get(name)
|
|
438
872
|
return proc is not None and proc.poll() is None
|
|
439
873
|
|
|
440
|
-
def _write_records_file(self, data
|
|
441
|
-
"""Write data
|
|
874
|
+
def _write_records_file(self, data):
|
|
875
|
+
"""Write data to the instance's processes JSON file."""
|
|
442
876
|
try:
|
|
443
877
|
with open(self.records_path, "w", encoding="utf-8") as f:
|
|
444
878
|
json.dump(data, f, indent=2)
|
|
445
879
|
except Exception as e:
|
|
446
|
-
print(f"[launcher]
|
|
880
|
+
print(f"[launcher] 警告: 写入 {os.path.basename(self.records_path)} 失败: {e}")
|