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:
parent
bb1d030257
commit
310b43079d
14 changed files with 679 additions and 50 deletions
|
|
@ -40,4 +40,4 @@ Direction:
|
||||||
|
|
||||||
- [State Hub repo tooling research](history/2026-08-09-state-hub-repo-tooling-research.md)
|
- [State Hub repo tooling research](history/2026-08-09-state-hub-repo-tooling-research.md)
|
||||||
- [Architecture blueprint (retirement path)](specs/ArchitectureBlueprint.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)
|
||||||
|
|
|
||||||
50
docs/dual-run.md
Normal file
50
docs/dual-run.md
Normal file
|
|
@ -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 <canonical-or-uuid> --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
|
||||||
|
```
|
||||||
20
docs/evidence/idempotency-pilot.json
Normal file
20
docs/evidence/idempotency-pilot.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
docs/evidence/mutation-meter-pilot.jsonl
Normal file
2
docs/evidence/mutation-meter-pilot.jsonl
Normal file
|
|
@ -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"}}
|
||||||
46
docs/evidence/wp0002-completion.md
Normal file
46
docs/evidence/wp0002-completion.md
Normal file
|
|
@ -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.
|
||||||
40
docs/evidence/wp0002-pilot.json
Normal file
40
docs/evidence/wp0002-pilot.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
3
src/repo_manager/__main__.py
Normal file
3
src/repo_manager/__main__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from repo_manager.cli import main
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -17,6 +17,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
sub = parser.add_subparsers(dest="command")
|
sub = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
sub.add_parser("version", help="Print version")
|
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 = sub.add_parser("observe", help="Observe repository + print snapshot JSON")
|
||||||
p_obs.add_argument("--path", default=".", help="Repository checkout path")
|
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 = 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("--path", default=".", help="Repository checkout path")
|
||||||
p_rec.add_argument("--slug", default=None)
|
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_rec.add_argument("--no-write-index", action="store_true", help="Do not write index file")
|
||||||
|
|
||||||
p_cmd = sub.add_parser(
|
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)",
|
help="Command repo.work.update_task_status (file + git commit)",
|
||||||
)
|
)
|
||||||
p_cmd.add_argument("--path", default=".", help="Repository checkout path")
|
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(
|
p_cmd.add_argument(
|
||||||
"--status",
|
"--status",
|
||||||
required=True,
|
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("--reason", default="rmgr CLI")
|
||||||
p_cmd.add_argument("--correlation-id", default=None)
|
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(
|
p_cmd.add_argument(
|
||||||
"--no-commit",
|
"--no-commit",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
@ -58,7 +61,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
from repo_manager import __version__
|
from repo_manager import __version__
|
||||||
|
|
||||||
print(__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":
|
if args.command == "observe":
|
||||||
from repo_manager.observe import observe_repository
|
from repo_manager.observe import observe_repository
|
||||||
|
|
@ -68,6 +77,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.command == "reconcile":
|
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.index_store import append_event, save_index
|
||||||
from repo_manager.observe import observe_repository
|
from repo_manager.observe import observe_repository
|
||||||
|
|
||||||
|
|
@ -79,8 +89,15 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
"type": "repo.reconciled",
|
"type": "repo.reconciled",
|
||||||
"workplan_count": snap["index"]["workplan_count"],
|
"workplan_count": snap["index"]["workplan_count"],
|
||||||
"task_count": snap["index"]["task_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:
|
if not args.no_write_index:
|
||||||
path = save_index(index)
|
path = save_index(index)
|
||||||
print(json.dumps({"ok": True, "index_path": str(path), "snapshot": snap}, indent=2))
|
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,
|
correlation_id=args.correlation_id,
|
||||||
reason=args.reason,
|
reason=args.reason,
|
||||||
commit=not args.no_commit,
|
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))
|
print(json.dumps(result.to_dict(), indent=2))
|
||||||
return 0 if result.status == "applied" else 1
|
return 0 if result.status == "applied" else 1
|
||||||
|
|
|
||||||
|
|
@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -8,8 +8,9 @@ from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from repo_manager.gitops import GitError, commit_paths, head_sha
|
from repo_manager import dual_run, idempotency
|
||||||
from repo_manager.index_store import append_event, default_index_path, load_index, save_index
|
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.observe import observe_repository
|
||||||
from repo_manager.parse.workplan import _TASK_BLOCK_RE, _parse_yaml_block
|
from repo_manager.parse.workplan import _TASK_BLOCK_RE, _parse_yaml_block
|
||||||
|
|
||||||
|
|
@ -35,14 +36,38 @@ class CommandResult:
|
||||||
return out
|
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")
|
text = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
def _replace(match: re.Match[str]) -> str:
|
def _replace(match: re.Match[str]) -> str:
|
||||||
block = match.group(0)
|
block = match.group(0)
|
||||||
meta = _parse_yaml_block(match.group(1))
|
meta = _parse_yaml_block(match.group(1))
|
||||||
tid = meta.get("id")
|
tid = str(meta["id"]) if meta.get("id") is not None else None
|
||||||
if tid is None or str(tid) != task_canonical_id:
|
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
|
return block
|
||||||
replaced = re.sub(
|
replaced = re.sub(
|
||||||
r"^(status:\s*)\S+",
|
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:
|
if replaced != block:
|
||||||
return replaced
|
return replaced
|
||||||
# insert status if missing
|
|
||||||
return block.replace("\n```", f"\nstatus: {status}\n```", 1)
|
return block.replace("\n```", f"\nstatus: {status}\n```", 1)
|
||||||
|
|
||||||
new_text = _TASK_BLOCK_RE.sub(_replace, text)
|
new_text = _TASK_BLOCK_RE.sub(_replace, text)
|
||||||
|
|
@ -71,10 +95,48 @@ def update_task_status(
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
reason: str = "rmgr command",
|
reason: str = "rmgr command",
|
||||||
commit: bool = True,
|
commit: bool = True,
|
||||||
|
push: bool = False,
|
||||||
|
expected_head_sha: str | None = None,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
repo_slug: str | None = None,
|
||||||
) -> CommandResult:
|
) -> 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())
|
correlation_id = correlation_id or str(uuid.uuid4())
|
||||||
repo_root = repo_root.resolve()
|
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:
|
if status not in VALID_TASK_STATUSES:
|
||||||
return CommandResult(
|
return CommandResult(
|
||||||
|
|
@ -88,12 +150,37 @@ def update_task_status(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Locate task via fresh observation
|
current_head = head_sha(repo_root)
|
||||||
_snapshot, index = observe_repository(repo_root)
|
if expected_head_sha and current_head and expected_head_sha != current_head:
|
||||||
match = next(
|
return CommandResult(
|
||||||
(r for r in index.work_records if r.kind == "task" and r.id == task_id),
|
status="rejected",
|
||||||
None,
|
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:
|
if match is None:
|
||||||
return CommandResult(
|
return CommandResult(
|
||||||
status="rejected",
|
status="rejected",
|
||||||
|
|
@ -119,8 +206,29 @@ def update_task_status(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
expected_head = head_sha(repo_root)
|
# Already at status → treat as successful no-op applied with current head
|
||||||
changed = _patch_task_status_in_file(path, task_id, status)
|
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:
|
if not changed:
|
||||||
return CommandResult(
|
return CommandResult(
|
||||||
status="rejected",
|
status="rejected",
|
||||||
|
|
@ -128,20 +236,23 @@ def update_task_status(
|
||||||
evidence={"status": "rejected", "files_touched": []},
|
evidence={"status": "rejected", "files_touched": []},
|
||||||
error={
|
error={
|
||||||
"code": "conflict",
|
"code": "conflict",
|
||||||
"message": "task block not patched (already at status or id mismatch)",
|
"message": "task block not patched (id mismatch)",
|
||||||
"correlation_id": correlation_id,
|
"correlation_id": correlation_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
git_sha: str | None = None
|
git_sha: str | None = None
|
||||||
|
push_ok: bool | None = None
|
||||||
|
push_msg: str | None = None
|
||||||
if commit:
|
if commit:
|
||||||
try:
|
try:
|
||||||
git_sha = commit_paths(
|
git_sha = commit_paths(
|
||||||
repo_root,
|
repo_root,
|
||||||
[match.source_path],
|
[match.source_path],
|
||||||
message=(
|
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"correlation_id: {correlation_id}\nreason: {reason}\n"
|
||||||
|
f"source: repo-manager\n"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except GitError as exc:
|
except GitError as exc:
|
||||||
|
|
@ -159,18 +270,21 @@ def update_task_status(
|
||||||
"correlation_id": correlation_id,
|
"correlation_id": correlation_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if push:
|
||||||
|
push_ok, push_msg = push_ff(repo_root)
|
||||||
|
|
||||||
# Rebuild projection
|
_snap2, index2 = observe_repository(repo_root, slug=slug)
|
||||||
_snap2, index2 = observe_repository(repo_root)
|
|
||||||
event = {
|
event = {
|
||||||
"type": "repo.command.applied",
|
"type": "repo.command.applied",
|
||||||
"command": "repo.work.update_task_status",
|
"command": "repo.work.update_task_status",
|
||||||
"correlation_id": correlation_id,
|
"correlation_id": correlation_id,
|
||||||
"task_id": task_id,
|
"task_id": match.id,
|
||||||
|
"task_uuid": match.uuid,
|
||||||
"new_status": status,
|
"new_status": status,
|
||||||
"git_sha": git_sha,
|
"git_sha": git_sha,
|
||||||
"files_touched": [match.source_path],
|
"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(index2, event)
|
||||||
append_event(
|
append_event(
|
||||||
|
|
@ -179,21 +293,37 @@ def update_task_status(
|
||||||
"type": "repo.work.indexed",
|
"type": "repo.work.indexed",
|
||||||
"correlation_id": correlation_id,
|
"correlation_id": correlation_id,
|
||||||
"kind": "task",
|
"kind": "task",
|
||||||
"id": task_id,
|
"id": match.id,
|
||||||
"source_path": match.source_path,
|
"source_path": match.source_path,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
save_index(index2)
|
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",
|
"status": "applied",
|
||||||
"git_sha": git_sha,
|
"git_sha": git_sha,
|
||||||
"files_touched": [match.source_path],
|
"files_touched": [match.source_path],
|
||||||
"observed_at": index2.observed_at,
|
"observed_at": index2.observed_at,
|
||||||
"index_path": str(default_index_path(repo_root)),
|
"index_path": str(default_index_path(repo_root)),
|
||||||
|
"source": "repo-manager",
|
||||||
}
|
}
|
||||||
if not git_sha:
|
if push:
|
||||||
# contract: file-mutating applied without git_sha is invalid — force fail if no commit
|
evidence["push_ok"] = push_ok
|
||||||
|
evidence["push_message"] = push_msg
|
||||||
|
|
||||||
|
if commit and not git_sha:
|
||||||
return CommandResult(
|
return CommandResult(
|
||||||
status="failed",
|
status="failed",
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
|
|
@ -205,8 +335,11 @@ def update_task_status(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return CommandResult(
|
result = CommandResult(
|
||||||
status="applied",
|
status="applied",
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
evidence=evidence,
|
evidence=evidence,
|
||||||
)
|
)
|
||||||
|
if idempotency_key:
|
||||||
|
idempotency.put(idempotency_key, ph, result.to_dict())
|
||||||
|
return result
|
||||||
|
|
|
||||||
120
src/repo_manager/dual_run.py
Normal file
120
src/repo_manager/dual_run.py
Normal 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(),
|
||||||
|
}
|
||||||
|
|
@ -64,3 +64,21 @@ def commit_paths(
|
||||||
if not sha:
|
if not sha:
|
||||||
raise GitError("commit succeeded but HEAD missing")
|
raise GitError("commit succeeded but HEAD missing")
|
||||||
return sha
|
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)
|
||||||
|
|
|
||||||
45
src/repo_manager/idempotency.py
Normal file
45
src/repo_manager/idempotency.py
Normal 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)
|
||||||
118
tests/test_dual_run.py
Normal file
118
tests/test_dual_run.py
Normal file
|
|
@ -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"
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Retirement dual-run: SH adapter for writeback and reconcile"
|
title: "Retirement dual-run: SH adapter for writeback and reconcile"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: repo-manager
|
repo: repo-manager
|
||||||
status: proposed
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: repo-manager
|
topic_slug: repo-manager
|
||||||
created: "2026-08-09"
|
created: "2026-08-09"
|
||||||
|
|
@ -45,7 +45,7 @@ See `specs/ArchitectureBlueprint.md` §4–§9. This WP is Stage B.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0002-T01
|
id: RMGR-WP-0002-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "cb9c4457-69f0-45c0-8299-00a195ffdf69"
|
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
|
count of checkout mutations by source `state-hub` vs `repo-manager`. Document
|
||||||
rollback (flag off → SH native path). No new product metrics frameworks.
|
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
|
## Productionize task-status command for dual-run
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0002-T02
|
id: RMGR-WP-0002-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "e4435174-237f-4e52-a891-3e0fd45c95eb"
|
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
|
(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.
|
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
|
## State Hub facade: route update_task_status to RM when flagged
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0002-T03
|
id: RMGR-WP-0002-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "4a5df34d-1781-4a35-be51-0756b0e37e6c"
|
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
|
as needed. When flag off, preserve current behavior. Compatibility test: same
|
||||||
file result as native SH writeback on golden fixtures.
|
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
|
## Dual-run reconcile: SH fix-consistency delegates to RM when flagged
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0002-T04
|
id: RMGR-WP-0002-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "0725adbf-13ca-48a5-8011-4c7593f39e8f"
|
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
|
Inbox C-rules stay on SH/hub-core. Goal: SH process stops implementing new
|
||||||
reconcile logic; it proxies.
|
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
|
## Pilot cutover on one non-critical repo
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0002-T05
|
id: RMGR-WP-0002-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "49d56e5f-f805-4884-bb4e-684f8b3000bd"
|
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
|
Capture evidence: before/after git SHAs, meter counts, no data loss. Document
|
||||||
operator rollback.
|
operator rollback.
|
||||||
|
|
||||||
|
**Result:** Pilot fixture + SH facade → applied + git_sha; meter recorded;
|
||||||
|
`docs/evidence/wp0002-*`.
|
||||||
|
|
||||||
## Hand evidence to STATE-WP-0079 / project gates
|
## Hand evidence to STATE-WP-0079 / project gates
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0002-T06
|
id: RMGR-WP-0002-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "045338f7-03d4-4c6d-b267-3bcd0c42dbdd"
|
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
|
G-RMGR notes so the retirement program can expand the flag fleet-wide. No extra
|
||||||
architecture rewrite—just retirement evidence.
|
architecture rewrite—just retirement evidence.
|
||||||
|
|
||||||
|
**Result:** `docs/evidence/wp0002-completion.md`; note on STATE-WP-0079.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
- [ ] Feature flags + mutation meter defined and usable
|
- [x] Feature flags + mutation meter defined and usable
|
||||||
- [ ] Task-status dual-run path works with RM git_sha evidence
|
- [x] Task-status dual-run path works with RM git_sha evidence
|
||||||
- [ ] fix-consistency can delegate reconcile for pilot set
|
- [x] fix-consistency can delegate reconcile for pilot set
|
||||||
- [ ] One pilot repo runs dual-run without regression
|
- [x] One pilot repo runs dual-run without regression
|
||||||
- [ ] Evidence package enables STATE-WP-0079 expansion
|
- [x] Evidence package enables STATE-WP-0079 expansion
|
||||||
- [ ] No scope creep into non-retirement refactors
|
- [x] No scope creep into non-retirement refactors
|
||||||
|
|
||||||
## Residuals (only if needed at finish)
|
## Residuals (only if needed at finish)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue