From d511576fc0d29b68c62b5ca852eeb0b498cd7d01 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Sat, 11 Jul 2026 17:15:47 -0700 Subject: [PATCH] DPLAN-0239 owner seating permanent+self-healing, fixes #693 (VERA seated, is_owner @vera=True). ROOT CAUSE: pre-2026-07-10 projects seated owner only in the self-editable passport, never the sealed registry (old ensure_project_has_owner bailed on passport owner:true without writing registry) -> 8/8 external projects unseated, get_owner None -> guard refused @vera watchdog/feedback/wake; + registry_id was a PROJECT id copied everywhere (13 AIPass entries shared one, metadata.id absent), drifting on registry recreation. IDENTITY MODEL (Patrick ruled): metadata.id=project credential (passports conform, majority-consensus restore); entry registry_id=set-once PER-CITIZEN UUID minted at add_to_registry; entry owner:true=the gate, ONE heuristic pick_owner_branch (manager->passport-owner->first-created) shared by create+reconcile. NEW: spawn sync-registry --check(--json, 7 flags, pinned schema)/--fix(--dry-run fully read-only, idempotent, never moves a seated owner); aipass doctor renders flags, doctor --fix/install/init-update delegate repair to spawn (the missing 0231 PART-4 retro-trigger, works from external project dirs); adopt path now seats; placeholders resolves registry from target dir not CWD, fails loud; hooks auto_watchdog injects real Monitor-tool command w/ @target (was dead one-liner + run_in_background which cannot wake). 4 dispatch rounds; devpulse verify caught 4 gaps round-1 tests missed (schema divergence->pinned v2, dry-run wrote via old-sync fix=True, unanimous->majority consensus vs BACKUP outlier, dual seating heuristic). DEPLOYED: AIPass dogfooded (restore metadata.id 7087bb93 majority 13/14, 16 citizen UIDs, --check clean, doctor owner OK) + 6 external projects reconciled/verified clean incl Vera-Studio (Seat VERA + 3 UIDs). Suites: spawn 343, aipass 673, hooks 961; full-repo 9364 pass (1 pre-existing skills litter -> #694). CHANGELOG updated. --- CHANGELOG.md | 29 + src/aipass/aipass/apps/modules/doctor.py | 78 ++- src/aipass/aipass/apps/modules/init_flow.py | 26 +- src/aipass/aipass/apps/modules/install.py | 37 ++ src/aipass/aipass/tests/test_doctor.py | 152 +++++ src/aipass/aipass/tests/test_init_flow.py | 70 ++- src/aipass/aipass/tests/test_install.py | 60 +- .../apps/handlers/lifecycle/auto_watchdog.py | 17 +- src/aipass/hooks/tests/test_auto_watchdog.py | 22 + src/aipass/spawn/README.md | 7 +- .../spawn/apps/handlers/placeholders.py | 13 +- src/aipass/spawn/apps/handlers/registry.py | 138 +++-- .../spawn/apps/handlers/sync_registry_ops.py | 297 ++++++++++ src/aipass/spawn/apps/modules/core.py | 5 +- .../spawn/apps/modules/sync_registry.py | 113 +++- .../.aipass/aipass_local_prompt.md | 1 + .../.spawn/.template_registry.json | 2 +- .../spawn/tests/test_check_fix_identity.py | 556 ++++++++++++++++++ src/aipass/spawn/tests/test_owner_resolver.py | 250 +++++--- 19 files changed, 1665 insertions(+), 208 deletions(-) create mode 100644 src/aipass/spawn/tests/test_check_fix_identity.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 17f0d9d5..0e1d6b0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,35 @@ PyPI version — not the changelog header. ## [2026-07-11] +### Added + +- **Owner seating made permanent + self-healing for every project (DPLAN-0239, + fixes #693).** The owner-capability guard was correct but the DATA was never + seeded: every project created before 2026-07-10 had its owner only in the + self-editable passport, never in the sealed registry (8/8 external projects + unseated; AIPass's own registry was missing `metadata.id` with 13 entries + sharing one stale id). Identity model settled: registry `metadata.id` = + project credential (passports conform); branch-entry `registry_id` = + set-once PER-CITIZEN UUID minted at entry creation; entry `owner:true` = + the authority gate (first agent), chosen by ONE shared heuristic + (`pick_owner_branch`: manager → passport owner → first-created). + New: `drone @spawn sync-registry --check [--json]` (read-only, 7 health + flags, pinned JSON schema) and `--fix [--dry-run]` (idempotent reconcile: + seat owner, majority-consensus restore of `metadata.id`, mint citizen UIDs, + align passports; dry-run fully read-only; never moves a seated owner). + `aipass doctor` renders owner health per flag; `doctor --fix`, `install`, + and `init update` delegate repair to spawn — existing/external projects + self-heal on next update (the missing DPLAN-0231 PART-4 trigger). The adopt + path now seats owners; `placeholders.py` resolves the registry from the + target dir (was CWD) and fails loud. @hooks `auto_watchdog` now injects the + real Monitor-tool watchdog command with the actual @target (was a dead + one-liner + `run_in_background`, which cannot wake a session). Deployed + live: AIPass + 6 external projects reconciled and verified clean — VERA is + now seated owner of Vera Studio (`is_owner('@vera') = True`, was refused). + Owners built (spawn 343 / aipass 673 / hooks 961 tests green); devpulse + verified every diff, live-ran every stage, full-repo sweep 9364 passed + (1 pre-existing skills litter fail → #694). + ### Changed - **Fleet seedgo compliance sweep — every branch to 100% (issues #686, #661).** diff --git a/src/aipass/aipass/apps/modules/doctor.py b/src/aipass/aipass/apps/modules/doctor.py index c2286db6..5dc4bac7 100644 --- a/src/aipass/aipass/apps/modules/doctor.py +++ b/src/aipass/aipass/apps/modules/doctor.py @@ -192,6 +192,76 @@ def _check_global_aipass_home() -> List[CheckResult]: return results +def _check_owner_seating() -> List[CheckResult]: + """Check owner/identity health via the frozen sync-registry --check contract.""" + try: + proc = subprocess.run( + ["drone", "@spawn", "sync-registry", "--check", "--json"], + capture_output=True, + text=True, + timeout=30, + ) + except FileNotFoundError: + logger.info("[doctor] drone not on PATH — skipping owner seating check") + return [CheckResult("owner", GLYPH_WARN, "drone not found", "Install drone to check owner seating")] + except subprocess.TimeoutExpired: + logger.warning("[doctor] sync-registry --check timed out") + return [CheckResult("owner", GLYPH_WARN, "check timed out", "")] + + stdout = proc.stdout.strip() + if not stdout: + if proc.returncode == 0: + return [CheckResult("owner", GLYPH_PASS, "clean (no details)", "")] + return [CheckResult("owner", GLYPH_WARN, "no output from check", "")] + + try: + data = json.loads(stdout) + except json.JSONDecodeError: + logger.warning("[doctor] sync-registry --check returned non-JSON: %s", stdout[:200]) + return [CheckResult("owner", GLYPH_WARN, "unparseable check output", "")] + + issues = data.get("issues", []) + owner_name = data.get("owner") + owner_uid = data.get("owner_uid", "") + uid_short = owner_uid[:8] if owner_uid else "" + + if data.get("clean", False) and not issues: + detail = f"@{owner_name} OK (seated, uid {uid_short})" if owner_name else "OK" + return [CheckResult("owner", GLYPH_PASS, detail, "")] + + results: List[CheckResult] = [] + for issue in issues: + flag = issue.get("flag", "unknown") + detail = issue.get("detail", flag) + results.append(CheckResult(f"owner/{flag}", GLYPH_FAIL, detail, "Run 'aipass doctor --fix'")) + + if not results: + label = f"@{owner_name} ISSUES" if owner_name else "UNSEATED" + results.append(CheckResult("owner", GLYPH_FAIL, label, "Run 'aipass doctor --fix'")) + + return results + + +def _fix_owner_seating() -> List[CheckResult]: + """Delegate owner/identity repair to spawn's sync-registry --fix.""" + try: + proc = subprocess.run( + ["drone", "@spawn", "sync-registry", "--fix"], + capture_output=True, + text=True, + timeout=60, + ) + except FileNotFoundError: + return [CheckResult("owner fix", GLYPH_WARN, "drone not found", "")] + except subprocess.TimeoutExpired: + return [CheckResult("owner fix", GLYPH_WARN, "fix timed out", "")] + + if proc.returncode == 0: + return [CheckResult("owner fix", GLYPH_PASS, "registry reconciled", "")] + detail = proc.stderr.strip()[:120] if proc.stderr else "non-zero exit" + return [CheckResult("owner fix", GLYPH_FAIL, detail, "")] + + def _check_identity() -> List[CheckResult]: """Run Identity group checks.""" results: List[CheckResult] = [] @@ -265,6 +335,8 @@ def _check_identity() -> List[CheckResult]: else: results.append(CheckResult("passport", GLYPH_WARN, "not found", "")) + results.extend(_check_owner_seating()) + return results @@ -951,6 +1023,10 @@ def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = Fal services = groups.get("Services", []) groups["Services"] = [r for r in services if r.label != "wire verify"] + wire_recheck + owner_fix = _fix_owner_seating() + identity = groups.get("Identity", []) + groups["Identity"] = [r for r in identity if not r.label.startswith("owner")] + owner_fix + pass_count = 0 warn_count = 0 error_count = 0 @@ -1003,7 +1079,7 @@ def print_help() -> None: console.print("[yellow]USAGE:[/yellow]") console.print(" [green]aipass doctor[/green] [dim]# Run all checks[/dim]") console.print(" [green]aipass doctor --verbose[/green] [dim]# Show sub-check detail[/dim]") - console.print(" [green]aipass doctor --fix[/green] [dim]# Auto-wire + remediation report[/dim]") + console.print(" [green]aipass doctor --fix[/green] [dim]# Auto-wire, owner seat repair + remediation[/dim]") console.print(" [green]aipass doctor --fix --json[/green][dim]# Remediation as JSON (for spawn)[/dim]") console.print(" [green]aipass doctor --cross-os[/green][dim]# OS-gap + routing/version/hooks pre-flight[/dim]") console.print(" [green]aipass doctor --cross-os --e2e[/green][dim]# …also run the heavy e2e suite[/dim]") diff --git a/src/aipass/aipass/apps/modules/init_flow.py b/src/aipass/aipass/apps/modules/init_flow.py index 06f4e36a..be558d92 100644 --- a/src/aipass/aipass/apps/modules/init_flow.py +++ b/src/aipass/aipass/apps/modules/init_flow.py @@ -959,16 +959,32 @@ def _handle_init_update(args: list[str]) -> int: success("All files already current.") if current: console.print(f" ({len(current)} already up to date)") - # Heal registry: prune stale entries (e.g. cross-project ../paths) + # Owner/identity check + heal via the frozen sync-registry contract try: - sync_proc = subprocess.run( - ["drone", "@spawn", "sync-registry", "--fix"], + check_proc = subprocess.run( + ["drone", "@spawn", "sync-registry", "--check"], capture_output=True, text=True, timeout=30, ) - if sync_proc.returncode == 0: - success("Registry synced.") + if check_proc.returncode != 0: + warning("Owner/identity issues detected — auto-repairing…") + fix_proc = subprocess.run( + ["drone", "@spawn", "sync-registry", "--fix"], + capture_output=True, + text=True, + timeout=60, + ) + if fix_proc.returncode == 0: + success("Registry owner/identity reconciled.") + else: + logger.warning("[init_flow] sync-registry --fix exit %s", fix_proc.returncode) + else: + success("Owner/identity OK.") + except FileNotFoundError: + logger.info("[init_flow] drone not on PATH — skipping owner check") + except subprocess.TimeoutExpired: + logger.warning("[init_flow] sync-registry timed out during update") except Exception as sync_exc: logger.warning("[init_flow] registry sync during update skipped: %s", sync_exc) diff --git a/src/aipass/aipass/apps/modules/install.py b/src/aipass/aipass/apps/modules/install.py index 8deda570..939a62b9 100644 --- a/src/aipass/aipass/apps/modules/install.py +++ b/src/aipass/aipass/apps/modules/install.py @@ -242,6 +242,39 @@ def _handoff_to_init( warning(f"Could not launch init: {exc}. Run 'aipass init run' in {project_dir} yourself.") +def _check_and_fix_owner(home: Path) -> None: + """Run sync-registry --check; if issues found, auto-heal with --fix.""" + try: + check_proc = subprocess.run( + ["drone", "@spawn", "sync-registry", "--check"], + capture_output=True, + text=True, + timeout=30, + cwd=str(home), + ) + if check_proc.returncode != 0: + warning("Owner/identity issues detected — auto-repairing…") + fix_proc = subprocess.run( + ["drone", "@spawn", "sync-registry", "--fix"], + capture_output=True, + text=True, + timeout=60, + cwd=str(home), + ) + if fix_proc.returncode == 0: + success("Registry owner/identity reconciled.") + else: + logger.warning("[install] sync-registry --fix exit %s", fix_proc.returncode) + else: + success("Owner/identity OK.") + except FileNotFoundError: + logger.info("[install] drone not on PATH — skipping owner check") + except subprocess.TimeoutExpired: + logger.warning("[install] sync-registry timed out during install") + except Exception as exc: + logger.warning("[install] owner check skipped: %s", exc) + + def _resolve_project_dir(project: str | None, non_interactive: bool) -> Path | None: """Resolve the first-project directory — --project / prompt / DEFAULT_PROJECT.""" if project: @@ -317,6 +350,10 @@ def run_install( console.print(render_step_header(3, TOTAL_STEPS, "Verifying install")) bins = _verify_binaries(home) if not dry_run else {"drone": "dry-run", "aipass": "dry-run"} + # Owner/identity retro-trigger — check and self-heal via spawn + if not dry_run: + _check_and_fix_owner(home) + # Step 4 — hand off into init (or print next steps) console.print() console.print(render_step_header(4, TOTAL_STEPS, "First project")) diff --git a/src/aipass/aipass/tests/test_doctor.py b/src/aipass/aipass/tests/test_doctor.py index 7213fb11..73c2b407 100644 --- a/src/aipass/aipass/tests/test_doctor.py +++ b/src/aipass/aipass/tests/test_doctor.py @@ -961,3 +961,155 @@ class TestCheckGlobalAipassHome: with patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path): results = _check_global_aipass_home() assert results == [] + + +# --------------------------------------------------------------------------- +# _check_owner_seating / _fix_owner_seating tests (DPLAN-0239 P3+P5) +# --------------------------------------------------------------------------- + + +class TestCheckOwnerSeating: + """Tests for owner/identity detection via sync-registry --check.""" + + def test_clean_owner_returns_pass(self): + from aipass.aipass.apps.modules.doctor import _check_owner_seating + + check_json = json.dumps({"clean": True, "owner": "vera", "owner_uid": "8fb38c96-abcd", "issues": []}) + mock_proc = MagicMock(returncode=0, stdout=check_json, stderr="") + with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc): + results = _check_owner_seating() + assert len(results) == 1 + assert results[0].glyph == GLYPH_PASS + assert "@vera" in results[0].detail + assert "8fb38c96" in results[0].detail + + def test_unseated_owner_returns_errors(self): + from aipass.aipass.apps.modules.doctor import _check_owner_seating + + check_json = json.dumps( + { + "clean": False, + "owner": None, + "owner_uid": "", + "issues": [ + {"flag": "no_owner", "detail": "No owner:true in registry"}, + {"flag": "metadata_id_missing", "detail": "metadata.id absent"}, + ], + } + ) + mock_proc = MagicMock(returncode=1, stdout=check_json, stderr="") + with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc): + results = _check_owner_seating() + assert len(results) == 2 + assert all(r.glyph == GLYPH_FAIL for r in results) + assert results[0].label == "owner/no_owner" + + def test_issue_with_branch_field(self): + from aipass.aipass.apps.modules.doctor import _check_owner_seating + + check_json = json.dumps( + { + "clean": False, + "owner": "vera", + "owner_uid": "8fb38c96", + "issues": [ + {"flag": "entry_rid_stale", "detail": "stale rid", "branch": "vera"}, + ], + } + ) + mock_proc = MagicMock(returncode=1, stdout=check_json, stderr="") + with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc): + results = _check_owner_seating() + assert len(results) == 1 + assert results[0].glyph == GLYPH_FAIL + assert results[0].label == "owner/entry_rid_stale" + + def test_drone_not_found_returns_warn(self): + from aipass.aipass.apps.modules.doctor import _check_owner_seating + + with patch( + "aipass.aipass.apps.modules.doctor.subprocess.run", + side_effect=FileNotFoundError("drone"), + ): + results = _check_owner_seating() + assert len(results) == 1 + assert results[0].glyph == GLYPH_WARN + assert "drone" in results[0].detail + + def test_timeout_returns_warn(self): + import subprocess as _sp + + from aipass.aipass.apps.modules.doctor import _check_owner_seating + + with patch( + "aipass.aipass.apps.modules.doctor.subprocess.run", + side_effect=_sp.TimeoutExpired("drone", 30), + ): + results = _check_owner_seating() + assert len(results) == 1 + assert results[0].glyph == GLYPH_WARN + + def test_non_json_output_returns_warn(self): + from aipass.aipass.apps.modules.doctor import _check_owner_seating + + mock_proc = MagicMock(returncode=1, stdout="not json at all", stderr="") + with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc): + results = _check_owner_seating() + assert len(results) == 1 + assert results[0].glyph == GLYPH_WARN + + def test_empty_stdout_exit_zero(self): + from aipass.aipass.apps.modules.doctor import _check_owner_seating + + mock_proc = MagicMock(returncode=0, stdout="", stderr="") + with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc): + results = _check_owner_seating() + assert len(results) == 1 + assert results[0].glyph == GLYPH_PASS + + +class TestFixOwnerSeating: + """Tests for owner/identity repair via sync-registry --fix.""" + + def test_fix_success_returns_pass(self): + from aipass.aipass.apps.modules.doctor import _fix_owner_seating + + mock_proc = MagicMock(returncode=0, stdout="", stderr="") + with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc): + results = _fix_owner_seating() + assert len(results) == 1 + assert results[0].glyph == GLYPH_PASS + assert "reconciled" in results[0].detail + + def test_fix_failure_returns_fail(self): + from aipass.aipass.apps.modules.doctor import _fix_owner_seating + + mock_proc = MagicMock(returncode=1, stdout="", stderr="owner conflict") + with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc): + results = _fix_owner_seating() + assert len(results) == 1 + assert results[0].glyph == GLYPH_FAIL + + def test_fix_drone_not_found(self): + from aipass.aipass.apps.modules.doctor import _fix_owner_seating + + with patch( + "aipass.aipass.apps.modules.doctor.subprocess.run", + side_effect=FileNotFoundError("drone"), + ): + results = _fix_owner_seating() + assert len(results) == 1 + assert results[0].glyph == GLYPH_WARN + + def test_fix_timeout(self): + import subprocess as _sp + + from aipass.aipass.apps.modules.doctor import _fix_owner_seating + + with patch( + "aipass.aipass.apps.modules.doctor.subprocess.run", + side_effect=_sp.TimeoutExpired("drone", 60), + ): + results = _fix_owner_seating() + assert len(results) == 1 + assert results[0].glyph == GLYPH_WARN diff --git a/src/aipass/aipass/tests/test_init_flow.py b/src/aipass/aipass/tests/test_init_flow.py index 3c30149a..3fc2520a 100644 --- a/src/aipass/aipass/tests/test_init_flow.py +++ b/src/aipass/aipass/tests/test_init_flow.py @@ -660,48 +660,74 @@ _MOD_UPDATE = "aipass.aipass.apps.modules.init_flow" class TestInitUpdateRegistrySync: - """Tests for registry sync subprocess call in _handle_init_update.""" + """Tests for owner/identity check+fix in _handle_init_update (DPLAN-0239 P5).""" - def test_sync_success_prints_message(self, tmp_path: Path) -> None: - """Successful drone sync-registry prints 'Registry synced.'""" - mock_result = MagicMock(returncode=0) + def test_clean_check_prints_ok(self, tmp_path: Path) -> None: + """Clean --check (exit 0) prints 'Owner/identity OK.' and skips --fix.""" + check_proc = MagicMock(returncode=0, stdout="", stderr="") 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}.subprocess.run", return_value=check_proc) as mock_run, patch(f"{_MOD_UPDATE}.console"), patch(f"{_MOD_UPDATE}.success") as mock_success, 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_success.call_args_list if "Registry synced" in str(c)] - assert len(sync_calls) == 1 + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert "--check" in args + ok_calls = [c for c in mock_success.call_args_list if "Owner/identity OK" in str(c)] + assert len(ok_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) + def test_issues_trigger_fix(self, tmp_path: Path) -> None: + """Non-zero --check triggers --fix; success prints reconciled.""" + check_proc = MagicMock(returncode=1, stdout="", stderr="") + fix_proc = MagicMock(returncode=0, stdout="", stderr="") 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}.subprocess.run", + side_effect=[check_proc, fix_proc], + ) as mock_run, + patch(f"{_MOD_UPDATE}.console"), + patch(f"{_MOD_UPDATE}.success") as mock_success, + patch(f"{_MOD_UPDATE}.warning"), + patch(f"{_MOD_UPDATE}.json_handler"), + ): + rc = _handle_init_update([str(tmp_path)]) + assert rc == 0 + assert mock_run.call_count == 2 + fix_args = mock_run.call_args_list[1][0][0] + assert "--fix" in fix_args + reconciled = [c for c in mock_success.call_args_list if "reconciled" in str(c)] + assert len(reconciled) == 1 + + def test_fix_failure_degrades_silently(self, tmp_path: Path) -> None: + """Non-zero --fix exit degrades gracefully (no crash).""" + check_proc = MagicMock(returncode=1, stdout="", stderr="") + fix_proc = MagicMock(returncode=1, stdout="", stderr="") + with ( + patch( + "aipass.aipass.apps.handlers.init.bootstrap.update_project", + return_value={"updated_files": [], "already_current": []}, + ), + patch( + f"{_MOD_UPDATE}.subprocess.run", + side_effect=[check_proc, fix_proc], + ), + patch(f"{_MOD_UPDATE}.console"), + patch(f"{_MOD_UPDATE}.warning"), 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.""" @@ -711,13 +737,11 @@ class TestInitUpdateRegistrySync: 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}.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.""" diff --git a/src/aipass/aipass/tests/test_install.py b/src/aipass/aipass/tests/test_install.py index 44245269..ce7b1449 100644 --- a/src/aipass/aipass/tests/test_install.py +++ b/src/aipass/aipass/tests/test_install.py @@ -196,7 +196,7 @@ class TestRunInstall: setup.assert_not_called() def test_full_happy_path(self, tmp_path: Path) -> None: - """Clone + setup + verify + next-steps returns success.""" + """Clone + setup + verify + owner check + next-steps returns success.""" home = tmp_path / "AIPass" with ( patch(f"{_MOD}._resolve_home", return_value=home), @@ -204,6 +204,7 @@ class TestRunInstall: patch(f"{_MOD}._clone_repo", return_value=True), patch(f"{_MOD}._run_setup", return_value=True), patch(f"{_MOD}._verify_binaries", return_value={"drone": "/x/drone", "aipass": "/x/aipass"}), + patch(f"{_MOD}._check_and_fix_owner"), patch(f"{_MOD}._handoff_to_init") as nxt, ): rc = run_install(non_interactive=True, dry_run=False) @@ -386,6 +387,63 @@ class TestThrowawayGate: return_value={"drone": "x", "aipass": "x"}, ), patch("aipass.aipass.apps.modules.install._handoff_to_init"), + patch("aipass.aipass.apps.modules.install._check_and_fix_owner"), ): result = run_install(non_interactive=True, no_init=True) assert result == 0 + + +# --------------------------------------------------------------------------- +# _check_and_fix_owner tests (DPLAN-0239 P5) +# --------------------------------------------------------------------------- + + +class TestCheckAndFixOwner: + """Tests for install-time owner/identity check+fix retro-trigger.""" + + def test_clean_check_skips_fix(self, tmp_path) -> None: + from aipass.aipass.apps.modules.install import _check_and_fix_owner + + mock_proc = MagicMock(returncode=0, stdout="", stderr="") + with patch( + "aipass.aipass.apps.modules.install.subprocess.run", + return_value=mock_proc, + ) as mock_run: + _check_and_fix_owner(tmp_path) + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert "--check" in args + + def test_issues_trigger_fix(self, tmp_path) -> None: + from aipass.aipass.apps.modules.install import _check_and_fix_owner + + check_proc = MagicMock(returncode=1, stdout="", stderr="") + fix_proc = MagicMock(returncode=0, stdout="", stderr="") + with patch( + "aipass.aipass.apps.modules.install.subprocess.run", + side_effect=[check_proc, fix_proc], + ) as mock_run: + _check_and_fix_owner(tmp_path) + assert mock_run.call_count == 2 + fix_args = mock_run.call_args_list[1][0][0] + assert "--fix" in fix_args + + def test_drone_not_found_is_silent(self, tmp_path) -> None: + from aipass.aipass.apps.modules.install import _check_and_fix_owner + + with patch( + "aipass.aipass.apps.modules.install.subprocess.run", + side_effect=FileNotFoundError("drone"), + ): + _check_and_fix_owner(tmp_path) + + def test_timeout_is_silent(self, tmp_path) -> None: + import subprocess as _sp + + from aipass.aipass.apps.modules.install import _check_and_fix_owner + + with patch( + "aipass.aipass.apps.modules.install.subprocess.run", + side_effect=_sp.TimeoutExpired("drone", 30), + ): + _check_and_fix_owner(tmp_path) diff --git a/src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py b/src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py index dd909a02..1c519788 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py @@ -11,6 +11,13 @@ """Checks for dispatch commands and reminds the agent to arm the watchdog.""" import json +import re + + +def _extract_target(command: str) -> str: + """Extract the @target branch name from a dispatch command.""" + match = re.search(r"dispatch\s+@(\S+)", command) + return f"@{match.group(1)}" if match else "@" def handle(hook_data: dict) -> dict: @@ -37,11 +44,15 @@ def handle(hook_data: dict) -> dict: if "dispatch wake" in command and "dispatch @" not in command: return {"stdout": "", "exit_code": 0} + target = _extract_target(command) + result = { "additionalContext": ( - "[AUTO-WATCHDOG] Dispatch detected — arm watchdog NOW. " - "Run the watchdog one-liner from your local prompt with " - "run_in_background: true and timeout: 600000." + f"[AUTO-WATCHDOG] Dispatch detected — arm watchdog NOW.\n" + f"Use the Monitor tool (NOT Bash run_in_background) to run:\n" + f" drone @devpulse watchdog agent {target}\n" + f"The Monitor tool's return is what wakes your session when " + f"the dispatched agent finishes. run_in_background cannot wake you." ) } return {"stdout": json.dumps(result), "exit_code": 0, "sound": "auto watchdog"} diff --git a/src/aipass/hooks/tests/test_auto_watchdog.py b/src/aipass/hooks/tests/test_auto_watchdog.py index 79fed2e3..0e1e4afe 100644 --- a/src/aipass/hooks/tests/test_auto_watchdog.py +++ b/src/aipass/hooks/tests/test_auto_watchdog.py @@ -34,6 +34,28 @@ class TestAutoWatchdogHandler: parsed = json.loads(result["stdout"]) assert "additionalContext" in parsed assert "AUTO-WATCHDOG" in parsed["additionalContext"] + assert "Monitor tool" in parsed["additionalContext"] + assert "NOT Bash run_in_background" in parsed["additionalContext"] + assert "drone @devpulse watchdog agent @hooks" in parsed["additionalContext"] + + def test_dispatch_extracts_target(self): + from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle + + result = handle( + { + "tool_name": "Bash", + "tool_input": {"command": 'drone @ai_mail dispatch @spawn "Task" "Do it"'}, + } + ) + parsed = json.loads(result["stdout"]) + assert "drone @devpulse watchdog agent @spawn" in parsed["additionalContext"] + + def test_dispatch_no_target_fallback(self): + from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import ( + _extract_target, + ) + + assert _extract_target("drone @ai_mail dispatch") == "@" def test_skip_non_bash(self): from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle diff --git a/src/aipass/spawn/README.md b/src/aipass/spawn/README.md index 66b4377e..cac9fb9a 100644 --- a/src/aipass/spawn/README.md +++ b/src/aipass/spawn/README.md @@ -177,7 +177,7 @@ spawn/ ## Tests -**297 tests | 0 skipped | 0 failed** across 14 test files: +**344 tests | 0 skipped | 0 failed** across 14 test files: | File | Focus | |------|-------| @@ -192,9 +192,10 @@ spawn/ | `test_contracts.py` | Handler contracts and interface compliance | | `test_spawn.py` | Basic CLI routing and help | | `test_error_resilience.py` | Error handling and edge cases | +| `test_check_fix_identity.py` | Owner/identity check and fix (DPLAN-0239 P4) | | `conftest.py` | Fixtures: mock templates, registry protection | -**Public functions:** 45 total, 41 tested (91%) +**Public functions:** 50 total, 49 tested (98%) --- @@ -224,7 +225,7 @@ spawn/ ## Metrics - **Seedgo:** 100% (34/34) -- **Tests:** 253 passed, 0 skipped, 0 failed +- **Tests:** 340 passed, 0 skipped, 0 failed - **Module coverage:** 23/23 (100%) - **Template registry:** 44 files, 23 dirs (aipass_framework) - **Battle test:** 17/17 commands pass (2026-04-22) diff --git a/src/aipass/spawn/apps/handlers/placeholders.py b/src/aipass/spawn/apps/handlers/placeholders.py index 573bdfbf..c73dbdf8 100644 --- a/src/aipass/spawn/apps/handlers/placeholders.py +++ b/src/aipass/spawn/apps/handlers/placeholders.py @@ -41,16 +41,11 @@ def build_replacements_dict(target_dir, branch_name, **overrides): lower = branch_name.lower().replace("-", "_") now = datetime.now() - # Read registry ID — never crash spawn if registry is missing registry_id = "" - try: - registry_path = find_registry() - if registry_path.exists(): - data = json.loads(registry_path.read_text(encoding="utf-8")) - registry_id = data.get("metadata", {}).get("id", "") - except Exception as e: - logger.warning(f"Failed to read registry ID for placeholders: {e}") - registry_id = "" + registry_path = find_registry(start_path=Path(target_dir).parent) + if registry_path.exists(): + data = json.loads(registry_path.read_text(encoding="utf-8")) + registry_id = data.get("metadata", {}).get("id", "") replacements = { "BRANCHNAME": upper, diff --git a/src/aipass/spawn/apps/handlers/registry.py b/src/aipass/spawn/apps/handlers/registry.py index 78c03fb2..59875b24 100644 --- a/src/aipass/spawn/apps/handlers/registry.py +++ b/src/aipass/spawn/apps/handlers/registry.py @@ -6,9 +6,29 @@ # Modified: 2026-06-10 # ============================================= -"""*_REGISTRY.json discovery and CRUD operations.""" +"""*_REGISTRY.json discovery and CRUD operations. + +Identity model (DPLAN-0239, settled 2026-07-11): + + registry.metadata.id + PROJECT credential — authoritative, minted once at ``aipass init``. + Passport ``citizenship.registry_id`` conforms to it (drone enforces + the pair at routing time). Spawn never mints this; bootstrap.py does. + + branch-entry registry_id + PER-CITIZEN UUID — set-once, minted by ``add_to_registry`` at entry + creation. Uniquely identifies the citizen *within* the project. + NOT the project credential; NOT copied from the passport. + + owner (entry field, ``True`` / absent) + Sealed authority flag. First agent = project owner. Seated via + ``ensure_project_has_owner`` at creation time. ``citizen_class == + 'manager'`` is a cosmetic preference for the seating heuristic, + never the gate — the entry ``owner: true`` IS the gate. +""" import sys +import uuid from datetime import datetime from pathlib import Path @@ -136,9 +156,12 @@ def _validate_path_containment(branch_path, registry_path): return False -def add_to_registry(registry_path, branch_name, branch_path, profile, email, purpose="", registry_id=""): - """ - Add a new branch entry to the registry. +def add_to_registry(registry_path, branch_name, branch_path, profile, email, purpose=""): + """Add a new branch entry to the registry. + + Always mints a fresh per-citizen UUID for the entry's ``registry_id`` + (the citizen UID). This is NOT the project credential — that lives + in ``metadata.id`` and is copied into passports separately. Uses file locking around the entire read-modify-write cycle to prevent corruption from concurrent spawns. Skips locking on Windows. @@ -193,9 +216,8 @@ def add_to_registry(registry_path, branch_name, branch_path, profile, email, pur "status": "active", "created": today, "last_active": today, + "registry_id": str(uuid.uuid4()), } - if registry_id: - entry["registry_id"] = registry_id if isinstance(branches, dict): branches[branch_name] = entry @@ -269,11 +291,49 @@ def fix_passport_registry_id(branch_dir: Path, registry_path: Path) -> bool: return False +def pick_owner_branch(branches, project_root): + """Select which branch entry should be the owner (no writes). + + Canonical heuristic (first match wins): + 1. citizen_class == "manager" (cosmetic preference, not the gate) + 2. passport citizenship.owner == true + 3. First agent by ``created`` date (ultimate fallback) + + Args: + branches: List of branch entry dicts. + project_root: Path to the project root (registry parent dir). + + Returns: + The chosen branch entry dict, or None if branches is empty. + """ + if not branches: + return None + + project_root = Path(project_root) + + for branch in branches: + branch_path = project_root / branch.get("path", "") + passport_path = branch_path / ".trinity" / "passport.json" + if passport_path.exists(): + passport = json_handler.read_json(passport_path) + if passport and passport.get("identity", {}).get("citizen_class") == "manager": + return branch + + for branch in branches: + branch_path = project_root / branch.get("path", "") + passport_path = branch_path / ".trinity" / "passport.json" + if passport_path.exists(): + passport = json_handler.read_json(passport_path) + if passport and passport.get("citizenship", {}).get("owner") is True: + return branch + + return min(branches, key=lambda b: b.get("created", "9999-99-99")) + + def ensure_project_has_owner(registry_path): """Ensure exactly one branch entry in the registry has owner:true. - Owner is determined by citizen_class=manager (read from passport). - Falls back to citizen_number==1 if no manager found. + Uses ``pick_owner_branch`` for the canonical seating heuristic. Writes to the REGISTRY ENTRY (sealed authority), not the passport. """ registry_path = Path(registry_path) @@ -286,28 +346,7 @@ def ensure_project_has_owner(registry_path): if branch.get("owner") is True: return False - registry_root = registry_path.parent - owner_branch = None - - for branch in branches: - branch_path = registry_root / branch.get("path", "") - passport_path = branch_path / ".trinity" / "passport.json" - if passport_path.exists(): - passport = json_handler.read_json(passport_path) - if passport and passport.get("identity", {}).get("citizen_class") == "manager": - owner_branch = branch - break - - if owner_branch is None: - for branch in branches: - branch_path = registry_root / branch.get("path", "") - passport_path = branch_path / ".trinity" / "passport.json" - if passport_path.exists(): - passport = json_handler.read_json(passport_path) - if passport and passport.get("citizenship", {}).get("owner") is True: - owner_branch = branch - break - + owner_branch = pick_owner_branch(branches, registry_path.parent) if owner_branch is None: return False @@ -318,10 +357,13 @@ def ensure_project_has_owner(registry_path): def backfill_owner_and_registry_id(registry_path): - """Backfill owner and registry_id fields into all registry branch entries. + """Backfill owner and per-citizen registry_id into branch entries. - - Sets registry_id from each branch's passport citizenship.registry_id - - Sets owner:true on the manager branch (devpulse in AIPass) + Mints a fresh UUID for any entry that is missing ``registry_id`` or + holds a stale project-id duplicate (same value as another entry). + Already-unique UUIDs are never touched. + + Also seats owner via ``ensure_project_has_owner`` if missing. """ registry_path = Path(registry_path) reg_data = load_registry(registry_path) @@ -329,32 +371,26 @@ def backfill_owner_and_registry_id(registry_path): if not branches: return False - registry_root = registry_path.parent changed = False + seen_ids: dict[str, int] = {} for branch in branches: - branch_path = registry_root / branch.get("path", "") - passport_path = branch_path / ".trinity" / "passport.json" - if not passport_path.exists(): - continue - passport = json_handler.read_json(passport_path) - if not passport: - continue + rid = branch.get("registry_id", "") + if rid: + seen_ids[rid] = seen_ids.get(rid, 0) + 1 - rid = passport.get("citizenship", {}).get("registry_id", "") - if rid and "registry_id" not in branch: - branch["registry_id"] = rid - changed = True - - citizen_class = passport.get("identity", {}).get("citizen_class", "") - if citizen_class == "manager" and not branch.get("owner"): - branch["owner"] = True + for branch in branches: + rid = branch.get("registry_id", "") + if not rid or seen_ids.get(rid, 0) > 1: + branch["registry_id"] = str(uuid.uuid4()) changed = True if changed: save_registry(registry_path, reg_data) - logger.info("[registry] Backfilled owner + registry_id into registry entries") - return changed + logger.info("[registry] Backfilled per-citizen registry_id into entries") + + owner_seated = ensure_project_has_owner(registry_path) + return changed or owner_seated def get_owner(start_path=None): diff --git a/src/aipass/spawn/apps/handlers/sync_registry_ops.py b/src/aipass/spawn/apps/handlers/sync_registry_ops.py index b148fd87..a8ba469f 100644 --- a/src/aipass/spawn/apps/handlers/sync_registry_ops.py +++ b/src/aipass/spawn/apps/handlers/sync_registry_ops.py @@ -17,6 +17,7 @@ looking for *_REGISTRY.json) so it works for both AIPass and external projects. """ import json +import uuid from datetime import datetime from pathlib import Path @@ -28,6 +29,7 @@ from aipass.spawn.apps.handlers.registry import ( save_registry, branches_as_list, fix_passport_registry_id, + pick_owner_branch, ) from aipass.spawn.apps.handlers.meta_ops import ( load_template_registry, @@ -323,3 +325,298 @@ def sync_registry(fix: bool = False) -> dict: "ids_fixed": ids_fixed, "descriptions_backfilled": descriptions_backfilled, } + + +# ============================================================================= +# OWNER / IDENTITY CHECK + FIX (DPLAN-0239 P4, frozen contract) +# ============================================================================= + + +def check_owner_identity(registry_path=None): + """Read-only owner/identity health check — 7 flags. + + Flags: + no_owner — no branch entry has owner:true + multi_owner — more than one branch has owner:true + owner_missing_branch — owner entry's path doesn't exist on disk + owner_wrong_branch — owner is not the manager and not the first agent + metadata_id_missing — registry metadata.id absent + passport_mismatch — passport citizenship.registry_id != metadata.id + entry_rid_stale — entry registry_id missing, duplicate, or == metadata.id + + Returns: + dict with pinned schema: + ``clean`` (bool), ``owner`` (name str or None), + ``owner_uid`` (entry registry_id str, empty if none), + ``issues`` (list of flag/detail/branch dicts). + """ + if registry_path is None: + registry_path = find_registry() + registry_path = Path(registry_path) + reg_data = load_registry(registry_path) + branches = branches_as_list(reg_data.get("branches", [])) + project_root = registry_path.parent + metadata_id = reg_data.get("metadata", {}).get("id", "") + + issues: list[dict] = [] + + # --- flag 1: no_owner --- + owners = [b for b in branches if b.get("owner") is True] + if not owners: + issues.append({"flag": "no_owner", "detail": "No branch entry has owner:true"}) + + # --- flag 2: multi_owner --- + if len(owners) > 1: + names = [b.get("name", "?") for b in owners] + issues.append({"flag": "multi_owner", "detail": f"Multiple owners: {', '.join(names)}"}) + + # --- flag 3: owner_missing_branch --- + for owner in owners: + owner_path = (project_root / owner.get("path", "")).resolve() + if not owner_path.is_dir(): + issues.append( + { + "flag": "owner_missing_branch", + "detail": f"Owner {owner.get('name', '?')} path does not exist: {owner.get('path', '')}", + } + ) + + # --- flag 4: owner_wrong_branch --- + for owner in owners: + owner_dir = project_root / owner.get("path", "") + passport_path = owner_dir / ".trinity" / "passport.json" + is_manager = False + if passport_path.exists(): + try: + passport = json.loads(passport_path.read_text(encoding="utf-8")) + is_manager = passport.get("identity", {}).get("citizen_class") == "manager" + except (json.JSONDecodeError, IOError) as e: + logger.warning("[check-identity] Cannot read passport for %s: %s", owner.get("name", "?"), e) + if not is_manager and branches: + first = min(branches, key=lambda b: b.get("created", "9999-99-99")) + if owner.get("name") != first.get("name"): + issues.append( + { + "flag": "owner_wrong_branch", + "detail": f"Owner {owner.get('name', '?')} is not the manager and not the first agent", + } + ) + + # --- flag 5: metadata_id_missing --- + if not metadata_id: + issues.append({"flag": "metadata_id_missing", "detail": "Registry metadata.id is absent"}) + + # --- flag 6: passport_mismatch --- + if metadata_id: + for branch in branches: + branch_dir = project_root / branch.get("path", "") + passport_path = branch_dir / ".trinity" / "passport.json" + if not passport_path.exists(): + continue + try: + passport = json.loads(passport_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, IOError) as e: + logger.warning("[check-identity] Cannot read passport for %s: %s", branch.get("name", "?"), e) + continue + passport_rid = passport.get("citizenship", {}).get("registry_id", "") + if passport_rid and passport_rid != metadata_id: + issues.append( + { + "flag": "passport_mismatch", + "detail": ( + f"{branch.get('name', '?')}: passport registry_id=" + f"{passport_rid[:8]}… != metadata.id={metadata_id[:8]}…" + ), + "branch": branch.get("name", ""), + } + ) + + # --- flag 7: entry_rid_stale --- + rid_counts: dict[str, int] = {} + for branch in branches: + rid = branch.get("registry_id", "") + if rid: + rid_counts[rid] = rid_counts.get(rid, 0) + 1 + + for branch in branches: + rid = branch.get("registry_id", "") + if not rid: + issues.append( + { + "flag": "entry_rid_stale", + "detail": f"{branch.get('name', '?')}: entry registry_id missing", + "branch": branch.get("name", ""), + } + ) + elif metadata_id and rid == metadata_id: + issues.append( + { + "flag": "entry_rid_stale", + "detail": f"{branch.get('name', '?')}: entry registry_id is a stale copy of metadata.id", + "branch": branch.get("name", ""), + } + ) + elif rid_counts.get(rid, 0) > 1: + issues.append( + { + "flag": "entry_rid_stale", + "detail": f"{branch.get('name', '?')}: entry registry_id={rid[:8]}… is a duplicate", + "branch": branch.get("name", ""), + } + ) + + owner_entry = owners[0] if len(owners) == 1 else None + return { + "clean": len(issues) == 0, + "owner": owner_entry.get("name") if owner_entry else None, + "owner_uid": owner_entry.get("registry_id", "") if owner_entry else "", + "issues": issues, + } + + +def fix_owner_identity(registry_path=None, dry_run=False): + """Reconcile owner + identity in the sealed registry. + + Actions (each idempotent, refuses to alter correct state): + - Seat missing owner (first agent) + - Restore missing metadata.id (consensus from passports, else mint) + - Align passports to metadata.id + - Mint per-citizen entry registry_id where missing or stale-duplicate + - Resolve multi-owner (keep first-created, unseat others) + + Deliberately NEVER moves a seated owner — ``owner_wrong_branch`` is a + flag-only diagnostic for human decision. + + Args: + registry_path: Path to registry (None = auto-discover from CWD). + dry_run: If True, print planned changes but don't write. + + Returns: + dict with ``actions`` (list of change descriptions) and ``applied`` (bool). + """ + if registry_path is None: + registry_path = find_registry() + registry_path = Path(registry_path) + reg_data = load_registry(registry_path) + branches = branches_as_list(reg_data.get("branches", [])) + project_root = registry_path.parent + metadata_id = reg_data.get("metadata", {}).get("id", "") + + actions: list[str] = [] + registry_changed = False + + # --- restore missing metadata.id (majority-restore or mint) --- + if not metadata_id: + passport_id_counts: dict[str, int] = {} + for branch in branches: + branch_dir = project_root / branch.get("path", "") + passport_path = branch_dir / ".trinity" / "passport.json" + if not passport_path.exists(): + continue + try: + passport = json.loads(passport_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, IOError) as e: + logger.warning("[fix-identity] Cannot read passport for consensus: %s", e) + continue + rid = passport.get("citizenship", {}).get("registry_id", "") + if rid: + passport_id_counts[rid] = passport_id_counts.get(rid, 0) + 1 + + total_with_id = sum(passport_id_counts.values()) + majority_id = None + if passport_id_counts: + top_id = max(passport_id_counts, key=lambda k: passport_id_counts[k]) + if passport_id_counts[top_id] > total_with_id / 2: + majority_id = top_id + + if majority_id: + metadata_id = majority_id + count = passport_id_counts[majority_id] + actions.append(f"Restore metadata.id = {metadata_id[:8]}… (passport majority {count} of {total_with_id})") + else: + metadata_id = str(uuid.uuid4()) + actions.append(f"Mint metadata.id = {metadata_id[:8]}…") + + reg_data.setdefault("metadata", {})["id"] = metadata_id + registry_changed = True + + # --- resolve multi-owner: keep earliest-created, unseat the rest --- + owners = [b for b in branches if b.get("owner") is True] + if len(owners) > 1: + owners_sorted = sorted(owners, key=lambda b: b.get("created", "9999-99-99")) + for extra in owners_sorted[1:]: + extra.pop("owner", None) + actions.append(f"Unseat extra owner: {extra.get('name', '?')}") + registry_changed = True + + # --- seat missing owner (canonical heuristic) --- + current_owners = [b for b in branches if b.get("owner") is True] + if not current_owners and branches: + chosen = pick_owner_branch(branches, project_root) + if chosen: + chosen["owner"] = True + actions.append(f"Seat owner: {chosen.get('name', '?')}") + registry_changed = True + + # --- mint per-citizen entry registry_id where missing or stale --- + rid_counts: dict[str, int] = {} + for branch in branches: + rid = branch.get("registry_id", "") + if rid: + rid_counts[rid] = rid_counts.get(rid, 0) + 1 + + for branch in branches: + rid = branch.get("registry_id", "") + needs_mint = False + if not rid: + needs_mint = True + elif metadata_id and rid == metadata_id: + needs_mint = True + elif rid_counts.get(rid, 0) > 1: + needs_mint = True + + if needs_mint: + new_rid = str(uuid.uuid4()) + old_display = rid[:8] + "…" if rid else "(empty)" + branch["registry_id"] = new_rid + actions.append(f"Mint citizen UID for {branch.get('name', '?')}: {old_display} → {new_rid[:8]}…") + registry_changed = True + + # --- align passports to metadata.id --- + passport_actions: list[str] = [] + for branch in branches: + branch_dir = project_root / branch.get("path", "") + passport_path = branch_dir / ".trinity" / "passport.json" + if not passport_path.exists(): + continue + try: + passport = json.loads(passport_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, IOError) as e: + logger.warning("[fix-identity] Cannot read passport for %s: %s", branch.get("name", "?"), e) + continue + passport_rid = passport.get("citizenship", {}).get("registry_id", "") + if passport_rid != metadata_id: + old_display = passport_rid[:8] + "…" if passport_rid else "(empty)" + passport.setdefault("citizenship", {})["registry_id"] = metadata_id + if not dry_run: + try: + passport_path.write_text(json.dumps(passport, indent=2, ensure_ascii=False), encoding="utf-8") + except IOError as e: + logger.warning("[fix-identity] Failed to write passport %s: %s", branch.get("name", "?"), e) + continue + passport_actions.append(f"Align passport for {branch.get('name', '?')}: {old_display} → {metadata_id[:8]}…") + + actions.extend(passport_actions) + + applied = False + if registry_changed and not dry_run: + applied = save_registry(registry_path, reg_data) + if applied: + logger.info("[fix-identity] Registry updated with %d action(s)", len(actions)) + else: + logger.error("[fix-identity] Failed to save registry") + + if dry_run and actions: + logger.info("[fix-identity] Dry-run: %d action(s) planned", len(actions)) + + return {"actions": actions, "applied": applied} diff --git a/src/aipass/spawn/apps/modules/core.py b/src/aipass/spawn/apps/modules/core.py index 5464461e..c7bd4387 100644 --- a/src/aipass/spawn/apps/modules/core.py +++ b/src/aipass/spawn/apps/modules/core.py @@ -283,13 +283,11 @@ def _spawn_agent( # Step 2b: Set owner field — first agent in the project is the owner passport_path = target / ".trinity" / "passport.json" - passport_registry_id = "" if passport_path.exists(): passport_data = json_handler.read_json(passport_path) if passport_data: passport_data.setdefault("citizenship", {})["owner"] = citizen_number == 1 json_handler.write_json(passport_path, passport_data) - passport_registry_id = passport_data.get("citizenship", {}).get("registry_id", "") # Step 3: Regenerate .template_registry.json with fresh hashes regenerate_template_registry(target) @@ -314,7 +312,6 @@ def _spawn_agent( detected_profile, f"@{branch_lower}", purpose or "New agent - purpose TBD", - registry_id=passport_registry_id, ) # Step 5: Ensure at least one agent in the project is the owner @@ -393,6 +390,8 @@ def _adopt_existing(target, purpose, profile, registry_path): purpose, ) + ensure_project_has_owner(reg_path) + json_handler.log_operation("branch_adopted", data={"branch": branch_upper}) logger.info("[spawn] Adopted existing branch: %s (registered in %s)", branch_upper, reg_path.name) diff --git a/src/aipass/spawn/apps/modules/sync_registry.py b/src/aipass/spawn/apps/modules/sync_registry.py index f270863c..7d14b319 100644 --- a/src/aipass/spawn/apps/modules/sync_registry.py +++ b/src/aipass/spawn/apps/modules/sync_registry.py @@ -17,7 +17,11 @@ from aipass.prax import logger # CLI service: from cli.apps.modules import console (via aipass namespace) from aipass.cli.apps.modules import console, error, warning -from aipass.spawn.apps.handlers.sync_registry_ops import sync_registry +from aipass.spawn.apps.handlers.sync_registry_ops import ( + sync_registry, + check_owner_identity, + fix_owner_identity, +) from aipass.spawn.apps.handlers.json import json_handler @@ -75,30 +79,119 @@ def handle_sync_registry(args: list[str]) -> int: """Parse args and execute sync. Args patterns: - [] -> report only (show mismatches) - ["--fix"] -> auto-repair mismatches + [] -> report only (show mismatches) + ["--fix"] -> auto-repair mismatches + owner/identity reconcile + ["--check"] -> read-only owner/identity health check + ["--check", "--json"] -> machine-readable check output + ["--fix", "--dry-run"] -> show planned owner/identity fixes without applying - Returns exit code (0=success, 1=failure). + An optional positional path can precede any flag to target + a specific project registry (default: CWD-based discovery). + + Returns exit code (0=success/clean, 1=failure/issues). """ if args and args[0] in ["--help", "-h"]: - warning("Usage: drone @spawn sync-registry [--fix]") + warning("Usage: drone @spawn sync-registry [project-path] [--fix|--check] [--json] [--dry-run]") console.print() - console.print(" [green](no args)[/green] Report mismatches between registry and filesystem") - console.print(" [green]--fix[/green] Auto-repair: remove stale, add unregistered") + console.print(" [green](no args)[/green] Report mismatches between registry and filesystem") + console.print(" [green]--fix[/green] Auto-repair: stale/unregistered + owner/identity reconcile") + console.print(" [green]--fix --dry-run[/green] Show planned owner/identity fixes without applying") + console.print(" [green]--check[/green] Read-only owner/identity health check (exit 0=clean)") + console.print(" [green]--check --json[/green] Machine-readable check output") return 0 - fix = "--fix" in args + project_path = None + flags = set() + for arg in args: + if arg.startswith("--"): + flags.add(arg) + elif project_path is None: + project_path = arg + + registry_path = None + if project_path: + from pathlib import Path + + from aipass.spawn.apps.handlers.registry import find_registry + + registry_path = find_registry(start_path=Path(project_path)) + + if "--check" in flags: + return _handle_check(registry_path, json_output="--json" in flags) + + fix = "--fix" in flags + dry_run = "--dry-run" in flags try: - result = sync_registry(fix=fix) + result = sync_registry(fix=fix and not dry_run) except Exception as exc: logger.error(f"[sync-registry] Unexpected error: {exc}") error(str(exc)) return 1 json_handler.log_operation("registry_synced") - _print_summary(result) + + if fix: + return _handle_fix(registry_path, dry_run=dry_run) + + return 0 + + +def _handle_check(registry_path, json_output=False) -> int: + """Run owner/identity health check and report.""" + import json as _json + + try: + result = check_owner_identity(registry_path=registry_path) + except Exception as exc: + logger.error(f"[sync-registry] Check error: {exc}") + error(str(exc)) + return 1 + + if json_output: + console.print(_json.dumps(result, indent=2, ensure_ascii=False)) + return 0 if result["clean"] else 1 + + issues = result["issues"] + console.print() + if not issues: + console.print("[green]Owner/identity check: clean[/green]") + return 0 + + error(f"Owner/identity check: {len(issues)} issue(s)") + console.print() + for issue in issues: + console.print(f" [{issue['flag']}] {issue['detail']}") + console.print() + console.print("[dim]Run with --fix to reconcile.[/dim]") + return 1 + + +def _handle_fix(registry_path, dry_run=False) -> int: + """Run owner/identity reconcile.""" + try: + result = fix_owner_identity(registry_path=registry_path, dry_run=dry_run) + except Exception as exc: + logger.error(f"[sync-registry] Fix error: {exc}") + error(str(exc)) + return 1 + + actions = result["actions"] + console.print() + if not actions: + console.print("[green]Owner/identity: nothing to reconcile[/green]") + return 0 + + label = "Planned" if dry_run else "Applied" + console.print(f"[bold]{label} owner/identity actions ({len(actions)}):[/bold]") + for action in actions: + console.print(f" {action}") + + if dry_run: + console.print() + console.print("[dim]Dry-run — no changes written. Remove --dry-run to apply.[/dim]") + console.print() return 0 diff --git a/src/aipass/spawn/templates/aipass_framework/.aipass/aipass_local_prompt.md b/src/aipass/spawn/templates/aipass_framework/.aipass/aipass_local_prompt.md index 416f419b..f53876dd 100644 --- a/src/aipass/spawn/templates/aipass_framework/.aipass/aipass_local_prompt.md +++ b/src/aipass/spawn/templates/aipass_framework/.aipass/aipass_local_prompt.md @@ -83,5 +83,6 @@ apps/ *Non-obvious quirks, hard-won lessons, things that will waste 20 minutes if you don't know them. These are the breadcrumbs that save time — the stuff you'd tell a new agent on day one.* +- After dispatching an agent, arm the Monitor-tool watchdog: `drone @devpulse watchdog agent @target` - {Gotcha or non-obvious behavior} - {Hard-won lesson from a past session} diff --git a/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json b/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json index 67b2631e..0c8d32e8 100644 --- a/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json +++ b/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json @@ -151,7 +151,7 @@ "path": ".aipass/README.md" }, "f004": { - "content_hash": "bf82b35fa7d5", + "content_hash": "840297f5e837", "has_branch_placeholder": false, "name": "aipass_local_prompt.md", "path": ".aipass/aipass_local_prompt.md" diff --git a/src/aipass/spawn/tests/test_check_fix_identity.py b/src/aipass/spawn/tests/test_check_fix_identity.py new file mode 100644 index 00000000..1397c674 --- /dev/null +++ b/src/aipass/spawn/tests/test_check_fix_identity.py @@ -0,0 +1,556 @@ +# =================== META ==================== +# Name: test_check_fix_identity.py +# Description: Tests for owner/identity check and fix (DPLAN-0239 P4) +# Version: 1.0.0 +# Created: 2026-07-11 +# Modified: 2026-07-11 +# ============================================= + +"""Tests for check_owner_identity and fix_owner_identity (sync-registry --check/--fix).""" + +import json + + +def _write_registry(tmp_path, metadata=None, branches=None): + """Helper: write a registry file and return its path.""" + reg = tmp_path / "AIPASS_REGISTRY.json" + data = { + "metadata": metadata or {"version": "1.0.0", "last_updated": "2026-07-11", "total_branches": 0}, + "branches": branches or [], + } + if branches: + data["metadata"]["total_branches"] = len(branches) + reg.write_text(json.dumps(data), encoding="utf-8") + return reg + + +def _make_branch(tmp_path, name, rel_path, citizen_class="aipass_framework", passport_rid=""): + """Helper: create a branch directory with passport on disk.""" + branch_dir = tmp_path / rel_path + trinity = branch_dir / ".trinity" + trinity.mkdir(parents=True, exist_ok=True) + passport = { + "identity": {"citizen_class": citizen_class}, + "citizenship": {}, + } + if passport_rid: + passport["citizenship"]["registry_id"] = passport_rid + (trinity / "passport.json").write_text(json.dumps(passport), encoding="utf-8") + return branch_dir + + +def _entry(name, path, created="2026-01-01", owner=None, registry_id=None): + """Helper: build a branch entry dict.""" + e = { + "name": name, + "path": path, + "email": f"@{name.lower()}", + "status": "active", + "profile": "library", + "description": "test", + "created": created, + "last_active": created, + } + if owner is not None: + e["owner"] = owner + if registry_id is not None: + e["registry_id"] = registry_id + return e + + +# ===================================================================== +# check_owner_identity +# ===================================================================== + + +class TestCheckOwnerIdentity: + """Tests for check_owner_identity — 7 flags.""" + + def test_clean_registry(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import check_owner_identity + + _make_branch(tmp_path, "alpha", "src/alpha", passport_rid="proj-id") + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[_entry("alpha", "src/alpha", owner=True, registry_id="unique-alpha")], + ) + + result = check_owner_identity(registry_path=reg) + assert result["clean"] is True + assert result["issues"] == [] + assert result["owner"] == "alpha" + assert result["owner_uid"] == "unique-alpha" + + def test_pinned_schema_no_owner(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import check_owner_identity + + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[_entry("alpha", "src/alpha", registry_id="uid-a")], + ) + + result = check_owner_identity(registry_path=reg) + assert result["owner"] is None + assert result["owner_uid"] == "" + + def test_no_owner_flag(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import check_owner_identity + + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[_entry("alpha", "src/alpha", registry_id="uid-a")], + ) + + result = check_owner_identity(registry_path=reg) + flags = [i["flag"] for i in result["issues"]] + assert "no_owner" in flags + + def test_multi_owner_flag(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import check_owner_identity + + _make_branch(tmp_path, "alpha", "src/alpha") + _make_branch(tmp_path, "beta", "src/beta") + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[ + _entry("alpha", "src/alpha", owner=True, registry_id="uid-a"), + _entry("beta", "src/beta", owner=True, registry_id="uid-b"), + ], + ) + + result = check_owner_identity(registry_path=reg) + flags = [i["flag"] for i in result["issues"]] + assert "multi_owner" in flags + + def test_owner_missing_branch_flag(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import check_owner_identity + + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[_entry("ghost", "src/ghost", owner=True, registry_id="uid-g")], + ) + + result = check_owner_identity(registry_path=reg) + flags = [i["flag"] for i in result["issues"]] + assert "owner_missing_branch" in flags + + def test_metadata_id_missing_flag(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import check_owner_identity + + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11"}, + branches=[_entry("alpha", "src/alpha", owner=True, registry_id="uid-a")], + ) + + result = check_owner_identity(registry_path=reg) + flags = [i["flag"] for i in result["issues"]] + assert "metadata_id_missing" in flags + + def test_passport_mismatch_flag(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import check_owner_identity + + _make_branch(tmp_path, "alpha", "src/alpha", passport_rid="old-project-id") + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "new-project-id"}, + branches=[_entry("alpha", "src/alpha", owner=True, registry_id="uid-a")], + ) + + result = check_owner_identity(registry_path=reg) + flags = [i["flag"] for i in result["issues"]] + assert "passport_mismatch" in flags + + def test_entry_rid_stale_missing(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import check_owner_identity + + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[_entry("alpha", "src/alpha", owner=True)], + ) + + result = check_owner_identity(registry_path=reg) + flags = [i["flag"] for i in result["issues"]] + assert "entry_rid_stale" in flags + + def test_entry_rid_stale_equals_metadata_id(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import check_owner_identity + + project_id = "proj-id-shared" + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": project_id}, + branches=[_entry("alpha", "src/alpha", owner=True, registry_id=project_id)], + ) + + result = check_owner_identity(registry_path=reg) + flags = [i["flag"] for i in result["issues"]] + assert "entry_rid_stale" in flags + + def test_entry_rid_stale_duplicate(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import check_owner_identity + + dup_id = "duplicate-id" + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[ + _entry("alpha", "src/alpha", owner=True, registry_id=dup_id), + _entry("beta", "src/beta", registry_id=dup_id), + ], + ) + + result = check_owner_identity(registry_path=reg) + stale_issues = [i for i in result["issues"] if i["flag"] == "entry_rid_stale"] + assert len(stale_issues) == 2 + + +# ===================================================================== +# fix_owner_identity +# ===================================================================== + + +class TestFixOwnerIdentity: + """Tests for fix_owner_identity — reconcile.""" + + def test_noop_when_clean(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + _make_branch(tmp_path, "alpha", "src/alpha", passport_rid="proj-id") + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[_entry("alpha", "src/alpha", owner=True, registry_id="unique-alpha")], + ) + + result = fix_owner_identity(registry_path=reg) + assert result["actions"] == [] + + def test_seats_missing_owner(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[ + _entry("beta", "src/beta", created="2026-02-01", registry_id="uid-b"), + _entry("alpha", "src/alpha", created="2026-01-01", registry_id="uid-a"), + ], + ) + + result = fix_owner_identity(registry_path=reg) + assert result["applied"] is True + actions_text = " ".join(result["actions"]) + assert "alpha" in actions_text.lower() + + data = json.loads(reg.read_text(encoding="utf-8")) + alpha = next(b for b in data["branches"] if b["name"] == "alpha") + assert alpha.get("owner") is True + + def test_resolves_multi_owner(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[ + _entry("alpha", "src/alpha", created="2026-01-01", owner=True, registry_id="uid-a"), + _entry("beta", "src/beta", created="2026-02-01", owner=True, registry_id="uid-b"), + ], + ) + + result = fix_owner_identity(registry_path=reg) + assert result["applied"] is True + + data = json.loads(reg.read_text(encoding="utf-8")) + owners = [b for b in data["branches"] if b.get("owner") is True] + assert len(owners) == 1 + assert owners[0]["name"] == "alpha" + + def test_mints_metadata_id_when_no_passport_consensus(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11"}, + branches=[_entry("alpha", "src/alpha", owner=True, registry_id="uid-a")], + ) + + result = fix_owner_identity(registry_path=reg) + assert result["applied"] is True + assert any("Mint" in a for a in result["actions"]) + + data = json.loads(reg.read_text(encoding="utf-8")) + assert len(data["metadata"]["id"]) == 36 + + def test_majority_restores_metadata_id_from_passports(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + majority_id = "7087bb93-aaaa-bbbb-cccc-dddddddddddd" + outlier_id = "deadbeef-0000-1111-2222-333333333333" + branches = [] + for i in range(13): + name = f"agent{i:02d}" + _make_branch(tmp_path, name, f"src/{name}", passport_rid=majority_id) + branches.append(_entry(name, f"src/{name}", owner=(i == 0), registry_id=f"uid-{i}")) + _make_branch(tmp_path, "outlier", "src/outlier", passport_rid=outlier_id) + branches.append(_entry("outlier", "src/outlier", registry_id="uid-out")) + + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11"}, + branches=branches, + ) + + result = fix_owner_identity(registry_path=reg) + assert result["applied"] is True + assert any("Restore" in a and "majority 13 of 14" in a for a in result["actions"]) + + data = json.loads(reg.read_text(encoding="utf-8")) + assert data["metadata"]["id"] == majority_id + + def test_majority_restore_aligns_outlier_passport(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + majority_id = "7087bb93-aaaa-bbbb-cccc-dddddddddddd" + outlier_id = "deadbeef-0000-1111-2222-333333333333" + _make_branch(tmp_path, "alpha", "src/alpha", passport_rid=majority_id) + _make_branch(tmp_path, "beta", "src/beta", passport_rid=majority_id) + _make_branch(tmp_path, "gamma", "src/gamma", passport_rid=majority_id) + _make_branch(tmp_path, "outlier", "src/outlier", passport_rid=outlier_id) + + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11"}, + branches=[ + _entry("alpha", "src/alpha", owner=True, registry_id="uid-a"), + _entry("beta", "src/beta", registry_id="uid-b"), + _entry("gamma", "src/gamma", registry_id="uid-g"), + _entry("outlier", "src/outlier", registry_id="uid-o"), + ], + ) + + fix_owner_identity(registry_path=reg) + + data = json.loads(reg.read_text(encoding="utf-8")) + assert data["metadata"]["id"] == majority_id + + outlier_passport = json.loads((tmp_path / "src/outlier/.trinity/passport.json").read_text(encoding="utf-8")) + assert outlier_passport["citizenship"]["registry_id"] == majority_id + + def test_mints_metadata_id_when_passports_disagree(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + _make_branch(tmp_path, "alpha", "src/alpha", passport_rid="id-aaa") + _make_branch(tmp_path, "beta", "src/beta", passport_rid="id-bbb") + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11"}, + branches=[ + _entry("alpha", "src/alpha", owner=True, registry_id="uid-a"), + _entry("beta", "src/beta", registry_id="uid-b"), + ], + ) + + result = fix_owner_identity(registry_path=reg) + assert result["applied"] is True + assert any("Mint" in a for a in result["actions"]) + + data = json.loads(reg.read_text(encoding="utf-8")) + assert data["metadata"]["id"] != "id-aaa" + assert data["metadata"]["id"] != "id-bbb" + assert len(data["metadata"]["id"]) == 36 + + def test_mints_per_citizen_uids_for_stale_duplicates(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + shared_id = "stale-project-id" + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[ + _entry("alpha", "src/alpha", owner=True, registry_id=shared_id), + _entry("beta", "src/beta", registry_id=shared_id), + ], + ) + + result = fix_owner_identity(registry_path=reg) + assert result["applied"] is True + + data = json.loads(reg.read_text(encoding="utf-8")) + ids = [b["registry_id"] for b in data["branches"]] + assert ids[0] != ids[1] + assert ids[0] != shared_id or ids[1] != shared_id + + def test_aligns_passports_to_metadata_id(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + _make_branch(tmp_path, "alpha", "src/alpha", passport_rid="old-id") + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "new-proj-id"}, + branches=[_entry("alpha", "src/alpha", owner=True, registry_id="uid-a")], + ) + + result = fix_owner_identity(registry_path=reg) + actions_text = " ".join(result["actions"]) + assert "passport" in actions_text.lower() + + passport = json.loads((tmp_path / "src" / "alpha" / ".trinity" / "passport.json").read_text(encoding="utf-8")) + assert passport["citizenship"]["registry_id"] == "new-proj-id" + + def test_dry_run_does_not_write(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11"}, + branches=[_entry("alpha", "src/alpha", owner=True, registry_id="uid-a")], + ) + original = reg.read_text(encoding="utf-8") + + result = fix_owner_identity(registry_path=reg, dry_run=True) + assert len(result["actions"]) > 0 + assert result["applied"] is False + assert reg.read_text(encoding="utf-8") == original + + def test_idempotent(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + _make_branch(tmp_path, "alpha", "src/alpha", passport_rid="proj-id") + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[ + _entry("alpha", "src/alpha", created="2026-01-01", registry_id="uid-a-stale"), + _entry("beta", "src/beta", created="2026-02-01"), + ], + ) + + result1 = fix_owner_identity(registry_path=reg) + assert result1["applied"] is True + + result2 = fix_owner_identity(registry_path=reg) + assert result2["actions"] == [] + + def test_refuses_to_alter_correct_seat(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + _make_branch(tmp_path, "alpha", "src/alpha", passport_rid="proj-id") + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[_entry("alpha", "src/alpha", owner=True, registry_id="unique-alpha")], + ) + + result = fix_owner_identity(registry_path=reg) + assert result["actions"] == [] + assert result["applied"] is False + + +class TestAdoptCallsEnsureOwner: + """Test that _adopt_existing calls ensure_project_has_owner.""" + + def test_adopt_seats_owner(self, tmp_path): + from unittest.mock import patch + + from aipass.spawn.apps.modules.core import _adopt_existing + + branch_dir = tmp_path / "my_agent" + trinity = branch_dir / ".trinity" + trinity.mkdir(parents=True) + (trinity / "passport.json").write_text( + json.dumps({"identity": {"purpose": "test"}, "citizenship": {}}), + encoding="utf-8", + ) + + reg = _write_registry(tmp_path, branches=[]) + + with patch("aipass.spawn.apps.modules.core.find_registry", return_value=reg): + with patch("aipass.spawn.apps.modules.core.ensure_project_has_owner") as mock_owner: + with patch("aipass.spawn.apps.modules.core.fix_passport_registry_id"): + _adopt_existing(branch_dir, "", None, None) + mock_owner.assert_called_once_with(reg) + + +class TestFixDryRunFullyReadOnly: + """--fix --dry-run must not write ANYTHING (including old-sync portion).""" + + def test_fix_dry_run_writes_nothing_with_stale_entries(self, tmp_path): + """Regression: dry-run must not apply old-sync repairs (prune stale, add unreg).""" + from unittest.mock import patch + + from aipass.spawn.apps.modules.sync_registry import handle_sync_registry + + _make_branch(tmp_path, "real", "src/real", passport_rid="proj-id") + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[ + _entry("real", "src/real", owner=True, registry_id="uid-real"), + _entry("ghost", "src/ghost", registry_id="uid-ghost"), + ], + ) + original = reg.read_text(encoding="utf-8") + + with patch( + "aipass.spawn.apps.handlers.sync_registry_ops.find_registry", + return_value=reg, + ): + handle_sync_registry(["--fix", "--dry-run"]) + + assert reg.read_text(encoding="utf-8") == original + + +class TestUnifiedOwnerHeuristic: + """Both ensure_project_has_owner and fix_owner_identity must agree.""" + + def test_both_paths_pick_same_owner(self, tmp_path): + """When first-created and passport-owner differ, both paths must agree.""" + from aipass.spawn.apps.handlers.registry import ensure_project_has_owner, pick_owner_branch + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + _make_branch(tmp_path, "older", "src/older", citizen_class="aipass_framework") + _make_branch(tmp_path, "newer", "src/newer", citizen_class="manager") + + branches = [ + _entry("older", "src/older", created="2026-01-01", registry_id="uid-old"), + _entry("newer", "src/newer", created="2026-03-01", registry_id="uid-new"), + ] + + picked = pick_owner_branch(branches, tmp_path) + assert picked is not None + assert picked["name"] == "newer" + + reg_fix = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[ + _entry("older", "src/older", created="2026-01-01", registry_id="uid-old"), + _entry("newer", "src/newer", created="2026-03-01", registry_id="uid-new"), + ], + ) + fix_result = fix_owner_identity(registry_path=reg_fix) + fix_data = json.loads(reg_fix.read_text(encoding="utf-8")) + fix_owner = next(b for b in fix_data["branches"] if b.get("owner") is True) + + reg_ensure = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[ + _entry("older", "src/older", created="2026-01-01", registry_id="uid-old2"), + _entry("newer", "src/newer", created="2026-03-01", registry_id="uid-new2"), + ], + ) + ensure_project_has_owner(reg_ensure) + ensure_data = json.loads(reg_ensure.read_text(encoding="utf-8")) + ensure_owner = next(b for b in ensure_data["branches"] if b.get("owner") is True) + + assert fix_owner["name"] == ensure_owner["name"] == "newer" + assert fix_result["applied"] is True diff --git a/src/aipass/spawn/tests/test_owner_resolver.py b/src/aipass/spawn/tests/test_owner_resolver.py index 75e3507b..178669c0 100644 --- a/src/aipass/spawn/tests/test_owner_resolver.py +++ b/src/aipass/spawn/tests/test_owner_resolver.py @@ -284,9 +284,9 @@ class TestEnsureProjectHasOwner: class TestBackfillOwnerAndRegistryId: - """Tests for backfill_owner_and_registry_id().""" + """Tests for backfill_owner_and_registry_id() — mints unique per-citizen UUIDs.""" - def test_backfills_registry_id_and_owner(self, tmp_path): + def test_mints_unique_uuids_for_entries_missing_registry_id(self, tmp_path): from aipass.spawn.apps.handlers.registry import backfill_owner_and_registry_id reg = tmp_path / "TEST_REGISTRY.json" @@ -306,9 +306,9 @@ class TestBackfillOwnerAndRegistryId: "last_active": "2026-01-01", }, { - "name": "devpulse", - "path": "src/devpulse", - "email": "@devpulse", + "name": "beta", + "path": "src/beta", + "email": "@beta", "status": "active", "profile": "library", "description": "test", @@ -321,25 +321,50 @@ class TestBackfillOwnerAndRegistryId: encoding="utf-8", ) - alpha_dir = tmp_path / "src" / "alpha" / ".trinity" - alpha_dir.mkdir(parents=True) - (alpha_dir / "passport.json").write_text( - json.dumps( - { - "identity": {"citizen_class": "aipass_framework"}, - "citizenship": {"registry_id": "uuid-alpha"}, - } - ), - encoding="utf-8", - ) + result = backfill_owner_and_registry_id(reg) + assert result is True - dp_dir = tmp_path / "src" / "devpulse" / ".trinity" - dp_dir.mkdir(parents=True) - (dp_dir / "passport.json").write_text( + data = json.loads(reg.read_text(encoding="utf-8")) + alpha_entry = next(b for b in data["branches"] if b["name"] == "alpha") + beta_entry = next(b for b in data["branches"] if b["name"] == "beta") + + assert len(alpha_entry["registry_id"]) == 36 + assert len(beta_entry["registry_id"]) == 36 + assert alpha_entry["registry_id"] != beta_entry["registry_id"] + + def test_remints_duplicate_registry_ids(self, tmp_path): + from aipass.spawn.apps.handlers.registry import backfill_owner_and_registry_id + + shared_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + reg = tmp_path / "TEST_REGISTRY.json" + reg.write_text( json.dumps( { - "identity": {"citizen_class": "manager"}, - "citizenship": {"registry_id": "uuid-dp"}, + "metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 2}, + "branches": [ + { + "name": "alpha", + "path": "src/alpha", + "email": "@alpha", + "status": "active", + "profile": "library", + "description": "test", + "created": "2026-01-01", + "last_active": "2026-01-01", + "registry_id": shared_id, + }, + { + "name": "beta", + "path": "src/beta", + "email": "@beta", + "status": "active", + "profile": "library", + "description": "test", + "created": "2026-01-02", + "last_active": "2026-01-02", + "registry_id": shared_id, + }, + ], } ), encoding="utf-8", @@ -349,15 +374,11 @@ class TestBackfillOwnerAndRegistryId: assert result is True data = json.loads(reg.read_text(encoding="utf-8")) - alpha_entry = next(b for b in data["branches"] if b["name"] == "alpha") - dp_entry = next(b for b in data["branches"] if b["name"] == "devpulse") + ids = [b["registry_id"] for b in data["branches"]] + assert ids[0] != ids[1] + assert ids[0] != shared_id or ids[1] != shared_id - assert alpha_entry["registry_id"] == "uuid-alpha" - assert dp_entry["registry_id"] == "uuid-dp" - assert dp_entry["owner"] is True - assert alpha_entry.get("owner") is None or alpha_entry.get("owner") is not True - - def test_noop_when_already_backfilled(self, tmp_path): + def test_noop_when_already_unique(self, tmp_path): from aipass.spawn.apps.handlers.registry import backfill_owner_and_registry_id reg = tmp_path / "TEST_REGISTRY.json" @@ -376,7 +397,7 @@ class TestBackfillOwnerAndRegistryId: "created": "2026-01-01", "last_active": "2026-01-01", "owner": True, - "registry_id": "uuid-dp", + "registry_id": "unique-uuid-dp", }, ], } @@ -384,22 +405,10 @@ class TestBackfillOwnerAndRegistryId: encoding="utf-8", ) - dp_dir = tmp_path / "src" / "devpulse" / ".trinity" - dp_dir.mkdir(parents=True) - (dp_dir / "passport.json").write_text( - json.dumps( - { - "identity": {"citizen_class": "manager"}, - "citizenship": {"registry_id": "uuid-dp"}, - } - ), - encoding="utf-8", - ) - result = backfill_owner_and_registry_id(reg) assert result is False - def test_skips_branches_without_passport(self, tmp_path): + def test_seats_owner_when_missing(self, tmp_path): from aipass.spawn.apps.handlers.registry import backfill_owner_and_registry_id reg = tmp_path / "TEST_REGISTRY.json" @@ -409,9 +418,101 @@ class TestBackfillOwnerAndRegistryId: "metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 1}, "branches": [ { - "name": "ghost", - "path": "src/ghost", - "email": "@ghost", + "name": "alpha", + "path": "src/alpha", + "email": "@alpha", + "status": "active", + "profile": "library", + "description": "test", + "created": "2026-01-01", + "last_active": "2026-01-01", + "registry_id": "unique-alpha-id", + }, + ], + } + ), + encoding="utf-8", + ) + + result = backfill_owner_and_registry_id(reg) + assert result is True + + data = json.loads(reg.read_text(encoding="utf-8")) + assert data["branches"][0].get("owner") is True + + +class TestAddToRegistryMintsPerCitizenUid: + """Tests for add_to_registry per-citizen UUID minting.""" + + def test_always_mints_unique_registry_id(self, tmp_path): + from aipass.spawn.apps.handlers.registry import add_to_registry + + reg = tmp_path / "TEST_REGISTRY.json" + reg.write_text( + json.dumps( + { + "metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 0}, + "branches": [], + } + ), + encoding="utf-8", + ) + + add_to_registry(reg, "BRANCH_A", "src/branch_a", "library", "@branch_a", purpose="test") + + data = json.loads(reg.read_text(encoding="utf-8")) + entry = data["branches"][0] + assert "registry_id" in entry + assert len(entry["registry_id"]) == 36 # UUID4 format + + def test_two_entries_get_different_uuids(self, tmp_path): + from aipass.spawn.apps.handlers.registry import add_to_registry + + reg = tmp_path / "TEST_REGISTRY.json" + reg.write_text( + json.dumps( + { + "metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 0}, + "branches": [], + } + ), + encoding="utf-8", + ) + + add_to_registry(reg, "BRANCH_A", "src/branch_a", "library", "@branch_a") + add_to_registry(reg, "BRANCH_B", "src/branch_b", "library", "@branch_b") + + data = json.loads(reg.read_text(encoding="utf-8")) + ids = [b["registry_id"] for b in data["branches"]] + assert ids[0] != ids[1] + + +class TestEnsureProjectHasOwnerFirstAgentFallback: + """Tests for ensure_project_has_owner first-agent fallback.""" + + def test_falls_back_to_first_agent_when_no_manager(self, tmp_path): + from aipass.spawn.apps.handlers.registry import ensure_project_has_owner + + reg = tmp_path / "TEST_REGISTRY.json" + reg.write_text( + json.dumps( + { + "metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 2}, + "branches": [ + { + "name": "beta", + "path": "src/beta", + "email": "@beta", + "status": "active", + "profile": "library", + "description": "test", + "created": "2026-02-01", + "last_active": "2026-02-01", + }, + { + "name": "alpha", + "path": "src/alpha", + "email": "@alpha", "status": "active", "profile": "library", "description": "test", @@ -424,58 +525,11 @@ class TestBackfillOwnerAndRegistryId: encoding="utf-8", ) - result = backfill_owner_and_registry_id(reg) - assert result is False - - -class TestAddToRegistryWithRegistryId: - """Tests for add_to_registry with registry_id parameter.""" - - def test_includes_registry_id_when_provided(self, tmp_path): - from aipass.spawn.apps.handlers.registry import add_to_registry - - reg = tmp_path / "TEST_REGISTRY.json" - reg.write_text( - json.dumps( - { - "metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 0}, - "branches": [], - } - ), - encoding="utf-8", - ) - - result = add_to_registry( - reg, - "NEW_BRANCH", - "src/new_branch", - "library", - "@new_branch", - purpose="test branch", - registry_id="uuid-new", - ) + result = ensure_project_has_owner(reg) assert result is True data = json.loads(reg.read_text(encoding="utf-8")) - entry = data["branches"][0] - assert entry["registry_id"] == "uuid-new" - - def test_omits_registry_id_when_empty(self, tmp_path): - from aipass.spawn.apps.handlers.registry import add_to_registry - - reg = tmp_path / "TEST_REGISTRY.json" - reg.write_text( - json.dumps( - { - "metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 0}, - "branches": [], - } - ), - encoding="utf-8", - ) - - add_to_registry(reg, "NEW_BRANCH", "src/new_branch", "library", "@new_branch") - - data = json.loads(reg.read_text(encoding="utf-8")) - entry = data["branches"][0] - assert "registry_id" not in entry + alpha = next(b for b in data["branches"] if b["name"] == "alpha") + beta = next(b for b in data["branches"] if b["name"] == "beta") + assert alpha.get("owner") is True + assert beta.get("owner") is not True