diff --git a/README.md b/README.md index e8f70fc..b812c9b 100644 --- a/README.md +++ b/README.md @@ -40,4 +40,4 @@ Direction: - [State Hub repo tooling research](history/2026-08-09-state-hub-repo-tooling-research.md) - [Architecture blueprint (retirement path)](specs/ArchitectureBlueprint.md) -- Next: [RMGR-WP-0002](workplans/RMGR-WP-0002-retirement-dual-run-strangler.md) dual-run strangler +- Dual-run: [docs/dual-run.md](docs/dual-run.md) · [RMGR-WP-0002](workplans/RMGR-WP-0002-retirement-dual-run-strangler.md) diff --git a/docs/dual-run.md b/docs/dual-run.md new file mode 100644 index 0000000..75cdaab --- /dev/null +++ b/docs/dual-run.md @@ -0,0 +1,50 @@ +# Dual-run flags (RMGR-WP-0002) + +State Hub remains the MCP/REST entrypoint. When flags are on, **checkout +mutation** is executed by Repo Manager. + +## Environment variables + +| Variable | Meaning | +| --- | --- | +| `RM_WRITEBACK=1` | Task file writeback via `rmgr update-task-status` | +| `RM_RECONCILE=1` | `fix-consistency` also runs `rmgr reconcile` for pilots | +| `RM_PILOT_REPOS=repo-manager` | Comma-separated slugs; **unset** = all repos when flags on | +| `RM_METER_PATH` | JSONL meter path (default `~/.repo-manager/mutation-meter.jsonl`) | +| `REPO_MANAGER_SRC` | Optional path to `repo-manager/src` for SH adapter | +| `RMGR_BIN` | Optional override command prefix for `rmgr` | + +## Rollback + +```bash +unset RM_WRITEBACK RM_RECONCILE RM_PILOT_REPOS +# or +export RM_WRITEBACK=0 RM_RECONCILE=0 +``` + +State Hub immediately uses native C-15 writeback again. + +## Pilot (repo-manager) + +```bash +export RM_WRITEBACK=1 +export RM_RECONCILE=1 +export RM_PILOT_REPOS=repo-manager +export REPO_MANAGER_SRC=$HOME/repo-manager/src + +# From API host environment, PATCH a task or: +cd ~/repo-manager +rmgr update-task-status --path . --task-id --status progress + +cd ~/repo-manager && statehub fix-consistency +rmgr dual-run-status +``` + +## Meter + +```bash +rmgr dual-run-status +# or +wc -l ~/.repo-manager/mutation-meter.jsonl +grep repo-manager ~/.repo-manager/mutation-meter.jsonl | tail +``` diff --git a/docs/evidence/idempotency-pilot.json b/docs/evidence/idempotency-pilot.json new file mode 100644 index 0000000..d09dac2 --- /dev/null +++ b/docs/evidence/idempotency-pilot.json @@ -0,0 +1,20 @@ +{ + "sh-dual-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa-done-d61b0719-b3fd-4d4d-8179-48f030c33786": { + "payload_hash": "1f2ca8802007e55c670745c596bbf92846e673d46d975ef2d1caf18b275240b8", + "result": { + "command": "repo.work.update_task_status", + "status": "applied", + "correlation_id": "d61b0719-b3fd-4d4d-8179-48f030c33786", + "evidence": { + "status": "applied", + "git_sha": "3a6366f837d01ddf8e10fe046547a4a0ccc85372", + "files_touched": [ + "workplans/RMGR-WP-0002-pilot.md" + ], + "observed_at": "2026-08-09T21:18:31.232949+00:00", + "index_path": "/tmp/rmgr-pilot-1960028/.repo-manager/index.json", + "source": "repo-manager" + } + } + } +} diff --git a/docs/evidence/mutation-meter-pilot.jsonl b/docs/evidence/mutation-meter-pilot.jsonl new file mode 100644 index 0000000..f316607 --- /dev/null +++ b/docs/evidence/mutation-meter-pilot.jsonl @@ -0,0 +1,2 @@ +{"ts": "2026-08-09T21:18:30.362077+00:00", "source": "repo-manager", "kind": "task_status_writeback", "repo_slug": "repo-manager", "detail": {"task_id": "RMGR-WP-0002-T01", "status": "progress", "git_sha": "0e9d6163318c8d69eeb132facab6570f3a135302", "correlation_id": "a9bcd3bc-24db-493f-8ee1-afce9e5dbe38"}} +{"ts": "2026-08-09T21:18:31.234835+00:00", "source": "repo-manager", "kind": "task_status_writeback", "repo_slug": "repo-manager", "detail": {"task_id": "RMGR-WP-0002-T01", "status": "done", "git_sha": "3a6366f837d01ddf8e10fe046547a4a0ccc85372", "correlation_id": "d61b0719-b3fd-4d4d-8179-48f030c33786"}} diff --git a/docs/evidence/wp0002-completion.md b/docs/evidence/wp0002-completion.md new file mode 100644 index 0000000..e23f9c7 --- /dev/null +++ b/docs/evidence/wp0002-completion.md @@ -0,0 +1,46 @@ +# RMGR-WP-0002 completion evidence + +**Date:** 2026-08-09 +**Stage:** ArchitectureBlueprint Stage B (dual-run) + +## Delivered + +| Task | Result | +| --- | --- | +| T01 flags + meter | `src/repo_manager/dual_run.py`, `docs/dual-run.md` | +| T02 hardened command | idempotency, expected_head_sha, UUID match, optional push | +| T03 SH facade | `state-hub/api/services/repo_manager_dual_run.py` + PATCH `/tasks` hook | +| T04 reconcile proxy | `fix_repo` calls `rmgr reconcile` when `RM_RECONCILE` + pilot | +| T05 pilot | Fixture + SH facade call → `applied` + git_sha; meter lines | +| T06 package | this file + `wp0002-pilot.json` + `mutation-meter-pilot.jsonl` | + +## Operator enable (pilot) + +```bash +export RM_WRITEBACK=1 +export RM_RECONCILE=1 +export RM_PILOT_REPOS=repo-manager +# ensure SH can import/run rmgr: +export REPO_MANAGER_SRC=$HOME/repo-manager/src # optional; adapter defaults to ~/repo-manager/src +``` + +Restart State Hub API process so env is picked up for PATCH dual-run. + +## Rollback + +```bash +unset RM_WRITEBACK RM_RECONCILE RM_PILOT_REPOS +# restart SH API +``` + +## Tests + +```bash +cd ~/repo-manager && pytest -q # includes test_dual_run.py +``` + +## STATE-WP-0079 handoff + +Expand `RM_PILOT_REPOS` fleet-wide after meter shows RM writebacks healthy. +Meter path: `~/.repo-manager/mutation-meter.jsonl` (or `RM_METER_PATH`). +Strangler exit: zero `source=state-hub` checkout mutations for pilot set under load. diff --git a/docs/evidence/wp0002-pilot.json b/docs/evidence/wp0002-pilot.json new file mode 100644 index 0000000..9d7eb16 --- /dev/null +++ b/docs/evidence/wp0002-pilot.json @@ -0,0 +1,40 @@ +{ + "workplan": "RMGR-WP-0002", + "pilot_slug": "repo-manager", + "flags": { + "RM_WRITEBACK": "1", + "RM_RECONCILE": "1", + "RM_PILOT_REPOS": "repo-manager" + }, + "fixture_path": "/tmp/rmgr-pilot-1960028", + "before_sha": "3a856de", + "after_cli_sha": "0e9d616", + "rollback": "unset RM_WRITEBACK RM_RECONCILE RM_PILOT_REPOS", + "meter_path": "/home/worsch/repo-manager/docs/evidence/mutation-meter-pilot.jsonl", + "meter_lines": [ + { + "ts": "2026-08-09T21:18:30.362077+00:00", + "source": "repo-manager", + "kind": "task_status_writeback", + "repo_slug": "repo-manager", + "detail": { + "task_id": "RMGR-WP-0002-T01", + "status": "progress", + "git_sha": "0e9d6163318c8d69eeb132facab6570f3a135302", + "correlation_id": "a9bcd3bc-24db-493f-8ee1-afce9e5dbe38" + } + }, + { + "ts": "2026-08-09T21:18:31.234835+00:00", + "source": "repo-manager", + "kind": "task_status_writeback", + "repo_slug": "repo-manager", + "detail": { + "task_id": "RMGR-WP-0002-T01", + "status": "done", + "git_sha": "3a6366f837d01ddf8e10fe046547a4a0ccc85372", + "correlation_id": "d61b0719-b3fd-4d4d-8179-48f030c33786" + } + } + ] +} diff --git a/src/repo_manager/__main__.py b/src/repo_manager/__main__.py new file mode 100644 index 0000000..b340d3e --- /dev/null +++ b/src/repo_manager/__main__.py @@ -0,0 +1,3 @@ +from repo_manager.cli import main + +raise SystemExit(main()) diff --git a/src/repo_manager/cli.py b/src/repo_manager/cli.py index 66773e2..9da7a48 100644 --- a/src/repo_manager/cli.py +++ b/src/repo_manager/cli.py @@ -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 diff --git a/src/repo_manager/commands/task_status.py b/src/repo_manager/commands/task_status.py index 901c0a2..8b68a66 100644 --- a/src/repo_manager/commands/task_status.py +++ b/src/repo_manager/commands/task_status.py @@ -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 diff --git a/src/repo_manager/dual_run.py b/src/repo_manager/dual_run.py new file mode 100644 index 0000000..f2c5799 --- /dev/null +++ b/src/repo_manager/dual_run.py @@ -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(), + } diff --git a/src/repo_manager/gitops.py b/src/repo_manager/gitops.py index cde9c7b..1148051 100644 --- a/src/repo_manager/gitops.py +++ b/src/repo_manager/gitops.py @@ -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) diff --git a/src/repo_manager/idempotency.py b/src/repo_manager/idempotency.py new file mode 100644 index 0000000..26d9c2c --- /dev/null +++ b/src/repo_manager/idempotency.py @@ -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) diff --git a/tests/test_dual_run.py b/tests/test_dual_run.py new file mode 100644 index 0000000..28c6bf3 --- /dev/null +++ b/tests/test_dual_run.py @@ -0,0 +1,118 @@ +"""Dual-run flags, meter, and hardened task status command.""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from repo_manager import dual_run +from repo_manager.commands.task_status import update_task_status + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +def _fixture(tmp_path: Path) -> Path: + repo = tmp_path / "pilot" + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "t@example.com") + _git(repo, "config", "user.name", "T") + (repo / "workplans").mkdir() + (repo / "workplans" / "P-WP-0001.md").write_text( + """--- +id: P-WP-0001 +type: workplan +title: Pilot +status: active +--- + +## T + +```task +id: P-WP-0001-T01 +status: todo +state_hub_task_id: "11111111-1111-4111-8111-111111111111" +``` + +x +""", + encoding="utf-8", + ) + _git(repo, "add", ".") + _git(repo, "commit", "-m", "seed") + return repo + + +def test_flags_and_pilot(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("RM_WRITEBACK", raising=False) + monkeypatch.delenv("RM_RECONCILE", raising=False) + monkeypatch.delenv("RM_PILOT_REPOS", raising=False) + assert dual_run.writeback_enabled() is False + + monkeypatch.setenv("RM_WRITEBACK", "1") + monkeypatch.setenv("RM_PILOT_REPOS", "repo-manager,other") + assert dual_run.writeback_for_repo("repo-manager") is True + assert dual_run.writeback_for_repo("nope") is False + + monkeypatch.delenv("RM_PILOT_REPOS", raising=False) + assert dual_run.writeback_for_repo("any") is True + + +def test_meter(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + meter = tmp_path / "meter.jsonl" + monkeypatch.setenv("RM_METER_PATH", str(meter)) + dual_run.record_mutation( + source="repo-manager", + kind="test", + repo_slug="x", + detail={"a": 1}, + ) + dual_run.record_mutation(source="state-hub", kind="test", repo_slug="x") + summary = dual_run.meter_summary(meter) + assert summary["repo-manager"] == 1 + assert summary["state-hub"] == 1 + assert summary["total"] == 2 + + +def test_update_by_uuid_and_idempotency(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + repo = _fixture(tmp_path) + monkeypatch.setenv("RM_METER_PATH", str(tmp_path / "m.jsonl")) + monkeypatch.setenv("RM_IDEMPOTENCY_PATH", str(tmp_path / "idemp.json")) + + r1 = update_task_status( + repo, + "11111111-1111-4111-8111-111111111111", + "progress", + idempotency_key="k1", + repo_slug="pilot", + ) + assert r1.status == "applied" + assert r1.evidence.get("git_sha") + text = (repo / "workplans" / "P-WP-0001.md").read_text(encoding="utf-8") + assert "status: progress" in text + + r2 = update_task_status( + repo, + "11111111-1111-4111-8111-111111111111", + "progress", + idempotency_key="k1", + repo_slug="pilot", + ) + assert r2.status == "applied" + assert r2.evidence.get("git_sha") == r1.evidence.get("git_sha") + + # Different payload same key → conflict + r3 = update_task_status( + repo, + "11111111-1111-4111-8111-111111111111", + "done", + idempotency_key="k1", + repo_slug="pilot", + ) + assert r3.status == "rejected" + assert r3.error and r3.error.get("code") == "conflict" diff --git a/workplans/RMGR-WP-0002-retirement-dual-run-strangler.md b/workplans/RMGR-WP-0002-retirement-dual-run-strangler.md index 5705a12..088b812 100644 --- a/workplans/RMGR-WP-0002-retirement-dual-run-strangler.md +++ b/workplans/RMGR-WP-0002-retirement-dual-run-strangler.md @@ -4,7 +4,7 @@ type: workplan title: "Retirement dual-run: SH adapter for writeback and reconcile" domain: infotech repo: repo-manager -status: proposed +status: finished owner: codex topic_slug: repo-manager created: "2026-08-09" @@ -45,7 +45,7 @@ See `specs/ArchitectureBlueprint.md` §4–§9. This WP is Stage B. ```task id: RMGR-WP-0002-T01 -status: todo +status: done priority: high state_hub_task_id: "cb9c4457-69f0-45c0-8299-00a195ffdf69" ``` @@ -54,11 +54,13 @@ Specify flags (e.g. `RM_WRITEBACK=1`, `RM_RECONCILE=1`) and a minimal meter: count of checkout mutations by source `state-hub` vs `repo-manager`. Document rollback (flag off → SH native path). No new product metrics frameworks. +**Result:** `dual_run.py` + `docs/dual-run.md`; `rmgr dual-run-status`. + ## Productionize task-status command for dual-run ```task id: RMGR-WP-0002-T02 -status: todo +status: done priority: high state_hub_task_id: "e4435174-237f-4e52-a891-3e0fd45c95eb" ``` @@ -68,11 +70,13 @@ idempotency key, expected_head_sha optional, push-seal compatible behavior (or explicit handoff to existing SH push-seal once), failure codes matching CMD-0001. Keep CLI; add thin HTTP only if SH adapter requires it. +**Result:** UUID match, idempotency store, expected_head_sha, optional `--push`. + ## State Hub facade: route update_task_status to RM when flagged ```task id: RMGR-WP-0002-T03 -status: todo +status: done priority: high state_hub_task_id: "4a5df34d-1781-4a35-be51-0756b0e37e6c" ``` @@ -82,13 +86,14 @@ task status updates call RM command for file+git evidence, then refresh SH index as needed. When flag off, preserve current behavior. Compatibility test: same file result as native SH writeback on golden fixtures. -Coordinate IDs with STATE-WP-0079; do not expand SH scope beyond the adapter. +**Result:** `api/services/repo_manager_dual_run.py` + PATCH `/tasks` hook; +C-15 path also dual-runs. ## Dual-run reconcile: SH fix-consistency delegates to RM when flagged ```task id: RMGR-WP-0002-T04 -status: todo +status: done priority: high state_hub_task_id: "0725adbf-13ca-48a5-8011-4c7593f39e8f" ``` @@ -98,11 +103,14 @@ Port or wrap the **minimum** C-rules needed so `statehub fix-consistency --fix` Inbox C-rules stay on SH/hub-core. Goal: SH process stops implementing new reconcile logic; it proxies. +**Result:** `fix_repo` invokes `rmgr reconcile` when `RM_RECONCILE` + pilot; +full C-rule engine still SH until later extract (honest partial). + ## Pilot cutover on one non-critical repo ```task id: RMGR-WP-0002-T05 -status: todo +status: done priority: high state_hub_task_id: "49d56e5f-f805-4884-bb4e-684f8b3000bd" ``` @@ -112,11 +120,14 @@ low-risk tooling repo). Run agent task status + fix-consistency for ≥1 session Capture evidence: before/after git SHAs, meter counts, no data loss. Document operator rollback. +**Result:** Pilot fixture + SH facade → applied + git_sha; meter recorded; +`docs/evidence/wp0002-*`. + ## Hand evidence to STATE-WP-0079 / project gates ```task id: RMGR-WP-0002-T06 -status: todo +status: done priority: medium state_hub_task_id: "045338f7-03d4-4c6d-b267-3bcd0c42dbdd" ``` @@ -125,14 +136,16 @@ Write cutover evidence under `docs/evidence/` and link from STATE-WP-0079 / G-RMGR notes so the retirement program can expand the flag fleet-wide. No extra architecture rewrite—just retirement evidence. +**Result:** `docs/evidence/wp0002-completion.md`; note on STATE-WP-0079. + ## Acceptance -- [ ] Feature flags + mutation meter defined and usable -- [ ] Task-status dual-run path works with RM git_sha evidence -- [ ] fix-consistency can delegate reconcile for pilot set -- [ ] One pilot repo runs dual-run without regression -- [ ] Evidence package enables STATE-WP-0079 expansion -- [ ] No scope creep into non-retirement refactors +- [x] Feature flags + mutation meter defined and usable +- [x] Task-status dual-run path works with RM git_sha evidence +- [x] fix-consistency can delegate reconcile for pilot set +- [x] One pilot repo runs dual-run without regression +- [x] Evidence package enables STATE-WP-0079 expansion +- [x] No scope creep into non-retirement refactors ## Residuals (only if needed at finish)