feat(RMGR-WP-0003): production pilot dual-run config, bulk, push
Config file dual-run (env override), writeback_push, bulk-status facade on State Hub, pilot example config, evidence and finished workplan.
This commit is contained in:
parent
a00a4264da
commit
5921b65c49
6 changed files with 324 additions and 55 deletions
10
config/dual-run.pilot.example.yaml
Normal file
10
config/dual-run.pilot.example.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Example dual-run pilot config (RMGR-WP-0003).
|
||||
# Install: cp config/dual-run.pilot.example.yaml ~/.repo-manager/dual-run.yaml
|
||||
# Env vars still override when set. Rollback: writeback/reconcile false or delete file.
|
||||
|
||||
writeback: true
|
||||
reconcile: true
|
||||
writeback_push: false # set true only if pilot checkouts can push safely
|
||||
pilot_repos:
|
||||
- repo-manager
|
||||
meter_path: ~/.repo-manager/mutation-meter.jsonl
|
||||
|
|
@ -1,50 +1,69 @@
|
|||
# Dual-run flags (RMGR-WP-0002)
|
||||
# Dual-run flags (RMGR-WP-0002 / 0003)
|
||||
|
||||
State Hub remains the MCP/REST entrypoint. When flags are on, **checkout
|
||||
State Hub remains the MCP/REST entrypoint. When dual-run is on, **checkout
|
||||
mutation** is executed by Repo Manager.
|
||||
|
||||
## Environment variables
|
||||
## Config file (preferred for production pilot)
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.repo-manager
|
||||
cp ~/repo-manager/config/dual-run.pilot.example.yaml ~/.repo-manager/dual-run.yaml
|
||||
# edit pilot_repos / writeback_push as needed
|
||||
```
|
||||
|
||||
```yaml
|
||||
writeback: true
|
||||
reconcile: true
|
||||
writeback_push: false
|
||||
pilot_repos:
|
||||
- repo-manager
|
||||
meter_path: ~/.repo-manager/mutation-meter.jsonl
|
||||
```
|
||||
|
||||
Optional path override: `RM_DUAL_RUN_CONFIG=/path/to/dual-run.yaml`
|
||||
|
||||
**Restart State Hub API** after config changes so the process reloads
|
||||
(settings are cached per process).
|
||||
|
||||
## Environment variables (override config when set)
|
||||
|
||||
| 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` |
|
||||
| `RM_WRITEBACK_PUSH=1` | `git push` after RM writeback commit (best-effort) |
|
||||
| `RM_PILOT_REPOS=slug1,slug2` | Restrict to slugs; **unset** = all when flags on |
|
||||
| `RM_METER_PATH` | JSONL meter path |
|
||||
| `REPO_MANAGER_SRC` | Path to `repo-manager/src` for SH adapter |
|
||||
| `RMGR_BIN` | Override `rmgr` command |
|
||||
|
||||
## Rollback
|
||||
|
||||
```bash
|
||||
unset RM_WRITEBACK RM_RECONCILE RM_PILOT_REPOS
|
||||
# or
|
||||
export RM_WRITEBACK=0 RM_RECONCILE=0
|
||||
# config: set writeback/reconcile false, or:
|
||||
rm ~/.repo-manager/dual-run.yaml
|
||||
unset RM_WRITEBACK RM_RECONCILE RM_WRITEBACK_PUSH RM_PILOT_REPOS
|
||||
# restart SH API
|
||||
```
|
||||
|
||||
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
|
||||
## Operator checks
|
||||
|
||||
```bash
|
||||
rmgr dual-run-status
|
||||
# or
|
||||
wc -l ~/.repo-manager/mutation-meter.jsonl
|
||||
grep repo-manager ~/.repo-manager/mutation-meter.jsonl | tail
|
||||
# or inspect meter
|
||||
tail -20 ~/.repo-manager/mutation-meter.jsonl
|
||||
```
|
||||
|
||||
## Paths covered
|
||||
|
||||
| Entry | Dual-run when flagged |
|
||||
| --- | --- |
|
||||
| `PATCH /tasks/{id}` | RM writeback (+ optional push) |
|
||||
| `POST /tasks/bulk-status-sync` | RM writeback per task |
|
||||
| C-15 fix-consistency writeback | RM writeback, else native |
|
||||
| fix-consistency fix_repo | RM reconcile for pilots |
|
||||
|
||||
## Pilot expand (STATE-WP-0079)
|
||||
|
||||
Add slugs to `pilot_repos`, restart SH, watch meter for `source=repo-manager`
|
||||
writebacks. Expand only when failures are absent under normal agent load.
|
||||
|
|
|
|||
26
docs/evidence/wp0003-completion.md
Normal file
26
docs/evidence/wp0003-completion.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# RMGR-WP-0003 completion
|
||||
|
||||
**Date:** 2026-08-11
|
||||
|
||||
## Delivered
|
||||
|
||||
| Item | Location |
|
||||
| --- | --- |
|
||||
| Config-file dual-run | `dual_run.py` (RM + SH) |
|
||||
| Example pilot config | `config/dual-run.pilot.example.yaml` |
|
||||
| Bulk dual-run | SH `POST /tasks/bulk-status-sync` |
|
||||
| writeback_push | config/env + facade |
|
||||
| Runbook | `docs/dual-run.md` |
|
||||
|
||||
## Enable production pilot
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.repo-manager
|
||||
cp ~/repo-manager/config/dual-run.pilot.example.yaml ~/.repo-manager/dual-run.yaml
|
||||
# restart State Hub API process to load config
|
||||
rmgr dual-run-status
|
||||
```
|
||||
|
||||
## Expand for STATE-WP-0079
|
||||
|
||||
Edit `pilot_repos` list, restart SH, watch `~/.repo-manager/mutation-meter.jsonl`.
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
"""Dual-run flags and checkout-mutation meter (RMGR-WP-0002 / ArchitectureBlueprint Stage B).
|
||||
"""Dual-run flags and checkout-mutation meter (RMGR-WP-0002/0003).
|
||||
|
||||
Environment variables (shared with State Hub adapter):
|
||||
Precedence (highest first):
|
||||
1. Environment variables when set
|
||||
2. Config file (~/.repo-manager/dual-run.yaml or RM_DUAL_RUN_CONFIG)
|
||||
3. Defaults (all off)
|
||||
|
||||
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
|
||||
Environment variables:
|
||||
RM_WRITEBACK, RM_RECONCILE, RM_WRITEBACK_PUSH, RM_PILOT_REPOS, RM_METER_PATH,
|
||||
RM_DUAL_RUN_CONFIG
|
||||
|
||||
Rollback: unset flags or set to 0 → State Hub native path only.
|
||||
Rollback: writeback/reconcile false in config and unset env → State Hub native.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -15,33 +17,106 @@ from __future__ import annotations
|
|||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import yaml
|
||||
|
||||
Source = Literal["state-hub", "repo-manager"]
|
||||
|
||||
_TRUE = frozenset({"1", "true", "yes", "on"})
|
||||
_FALSE = frozenset({"0", "false", "no", "off"})
|
||||
|
||||
def _truthy(name: str) -> bool:
|
||||
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
def config_path() -> Path:
|
||||
raw = os.environ.get("RM_DUAL_RUN_CONFIG", "~/.repo-manager/dual-run.yaml")
|
||||
return Path(raw).expanduser()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_file_config() -> dict[str, Any]:
|
||||
path = config_path()
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def reload_config() -> None:
|
||||
"""Clear cached config (tests / SIGHUP-style)."""
|
||||
_load_file_config.cache_clear()
|
||||
|
||||
|
||||
def _env_bool(name: str) -> bool | None:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None or raw.strip() == "":
|
||||
return None
|
||||
v = raw.strip().lower()
|
||||
if v in _TRUE:
|
||||
return True
|
||||
if v in _FALSE:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _cfg_bool(key: str, default: bool = False) -> bool:
|
||||
env_map = {
|
||||
"writeback": "RM_WRITEBACK",
|
||||
"reconcile": "RM_RECONCILE",
|
||||
"writeback_push": "RM_WRITEBACK_PUSH",
|
||||
}
|
||||
env_name = env_map.get(key)
|
||||
if env_name:
|
||||
ev = _env_bool(env_name)
|
||||
if ev is not None:
|
||||
return ev
|
||||
cfg = _load_file_config()
|
||||
if key in cfg:
|
||||
val = cfg[key]
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
return val.strip().lower() in _TRUE
|
||||
return default
|
||||
|
||||
|
||||
def writeback_enabled() -> bool:
|
||||
return _truthy("RM_WRITEBACK")
|
||||
return _cfg_bool("writeback", False)
|
||||
|
||||
|
||||
def reconcile_enabled() -> bool:
|
||||
return _truthy("RM_RECONCILE")
|
||||
return _cfg_bool("reconcile", False)
|
||||
|
||||
|
||||
def writeback_push_enabled() -> bool:
|
||||
return _cfg_bool("writeback_push", False)
|
||||
|
||||
|
||||
def pilot_slugs() -> set[str] | None:
|
||||
"""None means all repos; empty set after parse of blank list means none."""
|
||||
"""None = all repos; empty set = none."""
|
||||
raw = os.environ.get("RM_PILOT_REPOS")
|
||||
if raw is None:
|
||||
if raw is not None:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return set()
|
||||
return {s.strip() for s in raw.split(",") if s.strip()}
|
||||
cfg = _load_file_config()
|
||||
if "pilot_repos" not in cfg:
|
||||
return None
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return set()
|
||||
return {s.strip() for s in raw.split(",") if s.strip()}
|
||||
val = cfg["pilot_repos"]
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
if not val.strip():
|
||||
return set()
|
||||
return {s.strip() for s in val.split(",") if s.strip()}
|
||||
if isinstance(val, list):
|
||||
return {str(s).strip() for s in val if str(s).strip()}
|
||||
return None
|
||||
|
||||
|
||||
def slug_allowed(slug: str | None) -> bool:
|
||||
|
|
@ -62,8 +137,13 @@ def reconcile_for_repo(slug: str | None) -> bool:
|
|||
|
||||
|
||||
def meter_path() -> Path:
|
||||
raw = os.environ.get("RM_METER_PATH", "~/.repo-manager/mutation-meter.jsonl")
|
||||
return Path(raw).expanduser()
|
||||
raw = os.environ.get("RM_METER_PATH")
|
||||
if raw:
|
||||
return Path(raw).expanduser()
|
||||
cfg = _load_file_config()
|
||||
if cfg.get("meter_path"):
|
||||
return Path(str(cfg["meter_path"])).expanduser()
|
||||
return Path("~/.repo-manager/mutation-meter.jsonl").expanduser()
|
||||
|
||||
|
||||
def record_mutation(
|
||||
|
|
@ -73,7 +153,6 @@ def record_mutation(
|
|||
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)
|
||||
|
|
@ -112,9 +191,18 @@ def meter_summary(path: Path | None = None) -> dict[str, int]:
|
|||
|
||||
def flags_status() -> dict[str, Any]:
|
||||
return {
|
||||
"config_path": str(config_path()),
|
||||
"config_exists": config_path().is_file(),
|
||||
"writeback": writeback_enabled(),
|
||||
"reconcile": reconcile_enabled(),
|
||||
"writeback_push": writeback_push_enabled(),
|
||||
"pilot_repos": sorted(pilot_slugs()) if pilot_slugs() is not None else None,
|
||||
"meter_path": str(meter_path()),
|
||||
"meter": meter_summary(),
|
||||
# legacy env-style keys for operators
|
||||
"RM_WRITEBACK": writeback_enabled(),
|
||||
"RM_RECONCILE": reconcile_enabled(),
|
||||
"RM_WRITEBACK_PUSH": writeback_push_enabled(),
|
||||
"RM_PILOT_REPOS": sorted(pilot_slugs()) if pilot_slugs() is not None else None,
|
||||
"RM_METER_PATH": str(meter_path()),
|
||||
"meter": meter_summary(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Dual-run flags, meter, and hardened task status command."""
|
||||
"""Dual-run flags, config file, meter, and hardened task status command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -48,10 +48,19 @@ x
|
|||
return repo
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_dual_run_cache():
|
||||
dual_run.reload_config()
|
||||
yield
|
||||
dual_run.reload_config()
|
||||
|
||||
|
||||
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)
|
||||
monkeypatch.delenv("RM_DUAL_RUN_CONFIG", raising=False)
|
||||
dual_run.reload_config()
|
||||
assert dual_run.writeback_enabled() is False
|
||||
|
||||
monkeypatch.setenv("RM_WRITEBACK", "1")
|
||||
|
|
@ -63,6 +72,31 @@ def test_flags_and_pilot(monkeypatch: pytest.MonkeyPatch):
|
|||
assert dual_run.writeback_for_repo("any") is True
|
||||
|
||||
|
||||
def test_config_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
cfg = tmp_path / "dual-run.yaml"
|
||||
cfg.write_text(
|
||||
"writeback: true\nreconcile: true\nwriteback_push: true\n"
|
||||
"pilot_repos:\n - repo-manager\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.delenv("RM_WRITEBACK", raising=False)
|
||||
monkeypatch.delenv("RM_RECONCILE", raising=False)
|
||||
monkeypatch.delenv("RM_WRITEBACK_PUSH", raising=False)
|
||||
monkeypatch.delenv("RM_PILOT_REPOS", raising=False)
|
||||
monkeypatch.setenv("RM_DUAL_RUN_CONFIG", str(cfg))
|
||||
dual_run.reload_config()
|
||||
assert dual_run.writeback_enabled() is True
|
||||
assert dual_run.reconcile_enabled() is True
|
||||
assert dual_run.writeback_push_enabled() is True
|
||||
assert dual_run.writeback_for_repo("repo-manager") is True
|
||||
assert dual_run.writeback_for_repo("other") is False
|
||||
|
||||
# env overrides file
|
||||
monkeypatch.setenv("RM_WRITEBACK", "0")
|
||||
dual_run.reload_config()
|
||||
assert dual_run.writeback_enabled() is False
|
||||
|
||||
|
||||
def test_meter(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
meter = tmp_path / "meter.jsonl"
|
||||
monkeypatch.setenv("RM_METER_PATH", str(meter))
|
||||
|
|
@ -106,7 +140,6 @@ def test_update_by_uuid_and_idempotency(tmp_path: Path, monkeypatch: pytest.Monk
|
|||
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",
|
||||
|
|
|
|||
93
workplans/RMGR-WP-0003-production-pilot-dual-run.md
Normal file
93
workplans/RMGR-WP-0003-production-pilot-dual-run.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
id: RMGR-WP-0003
|
||||
type: workplan
|
||||
title: "Production pilot dual-run: config, bulk path, push-seal"
|
||||
domain: infotech
|
||||
repo: repo-manager
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: repo-manager
|
||||
created: "2026-08-11"
|
||||
updated: "2026-08-11"
|
||||
parent_project: prj-state-hub-retirement
|
||||
parent_workplan: SHR-WP-0001
|
||||
stream: S1
|
||||
related:
|
||||
- RMGR-WP-0002
|
||||
- STATE-WP-0079
|
||||
- specs/ArchitectureBlueprint.md
|
||||
---
|
||||
|
||||
# Production pilot dual-run: config, bulk path, push-seal
|
||||
|
||||
## Goal
|
||||
|
||||
Make Stage B dual-run **operable in production for a pilot set** without relying
|
||||
only on ad-hoc API process env vars, and close remaining high-frequency mutation
|
||||
gaps (bulk status, post-writeback push) so STATE-WP-0079 can expand cutover.
|
||||
|
||||
**Not in scope:** full C-rule port, Postgres registry, MCP rewrite, Stage C
|
||||
host_paths SoR, dashboards.
|
||||
|
||||
## Dual-run config file (shared RM + SH)
|
||||
|
||||
```task
|
||||
id: RMGR-WP-0003-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Load dual-run settings from `~/.repo-manager/dual-run.yaml` (and optional
|
||||
`RM_DUAL_RUN_CONFIG` path), with env vars still overriding. Fields: writeback,
|
||||
reconcile, pilot_repos, writeback_push, meter_path. Document and unit-test.
|
||||
|
||||
**Result:** Config loader in RM + SH dual_run modules; env overrides file.
|
||||
|
||||
## Bulk task-status dual-run on State Hub
|
||||
|
||||
```task
|
||||
id: RMGR-WP-0003-T02
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
When dual-run writeback is on for the task's repo, `POST /tasks/bulk-status-sync`
|
||||
delegates each status change to RM (same as PATCH). Preserve progress events on
|
||||
SH. Failures log and fall back to native DB-only (file via later C-15).
|
||||
|
||||
**Result:** bulk_status_sync hooks try_writeback_for_task per status change.
|
||||
|
||||
## Push-seal after RM writeback
|
||||
|
||||
```task
|
||||
id: RMGR-WP-0003-T03
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
When `writeback_push: true` (or `RM_WRITEBACK_PUSH=1`), RM writeback path pushes
|
||||
after commit (best-effort, never force). SH facade passes push=true for pilot.
|
||||
Meter push_ok/fail. Align with C-16/C-17 semantics (skip push issues as meter
|
||||
only; do not force).
|
||||
|
||||
**Result:** writeback_push config/env; SH facade sets push=writeback_push_enabled().
|
||||
|
||||
## Pilot defaults + operator runbook
|
||||
|
||||
```task
|
||||
id: RMGR-WP-0003-T04
|
||||
status: done
|
||||
priority: medium
|
||||
```
|
||||
|
||||
Ship example config for pilot `repo-manager`, update `docs/dual-run.md`, capture
|
||||
`docs/evidence/wp0003-*.md` with meter summary command for STATE-WP-0079.
|
||||
|
||||
**Result:** `config/dual-run.pilot.example.yaml`, dual-run.md, evidence note.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [x] Config file drives dual-run (env override still works)
|
||||
- [x] Bulk status uses RM writeback when flagged
|
||||
- [x] Optional push after writeback
|
||||
- [x] Evidence + runbook for pilot expansion
|
||||
Loading…
Add table
Add a link
Reference in a new issue