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

@ -13,11 +13,14 @@ package main
import (
"bufio"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
@ -740,6 +743,8 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
base.SetKnowledgeAPI(procKnowledge{})
base.SetLLMAPI(procLLM{})
base.SetSocialAPI(procSocial{})
base.SetSecretsAPI(procSecrets{})
base.SetRawFilesAPI(procRawFiles{})
base.SetTextMemoryAPI(procTextMemory{})
base.SetPluginMgrAPI(procPluginMgr{})
base.SetInputChannelRegistrar(func(chName string, def sdk.ChannelDef) error {
@ -1160,6 +1165,234 @@ func (procPluginMgr) IsPluginDisabled(name string) bool {
return r.Disabled
}
// ---- 运行态启停(设计 docs/zh/kernel-modularization-design.md §2.6.7)----
//
// ★ 注意 method 名是 `plugin.mgrStop/mgrStart/mgrIsRuntimeStopped`,
// **不是** `plugin.stop/plugin.start` —— 后两个是内核→插件的生命周期消息
// (MethodPluginStart/Stop,见 protocol.go),语义完全不同。用错名会撞车。
func (procPluginMgr) StopPlugin(name string) error {
return callCoreVoid("plugin.mgrStop", map[string]string{"name": name})
}
func (procPluginMgr) StartPlugin(name string) error {
return callCoreVoid("plugin.mgrStart", map[string]string{"name": name})
}
func (procPluginMgr) IsPluginRuntimeStopped(name string) bool {
raw, err := callCore("plugin.mgrIsRuntimeStopped", map[string]string{"name": name})
if err != nil {
return false
}
var r struct {
RuntimeStopped bool `json:"runtime_stopped"`
}
json.Unmarshal(raw, &r)
return r.RuntimeStopped
}
// procRawFiles 是 rawfile 资源的插件侧实现。
//
// ★ 它**不走 RPC**:资源就在插件进程自己的目录下(`<插件目录>/rawfile/`),
// 直接读本地文件即可。走 RPC 是错的 —— 随包资源可能有几十上百兆
// (如内置的 node 二进制),把它们搬过进程边界毫无意义。
//
// 路径来源:内核在 spawn 时注入 HA_PLUGIN_RAWFILE_DIR / HA_PLUGIN_WORK_DIR。
// 不用 cwd 推断(插件可能自己 chdir),也不读内核的 PluginDir()(那个不给插件)。
type procRawFiles struct{}
func (procRawFiles) root() string {
if v := os.Getenv("HA_PLUGIN_RAWFILE_DIR"); v != "" {
return v
}
// 回退:cwd 就是插件目录(内核 spawn 时设的 Dir)
wd, _ := os.Getwd()
return filepath.Join(wd, "rawfile")
}
func (r procRawFiles) workRoot() string {
if v := os.Getenv("HA_PLUGIN_WORK_DIR"); v != "" {
return v
}
wd, _ := os.Getwd()
return filepath.Join(wd, "work")
}
// resolve 解析相对路径并拒绝穿越(与内核侧 rawFilesImpl 同一套规则)。
func (r procRawFiles) resolve(rel string) (string, error) {
rel = strings.TrimSpace(rel)
if rel == "" {
return "", fmt.Errorf("rawfile 路径不能为空")
}
if filepath.IsAbs(rel) {
return "", fmt.Errorf("rawfile 路径必须是相对路径: %q", rel)
}
root := r.root()
clean := filepath.Clean(filepath.Join(root, rel))
if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) {
return "", fmt.Errorf("rawfile 路径逃出资源目录: %q", rel)
}
return clean, nil
}
func (r procRawFiles) Read(rel string) ([]byte, error) {
p, err := r.resolve(rel)
if err != nil {
return nil, err
}
b, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("rawfile 不存在: %s(打包时是否漏了 rawfile/ 或 plg.json 的 rawfiles?)", rel)
}
return nil, err
}
return b, nil
}
func (r procRawFiles) List(rel string) ([]string, error) {
p, err := r.resolve(rel)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(p)
if err != nil {
return nil, err
}
out := make([]string, 0, len(entries))
for _, e := range entries {
name := e.Name()
if e.IsDir() {
name += "/"
}
out = append(out, name)
}
sort.Strings(out)
return out, nil
}
func (r procRawFiles) Exists(rel string) bool {
p, err := r.resolve(rel)
if err != nil {
return false
}
st, err := os.Stat(p)
return err == nil && !st.IsDir()
}
func (r procRawFiles) Dir() string { return r.root() }
func (r procRawFiles) WorkDir() string {
d := r.workRoot()
_ = os.MkdirAll(d, 0o755)
return d
}
var _ sdk.RawFilesAPI = procRawFiles{}
// procSecrets 是变量库 API 的插件侧一半:全部经 RPC 打回内核。
//
// ★ 它**不是**插件自己的存储 —— 内核的变量库是进程级单例,
// 主/子/驻留子 agent 与全部插件共用同一套别名。所以这里只做转发,
// 不在插件侧留任何副本(留了就会出现"两库互不可见"的经典问题:
// 内核拦截登记的变量插件看不到)。
type procSecrets struct{}
// Available 用**专门的**可用性查询,不靠 List 推断。
//
// ★ 实测教训:初版用 `secrets.list` 成功与否判断 —— 而未启用时
// List 返回空列表(不报错),于是 Available() 误判为 true,
// 接着 Store 才失败。错误信息于是变成"存储失败"而非"能力不可用",
// 排查方向完全错。
func (procSecrets) Available() bool {
raw, err := callCore("secrets.available", nil)
if err != nil {
return false
}
var r struct {
Available bool `json:"available"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return false
}
return r.Available
}
func (procSecrets) Store(value, note string) (string, error) {
raw, err := callCore("secrets.store", map[string]interface{}{"value": value, "note": note})
if err != nil {
return "", err
}
var r struct {
Alias string `json:"alias"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return "", err
}
return r.Alias, nil
}
func (procSecrets) List() []sdk.SecretInfo {
raw, err := callCore("secrets.list", nil)
if err != nil {
return nil
}
var r struct {
Infos []sdk.SecretInfo `json:"infos"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return nil
}
return r.Infos
}
func (procSecrets) Forget(alias string) error {
return callCoreVoid("secrets.forget", map[string]string{"alias": alias})
}
func (procSecrets) Fill(text string) (string, int, []string) {
raw, err := callCore("secrets.fill", map[string]string{"text": text})
if err != nil {
return text, 0, nil
}
var r struct {
Text string `json:"text"`
Replaced int `json:"replaced"`
Aliases []string `json:"aliases"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return text, 0, nil
}
return r.Text, r.Replaced, r.Aliases
}
func (procSecrets) Encrypt(plain []byte) (string, error) {
raw, err := callCore("secrets.encrypt", map[string]string{
"plain_b64": base64.StdEncoding.EncodeToString(plain),
})
if err != nil {
return "", err
}
var r struct {
Encrypted string `json:"encrypted"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return "", err
}
return r.Encrypted, nil
}
func (procSecrets) Decrypt(stored string) ([]byte, error) {
raw, err := callCore("secrets.decrypt", map[string]string{"stored": stored})
if err != nil {
return nil, err
}
var r struct {
PlainB64 string `json:"plain_b64"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return nil, err
}
return base64.StdEncoding.DecodeString(r.PlainB64)
}
type procSettings struct{}
func (procSettings) Get(key string) (interface{}, error) {
@ -1168,6 +1401,26 @@ func (procSettings) Get(key string) (interface{}, error) {
func (procSettings) Set(key string, v interface{}) error {
return callCoreVoid("settings.set", map[string]interface{}{"key": key, "value": v})
}
// SetSecret 强制加密落盘(不看键名)。内核侧拒绝在无密钥时写明文。
func (procSettings) SetSecret(key string, v interface{}) error {
return callCoreVoid("settings.setSecret", map[string]interface{}{"key": key, "value": v})
}
// GetSecret 读取 SetSecret 写入的值。
func (procSettings) GetSecret(key string) (string, error) {
raw, err := callCore("settings.getSecret", map[string]string{"key": key})
if err != nil {
return "", err
}
var r struct {
Value string `json:"value"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return "", err
}
return r.Value, nil
}
func (procSettings) List(prefix string) ([]string, error) {
return settingsKeys("settings.list", map[string]string{"prefix": prefix})
}