feat: add scoped identifier registrar

This commit is contained in:
tegwick 2026-08-21 21:38:23 +02:00
parent 859df9aae7
commit 2bf569d3d5
6 changed files with 435 additions and 2 deletions

View file

@ -120,6 +120,16 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
```
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.

View file

@ -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`.

View file

@ -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

View file

@ -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)

View file

@ -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"

View file

@ -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.