Przeglądaj źródła

test(agent): verify generic mission observation e2e

SamLee 2 dni temu
rodzic
commit
e24bfe4bf2

+ 7 - 4
agent/agent/core/runner.py

@@ -20,7 +20,10 @@ import logging
 import uuid
 import uuid
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 from datetime import datetime
 from datetime import datetime
-from typing import AsyncIterator, Optional, Dict, Any, List, Callable, Literal, Tuple, Union
+from typing import AsyncIterator, Optional, Dict, Any, List, Callable, Literal, Tuple, Union, TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from agent.core.dream import DreamReport
 
 
 from agent.trace.models import Trace, Message
 from agent.trace.models import Trace, Message
 from agent.trace.protocols import TraceStore
 from agent.trace.protocols import TraceStore
@@ -1401,7 +1404,7 @@ class AgentRunner:
                         await self.trace_store.update_goal_tree(trace_id, goal_tree)
                         await self.trace_store.update_goal_tree(trace_id, goal_tree)
                     self.log.info(f"自动创建 root goal: {goal_tree.goals[0].id}(未自动 focus,等待模型决定)")
                     self.log.info(f"自动创建 root goal: {goal_tree.goals[0].id}(未自动 focus,等待模型决定)")
                 else:
                 else:
-                    self.log.debug(f"[Auto Root Goal] 检测到 goal 工具调用,跳过自动创建")
+                    self.log.debug("[Auto Root Goal] 检测到 goal 工具调用,跳过自动创建")
 
 
             # 获取当前 goal_id
             # 获取当前 goal_id
             current_goal_id = goal_tree.current_id if (goal_tree and goal_tree.current_id) else None
             current_goal_id = goal_tree.current_id if (goal_tree and goal_tree.current_id) else None
@@ -1763,7 +1766,7 @@ class AgentRunner:
                         if tool_name == "goal" and goal_tree:
                         if tool_name == "goal" and goal_tree:
                             self.log.debug(f"[Goal Tool] After execution: goal_tree.goals={len(goal_tree.goals)}, current_id={goal_tree.current_id}")
                             self.log.debug(f"[Goal Tool] After execution: goal_tree.goals={len(goal_tree.goals)}, current_id={goal_tree.current_id}")
                         if tool_name == "upload_knowledge" and isinstance(tool_result, dict):
                         if tool_name == "upload_knowledge" and isinstance(tool_result, dict):
-                            self.log.info(f"[Knowledge Tracking] 知识已上传")
+                            self.log.info("[Knowledge Tracking] 知识已上传")
                         if isinstance(tool_result, str):
                         if isinstance(tool_result, str):
                             tool_result = {"text": tool_result}
                             tool_result = {"text": tool_result}
                         elif not isinstance(tool_result, dict):
                         elif not isinstance(tool_result, dict):
@@ -1895,7 +1898,7 @@ class AgentRunner:
                         if tool_name == "upload_knowledge" and isinstance(tool_result, dict):
                         if tool_name == "upload_knowledge" and isinstance(tool_result, dict):
                             # upload_knowledge 返回的是统计信息,不是单个 knowledge_id
                             # upload_knowledge 返回的是统计信息,不是单个 knowledge_id
                             # 这里只记录上传动作,不跟踪具体 ID
                             # 这里只记录上传动作,不跟踪具体 ID
-                            self.log.info(f"[Knowledge Tracking] 知识已上传到 Knowledge Manager")
+                            self.log.info("[Knowledge Tracking] 知识已上传到 Knowledge Manager")
     
     
                         # --- 支持多模态工具反馈 ---
                         # --- 支持多模态工具反馈 ---
                         # execute() 返回 dict{"text","images","tool_usage"} 或 str
                         # execute() 返回 dict{"text","images","tool_usage"} 或 str

+ 13 - 4
agent/agent/docs/orchestration-v1.md

@@ -83,7 +83,7 @@ Worker 只能使用 `submit_attempt` 正式交付。Validator 只能使用 `subm
 - Planner 是唯一可以改变任务树和作出完成决策的角色。
 - Planner 是唯一可以改变任务树和作出完成决策的角色。
 - Ledger 中只有一个特殊 Root Task;调用方提供它的 objective 和验收标准,框架不生成业务验收语义。
 - Ledger 中只有一个特殊 Root Task;调用方提供它的 objective 和验收标准,框架不生成业务验收语义。
 - 普通顶层 Task 默认挂在 Root 下;所有直接子 Task 终态后,父 Task 回到 `needs_replan`,不会自动完成。
 - 普通顶层 Task 默认挂在 Root 下;所有直接子 Task 终态后,父 Task 回到 `needs_replan`,不会自动完成。
-- 父 Worker 会收到直接、已接受子 Task 的不可变结果引用,用于产生父 Task 的新 Attempt
+- 父 Attempt 创建时会按直接子 Task 的 `display_path` 冻结 ACCEPT Decision ID;父 Worker 只解析这组不可变绑定,不扫描后续“最新状态”。旧 RUNNING Attempt 没有该绑定时按协议失败并重新规划
 - Worker 看不到且不能执行 `agent`、`evaluate`、`goal` 或 Planner/Validator 工具。
 - Worker 看不到且不能执行 `agent`、`evaluate`、`goal` 或 Planner/Validator 工具。
 - Validator 使用独立 Trace,只能访问 preset 明确允许的只读工具。
 - Validator 使用独立 Trace,只能访问 preset 明确允许的只读工具。
 - explicit 模式的工具必须声明 capability;未分类工具默认拒绝。Validator 仅允许 `read + validation_submit`,Worker 不允许 `agent_spawn` 和 `task_control`。
 - explicit 模式的工具必须声明 capability;未分类工具默认拒绝。Validator 仅允许 `read + validation_submit`,Worker 不允许 `agent_spawn` 和 `task_control`。
@@ -104,11 +104,20 @@ Worker 只能使用 `submit_attempt` 正式交付。Validator 只能使用 `subm
 ```text
 ```text
 .trace/{root_trace_id}/orchestration/
 .trace/{root_trace_id}/orchestration/
 ├── ledger.json
 ├── ledger.json
-├── artifacts/{snapshot_id}.json
-└── events.jsonl
+└── artifacts/{snapshot_id}.json
 ```
 ```
 
 
-`TaskStore`、`ArtifactStore`、`AgentExecutor`、`ToolPolicy`、`EventSink` 都是端口。V2 可增加远程执行器和公开 API,V3 可增加队列与分布式 Store,而不改变 Task 状态机和 Coordinator 决策语义。
+Orchestration Event v2 的 durable journal 位于 `ledger.json`,是默认唯一真源;只保存变化通知与实体 ID。`wire_orchestration(..., event_sink=...)` 可以显式注入外部 Sink;`TraceEventSink` 仅作为兼容镜像适配器,不再默认创建 `orchestration/events.jsonl`。Trace 自身的消息事件文件不受影响。
+
+## 通用观测 API
+
+- `GET /api/v2/roots/{root_trace_id}/snapshot`:单次 Ledger load 得到同一 revision 的 Mission 原子视图。
+- `GET /api/v2/roots/{root_trace_id}/completion`:Root objective、状态、阻塞原因与结果摘要。
+- `GET /api/v2/roots/{root_trace_id}/snapshots/{snapshot_id}`:Artifact 摘要哈希与不可变引用,不重复返回正文。
+- `GET /api/v2/roots/{root_trace_id}/events`:支持 v1/v2 混读的稳定 cursor。
+- `GET /api/v2/capabilities`:API、Mission Snapshot、Event schema 版本与能力发现。
+
+`OrchestrationClient` 提供对应的 Mission、Completion、Artifact、Event 读取方法,并可按 role、Root、Task、Attempt、Validation、Operation 或父 Trace 查询关联 Trace。`TaskStore`、`ArtifactStore`、`AgentExecutor`、`ToolPolicy`、`EventSink` 都是端口,可增加远程执行器或分布式 Store,而不改变 Task 状态机和 Coordinator 决策语义。
 
 
 ## V1 限制
 ## V1 限制
 
 

+ 34 - 0
agent/agent/orchestration/client_v2.py

@@ -220,6 +220,40 @@ class OrchestrationClient:
             )
             )
         )
         )
 
 
+    async def list_correlated_traces(
+        self,
+        *,
+        agent_role: Optional[str] = None,
+        parent_trace_id: Optional[str] = None,
+        root_trace_id: Optional[str] = None,
+        task_id: Optional[str] = None,
+        attempt_id: Optional[str] = None,
+        validation_id: Optional[str] = None,
+        operation_id: Optional[str] = None,
+        limit: int = 100,
+    ) -> List[Dict[str, Any]]:
+        params = {
+            key: value
+            for key, value in {
+                "agent_role": agent_role,
+                "parent_trace_id": parent_trace_id,
+                "root_trace_id": root_trace_id,
+                "task_id": task_id,
+                "attempt_id": attempt_id,
+                "validation_id": validation_id,
+                "operation_id": operation_id,
+                "limit": limit,
+            }.items()
+            if value is not None
+        }
+        value = await self._request("GET", "/api/traces", params=params)
+        traces = value.get("traces") if isinstance(value, dict) else None
+        if not isinstance(traces, list) or any(
+            not isinstance(item, dict) for item in traces
+        ):
+            raise ValueError("Trace API returned an invalid response")
+        return traces
+
     async def watch_events(
     async def watch_events(
         self,
         self,
         root_trace_id: str,
         root_trace_id: str,

+ 2 - 1
agent/agent/trace/api.py

@@ -11,7 +11,6 @@ from datetime import datetime, timezone
 from typing import List, Optional, Dict, Any
 from typing import List, Optional, Dict, Any
 from fastapi import APIRouter, HTTPException, Query
 from fastapi import APIRouter, HTTPException, Query
 from pydantic import BaseModel
 from pydantic import BaseModel
-from agent.llm.openrouter import openrouter_llm_call
 
 
 from .protocols import TraceStore
 from .protocols import TraceStore
 
 
@@ -304,6 +303,8 @@ async def extract_comment_proxy(req: Dict[str, Any]):
 }}"""
 }}"""
 
 
     try:
     try:
+        from agent.llm.openrouter import openrouter_llm_call
+
         response = await openrouter_llm_call(
         response = await openrouter_llm_call(
             messages=[{"role": "user", "content": prompt}],
             messages=[{"role": "user", "content": prompt}],
             model="google/gemini-2.5-flash-lite",
             model="google/gemini-2.5-flash-lite",

+ 58 - 2
agent/tests/test_attempt_input_binding.py

@@ -46,7 +46,9 @@ async def test_new_attempt_distinguishes_empty_and_ordered_child_bindings(tmp_pa
 
 
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio
-@pytest.mark.parametrize("corruption", ["duplicate", "reverse", "cross", "non_accept"])
+@pytest.mark.parametrize(
+    "corruption", ["duplicate", "reverse", "missing", "cross", "non_accept"]
+)
 async def test_frozen_child_decision_binding_rejects_corruption(tmp_path, corruption):
 async def test_frozen_child_decision_binding_rejects_corruption(tmp_path, corruption):
     executor = FakeExecutor([ValidationVerdict.PASSED, ValidationVerdict.PASSED])
     executor = FakeExecutor([ValidationVerdict.PASSED, ValidationVerdict.PASSED])
     coordinator, store, _ = await make_coordinator(tmp_path, executor)
     coordinator, store, _ = await make_coordinator(tmp_path, executor)
@@ -64,8 +66,11 @@ async def test_frozen_child_decision_binding_rejects_corruption(tmp_path, corrup
         attempt.accepted_child_decision_ids = (ids[0], ids[0])
         attempt.accepted_child_decision_ids = (ids[0], ids[0])
     elif corruption == "reverse":
     elif corruption == "reverse":
         attempt.accepted_child_decision_ids = tuple(reversed(ids))
         attempt.accepted_child_decision_ids = tuple(reversed(ids))
-    elif corruption == "cross":
+    elif corruption == "missing":
         attempt.accepted_child_decision_ids = ("missing-decision",)
         attempt.accepted_child_decision_ids = ("missing-decision",)
+    elif corruption == "cross":
+        decision = ledger.decisions[ids[0]]
+        object.__setattr__(decision, "task_id", root.task_id)
     else:
     else:
         decision = ledger.decisions[ids[0]]
         decision = ledger.decisions[ids[0]]
         object.__setattr__(decision, "action", DecisionAction.RETRY)
         object.__setattr__(decision, "action", DecisionAction.RETRY)
@@ -73,6 +78,57 @@ async def test_frozen_child_decision_binding_rejects_corruption(tmp_path, corrup
         coordinator._accepted_child_results(ledger, root, attempt)
         coordinator._accepted_child_results(ledger, root, attempt)
 
 
 
 
+@pytest.mark.asyncio
+@pytest.mark.parametrize("missing", ["attempt", "validation", "snapshot"])
+async def test_child_accept_binding_requires_complete_references(tmp_path, missing):
+    executor = FakeExecutor([ValidationVerdict.PASSED])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    child = await create_task(coordinator, "child")
+    await _accept_child(coordinator, child, "accept-child")
+    ledger = await store.load("root")
+    root = ledger.tasks[ledger.root_task_id]
+    reserved = await coordinator._create_attempt("root", root.task_id, "worker", None)
+    ledger = await store.load("root")
+    parent_attempt = ledger.attempts[reserved["attempt_id"]]
+    decision = ledger.decisions[parent_attempt.accepted_child_decision_ids[0]]
+    child_attempt = ledger.attempts[decision.attempt_id]
+    if missing == "attempt":
+        del ledger.attempts[decision.attempt_id]
+    elif missing == "validation":
+        del ledger.validations[decision.validation_id]
+    else:
+        child_attempt.snapshot_id = None
+    with pytest.raises(OrchestrationError):
+        coordinator._accepted_child_results(ledger, root, parent_attempt)
+
+
+@pytest.mark.asyncio
+async def test_missing_artifact_snapshot_fails_parent_as_protocol_violation(tmp_path):
+    executor = FakeExecutor([ValidationVerdict.PASSED])
+    coordinator, store, _ = await make_coordinator(tmp_path, executor)
+    child = await create_task(coordinator, "child")
+    await _accept_child(coordinator, child, "accept-child")
+    ledger = await store.load("root")
+    root = ledger.tasks[ledger.root_task_id]
+    reserved = await coordinator._create_attempt("root", root.task_id, "worker", None)
+    decision = ledger.decisions[ledger.tasks[child].decision_ids[-1]]
+    child_attempt = ledger.attempts[decision.attempt_id]
+    artifact_path = (
+        tmp_path
+        / "root"
+        / "orchestration"
+        / "artifacts"
+        / f"{child_attempt.snapshot_id}.json"
+    )
+    artifact_path.unlink()
+
+    result = await coordinator.advance_cycle("root", root.task_id, reserved["attempt_id"])
+    failed = (await store.load("root")).attempts[reserved["attempt_id"]]
+    assert "Invalid frozen child input binding" in result.error
+    assert failed.execution_stats.failure_code == FailureCode.PROTOCOL_VIOLATION
+    assert executor.worker_calls == 1  # child only; the parent Worker was never invoked
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_legacy_running_attempt_never_guesses_child_inputs(tmp_path):
 async def test_legacy_running_attempt_never_guesses_child_inputs(tmp_path):
     executor = FakeExecutor([])
     executor = FakeExecutor([])

+ 140 - 0
agent/tests/test_framework_observation_e2e.py

@@ -0,0 +1,140 @@
+import httpx
+import pytest
+
+import agent as framework
+from agent.orchestration.api_v2 import create_orchestration_router
+from agent.tools.builtin.knowledge import KnowledgeConfig
+from agent.trace.api import router as trace_router
+from agent.trace.api import set_trace_store
+
+from test_mission_loop import MissionLoopLLM, ROOT_OBJECTIVE, ROOT_OBJECTIVE_V2
+
+
+FastAPI = pytest.importorskip("fastapi").FastAPI
+
+
+def _knowledge_off():
+    return KnowledgeConfig(
+        enable_extraction=False,
+        enable_completion_extraction=False,
+        enable_injection=False,
+    )
+
+
+def _all_keys(value):
+    if isinstance(value, dict):
+        return set(value).union(*(_all_keys(item) for item in value.values()))
+    if isinstance(value, list):
+        return set().union(*(_all_keys(item) for item in value))
+    return set()
+
+
+@pytest.mark.asyncio
+async def test_public_framework_mission_is_fully_observable_without_ledger_access(
+    tmp_path,
+):
+    root_trace_id = "framework-blackbox-root"
+    trace_store = framework.FileSystemTraceStore(str(tmp_path))
+    task_store = framework.FileSystemTaskStore(str(tmp_path))
+    fake_llm = MissionLoopLLM(task_store)
+    fake_llm.root_trace_id = root_trace_id
+    runner = framework.AgentRunner(trace_store=trace_store, llm_call=fake_llm)
+    coordinator = framework.wire_orchestration(
+        runner,
+        task_store,
+        framework.FileSystemArtifactStore(str(tmp_path)),
+    )
+
+    result = await runner.run_result(
+        [{"role": "user", "content": "run a generic observable mission"}],
+        framework.RunConfig(
+            agent_type="planner",
+            completion_policy=framework.CompletionPolicy.EXPLICIT_VALIDATION,
+            max_iterations=30,
+            new_trace_id=root_trace_id,
+            tools=["task_plan", "dispatch_tasks", "task_decide"],
+            tool_groups=[],
+            enable_memory=False,
+            enable_research_flow=False,
+            knowledge=_knowledge_off(),
+            root_task_spec={
+                "objective": ROOT_OBJECTIVE,
+                "acceptance_criteria": [{
+                    "criterion_id": "root-ready",
+                    "description": "the root result is ready",
+                    "hard": True,
+                }],
+                "context_refs": ["memory://input/root"],
+            },
+        ),
+    )
+    assert result["status"] == "completed"
+
+    app = FastAPI()
+    app.include_router(create_orchestration_router(coordinator))
+    set_trace_store(trace_store)
+    app.include_router(trace_router)
+    http = httpx.AsyncClient(
+        transport=httpx.ASGITransport(app=app), base_url="http://framework"
+    )
+    client = framework.OrchestrationClient("http://framework", client=http)
+
+    mission = await client.get_mission_snapshot(root_trace_id)
+    completion = await client.get_root_completion(root_trace_id)
+    events = await client.list_events(root_trace_id, limit=1000)
+    traces = await client.list_correlated_traces(
+        root_trace_id=root_trace_id, limit=100
+    )
+
+    assert mission.root_objective == ROOT_OBJECTIVE_V2
+    assert completion.root_objective == ROOT_OBJECTIVE_V2
+    assert completion.status == framework.TaskStatus.COMPLETED
+    assert completion.result_summary == f"submitted: {ROOT_OBJECTIVE_V2}"
+    assert any(
+        decision.action.value == "revise"
+        and decision.task_id == mission.root_task_id
+        for decision in mission.decisions
+    )
+    assert {event.schema_version for event in events.events} == {2}
+    assert {trace["agent_role"] for trace in traces} == {
+        "planner",
+        "worker",
+        "validator",
+    }
+
+    attempts = {item.attempt_id: item for item in mission.attempts}
+    validations = {item.validation_id: item for item in mission.validations}
+    trace_ids = {trace["trace_id"] for trace in traces}
+    accepted = [
+        decision for decision in mission.decisions
+        if decision.action.value == "accept"
+    ]
+    assert len(accepted) == 2
+    for decision in accepted:
+        attempt = attempts[decision.attempt_id]
+        validation = validations[decision.validation_id]
+        artifact = await client.get_artifact_snapshot(
+            root_trace_id, attempt.snapshot_id
+        )
+        assert attempt.task_id == decision.task_id
+        assert validation.task_id == decision.task_id
+        assert validation.attempt_id == attempt.attempt_id
+        assert validation.snapshot_id == artifact.snapshot_id
+        assert artifact.attempt_id == attempt.attempt_id
+        assert attempt.worker_trace_id in trace_ids
+        assert validation.validator_trace_id in trace_ids
+
+    root_attempt = attempts[next(
+        decision.attempt_id
+        for decision in accepted
+        if decision.task_id == mission.root_task_id
+    )]
+    child_accept = next(
+        decision for decision in accepted
+        if decision.task_id != mission.root_task_id
+    )
+    assert root_attempt.accepted_child_decision_ids == [child_accept.decision_id]
+
+    forbidden = {"script", "workflow", "round", "branch", "page_nodes"}
+    assert forbidden.isdisjoint(_all_keys(mission.model_dump(mode="json")))
+    await http.aclose()

+ 20 - 2
agent/tests/test_mission_loop.py

@@ -18,6 +18,7 @@ from agent.trace.store import FileSystemTraceStore
 
 
 
 
 ROOT_OBJECTIVE = "complete the generic root"
 ROOT_OBJECTIVE = "complete the generic root"
+ROOT_OBJECTIVE_V2 = "complete the revised generic root"
 CHILD_OBJECTIVE_V1 = "produce the generic unit"
 CHILD_OBJECTIVE_V1 = "produce the generic unit"
 CHILD_OBJECTIVE_V2 = "produce the revised generic unit"
 CHILD_OBJECTIVE_V2 = "produce the revised generic unit"
 
 
@@ -86,7 +87,7 @@ class MissionLoopLLM:
     def _worker(self, messages):
     def _worker(self, messages):
         prompt = _latest_json_user_prompt(messages)
         prompt = _latest_json_user_prompt(messages)
         spec = prompt["task_spec"]
         spec = prompt["task_spec"]
-        if spec["objective"] == ROOT_OBJECTIVE:
+        if spec["objective"] in (ROOT_OBJECTIVE, ROOT_OBJECTIVE_V2):
             self.root_worker_prompt = prompt
             self.root_worker_prompt = prompt
         return self._call("submit_attempt", {
         return self._call("submit_attempt", {
             "summary": f"submitted: {spec['objective']}",
             "summary": f"submitted: {spec['objective']}",
@@ -184,7 +185,22 @@ class MissionLoopLLM:
             })
             })
 
 
         assert child.status == TaskStatus.COMPLETED
         assert child.status == TaskStatus.COMPLETED
-        if root.status == TaskStatus.NEEDS_REPLAN:
+        if root.status in (TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN):
+            if root.current_spec_version == 1:
+                return self._call("task_decide", {
+                    "task_id": root.task_id,
+                    "action": "revise",
+                    "reason": "clarify the root objective after child acceptance",
+                    "payload": {
+                        "objective": ROOT_OBJECTIVE_V2,
+                        "acceptance_criteria": [{
+                            "criterion_id": "root-ready",
+                            "description": "the revised root result is ready",
+                            "hard": True,
+                        }],
+                        "context_refs": ["memory://input/root"],
+                    },
+                })
             return self._call("dispatch_tasks", {"task_ids": [root.task_id]})
             return self._call("dispatch_tasks", {"task_ids": [root.task_id]})
         if root.status == TaskStatus.AWAITING_DECISION:
         if root.status == TaskStatus.AWAITING_DECISION:
             validation = ledger.validations[root.validation_ids[-1]]
             validation = ledger.validations[root.validation_ids[-1]]
@@ -262,6 +278,8 @@ async def test_real_runner_completes_replanned_root_mission(tmp_path):
     assert child.current_spec_version == 2
     assert child.current_spec_version == 2
     assert child.status == TaskStatus.COMPLETED
     assert child.status == TaskStatus.COMPLETED
     assert root.status == TaskStatus.COMPLETED
     assert root.status == TaskStatus.COMPLETED
+    assert root.current_spec_version == 2
+    assert root.current_spec.objective == ROOT_OBJECTIVE_V2
     assert len(child.attempt_ids) == 2
     assert len(child.attempt_ids) == 2
     assert len(root.attempt_ids) == 1
     assert len(root.attempt_ids) == 1
 
 

+ 9 - 0
agent/tests/test_observation_events_and_traces.py

@@ -1,4 +1,5 @@
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
+import json
 from types import SimpleNamespace
 from types import SimpleNamespace
 
 
 import pytest
 import pytest
@@ -35,6 +36,14 @@ async def test_event_v2_payloads_are_refs_and_noop_stage_start_is_silent(tmp_pat
         event for event in page_after.events if event.event_type == "stage_started"
         event for event in page_after.events if event.event_type == "stage_started"
     )
     )
     assert stage_event.payload == {"task_id": root_id, "attempt_id": attempt_id}
     assert stage_event.payload == {"task_id": root_id, "attempt_id": attempt_id}
+    mirrored = [
+        json.loads(line)
+        for line in (
+            tmp_path / "root" / "orchestration" / "events.jsonl"
+        ).read_text(encoding="utf-8").splitlines()
+    ]
+    assert mirrored[-1]["event_id"] == stage_event.event_id
+    assert mirrored[-1]["schema_version"] == 2
 
 
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio

+ 28 - 0
agent/tests/test_runner_completion_gate.py

@@ -23,6 +23,16 @@ class StubCoordinator:
         return self.snapshots[0]
         return self.snapshots[0]
 
 
 
 
+class RootFirstCoordinator(StubCoordinator):
+    def __init__(self):
+        super().__init__([{"status": "legacy"}])
+        self.root_calls = 0
+
+    async def root_completion(self, root_trace_id):
+        self.root_calls += 1
+        return {"status": "completed", "root_task_id": "root-task"}
+
+
 def _knowledge_off():
 def _knowledge_off():
     from agent.tools.builtin.knowledge import KnowledgeConfig
     from agent.tools.builtin.knowledge import KnowledgeConfig
 
 
@@ -33,6 +43,24 @@ def _knowledge_off():
     )
     )
 
 
 
 
+@pytest.mark.asyncio
+async def test_runner_prefers_root_completion_and_keeps_legacy_fallback(tmp_path):
+    root_first = RootFirstCoordinator()
+    runner = AgentRunner(
+        trace_store=FileSystemTraceStore(str(tmp_path / "root-first")),
+        llm_call=None,
+        task_coordinator=root_first,
+    )
+    assert (await runner._get_mission_completion("root"))["status"] == "completed"
+    assert root_first.root_calls == 1
+    assert root_first.completion_calls == 0
+
+    legacy = StubCoordinator([{"status": "blocked"}])
+    runner.task_coordinator = legacy
+    assert (await runner._get_mission_completion("root"))["status"] == "blocked"
+    assert legacy.completion_calls == 1
+
+
 def _register_role_preset(name, role, max_iterations):
 def _register_role_preset(name, role, max_iterations):
     register_preset(
     register_preset(
         name,
         name,