数据截至 (上游 commit 5c995c365dfb)
运行时与会话
本章讲"执行"这一层: 对外的接口长什么样,本地实现怎么管多个 shell 会话,以及一个
BashSession从生到死的过程。输出/退出码怎么抠这个最难的点留到 02 章。
1. 接口:一个 Runtime 能做 8 件事
AbstractRuntime 是所有运行时的契约(runtime/abstract.py:234)。它全部是 async 抽象方法,可以分成三组:
| 组 | 方法 | 干什么 |
|---|---|---|
| 会话 | create_session / run_in_session / close_session | 开/用/关一个长活 REPL(如 bash) |
| 一次性执行 | execute | 跑一条子进程命令,像 subprocess.run(),跑完即弃 |
| 文件 & 探活 | read_file / write_file / upload / is_alive / close | 读写文件、上传、健康检查、关运行时 |
会话 vs execute 的区别是核心。 这俩很容易混:
run_in_session把命令送进一个已经开着、有状态的 shell。前一条命令cd /tmp了,后一条命令就在/tmp里——状态延续。这就是"像人开着终端"。execute起一个全新子进程跑完就结束,没有跨命令状态。对应subprocess.run()(runtime/local.py:419-453)。
数据模型:请求和响应都是 pydantic
每个动作的输入输出都是 pydantic 模型,这让它们能直接 JSON 序列化走 HTTP(这正是 02/03 章远程化的基础)。最该认识的两个:
BashAction(你要跑什么,runtime/abstract.py:51)关键字段:
| 字段 | 含义 |
|---|---|
command | 要跑的命令 |
session | 在哪个会话跑(默认 "default") |
timeout | 超时,None 表示不限 |
check | 退出码策略:"raise" / "silent" / "ignore"(abstract.py:71-76) |
is_interactive_command | 是否是 gdb 这类不退出的交互程序 |
expect | 除 PS1 外还要等待哪些输出串 |
BashObservation(你拿回什么,runtime/abstract.py:109):output(输出)、exit_code(退出码,可能 None)、failure_reason、expect_string(命中了哪个等待串而终止)。
一个巧思:IsAliveResponse 重写了 __bool__(abstract.py:19-20),所以可以直接 if await runtime.is_alive():。
为什么都用判别 union? 像 Action = BashAction | BashInterruptAction,用 action_type 字段判别(abstract.py:105)。这让 FastAPI 收到 JSON 时能自动反序列化成正确的子类型,也为将来加 session_type 留口子。目前 union 里 bash 是唯一成员。
2. LocalRuntime:用一个字典管多个会话
LocalRuntime 是真正干活的实现(runtime/local.py:365)。它的状态简单到一句话:一个 name -> Session 的字典(runtime/local.py:375)。
# 示意,非源码 —— 演示 LocalRuntime 的会话管理骨架
class LocalRuntime:
def __init__(self):
self._sessions: dict[str, Session] = {} # 会话名 -> 会话对象
async def create_session(self, request):
if request.session in self._sessions: # 重名直接报错
raise SessionExistsError(...)
session = BashSession(request)
self._sessions[request.session] = session
return await session.start() # 真正 spawn 一个 bash
async def run_in_session(self, action):
if action.session not in self._sessions: # 没开过就报错
raise SessionDoesNotExistError(...)
return await self._sessions[action.session].run(action)
真实实现见 create_session(runtime/local.py:390-401)、run_in_session(:403-408)、close_session(:410-417)。重点看: 多会话并行就是"字典里多个 key";agent 想同时开 bash + ipython + gdb,就 create 三个不同 session 名字的会话。close() 会遍历字典关掉所有会话(runtime/local.py:474-478)。
execute(不进会话的一次性命令)则完全独立——直接 subprocess.run,带 timeout / cwd / env / check,并能把 stderr 合并进 stdout(merge_output_streams,runtime/local.py:419-453)。
3. BashSession:把一个 bash 进程包成可控 REPL
BashSession 是"一个长活终端"的封装(runtime/local.py:129)。注释直接点明:"它基本就是一个 pexpect.REPLWrapper"(runtime/local.py:134)。
为什么用 pexpect? 因为要跑 ipython / gdb 这种交互程序,你不能用 subprocess.run(它跑完才返回)。pexpect 能伪装成一个终端(pty),持续地往 shell 写命令、读输出,正是"人坐在终端前"的程序化版本。
生命周期:start → run* → close
Session 抽象基类只要求三个方法(runtime/local.py:118-126):start() / run() / close()。
start():spawn 一个 bash,并驯服它的提示符。 这是全章最关键的设计动作:
# 示意,非源码 —— BashSession.start 的核心思路
self._shell = pexpect.spawn(
"/usr/bin/env bash",
echo=False, # 关回显,免得命令本身混进输出
env={**os.environ, "PS1": self._ps1, "PS2": "", "PS0": ""},
)
# 把 PS1 固定成一个我们认得的哨兵串,PS2/PS0 清空
self._shell.sendline("export PS1='SHELLPS1PREFIX' ; export PS2='' ; export PS0=''")
self._shell.expect(self._ps1) # 等到哨兵出现 = shell 就绪
真实实现见 BashSession.start(runtime/local.py:157-175)。重点看 _ps1 = "SHELLPS1PREFIX"(runtime/local.py:138):整个输出解析的命脉就是"把 PS1 提示符换成一个固定的、不会自然出现的字符串",这样"命令跑完、回到提示符"就变成"在输出流里 expect 到这个串"。详见 02 章。
为什么要 _get_reset_commands 强行重设 PS1/PS2/PS0(runtime/local.py:149-155)?因为用户的 startup_source 启动脚本经常会覆盖 PS1(abstract.py:25-28 的注释自陈),必须在 source 之后再盖回来。
run():按命令类型分流。 run 是个分流器(runtime/local.py:218-243):
┌─ BashInterruptAction ──▶ interrupt() 发 Ctrl-C 终止当前命令
run(action)─┤
├─ is_interactive_* ─────▶ _run_interactive() 不取退出码
└─ 普通命令 ─────────────▶ _run_normal() 切分 + 跑 + 抠退出码
三条分支都在 02 章细讲。这里只记结论:普通命令走 _run_normal,跑完若 check=="raise" 且退出码非 0,就抛 NonZeroExitCodeError(runtime/local.py:238-242)。
interrupt():打断卡住的命令。 不是简单发 Ctrl-C 就完事——它先重试 n_retry 次发中断信号(runtime/local.py:188-201),失败了还有兜底:Ctrl-Z 把任务挂到后台,再 kill -9 %1 干掉(runtime/local.py:202-216)。这是给"命令彻底卡死"准备的最后手段。
close(): 关掉 pexpect 进程并置空(runtime/local.py:353-358)。
4. 巧妙之处
- 回显关闭 + 哨兵 PS1 是整个解析的地基。
echo=False让命令文本不混进输出,固定 PS1 让"命令结束"可被expect精确捕捉(runtime/local.py:159-164)。一个看似不起眼的环境变量设置,撑起了后面所有解析。 - 会话即字典,并行天然免费。 没有线程池、没有锁——多会话就是字典多个 key,每个 key 一个独立 pexpect 进程(
runtime/local.py:375)。并发的复杂度被推到了"开多个会话"这个最简单的模型上。 IsAliveResponse.__bool__让健康检查能直接当布尔用(runtime/abstract.py:19),是_wait_until_alive轮询逻辑能写得很干净的原因(见 03 章)。
5. 代码地图
| 主题 | 文件路径 | 关键符号 |
|---|---|---|
| 执行接口定义 | src/swerex/runtime/abstract.py | AbstractRuntime |
| 请求/响应模型 | src/swerex/runtime/abstract.py | BashAction、BashObservation、IsAliveResponse、Command |
| 本地运行时 | src/swerex/runtime/local.py | LocalRuntime、LocalRuntime.create_session、LocalRuntime.execute |
| 会话抽象 | src/swerex/runtime/local.py | Session |
| bash 会话 | src/swerex/runtime/local.py | BashSession、BashSession.start、BashSession.run、BashSession.interrupt |
| 异常 | src/swerex/exceptions.py | SessionExistsError、SessionDoesNotExistError、NonZeroExitCodeError |