Sfoglia il codice sorgente

feat(snapshot): 增加应用快照 V2 和严格恢复门禁

保持 RunConfigSnapshotV1 的 JSON 形态与历史 hash 不变,新增 V2 固化 ApplicationRef、role_id、role_hash 和解析后的 effective_run_limits,并加入固定 V1 hash 回归夹具。

Runner 在创建和恢复时双重校验 Binding、Trace context、身份元数据、角色行为和全部有效限制;应用 Trace 不能由普通 Runner 恢复,V1 也不能被升级为应用 Trace。

HTTP 新建接口增加成对的 application_id/version,与 project_name 互斥且只允许收紧 max_iterations;续跑、审批、stop、reflect、compact 和启动对账统一先恢复应用 Binding,缺失或篡改时在模型与工具执行前稳定失败关闭。
SamLee 19 ore fa
parent
commit
75bbbfa9d9

+ 53 - 3
cyber_agent/core/run_snapshot.py

@@ -131,21 +131,71 @@ class RunConfigSnapshotV1(BaseModel):
         return sha256(payload.encode("utf-8")).hexdigest()
 
 
+class RunConfigSnapshotV2(RunConfigSnapshotV1):
+    """Application-bound snapshot; V1's serialized shape remains unchanged."""
+
+    schema_version: Literal[2] = 2
+    application_ref: dict[str, Any]
+    role_id: str = Field(min_length=1)
+    role_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
+    effective_run_limits: dict[str, Any]
+
+    @classmethod
+    def from_run_config(
+        cls,
+        config: Any,
+        *,
+        memory_identity: str | None,
+        system_prompt_hash: str | None = None,
+        legacy_inferred: bool = False,
+    ) -> "RunConfigSnapshotV2":
+        base = RunConfigSnapshotV1.from_run_config(
+            config,
+            memory_identity=memory_identity,
+            system_prompt_hash=system_prompt_hash,
+            legacy_inferred=legacy_inferred,
+        ).model_dump(mode="json")
+        base.pop("schema_version", None)
+        application_ref = config.application_ref
+        if isinstance(application_ref, BaseModel):
+            application_ref = application_ref.model_dump(mode="json")
+        if not isinstance(application_ref, dict):
+            raise TypeError("application_ref is required for a V2 run snapshot")
+        if not config.role_id or not config.role_hash:
+            raise TypeError("role_id and role_hash are required for a V2 run snapshot")
+        return cls(
+            **base,
+            application_ref=dict(application_ref),
+            role_id=config.role_id,
+            role_hash=config.role_hash,
+            effective_run_limits=dict(config.effective_run_limits),
+        )
+
+
+RunConfigSnapshot = RunConfigSnapshotV1 | RunConfigSnapshotV2
+
+
 def persist_run_config_snapshot(
     context: dict[str, Any],
-    snapshot: RunConfigSnapshotV1,
+    snapshot: RunConfigSnapshot,
 ) -> None:
     context[RUN_CONFIG_SNAPSHOT_CONTEXT_KEY] = snapshot.model_dump(mode="json")
     context[RUN_CONFIG_SNAPSHOT_HASH_CONTEXT_KEY] = snapshot.snapshot_hash
 
 
-def load_run_config_snapshot(context: dict[str, Any]) -> RunConfigSnapshotV1:
+def load_run_config_snapshot(context: dict[str, Any]) -> RunConfigSnapshot:
     raw = context.get(RUN_CONFIG_SNAPSHOT_CONTEXT_KEY)
     digest = context.get(RUN_CONFIG_SNAPSHOT_HASH_CONTEXT_KEY)
     if raw is None or digest is None:
         raise RunConfigSnapshotError("run config snapshot is missing")
     try:
-        snapshot = RunConfigSnapshotV1.model_validate(raw)
+        schema_version = raw.get("schema_version") if isinstance(raw, dict) else None
+        if schema_version == 1:
+            snapshot = RunConfigSnapshotV1.model_validate(raw)
+        elif schema_version == 2:
+            snapshot = RunConfigSnapshotV2.model_validate(raw)
+        else:
+            raise ValueError(f"unsupported schema_version: {schema_version!r}")
     except Exception as exc:
         raise RunConfigSnapshotError(f"invalid run config snapshot: {exc}") from exc
     if snapshot.snapshot_hash != digest:

+ 212 - 7
cyber_agent/core/runner.py

@@ -56,6 +56,7 @@ from cyber_agent.core.run_snapshot import (
     RUN_CONFIG_SNAPSHOT_CONTEXT_KEY,
     RunConfigSnapshotError,
     RunConfigSnapshotV1,
+    RunConfigSnapshotV2,
     load_run_config_snapshot,
     persist_run_config_snapshot,
 )
@@ -248,7 +249,16 @@ class RunConfig:
     # --- 一次性恢复动作(不进入持久化 RunConfigSnapshot) ---
     approval_batch_id: Optional[str] = None
 
-    def apply_snapshot(self, snapshot: RunConfigSnapshotV1) -> None:
+    # --- ApplicationRuntime 固化身份(只由框架装配) ---
+    application_ref: Any = None
+    role_id: Optional[str] = None
+    role_hash: Optional[str] = None
+    effective_run_limits: Dict[str, Any] = field(default_factory=dict)
+
+    def apply_snapshot(
+        self,
+        snapshot: RunConfigSnapshotV1 | RunConfigSnapshotV2,
+    ) -> None:
         """Restore all persisted behavior fields while retaining invocation routing."""
         self.model = snapshot.model
         self.temperature = snapshot.temperature
@@ -274,6 +284,11 @@ class RunConfig:
         self.enable_prompt_caching = snapshot.enable_prompt_caching
         self.enable_research_flow = snapshot.enable_research_flow
         self.context = dict(snapshot.custom_context)
+        if isinstance(snapshot, RunConfigSnapshotV2):
+            self.application_ref = dict(snapshot.application_ref)
+            self.role_id = snapshot.role_id
+            self.role_hash = snapshot.role_hash
+            self.effective_run_limits = dict(snapshot.effective_run_limits)
 
 
     # BUILTIN_TOOLS 硬编码列表已移除(2026-04)。
@@ -320,6 +335,8 @@ class AgentRunner:
         validator_search_provider: Optional[ValidatorWebSearchProvider] = None,
         validator_page_fetcher: Optional[PageFetcher] = None,
         artifact_resolver: Optional[ArtifactResolver] = None,
+        application_binding: Any = None,
+        context_provider: Any = None,
     ):
         """
         初始化 AgentRunner
@@ -350,6 +367,8 @@ class AgentRunner:
         self.validator_search_provider = validator_search_provider
         self.validator_page_fetcher = validator_page_fetcher
         self.artifact_resolver = artifact_resolver
+        self.application_binding = application_binding
+        self.context_provider = context_provider
         self.stdin_check: Optional[Callable] = None  # 由外部设置,用于子 agent 执行期间检查 stdin
         self._cancel_events: Dict[str, asyncio.Event] = {}  # trace_id → cancel event
         self._recursive_active_traces: Dict[str, asyncio.Event] = {}
@@ -1253,11 +1272,100 @@ class AgentRunner:
             (trace, goal_tree, next_sequence)
         """
         assert_removed_config_absent()
+        if self.application_binding is not None and any(
+            message.get("role") == "system" for message in messages
+        ):
+            raise ValueError(
+                "Application runs do not allow caller-provided system messages"
+            )
         if config.trace_id:
             return await self._prepare_existing_trace(config)
         else:
             return await self._prepare_new_trace(messages, config)
 
+    @staticmethod
+    def _application_ref_dict(value: Any) -> dict[str, Any] | None:
+        if value is None:
+            return None
+        if hasattr(value, "model_dump"):
+            return value.model_dump(mode="json")
+        return dict(value) if isinstance(value, dict) else None
+
+    def _validate_application_config(self, config: RunConfig) -> None:
+        """Reject a new application RunConfig that diverges from its Binding."""
+        binding = self.application_binding
+        if binding is None:
+            raise ValueError("Application binding is unavailable")
+        expected_ref = binding.application_ref.model_dump(mode="json")
+        if self._application_ref_dict(config.application_ref) != expected_ref:
+            raise ValueError("RunConfig ApplicationRef does not match Runner binding")
+        if not config.role_id:
+            raise ValueError("Application role_id is required")
+        role = binding.role(config.role_id)
+        if config.role_hash != role.role_hash:
+            raise ValueError("RunConfig role hash does not match Runner binding")
+        if (
+            config.model != role.role.model
+            or config.temperature != role.role.temperature
+            or config.extra_llm_params != role.role.model_parameters
+            or not set(config.tools or []).issubset(set(role.tool_names))
+            or config.system_prompt != role.system_prompt
+            or config.skills != []
+        ):
+            raise ValueError("RunConfig behavior does not match the bound application role")
+        limits = dict(config.effective_run_limits)
+        if not limits:
+            raise ValueError("Application effective_run_limits are required")
+        allowed_limits = role.effective_limits.model_dump(mode="json")
+        for name, allowed in allowed_limits.items():
+            value = limits.get(name)
+            if value is None or value > allowed:
+                raise ValueError(
+                    f"Application run limit exceeds the bound role: {name}"
+                )
+        if (
+            config.max_iterations != limits["max_iterations"]
+            or config.max_parallel_children != limits["max_parallel_children"]
+        ):
+            raise ValueError("RunConfig limits do not match effective_run_limits")
+
+    def _validate_application_snapshot(
+        self,
+        snapshot: RunConfigSnapshotV1 | RunConfigSnapshotV2,
+        trace: Trace,
+    ) -> None:
+        """Perform the Runner-side binding gate before any resume mutation."""
+        if isinstance(snapshot, RunConfigSnapshotV1) and not isinstance(
+            snapshot,
+            RunConfigSnapshotV2,
+        ):
+            if self.application_binding is not None:
+                raise ValueError("Application-bound Runner cannot resume a V1 Trace")
+            return
+        if self.application_binding is None:
+            raise ValueError(
+                "Application Trace requires an ApplicationRuntime-bound Runner"
+            )
+        expected_ref = self.application_binding.application_ref.model_dump(mode="json")
+        if snapshot.application_ref != expected_ref:
+            raise ValueError("ApplicationRef does not match Runner binding")
+        role = self.application_binding.role(snapshot.role_id)
+        if snapshot.role_hash != role.role_hash:
+            raise ValueError("Application role hash does not match Runner binding")
+        if (
+            trace.context.get("application_ref") != snapshot.application_ref
+            or trace.context.get("application_role_id") != snapshot.role_id
+            or trace.context.get("application_role_hash") != snapshot.role_hash
+            or trace.context.get("effective_run_limits")
+            != snapshot.effective_run_limits
+        ):
+            raise ValueError("Application snapshot does not match Trace context")
+        restored = RunConfig()
+        restored.apply_snapshot(snapshot)
+        restored.system_prompt = role.system_prompt
+        restored.skills = []
+        self._validate_application_config(restored)
+
     async def _prepare_new_trace(
         self,
         messages: List[Dict],
@@ -1267,6 +1375,12 @@ class AgentRunner:
 
         ``run`` 首次执行时调用;Recursive 会在此固化根任务锚点和树级预算。
         """
+        if self.application_binding is not None:
+            self._validate_application_config(config)
+        elif config.application_ref is not None:
+            raise ValueError(
+                "Application runs require an ApplicationRuntime-bound Runner"
+            )
         # 在任何标题生成/LLM 调用前完成模式校验。
         policy = policy_from_environment(
             recursive_revision=CURRENT_RECURSIVE_REVISION,
@@ -1286,7 +1400,29 @@ class AgentRunner:
                 )
             except ContextPolicyError as exc:
                 raise ValueError(str(exc)) from exc
-            budget = ResourceBudget.from_environment()
+            deployment_budget = ResourceBudget.from_environment()
+            if self.application_binding is not None:
+                limits = config.effective_run_limits
+                budget = ResourceBudget(
+                    enabled=deployment_budget.enabled,
+                    max_total_agents=int(limits["max_total_agents"]),
+                    max_llm_calls=int(limits["max_llm_calls"]),
+                    max_total_tokens=int(limits["max_total_tokens"]),
+                    max_total_cost_usd=float(limits["max_total_cost_usd"]),
+                    max_duration_seconds=int(limits["max_duration_seconds"]),
+                    reserved_final_calls=min(
+                        deployment_budget.reserved_final_calls,
+                        int(limits["max_llm_calls"]) - 1,
+                    ),
+                    max_validation_tool_calls=int(
+                        limits["max_validation_tool_calls"]
+                    ),
+                    max_validation_material_chars=int(
+                        limits["max_validation_material_chars"]
+                    ),
+                )
+            else:
+                budget = deployment_budget
             validator_settings = ValidatorSettings.from_environment()
         trace_id = str(uuid.uuid4())
 
@@ -1308,10 +1444,22 @@ class AgentRunner:
         )
         if memory_identity is not None:
             trace_context[MEMORY_IDENTITY_CONTEXT_KEY] = memory_identity
-        run_snapshot = RunConfigSnapshotV1.from_run_config(
-            config,
-            memory_identity=memory_identity,
-        )
+        if self.application_binding is not None:
+            run_snapshot = RunConfigSnapshotV2.from_run_config(
+                config,
+                memory_identity=memory_identity,
+            )
+            trace_context.update({
+                "application_ref": run_snapshot.application_ref,
+                "application_role_id": run_snapshot.role_id,
+                "application_role_hash": run_snapshot.role_hash,
+                "effective_run_limits": run_snapshot.effective_run_limits,
+            })
+        else:
+            run_snapshot = RunConfigSnapshotV1.from_run_config(
+                config,
+                memory_identity=memory_identity,
+            )
         persist_run_config_snapshot(trace_context, run_snapshot)
         if policy.requires_task_protocol:
             persist_root_task_anchor(trace_context, root_task_anchor)
@@ -1335,6 +1483,26 @@ class AgentRunner:
                 root_task_anchor=root_task_anchor,
                 task_brief=state.get("task_brief"),
             )
+            if self.application_binding is not None and self.context_provider is not None:
+                from cyber_agent.application.context import load_application_context
+                from cyber_agent.application.ports import ContextRequest
+
+                await load_application_context(
+                    self.application_binding,
+                    trace_context,
+                    ContextRequest(
+                        application_ref=self.application_binding.application_ref,
+                        root_trace_id=trace_id,
+                        trace_id=trace_id,
+                        uid=config.uid,
+                        role_id=config.role_id,
+                        task_brief=None,
+                        task_brief_revision=0,
+                    ),
+                    root_task_anchor=root_task_anchor,
+                    task_brief=None,
+                    granted_at_sequence=0,
+                )
 
         trace_obj = Trace(
             trace_id=trace_id,
@@ -1435,7 +1603,12 @@ class AgentRunner:
             )
         if snapshot.uid != trace_obj.uid or snapshot.agent_type != (trace_obj.agent_type or "default"):
             raise ValueError("run config snapshot identity does not match Trace metadata")
+        self._validate_application_snapshot(snapshot, trace_obj)
         config.apply_snapshot(snapshot)
+        if isinstance(snapshot, RunConfigSnapshotV2):
+            role = self.application_binding.role(snapshot.role_id)
+            config.system_prompt = role.system_prompt
+            config.skills = []
         persisted_memory_identity = trace_obj.context.get(
             MEMORY_IDENTITY_CONTEXT_KEY
         )
@@ -4939,6 +5112,20 @@ class AgentRunner:
                 available.discard("evaluate")
             return available
 
+        if self.application_binding is not None and trace.context.get(
+            "application_ref"
+        ):
+            role = self.application_binding.role(
+                trace.context.get("application_role_id")
+            )
+            limits = trace.context.get("effective_run_limits") or {}
+            depth = int(trace.context.get("agent_depth", 0) or 0)
+            if (
+                not role.role.allowed_child_roles
+                or depth >= int(limits.get("max_depth", policy.max_depth))
+            ):
+                tool_names.discard("agent")
+
         state = ensure_task_protocol(trace.context)
         tool_names.discard("evaluate")
         if state["pending_reviews"]:
@@ -4991,12 +5178,30 @@ class AgentRunner:
                 if "task_brief" in properties:
                     properties["task_brief"]["description"] = (
                         "Required structured contract for Recursive local delegation. "
-                        "remote_* agents are not supported in Recursive revision 2."
+                        "remote_* agents are not supported in Recursive revision 3."
                     )
                 if "task" in required:
                     required.remove("task")
                 if "task_brief" in properties and "task_brief" not in required:
                     required.append("task_brief")
+                if self.application_binding is not None and trace.context.get(
+                    "application_ref"
+                ):
+                    role = self.application_binding.role(
+                        trace.context.get("application_role_id")
+                    )
+                    properties.pop("skills", None)
+                    if "skills" in required:
+                        required.remove("skills")
+                    if "agent_type" in properties:
+                        properties["agent_type"]["enum"] = list(
+                            role.role.allowed_child_roles
+                        )
+                        properties["agent_type"]["description"] = (
+                            "Required application role for the direct child."
+                        )
+                        if "agent_type" not in required:
+                            required.append("agent_type")
             else:
                 properties.pop("task_brief", None)
                 if "task" in properties and "task" not in required:

+ 115 - 8
cyber_agent/trace/run_api.py

@@ -37,6 +37,7 @@ experiences_router = APIRouter(prefix="/api", tags=["experiences"])
 # ===== 全局 Runner(由 api_server.py 注入)=====
 
 _runner = None
+_application_runtime = None
 
 
 def set_runner(runner):
@@ -45,6 +46,12 @@ def set_runner(runner):
     _runner = runner
 
 
+def set_application_runtime(runtime):
+    """Inject the optional experimental application assembly runtime."""
+    global _application_runtime
+    _application_runtime = runtime
+
+
 def _get_runner():
     if _runner is None:
         raise HTTPException(
@@ -54,6 +61,15 @@ def _get_runner():
     return _runner
 
 
+def _get_application_runtime():
+    if _application_runtime is None:
+        raise HTTPException(
+            status_code=503,
+            detail="ApplicationRuntime is not configured",
+        )
+    return _application_runtime
+
+
 # ===== Request / Response 模型 =====
 
 
@@ -70,6 +86,8 @@ class CreateRequest(BaseModel):
     name: Optional[str] = Field(None, description="任务名称(None = 自动生成)")
     uid: Optional[str] = Field(None)
     project_name: Optional[str] = Field(None, description="示例项目名称,若提供则动态加载其执行环境")
+    application_id: Optional[str] = Field(None, min_length=1)
+    application_version: Optional[str] = Field(None, min_length=1)
     root_task_anchor: Optional[RootTaskAnchor] = Field(
         None,
         description="Recursive 根任务必须显式提供的不可变目标、完成标准和硬约束;Legacy 忽略",
@@ -84,6 +102,29 @@ class CreateRequest(BaseModel):
             )
         return value
 
+    @model_validator(mode="after")
+    def validate_application_selection(self):
+        has_id = self.application_id is not None
+        has_version = self.application_version is not None
+        if has_id != has_version:
+            raise ValueError(
+                "application_id and application_version must be provided together"
+            )
+        if has_id and self.project_name is not None:
+            raise ValueError("application and project_name are mutually exclusive")
+        if has_id and any(
+            value is not None
+            for value in (self.model, self.temperature, self.tools)
+        ):
+            raise ValueError(
+                "application runs cannot override model, temperature, or tools"
+            )
+        if has_id and any(message.get("role") == "system" for message in self.messages):
+            raise ValueError(
+                "application runs cannot provide caller-controlled system messages"
+            )
+        return self
+
 
 class TraceRunRequest(BaseModel):
     """运行(统一续跑 + 回溯)"""
@@ -239,6 +280,7 @@ async def _restore_runner_and_config(
     from cyber_agent.core.run_snapshot import (
         RUN_CONFIG_SNAPSHOT_CONTEXT_KEY,
         RunConfigSnapshotV1,
+        RunConfigSnapshotV2,
         RunConfigSnapshotError,
         load_run_config_snapshot,
         persist_run_config_snapshot,
@@ -252,6 +294,7 @@ async def _restore_runner_and_config(
             raise HTTPException(status_code=409, detail=str(exc)) from exc
         raise
 
+    runner = base_runner
     config = RunConfig(
         trace_id=trace.trace_id,
         after_sequence=after_sequence,
@@ -264,7 +307,20 @@ async def _restore_runner_and_config(
             snapshot = load_run_config_snapshot(trace.context)
         except RunConfigSnapshotError as exc:
             raise HTTPException(status_code=409, detail=str(exc)) from exc
-        config.apply_snapshot(snapshot)
+        if isinstance(snapshot, RunConfigSnapshotV2):
+            try:
+                runner, config = await _get_application_runtime().restore(
+                    trace.trace_id
+                )
+            except HTTPException:
+                raise
+            except Exception as exc:
+                raise HTTPException(
+                    status_code=409,
+                    detail=f"Failed to restore application binding: {exc}",
+                ) from exc
+        else:
+            config.apply_snapshot(snapshot)
         # apply_snapshot intentionally leaves invocation-only controls untouched.
         config.trace_id = trace.trace_id
         config.after_sequence = after_sequence
@@ -321,7 +377,6 @@ async def _restore_runner_and_config(
         config.approval_batch_id = approval_batch_id
         project_name = snapshot.project_name
 
-    runner = base_runner
     if project_name:
         if not re.fullmatch(r"[A-Za-z0-9_]+", project_name):
             raise HTTPException(status_code=409, detail="Invalid persisted project_name")
@@ -423,6 +478,21 @@ async def create_and_run(req: CreateRequest):
     config = None
     messages = req.messages
 
+    if req.application_id is not None:
+        try:
+            runner, config = _get_application_runtime().new_run(
+                req.application_id,
+                req.application_version,
+                uid=req.uid,
+                name=req.name,
+                root_task_anchor=req.root_task_anchor,
+                max_iterations=req.max_iterations,
+            )
+        except HTTPException:
+            raise
+        except ValueError as exc:
+            raise HTTPException(status_code=422, detail=str(exc)) from exc
+
     if req.project_name:
         try:
             # 动态加载对应 example 的 run.py
@@ -823,7 +893,10 @@ async def stop_trace(trace_id: str):
     if runner.trace_store:
         trace = await runner.trace_store.get_trace(trace_id)
         if trace:
-            _require_mutable_trace(trace)
+            runner, _config = await _restore_runner_and_config(
+                trace=trace,
+                base_runner=runner,
+            )
         if trace and trace.status == "waiting_confirmation":
             batch = (
                 await runner.trace_store.get_tool_approval_batch(trace_id)
@@ -1094,11 +1167,45 @@ async def reconcile_traces():
             from cyber_agent.core.agent_mode import policy_from_context
             if policy_from_context(trace.context).is_read_only:
                 continue
+            trace_runner = runner
             batch = (
                 await runner.trace_store.get_tool_approval_batch(tid)
                 if hasattr(runner.trace_store, "get_tool_approval_batch")
                 else None
             )
+            raw_snapshot = (trace.context or {}).get("run_config_snapshot")
+            is_application_trace = (
+                isinstance(raw_snapshot, dict)
+                and raw_snapshot.get("schema_version") == 2
+            )
+            needs_recovery_action = bool(
+                trace.status == "running"
+                or (
+                    batch is not None
+                    and batch.status in {"pending", "decided", "executing"}
+                )
+            )
+            if is_application_trace and needs_recovery_action:
+                try:
+                    trace_runner, _restored_config = (
+                        await _restore_runner_and_config(
+                            trace=trace,
+                            base_runner=runner,
+                        )
+                    )
+                except Exception as exc:
+                    logger.error(
+                        "[Reconciliation] Application binding recovery failed for %s: %s",
+                        tid,
+                        exc,
+                    )
+                    await runner.trace_store.update_trace(
+                        tid,
+                        status="failed",
+                        error_message=f"Application binding recovery failed: {exc}",
+                    )
+                    count += 1
+                    continue
             if batch and batch.status == "pending" and trace.status in {
                 "running", "stopped", "waiting_confirmation",
             }:
@@ -1108,7 +1215,7 @@ async def reconcile_traces():
                         tid,
                         trace.status,
                     )
-                    await runner.trace_store.update_trace(
+                    await trace_runner.trace_store.update_trace(
                         tid,
                         status="waiting_confirmation",
                         error_message=None,
@@ -1125,7 +1232,7 @@ async def reconcile_traces():
                 )
                 restored_runner, config = await _restore_runner_and_config(
                     trace=trace,
-                    base_runner=runner,
+                    base_runner=trace_runner,
                     approval_batch_id=batch.batch_id,
                 )
                 await restored_runner.trace_store.update_trace(
@@ -1151,8 +1258,8 @@ async def reconcile_traces():
                 for call in batch.calls:
                     if call.execution_status == "executing":
                         call.execution_status = "execution_unknown"
-                await runner.trace_store.replace_tool_approval_batch(tid, batch)
-                await runner.trace_store.update_trace(
+                await trace_runner.trace_store.replace_tool_approval_batch(tid, batch)
+                await trace_runner.trace_store.update_trace(
                     tid,
                     status="failed",
                     error_message=(
@@ -1164,7 +1271,7 @@ async def reconcile_traces():
                 continue
             if trace.status == "running":
                 logger.info(f"[Reconciliation] Fixing trace {tid}: running -> stopped")
-                await runner.trace_store.update_trace(
+                await trace_runner.trace_store.update_trace(
                     tid,
                     status="stopped",
                     result_summary="[Reconciliation] 任务由于服务重启或异常中断已自动停止。"

+ 35 - 0
tests/test_runtime_hardening.py

@@ -54,6 +54,41 @@ def quiet_knowledge() -> KnowledgeConfig:
 
 
 class RunSnapshotHardeningTest(unittest.IsolatedAsyncioTestCase):
+    async def test_v1_snapshot_shape_and_hash_remain_byte_compatible(self):
+        config = RunConfig(
+            model="v1-fixture",
+            temperature=0.25,
+            max_iterations=3,
+            tools=[],
+            tool_groups=[],
+            exclude_tools=[],
+            auto_execute_tools=True,
+            agent_type="default",
+            uid="fixture",
+            skills=[],
+            enable_memory=False,
+            knowledge=quiet_knowledge(),
+            parallel_tool_execution=False,
+            child_execution_mode="sequential",
+            max_parallel_children=1,
+            side_branch_max_turns=2,
+            goal_compression="none",
+            enable_prompt_caching=True,
+            enable_research_flow=False,
+            extra_llm_params={},
+            context={},
+        )
+        snapshot = RunConfigSnapshotV1.from_run_config(
+            config,
+            memory_identity=None,
+        )
+        self.assertEqual(1, snapshot.schema_version)
+        self.assertNotIn("application_ref", snapshot.model_dump())
+        self.assertEqual(
+            "1fdd11b90c121e13fa30aa5d246c20a50b602baa6b3a1aeed186b2c1ea72745b",
+            snapshot.snapshot_hash,
+        )
+
     async def test_full_snapshot_round_trip_and_hash_tamper_detection(self):
         with tempfile.TemporaryDirectory() as temp_dir:
             memory = MemoryConfig(