|
|
@@ -10,11 +10,15 @@ from agent.orchestration.client_v2 import OrchestrationAPIError, OrchestrationCl
|
|
|
from agent.orchestration.coordinator import TaskConflict
|
|
|
from agent.orchestration.models import (
|
|
|
AcceptanceCriterion,
|
|
|
+ ArtifactRef,
|
|
|
+ ArtifactSnapshot,
|
|
|
BackgroundOperation,
|
|
|
+ DecisionAction,
|
|
|
EventPage,
|
|
|
OperationKind,
|
|
|
OperationStatus,
|
|
|
OrchestrationEvent,
|
|
|
+ PlannerDecision,
|
|
|
TaskAttempt,
|
|
|
TaskLedger,
|
|
|
TaskRecord,
|
|
|
@@ -32,8 +36,10 @@ class StubStore:
|
|
|
def __init__(self, ledger: TaskLedger, event: OrchestrationEvent) -> None:
|
|
|
self.ledger = ledger
|
|
|
self.event = event
|
|
|
+ self.load_calls = 0
|
|
|
|
|
|
async def load(self, root_trace_id):
|
|
|
+ self.load_calls += 1
|
|
|
if root_trace_id != self.ledger.root_trace_id:
|
|
|
raise TaskStoreNotFound(f"No task ledger for root trace {root_trace_id}")
|
|
|
return self.ledger
|
|
|
@@ -47,8 +53,9 @@ class StubStore:
|
|
|
|
|
|
|
|
|
class StubCoordinator:
|
|
|
- def __init__(self, store, operation):
|
|
|
+ def __init__(self, store, operation, artifact_store):
|
|
|
self.task_store = store
|
|
|
+ self.artifact_store = artifact_store
|
|
|
self.operation = operation
|
|
|
self.calls = []
|
|
|
|
|
|
@@ -77,6 +84,28 @@ class StubCoordinator:
|
|
|
self.calls.append(("resume", idempotency_key))
|
|
|
return replace(self.operation, status=OperationStatus.PENDING)
|
|
|
|
|
|
+ async def root_completion(self, root_trace_id):
|
|
|
+ ledger = await self.task_store.load(root_trace_id)
|
|
|
+ root = ledger.tasks[ledger.root_task_id]
|
|
|
+ return {
|
|
|
+ "root_task_id": root.task_id,
|
|
|
+ "root_objective": ledger.root_objective,
|
|
|
+ "status": root.status.value,
|
|
|
+ "blocked_reason": root.blocked_reason,
|
|
|
+ "result_summary": None,
|
|
|
+ "pending_tasks": ["must not leak"],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+class StubArtifactStore:
|
|
|
+ def __init__(self, snapshot):
|
|
|
+ self.snapshot = snapshot
|
|
|
+
|
|
|
+ async def get(self, root_trace_id, snapshot_id):
|
|
|
+ if root_trace_id != "root" or snapshot_id != self.snapshot.snapshot_id:
|
|
|
+ raise FileNotFoundError(snapshot_id)
|
|
|
+ return self.snapshot
|
|
|
+
|
|
|
|
|
|
@pytest.fixture
|
|
|
def api_fixture():
|
|
|
@@ -106,6 +135,7 @@ def api_fixture():
|
|
|
worker_trace_id="worker-1",
|
|
|
worker_preset="worker",
|
|
|
execution_mode="new",
|
|
|
+ accepted_child_decision_ids=(),
|
|
|
)
|
|
|
validation = ValidationReport(
|
|
|
validation_id="validation-1",
|
|
|
@@ -132,6 +162,17 @@ def api_fixture():
|
|
|
ledger.tasks[task.task_id] = task
|
|
|
ledger.attempts[attempt.attempt_id] = attempt
|
|
|
ledger.validations[validation.validation_id] = validation
|
|
|
+ decision = PlannerDecision(
|
|
|
+ decision_id="decision-1",
|
|
|
+ task_id=task.task_id,
|
|
|
+ action=DecisionAction.RETRY,
|
|
|
+ reason="retry",
|
|
|
+ from_status=TaskStatus.NEEDS_REPLAN,
|
|
|
+ to_status=TaskStatus.PENDING,
|
|
|
+ created_at=task.created_at,
|
|
|
+ )
|
|
|
+ ledger.decisions[decision.decision_id] = decision
|
|
|
+ ledger.operations[operation.operation_id] = operation
|
|
|
event = OrchestrationEvent(
|
|
|
schema_version=1,
|
|
|
event_id="event-1",
|
|
|
@@ -142,7 +183,15 @@ def api_fixture():
|
|
|
occurred_at="2026-07-18T00:00:00+00:00",
|
|
|
)
|
|
|
store = StubStore(ledger, event)
|
|
|
- coordinator = StubCoordinator(store, operation)
|
|
|
+ snapshot = ArtifactSnapshot(
|
|
|
+ snapshot_id="snapshot-1",
|
|
|
+ attempt_id=attempt.attempt_id,
|
|
|
+ normalized_content={"summary": "must not leak"},
|
|
|
+ sha256="sha256",
|
|
|
+ artifact_refs=[ArtifactRef(uri="memory://artifact", version="1")],
|
|
|
+ evidence_refs=[],
|
|
|
+ )
|
|
|
+ coordinator = StubCoordinator(store, operation, StubArtifactStore(snapshot))
|
|
|
app = FastAPI()
|
|
|
app.include_router(create_orchestration_router(coordinator))
|
|
|
transport = httpx.ASGITransport(app=app)
|
|
|
@@ -270,6 +319,14 @@ async def test_async_client_round_trip_and_watch(api_fixture):
|
|
|
await client.get_validation("root", "validation-1")
|
|
|
).validation_id == "validation-1"
|
|
|
assert (await client.list_events("root", cursor=None)).next_cursor == "end"
|
|
|
+ snapshot = await client.get_mission_snapshot("root")
|
|
|
+ assert snapshot.revision == 3
|
|
|
+ assert snapshot.root_objective == "test"
|
|
|
+ assert snapshot.attempts[0].accepted_child_decision_ids == []
|
|
|
+ completion = await client.get_root_completion("root")
|
|
|
+ assert completion.root_task_id == "task-1"
|
|
|
+ artifact = await client.get_artifact_snapshot("root", "snapshot-1")
|
|
|
+ assert artifact.sha256 == "sha256"
|
|
|
|
|
|
stream = client.watch_events("root", wait_seconds=0)
|
|
|
assert (await anext(stream)).event_id == "event-1"
|
|
|
@@ -277,6 +334,49 @@ async def test_async_client_round_trip_and_watch(api_fixture):
|
|
|
await http.aclose()
|
|
|
|
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_atomic_mission_snapshot_completion_and_artifact_schema(api_fixture):
|
|
|
+ coordinator, http = api_fixture
|
|
|
+ coordinator.task_store.load_calls = 0
|
|
|
+ response = await http.get("/api/v2/roots/root/snapshot")
|
|
|
+ assert response.status_code == 200
|
|
|
+ assert coordinator.task_store.load_calls == 1
|
|
|
+ body = response.json()
|
|
|
+ assert set(body) == {
|
|
|
+ "schema_version",
|
|
|
+ "revision",
|
|
|
+ "root_trace_id",
|
|
|
+ "root_task_id",
|
|
|
+ "root_objective",
|
|
|
+ "focused_task_id",
|
|
|
+ "tasks",
|
|
|
+ "attempts",
|
|
|
+ "validations",
|
|
|
+ "decisions",
|
|
|
+ "operations",
|
|
|
+ }
|
|
|
+ assert body["schema_version"] == 1
|
|
|
+ assert "request" not in body["operations"][0]
|
|
|
+ assert "request_fingerprint" not in body["operations"][0]
|
|
|
+ assert body["attempts"][0]["accepted_child_decision_ids"] == []
|
|
|
+
|
|
|
+ completion = await http.get("/api/v2/roots/root/completion")
|
|
|
+ assert set(completion.json()) == {
|
|
|
+ "root_task_id",
|
|
|
+ "root_objective",
|
|
|
+ "status",
|
|
|
+ "blocked_reason",
|
|
|
+ "result_summary",
|
|
|
+ }
|
|
|
+ artifact = await http.get("/api/v2/roots/root/snapshots/snapshot-1")
|
|
|
+ assert artifact.status_code == 200
|
|
|
+ assert "normalized_content" not in artifact.json()
|
|
|
+ missing = await http.get("/api/v2/roots/root/snapshots/missing")
|
|
|
+ assert missing.status_code == 404
|
|
|
+ assert missing.json()["code"] == "not_found"
|
|
|
+ await http.aclose()
|
|
|
+
|
|
|
+
|
|
|
@pytest.mark.asyncio
|
|
|
async def test_client_transport_retry_and_non_problem_error():
|
|
|
calls = 0
|