diff --git a/src/autocoder/server/services/process_manager.py b/src/autocoder/server/services/process_manager.py index 9ae8f6cf..2038671b 100644 --- a/src/autocoder/server/services/process_manager.py +++ b/src/autocoder/server/services/process_manager.py @@ -14,6 +14,7 @@ import subprocess import sys import threading +import time from collections.abc import Awaitable, Callable from datetime import datetime from pathlib import Path @@ -63,6 +64,68 @@ ) +def _env_truthy(name: str, *, default: bool = False) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return str(raw).strip().lower() not in {"", "0", "false", "no", "off"} + + +def cleanup_tmpclaude_dirs(project_dir: Path) -> tuple[int, int]: + """ + Best-effort cleanup of Claude Code CLI scratch directories in the project root. + + These dirs are typically named like `tmpclaude--cwd` and can be left behind after + interrupted runs. Callers must ensure the agent is not running. + + Environment: + - AUTOCODER_CLEAN_TMPCLAUDE=0 disables this cleanup. + - AUTOCODER_TMPCLAUDE_MAX_AGE_S= deletes only dirs older than this age. + + Returns: + (deleted_count, failed_count) + """ + if not _env_truthy("AUTOCODER_CLEAN_TMPCLAUDE", default=True): + return 0, 0 + + project_dir = Path(project_dir).resolve() + deleted = 0 + failed = 0 + + max_age_s: float | None = None + max_age_raw = str(os.environ.get("AUTOCODER_TMPCLAUDE_MAX_AGE_S", "")).strip() + if max_age_raw: + with contextlib.suppress(Exception): + max_age_s = float(max_age_raw) + + for p in sorted(project_dir.glob("tmpclaude-*")): + try: + if not p.is_dir(): + continue + except Exception: + continue + + if max_age_s is not None: + with contextlib.suppress(Exception): + age_s = time.time() - float(p.stat().st_mtime) + if age_s < max_age_s: + continue + + try: + import shutil + + shutil.rmtree(p, ignore_errors=False) + deleted += 1 + except Exception as e: + failed += 1 + logger.debug("Failed to delete tmpclaude dir %s: %s", p, e) + + if deleted or failed: + logger.info("tmpclaude cleanup: deleted=%s failed=%s", deleted, failed) + + return deleted, failed + + def sanitize_output(line: str) -> str: """Remove sensitive information from output lines.""" for pattern in SENSITIVE_PATTERNS: @@ -518,6 +581,11 @@ async def start( if not self._check_lock(): return False, "Another agent instance is already running for this project" + # Safe cleanup of Claude temp dirs before launching a new run. + # We only do this after lock-check confirms no agent is running. + with contextlib.suppress(Exception): + cleanup_tmpclaude_dirs(self.project_dir) + # Store mode settings for status queries self.yolo_mode = yolo_mode self.parallel_mode = parallel_mode @@ -655,6 +723,10 @@ async def stop(self) -> tuple[bool, str]: self.yolo_mode = False # Reset YOLO mode self.parallel_mode = False + # Cleanup tmpclaude dirs now that the run is fully stopped. + with contextlib.suppress(Exception): + cleanup_tmpclaude_dirs(self.project_dir) + return True, "Agent stopped" except Exception as e: logger.exception("Failed to stop agent") diff --git a/tests/test_tmpclaude_cleanup.py b/tests/test_tmpclaude_cleanup.py new file mode 100644 index 00000000..6c4b3280 --- /dev/null +++ b/tests/test_tmpclaude_cleanup.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from pathlib import Path + +from autocoder.server.services.process_manager import cleanup_tmpclaude_dirs + + +def test_cleanup_tmpclaude_dirs_removes_dirs(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("AUTOCODER_TMPCLAUDE_MAX_AGE_S", raising=False) + monkeypatch.delenv("AUTOCODER_CLEAN_TMPCLAUDE", raising=False) + + d1 = tmp_path / "tmpclaude-aaaa-cwd" + d2 = tmp_path / "tmpclaude-bbbb-cwd" + d1.mkdir() + d2.mkdir() + (d1 / "foo.txt").write_text("x", encoding="utf-8") + (d2 / "bar.txt").write_text("y", encoding="utf-8") + + deleted, failed = cleanup_tmpclaude_dirs(tmp_path) + assert failed == 0 + assert deleted == 2 + assert not d1.exists() + assert not d2.exists() + + +def test_cleanup_tmpclaude_dirs_can_be_disabled(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("AUTOCODER_CLEAN_TMPCLAUDE", "0") + d = tmp_path / "tmpclaude-cccc-cwd" + d.mkdir() + + deleted, failed = cleanup_tmpclaude_dirs(tmp_path) + assert deleted == 0 + assert failed == 0 + assert d.exists()