diff --git a/AGENTS.md b/AGENTS.md index 3666454..410000d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,16 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ ``` Coding agents should run this directly; ask the operator only if the CLI or State Hub API is unavailable. This syncs task status from files into the hub DB. + If C-06/C-11 says this host is not the identifier registrar, do not retry + and do not set `STATEHUB_REGISTRAR` yourself. Use the governed, single-repo + fallback once the branch is clean and synchronized: + ```bash + uv run --project ~/repo-manager rmgr registrar-reconcile \ + --path . --confirm-primary --push + ``` + If that command is unavailable, send one registrar request to `repo-manager` + naming the repository and missing canonical ids, then continue file-backed + work without UUIDs. --- @@ -204,5 +214,7 @@ not a kind. Fleet list lives on State Hub, not in `SCOPE.md`. To create a new workplan: 1. Write the file following the format above -2. Run `statehub fix-consistency` locally; ask the operator only if the CLI or - State Hub API is unavailable. +2. Run `statehub fix-consistency` locally. +3. On a non-registrar C-06/C-11 skip, invoke `rmgr registrar-reconcile` once as + documented in the session-close protocol. Never set the registrar environment + variable directly and never send repeated requests for the same ids. diff --git a/docs/repository-standards_v0.1.md b/docs/repository-standards_v0.1.md index 1b47fc0..bcdb86a 100644 --- a/docs/repository-standards_v0.1.md +++ b/docs/repository-standards_v0.1.md @@ -53,4 +53,20 @@ the registrar. Disconnected work cannot register until T03. Implementation: `repo_manager.registrar.is_identifier_registrar`; consumed by `statehub fix-consistency` C-06 / C-11 / C-32. +The production fleet sweep is disabled and is not the interactive recovery +path. Repo Manager provides a bounded on-demand registrar for one clean, +up-to-date repository at a time: + +```bash +uv run --project ~/repo-manager rmgr registrar-reconcile \ + --path /path/to/repo --confirm-primary --push +``` + +The command verifies the authoritative State Hub health endpoint, refuses dirty +or ahead/behind branches and retired Gitea origins, serializes local registrar +runs, and grants `STATEHUB_REGISTRAR=1` only to its scoped child process. Agents +must not export that variable themselves. If the command is unavailable, send +one deduplicated request to `repo-manager`; repeated `fix-consistency` runs cannot +resolve the gate and waste execution time. + Work: `RMGR-WP-0005-T01`. diff --git a/src/repo_manager/cli.py b/src/repo_manager/cli.py index ba56fdc..e9c982b 100644 --- a/src/repo_manager/cli.py +++ b/src/repo_manager/cli.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse import json +import os from pathlib import Path from repo_manager.commands.rapp import add_rapp_parser @@ -45,6 +46,24 @@ def main(argv: list[str] | None = None) -> int: p_rec.add_argument("--slug", default=None) p_rec.add_argument("--no-write-index", action="store_true", help="Do not write index file") + p_registrar = sub.add_parser( + "registrar-reconcile", + help="Assign missing State Hub UUIDs through a scoped on-demand registrar", + ) + p_registrar.add_argument("--path", default=".", help="Repository checkout path") + p_registrar.add_argument( + "--api-base", + default=os.environ.get("STATE_HUB_API_BASE", "http://127.0.0.1:8000"), + help="Authoritative State Hub API base", + ) + p_registrar.add_argument("--statehub-bin", default=None, help="Override statehub executable") + p_registrar.add_argument( + "--confirm-primary", + action="store_true", + help="Confirm that --api-base is the authoritative hub", + ) + p_registrar.add_argument("--push", action="store_true", help="Push the registrar commit") + p_cmd = sub.add_parser( "update-task-status", help="Command repo.work.update_task_status (file + git commit)", @@ -236,6 +255,19 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps({"ok": True, "snapshot": snap, "index": index.to_dict()}, indent=2)) return 0 + if args.command == "registrar-reconcile": + from repo_manager.commands.registrar_reconcile import registrar_reconcile + + result = registrar_reconcile( + Path(args.path), + api_base=args.api_base, + statehub_bin=args.statehub_bin, + confirm_primary=args.confirm_primary, + push=args.push, + ) + print(json.dumps(result.to_dict(), indent=2)) + return 0 if result.status in {"applied", "noop"} else 1 + if args.command == "update-task-status": from repo_manager.commands.task_status import update_task_status diff --git a/src/repo_manager/commands/registrar_reconcile.py b/src/repo_manager/commands/registrar_reconcile.py new file mode 100644 index 0000000..561ed2b --- /dev/null +++ b/src/repo_manager/commands/registrar_reconcile.py @@ -0,0 +1,265 @@ +"""Scoped on-demand identifier registrar for the ADR-007 interim. + +The production fleet sweep is intentionally disabled. This command provides +one explicit, repository-scoped way to run the existing State Hub registration +adapter without teaching ordinary agent sessions to set ``STATEHUB_REGISTRAR`` +themselves. +""" + +from __future__ import annotations + +import fcntl +import os +import shutil +import subprocess +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import httpx + +from repo_manager.gitops import GitError, commit_paths, push_ff +from repo_manager.parse.workplan import parse_workplan_file + +LOCK_PATH = Path("/tmp/repo-manager-identifier-registrar.lock") + + +@dataclass +class RegistrarResult: + status: str + evidence: dict[str, Any] + error: dict[str, Any] | None = None + correlation_id: str = "" + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = { + "command": "repo.identifiers.registrar_reconcile", + "status": self.status, + "correlation_id": self.correlation_id, + "evidence": self.evidence, + } + if self.error: + result["error"] = self.error + return result + + +def _git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + + +def _missing_identifiers(repo: Path) -> dict[str, list[str]]: + workplans: list[str] = [] + tasks: list[str] = [] + workplans_dir = repo / "workplans" + if not workplans_dir.is_dir(): + return {"workplans": workplans, "tasks": tasks} + + # Closed archives are frozen under ADR-007 and are not registration work. + for path in sorted(workplans_dir.glob("*.md")): + parsed = parse_workplan_file(path, repo_root=repo) + if parsed.frontmatter.get("type") != "workplan" or not parsed.id: + continue + # Closed records are frozen provenance under ADR-007. State Hub also + # deliberately refuses to create missing task rows for them. + if (parsed.status or "").strip().lower() in {"finished", "archived"}: + continue + if not parsed.state_hub_workstream_id: + workplans.append(parsed.id) + for task in parsed.tasks: + if task.id and not task.state_hub_task_id: + tasks.append(task.id) + return {"workplans": workplans, "tasks": tasks} + + +def _check_git(repo: Path) -> tuple[dict[str, Any], str | None]: + status = _git(repo, "status", "--porcelain") + if status.returncode != 0: + return {}, status.stderr.strip() or "not a Git repository" + if status.stdout.strip(): + return {}, "worktree must be clean before registrar reconciliation" + + upstream = _git(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") + if upstream.returncode != 0: + return {}, "current branch must have an upstream" + counts = _git(repo, "rev-list", "--left-right", "--count", "@{u}...HEAD") + if counts.returncode != 0: + return {}, counts.stderr.strip() or "could not compare HEAD with upstream" + try: + behind, ahead = (int(value) for value in counts.stdout.split()) + except (TypeError, ValueError): + return {}, "could not parse Git ahead/behind state" + if behind or ahead: + return ( + {"upstream": upstream.stdout.strip(), "behind": behind, "ahead": ahead}, + "branch must match its upstream; pull or push before registrar reconciliation", + ) + + remote = _git(repo, "remote", "get-url", "origin") + if remote.returncode != 0: + return {}, "origin remote is required" + remote_url = remote.stdout.strip() + stale_markers = ("gitea-remote", "gitea.coulomb.social", "92.205.130.254") + if any(marker in remote_url for marker in stale_markers): + return {"origin": remote_url}, "origin still targets the retired Gitea lineage" + return { + "upstream": upstream.stdout.strip(), + "behind": behind, + "ahead": ahead, + "origin": remote_url, + }, None + + +def _check_primary(api_base: str) -> tuple[dict[str, Any], str | None]: + try: + response = httpx.get(f"{api_base.rstrip('/')}/state/health", timeout=10.0) + response.raise_for_status() + payload = response.json() + except (httpx.HTTPError, ValueError) as exc: + return {}, f"primary State Hub health check failed: {exc}" + if payload.get("status") != "ok" or payload.get("db") != "connected": + return payload, "State Hub is not healthy and database-connected" + return payload, None + + +def _run_statehub(command: list[str], *, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + capture_output=True, + text=True, + check=False, + timeout=300, + env=env, + ) + + +def registrar_reconcile( + path: Path, + *, + api_base: str = "http://127.0.0.1:8000", + statehub_bin: str | None = None, + confirm_primary: bool = False, + push: bool = False, +) -> RegistrarResult: + """Register missing workplan/task UUIDs through one scoped child process.""" + cid = str(uuid.uuid4()) + repo = path.expanduser().resolve() + before = _missing_identifiers(repo) + evidence: dict[str, Any] = { + "repo_path": str(repo), + "repo_slug": repo.name, + "api_base": api_base.rstrip("/"), + "missing_before": before, + } + + if not confirm_primary: + return RegistrarResult( + "rejected", + evidence, + { + "code": "confirmation_required", + "message": "pass --confirm-primary after verifying this is the authoritative hub", + }, + cid, + ) + if not before["workplans"] and not before["tasks"]: + evidence["missing_after"] = before + return RegistrarResult("noop", evidence, None, cid) + + git_evidence, git_error = _check_git(repo) + evidence["git"] = git_evidence + if git_error: + return RegistrarResult( + "rejected", evidence, {"code": "git_precondition_failed", "message": git_error}, cid + ) + + health, health_error = _check_primary(api_base) + evidence["state_hub_health"] = health + if health_error: + return RegistrarResult( + "rejected", evidence, {"code": "primary_unavailable", "message": health_error}, cid + ) + + executable = statehub_bin or shutil.which("statehub") + if not executable: + return RegistrarResult( + "rejected", + evidence, + {"code": "statehub_missing", "message": "statehub CLI is not installed"}, + cid, + ) + + LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) + with LOCK_PATH.open("a+", encoding="utf-8") as lock: + try: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return RegistrarResult( + "rejected", + evidence, + {"code": "registrar_busy", "message": "another registrar reconciliation is active"}, + cid, + ) + + child_env = {**os.environ, "STATEHUB_REGISTRAR": "1"} + command = [ + executable, + "fix-consistency", + "--path", + str(repo), + "--api-base", + api_base.rstrip("/"), + ] + completed = _run_statehub(command, env=child_env) + + evidence["statehub_exit_code"] = completed.returncode + evidence["statehub_stdout_tail"] = completed.stdout[-4000:] + evidence["statehub_stderr_tail"] = completed.stderr[-2000:] + after = _missing_identifiers(repo) + evidence["missing_after"] = after + if completed.returncode not in {0, 2} or after["workplans"] or after["tasks"]: + return RegistrarResult( + "failed", + evidence, + { + "code": "registration_incomplete", + "message": "State Hub reconciliation did not assign every requested identifier", + }, + cid, + ) + + changed = _git(repo, "status", "--porcelain") + paths = [line[3:] for line in changed.stdout.splitlines() if len(line) > 3] + if paths: + try: + evidence["git_sha"] = commit_paths( + repo, + paths, + "chore(registrar): assign State Hub identifiers", + ) + except GitError as exc: + return RegistrarResult( + "failed", + evidence, + {"code": "commit_failed", "message": str(exc)}, + cid, + ) + + if push: + pushed, message = push_ff(repo) + evidence["push"] = {"ok": pushed, "message": message} + if not pushed: + return RegistrarResult( + "failed", + evidence, + {"code": "push_failed", "message": message}, + cid, + ) + + return RegistrarResult("applied", evidence, None, cid) diff --git a/tests/test_registrar_reconcile.py b/tests/test_registrar_reconcile.py new file mode 100644 index 0000000..15dafcf --- /dev/null +++ b/tests/test_registrar_reconcile.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +from repo_manager.commands import registrar_reconcile as rr + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +def _fixture(tmp_path: Path) -> Path: + bare = tmp_path / "remote.git" + repo = tmp_path / "demo" + _git(tmp_path, "init", "--bare", str(bare)) + _git(tmp_path, "clone", str(bare), str(repo)) + _git(repo, "config", "user.name", "Test") + _git(repo, "config", "user.email", "test@example.com") + (repo / "workplans").mkdir() + (repo / "workplans" / "DEMO-WP-0001.md").write_text( + """--- +id: DEMO-WP-0001 +type: workplan +title: Demo +status: active +--- + +## Task + +```task +id: DEMO-WP-0001-T01 +status: todo +priority: high +``` +""", + encoding="utf-8", + ) + _git(repo, "add", ".") + _git(repo, "commit", "-m", "seed") + _git(repo, "push", "-u", "origin", "HEAD") + return repo + + +def test_requires_explicit_primary_confirmation(tmp_path: Path) -> None: + repo = _fixture(tmp_path) + result = rr.registrar_reconcile(repo) + assert result.status == "rejected" + assert result.error and result.error["code"] == "confirmation_required" + + +def test_closed_records_are_not_registrar_work(tmp_path: Path) -> None: + repo = _fixture(tmp_path) + workplan = repo / "workplans" / "DEMO-WP-0001.md" + workplan.write_text( + workplan.read_text(encoding="utf-8").replace("status: active", "status: finished"), + encoding="utf-8", + ) + assert rr._missing_identifiers(repo) == {"workplans": [], "tasks": []} + + +def test_rejects_dirty_or_unsynced_repository(tmp_path: Path, monkeypatch) -> None: + repo = _fixture(tmp_path) + monkeypatch.setattr(rr, "_check_primary", lambda _api: ({"status": "ok", "db": "connected"}, None)) + (repo / "note.txt").write_text("dirty", encoding="utf-8") + dirty = rr.registrar_reconcile(repo, confirm_primary=True) + assert dirty.status == "rejected" + assert dirty.error and dirty.error["code"] == "git_precondition_failed" + + +def test_scopes_registrar_env_and_commits_assigned_ids(tmp_path: Path, monkeypatch) -> None: + repo = _fixture(tmp_path) + monkeypatch.setattr(rr, "_check_primary", lambda _api: ({"status": "ok", "db": "connected"}, None)) + + def fake_run(command, *, env): + assert command[1:3] == ["fix-consistency", "--path"] + assert env["STATEHUB_REGISTRAR"] == "1" + workplan = repo / "workplans" / "DEMO-WP-0001.md" + text = workplan.read_text(encoding="utf-8") + text = text.replace("status: active\n---", 'status: active\nstate_hub_workstream_id: "11111111-1111-4111-8111-111111111111"\n---') + text = text.replace("priority: high\n```", 'priority: high\nstate_hub_task_id: "22222222-2222-4222-8222-222222222222"\n```') + workplan.write_text(text, encoding="utf-8") + return subprocess.CompletedProcess(command, 0, "ok", "") + + monkeypatch.setattr(rr, "_run_statehub", fake_run) + + result = rr.registrar_reconcile( + repo, + statehub_bin="statehub", + confirm_primary=True, + ) + + assert result.status == "applied" + assert result.evidence["missing_after"] == {"workplans": [], "tasks": []} + subject = subprocess.run( + ["git", "log", "-1", "--format=%s"], cwd=repo, capture_output=True, text=True, check=True + ).stdout.strip() + assert subject == "chore(registrar): assign State Hub identifiers" diff --git a/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md b/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md index a352452..1194022 100644 --- a/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md +++ b/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md @@ -170,6 +170,16 @@ scale back up. Note the pod runs as root and will re-create root-owned files under `/home/tegwick`, undoing today's ownership fix — it needs a non-root `runAsUser` or its own service account. +**Interim recovery revised 2026-08-21:** do not restore the host-wide sweep just +to drain UUID requests. Repo Manager now owns a scoped on-demand command, +`rmgr registrar-reconcile`, which preflights a clean synchronized Forgejo +checkout and the authoritative hub, serializes the run, and sets +`STATEHUB_REGISTRAR=1` only for one repository's `fix-consistency` child. +It commits the identifier writeback and pushes only when explicitly requested. +This is the coding-agent recovery path until T03 lands; agents must neither set +the environment variable directly nor retry C-06/C-11 or send duplicate +messages. The production sweep remains disabled behind T12. + `gitea` cannot be decommissioned yet: the rollback remotes still point at it by design. Drop them once a sweep or two confirms forgejo is healthy.