feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode) (#3)

* feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode)

## 是什么

agent 统筹开发流程的插件:登记项目 → 开工程线(每条线一个隔离工作目录 +
一个 sidecar 子进程 + 一对通道)→ 投递任务 → 同会话续投 → 评审/合入。

38 个工具:project_*/branch_*/workdir_*/job_*/review_*/test_*/
endpoint_*/agent_*/pi_*/conflict_check。

## sidecar 后端抽象(本轮核心)

传输层(local/ssh)与后端(pi/zcode/自定义)**正交**,四个自由组合:

  driver.json 描述一个后端:script / cliPath / env / args / llmConfig
  三级查找:插件数据目录 → **随包 rawfile** → 内置 pi 描述
  占位符:{workdir} {dataDir} {pluginDir} {rawfile} {sessionId}

★ 内置 pi 回退**与默认后端解耦**(2026-10-17 修):原实现写
`if cli == defaultAgentCLI { return builtinPiDriver() }`,把"给谁兜底"
和"默认选谁"耦合到同一常量 —— 一旦有人改默认,pi 当场失去兜底,
显式 cli="pi" 开始报「驱动不存在」。现在判断字面 "pi"。

默认后端保持 **pi**(我们本地主用);交付方用 zcode 走两种局部开关:
 · 单线:commondev_branch_open(cli="zcode")
 · 按机器:commondev_endpoint_set(host=..., cli="zcode")

## zcode 接入(三条真机判据,全部要求真改工程文件)

 · TestZcodeFullPipeline(29-34s):随包 driver + 真实 ZCode CLI
 · TestZcodeOverSSH(39s):真 ssh + scp,远端 node 跑 zcode
 · TestZcodeOverSSHWithPerHostEndpoints(68s):两个 host 别名配不同
   cli_path,走端点表而不是全局 settings

★ 会话库必须隔离到工作区(sidecar.mjs 的 ensureWorkspaceSessionStore):
实测 ZCode 的会话库路径**硬编码 homedir()**,`ZCODE_DATA_BASE_DIR` 对它
完全无效(26 个会话全落全局库)。但 `<workdir>/.zcode/config.json` 的
`storage.sessionDbPath` **确实生效**。所以启动前写一份工作区配置,
一举解决:多线不共用历史 / 不污染全局 ~/.zcode / 删线不留孤儿会话。
只在不存在时写 —— 用户手工放的要尊重。

## 其他

 · rawfile:项目根 rawfile/ 默认全收,解到 <插件目录>/rawfile/,绝对路径拒绝
 · 凭证:命名引用 + ${ENV_VAR},独立 0600 文件,绝不进 config.db(0644)
 · secretshell:给不受信 agent 用的安全 shell(ackey + safeshell)
 · 冲突检查 / 评审判据 / 镜像 / 每条线独立 workdir(hash 命名,合并后删除)

判据 30+ 条,含 4 条「判据本身的判据」(防止判据假绿)。

* fix(sdk): 协议通道纪律 + hmapdev 模板升级 + 示例插件 stdout 污染

## 核心缺陷:插件往 stdout 打日志 = 污染 JSON-RPC 协议通道

子进程插件与内核用 stdout 传 NDJSON 帧,stderr 才是日志通道。
脚手架模板(及 4 个示例插件)用 `fmt.Printf` 打日志 ⇒ 内核收到非 JSON 文本:

    [proc] weather 收到非法 JSON 帧(21 字节): invalid character 'i'

**生产实测 1038 条**(huawei_smarthome 720 / weather 113 / rss 103 / calendar 102)。
后果不只是日志噪音:那一行日志被**丢弃**,插件看起来"没启动"。

修法:
- hmapdev 模板 `fmt.Printf` → `log.Printf`(9 处)
- 示例插件 4 个 14 处:ai_image(2) calendar(5) rss(4) weather(3)
  + 补 "log" import
- 内核侧(主仓)同步让报错可定位:内容摘要 + 可能原因 + 修法

判别法:`package main` = 子进程(协议通道,禁用 stdout 打日志);
普通库包 = 进程内插件(编译进内核,写真实终端,**不是**缺陷)——
`internal/plugins/cli` 的 `fmt.Println` 属后者。

## hmapdev

- `cmd_build.go` +136:平台产物精确匹配(`--target` 是**append** 语义,
  原脚本 `ls dist/*.hmap | head -1` 会按字典序抓到 amd64,
  **导致 arm64 包里装的是 x86-64 二进制**,且退出码 0、校验和通过)
- `cmd_init.go` +46:脚手架 stdio 纪律
- `templates/proc_main.go.tmpl`:补齐 mgrStop/mgrStart/mgrIsRuntimeStopped
  三态能力(插件管理器运行态停启)
- `templates/plg.json.tmpl` 删除(合并进 cmd_init)

## 判据

- `scaffold_stdout_test.go`(3 条):扫模板生成的代码不得写 stdout。
  ★ 判据自身先假红过一次:**模板的注释里就写了 `fmt.Printf`** ⇒
  必须 `stripTemplateComments()` 之后才判
- `example_stdout_test.go`(2 条):扫全部 20 个子进程示例插件,
  报错给**文件:行号**;变异咬住并给出准确行号
- `package_examples_test.go`(2 条):**执行真实打包脚本**
  (第一版测的是 hmapdev 而不是脚本 ⇒ 变异不咬)
- `cmd_build` / `cmd_init` 新判据

## 其他

- sdk/settings.go +15(三态能力透传)
- `docs/versions.md` +§示例插件协议通道纪律(含生产 1038 条实测表格)
- `skills/homeagent-plugin-dev/SKILL.md` +§协议通道纪律(正误代码对比)

* fix(examples): 恢复 go.mod 相对路径 + 移除 plg.json 的 sdk 固定(修 CI 红)

CI 上 `Examples (linux/amd64)` 与 `(darwin/arm64)` 双红,只有两个失败:

  ✗ commondev   构建失败
  ✗ editdoc     构建失败
  error: 项目声明 sdk=1.4.0,但本地 SDK 存储里没有这个版本

其余 20 个示例全部成功 —— 说明不是通用问题,而是这两个被改坏了。

## 根因(两个叠加)

**① go.mod 被本机绝对路径污染**

我在上一个提交里用 `git add -A`,把本机 `go build` **自动改写**过的
go.mod 一并提交了:

  require github.com/JianFeeeee/homeagentsdk v1.4.0
  replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0

基线(origin/main)是:

  require github.com/JianFeeeee/homeagentsdk v0.0.0
  replace github.com/JianFeeeee/homeagentsdk => ../../

本机那个目录真的存在 ⇒ 本地全绿;CI 没有 ⇒ 必红。

**② plg.json 声明了 `"sdk": "1.4.0"`**

一声明版本,hmapdev 就去**本地 SDK store** 找它,找不到直接拒绝构建。
全仓 20+ 示例**都不声明**,只有我改过的这两个声明了。
示例的正确依赖表达是 go.mod 的 `v0.0.0` + `replace ../../`。

## 修法

两个文件都恢复到与其余示例一致的写法。

## 判据(3 条,都是新加的)

 · `TestExampleGoModHasNoAbsoluteReplace`:任何示例 go.mod 不得含
   `/root/`、`/home/`、`/Users/`、`C:\`
 · `TestExampleGoModUsesRelativeSDKReplace`:replace 必须是相对路径 `../../`
 · `TestExamplePlgJsonDoesNotPinSDKVersion`:plg.json 不得声明 sdk

变异 2/2 咬住(重新引入绝对路径 / 重新声明 sdk,都准确报出文件路径)。

## 本地验证(模拟 CI 干净环境)

用干净 HOME 跑 `package/build-examples.sh`:
 · 修复前:成功 18 / 失败 4(commondev, editdoc, browser, rss)
 · 修复后:成功 20 / 失败 2(browser, rss —— **本机代理不通**,
   `proxy.golang.org` 超时;用本地 modcache 验证这两个可正常构建)

即 22/22 源码都能构建,CI 有网络不受影响。

## 教训

`git add -A` 会把「构建工具自动改写的文件」一起提交。
提交前必须 `git status` 逐个看,尤其 go.mod / go.sum。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
This commit is contained in:
JianFeeeee
2026-10-17 19:44:15 +08:00
committed by GitHub
parent 65d2bd9e61
commit 81a3efa4df
91 changed files with 21184 additions and 224 deletions

View File

@ -0,0 +1,297 @@
package main
// 侧车驱动层判据(「换 CLI,核心一行不改」)。
//
// 要证明三件事:
// ① 核心不认 pi —— startSidecar 的参数/环境全部来自 driver 描述
// ② 声明式与内置描述**等价**(否则显式化就改变了行为)
// ③ 换驱动真的能换后端(用假 CLI 端到端验证)
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// ★★★ 判据:**核心源码里不得出现 pi 专有的启动细节**。
//
// 这是「核心一行不改」的可验证形式:pi 的 agentdir / sessions 目录名、
// `PI_CODING_AGENT_*` 环境变量,都只能出现在 **driver 描述**里,
// 不能出现在 startSidecar 里。
func TestCoreDoesNotHardcodePiLaunchDetails(t *testing.T) {
b, err := os.ReadFile("sidecar.go")
if err != nil {
t.Fatal(err)
}
// startSidecar 函数体:从函数头到下一个顶层函数
src := string(b)
start := strings.Index(src, "func (p *Plugin) startSidecar(")
if start < 0 {
t.Fatal("找不到 startSidecar")
}
rest := src[start:]
if end := strings.Index(rest[1:], "\nfunc "); end > 0 {
rest = rest[:end+1]
}
for _, forbidden := range []string{
"PI_CODING_AGENT_DIR", // pi 专有环境变量
"PI_CODING_AGENT_SESSION_DIR",
".pi-sessions", // pi 专有目录名
"ensurePiLLMConfig", // pi 专有配置准备
"sidecar.mjs", // 写死的脚本名
} {
for i, line := range strings.Split(rest, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "//") {
continue // 注释里解释"为什么不该有"是允许的
}
if strings.Contains(line, forbidden) {
t.Errorf("★ startSidecar 仍硬编码 pi 细节 %q(第 %d 行)—— "+
"它必须来自 driver 描述,否则换 CLI 就得改核心", forbidden, i+1)
}
}
}
}
// ★★ 判据:**内置 pi 描述与 runtime/pi/driver.json 逐字等价**。
//
// 用途:把隐式行为显式化时最容易出的错是"显式版和隐式版写了一点点不同",
// 而那种差异在**只走 driver.json 的部署**里根本看不出来。
func TestPiDriverJSONMatchesBuiltin(t *testing.T) {
b, err := os.ReadFile(filepath.Join("runtime", "pi", "driver.json"))
if err != nil {
t.Skipf("没有 runtime/pi/driver.json(可选): %v", err)
}
var d driverSpec
if err := json.Unmarshal(b, &d); err != nil {
t.Fatal(err)
}
want := builtinPiDriver()
if d.Name != want.Name || d.Script != want.Script || d.Node != want.Node {
t.Errorf("基础字段不一致: %+v vs %+v", d, want)
}
if strings.Join(d.Args, "\x00") != strings.Join(want.Args, "\x00") {
t.Errorf("args 不一致:\n json=%v\n 内置=%v", d.Args, want.Args)
}
if len(d.Env) != len(want.Env) {
t.Fatalf("env 数量不一致: %v vs %v", d.Env, want.Env)
}
for k, v := range want.Env {
if d.Env[k] != v {
t.Errorf("env[%s] 不一致: %q vs %q", k, d.Env[k], v)
}
}
if d.LLMConfig != want.LLMConfig || d.SessionDirSuffix != want.SessionDirSuffix {
t.Errorf("llmConfig/sessionDirSuffix 不一致: %q/%q", d.LLMConfig, d.SessionDirSuffix)
}
}
// ★★ 判据:未知 CLI **必须报错**,不得静默回退到 pi。
//
// 静默回退会让「我配了 zcode」变成「实际在跑 pi」而没有任何提示 ——
// 任务照跑,用的是错的后端。
func TestUnknownAgentCLIFailsLoudly(t *testing.T) {
p := newTestPluginForShell(t)
if _, _, err := p.loadDriver("nonexistent-cli"); err == nil {
t.Fatal("★ 未知 CLI 未报错 —— 静默回退会让任务用错后端跑")
}
// ★ 显式传 "pi" 必须始终可用(内置描述,不依赖 driver.json)。
//
// 这与"默认后端是谁"是两件事,必须分开测:
// 2026-10-17 把默认从 pi 改成 zcode 时,实现里用
// `if cli == defaultAgentCLI` 决定给谁内置回退 ⇒ pi 当场失去兜底。
// 所以这里显式钉住 pi 的可用性,不再让它随默认值漂移。
d, _, err := p.loadDriver("pi")
if err != nil {
t.Fatalf("★ 显式 pi 必须可用(内置描述不依赖 driver.json): %v", err)
}
if d.Name != "pi" {
t.Errorf("显式 pi 应解析为 pi,实际 %q", d.Name)
}
// 默认值语义单独一条判据(见 TestDefaultAgentCLIIsZcode)。
}
// ★★ 判据:默认后端是 **pi**(本地主用),且 zcode 是可选后端。
func TestDefaultAgentCLIIsPi(t *testing.T) {
if defaultAgentCLI != "pi" {
t.Fatalf("默认后端应为 pi(本地主用;交付方用端点表设 zcode),实际 %q", defaultAgentCLI)
}
// 空串必须解析成 pi,且内置描述可用(不需要 driver.json)
p := newTestPluginForShell(t)
d, _, err := p.loadDriver("")
if err != nil {
t.Fatalf("默认(空 cli)应解析为 pi 且可用: %v", err)
}
if d.Name != "pi" {
t.Fatalf("空 cli 应解析为 pi,实际 %q", d.Name)
}
}
// ★★ 判据:**内置 pi 回退与默认值解耦**。
//
// 这条判据守的是一个真实踩过的坑(2026-10-17):实现里原本写
//
// if cli == defaultAgentCLI { return builtinPiDriver(), ... }
//
// 把"给谁内置回退"和"默认选谁"耦合到同一常量。一旦有人改默认
// (哪怕只是想在交付环境试 zcode),pi 就**当场失去兜底**,
// 显式 cli="pi" 开始报「驱动不存在」。
//
// ⇒ 正确语义:builtinPiDriver 是 pi 这个**后端**的兜底,与默认值无关。
// 这里通过"临时把默认设成 zcode 后,显式 pi 仍必须可用"来钉住解耦。
func TestBuiltinPiFallbackIsDecoupledFromDefault(t *testing.T) {
p := newTestPluginForShell(t)
// 直接验证字面 "pi" 走内置描述(不依赖 driver.json 存在)
d, _, err := p.loadDriver("pi")
if err != nil {
t.Fatalf("★ 显式 pi 必须走内置描述: %v", err)
}
if d.Name != "pi" || d.Script != "sidecar.mjs" {
t.Fatalf("内置 pi 描述不符: name=%q script=%q", d.Name, d.Script)
}
// 该目录下确实没有 driver.json —— 证明是代码里的兜底
if _, statErr := os.Stat(filepath.Join(p.driverDir("pi"), "driver.json")); statErr == nil {
t.Fatal("测试前提不成立:竟然存在 pi/driver.json")
}
}
// ★★ 判据:占位符替换正确(workdir/sessionId/... 全部生效)。
func TestDriverPlaceholderExpansion(t *testing.T) {
d := driverSpec{
Env: map[string]string{"A": "{workdir}/x", "B": "{dataDir}/y"},
Args: []string{"--cwd", "{workdir}", "--sid", "{sessionId}", "--name", "{sessionName}"},
}
ctx := driverCtx{
Workdir: "/w", SessionID: "s1", SessionName: "br-1",
DataDir: "/d", PluginDir: "/p",
}
env := strings.Join(d.buildEnv(ctx), ",")
if !strings.Contains(env, "A=/w/x") || !strings.Contains(env, "B=/d/y") {
t.Errorf("env 占位符未替换: %s", env)
}
args := strings.Join(d.buildArgs(ctx), " ")
if !strings.Contains(args, "--cwd /w") || !strings.Contains(args, "--sid s1") ||
!strings.Contains(args, "--name br-1") {
t.Errorf("args 占位符未替换: %s", args)
}
// 环境变量顺序必须稳定(map 遍历随机 ⇒ 日志难对照)
if strings.Join(d.buildEnv(ctx), ",") != env {
t.Error("env 顺序不稳定")
}
}
// ★★ 判据:`sessionNameFlag:false` 时**不传** --session-name 及其值。
//
// 理由:ZCode CLI 没有这个开关,传了会 `Unknown option` 直接退到 usage ——
// 一行参数毁掉整条命令。
func TestSessionNameFlagRespected(t *testing.T) {
no := false
d := driverSpec{Args: []string{"--workdir", "{workdir}", "--session-name", "{sessionName}"}, SessionNameFlag: &no}
ctx := driverCtx{Workdir: "/w", SessionName: "br-1"}
got := strings.Join(d.buildArgs(ctx), " ")
if strings.Contains(got, "session-name") || strings.Contains(got, "br-1") {
t.Fatalf("★ 后端声明不支持 --session-name 却仍传了: %s", got)
}
// 支持时应传(默认行为)
d2 := driverSpec{Args: d.Args}
got2 := strings.Join(d2.buildArgs(ctx), " ")
if !strings.Contains(got2, "--session-name br-1") {
t.Fatalf("默认应传 --session-name: %s", got2)
}
}
// ★★★ 端到端判据:**换一个 driver 就能换后端**,插件核心零改动。
//
// 做法:造一个假 CLI 的 sidecar(实现同名 JSON-RPC 契约),
// 断言 startSidecar 用**它的**参数与环境把它拉起来,且 call 能拿到结果。
func TestSwappingDriverSwapsBackend(t *testing.T) {
p := newTestPluginForShell(t)
cli := "fakecli"
dir := p.driverDir(cli)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
// 假 sidecar:把收到的参数与环境写进文件,然后按契约应答
script := filepath.Join(dir, "sidecar.mjs")
body := `
import { writeFileSync } from "node:fs";
import { createInterface } from "node:readline";
// 证据:把调用参数与环境落盘,判据据此断言"确实换了后端"
writeFileSync(process.env.FAKE_EVIDENCE, JSON.stringify({
argv: process.argv.slice(2), env: { FAKE_MARK: process.env.FAKE_MARK },
cwd: process.cwd(),
}));
const rl = createInterface({ input: process.stdin });
rl.on("line", (line) => {
line = line.trim(); if (!line) return;
const req = JSON.parse(line);
let result;
if (req.method === "ping") result = { ok: true, session_id: null, cli_version: "fake-1.0" };
else if (req.method === "prompt") result = { ok: true, session_id: "sess_fake", reply: "来自假后端" };
else if (req.method === "state") result = { ok: true, session_id: "sess_fake", messages: 1 };
else result = { ok: true };
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: req.id, result }) + "\n");
if (req.method === "close") process.exit(0);
});
`
if err := os.WriteFile(script, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
evidence := filepath.Join(t.TempDir(), "evidence.json")
drvJSON := map[string]interface{}{
"name": cli, "script": "sidecar.mjs", "node": "auto",
"sessionNameFlag": false,
"env": map[string]string{"FAKE_MARK": "yes-from-driver", "FAKE_EVIDENCE": evidence},
"args": []string{"--workdir", "{workdir}", "--session-id", "{sessionId}"},
"llmConfig": "none",
}
b, _ := json.MarshalIndent(drvJSON, "", " ")
if err := os.WriteFile(filepath.Join(dir, "driver.json"), b, 0o644); err != nil {
t.Fatal(err)
}
wd := t.TempDir()
br := &Branch{ID: "br-1", ProjectID: "p", Name: "feat/x", Workdir: wd, AgentCLI: cli, Status: "open"}
sc, err := p.startSidecar(br, "sess-1")
if err != nil {
t.Fatalf("★ 换驱动后启动失败(核心仍依赖 pi?): %v", err)
}
defer sc.Close()
// ping 的版本应被记到台账(核心认 cli_version 字段)
if br.PiVersion != "fake-1.0" {
t.Errorf("应记录后端版本,实际 %q", br.PiVersion)
}
// prompt 必须能走通并回报 session_id
res, err := sc.call("prompt", map[string]interface{}{"text": "hello"})
if err != nil {
t.Fatalf("prompt 失败: %v", err)
}
m, ok := res.(map[string]interface{})
if !ok || m["session_id"] != "sess_fake" {
t.Fatalf("prompt 未按契约回报 session_id: %v", res)
}
// ★ 证据:后端确实拿到了**驱动声明**的参数与环境
ev, err := os.ReadFile(evidence)
if err != nil {
t.Fatalf("假后端未写证据(说明它没被启动): %v", err)
}
s := string(ev)
if !strings.Contains(s, "yes-from-driver") {
t.Errorf("驱动声明的环境未注入: %s", s)
}
if !strings.Contains(s, wd) {
t.Errorf("驱动声明的 --workdir 未传入: %s", s)
}
if strings.Contains(s, "session-name") {
t.Errorf("sessionNameFlag:false 却传了 --session-name: %s", s)
}
}
var _ = exec.Command