feat(dispatch): auto-spawn watchdog after every dispatch (FPLAN-0189 Task A) (#350)
* feat(devpulse): watchdog: breadcrumb on exit + default timeout 1800s to 600s (FPLAN-0189) Co-Authored-By: @devpulse <devpulse@aipass> * feat(dispatch): auto-spawn watchdog after every dispatch (FPLAN-0189 Task A) _orchestrate_dispatch_send now spawns `drone @devpulse watchdog agent <target>` as a detached background process (start_new_session=True) after a successful wake. cwd=devpulse_path bypasses _guard_caller's cross-branch rejection. --no-watchdog flag opts out. 6 new tests cover registry lookup, path resolution, and error paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: @devpulse <devpulse@aipass> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
@devpulse
parent
905f2a45fb
commit
1253ee8f09
@@ -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": {
|
||||
|
||||
@@ -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 <target> 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user