From c4bf90a6043b28e0608fbd9746c3d88c7402e92d Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 26 Sep 2026 13:51:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Host=20Bridge=20=E7=9F=AD?= =?UTF-8?q?=E6=9A=82=E5=A4=B1=E8=81=94=E6=81=A2=E5=A4=8D=E4=B8=8E=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E7=A7=9F=E7=BA=A6=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/host_bridge/client.py | 93 +++++- agent/host_bridge/filesystem.py | 68 +++- agent/host_bridge/host_bridge.proto | 1 + agent/host_bridge/host_bridge_pb2.py | 4 +- agent/host_bridge/host_bridge_pb2_grpc.py | 43 +++ agent/host_bridge/monitor.py | 52 ++- agent/host_bridge/server.py | 41 ++- bootstrap/app.py | 6 +- bootstrap/dashboard_api.py | 14 +- bootstrap/web_shell.py | 4 + docker/debug/host_bridge_notice.mjs | 54 ++++ docker/debug/host_bridge_reliability.py | 303 ++++++++++++++++++ docs/INDEX.md | 3 +- .../0075-host-bridge-runtime-recovery.md | 41 +++ docs/decisions/README.md | 1 + docs/design/host-bridge-protocol-v2.md | 20 +- docs/design/host-bridge-reliability.md | 87 +++++ docs/projectneed.md | 2 +- frontend/chat/src/desktop-chat-view.tsx | 2 + frontend/chat/src/host-bridge-notice.tsx | 33 ++ 20 files changed, 822 insertions(+), 50 deletions(-) create mode 100644 docker/debug/host_bridge_notice.mjs create mode 100644 docker/debug/host_bridge_reliability.py create mode 100644 docs/decisions/0075-host-bridge-runtime-recovery.md create mode 100644 docs/design/host-bridge-reliability.md create mode 100644 frontend/chat/src/host-bridge-notice.tsx diff --git a/agent/host_bridge/client.py b/agent/host_bridge/client.py index df145ef21..1231e1158 100644 --- a/agent/host_bridge/client.py +++ b/agent/host_bridge/client.py @@ -2,6 +2,7 @@ import asyncio import contextlib +import logging import uuid from dataclasses import dataclass from pathlib import Path @@ -27,6 +28,26 @@ from core.common.diagnostic_log import current_diagnostic_context _HEARTBEAT_INTERVAL_S = 2.0 +logger = logging.getLogger(__name__) + + +class HostBridgeRpcError(RuntimeError): + """保留传输状态;只有明确的暂时失联允许恢复探测和心跳。""" + + def __init__(self, method: str, code: grpc.StatusCode, detail: str | None) -> None: + self.method = method + self.code = code + uncertainty = ( + ";操作可能已生效,不得自动重发" + if method in {"Exec", "WriteStdin", "FileTool"} + else "" + ) + super().__init__(f"Host Bridge {method} 失败: {code.name}: {detail}{uncertainty}") + + @property + def transient(self) -> bool: + return self.code in {grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.DEADLINE_EXCEEDED} + @dataclass(frozen=True) @@ -121,6 +142,8 @@ def __init__( self._stub = rpc.HostBridgeStub(self._channel) self._heartbeat_task: asyncio.Task[None] | None = None self._lease_error: Exception | None = None + self._opened = False + self._open_lock = asyncio.Lock() self._unconfirmed_owners: dict[str, str] = {} self._closed = False @@ -142,6 +165,7 @@ async def probe(self) -> dict[str, Any]: pb.ContextRequest(context=self._request_context()), method="Probe", timeout=5, + lease=False, ) return self._identity_reply(reply) @@ -150,6 +174,7 @@ async def inspect(self) -> dict[str, Any]: self._stub.Inspect, pb.ContextRequest(context=self._request_context()), method="Inspect", + timeout=5, lease=False, ) return self._identity_reply(reply) @@ -288,6 +313,9 @@ async def terminate_owner( async def shutdown(self) -> ExecutionCleanupReport: if self._closed: return ExecutionCleanupReport((), (), ()) + if not self._opened: + await self.close_transport() + return ExecutionCleanupReport((), (), ()) await self._stop_heartbeat() reply: pb.CleanupReply = await self._call( self._stub.ShutdownManager, @@ -358,10 +386,10 @@ async def _call( """发起一次 RPC;失败或取消均不重放可能已生效的操作。""" if self._closed: raise RuntimeError("Host Bridge manager 已关闭") - if method not in {"Heartbeat", "ShutdownManager"} and self._lease_error is not None: - raise RuntimeError(f"Host Bridge lease 已失效: {self._lease_error}") + if lease and self._lease_error is not None: + raise self._lease_error if lease: - self._ensure_heartbeat() + await self._open_manager() try: return await call( request, @@ -369,14 +397,31 @@ async def _call( metadata=(("authorization", f"Bearer {self._token}"),), ) except grpc.aio.AioRpcError as exc: - uncertainty = ( - ";操作可能已生效,不得自动重发" - if method in {"Exec", "WriteStdin", "FileTool"} - else "" + error = HostBridgeRpcError(method, exc.code(), exc.details()) + if self._opened and error.code in { + grpc.StatusCode.NOT_FOUND, grpc.StatusCode.PERMISSION_DENIED, + grpc.StatusCode.UNAUTHENTICATED, grpc.StatusCode.FAILED_PRECONDITION, + }: + self._lease_error = error + raise error from exc + + async def _open_manager(self) -> None: + """业务调用前只登记一次;失联续期不能重新创建已丢失的 manager。""" + async with self._open_lock: + if self._opened: + return + reply: pb.HeartbeatReply = await self._call( + self._stub.OpenManager, + pb.ContextRequest(context=self._request_context()), + method="OpenManager", + lease=False, + timeout=5, ) - raise RuntimeError( - f"Host Bridge {method} 失败: {exc.code().name}: {exc.details()}{uncertainty}" - ) from exc + require_fields(reply, "alive") + if not reply.alive: + raise RuntimeError("Host Bridge 未确认 manager 登记") + self._opened = True + self._ensure_heartbeat() def _ensure_heartbeat(self) -> None: if self._heartbeat_task is None: @@ -385,22 +430,36 @@ def _ensure_heartbeat(self) -> None: ) async def _heartbeat_loop(self) -> None: + """暂时传输失败继续续期;租约丢失和身份错误终结旧 manager。""" + failures = 0 try: while True: - await asyncio.sleep(_HEARTBEAT_INTERVAL_S) - reply: pb.HeartbeatReply = await self._call( - self._stub.Heartbeat, - pb.ContextRequest(context=self._request_context()), - method="Heartbeat", - timeout=5, - ) + await asyncio.sleep(min(_HEARTBEAT_INTERVAL_S * (2 ** min(failures, 3)), 10)) + try: + reply: pb.HeartbeatReply = await self._call( + self._stub.Heartbeat, + pb.ContextRequest(context=self._request_context()), + method="Heartbeat", + lease=False, + timeout=5, + ) + except HostBridgeRpcError as exc: + if not exc.transient: + raise + failures += 1 + logger.warning("Host Bridge 心跳暂时失败,继续探测: %s", exc) + continue require_fields(reply, "alive") if not reply.alive: raise RuntimeError("Host Bridge 未确认 lease 存活") + if failures: + logger.info("Host Bridge 心跳恢复") + failures = 0 except asyncio.CancelledError: raise except Exception as exc: self._lease_error = exc + logger.error("Host Bridge manager 已失效: %s", exc) def _check_client_identity(socket_path: Path, boot_id: str, token: str) -> None: diff --git a/agent/host_bridge/filesystem.py b/agent/host_bridge/filesystem.py index c92f0fff0..716d29f35 100644 --- a/agent/host_bridge/filesystem.py +++ b/agent/host_bridge/filesystem.py @@ -7,8 +7,8 @@ import difflib import logging import os -from collections.abc import Awaitable, Callable -from dataclasses import dataclass +from collections.abc import Callable +from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, TypeVar @@ -32,6 +32,50 @@ class _FileMutationState: _FILE_MUTATION_LOCKS: dict[str, _FileMutationState] = {} +@dataclass +class _FileIoState: + slots: asyncio.Semaphore = field(default_factory=lambda: asyncio.Semaphore(4)) + users: int = 0 + + +_FILE_IO_SLOTS: dict[asyncio.AbstractEventLoop, _FileIoState] = {} + + +async def _run_file_io(fn: Callable[[], T]) -> T: + """最多四个磁盘操作并行;取消后仍等物理工作结束才归还锁与 owner。""" + # 1. 等待名额时可以取消;线程启动后不能把取消当作工作已结束。 + loop = asyncio.get_running_loop() + state = _FILE_IO_SLOTS.setdefault(loop, _FileIoState()) + state.users += 1 + try: + async with state.slots: + work = asyncio.create_task(asyncio.to_thread(fn)) + cancelled: asyncio.CancelledError | None = None + while not work.done(): + try: + await asyncio.shield(work) + except asyncio.CancelledError as exc: + cancelled = exc + except Exception: + # 实际错误由 result 取回;同时发生取消时保留两种失败。 + break + # 2. 到这里线程已结束,外层才可以释放文件锁和 manager operation。 + try: + result = work.result() + except Exception as exc: + if cancelled is not None: + raise BaseExceptionGroup("文件操作取消且物理工作失败", [cancelled, exc]) from None + raise + if cancelled is not None: + raise cancelled + return result + finally: + state.users -= 1 + if state.users == 0: + del _FILE_IO_SLOTS[loop] + + + def _is_inside(path: Path, allowed_dir: Path) -> bool: try: _ = path.relative_to(allowed_dir) @@ -106,12 +150,12 @@ def _get_file_mutation_key(file_path: Path) -> str: async def _run_with_file_mutation_lock( - file_path: Path, fn: Callable[[], Awaitable[T]] + file_path: Path, fn: Callable[[], T] ) -> T: """按规范化路径串行执行文件变更,并在异常或取消后回收锁状态。""" # 1. 登记当前调用,等待者也必须计入生命周期 - key = _get_file_mutation_key(file_path) + key = await _run_file_io(lambda: _get_file_mutation_key(file_path)) state = _FILE_MUTATION_LOCKS.get(key) if state is None: state = _FileMutationState(lock=asyncio.Lock()) @@ -121,7 +165,7 @@ async def _run_with_file_mutation_lock( try: # 2. 同一文件串行执行,取消也由 async with 释放底层锁 async with state.lock: - return await fn() + return await _run_file_io(fn) finally: # 3. 最后一个持有者或等待者退出后再移除路径映射 state.users -= 1 @@ -271,7 +315,7 @@ async def read_raw(self, path: str, **kwargs: Any) -> str | ToolResult: allowed_dir=self._allowed_dir, arguments={"path": path, **kwargs}, ) - return self.read_from_disk(path, **kwargs) + return await _run_file_io(lambda: self.read_from_disk(path, **kwargs)) def read_from_disk(self, path: str, **kwargs: Any) -> str | ToolResult: """Read host bytes without applying the current Turn model projection.""" @@ -363,9 +407,9 @@ async def execute(self, path: str, content: str, **kwargs: Any) -> str | ToolRes ) return result try: - file_path = _resolve_path(path, self._allowed_dir) + file_path = await _run_file_io(lambda: _resolve_path(path, self._allowed_dir)) - async def _write() -> str | ToolResult: + def _write() -> str | ToolResult: if file_path.exists() and file_path.is_dir(): return ToolResult( text=f"写入文件失败:目标路径是目录:{path}", is_error=True @@ -401,9 +445,9 @@ async def execute( ) return result try: - file_path = _resolve_path(path, self._allowed_dir) + file_path = await _run_file_io(lambda: _resolve_path(path, self._allowed_dir)) - async def _edit() -> str | ToolResult: + def _edit() -> str | ToolResult: if not file_path.exists(): return ToolResult(text=f"错误:文件不存在:{path}", is_error=True) if not file_path.is_file(): @@ -468,6 +512,10 @@ async def execute(self, path: str, **kwargs: Any) -> str | ToolResult: arguments={"path": path, **kwargs}, ) return result + return await _run_file_io(lambda: self._list_from_disk(path)) + + def _list_from_disk(self, path: str) -> str | ToolResult: + """在线程中完成路径解析、目录遍历和文件类型查询。""" try: dir_path = _resolve_path(path, self._allowed_dir) if not dir_path.exists(): diff --git a/agent/host_bridge/host_bridge.proto b/agent/host_bridge/host_bridge.proto index 9e7cbc79d..7ae858fa2 100644 --- a/agent/host_bridge/host_bridge.proto +++ b/agent/host_bridge/host_bridge.proto @@ -7,6 +7,7 @@ service HostBridge { rpc Inspect(ContextRequest) returns (IdentityReply); rpc ClaimBoot(ContextRequest) returns (ClaimBootReply); rpc Probe(ContextRequest) returns (IdentityReply); + rpc OpenManager(ContextRequest) returns (HeartbeatReply); rpc Heartbeat(ContextRequest) returns (HeartbeatReply); rpc Exec(ExecRequest) returns (ExecutionReply); rpc WriteStdin(WriteStdinRequest) returns (ExecutionReply); diff --git a/agent/host_bridge/host_bridge_pb2.py b/agent/host_bridge/host_bridge_pb2.py index 2ceb2c822..2abdeb264 100644 --- a/agent/host_bridge/host_bridge_pb2.py +++ b/agent/host_bridge/host_bridge_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#agent/host_bridge/host_bridge.proto\x12\x0f\x61kashic.host.v2\"\xd9\x01\n\x0eRequestContext\x12\x0f\n\x07\x62oot_id\x18\x01 \x01(\t\x12\x12\n\nmanager_id\x18\x02 \x01(\t\x12\x12\n\nrequest_id\x18\x03 \x01(\t\x12\x1f\n\x17\x65xpected_release_commit\x18\x04 \x01(\t\x12!\n\x19\x65xpected_toolchain_digest\x18\x05 \x01(\t\x12\x18\n\x0bsession_ref\x18\x06 \x01(\tH\x00\x88\x01\x01\x12\x14\n\x07turn_id\x18\x07 \x01(\tH\x01\x88\x01\x01\x42\x0e\n\x0c_session_refB\n\n\x08_turn_id\"B\n\x0e\x43ontextRequest\x12\x30\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\x1f.akashic.host.v2.RequestContext\"W\n\rIdentityReply\x12\x16\n\x0erelease_commit\x18\x01 \x01(\t\x12\x18\n\x10toolchain_digest\x18\x02 \x01(\t\x12\x14\n\x0c\x63\x61pabilities\x18\x03 \x03(\t\"\xdb\x01\n\x0e\x43laimBootReply\x12\x15\n\rowner_boot_id\x18\x01 \x01(\t\x12\x1d\n\x10previous_boot_id\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\"\n\x15\x63leaned_manager_count\x18\x03 \x01(\x04H\x01\x88\x01\x01\x12$\n\x17\x63leaned_execution_count\x18\x04 \x01(\x04H\x02\x88\x01\x01\x42\x13\n\x11_previous_boot_idB\x18\n\x16_cleaned_manager_countB\x1a\n\x18_cleaned_execution_count\".\n\x0eHeartbeatReply\x12\x12\n\x05\x61live\x18\x01 \x01(\x08H\x00\x88\x01\x01\x42\x08\n\x06_alive\"\xa1\x03\n\x0b\x45xecRequest\x12\x30\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\x1f.akashic.host.v2.RequestContext\x12\x0f\n\x07\x63ommand\x18\x02 \x01(\t\x12\x0c\n\x04\x61rgv\x18\x03 \x03(\t\x12\x10\n\x03\x63wd\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x32\n\x03\x65nv\x18\x05 \x03(\x0b\x32%.akashic.host.v2.ExecRequest.EnvEntry\x12\x10\n\x03tty\x18\x06 \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\ryield_time_ms\x18\x07 \x01(\x03H\x02\x88\x01\x01\x12\x1e\n\x11max_output_tokens\x18\x08 \x01(\x03H\x03\x88\x01\x01\x12\x1b\n\x0ehard_timeout_s\x18\t \x01(\x03H\x04\x88\x01\x01\x12\x19\n\x11owner_session_key\x18\n \x01(\t\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x06\n\x04_cwdB\x06\n\x04_ttyB\x10\n\x0e_yield_time_msB\x14\n\x12_max_output_tokensB\x11\n\x0f_hard_timeout_s\"\x8e\x02\n\x11WriteStdinRequest\x12\x30\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\x1f.akashic.host.v2.RequestContext\x12\x19\n\x0c\x65xecution_id\x18\x02 \x01(\x03H\x00\x88\x01\x01\x12\x12\n\x05\x63hars\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x1a\n\ryield_time_ms\x18\x04 \x01(\x03H\x02\x88\x01\x01\x12\x1e\n\x11max_output_tokens\x18\x05 \x01(\x03H\x03\x88\x01\x01\x12\x19\n\x11owner_session_key\x18\x06 \x01(\tB\x0f\n\r_execution_idB\x08\n\x06_charsB\x10\n\x0e_yield_time_msB\x14\n\x12_max_output_tokens\"\xcc\x02\n\x0e\x45xecutionReply\x12\x13\n\x06output\x18\x01 \x01(\x0cH\x01\x88\x01\x01\x12\x19\n\x0cwall_time_ms\x18\x02 \x01(\x03H\x02\x88\x01\x01\x12!\n\x14original_token_count\x18\x03 \x01(\x03H\x03\x88\x01\x01\x12!\n\x14output_omitted_bytes\x18\x04 \x01(\x03H\x04\x88\x01\x01\x12\x16\n\x0c\x65xecution_id\x18\x05 \x01(\x03H\x00\x12\x13\n\texit_code\x18\x06 \x01(\x11H\x00\x12\x18\n\x0boutput_path\x18\x07 \x01(\tH\x05\x88\x01\x01\x12\x15\n\rfinish_reason\x18\x08 \x01(\tB\x08\n\x06resultB\t\n\x07_outputB\x0f\n\r_wall_time_msB\x17\n\x15_original_token_countB\x17\n\x15_output_omitted_bytesB\x0e\n\x0c_output_path\"\x86\x01\n\x0bStopRequest\x12\x30\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\x1f.akashic.host.v2.RequestContext\x12\x19\n\x0c\x65xecution_id\x18\x02 \x01(\x03H\x00\x88\x01\x01\x12\x19\n\x11owner_session_key\x18\x03 \x01(\tB\x0f\n\r_execution_id\"-\n\tStopReply\x12\x14\n\x07stopped\x18\x01 \x01(\x08H\x00\x88\x01\x01\x42\n\n\x08_stopped\"[\n\x0cOwnerRequest\x12\x30\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\x1f.akashic.host.v2.RequestContext\x12\x19\n\x11owner_session_key\x18\x02 \x01(\t\"a\n\x0e\x43leanupFailure\x12\x19\n\x0c\x65xecution_id\x18\x01 \x01(\x03H\x00\x88\x01\x01\x12\x12\n\nerror_type\x18\x02 \x01(\t\x12\x0f\n\x07message\x18\x03 \x01(\tB\x0f\n\r_execution_id\"e\n\x0c\x43leanupReply\x12\x11\n\tattempted\x18\x01 \x03(\x03\x12\x0f\n\x07\x63leaned\x18\x02 \x03(\x03\x12\x31\n\x08\x66\x61ilures\x18\x03 \x03(\x0b\x32\x1f.akashic.host.v2.CleanupFailure\".\n\x15\x41\x63tiveExecutionsReply\x12\x15\n\rexecution_ids\x18\x01 \x03(\x03\"\xa3\x02\n\x0b\x46ileRequest\x12\x30\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\x1f.akashic.host.v2.RequestContext\x12\x18\n\x0b\x61llowed_dir\x18\x02 \x01(\tH\x01\x88\x01\x01\x12)\n\x04read\x18\x03 \x01(\x0b\x32\x19.akashic.host.v2.ReadFileH\x00\x12+\n\x05write\x18\x04 \x01(\x0b\x32\x1a.akashic.host.v2.WriteFileH\x00\x12)\n\x04\x65\x64it\x18\x05 \x01(\x0b\x32\x19.akashic.host.v2.EditFileH\x00\x12(\n\x04list\x18\x06 \x01(\x0b\x32\x18.akashic.host.v2.ListDirH\x00\x42\x0b\n\toperationB\x0e\n\x0c_allowed_dir\"d\n\x08ReadFile\x12\x11\n\x04path\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06offset\x18\x02 \x01(\x03H\x01\x88\x01\x01\x12\x12\n\x05limit\x18\x03 \x01(\x03H\x02\x88\x01\x01\x42\x07\n\x05_pathB\t\n\x07_offsetB\x08\n\x06_limit\"I\n\tWriteFile\x12\x11\n\x04path\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x14\n\x07\x63ontent\x18\x02 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05_pathB\n\n\x08_content\"\x98\x01\n\x08\x45\x64itFile\x12\x11\n\x04path\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x15\n\x08old_text\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x15\n\x08new_text\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x18\n\x0breplace_all\x18\x04 \x01(\x08H\x03\x88\x01\x01\x42\x07\n\x05_pathB\x0b\n\t_old_textB\x0b\n\t_new_textB\x0e\n\x0c_replace_all\"%\n\x07ListDir\x12\x11\n\x04path\x18\x01 \x01(\tH\x00\x88\x01\x01\x42\x07\n\x05_path\"\x7f\n\tFileReply\x12\x0e\n\x04text\x18\x01 \x01(\tH\x00\x12+\n\x05image\x18\x02 \x01(\x0b\x32\x1a.akashic.host.v2.FileImageH\x00\x12+\n\x05\x65rror\x18\x03 \x01(\x0b\x32\x1a.akashic.host.v2.FileErrorH\x00\x42\x08\n\x06result\"K\n\tFileError\x12\x11\n\x04text\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x15\n\x08is_error\x18\x02 \x01(\x08H\x01\x88\x01\x01\x42\x07\n\x05_textB\x0b\n\t_is_error\"f\n\tFileImage\x12\x11\n\x04text\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x11\n\tmime_type\x18\x02 \x01(\t\x12\x11\n\x04\x64\x61ta\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x12\x0e\n\x06\x64\x65tail\x18\x04 \x01(\tB\x07\n\x05_textB\x07\n\x05_data\"g\n\x18SkillRequirementsRequest\x12\x30\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\x1f.akashic.host.v2.RequestContext\x12\x0c\n\x04\x62ins\x18\x02 \x03(\t\x12\x0b\n\x03\x65nv\x18\x03 \x03(\t\"-\n\x10RequirementNames\x12\x0c\n\x04\x62ins\x18\x01 \x03(\t\x12\x0b\n\x03\x65nv\x18\x02 \x03(\t\"\x82\x01\n\x16SkillRequirementsReply\x12\x34\n\tavailable\x18\x01 \x01(\x0b\x32!.akashic.host.v2.RequirementNames\x12\x32\n\x07missing\x18\x02 \x01(\x0b\x32!.akashic.host.v2.RequirementNames2\xcb\x07\n\nHostBridge\x12J\n\x07Inspect\x12\x1f.akashic.host.v2.ContextRequest\x1a\x1e.akashic.host.v2.IdentityReply\x12M\n\tClaimBoot\x12\x1f.akashic.host.v2.ContextRequest\x1a\x1f.akashic.host.v2.ClaimBootReply\x12H\n\x05Probe\x12\x1f.akashic.host.v2.ContextRequest\x1a\x1e.akashic.host.v2.IdentityReply\x12M\n\tHeartbeat\x12\x1f.akashic.host.v2.ContextRequest\x1a\x1f.akashic.host.v2.HeartbeatReply\x12\x45\n\x04\x45xec\x12\x1c.akashic.host.v2.ExecRequest\x1a\x1f.akashic.host.v2.ExecutionReply\x12Q\n\nWriteStdin\x12\".akashic.host.v2.WriteStdinRequest\x1a\x1f.akashic.host.v2.ExecutionReply\x12@\n\x04Stop\x12\x1c.akashic.host.v2.StopRequest\x1a\x1a.akashic.host.v2.StopReply\x12N\n\x0eTerminateOwner\x12\x1d.akashic.host.v2.OwnerRequest\x1a\x1d.akashic.host.v2.CleanupReply\x12Q\n\x0fShutdownManager\x12\x1f.akashic.host.v2.ContextRequest\x1a\x1d.akashic.host.v2.CleanupReply\x12[\n\x10\x41\x63tiveExecutions\x12\x1f.akashic.host.v2.ContextRequest\x1a&.akashic.host.v2.ActiveExecutionsReply\x12\x44\n\x08\x46ileTool\x12\x1c.akashic.host.v2.FileRequest\x1a\x1a.akashic.host.v2.FileReply\x12g\n\x11SkillRequirements\x12).akashic.host.v2.SkillRequirementsRequest\x1a\'.akashic.host.v2.SkillRequirementsReplyb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#agent/host_bridge/host_bridge.proto\x12\x0f\x61kashic.host.v2\"\xd9\x01\n\x0eRequestContext\x12\x0f\n\x07\x62oot_id\x18\x01 \x01(\t\x12\x12\n\nmanager_id\x18\x02 \x01(\t\x12\x12\n\nrequest_id\x18\x03 \x01(\t\x12\x1f\n\x17\x65xpected_release_commit\x18\x04 \x01(\t\x12!\n\x19\x65xpected_toolchain_digest\x18\x05 \x01(\t\x12\x18\n\x0bsession_ref\x18\x06 \x01(\tH\x00\x88\x01\x01\x12\x14\n\x07turn_id\x18\x07 \x01(\tH\x01\x88\x01\x01\x42\x0e\n\x0c_session_refB\n\n\x08_turn_id\"B\n\x0e\x43ontextRequest\x12\x30\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\x1f.akashic.host.v2.RequestContext\"W\n\rIdentityReply\x12\x16\n\x0erelease_commit\x18\x01 \x01(\t\x12\x18\n\x10toolchain_digest\x18\x02 \x01(\t\x12\x14\n\x0c\x63\x61pabilities\x18\x03 \x03(\t\"\xdb\x01\n\x0e\x43laimBootReply\x12\x15\n\rowner_boot_id\x18\x01 \x01(\t\x12\x1d\n\x10previous_boot_id\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\"\n\x15\x63leaned_manager_count\x18\x03 \x01(\x04H\x01\x88\x01\x01\x12$\n\x17\x63leaned_execution_count\x18\x04 \x01(\x04H\x02\x88\x01\x01\x42\x13\n\x11_previous_boot_idB\x18\n\x16_cleaned_manager_countB\x1a\n\x18_cleaned_execution_count\".\n\x0eHeartbeatReply\x12\x12\n\x05\x61live\x18\x01 \x01(\x08H\x00\x88\x01\x01\x42\x08\n\x06_alive\"\xa1\x03\n\x0b\x45xecRequest\x12\x30\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\x1f.akashic.host.v2.RequestContext\x12\x0f\n\x07\x63ommand\x18\x02 \x01(\t\x12\x0c\n\x04\x61rgv\x18\x03 \x03(\t\x12\x10\n\x03\x63wd\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x32\n\x03\x65nv\x18\x05 \x03(\x0b\x32%.akashic.host.v2.ExecRequest.EnvEntry\x12\x10\n\x03tty\x18\x06 \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\ryield_time_ms\x18\x07 \x01(\x03H\x02\x88\x01\x01\x12\x1e\n\x11max_output_tokens\x18\x08 \x01(\x03H\x03\x88\x01\x01\x12\x1b\n\x0ehard_timeout_s\x18\t \x01(\x03H\x04\x88\x01\x01\x12\x19\n\x11owner_session_key\x18\n \x01(\t\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x06\n\x04_cwdB\x06\n\x04_ttyB\x10\n\x0e_yield_time_msB\x14\n\x12_max_output_tokensB\x11\n\x0f_hard_timeout_s\"\x8e\x02\n\x11WriteStdinRequest\x12\x30\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\x1f.akashic.host.v2.RequestContext\x12\x19\n\x0c\x65xecution_id\x18\x02 \x01(\x03H\x00\x88\x01\x01\x12\x12\n\x05\x63hars\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x1a\n\ryield_time_ms\x18\x04 \x01(\x03H\x02\x88\x01\x01\x12\x1e\n\x11max_output_tokens\x18\x05 \x01(\x03H\x03\x88\x01\x01\x12\x19\n\x11owner_session_key\x18\x06 \x01(\tB\x0f\n\r_execution_idB\x08\n\x06_charsB\x10\n\x0e_yield_time_msB\x14\n\x12_max_output_tokens\"\xcc\x02\n\x0e\x45xecutionReply\x12\x13\n\x06output\x18\x01 \x01(\x0cH\x01\x88\x01\x01\x12\x19\n\x0cwall_time_ms\x18\x02 \x01(\x03H\x02\x88\x01\x01\x12!\n\x14original_token_count\x18\x03 \x01(\x03H\x03\x88\x01\x01\x12!\n\x14output_omitted_bytes\x18\x04 \x01(\x03H\x04\x88\x01\x01\x12\x16\n\x0c\x65xecution_id\x18\x05 \x01(\x03H\x00\x12\x13\n\texit_code\x18\x06 \x01(\x11H\x00\x12\x18\n\x0boutput_path\x18\x07 \x01(\tH\x05\x88\x01\x01\x12\x15\n\rfinish_reason\x18\x08 \x01(\tB\x08\n\x06resultB\t\n\x07_outputB\x0f\n\r_wall_time_msB\x17\n\x15_original_token_countB\x17\n\x15_output_omitted_bytesB\x0e\n\x0c_output_path\"\x86\x01\n\x0bStopRequest\x12\x30\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\x1f.akashic.host.v2.RequestContext\x12\x19\n\x0c\x65xecution_id\x18\x02 \x01(\x03H\x00\x88\x01\x01\x12\x19\n\x11owner_session_key\x18\x03 \x01(\tB\x0f\n\r_execution_id\"-\n\tStopReply\x12\x14\n\x07stopped\x18\x01 \x01(\x08H\x00\x88\x01\x01\x42\n\n\x08_stopped\"[\n\x0cOwnerRequest\x12\x30\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\x1f.akashic.host.v2.RequestContext\x12\x19\n\x11owner_session_key\x18\x02 \x01(\t\"a\n\x0e\x43leanupFailure\x12\x19\n\x0c\x65xecution_id\x18\x01 \x01(\x03H\x00\x88\x01\x01\x12\x12\n\nerror_type\x18\x02 \x01(\t\x12\x0f\n\x07message\x18\x03 \x01(\tB\x0f\n\r_execution_id\"e\n\x0c\x43leanupReply\x12\x11\n\tattempted\x18\x01 \x03(\x03\x12\x0f\n\x07\x63leaned\x18\x02 \x03(\x03\x12\x31\n\x08\x66\x61ilures\x18\x03 \x03(\x0b\x32\x1f.akashic.host.v2.CleanupFailure\".\n\x15\x41\x63tiveExecutionsReply\x12\x15\n\rexecution_ids\x18\x01 \x03(\x03\"\xa3\x02\n\x0b\x46ileRequest\x12\x30\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\x1f.akashic.host.v2.RequestContext\x12\x18\n\x0b\x61llowed_dir\x18\x02 \x01(\tH\x01\x88\x01\x01\x12)\n\x04read\x18\x03 \x01(\x0b\x32\x19.akashic.host.v2.ReadFileH\x00\x12+\n\x05write\x18\x04 \x01(\x0b\x32\x1a.akashic.host.v2.WriteFileH\x00\x12)\n\x04\x65\x64it\x18\x05 \x01(\x0b\x32\x19.akashic.host.v2.EditFileH\x00\x12(\n\x04list\x18\x06 \x01(\x0b\x32\x18.akashic.host.v2.ListDirH\x00\x42\x0b\n\toperationB\x0e\n\x0c_allowed_dir\"d\n\x08ReadFile\x12\x11\n\x04path\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06offset\x18\x02 \x01(\x03H\x01\x88\x01\x01\x12\x12\n\x05limit\x18\x03 \x01(\x03H\x02\x88\x01\x01\x42\x07\n\x05_pathB\t\n\x07_offsetB\x08\n\x06_limit\"I\n\tWriteFile\x12\x11\n\x04path\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x14\n\x07\x63ontent\x18\x02 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05_pathB\n\n\x08_content\"\x98\x01\n\x08\x45\x64itFile\x12\x11\n\x04path\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x15\n\x08old_text\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x15\n\x08new_text\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x18\n\x0breplace_all\x18\x04 \x01(\x08H\x03\x88\x01\x01\x42\x07\n\x05_pathB\x0b\n\t_old_textB\x0b\n\t_new_textB\x0e\n\x0c_replace_all\"%\n\x07ListDir\x12\x11\n\x04path\x18\x01 \x01(\tH\x00\x88\x01\x01\x42\x07\n\x05_path\"\x7f\n\tFileReply\x12\x0e\n\x04text\x18\x01 \x01(\tH\x00\x12+\n\x05image\x18\x02 \x01(\x0b\x32\x1a.akashic.host.v2.FileImageH\x00\x12+\n\x05\x65rror\x18\x03 \x01(\x0b\x32\x1a.akashic.host.v2.FileErrorH\x00\x42\x08\n\x06result\"K\n\tFileError\x12\x11\n\x04text\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x15\n\x08is_error\x18\x02 \x01(\x08H\x01\x88\x01\x01\x42\x07\n\x05_textB\x0b\n\t_is_error\"f\n\tFileImage\x12\x11\n\x04text\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x11\n\tmime_type\x18\x02 \x01(\t\x12\x11\n\x04\x64\x61ta\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x12\x0e\n\x06\x64\x65tail\x18\x04 \x01(\tB\x07\n\x05_textB\x07\n\x05_data\"g\n\x18SkillRequirementsRequest\x12\x30\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\x1f.akashic.host.v2.RequestContext\x12\x0c\n\x04\x62ins\x18\x02 \x03(\t\x12\x0b\n\x03\x65nv\x18\x03 \x03(\t\"-\n\x10RequirementNames\x12\x0c\n\x04\x62ins\x18\x01 \x03(\t\x12\x0b\n\x03\x65nv\x18\x02 \x03(\t\"\x82\x01\n\x16SkillRequirementsReply\x12\x34\n\tavailable\x18\x01 \x01(\x0b\x32!.akashic.host.v2.RequirementNames\x12\x32\n\x07missing\x18\x02 \x01(\x0b\x32!.akashic.host.v2.RequirementNames2\x9c\x08\n\nHostBridge\x12J\n\x07Inspect\x12\x1f.akashic.host.v2.ContextRequest\x1a\x1e.akashic.host.v2.IdentityReply\x12M\n\tClaimBoot\x12\x1f.akashic.host.v2.ContextRequest\x1a\x1f.akashic.host.v2.ClaimBootReply\x12H\n\x05Probe\x12\x1f.akashic.host.v2.ContextRequest\x1a\x1e.akashic.host.v2.IdentityReply\x12O\n\x0bOpenManager\x12\x1f.akashic.host.v2.ContextRequest\x1a\x1f.akashic.host.v2.HeartbeatReply\x12M\n\tHeartbeat\x12\x1f.akashic.host.v2.ContextRequest\x1a\x1f.akashic.host.v2.HeartbeatReply\x12\x45\n\x04\x45xec\x12\x1c.akashic.host.v2.ExecRequest\x1a\x1f.akashic.host.v2.ExecutionReply\x12Q\n\nWriteStdin\x12\".akashic.host.v2.WriteStdinRequest\x1a\x1f.akashic.host.v2.ExecutionReply\x12@\n\x04Stop\x12\x1c.akashic.host.v2.StopRequest\x1a\x1a.akashic.host.v2.StopReply\x12N\n\x0eTerminateOwner\x12\x1d.akashic.host.v2.OwnerRequest\x1a\x1d.akashic.host.v2.CleanupReply\x12Q\n\x0fShutdownManager\x12\x1f.akashic.host.v2.ContextRequest\x1a\x1d.akashic.host.v2.CleanupReply\x12[\n\x10\x41\x63tiveExecutions\x12\x1f.akashic.host.v2.ContextRequest\x1a&.akashic.host.v2.ActiveExecutionsReply\x12\x44\n\x08\x46ileTool\x12\x1c.akashic.host.v2.FileRequest\x1a\x1a.akashic.host.v2.FileReply\x12g\n\x11SkillRequirements\x12).akashic.host.v2.SkillRequirementsRequest\x1a\'.akashic.host.v2.SkillRequirementsReplyb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -86,5 +86,5 @@ _globals['_SKILLREQUIREMENTSREPLY']._serialized_start=3386 _globals['_SKILLREQUIREMENTSREPLY']._serialized_end=3516 _globals['_HOSTBRIDGE']._serialized_start=3519 - _globals['_HOSTBRIDGE']._serialized_end=4490 + _globals['_HOSTBRIDGE']._serialized_end=4571 # @@protoc_insertion_point(module_scope) diff --git a/agent/host_bridge/host_bridge_pb2_grpc.py b/agent/host_bridge/host_bridge_pb2_grpc.py index 9494bbcce..cff32b327 100644 --- a/agent/host_bridge/host_bridge_pb2_grpc.py +++ b/agent/host_bridge/host_bridge_pb2_grpc.py @@ -50,6 +50,11 @@ def __init__(self, channel): request_serializer=agent_dot_host__bridge_dot_host__bridge__pb2.ContextRequest.SerializeToString, response_deserializer=agent_dot_host__bridge_dot_host__bridge__pb2.IdentityReply.FromString, _registered_method=True) + self.OpenManager = channel.unary_unary( + '/akashic.host.v2.HostBridge/OpenManager', + request_serializer=agent_dot_host__bridge_dot_host__bridge__pb2.ContextRequest.SerializeToString, + response_deserializer=agent_dot_host__bridge_dot_host__bridge__pb2.HeartbeatReply.FromString, + _registered_method=True) self.Heartbeat = channel.unary_unary( '/akashic.host.v2.HostBridge/Heartbeat', request_serializer=agent_dot_host__bridge_dot_host__bridge__pb2.ContextRequest.SerializeToString, @@ -119,6 +124,12 @@ def Probe(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def OpenManager(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def Heartbeat(self, request, context): """Missing associated documentation comment in .proto file.""" context.set_code(grpc.StatusCode.UNIMPLEMENTED) @@ -191,6 +202,11 @@ def add_HostBridgeServicer_to_server(servicer, server): request_deserializer=agent_dot_host__bridge_dot_host__bridge__pb2.ContextRequest.FromString, response_serializer=agent_dot_host__bridge_dot_host__bridge__pb2.IdentityReply.SerializeToString, ), + 'OpenManager': grpc.unary_unary_rpc_method_handler( + servicer.OpenManager, + request_deserializer=agent_dot_host__bridge_dot_host__bridge__pb2.ContextRequest.FromString, + response_serializer=agent_dot_host__bridge_dot_host__bridge__pb2.HeartbeatReply.SerializeToString, + ), 'Heartbeat': grpc.unary_unary_rpc_method_handler( servicer.Heartbeat, request_deserializer=agent_dot_host__bridge_dot_host__bridge__pb2.ContextRequest.FromString, @@ -329,6 +345,33 @@ def Probe(request, metadata, _registered_method=True) + @staticmethod + def OpenManager(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/akashic.host.v2.HostBridge/OpenManager', + agent_dot_host__bridge_dot_host__bridge__pb2.ContextRequest.SerializeToString, + agent_dot_host__bridge_dot_host__bridge__pb2.HeartbeatReply.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + @staticmethod def Heartbeat(request, target, diff --git a/agent/host_bridge/monitor.py b/agent/host_bridge/monitor.py index 5b010486d..a5e91a3cc 100644 --- a/agent/host_bridge/monitor.py +++ b/agent/host_bridge/monitor.py @@ -1,14 +1,33 @@ from __future__ import annotations import asyncio +import logging import os from collections.abc import Coroutine +from dataclasses import asdict, dataclass +from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, Literal -from agent.host_bridge.client import HostBridgeShellProcessManager +from core.common.diagnostic_log import log_event + +from agent.host_bridge.client import HostBridgeRpcError, HostBridgeShellProcessManager _MONITOR_INTERVAL_S = 2.0 +logger = logging.getLogger(__name__) + + +@dataclass +class HostBridgeStatus: + """由 App 监控任务更新的短命状态;只描述连接,不承诺旧 manager 仍存活。""" + + state: Literal["disabled", "checking", "healthy", "degraded"] = "disabled" + failures: int = 0 + code: str | None = None + checked_at: str | None = None + + def snapshot(self) -> dict[str, Any]: + return asdict(self) async def claim_host_bridge_boot() -> dict[str, Any] | None: @@ -24,13 +43,14 @@ async def claim_host_bridge_boot() -> dict[str, Any] | None: await manager.close_transport() -def build_host_bridge_monitor() -> Coroutine[Any, Any, None] | None: +def build_host_bridge_monitor(status: HostBridgeStatus) -> Coroutine[Any, Any, None] | None: """Build the required Core liveness monitor for host-bridge mode.""" identity = _configured_bridge_identity() if identity is None: return None - return _monitor(*identity) + status.state = "checking" + return _monitor(*identity, status=status) def _configured_bridge_identity() -> tuple[Path, str, str, str, str] | None: @@ -62,6 +82,8 @@ async def _monitor( token: str, release_commit: str, toolchain_digest: str, + *, + status: HostBridgeStatus, ) -> None: manager = HostBridgeShellProcessManager( socket_path, @@ -72,7 +94,25 @@ async def _monitor( ) try: while True: - await manager.probe() - await asyncio.sleep(_MONITOR_INTERVAL_S) + try: + await manager.probe() + except HostBridgeRpcError as exc: + status.checked_at = datetime.now(UTC).isoformat() + status.state = "degraded" + status.failures += 1 + status.code = exc.code.name + log_event(logger, logging.WARNING, "host_bridge.degraded", + reason=exc.code.name, counts=f"failures:{status.failures}") + if not exc.transient: + raise + else: + if status.failures: + log_event(logger, logging.INFO, "host_bridge.recovered", + counts=f"failures:{status.failures}") + status.checked_at = datetime.now(UTC).isoformat() + status.state = "healthy" + status.failures = 0 + status.code = None + await asyncio.sleep(min(_MONITOR_INTERVAL_S * (2 ** min(status.failures, 3)), 10)) finally: await manager.close_transport() diff --git a/agent/host_bridge/server.py b/agent/host_bridge/server.py index 2ee42d459..f14214387 100644 --- a/agent/host_bridge/server.py +++ b/agent/host_bridge/server.py @@ -67,6 +67,14 @@ def __post_init__(self) -> None: self.operations_drained.set() +class _ManagerUnavailable(Exception): + """manager 已停止接纳操作,不能继续复用。""" + + +class _ManagerNotFound(Exception): + """已登记的 manager 不再存在,旧执行句柄不能恢复使用。""" + + def _rpc[Request: Message, Reply: Message]( handler: Callable[["HostBridgeService", Request], Awaitable[Reply]], ) -> Callable[ @@ -114,6 +122,14 @@ async def run( except asyncio.CancelledError: # 2. 取消只结束本次 RPC 等待,不承诺进程未执行或输入未写入。 raise + except _ManagerUnavailable as exc: + self._log_rpc_failure(method, identity.request_id, identity.boot_id, + identity.manager_id, started, exc, "manager_unavailable") + await context.abort(grpc.StatusCode.FAILED_PRECONDITION, str(exc)) + except _ManagerNotFound as exc: + self._log_rpc_failure(method, identity.request_id, identity.boot_id, + identity.manager_id, started, exc, "manager_not_found") + await context.abort(grpc.StatusCode.NOT_FOUND, str(exc)) except (KeyError, TypeError, ValueError) as exc: self._log_rpc_failure( method, @@ -324,7 +340,8 @@ async def ClaimBoot(self, request: pb.ContextRequest) -> pb.ClaimBootReply: @_rpc async def Probe(self, request: pb.ContextRequest) -> pb.IdentityReply: - _ = await self._lease(request.context) + async with self._lock: + self._assert_active_boot(request.context.boot_id) return self._probe_payload() def _probe_payload(self) -> pb.IdentityReply: @@ -344,6 +361,12 @@ def _probe_payload(self) -> pb.IdentityReply: ], ) + @_rpc + async def OpenManager(self, request: pb.ContextRequest) -> pb.HeartbeatReply: + """唯一的首次登记入口,业务调用和心跳都不能创建 manager。""" + _ = await self._lease(request.context, create=True) + return pb.HeartbeatReply(alive=True) + @_rpc async def Heartbeat(self, request: pb.ContextRequest) -> pb.HeartbeatReply: _ = await self._lease(request.context) @@ -440,7 +463,7 @@ async def ShutdownManager(self, request: pb.ContextRequest) -> pb.CleanupReply: self._assert_active_boot(key[0]) lease = self._managers.get(key) if lease is None: - return encode_cleanup(ExecutionCleanupReport((), (), ())) + raise _ManagerNotFound("Host Bridge manager 已不存在,无法确认本次清理") lease.reaping = True await lease.operations_drained.wait() report = await lease.manager.shutdown() @@ -479,9 +502,9 @@ async def FileTool(self, request: pb.FileRequest) -> pb.FileReply: if read.HasField("limit"): require_positive(read.limit, "limit") async with self._manager_operation(request.context): - result = ReadFileOperation( + result = await ReadFileOperation( allowed_dir=allowed_dir, enable_bridge=False - ).read_from_disk( + ).read_raw( read.path, offset=read.offset, limit=read.limit if read.HasField("limit") else None, @@ -563,12 +586,14 @@ def _log_rpc_failure( exc_info=True, ) - async def _lease(self, context: pb.RequestContext) -> _ManagerLease: + async def _lease(self, context: pb.RequestContext, *, create: bool = False) -> _ManagerLease: key = (context.boot_id, context.manager_id) async with self._lock: self._assert_active_boot(key[0]) lease = self._managers.get(key) if lease is None: + if not create: + raise _ManagerNotFound("Host Bridge manager 已不存在,旧执行句柄已失效") manager_root = self._artifact_root / key[0] / key[1] lease = _ManagerLease( ShellProcessManager(output_dir=manager_root), @@ -577,9 +602,9 @@ async def _lease(self, context: pb.RequestContext) -> _ManagerLease: self._managers[key] = lease else: if lease.cleanup_failure is not None: - raise RuntimeError("Host Bridge manager cleanup 未确认,拒绝复用") + raise _ManagerUnavailable("Host Bridge manager cleanup 未确认,拒绝复用") if lease.reaping: - raise RuntimeError("Host Bridge manager lease 正在回收,拒绝复用") + raise _ManagerUnavailable("Host Bridge manager lease 正在回收,拒绝复用") lease.last_seen = time.monotonic() return lease @@ -595,7 +620,7 @@ async def _manager_operation( async with self._lock: self._assert_active_boot(key[0]) if self._managers.get(key) is not lease or lease.reaping: - raise RuntimeError("Host Bridge manager admission 已关闭,拒绝执行") + raise _ManagerUnavailable("Host Bridge manager admission 已关闭,拒绝执行") lease.active_operations += 1 lease.operations_drained.clear() try: diff --git a/bootstrap/app.py b/bootstrap/app.py index 15135e373..a6f8104fa 100644 --- a/bootstrap/app.py +++ b/bootstrap/app.py @@ -13,7 +13,7 @@ from agent.config import resolve_app_server_endpoint from agent.control.service import ControlService -from agent.host_bridge.monitor import build_host_bridge_monitor +from agent.host_bridge.monitor import HostBridgeStatus, build_host_bridge_monitor from agent.host_bridge.monitor import claim_host_bridge_boot from agent.restart import RestartGate from agent.config_models import Config @@ -200,6 +200,7 @@ def __init__( ) self.restart_gate = restart_gate self.readiness = readiness + self.host_bridge_status = HostBridgeStatus() self.http_resources = SharedHttpResources() self.app_server: SocketAppServer | None = None self.control_service: ControlService | None = None @@ -245,6 +246,7 @@ async def start(self) -> None: self.dashboard_server = build_dashboard_server( workspace=self.workspace, plugin_manager=manager, + host_bridge_status=self.host_bridge_status.snapshot, ) await self.core.start() if self.readiness is not None: @@ -284,7 +286,7 @@ async def start(self) -> None: self.readiness.mark_stage("channels.ready") if plugin_manager is None: raise RuntimeError("插件 Runtime 不可用") - host_bridge_monitor = build_host_bridge_monitor() + host_bridge_monitor = build_host_bridge_monitor(self.host_bridge_status) self.tasks = [] if host_bridge_monitor is not None: self.tasks.append(host_bridge_monitor) diff --git a/bootstrap/dashboard_api.py b/bootstrap/dashboard_api.py index 739118cc0..ac67c86e6 100644 --- a/bootstrap/dashboard_api.py +++ b/bootstrap/dashboard_api.py @@ -1,8 +1,9 @@ from __future__ import annotations import logging +from collections.abc import Callable from pathlib import Path -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: from agent.plugins.manager import PluginManager @@ -58,12 +59,19 @@ def create_dashboard_app( workspace: Path, *, plugin_manager: object | None = None, + host_bridge_status: Callable[[], dict[str, Any]] | None = None, ) -> FastAPI: workspace.mkdir(parents=True, exist_ok=True) project_root = Path(__file__).resolve().parent.parent static_dir = project_root / "static" / "dashboard" app = FastAPI(title="Akashic Dashboard API") + if host_bridge_status is not None: + + @app.get("/api/runtime/host-bridge") + async def read_host_bridge_status() -> dict[str, Any]: + return host_bridge_status() + # Vite 构建产物被 gitignore,新 clone 或 CI 环境可能没有该目录。 # 预先创建目录并在挂载时关闭目录检查,避免 app 创建依赖构建是否执行; # dashboard_index() 会在入口文件缺失时报告错误。 @@ -125,11 +133,13 @@ def _build_dashboard_uvicorn_config( port: int | None, uds: str | None = None, plugin_manager: object | None = None, + host_bridge_status: Callable[[], dict[str, Any]] | None = None, ) -> uvicorn.Config: config = uvicorn.Config( create_dashboard_app( workspace, plugin_manager=plugin_manager, + host_bridge_status=host_bridge_status, ), host=host or "127.0.0.1", port=port or 2236, @@ -147,6 +157,7 @@ def build_dashboard_server( port: int | None = None, uds: str | None = None, plugin_manager: object | None = None, + host_bridge_status: Callable[[], dict[str, Any]] | None = None, ) -> uvicorn.Server: config = _build_dashboard_uvicorn_config( workspace=workspace, @@ -154,5 +165,6 @@ def build_dashboard_server( port=port, uds=uds, plugin_manager=plugin_manager, + host_bridge_status=host_bridge_status, ) return uvicorn.Server(config) diff --git a/bootstrap/web_shell.py b/bootstrap/web_shell.py index 873ae5c04..084a131e4 100644 --- a/bootstrap/web_shell.py +++ b/bootstrap/web_shell.py @@ -133,6 +133,10 @@ async def shell_state() -> dict[str, object]: "chatReady": chat_ready, } + @app.get("/api/runtime/host-bridge") + async def proxy_host_bridge_status(request: Request) -> Response: + return await _proxy_http(request, dashboard_socket, "/api/runtime/host-bridge") + @app.api_route( "/api/chat/{proxy_path:path}", methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], diff --git a/docker/debug/host_bridge_notice.mjs b/docker/debug/host_bridge_notice.mjs new file mode 100644 index 000000000..6b371c6f8 --- /dev/null +++ b/docker/debug/host_bridge_notice.mjs @@ -0,0 +1,54 @@ +// 用真实 React 组件验证故障、恢复和未知状态,HTTP 响应由实验控制。 +import { build } from "esbuild"; +import { chromium } from "playwright-core"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +const output = await mkdtemp(resolve(tmpdir(), "bridge-notice-")); +let state = "degraded"; +let httpFailure = false; +let browser; +const server = createServer(async (request, response) => { + if (request.url === "/api/runtime/host-bridge") { + response.writeHead(httpFailure ? 503 : 200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ state })); + } else if (request.url === "/app.js") { + response.writeHead(200, { "Content-Type": "text/javascript" }); + response.end(await readFile(resolve(output, "app.js"))); + } else { + response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + response.end('
'); + } +}); +try { + await build({ + stdin: { + contents: 'import {createRoot} from "react-dom/client"; import {HostBridgeNotice} from "./frontend/chat/src/host-bridge-notice"; createRoot(document.getElementById("root")).render();', + resolveDir: process.cwd(), loader: "tsx", + }, + bundle: true, jsx: "automatic", outfile: resolve(output, "app.js"), + }); + await new Promise(resolveReady => server.listen(0, "127.0.0.1", resolveReady)); + browser = await chromium.launch({ executablePath: "/usr/bin/chromium", headless: true }); + const page = await browser.newPage(); + await page.clock.install(); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.getByRole("status").filter({ hasText: "宿主执行暂时不可用" }).waitFor(); + state = "healthy"; + await page.clock.fastForward(5100); + await page.getByRole("status").waitFor({ state: "detached" }); + httpFailure = true; + await page.clock.fastForward(5100); + await page.getByRole("status").filter({ hasText: "暂时无法确认" }).waitFor(); + httpFailure = false; + state = "disabled"; + await page.clock.fastForward(5100); + await page.getByRole("status").waitFor({ state: "detached" }); + console.log(JSON.stringify({ result: "passed", experiments: ["degraded_visible", "recovery_clears_notice", "unknown_is_not_healthy", "local_mode_no_notice"] })); +} finally { + await browser?.close(); + await new Promise(resolveClosed => server.close(resolveClosed)); + await rm(output, { recursive: true }); +} diff --git a/docker/debug/host_bridge_reliability.py b/docker/debug/host_bridge_reliability.py new file mode 100644 index 000000000..f07e94d1d --- /dev/null +++ b/docker/debug/host_bridge_reliability.py @@ -0,0 +1,303 @@ +"""临时 UDS、真实进程和磁盘故障实验;只写 TemporaryDirectory。""" +from __future__ import annotations + +import asyncio +import contextlib +import json +from pathlib import Path +import sys +import tempfile +import threading +from unittest.mock import patch + +import grpc +import httpx +import uvicorn + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from agent.host_bridge import client as bridge_client, filesystem, monitor +from agent.host_bridge import host_bridge_pb2_grpc as rpc +from agent.host_bridge.client import HostBridgeRpcError, HostBridgeShellProcessManager +from agent.host_bridge.server import HostBridgeService +from bootstrap.app import _run_primary_tasks +from bootstrap.dashboard_api import create_dashboard_app +from bootstrap.web_shell import create_web_shell_app +from bootstrap.web_runtime import dashboard_socket_path + +COMMIT = "a" * 40 +DIGEST = "b" * 64 +TOKEN = "local-experiment" + + +class FaultService(HostBridgeService): + """仅在真实 handler 边界注入传输错误,业务仍由生产代码执行。""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.probe_error = None + self.heartbeat_error = None + self.drop_exec_reply = False + self.calls: dict[str, int] = {} + self.changed = asyncio.Condition() + + async def record(self, method): + async with self.changed: + self.calls[method] = self.calls.get(method, 0) + 1 + self.changed.notify_all() + + async def until(self, method, count): + async with asyncio.timeout(3), self.changed: + await self.changed.wait_for(lambda: self.calls.get(method, 0) >= count) + + async def Probe(self, request, context): + reply = await super().Probe(request, context) + await self.record("probe") + if self.probe_error is not None: + await context.abort(self.probe_error, "实验:探测故障") + return reply + + async def Heartbeat(self, request, context): + await self.record("heartbeat") + if self.heartbeat_error is not None: + await context.abort(self.heartbeat_error, "实验:心跳故障") + return await super().Heartbeat(request, context) + + async def OpenManager(self, request, context): + await self.record("open") + return await super().OpenManager(request, context) + + async def Exec(self, request, context): + await self.record("exec") + reply = await super().Exec(request, context) + if self.drop_exec_reply: + await context.abort(grpc.StatusCode.UNAVAILABLE, "实验:命令执行后丢失响应") + return reply + + async def FileTool(self, request, context): + try: + return await super().FileTool(request, context) + finally: + await self.record("file_finished") + + +async def expect_rpc(code, awaitable): + """明确核对状态码,不把任何异常都算作预期失败。""" + try: + await awaitable + except HostBridgeRpcError as exc: + assert exc.code is code, str(exc) + return exc + raise AssertionError(f"预期 {code.name}") + + +async def until(predicate): + """外部服务和线程的完成信号有期限;不靠固定等待猜测完成。""" + async with asyncio.timeout(3): + while not predicate(): + await asyncio.sleep(0.005) + + +async def run() -> None: + """每个断言观察真实边界结果,最后排空线程、RPC 和子进程。""" + results = [] + with tempfile.TemporaryDirectory(prefix="bridge-reliability-") as temporary: + root = Path(temporary) + socket = root / "bridge.sock" + service = FaultService(TOKEN, 0.1, root / "artifacts", release_commit=COMMIT, + toolchain_digest=DIGEST, runtime_checkout=ROOT, + bridge_python=Path(sys.executable)) + server = grpc.aio.server() + rpc.add_HostBridgeServicer_to_server(service, server) + assert server.add_insecure_port(f"unix:{socket}") + await server.start() + clients = [] + tasks = [] + dashboard = None + + def client(boot="experiment", token=TOKEN): + value = HostBridgeShellProcessManager(socket, boot, token, COMMIT, DIGEST) + clients.append(value) + return value + + async def command(owner, text): + return await owner.exec_command(command=text, argv=["/bin/sh", "-c", text], + cwd=root, env={}, tty=False, yield_time_ms=1000, max_output_tokens=1000, + hard_timeout_s=20, owner_session_key="experiment") + + try: + monitor._MONITOR_INTERVAL_S = 0.01 + bridge_client._HEARTBEAT_INTERVAL_S = 0.01 + control = client() + await control.claim_boot() + status = monitor.HostBridgeStatus(state="checking") + service.probe_error = grpc.StatusCode.DEADLINE_EXCEEDED + monitoring = asyncio.create_task(monitor._monitor(socket, "experiment", TOKEN, COMMIT, DIGEST, status=status)) + sibling = asyncio.create_task(asyncio.Event().wait()) + primary = asyncio.create_task(_run_primary_tasks([monitoring, sibling])) + tasks.append(primary) + await service.until("probe", 2) + assert not primary.done() and not sibling.done() + assert status.state == "degraded" and status.code == "DEADLINE_EXCEEDED" + assert not service._managers, "健康探测不能创建 execution manager" + workspace = root / "dashboard" + app = create_dashboard_app(workspace, host_bridge_status=status.snapshot) + dashboard_socket = dashboard_socket_path(workspace) + dashboard_socket.parent.mkdir(parents=True, exist_ok=True) + dashboard = uvicorn.Server(uvicorn.Config(app, uds=str(dashboard_socket), log_level="error")) + dashboard_task = asyncio.create_task(dashboard.serve()) + tasks.append(dashboard_task) + await until(lambda: dashboard.started) + shell = create_web_shell_app(root / "config.json", workspace) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=shell), base_url="http://local", + headers={"sec-fetch-site": "same-origin"}) as web: + response = await web.get("/api/runtime/host-bridge") + assert response.json()["state"] == "degraded" + service.probe_error = None + await service.until("probe", 4) + assert (await web.get("/api/runtime/host-bridge")).json()["state"] == "healthy" + results.append("probe_deadline_core_survives_and_public_shell_http_recovers") + + # 真正断开 UDS transport,然后重建监听;保留 service 的 boot/lease owner。 + await server.stop(0) + await expect_rpc(grpc.StatusCode.UNAVAILABLE, control.probe()) + await until(lambda: status.state == "degraded") + assert not primary.done() and not sibling.done() + server = grpc.aio.server() + rpc.add_HostBridgeServicer_to_server(service, server) + assert server.add_insecure_port(f"unix:{socket}") + await server.start() + await until(lambda: status.state == "healthy") + results.append("real_transport_disconnect_and_reconnect") + + # 并发首次调用只登记一次;短暂心跳故障保持同一个 execution owner。 + worker = client() + before = service.calls.get("open", 0) + await asyncio.gather(*(worker.active_execution_ids() for _ in range(8))) + assert service.calls["open"] == before + 1 + identity = (worker._boot_id, worker._manager_id) + original = service._managers[identity] + service.heartbeat_error = grpc.StatusCode.UNAVAILABLE + count = service.calls.get("heartbeat", 0) + await service.until("heartbeat", count + 2) + service.heartbeat_error = None + await service.until("heartbeat", count + 4) + assert worker._lease_error is None and service._managers[identity] is original + result = await command(worker, "printf recovered") + assert result.exit_code == 0 + results.append("one_acquire_and_heartbeat_recovery") + + # 超过 lease 的失联必须终结旧 manager,不能重建一个空执行表。 + await worker._stop_heartbeat() + original.last_seen = 0 + reaper = asyncio.create_task(service.reap_expired()) + tasks.append(reaper) + async with asyncio.timeout(2): + while identity in service._managers: + await asyncio.sleep(0.01) + reaper.cancel() + with contextlib.suppress(asyncio.CancelledError): + await reaper + opens = service.calls["open"] + await expect_rpc(grpc.StatusCode.NOT_FOUND, worker.active_execution_ids()) + await expect_rpc(grpc.StatusCode.NOT_FOUND, worker.shutdown()) + assert service.calls["open"] == opens and identity not in service._managers + results.append("expired_manager_rejected_without_reopen_or_false_cleanup") + + # 业务执行后响应丢失:文件只能增加一次,客户端不得重发。 + writer = client() + service.drop_exec_reply = True + error = await expect_rpc(grpc.StatusCode.UNAVAILABLE, + command(writer, "printf x >> once.txt")) + assert "不得自动重发" in str(error) + service.drop_exec_reply = False + assert (root / "once.txt").read_text() == "x" + assert service.calls["exec"] == 2 + results.append("lost_exec_reply_no_replay") + + # 单次命令参数/内部失败不能把仍存在的 manager 宣判为丢失。 + await expect_rpc(grpc.StatusCode.INTERNAL, writer.exec_command( + command="true", argv=["/bin/sh", "-c", "true"], cwd=root / "missing", + env={}, tty=False, yield_time_ms=1000, max_output_tokens=100, + hard_timeout_s=20, owner_session_key="experiment")) + assert (await command(writer, "printf still-alive")).exit_code == 0 + results.append("business_error_does_not_poison_manager") + + # 四种文件操作走真实磁盘;慢写入期间 Probe 与同文件串行性都保留。 + io = client() + entered = asyncio.Event() + release = threading.Event() + loop = asyncio.get_running_loop() + real_write = filesystem.atomic_write_text + def slow_write(path, content, **kwargs): + if content == "first": + loop.call_soon_threadsafe(entered.set) + if not release.wait(3): + raise TimeoutError("实验未释放慢写") + return real_write(path, content, **kwargs) + with patch.object(filesystem, "atomic_write_text", slow_write): + writing = asyncio.create_task(io.execute_file_tool("write_file", allowed_dir=root, + arguments={"path": "slow.txt", "content": "first"})) + tasks.append(writing) + try: + await asyncio.wait_for(entered.wait(), 2) + await asyncio.wait_for(control.probe(), 0.3) + writing.cancel() + with contextlib.suppress(asyncio.CancelledError): + await writing + lease = service._managers[(io._boot_id, io._manager_id)] + assert lease.active_operations == 1 and not lease.operations_drained.is_set() + second = asyncio.create_task(io.execute_file_tool("write_file", allowed_dir=root, + arguments={"path": "slow.txt", "content": "second"})) + tasks.append(second) + await until(lambda: any(state.users == 2 for state in filesystem._FILE_MUTATION_LOCKS.values())) + shutdown = asyncio.create_task(io.shutdown()) + tasks.append(shutdown) + await until(lambda: lease.reaping) + assert not lease.operations_drained.is_set() and not shutdown.done() + release.set() + await second + assert not (await shutdown).failures + assert (root / "slow.txt").read_text() == "second" + finally: + release.set() + io = client() + edited = await io.execute_file_tool("edit_file", allowed_dir=root, + arguments={"path": "slow.txt", "old_text": "second", "new_text": "third"}) + assert isinstance(edited, str) + read = await io.execute_file_tool("read_file", allowed_dir=root, arguments={"path": "slow.txt"}) + listing = await io.execute_file_tool("list_dir", allowed_dir=root, arguments={"path": "."}) + assert "third" in str(read) and "slow.txt" in str(listing) + assert not filesystem._FILE_MUTATION_LOCKS and not filesystem._FILE_IO_SLOTS + results.append("slow_disk_probe_cancel_drain_and_four_file_operations") + + # 认证错误不能被恢复策略吞掉;旧 boot 的所有执行入口被 fencing。 + await expect_rpc(grpc.StatusCode.PERMISSION_DENIED, client(token="wrong").probe()) + await control.close_transport() + primary.cancel() + with contextlib.suppress(asyncio.CancelledError): + await primary + next_boot = client("next-boot") + await next_boot.claim_boot() + await expect_rpc(grpc.StatusCode.PERMISSION_DENIED, io.probe()) + await expect_rpc(grpc.StatusCode.PERMISSION_DENIED, io.active_execution_ids()) + assert not service._managers + results.append("auth_and_boot_fencing_stay_fatal") + print(json.dumps({"result": "passed", "experiments": results}, ensure_ascii=False)) + finally: + if dashboard is not None: + dashboard.should_exit = True + await asyncio.wait_for(dashboard_task, 3) + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + for value in clients: + await value.close_transport() + await service.shutdown() + await server.stop(0) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/docs/INDEX.md b/docs/INDEX.md index ab1cafe86..118a51e50 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -126,7 +126,7 @@ | 主动流程、Wake、Drift、调度 | `projectneed` 第 6、9、12~13 节 → [持久化状态地图](design/persistence-state-map.md) → [Wake 最近主动消息上下文](design/wake-recent-delivery-context.md) → [Content / Wake 现有原子能力与第一阶段](design/content-wake-existing-atoms-first-stage.md) → [Content / Wake / Proactive 分层任务合同](design/content-wake-proactive-migration-task-contract.md) → [0040](decisions/0040-wake-duty-gate-lives-in-scoped-react.md) → [0048](decisions/0048-eventmail-keeps-three-mail-lifecycles.md) | `plugins/eventmail/`、`plugins/wake/`、`plugins/drift/`、`plugins/scheduler/`、`plugins/subagent/`、`agent/plugin_composition/timers.py`、`agent/plugin_composition/tasks.py` | | React Core 原子能力、Scheduler/Subagent 非特权插件 | `projectneed` 第 6、9~13 节 → [0034](decisions/0034-turn-is-the-logical-work-unit.md) → [0039](decisions/0039-react-core-atoms-keep-sources-unprivileged.md) → [React Core 与 Scheduler/Subagent 设计](design/react-core-scheduler-subagent.md) → [分阶段任务合同](design/react-core-scheduler-subagent-task-contract.md) → [持久化状态地图](design/persistence-state-map.md) | `plugins/react/`、`plugins/scheduler/`、`plugins/subagent/`、`plugins/wake/`、`plugins/turn_projection/`、`agent/plugin_composition/`、`agent/plugins/manager.py` | | 正式启动、Supervisor、自重启、停止信号 | `projectneed` RUN-001~RUN-004 → [Linux Supervisor 安全自重启提议](design/linux-supervisor-safe-self-restart.md) → [`docker/debug/README.md`](../docker/debug/README.md) | `main.py`、`agent/supervisor.py`、`agent/restart.py`、`plugins/message_push/restart.py`、`scripts/stop-runtime.sh`、restart Gate 报告 | -| 容器、云主机运行适配、Host Bridge、插件 Workload、hua-home迁移 | `projectneed` RUN-013~RUN-016、PLG-017、WSP-005~WSP-006 → [0032](decisions/0032-host-bridge-preserves-host-equivalent-execution.md) → [0055](decisions/0055-host-bridge-uses-typed-protobuf.md) → [Host Bridge Protocol V2](design/host-bridge-protocol-v2.md) → [0053](decisions/0053-plugins-declare-managed-workloads.md) → [Computer 插件与 Workload 合同](design/computer-plugin-workload-task-contract.md) → [容器与 Linux 主机运行适配设计](design/akashic-container-cloud-runtime-adaptation.md) → [Core 与 Host Bridge 安装设计](design/akashic-core-bridge-installer.md) → [非迁移实验合同](design/akashic-container-host-bridge-experiment-contract.md) → [Unified Shell Execution 设计](design/unified-shell-execution.md) → [持久化状态地图](design/persistence-state-map.md) | `agent/plugin_composition/`、`agent/plugins/`、Workload Controller、exact-commit 安装、runtime identity、Supervisor 与隔离实验;正式 profile 迁移前先运行 plan-only 清单并取得独立批准 | +| 容器、云主机运行适配、Host Bridge、插件 Workload、hua-home迁移 | `projectneed` RUN-013~RUN-016、PLG-017、WSP-005~WSP-006 → [0032](decisions/0032-host-bridge-preserves-host-equivalent-execution.md) → [0055](decisions/0055-host-bridge-uses-typed-protobuf.md) → [0075](decisions/0075-host-bridge-runtime-recovery.md) → [运行期可靠性](design/host-bridge-reliability.md) → [Host Bridge Protocol V2](design/host-bridge-protocol-v2.md) → [0053](decisions/0053-plugins-declare-managed-workloads.md) → [Computer 插件与 Workload 合同](design/computer-plugin-workload-task-contract.md) → [容器与 Linux 主机运行适配设计](design/akashic-container-cloud-runtime-adaptation.md) → [Core 与 Host Bridge 安装设计](design/akashic-core-bridge-installer.md) → [非迁移实验合同](design/akashic-container-host-bridge-experiment-contract.md) → [Unified Shell Execution 设计](design/unified-shell-execution.md) → [持久化状态地图](design/persistence-state-map.md) | `agent/plugin_composition/`、`agent/plugins/`、Workload Controller、exact-commit 安装、runtime identity、Supervisor 与隔离实验;正式 profile 迁移前先运行 plan-only 清单并取得独立批准 | | Computer 驱动源码迁移 | [源码迁移合同](design/computer-driver-source-migration.md) → [Computer 插件与 Workload 合同](design/computer-plugin-workload-task-contract.md) | `docker/computer/`、`plugins/computer/`、固定 Cua 夹具与原版驱动对照 | | Provider、模型角色、运行时切换、usage、首次配置、模型普通插件化 | `projectneed` RUN-005~RUN-012、ONB-001、CTX-001 → [0050](decisions/0050-model-revision-lives-in-ordinary-plugin.md) → [0054](decisions/0054-model-sync-refreshes-public-capabilities.md) → [模型普通插件与 Provider 组合规格](design/model-plugin-ordinary-capability-spec.md) → [0027](decisions/0027-runtime-models-use-generation-leases.md) → [0028](decisions/0028-model-credentials-live-with-workspace-connections.md) → [现行实现与历史验收基线](design/runtime-model-registry-and-onboarding.md) → [持久化状态地图](design/persistence-state-map.md) | `plugins/models/`、`plugins/opencode_go/`、`agent/plugin_composition/models.py`、`agent/model_runtime/`、`bootstrap/settings_api.py`、`frontend/chat/src` | | 插件安装、热重载、自验证、Cordis 迁移、plugin-data、Skill、Drift skill、MCP | `projectneed` 第 6、9~13 节 → [0072](decisions/0072-single-graph-local-plugin-updates.md) → [单图插件系统设计](design/issue-750-plugin-publication-simplification.md) → [0008](decisions/0008-plugin-runtime-publishes-only-committed-snapshots.md) → [0024](decisions/0024-plugin-self-validation-uses-stable-and-latest.md) → [0026](decisions/0026-plugin-rollout-is-owned-by-the-parent-turn.md) → [插件自更新复杂度审查](design/plugin-update-entropy-audit.md) → [0036](decisions/0036-plugin-composition-keeps-promotion-owner.md) → [0038](decisions/0038-operator-trust-can-publish-offline-plugin-batches.md) → [0042](decisions/0042-plugin-diagnostics-preserve-domain-owners.md) → [0046](decisions/0046-plugin-candidate-validation-is-incremental.md) → [插件 install/uninstall/revert turn 边界发布合同](design/plugin-install-uninstall-turn-boundary-rollout.md) → [插件递归自验证运行时设计](design/recursive-plugin-self-validation.md) → [Cordis 插件迁移能力等价验收](design/cordis-plugin-capability-parity.md) → [插件 v3 最终迁移地图(历史)](design/plugin-v3-final-migration-map.md) → [插件 v3 生产替代清单(历史)](design/plugin-v3-production-readiness-checklist.md) → [插件 v3 admission/lifecycle 收口合同](design/plugin-v3-admission-lifecycle-closeout-task-contract.md) → [插件 v3 generation metadata 收口合同](design/plugin-v3-generation-metadata-task-contract.md) → [插件 v3 Runtime Inspection 合同](design/plugin-v3-runtime-inspection-task-contract.md) → [插件 v3 committed command catalog 合同](design/plugin-v3-command-catalog-task-contract.md) → [插件组合内核第一阶段任务合同](design/plugin-composition-kernel-task-contract.md) → [插件事件与同步执行能力任务合同](design/plugin-event-executor-task-contract.md) → [插件 TopologyView 任务合同](design/plugin-topology-view-task-contract.md) → [插件 lifecycle 接入点任务合同](design/plugin-lifecycle-seam-task-contract.md) → [Turn committed typed event 合同](design/plugin-turn-committed-event-task-contract.md) → [插件 v3 generation loader 任务合同](design/plugin-v3-loader-task-contract.md) → [插件 stable 原子组装任务合同](design/plugin-stable-atomic-assembly-task-contract.md) → [插件 candidate Root 隔离任务合同](design/plugin-candidate-root-isolation-task-contract.md) → [插件组合结构身份与 revision 任务合同](design/plugin-composition-revision-task-contract.md) → [插件组合 Health/Incident/Validation 任务合同](design/plugin-composition-health-incident-task-contract.md) → [插件 Transform/Observe 事件任务合同](design/plugin-transform-observe-task-contract.md) → [插件 generation 数据根任务合同](design/plugin-data-root-task-contract.md) → [插件 Tool 组合事件任务合同](design/plugin-tool-composition-events-task-contract.md) → [插件 Tool v3 迁移组合 Gate 任务合同](design/plugin-tool-v3-migration-gate-task-contract.md) → [Citation + Meme 纯 v3 组合 Gate(历史)](design/plugin-passive-composition-v3-gate-task-contract.md) → [持久化状态地图](design/persistence-state-map.md) | `agent/plugins/composable.py`、`agent/plugins/install.py`、`agent/plugins/manager.py`、`agent/plugin_composition/runtime_catalog.py`、`agent/plugins/reload_journal.py`、`agent/plugins/selection.py`、`agent/plugins/generation.py`、`agent/mcp/client.py`、`agent/plugin_composition/context.py`、`agent/plugin_composition/effect.py`、`plugins/plugin_update/`、`plugins/models/`、`bootstrap/app.py`、`utils/process_group.py` | @@ -314,6 +314,7 @@ docs/ │ ├── runtime-model-registry-and-onboarding.md │ ├── server-published-mobile-webui.md │ ├── shared-chat-webui.md +│ ├── host-bridge-reliability.md │ ├── host-bridge-protocol-v2.md │ ├── unified-shell-execution.md │ ├── veda-persona.md diff --git a/docs/decisions/0075-host-bridge-runtime-recovery.md b/docs/decisions/0075-host-bridge-runtime-recovery.md new file mode 100644 index 000000000..c64a5e0db --- /dev/null +++ b/docs/decisions/0075-host-bridge-runtime-recovery.md @@ -0,0 +1,41 @@ +# 0075 · Host Bridge 运行期按故障范围恢复 + +- 状态:accepted +- 日期:2026-09-26 +- 关联:RUN-013、RUN-015、SH-001~SH-003、ERR-001 +- 补充:[0032](0032-host-bridge-preserves-host-equivalent-execution.md)、[0055](0055-host-bridge-uses-typed-protobuf.md) + +## 背景 + +一次 Probe DEADLINE_EXCEEDED 会结束 Core 的关键监控任务,关闭已有聊天连接。 +服务端旧实现让 Probe/Heartbeat 隐式创建 manager;客户端又把一次心跳失败永久记为租约失效。 +这使短暂传输故障扩大为整个应用重启,同时缺乏安全的续期边界。 +维护者授权本地实现与故障实验,PR 评审后才决定合并和部署。 + +## 决定 + +1. 启动 ClaimBoot、身份和权限检查保持严格;运行期仅 UNAVAILABLE/DEADLINE_EXCEEDED + 降级宿主执行并继续探测。身份、boot 和程序合同错误不按暂时失联吞掉。 +2. 使用现有随机 manager_id。OpenManager 是唯一首次登记入口;续期、业务操作和关闭 + 只能引用已存在的 manager。旧 manager 丢失后由原生命周期 owner 创建新客户端,旧句柄不迁移。 +3. Exec、stdin 和文件操作每次只发送一次。传输失败可能已有外部效果,不自动重放。 +4. 文件读写、编辑和目录遍历离开事件循环,并限制同时执行数量。RPC 取消后必须等实际线程 + 结束再释放文件锁和 manager operation,不能把返回取消当作写入回滚。 +5. AppRuntime 的监控任务独占短命连接状态;HTTP 只读投影和聊天提示消费同一状态。 + 它与某个执行 manager 的存亡不同,不进入 Root/Fiber 健康项或持久化 readiness。 + +## 理由与影响 + +增加另一层 lease_id 不能解决执行 owner 丢失,已有 manager_id 足以表达一次生命周期。 +恢复策略只重试可安全重复的探测/心跳,避免以便利性换取命令重复执行。 +文件线程改善事件循环响应,但不能中断卡在内核里的磁盘调用;回收仍需等待物理完成。 +长期失联可能使 lease 被回收,此时明确失败,不能把健康 Probe 当作旧命令仍存在的证明。 + +本变更修改同 release 私有协议,发布须 Core/Bridge 同 commit。回滚也须整对回滚。 +会话、项目、记忆、附件保留语义和数据库 schema 不变。 + +## 验收 + +真实 UDS 验证超时/断线恢复、租约过期、boot fencing、响应丢失不重放和文件取消排空; +真实公开 Shell 代理验证状态查询;Chromium 验证实际 React 提示出现、恢复消失和未知状态。 +详细命令和证据边界见[设计](../design/host-bridge-reliability.md)。 diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 0cdf0b71c..a276e5087 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -7,6 +7,7 @@ | ID | 状态 | 主题 | 关联条款 | |---|---|---|---| | [0073](0073-session-scope-routes-akasha-graphs.md) | accepted / implemented(首版) | Session scope 宽键路由 Akasha 物化图 | SES-010、MEM-009、MEM-013、CTRL-003 | +| [0075](0075-host-bridge-runtime-recovery.md) | accepted | Host Bridge 运行期按故障范围恢复 | RUN-013、RUN-015、SH-001~SH-003、ERR-001 | | [0074](0074-deployment-policy-belongs-to-operator.md) | accepted | 部署者选择备份、插件映射与迁移 | MIG-001、MIG-002、BAK-001、PLG-013、WSP-003 | | [0072](0072-single-graph-local-plugin-updates.md) | accepted / 设计已确认,实现未完成 | 单张运行图与局部插件换代 | PLG-001~PLG-018、RUN-007、RUN-009、RUN-016、CTRL-003 | | [0071](0071-plugin-composition-and-whole-runtime-updates.md) | accepted / implementing;整图换代部分 superseded by 0072 | 插件底座只解释组合与整体换代 | PLG-001~PLG-018 | diff --git a/docs/design/host-bridge-protocol-v2.md b/docs/design/host-bridge-protocol-v2.md index a95794473..2b7c50b4d 100644 --- a/docs/design/host-bridge-protocol-v2.md +++ b/docs/design/host-bridge-protocol-v2.md @@ -42,7 +42,7 @@ Bridge service 拥有 boot admission 和 manager lease;ShellProcessManager 拥 | RPC | Context 之外的请求字段与规则 | |---|---| -| Inspect、ClaimBoot、Probe、Heartbeat、ShutdownManager、ActiveExecutions | 无 | +| Inspect、ClaimBoot、Probe、OpenManager、Heartbeat、ShutdownManager、ActiveExecutions | 无 | | Exec | 必需非空 command、owner_session_key;argv 非空且元素非空;env 为 string map,可空且 value 可空;cwd optional;必需 tty(false 合法)、yield_time_ms(零合法,等待仍按原 manager clamp)、max_output_tokens(≥0)、hard_timeout_s(>0) | | WriteStdin | 必需 execution_id>0、非空 owner_session_key、chars(空代表等待)、yield_time_ms、max_output_tokens≥0 | | Stop | 必需 execution_id>0、非空 owner_session_key | @@ -69,7 +69,7 @@ Bridge service 拥有 boot admission 和 manager lease;ShellProcessManager 拥 |---|---| | Inspect、Probe | 非空 release_commit/toolchain_digest;非空 capabilities 集合,元素非空 | | ClaimBoot | 非空 owner_boot_id;previous_boot_id optional,省略表示此前无 owner;必需 cleaned_manager_count、cleaned_execution_count,可为0 | -| Heartbeat | 必需 alive,成功须为true | +| OpenManager、Heartbeat | 必需 alive,成功须为true | | Exec、WriteStdin | 必需 output bytes(可空)、wall_time_ms/original_token_count/output_omitted_bytes≥0、非空 finish_reason;必需 result oneof:execution_id>0 或 exit_code(零与负信号值合法);output_path optional,设置时非空 | | Stop | 必需 stopped,false 的存在性不能丢失 | | TerminateOwner、ShutdownManager | attempted/cleaned 为正整数集合,可空;failures 为 execution_id>0、非空 error_type/message 列表,可空 | @@ -86,6 +86,7 @@ Bridge service 拥有 boot admission 和 manager lease;ShellProcessManager 拥 结构/范围错误为 INVALID_ARGUMENT;缺失、重复、格式错误或不匹配的 token 为 PERMISSION_DENIED; release、toolchain、boot 或 owner 的权限错误仍为 PERMISSION_DENIED;内部未预期错误为 INTERNAL。 +manager 不存在为 NOT_FOUND;正在回收或 cleanup 未确认为 FAILED_PRECONDITION。 命令非零退出是正常 ExecutionReply。请求身份只在 RPC 入口认证一次,内部仍逐操作检查实时 lease。 取消显式传播 CancelledError,不改为 INTERNAL,不终止已登记的 execution。Exec 响应丢失、 @@ -128,3 +129,18 @@ service package 是唯一协议 major owner:`akashic.host.v2`。V1 route 返 40KB、1MiB 输出、1/8/32 并发、PTY;记录 p50/p95,不把编码成本当端到端速度。 4. 运行生成一致性、类型检查、现有 Python/Web 回归和 change-impact Gate,再由 Terra xhigh 对完整实现 diff 和证据做独立审查。未执行/环境失败项保持未验证。 + +## 运行期恢复补充 + +[0075](../decisions/0075-host-bridge-runtime-recovery.md) 区分连接探测和执行租约: + +- Inspect 只验证发布身份,供部署检查使用;Probe 还核对 active boot,两者都不创建 manager。 +- OpenManager 是唯一创建入口;客户端首次业务请求前串行登记,成功后只续期、不重新登记。 + 首次登记响应丢失时,业务请求尚未发出,再次登记安全;Exec/WriteStdin/FileTool 不重放。 +- Heartbeat 与业务 RPC 只使用已存在 manager;缺失时拒绝,不能创建空执行表。 + 未打开的客户端 shutdown 只关闭本地传输;打开后远端不存在不是 cleanup 成功证据。 +- UNAVAILABLE/DEADLINE_EXCEEDED 只允许健康探测和心跳继续尝试;NOT_FOUND 或身份/租约错误 + 终结旧 manager。单次业务 INTERNAL/INVALID_ARGUMENT 不等于 manager 丢失。 +- 同一 release 的 Core/Bridge 必须成对发布;不能用混合版本运行 OpenManager 新合同。 + +文件线程排空、状态消费者和本地实验见[运行期可靠性](host-bridge-reliability.md)。 diff --git a/docs/design/host-bridge-reliability.md b/docs/design/host-bridge-reliability.md new file mode 100644 index 000000000..e982330d5 --- /dev/null +++ b/docs/design/host-bridge-reliability.md @@ -0,0 +1,87 @@ +# Host Bridge 运行期可靠性 + +状态:已实现并完成本地实验;用户授权 PR,合并和部署另行评审。 + +## 任务合同 + +- base:db215d872d56c35cff15129f458d89535ad0cad8;writer:Codex;独立 worktree。 +- change_type:fix;semantic_delta:breaking(同 release 私有协议与 RUN-013 失败范围)。 +- capability_owner:Core 运行监督、Bridge RPC/租约、ShellProcessManager execution 各自保留。 +- consumer_scope:Web/Mobile 共用 Core 与所有 Host Bridge Shell/File 调用。 +- runtime_patch:required;客户端无法区分 RPC 未达、租约回收或 host ownership 丢失。 +- authoritative_state_owner:会话仍属 MessageLog;宿主 execution 仍属 ShellProcessManager; + Bridge 独占 boot admission 与 manager lease;本次不迁移持久状态。 +- client_only_alternative:不可行,浏览器重连不能阻止 Core 因 Probe 超时退出。 +- concept_gate:required;最终 head 由独立 terra/xhigh 审查。 +- 允许:源码、手册、本地临时 UDS、临时文件和受控子进程;不写正式 workspace,不调用生产控制面。 +- 恢复点:Git base 与 /tmp/host-bridge-reliability-backup-20260926/baseline.tar。 + +## 行为与 owner + +```text +Core 运行监督 + ├─ Probe:只验证身份与 boot,短暂传输失败报告降级后继续探测 + └─ manager:首次登记 → RPC/Heartbeat → 明确关闭或 lease 回收 + └─ ShellProcessManager:唯一 execution 和输出消费 owner +``` + +1. 启动 ClaimBoot 与身份核对仍必须成功。运行期只恢复 UNAVAILABLE / DEADLINE_EXCEEDED; + 权限、boot、版本、响应损坏和内部程序错误继续明确失败。 +2. 探测不创建 execution manager。租约首次登记与续期分开;过期/已关闭 manager 的请求 + 必须被拒绝,不能创建一个空 manager 后继续使用旧 execution ID。 +3. 连接恢复不重放业务操作。Exec、WriteStdin、写文件或编辑文件的异常仍可能已经产生效果。 +4. 文件操作的阻塞阶段离开事件循环;文件锁、manager active operation 必须保持到物理工作结束。 +5. 运行期降级由 AppRuntime 监控状态、只读 HTTP 和聊天提示及结构化日志暴露;不修改 Message、Turn 或项目数据。 + +## 本地实验 + +使用真实 gRPC UDS、生产 service/client、临时目录和真实子进程;控制传输故障及慢文件边界。 +依次验证:探测失联/恢复、租约心跳恢复、过期拒绝旧句柄、boot fencing、响应丢失不重放、 +慢文件不阻塞 Probe、取消时物理写入完成前不释放 owner。固定每次源码身份并记录结果。 + +## 持久状态边界 + +- 正常业务写入仍由既有 Shell/File 操作执行;实验只写一次性目录。 +- manager/health 是内存状态;租约回收只执行原 owner 的进程清理,不减少会话或插件数据。 +- RPC 取消不能作为写入回滚证据;实际线程结束才允许排空。 +- 本次不改正式数据库 schema、迁移、备份和消息保留协议。 + +## 可观察状态与故障范围 + +```text +AppRuntime monitor ──写入── HostBridgeStatus(内存) + │ │ + ├─ Probe/identity └─ GET /api/runtime/host-bridge + │ └─ Web Shell → Dashboard UDS → 聊天提示 + └─ UNAVAILABLE/DEADLINE → 降级 → 继续探测 → 恢复 + +执行调用 ── OpenManager(一次)→ RPC / Heartbeat + ├─ 响应丢失:报告可能已生效,交给调用者核实 + └─ NOT_FOUND:旧 manager 已失效,不重建或复用旧句柄 +``` + +状态是 disabled/checking/healthy/degraded,包含连续失败数、最近错误分类和检查时间; +不返回 token、路径或命令内容。浏览器每五秒查询,五秒内未取得响应显示“无法确认”; +这与已知 Bridge 降级不同。状态恢复只代表传输与身份正常,不承诺旧 manager 仍存在。 + +## 本地证据 + +- 修复前:同一真实服务的 Probe 注入 DEADLINE_EXCEEDED,原 monitor 结束并抛错。 +- `.venv/bin/python docker/debug/host_bridge_reliability.py`:八组真实边界实验通过。 + 包含实际 UDS 监听停止/重建、Core 主任务监督存活、公开 Web Shell 到 Dashboard UDS 的状态恢复、 + 并发首次登记、心跳恢复、过期拒绝、真实命令响应丢失、业务错误后继续执行、四类文件操作、 + 慢写入取消后同文件串行与 shutdown 排空,以及认证/boot fencing。 +- `node docker/debug/host_bridge_notice.mjs`:Chromium 加载实际 React 组件,验证降级提示、恢复清除、 + HTTP 失败显示未知和 local mode 不显示。这里的 HTTP 状态由实验控制,不冒充完整在线对话验收。 +- 概念基线 44 项通过;源码/测试 pyright、前端 typecheck、plugin_boundary、yoyo、 + control schema 与 Host Bridge 生成物检查通过。 + +## 本次边界与后续风险 + +- 不重启或修改线上服务,不建立正式 workspace;生产发布与完整聊天/设备验收尚未执行。 +- 永久磁盘阻塞仍会延迟物理排空;本方案不会伪造线程已取消。 +- 过期 manager 不自动复活。其原 owner 需要结束旧生命周期、创建新客户端;页面整体连接可继续, + 旧执行工具仍会明确报告失效。 +- Standard Tools 的同步 skill capture 仍可能在 Core 事件循环执行宿主能力检查。 + 本次不修改同步 capture 公共合同或缓存资产生命周期;该路径不属于已验证的文件 I/O 非阻塞保证。 +- Memoh 的参考价值是限定故障范围与由连接 owner 恢复;未照搬其容器供应状态或重试业务操作。 diff --git a/docs/projectneed.md b/docs/projectneed.md index 433ab26b2..34f9edb92 100644 --- a/docs/projectneed.md +++ b/docs/projectneed.md @@ -639,7 +639,7 @@ Codex、OpenCode 等 Provider 插件的权威目录优先提供模型能力; ### RUN-013 正式容器通过 Host Bridge 保留宿主执行能力 -原生开发运行继续使用本地执行后端。正式容器运行只能注册与 Core 同版本的 Python Host Bridge 后端;Bridge 未就绪、版本不匹配或能力探针失败时 readiness 必须失败并退出,不得静默回退到容器内执行。主 Turn、programmatic Turn、subagent 与 Drift 的 Agent-facing Shell、File 和 Process 工具默认以 Bridge 宿主用户身份工作,能力边界等同该用户通过 SSH 登录后可执行的操作;Core control plane、SessionDB、插件 generation、MCP/managed service、Supervisor 和 restart 事务仍由 Core 容器拥有。 +原生开发运行继续使用本地执行后端。正式容器运行只能注册与 Core 同版本的 Python Host Bridge 后端;启动时必须确认 Bridge 就绪、同版本和 boot ownership,失败不得进入 readiness;运行期身份或 ownership 错误仍明确失败。运行期暂时传输失败只降级宿主执行能力,保留 Core 与聊天连接并继续探测,客户端可见降级与恢复;不得静默回退到容器内执行。恢复传输不得重放可能已生效的操作,也不得重建已过期的 manager 后继续使用旧执行句柄。主 Turn、programmatic Turn、subagent 与 Drift 的 Agent-facing Shell、File 和 Process 工具默认以 Bridge 宿主用户身份工作,能力边界等同该用户通过 SSH 登录后可执行的操作;Core control plane、SessionDB、插件 generation、MCP/managed service、Supervisor 和 restart 事务仍由 Core 容器拥有。 ### RUN-014 运行镜像拥有不可变且可诊断的身份 diff --git a/frontend/chat/src/desktop-chat-view.tsx b/frontend/chat/src/desktop-chat-view.tsx index 6e92a4669..c642c22c3 100644 --- a/frontend/chat/src/desktop-chat-view.tsx +++ b/frontend/chat/src/desktop-chat-view.tsx @@ -9,6 +9,7 @@ import { ConversationEmptyState, ConversationScrollButton, } from "@/components/ai-elements/conversation"; +import { HostBridgeNotice } from "./host-bridge-notice"; import { ChatProductBand } from "./chat-product-band"; import { DesktopAutoScroll } from "./desktop-auto-scroll"; import { ComposerStatsLine } from "./composer-stats-line"; @@ -128,6 +129,7 @@ export function DesktopChatView({ embeddedShell, controller }: DesktopChatViewPr /> {replyAvailable === false ?

当前未加载回复插件

: null} + {error ?
{error} 重试
: null} diff --git a/frontend/chat/src/host-bridge-notice.tsx b/frontend/chat/src/host-bridge-notice.tsx new file mode 100644 index 000000000..cc671f5c7 --- /dev/null +++ b/frontend/chat/src/host-bridge-notice.tsx @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; + +/** 只显示宿主执行连接状态;恢复连接不会恢复已经失效的命令句柄。 */ +export function HostBridgeNotice() { + const [notice, setNotice] = useState(""); + useEffect(() => { + const abort = new AbortController(); + let timer: ReturnType; + async function refresh() { + try { + const response = await fetch("/api/runtime/host-bridge", { + signal: AbortSignal.any([abort.signal, AbortSignal.timeout(5000)]), cache: "no-store", + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body: unknown = await response.json(); + if (typeof body !== "object" || body === null || !("state" in body) + || !["disabled", "checking", "healthy", "degraded"].includes(String(body.state))) { + throw new Error("宿主状态响应无效"); + } + setNotice(body.state === "degraded" + ? "宿主执行暂时不可用,正在恢复连接。依赖宿主的命令和文件操作可能失败;已有对话仍可阅读。" + : body.state === "checking" ? "正在检查宿主执行连接…" : ""); + } catch { + if (abort.signal.aborted) return; + setNotice("暂时无法确认宿主执行状态,正在重新检查。"); + } + if (!abort.signal.aborted) timer = setTimeout(refresh, 5000); + } + void refresh(); + return () => { abort.abort(); clearTimeout(timer); }; + }, []); + return notice ?

{notice}

: null; +}