# ADR-0010: 引擎 module path 子模块化 flyto-agent -> flyto-agent/core 支持外部 go get - **Status**: Accepted (PM 拍板 2026-05-28) - **Date**: 2026-05-28 - **Deciders**: PM (产品经理) + Flyto Agent core team - **Related code**: `core/go.mod`, `platform/common/go.mod`, `tui/go.mod`, `go.work`, `core/docs/consumer-onboarding.md`, `.gitea/workflows/release.yml` - **Related TODO**: 引擎对外 SDK 化 (中期计划, CLAUDE.md) 的基础设施前置 - **Related ADRs**: ADR-0005 (引擎中性化 - 公共 API 纪律是引擎可被外部消费的前提), ADR-0007 (capability tracking - 引擎作为库对外暴露后, intake 纪律门槛上升) - **Commit chain**: `ea217df` (单 refactor commit, module path + 288 .go import + 3 go.mod + 14 .md) + 本 ADR --- ## 1. 背景 / Context ### 1.1 新消费者触发 - 引擎要被当 Go 库 go get 出现新消费者 (公司内部同 team 同事, 用 Codex 开发), 要把引擎当 Go 库消费. 这是第一次有人尝试在我们的 monorepo 之外, 用标准 `go get` 拉引擎. 一拉就失败. 真因: 引擎物理在 `Flyto-Agent` 仓库的 `core/` 子目录里, 但 `core/go.mod` 的 module path 写的是 `git.flytoex.net/yuanwei/flyto-agent` (没有尾部 `/core`). Go 的硬规则是 import path = module path = repo path [+ 子目录], 三者必须对齐. module path 跟 repo 实际物理路径对不上, 外部 `go get git.flytoex.net/yuanwei/flyto-agent` 必然解析失败. ### 1.2 为什么这问题一直被掩盖 现有消费者 (FlySafe / platform/common / tui) 从来没真正 `go get` 过引擎. 它们全靠 monorepo 的 `go.work` + `replace ../core` 本地联合编译: `go.work` 用相对路径 (`use ./core`), `replace` 指向本地目录, 两者都**绕开了 module path 解析**. 只要在同一棵 working tree 里, module path 写错也无所谓 - 没人走网络 go proxy 路径拉过引擎. 错误的 module path 就这么潜伏到第一个真外部消费者出现才暴露. ### 1.3 这步同时是 SDK 化前置 CLAUDE.md 记的中期计划是引擎对外 "SDK 化解耦" (FlySafe 现在 `replace` 直接消费, 计划改 require stable tag). 让外部能 `go get` 是这条路径上绕不开的第一块基础设施 - 不解决 module path, 后面的 stable tag / SDK 包都无从谈起. --- ## 2. 决策 / Decision module path 改为 `git.flytoex.net/yuanwei/flyto-agent/core`, 走 Go 官方 multi-module monorepo 子目录形态, 让外部消费者能直接 `go get`. ### 2.1 module path 必须带尾部 /core (不能去掉) `core/go.mod` 的 module 行从 `git.flytoex.net/yuanwei/flyto-agent` 改为 `git.flytoex.net/yuanwei/flyto-agent/core`. 尾部 `/core` 是硬约束, 不能去掉: 引擎物理在 repo 的 `core/` 子目录, Go 要求 module path 反映完整路径 (repo root + 子目录). 去掉 `/core` 就回到 § 1.1 的错位. 这是**为什么必须长这样**, 防止将来误改回短形式. ### 2.2 全小写, 不用 Flyto-Agent/core 大写形态 module path 用全小写 `flyto-agent/core`, 不用仓库名的 `Flyto-Agent/core` 大写形态. 两个原因: 1. Go module path 含大写字母时, 在 module proxy 路径里要做 `!` 转义 (e.g. `Flyto` -> `!flyto`), 给消费者制造无谓的认知负担和工具兼容坑. 2. Gitea 仓库名解析大小写不敏感 (见 § 3.1 实测), 小写 `flyto-agent` 能正确解析到现有 `Flyto-Agent` 仓库. 全小写既合 Go 惯例又能解析成功. ### 2.3 go module 版本 tag 带 core/ 前缀 go module 版本 tag 必须带 `core/` 前缀 (e.g. `core/v0.5.0-alpha.25`). 这是 Go multi-module 子目录的**强制规则**: 子目录里的 module, 其版本 tag 必须是 `子目录/vX.Y.Z` 形态, 否则 go 不认这个版本属于该 submodule. 不是我们的选择, 是 Go 工具链的要求. ### 2.4 两套 tag 序列独立共存 (最易被误改, 锁死) 仓库里现在有**两套独立的 tag 序列**, 共存不冲突, 严禁混淆: | tag 形态 | 用途 | 触发 | |---|---|---| | `v*` (e.g. `v0.5.0`) | 仓库部署 tag | 触发 `.gitea/workflows/release.yml` 的 build + push image + SSH HK-133 deploy | | `core/v*` (e.g. `core/v0.5.0-alpha.25`) | go module 版本 tag | **不触发任何部署** | 关键约束: `release.yml` 的 trigger glob 是 `v*`, **不匹配** `core/v*` (glob 不跨 `/` 字面前缀). 所以打 go module tag 不会误触发一次生产部署, 打部署 tag 也不会被 go 当成 module 版本. 两套序列必须保持独立, 不要试图统一成一套 - 统一会让 "发一个 go module 版本" 和 "部署一次生产" 这两件正交的事互相绑死. ### 2.5 版本契约 (向消费者承诺) 引擎对外承诺: `pkg/flyto` + `pkg/engine` + `pkg/config` 三个公共包在 `v0.5.x` 周期内**只增不改** (不删字段 / 不改签名). `internal/` 随时重构, 不进契约. 这给外部消费者一个可依赖的稳定面, 同时给引擎内部保留迭代空间 - 跟 ADR-0005 引擎中性化 + 公共 API 纪律一脉相承. --- ## 3. 替代方案 / Alternatives 消费模式分两层决策: (1) 消费者用什么开发形态; (2) 独立 repo 怎么依赖引擎. 两层各有子选择. ### 3.1 消费者 fork 整个 monorepo (rejected) **做法**: 消费者 fork `Flyto-Agent` 整仓. **否决**: fork 到的是引擎 + platform + tui + 部署全套, 消费者只要引擎一块. 且引擎天天迭代, fork 后持续 merge 上游极痛苦 (跟无关的 platform / 部署改动一起冲突). ### 3.2 消费者在我们 monorepo 里建子目录开发 (rejected) **做法**: 让消费者在 `Flyto-Agent` repo 里开一个子目录写业务代码. **否决**: 业务代码混进引擎 monorepo, 撞共享 working tree, 被引擎日常迭代搅动, CI / 版本边界乱成一团. 引擎 monorepo 不该承载消费者的业务代码. ### 3.3 消费者独立 repo + 引擎当外部库 (accepted - 消费模式层) **做法**: 消费者维护自己独立的 repo, 把引擎当外部依赖库引入. **采纳**: 这是消费模式层的决策. 干净的边界 - 消费者业务归消费者, 引擎归引擎, 通过版本化依赖耦合. § 3.4 进一步决定 "独立 repo 怎么依赖引擎". ### 3.4 独立 repo 依赖引擎的子选择 #### 3.4a 同名 mirror repo (rejected) **做法**: 新建一个叫 `flyto-agent` 的 repo, 把 `core/` 内容 subtree split 过去, import 路径零改动 (mirror 的 module path 就是 `flyto-agent`). **否决 (实测)**: Gitea 仓库名解析**大小写不敏感** - `flyto-agent` (小写) 和 `Flyto-Agent` (大写) 返回**同一个 repo** (实测 Gitea API 返回 id 23). 同名 mirror 物理上建不出来, 一建就撞现有 monorepo. 此方案被实证直接堵死. #### 3.4b git submodule + go.mod replace 本地消费 (备选保留) **做法**: 消费者把引擎作为 git submodule 嵌入, `go.mod` 用 `replace` 指向本地 submodule 路径 (纯本地依赖, 不走网络 go proxy). **保留为备选**: 可行, 但要消费者多几步 (submodule init / update / replace 维护), 不是 `go get` 直通. 退化为 "改引擎源码调试 / 离线开发" 场景的备选路径 (见 `consumer-onboarding.md`). #### 3.4c module path 子模块化 + core/ 前缀 tag (accepted - 本 ADR) **做法**: 见 § 2. Go 官方 multi-module monorepo 方案. **采纳**: 唯一能让外部 `go get` 直通 + 不新建 repo + 不破坏现有 monorepo 的方案. 落地成本是一次性 import 批量改 (§ 7), 之后消费者标准 `go get` 即可. --- ## 4. 影响 / Consequences ### 4.1 得益 1. **外部 go get 直通**: 新消费者 (含未来 SDK 消费者) 可标准 `go get git.flytoex.net/yuanwei/flyto-agent/core@core/vX`, 不必 fork / submodule / 进 monorepo. 2. **SDK 化前置就位**: 中期 "引擎对外 SDK 化" 的第一块基础设施落地 (CLAUDE.md 记的计划). 3. **版本契约可依赖**: `pkg/flyto` + `pkg/engine` + `pkg/config` v0.5.x 只增不改, 外部消费者有稳定面. 4. **现有消费者零行为变化**: go.work 用相对路径不动, monorepo 内 `replace` 路径不变, 本地联合编译照旧. ### 4.2 不便 / 代价 1. **跨仓 import 跟改 (FlySafe)**: FlySafe 是另一个 repo (在 LA 那台机器), 也消费引擎. module path 变了它的 `require` + `replace` + `import` 要跨仓跟改, 由 PM 协调 (本 ADR 不在 FlySafe 仓内落地). 2. **墙内 sumdb 坑**: 公共依赖 (`x/image` / sqlite 等) 的 checksum 校验走 `sum.golang.org` 在墙内不可达. 消费者必须 `GOSUMDB=off` + `GOPROXY=goproxy.cn`. 注意 `GOPRIVATE` 只覆盖私有包 (我们的引擎), **不覆盖**这些传递公共依赖的 sumdb 校验 - 这是易踩的细节, `consumer-onboarding.md` 已记完整 recipe. 3. **两套 tag 序列认知成本**: 维护者需要分清 `v*` (部署) 和 `core/v*` (go module) 两套 tag, 见 § 2.4. 误打错前缀会要么不触发部署 (期望部署时), 要么 go 不认版本 (期望发 module 版本时). ### 4.3 跟 ADR-0005 引擎中性化的关系 - 一脉相承 ADR-0005 把引擎从 "Claude Code 再造" 重定位为中性 transport + 公共 API 纪律. 引擎能被外部干净消费, 本就是中性化的目的之一. 本 ADR 把这个能力从 "理论上中性" 兑现成 "物理上 go get 得到 + 有版本契约". § 2.5 的公共包契约是中性化公共 API 纪律的延续. --- ## 5. 验证 / Validation ### 5.1 go-import meta 实测 (multi-module 子目录解析成立) ``` curl 'https://git.flytoex.net/yuanwei/flyto-agent/core?go-get=1' ``` 返回的 `go-import` meta 的 import-prefix 是 `git.flytoex.net/yuanwei/flyto-agent` (repo root). go 据此 clone repo, 然后在 `core/` 子目录里找 module + `core/vX` tag, 解析成立. 这是 Gitea 上 multi-module 子目录方案能跑通的经验证据. ### 5.2 空目录 go get 拉通实测 空目录里 `go get git.flytoex.net/yuanwei/flyto-agent/core@core/v0.5.0-alpha.25` 实测拉通 (配 `GOPRIVATE` + `GOSUMDB=off` + `GOPROXY=goproxy.cn`). ### 5.3 三 module build + test 全绿 `ea217df` 落地后, 三 module (core / platform/common / tui) `go build` + `go test` 全绿. core 2 个 fail (checkpoint nil deref + macOS `/private/var` symlink) 经 `git stash` 对照确认是 pre-existing 环境问题, 非 rename 引起; quotedispatch 2 个 JSONSchema fail 是 ADR-0008 v3.3 禁 wire 的已知遗留. 均与本次 module path 改动无关. --- ## 6. 触发重新评估的条件 / Trigger conditions 1. **Gitea 升级改变大小写解析**: 若未来 Gitea 版本改为大小写敏感, § 2.2 小写解析到 `Flyto-Agent` 的前提失效, 重新评估 module path 大小写 / 是否真要建独立 mirror repo. 2. **release.yml glob 演进**: 若 `release.yml` trigger 改成更宽的 glob (e.g. `**`) 误匹配 `core/v*`, 打 go module tag 会误触发部署 - 重新审 § 2.4 两套序列的隔离. 3. **多个外部 module 出现**: 若 `platform/common` / `tui` 也需要被外部 go get (变成第二 / 第三个对外 submodule), 重新评估 tag 命名空间 (`common/v*` / `tui/v*`) 与 release.yml 隔离规则. 4. **公共包契约被迫破坏**: 若 v0.5.x 内 `pkg/flyto` / `pkg/engine` / `pkg/config` 出现不得不改签名 / 删字段的硬需求, 触发 minor 版本号跳跃 (v0.6) + 消费者迁移协调, 重审契约边界 (§ 2.5). 5. **SDK 包独立化**: 中期 SDK 化若决定把对外 surface 收敛到独立 `sdk/` 子目录 (而非整 `core/`), 重新评估 module path 是否再分一层. --- ## 7. 工程量 / Engineering footprint | Commit | 内容 | 范围 | |---|---|---| | `ea217df` | module path 子模块化 | 305 文件: `core/go.mod` module 行 + 288 .go import path 批量改 (`flyto-agent/` -> `flyto-agent/core/`, 一次性脚本 sed 前缀替换) + `platform/common` + `tui` go.mod 的 require/replace 跟改 + 14 .md 文档同步 | | 本 ADR | 决策记录 | doc | **关键约束**: 批量 sed 只碰带域名 + 尾斜杠的标准 import 前缀 (`git.flytoex.net/yuanwei/flyto-agent/`), 不碰 error message 前缀 / MCP client name / 业务字符串 / 注释里的短形式. `go.work` 不动 (use 相对路径不含 module path). `consumer-onboarding.md` 翻转: `go get` 主推 (GOPRIVATE + `core/vX` tag), submodule 降为 "改引擎源码调试 / 离线" 备选. --- ## 8. 修订记录 / Revision history - **v1.0 (2026-05-28)**: 初版. 新消费者 (内部同 team, Codex 开发) 触发. module path `flyto-agent` -> `flyto-agent/core` 走 Go 官方 multi-module monorepo 子目录形态. 否决 fork / monorepo 子目录 / 同名 mirror (Gitea 大小写不敏感堵死) 三方案, 采纳子模块化 + `core/v*` tag. 两套 tag 序列独立共存 + v0.5.x 公共包只增不改契约锁定. 单 refactor commit `ea217df` 落地, go-get meta + 空目录 go get 实测拉通.