feat(ops-run): carry harness_profile_ref and attribution refs
ACTIVITY-WP-0032-T02 / ACT-ADR-006. ops_runs grows harness_profile_ref (text, indexed) and execution_refs (jsonb), migration 0008, threaded through the emission path, queue projection, and run artefacts. The important part is the enforcement, not the columns. ACT-ADR-006 says approach_hint must never override, synthesize, or fall back from an absent or invalid harness_profile_ref — a silent fallback would reintroduce the claim-time routing failure of 2026-08-17. resolve_execution_selector never consults the hint: a malformed ref raises even when a hint is present, and ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE turns an absent ref into an error once definitions have migrated. Validation is structural only, since the glas-harness catalogue is authoritative and must not be mirrored. Requiring the <id>@<version> pin is worth doing locally: GlasProfiles.resolve matches an unpinned ref against every version and refuses it as ambiguous, so the pin converts a late failure into an emission-time error without knowing any profile id. Migration verified on real PostgreSQL 16: upgrade, downgrade, re-upgrade, and a legacy-shaped row still inserts and stays claimable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
1c4b3c592c
commit
5bd0ee5ff9
10 changed files with 428 additions and 7 deletions
|
|
@ -35,7 +35,7 @@
|
|||
| workplan | ACTIVITY-WP-0029 | active | — | workplans/ACTIVITY-WP-0029-hub-port-alignment.md |
|
||||
| workplan | ACTIVITY-WP-0030 | active | — | workplans/ACTIVITY-WP-0030-daily-sbom-catchup.md |
|
||||
| workplan | ACTIVITY-WP-0031 | active | — | workplans/ACTIVITY-WP-0031-production-execution-reliability-cleanup.md |
|
||||
| workplan | ACTIVITY-WP-0032 | proposed | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md |
|
||||
| workplan | ACTIVITY-WP-0032 | active | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md |
|
||||
| workplan | ADHOC-2026-06-01 | finished | — | workplans/ADHOC-2026-06-01.md |
|
||||
| workplan | ADHOC-2026-08-20 | finished | — | workplans/ADHOC-2026-08-20.md |
|
||||
| workplan | custodian-WP-0001 | done | — | workplans/custodian-WP-0001-temporal-backbone.md |
|
||||
|
|
@ -194,9 +194,9 @@
|
|||
| task | ACTIVITY-WP-0031-T03 | done | — | workplans/ACTIVITY-WP-0031-production-execution-reliability-cleanup.md |
|
||||
| task | ACTIVITY-WP-0031-T04 | done | — | workplans/ACTIVITY-WP-0031-production-execution-reliability-cleanup.md |
|
||||
| task | ACTIVITY-WP-0031-T05 | wait | — | workplans/ACTIVITY-WP-0031-production-execution-reliability-cleanup.md |
|
||||
| task | ACTIVITY-WP-0032-T01 | wait | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md |
|
||||
| task | ACTIVITY-WP-0032-T02 | wait | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md |
|
||||
| task | ACTIVITY-WP-0032-T03 | wait | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md |
|
||||
| task | ACTIVITY-WP-0032-T01 | done | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md |
|
||||
| task | ACTIVITY-WP-0032-T02 | todo | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md |
|
||||
| task | ACTIVITY-WP-0032-T03 | todo | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md |
|
||||
| task | ACTIVITY-WP-0032-T04 | wait | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md |
|
||||
| task | ACTIVITY-WP-0032-T05 | wait | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md |
|
||||
| task | ADHOC-2026-06-01-T01 | done | — | workplans/ADHOC-2026-06-01.md |
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ workplan task file. Not an issue-core or Forgejo ticket.
|
|||
| `source_type` | text | rule \| instruction |
|
||||
| `source_id` | text | Rule id |
|
||||
| `triggering_event_id` | text | Event or workflow key |
|
||||
| `approach_hint` | text nullable | Optional from rule |
|
||||
| `approach_hint` | text nullable | **Legacy** definition-matching hint (ACT-ADR-006) |
|
||||
| `harness_profile_ref` | text nullable | Authoritative execution selector, pinned `<id>@<version>` |
|
||||
| `execution_refs` | jsonb | Attribution refs carried through, not authored here |
|
||||
| `result` | JSONB | Completion metadata |
|
||||
| `created_at` / `updated_at` | timestamptz | |
|
||||
|
||||
|
|
@ -134,3 +136,32 @@ completion event (`fi_daily_brief`, etc.).
|
|||
| Multi-day engineering tasks | Workplan files + State Hub |
|
||||
| External tracker tickets | issue-core → **Forgejo** (optional projection) |
|
||||
| Schedule truth | Temporal + activity definitions |
|
||||
|
||||
|
||||
## Execution selection (ACT-ADR-006)
|
||||
|
||||
`harness_profile_ref` names an approved, **version-pinned** glas-harness profile
|
||||
(e.g. `harness.agent-dev-local@1.0.0`). The claiming executor passes the queued
|
||||
request into Glas, which resolves or refuses it **before sandbox creation**.
|
||||
|
||||
`approach_hint` and `harness_profile_ref` coexist with **distinct semantics**:
|
||||
|
||||
- `harness_profile_ref` is the authoritative execution-constellation selector.
|
||||
- `approach_hint` is a legacy definition-matching hint only. It must **never**
|
||||
override, synthesize, or fall back from an absent or invalid profile ref. A
|
||||
malformed ref is an error at emission, not an invitation to route on the hint.
|
||||
|
||||
activity-core does **not** mirror the glas-harness profile catalogue — it is
|
||||
authoritative there, and Glas exposes no network validation service. So we
|
||||
validate structure only (present, no whitespace, `<id>@<version>` pinned). The
|
||||
pin matters: `GlasProfiles.resolve` treats an unpinned ref as matching every
|
||||
version and refuses it as ambiguous, so requiring the pin locally converts a
|
||||
late failure into an emission-time error without knowing any profile id.
|
||||
|
||||
A well-formed but *unknown* profile is still caught by the execution-side Glas
|
||||
resolver rather than at emission. That residual gap is accepted and recorded in
|
||||
ACT-ADR-006; closing it needs a scoped glas-harness API, not a local catalogue.
|
||||
|
||||
`ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE=true` makes a missing profile ref an
|
||||
error. It stays off during coexistence while definitions adopt refs one at a
|
||||
time; turn it on once no caller depends on `approach_hint` for routing.
|
||||
|
|
|
|||
52
migrations/versions/0008_add_harness_profile_to_ops_runs.py
Normal file
52
migrations/versions/0008_add_harness_profile_to_ops_runs.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""add_harness_profile_to_ops_runs
|
||||
|
||||
Revision ID: 0008
|
||||
Revises: 0007
|
||||
Create Date: 2026-08-21
|
||||
|
||||
ACTIVITY-WP-0032 / ACT-ADR-006 — profile-driven execution selection.
|
||||
|
||||
`harness_profile_ref` is the authoritative execution-constellation selector;
|
||||
`execution_refs` carries the contract's attribution references. Both are
|
||||
additive and nullable/defaulted, so existing open ops_runs stay claimable and
|
||||
`approach_hint` keeps working as the legacy hint during coexistence.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0008"
|
||||
down_revision: Union[str, Sequence[str], None] = "0007"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"ops_runs",
|
||||
sa.Column("harness_profile_ref", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ops_runs",
|
||||
sa.Column(
|
||||
"execution_refs",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
)
|
||||
# Operators need to answer "what is still on the legacy hint?" during the
|
||||
# coexistence window, and executors filter claimable work by profile.
|
||||
op.create_index(
|
||||
"idx_ops_runs_harness_profile",
|
||||
"ops_runs",
|
||||
["harness_profile_ref"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_ops_runs_harness_profile", table_name="ops_runs")
|
||||
op.drop_column("ops_runs", "execution_refs")
|
||||
op.drop_column("ops_runs", "harness_profile_ref")
|
||||
|
|
@ -495,6 +495,8 @@ async def emit_tasks(payload: dict) -> list[str]:
|
|||
session,
|
||||
spec,
|
||||
approach_hint=spec_dict.get("approach_hint"),
|
||||
harness_profile_ref=spec_dict.get("harness_profile_ref"),
|
||||
execution_refs=spec_dict.get("execution_refs"),
|
||||
)
|
||||
if ops_id is not None:
|
||||
activity.logger.info(
|
||||
|
|
|
|||
131
src/activity_core/glas_profile.py
Normal file
131
src/activity_core/glas_profile.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Harness profile selection for queued ops runs (ACT-ADR-006).
|
||||
|
||||
activity-core names an **approved, versioned harness profile** on a governed
|
||||
ops_run and passes the attribution references through. It does not resolve the
|
||||
profile, select a rein, or mirror the glas-harness catalogue — that catalogue is
|
||||
authoritative and Glas exposes no network validation service, so a local copy
|
||||
would drift and produce a second, disagreeing opinion about what is executable.
|
||||
|
||||
What we can check locally is *structure*, and one structural property matters a
|
||||
great deal: ``GlasProfiles.resolve`` (glas_harness/profiles.py) treats a ref
|
||||
without ``@version`` as matching every version of that id and raises
|
||||
``AmbiguousProfileError`` when more than one exists. Requiring the pin here
|
||||
turns a late ambiguity failure into an emission-time error, without knowing a
|
||||
single profile id.
|
||||
|
||||
Everything else — whether a well-formed ref names a profile that exists, is
|
||||
enabled, and matches the contract version — is the execution-side Glas
|
||||
resolver's mandatory fail-closed check, by design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# `harness.agent-dev-local@1.0.0` — id, then a pinned version.
|
||||
_PROFILE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
_PROFILE_VERSION_PATTERN = re.compile(r"^[0-9]+(\.[0-9]+)*([A-Za-z0-9.\-+]*)$")
|
||||
|
||||
# Passed through from workforce/leadership vocabulary (info-tech-canon). We
|
||||
# carry these; we do not author or interpret them.
|
||||
EXECUTION_REF_KEYS: tuple[str, ...] = (
|
||||
"correlation_id",
|
||||
"assignment_ref",
|
||||
"role_ref",
|
||||
"duty_ref",
|
||||
"goal_refs",
|
||||
"resource_envelope_refs",
|
||||
)
|
||||
_LIST_REF_KEYS = frozenset({"goal_refs", "resource_envelope_refs"})
|
||||
|
||||
|
||||
class ProfileRefError(ValueError):
|
||||
"""A harness profile reference is absent or structurally unusable."""
|
||||
|
||||
|
||||
def require_harness_profile() -> bool:
|
||||
"""Whether a governed ops_run must carry a profile ref.
|
||||
|
||||
Off during migration (ACT-ADR-006 coexistence): definitions adopt the ref
|
||||
one at a time. Turn on once inventory shows no caller depends on
|
||||
``approach_hint`` for runtime selection.
|
||||
"""
|
||||
return os.getenv("ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE", "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
|
||||
def validate_profile_ref(raw: Any) -> str:
|
||||
"""Return a normalised, pinned profile ref or raise ProfileRefError."""
|
||||
if raw is None or not isinstance(raw, str) or not raw.strip():
|
||||
raise ProfileRefError("harness_profile_ref is empty")
|
||||
|
||||
ref = raw.strip()
|
||||
if any(character.isspace() for character in ref):
|
||||
raise ProfileRefError(f"harness_profile_ref contains whitespace: {ref!r}")
|
||||
|
||||
profile_id, separator, version = ref.partition("@")
|
||||
if not separator:
|
||||
# Glas would match every version of this id and refuse as ambiguous.
|
||||
raise ProfileRefError(
|
||||
f"harness_profile_ref must pin a version as '<id>@<version>': {ref!r}"
|
||||
)
|
||||
if not _PROFILE_ID_PATTERN.match(profile_id):
|
||||
raise ProfileRefError(f"harness_profile_ref has an invalid id: {ref!r}")
|
||||
if not _PROFILE_VERSION_PATTERN.match(version):
|
||||
raise ProfileRefError(f"harness_profile_ref has an invalid version: {ref!r}")
|
||||
return ref
|
||||
|
||||
|
||||
def resolve_execution_selector(
|
||||
harness_profile_ref: Any,
|
||||
approach_hint: Any = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Return ``(harness_profile_ref, approach_hint)`` for a queued ops_run.
|
||||
|
||||
ACT-ADR-006 §2: the two coexist with **distinct semantics**. The profile ref
|
||||
is the authoritative execution-constellation selector; ``approach_hint`` is
|
||||
a legacy definition-matching hint only. It must never override, synthesize,
|
||||
or fall back from an absent or invalid profile ref — a silent fallback would
|
||||
reintroduce exactly the claim-time routing failure this replaces.
|
||||
|
||||
So the hint is carried through untouched and is never consulted here.
|
||||
"""
|
||||
hint = approach_hint if isinstance(approach_hint, str) and approach_hint.strip() else None
|
||||
|
||||
if harness_profile_ref is None or (
|
||||
isinstance(harness_profile_ref, str) and not harness_profile_ref.strip()
|
||||
):
|
||||
if require_harness_profile():
|
||||
raise ProfileRefError(
|
||||
"governed ops_run has no harness_profile_ref "
|
||||
"(approach_hint cannot substitute for one)"
|
||||
)
|
||||
return None, hint
|
||||
|
||||
# Malformed refs always raise, migration flag or not: a ref that was meant
|
||||
# to route this run and cannot must not silently degrade to the legacy hint.
|
||||
return validate_profile_ref(harness_profile_ref), hint
|
||||
|
||||
|
||||
def normalise_execution_refs(raw: Any) -> dict[str, Any]:
|
||||
"""Keep only the contract's attribution refs, as strings / lists of strings."""
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
|
||||
refs: dict[str, Any] = {}
|
||||
for key in EXECUTION_REF_KEYS:
|
||||
value = raw.get(key)
|
||||
if key in _LIST_REF_KEYS:
|
||||
if isinstance(value, (list, tuple)):
|
||||
items = [str(item).strip() for item in value if str(item).strip()]
|
||||
if items:
|
||||
refs[key] = items
|
||||
elif isinstance(value, str) and value.strip():
|
||||
refs[key] = value.strip()
|
||||
return refs
|
||||
|
|
@ -11,6 +11,10 @@ from sqlalchemy import Select, and_, func, select, update
|
|||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from activity_core.glas_profile import (
|
||||
normalise_execution_refs,
|
||||
resolve_execution_selector,
|
||||
)
|
||||
from activity_core.orm import OpsRun
|
||||
from activity_core.rules.models import TaskSpec
|
||||
|
||||
|
|
@ -92,6 +96,8 @@ def ops_run_to_dict(row: OpsRun) -> dict[str, Any]:
|
|||
"source_id": row.source_id,
|
||||
"triggering_event_id": row.triggering_event_id,
|
||||
"approach_hint": row.approach_hint,
|
||||
"harness_profile_ref": row.harness_profile_ref,
|
||||
"execution_refs": dict(row.execution_refs or {}),
|
||||
"result": dict(row.result or {}),
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
|
|
@ -103,6 +109,8 @@ async def create_ops_run_from_spec(
|
|||
spec: TaskSpec,
|
||||
*,
|
||||
approach_hint: str | None = None,
|
||||
harness_profile_ref: str | None = None,
|
||||
execution_refs: dict | None = None,
|
||||
) -> uuid.UUID | None:
|
||||
"""Insert ops_run if queue enabled; return id or None if disabled/duplicate."""
|
||||
if not ops_run_queue_enabled():
|
||||
|
|
@ -115,6 +123,10 @@ async def create_ops_run_from_spec(
|
|||
except ValueError:
|
||||
return None
|
||||
|
||||
# ACT-ADR-006: raises on a malformed ref, and on an absent one once
|
||||
# ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE is enabled. Never falls back to the hint.
|
||||
profile_ref, hint = resolve_execution_selector(harness_profile_ref, approach_hint)
|
||||
|
||||
key = build_idempotency_key(spec)
|
||||
now = datetime.now(timezone.utc)
|
||||
stmt = (
|
||||
|
|
@ -133,7 +145,9 @@ async def create_ops_run_from_spec(
|
|||
source_type=spec.source_type or "rule",
|
||||
source_id=spec.source_id or "",
|
||||
triggering_event_id=spec.triggering_event_id or "",
|
||||
approach_hint=approach_hint,
|
||||
approach_hint=hint,
|
||||
harness_profile_ref=profile_ref,
|
||||
execution_refs=normalise_execution_refs(execution_refs),
|
||||
result={},
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
|
|
|
|||
|
|
@ -170,6 +170,12 @@ class OpsRun(Base):
|
|||
source_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
triggering_event_id: Mapped[str] = mapped_column(Text, nullable=False, index=True)
|
||||
approach_hint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# ACT-ADR-006: authoritative execution-constellation selector. Legacy
|
||||
# `approach_hint` above must never substitute for an absent/invalid one.
|
||||
harness_profile_ref: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Attribution refs carried through from workforce/leadership vocabulary;
|
||||
# activity-core does not author or interpret them.
|
||||
execution_refs: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
result: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@ def _ops_summary(row: OpsRun) -> dict[str, Any]:
|
|||
"triggering_event_id": row.triggering_event_id,
|
||||
"source_id": row.source_id,
|
||||
"approach_hint": row.approach_hint,
|
||||
"harness_profile_ref": row.harness_profile_ref,
|
||||
"execution_refs": dict(row.execution_refs or {}),
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
"result": {
|
||||
|
|
|
|||
166
tests/test_glas_profile_selection.py
Normal file
166
tests/test_glas_profile_selection.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""Profile-driven execution selection (ACT-ADR-006 / ACTIVITY-WP-0032-T02)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from activity_core.glas_profile import (
|
||||
EXECUTION_REF_KEYS,
|
||||
ProfileRefError,
|
||||
normalise_execution_refs,
|
||||
resolve_execution_selector,
|
||||
validate_profile_ref,
|
||||
)
|
||||
|
||||
|
||||
class TestProfileRefStructure:
|
||||
def test_accepts_a_pinned_ref(self) -> None:
|
||||
assert (
|
||||
validate_profile_ref("harness.agent-dev-local@1.0.0")
|
||||
== "harness.agent-dev-local@1.0.0"
|
||||
)
|
||||
|
||||
def test_strips_surrounding_whitespace(self) -> None:
|
||||
assert validate_profile_ref(" harness.agent-dev@1.0.0\n") == "harness.agent-dev@1.0.0"
|
||||
|
||||
def test_requires_a_version_pin(self) -> None:
|
||||
"""Glas resolves an unpinned ref against every version and refuses it
|
||||
as ambiguous — catch that here instead, without knowing any profile."""
|
||||
with pytest.raises(ProfileRefError, match="must pin a version"):
|
||||
validate_profile_ref("harness.agent-dev-local")
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", " ", None, 42, ["harness@1.0.0"]])
|
||||
def test_rejects_empty_or_non_string(self, bad) -> None:
|
||||
with pytest.raises(ProfileRefError):
|
||||
validate_profile_ref(bad)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
["harness agent@1.0.0", "harness.agent@1.0 .0", "harness\tagent@1.0.0"],
|
||||
)
|
||||
def test_rejects_internal_whitespace(self, bad: str) -> None:
|
||||
with pytest.raises(ProfileRefError, match="whitespace"):
|
||||
validate_profile_ref(bad)
|
||||
|
||||
@pytest.mark.parametrize("bad", ["@1.0.0", "-harness@1.0.0", "har/ness@1.0.0"])
|
||||
def test_rejects_invalid_ids(self, bad: str) -> None:
|
||||
with pytest.raises(ProfileRefError, match="invalid id"):
|
||||
validate_profile_ref(bad)
|
||||
|
||||
@pytest.mark.parametrize("bad", ["harness@", "harness@v1.0.0", "harness@latest"])
|
||||
def test_rejects_invalid_versions(self, bad: str) -> None:
|
||||
with pytest.raises(ProfileRefError, match="invalid version"):
|
||||
validate_profile_ref(bad)
|
||||
|
||||
|
||||
class TestNoFallbackToApproachHint:
|
||||
"""ACT-ADR-006 §2 — the single most important property of this change.
|
||||
|
||||
approach_hint must never override, synthesize, or fall back from an absent
|
||||
or invalid harness_profile_ref. A silent fallback would reintroduce the
|
||||
claim-time routing failure of 2026-08-17.
|
||||
"""
|
||||
|
||||
def test_hint_does_not_synthesize_a_missing_profile(self) -> None:
|
||||
profile, hint = resolve_execution_selector(None, "sbom-rescan")
|
||||
|
||||
assert profile is None, "approach_hint must not become a profile ref"
|
||||
assert hint == "sbom-rescan"
|
||||
|
||||
def test_hint_does_not_rescue_a_malformed_profile(self) -> None:
|
||||
with pytest.raises(ProfileRefError, match="must pin a version"):
|
||||
resolve_execution_selector("harness.agent-dev-local", "sbom-rescan")
|
||||
|
||||
def test_malformed_profile_raises_even_with_no_hint(self) -> None:
|
||||
with pytest.raises(ProfileRefError):
|
||||
resolve_execution_selector("not a ref", None)
|
||||
|
||||
def test_profile_does_not_overwrite_the_legacy_hint(self) -> None:
|
||||
"""Coexistence, not replacement: both are carried."""
|
||||
profile, hint = resolve_execution_selector(
|
||||
"harness.agent-dev-local@1.0.0", "sbom-rescan"
|
||||
)
|
||||
|
||||
assert profile == "harness.agent-dev-local@1.0.0"
|
||||
assert hint == "sbom-rescan"
|
||||
|
||||
def test_absent_profile_is_allowed_during_coexistence(self) -> None:
|
||||
assert resolve_execution_selector(None, None) == (None, None)
|
||||
|
||||
def test_absent_profile_is_an_error_once_required(self, monkeypatch) -> None:
|
||||
monkeypatch.setenv("ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE", "true")
|
||||
|
||||
with pytest.raises(ProfileRefError, match="cannot substitute"):
|
||||
resolve_execution_selector(None, "sbom-rescan")
|
||||
|
||||
def test_required_mode_still_accepts_a_valid_profile(self, monkeypatch) -> None:
|
||||
monkeypatch.setenv("ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE", "1")
|
||||
|
||||
profile, _ = resolve_execution_selector("harness.agent-dev@2.1.0", None)
|
||||
|
||||
assert profile == "harness.agent-dev@2.1.0"
|
||||
|
||||
def test_blank_hint_is_normalised_away(self) -> None:
|
||||
assert resolve_execution_selector(None, " ") == (None, None)
|
||||
|
||||
|
||||
class TestExecutionRefs:
|
||||
def test_keeps_the_contract_refs(self) -> None:
|
||||
refs = normalise_execution_refs(
|
||||
{
|
||||
"correlation_id": "activity-42",
|
||||
"assignment_ref": "role-assignment:agent-7:42",
|
||||
"role_ref": "role:maintainer@3",
|
||||
"duty_ref": "duty:bounded-change@2",
|
||||
"goal_refs": ["goal:reliable-delivery@5"],
|
||||
"resource_envelope_refs": ["resource-envelope:standard-coding@1"],
|
||||
}
|
||||
)
|
||||
|
||||
assert set(refs) == set(EXECUTION_REF_KEYS)
|
||||
assert refs["goal_refs"] == ["goal:reliable-delivery@5"]
|
||||
|
||||
def test_drops_unknown_keys(self) -> None:
|
||||
"""We carry the contract's refs; we do not become a bag for anything."""
|
||||
refs = normalise_execution_refs(
|
||||
{"role_ref": "role:maintainer@3", "api_key": "sk-nope", "extra": {"a": 1}}
|
||||
)
|
||||
|
||||
assert refs == {"role_ref": "role:maintainer@3"}
|
||||
|
||||
def test_drops_empty_and_wrongly_typed_values(self) -> None:
|
||||
refs = normalise_execution_refs(
|
||||
{
|
||||
"correlation_id": " ",
|
||||
"role_ref": 17,
|
||||
"goal_refs": "not-a-list",
|
||||
"resource_envelope_refs": ["ok", "", " "],
|
||||
}
|
||||
)
|
||||
|
||||
assert refs == {"resource_envelope_refs": ["ok"]}
|
||||
|
||||
@pytest.mark.parametrize("bad", [None, "refs", ["a"], 5])
|
||||
def test_non_dict_becomes_empty(self, bad) -> None:
|
||||
assert normalise_execution_refs(bad) == {}
|
||||
|
||||
|
||||
def test_ops_run_model_carries_the_new_columns() -> None:
|
||||
from activity_core.orm import OpsRun
|
||||
|
||||
columns = OpsRun.__table__.columns
|
||||
assert "harness_profile_ref" in columns
|
||||
assert columns["harness_profile_ref"].nullable is True
|
||||
assert "execution_refs" in columns
|
||||
assert columns["execution_refs"].nullable is False
|
||||
|
||||
|
||||
def test_queue_projection_exposes_selection_fields() -> None:
|
||||
"""Executors claim from this projection; the profile must reach them."""
|
||||
import inspect
|
||||
|
||||
from activity_core import ops_run_queue
|
||||
|
||||
source = inspect.getsource(ops_run_queue)
|
||||
assert '"harness_profile_ref": row.harness_profile_ref' in source
|
||||
assert '"execution_refs"' in source
|
||||
|
|
@ -117,10 +117,27 @@ Written up as `docs/adr/adr-006-glas-profile-execution.md`.
|
|||
|
||||
```task
|
||||
id: ACTIVITY-WP-0032-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Done 2026-08-21. `ops_runs` grows `harness_profile_ref` (text, indexed) and
|
||||
`execution_refs` (jsonb, default `{}`), migration `0008`, threaded through
|
||||
`ops_run_queue.py`, `activities.py`, and `run_artifacts.py`.
|
||||
`src/activity_core/glas_profile.py` holds the selection rules;
|
||||
`docs/ops-run-queue.md` documents the contract.
|
||||
|
||||
The no-fallback rule is enforced in `resolve_execution_selector`, not merely
|
||||
documented: a malformed ref raises even when a hint is present, and
|
||||
`ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE` makes an absent one an error once
|
||||
definitions have migrated. 34 tests, with the `TestNoFallbackToApproachHint`
|
||||
class covering the property directly.
|
||||
|
||||
Migration verified against a real PostgreSQL 16: upgrade → columns and index
|
||||
present → downgrade 0007 → clean re-upgrade. A legacy-shaped row (approach_hint
|
||||
only, no profile) still inserts, defaults `execution_refs` to `{}`, and stays
|
||||
`open`/claimable — existing production runs are unaffected.
|
||||
|
||||
Add `harness_profile_ref` and the correlation/attribution refs
|
||||
to the ops_run model, emission path (`activities.py`), queue projection
|
||||
(`ops_run_queue.py`), and run artefacts (`run_artifacts.py`), with a migration.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue