diff --git a/README.md b/README.md index e478eff..f84ce86 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ rein-aharness run --task-file examples/task-hello-sandbox.json \ # Primary production intake: activity-core ops_run claim (ACT-ADR-005 / REIN-A-0002) export ACTIVITY_CORE_URL=http://127.0.0.1:8010 export ACTIVITY_CORE_WORKER_TOKEN=… +export AGENT_HARNESS_LEGACY_APPROACHES_UNTIL=2026-12-31 # temporary profile-absent routes export AGENT_HARNESS_REPO_MAP='{"freedom-intelligence":"~/freedom-intelligence","binky-control":"~/binky-control"}' rein-aharness poll --source=ops-run --no-claim rein-aharness run --from-ops-run @@ -88,8 +89,11 @@ Railiance deploy: [deploy/README.md](deploy/README.md). Each `run` resolves the agent's tool profile + budget from the target repo's instance manifest (default `green-commit-only`), loads a persona bundle (`kaizen-agentic schedule prepare`), runs a bounded agentic -session (cwd-pinned, hard allow-list, never pushes), verifies a local -commit, writes `.kaizen/metrics`, and posts a State Hub progress event. +session (cwd-pinned, hard allow-list, never pushes), verifies a local commit, +records metrics, and posts a State Hub progress event. Local task files with a +v1 `repository_grant` receive full repository acceptance and mandatory durable +external metrics; grant-absent compatibility runs retain repository-local +`.kaizen/metrics` during migration. When invoked through `glas-harness`, the explicit versioned Glas profile may override model, tool profile, and budget for that bounded run. Workforce and activity consumers should reference the Glas profile rather than encode these @@ -98,6 +102,7 @@ rein CLI details in an assignment. Instance manifest contract: [docs/instance-manifest.md](docs/instance-manifest.md). Example: [examples/schedule.harness.yml](examples/schedule.harness.yml). Repository mutation authority: [docs/repository-grant.md](docs/repository-grant.md). +Granted-run metrics: [docs/external-metrics.md](docs/external-metrics.md). Durable result-close design: [docs/close-evidence-outbox.md](docs/close-evidence-outbox.md). Tests: `PYTHONPATH=".:$HOME/llm-connect" python3 -m pytest tests/ -q` diff --git a/SCOPE.md b/SCOPE.md index 268c0a3..e2447c1 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -1,6 +1,6 @@ # SCOPE -> Capability snapshot: 2026-08-23. `INTENT.md` states the destination and +> Capability snapshot: 2026-09-04. `INTENT.md` states the destination and > boundaries; this file records what the repository currently implements and > has evidence for. @@ -22,8 +22,10 @@ retains a legacy set of local task executors for unattended repository work. actor `agt`, returns the complete `GatewayResult`, and never falls back to a legacy approach after profile refusal or failure. - Profile-absent rows use the coexistence registry in `approaches.py`. - Unmatched rows fail visibly. JSON task files remain available for local - development, and issue-core polling remains as a legacy compatibility path. + Dispatch requires an explicit, non-expired + `AGENT_HARNESS_LEGACY_APPROACHES_UNTIL` date; unmatched or disabled routes + fail visibly. JSON task files remain available for local development, and + issue-core polling remains as a legacy compatibility path. - The production claim loop is a single-concurrency user systemd service on railiance01. Claim failures use the configured poll interval and active runs receive lease heartbeats. @@ -34,6 +36,9 @@ retains a legacy set of local task executors for unattended repository work. kaizen-agentic persona bundle, invoke Claude Code in the target checkout, impose a wall-clock timeout and named Claude tool allow-list, and require a new Git `HEAD` before reporting success. +- Local task files may carry an explicit v1 `repository_grant`. These runs + validate commit ancestry/count, changed paths, clean post-state, protected Git + metadata, and remote refs under the repository transaction before success. - Optional Claude stream JSON is reduced to tool/hook audit events and can be reported to State Hub. - Versioned profiled rows delegate profile, rein, model, tool, sandbox, and @@ -58,9 +63,10 @@ retains a legacy set of local task executors for unattended repository work. session to repository read/edit operations and selected local Git commands; lane itself is metadata and consistency validation, not an OS security boundary. -- Runs can append `.kaizen/metrics//executions.jsonl`, regenerate the - summary, emit State Hub progress/tool/token events, and close an associated - Hub task. These reporting operations are best-effort. +- Grant-absent compatibility runs can append repository-local kaizen metrics. + Granted runs instead require private, durable external metrics with a + projection descriptor so accepted checkouts remain clean. State Hub + progress/tool/token events and task closing remain best-effort. - llm-connect HTTP errors retain only bounded, allowlisted provider diagnosis. ### Packaging and deployment @@ -87,12 +93,11 @@ retains a legacy set of local task executors for unattended repository work. - Direct legacy agent sessions rely on Claude Code's own tool mediation and the host checkout. They are not an OS-level sandbox and do not prove that no push/network activity occurred after the run. Unattended mutation now - takes a process-safe repository lock (ADR-002 / T02); commit acceptance - is still the `HEAD`-changed check until T03. -- Commit acceptance currently means only that `HEAD` changed. It does not yet - validate the changed-file set, branch/parent shape, clean working tree, - remote state, or provenance of the new commit. Metrics are written after - this check and can leave target-repo changes for a later commit. + takes a process-safe repository lock (ADR-002 / T02). +- Explicitly granted local task files have full repository acceptance and + clean external metrics. Grant-absent compatibility and profiled queue runs + still use their prior result contracts because Activity Core carries no + authoritative repository grant. Durable queue-close replay is also not live. - The worker is sequential and repository mapping is host configuration. There is no multi-worker repository lease, per-tenant process isolation, generic credential broker, or tenant onboarding API. @@ -109,8 +114,9 @@ retains a legacy set of local task executors for unattended repository work. - Profile routing and rein selection belong to Glas. - Sandbox lifecycle and isolation belong to sand-boxer. - Provider abstraction belongs to llm-connect. -- Durable work history and decisions belong to State Hub; agent memory and - metrics remain in the consuming repository. +- Durable work history and decisions belong to State Hub; agent memory remains + in the consuming repository. Granted-run metrics remain external until a + separately granted kaizen projection commits them to the instance. ## Orientation diff --git a/deploy/README.md b/deploy/README.md index 2c23209..578f9a3 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -3,6 +3,21 @@ Single shared harness instance on **railiance01**. Secrets stay on the host (Lanes 2–3); the container image is the portable runtime package. +## Supported runtime topology + +The authoritative production runtime is the railiance01 user service +`rein-aharness-claim-loop.service`. It owns the configured repository +checkouts, private runtime state, worker credential, lease heartbeats, and +signal-driven shutdown behavior. + +The Kubernetes Deployment is explicitly labeled `packaging-smoke` and runs +`sleep infinity`. It proves that the image can be scheduled with its hardened +container settings; it is not ready to claim work and is not a failover worker. +Do not give it an Activity Core worker credential or scale it as execution +capacity. Promoting Kubernetes requires a separate reviewed cutover with real +workspace, credential, repository-lock, Glas/sandbox, shutdown, and recovery +semantics. + > **Renamed from agent-harness (HARNESS-WP-0002-T02) — cutover done > 2026-07-26.** Railiance now runs `rein-aharness` end to end: image tag, > k8s namespace, CLI command, Python package, host secrets dir, and @@ -48,6 +63,9 @@ Single shared harness instance on **railiance01**. Secrets stay on the host - Lane 3 AppRole under `~/.local/rein-aharness/approle-binky-mail` - `source ~/.local/rein-aharness/env` - Hub: `http://127.0.0.1:18000` (ops-bridge) or in-cluster `state-hub.state-hub.svc` +- While profile-absent tenant definitions remain, set a reviewed ISO expiry in + `AGENT_HARNESS_LEGACY_APPROACHES_UNTIL`. The checked-in example expires + 2026-12-31; missing or expired values refuse compatibility dispatch. ## Build & load image (workstation → railiance01) diff --git a/deploy/k8s/railiance/deployment.yaml b/deploy/k8s/railiance/deployment.yaml index 06b6b00..63e32b3 100644 --- a/deploy/k8s/railiance/deployment.yaml +++ b/deploy/k8s/railiance/deployment.yaml @@ -1,5 +1,5 @@ -# Long-lived instance placeholder until T03 task intake polls issue-core. -# Keeps one ready replica with harness CLI + git tools; no LLM session here. +# Packaging-smoke placeholder only. The supported production runtime is the +# railiance01 user service; this Deployment never claims or executes work. apiVersion: apps/v1 kind: Deployment metadata: @@ -8,6 +8,8 @@ metadata: labels: app.kubernetes.io/name: rein-aharness app.kubernetes.io/part-of: rein-aharness + app.kubernetes.io/component: packaging-smoke + rein-aharness.railiance.io/runtime-role: packaging-smoke spec: replicas: 1 selector: @@ -18,6 +20,8 @@ spec: labels: app.kubernetes.io/name: rein-aharness app.kubernetes.io/part-of: rein-aharness + app.kubernetes.io/component: packaging-smoke + rein-aharness.railiance.io/runtime-role: packaging-smoke spec: securityContext: fsGroup: 10001 diff --git a/deploy/systemd/claim-loop.env.example b/deploy/systemd/claim-loop.env.example index 431eefa..a30aa80 100644 --- a/deploy/systemd/claim-loop.env.example +++ b/deploy/systemd/claim-loop.env.example @@ -9,6 +9,7 @@ AGENT_HARNESS_OPS_LABELS=automated AGENT_HARNESS_OPS_LABELS_MODE=any AGENT_HARNESS_OPS_LEASE_SECONDS=900 AGENT_HARNESS_CLAIM_INTERVAL=30 +AGENT_HARNESS_LEGACY_APPROACHES_UNTIL=2026-12-31 AGENT_HARNESS_REPO_MAP={"freedom-intelligence":"/home/tegwick/freedom-intelligence","binky-control":"/home/tegwick/binky-control"} AGENT_HARNESS_REPO_ROOTS=/home/tegwick:/home/tegwick/work LLM_CONNECT_URL=k8s://activity-core/llm-connect:8080 diff --git a/deploy/systemd/rein-aharness-claim-loop.service b/deploy/systemd/rein-aharness-claim-loop.service index 4575354..14a66c2 100644 --- a/deploy/systemd/rein-aharness-claim-loop.service +++ b/deploy/systemd/rein-aharness-claim-loop.service @@ -9,6 +9,8 @@ Type=simple WorkingDirectory=%h/rein-aharness Environment=PYTHONUNBUFFERED=1 Environment=KUBECONFIG=/etc/rancher/k3s/k3s.yaml +# Temporary profile-absent compatibility; remove after the four tenant migrations. +Environment=AGENT_HARNESS_LEGACY_APPROACHES_UNTIL=2026-12-31 # Ensure kubectl is available for k8s:// URL resolution in rein-aharness-claim Environment=PATH=/usr/local/bin:/usr/bin:/bin:%h/bin # Wrapper loads claim-loop.env (JSON-safe) and resolves k8s:// ClusterIPs diff --git a/docs/architecture.md b/docs/architecture.md index 76a61aa..8cf0eb1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,8 +37,8 @@ | Task intake | `intake.py` + `taskspec.py` | issue-core GET/PATCH poll+claim; JSON task-file for local dev | NATS when activity-core migrates | | Persona | `persona.py` | `kaizen-agentic schedule prepare` (ADR-005) | unchanged, plus phase-memory profile hook | | Session | `adapter.py` | `AgenticClaudeCodeAdapter` (cwd-pinned, profile allow-list) | + hosted adapters | -| Orchestration | `runner.py` | profile/budget → session → commit → metrics → hub | activity-core intake | -| Metrics | `metrics.py` | ADR-004 `.kaizen/metrics` write per run | correlate with Helix fleet metrics | +| Orchestration | `runner.py` | profile/budget → transaction → session → grant acceptance → metrics → hub | activity-core intake | +| Metrics | `metrics.py` | compatibility `.kaizen/metrics`; durable external ledger for granted runs | kaizen-owned projection | | Mail lane | `mailscan.py` | deterministic credentialed pre-step outside the session | pattern generalizes to other credentialed pre-steps | | Hub reporting | `hub.py` | REST progress event + task close + token event | unchanged | @@ -71,7 +71,7 @@ non-interactive AppRole. See binky-control - **Regulation:** manifest declares, harness enforces. flex-auth gates apply at credential acquisition; ops-warden catalogs every lane; each instance runs under a named hub identity (`agt-…`). -- **Evolution:** the harness emits `.kaizen/metrics` per run so the +- **Evolution:** the harness emits kaizen-compatible metrics per run so the kaizen optimization loop covers both blueprints and the harness itself. Harness releases are versioned; instances pin majors; blueprint conformance tests run before rollout. diff --git a/docs/external-metrics.md b/docs/external-metrics.md new file mode 100644 index 0000000..d8e30b4 --- /dev/null +++ b/docs/external-metrics.md @@ -0,0 +1,36 @@ +# Durable external metrics + +Repository-granted runs must not dirty a checkout after acceptance. Their +kaizen-compatible execution record and summary are therefore written beneath: + +```text +$REIN_AHARNESS_STATE_DIR/execution-metrics/// +``` + +When the explicit state variable is absent, the runtime uses +`$XDG_STATE_HOME/rein-aharness` or `~/.local/state/rein-aharness`. Repository +and agent directory names are SHA-256-derived identifiers rather than caller +text. Directories are mode `0700`; records and lock files are mode `0600`. + +Each directory contains: + +- `executions.jsonl`: append-only kaizen `ExecutionRecord` values; +- `summary.json`: regenerated aggregate metrics; and +- `projection.json`: repository id/name, agent name, and intended consuming + path `.kaizen/metrics/`. + +Writes serialize on a process-safe lock. The execution record is flushed and +`fsync`ed before the summary and projection descriptors are atomically replaced +and directory metadata is `fsync`ed. Replaying the same transaction identity +regenerates the derived files without adding a duplicate ledger record. Failure +to persist this evidence makes a granted run unsuccessful; granted execution +cannot opt out with `--no-metrics`. + +Projection is a separate repository mutation. This harness does not copy these +files back automatically, because doing so would invalidate the accepted clean +post-state. A future kaizen-owned projection lane may consume +`projection.json`, copy `executions.jsonl` and `summary.json` to the declared +target, and commit them under its own explicit repository grant. Until that +lane exists, the external files are the durable source of truth for granted +runs. Grant-absent compatibility runs continue to write repository-local +metrics directly. diff --git a/docs/legacy-runtime-inventory.md b/docs/legacy-runtime-inventory.md index 8d9d711..f4a617c 100644 --- a/docs/legacy-runtime-inventory.md +++ b/docs/legacy-runtime-inventory.md @@ -1,15 +1,16 @@ # Legacy profile-absent runtime inventory -Status: preparatory inventory for `HARNESS-WP-0003-T04` +Status: active migration inventory for `REINAH-WP-0003-T04` -Captured: 2026-08-23 +Captured: 2026-08-23; compatibility guard added 2026-09-04 -Runtime behavior source: rein-aharness `6e0e23c` +Initial inventory source: rein-aharness `6e0e23c`; guard behavior updated in +the implementation recorded by this workplan. This inventory records the tenant-specific execution paths selected when an -Activity Core `ops_run` has no `harness_profile_ref`. It is deliberately -read-only preparation: T04 remains `wait` until ADR-002 is accepted, and this -document neither enables a definition nor changes a production route. +Activity Core `ops_run` has no `harness_profile_ref`. It began as read-only +preparation; after ADR-002 acceptance it also records the dated compatibility +guard. This document does not itself enable a definition. The definition state below is the committed file state in Activity Core, freedom-intelligence, and binky-control. Live schedule projection, timer state, @@ -24,12 +25,15 @@ id, title, hint, labels, source id, and target repo. None of the four committed definitions below declares `harness_profile_ref` or `approach_hint`; their current route therefore depends on labels and substring -matching. A present profile ref bypasses this table and must never fall back to -it. +matching. Dispatch now additionally requires an explicit, non-expired ISO date +in `AGENT_HARNESS_LEGACY_APPROACHES_UNTIL`. Missing, malformed, or expired +values fail terminally before target resolution or executor invocation. A +present profile ref bypasses both this table and the compatibility flag and +must never fall back to either. | Selector | Tenant coupling | Direct executor | |---|---|---| -| `fi-research-brief` | FI labels, names, output schema, completion event, literal result repo, default push | `fi_research_brief.run_fi_research_brief` | +| `fi-research-brief` | FI labels, names, output schema, completion event, literal result repo | `fi_research_brief.run_fi_research_brief` | | `brief-daily` | Binky labels, files, prompt/output schema, completion event, literal result repo | `brief_daily.run_brief_daily` | | `brief-weekly` | Binky labels, milestone/RISK-005 semantics, completion event, literal result repo | `brief_weekly.run_brief_weekly` | | `mail-scan+triage` | Binky labels, mailbox config/path, OpenBao path, report/log schema and events | `mailscan.run_mail_scan` then `mail_triage.run_mail_triage` | @@ -38,9 +42,10 @@ it. An unmatched route fails terminally. Most matched executor failures set `reopen=true`; an unexpected exception also reopens. Activity Core applies its -attempt limit, but the harness currently has no shared repository transaction -identity with which to distinguish a safe retry from a repeat after partial -mutation. +attempt limit. The harness now records a repository transaction identity, but +Activity Core does not yet carry an accepted repository grant or reconcile a +response-lost terminal close, so a retry after ambiguous completion remains a +T03 gate. ## Scheduled definitions @@ -55,13 +60,13 @@ mutation. | Model/credential lane | HTTP `LLM_CONNECT_URL`; model from `FI_RESEARCH_BRIEF_MODEL`, then Binky-named `BRIEF_DAILY_MODEL` / `MAIL_TRIAGE_MODEL`; provider credential remains behind llm-connect | | Output | `briefs/YYYY/MM/YYYY-MM-DD.md`; model JSON is rendered deterministically | | Repository mutation | Stages only the brief path and creates one local commit | -| Publication | **Pushes `origin HEAD` by default** unless `FI_RESEARCH_BRIEF_PUSH` is false; push failure is swallowed and the run remains successful | +| Publication | Local commit only. The former `FI_RESEARCH_BRIEF_PUSH` path was removed 2026-09-04; publication requires a separate owner/grant. | | Completion | Best-effort State Hub `fi_daily_brief` with path/date/candidate count; `fi_brief_status` uses it to clear due state | | Failure/retry | Normal generation/commit failure reopens; an existing daily path succeeds idempotently | | Current rollback material | Disable/pause the Activity Core definition. FI documents a disabled 07:35 host timer as break-glass, but its installer still enables that timer and must not be invoked casually | -Migration must remove the implicit push. Publication, if still needed, becomes -a separately named and granted capability with remote-ref and result evidence. +Publication, if still needed, must become a separately named and granted +capability with remote-ref and result evidence. The Binky-named fallback model environment variables must not survive in an FI owned declaration. @@ -148,15 +153,17 @@ a repository transaction or remote-ref check. 2. **Tenant code in the shared runtime:** prompts, output schemas, file paths, event types, FI publication, Binky risk logic, mailbox paths, and credential lanes require edits to rein-aharness. -3. **No shared repository transaction:** dirty baselines, partial writes, - unexpected commits/paths, lease loss, and retries after mutation are not - bounded. T02/T03 remain prerequisites for any retained direct executor. +3. **Queue acceptance is incomplete:** T02 now bounds dirty baselines, + transaction identity, and lease-loss cancellation, and local granted tasks + have T03 acceptance. Activity Core still lacks authoritative grant carriage + and terminal-close reconciliation for queued execution. 4. **Required evidence is best-effort:** Hub delivery failure does not fail or reconcile a run even though the due resolvers depend on those events. 5. **Completion meanings diverge:** mailbox scan and triage have different events, while the Activity definition describes one intake task. -6. **Publication is implicit in FI:** default push plus swallowed failure cannot - meet an explicit grant/evidence contract. +6. **FI publication is now separated:** the compatibility executor is + local-commit-only; any future publish lane still needs an explicit owner, + grant, and evidence contract. 7. **Mutable sibling/runtime dependencies:** mail scan defaults to a sibling email-connect checkout, and model selection depends on process environment rather than a versioned execution declaration. @@ -169,8 +176,9 @@ a repository transaction or remote-ref check. This sequence is preparatory and does not choose a profile or capability owner before ADR-002 acknowledgement. -1. Land T02/T03 repository transaction, lease-loss, acceptance, and evidence - reconciliation for any compatibility executor that remains callable. +1. Carry T02's transaction/lease evidence and T03's repository grant through + Activity Core, then activate reconciled close evidence for any compatibility + executor that remains callable. 2. For each scheduled definition, obtain an owner-approved declaration of the exact input schema, allowed paths, model route, credential route, completion event, commit policy, publication policy, and rollback. @@ -184,6 +192,6 @@ before ADR-002 acknowledgement. 5. Enable the replacement, verify the due resolver and durable evidence, then remove that definition from substring routing. 6. After all four definitions migrate, enable - `ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE=true` for governed execution, delete - `_DEFINITION_HINTS` and tenant approach rules, and reconcile or retire the - tenant host timers. + `ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE=true` for governed execution, remove + `AGENT_HARNESS_LEGACY_APPROACHES_UNTIL`, delete `_DEFINITION_HINTS` and + tenant approach rules, and reconcile or retire the tenant host timers. diff --git a/docs/ops-run-claim-loop.md b/docs/ops-run-claim-loop.md index 3791c0b..2a60b40 100644 --- a/docs/ops-run-claim-loop.md +++ b/docs/ops-run-claim-loop.md @@ -38,6 +38,7 @@ the legacy coexistence path until the migration flag is enabled upstream. | `AGENT_HARNESS_OPS_LABELS_MODE` | `any` | `any` or `all` | | `AGENT_HARNESS_OPS_LEASE_SECONDS` | `900` | claim lease | | `AGENT_HARNESS_CLAIM_INTERVAL` | `30` | empty-queue poll interval | +| `AGENT_HARNESS_LEGACY_APPROACHES_UNTIL` | unset | ISO expiry date required for profile-absent compatibility routing | | `AGENT_HARNESS_REPO_MAP` | `{}` | JSON slug→checkout path | | `AGENT_HARNESS_REPO_ROOTS` | `~:~/work` | slug search roots | @@ -48,6 +49,7 @@ export AGENT_HARNESS_REPO_MAP='{ "freedom-intelligence":"/home/tegwick/freedom-intelligence", "binky-control":"/home/tegwick/binky-control" }' +export AGENT_HARNESS_LEGACY_APPROACHES_UNTIL=2026-12-31 export ACTIVITY_CORE_URL=http://127.0.0.1:8010 # or ClusterIP via tunnel export ACTIVITY_CORE_WORKER_TOKEN=… # from actcore-runtime-secret ``` diff --git a/docs/repository-grant.md b/docs/repository-grant.md index 94f8bcf..837d1ec 100644 --- a/docs/repository-grant.md +++ b/docs/repository-grant.md @@ -1,6 +1,7 @@ # Repository grant contract -Status: **v1 parsed, validation-ready, not execution-enabled**. +Status: **v1 enabled for local `TaskSpec` files; queued/profiled carriage is +not yet available**. `repository_grant` is the explicit authority envelope for a bounded local Git mutation. It is separate from task prose, labels, organizational attribution, @@ -39,20 +40,28 @@ The repository acceptance validator converts the grant into the policy used to check descendant commits, changed paths, clean post-state, protected Git metadata, and local remote-tracking refs. -## Current fail-closed posture +## Current execution posture -`TaskSpec.from_file` parses this contract, but `run_task` deliberately refuses -any task that supplies it before adapter dispatch. The Activity Core and -issue-core adapters do not currently populate it. Existing grant-absent direct -and compatibility runs retain their prior behavior while the transaction path -remains production-inert. +`TaskSpec.from_file` parses this contract and `run_task` executes it under the +canonical repository transaction. A successful adapter result is accepted only +when the commit ancestry/count, changed paths, clean post-state, protected Git +metadata, and remote-tracking refs satisfy the exact grant. The result carries +bounded grant, baseline, policy, commit, and path evidence without raw grant +paths, prompts, or provider output. -Execution may be enabled only after: +Granted runs require durable external metrics and refuse `--no-metrics`. Their +metrics are written outside the checkout only after acceptance, so a successful +run remains clean. See [external-metrics.md](external-metrics.md). + +Activity Core and issue-core adapters do not populate `repository_grant`. +Queued/profiled runs therefore cannot claim repository acceptance under this +contract. Existing grant-absent direct and compatibility runs retain their +legacy `HEAD`-changed behavior during migration. + +Queued/profiled execution may use this contract only after: 1. an authoritative Activity Core/profile field carries the reviewed grant; -2. the lease-bound transaction wraps adapter dispatch and result close; -3. the runner validates the accepted result against this exact grant; and -4. required close evidence durably records the grant, transaction, and accepted +2. required close evidence durably records the grant, transaction, and accepted result identities. Task descriptions, labels, `execution_refs`, consuming-repo defaults, and diff --git a/rein_aharness/__init__.py b/rein_aharness/__init__.py index 6c571f7..0b4e4c9 100644 --- a/rein_aharness/__init__.py +++ b/rein_aharness/__init__.py @@ -3,8 +3,8 @@ Consuming repos declare instances in `.kaizen/schedule.yml`; this package runs them: resolve tool profile + budget, load persona orientation (kaizen-agentic schedule prepare), run a bounded agentic session via an -llm-connect adapter, verify the local commit, write `.kaizen/metrics`, -and report to the Custodian State Hub. +llm-connect adapter, validate granted local commits, record metrics, and report +to the Custodian State Hub. """ __version__ = "0.1.0" diff --git a/rein_aharness/approaches.py b/rein_aharness/approaches.py index e8d0642..71d9a4f 100644 --- a/rein_aharness/approaches.py +++ b/rein_aharness/approaches.py @@ -12,7 +12,9 @@ How to add a row: from __future__ import annotations +import os from dataclasses import dataclass, field +from datetime import date from pathlib import Path from typing import Any @@ -35,6 +37,21 @@ APPROACH_MAIL_TRIAGE = "mail-triage" APPROACH_MAIL_PIPELINE = "mail-scan+triage" APPROACH_AGENT_SESSION = "agent-session" APPROACH_UNMATCHED = "unmatched" +LEGACY_APPROACHES_UNTIL_ENV = "AGENT_HARNESS_LEGACY_APPROACHES_UNTIL" + + +def legacy_approaches_enabled( + value: str | None = None, + *, + today: date | None = None, +) -> bool: + """Require an explicit, non-expired ISO date for profile-absent routing.""" + raw = os.environ.get(LEGACY_APPROACHES_UNTIL_ENV, "") if value is None else value + try: + expires = date.fromisoformat(raw.strip()) + except (AttributeError, ValueError): + return False + return (today or date.today()) <= expires @dataclass(frozen=True) @@ -185,6 +202,19 @@ def execute_approach( reopen=False, ) + if not legacy_approaches_enabled(): + configured = os.environ.get(LEGACY_APPROACHES_UNTIL_ENV, "").strip() + state = f"expired at {configured}" if configured else "not configured" + return ApproachResult( + ok=False, + approach=name, + reason=( + "refused: profile-absent compatibility routing is disabled " + f"({LEGACY_APPROACHES_UNTIL_ENV} {state})" + ), + reopen=False, + ) + try: target = resolve_ops_target(run, cfg) except TaskSpecError as exc: @@ -266,6 +296,7 @@ def execute_approach( reopen=True, ) + def _run_fi(target: Path, *, report_to_hub: bool, commit: bool) -> ApproachResult: from rein_aharness.fi_research_brief import run_fi_research_brief diff --git a/rein_aharness/cli.py b/rein_aharness/cli.py index 6a6a634..186ff85 100644 --- a/rein_aharness/cli.py +++ b/rein_aharness/cli.py @@ -305,7 +305,10 @@ def main(argv: list[str] | None = None) -> int: run.add_argument( "--no-metrics", action="store_true", - help="Skip writing .kaizen/metrics in the target repo", + help=( + "Skip compatibility .kaizen/metrics writes " + "(not permitted with repository_grant)" + ), ) run.add_argument( "--stream-tool-events", diff --git a/rein_aharness/fi_research_brief.py b/rein_aharness/fi_research_brief.py index 8cd5a59..4b5f290 100644 --- a/rein_aharness/fi_research_brief.py +++ b/rein_aharness/fi_research_brief.py @@ -420,20 +420,6 @@ def run_fi_research_brief( ) committed = True head_after = _git(repo, "rev-parse", "HEAD") - # Best-effort push so workstation / Forgejo see the brief. - # Diverged branches must not fail the run; log via reason only if push fails - # after a successful write. - if committed and os.environ.get("FI_RESEARCH_BRIEF_PUSH", "1").strip().lower() not in { - "0", - "false", - "no", - "off", - }: - try: - _git(repo, "push", "origin", "HEAD") - except FiResearchBriefError: - # Leave brief committed locally; operators reconcile git separately. - pass except FiResearchBriefError as exc: result = FiResearchBriefResult( ok=False, diff --git a/rein_aharness/metrics.py b/rein_aharness/metrics.py index 4e5fcc5..83c0290 100644 --- a/rein_aharness/metrics.py +++ b/rein_aharness/metrics.py @@ -1,7 +1,8 @@ -"""Write per-run records into the target repo's `.kaizen/metrics` tree. +"""Write per-run metrics to compatibility or durable external storage. -Follows kaizen-agentic ADR-004 conventions so the optimization loop can -observe harness-run agents: +Legacy grant-absent runs retain the kaizen-agentic ADR-004 repository layout. +Accepted grant runs use private external state and a projection descriptor so +metrics cannot dirty the validated checkout. .kaizen/metrics// executions.jsonl # append-only @@ -10,7 +11,11 @@ observe harness-run agents: from __future__ import annotations +import fcntl +import hashlib import json +import os +import uuid from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path @@ -46,6 +51,20 @@ def metrics_dir(project_root: Path, agent: str) -> Path: return Path(project_root) / ".kaizen" / "metrics" / agent +def external_metrics_dir( + project_root: Path | str, + agent: str, + *, + state_dir: Path | None = None, +) -> Path: + """Return the private durable metrics directory for a granted run.""" + root = Path(project_root).expanduser().resolve() + base = state_dir.expanduser().resolve() if state_dir else _state_dir() + repo_id = hashlib.sha256(str(root).encode("utf-8")).hexdigest()[:32] + agent_id = hashlib.sha256(str(agent).encode("utf-8")).hexdigest()[:32] + return base / "execution-metrics" / repo_id / agent_id + + def _utc_now() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace( "+00:00", "Z" @@ -133,18 +152,17 @@ def record_execution( directory = metrics_dir(root, agent) directory.mkdir(parents=True, exist_ok=True) - record = ExecutionRecord( - timestamp=_utc_now(), - agent=agent, + record = _execution_record( + root, + agent, success=success, - execution_time_s=float(execution_time_s), - session_id=session_id, - metadata=metadata or {}, - repo=root.name, + execution_time_s=execution_time_s, tokens=tokens, committed=committed, head_after=head_after, reason=reason, + metadata=metadata, + session_id=session_id, ) executions_path = directory / "executions.jsonl" @@ -158,3 +176,189 @@ def record_execution( encoding="utf-8", ) return executions_path + + +def record_external_execution( + project_root: Path | str, + agent: str, + *, + success: bool, + execution_time_s: float = 0.0, + tokens: int | None = None, + committed: bool | None = None, + head_after: str | None = None, + reason: str | None = None, + metadata: dict[str, Any] | None = None, + session_id: str | None = None, + state_dir: Path | None = None, +) -> Path: + """Durably record granted-run metrics without dirtying the target checkout.""" + root = Path(project_root).expanduser().resolve() + projection_target = _projection_target(agent) + directory = external_metrics_dir(root, agent, state_dir=state_dir) + _ensure_private_directory(directory) + lock_path = directory / ".lock" + lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) + try: + os.fchmod(lock_fd, 0o600) + fcntl.flock(lock_fd, fcntl.LOCK_EX) + record = _execution_record( + root, + agent, + success=success, + execution_time_s=execution_time_s, + tokens=tokens, + committed=committed, + head_after=head_after, + reason=reason, + metadata=metadata, + session_id=session_id, + ) + executions_path = directory / "executions.jsonl" + existing = ( + executions_path.read_text(encoding="utf-8") + if executions_path.exists() + else "" + ) + if existing and not existing.endswith("\n"): + raise OSError("external metrics ledger has an incomplete record") + records = _load_external_executions(executions_path) if existing else [] + already_recorded = session_id is not None and any( + item.get("session_id") == session_id for item in records + ) + if not already_recorded: + record_line = record.to_json_line() + _atomic_write_text(executions_path, existing + record_line + "\n") + records.append(json.loads(record_line)) + _atomic_write_text( + directory / "summary.json", + json.dumps(regenerate_summary(agent, records), indent=2, sort_keys=True) + + "\n", + ) + repo_id = directory.parent.name + _atomic_write_text( + directory / "projection.json", + json.dumps( + { + "agent": agent, + "repository_id": repo_id, + "repository_name": root.name, + "target_relative_directory": projection_target, + }, + indent=2, + sort_keys=True, + ) + + "\n", + ) + _fsync_directory(directory) + return executions_path + finally: + try: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + finally: + os.close(lock_fd) + + +def _projection_target(agent: str) -> str: + """Return a checkout-relative projection target for a safe agent identity.""" + if ( + not agent + or agent in {".", ".."} + or "/" in agent + or "\\" in agent + or "\x00" in agent + or len(agent) > 200 + ): + raise OSError("agent identity cannot form a safe metrics projection") + return f".kaizen/metrics/{agent}" + + +def _execution_record( + root: Path, + agent: str, + *, + success: bool, + execution_time_s: float, + tokens: int | None, + committed: bool | None, + head_after: str | None, + reason: str | None, + metadata: dict[str, Any] | None, + session_id: str | None, +) -> ExecutionRecord: + return ExecutionRecord( + timestamp=_utc_now(), + agent=agent, + success=success, + execution_time_s=float(execution_time_s), + session_id=session_id, + metadata=metadata or {}, + repo=root.name, + tokens=tokens, + committed=committed, + head_after=head_after, + reason=reason, + ) + + +def _load_external_executions(path: Path) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + try: + value = json.loads(line) + except json.JSONDecodeError as exc: + raise OSError("external metrics ledger contains invalid JSON") from exc + if not isinstance(value, dict): + raise OSError("external metrics ledger contains a non-object record") + records.append(value) + return records + + +def _state_dir() -> Path: + explicit = os.environ.get("REIN_AHARNESS_STATE_DIR", "").strip() + if explicit: + return Path(explicit).expanduser().resolve() + xdg = os.environ.get("XDG_STATE_HOME", "").strip() + if xdg: + return (Path(xdg).expanduser() / "rein-aharness").resolve() + return (Path.home() / ".local" / "state" / "rein-aharness").resolve() + + +def _ensure_private_directory(path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + current = path + while current.name and current != current.parent: + os.chmod(current, 0o700) + if current.name == "execution-metrics": + break + current = current.parent + + +def _atomic_write_text(path: Path, value: str) -> None: + temporary = path.parent / f".{path.name}.{uuid.uuid4().hex}.tmp" + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + fd = -1 + handle.write(value) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + os.chmod(path, 0o600) + _fsync_directory(path.parent) + except BaseException: + if fd >= 0: + os.close(fd) + try: + temporary.unlink() + except FileNotFoundError: + pass + raise + + +def _fsync_directory(path: Path) -> None: + fd = os.open(path, os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) diff --git a/rein_aharness/repository_grant.py b/rein_aharness/repository_grant.py index 5b509ae..ac2b23a 100644 --- a/rein_aharness/repository_grant.py +++ b/rein_aharness/repository_grant.py @@ -1,10 +1,4 @@ -"""Versioned authority contract for repository mutation. - -The grant is parsed by TaskSpec but is not yet wired into run execution. A -supplied grant therefore causes run_task to refuse before adapter dispatch. -This keeps the contract reviewable without implying enforcement that the live -runner does not yet provide. -""" +"""Versioned authority contract for accepted local repository mutation.""" from __future__ import annotations diff --git a/rein_aharness/repository_transaction.py b/rein_aharness/repository_transaction.py index e3889ec..d11012d 100644 --- a/rein_aharness/repository_transaction.py +++ b/rein_aharness/repository_transaction.py @@ -1,9 +1,9 @@ """Process-safe transaction boundary for a local Git checkout. Wired into `run_task`, profile-absent `execute_approach` mutators, and the -profiled claim path under REINAH-WP-0003-T02 / ADR-002. Repository -acceptance (T03) remains a separate read-only validator and is not applied -to live results yet. +profiled claim path under REINAH-WP-0003-T02 / ADR-002. Explicit local +TaskSpec grants activate repository acceptance; queued/profiled grant carriage +remains an upstream contract dependency. """ from __future__ import annotations diff --git a/rein_aharness/runner.py b/rein_aharness/runner.py index 4febd55..a00c007 100644 --- a/rein_aharness/runner.py +++ b/rein_aharness/runner.py @@ -2,10 +2,10 @@ Flow: lock target repo → resolve tool profile / budget from instance manifest → snapshot HEAD → persona bundle → prompt → agentic session → -verify a new commit exists → kaizen metrics + hub progress event -(+ task close). The run *fails* if the session pushed anywhere or left -the repo dirty in a way it should not — the worker never pushes; -publishing is a separate, explicitly-granted lane. +validate an explicit repository grant when present → metrics + hub progress +event (+ task close). Granted runs use durable external metrics so repository +acceptance remains clean. The worker never pushes; publishing is a separate, +explicitly-granted lane. """ from __future__ import annotations @@ -24,6 +24,7 @@ from rein_aharness.profiles import UnknownToolProfileError, get_profile from rein_aharness.repository_transaction import ( DirtyRepositoryError, GitRepositoryError, + RepositoryAcceptanceError, RepositoryBusyError, RepositoryTransaction, RepositoryTransactionError, @@ -116,11 +117,11 @@ def run_task( budget_tokens_override: int | None = None, transaction: RepositoryTransaction | None = None, ) -> RunResult: - if spec.repository_grant is not None: + if spec.repository_grant is not None and not write_metrics: return _refused_run( reason=( - "refused: repository_grant enforcement is not enabled; " - "no adapter was dispatched" + "refused: repository_grant runs require durable external metrics; " + "--no-metrics is incompatible" ), model=model, ) @@ -255,11 +256,62 @@ def _execute_locked_task( head_after = _git(spec.target_repo, "rev-parse", "HEAD") committed = head_after != head_before + if session_ok and spec.repository_grant is not None: + try: + tx.validate_acceptance(spec.repository_grant.acceptance_policy()) + except RepositoryAcceptanceError as exc: + session_ok = False + reason = str(exc) ok = session_ok and committed - if session_ok and not committed: + if session_ok and not committed and spec.repository_grant is None: reason = "session completed without committing" tokens_spent = budget_tracker.spent if budget_tracker is not None else None + transaction_evidence = tx.evidence() + if spec.repository_grant is not None: + transaction_evidence["repository_grant"] = spec.repository_grant.evidence() + + metric_metadata = { + "task_title": spec.title, + "tool_profile": profile.name, + "labels": list(spec.labels), + "completion_event_type": spec.completion_event_type, + } + if spec.repository_grant is not None: + metric_metadata["repository_grant_id"] = spec.repository_grant.grant_id + + if write_metrics: + try: + recorder = ( + metrics.record_external_execution + if spec.repository_grant is not None + else metrics.record_execution + ) + recorder( + spec.target_repo, + spec.agent, + success=ok, + execution_time_s=execution_time_s, + tokens=tokens_spent, + committed=committed, + head_after=head_after, + reason=reason or None, + metadata=metric_metadata, + session_id=tx.transaction_id, + ) + if spec.repository_grant is not None: + transaction_evidence["metrics"] = { + "storage": "external", + "session_id": tx.transaction_id, + "projection_ready": True, + } + except OSError as exc: + if spec.repository_grant is not None: + ok = False + reason = ( + "required external metrics persistence failed " + f"({type(exc).__name__})" + ) result = RunResult( ok=ok, @@ -275,30 +327,9 @@ def _execute_locked_task( tokens_spent=tokens_spent, execution_time_s=execution_time_s, tool_events=collected_events, - transaction=tx.evidence(), + transaction=transaction_evidence, ) - if write_metrics: - try: - metrics.record_execution( - spec.target_repo, - spec.agent, - success=ok, - execution_time_s=execution_time_s, - tokens=tokens_spent, - committed=committed, - head_after=head_after, - reason=reason or None, - metadata={ - "task_title": spec.title, - "tool_profile": profile.name, - "labels": list(spec.labels), - "completion_event_type": spec.completion_event_type, - }, - ) - except OSError: - pass # metrics must not block run completion reporting - if report_to_hub: detail = { "repo": spec.target_repo.name, @@ -314,6 +345,7 @@ def _execute_locked_task( "budget_tokens": budget_tokens, "tokens_spent": tokens_spent, "execution_time_s": round(execution_time_s, 3), + "repository_transaction": transaction_evidence, } hub.post_progress_event( summary=f"executor run: {spec.title} ({'ok' if ok else 'failed'})", diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dee32b9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture(autouse=True) +def isolated_runtime_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep repository locks and close evidence out of operator-owned state.""" + monkeypatch.setenv("REIN_AHARNESS_STATE_DIR", str(tmp_path / "runtime-state")) + monkeypatch.setenv("AGENT_HARNESS_LEGACY_APPROACHES_UNTIL", "2099-12-31") diff --git a/tests/test_approaches.py b/tests/test_approaches.py index d1cb12e..0c83d36 100644 --- a/tests/test_approaches.py +++ b/tests/test_approaches.py @@ -2,6 +2,8 @@ from __future__ import annotations +from datetime import date + from rein_aharness.approaches import ( APPROACH_AGENT_SESSION, APPROACH_BRIEF_DAILY, @@ -9,6 +11,8 @@ from rein_aharness.approaches import ( APPROACH_FI_RESEARCH_BRIEF, APPROACH_MAIL_PIPELINE, APPROACH_UNMATCHED, + execute_approach, + legacy_approaches_enabled, select_approach, ) from rein_aharness.ops_run_client import OpsRun @@ -113,3 +117,26 @@ def test_fi_before_generic_automated() -> None: assert ( select_approach(_run(labels=["research-brief"])) == APPROACH_FI_RESEARCH_BRIEF ) + + +def test_legacy_approaches_require_non_expired_explicit_date() -> None: + today = date(2026, 9, 4) + assert legacy_approaches_enabled("2026-09-04", today=today) is True + assert legacy_approaches_enabled("2026-09-03", today=today) is False + assert legacy_approaches_enabled("true", today=today) is False + assert legacy_approaches_enabled("", today=today) is False + + +def test_execute_approach_refuses_profile_absent_route_without_flag( + monkeypatch, +) -> None: + monkeypatch.delenv("AGENT_HARNESS_LEGACY_APPROACHES_UNTIL") + + result = execute_approach( + _run(labels=["research-brief"], target_repo="not-resolved"), + report_to_hub=False, + ) + + assert result.ok is False + assert result.reopen is False + assert "compatibility routing is disabled" in result.reason diff --git a/tests/test_claim_loop.py b/tests/test_claim_loop.py index 8eb841c..be85a93 100644 --- a/tests/test_claim_loop.py +++ b/tests/test_claim_loop.py @@ -2,8 +2,10 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +import subprocess import time +from pathlib import Path +from unittest.mock import MagicMock, patch from rein_aharness.approaches import ApproachResult, APPROACH_FI_RESEARCH_BRIEF from rein_aharness.claim_loop import ( @@ -20,6 +22,7 @@ from rein_aharness.ops_run_client import ( OpsRunConfig, OpsRunError, ) +from rein_aharness.repository_transaction import RepositoryTransaction def _claimed_run() -> OpsRun: @@ -37,6 +40,41 @@ def _claimed_run() -> OpsRun: ) +def _profiled_case( + tmp_path: Path, +) -> tuple[Path, OpsRun, MagicMock]: + repo = tmp_path / "freedom-intelligence" + repo.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + (repo / "README.md").write_text("controlled target\n", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.email=test@example.invalid", + "-c", + "user.name=test", + "commit", + "-qm", + "baseline", + ], + cwd=repo, + check=True, + ) + + run = _claimed_run() + run.harness_profile_ref = "harness.agent-dev-local@1.0.0" + client = MagicMock(spec=ActivityCoreOpsClient) + client.config = OpsRunConfig( + worker_id="w", + lease_seconds=90, + repo_roots=(str(tmp_path),), + ) + client.claim.return_value = [run] + return repo, run, client + + def test_process_one_empty() -> None: client = MagicMock(spec=ActivityCoreOpsClient) client.config = OpsRunConfig(worker_id="w", lease_seconds=90) @@ -231,12 +269,8 @@ def test_process_one_records_adapter_exception_without_unbound_result() -> None: client.fail.assert_not_called() -def test_profiled_exception_after_lease_loss_skips_close() -> None: - client = MagicMock(spec=ActivityCoreOpsClient) - client.config = OpsRunConfig(worker_id="w", lease_seconds=90) - run = _claimed_run() - run.harness_profile_ref = "harness.agent-dev-local@1.0.0" - client.claim.return_value = [run] +def test_profiled_exception_after_lease_loss_skips_close(tmp_path: Path) -> None: + repo, _run, client = _profiled_case(tmp_path) client.heartbeat.side_effect = OpsRunError("lease rejected", status_code=409) def slow_profile(*_args, **_kwargs): @@ -253,6 +287,8 @@ def test_profiled_exception_after_lease_loss_skips_close() -> None: assert result.reason.startswith("lease lost") client.complete.assert_not_called() client.fail.assert_not_called() + with RepositoryTransaction(repo) as retry: + assert retry.locked is True def test_poll_peek() -> None: @@ -263,13 +299,11 @@ def test_poll_peek() -> None: assert rows[0]["approach"] == APPROACH_FI_RESEARCH_BRIEF -def test_profiled_run_uses_glas_and_completes_with_full_result() -> None: - client = MagicMock(spec=ActivityCoreOpsClient) - client.config = OpsRunConfig(worker_id="w", lease_seconds=90) - run = _claimed_run() - run.harness_profile_ref = "harness.agent-dev-local@1.0.0" +def test_profiled_run_uses_glas_and_completes_with_full_result( + tmp_path: Path, +) -> None: + _repo, run, client = _profiled_case(tmp_path) run.approach_hint = "fi-research-brief" - client.claim.return_value = [run] client.complete.return_value = OpsRun( id=run.id, activity_definition_id="def", @@ -313,13 +347,12 @@ def test_profiled_run_uses_glas_and_completes_with_full_result() -> None: execute.assert_not_called() -def test_profile_refusal_fails_terminally_without_legacy_fallback() -> None: - client = MagicMock(spec=ActivityCoreOpsClient) - client.config = OpsRunConfig(worker_id="w", lease_seconds=90) - run = _claimed_run() +def test_profile_refusal_fails_terminally_without_legacy_fallback( + tmp_path: Path, +) -> None: + _repo, run, client = _profiled_case(tmp_path) run.harness_profile_ref = "harness.unknown@9.9.9" run.approach_hint = "fi-research-brief" - client.claim.return_value = [run] client.fail.return_value = OpsRun( id=run.id, activity_definition_id="def", @@ -346,6 +379,49 @@ def test_profile_refusal_fails_terminally_without_legacy_fallback() -> None: execute.assert_not_called() +def test_profiled_signal_cancellation_releases_repository_lock( + tmp_path: Path, +) -> None: + repo, _run, client = _profiled_case(tmp_path) + + def cancel_during_gateway(*_args, **_kwargs): + _cancel_active_run("signal") + return {"ok": True, "evidence": {"outcome": "late-success"}} + + with patch( + "rein_aharness.claim_loop.execute_profiled_run", + side_effect=cancel_during_gateway, + ): + result = process_one(client) + + assert result.ok is False + assert result.reason == "execution cancelled (signal)" + client.complete.assert_not_called() + client.fail.assert_not_called() + with RepositoryTransaction(repo) as retry: + assert retry.locked is True + + +def test_profiled_close_failure_happens_after_repository_lock_release( + tmp_path: Path, +) -> None: + repo, _run, client = _profiled_case(tmp_path) + client.complete.side_effect = OpsRunError("complete transport failed") + gateway_result = {"ok": True, "evidence": {"outcome": "succeeded"}} + + with patch( + "rein_aharness.claim_loop.execute_profiled_run", + return_value=gateway_result, + ): + result = process_one(client) + + assert result.ok is False + assert result.reason.startswith("close ops_run failed:") + assert "repository_transaction" in result.detail + with RepositoryTransaction(repo) as retry: + assert retry.locked is True + + def test_poll_peek_reports_authoritative_profile_route() -> None: client = MagicMock(spec=ActivityCoreOpsClient) run = _claimed_run() diff --git a/tests/test_fi_research_brief.py b/tests/test_fi_research_brief.py new file mode 100644 index 0000000..0db0a8a --- /dev/null +++ b/tests/test_fi_research_brief.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import json +import subprocess +from datetime import date +from pathlib import Path + +from rein_aharness import fi_research_brief + + +def _repo(tmp_path: Path) -> Path: + repo = tmp_path / "freedom-intelligence" + repo.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + (repo / "README.md").write_text("baseline\n", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.email=test@example.invalid", + "-c", + "user.name=test", + "commit", + "-qm", + "baseline", + ], + cwd=repo, + check=True, + ) + return repo + + +def test_fi_brief_never_pushes_even_when_legacy_env_requests_it( + tmp_path: Path, + monkeypatch, +) -> None: + repo = _repo(tmp_path) + monkeypatch.setenv("FI_RESEARCH_BRIEF_PUSH", "1") + original_git = fi_research_brief._git + calls: list[tuple[str, ...]] = [] + + def observed_git(target: Path, *args: str) -> str: + calls.append(args) + return original_git(target, *args) + + monkeypatch.setattr(fi_research_brief, "_git", observed_git) + result = fi_research_brief.run_fi_research_brief( + repo, + day=date(2026, 9, 4), + report_to_hub=False, + complete_fn=lambda _prompt: json.dumps( + { + "headline_deltas": ["No material delta after allowlist review."], + "axis_a": [], + "axis_b": [], + "axis_c": [], + "axis_d": [], + "collection_candidates": [], + "lab_implications": ["Recheck tomorrow."], + } + ), + ) + + assert result.ok is True + assert result.committed is True + assert not any(args and args[0] == "push" for args in calls) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 24c2369..11f97e0 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -3,7 +3,14 @@ from __future__ import annotations import json from pathlib import Path -from rein_aharness.metrics import record_execution, regenerate_summary +import pytest + +from rein_aharness.metrics import ( + external_metrics_dir, + record_execution, + record_external_execution, + regenerate_summary, +) def test_record_execution_writes_jsonl_and_summary(tmp_path: Path) -> None: @@ -49,3 +56,89 @@ def test_summary_aggregates_multiple(tmp_path: Path) -> None: def test_regenerate_summary_empty() -> None: assert regenerate_summary("x", [])["execution_count"] == 0 + + +def test_external_metrics_are_durable_and_projection_ready(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + state_dir = tmp_path / "state" + + path = record_external_execution( + repo, + "coach", + success=True, + committed=True, + head_after="abc123", + metadata={"repository_grant_id": "grant-1"}, + session_id="transaction-1", + state_dir=state_dir, + ) + + directory = external_metrics_dir(repo, "coach", state_dir=state_dir) + assert path == directory / "executions.jsonl" + assert path.stat().st_mode & 0o777 == 0o600 + assert directory.stat().st_mode & 0o777 == 0o700 + record = json.loads(path.read_text(encoding="utf-8").strip()) + assert record["metadata"]["repository_grant_id"] == "grant-1" + assert record["session_id"] == "transaction-1" + projection = json.loads( + (directory / "projection.json").read_text(encoding="utf-8") + ) + assert projection["repository_name"] == "repo" + assert projection["target_relative_directory"] == ".kaizen/metrics/coach" + assert not (repo / ".kaizen").exists() + + +def test_external_metrics_deduplicate_transaction_replay(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + state_dir = tmp_path / "state" + + for _attempt in range(2): + path = record_external_execution( + repo, + "coach", + success=True, + session_id="transaction-1", + state_dir=state_dir, + ) + + assert len(path.read_text(encoding="utf-8").splitlines()) == 1 + summary = json.loads((path.parent / "summary.json").read_text(encoding="utf-8")) + assert summary["execution_count"] == 1 + + +def test_external_metrics_refuse_an_incomplete_ledger(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + state_dir = tmp_path / "state" + directory = external_metrics_dir(repo, "coach", state_dir=state_dir) + directory.mkdir(parents=True) + ledger = directory / "executions.jsonl" + ledger.write_text('{"incomplete":true}', encoding="utf-8") + + with pytest.raises(OSError, match="incomplete record"): + record_external_execution( + repo, + "coach", + success=True, + state_dir=state_dir, + ) + + assert ledger.read_text(encoding="utf-8") == '{"incomplete":true}' + + +def test_external_metrics_refuse_unsafe_projection_agent(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + state_dir = tmp_path / "state" + + with pytest.raises(OSError, match="safe metrics projection"): + record_external_execution( + repo, + "../escape", + success=True, + state_dir=state_dir, + ) + + assert not state_dir.exists() diff --git a/tests/test_repository_grant.py b/tests/test_repository_grant.py index 0bc0ebb..814edba 100644 --- a/tests/test_repository_grant.py +++ b/tests/test_repository_grant.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest +from rein_aharness.metrics import external_metrics_dir from rein_aharness.repository_grant import RepositoryGrant, RepositoryGrantError from rein_aharness.repository_transaction import RepositoryTransaction from rein_aharness.runner import run_task @@ -242,18 +243,129 @@ def test_taskspec_file_wraps_invalid_repository_grant(tmp_path: Path) -> None: TaskSpec.from_file(task_file) -def test_runner_refuses_grant_before_adapter_dispatch_or_mutation(tmp_path: Path) -> None: +def test_runner_accepts_granted_commit_and_keeps_checkout_clean(tmp_path: Path) -> None: + repo = _make_repo(tmp_path) + + class Adapter: + def execute_prompt(self, prompt: str, config: object): + docs = repo / "docs" + docs.mkdir() + (docs / "result.md").write_text("accepted\n", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.email=test@example.invalid", + "-c", + "user.name=test", + "commit", + "-qm", + "accepted result", + ], + cwd=repo, + check=True, + ) + from llm_connect.models import LLMResponse + + return LLMResponse( + content="done", + model="fake", + usage={}, + finish_reason="stop", + ) + + result = run_task( + TaskSpec( + title="bounded change", + description="update docs", + target_repo=repo, + repository_grant=RepositoryGrant.from_mapping(_grant()), + ), + adapter=Adapter(), + report_to_hub=False, + write_metrics=True, + ) + + assert result.ok is True + assert result.committed is True + assert result.transaction is not None + assert result.transaction["acceptance"]["accepted"] is True + assert result.transaction["acceptance"]["changed_paths"] == ["docs/result.md"] + assert result.transaction["repository_grant"]["grant_id"] + assert result.transaction["metrics"] == { + "storage": "external", + "session_id": result.transaction["transaction_id"], + "projection_ready": True, + } + assert _git(repo, "status", "--porcelain=v2") == "" + assert not (repo / ".kaizen" / "metrics").exists() + metric_path = external_metrics_dir(repo, "coach") / "executions.jsonl" + metric_record = json.loads(metric_path.read_text(encoding="utf-8").strip()) + assert metric_record["session_id"] == result.transaction["transaction_id"] + + +def test_runner_rejects_commit_outside_repository_grant(tmp_path: Path) -> None: + repo = _make_repo(tmp_path) + + class Adapter: + def execute_prompt(self, prompt: str, config: object): + (repo / "UNRELATED.md").write_text("not granted\n", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.email=test@example.invalid", + "-c", + "user.name=test", + "commit", + "-qm", + "ungranted result", + ], + cwd=repo, + check=True, + ) + from llm_connect.models import LLMResponse + + return LLMResponse( + content="done", + model="fake", + usage={}, + finish_reason="stop", + ) + + result = run_task( + TaskSpec( + title="bounded change", + description="update docs", + target_repo=repo, + repository_grant=RepositoryGrant.from_mapping(_grant()), + ), + adapter=Adapter(), + report_to_hub=False, + write_metrics=True, + ) + + assert result.ok is False + assert result.committed is True + assert result.reason.startswith("repository acceptance failed: path-not-granted") + assert result.transaction is not None + assert "acceptance" not in result.transaction + + +def test_runner_refuses_grant_when_durable_metrics_are_disabled( + tmp_path: Path, +) -> None: repo = _make_repo(tmp_path) called = False class Adapter: - def execute_prompt(self, prompt: str, config: object) -> None: + def execute_prompt(self, prompt: str, config: object): nonlocal called called = True raise AssertionError("adapter must not be dispatched") - head_before = _git(repo, "rev-parse", "HEAD") - status_before = _git(repo, "status", "--porcelain=v2") result = run_task( TaskSpec( title="bounded change", @@ -267,11 +379,69 @@ def test_runner_refuses_grant_before_adapter_dispatch_or_mutation(tmp_path: Path ) assert result.ok is False - assert result.committed is False - assert result.reason.startswith("refused: repository_grant enforcement") + assert "require durable external metrics" in result.reason assert called is False - assert _git(repo, "rev-parse", "HEAD") == head_before - assert _git(repo, "status", "--porcelain=v2") == status_before + + +def test_runner_fails_granted_result_when_external_metrics_cannot_persist( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _make_repo(tmp_path) + + class Adapter: + def execute_prompt(self, prompt: str, config: object): + docs = repo / "docs" + docs.mkdir() + (docs / "result.md").write_text("accepted\n", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.email=test@example.invalid", + "-c", + "user.name=test", + "commit", + "-qm", + "accepted result", + ], + cwd=repo, + check=True, + ) + from llm_connect.models import LLMResponse + + return LLMResponse( + content="done", + model="fake", + usage={}, + finish_reason="stop", + ) + + def fail_metrics(*args: object, **kwargs: object) -> None: + raise OSError("state volume unavailable") + + monkeypatch.setattr( + "rein_aharness.runner.metrics.record_external_execution", + fail_metrics, + ) + result = run_task( + TaskSpec( + title="bounded change", + description="update docs", + target_repo=repo, + repository_grant=RepositoryGrant.from_mapping(_grant()), + ), + adapter=Adapter(), + report_to_hub=False, + write_metrics=True, + ) + + assert result.ok is False + assert result.reason == "required external metrics persistence failed (OSError)" + assert result.transaction is not None + assert result.transaction["acceptance"]["accepted"] is True + assert _git(repo, "status", "--porcelain=v2") == "" def _git(repo: Path, *args: str) -> str: diff --git a/tests/test_repository_transaction.py b/tests/test_repository_transaction.py index b507d17..81c579a 100644 --- a/tests/test_repository_transaction.py +++ b/tests/test_repository_transaction.py @@ -78,9 +78,15 @@ def test_transaction_captures_clean_branch_and_remote_refs(tmp_path: Path) -> No def test_dirty_baseline_is_refused_without_changing_user_files(tmp_path: Path) -> None: repo = _make_repo(tmp_path) + subprocess.run( + ["git", "update-ref", "refs/remotes/origin/main", "HEAD"], + cwd=repo, + check=True, + ) changed = repo / "README.md" changed.write_text("operator change\n", encoding="utf-8") before = _status(repo) + remote_refs_before = _remote_refs(repo) with pytest.raises(DirtyRepositoryError) as excinfo: with RepositoryTransaction(repo, state_dir=tmp_path / "state"): @@ -90,6 +96,7 @@ def test_dirty_baseline_is_refused_without_changing_user_files(tmp_path: Path) - assert excinfo.value.baseline.dirty_entries == 1 assert changed.read_text(encoding="utf-8") == "operator change\n" assert _status(repo) == before + assert _remote_refs(repo) == remote_refs_before # Refusal released the lock; inspection can explicitly opt into dirty state. with RepositoryTransaction( @@ -469,3 +476,18 @@ def _status(repo: Path) -> str: capture_output=True, text=True, ).stdout + + +def _remote_refs(repo: Path) -> str: + return subprocess.run( + [ + "git", + "for-each-ref", + "--format=%(refname) %(objectname)", + "refs/remotes", + ], + cwd=repo, + check=True, + capture_output=True, + text=True, + ).stdout diff --git a/tests/test_runner.py b/tests/test_runner.py index 3376a3e..2301052 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -8,6 +8,7 @@ import pytest import yaml from rein_aharness.manifest import HARNESS_MAJOR +from rein_aharness.repository_transaction import RepositoryTransaction from rein_aharness.runner import RunResult, run_task from rein_aharness.taskspec import TaskSpec, TaskSpecError @@ -148,6 +149,26 @@ def test_run_task_records_bounded_cancellation_without_session_output(tmp_path) assert result.reason == "execution cancelled (lease-loss)" +def test_run_task_timeout_releases_repository_lock(tmp_path) -> None: + repo = _make_repo(tmp_path) + + class TimingOutAdapter: + def execute_prompt(self, prompt, config): + raise TimeoutError("adapter deadline elapsed") + + result = run_task( + _spec(repo), + adapter=TimingOutAdapter(), + report_to_hub=False, + write_metrics=False, + ) + + assert result.ok is False + assert result.reason == "session failed: adapter deadline elapsed" + with RepositoryTransaction(repo) as retry: + assert retry.locked is True + + def test_run_task_refuses_unknown_tool_profile(tmp_path) -> None: repo = _make_repo(tmp_path) _write_manifest( diff --git a/workplans/REINAH-WP-0003-governed-runtime-integrity.md b/workplans/REINAH-WP-0003-governed-runtime-integrity.md index 39ba5c0..4dbc4de 100644 --- a/workplans/REINAH-WP-0003-governed-runtime-integrity.md +++ b/workplans/REINAH-WP-0003-governed-runtime-integrity.md @@ -158,7 +158,7 @@ and T02–T06 can cite stable decisions rather than infer ownership from code. ```task id: REINAH-WP-0003-T02 -status: progress +status: done priority: high state_hub_task_id: "31da4226-27c0-5e3c-baeb-de01e07cebb1" ``` @@ -258,6 +258,22 @@ affected profiled-result and clean-baseline test setup. Full verification passes `209 passed, 1 skipped`. T02 remains `progress` until the named timeout, signal, and result-close lock-release cases are covered directly. +### Completion — 2026-09-04 + +Added direct integration proof that the canonical repository lock is released +after an adapter timeout, Activity Core lease loss, process-signal cancellation, +and a terminal result-close transport failure. The close-failure test also +confirms transaction evidence survives in the bounded process result. Refusal +coverage now proves both the checkout status and local remote-tracking refs are +unchanged. Profiled tests use disposable repositories, and an autouse fixture +keeps runtime state outside operator-owned paths. The documented full-suite +command passes without an environment override: `212 passed, 1 skipped`. + +Together with the existing cross-process contention, distinct-repository, +dirty/staged/untracked, detached/moved-HEAD, metadata, remote-ref, bounded +evidence, and lease-cancellation cases, all T02 exit criteria are met. T02 is +`done`; T03 is unblocked. + ## Verify accepted commits and reconcile metrics/reporting ```task @@ -371,11 +387,41 @@ terminal evidence, and expired-lease behavior in message `1c13bfb1-9015-4b8a-8b30-ee748c1d8dc5`. No claim-loop or deployment change was requested. +### Local grant activation and metrics atomicity — 2026-09-04 + +Enabled the reviewed v1 `repository_grant` for local `TaskSpec` files now that +T02 supplies the lease-bound transaction. Successful granted runs validate the +exact commit count/ancestry, changed paths, clean post-state, protected Git +metadata, and remote-tracking refs before reporting success. Their bounded +transaction evidence includes grant and acceptance identities without raw +grant paths, prompts, or provider output. Adversarial runner coverage rejects +out-of-grant commits. + +Chose the durable-external metrics branch of ADR-002's T03 decision. Granted +runs cannot use `--no-metrics`; they atomically append a kaizen-compatible +record under private `REIN_AHARNESS_STATE_DIR` storage and regenerate a summary +plus projection descriptor without touching the accepted checkout. Ledger +corruption or persistence failure is terminal for the granted result. A future +kaizen-owned, separately granted projection may commit those files to the +instance; the rein does not invalidate its own accepted post-state. Legacy +grant-absent runs retain repository-local metrics during migration. + +Full verification passes: `222 passed, 1 skipped`. T03 remains `wait`, not +`done`, on two verified Activity Core contract gaps at current main `b72fdb5`: +the queue schema carries no authoritative repository grant, and complete/fail +accept only an actively leased claimed row while normalized result storage +drops transaction/grant/acceptance identity. A response-lost retry therefore +returns an ambiguous 409 and cannot be safely treated as delivered. Requested +a separately typed grant field plus identity-preserving, conflict-detecting +terminal reconciliation in State Hub message +`6c4189a5-5971-4eb9-a7bd-62fe368f9a02`. Live outbox activation stays +fail-closed until that exact upstream contract is available. + ## Remove tenant logic from the shared runtime ```task id: REINAH-WP-0003-T04 -status: todo +status: wait priority: high state_hub_task_id: "f6c7f80c-3807-54ac-b44a-ef582433b927" ``` @@ -407,11 +453,36 @@ and owner files. It identifies inputs, outputs, credentials, completion events, commit/push behavior, retry posture, rollback material, and migration gates. This is read-only preparation; T04 remains `wait` until T01 accepts ADR-002. +### Dated compatibility guard — 2026-09-04 + +Profile-absent `ops_run` dispatch now requires an explicit, non-expired ISO +date in `AGENT_HARNESS_LEGACY_APPROACHES_UNTIL`. Missing, malformed, or expired +values refuse terminally before target resolution or tenant executor dispatch. +Authoritative `harness_profile_ref` rows bypass the legacy registry and this +flag entirely. The deployment example currently declares `2026-12-31`, making +the remaining four tenant migrations visible and time-bounded rather than a +silent permanent default. Focused tests cover boundary-day, expired, malformed, +missing, and pre-dispatch refusal behavior. + +Removed the FI compatibility executor's `git push origin HEAD` path entirely. +`FI_RESEARCH_BRIEF_PUSH` no longer widens behavior even when set; the executor +creates a local commit only. A regression test observes every Git invocation +and proves no push occurs. Any publication now requires a separate owner, +grant, and evidence contract. + +T04 remains `wait`: the FI and Binky definition owners still need to move +their four scheduled behaviors to approved profiles/tenant-owned capabilities +before the compatibility flag and tenant modules can be removed. Requests: +Freedom Intelligence `d0b45acb-0002-405b-9620-cc44568170fb`, Binky +`c941f47b-d100-4df3-b4f4-feb1e187d2d8`, kaizen-agentic +`29a010f4-7a5c-42cd-a13e-3a13326f75ff`, and Glas +`e91f3f67-2d5d-4ad2-923c-431ebafed77e`. + ## Align deployment, recovery, and conformance with the supported runtime ```task id: REINAH-WP-0003-T05 -status: todo +status: progress priority: high state_hub_task_id: "c4a3f08f-2874-5672-9f29-aecddd697d90" ``` @@ -441,6 +512,20 @@ runtime boundary is unavailable; recovery drills leave no held repo lock, claimed row, orphan sandbox, or lost required evidence; and CI/release gates exercise the same contract versions as production. +### Supported topology declaration — 2026-09-04 + +Selected the existing railiance01 user systemd service as the authoritative +production topology. It is the only runtime that owns real checkouts, private +state, worker credentials, heartbeats, and signal shutdown. The Kubernetes +Deployment is now explicitly labeled `packaging-smoke`; its source comments and +deployment guide state that `sleep infinity` is neither ready to claim nor a +failover worker and must never receive the Activity Core worker credential. + +T05 remains `progress`. Reproducible pinned sibling packages, startup readiness, +recovery/outbox controls, and non-skipped cross-package CI still need +implementation; the currently catalogued local Glas profiles are also +operationally `blocked` on `GLAS-IN-0002`. + ## Re-prove one governed profiled run and close residuals ```task