我做自动开发平台有一阵了。最早是纯 Python 的 ai-factory,把调度、决策、收尾全塞进一个 daemon,跑得动但是黑盒——改一个智能体的激活条件就得翻代码。后来引入 n8n 当可视化前端,但第一版集成犯了个根本错误:让 n8n 的 Execute Command 节点阻塞等 pi 跑完(一个任务几十分钟),n8n 一重启就留下一堆 pi 孤儿进程没人收尾。ai-dev-platform 是我把这条链路彻底重做后的产物,它围绕一句判断展开——n8n 永远不等 pi

这篇讲清楚这套三层架构为什么这么切、「永远 exit 0」背后踩了什么坑、以及它和我另一个纯 Python 项目 ai-factory 在哲学上为什么恰好是相反的两极。适合正在搭自动开发流水线、或者想把 n8n 当智能体调度层的人读。先坦白一句:这是极早期的项目,仓库目前只有 1 个 commit(init: 从 n8n 项目独立出 ai-dev-platform),代码约 2400 行、6 个 Python 文件,是刚从一个更大的 n8n 实验里固化成独立仓库的形态,文档比代码厚。

一、核心洞察:n8n 永远不等 pi

旧架构里,spawn.py fork 一个 watcher 子进程,watcher 调 proc.wait() 阻塞等 pi 跑完,然后在 Python 里一口气做完 push、开 PR、打标签、记 failure。n8n 那边的 Execute Command 节点就这么挂着,一个 execution 挂几十分钟。问题在于:n8n 是会重启的(升级、systemctl 重拉、OOM),一旦重启,挂在 execution 上的 watcher 逻辑直接断了,pi 还在后台跑,但已经没人会去收尾——孤儿进程 + 状态丢失。

新版的核心动作很简单:每轮定时器触发时,看一眼状态、做一件原子事、立刻结束。激活定时器查 query_state,能派活就 claim + spawn_agent,然后结束(pi 在后台跑);收尾定时器调 reap 推进状态机、调 finish_pr 收尾,然后结束。spawn_agent 是秒回的——它 fork 一个”瘦 watcher”在后台等 pi 并写一个 {token}.exit 文件,父进程登记完 active.json 立刻 emit 返回。pi 跑完(几十分钟后)的下一轮收尾定时器,自然会读到 exit 文件、发现 phase=exited、接着做收尾。

激活定时器(每30秒)            收尾定时器(每30秒)
  → query_state                  → reap(读 exit_file 推进 phase)
  → IF(可派?)                     → IF(有 exited 待收尾的?)
  → claim + spawn_agent           → finish_pr(按 phase 续作)
  → 结束(秒回,pi 后台跑)        → 结束

最大延迟就是一个定时器间隔(30 秒),零阻塞。n8n 重启无所谓——状态全在 active.json 的 phase 字段里,下轮定时器接着干。这是整个架构能成立的地基。

Important

异步的关键不是”用回调或协程”,而是不让编排层持有任何长生命周期的事务。n8n 的 execution 是短脉冲,不是长连接;pi 的执行状态被显式地物化到磁盘(active.json + {token}.exit),编排层每次只是无状态地扫一眼。状态外化,编排无状态——这是所有”重启不丢”系统的共同点。

二、三层架构:编排、能力、配置各归其位

┌──────────────────────────────────────────────────────────────┐
│  编排层(n8n 画布)                                            │
│  单工作流 6 个 ScheduleTrigger:每 5 秒~1 小时看状态          │
│  所有分支、条件、调度在画布上可见可编辑                         │
└──────────────────────────┬───────────────────────────────────┘
                           │ Execute Command 调用原子命令
┌──────────────────────────▼───────────────────────────────────┐
│  原子能力层(Python CLI,tools/)                              │
│  5 个无状态命令:spawn_agent / query_state / claim /           │
│                  finish_pr / reap                              │
│  永远 exit 0,业务结果在 stdout JSON 的 success 字段           │
│  全异步:永不等 pi,秒级返回                                   │
└──────────────────────────┬───────────────────────────────────┘
                           │ 读
┌──────────────────────────▼───────────────────────────────────┐
│  配置层(.AI/,声明式,Git 管理)                               │
│  config.yaml         项目/gitea/git/labels/scheduler           │
│  agents/<role>.yaml  模型/权限/工作目录/超时                    │
│  prompts/<role>.md   系统提示词(决定怎么干活)                 │
└──────────────────────────────────────────────────────────────┘

这三层的边界是硬的。编排层只负责”什么时候、按什么条件、调哪个命令”,不含任何业务逻辑;原子能力层只负责”做一件无状态的事并报告结果”,不含任何调度、分支、等待逻辑;配置层只负责”声明事物长什么样”,是纯数据。我在 AGENTS.md 里把这条规则写成了编辑守则——“不要在 Python 里加调度/等待/分支逻辑,这些属于 n8n 画布”,因为一旦 Python 开始藏着 if/else 决策,画布就又退化成黑盒了。

这套分层带来一个我特别喜欢的扩展性:加一种新 agent = 加一个 YAML + 一个 MD,不改 n8n、不改 Python。比如想加一个”修复 reviewer 反馈”的角色,只要写一份 .AI/agents/fixer.yaml(定义模型、权限、workspace 模板)和一份 .AI/prompts/fixer.md(系统提示词),然后在 n8n 画布上拖一组定时器调 spawn_agent fixer。整个平台的核心代码(那 5 个命令)一行都不用动。我的判断是,当一个系统的扩展点从”改代码”退化成”改配置”,它才真正算平台

三、「永远 exit 0」:一个用血泪换来的约定

整份代码里最反直觉、也最重要的约定,是 common.py 里那个 emit() 函数:

def emit(result: dict) -> None:
    """所有命令的统一输出。
    永远 exit 0(业务结果在 JSON 的 success 字段)。
    仅当命令顶层 try 捕获到 Python 异常时才 exit 1。
    """
    print(json.dumps(result, ensure_ascii=False))
    sys.exit(0)

业务成功是 {"success": true, ...} + exit 0;业务失败(issue 已被抢、gitea 不可达、当前无任务)是 {"success": false, "reason": "..."} + 还是 exit 0;只有 Python 自己崩溃才 exit 1。这看起来很别扭——失败为什么不返回非零退出码?

原因是 n8n 的 Execute Command 节点对 exitCode != 0 的处理是直接判 “Command failed” 并中止整条分支。如果”issue 已被抢”这种日常业务态用 exit 1 表达,n8n 收尾定时器这一轮就会整个挂掉,画布上是一片红。这在对抗性审查里被列为两个 deal-breaker 之一(评审原话叫”emit 退出码语义错误”)。所以业务结果必须塞进 JSON 的 success 字段,退出码留给”进程本身是否健康”,n8n 那边接一个 Code 节点 JSON.parse(stdout) 再用 IF 节点按 success 分流。

// n8n Code 节点:解析 stdout(每个 Execute Command 后都要接一个)
const out = $input.first().json.stdout;
try { return [{ json: JSON.parse(out) }]; }
catch(e) { return [{ json: { success: false, error: 'parse: ' + e.message } }]; }

这条约定之所以重要,是因为它把”业务失败”和”系统故障”彻底解耦了。业务失败是可预期的、高频的、不应该中断流程的(这一轮没活干,下一轮再来);系统故障才是真异常。混在一起用退出码表达,编排层就永远在猜”这个 exit 1 到底是没活干还是 Python 炸了”。

四、phase 状态机:用 CAS 把幂等做实

每个 job(一次智能体执行)在 active.json 里有一个 phase 字段,流转规则如下:

spawn_agent ──> spawned(pi 后台跑)
     │
瘦 watcher(pi 结束)──写 {token}.exit(exit_code/dirty/commits)
     │
reap ──读 exit_file──> exited
     │
finish_pr(worker):  exited ──push──> pushed ──create_pr──> pr_created
                                                 ──标签流转──> labeled ──> done
finish_pr(reviewer): exited ──读 verdict──> done(APPROVED 合并 / REQUEST_CHANGES 评论 / REJECT 关 PR)
finish_pr(scout):    exited ──────────────────────────────────────> done
     │
reap ──清理 done/failed 的 job(移出 active.json + 删文件)
失败任意一步 ──> failed

这套状态机之所以能扛住 n8n 多定时器并发,靠的是另一个 deal-breaker 的修法——所有 phase 推进都用 CAS(compare-and-swap),在 fcntl.flock 排他锁内完成。n8n 默认不做”上一轮没跑完就不触发下一轮”的重叠保护(环境变量 N8N_SCHEDULED_EXECUTION_DEDUPLICATION_ENABLED 默认 false),5 秒高频下,上一个 finish_pr 还在等 gitea(远程 API 慢),下一个又触发了,两个 execution 同时想推进同一个 job 的 phase。评审原话:“5 秒间隔 + finish_pr 慢 → 这种重叠是常态,不是边缘情况”。

update_job() 的实现是典型的 CAS 模式:锁内读当前 phase,mutator 判断符合预期才改,不符合返回 None(说明别人已经推进了,本次跳过):

def update_job(project_dir, token, mutator):
    with active_lock(project_dir):           # fcntl.flock(LOCK_EX)
        data = _read_active_unlocked(project_dir)
        for idx, j in enumerate(data["jobs"]):
            if j.get("token") == token:
                new_j = mutator(j)           # CAS:mutator 内判 phase 是否符合预期
                if new_j is not None:
                    data["jobs"][idx] = new_j
                    _write_active_unlocked(project_dir, data)
                return new_j

光 CAS 还不够,因为 create_pr 这类操作是非幂等的(开两次就是两个 PR),所以在它前面还要加一道 per-job 领单锁 try_job_lock(非阻塞 trylock,拿不到说明别的进程在处理这个 job,直接跳过)+ find_pull_by_head 查重。三重防护(CAS 推进 + 领单锁 + n8n dedup 环境变量)叠加,才把并发安全做实。我的判断是,幂等不能靠”我小心一点”,必须靠状态机 + 锁的机械保证——任何依赖”程序员不会写错”的并发设计,迟早会被 n8n 的定时器打穿。

五、n8n 集成的四个坑(前期同步方案的遗产)

ai-dev-platform 现在是全异步的,但它脱胎于一个更早的同步集成方案。那阶段我在 n8n 调 pi 上踩了四个坑,每一个都直接塑造了现在的设计:

坑一:n8n v2 默认禁用 Execute Command 节点。 n8n 2.0 出于安全把 executeCommandlocalFileTrigger 加进了默认禁用名单(源码里 DISABLED_NODES = ['n8n-nodes-base.executeCommand', ...]),导入工作流会报 Unrecognized node type。解法是 ~/.n8n.envNODES_EXCLUDE=[] 重启。这是个部署期必踩的坑,不踩不知道。

坑二:工作流 JSON 的 type 字段前缀写错。 内置节点的 type 是 n8n-nodes-base.<驼峰名>,没有 @n8n/ 前缀。写成 @n8n/n8n-nodes-base.xxx 导入后节点全显示问号。

坑三:Execute Command 的 exec maxBuffer 只有 1MB。 n8n 的 Execute Command 节点用 Node 的 child_process.exec,默认 maxBuffer = 1MB,且要等子进程完全结束才回调。而 pi --mode json 的流式 NDJSON(含 thinking delta)轻松上 MB,直接调会 stdout maxBuffer length exceeded 卡死。这就是为什么不能让 n8n 直连 pi——中间必须隔一层只吐几百字节最终 JSON 的封装。

坑四(最隐蔽):pi 在 n8n session 里会卡死。 命令行 7 秒返回的 pi,从 n8n 调 30 秒超时都跑不完。排查到最后发现是 pi 启动时连 intercom 会话系统,和已活跃的 pi 终端存在会话/锁竞争。解法是 setsid 让 pi 完全脱离调用方 session(Python 里就是 subprocess.Popen(..., start_new_session=True))。

这四个坑里,坑三和坑四直接决定了 ai-dev-platform 的形态。坑三的解法在同步方案里是”隔一层 pi_call.py 只吐小 JSON”;到了异步方案,干脆让 pi 的 MB 级输出根本不经过 n8n——spawn_agent fork 的瘦 watcher 直接 proc.wait() pi 并把结果写进 {token}.exit 文件,n8n 只看 spawn_agent 吐的那一行几百字节的 JSON。坑四的 start_new_session=True 则原封不动地留在了 spawn_agent 里。早期踩的坑不会消失,它们会固化成架构的形状

六、编排回归 n8n 可视化:一次刻意的取舍

把调度逻辑从 Python 搬回 n8n 画布,是这套架构的核心取舍,也是我反复犹豫过的一步。旧版的 spawn.py 是个大黑盒:fork watcher、git push、开 PR、打标签、记 failure 全藏在 Python 里,n8n 工作流只有”触发→跑脚本”一条线,看不到业务流程。想加一个 worker、改一个激活条件、插一个通知节点,都得改 Python 代码。

方案代价为何不选
调度留在 Python daemon改激活条件要改代码、重启;流程不可见黑盒,违背”编排应在画布”的初衷
调度放进 n8n 画布画布节点多(30+)、stdout 要手动 JSON.parse可控性优于封闭的便利,这是我要的
混合(重逻辑在 Python,轻分支在 n8n)边界模糊,迟早滑回黑盒最差的选项,责任不清

我选了第二种。代价是真实的:一个 n8n 工作流塞了 6 个 ScheduleTrigger 起点、30 多个节点,每个 Execute Command 后面都得跟一个 Code 节点做 JSON.parse(stdout)(因为 n8n 的 stdout 是字符串,不是结构化对象),重复且丑。但换来的是所有调度逻辑在画布上一眼可见、可拖拽修改——worker 30 秒激活一次、reviewer 60 秒、scout 5 分钟、planner 1 小时,这些间隔和条件全是画布上的节点参数,不用碰代码。

值得对照的是我另一个项目 ai-factory。它走的是完全相反的路:调度、决策、收尾全在一个 Python daemon 里,没有 n8n,没有画布。两种哲学各有各的道理——ai-factory 适合”我自己懂代码、要极致控制力、不想依赖 n8n 这个外部组件”的场景;ai-dev-platform 适合”我要可视化、要能让别人看懂流程、能接受 n8n 这个依赖”的场景。隔离优于复用——我宁可维护两套调度逻辑,也不想在 ai-factory 里硬塞一个 n8n 适配层。它们共享的只有 GiteaClient 这一个类(ai-dev-platform 直接 sys.path.insert 进 ai-factory 复用),调度层各走各的。

七、可迁移的几条原则

把这套架构抽象一下,有几条我认为可以迁移到别的”可视化编排 + 智能体”系统里:

第一,编排层永远不要持有长事务。n8n 的 execution 是短脉冲,长任务的状态必须物化到磁盘(这里的 active.json + {token}.exit),编排层每次无状态地扫一眼。这条原则适用于任何会重启的调度器——Airflow、Temporal、n8n 都一样。

第二,业务失败和系统故障必须用不同通道表达。业务失败走数据(JSON 的 success 字段),系统故障走退出码(exit 1)。混在一起,编排层就永远在猜。这就是”永远 exit 0”的本质。

第三,幂等靠状态机 + 锁,不靠程序员的小心。任何可能被并发重入的操作(尤其是非幂等的 create_pr),都要有 CAS 推进 + 领单锁 + 查重三重防护。少一重都会在某个高频场景下被打穿。

第四,扩展点应该是配置,不是代码。当一个系统的扩展方式从”写一个新 Python 类”退化成”写一个 YAML + 一个 MD”,它才从”脚本”变成了”平台”。ai-dev-platform 的 5 个原子命令是稳定的内核,YAML/MD 是变化的边缘。

最后,早期踩的坑会固化成架构。setsid 防 session 竞争、隔一层防 maxBuffer、exit 0 防 n8n 中止分支——这些都不是预先设计出来的,是被 n8n 的实际行为逼出来的。所以与其在白板上画完美架构,不如早点把链路打通、踩一遍真实的坑。

八、诚实的局限

说完优点,必须说清楚这个项目现在到底处在什么阶段。仓库目前只有 1 个 commit,是刚从一个更大的 n8n 实验项目里独立出来的形态。代码约 2400 行、6 个 Python 文件(其中 common.py 588 行是公共模块,finish_pr.py 接近 800 行是状态机收尾),文档的完整度远高于代码的成熟度——这是我的习惯,先把架构和契约写清楚再动手,但反过来也意味着很多设计还停留在”文档里成立”的阶段。

已经验证的只有 worker 全链路(在 test-demo 仓库真实跑通了 spawn→reap→finish_pr→开 PR→合并),scout、reviewer、planner 三个角色只做了代码审查、还没各跑一轮冒烟。n8n 工作流 JSON 已导入数据库但 active=false,多定时器并发在 UI 里的实际行为还没长期观察过。失败路径(pi 崩溃→failed 标签→reap 清理)测过,但 REQUEST_CHANGES 和 REJECT 这两个分支只做了代码走查。还有一些硬编码(pi 路径写死 /home/liangruochong/.pi/agent/bin/pi、复用了 ai-factory 的 GiteaClient),意味着现在它只在我的机器上能跑,离”复制给别人用”还有距离。

所以更准确地说,ai-dev-platform 现在是一个架构已经定型、核心链路已验证、但尚未长期运行检验的项目。我把它写出来,不是为了说”这套方案已经生产就绪”,而是为了把这套”n8n 永远不等 pi”的取舍和踩过的坑记录下来——如果你也在搭类似的东西,希望这些经验能帮你少走几步。代码在 git.ruochongliang.top/lrc/ai-dev-platform,欢迎拍砖。