环境搭建
开场解释
这个 Stage 在解决什么问题?它把“已经定好的静态配置”变成“绑定到这次 trial 的活终端”。前一阶段只把尺寸、用户、额外环境变量这些参数定住;但真正的终端不能更早启动,因为必须等 Harbor orchestrator(外层调度器)把这次 trial 的 environment 交进来,才能知道要连到哪个环境、是否要录制。这个 Stage 的产物很明确:一个已启动、可被后续 Stage 驱动的 tmux session(可远程控制的终端会话)。
主流程
- 先接收这次 trial 的
environmentTerminus2.setup()(把当前 trial 的环境接进来)不是在“跑任务”,而是在拿到这次运行的现场信息。 这样设计是因为同一个 Agent 配置可以复用很多次,但 trial 环境每次都不同。必须先知道“这次是谁”,后面才能开对终端。 - 用前一阶段定好的配置,拼出一个 session 描述
TmuxSession.__init__()(创建一个 tmux 会话对象)会把前一阶段已经定死的东西装进去:比如 pane size(终端窗口大小)、extra env(额外环境变量)、default user(默认用户)。 同时,它会把这个 session 绑定到当前environment。这一步的意义是把“通用配置”落到“这次 trial 的具体终端”上。 - 按是否有
trial_paths,决定要不要带录制配置这里有一个明确分支:
- 如果
environment.trial_paths存在,就从里面推导 asciinema(终端录屏工具)相关路径,把录制配置挂到 session 上。 - 如果没有,就跳过录制配置。
为什么放在这里?因为录制文件路径天然属于“这次 trial 的产物路径”,只有拿到 trial 环境后才知道该写到哪。放早了拿不到路径,放晚了又可能错过最开始的终端内容。
- 如果
- 启动 session,让它变成可驱动的活对象
TmuxSession.start()(真正把 tmux 会话拉起来)之后,这个 session 才不是纸面配置,而是一个后续 Stage 可以发命令、读输出、做观察的真实终端。 这一步成功,才算本 Stage 完成;如果不启动,后面的run()虽然能进入流程,但没有可操作的终端,整条 Agent 回路就没法落地。
状态流动
- 写: 无 — 输入给出的 skeleton 明确说明本 stage 未被任何 register 显式提及;本阶段的产物落在
Terminus2实例上的运行时对象字段(创建并保存TmuxSession),不写入 register - 读: 无 — 本阶段不从给定 register 集合取值;它读取的是参数
environment,以及 stage-1 已经固化在对象构造状态里的配置,这些都不是本题列出的 register - 清: 无 — 没有给定 register 在本阶段被清空
- 触发下游:
stage-3 Run Onset— 当setup(environment)完成,且 tmux session 已成功启动、处于可驱动状态后进入
与前后 Stage 的衔接
上游 stage-1 交付的是静态配置:窗口大小、用户、额外环境变量这些“该怎么开终端”的规则。这个 Stage 把它们和当前 trial 的 environment 绑在一起,产出一个已启动的 tmux session;下游 stage-3 才能基于这个活会话正式进入 run(),去发命令、看输出、继续 Agent 循环。
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_paths与default_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(),启动终端会话
执行流
- 先检查
self._record_terminal_session:若开启录制,则从environment.trial_paths.agent_dir / "recording.cast"生成本地录制文件路径,并从EnvironmentPaths.agent_dir / "recording.cast"生成远端路径。 - 若未开启录制,则不走另一套构造分支,而是把
local_recording_path与remote_recording_path都显式设为None,作为后续统一传参的占位值。 - 随后创建
TmuxSession并写入self._session:会话名取自self.name(),环境对象直接传入environment,pane 日志固定写到EnvironmentPaths.agent_dir / "terminus_2.pane",录制路径使用前两步得到的值。 - 在同一次构造中继续注入 stage-1 已确定的运行配置,包括
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(),把刚创建的 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
执行流
- 首先对
pane_width与pane_height执行整数化:通过int(...)规范输入;若出现ValueError或TypeError,立即转成统一的ValueError("pane_width and pane_height must be valid integers.")终止构造。 - 随后检查整数化后的
self._pane_width与self._pane_height是否大于 0;任一非正值都会抛出ValueError("pane_width and pane_height must be positive integers."),避免把无效尺寸带入后续会话启动。 - 尺寸通过后,把日志与录制相关路径原样保存到
self._logging_path、self._local_asciinema_recording_path、self._remote_asciinema_recording_path,并记录self._session_name。 - 接着初始化运行期基础字段:把模块级
logger绑定到self._logger,把self._previous_buffer设为None,把self._disable_recording设为False,并保存environment到self.environment。 - 最后建立后续 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_path、self._remote_asciinema_recording_path、self._disable_recording与self._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_recording与self._markers);Terminus2.setup保存到self._session的会话对象 - 同类 sibling: Terminus2.setup:负责判定是否录屏并准备本地/远端路径,然后调用本构造函数;本函数只接收并固化这些参数,不负责启动。
reg-asciinema-markers— 初始化 marker 容器为空列表
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 判定成败;失败时立即抛出带 stderr 的 RuntimeError,成功后再尽力把 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_code与stderr,失败时抛出RuntimeError; 通过第二次self.environment.exec(...)尝试在远端执行tmux set-option -g history-limit 10000000; 读取 history 设置结果的return_code与stderr,失败时写入 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 时间戳辅助脚本到远端
执行流
- 首先调用
await self._attempt_tmux_installation(),先行执行 tmux 安装尝试,然后用self.environment.exec(command=self._tmux_start_session, user=self._user)启动会话。 - 检查启动结果
start_session_result.return_code;若不为0,立即以start_session_result.stderr拼接错误信息并抛出RuntimeError,后续步骤不再继续。 - 在启动成功后构造
history_limit = 10_000_000与命令tmux set-option -g history-limit ...,再次通过self.environment.exec(command=command, user=self._user)尝试调大 tmux 回滚历史。 - 检查
set_history_result.return_code;若不为0,则仅把(set_history_result.stderr or "").strip()写入 warning 日志,不抛异常。 - 进入第一个
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。 - 仍在第一个录制条件块内,紧接着再次调用
self.send_keys(...)发送clear与Enter,用于在 pane 中清屏。 - 随后代码再次进行第二个独立的
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,本函数则承载启动后的具体远端操作