ripperdoc 0.2.9__py3-none-any.whl → 0.3.0__py3-none-any.whl
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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +379 -51
- ripperdoc/cli/commands/__init__.py +6 -0
- ripperdoc/cli/commands/agents_cmd.py +128 -5
- ripperdoc/cli/commands/clear_cmd.py +8 -0
- ripperdoc/cli/commands/doctor_cmd.py +29 -0
- ripperdoc/cli/commands/exit_cmd.py +1 -0
- ripperdoc/cli/commands/memory_cmd.py +2 -1
- ripperdoc/cli/commands/models_cmd.py +63 -7
- ripperdoc/cli/commands/resume_cmd.py +5 -0
- ripperdoc/cli/commands/skills_cmd.py +103 -0
- ripperdoc/cli/commands/stats_cmd.py +244 -0
- ripperdoc/cli/commands/status_cmd.py +10 -0
- ripperdoc/cli/commands/tasks_cmd.py +6 -3
- ripperdoc/cli/commands/themes_cmd.py +139 -0
- ripperdoc/cli/ui/file_mention_completer.py +63 -13
- ripperdoc/cli/ui/helpers.py +6 -3
- ripperdoc/cli/ui/interrupt_handler.py +34 -0
- ripperdoc/cli/ui/panels.py +14 -8
- ripperdoc/cli/ui/rich_ui.py +737 -47
- ripperdoc/cli/ui/spinner.py +93 -18
- ripperdoc/cli/ui/thinking_spinner.py +1 -2
- ripperdoc/cli/ui/tool_renderers.py +10 -9
- ripperdoc/cli/ui/wizard.py +24 -19
- ripperdoc/core/agents.py +14 -3
- ripperdoc/core/config.py +238 -6
- ripperdoc/core/default_tools.py +91 -10
- ripperdoc/core/hooks/events.py +4 -0
- ripperdoc/core/hooks/llm_callback.py +58 -0
- ripperdoc/core/hooks/manager.py +6 -0
- ripperdoc/core/permissions.py +160 -9
- ripperdoc/core/providers/openai.py +84 -28
- ripperdoc/core/query.py +489 -87
- ripperdoc/core/query_utils.py +17 -14
- ripperdoc/core/skills.py +1 -0
- ripperdoc/core/theme.py +298 -0
- ripperdoc/core/tool.py +15 -5
- ripperdoc/protocol/__init__.py +14 -0
- ripperdoc/protocol/models.py +300 -0
- ripperdoc/protocol/stdio.py +1453 -0
- ripperdoc/tools/background_shell.py +354 -139
- ripperdoc/tools/bash_tool.py +117 -22
- ripperdoc/tools/file_edit_tool.py +228 -50
- ripperdoc/tools/file_read_tool.py +154 -3
- ripperdoc/tools/file_write_tool.py +53 -11
- ripperdoc/tools/grep_tool.py +98 -8
- ripperdoc/tools/lsp_tool.py +609 -0
- ripperdoc/tools/multi_edit_tool.py +26 -3
- ripperdoc/tools/skill_tool.py +52 -1
- ripperdoc/tools/task_tool.py +539 -65
- ripperdoc/utils/conversation_compaction.py +1 -1
- ripperdoc/utils/file_watch.py +216 -7
- ripperdoc/utils/image_utils.py +125 -0
- ripperdoc/utils/log.py +30 -3
- ripperdoc/utils/lsp.py +812 -0
- ripperdoc/utils/mcp.py +80 -18
- ripperdoc/utils/message_formatting.py +7 -4
- ripperdoc/utils/messages.py +198 -33
- ripperdoc/utils/pending_messages.py +50 -0
- ripperdoc/utils/permissions/shell_command_validation.py +3 -3
- ripperdoc/utils/permissions/tool_permission_utils.py +180 -15
- ripperdoc/utils/platform.py +198 -0
- ripperdoc/utils/session_heatmap.py +242 -0
- ripperdoc/utils/session_history.py +2 -2
- ripperdoc/utils/session_stats.py +294 -0
- ripperdoc/utils/shell_utils.py +8 -5
- ripperdoc/utils/todo.py +0 -6
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +55 -17
- ripperdoc-0.3.0.dist-info/RECORD +136 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
- ripperdoc/sdk/__init__.py +0 -9
- ripperdoc/sdk/client.py +0 -333
- ripperdoc-0.2.9.dist-info/RECORD +0 -123
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -8,11 +8,13 @@ via the KillBash tool.
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import concurrent.futures
|
|
10
10
|
import contextlib
|
|
11
|
+
import os
|
|
11
12
|
import threading
|
|
12
13
|
import time
|
|
13
14
|
import uuid
|
|
15
|
+
import weakref
|
|
14
16
|
from dataclasses import dataclass, field
|
|
15
|
-
from typing import Any, Dict, List, Optional
|
|
17
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
16
18
|
|
|
17
19
|
import atexit
|
|
18
20
|
|
|
@@ -32,6 +34,7 @@ class BackgroundTask:
|
|
|
32
34
|
process: asyncio.subprocess.Process
|
|
33
35
|
start_time: float
|
|
34
36
|
timeout: Optional[float] = None
|
|
37
|
+
end_time: Optional[float] = None
|
|
35
38
|
stdout_chunks: List[str] = field(default_factory=list)
|
|
36
39
|
stderr_chunks: List[str] = field(default_factory=list)
|
|
37
40
|
exit_code: Optional[int] = None
|
|
@@ -39,69 +42,277 @@ class BackgroundTask:
|
|
|
39
42
|
timed_out: bool = False
|
|
40
43
|
reader_tasks: List[asyncio.Task] = field(default_factory=list)
|
|
41
44
|
done_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
45
|
+
completion_callbacks: List[Callable[["BackgroundTask"], None]] = field(default_factory=list)
|
|
42
46
|
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
_tasks_lock = threading.Lock()
|
|
46
|
-
_background_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
47
|
-
_background_thread: Optional[threading.Thread] = None
|
|
48
|
-
_loop_lock = threading.Lock()
|
|
49
|
-
_shutdown_registered = False
|
|
48
|
+
DEFAULT_TASK_TTL_SEC = float(os.getenv("RIPPERDOC_BASH_TASK_TTL_SEC", "3600"))
|
|
50
49
|
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
"""
|
|
54
|
-
try:
|
|
55
|
-
logger.exception(message, extra=extra)
|
|
56
|
-
except (OSError, RuntimeError, ValueError):
|
|
57
|
-
pass
|
|
51
|
+
class BackgroundShellManager:
|
|
52
|
+
"""Manager for background shell tasks with proper lifecycle control.
|
|
58
53
|
|
|
54
|
+
This class encapsulates all global state for background shell management,
|
|
55
|
+
providing better testability and proper resource cleanup.
|
|
56
|
+
"""
|
|
59
57
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
_instance: Optional["BackgroundShellManager"] = None
|
|
59
|
+
_instance_lock = threading.Lock()
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
"""Initialize the manager. Use get_instance() for singleton access."""
|
|
63
|
+
self._tasks: Dict[str, BackgroundTask] = {}
|
|
64
|
+
self._tasks_lock = threading.Lock()
|
|
65
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
66
|
+
self._thread: Optional[threading.Thread] = None
|
|
67
|
+
self._loop_lock = threading.Lock()
|
|
68
|
+
self._shutdown_event = threading.Event()
|
|
69
|
+
self._shutdown_registered = False
|
|
70
|
+
self._is_shutting_down = False
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def get_instance(cls) -> "BackgroundShellManager":
|
|
74
|
+
"""Get or create the singleton instance."""
|
|
75
|
+
if cls._instance is None:
|
|
76
|
+
with cls._instance_lock:
|
|
77
|
+
if cls._instance is None:
|
|
78
|
+
cls._instance = cls()
|
|
79
|
+
return cls._instance
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def reset_instance(cls) -> None:
|
|
83
|
+
"""Reset the singleton instance. Useful for testing.
|
|
84
|
+
|
|
85
|
+
This method shuts down the current instance and clears it,
|
|
86
|
+
allowing a fresh instance to be created on next access.
|
|
87
|
+
"""
|
|
88
|
+
with cls._instance_lock:
|
|
89
|
+
if cls._instance is not None:
|
|
90
|
+
cls._instance.shutdown()
|
|
91
|
+
cls._instance = None
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def _set_instance_for_testing(cls, instance: Optional["BackgroundShellManager"]) -> None:
|
|
95
|
+
"""Set a custom instance for testing purposes."""
|
|
96
|
+
with cls._instance_lock:
|
|
97
|
+
cls._instance = instance
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def tasks(self) -> Dict[str, BackgroundTask]:
|
|
101
|
+
"""Access to tasks dict (for internal use)."""
|
|
102
|
+
return self._tasks
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def tasks_lock(self) -> threading.Lock:
|
|
106
|
+
"""Access to tasks lock (for internal use)."""
|
|
107
|
+
return self._tasks_lock
|
|
108
|
+
|
|
109
|
+
def ensure_loop(self) -> asyncio.AbstractEventLoop:
|
|
110
|
+
"""Create (or return) a dedicated loop for background processes."""
|
|
111
|
+
if self._loop and self._loop.is_running():
|
|
112
|
+
return self._loop
|
|
113
|
+
|
|
114
|
+
with self._loop_lock:
|
|
115
|
+
if self._loop and self._loop.is_running():
|
|
116
|
+
return self._loop
|
|
117
|
+
|
|
118
|
+
loop = asyncio.new_event_loop()
|
|
119
|
+
ready = threading.Event()
|
|
120
|
+
shutdown_event = self._shutdown_event
|
|
121
|
+
|
|
122
|
+
def _run_loop() -> None:
|
|
123
|
+
asyncio.set_event_loop(loop)
|
|
124
|
+
ready.set()
|
|
125
|
+
try:
|
|
126
|
+
loop.run_forever()
|
|
127
|
+
finally:
|
|
128
|
+
# Ensure cleanup happens even if loop is stopped abruptly
|
|
129
|
+
shutdown_event.set()
|
|
130
|
+
|
|
131
|
+
# Use non-daemon thread to ensure atexit handlers can complete
|
|
132
|
+
thread = threading.Thread(
|
|
133
|
+
target=_run_loop,
|
|
134
|
+
name="ripperdoc-bg-loop",
|
|
135
|
+
daemon=False, # Non-daemon for proper shutdown
|
|
136
|
+
)
|
|
137
|
+
thread.start()
|
|
138
|
+
ready.wait()
|
|
139
|
+
|
|
140
|
+
self._loop = loop
|
|
141
|
+
self._thread = thread
|
|
142
|
+
self._register_shutdown_hook()
|
|
143
|
+
return loop
|
|
144
|
+
|
|
145
|
+
def _register_shutdown_hook(self) -> None:
|
|
146
|
+
"""Register atexit handler for cleanup."""
|
|
147
|
+
if self._shutdown_registered:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
# Use weakref to avoid preventing garbage collection
|
|
151
|
+
manager_ref = weakref.ref(self)
|
|
152
|
+
|
|
153
|
+
def _shutdown_callback() -> None:
|
|
154
|
+
manager = manager_ref()
|
|
155
|
+
if manager is not None:
|
|
156
|
+
manager.shutdown()
|
|
157
|
+
|
|
158
|
+
atexit.register(_shutdown_callback)
|
|
159
|
+
self._shutdown_registered = True
|
|
160
|
+
|
|
161
|
+
def submit_to_loop(self, coro: Any) -> concurrent.futures.Future:
|
|
162
|
+
"""Run a coroutine on the background loop and return a thread-safe future."""
|
|
163
|
+
loop = self.ensure_loop()
|
|
164
|
+
return asyncio.run_coroutine_threadsafe(coro, loop)
|
|
165
|
+
|
|
166
|
+
def shutdown(self, force: bool = False) -> None:
|
|
167
|
+
"""Stop background tasks/loop to avoid resource leaks.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
force: If True, use minimal timeouts for faster exit.
|
|
171
|
+
"""
|
|
172
|
+
if self._is_shutting_down:
|
|
173
|
+
# If already shutting down and force is requested, just mark done
|
|
174
|
+
if force:
|
|
175
|
+
self._shutdown_event.set()
|
|
176
|
+
return
|
|
177
|
+
self._is_shutting_down = True
|
|
178
|
+
|
|
179
|
+
loop = self._loop
|
|
180
|
+
thread = self._thread
|
|
181
|
+
|
|
182
|
+
if not loop or loop.is_closed():
|
|
183
|
+
self._loop = None
|
|
184
|
+
self._thread = None
|
|
185
|
+
self._is_shutting_down = False
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
# Use shorter timeouts for faster exit
|
|
189
|
+
async_timeout = 0.5 if force else 2.0
|
|
190
|
+
join_timeout = 0.5 if force else 1.0
|
|
63
191
|
|
|
64
|
-
|
|
65
|
-
|
|
192
|
+
try:
|
|
193
|
+
if loop.is_running():
|
|
194
|
+
try:
|
|
195
|
+
fut = asyncio.run_coroutine_threadsafe(
|
|
196
|
+
self._shutdown_loop_async(loop, force=force), loop
|
|
197
|
+
)
|
|
198
|
+
fut.result(timeout=async_timeout)
|
|
199
|
+
except (RuntimeError, TimeoutError, concurrent.futures.TimeoutError):
|
|
200
|
+
logger.debug("Failed to cleanly shutdown background loop", exc_info=True)
|
|
201
|
+
try:
|
|
202
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
203
|
+
except (RuntimeError, OSError):
|
|
204
|
+
logger.debug("Failed to stop background loop", exc_info=True)
|
|
205
|
+
else:
|
|
206
|
+
# If loop isn't running, try to run cleanup synchronously
|
|
207
|
+
try:
|
|
208
|
+
loop.run_until_complete(self._shutdown_loop_async(loop, force=force))
|
|
209
|
+
except RuntimeError:
|
|
210
|
+
pass # Loop may already be closed
|
|
211
|
+
finally:
|
|
212
|
+
if thread and thread.is_alive():
|
|
213
|
+
thread.join(timeout=join_timeout)
|
|
214
|
+
# If thread is still alive after timeout, don't block further
|
|
215
|
+
if thread.is_alive():
|
|
216
|
+
logger.debug("Background thread did not stop in time, continuing shutdown")
|
|
217
|
+
with contextlib.suppress(Exception):
|
|
218
|
+
if not loop.is_closed():
|
|
219
|
+
loop.close()
|
|
220
|
+
self._loop = None
|
|
221
|
+
self._thread = None
|
|
222
|
+
self._shutdown_event.set()
|
|
223
|
+
self._is_shutting_down = False
|
|
224
|
+
|
|
225
|
+
async def _shutdown_loop_async(
|
|
226
|
+
self, loop: asyncio.AbstractEventLoop, force: bool = False
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Drain running background processes before stopping the loop.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
loop: The event loop to shutdown.
|
|
232
|
+
force: If True, use minimal timeouts for faster exit.
|
|
233
|
+
"""
|
|
234
|
+
with self._tasks_lock:
|
|
235
|
+
tasks = list(self._tasks.values())
|
|
236
|
+
self._tasks.clear()
|
|
237
|
+
|
|
238
|
+
# Use shorter timeouts when force is True
|
|
239
|
+
wait_timeout = 0.3 if force else 1.5
|
|
240
|
+
kill_timeout = 0.2 if force else 0.5
|
|
241
|
+
|
|
242
|
+
for task in tasks:
|
|
243
|
+
try:
|
|
244
|
+
task.killed = True
|
|
245
|
+
with contextlib.suppress(ProcessLookupError):
|
|
246
|
+
task.process.kill()
|
|
247
|
+
try:
|
|
248
|
+
with contextlib.suppress(ProcessLookupError):
|
|
249
|
+
await asyncio.wait_for(task.process.wait(), timeout=wait_timeout)
|
|
250
|
+
except asyncio.TimeoutError:
|
|
251
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
252
|
+
task.process.kill()
|
|
253
|
+
with contextlib.suppress(asyncio.TimeoutError, ProcessLookupError):
|
|
254
|
+
await asyncio.wait_for(task.process.wait(), timeout=kill_timeout)
|
|
255
|
+
task.exit_code = task.process.returncode or -1
|
|
256
|
+
task.end_time = task.end_time or _loop_time()
|
|
257
|
+
except (OSError, RuntimeError, asyncio.CancelledError) as exc:
|
|
258
|
+
if not isinstance(exc, asyncio.CancelledError):
|
|
259
|
+
_safe_log_exception(
|
|
260
|
+
"Error shutting down background task",
|
|
261
|
+
task_id=task.id,
|
|
262
|
+
command=task.command,
|
|
263
|
+
)
|
|
264
|
+
finally:
|
|
265
|
+
await _finalize_reader_tasks(task.reader_tasks, timeout=0.3 if force else 1.0)
|
|
266
|
+
task.done_event.set()
|
|
267
|
+
_run_completion_callbacks(task)
|
|
268
|
+
|
|
269
|
+
current = asyncio.current_task()
|
|
270
|
+
pending = [t for t in asyncio.all_tasks(loop) if t is not current]
|
|
271
|
+
for pending_task in pending:
|
|
272
|
+
pending_task.cancel()
|
|
273
|
+
if pending:
|
|
274
|
+
with contextlib.suppress(Exception):
|
|
275
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
66
276
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return _background_loop
|
|
277
|
+
with contextlib.suppress(Exception):
|
|
278
|
+
await loop.shutdown_asyncgens()
|
|
70
279
|
|
|
71
|
-
loop = asyncio.new_event_loop()
|
|
72
|
-
ready = threading.Event()
|
|
73
280
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
ready.set()
|
|
77
|
-
loop.run_forever()
|
|
281
|
+
# Module-level functions that delegate to the singleton manager
|
|
282
|
+
# These maintain backward compatibility with existing code
|
|
78
283
|
|
|
79
|
-
thread = threading.Thread(
|
|
80
|
-
target=_run_loop,
|
|
81
|
-
name="ripperdoc-bg-loop",
|
|
82
|
-
daemon=True,
|
|
83
|
-
)
|
|
84
|
-
thread.start()
|
|
85
|
-
ready.wait()
|
|
86
284
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return loop
|
|
285
|
+
def _get_manager() -> BackgroundShellManager:
|
|
286
|
+
"""Get the singleton manager instance."""
|
|
287
|
+
return BackgroundShellManager.get_instance()
|
|
91
288
|
|
|
92
289
|
|
|
93
|
-
def
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
290
|
+
def _get_tasks_lock() -> threading.Lock:
|
|
291
|
+
"""Get the tasks lock from the manager."""
|
|
292
|
+
return _get_manager().tasks_lock
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _get_tasks() -> Dict[str, BackgroundTask]:
|
|
296
|
+
"""Get the tasks dict from the manager."""
|
|
297
|
+
return _get_manager().tasks
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _safe_log_exception(message: str, **extra: Any) -> None:
|
|
301
|
+
"""Log an exception but never let logging failures bubble up."""
|
|
302
|
+
try:
|
|
303
|
+
logger.exception(message, extra=extra)
|
|
304
|
+
except (OSError, RuntimeError, ValueError):
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _ensure_background_loop() -> asyncio.AbstractEventLoop:
|
|
309
|
+
"""Create (or return) a dedicated loop for background processes."""
|
|
310
|
+
return _get_manager().ensure_loop()
|
|
99
311
|
|
|
100
312
|
|
|
101
313
|
def _submit_to_background_loop(coro: Any) -> concurrent.futures.Future:
|
|
102
314
|
"""Run a coroutine on the background loop and return a thread-safe future."""
|
|
103
|
-
|
|
104
|
-
return asyncio.run_coroutine_threadsafe(coro, loop)
|
|
315
|
+
return _get_manager().submit_to_loop(coro)
|
|
105
316
|
|
|
106
317
|
|
|
107
318
|
async def _pump_stream(stream: asyncio.StreamReader, sink: List[str]) -> None:
|
|
@@ -112,7 +323,7 @@ async def _pump_stream(stream: asyncio.StreamReader, sink: List[str]) -> None:
|
|
|
112
323
|
if not chunk:
|
|
113
324
|
break
|
|
114
325
|
text = chunk.decode("utf-8", errors="replace")
|
|
115
|
-
with
|
|
326
|
+
with _get_tasks_lock():
|
|
116
327
|
sink.append(text)
|
|
117
328
|
except (OSError, RuntimeError, asyncio.CancelledError) as exc:
|
|
118
329
|
if isinstance(exc, asyncio.CancelledError):
|
|
@@ -140,6 +351,21 @@ async def _finalize_reader_tasks(reader_tasks: List[asyncio.Task], timeout: floa
|
|
|
140
351
|
await asyncio.gather(*reader_tasks, return_exceptions=True)
|
|
141
352
|
|
|
142
353
|
|
|
354
|
+
def _run_completion_callbacks(task: BackgroundTask) -> None:
|
|
355
|
+
"""Invoke completion callbacks safely for a finished task."""
|
|
356
|
+
if not task.completion_callbacks:
|
|
357
|
+
return
|
|
358
|
+
for callback in list(task.completion_callbacks):
|
|
359
|
+
try:
|
|
360
|
+
callback(task)
|
|
361
|
+
except Exception:
|
|
362
|
+
logger.debug(
|
|
363
|
+
"Background task completion callback failed",
|
|
364
|
+
exc_info=True,
|
|
365
|
+
extra={"task_id": task.id, "command": task.command},
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
143
369
|
async def _monitor_task(task: BackgroundTask) -> None:
|
|
144
370
|
"""Wait for a background process to finish or timeout, then mark status."""
|
|
145
371
|
try:
|
|
@@ -147,16 +373,18 @@ async def _monitor_task(task: BackgroundTask) -> None:
|
|
|
147
373
|
await asyncio.wait_for(task.process.wait(), timeout=task.timeout)
|
|
148
374
|
else:
|
|
149
375
|
await task.process.wait()
|
|
150
|
-
with
|
|
376
|
+
with _get_tasks_lock():
|
|
151
377
|
task.exit_code = task.process.returncode
|
|
378
|
+
task.end_time = task.end_time or _loop_time()
|
|
152
379
|
except asyncio.TimeoutError:
|
|
153
380
|
logger.warning(f"Background task {task.id} timed out after {task.timeout}s: {task.command}")
|
|
154
|
-
with
|
|
381
|
+
with _get_tasks_lock():
|
|
155
382
|
task.timed_out = True
|
|
156
383
|
task.process.kill()
|
|
157
384
|
await task.process.wait()
|
|
158
|
-
with
|
|
385
|
+
with _get_tasks_lock():
|
|
159
386
|
task.exit_code = -1
|
|
387
|
+
task.end_time = task.end_time or _loop_time()
|
|
160
388
|
except asyncio.CancelledError:
|
|
161
389
|
return
|
|
162
390
|
except (OSError, RuntimeError, ProcessLookupError) as exc:
|
|
@@ -166,16 +394,21 @@ async def _monitor_task(task: BackgroundTask) -> None:
|
|
|
166
394
|
exc,
|
|
167
395
|
extra={"task_id": task.id, "command": task.command},
|
|
168
396
|
)
|
|
169
|
-
with
|
|
397
|
+
with _get_tasks_lock():
|
|
170
398
|
task.exit_code = -1
|
|
399
|
+
task.end_time = task.end_time or _loop_time()
|
|
171
400
|
finally:
|
|
172
401
|
# Ensure readers are finished before marking done.
|
|
173
402
|
await _finalize_reader_tasks(task.reader_tasks)
|
|
174
403
|
task.done_event.set()
|
|
404
|
+
_run_completion_callbacks(task)
|
|
175
405
|
|
|
176
406
|
|
|
177
407
|
async def _start_background_command(
|
|
178
|
-
command: str,
|
|
408
|
+
command: str,
|
|
409
|
+
timeout: Optional[float] = None,
|
|
410
|
+
shell_executable: Optional[str] = None,
|
|
411
|
+
completion_callbacks: Optional[List[Callable[["BackgroundTask"], None]]] = None,
|
|
179
412
|
) -> str:
|
|
180
413
|
"""Launch a background shell command on the dedicated loop."""
|
|
181
414
|
selected_shell = shell_executable or find_suitable_shell()
|
|
@@ -195,9 +428,10 @@ async def _start_background_command(
|
|
|
195
428
|
process=process,
|
|
196
429
|
start_time=_loop_time(),
|
|
197
430
|
timeout=timeout,
|
|
431
|
+
completion_callbacks=list(completion_callbacks or []),
|
|
198
432
|
)
|
|
199
|
-
with
|
|
200
|
-
|
|
433
|
+
with _get_tasks_lock():
|
|
434
|
+
_get_tasks()[task_id] = record
|
|
201
435
|
|
|
202
436
|
# Start stream pumps and monitor task.
|
|
203
437
|
if process.stdout:
|
|
@@ -214,11 +448,16 @@ async def _start_background_command(
|
|
|
214
448
|
|
|
215
449
|
|
|
216
450
|
async def start_background_command(
|
|
217
|
-
command: str,
|
|
451
|
+
command: str,
|
|
452
|
+
timeout: Optional[float] = None,
|
|
453
|
+
shell_executable: Optional[str] = None,
|
|
454
|
+
completion_callbacks: Optional[List[Callable[["BackgroundTask"], None]]] = None,
|
|
218
455
|
) -> str:
|
|
219
456
|
"""Launch a background shell command and return its task id."""
|
|
220
457
|
future = _submit_to_background_loop(
|
|
221
|
-
_start_background_command(
|
|
458
|
+
_start_background_command(
|
|
459
|
+
command, timeout, shell_executable, completion_callbacks=completion_callbacks
|
|
460
|
+
)
|
|
222
461
|
)
|
|
223
462
|
return await asyncio.wrap_future(future)
|
|
224
463
|
|
|
@@ -247,14 +486,24 @@ def get_background_status(task_id: str, consume: bool = True) -> dict:
|
|
|
247
486
|
|
|
248
487
|
If consume is True, buffered stdout/stderr are cleared after reading.
|
|
249
488
|
"""
|
|
250
|
-
|
|
251
|
-
|
|
489
|
+
now = _loop_time()
|
|
490
|
+
tasks = _get_tasks()
|
|
491
|
+
with _get_tasks_lock():
|
|
492
|
+
if task_id not in tasks:
|
|
252
493
|
raise KeyError(f"No background task found with id '{task_id}'")
|
|
253
494
|
|
|
254
|
-
task =
|
|
495
|
+
task = tasks[task_id]
|
|
255
496
|
stdout = "".join(task.stdout_chunks)
|
|
256
497
|
stderr = "".join(task.stderr_chunks)
|
|
257
498
|
|
|
499
|
+
finished = task.exit_code is not None or task.killed or task.timed_out
|
|
500
|
+
if finished and task.end_time is None:
|
|
501
|
+
task.end_time = now
|
|
502
|
+
duration_ms = (
|
|
503
|
+
((task.end_time or now) - task.start_time) * 1000.0 if task.start_time else None
|
|
504
|
+
)
|
|
505
|
+
age_ms = (now - task.start_time) * 1000.0 if task.start_time else None
|
|
506
|
+
|
|
258
507
|
if consume:
|
|
259
508
|
task.stdout_chunks.clear()
|
|
260
509
|
task.stderr_chunks.clear()
|
|
@@ -268,7 +517,8 @@ def get_background_status(task_id: str, consume: bool = True) -> dict:
|
|
|
268
517
|
"exit_code": task.exit_code,
|
|
269
518
|
"timed_out": task.timed_out,
|
|
270
519
|
"killed": task.killed,
|
|
271
|
-
"duration_ms":
|
|
520
|
+
"duration_ms": duration_ms,
|
|
521
|
+
"age_ms": age_ms,
|
|
272
522
|
}
|
|
273
523
|
|
|
274
524
|
|
|
@@ -277,8 +527,9 @@ async def kill_background_task(task_id: str) -> bool:
|
|
|
277
527
|
KILL_WAIT_SECONDS = 2.0
|
|
278
528
|
|
|
279
529
|
async def _kill(task_id: str) -> bool:
|
|
280
|
-
|
|
281
|
-
|
|
530
|
+
tasks = _get_tasks()
|
|
531
|
+
with _get_tasks_lock():
|
|
532
|
+
task = tasks.get(task_id)
|
|
282
533
|
if not task:
|
|
283
534
|
return False
|
|
284
535
|
|
|
@@ -296,8 +547,9 @@ async def kill_background_task(task_id: str) -> bool:
|
|
|
296
547
|
task.process.kill()
|
|
297
548
|
await asyncio.wait_for(task.process.wait(), timeout=1.0)
|
|
298
549
|
|
|
299
|
-
with
|
|
550
|
+
with _get_tasks_lock():
|
|
300
551
|
task.exit_code = task.process.returncode or -1
|
|
552
|
+
task.end_time = task.end_time or _loop_time()
|
|
301
553
|
return True
|
|
302
554
|
finally:
|
|
303
555
|
await _finalize_reader_tasks(task.reader_tasks)
|
|
@@ -309,82 +561,45 @@ async def kill_background_task(task_id: str) -> bool:
|
|
|
309
561
|
|
|
310
562
|
def list_background_tasks() -> List[str]:
|
|
311
563
|
"""Return known background task ids."""
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
task.done_event.set()
|
|
346
|
-
|
|
347
|
-
current = asyncio.current_task()
|
|
348
|
-
pending = [t for t in asyncio.all_tasks(loop) if t is not current]
|
|
349
|
-
for pending_task in pending:
|
|
350
|
-
pending_task.cancel()
|
|
351
|
-
if pending:
|
|
352
|
-
with contextlib.suppress(Exception):
|
|
353
|
-
await asyncio.gather(*pending, return_exceptions=True)
|
|
354
|
-
|
|
355
|
-
with contextlib.suppress(Exception):
|
|
356
|
-
await loop.shutdown_asyncgens()
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
def shutdown_background_shell() -> None:
|
|
360
|
-
"""Stop background tasks/loop to avoid asyncio 'Event loop is closed' warnings."""
|
|
361
|
-
global _background_loop, _background_thread
|
|
564
|
+
_prune_background_tasks()
|
|
565
|
+
with _get_tasks_lock():
|
|
566
|
+
return list(_get_tasks().keys())
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _prune_background_tasks(max_age_seconds: Optional[float] = None) -> int:
|
|
570
|
+
"""Remove finished background tasks older than the TTL."""
|
|
571
|
+
ttl = DEFAULT_TASK_TTL_SEC if max_age_seconds is None else max_age_seconds
|
|
572
|
+
if ttl is None or ttl <= 0:
|
|
573
|
+
return 0
|
|
574
|
+
now = _loop_time()
|
|
575
|
+
removed = 0
|
|
576
|
+
tasks = _get_tasks()
|
|
577
|
+
with _get_tasks_lock():
|
|
578
|
+
for task_id, task in list(tasks.items()):
|
|
579
|
+
if task.exit_code is None:
|
|
580
|
+
continue
|
|
581
|
+
age = (now - task.start_time) if task.start_time else 0.0
|
|
582
|
+
if age > ttl:
|
|
583
|
+
tasks.pop(task_id, None)
|
|
584
|
+
removed += 1
|
|
585
|
+
return removed
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def shutdown_background_shell(force: bool = False) -> None:
|
|
589
|
+
"""Stop background tasks/loop to avoid asyncio 'Event loop is closed' warnings.
|
|
590
|
+
|
|
591
|
+
This function maintains backward compatibility by delegating to the manager.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
force: If True, use minimal timeouts for faster exit.
|
|
595
|
+
"""
|
|
596
|
+
_get_manager().shutdown(force=force)
|
|
362
597
|
|
|
363
|
-
loop = _background_loop
|
|
364
|
-
thread = _background_thread
|
|
365
598
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
_background_thread = None
|
|
369
|
-
return
|
|
599
|
+
def reset_background_shell_for_testing() -> None:
|
|
600
|
+
"""Reset all background shell state. Useful for testing.
|
|
370
601
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
fut.result(timeout=3)
|
|
376
|
-
except (RuntimeError, TimeoutError, concurrent.futures.TimeoutError):
|
|
377
|
-
logger.debug("Failed to cleanly shutdown background loop", exc_info=True)
|
|
378
|
-
try:
|
|
379
|
-
loop.call_soon_threadsafe(loop.stop)
|
|
380
|
-
except (RuntimeError, OSError):
|
|
381
|
-
logger.debug("Failed to stop background loop", exc_info=True)
|
|
382
|
-
else:
|
|
383
|
-
loop.run_until_complete(_shutdown_loop(loop))
|
|
384
|
-
finally:
|
|
385
|
-
if thread and thread.is_alive():
|
|
386
|
-
thread.join(timeout=2)
|
|
387
|
-
with contextlib.suppress(Exception):
|
|
388
|
-
loop.close()
|
|
389
|
-
_background_loop = None
|
|
390
|
-
_background_thread = None
|
|
602
|
+
This function shuts down the current manager instance and clears it,
|
|
603
|
+
allowing a fresh instance to be created on next access.
|
|
604
|
+
"""
|
|
605
|
+
BackgroundShellManager.reset_instance()
|