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 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. 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: To create a new workplan:
1. Write the file following the format above 1. Write the file following the format above
2. Run `statehub fix-consistency` locally; ask the operator only if the CLI or 2. Run `statehub fix-consistency` locally.
State Hub API is unavailable. 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 `repo_manager.registrar.is_identifier_registrar`; consumed by
`statehub fix-consistency` C-06 / C-11 / C-32. `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`. Work: `RMGR-WP-0005-T01`.

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
from pathlib import Path from pathlib import Path
from repo_manager.commands.rapp import add_rapp_parser 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("--slug", default=None)
p_rec.add_argument("--no-write-index", action="store_true", help="Do not write index file") 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( p_cmd = sub.add_parser(
"update-task-status", "update-task-status",
help="Command repo.work.update_task_status (file + git commit)", 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)) print(json.dumps({"ok": True, "snapshot": snap, "index": index.to_dict()}, indent=2))
return 0 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": if args.command == "update-task-status":
from repo_manager.commands.task_status import 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 under `/home/tegwick`, undoing today's ownership fix — it needs a non-root
`runAsUser` or its own service account. `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 `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. design. Drop them once a sweep or two confirms forgejo is healthy.