Supervisor 与 Worker
执行由 Worker 子进程承载,Supervisor 统一管理审批、取消、心跳与进程回收。
执行器与视图区分
当前 Teams、子 Agent 等执行路径使用 RuntimeSupervisor 管理 Worker 子进程。每个 Worker 保留持续对话和自己的有效信任档案,tmux / iTerm2 只显示只读视图。关闭终端视图不会自动停止 Worker,执行生命周期由 Supervisor 明确管理。
flowchart TD
A[TUI / Print / Remote] --> B[RuntimeSupervisor]
B --> C[WorkerHandle A]
B --> D[WorkerHandle B]
C --> E[Unix Socket]
D --> F[Unix Socket]
E --> G[Worker process A]
F --> H[Worker process B]
G -.日志.-> I[只读终端视图]
H -.日志.-> I
这让“在哪个终端窗口显示”不再决定“由谁持有进程、谁处理审批、谁收尾”。
三个层次的身份
| 身份 | 生命周期 | 避免的问题 |
|---|---|---|
| Worker ID | 一个受管理 Worker | 将别的进程事件误当成本进程事件 |
| Request ID | 一轮任务 | 旧轮取消或完成消息作用于新轮 |
| Approval ID | 一次具体审批 | 用户的过期批准作用于另一份计划 |
Worker 是连续会话容器,一轮结束后可以进入空闲并接受后续任务,不必每次重建对话。Request 则只属于其中一轮。将这两层混为一谈,会很难判断“取消当前任务”究竟应该停止一轮还是终止整个 Worker。
协议必须先有边界
Unix Socket 是字节流,一次读取不保证恰好一条消息。FrameCodec 使用四字节大端长度前缀和 JSON 载荷,先读完整头部,再按长度读消息,拒绝超长帧或无效载荷。
class FrameCodec:
@staticmethod
def encode(message: RuntimeMessage | Mapping[str, Any], max_frame_size: int = DEFAULT_MAX_FRAME_SIZE) -> bytes:
if isinstance(message, RuntimeMessage):
data = message.to_dict()
else:
data = message
try:
validated = validate_message(data)
payload = json.dumps(
validated.to_dict(),
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
except (TypeError, ValueError, UnicodeError) as exc:
if isinstance(exc, ProtocolError):
raise
raise ProtocolError("message cannot be encoded") from exc
if len(payload) > max_frame_size:
raise ProtocolError("frame length exceeds maximum")
return struct.pack(">I", len(payload)) + payload
@staticmethod
async def read(reader: Any, max_frame_size: int = DEFAULT_MAX_FRAME_SIZE) -> RuntimeMessage:
if max_frame_size < 0 or max_frame_size > 0xFFFFFFFF:
raise ValueError("max_frame_size must fit an unsigned 32-bit length")
try:
header = await reader.readexactly(4)
except (asyncio.IncompleteReadError, ConnectionError) as exc:
raise ConnectionError("peer closed the runtime connection") from exc
length = struct.unpack(">I", header)[0]
if length > max_frame_size:
raise ProtocolError("frame length exceeds maximum")
try:
raw = await reader.readexactly(length)
except (asyncio.IncompleteReadError, ConnectionError) as exc:
raise ConnectionError("peer closed during a runtime frame") from exc
try:
text = raw.decode("utf-8")
data = json.loads(text)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ProtocolError("invalid frame payload") from exc
try:
return validate_message(data)
except (TypeError, ValueError) as exc:
if isinstance(exc, ProtocolError):
raise
raise ProtocolError("invalid frame message") from exc
消息还有协议版本、Worker ID、请求 ID、序列号及受约束的 payload。Transport 校验身份与顺序,审批消息再校验审批 ID 和目标轮次。启动认证 token 经继承文件描述符传递,不放进命令行参数或环境变量。
长度前缀解决拆包与粘包,身份和序列校验解决消息是否属于这条连接,payload 校验解决消息结构是否符合命令类型。这些约束逐层成立才能把消息交给状态机,JSON 能解析本身远远不够。
启动过程先准备通信端点并创建子进程,再等待连接与 ready。启动中任何一步失败,都需要关闭监听、连接、文件描述符和已创建进程;只回报“启动超时”而不回收资源,会在多次重试后积累孤儿进程。
Handle 持有生命周期
运行中不能启动第二轮,待审批也不能同时接收新轮。取消必须指定当前请求,避免旧取消命令误伤后来任务。
async def cancel_turn(self, request_id: str) -> None:
if request_id != self._current_request_id:
raise ValueError("request ID does not match the active turn")
waiter = asyncio.get_running_loop().create_future()
self._pending_cancellations[request_id] = waiter
try:
await self._send(
"cancel_turn",
uuid.uuid4().hex,
{"target_request_id": request_id},
)
await asyncio.wait_for(asyncio.shield(waiter), self._shutdown_timeout)
finally:
self._pending_cancellations.pop(request_id, None)
Supervisor 还负责心跳超时、断线清理、关闭与升级回收。进程存在、连接就绪、当前轮完成、Worker 空闲、Worker 停止是不同状态,不能仅凭终端输出停住就判定进程已退出。
状态机怎样限制命令
| 状态 | 含义 | 典型动作 |
|---|---|---|
| STARTING | 正在启动和握手 | 等待 ready 或处理启动失败 |
| READY / IDLE | 可接收一轮任务 | 发出 run_turn |
| RUNNING | 当前轮正在执行 | 处理事件、取消或等待完成 |
| WAITING_APPROVAL | 当前轮等待计划决定 | 对匹配审批批准或带反馈拒绝 |
| STOPPING | 已进入关闭流程 | 等待停止并回收 |
| STOPPED / FAILED | 生命周期终态 | 拒绝继续发起新轮 |
WAITING_APPROVAL 也是忙碌状态,不能在用户尚未决定当前计划时塞入另一轮。拒绝计划可携带反馈继续修订,而不是无条件销毁整个 Worker;批准则继续当前轮并按有效权限模式执行。
取消命令携带当前 request ID,并等待对应取消确认。对于结束、断线和心跳超时可能同时出现的情况,清理流程要合并为一次,避免多个协程重复关闭同一资源或交叉修改终态。
从温和关闭到强制回收
Supervisor 先请求关闭并等待;进程未退出时,再按配置升级为终止或强制回收,并等待进程结果。正常返回、通信 EOF 和心跳失联都需要明确处理,不能依赖操作系统最终“自然清理”。
只读视图通过事件订阅更新,慢订阅者使用有界队列,避免一个没人看的终端窗口无限积累输出。视图可以丢失部分展示事件,但执行状态与审批仍由运行时持有;关闭视图和关闭 Worker 因而是两个操作。
独立进程隔离运行上下文,不等于共享文件已经隔离。对子任务创建独立 worktree 可以减少并行写入冲突,合并结果仍需要明确的文件范围和检查;通信协议不能替代代码合并。
回归与边界
项目提交的历史验收记录为 1570 passed、3 skipped,覆盖真实 Worker 子进程、过期审批、取消、异常退出及只读视图,并包含真实 Linux tmux 验证。该数字来自已有验收记录,本次站点建设没有重跑整套 Python 测试。
原生 Windows 传输尚未实现;真实 macOS / iTerm2 平台用例未执行。恢复历史对话不表示会收养旧的存活进程,这两种恢复能力需要分别描述。
验证 Worker 时应覆盖连续两轮保留历史、忙碌时拒绝新轮、过期审批失效、取消只作用当前轮、断线后进程回收,以及只读视图关闭后执行仍正常。只验证“子进程可以启动并输出一句话”不足以证明生命周期正确。
