diff --git a/src/aipass/ai_mail/.seedgo/bypass.json b/src/aipass/ai_mail/.seedgo/bypass.json index 8c268bbf..d4b12982 100644 --- a/src/aipass/ai_mail/.seedgo/bypass.json +++ b/src/aipass/ai_mail/.seedgo/bypass.json @@ -324,6 +324,16 @@ "file": "tests/test_wake.py", "standard": "documentation", "reason": "Test helper functions (_fake_open_factory, _raise_process_lookup, repo_root fixture) are private test infrastructure — docstring requirement does not apply to test helpers." + }, + { + "file": "tests/test_dispatch_watchdog.py", + "standard": "architecture", + "reason": "Test file lives in tests/ directory — not subject to 3-layer app structure rule." + }, + { + "file": "tests/test_dispatch_watchdog.py", + "standard": "encapsulation", + "reason": "Unit tests must access _spawn_watchdog directly to verify internal spawn behavior. Module entry-point-only rule does not apply to tests." } ], "notes": { diff --git a/src/aipass/ai_mail/apps/modules/dispatch.py b/src/aipass/ai_mail/apps/modules/dispatch.py index 2af2c82c..1d6f67e0 100644 --- a/src/aipass/ai_mail/apps/modules/dispatch.py +++ b/src/aipass/ai_mail/apps/modules/dispatch.py @@ -13,6 +13,7 @@ Orchestrates dispatch commands: status tracking and daemon management. Delegates all business logic to handlers. """ +import subprocess import sys from pathlib import Path from typing import List @@ -35,8 +36,9 @@ COMMANDS: dispatch wake @branch - Wake only (no email sent) DISPATCH (send + wake): - drone @ai_mail dispatch @branch "Subject" "Body" # Send + continue wake - drone @ai_mail dispatch @branch "Subject" "Body" --fresh # Send + fresh wake + drone @ai_mail dispatch @branch "Subject" "Body" # Send + wake + watchdog + drone @ai_mail dispatch @branch "Subject" "Body" --no-watchdog # Send + wake, no watchdog + drone @ai_mail dispatch @branch "Subject" "Body" --fresh # Send + fresh wake + watchdog drone @ai_mail dispatch @branch "Subject" "Body" --model opus # Send + wake with Opus drone @ai_mail dispatch @branch "Subject" "Body" --no-memory-save @@ -202,11 +204,57 @@ def _orchestrate_wake(args: List[str]) -> bool: return success +def _spawn_watchdog(target: str, repo_root: Path) -> bool: + """Detach a watchdog process to wake devpulse when the dispatched agent exits. + + Spawns drone @devpulse watchdog agent with cwd=devpulse branch path + so _guard_caller() accepts the cross-branch invocation. The process is fully + detached (start_new_session=True) so dispatch returns immediately. + + Returns True if watchdog was spawned successfully. + """ + import json + + # Locate devpulse path from registry + registry_file = repo_root / "AIPASS_REGISTRY.json" + devpulse_path: Path | None = None + try: + with open(registry_file, "r", encoding="utf-8") as f: + registry = json.load(f) + for branch in registry.get("branches", []): + if branch.get("email", "").lower() == "@devpulse": + p = Path(branch.get("path", "")) + devpulse_path = p if p.is_absolute() else (repo_root / p) + break + except Exception as e: + logger.warning("[dispatch] watchdog auto-spawn: registry lookup failed: %s", e) + return False + + if devpulse_path is None or not devpulse_path.exists(): + logger.warning("[dispatch] watchdog auto-spawn: devpulse path not found in registry") + return False + + try: + proc = subprocess.Popen( + ["drone", "@devpulse", "watchdog", "agent", target], + cwd=str(devpulse_path), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + logger.info("[dispatch] watchdog auto-spawned for %s (PID %d)", target, proc.pid) + return True + except (FileNotFoundError, OSError) as e: + logger.warning("[dispatch] watchdog auto-spawn failed for %s: %s", target, e) + return False + + def _orchestrate_dispatch_send(args: List[str]) -> bool: """Orchestrate combined dispatch: send email with --dispatch flag + wake branch.""" # Parse flags use_fresh = False no_memory_save = False + no_watchdog = False from_branch = None use_model = None filtered = [] @@ -220,6 +268,10 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool: no_memory_save = True i += 1 continue + if args[i] == "--no-watchdog": + no_watchdog = True + i += 1 + continue if args[i] == "--from" and i + 1 < len(args): from_branch = args[i + 1] i += 2 @@ -324,7 +376,15 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool: console.print(dispatch_status.format()) if not wake_ok: - console.print(f"[yellow]Email sent but wake failed — retry: drone @ai_mail dispatch wake {target}[/yellow]") + logger.warning("[dispatch] Wake failed for %s — email was sent", target) + error(f"Email sent but wake failed — retry: drone @ai_mail dispatch wake {target}") + + # --- Step 3: Auto-spawn watchdog (skipped if wake failed or --no-watchdog) --- + if wake_ok and not no_watchdog: + if _spawn_watchdog(target, _repo_root): + console.print(f"[dim]Watchdog armed for {target}[/dim]") + else: + logger.info("[dispatch] Watchdog auto-spawn skipped (devpulse not found or spawn failed)") return True diff --git a/src/aipass/ai_mail/tests/test_dispatch_watchdog.py b/src/aipass/ai_mail/tests/test_dispatch_watchdog.py new file mode 100644 index 00000000..46df4e3e --- /dev/null +++ b/src/aipass/ai_mail/tests/test_dispatch_watchdog.py @@ -0,0 +1,121 @@ +# =================== AIPass ==================== +# Name: test_dispatch_watchdog.py +# Description: Tests for watchdog auto-spawn in dispatch pipeline +# Version: 1.0.0 +# Created: 2026-04-19 +# Modified: 2026-04-19 +# ============================================= + +"""Tests for _spawn_watchdog() — watchdog auto-spawn in dispatch pipeline.""" + +import json +from unittest.mock import patch, MagicMock +import pytest +from pathlib import Path + +import aipass.ai_mail.apps.modules.dispatch as dispatch_mod + + +# Retrieve private function via module attribute access +_spawn_watchdog = getattr(dispatch_mod, "_spawn_watchdog") + +_POPEN_PATH = "aipass.ai_mail.apps.modules.dispatch.subprocess.Popen" + + +@pytest.fixture(autouse=True) +def _suppress_log_operation(monkeypatch): + """Prevent json_handler.log_operation from touching real files.""" + monkeypatch.setattr( + "aipass.ai_mail.apps.modules.dispatch.json_handler.log_operation", + lambda *a, **kw: None, + ) + + +class TestSpawnWatchdog: + """Tests for _spawn_watchdog() — registry lookup + detached Popen.""" + + def test_spawns_with_devpulse_cwd(self, tmp_path): + """_spawn_watchdog sets cwd=devpulse_path when spawning.""" + devpulse_path = tmp_path / "devpulse" + devpulse_path.mkdir() + registry_file = tmp_path / "AIPASS_REGISTRY.json" + registry_file.write_text( + json.dumps({"branches": [{"name": "DEVPULSE", "email": "@devpulse", "path": str(devpulse_path)}]}), + encoding="utf-8", + ) + + fake_proc = MagicMock() + fake_proc.pid = 42 + + with patch(_POPEN_PATH, return_value=fake_proc) as mock_popen: + result = _spawn_watchdog("@drone", tmp_path) + + assert result is True + mock_popen.assert_called_once() + call_kwargs = mock_popen.call_args + assert call_kwargs[0][0] == ["drone", "@devpulse", "watchdog", "agent", "@drone"] + assert call_kwargs[1]["cwd"] == str(devpulse_path) + assert call_kwargs[1]["start_new_session"] is True + + def test_returns_false_when_devpulse_not_in_registry(self, tmp_path): + """Returns False when devpulse not found in registry.""" + registry_file = tmp_path / "AIPASS_REGISTRY.json" + registry_file.write_text( + json.dumps({"branches": [{"name": "DRONE", "email": "@drone", "path": str(tmp_path / "drone")}]}), + encoding="utf-8", + ) + + result = _spawn_watchdog("@drone", tmp_path) + assert result is False + + def test_returns_false_when_registry_missing(self, tmp_path): + """Returns False when AIPASS_REGISTRY.json does not exist.""" + result = _spawn_watchdog("@drone", tmp_path) + assert result is False + + def test_returns_false_when_devpulse_path_missing(self, tmp_path): + """Returns False when devpulse path from registry does not exist on disk.""" + registry_file = tmp_path / "AIPASS_REGISTRY.json" + registry_file.write_text( + json.dumps( + {"branches": [{"name": "DEVPULSE", "email": "@devpulse", "path": str(tmp_path / "nonexistent")}]} + ), + encoding="utf-8", + ) + + result = _spawn_watchdog("@drone", tmp_path) + assert result is False + + def test_returns_false_when_drone_not_found(self, tmp_path): + """Returns False when 'drone' binary not on PATH (FileNotFoundError).""" + devpulse_path = tmp_path / "devpulse" + devpulse_path.mkdir() + registry_file = tmp_path / "AIPASS_REGISTRY.json" + registry_file.write_text( + json.dumps({"branches": [{"name": "DEVPULSE", "email": "@devpulse", "path": str(devpulse_path)}]}), + encoding="utf-8", + ) + + with patch(_POPEN_PATH, side_effect=FileNotFoundError("drone not found")): + result = _spawn_watchdog("@drone", tmp_path) + + assert result is False + + def test_resolves_relative_path_in_registry(self, tmp_path): + """Resolves relative devpulse path relative to repo_root.""" + devpulse_path = tmp_path / "src" / "devpulse" + devpulse_path.mkdir(parents=True) + registry_file = tmp_path / "AIPASS_REGISTRY.json" + registry_file.write_text( + json.dumps({"branches": [{"name": "DEVPULSE", "email": "@devpulse", "path": "src/devpulse"}]}), + encoding="utf-8", + ) + + fake_proc = MagicMock() + fake_proc.pid = 99 + + with patch(_POPEN_PATH, return_value=fake_proc) as mock_popen: + result = _spawn_watchdog("@flow", tmp_path) + + assert result is True + assert mock_popen.call_args[1]["cwd"] == str(devpulse_path)