Agent 平台

引入的第二天,我决定不再跟随上游

前一天刚定下「前端原样保留、定期同步上游」,二十小时后就废掉了同步这条。触发点是一条冲突:上游的松散连线与旧引擎的端口模型接不上。

STRUCTURE ── 篇幅剖面7 节 · 点击跳转

上一篇(07-22)刚定下「前端全量原样保留、定期人工同步上游」。

二十小时后,「定期同步」这条被废掉了。

时间线可以精确到小时:

时间事件
07-22 14:43接入决策文档定稿,写着「定期人工同步」
07-22 16:11fork 首次进仓库
07-23 11:20图运行设计文档定稿,前置决策第一条就是脱钩
07-23 11:42 / 11:57 / 12:32三个提交把「图运行 + 脱钩」一起落进主干

从写下调解决定到写下推翻决定,隔了约 20.5 小时。

一、推翻的理由

原文不长,两句:

脱钩上游:不再跟随上游同步。登记文件降级为引入历史存档,「最小改动/过滤不删」纪律废除,前端代码从此按自有代码演进。

而脱钩的书面理由,写在登记文件的顶部:

决策背景:我们的产品形态(服务端图运行 / DAG 调度)与上游的「逐节点手动推演」哲学根本分叉,同步收益趋近于零而冲突成本持续上升;商业授权为纸质合同(含 CLA 再许可权),脱钩无法律障碍。

两句合起来是一条判据:同步的收益,取决于我要不要改它给我的东西。

如果我的改动都在外围——加个鉴权、换个存储——那上游每次更新都是净收益,合进来就行。但如果我要改的正是它的执行语义,那上游的每次前进都会加深冲突,同步从收益变成了成本。

二、具体冲突是什么

三条前置决策,第一条是脱钩,第二条解释了冲突在哪:

  1. 路线 B + 服务端 Worker:执行层不复用旧自研画布引擎运行时(其端口模型与新前端的松散连线哲学冲突),新写贴合新前端数据模型的图调度;但编排基建照抄旧引擎设计(BullMQ 队列、lease / 恢复、计费包装模式——已验证过)。服务端形态也已被旧画布验证,不做客户端调度中间品。

这句话里有两个反对称的决定,值得拆开看。

不复用的是运行时。 旧自研引擎有一套端口模型:节点声明具名输入输出端口,连接受类型校验,不兼容的端口连不上。新前端没有端口——它的连线就是 { fromNodeId, toNodeId },语义只有「上游产物是下游的素材」。

两套模型接不上。想复用旧运行时,就得给新前端的图补出端口信息,而补不出来——原始数据里没有。

照抄的是编排基建。 队列、租约、心跳、恢复循环、计费包装,这些全部沿用旧引擎的设计。理由写在括号里:「已验证过」。

具体到实现,这一部分是逐项搬的:

机制旧引擎新实现
队列名canvas-workflow-runscanvas-graph-runs
租约时长60 秒60 秒
心跳间隔15 秒20 秒
恢复循环周期30 秒30 秒
停滞判定300 秒(队列里待太久就重新入队)
入队幂等jobId = runId
计费包装台账状态机 planned → charged → settled / refund
单副本并发2(生产环境 20)

租约、心跳、恢复这一套要解决的问题是:调度进程崩了怎么办。做法是让「正在执行」这个状态带一个有效期,执行者定期续期;一旦停止续期,另一个实例就能接管。

计费包装那层解决的是另一件事:调度器不许碰钱。节点执行时通过一个包装过的客户端扣费,包装层记台账,状态机的每一步都落库,于是「扣了没结算」「退了没记」这类状态可以事后对账。

三、保住的边界

脱钩之后,第一期的范围写成了五条目标:

  1. 画布工具栏新增「运行」:全图运行;选中节点时为「运行到此节点」(只跑该节点及其未完成上游)。
  2. 服务端 Worker 执行:关浏览器照样跑完,重启可恢复,不重复执行、不重复扣费。
  3. 节点级状态可视:排队 / 运行中 / 完成 / 失败角标 + 顶部运行进度;失败节点展示可读错误与重试。
  4. 运行记录落库:快照、每节点状态、产物、计费关联可追踪。
  5. 结果对账:用户不在线时产生的结果,下次打开画布自动回填。

第二条是这一期的核心。「关浏览器照样跑完」意味着执行必须在服务端,不能在客户端。这也解释了为什么不复用上游的调度——它是给「用户坐在画布前逐个点节点」设计的,那种形态下浏览器就是执行环境。

非目标同样是四条,其中第三条最长:

  • 不做多人协同、不做可配置并发上限(沿用「就绪即并发」)。
  • 不引入端口 / 类型校验 UI——连线保持上游的松散语义(这是保留该前端的核心理由)。
  • 不做运行详情独立页面(进度浮层 + 节点角标够用,详情页二期)。
  • 旧自研画布引擎不删除、不改动,仅作参考代码。

第二条是这次决策的边界线。脱钩的是「跟随上游」,不是「保留前端」——保留前端的理由正是它的松散连线,那么这条不能反悔。如果哪天要给连线加类型校验,等于承认保错了,得回到第一天重选。

第四条是给旧引擎的处置:不删、不动。它现在的作用是参考代码——编排基建那部分是照着它抄的。

四、崩溃与重复执行怎么处理

服务端执行最容易出问题的地方是「执行者中途没了」。设计里分成几条路:

  • worker 崩溃:租约过期,另一个实例接管。
  • 取消:运行置取消,待执行的节点一并置取消;正在跑的视频任务走既有的取消链路。
  • 不自动重跑:租约过期后接管的实例只做对账,不重新执行节点。要重跑由用户手动点重试。这一条是「一期从简」,因为自动重跑要先解决「上次到底跑没跑完」。
  • 不双扣:每个节点有一个幂等键,任何路径都不产生第二次扣费。

验收标准里有三条是直接对着这些路径写的:

点运行后关闭浏览器,重开画布可见运行完成且产物就位
删除 worker 进程后运行继续完成,无重复扣费
重复点运行(同一请求 id)不产生第二个 run

第二条用的是「删掉进程」这种验证方式,而不是注入一个异常。前者更接近真实故障。

五、纪律怎么废

废止一条纪律,落到文件上是一组具体动作。

登记文件的标题从

# canvas-web 上游同步记录

改成

# canvas-web 上游引入历史(已脱钩,存档)

「上游同步流程」那一节标为已废止,原来的频率说明用删除线划掉,后面加一句「已脱钩,不再执行」。改动清单那一节标题加上「已停止维护」,原来的登记要求划掉,留一句「以下为脱钩前的引入期改动存档」。

引入时那句「前端代码原样保留」也加了限定:「引入初期前端代码原样保留」。

这件事单独占一个实施任务,任务描述里写清了改哪几句、怎么改。把「废止纪律」当成一项要交付的工作,是因为纪律如果只在新文档里宣布作废、旧文件还挂着原有的要求,下一次有人翻到旧文件就会照它做。

六、这不是朝令夕改

隔一天改主意,看上去像反复。区分点在于被推翻的是哪一条

前一天定的三条里,两条一直没变:前端全量保留、不修改它的设计与交互。变的是第三条「定期人工同步上游」。

而这条之所以要变,是因为另一件事在同期确定了:执行要放在服务端,做成 DAG 调度。这件事一旦定下来,同步的性质就变了——从「拿上游的新功能」变成「不断解决我和上游之间的语义冲突」。

「引入的目标已经达成」这种说法我没有写在当时的文档里,事后也不想补上去。当时的书面理由就是那条收益成本判断:产品形态与上游哲学分叉之后,同步收益趋近于零,冲突成本持续上升。

七、脱钩之后

值得一提的是后面发生了什么,因为它验证了这条判断的方向。

这套 fork 出来的前端后来又经历了两轮变化,最后整个 fork 被移除——前端改由自研重写,队列名从 canvas-graph-runs 换成了 canvas-flow-runs

所以「脱钩」不等于「稳定了」。它只是把一件事确定下来:这个模块从此按自有代码演进,好坏都由自己承担。

而 07-23 那天真正保住的,是判断里的另一半——编排基建照抄旧引擎。队列、租约、恢复、计费台账这套模式一直用到了今天的画布运行上。脱钩的是前端血缘,不是自己的工程积累。

这两件事分开看,是那一天最值得记的部分:丢掉的是「别人的演进」,留下的是「自己验证过的东西」。判断的依据不是血缘远近,是有没有跑过。

星野的头像

星野 XINGYE

全栈工程师。这里记录 82 篇复盘:24 份故障档案、OTA、架构演进与工作流。