refactor(aipass): subprocess to drone @spawn sync-registry — kill last cross-branch engine import (TDPLAN-0006 P3)
init_flow.py:896 was the one place aipass imported spawn's Python directly: from aipass.spawn.apps.modules.sync_registry import sync_registry Replaced with subprocess.run(['drone','@spawn','sync-registry','--fix']) — the command spawn already exposes — matching the aipass init agent -> drone @spawn create pattern. Graceful degradation preserved: FileNotFoundError (no drone), non-zero exit, and timeout are all silently skipped so a registry-sync hiccup never hard-fails an init update. Safe because init update runs on an existing project where drone is installed (NOT the pre-drone fresh-init path). aipass branch now has ZERO direct imports of another branch's ENGINE code. Remaining cross-branch imports are shared SERVICE layers only (cli Rich UI, prax logger used in 347 files, trigger events) — infrastructure, not duplication. Verified: zero aipass.spawn imports in .py code; fresh aipass init still scaffolds (bootstrap pre-drone intact, 69 tests); 438 tests green (4 new for the subprocess path: success/failure/missing-drone/timeout); seedgo 100%. TDPLAN-0006 P3. P4 (.recovery relocate) is the last phase. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
14e134b8e6
commit
59a6fcee13
@@ -12,6 +12,17 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
|
||||
|
||||
### Changed
|
||||
|
||||
- **No more cross-branch engine imports — `aipass init update` calls spawn via
|
||||
subprocess (TDPLAN-0006 P3).** `init_flow.py` previously did
|
||||
`from aipass.spawn.apps.modules.sync_registry import sync_registry` — the one
|
||||
place aipass reached directly into spawn's Python. Replaced with a subprocess
|
||||
call to the already-existing `drone @spawn sync-registry --fix` (same pattern as
|
||||
`aipass init agent` → `drone @spawn create`), preserving graceful degradation
|
||||
(a missing `drone`, non-zero exit, or timeout is silently skipped — registry
|
||||
sync never hard-fails an update). The aipass branch now has **zero** direct
|
||||
imports of another branch's engine code; the remaining cross-branch imports are
|
||||
shared service layers only (cli Rich UI, prax logging, trigger events). (438
|
||||
tests, seedgo 100%.)
|
||||
- **`aipass.common` shared library — dedup spawn/aipass scaffold machinery
|
||||
(TDPLAN-0006 P2).** `@spawn` and `@aipass` each carried their own copy of the
|
||||
JSON merge/handler utilities and registry discovery. Extracted them into a new
|
||||
|
||||
@@ -893,12 +893,14 @@ def _handle_init_update(args: list[str]) -> int:
|
||||
console.print(f" ({len(current)} already up to date)")
|
||||
# Heal registry: prune stale entries (e.g. cross-project ../paths)
|
||||
try:
|
||||
from aipass.spawn.apps.modules.sync_registry import sync_registry
|
||||
|
||||
sync_result = sync_registry(fix=True)
|
||||
pruned = sync_result.get("stale", [])
|
||||
if pruned:
|
||||
console.print(f" [green]Registry healed:[/green] removed {len(pruned)} stale entry(ies)")
|
||||
sync_proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--fix"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if sync_proc.returncode == 0:
|
||||
console.print(" [green]Registry synced.[/green]")
|
||||
except Exception as sync_exc:
|
||||
logger.warning("[init_flow] registry sync during update skipped: %s", sync_exc)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from aipass.aipass.apps.modules.init_flow import (
|
||||
TOTAL_STAGES,
|
||||
_get_last_completed_stage,
|
||||
_get_setup_progress,
|
||||
_handle_init_update,
|
||||
_save_stage,
|
||||
handle_command,
|
||||
print_help,
|
||||
@@ -574,3 +575,87 @@ class TestStages:
|
||||
assert result == {}
|
||||
stored = json.loads(tmp_local_json.read_text())
|
||||
assert stored["setup_progress"]["last_completed_stage"] == 12
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# init_update_registry_sync: subprocess_sync
|
||||
# =============================================================================
|
||||
|
||||
|
||||
_MOD_UPDATE = "aipass.aipass.apps.modules.init_flow"
|
||||
|
||||
|
||||
class TestInitUpdateRegistrySync:
|
||||
"""Tests for registry sync subprocess call in _handle_init_update."""
|
||||
|
||||
def test_sync_success_prints_message(self, tmp_path: Path) -> None:
|
||||
"""Successful drone sync-registry prints 'Registry synced.'"""
|
||||
mock_result = MagicMock(returncode=0)
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
|
||||
return_value={"updated_files": [], "already_current": []},
|
||||
),
|
||||
patch(f"{_MOD_UPDATE}.subprocess.run", return_value=mock_result) as mock_run,
|
||||
patch(f"{_MOD_UPDATE}.console") as mock_console,
|
||||
patch(f"{_MOD_UPDATE}.json_handler"),
|
||||
):
|
||||
rc = _handle_init_update([str(tmp_path)])
|
||||
assert rc == 0
|
||||
mock_run.assert_called_once_with(
|
||||
["drone", "@spawn", "sync-registry", "--fix"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)]
|
||||
assert len(sync_calls) == 1
|
||||
|
||||
def test_sync_failure_degrades_silently(self, tmp_path: Path) -> None:
|
||||
"""Non-zero exit from drone sync-registry is silently skipped."""
|
||||
mock_result = MagicMock(returncode=1)
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
|
||||
return_value={"updated_files": [], "already_current": []},
|
||||
),
|
||||
patch(f"{_MOD_UPDATE}.subprocess.run", return_value=mock_result),
|
||||
patch(f"{_MOD_UPDATE}.console") as mock_console,
|
||||
patch(f"{_MOD_UPDATE}.json_handler"),
|
||||
):
|
||||
rc = _handle_init_update([str(tmp_path)])
|
||||
assert rc == 0
|
||||
sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)]
|
||||
assert len(sync_calls) == 0
|
||||
|
||||
def test_sync_missing_drone_degrades_silently(self, tmp_path: Path) -> None:
|
||||
"""FileNotFoundError (no drone binary) degrades gracefully."""
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
|
||||
return_value={"updated_files": [], "already_current": []},
|
||||
),
|
||||
patch(f"{_MOD_UPDATE}.subprocess.run", side_effect=FileNotFoundError("drone not found")),
|
||||
patch(f"{_MOD_UPDATE}.console") as mock_console,
|
||||
patch(f"{_MOD_UPDATE}.json_handler"),
|
||||
):
|
||||
rc = _handle_init_update([str(tmp_path)])
|
||||
assert rc == 0
|
||||
sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)]
|
||||
assert len(sync_calls) == 0
|
||||
|
||||
def test_sync_timeout_degrades_silently(self, tmp_path: Path) -> None:
|
||||
"""subprocess.TimeoutExpired degrades gracefully."""
|
||||
import subprocess as _sp
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
|
||||
return_value={"updated_files": [], "already_current": []},
|
||||
),
|
||||
patch(f"{_MOD_UPDATE}.subprocess.run", side_effect=_sp.TimeoutExpired(cmd="drone", timeout=30)),
|
||||
patch(f"{_MOD_UPDATE}.console"),
|
||||
patch(f"{_MOD_UPDATE}.json_handler"),
|
||||
):
|
||||
rc = _handle_init_update([str(tmp_path)])
|
||||
assert rc == 0
|
||||
|
||||
Reference in New Issue
Block a user