@aliwey/bmo 2.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/README.md +90 -0
- package/bin/bmo.js +188 -0
- package/cli.py +1129 -0
- package/config/__init__.py +0 -0
- package/config/__pycache__/__init__.cpython-313.pyc +0 -0
- package/config/__pycache__/settings.cpython-313.pyc +0 -0
- package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
- package/config/settings.py +104 -0
- package/config/system-prompt.json +18 -0
- package/core/__init__.py +0 -0
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
- package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
- package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
- package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
- package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
- package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
- package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
- package/core/__pycache__/security.cpython-313.pyc +0 -0
- package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
- package/core/bfp_a2a_bridge.py +399 -0
- package/core/bfp_agent.py +98 -0
- package/core/bfp_agent_card.py +161 -0
- package/core/bfp_connector.py +177 -0
- package/core/bfp_discovery.py +105 -0
- package/core/bfp_identity.py +83 -0
- package/core/bfp_tasks.py +70 -0
- package/core/bfp_transport.py +368 -0
- package/core/bmo_engine.py +405 -0
- package/core/bot_client.py +838 -0
- package/core/budget_tracker.py +62 -0
- package/core/cli_renderer.py +177 -0
- package/core/goal_runner.py +129 -0
- package/core/request_worker.py +242 -0
- package/core/security.py +42 -0
- package/core/shared_state.py +4 -0
- package/core/worker_manager.py +71 -0
- package/core/worker_multiproc.py +155 -0
- package/core/worker_protocol.py +30 -0
- package/core/worker_subprocess.py +222 -0
- package/handlers/__init__.py +0 -0
- package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
- package/handlers/messages.py +2761 -0
- package/main.py +125 -0
- package/memory.md +43 -0
- package/models/__init__.py +0 -0
- package/models/__pycache__/__init__.cpython-313.pyc +0 -0
- package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
- package/models/chat_models.py +143 -0
- package/package.json +50 -0
- package/registry/worker.js +108 -0
- package/registry/wrangler.toml +11 -0
- package/requirements.txt +13 -0
- package/scripts/bmo_init.js +115 -0
- package/scripts/postinstall.js +265 -0
- package/scripts/relay_cmd.js +276 -0
- package/scripts/web_cmd.js +136 -0
- package/setup.py +26 -0
- package/storage/__init__.py +0 -0
- package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
- package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
- package/storage/__pycache__/storage.cpython-313.pyc +0 -0
- package/storage/sqlite_storage.py +658 -0
- package/storage/storage.py +265 -0
- package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
- package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
- package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
- package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
- package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
- package/tools/bfp_relay.py +359 -0
- package/tools/bot.db +0 -0
- package/tools/get_session_summaries.py +45 -0
- package/tools/mcp_bridge.py +109 -0
- package/tools/mcp_server.py +531 -0
- package/tools/register_mcp_task.py +20 -0
- package/tools/run_detached.bat +32 -0
- package/tools/run_mcp_standalone.py +16 -0
- package/tools/task_registry.py +184 -0
- package/tools/test_mcp_connection.py +80 -0
- package/webchat/package-lock.json +1528 -0
- package/webchat/package.json +12 -0
- package/webchat/public/app.js +1293 -0
- package/webchat/public/index.html +226 -0
- package/webchat/public/index.html.bak +416 -0
- package/webchat/public/styles.css +2435 -0
- package/webchat/server.js +645 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import Callable, Optional
|
|
4
|
+
|
|
5
|
+
from core.worker_subprocess import AsyncSubprocessWorker
|
|
6
|
+
from core.worker_multiproc import MultiprocessWorker
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WorkerManager:
|
|
12
|
+
"""Unified facade over both worker backends.
|
|
13
|
+
Selects backend via env WORKER_BACKEND (subprocess|multiproc).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, backend: Optional[str] = None):
|
|
17
|
+
if backend is None:
|
|
18
|
+
backend = os.getenv("WORKER_BACKEND", "subprocess")
|
|
19
|
+
self.backend = backend.lower()
|
|
20
|
+
|
|
21
|
+
if self.backend == "subprocess":
|
|
22
|
+
self._worker = AsyncSubprocessWorker()
|
|
23
|
+
elif self.backend == "multiproc":
|
|
24
|
+
self._worker = MultiprocessWorker()
|
|
25
|
+
else:
|
|
26
|
+
raise ValueError(f"Unknown worker backend: {backend}")
|
|
27
|
+
|
|
28
|
+
logger.info(f"WorkerManager using backend: {self.backend}")
|
|
29
|
+
|
|
30
|
+
async def start(self):
|
|
31
|
+
await self._worker.start()
|
|
32
|
+
|
|
33
|
+
async def send_query(
|
|
34
|
+
self,
|
|
35
|
+
session_id: str,
|
|
36
|
+
payload: dict,
|
|
37
|
+
base_url: str,
|
|
38
|
+
poll_interval: float = 2.0,
|
|
39
|
+
poll_timeout: float = 600.0,
|
|
40
|
+
callback: Optional[Callable] = None,
|
|
41
|
+
) -> str:
|
|
42
|
+
return await self._worker.send_query(
|
|
43
|
+
session_id=session_id,
|
|
44
|
+
payload=payload,
|
|
45
|
+
base_url=base_url,
|
|
46
|
+
poll_interval=poll_interval,
|
|
47
|
+
poll_timeout=poll_timeout,
|
|
48
|
+
callback=callback,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
async def connect(self, base_url: str):
|
|
52
|
+
await self._worker.connect(base_url)
|
|
53
|
+
|
|
54
|
+
async def shutdown(self):
|
|
55
|
+
await self._worker.shutdown()
|
|
56
|
+
|
|
57
|
+
async def cancel_current(self):
|
|
58
|
+
"""Cancel the in-flight request by terminating the underlying worker.
|
|
59
|
+
|
|
60
|
+
Delegates to the active backend (subprocess or multiproc). Called by
|
|
61
|
+
the CLI SIGINT handler so Ctrl+C interrupts the actual model request,
|
|
62
|
+
not just the asyncio task wrapping it.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
await self._worker.cancel_current()
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.warning("WorkerManager.cancel_current error: %s", e)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_alive(self) -> bool:
|
|
71
|
+
return self._worker.is_alive
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import multiprocessing
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
|
|
10
|
+
from core.request_worker import worker_main
|
|
11
|
+
from core.worker_protocol import (
|
|
12
|
+
MSG_RESULT, MSG_UPDATE, MSG_ERROR, MSG_CANCELLED,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
HEARTBEAT_INTERVAL = 5.0
|
|
18
|
+
HEARTBEAT_TIMEOUT = 12.0
|
|
19
|
+
MAX_RESPAWN_DELAY = 30.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MultiprocessWorker:
|
|
23
|
+
"""Approach 2: Multiprocessing.Process + Queue worker.
|
|
24
|
+
Uses loop.run_in_executor for non-blocking result reads.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self.task_queue: multiprocessing.Queue = multiprocessing.Queue()
|
|
29
|
+
self.result_queue: multiprocessing.Queue = multiprocessing.Queue()
|
|
30
|
+
self.process: Optional[multiprocessing.Process] = None
|
|
31
|
+
self._running = False
|
|
32
|
+
self._spawn_count = 0
|
|
33
|
+
self._closed = False
|
|
34
|
+
|
|
35
|
+
async def start(self):
|
|
36
|
+
if self._running:
|
|
37
|
+
return
|
|
38
|
+
self._running = True
|
|
39
|
+
self._spawn_worker()
|
|
40
|
+
|
|
41
|
+
def _spawn_worker(self):
|
|
42
|
+
if self.process and self.process.is_alive():
|
|
43
|
+
self._kill_worker()
|
|
44
|
+
|
|
45
|
+
self._spawn_count += 1
|
|
46
|
+
self.process = multiprocessing.Process(
|
|
47
|
+
target=worker_main,
|
|
48
|
+
args=(self.task_queue, self.result_queue),
|
|
49
|
+
daemon=True,
|
|
50
|
+
name=f"request-worker-{self._spawn_count}",
|
|
51
|
+
)
|
|
52
|
+
self.process.start()
|
|
53
|
+
logger.info(f"Spawned multiproc worker (PID {self.process.pid}, spawn #{self._spawn_count})")
|
|
54
|
+
|
|
55
|
+
def _kill_worker(self):
|
|
56
|
+
if self.process and self.process.is_alive():
|
|
57
|
+
try:
|
|
58
|
+
if sys.platform == "win32":
|
|
59
|
+
self.process.terminate()
|
|
60
|
+
else:
|
|
61
|
+
os.kill(self.process.pid, signal.SIGKILL)
|
|
62
|
+
self.process.join(timeout=5)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.warning(f"Error killing worker: {e}")
|
|
65
|
+
finally:
|
|
66
|
+
self.process = None
|
|
67
|
+
|
|
68
|
+
def _on_worker_dead(self):
|
|
69
|
+
if self._closed:
|
|
70
|
+
return
|
|
71
|
+
logger.warning("Multiproc worker dead, respawning...")
|
|
72
|
+
self._kill_worker()
|
|
73
|
+
self._spawn_worker()
|
|
74
|
+
|
|
75
|
+
async def send_query(
|
|
76
|
+
self,
|
|
77
|
+
session_id: str,
|
|
78
|
+
payload: dict,
|
|
79
|
+
base_url: str,
|
|
80
|
+
poll_interval: float = 2.0,
|
|
81
|
+
poll_timeout: float = 600.0,
|
|
82
|
+
callback: Optional[Callable] = None,
|
|
83
|
+
) -> str:
|
|
84
|
+
task = {
|
|
85
|
+
"type": "query",
|
|
86
|
+
"session_id": session_id,
|
|
87
|
+
"payload": payload,
|
|
88
|
+
"base_url": base_url,
|
|
89
|
+
"poll_interval": poll_interval,
|
|
90
|
+
"poll_timeout": poll_timeout,
|
|
91
|
+
}
|
|
92
|
+
self.task_queue.put(task)
|
|
93
|
+
|
|
94
|
+
loop = asyncio.get_event_loop()
|
|
95
|
+
full_text = ""
|
|
96
|
+
deadline = time.monotonic() + poll_timeout
|
|
97
|
+
|
|
98
|
+
while time.monotonic() < deadline:
|
|
99
|
+
try:
|
|
100
|
+
result = await loop.run_in_executor(
|
|
101
|
+
None, self.result_queue.get, True, 5.0
|
|
102
|
+
)
|
|
103
|
+
except Exception:
|
|
104
|
+
if not self.process or not self.process.is_alive():
|
|
105
|
+
return "Error: Worker process died"
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
if not isinstance(result, dict):
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
msg_type = result.get("type")
|
|
112
|
+
if msg_type == MSG_UPDATE:
|
|
113
|
+
diff = result.get("content", "")
|
|
114
|
+
full_text = result.get("full_text", full_text + diff)
|
|
115
|
+
if callback and diff:
|
|
116
|
+
callback(full_text)
|
|
117
|
+
elif msg_type == MSG_RESULT:
|
|
118
|
+
content = result.get("content", "")
|
|
119
|
+
if callback and content:
|
|
120
|
+
callback(content)
|
|
121
|
+
return content
|
|
122
|
+
elif msg_type == MSG_ERROR:
|
|
123
|
+
return f"Error: {result.get('message', 'Unknown worker error')}"
|
|
124
|
+
elif msg_type == MSG_CANCELLED:
|
|
125
|
+
return "Error: Request was cancelled."
|
|
126
|
+
|
|
127
|
+
return "Error: Request timed out"
|
|
128
|
+
|
|
129
|
+
async def connect(self, base_url: str):
|
|
130
|
+
self.task_queue.put({"type": "connect", "base_url": base_url})
|
|
131
|
+
|
|
132
|
+
async def shutdown(self):
|
|
133
|
+
self._closed = True
|
|
134
|
+
self._running = False
|
|
135
|
+
try:
|
|
136
|
+
self.task_queue.put({"type": "shutdown"})
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
self._kill_worker()
|
|
140
|
+
|
|
141
|
+
async def cancel_current(self):
|
|
142
|
+
"""Cancel the in-flight request by killing and respawning the multiproc worker.
|
|
143
|
+
|
|
144
|
+
Mirrors AsyncSubprocessWorker.cancel_current() for the multiprocessing
|
|
145
|
+
backend so the SIGINT handler in CLI works uniformly.
|
|
146
|
+
"""
|
|
147
|
+
logger.info("Cancelling in-flight request by killing multiproc worker (PID %s)", self.process.pid if self.process else None)
|
|
148
|
+
self._kill_worker()
|
|
149
|
+
# Don't respawn immediately — let the next send_query call it via
|
|
150
|
+
# _on_worker_dead pattern, or let BotClient ensure a fresh one.
|
|
151
|
+
self._running = False
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def is_alive(self) -> bool:
|
|
155
|
+
return self.process is not None and self.process.is_alive()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
MSG_QUERY = "query"
|
|
7
|
+
MSG_RESULT = "result"
|
|
8
|
+
MSG_UPDATE = "update"
|
|
9
|
+
MSG_ERROR = "error"
|
|
10
|
+
MSG_CANCELLED = "cancelled"
|
|
11
|
+
MSG_CONNECT = "connect"
|
|
12
|
+
MSG_PING = "ping"
|
|
13
|
+
MSG_PONG = "pong"
|
|
14
|
+
MSG_SHUTDOWN = "shutdown"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def encode_msg(msg_id: int, msg_type: str, **kwargs) -> str:
|
|
18
|
+
data = {"id": msg_id, "type": msg_type, **kwargs}
|
|
19
|
+
return json.dumps(data, default=str) + "\n"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def decode_msg(line: str) -> dict | None:
|
|
23
|
+
line = line.strip().lstrip("\ufeff")
|
|
24
|
+
if not line:
|
|
25
|
+
return None
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(line)
|
|
28
|
+
except json.JSONDecodeError as e:
|
|
29
|
+
logger.warning("Failed to decode worker message: %s", e)
|
|
30
|
+
return None
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from typing import Callable, Optional
|
|
8
|
+
|
|
9
|
+
from core.worker_protocol import (
|
|
10
|
+
encode_msg, decode_msg, MSG_QUERY, MSG_RESULT, MSG_UPDATE,
|
|
11
|
+
MSG_ERROR, MSG_CANCELLED, MSG_CONNECT, MSG_SHUTDOWN,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
WORKER_SCRIPT = os.path.join(os.path.dirname(__file__), "request_worker.py")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AsyncSubprocessWorker:
|
|
20
|
+
"""Approach 1: Spawn worker via asyncio.create_subprocess_exec.
|
|
21
|
+
Communicates via JSON-RPC over stdin/stdout.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self._process: Optional[asyncio.subprocess.Process] = None
|
|
26
|
+
self._msg_id = 0
|
|
27
|
+
self._pending: dict[int, asyncio.Future] = {}
|
|
28
|
+
self._running = False
|
|
29
|
+
self._reader_task: Optional[asyncio.Task] = None
|
|
30
|
+
|
|
31
|
+
async def start(self):
|
|
32
|
+
if self._running:
|
|
33
|
+
return
|
|
34
|
+
self._running = True
|
|
35
|
+
|
|
36
|
+
python = sys.executable
|
|
37
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
38
|
+
python, WORKER_SCRIPT, "--mode", "stdio",
|
|
39
|
+
stdin=asyncio.subprocess.PIPE,
|
|
40
|
+
stdout=asyncio.subprocess.PIPE,
|
|
41
|
+
stderr=asyncio.subprocess.PIPE,
|
|
42
|
+
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
43
|
+
)
|
|
44
|
+
logger.info(f"Subprocess worker started (PID {self._process.pid})")
|
|
45
|
+
|
|
46
|
+
self._reader_task = asyncio.create_task(self._read_stdout())
|
|
47
|
+
|
|
48
|
+
async def _read_stdout(self):
|
|
49
|
+
while self._running and self._process and not self._process.stdout.at_eof():
|
|
50
|
+
line = await self._process.stdout.readline()
|
|
51
|
+
if not line:
|
|
52
|
+
break
|
|
53
|
+
msg = decode_msg(line.decode("utf-8", errors="replace"))
|
|
54
|
+
if not msg:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
msg_id = msg.get("id")
|
|
58
|
+
if msg_id in self._pending:
|
|
59
|
+
future = self._pending.pop(msg_id)
|
|
60
|
+
if not future.done():
|
|
61
|
+
future.set_result(msg)
|
|
62
|
+
|
|
63
|
+
logger.info("Subprocess worker stdout closed")
|
|
64
|
+
|
|
65
|
+
async def send_query(
|
|
66
|
+
self,
|
|
67
|
+
session_id: str,
|
|
68
|
+
payload: dict,
|
|
69
|
+
base_url: str,
|
|
70
|
+
poll_interval: float = 2.0,
|
|
71
|
+
poll_timeout: float = 600.0,
|
|
72
|
+
callback: Optional[Callable] = None,
|
|
73
|
+
) -> str:
|
|
74
|
+
self._msg_id += 1
|
|
75
|
+
msg_id = self._msg_id
|
|
76
|
+
future: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
77
|
+
self._pending[msg_id] = future
|
|
78
|
+
|
|
79
|
+
cmd = {
|
|
80
|
+
"id": msg_id,
|
|
81
|
+
"type": MSG_QUERY,
|
|
82
|
+
"session_id": session_id,
|
|
83
|
+
"payload": payload,
|
|
84
|
+
"base_url": base_url,
|
|
85
|
+
"poll_interval": poll_interval,
|
|
86
|
+
"poll_timeout": poll_timeout,
|
|
87
|
+
}
|
|
88
|
+
await self._write_cmd(cmd)
|
|
89
|
+
|
|
90
|
+
full_text = ""
|
|
91
|
+
deadline = time.monotonic() + poll_timeout
|
|
92
|
+
while time.monotonic() < deadline:
|
|
93
|
+
try:
|
|
94
|
+
result = await asyncio.wait_for(
|
|
95
|
+
asyncio.shield(future), timeout=5.0
|
|
96
|
+
)
|
|
97
|
+
except asyncio.TimeoutError:
|
|
98
|
+
if msg_id in self._pending:
|
|
99
|
+
future = self._pending[msg_id]
|
|
100
|
+
if future.done():
|
|
101
|
+
result = future.result()
|
|
102
|
+
else:
|
|
103
|
+
continue
|
|
104
|
+
else:
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
msg_type = result.get("type")
|
|
108
|
+
if msg_type == MSG_UPDATE:
|
|
109
|
+
diff = result.get("content", "")
|
|
110
|
+
full_text = result.get("full_text", full_text + diff)
|
|
111
|
+
if callback:
|
|
112
|
+
callback(full_text)
|
|
113
|
+
new_future = asyncio.get_event_loop().create_future()
|
|
114
|
+
self._pending[msg_id] = new_future
|
|
115
|
+
future = new_future
|
|
116
|
+
elif msg_type == MSG_RESULT:
|
|
117
|
+
content = result.get("content", "")
|
|
118
|
+
if callback:
|
|
119
|
+
callback(content)
|
|
120
|
+
return content
|
|
121
|
+
elif msg_type == MSG_ERROR:
|
|
122
|
+
return f"Error: {result.get('message', 'Unknown worker error')}"
|
|
123
|
+
elif msg_type == MSG_CANCELLED:
|
|
124
|
+
return "Error: Request was cancelled."
|
|
125
|
+
else:
|
|
126
|
+
if msg_id in self._pending:
|
|
127
|
+
del self._pending[msg_id]
|
|
128
|
+
return full_text or "Error: Unexpected response"
|
|
129
|
+
|
|
130
|
+
if msg_id in self._pending:
|
|
131
|
+
del self._pending[msg_id]
|
|
132
|
+
return full_text or "Error: Request timed out"
|
|
133
|
+
|
|
134
|
+
async def connect(self, base_url: str):
|
|
135
|
+
self._msg_id += 1
|
|
136
|
+
cmd = {"id": self._msg_id, "type": MSG_CONNECT, "base_url": base_url}
|
|
137
|
+
await self._write_cmd(cmd)
|
|
138
|
+
|
|
139
|
+
async def _write_cmd(self, cmd: dict):
|
|
140
|
+
line = json.dumps(cmd, default=str) + "\n"
|
|
141
|
+
if self._process and self._process.stdin:
|
|
142
|
+
self._process.stdin.write(line.encode("utf-8"))
|
|
143
|
+
await self._process.stdin.drain()
|
|
144
|
+
|
|
145
|
+
async def shutdown(self):
|
|
146
|
+
self._running = False
|
|
147
|
+
try:
|
|
148
|
+
if self._process:
|
|
149
|
+
# Close stdin first to signal EOF to worker (graceful path)
|
|
150
|
+
try:
|
|
151
|
+
if self._process.stdin and not self._process.stdin.is_closing():
|
|
152
|
+
self._process.stdin.close()
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
# Send shutdown command (may fail if stdin already closed)
|
|
156
|
+
try:
|
|
157
|
+
await self._write_cmd({"type": MSG_SHUTDOWN})
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
try:
|
|
161
|
+
await asyncio.wait_for(self._process.wait(), timeout=5.0)
|
|
162
|
+
except asyncio.TimeoutError:
|
|
163
|
+
self._process.kill()
|
|
164
|
+
await self._process.wait()
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.warning(f"Subprocess worker shutdown error: {e}")
|
|
167
|
+
finally:
|
|
168
|
+
if self._reader_task:
|
|
169
|
+
self._reader_task.cancel()
|
|
170
|
+
try:
|
|
171
|
+
await self._reader_task
|
|
172
|
+
except (asyncio.CancelledError, Exception):
|
|
173
|
+
pass
|
|
174
|
+
self._reader_task = None
|
|
175
|
+
# Explicitly close all pipe transports to prevent ResourceWarning
|
|
176
|
+
# when the proactor loop tears them down at GC time. On Windows
|
|
177
|
+
# these are `_ProactorBasePipeTransport` whose __del__ tries
|
|
178
|
+
# `fileno()` on a closed pipe and raises ValueError.
|
|
179
|
+
if self._process is not None:
|
|
180
|
+
for pipe_name in ('stdin', 'stdout', 'stderr'):
|
|
181
|
+
pipe = getattr(self._process, pipe_name, None)
|
|
182
|
+
if pipe is not None:
|
|
183
|
+
try:
|
|
184
|
+
transport = getattr(pipe, '_transport', None) or pipe
|
|
185
|
+
if hasattr(transport, 'close'):
|
|
186
|
+
transport.close()
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
self._process = None
|
|
190
|
+
|
|
191
|
+
async def cancel_current(self):
|
|
192
|
+
"""Cancel the in-flight request by killing and respawning the subprocess.
|
|
193
|
+
|
|
194
|
+
Used by SIGINT handler in CLI when user presses Ctrl+C. The pending
|
|
195
|
+
future in self._pending will never resolve (subprocess is dead), so
|
|
196
|
+
the awaiter will hit its timeout and propagate the cancellation.
|
|
197
|
+
"""
|
|
198
|
+
if not self._process or self._process.returncode is not None:
|
|
199
|
+
return
|
|
200
|
+
logger.info("Cancelling in-flight request by terminating subprocess (PID %s)", self._process.pid)
|
|
201
|
+
try:
|
|
202
|
+
self._process.kill()
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.warning("Error killing subprocess: %s", e)
|
|
205
|
+
# Mark all pending futures as cancelled so awaiters unblock immediately
|
|
206
|
+
for msg_id, future in list(self._pending.items()):
|
|
207
|
+
if not future.done():
|
|
208
|
+
future.cancel()
|
|
209
|
+
self._pending.pop(msg_id, None)
|
|
210
|
+
# Wait for the process to actually die, then respawn so the next
|
|
211
|
+
# request gets a fresh worker (the old one is in a bad state).
|
|
212
|
+
try:
|
|
213
|
+
await asyncio.wait_for(self._process.wait(), timeout=2.0)
|
|
214
|
+
except asyncio.TimeoutError:
|
|
215
|
+
pass
|
|
216
|
+
self._process = None
|
|
217
|
+
self._running = False
|
|
218
|
+
# The next send_query() will trigger a fresh start() via BotClient.ensure_worker()
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def is_alive(self) -> bool:
|
|
222
|
+
return self._process is not None and self._process.returncode is None
|
|
File without changes
|
|
Binary file
|
|
Binary file
|