feat(RMGR-WP-0002): dual-run writeback, flags, meter, SH facade

Add dual-run flags/meter, harden task-status (idempotency, UUID, head,
push), State Hub adapter for PATCH /tasks and C-15/reconcile proxy, pilot
evidence, and finish RMGR-WP-0002.
This commit is contained in:
tegwick 2026-08-09 23:19:56 +02:00
parent bb1d030257
commit 310b43079d
14 changed files with 679 additions and 50 deletions

View file

@ -0,0 +1,3 @@
from repo_manager.cli import main
raise SystemExit(main())

View file

@ -17,6 +17,7 @@ def main(argv: list[str] | None = None) -> int:
sub = parser.add_subparsers(dest="command")
sub.add_parser("version", help="Print version")
sub.add_parser("dual-run-status", help="Show dual-run flags and mutation meter")
p_obs = sub.add_parser("observe", help="Observe repository + print snapshot JSON")
p_obs.add_argument("--path", default=".", help="Repository checkout path")
@ -25,12 +26,6 @@ def main(argv: list[str] | None = None) -> int:
p_rec = sub.add_parser("reconcile", help="Rebuild local work-record index from files")
p_rec.add_argument("--path", default=".", help="Repository checkout path")
p_rec.add_argument("--slug", default=None)
p_rec.add_argument(
"--write-index",
action="store_true",
default=True,
help="Write .repo-manager/index.json (default true)",
)
p_rec.add_argument("--no-write-index", action="store_true", help="Do not write index file")
p_cmd = sub.add_parser(
@ -38,7 +33,11 @@ def main(argv: list[str] | None = None) -> int:
help="Command repo.work.update_task_status (file + git commit)",
)
p_cmd.add_argument("--path", default=".", help="Repository checkout path")
p_cmd.add_argument("--task-id", required=True, help="Canonical task id e.g. RMGR-WP-0001-T05")
p_cmd.add_argument(
"--task-id",
required=True,
help="Canonical task id or State Hub task UUID",
)
p_cmd.add_argument(
"--status",
required=True,
@ -46,6 +45,10 @@ def main(argv: list[str] | None = None) -> int:
)
p_cmd.add_argument("--reason", default="rmgr CLI")
p_cmd.add_argument("--correlation-id", default=None)
p_cmd.add_argument("--idempotency-key", default=None)
p_cmd.add_argument("--expected-head-sha", default=None)
p_cmd.add_argument("--slug", default=None)
p_cmd.add_argument("--push", action="store_true", help="git push after commit (push-seal)")
p_cmd.add_argument(
"--no-commit",
action="store_true",
@ -58,7 +61,13 @@ def main(argv: list[str] | None = None) -> int:
from repo_manager import __version__
print(__version__)
return 0 if args.command or args.version else 0
return 0
if args.command == "dual-run-status":
from repo_manager.dual_run import flags_status
print(json.dumps(flags_status(), indent=2))
return 0
if args.command == "observe":
from repo_manager.observe import observe_repository
@ -68,6 +77,7 @@ def main(argv: list[str] | None = None) -> int:
return 0
if args.command == "reconcile":
from repo_manager.dual_run import record_mutation
from repo_manager.index_store import append_event, save_index
from repo_manager.observe import observe_repository
@ -79,8 +89,15 @@ def main(argv: list[str] | None = None) -> int:
"type": "repo.reconciled",
"workplan_count": snap["index"]["workplan_count"],
"task_count": snap["index"]["task_count"],
"source": "repo-manager",
},
)
record_mutation(
source="repo-manager",
kind="reconcile",
repo_slug=snap.get("slug"),
detail=snap["index"],
)
if not args.no_write_index:
path = save_index(index)
print(json.dumps({"ok": True, "index_path": str(path), "snapshot": snap}, indent=2))
@ -98,6 +115,10 @@ def main(argv: list[str] | None = None) -> int:
correlation_id=args.correlation_id,
reason=args.reason,
commit=not args.no_commit,
push=args.push,
expected_head_sha=args.expected_head_sha,
idempotency_key=args.idempotency_key,
repo_slug=args.slug,
)
print(json.dumps(result.to_dict(), indent=2))
return 0 if result.status == "applied" else 1

View file

@ -1,4 +1,4 @@
"""Governed command: repo.work.update_task_status (vertical-slice implementation)."""
"""Governed command: repo.work.update_task_status (dual-run hardened)."""
from __future__ import annotations
@ -8,8 +8,9 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from repo_manager.gitops import GitError, commit_paths, head_sha
from repo_manager.index_store import append_event, default_index_path, load_index, save_index
from repo_manager import dual_run, idempotency
from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
from repo_manager.index_store import append_event, default_index_path, save_index
from repo_manager.observe import observe_repository
from repo_manager.parse.workplan import _TASK_BLOCK_RE, _parse_yaml_block
@ -35,14 +36,38 @@ class CommandResult:
return out
def _patch_task_status_in_file(path: Path, task_canonical_id: str, status: str) -> bool:
def _is_uuid(value: str) -> bool:
try:
uuid.UUID(value)
return True
except ValueError:
return False
def _patch_task_status_in_file(
path: Path,
*,
canonical_id: str | None,
hub_uuid: str | None,
status: str,
) -> bool:
text = path.read_text(encoding="utf-8")
def _replace(match: re.Match[str]) -> str:
block = match.group(0)
meta = _parse_yaml_block(match.group(1))
tid = meta.get("id")
if tid is None or str(tid) != task_canonical_id:
tid = str(meta["id"]) if meta.get("id") is not None else None
hub = (
str(meta["state_hub_task_id"]).strip().strip('"')
if meta.get("state_hub_task_id") is not None
else None
)
matched = False
if canonical_id and tid == canonical_id:
matched = True
if hub_uuid and hub and hub.lower() == hub_uuid.lower():
matched = True
if not matched:
return block
replaced = re.sub(
r"^(status:\s*)\S+",
@ -53,7 +78,6 @@ def _patch_task_status_in_file(path: Path, task_canonical_id: str, status: str)
)
if replaced != block:
return replaced
# insert status if missing
return block.replace("\n```", f"\nstatus: {status}\n```", 1)
new_text = _TASK_BLOCK_RE.sub(_replace, text)
@ -71,10 +95,48 @@ def update_task_status(
correlation_id: str | None = None,
reason: str = "rmgr command",
commit: bool = True,
push: bool = False,
expected_head_sha: str | None = None,
idempotency_key: str | None = None,
repo_slug: str | None = None,
) -> CommandResult:
"""Apply task status change to the workplan file and optionally git-commit."""
"""Apply task status change to the workplan file and optionally git-commit.
``task_id`` may be a canonical id (e.g. RMGR-WP-0001-T05) or a State Hub
task UUID (matched against ``state_hub_task_id`` in the file).
"""
correlation_id = correlation_id or str(uuid.uuid4())
repo_root = repo_root.resolve()
payload = {
"repo_root": str(repo_root),
"task_id": task_id,
"status": status,
"expected_head_sha": expected_head_sha,
}
ph = idempotency.payload_hash(payload)
if idempotency_key:
prior = idempotency.get(idempotency_key)
if prior:
if prior.get("payload_hash") != ph:
return CommandResult(
status="rejected",
correlation_id=correlation_id,
evidence={"status": "rejected"},
error={
"code": "conflict",
"message": "idempotency key reused with different payload",
"correlation_id": correlation_id,
},
)
# Replay prior result
prev = prior.get("result") or {}
return CommandResult(
status=prev.get("status", "applied"),
correlation_id=prev.get("correlation_id", correlation_id),
evidence=prev.get("evidence") or {"status": "applied", "replay": True},
error=prev.get("error"),
)
if status not in VALID_TASK_STATUSES:
return CommandResult(
@ -88,12 +150,37 @@ def update_task_status(
},
)
# Locate task via fresh observation
_snapshot, index = observe_repository(repo_root)
match = next(
(r for r in index.work_records if r.kind == "task" and r.id == task_id),
None,
)
current_head = head_sha(repo_root)
if expected_head_sha and current_head and expected_head_sha != current_head:
return CommandResult(
status="rejected",
correlation_id=correlation_id,
evidence={"status": "rejected", "head_sha": current_head},
error={
"code": "precondition_failed",
"message": f"expected_head_sha {expected_head_sha} != {current_head}",
"correlation_id": correlation_id,
"retryable": True,
},
)
snapshot, index = observe_repository(repo_root, slug=repo_slug)
slug = repo_slug or snapshot.get("slug")
hub_uuid = task_id if _is_uuid(task_id) else None
canonical = None if hub_uuid else task_id
match = None
for r in index.work_records:
if r.kind != "task":
continue
if canonical and r.id == canonical:
match = r
break
if hub_uuid and r.uuid and r.uuid.lower() == hub_uuid.lower():
match = r
break
if match is None:
return CommandResult(
status="rejected",
@ -119,8 +206,29 @@ def update_task_status(
},
)
expected_head = head_sha(repo_root)
changed = _patch_task_status_in_file(path, task_id, status)
# Already at status → treat as successful no-op applied with current head
if match.status == status:
result = CommandResult(
status="applied",
correlation_id=correlation_id,
evidence={
"status": "applied",
"git_sha": current_head,
"files_touched": [],
"noop": True,
"observed_at": index.observed_at,
},
)
if idempotency_key:
idempotency.put(idempotency_key, ph, result.to_dict())
return result
changed = _patch_task_status_in_file(
path,
canonical_id=match.id,
hub_uuid=match.uuid or hub_uuid,
status=status,
)
if not changed:
return CommandResult(
status="rejected",
@ -128,20 +236,23 @@ def update_task_status(
evidence={"status": "rejected", "files_touched": []},
error={
"code": "conflict",
"message": "task block not patched (already at status or id mismatch)",
"message": "task block not patched (id mismatch)",
"correlation_id": correlation_id,
},
)
git_sha: str | None = None
push_ok: bool | None = None
push_msg: str | None = None
if commit:
try:
git_sha = commit_paths(
repo_root,
[match.source_path],
message=(
f"repo.work.update_task_status {task_id} -> {status}\n\n"
f"repo.work.update_task_status {match.id or task_id} -> {status}\n\n"
f"correlation_id: {correlation_id}\nreason: {reason}\n"
f"source: repo-manager\n"
),
)
except GitError as exc:
@ -159,18 +270,21 @@ def update_task_status(
"correlation_id": correlation_id,
},
)
if push:
push_ok, push_msg = push_ff(repo_root)
# Rebuild projection
_snap2, index2 = observe_repository(repo_root)
_snap2, index2 = observe_repository(repo_root, slug=slug)
event = {
"type": "repo.command.applied",
"command": "repo.work.update_task_status",
"correlation_id": correlation_id,
"task_id": task_id,
"task_id": match.id,
"task_uuid": match.uuid,
"new_status": status,
"git_sha": git_sha,
"files_touched": [match.source_path],
"expected_head_sha_before": expected_head,
"expected_head_sha_before": current_head,
"source": "repo-manager",
}
append_event(index2, event)
append_event(
@ -179,21 +293,37 @@ def update_task_status(
"type": "repo.work.indexed",
"correlation_id": correlation_id,
"kind": "task",
"id": task_id,
"id": match.id,
"source_path": match.source_path,
},
)
save_index(index2)
evidence = {
dual_run.record_mutation(
source="repo-manager",
kind="task_status_writeback",
repo_slug=slug,
detail={
"task_id": match.id,
"status": status,
"git_sha": git_sha,
"correlation_id": correlation_id,
},
)
evidence: dict[str, Any] = {
"status": "applied",
"git_sha": git_sha,
"files_touched": [match.source_path],
"observed_at": index2.observed_at,
"index_path": str(default_index_path(repo_root)),
"source": "repo-manager",
}
if not git_sha:
# contract: file-mutating applied without git_sha is invalid — force fail if no commit
if push:
evidence["push_ok"] = push_ok
evidence["push_message"] = push_msg
if commit and not git_sha:
return CommandResult(
status="failed",
correlation_id=correlation_id,
@ -205,8 +335,11 @@ def update_task_status(
},
)
return CommandResult(
result = CommandResult(
status="applied",
correlation_id=correlation_id,
evidence=evidence,
)
if idempotency_key:
idempotency.put(idempotency_key, ph, result.to_dict())
return result

View file

@ -0,0 +1,120 @@
"""Dual-run flags and checkout-mutation meter (RMGR-WP-0002 / ArchitectureBlueprint Stage B).
Environment variables (shared with State Hub adapter):
RM_WRITEBACK=1|true|yes file+git task writeback via repo-manager
RM_RECONCILE=1|true|yes reconcile path prefers repo-manager for pilot repos
RM_PILOT_REPOS=slug1,slug2 if set, flags only apply to these slugs; empty = all
RM_METER_PATH=~/.repo-manager/mutation-meter.jsonl append-only meter log
Rollback: unset flags or set to 0 State Hub native path only.
"""
from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Literal
Source = Literal["state-hub", "repo-manager"]
def _truthy(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
def writeback_enabled() -> bool:
return _truthy("RM_WRITEBACK")
def reconcile_enabled() -> bool:
return _truthy("RM_RECONCILE")
def pilot_slugs() -> set[str] | None:
"""None means all repos; empty set after parse of blank list means none."""
raw = os.environ.get("RM_PILOT_REPOS")
if raw is None:
return None
raw = raw.strip()
if not raw:
return set()
return {s.strip() for s in raw.split(",") if s.strip()}
def slug_allowed(slug: str | None) -> bool:
if not slug:
return False
pilots = pilot_slugs()
if pilots is None:
return True
return slug in pilots
def writeback_for_repo(slug: str | None) -> bool:
return writeback_enabled() and slug_allowed(slug)
def reconcile_for_repo(slug: str | None) -> bool:
return reconcile_enabled() and slug_allowed(slug)
def meter_path() -> Path:
raw = os.environ.get("RM_METER_PATH", "~/.repo-manager/mutation-meter.jsonl")
return Path(raw).expanduser()
def record_mutation(
*,
source: Source,
kind: str,
repo_slug: str | None,
detail: dict[str, Any] | None = None,
) -> None:
"""Append one meter line. Best-effort; never raises to callers."""
try:
path = meter_path()
path.parent.mkdir(parents=True, exist_ok=True)
row = {
"ts": datetime.now(timezone.utc).isoformat(),
"source": source,
"kind": kind,
"repo_slug": repo_slug,
"detail": detail or {},
}
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(row, default=str) + "\n")
except OSError:
pass
def meter_summary(path: Path | None = None) -> dict[str, int]:
path = path or meter_path()
counts: dict[str, int] = {"state-hub": 0, "repo-manager": 0, "total": 0}
if not path.is_file():
return counts
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
src = row.get("source")
if src in counts:
counts[src] += 1
counts["total"] += 1
return counts
def flags_status() -> dict[str, Any]:
return {
"RM_WRITEBACK": writeback_enabled(),
"RM_RECONCILE": reconcile_enabled(),
"RM_PILOT_REPOS": sorted(pilot_slugs()) if pilot_slugs() is not None else None,
"RM_METER_PATH": str(meter_path()),
"meter": meter_summary(),
}

View file

@ -64,3 +64,21 @@ def commit_paths(
if not sha:
raise GitError("commit succeeded but HEAD missing")
return sha
def push_ff(repo: Path) -> tuple[bool, str]:
"""Best-effort push (push-seal compatible). Never force-pushes."""
try:
proc = subprocess.run(
["git", "push"],
cwd=repo,
capture_output=True,
text=True,
timeout=60,
check=False,
)
if proc.returncode == 0:
return True, (proc.stdout.strip() or "pushed")
return False, (proc.stderr.strip() or proc.stdout.strip() or "push failed")
except Exception as exc: # noqa: BLE001
return False, str(exc)

View file

@ -0,0 +1,45 @@
"""Simple file-backed idempotency for dual-run commands."""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
from typing import Any
def _store_path() -> Path:
raw = os.environ.get("RM_IDEMPOTENCY_PATH", "~/.repo-manager/idempotency.json")
return Path(raw).expanduser()
def _load() -> dict[str, Any]:
path = _store_path()
if not path.is_file():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def _save(data: dict[str, Any]) -> None:
path = _store_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
def payload_hash(payload: dict[str, Any]) -> str:
blob = json.dumps(payload, sort_keys=True, default=str)
return hashlib.sha256(blob.encode()).hexdigest()
def get(key: str) -> dict[str, Any] | None:
return _load().get(key)
def put(key: str, payload_hash_value: str, result: dict[str, Any]) -> None:
data = _load()
data[key] = {"payload_hash": payload_hash_value, "result": result}
_save(data)