ACTIVITY-WP-0043-T06: single-repo owner-return pickup caller in the worker
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 52s

Precondition 3 of the activation plan. New bounded operation
owner_return_pickup (state-hub-owner-return/pickup), executed by the existing
execute_bounded_operation activity and operator-triggered via the ops console
trigger with confirm_side_effect; no schedule, definition stays disabled.

The caller reads an operator-supplied C-25 checker artefact pinned by sha256
(the checker needs a local checkout and cannot run in the worker), requires a
full-SHA source_revision on the report and every C-25 finding, refuses failed
or incomplete collection, binds build_current_tasks to that SHA (Forge HEAD
verified), runs the State Hub planner vendored byte-identically from
state-hub 150cb0b, and submits only reviewable candidates under
ACTIVITY_CORE_OWNER_RETURN_PICKUP_ENABLED. Evidence is IDs/counts only.

build_current_tasks now refuses a report without source_revision and names
source_revision_reason when it is null.

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:40:29 +02:00
parent 98fd82218e
commit f89ebe9c82
12 changed files with 999 additions and 3 deletions

View file

@ -13,6 +13,8 @@ STATE-WP-0094-T03. Nothing below has been applied.
| Repository grant | v1, `allowed_paths: [workplans]`, commits 1..1, `publish: false` |
| Adapter | `activity_core.owner_return_pickup.submit_owner_returns(session, candidates, collection_complete=...)` |
| Gate | env `ACTIVITY_CORE_OWNER_RETURN_PICKUP_ENABLED` (unset = refuse before any write) |
| Caller | `activity_core.owner_return_runner.run_owner_return_pickup`, bounded operation `owner_return_pickup` (source `state-hub-owner-return` / `pickup`), ACTIVITY-WP-0043-T06 |
| Planner | State Hub `scripts/owner_return_handoff.py`, vendored byte-identically from state-hub `150cb0b` as `activity_core/vendored/state_hub_owner_return_handoff.py` (drift test when state-hub is checked out) |
## Preconditions (all required before activation)
@ -25,8 +27,77 @@ STATE-WP-0094-T03. Nothing below has been applied.
(ACTIVITY-WP-0043-T05); the C-25 caller must pass the scanned commit SHA.
3. The caller runs inside the existing supported runner (Custodian dependency
monitor / established Activity Core worker); no new schedule is introduced.
**Built at source level (ACTIVITY-WP-0043-T06)**, see "Caller wiring" below;
not deployed.
4. Founder review of this plan and of the definition/grant above.
## Caller wiring (precondition 3)
The caller is the code-registered bounded operation `owner_return_pickup`
executed by the existing `RunActivityWorkflow` → `execute_bounded_operation`
activity on `orchestrator-tq` (single Temporal attempt, 300 s). It is exposed
like every other operator-run bounded operation: the context source sits on a
definition and the operator fires it with
`POST /ops/automations/{id}/trigger` + `confirm_side_effect: true` (the
`owner_return_pickup` side-effect marker forces the confirmation). Manual
trigger does not require `enabled: true`; the definition stays disabled, so
there is no schedule.
Context-source stanza (exactly one repository; params validated at parse):
```yaml
context_sources:
- type: state-hub-owner-return
query: pickup
operation: owner_return_pickup
required: true
bind_to: context.owner_return
params:
target_repo: <one repo slug>
report_file: activity-core://owner-return/<repo>-c25.json # worker-mounted
report_sha256: <sha256 of that artefact>
submit: false # true only under the activation flag
# optional: expected_source_revision: <40-hex>
# optional: predecessor_repos: {"STATE-WP-": state-hub}
```
How the C-25 report and SHA are obtained: **operator-supplied artefact.** The
checker reads workplans from the repository's registered `local_path` and gets
`source_revision` from `git` in that tree; the worker has no checkouts
(ADR-002), no checker image, and Repo Manager's C-rule engine has no C-25. So
the operator runs `consistency_check.py --json` for the repository (clean
`workplans/` so `source_revision` is a full SHA), places the JSON at the mounted
path (a `--all` list is accepted; exactly one object must match `target_repo`), and pins its sha256 in the reviewed params. The caller then refuses
unless: the digest matches; `repo_slug` equals `target_repo`;
`source_revision` (report and every C-25 finding) is the same full 40-hex SHA
(`null` refuses with `source_revision_reason`); `result != error`; no C-25
"reply collection ... is incomplete" finding exists; and `build_current_tasks`
confirms via Forge that the SHA is still the default-branch HEAD. Planner
candidates must echo the same repo/revision. Only `reviewable` candidates reach
`submit_owner_returns`; existing ops runs are not fed to the planner, so
deduplication stays server-side and a re-run reports `duplicate` receipts.
Evidence (bound to `context.owner_return`, IDs/counts only): planner input
(report result, revision, finding/message counts, task record IDs,
counterparts, completeness, `current_tasks` schema/revision/count, HEAD
verified), planner output (dispositions, stop reasons, candidate IDs) and
receipts (`triggering_event_id`, `idempotency_key`, `status`, `ops_run_id`,
`state`). Tests: `tests/test_owner_return_runner.py`.
Remaining gaps before the first trial:
- No in-cluster report producer: the artefact is produced on the workstation
and mounted by the operator. A Forge-content C-25 (Repo Manager C-rule engine
or a checker mode reading `ref=<sha>`) would remove that step.
- Completeness is inferred from the absence of the C-25 "incomplete" WARN
text; the checker JSON has no structured per-counterpart reply count / last
page field, which the evidence section below asks for.
- Forge reads are unauthenticated (public repositories only); a private target
needs a reviewed read-credential route.
- The definition does not yet carry the stanza: adding it (with the chosen
repository and artefact digest) and mounting the artefact are part of the
founder-reviewed activation, as is the flag below.
## Exactly what would be enabled
1. Set `ACTIVITY_CORE_OWNER_RETURN_PICKUP_ENABLED=true` on the single process

View file

@ -258,6 +258,13 @@ async def execute_bounded_operation(payload: dict[str, Any]) -> dict[str, Any]:
from activity_core.context_resolvers.repo_manager import repo_manager_freshness
result = repo_manager_freshness(params)
elif spec.operation_id == "owner_return_pickup":
from activity_core.owner_return_runner import run_owner_return_pickup
result = await run_owner_return_pickup(
params,
session_factory=_get_session_factory() if params.get("submit") is True else None,
)
else:
raise ApplicationError(
f"bounded operation {spec.operation_id!r} uses a dedicated activity",

View file

@ -73,6 +73,21 @@ _SPECS = (
credential_route="repo-manager-freshness-runner",
evidence_mode="repo-manager-freshness-receipt",
),
# ACTIVITY-WP-0043-T06: single-repo owner-return pickup (C-25 artefact ->
# Forge-bound eligibility -> vendored State Hub planner -> adapter).
# Operator-triggered only; submission additionally gated by
# ACTIVITY_CORE_OWNER_RETURN_PICKUP_ENABLED.
BoundedOperationSpec(
operation_id="owner_return_pickup",
source_type="state-hub-owner-return",
query="pickup",
resolve_before_execute=False,
max_timeout_seconds=300,
temporal_max_attempts=1,
idempotency="single Temporal attempt; ops_runs.idempotency_key unique (ON CONFLICT DO NOTHING)",
credential_route="none (public Forge reads; State Hub decision reads)",
evidence_mode="owner-return-pickup-receipts",
),
)
BOUNDED_OPERATION_REGISTRY = {spec.operation_id: spec for spec in _SPECS}
@ -177,6 +192,12 @@ def normalize_operation_result(operation_id: str, raw: Any) -> dict[str, Any]:
"outcomes": outcomes,
"exit_code": exit_code,
}
if operation_id == "owner_return_pickup":
# The runner already projects to counts/identities; keep only its keys.
keys = ("kind", "mode", "target_repo", "source_revision", "report_sha256",
"observed_on", "planner_input", "planner_output", "receipts",
"receipt_counts", "submitted_count")
return {key: result.get(key) for key in keys} | {"kind": operation_id}
raise ValueError(f"no result projection for bounded operation {operation_id!r}")
@ -235,6 +256,10 @@ def validate_bounded_operations(
_validate_backup(params)
elif spec.operation_id == "repo_manager_freshness":
_validate_repo_manager_freshness(params)
elif spec.operation_id == "owner_return_pickup":
from activity_core.owner_return_runner import validate_params
validate_params(params)
def _validate_timeout(spec: BoundedOperationSpec, params: dict[str, Any]) -> None:

View file

@ -41,6 +41,7 @@ SIDE_EFFECT_MARKERS = (
"forgejo_package_prune",
"cnpg_option_a_backup",
"repo_manager_freshness",
"owner_return_pickup",
"apply: true",
'"apply": true',
"'apply': true",

View file

@ -328,6 +328,16 @@ def build_current_tasks(
if report is not None:
if report.get("repo_slug") != target_repo:
raise EligibilityError("C-25 report is for a different repository")
# State Hub 150cb0b: every checker report carries source_revision (a
# full SHA, or null + source_revision_reason). A report without the
# key predates the contract and is unbound -- refuse it.
if "source_revision" not in report:
raise EligibilityError("C-25 report has no source_revision (unbound report)")
if report["source_revision"] is None:
raise EligibilityError(
"C-25 report has no source revision: "
f"{report.get('source_revision_reason') or 'reason not given'}"
)
for key in ("source_revision", "commit"):
if key in report and report[key] != scan_revision:
raise EligibilityError("C-25 report revision does not match scan_revision")

View file

@ -0,0 +1,324 @@
"""Single-repository owner-return pickup caller (ACTIVITY-WP-0043-T06).
Precondition 3 of ``docs/owner-return-pickup-activation-plan.md``: the C-25 →
planner → adapter chain runs inside the existing Activity Core worker as the
code-registered bounded operation ``owner_return_pickup`` (ACT-ADR-007),
operator-triggered only (``POST /ops/automations/{id}/trigger`` with
``confirm_side_effect``). No schedule exists; the definition stays disabled.
Report acquisition (why an operator-supplied artefact)
------------------------------------------------------
State Hub's C-25 checker (``scripts/consistency_check.py``) reads workplan files
from the repository's registered ``local_path`` and derives ``source_revision``
from ``git`` in that working tree. The worker has no such checkouts (ADR-002)
and no checker image, and Repo Manager's C-rule engine does not implement C-25
yet. So the worker cannot produce the report itself. It accepts a checker JSON
artefact supplied by the operator at a worker-mounted path, pinned by sha256 in
the reviewed definition params, and binds it to its revision:
1. ``report.repo_slug`` must equal ``target_repo``; ``report.source_revision``
must be a full 40-hex SHA (``null`` + ``source_revision_reason`` refuses);
every C-25 finding must carry the same ``repo_slug``/``source_revision``.
2. ``build_current_tasks`` re-reads the workplans from immutable Forge content
at that SHA and refuses unless it is still the default-branch HEAD, so a
stale or forged revision cannot be planned from.
3. Collection must be complete: a failed checker run or any C-25
"reply collection ... is incomplete" finding refuses.
The State Hub planner is vendored byte-identically
(``activity_core.vendored.state_hub_owner_return_handoff``). Existing ops-run
rows are deliberately *not* passed to it: deduplication stays server-side in
``submit_owner_returns`` (unique idempotency key), so a re-run reports
``duplicate`` receipts rather than trusting a snapshot.
Evidence carries counts and identities only (event/message/ops-run IDs, task
record IDs, dispositions) -- never message bodies or finding text.
"""
from __future__ import annotations
import hashlib
import json
import re
from collections import Counter
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Callable, Mapping
import httpx
from activity_core import owner_return_pickup as orp
from activity_core.owner_return_eligibility import (
DEFAULT_FORGE_API,
build_current_tasks,
forge_head_status_resolver,
state_hub_decision_status,
)
from activity_core.vendored import state_hub_owner_return_handoff as planner
OPERATION_ID = "owner_return_pickup"
MAX_REPORT_BYTES = 5 * 1024 * 1024
MAX_EVIDENCE_ROWS = 200
_SHA = re.compile(r"^[0-9a-f]{40}$")
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
_REPO = re.compile(r"^[a-z0-9][a-z0-9._-]{0,127}$")
_PREFIX = re.compile(r"^[A-Z][A-Z0-9]*-(?:WP-)?$")
_INCOMPLETE = re.compile(r"reply collection from \S+ is incomplete")
class OwnerReturnPickupRefused(ValueError):
"""A precondition failed; nothing was submitted."""
def validate_params(params: Mapping[str, Any]) -> None:
"""Admission checks shared by definition parsing and execution."""
repo = params.get("target_repo")
if not isinstance(repo, str) or not _REPO.fullmatch(repo):
raise ValueError("bounded operation owner_return_pickup requires one target_repo")
if not isinstance(params.get("report_file"), str) or not params["report_file"].strip():
raise ValueError("bounded operation owner_return_pickup requires report_file")
digest = params.get("report_sha256")
if not isinstance(digest, str) or not _SHA256.fullmatch(digest):
raise ValueError("bounded operation owner_return_pickup requires report_sha256 (64 hex)")
if not isinstance(params.get("submit"), bool):
raise ValueError("bounded operation owner_return_pickup must declare boolean submit explicitly")
expected = params.get("expected_source_revision")
if expected is not None and (not isinstance(expected, str) or not _SHA.fullmatch(expected)):
raise ValueError("expected_source_revision must be a full 40-hex SHA")
mapping = params.get("predecessor_repos", {})
if not isinstance(mapping, dict) or any(
not isinstance(k, str) or not _PREFIX.fullmatch(k)
or not isinstance(v, str) or not _REPO.fullmatch(v)
for k, v in mapping.items()
):
raise ValueError("predecessor_repos must map ID prefixes (e.g. 'STATE-WP-') to repo slugs")
for forbidden in ("target_repos", "repos", "command", "argv", "ops_runs"):
if forbidden in params:
raise ValueError(f"bounded operation owner_return_pickup must not declare {forbidden}")
def load_report(path: Path, expected_sha256: str) -> Any:
try:
raw = path.read_bytes()
except OSError as exc:
raise OwnerReturnPickupRefused(f"report artefact unreadable: {path}") from exc
if len(raw) > MAX_REPORT_BYTES:
raise OwnerReturnPickupRefused("report artefact exceeds size bound")
if hashlib.sha256(raw).hexdigest() != expected_sha256:
raise OwnerReturnPickupRefused("report artefact does not match pinned report_sha256")
try:
report = json.loads(raw)
except json.JSONDecodeError as exc:
raise OwnerReturnPickupRefused("report artefact is not JSON") from exc
if not isinstance(report, (dict, list)):
raise OwnerReturnPickupRefused("report artefact must be a checker JSON object or list")
return report
def select_repo_report(artefact: Any, target_repo: str) -> dict[str, Any]:
"""Pick the single repository object (``--repo`` object or ``--all`` list)."""
if isinstance(artefact, dict):
return artefact
matches = [r for r in artefact if isinstance(r, dict) and r.get("repo_slug") == target_repo]
if len(matches) != 1:
raise OwnerReturnPickupRefused(
f"report artefact holds {len(matches)} objects for {target_repo}; exactly one required"
)
return matches[0]
def bind_report(
report: Mapping[str, Any], target_repo: str, expected_revision: str | None = None
) -> dict[str, Any]:
"""Return the planner-input summary, refusing unbound or incomplete reports."""
if report.get("repo_slug") != target_repo:
raise OwnerReturnPickupRefused("report is for a different repository")
revision = report.get("source_revision")
if not isinstance(revision, str) or not _SHA.fullmatch(revision):
reason = report.get("source_revision_reason")
raise OwnerReturnPickupRefused(
"report source_revision is not a full 40-hex SHA"
+ (f" (reason: {reason})" if isinstance(reason, str) else "")
)
if expected_revision is not None and revision != expected_revision:
raise OwnerReturnPickupRefused("report source_revision differs from expected_source_revision")
if report.get("result") == "error":
raise OwnerReturnPickupRefused("checker collection failed (result=error)")
issues = report.get("issues")
if not isinstance(issues, list):
raise OwnerReturnPickupRefused("report issues must be a list")
findings = 0
owner_returns: list[Mapping[str, Any]] = []
incomplete: list[str] = []
for issue in issues:
if not isinstance(issue, Mapping) or issue.get("check_id") != "C-25":
continue
findings += 1
if issue.get("repo_slug") != target_repo or issue.get("source_revision") != revision:
raise OwnerReturnPickupRefused("C-25 finding is not bound to the report repository/revision")
if "owner_return" in issue:
owner_returns.append(issue)
elif _INCOMPLETE.search(str(issue.get("message") or "")):
incomplete.append(str(issue.get("file_value") or ""))
if incomplete:
raise OwnerReturnPickupRefused(
f"owner-return reply collection incomplete for {len(incomplete)} counterpart(s)"
)
tasks: list[str] = []
messages = 0
counterparts: set[str] = set()
for issue in owner_returns:
context = issue["owner_return"]
if isinstance(context, Mapping):
task = context.get("task_record_id")
if isinstance(task, str) and task not in tasks:
tasks.append(task)
ids = context.get("message_ids")
messages += len(ids) if isinstance(ids, list) else 0
marker = str(issue.get("file_value") or "")
if marker.startswith("message-from:"):
counterparts.add(marker.removeprefix("message-from:"))
return {
"report_result": report.get("result"),
"source_revision": revision,
"c25_finding_count": findings,
"owner_return_finding_count": len(owner_returns),
"task_record_ids": tasks,
"message_id_count": messages,
"counterparts": sorted(counterparts),
"collection_complete": True,
"incomplete_counterpart_count": 0,
}
def _candidate_evidence(candidates: list[dict[str, Any]]) -> dict[str, Any]:
return {
"candidate_count": len(candidates),
"dispositions": dict(Counter(c["disposition"] for c in candidates)),
"stop_reasons": dict(Counter(c["stop_reason"] for c in candidates if c["stop_reason"])),
"candidates": [
{k: c.get(k) for k in ("triggering_event_id", "task_record_id", "message_id",
"disposition", "stop_reason")}
for c in candidates[:MAX_EVIDENCE_ROWS]
],
}
def _receipt_evidence(receipts: list[dict[str, Any]]) -> list[dict[str, Any]]:
keys = ("triggering_event_id", "idempotency_key", "task_record_id", "message_id",
"status", "ops_run_id", "state")
return [{k: r.get(k) for k in keys} for r in receipts[:MAX_EVIDENCE_ROWS]]
async def run_owner_return_pickup(
params: Mapping[str, Any],
*,
session_factory: Callable[[], Any] | None = None,
client: httpx.Client | None = None,
state_hub_url: str | None = None,
forge_api: str = DEFAULT_FORGE_API,
today: date | None = None,
resolve_path: Callable[[str], Path] | None = None,
) -> dict[str, Any]:
"""Plan (and, when ``submit`` and the gate allow, submit) one repository."""
validate_params(params)
target_repo: str = params["target_repo"]
submit: bool = params["submit"]
# Fail fast before any read when submission is requested but not gated on.
if submit and not orp.pickup_enabled():
raise OwnerReturnPickupRefused(f"{orp.ENABLE_ENV} is not enabled")
if submit and session_factory is None:
raise OwnerReturnPickupRefused("submission requires a database session factory")
if resolve_path is None:
from activity_core.runtime_paths import resolve_runtime_path as resolve_path
report = select_repo_report(
load_report(resolve_path(params["report_file"]), params["report_sha256"]), target_repo
)
planner_input = bind_report(report, target_repo, params.get("expected_source_revision"))
revision = planner_input["source_revision"]
observed_on = today or datetime.now(tz=timezone.utc).date()
evidence: dict[str, Any] = {
"kind": OPERATION_ID,
"mode": "submit" if submit else "plan-only",
"target_repo": target_repo,
"source_revision": revision,
"report_sha256": params["report_sha256"],
"observed_on": observed_on.isoformat(),
"planner_input": planner_input,
"planner_output": _candidate_evidence([]),
"receipts": [],
"receipt_counts": {"inserted": 0, "duplicate": 0, "skipped": 0},
"submitted_count": 0,
}
if not planner_input["task_record_ids"]:
return evidence
own_client = client is None
http = client or httpx.Client(timeout=30.0)
try:
hub = (state_hub_url or _state_hub_url()).rstrip("/")
mapping = params.get("predecessor_repos") or {}
built = build_current_tasks(
target_repo=target_repo,
scan_revision=revision,
task_record_ids=planner_input["task_record_ids"],
client=http,
decision_status=state_hub_decision_status(http, hub),
external_status=forge_head_status_resolver(http, mapping, forge_api=forge_api)
if mapping else None,
require_current_head=True,
report=report,
forge_api=forge_api,
)
except (ValueError, httpx.HTTPError) as exc:
raise OwnerReturnPickupRefused(f"eligibility refused: {exc}") from exc
finally:
if own_client:
http.close()
if built.get("source_revision") != revision:
raise OwnerReturnPickupRefused("current_tasks are bound to a different revision")
planner_input["current_tasks_schema"] = built.get("schema")
planner_input["current_tasks_source_revision"] = built["source_revision"]
planner_input["current_tasks_count"] = len(built["current_tasks"])
planner_input["head_verified"] = True
try:
candidates = planner.plan_owner_returns(
report,
activity_definition_id=orp.ACTIVITY_DEFINITION_ID,
current_tasks=built["current_tasks"],
observed_on=observed_on,
)
except ValueError as exc:
raise OwnerReturnPickupRefused(f"planner refused: {exc}") from exc
for candidate in candidates:
if candidate.get("target_repo") != target_repo or candidate.get(
"source_revision", revision
) != revision:
raise OwnerReturnPickupRefused("planner candidate is bound to a different repository/revision")
evidence["planner_output"] = _candidate_evidence(candidates)
reviewable = [c for c in candidates if c["disposition"] == "reviewable"]
if not submit or not reviewable:
return evidence
async with session_factory() as session:
async with session.begin():
receipts = await orp.submit_owner_returns(
session, reviewable, collection_complete=planner_input["collection_complete"]
)
evidence["submitted_count"] = len(reviewable)
evidence["receipts"] = _receipt_evidence(receipts)
counts = Counter(r["status"] for r in receipts)
evidence["receipt_counts"] = {k: counts.get(k, 0) for k in ("inserted", "duplicate", "skipped")}
return evidence
def _state_hub_url() -> str:
import os
from activity_core.automation_status import DEFAULT_STATE_HUB_URL
return os.environ.get("STATE_HUB_URL", DEFAULT_STATE_HUB_URL)

View file

@ -0,0 +1,5 @@
"""Verbatim copies of small upstream modules the worker cannot import at runtime.
Each file is a byte-identical copy; drift is detected by tests when the
upstream checkout is present. Never edit a vendored file locally -- re-copy it.
"""

View file

@ -0,0 +1,159 @@
"""Offline C-25 → existing Activity Core ops-run handoff planning.
No network, scheduling, dispatch or task mutation. An admitted ActivityDefinition
and owner harness are prerequisites for a caller to use these candidate IDs.
"""
from __future__ import annotations
import hashlib
import json
import re
import uuid
from collections.abc import Mapping, Sequence
from datetime import date
_REPO = re.compile(r"^[a-z0-9][a-z0-9-]*$")
_TASK = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
_STATES = {"open", "claimed", "succeeded", "failed", "expired"}
_SHA = re.compile(r"^[0-9a-f]{40}$")
def _uuid(value: object) -> str:
try:
return str(uuid.UUID(str(value)))
except (ValueError, TypeError, AttributeError):
raise ValueError("invalid ops-run/message UUID") from None
def _eligibility_stop(current: Mapping, *, repo: str, task: str,
marker: object, observed_on: date) -> str | None:
required = {"target_repo", "task_record_id", "status", "workplan_status",
"workplan_flavor", "needs_human", "blocked_on", "depends_on",
"predecessor_statuses", "wait_until", "decision_id", "decision_status"}
if not isinstance(current, Mapping) or not required <= current.keys():
raise ValueError("missing authoritative task eligibility fields")
if current["target_repo"] != repo or current["task_record_id"] != task:
return "stale_task_identity"
if type(current["needs_human"]) is not bool:
raise ValueError("needs_human must be an authoritative boolean")
if current["needs_human"]:
return "human_gate"
if current["decision_id"] is not None and current["decision_status"] != "resolved":
return "decision_not_resolved_or_unknown"
if current["workplan_flavor"] == "residual":
return "unpromoted_residual"
if current["workplan_status"] not in {"proposed", "ready", "active", "blocked", "backlog"}:
return "workplan_not_open"
if current["status"] != "wait":
return "task_not_waiting"
if not isinstance(marker, str) or not re.fullmatch(r"message-from:[a-z0-9][a-z0-9-]*", marker):
raise ValueError("missing owner-return marker in finding")
if current["blocked_on"] != marker:
return "stale_owner_return_marker"
dependencies = current["depends_on"]
statuses = current["predecessor_statuses"]
if not isinstance(dependencies, list) or not isinstance(statuses, Mapping):
raise ValueError("invalid authoritative predecessor eligibility")
if any(not isinstance(item, str) or not item for item in dependencies):
raise ValueError("invalid predecessor identity")
if any(statuses.get(item) not in {"done", "cancel", "finished", "archived"}
for item in dependencies):
return "predecessor_not_terminal_or_unknown"
wait_until = current["wait_until"]
if wait_until is not None:
if type(wait_until) is date:
until = wait_until
elif isinstance(wait_until, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", wait_until):
until = date.fromisoformat(wait_until)
else:
raise ValueError("invalid authoritative wait_until date")
if until > observed_on:
return "future_wait_window"
return None
def plan_owner_returns(
report: Mapping, *, activity_definition_id: str, current_tasks: Mapping,
observed_on: date, ops_runs: Sequence[Mapping] = ()
) -> list[dict]:
"""Deduplicate by repository, task and message against correlated ops runs.
Existing open/claimed rows are pending, succeeded rows are completed, and
failed/expired rows require owner review. None is dispatched again here.
A succeeded automation receipt does not itself close a workplan task.
Callers must build current_tasks from fresh authoritative source, resolving
every predecessor; checker findings alone contain insufficient safety data.
"""
if not isinstance(current_tasks, Mapping) or type(observed_on) is not date:
raise ValueError("authoritative eligibility and observation date required")
definition = _uuid(activity_definition_id)
repo = report.get("repo_slug")
if not isinstance(repo, str) or not _REPO.fullmatch(repo):
raise ValueError("invalid repository identity")
if report.get("result") == "error":
raise ValueError("cannot plan from failed checker collection")
# Passed through unchanged so candidates stay bound to the scanned commit;
# None (unknown/dirty scan) travels as None and the eligibility builder
# refuses it. Never substituted or guessed here.
revision = report.get("source_revision")
if revision is not None and (not isinstance(revision, str) or not _SHA.fullmatch(revision)):
raise ValueError("source_revision must be a full 40-hex commit SHA or null")
issues = report.get("issues")
if not isinstance(issues, list):
raise ValueError("checker issues must be a list")
candidates = {}
for issue in issues:
if not isinstance(issue, Mapping):
raise ValueError("invalid checker issue")
if issue.get("check_id") != "C-25" or "owner_return" not in issue:
continue
if issue.get("repo_slug", repo) != repo or issue.get("source_revision", revision) != revision:
raise ValueError("C-25 finding is bound to a different repository or revision")
context = issue["owner_return"]
if not isinstance(context, Mapping):
raise ValueError("invalid owner-return context")
task = context.get("task_record_id")
messages = context.get("message_ids")
if not isinstance(task, str) or not _TASK.fullmatch(task):
raise ValueError("missing task identity")
if not isinstance(messages, list) or not messages:
raise ValueError("missing message identities")
if task not in current_tasks:
raise ValueError("missing authoritative task eligibility")
stop = _eligibility_stop(current_tasks[task], repo=repo, task=task,
marker=issue.get("file_value"), observed_on=observed_on)
for raw_message in messages:
message = _uuid(raw_message)
encoded = json.dumps([repo, task, message], separators=(",", ":")).encode()
event = "owner-return:" + hashlib.sha256(encoded).hexdigest()
candidates[event] = {
"target_repo": repo, "source_revision": revision,
"task_record_id": task, "message_id": message,
"activity_definition_id": definition, "triggering_event_id": event,
"disposition": "stopped" if stop else "reviewable",
"stop_reason": stop, "ops_run_id": None,
}
matched = set()
for row in ops_runs:
if not isinstance(row, Mapping):
raise ValueError("invalid ops-run receipt")
event = row.get("triggering_event_id")
if event not in candidates or row.get("target_repo") != repo:
continue
if _uuid(row.get("activity_definition_id")) != definition:
continue
if event in matched:
raise ValueError("multiple correlated ops runs require owner reconciliation")
matched.add(event)
state = row.get("state")
if state not in _STATES:
raise ValueError("invalid correlated ops-run state")
candidate = candidates[event]
candidate["ops_run_id"] = _uuid(row.get("id"))
if candidate["disposition"] == "stopped":
continue
candidate["disposition"] = {
"open": "pending", "claimed": "pending", "succeeded": "completed",
"failed": "review_required", "expired": "review_required",
}[state]
return [candidates[key] for key in sorted(candidates)]

View file

@ -21,6 +21,8 @@ def test_registry_names_only_the_accepted_operations() -> None:
"cnpg_option_a_backup",
# ACTIVITY-WP-0044 / RMGR-WP-0020-T03 (draft definition, disabled)
"repo_manager_freshness",
# ACTIVITY-WP-0043-T06 (operator-triggered single-repo pickup)
"owner_return_pickup",
}

View file

@ -196,6 +196,18 @@ def test_refuses_unknown_commit():
_build(transport=_transport(_files(), sha=OLD, head=SHA))
def test_refuses_report_without_source_revision_key():
with pytest.raises(ore.EligibilityError, match="unbound report"):
_build(report={"repo_slug": REPO, "issues": []})
def test_refuses_null_source_revision_with_reason():
report = {"repo_slug": REPO, "source_revision": None,
"source_revision_reason": "dirty_workplans", "issues": []}
with pytest.raises(ore.EligibilityError, match="dirty_workplans"):
_build(report=report)
def test_refuses_report_revision_mismatch():
report = {"repo_slug": REPO, "source_revision": OLD, "issues": []}
with pytest.raises(ore.EligibilityError, match="revision"):
@ -288,7 +300,7 @@ def test_output_is_accepted_by_state_hub_planner():
planner = importlib.util.module_from_spec(spec)
spec.loader.exec_module(planner)
msg = "11111111-2222-3333-4444-555555555555"
report = {"repo_slug": REPO, "result": "warn", "issues": [{
report = {"repo_slug": REPO, "source_revision": SHA, "result": "warn", "issues": [{
"check_id": "C-25", "file_value": "message-from:state-hub",
"owner_return": {"task_record_id": "WARDEN-WP-0041-T03", "message_ids": [msg]},
}]}

View file

@ -0,0 +1,354 @@
"""Tests for the single-repo owner-return pickup caller (ACTIVITY-WP-0043-T06).
Forge/State Hub HTTP is served by ``httpx.MockTransport``; the ops-run insert
is replaced by an in-memory unique-key store standing in for the DB.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import uuid
from contextlib import asynccontextmanager
from datetime import date
from pathlib import Path
from unittest.mock import MagicMock
from urllib.parse import unquote
import httpx
import pytest
from activity_core import owner_return_pickup as orp
from activity_core import owner_return_runner as runner
from activity_core.activities import execute_bounded_operation
from activity_core.bounded_operations import (
BOUNDED_OPERATION_REGISTRY,
normalize_operation_result,
validate_bounded_operations,
)
from activity_core.owner_return_eligibility import DEFAULT_FORGE_API
REPO = "ops-warden"
SHA = "a" * 40
TASK = "WARDEN-WP-0041-T03"
MSG1 = "11111111-2222-3333-4444-555555555555"
MSG2 = "66666666-7777-8888-9999-000000000000"
BASE = f"{DEFAULT_FORGE_API}/repos/coulomb/{REPO}"
WP = f"""---
id: WARDEN-WP-0041
status: active
---
```task
id: {TASK}
status: wait
needs_human: false
blocked_on: "message-from:state-hub"
```
```task
id: WARDEN-WP-0041-T04
status: wait
needs_human: true
blocked_on: "message-from:state-hub"
```
"""
FILES = {"workplans/WARDEN-WP-0041-owner.md": WP}
VENDORED = Path(runner.__file__).parent / "vendored" / "state_hub_owner_return_handoff.py"
UPSTREAM = Path("/home/worsch/state-hub/scripts/owner_return_handoff.py")
def _transport(head=SHA, seen=None):
def handler(request: httpx.Request) -> httpx.Response:
path = unquote(str(request.url).split("?")[0])
ref = request.url.params.get("ref")
if seen is not None:
seen.append(path)
if path == BASE:
return httpx.Response(200, json={"default_branch": "main"})
if path == f"{BASE}/branches/main":
return httpx.Response(200, json={"commit": {"id": head}})
if path.startswith(f"{BASE}/git/commits/"):
return httpx.Response(200, json={"sha": path.rsplit("/", 1)[1]})
if path == f"{BASE}/contents/workplans" and ref == SHA:
return httpx.Response(200, json=[
{"type": "file", "name": p.rsplit("/", 1)[1], "path": p} for p in FILES])
if path.startswith(f"{BASE}/raw/") and ref == SHA:
name = path[len(f"{BASE}/raw/"):]
if name in FILES:
return httpx.Response(200, text=FILES[name])
return httpx.Response(404)
return httpx.MockTransport(handler)
def _issue(task, messages, **over):
issue = {
"severity": "WARN", "check_id": "C-25", "message": "Owner-return task ...",
"file_value": "message-from:state-hub", "repo_slug": REPO,
"source_revision": SHA, "source_revision_reason": None,
"owner_return": {"task_record_id": task, "message_ids": messages},
}
issue.update(over)
return issue
def _report(**over):
report = {
"repo_slug": REPO, "repo_path": "/x", "source_revision": SHA,
"source_revision_reason": None, "result": "warn",
"issues": [_issue(TASK, [MSG1, MSG2]), _issue("WARDEN-WP-0041-T04", [MSG1]),
{"severity": "WARN", "check_id": "C-04", "message": "other"}],
}
report.update(over)
return report
def _write(tmp_path, report):
raw = json.dumps(report).encode()
path = tmp_path / "c25.json"
path.write_bytes(raw)
return {"target_repo": REPO, "report_file": str(path),
"report_sha256": hashlib.sha256(raw).hexdigest(), "submit": True}
class _Store:
"""Stand-in for the ops_runs unique index (ON CONFLICT DO NOTHING)."""
def __init__(self):
self.rows: dict[str, uuid.UUID] = {}
self.last_conflict: str | None = None
async def create(self, session, spec, **_kw):
from activity_core.ops_run_queue import build_idempotency_key
key = build_idempotency_key(spec)
if key in self.rows:
self.last_conflict = key
return None
self.rows[key] = uuid.uuid4()
return self.rows[key]
def session_factory(self):
store = self
@asynccontextmanager
async def begin():
yield
@asynccontextmanager
async def factory():
session = MagicMock()
session.begin = begin
async def execute(_stmt):
result = MagicMock()
result.first.return_value = (store.rows[store.last_conflict], "open")
return result
session.execute = execute
yield session
return factory
@pytest.fixture
def store(monkeypatch):
s = _Store()
monkeypatch.setattr(orp, "create_ops_run_from_spec", s.create)
monkeypatch.setenv(orp.ENABLE_ENV, "true")
monkeypatch.delenv("OPS_RUN_QUEUE_ENABLED", raising=False)
return s
def _run(params, store=None, head=SHA, seen=None):
return asyncio.run(runner.run_owner_return_pickup(
params,
session_factory=store.session_factory() if store else None,
client=httpx.Client(transport=_transport(head=head, seen=seen)),
state_hub_url="http://hub",
today=date(2026, 10, 4),
resolve_path=Path,
))
def test_submits_only_reviewable_and_rerun_yields_only_duplicates(tmp_path, store):
params = _write(tmp_path, _report())
first = _run(params, store)
out = first["planner_output"]
assert out["candidate_count"] == 3
assert out["dispositions"] == {"reviewable": 2, "stopped": 1}
assert out["stop_reasons"] == {"human_gate": 1}
assert first["submitted_count"] == 2
assert first["receipt_counts"] == {"inserted": 2, "duplicate": 0, "skipped": 0}
assert {r["message_id"] for r in first["receipts"]} == {MSG1, MSG2}
assert all(r["task_record_id"] == TASK for r in first["receipts"])
pin = first["planner_input"]
assert pin["source_revision"] == pin["current_tasks_source_revision"] == SHA
assert pin["collection_complete"] is True and pin["head_verified"] is True
assert pin["message_id_count"] == 3 and pin["counterparts"] == ["state-hub"]
again = _run(params, store)
assert again["receipt_counts"] == {"inserted": 0, "duplicate": 2, "skipped": 0}
assert [r["ops_run_id"] for r in again["receipts"]] == [
r["ops_run_id"] for r in first["receipts"]]
assert len(store.rows) == 2
# Evidence is IDs/counts only: no finding or message text survives.
blob = json.dumps(again)
assert "Owner-return task" not in blob and "other" not in blob
@pytest.mark.parametrize("revision,reason", [
(None, "dirty_workplans"), ("a" * 39, None), ("main", None), ("A" * 40, None)])
def test_refuses_missing_or_short_sha(tmp_path, store, revision, reason):
params = _write(tmp_path, _report(source_revision=revision, source_revision_reason=reason))
with pytest.raises(runner.OwnerReturnPickupRefused, match="full 40-hex SHA"):
_run(params, store)
assert store.rows == {}
def test_refuses_revision_mismatch_in_finding(tmp_path, store):
report = _report()
report["issues"][0]["source_revision"] = "b" * 40
with pytest.raises(runner.OwnerReturnPickupRefused, match="not bound"):
_run(_write(tmp_path, report), store)
assert store.rows == {}
def test_refuses_finding_without_revision_binding(tmp_path, store):
report = _report()
del report["issues"][0]["source_revision"]
with pytest.raises(runner.OwnerReturnPickupRefused, match="not bound"):
_run(_write(tmp_path, report), store)
def test_refuses_when_scanned_revision_is_no_longer_head(tmp_path, store):
with pytest.raises(runner.OwnerReturnPickupRefused, match="default-branch head"):
_run(_write(tmp_path, _report()), store, head="c" * 40)
assert store.rows == {}
def test_refuses_expected_revision_mismatch(tmp_path, store):
params = _write(tmp_path, _report()) | {"expected_source_revision": "b" * 40}
with pytest.raises(runner.OwnerReturnPickupRefused, match="expected_source_revision"):
_run(params, store)
def test_refuses_other_repository(tmp_path, store):
with pytest.raises(runner.OwnerReturnPickupRefused, match="different repository"):
_run(_write(tmp_path, _report(repo_slug="state-hub")), store)
def test_refuses_incomplete_collection(tmp_path, store):
report = _report()
report["issues"].append({
"severity": "WARN", "check_id": "C-25", "repo_slug": REPO, "source_revision": SHA,
"file_value": "message-from:state-hub",
"message": "Owner-return reply collection from state-hub is incomplete (10000 "
"message(s) read; hub ignored paging or the 50-page bound was reached)",
})
seen: list = []
with pytest.raises(runner.OwnerReturnPickupRefused, match="incomplete"):
_run(_write(tmp_path, report), store, seen=seen)
assert seen == [] and store.rows == {}
def test_refuses_failed_checker_collection(tmp_path, store):
with pytest.raises(runner.OwnerReturnPickupRefused, match="result=error"):
_run(_write(tmp_path, _report(result="error")), store)
def test_refuses_when_flag_unset_before_any_read(tmp_path, store, monkeypatch):
monkeypatch.delenv(orp.ENABLE_ENV)
seen: list = []
with pytest.raises(runner.OwnerReturnPickupRefused, match=orp.ENABLE_ENV):
_run(_write(tmp_path, _report()), store, seen=seen)
assert seen == [] and store.rows == {}
def test_plan_only_needs_no_flag_and_writes_nothing(tmp_path, store, monkeypatch):
monkeypatch.delenv(orp.ENABLE_ENV)
params = _write(tmp_path, _report()) | {"submit": False}
out = _run(params)
assert out["mode"] == "plan-only"
assert out["planner_output"]["dispositions"] == {"reviewable": 2, "stopped": 1}
assert out["receipts"] == [] and store.rows == {}
def test_all_list_selects_the_single_repo_object(tmp_path, store):
other = _report(repo_slug="state-hub", issues=[])
out = _run(_write(tmp_path, [other, _report()]) | {"submit": False}, store)
assert out["planner_output"]["candidate_count"] == 3
with pytest.raises(runner.OwnerReturnPickupRefused, match="exactly one"):
_run(_write(tmp_path, [_report(), _report()]), store)
with pytest.raises(runner.OwnerReturnPickupRefused, match="exactly one"):
_run(_write(tmp_path, [other]), store)
def test_null_revision_refusal_names_reason(tmp_path, store):
params = _write(tmp_path, _report(source_revision=None,
source_revision_reason="changed_during_scan"))
with pytest.raises(runner.OwnerReturnPickupRefused, match="changed_during_scan"):
_run(params, store)
def test_refuses_tampered_artefact(tmp_path, store):
params = _write(tmp_path, _report())
Path(params["report_file"]).write_text(json.dumps(_report(result="pass")))
with pytest.raises(runner.OwnerReturnPickupRefused, match="report_sha256"):
_run(params, store)
def test_no_owner_return_findings_skips_forge(tmp_path, store):
seen: list = []
out = _run(_write(tmp_path, _report(issues=[])), store, seen=seen)
assert seen == [] and out["planner_output"]["candidate_count"] == 0
@pytest.mark.parametrize("bad", [
{"target_repo": None}, {"report_sha256": "x"}, {"submit": "yes"},
{"repos": ["a", "b"]}, {"predecessor_repos": {"bad": "x"}},
{"expected_source_revision": "abc"}])
def test_param_admission(tmp_path, bad):
params = _write(tmp_path, _report()) | bad
with pytest.raises(ValueError):
runner.validate_params(params)
def test_registered_as_operator_bounded_operation(tmp_path, store, monkeypatch):
spec = BOUNDED_OPERATION_REGISTRY["owner_return_pickup"]
assert spec.temporal_max_attempts == 1
params = _write(tmp_path, _report()) | {"submit": False}
source = {"type": spec.source_type, "query": spec.query,
"operation": "owner_return_pickup", "params": params,
"bind_to": "context.owner_return"}
validate_bounded_operations([source], [])
with pytest.raises(ValueError):
validate_bounded_operations([source | {"params": {"submit": True}}], [])
async def fake(params, **kw):
assert kw["session_factory"] is None
return {"kind": "owner_return_pickup", "mode": "plan-only", "secret": "x"}
monkeypatch.setattr(runner, "run_owner_return_pickup", fake)
patch = asyncio.run(execute_bounded_operation({"source": source, "run_id": "r"}))
assert patch["owner_return"]["mode"] == "plan-only"
assert "secret" not in patch["owner_return"]
assert normalize_operation_result("owner_return_pickup", {})["kind"] == "owner_return_pickup"
def test_definition_stays_disabled_and_is_side_effect_marked():
from activity_core.definition_parser import parse_file
from activity_core.ops_console import SIDE_EFFECT_MARKERS
d = parse_file(Path(__file__).parent.parent / "activity-definitions" / "owner-return-pickup.md")
assert d.enabled is False
assert "owner_return_pickup" in SIDE_EFFECT_MARKERS
@pytest.mark.skipif(not UPSTREAM.is_file(), reason="state-hub planner not checked out")
def test_vendored_planner_matches_upstream():
assert VENDORED.read_bytes() == UPSTREAM.read_bytes(), (
"re-copy state-hub/scripts/owner_return_handoff.py into activity_core/vendored/"
)

View file

@ -85,8 +85,9 @@ blocking_reason: "Activation is a separately reviewed founder action"
state_hub_task_id: "254526ce-5a76-57b3-b7d5-a874518c7b40"
```
Plan: `docs/owner-return-pickup-activation-plan.md`. Requires T03 and T05
(eligibility-source binding of fresh authoritative `current_tasks`).
Plan: `docs/owner-return-pickup-activation-plan.md`. Requires T03, T05 and T06
(eligibility-source binding of fresh authoritative `current_tasks`; single-repo
caller in the existing worker).
## Eligibility input builder bound to the scan revision
@ -109,3 +110,28 @@ pinned per foreign repo); unresolved targets stay `None`. Decision status comes
from an injected resolver (`state_hub_decision_status`); failure stays unknown.
Tests: `tests/test_owner_return_eligibility.py` (HTTP via MockTransport,
including acceptance by the State Hub planner).
## Single-repo caller inside the existing worker (precondition 3)
```task
id: ACTIVITY-WP-0043-T06
status: done
priority: high
```
`src/activity_core/owner_return_runner.py`, registered as bounded operation
`owner_return_pickup` (`state-hub-owner-return`/`pickup`) and executed by the
existing `execute_bounded_operation` activity; operator-triggered via
`/ops/automations/{id}/trigger` with `confirm_side_effect`, no schedule,
definition still `enabled: false`. Reads an operator-supplied C-25 JSON
artefact pinned by sha256 (the checker cannot run in the worker: it needs a
local checkout), requires a full-SHA `source_revision` on the report and every
C-25 finding, refuses incomplete collection/failed checker, binds
`build_current_tasks` to the SHA (Forge HEAD verified), runs the vendored State
Hub planner, and submits only `reviewable` candidates under
`ACTIVITY_CORE_OWNER_RETURN_PICKUP_ENABLED`. Evidence is IDs/counts only.
Planner vendored from state-hub `150cb0b` (C-25 source-revision contract);
`build_current_tasks` now also refuses a report lacking `source_revision` and
names `source_revision_reason` when it is null. Gaps (in-cluster report producer, structured completeness proof, private-repo
Forge reads) are listed in the activation plan. Tests:
`tests/test_owner_return_runner.py`.