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.

This commit is contained in:
AIOSAI
2026-07-11 17:15:47 -07:00
parent 380eca813b
commit d511576fc0
19 changed files with 1665 additions and 208 deletions
+29
View File
@@ -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).**
+77 -1
View File
@@ -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]")
+21 -5
View File
@@ -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)
+37
View File
@@ -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"))
+152
View File
@@ -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
+47 -23
View File
@@ -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."""
+59 -1
View File
@@ -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)
@@ -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 "@<target>"
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"}
@@ -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") == "@<target>"
def test_skip_non_bash(self):
from aipass.hooks.apps.handlers.lifecycle.auto_watchdog import handle
+4 -3
View File
@@ -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)
@@ -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,
+87 -51
View File
@@ -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):
@@ -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}
+2 -3
View File
@@ -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)
+103 -10
View File
@@ -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
@@ -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}
@@ -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"
@@ -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
+152 -98
View File
@@ -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