feat: reconcile granted ops run closes

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a06bfe-2a55-7ed3-bacd-879977b099bf
This commit is contained in:
tegwick 2026-09-04 12:57:12 +02:00
parent b72fdb5452
commit b63131e863
21 changed files with 1240 additions and 43 deletions

View file

@ -86,6 +86,35 @@ def test_action_carries_declared_profile_and_renders_attribution_refs() -> None:
}
def test_action_carries_repository_grant_without_rendering_authority() -> None:
grant = {
"version": "1",
"allowed_paths": ["docs/{context.request.scope}", "README.md"],
"commit_count": {"min": 1, "max": 1},
"publish": False,
}
rules = [
{
"id": "granted-task",
"condition": "",
"action": {
"task_template": "Run controlled task",
"target_repo": "activity-core",
"repository_grant": grant,
},
}
]
specs = expand_rule_actions(
rules,
_Event(),
{"request": {"scope": "must-not-be-authority"}},
)
assert specs[0]["repository_grant"] == grant
assert specs[0]["repository_grant"] is not grant
def test_for_each_binds_each_list_item_before_condition_and_action_rendering() -> None:
rules = [
{

View file

@ -7,6 +7,7 @@ import json
from activity_core.glas_evidence import (
normalise_execution_evidence,
normalise_ops_result,
normalise_repository_transaction,
)
@ -113,3 +114,61 @@ def test_keeps_legacy_artifact_fields_but_not_unknown_blobs() -> None:
{"kind": "report", "label": "Report", "url": "https://example.test/a"}
]
assert "messages" not in result
def test_preserves_bounded_repository_transaction_identity() -> None:
transaction = normalise_repository_transaction(
{
"transaction_id": "tx-1",
"correlation_id": "run-1",
"baseline": {"repo_id": "repo-1", "head": "base", "secret": "drop"},
"repository_grant": {
"grant_id": "grant-1",
"acceptance_policy_id": "policy-1",
"version": "1",
"allowed_path_count": 2,
"allowed_paths_digest": "digest",
"min_commits": 1,
"max_commits": 1,
"publish": False,
"allowed_paths": ["must-not-persist"],
},
"acceptance": {
"policy_id": "policy-1",
"accepted": True,
"head": "result",
"commit_count": 1,
"commits": ["result"],
"changed_path_count": 1,
"changed_paths": ["docs/result.md"] * 101,
"changed_paths_digest": "paths-digest",
"clean_post_state": True,
"provider_blob": "drop",
},
"metrics": {
"storage": "external",
"session_id": "tx-1",
"projection_ready": True,
},
"tool_output": "drop",
}
)
assert transaction["transaction_id"] == "tx-1"
assert transaction["repository_grant"]["grant_id"] == "grant-1"
assert transaction["acceptance"]["accepted"] is True
assert len(transaction["acceptance"]["changed_paths"]) == 100
assert transaction["metrics"]["session_id"] == "tx-1"
assert "allowed_paths" not in transaction["repository_grant"]
assert "tool_output" not in transaction
assert "secret" not in transaction["baseline"]
def test_ops_result_accepts_repository_transaction_alias() -> None:
result = normalise_ops_result(
{"transaction": {"transaction_id": "tx-alias", "unknown": "drop"}}
)
assert result == {
"repository_transaction": {"transaction_id": "tx-alias"}
}

View file

@ -242,6 +242,47 @@ async def test_emit_tasks_refuses_absent_profile_in_strict_mode(monkeypatch) ->
)
@pytest.mark.asyncio
async def test_emit_tasks_refuses_invalid_grant_before_any_side_effect(
monkeypatch,
) -> None:
from temporalio.exceptions import ApplicationError
monkeypatch.setattr(
activities,
"get_issue_sink",
lambda: pytest.fail("IssueSink must not be opened after grant refusal"),
)
monkeypatch.setattr(
activities,
"_get_session_factory",
lambda: pytest.fail("DB must not be opened after grant refusal"),
)
with pytest.raises(ApplicationError, match="repository grant refused") as exc_info:
await activities.emit_tasks(
{
"activity_id": "00000000-0000-0000-0000-000000000001",
"triggering_event_id": "event-1",
"task_specs": [
{
"title": "Must not emit",
"source_type": "rule",
"source_id": "unsafe-grant",
"repository_grant": {
"version": "1",
"allowed_paths": ["../escape"],
"commit_count": {"min": 1, "max": 1},
"publish": False,
},
}
],
}
)
assert exc_info.value.non_retryable is True
@pytest.mark.asyncio
async def test_emit_tasks_passes_unknown_versioned_profile_to_queue(monkeypatch) -> None:
captured: dict[str, Any] = {}
@ -297,6 +338,12 @@ async def test_emit_tasks_passes_unknown_versioned_profile_to_queue(monkeypatch)
"correlation_id": "corr-9",
"api_key": "must-be-dropped",
},
"repository_grant": {
"version": "1",
"allowed_paths": ["docs/", "README.md"],
"commit_count": {"min": 1, "max": 1},
"publish": False,
},
}
],
}
@ -306,3 +353,9 @@ async def test_emit_tasks_passes_unknown_versioned_profile_to_queue(monkeypatch)
assert captured["harness_profile_ref"] == "harness.unknown-locally@9.9.9"
assert captured["approach_hint"] == "legacy-only"
assert captured["execution_refs"] == {"correlation_id": "corr-9"}
assert captured["repository_grant"] == {
"version": "1",
"allowed_paths": ["docs/", "README.md"],
"commit_count": {"min": 1, "max": 1},
"publish": False,
}

View file

@ -118,7 +118,11 @@ def test_ops_run_to_dict_shape() -> None:
row.source_id = "emit"
row.triggering_event_id = "e1"
row.approach_hint = None
row.harness_profile_ref = None
row.execution_refs = {}
row.repository_grant = None
row.result = {}
row.close_intent_digest = None
row.created_at = now
row.updated_at = now
d = ops_run_to_dict(row)
@ -167,6 +171,33 @@ async def test_create_ops_run_inserts(monkeypatch: pytest.MonkeyPatch) -> None:
session.execute.assert_awaited()
@pytest.mark.asyncio
async def test_create_ops_run_carries_repository_grant_separately(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from activity_core.ops_run_queue import create_ops_run_from_spec
monkeypatch.setenv("OPS_RUN_QUEUE_ENABLED", "true")
result = MagicMock()
result.scalar_one_or_none.return_value = uuid.uuid4()
session = AsyncMock()
session.execute = AsyncMock(return_value=result)
spec = TaskSpec(
title="bounded",
target_repo="activity-core",
activity_definition_id=str(uuid.uuid4()),
source_id="emit-bounded",
triggering_event_id="manual-1",
)
grant = _repository_grant()
await create_ops_run_from_spec(session, spec, repository_grant=grant)
statement = session.execute.await_args.args[0]
assert statement.compile().params["repository_grant"] == grant
assert statement.compile().params["execution_refs"] == {}
@pytest.mark.asyncio
async def test_claim_and_complete_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
"""In-memory style: claim filters labels and complete transitions state."""
@ -181,6 +212,8 @@ async def test_claim_and_complete_roundtrip(monkeypatch: pytest.MonkeyPatch) ->
open_run.claim_owner = None
open_run.lease_until = None
open_run.result = {}
open_run.repository_grant = None
open_run.close_intent_digest = None
session = AsyncMock()
@ -234,6 +267,8 @@ async def test_complete_persists_only_normalized_glas_evidence() -> None:
row.claim_owner = "worker-1"
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
row.result = {}
row.repository_grant = None
row.close_intent_digest = None
session = AsyncMock()
session.get = AsyncMock(return_value=row)
@ -279,6 +314,8 @@ async def test_fail_reopen_under_max_attempts(monkeypatch: pytest.MonkeyPatch) -
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
row.attempt = 1
row.result = {}
row.repository_grant = None
row.close_intent_digest = None
session = AsyncMock()
session.get = AsyncMock(return_value=row)
@ -302,6 +339,8 @@ async def test_fail_permanent_at_max_attempts(monkeypatch: pytest.MonkeyPatch) -
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
row.attempt = 3
row.result = {}
row.repository_grant = None
row.close_intent_digest = None
session = AsyncMock()
session.get = AsyncMock(return_value=row)
@ -322,6 +361,8 @@ async def test_fail_persists_redacted_failure_evidence() -> None:
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
row.attempt = 1
row.result = {}
row.repository_grant = None
row.close_intent_digest = None
session = AsyncMock()
session.get = AsyncMock(return_value=row)
@ -390,6 +431,8 @@ async def test_complete_rejects_expired_lease_without_mutation() -> None:
row.claim_owner = "worker-1"
row.lease_until = now - timedelta(microseconds=1)
row.result = {"before": True}
row.repository_grant = None
row.close_intent_digest = None
session = AsyncMock()
session.get = AsyncMock(return_value=row)
@ -416,6 +459,8 @@ async def test_fail_rejects_wrong_owner_with_active_lease() -> None:
row.claim_owner = "worker-1"
row.lease_until = now + timedelta(minutes=1)
row.result = {"before": True}
row.repository_grant = None
row.close_intent_digest = None
session = AsyncMock()
session.get = AsyncMock(return_value=row)
@ -432,6 +477,155 @@ async def test_fail_rejects_wrong_owner_with_active_lease() -> None:
assert row.result == {"before": True}
@pytest.mark.asyncio
async def test_terminal_complete_exact_repeat_reconciles_and_conflict_is_distinct() -> None:
from activity_core import ops_run_queue as oq
row = MagicMock()
row.state = "claimed"
row.claim_owner = "worker-1"
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
row.result = {}
row.repository_grant = None
row.close_intent_digest = None
session = AsyncMock()
session.get = AsyncMock(return_value=row)
applied = await oq.close_ops_run(
session,
uuid.uuid4(),
worker_id="worker-1",
action="complete",
result={"ok": True, "path": "docs/result.md"},
)
digest = row.close_intent_digest
reconciled = await oq.close_ops_run(
session,
uuid.uuid4(),
worker_id="worker-1",
action="complete",
result={"path": "docs/result.md", "ok": True},
)
conflict = await oq.close_ops_run(
session,
uuid.uuid4(),
worker_id="worker-1",
action="complete",
result={"ok": True, "path": "docs/different.md"},
)
wrong_owner = await oq.close_ops_run(
session,
uuid.uuid4(),
worker_id="worker-2",
action="complete",
result={"ok": True, "path": "docs/result.md"},
)
assert applied.disposition == "applied"
assert reconciled.disposition == "reconciled"
assert conflict.disposition == "terminal_conflict"
assert wrong_owner.disposition == "wrong_owner"
assert row.state == "succeeded"
assert row.lease_until is None
assert row.close_intent_digest == digest
@pytest.mark.asyncio
async def test_close_reports_expired_lease_without_mutation() -> None:
from activity_core import ops_run_queue as oq
now = datetime(2026, 9, 4, 10, 0, tzinfo=timezone.utc)
row = MagicMock()
row.state = "claimed"
row.claim_owner = "worker-1"
row.lease_until = now
row.result = {"before": True}
row.repository_grant = None
row.close_intent_digest = None
session = AsyncMock()
session.get = AsyncMock(return_value=row)
with patch.object(oq, "_utcnow", return_value=now):
outcome = await oq.close_ops_run(
session,
uuid.uuid4(),
worker_id="worker-1",
action="complete",
result={"ok": True},
)
assert outcome.disposition == "expired_lease"
assert row.result == {"before": True}
assert row.close_intent_digest is None
def _repository_grant() -> dict:
return {
"version": "1",
"allowed_paths": ["docs/", "README.md"],
"commit_count": {"min": 1, "max": 1},
"publish": False,
}
def _accepted_repository_result() -> dict:
from activity_core.repository_grant import RepositoryGrant
grant = RepositoryGrant.model_validate(_repository_grant())
return {
"ok": True,
"repository_transaction": {
"transaction_id": "tx-1",
"repository_grant": {
"grant_id": grant.grant_id,
"acceptance_policy_id": grant.acceptance_policy_id,
"version": "1",
},
"acceptance": {
"accepted": True,
"policy_id": grant.acceptance_policy_id,
"head": "deadbeef",
},
},
}
@pytest.mark.asyncio
async def test_granted_complete_requires_matching_acceptance_identity() -> None:
from activity_core import ops_run_queue as oq
row = MagicMock()
row.state = "claimed"
row.claim_owner = "worker-1"
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
row.result = {}
row.repository_grant = _repository_grant()
row.close_intent_digest = None
session = AsyncMock()
session.get = AsyncMock(return_value=row)
mismatch = _accepted_repository_result()
mismatch["repository_transaction"]["repository_grant"]["grant_id"] = "other"
refused = await oq.close_ops_run(
session,
uuid.uuid4(),
worker_id="worker-1",
action="complete",
result=mismatch,
)
applied = await oq.close_ops_run(
session,
uuid.uuid4(),
worker_id="worker-1",
action="complete",
result=_accepted_repository_result(),
)
assert refused.disposition == "evidence_conflict"
assert applied.disposition == "applied"
assert row.result["repository_transaction"]["transaction_id"] == "tx-1"
def test_label_filter_any_vs_all() -> None:
"""Document labels_mode semantics used by claim_ops_runs."""
row_labels = {"automated", "research-brief"}

View file

@ -8,7 +8,9 @@ import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from activity_core.ops_run_queue import CloseOpsRunOutcome
from activity_core.ops_runs_api import (
_close_response,
bind_worker_id,
require_worker,
require_worker_or_operator,
@ -140,3 +142,26 @@ def test_read_auth_no_longer_defaults_open(monkeypatch: pytest.MonkeyPatch) -> N
require_worker_or_operator(_request())
assert exc.value.status_code == 503
def test_close_response_distinguishes_reconciled_and_refusal_codes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
row = MagicMock()
monkeypatch.setattr(
"activity_core.ops_runs_api.ops_run_to_dict",
lambda value: {"id": "run-1", "state": value.state},
)
row.state = "succeeded"
response = _close_response(CloseOpsRunOutcome("reconciled", row))
assert response == {
"id": "run-1",
"state": "succeeded",
"close_disposition": "reconciled",
}
with pytest.raises(HTTPException) as exc:
_close_response(CloseOpsRunOutcome("expired_lease", row))
assert exc.value.status_code == 409
assert exc.value.detail["code"] == "expired_lease"

View file

@ -0,0 +1,82 @@
"""Repository-grant v1 validation and identity compatibility."""
from __future__ import annotations
import pytest
from activity_core.models import ActionDef, InstructionDef
from activity_core.orm import OpsRun
from activity_core.repository_grant import (
RepositoryGrant,
RepositoryGrantError,
validate_repository_grant,
)
def _grant(**changes: object) -> dict:
value = {
"version": "1",
"allowed_paths": ["docs/", "README.md"],
"commit_count": {"min": 1, "max": 1},
"publish": False,
}
value.update(changes)
return value
def test_repository_grant_preserves_payload_and_has_stable_identity() -> None:
raw = _grant()
grant = RepositoryGrant.model_validate(raw)
reordered = RepositoryGrant.model_validate(
_grant(allowed_paths=["README.md", "docs/"])
)
assert grant.payload() == raw
assert grant.payload() is not raw
assert grant.grant_id == reordered.grant_id
assert grant.acceptance_policy_id == reordered.acceptance_policy_id
assert grant.grant_id == "af2e7c8275c9ba8c8f78485067f9608e"
assert grant.acceptance_policy_id == "2ed31282d721f4466bc9ea722067d9d0"
def test_action_and_instruction_parse_repository_grant_as_typed_authority() -> None:
action = ActionDef(task_template="Do work", repository_grant=_grant())
instruction = InstructionDef(
id="do-work",
trusted_fields=[],
model="model",
prompt="Do work",
output_schema="schema.json",
repository_grant=_grant(),
)
assert isinstance(action.repository_grant, RepositoryGrant)
assert isinstance(instruction.repository_grant, RepositoryGrant)
@pytest.mark.parametrize(
"value",
[
_grant(extra="not-allowed"),
_grant(version=1),
_grant(allowed_paths=[]),
_grant(allowed_paths=["docs/", "docs/"]),
_grant(allowed_paths=["../escape"]),
_grant(allowed_paths=[".git/config"]),
_grant(commit_count={"min": True, "max": 1}),
_grant(commit_count={"min": 2, "max": 1}),
_grant(commit_count={"min": 1, "max": 33}),
_grant(publish=True),
_grant(publish=0),
],
)
def test_repository_grant_rejects_ambiguous_or_unsafe_values(value: dict) -> None:
with pytest.raises(RepositoryGrantError):
validate_repository_grant(value)
def test_ops_run_schema_has_separate_grant_and_close_identity_columns() -> None:
columns = OpsRun.__table__.columns
assert columns["repository_grant"].nullable is True
assert columns["close_intent_digest"].nullable is True

View file

@ -117,6 +117,8 @@ def test_ops_summary_exposes_compact_execution_constellation() -> None:
row.approach_hint = "legacy-only"
row.harness_profile_ref = "harness.agent-dev@1.0.0"
row.execution_refs = {"goal_refs": ["goal:42@1"]}
row.repository_grant = None
row.close_intent_digest = "close-digest"
row.created_at = now
row.updated_at = now
row.result = {

View file

@ -145,6 +145,42 @@ def test_definition_parse_rejects_a_malformed_profile(tmp_path) -> None:
parse_file(tmp_path / "profiled.md")
def test_definition_parse_validates_and_preserves_repository_grant(tmp_path) -> None:
_write_profiled_definition(
tmp_path,
""" repository_grant:
version: "1"
allowed_paths: [docs/, README.md]
commit_count: {min: 1, max: 1}
publish: false
""",
)
definition = parse_file(tmp_path / "profiled.md")
assert definition.rules[0]["action"]["repository_grant"] == {
"version": "1",
"allowed_paths": ["docs/", "README.md"],
"commit_count": {"min": 1, "max": 1},
"publish": False,
}
def test_definition_parse_rejects_unsafe_repository_grant(tmp_path) -> None:
_write_profiled_definition(
tmp_path,
""" repository_grant:
version: "1"
allowed_paths: [../escape]
commit_count: {min: 1, max: 1}
publish: false
""",
)
with pytest.raises(ParseError, match="invalid repository_grant"):
parse_file(tmp_path / "profiled.md")
def test_definition_parse_requires_rule_profile_in_strict_mode(
tmp_path,
monkeypatch,