ripperdoc 0.2.8__py3-none-any.whl → 0.2.10__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 +257 -123
- ripperdoc/cli/commands/__init__.py +2 -1
- ripperdoc/cli/commands/agents_cmd.py +138 -8
- ripperdoc/cli/commands/clear_cmd.py +9 -4
- ripperdoc/cli/commands/config_cmd.py +1 -1
- ripperdoc/cli/commands/context_cmd.py +3 -2
- ripperdoc/cli/commands/doctor_cmd.py +18 -4
- ripperdoc/cli/commands/exit_cmd.py +1 -0
- ripperdoc/cli/commands/hooks_cmd.py +27 -53
- ripperdoc/cli/commands/models_cmd.py +27 -10
- ripperdoc/cli/commands/permissions_cmd.py +27 -9
- ripperdoc/cli/commands/resume_cmd.py +9 -3
- ripperdoc/cli/commands/stats_cmd.py +244 -0
- ripperdoc/cli/commands/status_cmd.py +4 -4
- ripperdoc/cli/commands/tasks_cmd.py +8 -4
- ripperdoc/cli/ui/file_mention_completer.py +2 -1
- ripperdoc/cli/ui/interrupt_handler.py +2 -3
- ripperdoc/cli/ui/message_display.py +4 -2
- ripperdoc/cli/ui/panels.py +1 -0
- ripperdoc/cli/ui/provider_options.py +247 -0
- ripperdoc/cli/ui/rich_ui.py +403 -81
- ripperdoc/cli/ui/spinner.py +54 -18
- ripperdoc/cli/ui/thinking_spinner.py +1 -2
- ripperdoc/cli/ui/tool_renderers.py +8 -2
- ripperdoc/cli/ui/wizard.py +213 -0
- ripperdoc/core/agents.py +19 -6
- ripperdoc/core/config.py +51 -17
- ripperdoc/core/custom_commands.py +7 -6
- ripperdoc/core/default_tools.py +101 -12
- ripperdoc/core/hooks/config.py +1 -3
- ripperdoc/core/hooks/events.py +27 -28
- ripperdoc/core/hooks/executor.py +4 -6
- ripperdoc/core/hooks/integration.py +12 -21
- ripperdoc/core/hooks/llm_callback.py +59 -0
- ripperdoc/core/hooks/manager.py +40 -15
- ripperdoc/core/permissions.py +118 -12
- ripperdoc/core/providers/anthropic.py +109 -36
- ripperdoc/core/providers/gemini.py +70 -5
- ripperdoc/core/providers/openai.py +89 -24
- ripperdoc/core/query.py +273 -68
- ripperdoc/core/query_utils.py +2 -0
- ripperdoc/core/skills.py +9 -3
- ripperdoc/core/system_prompt.py +4 -2
- ripperdoc/core/tool.py +17 -8
- ripperdoc/sdk/client.py +79 -4
- ripperdoc/tools/ask_user_question_tool.py +5 -3
- ripperdoc/tools/background_shell.py +307 -135
- ripperdoc/tools/bash_output_tool.py +1 -1
- ripperdoc/tools/bash_tool.py +63 -24
- ripperdoc/tools/dynamic_mcp_tool.py +29 -8
- ripperdoc/tools/enter_plan_mode_tool.py +1 -1
- ripperdoc/tools/exit_plan_mode_tool.py +1 -1
- ripperdoc/tools/file_edit_tool.py +167 -54
- ripperdoc/tools/file_read_tool.py +28 -4
- ripperdoc/tools/file_write_tool.py +13 -10
- ripperdoc/tools/glob_tool.py +3 -2
- ripperdoc/tools/grep_tool.py +3 -2
- ripperdoc/tools/kill_bash_tool.py +1 -1
- ripperdoc/tools/ls_tool.py +1 -1
- ripperdoc/tools/lsp_tool.py +615 -0
- ripperdoc/tools/mcp_tools.py +13 -10
- ripperdoc/tools/multi_edit_tool.py +8 -7
- ripperdoc/tools/notebook_edit_tool.py +7 -4
- ripperdoc/tools/skill_tool.py +1 -1
- ripperdoc/tools/task_tool.py +519 -69
- ripperdoc/tools/todo_tool.py +2 -2
- ripperdoc/tools/tool_search_tool.py +3 -2
- ripperdoc/utils/conversation_compaction.py +9 -5
- ripperdoc/utils/file_watch.py +214 -5
- ripperdoc/utils/json_utils.py +2 -1
- ripperdoc/utils/lsp.py +806 -0
- ripperdoc/utils/mcp.py +11 -3
- ripperdoc/utils/memory.py +4 -2
- ripperdoc/utils/message_compaction.py +21 -7
- ripperdoc/utils/message_formatting.py +14 -7
- ripperdoc/utils/messages.py +126 -67
- ripperdoc/utils/path_ignore.py +35 -8
- ripperdoc/utils/permissions/path_validation_utils.py +2 -1
- ripperdoc/utils/permissions/shell_command_validation.py +427 -91
- ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
- ripperdoc/utils/safe_get_cwd.py +2 -1
- ripperdoc/utils/session_heatmap.py +244 -0
- ripperdoc/utils/session_history.py +13 -6
- ripperdoc/utils/session_stats.py +293 -0
- ripperdoc/utils/todo.py +2 -1
- ripperdoc/utils/token_estimation.py +6 -1
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
- ripperdoc-0.2.10.dist-info/RECORD +129 -0
- ripperdoc-0.2.8.dist-info/RECORD +0 -121
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
|
@@ -8,9 +8,11 @@ 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
17
|
from typing import Any, Dict, List, Optional
|
|
16
18
|
|
|
@@ -41,67 +43,271 @@ class BackgroundTask:
|
|
|
41
43
|
done_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
42
44
|
|
|
43
45
|
|
|
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
|
|
46
|
+
DEFAULT_TASK_TTL_SEC = float(os.getenv("RIPPERDOC_BASH_TASK_TTL_SEC", "3600"))
|
|
50
47
|
|
|
51
48
|
|
|
52
|
-
|
|
53
|
-
"""
|
|
54
|
-
try:
|
|
55
|
-
logger.exception(message, extra=extra)
|
|
56
|
-
except (OSError, RuntimeError, ValueError):
|
|
57
|
-
pass
|
|
49
|
+
class BackgroundShellManager:
|
|
50
|
+
"""Manager for background shell tasks with proper lifecycle control.
|
|
58
51
|
|
|
52
|
+
This class encapsulates all global state for background shell management,
|
|
53
|
+
providing better testability and proper resource cleanup.
|
|
54
|
+
"""
|
|
59
55
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
_instance: Optional["BackgroundShellManager"] = None
|
|
57
|
+
_instance_lock = threading.Lock()
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
"""Initialize the manager. Use get_instance() for singleton access."""
|
|
61
|
+
self._tasks: Dict[str, BackgroundTask] = {}
|
|
62
|
+
self._tasks_lock = threading.Lock()
|
|
63
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
64
|
+
self._thread: Optional[threading.Thread] = None
|
|
65
|
+
self._loop_lock = threading.Lock()
|
|
66
|
+
self._shutdown_event = threading.Event()
|
|
67
|
+
self._shutdown_registered = False
|
|
68
|
+
self._is_shutting_down = False
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def get_instance(cls) -> "BackgroundShellManager":
|
|
72
|
+
"""Get or create the singleton instance."""
|
|
73
|
+
if cls._instance is None:
|
|
74
|
+
with cls._instance_lock:
|
|
75
|
+
if cls._instance is None:
|
|
76
|
+
cls._instance = cls()
|
|
77
|
+
return cls._instance
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def reset_instance(cls) -> None:
|
|
81
|
+
"""Reset the singleton instance. Useful for testing.
|
|
82
|
+
|
|
83
|
+
This method shuts down the current instance and clears it,
|
|
84
|
+
allowing a fresh instance to be created on next access.
|
|
85
|
+
"""
|
|
86
|
+
with cls._instance_lock:
|
|
87
|
+
if cls._instance is not None:
|
|
88
|
+
cls._instance.shutdown()
|
|
89
|
+
cls._instance = None
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def _set_instance_for_testing(cls, instance: Optional["BackgroundShellManager"]) -> None:
|
|
93
|
+
"""Set a custom instance for testing purposes."""
|
|
94
|
+
with cls._instance_lock:
|
|
95
|
+
cls._instance = instance
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def tasks(self) -> Dict[str, BackgroundTask]:
|
|
99
|
+
"""Access to tasks dict (for internal use)."""
|
|
100
|
+
return self._tasks
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def tasks_lock(self) -> threading.Lock:
|
|
104
|
+
"""Access to tasks lock (for internal use)."""
|
|
105
|
+
return self._tasks_lock
|
|
106
|
+
|
|
107
|
+
def ensure_loop(self) -> asyncio.AbstractEventLoop:
|
|
108
|
+
"""Create (or return) a dedicated loop for background processes."""
|
|
109
|
+
if self._loop and self._loop.is_running():
|
|
110
|
+
return self._loop
|
|
111
|
+
|
|
112
|
+
with self._loop_lock:
|
|
113
|
+
if self._loop and self._loop.is_running():
|
|
114
|
+
return self._loop
|
|
115
|
+
|
|
116
|
+
loop = asyncio.new_event_loop()
|
|
117
|
+
ready = threading.Event()
|
|
118
|
+
shutdown_event = self._shutdown_event
|
|
119
|
+
|
|
120
|
+
def _run_loop() -> None:
|
|
121
|
+
asyncio.set_event_loop(loop)
|
|
122
|
+
ready.set()
|
|
123
|
+
try:
|
|
124
|
+
loop.run_forever()
|
|
125
|
+
finally:
|
|
126
|
+
# Ensure cleanup happens even if loop is stopped abruptly
|
|
127
|
+
shutdown_event.set()
|
|
128
|
+
|
|
129
|
+
# Use non-daemon thread to ensure atexit handlers can complete
|
|
130
|
+
thread = threading.Thread(
|
|
131
|
+
target=_run_loop,
|
|
132
|
+
name="ripperdoc-bg-loop",
|
|
133
|
+
daemon=False, # Non-daemon for proper shutdown
|
|
134
|
+
)
|
|
135
|
+
thread.start()
|
|
136
|
+
ready.wait()
|
|
137
|
+
|
|
138
|
+
self._loop = loop
|
|
139
|
+
self._thread = thread
|
|
140
|
+
self._register_shutdown_hook()
|
|
141
|
+
return loop
|
|
142
|
+
|
|
143
|
+
def _register_shutdown_hook(self) -> None:
|
|
144
|
+
"""Register atexit handler for cleanup."""
|
|
145
|
+
if self._shutdown_registered:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
# Use weakref to avoid preventing garbage collection
|
|
149
|
+
manager_ref = weakref.ref(self)
|
|
150
|
+
|
|
151
|
+
def _shutdown_callback() -> None:
|
|
152
|
+
manager = manager_ref()
|
|
153
|
+
if manager is not None:
|
|
154
|
+
manager.shutdown()
|
|
155
|
+
|
|
156
|
+
atexit.register(_shutdown_callback)
|
|
157
|
+
self._shutdown_registered = True
|
|
158
|
+
|
|
159
|
+
def submit_to_loop(self, coro: Any) -> concurrent.futures.Future:
|
|
160
|
+
"""Run a coroutine on the background loop and return a thread-safe future."""
|
|
161
|
+
loop = self.ensure_loop()
|
|
162
|
+
return asyncio.run_coroutine_threadsafe(coro, loop)
|
|
163
|
+
|
|
164
|
+
def shutdown(self, force: bool = False) -> None:
|
|
165
|
+
"""Stop background tasks/loop to avoid resource leaks.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
force: If True, use minimal timeouts for faster exit.
|
|
169
|
+
"""
|
|
170
|
+
if self._is_shutting_down:
|
|
171
|
+
# If already shutting down and force is requested, just mark done
|
|
172
|
+
if force:
|
|
173
|
+
self._shutdown_event.set()
|
|
174
|
+
return
|
|
175
|
+
self._is_shutting_down = True
|
|
176
|
+
|
|
177
|
+
loop = self._loop
|
|
178
|
+
thread = self._thread
|
|
179
|
+
|
|
180
|
+
if not loop or loop.is_closed():
|
|
181
|
+
self._loop = None
|
|
182
|
+
self._thread = None
|
|
183
|
+
self._is_shutting_down = False
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
# Use shorter timeouts for faster exit
|
|
187
|
+
async_timeout = 0.5 if force else 2.0
|
|
188
|
+
join_timeout = 0.5 if force else 1.0
|
|
63
189
|
|
|
64
|
-
|
|
65
|
-
|
|
190
|
+
try:
|
|
191
|
+
if loop.is_running():
|
|
192
|
+
try:
|
|
193
|
+
fut = asyncio.run_coroutine_threadsafe(
|
|
194
|
+
self._shutdown_loop_async(loop, force=force), loop
|
|
195
|
+
)
|
|
196
|
+
fut.result(timeout=async_timeout)
|
|
197
|
+
except (RuntimeError, TimeoutError, concurrent.futures.TimeoutError):
|
|
198
|
+
logger.debug("Failed to cleanly shutdown background loop", exc_info=True)
|
|
199
|
+
try:
|
|
200
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
201
|
+
except (RuntimeError, OSError):
|
|
202
|
+
logger.debug("Failed to stop background loop", exc_info=True)
|
|
203
|
+
else:
|
|
204
|
+
# If loop isn't running, try to run cleanup synchronously
|
|
205
|
+
try:
|
|
206
|
+
loop.run_until_complete(self._shutdown_loop_async(loop, force=force))
|
|
207
|
+
except RuntimeError:
|
|
208
|
+
pass # Loop may already be closed
|
|
209
|
+
finally:
|
|
210
|
+
if thread and thread.is_alive():
|
|
211
|
+
thread.join(timeout=join_timeout)
|
|
212
|
+
# If thread is still alive after timeout, don't block further
|
|
213
|
+
if thread.is_alive():
|
|
214
|
+
logger.debug("Background thread did not stop in time, continuing shutdown")
|
|
215
|
+
with contextlib.suppress(Exception):
|
|
216
|
+
if not loop.is_closed():
|
|
217
|
+
loop.close()
|
|
218
|
+
self._loop = None
|
|
219
|
+
self._thread = None
|
|
220
|
+
self._shutdown_event.set()
|
|
221
|
+
self._is_shutting_down = False
|
|
222
|
+
|
|
223
|
+
async def _shutdown_loop_async(
|
|
224
|
+
self, loop: asyncio.AbstractEventLoop, force: bool = False
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Drain running background processes before stopping the loop.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
loop: The event loop to shutdown.
|
|
230
|
+
force: If True, use minimal timeouts for faster exit.
|
|
231
|
+
"""
|
|
232
|
+
with self._tasks_lock:
|
|
233
|
+
tasks = list(self._tasks.values())
|
|
234
|
+
self._tasks.clear()
|
|
235
|
+
|
|
236
|
+
# Use shorter timeouts when force is True
|
|
237
|
+
wait_timeout = 0.3 if force else 1.5
|
|
238
|
+
kill_timeout = 0.2 if force else 0.5
|
|
239
|
+
|
|
240
|
+
for task in tasks:
|
|
241
|
+
try:
|
|
242
|
+
task.killed = True
|
|
243
|
+
with contextlib.suppress(ProcessLookupError):
|
|
244
|
+
task.process.kill()
|
|
245
|
+
try:
|
|
246
|
+
with contextlib.suppress(ProcessLookupError):
|
|
247
|
+
await asyncio.wait_for(task.process.wait(), timeout=wait_timeout)
|
|
248
|
+
except asyncio.TimeoutError:
|
|
249
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
250
|
+
task.process.kill()
|
|
251
|
+
with contextlib.suppress(asyncio.TimeoutError, ProcessLookupError):
|
|
252
|
+
await asyncio.wait_for(task.process.wait(), timeout=kill_timeout)
|
|
253
|
+
task.exit_code = task.process.returncode or -1
|
|
254
|
+
except (OSError, RuntimeError, asyncio.CancelledError) as exc:
|
|
255
|
+
if not isinstance(exc, asyncio.CancelledError):
|
|
256
|
+
_safe_log_exception(
|
|
257
|
+
"Error shutting down background task",
|
|
258
|
+
task_id=task.id,
|
|
259
|
+
command=task.command,
|
|
260
|
+
)
|
|
261
|
+
finally:
|
|
262
|
+
await _finalize_reader_tasks(task.reader_tasks, timeout=0.3 if force else 1.0)
|
|
263
|
+
task.done_event.set()
|
|
264
|
+
|
|
265
|
+
current = asyncio.current_task()
|
|
266
|
+
pending = [t for t in asyncio.all_tasks(loop) if t is not current]
|
|
267
|
+
for pending_task in pending:
|
|
268
|
+
pending_task.cancel()
|
|
269
|
+
if pending:
|
|
270
|
+
with contextlib.suppress(Exception):
|
|
271
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
66
272
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return _background_loop
|
|
273
|
+
with contextlib.suppress(Exception):
|
|
274
|
+
await loop.shutdown_asyncgens()
|
|
70
275
|
|
|
71
|
-
loop = asyncio.new_event_loop()
|
|
72
|
-
ready = threading.Event()
|
|
73
276
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
ready.set()
|
|
77
|
-
loop.run_forever()
|
|
277
|
+
# Module-level functions that delegate to the singleton manager
|
|
278
|
+
# These maintain backward compatibility with existing code
|
|
78
279
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
daemon=True,
|
|
83
|
-
)
|
|
84
|
-
thread.start()
|
|
85
|
-
ready.wait()
|
|
280
|
+
def _get_manager() -> BackgroundShellManager:
|
|
281
|
+
"""Get the singleton manager instance."""
|
|
282
|
+
return BackgroundShellManager.get_instance()
|
|
86
283
|
|
|
87
|
-
_background_loop = loop
|
|
88
|
-
_background_thread = thread
|
|
89
|
-
_register_shutdown_hook()
|
|
90
|
-
return loop
|
|
91
284
|
|
|
285
|
+
def _get_tasks_lock() -> threading.Lock:
|
|
286
|
+
"""Get the tasks lock from the manager."""
|
|
287
|
+
return _get_manager().tasks_lock
|
|
92
288
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
289
|
+
|
|
290
|
+
def _get_tasks() -> Dict[str, BackgroundTask]:
|
|
291
|
+
"""Get the tasks dict from the manager."""
|
|
292
|
+
return _get_manager().tasks
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _safe_log_exception(message: str, **extra: Any) -> None:
|
|
296
|
+
"""Log an exception but never let logging failures bubble up."""
|
|
297
|
+
try:
|
|
298
|
+
logger.exception(message, extra=extra)
|
|
299
|
+
except (OSError, RuntimeError, ValueError):
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _ensure_background_loop() -> asyncio.AbstractEventLoop:
|
|
304
|
+
"""Create (or return) a dedicated loop for background processes."""
|
|
305
|
+
return _get_manager().ensure_loop()
|
|
99
306
|
|
|
100
307
|
|
|
101
308
|
def _submit_to_background_loop(coro: Any) -> concurrent.futures.Future:
|
|
102
309
|
"""Run a coroutine on the background loop and return a thread-safe future."""
|
|
103
|
-
|
|
104
|
-
return asyncio.run_coroutine_threadsafe(coro, loop)
|
|
310
|
+
return _get_manager().submit_to_loop(coro)
|
|
105
311
|
|
|
106
312
|
|
|
107
313
|
async def _pump_stream(stream: asyncio.StreamReader, sink: List[str]) -> None:
|
|
@@ -112,7 +318,7 @@ async def _pump_stream(stream: asyncio.StreamReader, sink: List[str]) -> None:
|
|
|
112
318
|
if not chunk:
|
|
113
319
|
break
|
|
114
320
|
text = chunk.decode("utf-8", errors="replace")
|
|
115
|
-
with
|
|
321
|
+
with _get_tasks_lock():
|
|
116
322
|
sink.append(text)
|
|
117
323
|
except (OSError, RuntimeError, asyncio.CancelledError) as exc:
|
|
118
324
|
if isinstance(exc, asyncio.CancelledError):
|
|
@@ -147,25 +353,26 @@ async def _monitor_task(task: BackgroundTask) -> None:
|
|
|
147
353
|
await asyncio.wait_for(task.process.wait(), timeout=task.timeout)
|
|
148
354
|
else:
|
|
149
355
|
await task.process.wait()
|
|
150
|
-
with
|
|
356
|
+
with _get_tasks_lock():
|
|
151
357
|
task.exit_code = task.process.returncode
|
|
152
358
|
except asyncio.TimeoutError:
|
|
153
359
|
logger.warning(f"Background task {task.id} timed out after {task.timeout}s: {task.command}")
|
|
154
|
-
with
|
|
360
|
+
with _get_tasks_lock():
|
|
155
361
|
task.timed_out = True
|
|
156
362
|
task.process.kill()
|
|
157
363
|
await task.process.wait()
|
|
158
|
-
with
|
|
364
|
+
with _get_tasks_lock():
|
|
159
365
|
task.exit_code = -1
|
|
160
366
|
except asyncio.CancelledError:
|
|
161
367
|
return
|
|
162
368
|
except (OSError, RuntimeError, ProcessLookupError) as exc:
|
|
163
369
|
logger.warning(
|
|
164
370
|
"Error monitoring background task: %s: %s",
|
|
165
|
-
type(exc).__name__,
|
|
371
|
+
type(exc).__name__,
|
|
372
|
+
exc,
|
|
166
373
|
extra={"task_id": task.id, "command": task.command},
|
|
167
374
|
)
|
|
168
|
-
with
|
|
375
|
+
with _get_tasks_lock():
|
|
169
376
|
task.exit_code = -1
|
|
170
377
|
finally:
|
|
171
378
|
# Ensure readers are finished before marking done.
|
|
@@ -195,8 +402,8 @@ async def _start_background_command(
|
|
|
195
402
|
start_time=_loop_time(),
|
|
196
403
|
timeout=timeout,
|
|
197
404
|
)
|
|
198
|
-
with
|
|
199
|
-
|
|
405
|
+
with _get_tasks_lock():
|
|
406
|
+
_get_tasks()[task_id] = record
|
|
200
407
|
|
|
201
408
|
# Start stream pumps and monitor task.
|
|
202
409
|
if process.stdout:
|
|
@@ -246,11 +453,12 @@ def get_background_status(task_id: str, consume: bool = True) -> dict:
|
|
|
246
453
|
|
|
247
454
|
If consume is True, buffered stdout/stderr are cleared after reading.
|
|
248
455
|
"""
|
|
249
|
-
|
|
250
|
-
|
|
456
|
+
tasks = _get_tasks()
|
|
457
|
+
with _get_tasks_lock():
|
|
458
|
+
if task_id not in tasks:
|
|
251
459
|
raise KeyError(f"No background task found with id '{task_id}'")
|
|
252
460
|
|
|
253
|
-
task =
|
|
461
|
+
task = tasks[task_id]
|
|
254
462
|
stdout = "".join(task.stdout_chunks)
|
|
255
463
|
stderr = "".join(task.stderr_chunks)
|
|
256
464
|
|
|
@@ -276,8 +484,9 @@ async def kill_background_task(task_id: str) -> bool:
|
|
|
276
484
|
KILL_WAIT_SECONDS = 2.0
|
|
277
485
|
|
|
278
486
|
async def _kill(task_id: str) -> bool:
|
|
279
|
-
|
|
280
|
-
|
|
487
|
+
tasks = _get_tasks()
|
|
488
|
+
with _get_tasks_lock():
|
|
489
|
+
task = tasks.get(task_id)
|
|
281
490
|
if not task:
|
|
282
491
|
return False
|
|
283
492
|
|
|
@@ -295,7 +504,7 @@ async def kill_background_task(task_id: str) -> bool:
|
|
|
295
504
|
task.process.kill()
|
|
296
505
|
await asyncio.wait_for(task.process.wait(), timeout=1.0)
|
|
297
506
|
|
|
298
|
-
with
|
|
507
|
+
with _get_tasks_lock():
|
|
299
508
|
task.exit_code = task.process.returncode or -1
|
|
300
509
|
return True
|
|
301
510
|
finally:
|
|
@@ -308,82 +517,45 @@ async def kill_background_task(task_id: str) -> bool:
|
|
|
308
517
|
|
|
309
518
|
def list_background_tasks() -> List[str]:
|
|
310
519
|
"""Return known background task ids."""
|
|
311
|
-
|
|
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
|
-
task.done_event.set()
|
|
345
|
-
|
|
346
|
-
current = asyncio.current_task()
|
|
347
|
-
pending = [t for t in asyncio.all_tasks(loop) if t is not current]
|
|
348
|
-
for pending_task in pending:
|
|
349
|
-
pending_task.cancel()
|
|
350
|
-
if pending:
|
|
351
|
-
with contextlib.suppress(Exception):
|
|
352
|
-
await asyncio.gather(*pending, return_exceptions=True)
|
|
353
|
-
|
|
354
|
-
with contextlib.suppress(Exception):
|
|
355
|
-
await loop.shutdown_asyncgens()
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
def shutdown_background_shell() -> None:
|
|
359
|
-
"""Stop background tasks/loop to avoid asyncio 'Event loop is closed' warnings."""
|
|
360
|
-
global _background_loop, _background_thread
|
|
520
|
+
_prune_background_tasks()
|
|
521
|
+
with _get_tasks_lock():
|
|
522
|
+
return list(_get_tasks().keys())
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _prune_background_tasks(max_age_seconds: Optional[float] = None) -> int:
|
|
526
|
+
"""Remove finished background tasks older than the TTL."""
|
|
527
|
+
ttl = DEFAULT_TASK_TTL_SEC if max_age_seconds is None else max_age_seconds
|
|
528
|
+
if ttl is None or ttl <= 0:
|
|
529
|
+
return 0
|
|
530
|
+
now = _loop_time()
|
|
531
|
+
removed = 0
|
|
532
|
+
tasks = _get_tasks()
|
|
533
|
+
with _get_tasks_lock():
|
|
534
|
+
for task_id, task in list(tasks.items()):
|
|
535
|
+
if task.exit_code is None:
|
|
536
|
+
continue
|
|
537
|
+
age = (now - task.start_time) if task.start_time else 0.0
|
|
538
|
+
if age > ttl:
|
|
539
|
+
tasks.pop(task_id, None)
|
|
540
|
+
removed += 1
|
|
541
|
+
return removed
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def shutdown_background_shell(force: bool = False) -> None:
|
|
545
|
+
"""Stop background tasks/loop to avoid asyncio 'Event loop is closed' warnings.
|
|
546
|
+
|
|
547
|
+
This function maintains backward compatibility by delegating to the manager.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
force: If True, use minimal timeouts for faster exit.
|
|
551
|
+
"""
|
|
552
|
+
_get_manager().shutdown(force=force)
|
|
361
553
|
|
|
362
|
-
loop = _background_loop
|
|
363
|
-
thread = _background_thread
|
|
364
554
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
_background_thread = None
|
|
368
|
-
return
|
|
555
|
+
def reset_background_shell_for_testing() -> None:
|
|
556
|
+
"""Reset all background shell state. Useful for testing.
|
|
369
557
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
fut.result(timeout=3)
|
|
375
|
-
except (RuntimeError, TimeoutError, concurrent.futures.TimeoutError):
|
|
376
|
-
logger.debug("Failed to cleanly shutdown background loop", exc_info=True)
|
|
377
|
-
try:
|
|
378
|
-
loop.call_soon_threadsafe(loop.stop)
|
|
379
|
-
except (RuntimeError, OSError):
|
|
380
|
-
logger.debug("Failed to stop background loop", exc_info=True)
|
|
381
|
-
else:
|
|
382
|
-
loop.run_until_complete(_shutdown_loop(loop))
|
|
383
|
-
finally:
|
|
384
|
-
if thread and thread.is_alive():
|
|
385
|
-
thread.join(timeout=2)
|
|
386
|
-
with contextlib.suppress(Exception):
|
|
387
|
-
loop.close()
|
|
388
|
-
_background_loop = None
|
|
389
|
-
_background_thread = None
|
|
558
|
+
This function shuts down the current manager instance and clears it,
|
|
559
|
+
allowing a fresh instance to be created on next access.
|
|
560
|
+
"""
|
|
561
|
+
BackgroundShellManager.reset_instance()
|
|
@@ -40,7 +40,7 @@ class BashOutputTool(Tool[BashOutputInput, BashOutputData]):
|
|
|
40
40
|
async def description(self) -> str:
|
|
41
41
|
return "Read output and status from a background bash command started with BashTool(run_in_background=True)."
|
|
42
42
|
|
|
43
|
-
async def prompt(self,
|
|
43
|
+
async def prompt(self, yolo_mode: bool = False) -> str:
|
|
44
44
|
return "Fetch buffered output and status for a background bash task by id."
|
|
45
45
|
|
|
46
46
|
@property
|