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.
Files changed (76) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +379 -51
  3. ripperdoc/cli/commands/__init__.py +6 -0
  4. ripperdoc/cli/commands/agents_cmd.py +128 -5
  5. ripperdoc/cli/commands/clear_cmd.py +8 -0
  6. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  7. ripperdoc/cli/commands/exit_cmd.py +1 -0
  8. ripperdoc/cli/commands/memory_cmd.py +2 -1
  9. ripperdoc/cli/commands/models_cmd.py +63 -7
  10. ripperdoc/cli/commands/resume_cmd.py +5 -0
  11. ripperdoc/cli/commands/skills_cmd.py +103 -0
  12. ripperdoc/cli/commands/stats_cmd.py +244 -0
  13. ripperdoc/cli/commands/status_cmd.py +10 -0
  14. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  15. ripperdoc/cli/commands/themes_cmd.py +139 -0
  16. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  17. ripperdoc/cli/ui/helpers.py +6 -3
  18. ripperdoc/cli/ui/interrupt_handler.py +34 -0
  19. ripperdoc/cli/ui/panels.py +14 -8
  20. ripperdoc/cli/ui/rich_ui.py +737 -47
  21. ripperdoc/cli/ui/spinner.py +93 -18
  22. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  23. ripperdoc/cli/ui/tool_renderers.py +10 -9
  24. ripperdoc/cli/ui/wizard.py +24 -19
  25. ripperdoc/core/agents.py +14 -3
  26. ripperdoc/core/config.py +238 -6
  27. ripperdoc/core/default_tools.py +91 -10
  28. ripperdoc/core/hooks/events.py +4 -0
  29. ripperdoc/core/hooks/llm_callback.py +58 -0
  30. ripperdoc/core/hooks/manager.py +6 -0
  31. ripperdoc/core/permissions.py +160 -9
  32. ripperdoc/core/providers/openai.py +84 -28
  33. ripperdoc/core/query.py +489 -87
  34. ripperdoc/core/query_utils.py +17 -14
  35. ripperdoc/core/skills.py +1 -0
  36. ripperdoc/core/theme.py +298 -0
  37. ripperdoc/core/tool.py +15 -5
  38. ripperdoc/protocol/__init__.py +14 -0
  39. ripperdoc/protocol/models.py +300 -0
  40. ripperdoc/protocol/stdio.py +1453 -0
  41. ripperdoc/tools/background_shell.py +354 -139
  42. ripperdoc/tools/bash_tool.py +117 -22
  43. ripperdoc/tools/file_edit_tool.py +228 -50
  44. ripperdoc/tools/file_read_tool.py +154 -3
  45. ripperdoc/tools/file_write_tool.py +53 -11
  46. ripperdoc/tools/grep_tool.py +98 -8
  47. ripperdoc/tools/lsp_tool.py +609 -0
  48. ripperdoc/tools/multi_edit_tool.py +26 -3
  49. ripperdoc/tools/skill_tool.py +52 -1
  50. ripperdoc/tools/task_tool.py +539 -65
  51. ripperdoc/utils/conversation_compaction.py +1 -1
  52. ripperdoc/utils/file_watch.py +216 -7
  53. ripperdoc/utils/image_utils.py +125 -0
  54. ripperdoc/utils/log.py +30 -3
  55. ripperdoc/utils/lsp.py +812 -0
  56. ripperdoc/utils/mcp.py +80 -18
  57. ripperdoc/utils/message_formatting.py +7 -4
  58. ripperdoc/utils/messages.py +198 -33
  59. ripperdoc/utils/pending_messages.py +50 -0
  60. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  61. ripperdoc/utils/permissions/tool_permission_utils.py +180 -15
  62. ripperdoc/utils/platform.py +198 -0
  63. ripperdoc/utils/session_heatmap.py +242 -0
  64. ripperdoc/utils/session_history.py +2 -2
  65. ripperdoc/utils/session_stats.py +294 -0
  66. ripperdoc/utils/shell_utils.py +8 -5
  67. ripperdoc/utils/todo.py +0 -6
  68. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +55 -17
  69. ripperdoc-0.3.0.dist-info/RECORD +136 -0
  70. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
  71. ripperdoc/sdk/__init__.py +0 -9
  72. ripperdoc/sdk/client.py +0 -333
  73. ripperdoc-0.2.9.dist-info/RECORD +0 -123
  74. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
  75. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
  76. {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
- _tasks: Dict[str, BackgroundTask] = {}
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
- def _safe_log_exception(message: str, **extra: Any) -> None:
53
- """Log an exception but never let logging failures bubble up."""
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
- def _ensure_background_loop() -> asyncio.AbstractEventLoop:
61
- """Create (or return) a dedicated loop for background processes."""
62
- global _background_loop, _background_thread
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
- if _background_loop and _background_loop.is_running():
65
- return _background_loop
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
- with _loop_lock:
68
- if _background_loop and _background_loop.is_running():
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
- def _run_loop() -> None:
75
- asyncio.set_event_loop(loop)
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
- _background_loop = loop
88
- _background_thread = thread
89
- _register_shutdown_hook()
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 _register_shutdown_hook() -> None:
94
- global _shutdown_registered
95
- if _shutdown_registered:
96
- return
97
- atexit.register(shutdown_background_shell)
98
- _shutdown_registered = True
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
- loop = _ensure_background_loop()
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 _tasks_lock:
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 _tasks_lock:
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 _tasks_lock:
381
+ with _get_tasks_lock():
155
382
  task.timed_out = True
156
383
  task.process.kill()
157
384
  await task.process.wait()
158
- with _tasks_lock:
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 _tasks_lock:
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, timeout: Optional[float] = None, shell_executable: Optional[str] = None
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 _tasks_lock:
200
- _tasks[task_id] = record
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, timeout: Optional[float] = None, shell_executable: Optional[str] = None
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(command, timeout, shell_executable)
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
- with _tasks_lock:
251
- if task_id not in _tasks:
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 = _tasks[task_id]
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": (_loop_time() - task.start_time) * 1000.0,
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
- with _tasks_lock:
281
- task = _tasks.get(task_id)
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 _tasks_lock:
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
- with _tasks_lock:
313
- return list(_tasks.keys())
314
-
315
-
316
- async def _shutdown_loop(loop: asyncio.AbstractEventLoop) -> None:
317
- """Drain running background processes before stopping the loop."""
318
- with _tasks_lock:
319
- tasks = list(_tasks.values())
320
- _tasks.clear()
321
-
322
- for task in tasks:
323
- try:
324
- task.killed = True
325
- with contextlib.suppress(ProcessLookupError):
326
- task.process.kill()
327
- try:
328
- with contextlib.suppress(ProcessLookupError):
329
- await asyncio.wait_for(task.process.wait(), timeout=1.5)
330
- except asyncio.TimeoutError:
331
- with contextlib.suppress(ProcessLookupError, PermissionError):
332
- task.process.kill()
333
- with contextlib.suppress(asyncio.TimeoutError, ProcessLookupError):
334
- await asyncio.wait_for(task.process.wait(), timeout=0.5)
335
- task.exit_code = task.process.returncode or -1
336
- except (OSError, RuntimeError, asyncio.CancelledError) as exc:
337
- if not isinstance(exc, asyncio.CancelledError):
338
- _safe_log_exception(
339
- "Error shutting down background task",
340
- task_id=task.id,
341
- command=task.command,
342
- )
343
- finally:
344
- await _finalize_reader_tasks(task.reader_tasks)
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
- if not loop or loop.is_closed():
367
- _background_loop = None
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
- try:
372
- if loop.is_running():
373
- try:
374
- fut = asyncio.run_coroutine_threadsafe(_shutdown_loop(loop), loop)
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()