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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user