Hermes Agent 源码专题【左扬精讲】—— 6 种终端后端架构

Hermes Agent 源码专题【左扬精讲】—— 6 种终端后端架构

这是 Hermes Agent 源码专题【左扬精讲】系列 第 7 篇

本篇是 Layer 2 工具系统的执行后端专题 ── 当模型调一次 terminal 时,Hermes 是怎么把这条命令送到 Docker 容器 / SSH 远端 / Modal 沙箱 / Singularity 实例 / Daytona 云沙箱去执行的。

第 5、6 篇讲了工具如何注册、如何编排到模型。本篇切到执行层:Hermes 把 6 种后端(外加 ManagedModal 一类变体)放在一个统一的 BaseEnvironment ABC 后面,所有上层调用(terminal_tool.execute()、gateway 转发、batch_runner 并发跑)拿到的都是同一个 execute() 接口 ── 不需要知道命令是落在本机 bash 还是 Modal 远程沙箱里。

Layer 框架视角:先看 分层(BaseEnvironment 是窄腰,7 个子类是各后端的实现差异),再看 路由_create_environment() 怎么选后端),最后看 边界(bind-mount vs copy-sync vs SDK 适配)。

Layer 2 ─ 终端执行层(tools/environments/)
════════════════════════════════════════════════════════════

    Layer 2 ─ 工具系统入口(tools/terminal_tool.py)
      _create_environment(env_type)                ← 工厂,按 TERMINAL_ENV 选后端
      terminal.execute() / spawn_via_env()         ← 调用方,全部走统一接口

    Layer 2 ─ 统一接口(tools/environments/base.py)
      BaseEnvironment ABC                          ← 窄腰:execute() / init_session()
      ProcessHandle Protocol                       ← 返回值类型契约
      _ThreadedProcessHandle                       ← SDK 后端(Modal/Daytona)的适配器

    Layer 3 ─ 各后端实现(tools/environments/*.py)
      local.py              LocalEnvironment         ← os.setsid + 文件式 cwd
      docker.py             DockerEnvironment        ← docker exec + bind mount
      ssh.py                SSHEnvironment           ← ControlMaster 复用连接 + scp 同步
      singularity.py        SingularityEnvironment   ← self.executable instance + overlay
      modal.py              ModalEnvironment         ← Modal SDK + sandbox.terminate
      managed_modal.py      ManagedModalEnvironment  ← 走 Nous Tool Gateway(中间类 mode)
      modal_utils.py        BaseModalExecutionEnvironment ← 中间抽象
      daytona.py            DaytonaEnvironment       ← Daytona SDK + sandbox.stop()
      file_sync.py          FileSyncManager          ← mtime 检测 + 批量 SCP 上传

Layer 2 BaseEnvironment terminal 后端 spawn-per-call session snapshot CWD marker ProcessHandle

本篇学习重点

必须掌握

  • 理解 tools/environments/base.py 中的 BaseEnvironment ABC 是怎么定义 execute() 契约的
  • 理解 init_session() 怎么用 bash -l 一次性快照出登录 shell 的 env vars
  • 理解 _wait_for_process() 怎么统一处理 interrupt / timeout / output drain
  • 理解 CWD 跟踪的两种模式:本地用 /tmp/hermes-cwd-*.txt 文件,远程用 stdout 中的 __HERMES_CWD_<id>__ 标记

需要了解

  • SSH 后端用 ControlMaster 复用单连接,省掉每个命令的 TCP 握手
  • Docker / Singularity 走 bind-mount,无须文件同步;SSH / Modal / Daytona 走 FileSyncManager
  • Modal 有两个 mode:direct(本地 token 调 SDK)与 managed(走 Nous Tool Gateway)

目录

一、BaseEnvironment 窄腰:6 个后端共用一套契约

Layer 视角 ─ 窄腰为什么是 ABC?

Hermes 的执行层有 6 种后端(local / docker / ssh / singularity / modal / daytona),外加 managed_modal.py 中的 ManagedModalEnvironment 作为 modal 的 mode 变体(都继承自 modal_utils.py 中的 BaseModalExecutionEnvironment 中间类)。如果每种都自己定义 execute() 签名,调用方就要写 if env_type == "local": local_env.run(...) elif env_type == "docker": docker_env.exec(...) ── 增加一条后端就要改所有调用点。

解决:tools/environments/base.py 第 288 行的 BaseEnvironment(ABC) 把所有后端的共同点提到基类(execute() / init_session() / _wait_for_process() / CWD tracking),只把差异点留作 abstract method(_run_bash())。调用方认 ABC 不认具体类。tools/environments/__init__.py 的模块 docstring 也明确写出这套设计:

  • 每条后端实现的都是同一个接口(BaseEnvironment
  • 所有路径在 shell 这一层意义上都是"在某个进程里跑 bash -c <命令>" ── 所以基类的统一方法才合算

这一层(Layer 2.5)的关键约束:后端可以加,但不能改 ABC 签名。任何对 execute() 行为的修改都得在子类里做 hook,不能改基类的语义。

1.1 BaseEnvironment 的真正契约

tools/environments/base.py 第 288 行的 class BaseEnvironment(ABC):。两个抽象方法 + 三个共享字段 + 一组被所有子类复用的实现方法(execute() / init_session() / _wait_for_process())。

源码视角 ─ ABC + 共享字段

class BaseEnvironment(ABC):
    """Common interface and unified execution flow for all Hermes backends.

    Subclasses implement ``_run_bash()`` and ``cleanup()``.  The base class
    provides ``execute()`` with session snapshot sourcing, CWD tracking,
    interrupt handling, and timeout enforcement.
    """

    # Subclasses that embed stdin as a heredoc (Modal, Daytona) set this.
    _stdin_mode: str = "pipe"  # "pipe" or "heredoc"

    # Snapshot creation timeout (override for slow cold-starts).
    _snapshot_timeout: int = 30

    def get_temp_dir(self) -> str:
        """Return the backend temp directory used for session artifacts.

        Most sandboxed backends use ``/tmp`` inside the target environment.
        LocalEnvironment overrides this on platforms like Termux where ``/tmp``
        may be missing and ``TMPDIR`` is the portable writable location.
        """
        return "/tmp"

    def __init__(self, cwd: str, timeout: int, env: dict = None):
        self.cwd = cwd
        self.timeout = timeout
        self.env = env or {}

        self._session_id = uuid.uuid4().hex[:12]
        temp_dir = self.get_temp_dir().rstrip("/") or "/"
        self._snapshot_path = f"{temp_dir}/hermes-snap-{self._session_id}.sh"
        self._cwd_file = f"{temp_dir}/hermes-cwd-{self._session_id}.txt"
        self._cwd_marker = _cwd_marker(self._session_id)
        self._snapshot_ready = False

几个关键设计点:

  • _stdin_mode = "heredoc" 把 stdin 数据嵌入到 bash 命令字符串尾部的 heredoc(Modal / Daytona 用,因为它们的 SDK 是"一次塞整段命令"),默认 "pipe"_pipe_stdin() 异步写管。
  • _snapshot_timeout 默认 30s,ModalEnvironment 覆写成 60s(Modal 冷启动慢)。
  • _snapshot_path / _cwd_file 是同一会话的两个临时文件 ── 走 get_temp_dir() 而不是硬编码 /tmp。LocalEnvironment 在 Windows 下覆写返回 HERMES_HOME 下的子目录(Windows 没有 /tmp%TEMP% 含空格,无法被 bash 单引号安全引用)。

1.2 _run_bash 与 cleanup 是后端仅存的实现细节

看 BaseEnvironment 的两个 abstract method:

    def _run_bash(
        self,
        cmd_string: str,
        *,
        login: bool = False,
        timeout: int = 120,
        stdin_data: str | None = None,
    ) -> ProcessHandle:
        """Spawn a bash process to run *cmd_string*.

        Returns a ProcessHandle (subprocess.Popen or _ThreadedProcessHandle).
        Must be overridden by every backend.
        """
        raise NotImplementedError(f"{type(self).__name__} must implement _run_bash()")

    @abstractmethod
    def cleanup(self):
        """Release backend resources (container, instance, connection)."""
        ...

这两个方法就是窄腰留给后端的全部"差异点"。后端要做的事只是:

  • 怎么起一个进程_run_bash())── 本地 spawn bash、SSH spawn ssh、Docker 执行 docker exec 命令、Modal / Daytona 调 SDK
  • 怎么释放cleanup())── 删容器 / 杀 SSH control socket / stop sandbox

其余所有事情 ── 命令包装、env snapshot、CWD 持久化、interrupt 检查、timeout 强制、output drain ── 都在基类。后端作者的工作量从"几百行 + 五六个文件"压到"一个方法 + 一个资源释放"。

源码视角 ─ ProcessHandle 协议:后端返回值的统一类型

6 个后端都要在 _run_bash() 里返回一个 ProcessHandle。原生 subprocess.Popen 已经满足要求;SDK 后端(Modal / Daytona)没有真子进程,就用 _ThreadedProcessHandle 适配器把同步阻塞 SDK 调用包成符合协议的对象。协议定义在 base.py 第 187 行:

class ProcessHandle(Protocol):
    """Duck type that every backend's _run_bash() must return.

    subprocess.Popen satisfies this natively.  SDK backends (Modal, Daytona)
    return _ThreadedProcessHandle which adapts their blocking calls.
    """

    def poll(self) -> int | None: ...
    def kill(self) -> None: ...
    def wait(self, timeout: float | None = None) -> int: ...

    @property
    def stdout(self) -> IO[str] | None: ...

    @property
    def returncode(self) -> int | None: ...
  • 本地 / SSH / Docker / Singularity:直接返回 subprocess.Popen,天然满足
  • Modal / Daytona:返回 _ThreadedProcessHandle(在 base.py 第 205 行)── 内部起了个 daemon 线程跑阻塞 SDK 调用、通过 os.pipe() 把输出推到文件描述符供 _drain 线程读

基类的 _wait_for_process()(第 483 行起)只用这 5 个属性(poll / stdout / returncode / kill / wait),所以基类不需要为某一种后端写特殊分支。

本节小结

  • BaseEnvironment ABC 把 6 个后端的共同点提到基类(execute() / init_session() / _wait_for_process() / CWD tracking)
  • 每个后端只需要实现两个 abstract method:_run_bash()cleanup()
  • 返回值统一为 ProcessHandle 协议(subprocess.Popen 直接满足;SDK 后端用 _ThreadedProcessHandle 适配)
  • 调用方认 ABC 不认具体类 ── 下一章讲 _create_environment() 工厂怎么按 TERMINAL_ENV 选具体类

二、_create_environment 工厂:按 TERMINAL_ENV 路由

源码视角 ─ 工厂导入 + 6 个分支的形态

tools/terminal_tool.py 的导入段(第 826-831 行)和工厂函数 _create_environment()(第 1225 行起):

from tools.environments.local import LocalEnvironment as _LocalEnvironment
from tools.environments.singularity import SingularityEnvironment as _SingularityEnvironment
from tools.environments.ssh import SSHEnvironment as _SSHEnvironment
from tools.environments.docker import DockerEnvironment as _DockerEnvironment
from tools.environments.modal import ModalEnvironment as _ModalEnvironment
from tools.environments.managed_modal import ManagedModalEnvironment as _ManagedModalEnvironment

所有后端都是延迟导入(lazy import),只有用户真的选了某个 TERMINAL_ENV 时才需要那个 SDK

  • local / docker / ssh / singularity / modal / managed_modal:顶层 import(terminal_tool.py 第 826-831 行)
  • daytona:在工厂分支内才 from tools.environments.daytona import DaytonaEnvironment as _DaytonaEnvironment(第 1349 行)── 默认用户不需装 daytona SDK

2.1 工厂函数签名

tools/terminal_tool.py 第 1225 行:

def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
                        ssh_config: dict = None, container_config: dict = None,
                        local_config: dict = None,
                        task_id: str = "default",
                        host_cwd: str = None):
    """
    Create an execution environment for sandboxed command execution.

    Args:
        env_type: One of "local", "docker", "singularity", "modal",
            "daytona", "ssh"
        image: Docker/Singularity/Modal image name (ignored for local/ssh)
        cwd: Working directory
        timeout: Default command timeout
        ssh_config: SSH connection config (for env_type="ssh")
        container_config: Resource config for container backends (cpu, memory, disk, persistent)
        task_id: Task identifier for environment reuse and snapshot keying
        host_cwd: Optional host working directory to bind into Docker when explicitly enabled
    """

2.2 6 个分支:本地 / 容器 / 远端 / 云沙箱

看工厂的 if/elif 链(节选真实分支):

    if env_type == "local":
        return _LocalEnvironment(cwd=cwd, timeout=timeout)

    elif env_type == "docker":
        # One-shot orphan reaper: clean up labeled containers left behind by
        # prior Hermes processes that hit SIGKILL / OOM / a closed terminal
        # before the atexit cleanup hook could run.
        _maybe_reap_docker_orphans(cc)
        return _DockerEnvironment(
            image=image, cwd=cwd, timeout=timeout,
            cpu=cpu, memory=memory, disk=disk,
            persistent_filesystem=persistent, task_id=task_id,
            volumes=volumes,
            host_cwd=host_cwd,
            auto_mount_cwd=cc.get("docker_mount_cwd_to_workspace", False),
            forward_env=docker_forward_env,
            env=docker_env,
            run_as_host_user=cc.get("docker_run_as_host_user", False),
            extra_args=docker_extra_args,
            persist_across_processes=cc.get("docker_persist_across_processes", True),
        )

    elif env_type == "singularity":
        return _SingularityEnvironment(
            image=image, cwd=cwd, timeout=timeout,
            cpu=cpu, memory=memory, disk=disk,
            persistent_filesystem=persistent, task_id=task_id,
        )

    elif env_type == "modal":
        # ... (Modal 有 direct vs managed 两条子分支,见 2.3)
        ...

    elif env_type == "daytona":
        from tools.environments.daytona import DaytonaEnvironment as _DaytonaEnvironment
        return _DaytonaEnvironment(
            image=image, cwd=cwd, timeout=timeout,
            cpu=int(cpu), memory=memory, disk=disk,
            persistent_filesystem=persistent, task_id=task_id,
        )

    elif env_type == "ssh":
        if not ssh_config or not ssh_config.get("host") or not ssh_config.get("user"):
            raise ValueError("SSH environment requires ssh_host and ssh_user to be configured")
        return _SSHEnvironment(
            host=ssh_config["host"],
            user=ssh_config["user"],
            port=ssh_config.get("port", 22),
            key_path=ssh_config.get("key", ""),
            cwd=cwd,
            timeout=timeout,
        )

    else:
        raise ValueError(
            f"Unknown environment type: {env_type}. Use 'local', 'docker', "
            f"'singularity', 'modal', 'daytona', or 'ssh'"
        )
  • local ── 最简单:直接 _LocalEnvironment(cwd, timeout)
  • docker ── 先调 _maybe_reap_docker_orphans(cc) 清理孤儿容器(一次性,进程级缓存),然后实例化
  • modal ── 唯一在工厂内部做进一步路由的环境(见 2.3)
  • daytona ── 唯一用 lazy import 的环境(避免没装 SDK 时 import 失败)
  • ssh ── 唯一做必填参数校验的环境(hostuser 缺一不可)
  • unknown ── 走到 else,主动抛 ValueError,把合法列表写在错误消息里(与 check_terminal_requirements() 第 2591 行的错误一致)

2.3 Modal 的 direct / managed 双模式路由

tools/environments/modal.pyModalEnvironment(第 164 行)直接调 Modal SDK;tools/environments/managed_modal.pyManagedModalEnvironment(第 36 行)继承中间类 BaseModalExecutionEnvironmentmodal_utils.py 第 58 行),而后者又继承 BaseEnvironment ── 改写了 execute(),因为 managed 模式下服务端已经处理了 CWD / env snapshot,基类的包装逻辑不适用

源码视角 ─ Modal 在工厂里的 mode 路由

    elif env_type == "modal":
        sandbox_kwargs = {}
        if cpu > 0:
            sandbox_kwargs["cpu"] = cpu
        if memory > 0:
            sandbox_kwargs["memory"] = memory
        if disk > 0:
            try:
                import inspect, modal
                if "ephemeral_disk" in inspect.signature(modal.Sandbox.create).parameters:
                    sandbox_kwargs["ephemeral_disk"] = disk
            except Exception:
                pass

        modal_state = _get_modal_backend_state(cc.get("modal_mode"))

        if modal_state["selected_backend"] == "managed":
            return _ManagedModalEnvironment(
                image=image, cwd=cwd, timeout=timeout,
                modal_sandbox_kwargs=sandbox_kwargs,
                persistent_filesystem=persistent, task_id=task_id,
            )

        if modal_state["selected_backend"] != "direct":
            # ...raise ValueError with detailed missing-credentials error...
            raise ValueError(message)

        return _ModalEnvironment(
            image=image, cwd=cwd, timeout=timeout,
            modal_sandbox_kwargs=sandbox_kwargs,
            persistent_filesystem=persistent, task_id=task_id,
        )

关键设计:

  • _get_modal_backend_state(cc.get("modal_mode")) 决定走哪条分支,selected_backend"managed" / "direct" / None(无法识别)。
  • managed ── _ManagedModalEnvironment,命令走 Nous Tool Gateway,不需要本地 Modal token
  • direct ── _ModalEnvironment,本地 SDK 调 Modal API(需 MODAL_TOKEN_ID / MODAL_TOKEN_SECRET
  • 无 mode + 无 credentials ── 抛详细错误,错误消息里同时给出 "managed 没通" 和 "direct 没凭证" 两条修复路径
  • ephemeral_disk 这个键在 Modal SDK 旧版可能不存在 ── 用 inspect.signature(modal.Sandbox.create).parameters 检查

本节小结

  • _create_environment()terminal_tool.py 第 1225 行,集中处理"按 TERMINAL_ENV 选哪个后端类"的路由
  • 5 个本地容器类(local/docker/ssh/singularity/modal/managed_modal)顶层 import;daytona 延迟 import
  • Modal 是唯一双 mode 的后端 ── direct vs managed 在工厂内按 _get_modal_backend_state() 结果分支
  • 所有 6 个分支返回的都是同一个 BaseEnvironment 子类,调用方不需要 if/elif

三、spawn-per-call 统一执行流:snapshot + wrap + wait

Layer 视角 ─ 所有后端共用的执行骨架

BaseEnvironment.execute()base.py 第 829 行)定义了所有后端的统一执行流。它把一次命令的完整生命周期切成 5 步:

  1. _before_execute() ── 远程后端 hook 触发文件同步
  2. _prepare_command() ── 处理 sudo(来自 terminal_tool.py_transform_sudo_command()
  3. _rewrite_compound_background() ── 防 A && B & 子 shell 等待陷阱
  4. 命令拼包 + _run_bash() ── 由子类实现
  5. _wait_for_process() ── interrupt / timeout / output drain 统一处理

spawn-per-call 意味着每条命令都 spawn 一个新进程 ── 这看起来很贵,但换来所有跨命令的状态"挂在一个快照文件里",避免每次重新加载 ~/.bashrc(慢 200~500ms)或丢失 env var。

3.1 init_session ── 一次性的 env snapshot

源码视角 ─ init_session 的 bootstrap 脚本

base.py 第 351 行 init_session():它在子类构造时被调一次(LocalEnvironment.__init__ 第 499 行就调;远程后端用同样的模式),把 bash -l 的 login shell 输出 dump 到 /tmp/hermes-snap-<session>.sh。后续每条命令都 source 这个文件,不再重新跑 ~/.bashrc

    def init_session(self):
        """Capture login shell environment into a snapshot file."""
        # Full capture: env vars, functions (filtered), aliases, shell options.
        # Restore configured cwd after login shell profile scripts, which may
        # change the working directory (e.g. bashrc `cd ~`).  Without this,
        # pwd -P captures the profile's directory, not terminal.cwd.
        _quoted_cwd = shlex.quote(self.cwd)
        # Quote the snapshot / cwd-file paths so Git Bash on Windows handles
        # ``C:/Users/...``-shaped paths without glob-splitting the colon or
        # tripping on drive letters.
        _quoted_snap = shlex.quote(self._snapshot_path)
        _quoted_cwd_file = shlex.quote(self._cwd_file)
        bootstrap = (
            f"export -p > {_quoted_snap}\n"
            f"declare -f | grep -vE '^_[^_]' >> {_quoted_snap}\n"
            f"alias -p >> {_quoted_snap}\n"
            f"echo 'shopt -s expand_aliases' >> {_quoted_snap}\n"
            f"echo 'set +e' >> {_quoted_snap}\n"
            f"echo 'set +u' >> {_quoted_snap}\n"
            f"builtin cd {_quoted_cwd} 2>/dev/null || true\n"
            f"pwd -P > {_quoted_cwd_file} 2>/dev/null || true\n"
            f"printf '\\n{self._cwd_marker}%s{self._cwd_marker}\\n' \"$(pwd -P)\"\n"
        )
        try:
            proc = self._run_bash(bootstrap, login=True, timeout=self._snapshot_timeout)
            result = self._wait_for_process(proc, timeout=self._snapshot_timeout)
            self._snapshot_ready = True
            self._update_cwd(result)
        except Exception as exc:
            logger.warning(
                "init_session failed (session=%s): %s — "
                "falling back to bash -l per command",
                self._session_id, exc,
            )
            self._snapshot_ready = False

5 个关键设计点:

  • Path 用 shlex.quote():Windows 下 C:/Users/.../hermes-snap-*.sh 含冒号 ── 不加引号会被 bash glob-split 或误判为 drive letter
  • builtin cd:不用 cd 而用 builtin cd,避免用户的 cd shell function 干扰(更可靠地 cd 到目标目录)
  • pwd -P > cwd_file:本地后端用文件读 CWD(_update_cwd 第 773 行)
  • __HERMES_CWD_<id>__%s__HERMES_CWD_<id>__ marker:远程后端无法传文件 ── 把 CWD 嵌到 stdout 里,用唯一 marker 字符串分隔
  • 失败 fallback_snapshot_ready = False ── 后续 execute()login=True 分支,每次重新跑 bash -l

3.2 _wrap_command ── 命令的拼包

源码视角 ─ _wrap_command 把命令变成完整 bash 脚本

base.py 第 417 行:

    def _wrap_command(self, command: str, cwd: str) -> str:
        """Build the full bash script that sources snapshot, cd's, runs command,
        re-dumps env vars, and emits CWD markers."""
        escaped = command.replace("'", "'\\''")

        # Quote the snapshot / cwd-file paths (Windows safety, see init_session).
        _quoted_snap = shlex.quote(self._snapshot_path)
        _quoted_cwd_file = shlex.quote(self._cwd_file)

        parts = []

        # Source snapshot (env vars from previous commands).
        if self._snapshot_ready:
            parts.append(
                f"source {_quoted_snap} >/dev/null 2>&1 || true"
            )

        # Preserve bare ``~`` expansion, but rewrite ``~/...`` through
        # ``$HOME`` so suffixes with spaces remain a single shell word.
        quoted_cwd = self._quote_cwd_for_cd(cwd)
        parts.append(f"builtin cd -- {quoted_cwd} || exit 126")

        # Run the actual command
        parts.append(f"eval '{escaped}'")
        parts.append("__hermes_ec=$?")

        # Re-dump env vars to snapshot (last-writer-wins for concurrent calls)
        if self._snapshot_ready:
            parts.append(f"export -p > {_quoted_snap} 2>/dev/null || true")

        # Write CWD to file (local reads this) and stdout marker (remote parses this)
        parts.append(f"pwd -P > {_quoted_cwd_file} 2>/dev/null || true")
        parts.append(
            f"printf '\\n{self._cwd_marker}%s{self._cwd_marker}\\n' \"$(pwd -P)\""
        )
        parts.append("exit $__hermes_ec")

        return "\n".join(parts)

每次调用都构造的 bash 脚本,结构是 source snapshot → cd → eval → 重 dump env → 输出 CWD marker → exit 沿用原退出码。注意:

  • source ... >/dev/null 2>&1 || true ── macOS bash 3.2 上 declare -x 在 source 时可能输出到 stdout(issue #15459),必须重定向
  • __hermes_ec=$? + 最后 exit $__hermes_ec ── 把用户命令的退出码传给外部,而不混进 source 等 wrapper 命令的失败信号
  • export -p > snap.sh ── 每次命令结束都重 dump env,连续 export / setenv 在快照里自动延续

3.3 _wait_for_process ── interrupt / timeout / output drain 统一处理

_wait_for_process() 是基类里最长、最复杂的方法(base.py 第 483-760 行)。它要做三件事:

  1. non-blocking drain 输出 ── 在 daemon 线程里通过 select()(POSIX)/ os.read()(Windows)循环读 stdout
  2. poll 循环检查中断 ── 每 5~200ms 自适应 poll,调 is_interrupted()(来自 tools/interrupt.py
  3. timeout 强制 ── 超过 effective_timeout_kill_process(proc) 并把退出码改成 124(和 timeout 命令语义一致)

源码视角 ─ drain 线程如何避免 backgrounded grandchild 的孤儿管道

        decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")

        def _drain():
            # Resolve a real OS file descriptor up front.
            stream = proc.stdout
            if stream is None:
                return
            fileno = getattr(stream, "fileno", None)
            try:
                fd = fileno() if callable(fileno) else None
            except Exception:
                fd = None
            if not isinstance(fd, int) or fd < 0:
                _drain_iterable(stream)  # in-memory ProcessHandle adapters
                return
            # select.select does NOT work on pipe fds on Windows (only sockets).
            # Use blocking os.read in a daemon thread instead — safe because
            # EOF arrives promptly when bash exits.
            if os.name == "nt":
                try:
                    while True:
                        chunk = os.read(fd, 4096)
                        if not chunk:
                            break
                        output_chunks.append(decoder.decode(chunk))
                except (ValueError, OSError):
                    pass
                return
            idle_after_exit = 0
            try:
                while True:
                    try:
                        ready, _, _ = select.select([fd], [], [], 0.1)
                    except (ValueError, OSError):
                        break  # fd already closed
                    if ready:
                        try:
                            chunk = os.read(fd, 4096)
                        except (ValueError, OSError):
                            break
                        if not chunk:
                            break  # true EOF — all writers closed
                        output_chunks.append(decoder.decode(chunk))
                        idle_after_exit = 0
                    elif proc.poll() is not None:
                        # bash is gone and the pipe was idle for ~100ms.  Give
                        # it two more cycles to catch any buffered tail, then
                        # stop — otherwise we wait forever on a grandchild pipe.
                        idle_after_exit += 1
                        if idle_after_exit >= 3:
                            break
            finally:
                tail = decoder.decode(b"", final=True)
                if tail:
                    output_chunks.append(tail)

几个微妙决策:

  • grandchild 孤儿管道修复(issue #8340):命令如果 setsid cmd & disown 起子进程,子进程会持有 stdout pipe 的写端。传统 for line in proc.stdout 阻塞 EOF ── 这里用 select() 看管道是否 ready,配合 proc.poll() 看 bash 是否退出;bash 退出后还有 3 轮 ~300ms idle 给 grandchild flush 残留数据
  • UTF-8 分块解码os.read(fd, 4096) 跨块可能截断多字节字符 ── 用 codecs.getincrementaldecoder + errors="replace" 累积 buffer
  • Windows 不用 select()select.select() 在 Windows pipe fd 上不支持 ── 切到阻塞 os.read() 循环(注释说明 EOF 在 bash 退出时会立刻到)
  • Mock / 不可用 fd 回退到 iterable drain:测试和 in-memory ProcessHandle 没有真 fd,触发 _drain_iterable()(第 527 行)走 for piece in stream 退化路径

What-if ─ 删 os.setsid 把 bash 进程和 python 同进程组会怎样?

① 改 1 行:local.py 第 604 行 preexec_fn=None if _IS_WINDOWS else os.setsid, 删掉 os.setsid ── bash 跟 python 同一个 process group,KeyboardInterrupt 传到 python 会一并传到 bash,行为"看起来正常",但 ──

except (KeyboardInterrupt, SystemExit): 分支(第 719 行)杀进程组时直接 _kill_process(proc) → 杀的是 bash 自己,但 bash 派生的孙子进程(grandchild)不挂在这个 group 上 ── 那就 reparent 到 PID 1,孤儿跑下去,issue #8340 类的"sleep 300 之后还在跑"bug 复发。

③ 现实案例:用户跑了 python -c "import hermes; ..." 然后 Ctrl-C ── python 死得快,bash 慢点退出;缺 os.setsid 时整个进程组是同 PID,python 退出后 bash 才收到 SIGTERM。Hermes 在 _wait_for_processexcept 里专门处理这个问题。

本节小结

  • execute() 由基类统一调度:_before_execute → _prepare_command → _wrap_command → _run_bash → _wait_for_process → _update_cwd
  • init_session()bash -l dump env 到 snapshot,后续命令 source 它 ── 跨调用状态保留 + 启动开销 0
  • _wait_for_process()select() 替代 readline(),避开 backgrounded grandchild 孤儿管道
  • 三条 sentinel(login / __HERMES_CWD_<id>__ marker / __hermes_ec)让 base class 可以安全剥离 wrapper 与用户命令的边界

四、7 个后端实现的差异点:bind-mount / copy-sync / SDK 适配

Layer 视角 ─ 差异的三个维度

7 个后端(含 managed_modal 作为 mode 变体)实际差异落在三个维度上 ── 后端作者只需要在这三点里回答问题:

  1. 怎么起进程_run_bash())── 本地 spawn bash / SSH spawn ssh / Docker docker exec / Singularity self.executable instance execself.executable = singularityapptainer,运行时由 _ensure_singularity_available() 自动检测) / Modal & Daytona SDK
  2. 文件怎么同步_before_execute() hook)── bind-mount(Docker / Singularity)vs FileSyncManager(SSH / Modal / Daytona)vs N/A(local)
  3. 怎么 cleanupcleanup())── 进程组杀 vs container remove vs sandbox.stop vs session.close

4.1 LocalEnvironment ── 最薄的一层

源码视角 ─ 本地的 spawn

LocalEnvironment._run_bash()local.py 第 549 行)直接把 bash -c <command> 作为 subprocess.Popen 跑起来。所有 SSH / Docker / Modal 都是这个模式的变体:

    def _run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None):
        # Set login shell for env snapshot / fallback.
        args = ["bash"]
        if login:
            args.append("-l")
        else:
            args.extend(["-c", cmd_string])

        proc = subprocess.Popen(
            args,
            text=True,
            env=run_env,
            encoding="utf-8",
            errors="replace",
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
            preexec_fn=None if _IS_WINDOWS else os.setsid,  # 新进程组
            cwd=_popen_cwd,
            **_popen_kwargs,
        )
        if stdin_data is not None:
            _pipe_stdin(proc, stdin_data)
        return proc

关键点:

  • login=True 走 bash -l ── 触发 ~/.profile + ~/.bashrc;login=False 走 bash -c <cmd> ── init_session 时用前者,execute 时用后者
  • preexec_fn=os.setsid ── bash 起在新进程组,_kill_process() 才能"杀整组"而不是只杀 bash 自己
  • _popen_cwd ── 一次性决定 cwd;command 的 cd 在 wrapper 阶段已经处理

4.2 DockerEnvironment ── bind-mount + container 复用

docker.py 第 503 行 class DockerEnvironment(BaseEnvironment)_run_bash() 在第 943 行。它在初始化时 docker run -d 起一个 daemon container,把用户的 cwd bind-mount 进去 ── 后续每条命令都是 docker exec <container> bash -c <cmd>

源码视角 ─ docker exec 命令拼接

    def _run_bash(self, cmd_string: str, *, login: bool = False,
                  timeout: int = 120,
                  stdin_data: str | None = None) -> subprocess.Popen:
        """Spawn a bash process inside the Docker container."""
        assert self._container_id, "Container not started"
        cmd = [self._docker_exe, "exec"]
        if stdin_data is not None:
            cmd.append("-i")

        # Only inject -e env args during init_session (login=True).
        # Subsequent commands get env vars from the snapshot.
        if login:
            cmd.extend(self._init_env_args)

        cmd.extend([self._container_id])

        if login:
            cmd.extend(["bash", "-l", "-c", cmd_string])
        else:
            cmd.extend(["bash", "-c", cmd_string])

        return _popen_bash(cmd, stdin_data)

关键设计:

  • _popen_bash() 来自 base.py ── 第 135 行的辅助函数,统一处理 stdout=PIPE / stderr=STDOUT / stdin=PIPE 三个管道 + _pipe_stdin() 异步写管
  • -i 只在 stdin_data 时追加 ── 大多数 execute 不带 stdin,不要无谓加 -i
  • _init_env_args 只在 login=True 时注入 ── init_session 时把 env vars 通过 -e KEY=VAL 传给容器;后续命令从 /tmp/hermes-snap-*.sh 拿状态,不再注入(避免重复)
  • bash 命令格式 bash -c <cmd> ── 直接传 cmd_string,没有 shlex.quote(因为 cmd_string 是基类已经拼包好的整段 bash 脚本)

源码视角 ─ cleanup 的三态分支(docker.py 第 1180-1277 行)

    def cleanup(self, *, force_remove: bool = False):
        # ...
        if force_remove:
            should_stop = True
            should_remove = True
        elif self._persist_across_processes:
            # No-op for the container. Drop the in-process handle so a fresh
            # __init__ will re-probe via labels (and find the running
            # container) instead of trying to reuse a stale Python reference.
            self._container_id = None
            return
        else:
            should_stop = True
            should_remove = True

        def _do_cleanup() -> None:
            if should_stop:
                subprocess.run(
                    [docker_exe, "stop", "-t", "10", container_id],
                    capture_output=True, timeout=30,
                    stdin=subprocess.DEVNULL,
                )
            if should_remove:
                subprocess.run(
                    [docker_exe, "rm", "-f", container_id],
                    capture_output=True, timeout=30,
                    stdin=subprocess.DEVNULL,
                )

三态分支(来自 docker.py 第 1232-1236 行注释):

  • force_remove=True ── docker stop + docker rm -f(显式 teardown)
  • persist_across_processes=True(默认) ── no-op,container 继续运行,下个 Hermes 进程通过 label 重连。这是 issue #20561 的契约:container 跨 Hermes 进程存活,背景进程(npm run dev、长跑测试)不会被 /quit 误杀
  • persist_across_processes=False ── docker stop + docker rm -f(每进程隔离)
  • atexit hooktools/terminal_tool.py,等 daemon 线程跑完 docker stop/rm 最长 ~60s
  • orphan reaper ── 工厂里的 _maybe_reap_docker_orphans(cc)(第 876 行)一次性清理掉上一轮 crash 留下的容器(label = hermes-agent=1 + status=exited)

4.3 SSHEnvironment ── ControlMaster + FileSync

ssh.py 第 36 行 class SSHEnvironment(BaseEnvironment)。它做两件本地环境不做的事:

  1. ControlMaster 复用 TCP 连接 ── 每条命令免一次 SSH handshake(节省 200~500ms)
  2. scp 同步文件 ── 本地修改文件后下次 execute() 触发增量上传

源码视角 ─ SSH 的连接参数(ssh.py 第 83 行)

    def _build_ssh_command(self, extra_args: list | None = None) -> list:
        cmd = ["ssh"]
        cmd.extend(["-o", f"ControlPath={self.control_socket}"])
        cmd.extend(["-o", "ControlMaster=auto"])      # 第一次自动建 master
        cmd.extend(["-o", "ControlPersist=300"])      # master 空闲 5min 后退出
        cmd.extend(["-o", "BatchMode=yes"])           # 不要交互提示密码
        cmd.extend(["-o", "StrictHostKeyChecking=accept-new"])
        cmd.extend(["-o", "ConnectTimeout=10"])
        if self.port != 22:
            cmd.extend(["-p", str(self.port)])
        if self.key_path:
            cmd.extend(["-i", self.key_path])
        # ...

control socket 路径的真实形态ssh.py 第 53-66 行):

        self.control_dir = Path(tempfile.gettempdir()) / "hermes-ssh"
        self.control_dir.mkdir(parents=True, exist_ok=True)
        # Hash user@host:port → 16-char hex to stay under macOS sun_path limit
        # (104 bytes) and keep the socket filename stable across reconnects.
        _socket_id = hashlib.sha256(
            f"{user}@{host}:{port}".encode()
        ).hexdigest()[:16]
        self.control_socket = self.control_dir / f"{_socket_id}.sock"
  • 路径 ── <tempfile.gettempdir()>/hermes-ssh/<sha256[:16]>.sock(不是 ~/.hermes/ssh-control/,文件名是 16 字符 hex 不是 <host>_<port>_<user>
  • 为什么 hash ── 注释明确写出:macOS Unix domain socket 的 sun_path 上限 104 字节,深嵌套 $TMPDIRuser@host:port + IPv6 + SSH 自加 16 字节 suffix 容易超限
  • 第 1 次 _run_bash() ── ssh.py 第 68 行 self._establish_connection() 起 master daemon;socket 文件被创建
  • 第 2 次起 ── ssh 看到 ControlMaster=auto + socket 已存在 ── 复用 master,跳过 auth
  • 5 分钟 idleControlPersist 关 master;下次需要时再起
  • cleanup()ssh.py 第 355-375 行):先 self._sync_manager.sync_back()(如果非 None),再 ssh -O exit <user@host> 优雅退出 master,最后 self.control_socket.unlink()

SSH 的 _before_execute()ssh.py 第 335 行)实际只有一行 ── 把控制权交给 FileSyncManager 自身的 rate-limit:

    def _before_execute(self) -> None:
        """Sync files to remote via FileSyncManager (rate-limited internally)."""
        self._sync_manager.sync()

Modal 的 _before_execute()modal.py 第 400 行)和 Daytona 的 _before_execute()daytona.py 第 213 行)也是同样模式 ── 三者都委托 FileSyncManager.sync()(base class 默认是 pass,第 815 行)。

4.4 SingularityEnvironment ── instance + overlay

singularity.py 的实现跟 Docker 类似但用 apptainer instance start(HPC 集群上 Docker 通常没有 daemon 权限)。bind-mount 行为也类似 ── 用户的 $HOME 透传到容器内。

关键点 ─ 跟 Docker 的差异

  • 没有 daemon ── Singularity instance 是 self.executable instance start <image> <name> + self.executable exec instance://<name> ...self.executable__init___ensure_singularity_available() 自动检测 singularityapptainer
  • overlay 持久化 ── persistent_filesystem=True 时用 --overlay /path/to/overlay.img 让写操作落盘(默认 Singularity container 是 read-only)
  • cleanup ── self.executable instance stop <name>
  • 没有挂起机制 ── 不像 Docker 的 docker pause,Singularity 没有同等原语

4.5 ModalEnvironment ── Sandbox SDK + _ThreadedProcessHandle

modal.py 第 164 行 class ModalEnvironment(BaseEnvironment)_run_bash() 通过 Modal SDK 的 Sandbox.exec() 启动命令。它跟本地最不一样:

  1. Modal SDK 是同步阻塞 ── 没有 Popen 那种 proc.stdout pipe,需要 _ThreadedProcessHandle 适配
  2. stdin_data 不能走 pipe ── 走 _stdin_mode = "heredoc" 嵌进 bash 脚本
  3. _snapshot_timeout 默认 60s ── Modal sandbox 冷启动比 docker run 慢

源码视角 ─ Modal 的 _ThreadedProcessHandle 包装

modal.py 第 408 行的 _run_bash() 真实形态:

    def _run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None):
        """Return a _ThreadedProcessHandle wrapping an async Modal sandbox exec."""
        sandbox = self._sandbox
        worker = self._worker

        def cancel():
            worker.run_coroutine(sandbox.terminate.aio(), timeout=15)

        def exec_fn() -> tuple[str, int]:
            async def _do():
                args = ["bash"]
                if login:
                    args.extend(["-l", "-c", cmd_string])
                else:
                    args.extend(["-c", cmd_string])
                process = await sandbox.exec.aio(*args, timeout=timeout)
                stdout = await process.stdout.read.aio()
                stderr = await process.stderr.read.aio()
                exit_code = await process.wait.aio()
                # ... decode stdout/stderr from bytes if needed ...
                return output, exit_code

            return worker.run_coroutine(_do(), timeout=timeout + 30)

        return _ThreadedProcessHandle(exec_fn, cancel_fn=cancel)

关键点:

  • 两个内部函数 cancel() + exec_fn() ── cancelworker.run_coroutine(sandbox.terminate.aio(), timeout=15)(Modal 文档的 cancel_fn 协议);exec_fn 是同步阻塞的"包装器",内部用 async / await 跑 SDK 调用,最终 worker.run_coroutine 把 async coroutine 桥到同步线程
  • _ThreadedProcessHandle(exec_fn, cancel_fn=cancel) ── 注意真实签名是 (exec_fn, cancel_fn=None)不是 target=... / args=...base.py 第 214-218 行定义
  • 没有手动拼 heredoc ── stdin_data 处理走基类的 _stdin_mode = "heredoc"(第 171 行) + _wrap_command()(基类第 417 行)
  • cleanup ── modal.py 第 442 行 cleanup():先 self._sync_manager.sync_back()(如果 persistent),然后 self._worker.run_coroutine(self._sandbox.terminate.aio(), timeout=15),最后 self._worker.stop()

4.6 ManagedModalEnvironment ── 走 Nous Tool Gateway

managed_modal.py 第 36 行的 ManagedModalEnvironment 继承中间类 BaseModalExecutionEnvironmentmodal_utils.py 第 58 行)。它不直接调 Modal SDK ── 通过 HTTP 调 Nous Tool Gateway(NTG),由服务端去 spawn sandbox。差异:

源码视角 ─ managed 模式的 transport

    def _request(self, method: str, path: str, *,
                 json: Dict[str, Any] | None = None,
                 timeout: int = 30,
                 extra_headers: Dict[str, str] | None = None) -> requests.Response:
        headers = {
            "Authorization": f"Bearer {self._nous_user_token}",
            "Content-Type": "application/json",
        }
        if extra_headers:
            headers.update(extra_headers)

        return requests.request(
            method,
            f"{self._gateway_origin}{path}",
            headers=headers,
            json=json,
            timeout=timeout,
        )

关键设计(managed_modal.py 第 229-247 行):

  • requests 同步 HTTP 客户端 ── 不是 SSE,不是异步 ── 通过 POST 一次性发命令,GET 轮询 _poll_modal_exec() 拿结果
  • Bearer Token 鉴权 ── Authorization: Bearer <nous_user_token>(第 234 行),用户不需要 Modal 凭证 ── token 由 NTG 持有,对用户隐藏
  • JSON-RPC 风格的 payload ── execId / command / cwd / timeoutMs / stdinData(看 managed_modal.py 第 73-82 行 _start_modal_exec()
  • 无 mode 变体也用同一 _ThreadedProcessHandle ── 调 _poll_modal_exec() 看是否完成,跟 Modal 的 async 等待等价
  • 凭证文件 passthrough 禁用 ── 第 222-227 行 _guard_unsupported_credential_passthrough() 主动 raise ValueError,不能用 挂载 host credential 文件到 sandbox;如需,TERMINAL_MODAL_MODE=direct

4.7 DaytonaEnvironment ── 跟 Modal 几乎镜像

daytona.py 的实现跟 ModalEnvironment 几乎是镜像 ── 都用 SDK、都用 _ThreadedProcessHandle、都需要 heredoc stdin。差异:

关键点 ─ Daytona vs Modal 的差异

  • SDK 名字不同 ── daytona.Sandbox.create() vs modal.Sandbox.create()
  • cleanup API ── Daytona 用 sandbox.stop();Modal 用 sandbox.terminate()
  • 资源 key ── Daytona 的 cpu 是 int;Modal 接受 float
  • 无 mode 变体 ── Daytona 没有 managed 模式(Hermes 暂未集成 NTG for Daytona)
  • lazy import ── Daytona SDK 在工厂分支内才 import(避免装 SDK 时影响其他后端启动)

本节小结

  • local ── os.setsid 起新进程组,CWD 用文件传
  • docker ── bind-mount + 复用 container;orphan reaper 清理 crash 残留
  • ssh ── ControlMaster 复用 TCP + scp 同步文件
  • singularity ── apptainer instance + overlay 持久化
  • modal / daytona ── SDK + _ThreadedProcessHandle 适配器 + heredoc stdin
  • managed_modal ── 走 Nous Tool Gateway,隐藏凭证 + 受限功能

五、FileSyncManager:远程后端的 mtime 增量同步

Layer 视角 ─ 谁需要 file sync,谁不需要?

Bind-mount 后端(Docker / Singularity)跟 host 共享文件系统视图,文件改动是即时的;本地后端根本不用"同步"这个概念。只有 SSH / Modal / Daytona 这类"remote sandbox"才需要把本地的 cwd 推到沙箱里 ── Hermes 把这份职责集中到 tools/environments/file_sync.pyFileSyncManager

模块 docstring(第 1-7 行)写得很清楚:

  • Tracks local file changes via mtime+size, detects deletions, and syncs to remote environments transactionally
  • Used by SSH, Modal, and Daytona
  • Docker and Singularity use bind mounts (live host FS view) and don't need this

5.1 触发时机:_before_execute() hook

SSH / Modal / Daytona 三个远程后端都在 _before_execute() 钩子里调 self._sync_manager.sync()。基类的 execute()_run_bash() 之前自动调这个 hook(base.py 第 829 行起的 execute 流程)── 所以本地 / Docker / Singularity 不覆写 _before_execute() 时就自动 pass(第 815 行 base 默认)。

三个远程后端的 _before_execute() 形态一致:

# ssh.py 第 335 行
def _before_execute(self) -> None:
    """Sync files to remote via FileSyncManager (rate-limited internally)."""
    self._sync_manager.sync()

# modal.py 第 400 行
def _before_execute(self) -> None:
    """Sync files to sandbox via FileSyncManager (rate-limited internally)."""
    self._sync_manager.sync()

# daytona.py 第 213 行
def _before_execute(self) -> None:
    """Ensure sandbox is ready, then sync files via FileSyncManager."""
    with self._lock:
        self._ensure_sandbox_ready()
    self._sync_manager.sync()

关键设计:

  • 三个后端委托 FileSyncManager.sync() ── 失败处理 / rate-limit 全在 manager 内部,不在后端 hook 里 try/except
  • Daytona 多一步 _ensure_sandbox_ready() ── Daytona sandbox 可能被外部销毁,先在 lock 下确认可用

5.2 mtime + size 增量检测

源码视角 ─ FileSyncManager 的真实构造参数

file_sync.py__init__(第 119 行):

class FileSyncManager:
    def __init__(
        self,
        get_files_fn: GetFilesFn,
        upload_fn: UploadFn,
        delete_fn: DeleteFn,
        sync_interval: float = _SYNC_INTERVAL_SECONDS,
        bulk_upload_fn: BulkUploadFn | None = None,
        bulk_download_fn: BulkDownloadFn | None = None,
    ):
        self._get_files_fn = get_files_fn
        self._upload_fn = upload_fn
        self._bulk_upload_fn = bulk_upload_fn
        self._delete_fn = delete_fn
        self._synced_files: dict[str, tuple[float, int]] = {}  # remote_path -> (mtime, size)
        self._pushed_hashes: dict[str, str] = {}  # remote_path -> sha256 hex digest
        self._last_sync_time: float = 0.0
        self._sync_interval = sync_interval

关键设计:

  • callback 注入而非属性注入 ── get_files_fn / upload_fn / delete_fn 都是 callable,由后端在 __init__ 时传入。例如 ssh.py 第 72-75 行
  • _synced_files 字典 ── remote_path -> (mtime, size),热路径只 stat 不 hash
  • _pushed_hashes ── upload 成功后写入(sync() 第 198 行)── 仅在差异检测后再做权威比较

sync() 整体流程(file_sync.py 第 138 行):

  1. 检查 _sync_interval 节流(除非 force=TrueHERMES_FORCE_FILE_SYNC=1
  2. self._get_files_fn() 拿到当前 (host_path, remote_path) 列表
  3. 对照 _synced_files 找差异(_file_mtime_key(host_path) → 拿 (mtime, size) 比较)
  4. 新文件 / 改动文件 → 优先 _bulk_upload_fn(有的话),否则 _upload_fn 单文件
  5. 已删除文件 → _delete_fn
  6. 全部成功才更新 _synced_files / _pushed_hashes;任何步骤失败 rollback 到旧值

5.3 HERMES_FORCE_FILE_SYNC + sync_back 反向同步

源码视角 ─ sync() 的开关

    def sync(self, *, force: bool = False) -> None:
        """Run a sync cycle: upload changed files, delete removed files.

        Rate-limited to once per ``sync_interval`` unless *force* is True
        or ``HERMES_FORCE_FILE_SYNC=1`` is set.

        Transactional: state only committed if ALL operations succeed.
        On failure, state rolls back so the next cycle retries everything.
        """
        if not force and not os.environ.get(_FORCE_SYNC_ENV):
            now = time.monotonic()
            if now - self._last_sync_time < self._sync_interval:
                return  # rate-limited
        # ...

关键设计:

  • HERMES_FORCE_FILE_SYNC=1 环境变量(file_sync.py 第 40 行)── 跳过节流,对调试有用
  • rollback 语义 ── 中途失败时回滚 _synced_files / _pushed_hashes 到旧值,下次重试整个 cycle(sync() 第 207-211 行)
  • sync_back() 反向同步file_sync.py 第 217 行)── 从 sandbox 拉文件回 host,在后端 cleanup() 之前调用,避免沙箱销毁时丢失数据

Lesson 1 远程后端的"文件一致性"是个工程权衡问题 ── 不是"看起来就 bind-mount"那么简单。Hermes 选择 mtime+size(轻量但漏检)+ manifest 持久化(跨进程)+ 失败 fallback(不阻塞命令)。在 RPC 边界外的资源同步都是这样设计的:单边写入 + best-effort + 用户可见的失败 ── 而不是死磕一致性。

本节小结

  • 3 个远程后端用 FileSyncManager(SSH / Modal / Daytona),3 个不用(local / Docker / Singularity)
  • 触发点在 _before_execute() hook ── 基类自动调,后端不写就是 no-op
  • mtime + size 检测 + 跨进程 manifest ── 单文件 O(1) 开销
  • 默认忽略 .git / node_modules / .venv / logs ── 避免负优化

FAQ 20 问

本节是答读者问 ── 把阅读源码时常被卡住的细节先讲清楚

    1. Q:BaseEnvironment 是 ABC 还是直接 class?
      A:tools/environments/base.py 第 288 行写明 class BaseEnvironment(ABC)。两个 abstract method:_run_bash()cleanup()。两个方法不实现就 TypeError: Can't instantiate abstract class
    2. Q:_ThreadedProcessHandlesubprocess.Popen 在协议上等价吗?
      A:等价(Protocol 模式)。Popen 5 个属性 poll / kill / wait / stdout / returncode_ThreadedProcessHandle 也提供同样 5 个 ── 内部用 daemon 线程 + os.pipe 把 SDK 输出推到文件描述符供 base class 的 drain 线程读。
    3. Q:TERMINAL_ENV 在哪个文件配置?
      A:hermes_cli/config.pyDEFAULT_CONFIG["terminal"]["env"],合法值 "local" / "docker" / "ssh" / "singularity" / "modal" / "daytona"。运行时通过 terminal_tool.check_terminal_requirements()terminal_tool.py 第 2491 行)验证。
    4. Q:为什么 daytona 走 lazy import,其他顶层 import?
      A:Modal SDK 较轻,且本机常驻;Daytona SDK 包含 CLI 工具(daytona 二进制)── 用户没装 Daytone 时 import 会失败。Lazy import 保证默认安装的用户不会被 Daytone 缺失拖死。
    5. Q:spawn-per-call vs 复用常驻进程,性能差异?
      A:spawn-per-call 每条命令 ~30ms spawn 开销(bash 启动 + env snapshot source),换来跨调用状态在 snapshot 文件里自动保留。常驻进程方案(如 SSH long-lived shell)要自己维护 state machine,Hermes 选前者是因为隔离性 > 性能。
    6. Q:os.setsid 起新进程组,Windows 下怎么办?
      A:local.py 第 604 行用 preexec_fn=None if _IS_WINDOWS else os.setsid ── Windows 没有 process group 概念,Hermes 改用 CREATE_NEW_PROCESS_GROUP flag(通过 _popen_kwargs 传入),Ctrl-Break 替代 SIGTERM。
    7. Q:_wait_for_process 的 124 退出码是 Linux timeout 命令的约定?
      A:是的。GNU timeout 命令超时返回 124。Hermes 在 _wait_for_process() 超过 effective_timeout 时调 _kill_process() 后把 returncode = 124 写回 ── 调用方写 if ec == 124: handle_timeout() 逻辑跟 timeout 命令无缝衔接。
    8. Q:bind-mount 后端为什么不需要 FileSyncManager?
      A:因为容器内的 $cwd 实际是 host 的某个路径 ── 写文件就是写 host,不需要同步。Docker / Singularity 共享文件系统视图,跟 local 后端的"文件在哪"是同一个问题。
    9. Q:ControlMaster 复用连接,安全上有风险吗?
      A:socket 文件在 ~/.hermes/ssh-control/ 下,权限 0600(只有当前用户可读写)。其他用户拿到 socket 也不能用 ── SSH 还会做密钥校验。5 分钟 idle 后 master 自动退出,cleanup() 时强制 ssh -O exit
    10. Q:Modal 和 Daytona 都用 _ThreadedProcessHandle,为什么 Modal 还要 60s snapshot timeout?
      A:Modal sandbox 冷启动比 Daytona 慢 ── Sandbox.create() 在新 region 时可能 30s+ 才返回。Daytona 走预先 pool 的 sandbox,启动快。_snapshot_timeout = 60modal.py)是给冷启动多 30s 余量,Daytona 保持默认 30s。
    11. Q:managed_modal 模式下输出延迟比 direct 模式高多少?
      A:30~80ms(HTTP / SSE 多一跳)。冷启动时差距更大 ── direct 模式 Modal 服务端已建好 sandbox,managed 还要走 NTG 转发。不频繁交互的批量任务不敏感,交互频繁的 TUI / batch_runner 建议 direct。
    12. Q:hermes-cwd-<id>.txt 文件什么时候清理?
      A:cleanup() 时由后端自己释放(容器 / sandbox 关掉时临时目录随之消失);Python GC 触发的 __del__base.py 第 885 行)会再兜底一次 ── SSH 后端在 cleanup() 里显式 scp 删文件,其他后端依赖 sandbox 销毁。
    13. Q:能否自定义后端(比如 k8s pod)?
      A:可以。继承 BaseEnvironment,实现 _run_bash() + cleanup(),在 terminal_tool.py_create_environment() 里加一个 elif env_type == "k8s": 分支。完成后 TERMINAL_ENV=k8s 即可 ── 其他代码(base.py / file_sync.py / interrupt.py)不需要改
    14. Q:__hermes_ec=$? 为什么不直接 exit $?
      A:因为前面有 source ... || truepwd -P > file 这些 wrapper 指令 ── $? 在那些指令后会变成 wrapper 自己的退出码,覆盖原命令的退出码。__hermes_ec=$? 紧跟 eval '$escaped' 之后捕获,最后 exit $__hermes_ec 把原码透传出去。
    15. A:POSIX 用
fcntl.flock()
file_sync.py
      第 22 行 import guard),Windows 下
fcntl
      import 失败回退到
fcntl = None
      ── Windows 文件锁直接跳过(注释 "Windows — file locking skipped"),靠
sync_interval
    节流避免并发冲突。
  1. Q:init_session 失败后怎么办?
    A:base.py 第 351 行 init_session() 把异常 catch 住,_snapshot_ready = False + log warning ── 后续 execute()login=True 分支,每次重新跑 bash -l(慢 200~500ms 但能 work)。
  2. Q:ephemeral_disk 在 Modal 旧版不存在怎么办?
    A:terminal_tool.py 第 1298 行用 inspect.signature(modal.Sandbox.create).parameters 检查 ── 不存在就 pass,不报错。这让 Hermes 能兼容 Modal SDK 旧版。
  3. Q:同一会话连续 export 多个 env var,最后值会不会丢?
    A:不会。_wrap_command 第 454 行 export -p > snap.sh 每次命令结束都重 dump ── 后续 source snap.sh 加载最后状态。并发场景下"last-writer-wins" 注释明确说明代价 ── Hermes 不期望同一会话并发跑命令(Agent loop 是串行的)。
  4. Q:orphan reaper 多久跑一次?
    A:进程级一次性 ── _maybe_reap_docker_orphans(cc)terminal_tool.py 第 876 行)内部用 global _docker_orphan_reaper_ran 布尔标志 + threading.Lock 双检锁,同一 Hermes 进程生命周期内只跑一次。
  5. Q:_update_cwd 从 stdout marker 还是文件读?
    A:本地后端读 /tmp/hermes-cwd-<id>.txt 文件(local.py 第 705 行 open(self._cwd_file))── 远程后端从 stdout 用正则解析 __HERMES_CWD_<id>__PATH__HERMES_CWD_<id>__ marker(base.py 第 777 行 _extract_cwd_from_output())。两者最终都写到 self.cwd,调用方无感。

下一篇预告

第 8 篇 ─ Memory 子系统全解(敬请期待)

终端执行层覆盖完了 ── 模型怎么调工具(Layer 2)、怎么执行命令(Layer 2.5)。Layer 3 是长期记忆 ── 让 Agent 不只是当前会话记得住。下一篇会展开:

  • MemoryProvider ABC 怎么定义 sync_turn / prefetch / shutdown
  • MemoryManager ── 8 个内置 provider 的 orchestrator
  • honcho / mem0 / supermemory / byterover 等后端差异 ── 跟 Layer 2.5 同样的窄腰模式(BaseEnvironment ↔ MemoryProvider ABC)
  • prefetch 与 prompt caching 的边界 ── 什么时候注入 memory,什么时候不注入
  • TTL / 跨 profile 隔离 / setup wizard

Layer 2 / Layer 2.5 / Layer 3 三层结构完整后,下一步是 Layer 4 ─ Gateway 多平台适配(TG / Discord / Slack / 飞书 / 钉钉 / 企业微信 / 邮件 …) ── 同样的窄腰模式(BaseAdapter ↔ MessageBus)。


posted @ 2026-07-17 21:34  左扬  阅读(35)  评论(0)    收藏  举报