ACTIVITY-WP-0044: disabled repo-manager-bounded-freshness draft (RMGR-WP-0020-T03)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 1m56s

Register bounded operation repo_manager_freshness (single attempt, 300 s,
code-fixed 'rmgr freshness run --limit N --max-seconds S [--dry-run]') and add
the disabled cron definition (40 */2 * * * UTC, limit 6, max_seconds 240).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 692292@bnt-lap001
Assistant-Session: 89c7d8e8-e1b3-47eb-9411-a0ab227fca2f
This commit is contained in:
tegwick 2026-10-04 13:11:54 +02:00
parent c6df768ef0
commit ba4ef00128
9 changed files with 449 additions and 2 deletions

View file

@ -0,0 +1,63 @@
---
id: repo-manager-bounded-freshness
name: Repo Manager Bounded Freshness
type: activity-definition
version: "0.1"
enabled: false
owner: repo-manager
governance: custodian
status: proposed
trigger:
type: cron
cron_expression: "40 */2 * * *"
timezone: UTC
misfire_policy: skip
context_sources:
- type: repo-manager
query: freshness_run
operation: repo_manager_freshness
required: true
params:
limit: 6
max_seconds: 240
apply: true
bind_to: context.freshness
---
# Repo Manager Bounded Freshness
Draft from RMGR-WP-0020-T03, tracked in ACTIVITY-WP-0044. **Not activated.**
`enabled: false` prevents any Temporal schedule; the founder flips it only after
the RMGR-WP-0020-T04 activation steps and T05 proof.
Each fire runs one bounded operation `repo_manager_freshness`
(ACT-ADR-007 registry), which invokes the Repo Manager CLI with a code-fixed argv:
```text
rmgr freshness run --limit 6 --max-seconds 240
```
(`--dry-run` is appended when `apply: false`.) Repo Manager owns repository
selection, Forge HEAD resolution, reconciliation from immutable Forge commits
and the durable `repo-manager.freshness-receipt.v1` receipts. Activity Core
persists only a counted projection (receipt count per outcome, exit code).
- Every 2 h at :40 UTC; 141 repositories / 6 per fire x 12 fires/day is about a
two-day rotation. Target SLA once enabled: every admissible repository has a
receipt younger than 48 h.
- Single Temporal attempt, timeout 300 s (240 s CLI budget + grace); misfires
are skipped, never replayed.
- No workstation checkout reads (ADR-002), no legacy `--all --remote` sweep, no
`for_each` task spawning; the rule below never emits tasks.
Rollback: set `enabled: false` (or `status: paused`).
```rule
id: repo-manager-bounded-freshness-no-tasks
condition: "False"
action:
task_template: Repo Manager freshness (operation-only; never rule-emitted)
description: The bounded operation does the work; this rule never emits tasks.
priority: low
labels: ["automated", "freshness"]
```

View file

@ -25,7 +25,8 @@ schedule, retention action, or production rollout follows from role acceptance.
Scaleway physical recovery or Forgejo archive recovery evidence. Its old
host kubeconfig names and RPO wording are not proof of current cluster
routing or an accepted guarantee; review those before reusing the wiring.
- `src/activity_core/bounded_operations.py` permits exactly three operations.
- `src/activity_core/bounded_operations.py` permits exactly four operations
(the fourth, `repo_manager_freshness`, backs a disabled draft; ACTIVITY-WP-0044).
CNPG backup has one Temporal attempt because no durable remote idempotency
receipt is available. New restore dispatch must not inherit retry defaults.
- The stale-token blocker was resolved under RPF-WP-0045. Read-only inspection

View file

@ -254,6 +254,10 @@ async def execute_bounded_operation(payload: dict[str, Any]) -> dict[str, Any]:
from activity_core.context_resolvers.cnpg_backup import cnpg_option_a_backup
result = cnpg_option_a_backup(params)
elif spec.operation_id == "repo_manager_freshness":
from activity_core.context_resolvers.repo_manager import repo_manager_freshness
result = repo_manager_freshness(params)
else:
raise ApplicationError(
f"bounded operation {spec.operation_id!r} uses a dedicated activity",

View file

@ -60,6 +60,19 @@ _SPECS = (
credential_route="railiance-cnpg-option-a-backup",
evidence_mode="context-evidence-sink",
),
# RMGR-WP-0020-T03 / ACTIVITY-WP-0044: Repo Manager owns the freshness
# engine; Activity Core only invokes its fixed CLI with bounded arguments.
BoundedOperationSpec(
operation_id="repo_manager_freshness",
source_type="repo-manager",
query="freshness_run",
resolve_before_execute=False,
max_timeout_seconds=300,
temporal_max_attempts=1,
idempotency="single Temporal attempt; per-commit projections are idempotent (RMGR-WP-0017)",
credential_route="repo-manager-freshness-runner",
evidence_mode="repo-manager-freshness-receipt",
),
)
BOUNDED_OPERATION_REGISTRY = {spec.operation_id: spec for spec in _SPECS}
@ -80,6 +93,12 @@ _CANONICAL_SCRIPT_PATHS = {
"forgejo_package_prune": "/opt/railiance-platform/tools/cmd/forgejo-package-prune",
"cnpg_option_a_backup": "/opt/railiance-platform/tools/cmd/cnpg-option-a-backup",
}
# repo-manager.freshness-receipt.v1 outcomes (RMGR-WP-0020-T01).
REPO_MANAGER_FRESHNESS_OUTCOMES = (
"applied", "noop", "queued", "failed", "refused", "skipped",
)
REPO_MANAGER_FRESHNESS_MAX_LIMIT = 12
REPO_MANAGER_FRESHNESS_MAX_SECONDS = 270
_TARGET_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
@ -136,6 +155,26 @@ def normalize_operation_result(operation_id: str, raw: Any) -> dict[str, Any]:
"failed": _safe_count(result.get("failed")),
"script_exit_code": exit_code,
}
if operation_id == "repo_manager_freshness":
receipts = result.get("receipts")
outcomes = {name: 0 for name in REPO_MANAGER_FRESHNESS_OUTCOMES}
if isinstance(receipts, list):
for receipt in receipts:
outcome = receipt.get("outcome") if isinstance(receipt, dict) else None
if outcome in outcomes:
outcomes[outcome] += 1
exit_code = result.get("exit_code", 0)
if isinstance(exit_code, bool) or not isinstance(exit_code, int):
exit_code = 1
return {
"kind": operation_id,
"apply": bool(result.get("apply")),
"limit": _safe_count(result.get("limit")),
"max_seconds": _safe_count(result.get("max_seconds")),
"receipt_count": len(receipts) if isinstance(receipts, list) else 0,
"outcomes": outcomes,
"exit_code": exit_code,
}
raise ValueError(f"no result projection for bounded operation {operation_id!r}")
@ -192,6 +231,8 @@ def validate_bounded_operations(
_validate_prune(params)
elif spec.operation_id == "cnpg_option_a_backup":
_validate_backup(params)
elif spec.operation_id == "repo_manager_freshness":
_validate_repo_manager_freshness(params)
def _validate_timeout(spec: BoundedOperationSpec, params: dict[str, Any]) -> None:
@ -259,6 +300,36 @@ def _validate_backup(params: dict[str, Any]) -> None:
_require_context_evidence(params, "cnpg_option_a_backup")
def _validate_repo_manager_freshness(params: dict[str, Any]) -> None:
_explicit_bool(params, "apply", "repo_manager_freshness")
limit = params.get("limit")
if (
isinstance(limit, bool)
or not isinstance(limit, int)
or not 1 <= limit <= REPO_MANAGER_FRESHNESS_MAX_LIMIT
):
raise ValueError(
"bounded operation repo_manager_freshness limit must be in "
f"1..{REPO_MANAGER_FRESHNESS_MAX_LIMIT}"
)
max_seconds = params.get("max_seconds")
if (
isinstance(max_seconds, bool)
or not isinstance(max_seconds, int)
or not 1 <= max_seconds <= REPO_MANAGER_FRESHNESS_MAX_SECONDS
):
raise ValueError(
"bounded operation repo_manager_freshness max_seconds must be in "
f"1..{REPO_MANAGER_FRESHNESS_MAX_SECONDS}"
)
for forbidden in ("command", "argv", "rmgr_bin", "script"):
if forbidden in params:
raise ValueError(
f"bounded operation repo_manager_freshness must not declare {forbidden}; "
"the CLI invocation is code-fixed"
)
def _validate_canonical_script(
params: dict[str, Any], field: str, operation_id: str
) -> None:

View file

@ -0,0 +1,63 @@
"""Repo Manager bounded freshness invocation (RMGR-WP-0020-T03, ACTIVITY-WP-0044).
Invokes the Repo Manager CLI ``rmgr freshness run --limit N --max-seconds S
[--dry-run]`` (RMGR-WP-0020-T02 interface). The argv is code-fixed: a
definition supplies only the validated ``limit``, ``max_seconds`` and ``apply``
values. The executable is ``rmgr`` on PATH unless the worker sets
``ACTIVITY_CORE_RMGR_BIN``; definitions cannot choose it. Stdout is expected to
be a JSON object carrying ``receipts`` (``repo-manager.freshness-receipt.v1``);
only the counted projection in ``bounded_operations`` is persisted.
"""
from __future__ import annotations
import json
import os
import subprocess
from typing import Any
# Grace on top of the CLI's own --max-seconds budget for start-up/teardown.
_TIMEOUT_GRACE_SECONDS = 30
def build_freshness_argv(params: dict[str, Any]) -> list[str]:
limit = int(params["limit"])
max_seconds = int(params["max_seconds"])
argv = [
os.environ.get("ACTIVITY_CORE_RMGR_BIN", "rmgr"),
"freshness",
"run",
"--limit",
str(limit),
"--max-seconds",
str(max_seconds),
]
if params.get("apply") is not True:
argv.append("--dry-run")
return argv
def repo_manager_freshness(params: dict[str, Any]) -> dict[str, Any]:
"""Run one bounded freshness pass and return its JSON summary."""
argv = build_freshness_argv(params)
timeout = int(params["max_seconds"]) + _TIMEOUT_GRACE_SECONDS
completed = subprocess.run(
argv, capture_output=True, text=True, timeout=timeout, check=False
)
stdout = (completed.stdout or "").strip()
if completed.returncode != 0 and not stdout:
raise RuntimeError(
f"repo_manager_freshness failed (exit {completed.returncode}): "
f"{(completed.stderr or '').strip()[:300]}"
)
try:
summary = json.loads(stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(f"repo_manager_freshness returned invalid JSON: {exc}") from exc
if not isinstance(summary, dict):
raise RuntimeError("repo_manager_freshness output must be a JSON object")
summary.setdefault("apply", params.get("apply") is True)
summary.setdefault("limit", int(params["limit"]))
summary.setdefault("max_seconds", int(params["max_seconds"]))
summary["exit_code"] = completed.returncode
return summary

View file

@ -40,6 +40,7 @@ _AUDIT_BUFFER: deque[dict[str, Any]] = deque(maxlen=100)
SIDE_EFFECT_MARKERS = (
"forgejo_package_prune",
"cnpg_option_a_backup",
"repo_manager_freshness",
"apply: true",
'"apply": true',
"'apply': true",

View file

@ -14,11 +14,13 @@ def _evidence_sinks(event_type: str) -> list[dict[str, str]]:
return [{"type": "state-hub-progress", "event_type": event_type}]
def test_registry_names_only_the_three_accepted_operations() -> None:
def test_registry_names_only_the_accepted_operations() -> None:
assert set(BOUNDED_OPERATION_REGISTRY) == {
"sbom_nexus_ingest",
"forgejo_package_prune",
"cnpg_option_a_backup",
# ACTIVITY-WP-0044 / RMGR-WP-0020-T03 (draft definition, disabled)
"repo_manager_freshness",
}

View file

@ -0,0 +1,170 @@
"""Draft repo-manager-bounded-freshness definition (ACTIVITY-WP-0044 / RMGR-WP-0020-T03)."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy.dialects import postgresql
from activity_core import activities, sync_activity_definitions, sync_schedules
from activity_core.bounded_operations import (
BOUNDED_OPERATION_REGISTRY,
normalize_operation_result,
operation_sources,
validate_bounded_operations,
)
from activity_core.context_resolvers import repo_manager
from activity_core.definition_parser import parse_file
DEF_PATH = (
Path(__file__).parent.parent / "activity-definitions" / "repo-manager-bounded-freshness.md"
)
def _source(**params):
base = {"limit": 6, "max_seconds": 240, "apply": True}
base.update(params)
return {
"type": "repo-manager",
"query": "freshness_run",
"operation": "repo_manager_freshness",
"required": True,
"params": base,
"bind_to": "context.freshness",
}
def test_definition_parses_disabled_with_reviewed_shape():
d = parse_file(DEF_PATH)
assert d.id == "repo-manager-bounded-freshness"
assert d.enabled is False
tc = d.trigger_config
assert tc["trigger_type"] == "cron"
assert tc["cron_expression"] == "40 */2 * * *"
assert tc["timezone"] == "UTC"
assert tc["misfire_policy"] == "skip"
[(source, spec)] = operation_sources(d.context_sources)
assert spec.operation_id == "repo_manager_freshness"
assert source["params"] == {"limit": 6, "max_seconds": 240, "apply": True}
assert all(rule["condition"] == "False" for rule in d.rules)
def test_operation_is_registered_with_single_attempt_and_timeout():
spec = BOUNDED_OPERATION_REGISTRY["repo_manager_freshness"]
assert spec.temporal_max_attempts == 1
assert spec.resolve_before_execute is False
assert spec.max_timeout_seconds >= 240 + repo_manager._TIMEOUT_GRACE_SECONDS
@pytest.mark.parametrize(
"params,error",
[
({"limit": 0}, "limit"),
({"limit": 13}, "limit"),
({"limit": True}, "limit"),
({"max_seconds": 271}, "max_seconds"),
({"max_seconds": "240"}, "max_seconds"),
({"apply": "yes"}, "apply"),
({"command": "rm -rf /"}, "code-fixed"),
({"rmgr_bin": "/tmp/x"}, "code-fixed"),
],
)
def test_admission_fails_closed(params, error):
with pytest.raises(ValueError, match=error):
validate_bounded_operations([_source(**params)], [])
def test_argv_is_code_fixed(monkeypatch):
monkeypatch.delenv("ACTIVITY_CORE_RMGR_BIN", raising=False)
assert repo_manager.build_freshness_argv({"limit": 6, "max_seconds": 240, "apply": True}) == [
"rmgr", "freshness", "run", "--limit", "6", "--max-seconds", "240",
]
assert repo_manager.build_freshness_argv(
{"limit": 3, "max_seconds": 60, "apply": False}
)[-1] == "--dry-run"
def test_invocation_projects_counts_only(monkeypatch):
calls = []
def fake_run(argv, **kwargs):
calls.append((argv, kwargs["timeout"]))
out = {"receipts": [
{"repo": "a", "outcome": "applied", "detail": "must not persist"},
{"repo": "b", "outcome": "noop"},
{"repo": "c", "outcome": "refused"},
{"repo": "d", "outcome": "bogus"},
]}
return subprocess.CompletedProcess(argv, 0, json.dumps(out), "")
monkeypatch.setattr(repo_manager.subprocess, "run", fake_run)
raw = repo_manager.repo_manager_freshness({"limit": 6, "max_seconds": 240, "apply": True})
projected = normalize_operation_result("repo_manager_freshness", raw)
assert calls[0][1] == 240 + repo_manager._TIMEOUT_GRACE_SECONDS
assert projected["receipt_count"] == 4
assert projected["outcomes"]["applied"] == 1
assert projected["outcomes"]["noop"] == 1
assert projected["outcomes"]["refused"] == 1
assert projected["apply"] is True and projected["exit_code"] == 0
assert "must not persist" not in json.dumps(projected)
def test_invocation_fails_on_non_json(monkeypatch):
monkeypatch.setattr(
repo_manager.subprocess, "run",
lambda argv, **kw: subprocess.CompletedProcess(argv, 2, "", "boom"),
)
with pytest.raises(RuntimeError, match="exit 2"):
repo_manager.repo_manager_freshness({"limit": 1, "max_seconds": 10, "apply": False})
@pytest.mark.asyncio
async def test_execute_bounded_operation_dispatches_to_repo_manager(monkeypatch):
monkeypatch.setattr(
repo_manager, "repo_manager_freshness",
lambda params: {"receipts": [], "apply": False, "limit": 1, "max_seconds": 10},
)
patch = await activities.execute_bounded_operation({"source": _source(apply=False)})
assert patch["freshness"]["kind"] == "repo_manager_freshness"
@pytest.mark.asyncio
async def test_sync_upserts_disabled_row_and_paused_schedule(monkeypatch):
d = parse_file(DEF_PATH)
monkeypatch.setattr(sync_activity_definitions, "scan_and_parse", lambda: [d])
statements = []
session = MagicMock()
session.execute = AsyncMock(side_effect=lambda stmt: statements.append(stmt))
begin = MagicMock()
begin.__aenter__ = AsyncMock(return_value=None)
begin.__aexit__ = AsyncMock(return_value=False)
session.begin = MagicMock(return_value=begin)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=session)
ctx.__aexit__ = AsyncMock(return_value=False)
assert await sync_activity_definitions.sync(lambda: ctx) == 1
params = statements[0].compile(dialect=postgresql.dialect()).params
assert params["enabled"] is False
assert params["id"] == sync_activity_definitions._definition_uuid(d.id)
upserted = []
async def fake_upsert(client, defn):
upserted.append(defn)
monkeypatch.setattr(sync_schedules, "upsert_schedule", fake_upsert)
monkeypatch.setattr(sync_schedules, "list_schedules", AsyncMock(return_value=[]))
row = SimpleNamespace(
id=params["id"], name=d.name, enabled=params["enabled"],
trigger_config=d.trigger_config, context_sources=d.context_sources,
task_templates=[], dedupe_key_strategy="skip", version=1,
)
result = await sync_schedules.sync_schedule_rows(object(), [row])
assert result.upserted == 0 and result.paused == 1
assert upserted[0].enabled is False

View file

@ -0,0 +1,72 @@
---
id: ACTIVITY-WP-0044
type: workplan
title: "Repo Manager bounded freshness ActivityDefinition (RMGR-WP-0020-T03)"
domain: infotech
repo: activity-core
status: active
owner: claude-activity-core
topic_slug: activity-core
created: "2026-10-04"
updated: "2026-10-04"
flavor: implementation
related: [RMGR-WP-0020, STATE-WP-0094]
---
Origin: Repo Manager RMGR-WP-0020-T03 hands Activity Core a draft definition
for the bounded Forge-sourced freshness runner. Repo Manager owns the engine
(`rmgr freshness run`, RMGR-WP-0020-T02) and receipts (T01); Activity Core owns
the schedule. Source level only: no schedule enabled, no deployment, no
credential handling, no run dispatched.
## Draft definition and bounded operation (disabled)
```task
id: ACTIVITY-WP-0044-T01
status: done
priority: medium
```
- `activity-definitions/repo-manager-bounded-freshness.md`: `enabled: false`,
cron `40 */2 * * *` UTC, `misfire_policy: skip`, `limit: 6`,
`max_seconds: 240`, `apply: true`; rule condition `False` (no task spawning).
- ACT-ADR-007 registry entry `repo_manager_freshness` (source `repo-manager` /
`freshness_run`): single Temporal attempt, timeout 300 s, admission requires
explicit boolean `apply`, `limit` 1..12, `max_seconds` 1..270, and refuses
any definition-supplied command/binary.
- `context_resolvers/repo_manager.py`: code-fixed argv
`rmgr freshness run --limit N --max-seconds S [--dry-run]` (executable from
worker env `ACTIVITY_CORE_RMGR_BIN`, default `rmgr`); only a counted
projection (receipt count per outcome, exit code) is persisted.
- Tests: `tests/test_repo_manager_freshness_definition.py` (parse, admission,
argv, projection, dispatch, DB sync stays disabled, schedule sync paused).
## Interface confirmation against the Repo Manager CLI
```task
id: ACTIVITY-WP-0044-T02
status: wait
priority: medium
needs_human: false
blocking_reason: "rmgr freshness run is being built in RMGR-WP-0020-T02; JSON output (receipts[].outcome) assumed from the T01 receipt contract"
depends_on: [RMGR-WP-0020-T01, RMGR-WP-0020-T02]
```
When RMGR-WP-0020-T01/T02 land, confirm the CLI flags and that stdout is one
JSON object with `receipts[]` carrying `repo-manager.freshness-receipt.v1`
outcomes; adjust `normalize_operation_result` if the shape differs. Decide the
worker image/packaging of `rmgr` (no deployment in this workplan).
## Founder-reviewed activation
```task
id: ACTIVITY-WP-0044-T03
status: wait
priority: medium
needs_human: true
blocking_reason: "Activation follows RMGR-WP-0020-T04 and is a founder action"
depends_on: [RMGR-WP-0020-T04]
```
Flip `enabled: true` only after the RMGR-WP-0020-T04 manual dry-run/apply fires.
Rollback: `enabled: false` (or `status: paused`).