#684 os.kill(pid,0) fleet migration: Windows-guard 8 liveness probes — @ai_mail (daemon.py x2, wake.py x2), @drone (lock_handler.py), @flow (lock_ops.py), @hooks (cc_sessions.py, presence.py). Each early-returns to OpenProcess+GetExitCodeProcess on win32 (os.kill(pid,0)=TerminateProcess, KILLS target). Fleet sweep now clears all but git_lock_tool.py (deferred #687). ai_mail 168 / flow 17 tests green, drone/hooks import-clean.

This commit is contained in:
AIOSAI
2026-07-10 18:58:49 -07:00
parent d872d7101f
commit 663c801c05
9 changed files with 301 additions and 73 deletions
@@ -86,6 +86,56 @@ def _write_json(filepath: Path, data: Dict[str, Any]) -> 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]
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."""
if sys.platform == "win32":
try:
return _pid_alive_windows(pid)
except Exception as exc:
logger.info("[daemon] PID %s Windows check failed (assuming alive): %s", pid, exc)
return True
try:
os.kill(pid, 0)
except ProcessLookupError as exc:
logger.info("[daemon] PID %s not found: %s", pid, exc)
return False
except PermissionError as exc:
logger.info("[daemon] PID %s permission denied (alive): %s", pid, exc)
return True
except OSError as exc:
logger.info("[daemon] PID %s os.kill error (assuming dead): %s", pid, exc)
return False
return True
def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
"""Check if branch has an active dispatch lock. Returns lock data or None."""
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
@@ -96,14 +146,9 @@ def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
data = json.load(f)
pid = data.get("pid")
if pid is not None:
try:
os.kill(pid, 0)
return data # Process alive, lock valid
except ProcessLookupError:
logger.info("Lock PID %s dead — stale lock cleanup needed", pid)
except PermissionError as e:
logger.warning("[daemon] Lock PID %s permission error: %s", pid, e)
return data # Process exists, can't signal
if _pid_alive(pid):
return data
logger.info("Lock PID %s dead — stale lock cleanup needed", pid)
# Stale lock — check age (10 min timeout)
ts = data.get("timestamp", "")
if ts:
@@ -214,15 +259,10 @@ def _write_pid_file() -> bool:
# PID file exists — check if the owning process is alive
try:
old_pid = int(DAEMON_PID_FILE.read_text().strip())
try:
os.kill(old_pid, 0)
logger.info(f"Another daemon already running (PID {old_pid}). Exiting.")
return False
except ProcessLookupError:
logger.info(f"Removing stale PID file (PID {old_pid} is dead)")
except PermissionError:
logger.info(f"Another daemon already running (PID {old_pid}, permission denied). Exiting.")
if _pid_alive(old_pid):
logger.info("Another daemon already running (PID %s). Exiting.", old_pid)
return False
logger.info("Removing stale PID file (PID %s is dead)", old_pid)
except (ValueError, OSError):
logger.info("Corrupt PID file — removing")
@@ -141,6 +141,34 @@ def _read_json(filepath: Path) -> Optional[dict]:
return None
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]
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 _check_lock(branch_path: Path) -> Optional[dict]:
"""Check if branch has an active dispatch lock. Returns lock data or None."""
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
@@ -151,14 +179,9 @@ def _check_lock(branch_path: Path) -> Optional[dict]:
data = json.load(f)
pid = data.get("pid")
if pid is not None:
try:
os.kill(pid, 0)
return data # Process alive, lock valid
except ProcessLookupError:
logger.info("[wake] Lock PID %s dead — cleaning stale lock", pid)
except PermissionError as e:
logger.warning("[wake] Lock PID %s permission error: %s", pid, e)
return data # Process exists but can't signal — treat as active
if _check_pid_alive(pid):
return data
logger.info("[wake] Lock PID %s dead — cleaning stale lock", pid)
# Stale lock — check age (10 min timeout)
ts = data.get("timestamp", "")
if ts:
@@ -273,21 +296,33 @@ def _clean_zombies() -> int:
def _check_pid_alive(pid: int) -> bool:
"""Check if a process is alive (not zombie)."""
if sys.platform == "win32":
try:
return _pid_alive_windows(pid)
except Exception as exc:
logger.info("[wake] PID %s Windows check failed (assuming alive): %s", pid, exc)
return True
try:
os.kill(pid, 0)
# Also verify not zombie via /proc (Linux only)
if sys.platform == "linux":
except ProcessLookupError as exc:
logger.warning("[wake] PID %s not found: %s", pid, exc)
return False
except PermissionError as exc:
logger.warning("[wake] PID %s permission denied: %s", pid, exc)
return True
except OSError as exc:
logger.warning("[wake] PID %s os.kill error (assuming dead): %s", pid, exc)
return False
if sys.platform == "linux":
try:
with open(f"/proc/{pid}/status", "r") as f:
for line in f:
if line.startswith("State:"):
return "Z" not in line
return True
except (ProcessLookupError, FileNotFoundError) as e:
logger.warning("[wake] PID %s not found: %s", pid, e)
return False
except PermissionError as e:
logger.warning("[wake] PID %s permission denied: %s", pid, e)
return True # Exists but can't check — assume alive
except FileNotFoundError as exc:
logger.warning("[wake] PID %s /proc not found: %s", pid, exc)
return False
return True
def _spawn_in_systemd_scope(monitor_cmd, branch_path, spawn_env, branch_email, lock_file_path, custom_message, status):
+6 -18
View File
@@ -814,7 +814,7 @@ def test_check_lock_alive_pid(tmp_path, monkeypatch):
lock_data = {"pid": 99999, "timestamp": datetime.now().isoformat()}
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
monkeypatch.setattr(os, "kill", lambda pid, sig: None)
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: True)
result = _check_lock(tmp_path)
@@ -823,17 +823,14 @@ def test_check_lock_alive_pid(tmp_path, monkeypatch):
def test_check_lock_dead_pid(tmp_path, monkeypatch):
"""Lock with dead PID (ProcessLookupError) is cleaned up."""
"""Lock with dead PID is cleaned up."""
lock_dir = tmp_path / ".ai_mail.local"
lock_dir.mkdir(parents=True)
lock_file = lock_dir / ".dispatch.lock"
lock_data = {"pid": 99999, "timestamp": datetime.now().isoformat()}
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
def _raise_process_lookup(pid, sig):
raise ProcessLookupError("No such process")
monkeypatch.setattr(os, "kill", _raise_process_lookup)
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: False)
result = _check_lock(tmp_path)
@@ -849,10 +846,7 @@ def test_check_lock_permission_error(tmp_path, monkeypatch):
lock_data = {"pid": 99999, "timestamp": datetime.now().isoformat()}
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
def _raise_permission(pid, sig):
raise PermissionError("Operation not permitted")
monkeypatch.setattr(os, "kill", _raise_permission)
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: True)
result = _check_lock(tmp_path)
@@ -869,10 +863,7 @@ def test_check_lock_stale_over_10min_removed(tmp_path, monkeypatch):
lock_data = {"pid": 99999, "timestamp": old_time}
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
def _raise_process_lookup(pid, sig):
raise ProcessLookupError("No such process")
monkeypatch.setattr(os, "kill", _raise_process_lookup)
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: False)
result = _check_lock(tmp_path)
@@ -889,10 +880,7 @@ def test_check_lock_stale_under_10min_dead_pid_removed(tmp_path, monkeypatch):
lock_data = {"pid": 99999, "timestamp": recent_time}
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
def _raise_process_lookup(pid, sig):
raise ProcessLookupError("No such process")
monkeypatch.setattr(os, "kill", _raise_process_lookup)
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: False)
result = _check_lock(tmp_path)
+2 -2
View File
@@ -222,7 +222,7 @@ def test_check_lock_alive_pid(tmp_path, monkeypatch):
lock_file = lock_dir / ".dispatch.lock"
lock_data = {"pid": 1234, "timestamp": "2026-03-29T10:00:00"}
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
monkeypatch.setattr(os, "kill", lambda pid, sig: None)
monkeypatch.setattr(wake_mod, "_check_pid_alive", lambda pid: True)
result = _check_lock(tmp_path)
assert result is not None
assert result["pid"] == 1234
@@ -235,7 +235,7 @@ def test_check_lock_dead_pid_removes_lock(tmp_path, monkeypatch):
lock_file = lock_dir / ".dispatch.lock"
lock_data = {"pid": 99999, "timestamp": "2026-03-29T10:00:00"}
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
monkeypatch.setattr(os, "kill", _raise_process_lookup)
monkeypatch.setattr(wake_mod, "_check_pid_alive", lambda pid: False)
result = _check_lock(tmp_path)
assert result is None
assert not lock_file.exists()
@@ -19,6 +19,7 @@ from __future__ import annotations
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
@@ -29,6 +30,57 @@ _LOCK_FILENAME = ".git_pr.lock"
_STALE_THRESHOLD_SECONDS = 600
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]
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. Platform-guarded: Windows uses
OpenProcess (os.kill on win32 calls TerminateProcess — kills the target)."""
if sys.platform == "win32":
try:
return _pid_alive_windows(pid)
except Exception as exc:
logger.info("_pid_alive: PID %s Windows check failed (assuming alive): %s", pid, exc)
return True
try:
os.kill(pid, 0)
except ProcessLookupError:
logger.info("_pid_alive: PID %s not found", pid)
return False
except PermissionError:
logger.info("_pid_alive: PID %s permission denied (alive)", pid)
return True
except OSError as exc:
logger.info("_pid_alive: PID %s OSError (assuming dead): %s", pid, exc)
return False
return True
def find_repo_root() -> Path:
"""Walk up from CWD looking for AIPASS_REGISTRY.json, fallback to git rev-parse."""
cwd = Path.cwd()
@@ -183,19 +235,9 @@ def check_lock_status() -> dict:
# Check if PID is still alive (orphan detection)
orphaned = False
if pid:
try:
os.kill(pid, 0)
except ProcessLookupError:
logger.info("check_lock_status: PID %d not found — lock is orphaned", pid)
orphaned = True
except PermissionError as exc:
# Process exists but we can't signal it — not orphaned
logger.warning("check_lock_status: PID %d exists but permission denied for signal check: %s", pid, exc)
except OSError:
# On Windows, os.kill(pid, 0) raises OSError for non-existent PIDs
logger.info("check_lock_status: PID %d not found (OSError) — lock is orphaned", pid)
orphaned = True
if pid and not _pid_alive(pid):
logger.info("check_lock_status: PID %d not alive — lock is orphaned", pid)
orphaned = True
status = "active"
if orphaned:
@@ -19,6 +19,7 @@ Usage:
"""
import os
import sys
from pathlib import Path
from aipass.prax import logger
@@ -26,6 +27,57 @@ from aipass.prax import logger
from aipass.flow.apps.handlers.json import json_handler
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. Platform-guarded: win32 uses
OpenProcess instead of os.kill (which terminates on Windows)."""
if sys.platform == "win32":
try:
return _pid_alive_windows(pid)
except Exception as exc:
logger.info("PID %s Windows check failed (assuming alive): %s", pid, exc)
return True
try:
os.kill(pid, 0)
except ProcessLookupError as exc:
logger.info("PID %s not found: %s", pid, exc)
return False
except PermissionError as exc:
logger.info("PID %s permission denied (alive): %s", pid, exc)
return True
except OSError as exc:
logger.info("PID %s os.kill error (assuming dead): %s", pid, exc)
return False
return True
def try_create_lock(lock_file: Path) -> bool:
"""Atomically create lock file with current PID. Returns True on success."""
try:
@@ -42,12 +94,14 @@ def is_lock_stale(lock_file: Path) -> bool:
"""Check if existing lock file belongs to a dead process."""
try:
pid = int(lock_file.read_text(encoding="utf-8").strip())
os.kill(pid, 0)
except (ValueError, OSError):
logger.info("Stale lock found (unreadable), taking over: %s", lock_file)
return True
if _pid_alive(pid):
logger.info("Another instance running (PID %d), lock valid: %s", pid, lock_file)
return False
except (ValueError, ProcessLookupError, PermissionError):
logger.info("Stale lock found, taking over: %s", lock_file)
return True
logger.info("Stale lock found (PID %d dead), taking over: %s", pid, lock_file)
return True
def acquire_lock(lock_file: Path) -> bool:
+3 -3
View File
@@ -99,14 +99,14 @@ class TestIsLockStale:
result = mod.is_lock_stale(lock)
assert result is True
def test_permission_error_treated_as_stale(self, tmp_path):
"""PermissionError from os.kill should treat lock as stale."""
def test_permission_error_treated_as_alive(self, tmp_path):
"""PermissionError from os.kill means process exists — lock valid."""
mod = _import_lock_ops()
lock = tmp_path / ".test.lock"
lock.write_text("1", encoding="utf-8")
with patch(f"{_MOD}.os.kill", side_effect=PermissionError):
result = mod.is_lock_stale(lock)
assert result is True
assert result is False
# ═══════════════════════════════════════════════════════════
@@ -20,6 +20,7 @@ Used by presence_gate to source truth instead of PRESENCE.central.json.
import json
import os
import sys
from pathlib import Path
from aipass.cli.apps.modules import err_console
@@ -30,10 +31,44 @@ CONSOLE = err_console
CC_SESSIONS_DIR = Path.home() / ".claude" / "sessions"
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 _is_pid_alive(pid: int) -> bool:
"""Check if a process with the given PID exists."""
if pid <= 1:
return False
if sys.platform == "win32":
try:
return _pid_alive_windows(pid)
except Exception as exc:
logger.info("[CC_SESSIONS] PID %d Windows check failed (assuming alive): %s", pid, exc)
return True
try:
os.kill(pid, 0)
return True
+34
View File
@@ -187,8 +187,42 @@ def _resolve_session_pid() -> int | None:
# ---------------------------------------------------------------------------
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 _is_pid_alive(pid: int) -> bool:
"""Check if a process with the given PID exists."""
if sys.platform == "win32":
try:
return _pid_alive_windows(pid)
except Exception as exc:
logger.info("[PRESENCE] PID %d Windows check failed (assuming alive): %s", pid, exc)
return True
try:
os.kill(pid, 0)
return True