diff --git a/setup.sh b/setup.sh index 57d7d913..b80a7103 100755 --- a/setup.sh +++ b/setup.sh @@ -329,6 +329,17 @@ bootstrap_branch "memory" "$SCRIPT_DIR/src/aipass/memory" "builder" "Vector echo " 15 branches bootstrapped" +# --- Seed branch config files from .example defaults --- +# Some branches need a config file that's gitignored (contains local state). +# Ship `*.example.json` in git; seed the real file from it on fresh install. +MEMORY_CONFIG_DIR="$SCRIPT_DIR/src/aipass/memory/config" +MEMORY_CONFIG_FILE="$MEMORY_CONFIG_DIR/memory_bank.config.json" +MEMORY_CONFIG_EXAMPLE="$MEMORY_CONFIG_DIR/memory_bank.config.example.json" +if [ -f "$MEMORY_CONFIG_EXAMPLE" ] && [ ! -f "$MEMORY_CONFIG_FILE" ]; then + cp "$MEMORY_CONFIG_EXAMPLE" "$MEMORY_CONFIG_FILE" + echo " memory_bank.config.json seeded from example" +fi + # --- Install Claude Code hooks --- CLAUDE_SETTINGS="$HOME/.claude/settings.json" @@ -540,6 +551,31 @@ if [ "$IS_WINDOWS" -eq 1 ]; then export AIPASS_HOME="$SCRIPT_DIR" export PATH="$VENV_SCRIPTS:$PATH" export PYTHONUTF8=1 + + # PowerShell profile wrapper — makes `drone @branch cmd` work from PowerShell + # without @ being consumed by PS splatting operator. See issue #340. + PS_PROFILE_DIR="$HOME/Documents/WindowsPowerShell" + PS_PROFILE="$PS_PROFILE_DIR/Microsoft.PowerShell_profile.ps1" + mkdir -p "$PS_PROFILE_DIR" + if [ ! -f "$PS_PROFILE" ] || ! grep -q "AIPass drone wrapper" "$PS_PROFILE" 2>/dev/null; then + cat >> "$PS_PROFILE" <<'PSWRAP' + +# AIPass drone wrapper — preserves @branch args that PowerShell would otherwise splat +function drone { + $exe = Join-Path $env:AIPASS_HOME '.venv\Scripts\drone.exe' + if (-not (Test-Path $exe)) { Write-Error "drone.exe not found at $exe"; return } + $raw = $MyInvocation.Line.Trim() + if ($raw -match '^drone\s+(.+)$') { + $argsPart = $Matches[1] + $argsPart = ($argsPart -split '\s*\|\s*')[0].TrimEnd() + cmd /c "`"$exe`" $argsPart" + } else { & $exe } +} +PSWRAP + echo " PowerShell drone wrapper written to $PS_PROFILE" + else + echo " PowerShell drone wrapper already in $PS_PROFILE" + fi else # Linux/macOS: write to ~/.bashrc PROFILE="$HOME/.bashrc" diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py index baecbbb6..cbfaa53d 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py @@ -109,8 +109,16 @@ def _make_fresh_cmd(claude_cmd: list) -> list: def _get_jsonl_projects_dir(cwd: str) -> Path: - """Get Claude's JSONL projects directory for a branch CWD.""" - encoded = cwd.replace("/", "-").replace("_", "-") + """Get Claude's JSONL projects directory for a branch CWD. + + Claude encodes the cwd by replacing path separators and ':' with '-'. + Windows path ``C:\\repo\\AIPass`` becomes ``C--repo-AIPass``. + """ + encoded = (cwd.replace("\\", "-") + .replace("/", "-") + .replace(":", "-") + .replace("_", "-") + .replace(".", "-")) return Path.home() / ".claude" / "projects" / encoded diff --git a/src/aipass/api/tests/test_integrations.py b/src/aipass/api/tests/test_integrations.py index 98dd6aae..ad55af97 100644 --- a/src/aipass/api/tests/test_integrations.py +++ b/src/aipass/api/tests/test_integrations.py @@ -41,7 +41,9 @@ def clean_bridge(): class TestBridge: def test_register_and_resolve(self): """register() then resolve() returns the same callable.""" - fn = lambda: "result" + def fn(): + """Test fixture.""" + return "result" bridge.register("my_contract", fn) assert bridge.resolve("my_contract") is fn @@ -62,8 +64,12 @@ class TestBridge: def test_register_overwrites(self): """Registering same name twice replaces the driver.""" - fn1 = lambda: "first" - fn2 = lambda: "second" + def fn1(): + """First test fixture.""" + return "first" + def fn2(): + """Second test fixture.""" + return "second" bridge.register("dup", fn1) bridge.register("dup", fn2) assert bridge.resolve("dup") is fn2 diff --git a/src/aipass/devpulse/apps/handlers/watchdog/agent.py b/src/aipass/devpulse/apps/handlers/watchdog/agent.py index 4fed05e8..c3289ffe 100644 --- a/src/aipass/devpulse/apps/handlers/watchdog/agent.py +++ b/src/aipass/devpulse/apps/handlers/watchdog/agent.py @@ -89,8 +89,42 @@ def _is_zombie_linux(pid: int) -> bool: return False +def _pid_alive_windows(pid: int) -> bool: + """Windows-safe liveness check via OpenProcess + GetExitCodeProcess.""" + import ctypes + from ctypes import wintypes + + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + STILL_ACTIVE = 259 + + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] # Windows-only + kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel32.OpenProcess.restype = wintypes.HANDLE + kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] + kernel32.GetExitCodeProcess.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return False + try: + exit_code = wintypes.DWORD() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): + return False + return exit_code.value == STILL_ACTIVE + finally: + kernel32.CloseHandle(handle) + + def _pid_alive(pid: int) -> bool: """Return True if the process is alive (not zombie).""" + if sys.platform == "win32": + try: + return _pid_alive_windows(pid) + except Exception as exc: + logger.info("[watchdog.agent] PID %s Windows check failed (assuming alive): %s", pid, exc) + return True try: os.kill(pid, 0) except ProcessLookupError as exc: @@ -99,6 +133,9 @@ def _pid_alive(pid: int) -> bool: except PermissionError as exc: logger.info("[watchdog.agent] PID %s permission denied (alive): %s", pid, exc) return True + except OSError as exc: + logger.info("[watchdog.agent] PID %s os.kill error (assuming dead): %s", pid, exc) + return False if sys.platform == "linux" and _is_zombie_linux(pid): return False return True diff --git a/src/aipass/memory/config/memory_bank.config.example.json b/src/aipass/memory/config/memory_bank.config.example.json new file mode 100644 index 00000000..0cce8885 --- /dev/null +++ b/src/aipass/memory/config/memory_bank.config.example.json @@ -0,0 +1,22 @@ +{ + "memory_pool": { + "enabled": false, + "process_on_startup": false, + "extensions": [".md", ".txt"] + }, + "rollover": { + "defaults": { + "max_lines": 500, + "archive_oldest": 100 + }, + "per_branch": {} + }, + "dashboard_push": { + "enabled": false, + "interval_seconds": 300 + }, + "intake": { + "enabled": false, + "pool_dir": "memory_pool" + } +} diff --git a/src/aipass/prax/apps/handlers/json/json_handler.py b/src/aipass/prax/apps/handlers/json/json_handler.py index 091e4b13..2122e98b 100755 --- a/src/aipass/prax/apps/handlers/json/json_handler.py +++ b/src/aipass/prax/apps/handlers/json/json_handler.py @@ -151,9 +151,15 @@ def load_json(module_name: str, json_type: str) -> Optional[Any]: def _atomic_write(json_path: Path, content: str) -> None: - """Write content to file atomically via temp file + rename.""" + """Write content to file atomically via temp file + rename. + + On Windows, ``os.replace`` can fail with PermissionError (WinError 5) + when another process briefly holds the target file open (e.g. a reader + scan). Retry with exponential backoff before giving up. + """ import os import tempfile + import time fd, tmp_path = tempfile.mkstemp(dir=json_path.parent, suffix='.tmp') try: @@ -161,7 +167,18 @@ def _atomic_write(json_path: Path, content: str) -> None: f.write(content) f.flush() os.fsync(f.fileno()) - os.replace(tmp_path, json_path) + + last_exc: Exception | None = None + for attempt in range(5): + try: + os.replace(tmp_path, json_path) + return + except PermissionError as exc: + last_exc = exc + logger.info("json_handler: os.replace attempt %d failed (PermissionError), retrying: %s", attempt + 1, exc) + time.sleep(0.05 * (2 ** attempt)) + if last_exc is not None: + raise last_exc except Exception: try: os.unlink(tmp_path) diff --git a/src/aipass/prax/apps/handlers/logging/direct.py b/src/aipass/prax/apps/handlers/logging/direct.py index f1694ecd..8110ca28 100644 --- a/src/aipass/prax/apps/handlers/logging/direct.py +++ b/src/aipass/prax/apps/handlers/logging/direct.py @@ -31,7 +31,20 @@ Everyone else should use the regular system_logger. import inspect import logging -from logging.handlers import RotatingFileHandler +from logging.handlers import RotatingFileHandler as _BaseRotatingFileHandler + +logger = logging.getLogger(__name__) + + +class RotatingFileHandler(_BaseRotatingFileHandler): + """Windows-safe RotatingFileHandler — skips rotation on file locking errors.""" + + def doRollover(self) -> None: + """Rotate log file; skip on Windows file-lock errors rather than crash.""" + try: + super().doRollover() + except (PermissionError, OSError) as exc: + logger.warning("Log rotation skipped (file locked): %s", exc) from pathlib import Path from typing import Dict, Optional, Tuple diff --git a/src/aipass/prax/apps/handlers/logging/setup.py b/src/aipass/prax/apps/handlers/logging/setup.py index 092fea72..80e01952 100755 --- a/src/aipass/prax/apps/handlers/logging/setup.py +++ b/src/aipass/prax/apps/handlers/logging/setup.py @@ -55,8 +55,8 @@ except ImportError as e: create_terminal_handler = None # type: ignore[assignment] should_display_terminal = None # type: ignore[assignment] -class _SafeRotatingHandler(RotatingFileHandler): - """RotatingFileHandler that catches PermissionError during rotation on Windows. +class _WindowsSafeRotatingHandler(RotatingFileHandler): + """RotatingFileHandler that catches PermissionError/OSError during rotation on Windows. Windows enforces mandatory file locking — rename fails if another handle has the file open. On rotation failure, skip the rotation and keep writing @@ -64,11 +64,12 @@ class _SafeRotatingHandler(RotatingFileHandler): """ def doRollover(self) -> None: + """Rotate log file; skip on Windows file-lock errors rather than crash.""" try: super().doRollover() - except PermissionError: + except (PermissionError, OSError) as exc: # Windows: file locked by another process. Skip rotation, keep writing. - pass + logger.warning("Log rotation skipped (file locked): %s", exc) def _safe_rotating_handler(log_file: Path, max_bytes: int, backup_count: int) -> logging.Handler: """Create RotatingFileHandler — self-heals missing directories, never crashes.""" @@ -78,7 +79,7 @@ def _safe_rotating_handler(log_file: Path, max_bytes: int, backup_count: int) -> parent.mkdir(parents=True, exist_ok=True) if _system_logger: _system_logger.warning(f"Self-healed missing log directory: {parent}") - return _SafeRotatingHandler(log_file, maxBytes=max_bytes, backupCount=backup_count, encoding='utf-8') + return _WindowsSafeRotatingHandler(log_file, maxBytes=max_bytes, backupCount=backup_count, encoding='utf-8') except OSError as e: logger.error("Log handler failed for %s: %s", log_file, e) return logging.NullHandler() diff --git a/src/aipass/prax/apps/handlers/monitoring/branch_detector.py b/src/aipass/prax/apps/handlers/monitoring/branch_detector.py index c4c221de..da05d826 100644 --- a/src/aipass/prax/apps/handlers/monitoring/branch_detector.py +++ b/src/aipass/prax/apps/handlers/monitoring/branch_detector.py @@ -299,7 +299,7 @@ class BranchDetector: agent_name = parts[2].upper() # Append TESTS suffix when path is clearly test output - path_str_lower = str(path).lower() + path_str_lower = str(path).replace('\\', '/').lower() is_test = ('/tests/' in path_str_lower or '/test_' in path_str_lower or path_str_lower.endswith('_test.py') or path_str_lower.endswith('_test.log')) test_suffix = ' TESTS' if is_test else '' @@ -339,6 +339,10 @@ class BranchDetector: try: path = Path(file_path).resolve() path_str = str(path) + # Normalize to forward slashes for all string-based pattern matching. + # Path.resolve() returns OS-native separators (backslashes on Windows), which + # breaks every hardcoded '/' check. branch_map lookups still use path_str (OS-native). + path_str_fwd = path_str.replace('\\', '/') # Check cache first if path_str in self.log_map: @@ -350,9 +354,9 @@ class BranchDetector: # add the project prefix. Check external paths first to return 'AIPL/POLYGLOT TESTS'. _repo_root = self._find_repo_root() _projects_base = Path.home() / 'Projects' - _path_str_lower = path_str.lower() - _projects_str = str(_projects_base).lower() - _repo_str = str(_repo_root).lower() + _path_str_lower = path_str_fwd.lower() + _projects_str = str(_projects_base).replace('\\', '/').lower() + _repo_str = str(_repo_root).replace('\\', '/').lower() _is_external = ( _path_str_lower.startswith(_projects_str + '/') and not _path_str_lower.startswith(_repo_str + '/') @@ -386,8 +390,8 @@ class BranchDetector: return result # Strategy 3: Claude Code project files - if '.claude/projects/' in path_str: - result = self._detect_from_claude_project(path_str) + if '.claude/projects/' in path_str_fwd: + result = self._detect_from_claude_project(path_str_fwd) if result: self.log_map[path_str] = result return result @@ -405,7 +409,7 @@ class BranchDetector: return 'SYSTEM' # Strategy 6: Parse path for known branch names - path_parts = path_str.lower().split('/') + path_parts = path_str_fwd.lower().split('/') for part in path_parts: branch_upper = part.upper() if branch_upper in self.known_branches: