Dev.to AI 🤖 Ai 👁 0

停掉控制面之后,AI 任务还在跑:我们为什么把它拆成三层

用一个界面对着某台机器上的某个终端窗口,是大多数人开始用 AI 写代码的方式。任务变多之后会出现三个具体问题:任务分散在多台机器上、每个任务需要独立且可丢弃的环境、长任务不能因为浏览器关了或者界面断了就死掉。 这篇讲 TaskHandoff 在这三个问题上做的架构选择:为什么控制面和执行面要拆开,状态到底归谁管,以及由此付出的代价。 问题:任务一多,你就开始手动当调度器 假设你手上有三台机器:一台笔记本、一台常

用一个界面对着某台机器上的某个终端窗口,是大多数人开始用 AI 写代码的方式。任务变多之后会出现三个具体问题:任务分散在多台机器上、每个任务需要独立且可丢弃的环境、长任务不能因为浏览器关了或者界面断了就死掉。

这篇讲 TaskHandoff 在这三个问题上做的架构选择:为什么控制面和执行面要拆开,状态到底归谁管,以及由此付出的代价。

问题:任务一多,你就开始手动当调度器

假设你手上有三台机器:一台笔记本、一台常开的家用服务器、一台公司内网主机。你在它们上面分别跑 Codex 或 OpenCode。于是你每天在做这些事:

  • 记得哪台机器上有哪个仓库、哪个分支、哪个 worktree;
  • 手动把环境装好,或者忍受容器里从零开始装工具;
  • 反复切 SSH 窗口看任务进度,因为任务只在那个终端的上下文里存在;
  • 关掉笔记本之后,不知道家用服务器上的任务到底还在不在跑。

这些动作里没有一个是真正的开发工作,但它们都会消耗注意力,而且出错时很难回溯。

我们的做法:三层,各管一件事

浏览器 / 桌面端 / 移动端 / 聊天平台
              │
              ▼
        Control Plane(UI、API、聊天网关)
              │
              ▼
         Node Agent(节点资源与实例生命周期)
              │
              ▼
     Controlled Instance(工作区、应用、AI 会话)

关键不在分层本身,而在每层各自拥有什么状态:

  • Control Plane 拥有节点清单、实例看板、跨实例的 AI 会话视图、聊天凭据与消息路由。它是你操作的地方,也是多端共用的入口。
  • Node Agent 拥有节点本地配置、运行时资源、目录清单和实例生命周期。实例的启停由它执行。
  • Controlled Instance 承载工作区、应用、AI 会话和触发器,可以独立运行。

因为实例生命周期归 Node Agent,停止或重启 Control Plane 不会顺带停掉节点上由 Node Agent 管理的 Docker 实例;控制面板恢复后会重新同步状态。

同理,AI 会话的对话状态以 AI Session 本身为准,控制面负责的是凭据、绑定、命令解析和路由。所以关掉网页不会中断任务——这条被写进了文档 FAQ,因为它是最常被问的问题。

一篇会话绑定固定工作目录,创建后不会自动改变(这条也在文档里)。要换个 worktree 干活,是「在目标 worktree 上新建会话」,而不是「把当前会话挪过去」。这是同一套状态所有权思路的延续:会话不追着环境跑。

停掉控制面之后会发生什么

这条不用实测也能从状态所有权推出来,文档里也已经把它写成了 FAQ——因为问的人最多:

  • 关掉浏览器页面:任务继续跑,重新打开后恢复权威状态。
  • 停止或重启 Control Plane:由 Node Agent 管理的 Docker 实例继续跑,控制面板恢复后重新同步。
  • 真正会中断任务的操作:停止或删除实例、关闭会话、停止 Node Agent。

这里有个最容易误判的坑,我们也把它写进了文档:页面显示「正在重连」不等于后台任务停了。 如果界面一直卡在重连,问题通常在网络路径上——最常见的原因是反向代理没有转发 /api/events 的 WebSocket,而不是 Agent 挂了。把「显示状态」和「权威状态」分开看,能省掉不少误判。

取舍与被否决的方案

为什么不把实例生命周期直接放在控制面? 那样控制面重启就会波及正在跑的长任务,而且单点变成了「UI 挂掉 = 所有任务挂掉」。把生命周期的所有权下移到节点,代价是控制面离线期间你只能在节点上继续跑已经存在的实例——新建实例、改配置这类操作都要等控制面回来。

断线重连的语义要写清楚。 界面断线重连时会重新获取权威会话快照。最容易造成误解的是那个「正在重连」提示——它不代表后台任务已经停止。我们把这条明确写进文档,而不是靠用户自己猜:会话状态在 Agent 那边,界面的连接状态只是显示层的问题。

运行时不锁死在 Docker。 Docker Runtime 是当前主要的隔离运行时,一个节点可以跑多个实例;Local Runtime 直接用宿主机环境,同一宿主用户只能有一个本地实例,也不支持 Windows。两者共享同一套实例模型,运行时通过 adapter 扩展,所以将来接 Kubernetes 不需要另做一套 UI 流程。

结果:行为上发生了什么变化

不写数字,只列可验证的行为差异:

  • 关掉网页、甚至停掉控制面,节点上正在跑的任务不受影响;重新打开后能拿回权威状态。
  • 多台机器上的实例出现在同一个看板里,可以按节点、项目、状态筛选,不用逐个 SSH 上去看。
  • 长任务可以在移动端或聊天里跟进;Telegram、微信、钉钉、飞书的桥接把消息和审批路由到指定实例的会话。
  • 实例可以独立运行,节点负责它的生命周期,控制面挂了也不会连带停掉它。

可复用的四条原则

  1. 把生命周期所有权放在离资源最近的那一层。 编排层可以崩、可以升级,但它不该拥有「谁在运行」这个事实。
  2. 区分「显示状态」和「权威状态」。 界面上的连接状态、重连提示,都不等于后台任务的状态;接错这两个概念会带来大量虚假告警和误判。
  3. 让长任务的存活不依赖交互层。 判定标准很简单:关掉浏览器、重启 UI 服务,任务还在不在跑。
  4. 状态所有权写进文档,而不是留在实现里。 用户遇到「正在重连」时,需要一句话告诉他这意味着什么。

想自己跑一遍

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.