Terminus 2 手册

环境搭建

stage-2Environment Setup3 个函数

开场解释

这个 Stage 在解决什么问题?它把“已经定好的静态配置”变成“绑定到这次 trial 的活终端”。前一阶段只把尺寸、用户、额外环境变量这些参数定住;但真正的终端不能更早启动,因为必须等 Harbor orchestrator(外层调度器)把这次 trial 的 environment 交进来,才能知道要连到哪个环境、是否要录制。这个 Stage 的产物很明确:一个已启动、可被后续 Stage 驱动的 tmux session(可远程控制的终端会话)。

主流程

  1. 先接收这次 trial 的 environment

    Terminus2.setup()(把当前 trial 的环境接进来)不是在“跑任务”,而是在拿到这次运行的现场信息。 这样设计是因为同一个 Agent 配置可以复用很多次,但 trial 环境每次都不同。必须先知道“这次是谁”,后面才能开对终端。

  2. 用前一阶段定好的配置,拼出一个 session 描述

    TmuxSession.__init__()(创建一个 tmux 会话对象)会把前一阶段已经定死的东西装进去:比如 pane size(终端窗口大小)、extra env(额外环境变量)、default user(默认用户)。 同时,它会把这个 session 绑定到当前 environment。这一步的意义是把“通用配置”落到“这次 trial 的具体终端”上。

  3. 按是否有 trial_paths,决定要不要带录制配置

    这里有一个明确分支:

    • 如果 environment.trial_paths 存在,就从里面推导 asciinema(终端录屏工具)相关路径,把录制配置挂到 session 上。
    • 如果没有,就跳过录制配置。

    为什么放在这里?因为录制文件路径天然属于“这次 trial 的产物路径”,只有拿到 trial 环境后才知道该写到哪。放早了拿不到路径,放晚了又可能错过最开始的终端内容。

  4. 启动 session,让它变成可驱动的活对象

    TmuxSession.start()(真正把 tmux 会话拉起来)之后,这个 session 才不是纸面配置,而是一个后续 Stage 可以发命令、读输出、做观察的真实终端。 这一步成功,才算本 Stage 完成;如果不启动,后面的 run() 虽然能进入流程,但没有可操作的终端,整条 Agent 回路就没法落地。

状态流动

与前后 Stage 的衔接

上游 stage-1 交付的是静态配置:窗口大小、用户、额外环境变量这些“该怎么开终端”的规则。这个 Stage 把它们和当前 trial 的 environment 绑在一起,产出一个已启动的 tmux session;下游 stage-3 才能基于这个活会话正式进入 run(),去发命令、看输出、继续 Agent 循环。

函数细节3

Terminus2.setupterminus_2.py:354–373 ↗

创建并启动试验绑定终端会话

stage 上下文: 这是 stage-2“Environment Setup”中把 agent 真正接到 trial 环境上的入口:在编排器已提供 environment、但主运行逻辑 run() 尚未开始时执行一次。它承接 stage-1 已固化的构造配置,把 pane 尺寸、额外环境变量、是否录屏等设置落实到 TmuxSession。本 stage 目前无已翻译兄弟函数;该函数因此承担该阶段的核心落地职责。

这段代码在干什么

该函数根据 environment 与实例配置创建一个 TmuxSession,并把它保存到 self._session 后异步启动。若 self._record_terminal_session 为真,它会同时准备本地与远端两套 asciinema 录制路径;否则显式把两者都设为 None。函数本身不返回业务值,真正产出是一个已启动、绑定到当前 trial 环境的 tmux 终端会话。

接口 · 参数 / IO

(self, environment: BaseEnvironment) -> None

  • 参数: environment: BaseEnvironment — 当前 trial 的环境对象,提供 trial_pathsdefault_user 等会话绑定信息
  • 读状态: self._record_terminal_session, self._tmux_pane_width, self._tmux_pane_height, self._extra_env, self.name()
  • 返回: 返回 None;实际产出是写入 self._session,并启动对应的 tmux 会话进程
  • 副作用: 写入 self._session; 构造 TmuxSession(...); 调用并等待 self._session.start(),启动终端会话

执行流

  1. 先检查 self._record_terminal_session:若开启录制,则从 environment.trial_paths.agent_dir / "recording.cast" 生成本地录制文件路径,并从 EnvironmentPaths.agent_dir / "recording.cast" 生成远端路径。
  2. 若未开启录制,则不走另一套构造分支,而是把 local_recording_pathremote_recording_path 都显式设为 None,作为后续统一传参的占位值。
  3. 随后创建 TmuxSession 并写入 self._session:会话名取自 self.name(),环境对象直接传入 environment,pane 日志固定写到 EnvironmentPaths.agent_dir / "terminus_2.pane",录制路径使用前两步得到的值。
  4. 在同一次构造中继续注入 stage-1 已确定的运行配置,包括 pane_width=self._tmux_pane_widthpane_height=self._tmux_pane_heightextra_env=self._extra_env,以及来自环境的 user=environment.default_user
  5. 最后执行 await self._session.start(),把刚创建的 tmux 会话实际启动起来,使后续 run() 能在已就绪的终端中工作。

源码

    async def setup(self, environment: BaseEnvironment) -> None:
        if self._record_terminal_session:
            local_recording_path = environment.trial_paths.agent_dir / "recording.cast"
            remote_recording_path = EnvironmentPaths.agent_dir / "recording.cast"
        else:
            local_recording_path = None
            remote_recording_path = None

        self._session = TmuxSession(
            session_name=self.name(),
            environment=environment,
            logging_path=EnvironmentPaths.agent_dir / "terminus_2.pane",
            local_asciinema_recording_path=local_recording_path,
            remote_asciinema_recording_path=remote_recording_path,
            pane_width=self._tmux_pane_width,
            pane_height=self._tmux_pane_height,
            extra_env=self._extra_env,
            user=environment.default_user,
        )
        await self._session.start()

Non-obvious 设计决策

  • 录制开关只影响两条路径参数,而不改变 TmuxSession 的创建流程:代码在关闭录制时传入两个 None,而不是分出“录制版/非录制版”两套构造逻辑。这一取舍减少了分支面,保证会话初始化的主体参数始终一致。
  • 录制路径区分本地与远端来源:本地路径取自 environment.trial_paths.agent_dir,远端路径取自固定的 EnvironmentPaths.agent_dir。这表明会话同时面向宿主侧产物归档与环境内文件定位,两者语义不同,不能复用同一目录基准。
  • 默认用户不从实例缓存读取,而是每次以 environment.default_user 传入会话。这样用户身份绑定的是当前试验环境,而非 agent 名称或构造期常量,避免环境切换时用户上下文失真。

上下游关系

  • 调用方: Harbor/运行时编排器在 trial 启动阶段调用 Terminus2.setup(environment); stage-2 的环境准备流程,在 run() 之前一次性触发
  • 核心被调用: TmuxSession.__init__; self._session.start; self.name
  • 配置/状态来源: self._record_terminal_session; self._tmux_pane_width; self._tmux_pane_height; self._extra_env; environment.trial_paths.agent_dir; environment.default_user; EnvironmentPaths.agent_dir
  • 结果去向: self._session,供后续运行阶段复用同一终端会话; tmux 终端进程本身,作为后续命令执行载体; terminus_2.pane 日志文件路径下的 pane 输出; 可选的 recording.cast 录制文件(本地与环境内路径)
TmuxSession.__init__tmux_session.py:28–57 ↗

tmux 会话对象的构造初始化入口

stage 上下文: 该函数处于 stage-2 的对象落地环节:在 Terminus2.setup 已决定要创建 TmuxSession 之后,用 trial 环境与会话参数把实例状态一次性定型。触发条件是 setup 阶段需要为当前 environment 建立终端会话包装器,但真正的启动动作不在这里发生。与兄弟函数 Terminus2.setup 的关系是:后者负责推导 recording 路径并调用本构造器,本函数负责接收这些结果并保存为后续 start() / stop() 可用的内部状态。

这段代码在干什么

该构造函数为一个 TmuxSession 实例建立完整初始状态:校验并规范化 pane 尺寸,保存会话名、环境对象、日志路径、录屏路径、额外环境变量与目标用户等输入参数。它不读取既有可变实例状态,也不启动 tmux;真正的产出是若干 self._... 字段与 self.environment 被写入,供后续 stage-2 的启动步骤继续使用。除参数校验失败时抛出 ValueError 外,没有外部 I/O 副作用。

接口 · 参数 / IO

(self, session_name: str, environment: BaseEnvironment, logging_path: Path | PurePosixPath, local_asciinema_recording_path: Path | None, remote_asciinema_recording_path: Path | PurePosixPath | None, pane_width: int = 160, pane_height: int = 40, extra_env: dict[str, str] | None = None, user: str | int | None = None) -> None

  • 参数: session_name: str — tmux 会话标识,写入 self._session_name 供后续会话管理使用; environment: BaseEnvironment — 当前 trial 绑定的环境对象,保存到 self.environment; logging_path: Path | PurePosixPath — 终端日志路径,写入 self._logging_path; local_asciinema_recording_path: Path | None — 本地 asciinema 录制文件路径,可为空,写入 self._local_asciinema_recording_path; remote_asciinema_recording_path: Path | PurePosixPath | None — 远端录制路径,可为空,写入 self._remote_asciinema_recording_path; pane_width: int — tmux pane 宽度配置,先转整数并校验,再写入 self._pane_width; pane_height: int — tmux pane 高度配置,先转整数并校验,再写入 self._pane_height; extra_env: dict[str, str] | None — 附加环境变量映射;若为 None 则退化为空字典,写入 self._extra_env; user: str | int | None — 默认执行用户标识,保存到 self._user
  • 返回: 返回 None;真正产出是一个已完成基础配置装配、尚未启动的 TmuxSession 实例状态。
  • 副作用: 写入 self._pane_width; 写入 self._pane_height; 写入 self._logging_path; 写入 self._local_asciinema_recording_path; 写入 self._remote_asciinema_recording_path; 写入 self._session_name; 写入 self._logger; 写入 self._previous_buffer; 写入 self._disable_recording; 写入 self.environment; 写入 self._markers; 写入 self._extra_env; 写入 self._user; 在 pane 尺寸非法时抛出 ValueError

执行流

  1. 首先对 pane_widthpane_height 执行整数化:通过 int(...) 规范输入;若出现 ValueErrorTypeError,立即转成统一的 ValueError("pane_width and pane_height must be valid integers.") 终止构造。
  2. 随后检查整数化后的 self._pane_widthself._pane_height 是否大于 0;任一非正值都会抛出 ValueError("pane_width and pane_height must be positive integers."),避免把无效尺寸带入后续会话启动。
  3. 尺寸通过后,把日志与录制相关路径原样保存到 self._logging_pathself._local_asciinema_recording_pathself._remote_asciinema_recording_path,并记录 self._session_name
  4. 接着初始化运行期基础字段:把模块级 logger 绑定到 self._logger,把 self._previous_buffer 设为 None,把 self._disable_recording 设为 False,并保存 environmentself.environment
  5. 最后建立后续 recording / 注入环境 / 用户切换所需的初始容器与配置:self._markers 初始化为空列表,self._extra_env 采用 extra_env or {} 归一化为字典,self._user 保存传入用户标识。

源码

    def __init__(
        self,
        session_name: str,
        environment: BaseEnvironment,
        logging_path: Path | PurePosixPath,
        local_asciinema_recording_path: Path | None,
        remote_asciinema_recording_path: Path | PurePosixPath | None,
        pane_width: int = 160,
        pane_height: int = 40,
        extra_env: dict[str, str] | None = None,
        user: str | int | None = None,
    ):
        try:
            self._pane_width = int(pane_width)
            self._pane_height = int(pane_height)
        except (ValueError, TypeError):
            raise ValueError("pane_width and pane_height must be valid integers.")
        if self._pane_width <= 0 or self._pane_height <= 0:
            raise ValueError("pane_width and pane_height must be positive integers.")
        self._logging_path = logging_path
        self._local_asciinema_recording_path = local_asciinema_recording_path
        self._remote_asciinema_recording_path = remote_asciinema_recording_path
        self._session_name = session_name
        self._logger = logger
        self._previous_buffer: str | None = None
        self._disable_recording = False
        self.environment = environment
        self._markers: list[tuple[float, str]] = []
        self._extra_env: dict[str, str] = extra_env or {}
        self._user = user

Non-obvious 设计决策

  • 对 pane 尺寸先做 int(...) 再校验正数,体现的是“尽早拒绝无效配置”的构造策略:后续 tmux 启动依赖尺寸参数,若把字符串、空值或非正数留到更晚阶段才暴露,错误定位会从构造期漂移到运行期。
  • 异常分成两层语义:TypeError / ValueError 被折叠为“不是合法整数”,而非正整数单独报“必须为正整数”。这让调用方能区分“不可解析”和“可解析但业务无效”两类配置问题。
  • extra_env 使用 extra_env or {} 做空值归一化,说明构造后下游代码可以把 self._extra_env 当成总是可迭代的字典处理,而不必每次再判空;代价是调用方传入空字典也会被等价视为默认空配置。
  • 录制相关状态在构造时就同时准备 self._local_asciinema_recording_pathself._remote_asciinema_recording_pathself._disable_recordingself._markers,反映出本类把“是否录制”与“录制过程记账”视为会话固有状态,而不是启动时临时拼装。

上下游关系

  • 调用方: Terminus2.setup; stage-2 中创建 trial 终端会话包装器的构造调用点
  • 核心被调用: int; ValueError; logger
  • 配置/状态来源: Terminus2.setup 传入的 environment; Terminus2.setup 推导出的 local_asciinema_recording_path; Terminus2.setup 推导出的 remote_asciinema_recording_path; stage-1 已结晶的 pane 尺寸配置 pane_width / pane_height; stage-1 已结晶的额外环境变量 extra_env; stage-1 已结晶的默认用户 user
  • 结果去向: 实例后续的 TmuxSession.start() 启动流程; 实例后续的 TmuxSession.stop() 收尾与 marker 消费流程; 会话内后续日志写出逻辑(通过 self._logging_path); 会话内后续 asciinema 录制逻辑(通过 recording 路径、self._disable_recordingself._markers); Terminus2.setup 保存到 self._session 的会话对象
  • 同类 sibling: Terminus2.setup:负责判定是否录屏并准备本地/远端路径,然后调用本构造函数;本函数只接收并固化这些参数,不负责启动。
寄存器交互
TmuxSession.starttmux_session.py:429–470 ↗

启动并配置远端 tmux 会话

stage 上下文: 该条目对应 TmuxSession 在环境准备阶段的启动动作实现,内容集中在远端 tmux 会话创建与附带配置。与同阶段的 TmuxSession.__init__ 相比,这里不再做参数固化,而是消费既有 self._... 状态并对 environment 产生实际远端副作用。与兄弟单元 Terminus2.setup 的关系仅能从已给上下文得知:后者会创建并启动 TmuxSession,而本函数是其中承担启动细节的实现点。

这段代码在干什么

该异步函数先尝试确保 tmux 可用,然后通过 self.environment.exec(...) 执行 self._tmux_start_session 来启动远端 tmux 会话。启动结果以 return_code 判定成败;失败时立即抛出带 stderrRuntimeError,成功后再尽力把 tmux 的 history-limit 提高到 10_000_000,但这一配置失败只记 warning、不阻断返回。若 self._remote_asciinema_recording_path 为真,函数会先在 pane 内发送 asciinema 开始录制命令并清屏,随后在第二个独立条件块中把时间戳辅助脚本上传到远端。

接口 · 参数 / IO

(self) -> None

  • 参数: self: TmuxSession — 提供远端环境对象、tmux 启动命令、目标用户、录屏路径、日志器与脚本路径常量
  • 读状态: self.environment, self._tmux_start_session, self._user, self._remote_asciinema_recording_path, self._logger, self._GET_ASCIINEMA_TIMESTAMP_SCRIPT_HOST_PATH, self.GET_ASCIINEMA_TIMESTAMP_SCRIPT_CONTAINER_PATH
  • 返回: 返回 None。正常返回表示 tmux 启动命令已成功执行,且后续 history-limit 设置、录制启动、脚本上传已按各自分支尝试完成;其中 history-limit 提升并不被保证成功。
  • 副作用: 调用 self._attempt_tmux_installation() 触发 tmux 安装尝试; 通过 self.environment.exec(command=self._tmux_start_session, user=self._user) 在远端启动 tmux 会话; 读取启动结果的 return_codestderr,失败时抛出 RuntimeError; 通过第二次 self.environment.exec(...) 尝试在远端执行 tmux set-option -g history-limit 10000000; 读取 history 设置结果的 return_codestderr,失败时写入 warning 日志; 当 self._remote_asciinema_recording_path 为真时,调用 self.send_keys(...) 在 tmux pane 中启动 asciinema 录制,且显式使用 min_timeout_sec=1.0; 当 self._remote_asciinema_recording_path 为真时,再调用一次 self.send_keys(...) 发送 clear 清屏; 在第二个独立的录制条件判断中,通过 self.environment.upload_file(...) 上传 asciinema 时间戳辅助脚本到远端

执行流

  1. 首先调用 await self._attempt_tmux_installation(),先行执行 tmux 安装尝试,然后用 self.environment.exec(command=self._tmux_start_session, user=self._user) 启动会话。
  2. 检查启动结果 start_session_result.return_code;若不为 0,立即以 start_session_result.stderr 拼接错误信息并抛出 RuntimeError,后续步骤不再继续。
  3. 在启动成功后构造 history_limit = 10_000_000 与命令 tmux set-option -g history-limit ...,再次通过 self.environment.exec(command=command, user=self._user) 尝试调大 tmux 回滚历史。
  4. 检查 set_history_result.return_code;若不为 0,则仅把 (set_history_result.stderr or "").strip() 写入 warning 日志,不抛异常。
  5. 进入第一个 if self._remote_asciinema_recording_path: 条件块时,先写一条 debug 日志 Starting recording.,再通过 self.send_keys(...) 发送 asciinema rec --stdin {self._remote_asciinema_recording_path}Enter,并显式指定 min_timeout_sec=1.0
  6. 仍在第一个录制条件块内,紧接着再次调用 self.send_keys(...) 发送 clearEnter,用于在 pane 中清屏。
  7. 随后代码再次进行第二个独立的 if self._remote_asciinema_recording_path: 判断;若条件仍成立,则调用 self.environment.upload_file(...),把主机侧脚本 self._GET_ASCIINEMA_TIMESTAMP_SCRIPT_HOST_PATH 上传到容器侧 self.GET_ASCIINEMA_TIMESTAMP_SCRIPT_CONTAINER_PATH

源码

    async def start(self) -> None:
        await self._attempt_tmux_installation()
        start_session_result = await self.environment.exec(
            command=self._tmux_start_session, user=self._user
        )
        if start_session_result.return_code != 0:
            raise RuntimeError(
                f"Failed to start tmux session. Error: {start_session_result.stderr}"
            )

        history_limit = 10_000_000
        command = f"tmux set-option -g history-limit {history_limit}"
        set_history_result = await self.environment.exec(
            command=command, user=self._user
        )
        if set_history_result.return_code != 0:
            self._logger.warning(
                "Failed to increase tmux history-limit: %s",
                (set_history_result.stderr or "").strip(),
            )

        if self._remote_asciinema_recording_path:
            self._logger.debug("Starting recording.")
            await self.send_keys(
                keys=[
                    f"asciinema rec --stdin {self._remote_asciinema_recording_path}",
                    "Enter",
                ],
                min_timeout_sec=1.0,
            )
            await self.send_keys(
                keys=[
                    "clear",
                    "Enter",
                ],
            )

        if self._remote_asciinema_recording_path:
            await self.environment.upload_file(
                source_path=self._GET_ASCIINEMA_TIMESTAMP_SCRIPT_HOST_PATH,
                target_path=str(self.GET_ASCIINEMA_TIMESTAMP_SCRIPT_CONTAINER_PATH),
            )

Non-obvious 设计决策

  • tmux 会话启动被视为硬失败点:代码对 start_session_result.return_code != 0 直接 raise RuntimeError(...),并把 stderr 暴露到异常文本中;这与后续 history-limit 设置的处理强度明显不同。
  • history-limit 调整采用宽松容错:同样检查 return_code,但失败时只记录 warning,且日志内容取自 stderr 的去空白版本,不改变函数的正常返回路径。
  • 录制逻辑被拆成两个彼此独立的 if self._remote_asciinema_recording_path: 条件块,而非单一连续分支;代码层面上,这意味着“在 pane 内启动录制/清屏”和“上传时间戳脚本”是分开组织的两个条件动作。
  • 启动录制时对 send_keys(...) 显式传入 min_timeout_sec=1.0,但随后的 clear 发送未复用该参数;这是源码中对两次按键发送采取的不对称等待策略。

上下游关系

  • 调用方: 已知兄弟上下文指出:Terminus2.setup 会创建并异步启动 TmuxSession
  • 核心被调用: TmuxSession._attempt_tmux_installation; environment.exec; TmuxSession.send_keys; environment.upload_file; self._logger.warning; self._logger.debug
  • 配置/状态来源: self._tmux_start_session 提供 tmux 启动命令; self._user 提供远端执行用户; self._remote_asciinema_recording_path 控制两段录制相关分支并参与命令拼接; self._GET_ASCIINEMA_TIMESTAMP_SCRIPT_HOST_PATH 提供上传源脚本路径; self.GET_ASCIINEMA_TIMESTAMP_SCRIPT_CONTAINER_PATH 提供上传目标路径; self.environment 提供远端命令执行与文件上传能力
  • 结果去向: 远端环境中的 tmux 会话状态; 远端 tmux 全局 history-limit 配置; 远端 pane 内正在运行的 asciinema 录制进程(条件成立时); 远端文件系统中的时间戳辅助脚本(条件成立时); 异常通道:启动失败时抛出 RuntimeError; 日志通道:history-limit 设置失败写 warning,录制开始前写 debug
  • 同类 sibling: TmuxSession.__init__:前者只固化 self._... 输入与校验参数,本函数实际消费这些状态并对远端环境产生副作用; Terminus2.setup:兄弟上下文表明其负责构造并启动 TmuxSession,本函数则承载启动后的具体远端操作