我做自动开发平台有一阵了。最早是纯 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 跑完。等 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,而且整个过程在 fcntl.flock 排他锁内完成。

CAS 就是 compare-and-swap。白话说是:先看一眼现在的值是不是我预期的那个,是才改,不是就不动手。幂等的意思是同一件事重复做多少遍,结果都跟做一遍一样。

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 出于安全,把 executeCommand 和 localFileTrigger 加进了默认禁用名单。源码里写作 DISABLED_NODES = ['n8n-nodes-base.executeCommand', ...],导入工作流会报 Unrecognized node type。解法是 ~/.n8n.env 加 NODES_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,欢迎拍砖。