NovaCode 终端:编辑 README.md 前弹出确认,选项是允许、本次模式不再询问、拒绝

终端 AI 编码助手

NovaCode

运行在终端中的 AI 编程助手,通过自然语言读写代码、执行命令,并管理权限与长会话。

  • Python
  • Textual
  • MCP
  • OS Sandbox
03 / 05 · DOCUMENTATION

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 载荷,先读完整头部,再按长度读消息,拒绝超长帧或无效载荷。

novacode/novacode/runtime/transport.pyL14-61 @8484bf0
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 持有生命周期

运行中不能启动第二轮,待审批也不能同时接收新轮。取消必须指定当前请求,避免旧取消命令误伤后来任务。

novacode/novacode/runtime/supervisor.pyL233-246 @8484bf0
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 时应覆盖连续两轮保留历史、忙碌时拒绝新轮、过期审批失效、取消只作用当前轮、断线后进程回收,以及只读视图关闭后执行仍正常。只验证“子进程可以启动并输出一句话”不足以证明生命周期正确。