| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570 |
- from __future__ import annotations
- import asyncio
- from types import SimpleNamespace
- import pytest
- from agent.orchestration import (
- ArtifactRef,
- DecisionAction,
- OperationStatus,
- TaskStatus,
- ValidationVerdict,
- )
- from script_build_host.application.mission_service import (
- BuildTransitionGate,
- DirectionReconciler,
- ScriptMissionService,
- StartScriptBuildCommand,
- )
- from script_build_host.domain.artifacts import (
- ArtifactKind,
- ArtifactState,
- DirectionArtifact,
- DirectionGoal,
- )
- from script_build_host.domain.errors import (
- DirectionProjectionConflict,
- InputRelationMismatch,
- ProtocolViolation,
- )
- from script_build_host.domain.records import BuildStatus, Principal, PublicationState
- class _RejectingInputs:
- async def validate_source(self, **_: object) -> None:
- raise InputRelationMismatch()
- class _SourceAuthorizer:
- async def require_source_access(self, *_args: object, **_kwargs: object) -> None:
- return None
- async def require_access(self, *_args: object, **_kwargs: object) -> None:
- return None
- class _LegacyState:
- def __init__(self) -> None:
- self.created = 0
- async def create(self, **_: object) -> int:
- self.created += 1
- return 1
- @pytest.mark.asyncio
- async def test_relation_mismatch_produces_no_build_or_binding_write() -> None:
- legacy = _LegacyState()
- bindings = SimpleNamespace(created=0)
- service = ScriptMissionService(
- runner=SimpleNamespace(),
- coordinator=SimpleNamespace(),
- factory=SimpleNamespace(),
- input_snapshots=_RejectingInputs(), # type: ignore[arg-type]
- bindings=bindings,
- legacy_state=legacy, # type: ignore[arg-type]
- authorizer=_SourceAuthorizer(),
- direction_reconciler=SimpleNamespace(),
- )
- with pytest.raises(InputRelationMismatch):
- await service.start(
- StartScriptBuildCommand(
- execution_id=1,
- topic_build_id=2,
- topic_id=3,
- principal=Principal("tester"),
- )
- )
- assert legacy.created == 0
- assert bindings.created == 0
- @pytest.mark.asyncio
- async def test_source_authorization_runs_before_input_or_build_access() -> None:
- legacy = _LegacyState()
- inputs = SimpleNamespace(validated=False)
- class Denied:
- async def require_source_access(self, *_args: object, **_kwargs: object) -> None:
- raise PermissionError("source forbidden")
- service = ScriptMissionService(
- runner=SimpleNamespace(),
- coordinator=SimpleNamespace(),
- factory=SimpleNamespace(),
- input_snapshots=inputs,
- bindings=SimpleNamespace(),
- legacy_state=legacy, # type: ignore[arg-type]
- authorizer=Denied(), # type: ignore[arg-type]
- direction_reconciler=SimpleNamespace(),
- )
- with pytest.raises(PermissionError, match="source forbidden"):
- await service.start(StartScriptBuildCommand(1, 2, 3, Principal("intruder")))
- assert legacy.created == 0
- assert inputs.validated is False
- class _StopState:
- def __init__(self, status: BuildStatus) -> None:
- self.status = status
- async def get_status(self, _build: int) -> BuildStatus:
- return self.status
- async def set_status(self, _build: int, status: BuildStatus, **_: object) -> None:
- self.status = status
- @pytest.mark.asyncio
- async def test_stop_waits_for_planner_even_when_there_are_no_operations() -> None:
- state = _StopState(BuildStatus.RUNNING)
- pending: asyncio.Future[None] = asyncio.get_running_loop().create_future()
- trace = SimpleNamespace(status="running")
- runner = SimpleNamespace(
- stop=lambda _root: _async_value(True),
- trace_store=SimpleNamespace(get_trace=lambda _root: _async_value(trace)),
- )
- coordinator = SimpleNamespace(
- task_store=SimpleNamespace(load=lambda _root: _async_value(SimpleNamespace(operations={})))
- )
- service = ScriptMissionService(
- runner=runner,
- coordinator=coordinator,
- factory=SimpleNamespace(),
- input_snapshots=SimpleNamespace(),
- bindings=SimpleNamespace(
- get_by_build=lambda _build: _async_value(SimpleNamespace(root_trace_id="root"))
- ),
- legacy_state=state,
- authorizer=SimpleNamespace(require_access=lambda _principal, _build: _async_value(None)),
- direction_reconciler=SimpleNamespace(),
- stop_timeout_seconds=0.01,
- )
- service._runs[7] = pending # type: ignore[assignment]
- result = await service.stop(7, Principal("owner"))
- assert result.status == BuildStatus.STOPPING
- assert state.status == BuildStatus.STOPPING
- pending.cancel()
- @pytest.mark.asyncio
- async def test_stop_is_idempotent_and_publication_is_gated_by_durable_status() -> None:
- state = _StopState(BuildStatus.STOPPED)
- service = ScriptMissionService(
- runner=SimpleNamespace(),
- coordinator=SimpleNamespace(),
- factory=SimpleNamespace(),
- input_snapshots=SimpleNamespace(),
- bindings=SimpleNamespace(),
- legacy_state=state,
- authorizer=SimpleNamespace(require_access=lambda _principal, _build: _async_value(None)),
- direction_reconciler=SimpleNamespace(),
- )
- result = await service.stop(7, Principal("owner"))
- assert result.status == BuildStatus.STOPPED
- state.status = BuildStatus.STOPPING
- publications = SimpleNamespace(prepared=False)
- reconciler = DirectionReconciler(
- coordinator=SimpleNamespace(),
- bindings=SimpleNamespace(),
- artifacts=SimpleNamespace(),
- publications=publications,
- legacy_state=state,
- )
- with pytest.raises(ProtocolViolation, match="forbidden while stopping"):
- await reconciler.reconcile(7, "root")
- assert publications.prepared is False
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ("publication_state", "has_projection", "active"),
- [
- (PublicationState.PENDING, True, None),
- (PublicationState.FAILED, True, 3),
- (PublicationState.PUBLISHED, True, 3),
- ],
- )
- async def test_direction_reconciler_reenters_each_publication_crash_point(
- publication_state: PublicationState,
- has_projection: bool,
- active: int | None,
- ) -> None:
- ref = ArtifactRef(
- "script-build://artifact-versions/3",
- ArtifactKind.DIRECTION.value,
- "3",
- "sha256:" + "a" * 64,
- )
- direction = DirectionArtifact(
- goals=(
- DirectionGoal(
- "goal", "statement", "the statement is testable", success_criteria=("done",)
- ),
- ),
- evidence_refs=("script-build://artifact-versions/2",),
- )
- projected = direction.legacy_markdown if has_projection else None
- version = SimpleNamespace(
- artifact_version_id=3,
- canonical_sha256=ref.digest,
- state=(
- ArtifactState.PUBLISHED
- if publication_state is PublicationState.PUBLISHED
- else ArtifactState.FROZEN
- ),
- artifact=direction,
- )
- task = SimpleNamespace(
- task_id="direction-task",
- current_spec=SimpleNamespace(context_refs=("script-build://task-kinds/direction",)),
- status=TaskStatus.COMPLETED,
- decision_ids=["accept"],
- )
- attempt = SimpleNamespace(
- attempt_id="attempt",
- snapshot_id="snapshot",
- submission=SimpleNamespace(artifact_refs=(ref,)),
- )
- ledger = SimpleNamespace(
- tasks={task.task_id: task},
- decisions={
- "accept": SimpleNamespace(
- action=DecisionAction.ACCEPT,
- decision_id="accept",
- attempt_id="attempt",
- validation_id="validation",
- )
- },
- attempts={"attempt": attempt},
- validations={
- "validation": SimpleNamespace(
- attempt_id="attempt",
- verdict=ValidationVerdict.PASSED,
- snapshot_id="snapshot",
- )
- },
- )
- class Bindings:
- current = active
- async def get_by_build(self, _build: int):
- return SimpleNamespace(active_direction_artifact_version_id=self.current)
- async def set_active_direction(self, *, artifact_version_id: int, **_kwargs):
- self.current = artifact_version_id
- class Publications:
- state = publication_state
- published_calls = 0
- async def prepare(self, **_kwargs):
- return SimpleNamespace(publication_id=1, state=self.state)
- async def mark_published(self, _publication_id: int):
- self.state = PublicationState.PUBLISHED
- self.published_calls += 1
- async def mark_failed(self, *_args, **_kwargs):
- self.state = PublicationState.FAILED
- class State(_StopState):
- direction = projected
- async def get_direction(self, _build: int):
- return self.direction
- async def project_direction(self, _build: int, value: str):
- self.direction = value
- bindings = Bindings()
- publications = Publications()
- state = State(BuildStatus.PARTIAL)
- reconciler = DirectionReconciler(
- coordinator=SimpleNamespace(
- task_store=SimpleNamespace(load=lambda _root: _async_value(ledger))
- ),
- bindings=bindings,
- artifacts=SimpleNamespace(read_by_ref=lambda *_args, **_kwargs: _async_value(version)),
- publications=publications,
- legacy_state=state,
- )
- assert await reconciler.reconcile(7, "root") == 3
- assert state.direction == direction.legacy_markdown
- assert bindings.current == 3
- assert publications.state is PublicationState.PUBLISHED
- assert publications.published_calls == (
- 0 if publication_state is PublicationState.PUBLISHED else 1
- )
- @pytest.mark.asyncio
- async def test_direction_reconciler_fails_closed_on_projection_conflict() -> None:
- ref = ArtifactRef(
- "script-build://artifact-versions/3",
- ArtifactKind.DIRECTION.value,
- "3",
- "sha256:" + "a" * 64,
- )
- direction = DirectionArtifact(
- goals=(
- DirectionGoal(
- "goal", "statement", "the statement is testable", success_criteria=("done",)
- ),
- ),
- evidence_refs=("script-build://artifact-versions/2",),
- )
- task = SimpleNamespace(
- task_id="direction-task",
- current_spec=SimpleNamespace(context_refs=("script-build://task-kinds/direction",)),
- status=TaskStatus.COMPLETED,
- decision_ids=["accept"],
- )
- ledger = SimpleNamespace(
- tasks={task.task_id: task},
- decisions={
- "accept": SimpleNamespace(
- action=DecisionAction.ACCEPT,
- decision_id="accept",
- attempt_id="attempt",
- validation_id="validation",
- )
- },
- attempts={
- "attempt": SimpleNamespace(
- attempt_id="attempt",
- snapshot_id="snapshot",
- submission=SimpleNamespace(artifact_refs=(ref,)),
- )
- },
- validations={
- "validation": SimpleNamespace(
- attempt_id="attempt",
- verdict=ValidationVerdict.PASSED,
- snapshot_id="snapshot",
- )
- },
- )
- class Publications:
- failed = False
- async def prepare(self, **_kwargs):
- return SimpleNamespace(publication_id=1, state=PublicationState.PENDING)
- async def mark_failed(self, *_args, **_kwargs):
- self.failed = True
- publications = Publications()
- reconciler = DirectionReconciler(
- coordinator=SimpleNamespace(
- task_store=SimpleNamespace(load=lambda _root: _async_value(ledger))
- ),
- bindings=SimpleNamespace(),
- artifacts=SimpleNamespace(
- read_by_ref=lambda *_args, **_kwargs: _async_value(
- SimpleNamespace(
- artifact_version_id=3,
- canonical_sha256=ref.digest,
- state=ArtifactState.FROZEN,
- artifact=direction,
- )
- )
- ),
- publications=publications,
- legacy_state=SimpleNamespace(
- get_status=lambda _build: _async_value(BuildStatus.PARTIAL),
- get_direction=lambda _build: _async_value("# conflicting direction"),
- ),
- )
- with pytest.raises(DirectionProjectionConflict):
- await reconciler.reconcile(7, "root")
- assert publications.failed is True
- @pytest.mark.asyncio
- @pytest.mark.parametrize("status", [BuildStatus.FAILED, BuildStatus.SUCCESS])
- async def test_stop_does_not_rewrite_terminal_failed_or_success(status: BuildStatus) -> None:
- state = _StopState(status)
- service = ScriptMissionService(
- runner=SimpleNamespace(),
- coordinator=SimpleNamespace(),
- factory=SimpleNamespace(),
- input_snapshots=SimpleNamespace(),
- bindings=SimpleNamespace(),
- legacy_state=state,
- authorizer=SimpleNamespace(require_access=lambda _principal, _build: _async_value(None)),
- direction_reconciler=SimpleNamespace(),
- )
- result = await service.stop(7, Principal("owner"))
- assert result.status is status
- assert state.status is status
- @pytest.mark.asyncio
- async def test_stop_intent_and_direction_reconcile_are_serialized() -> None:
- gate = BuildTransitionGate()
- entered = asyncio.Event()
- release = asyncio.Event()
- state = _StopState(BuildStatus.RUNNING)
- class BlockingReconciler:
- transition_gate = gate
- async def reconcile(self, script_build_id: int, _root: str) -> int:
- async with gate.hold(script_build_id):
- entered.set()
- await release.wait()
- assert state.status is BuildStatus.RUNNING
- return 1
- runner = SimpleNamespace(
- stop=lambda _root: _async_value(True),
- trace_store=SimpleNamespace(get_trace=lambda _root: _async_value(None)),
- )
- coordinator = SimpleNamespace(
- task_store=SimpleNamespace(load=lambda _root: _async_value(SimpleNamespace(operations={})))
- )
- service = ScriptMissionService(
- runner=runner,
- coordinator=coordinator,
- factory=SimpleNamespace(),
- input_snapshots=SimpleNamespace(),
- bindings=SimpleNamespace(
- get_by_build=lambda _build: _async_value(SimpleNamespace(root_trace_id="root"))
- ),
- legacy_state=state,
- authorizer=SimpleNamespace(require_access=lambda _principal, _build: _async_value(None)),
- direction_reconciler=BlockingReconciler(), # type: ignore[arg-type]
- transition_gate=gate,
- )
- reconcile = asyncio.create_task(service.direction_reconciler.reconcile(7, "root"))
- await entered.wait()
- stop = asyncio.create_task(service.stop(7, Principal("owner")))
- await asyncio.sleep(0)
- assert state.status is BuildStatus.RUNNING
- release.set()
- assert await reconcile == 1
- assert (await stop).status is BuildStatus.STOPPED
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- "initial_status",
- [BuildStatus.RUNNING, BuildStatus.STOPPING, BuildStatus.PARTIAL],
- )
- async def test_stop_closes_all_nonterminal_states_when_no_execution_remains(
- initial_status: BuildStatus,
- ) -> None:
- state = _StopState(initial_status)
- service = ScriptMissionService(
- runner=SimpleNamespace(
- stop=lambda _root: _async_value(True),
- trace_store=SimpleNamespace(get_trace=lambda _root: _async_value(None)),
- ),
- coordinator=SimpleNamespace(
- task_store=SimpleNamespace(
- load=lambda _root: _async_value(SimpleNamespace(operations={}))
- )
- ),
- factory=SimpleNamespace(),
- input_snapshots=SimpleNamespace(),
- bindings=SimpleNamespace(
- get_by_build=lambda _build: _async_value(SimpleNamespace(root_trace_id="root"))
- ),
- legacy_state=state,
- authorizer=SimpleNamespace(require_access=lambda _principal, _build: _async_value(None)),
- direction_reconciler=SimpleNamespace(),
- )
- result = await service.stop(7, Principal("owner"))
- assert result.status is BuildStatus.STOPPED
- assert state.status is BuildStatus.STOPPED
- @pytest.mark.asyncio
- async def test_stop_timeout_keeps_intent_and_reports_active_operation_ids() -> None:
- state = _StopState(BuildStatus.RUNNING)
- operation = SimpleNamespace(
- operation_id="operation-1",
- status=OperationStatus.RUNNING,
- )
- service = ScriptMissionService(
- runner=SimpleNamespace(
- stop=lambda _root: _async_value(True),
- trace_store=SimpleNamespace(get_trace=lambda _root: _async_value(None)),
- ),
- coordinator=SimpleNamespace(
- task_store=SimpleNamespace(
- load=lambda _root: _async_value(
- SimpleNamespace(operations={"operation-1": operation})
- )
- ),
- stop_operation=lambda *_args, **_kwargs: _async_value(None),
- get_operation=lambda *_args, **_kwargs: _async_value(operation),
- ),
- factory=SimpleNamespace(),
- input_snapshots=SimpleNamespace(),
- bindings=SimpleNamespace(
- get_by_build=lambda _build: _async_value(SimpleNamespace(root_trace_id="root"))
- ),
- legacy_state=state,
- authorizer=SimpleNamespace(require_access=lambda _principal, _build: _async_value(None)),
- direction_reconciler=SimpleNamespace(),
- stop_timeout_seconds=0.01,
- )
- result = await service.stop(7, Principal("owner"))
- assert result.status is BuildStatus.STOPPING
- assert result.stopped_operation_ids == ("operation-1",)
- assert state.status is BuildStatus.STOPPING
- @pytest.mark.asyncio
- async def test_stop_with_missing_binding_records_terminal_failure() -> None:
- class RecordingState(_StopState):
- error_summary: str | None = None
- async def set_status(
- self,
- _build: int,
- status: BuildStatus,
- *,
- error_summary: str | None = None,
- ) -> None:
- self.status = status
- self.error_summary = error_summary
- state = RecordingState(BuildStatus.PARTIAL)
- async def missing_binding(_build: int):
- raise LookupError("missing")
- service = ScriptMissionService(
- runner=SimpleNamespace(),
- coordinator=SimpleNamespace(),
- factory=SimpleNamespace(),
- input_snapshots=SimpleNamespace(),
- bindings=SimpleNamespace(get_by_build=missing_binding),
- legacy_state=state,
- authorizer=SimpleNamespace(require_access=lambda _principal, _build: _async_value(None)),
- direction_reconciler=SimpleNamespace(),
- )
- with pytest.raises(LookupError, match="missing"):
- await service.stop(7, Principal("owner"))
- assert state.status is BuildStatus.FAILED
- assert state.error_summary == "MISSION_BINDING_MISSING"
- async def _async_value(value):
- return value
|