feat(system): feat(windows): cherry-pick Windows parity fixes from #328 — watchdog ctypes (#323), dispatch monitor projects dir encoding (#324), prax atomic_write retry on PermissionError (#325), prax _WindowsSafeRotatingHandler (#318), branch_detector forward-slash normalize, memory_bank config seed (#322), PS profile wrapper (#319/#340). All Windows-only branches or no-op on Linux. 3146 tests pass, ruff zero. Closes #328 scope. Input-X credit preserved. (#342)

Co-authored-by: @devpulse <devpulse@aipass>
This commit is contained in:
AIPass
2026-04-16 13:41:31 -07:00
committed by GitHub
co-authored by @devpulse
parent fd033672af
commit e4a42f2186
9 changed files with 164 additions and 20 deletions
+36
View File
@@ -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"
@@ -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
+9 -3
View File
@@ -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
@@ -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
@@ -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"
}
}
@@ -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)
@@ -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
@@ -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()
@@ -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: