Files
homeagent-sdk/example/commondev/workdir_remote.go
JianFeeeee 81a3efa4df 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>
2026-10-17 19:44:15 +08:00

236 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
// 远端(ssh)工作目录准备。
//
// ## 为什么必须有这个文件
//
// `prepareWorkdir`(plugin.go)走的是**本地** git:`clone --mirror` +
// `clone mirror workdir`,全部在本机磁盘上。对 `runtime=ssh` 的分支来说
// 这是错的 —— 远端 pi 的 cwd 指向远端目录,而那个目录**从未被创建**。
// 症状是「agent 起来了,但一执行命令就说目录不存在」。
//
// 所以远端线必须有一套对应的远端准备流程:
//
// ① 远端建 workdir 的父目录
// ② 有共享 mirror 就用它(省一次全量拉取),没有就直连 remote 拉
// ③ 切到目标分支
//
// ## 为什么远端不复用本地的 mirror
//
// mirror 是**本机路径**(`<插件数据>/mirrors/<name>.git`),远端看不到。
// 远端有自己的 mirror 根(`<ssh_workdir_root>/.mirrors/`),
// 这样同一台远端上的多条分支线可以共享一次拉取。
//
// ## 凭据
//
// 远端拉取需要远端自己能认证(远端已配好 key 或 credential helper)。
// 本插件**不把凭据推到远端**(设计 §7.2:凭据不进工作目录、不进 remote URL)。
// 拉不动就响亮报错并说明原因,不静默降级。
import (
"fmt"
"os/exec"
"strings"
"time"
)
// remoteGit 在远端跑一条 git 命令(BatchMode,不交互输密码)。
//
// cwd 用 `cd <dir> &&` 而不是 `git -C`:`git -C` 在目录不存在时
// 报的是 "cannot change to",而 `cd` 失败更直观;两者都会响亮失败。
func (t sshTarget) remoteGit(dir string, args ...string) (string, error) {
parts := []string{"git"}
parts = append(parts, args...)
for i, a := range parts {
if i > 0 {
parts[i] = shellQuote(a)
}
}
cmdline := strings.Join(parts, " ")
if strings.TrimSpace(dir) != "" {
cmdline = "cd " + shellQuote(dir) + " && " + cmdline
}
argv := append(t.sshBaseArgs(), t.Host, cmdline)
return t.runSSHCapture(argv, 10*time.Minute)
}
// remoteShell 在远端跑一条**原始 shell 命令**(不经 git)。
//
// 与 remoteGit 分开是必要的:`git mv` / `git rm` 是仓库内的操作,
// 用于文件系统层面的搬移会报 "not a git repository"(实测踩到)。
func (t sshTarget) remoteShell(cmdline string) (string, error) {
argv := append(t.sshBaseArgs(), t.Host, cmdline)
return t.runSSHCapture(argv, 10*time.Minute)
}
// remoteMkdir 在远端建目录(幂等)。
func (t sshTarget) remoteMkdir(dir string) error {
argv := append(t.sshBaseArgs(), t.Host, "mkdir -p "+shellQuote(dir))
_, err := t.runSSHCapture(argv, 60*time.Second)
return err
}
// remotePathExists 判断远端路径是否存在且是目录。
func (t sshTarget) remotePathExists(dir string) bool {
argv := append(t.sshBaseArgs(), t.Host,
"test -d "+shellQuote(dir)+" && echo yes")
out, err := t.runSSHCapture(argv, 60*time.Second)
return err == nil && strings.TrimSpace(out) == "yes"
}
// runSSHCapture 跑一条 ssh 命令并返回 stdout(统一超时与错误包装)。
func (t sshTarget) runSSHCapture(argv []string, timeout time.Duration) (string, error) {
return runSSHCaptureOn(t.Host, argv, timeout)
}
// runSSHCaptureOn 是 sshTarget.runSSHCapture 的自由函数形式
// (便于判据在不构造完整 target 的情况下复用)。
//
// ★ 超时用 goroutine + Wait 实现,**不能**在 Start() 之后调 CombinedOutput()
// —— 那会二次启动并报 `exec: already started`(实测踩到)。
func runSSHCaptureOn(host string, argv []string, timeout time.Duration) (string, error) {
cmd := exec.Command("ssh", argv...)
cmd.Env = append(cmd.Environ(), "GIT_TERMINAL_PROMPT=0")
var buf strings.Builder
cmd.Stdout = &buf
cmd.Stderr = &buf
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("启动 ssh 失败: %w", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
s := strings.TrimSpace(buf.String())
if err != nil {
return s, fmt.Errorf("远端命令失败(%s): %w: %s", host, err, truncate(s, 300))
}
return s, nil
case <-time.After(timeout):
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
<-done // 收尾,不留 zombie
return "", fmt.Errorf("远端命令超时(%s,%s)—— 检查远端网络与 git 是否卡在认证提示",
host, timeout)
}
}
// remoteWorkdirRoot 取远端放工程目录的根。
//
// ★ 按 host 取(多台机器的目录布局不同):先问端点表,再回退到旧的
// 全局 settings。缺配置时响亮报错:默认值猜错会把文件写到远端别的项目的目录里。
func (p *Plugin) remoteWorkdirRoot(host string) (string, error) {
if he := p.getHost(host, false); he != nil && strings.TrimSpace(he.WorkdirRoot) != "" {
return he.WorkdirRoot, nil
}
if v, _ := p.sdk.Settings().Get("ssh_workdir_root"); v != nil {
if s := strings.TrimSpace(fmt.Sprint(v)); s != "" && s != "<nil>" {
return s, nil
}
}
return "", fmt.Errorf(
"远端 %s 未配置工程目录根。请用 commondev_endpoint_set host=%s "+
"workdir_root=<远端根目录,如 /home/user/.commondev/program>(多台机器各配各的),"+
"或配置全局 settings 的 ssh_workdir_root", host, host)
}
// prepareRemoteWorkdir 在远端准备这条分支的工作目录。
//
// 与本地 prepareWorkdir 的对应关系:
//
// 本地: 建父目录 → mirror clone(本机)→ clone mirror workdir → checkout
// 远端: 建父目录 → 远端 mirror(共享)→ clone mirror workdir → checkout
//
// 复用判断与本地一致:目录已存在则只确保分支正确(分支线重开时)。
func (p *Plugin) prepareRemoteWorkdir(t sshTarget, pr *Project, branch, workdir string) error {
// ★ 串行化:见 Plugin.prepMu 的注释(mirror 的"检查后 clone"是 TOCTOU)。
p.prepMu.Lock()
defer p.prepMu.Unlock()
root := t.WorkdirRoot
if root == "" {
var err error
root, err = p.remoteWorkdirRoot(t.Host)
if err != nil {
return err
}
t.WorkdirRoot = root
}
// ① 复用:目录已在就直接切分支
if t.remotePathExists(workdir) {
return p.ensureRemoteOnBranch(t, branch, workdir)
}
// ② 建父目录
if err := t.remoteMkdir(workdir); err != nil {
return fmt.Errorf("远端建目录失败: %w", err)
}
// ③ 共享 mirror(一次全量拉取,供该远端上所有分支线复用)
mirror := root + "/.mirrors/" + pr.Name + ".git"
// MirrorPath 是**本地**路径,远端用不了 —— 一律用远端自己的 mirror 根。
// (这正是当初把 MirrorPath 当通用字段用会踩的坑。)
if !t.remotePathExists(mirror) {
if err := t.remoteMkdir(root + "/.mirrors"); err != nil {
return fmt.Errorf("远端建 mirror 目录失败: %w", err)
}
// clone 到**临时目录**再 mv 就位:即使有别的进程刚好抢先建好了,
// 结果也只是一个未使用的临时目录,而不是一个半成品 mirror。
// (prepMu 已经串行化了插件内的并发;这里是防**插件外**的干扰
// 与未来可能的分进程部署。)
// ★ 就位用 **shell 的 mv**,不是 `git mv`(后者只在仓库内工作,
// 实测报 "not a git repository")。清理同理用 shell 的 rm -rf。
tmp := mirror + ".tmp"
_, _ = t.remoteShell("rm -rf " + shellQuote(tmp))
if _, err := t.remoteGit("", "clone", "--mirror", pr.RemoteURL, tmp); err != nil {
// 清掉半成品,避免下次误判"已存在"
_, _ = t.remoteShell("rm -rf " + shellQuote(tmp))
return fmt.Errorf(
"远端建 mirror 失败(远端需自己能认证该仓库,本插件不推凭据): %w", err)
}
if _, err := t.remoteShell("mv " + shellQuote(tmp) + " " + shellQuote(mirror)); err != nil {
_, _ = t.remoteShell("rm -rf " + shellQuote(tmp))
return fmt.Errorf("远端 mirror 就位失败: %w", err)
}
}
// ④ 由远端 mirror 派生工作目录
if _, err := t.remoteGit("", "clone", mirror, workdir); err != nil {
return fmt.Errorf("远端由 mirror 派生工作目录失败: %w", err)
}
// ⑤ 切到目标分支(与本地同一条硬要求:否则 agent 会在默认分支上改)
return p.ensureRemoteOnBranch(t, branch, workdir)
}
// ensureRemoteOnBranch 把远端工作目录切到目标分支(已在则不动)。
func (p *Plugin) ensureRemoteOnBranch(t sshTarget, branch, workdir string) error {
if strings.TrimSpace(branch) == "" {
return nil
}
cur, err := t.remoteGit(workdir, "rev-parse", "--abbrev-ref", "HEAD")
if err == nil && strings.TrimSpace(cur) == branch {
return nil
}
if _, err := t.remoteGit(workdir, "checkout", branch); err != nil {
if _, err2 := t.remoteGit(workdir, "checkout", "-b", branch); err2 != nil {
return fmt.Errorf("远端切到分支 %s 失败: %v", branch, err2)
}
}
return nil
}
// remoteCaptureBaseSHA 取远端工作目录的 base_sha。
//
// 与本地 captureBaseSHA 对应:必须在 agent 动手之前取。
func (p *Plugin) remoteCaptureBaseSHA(t sshTarget, workdir string) (string, error) {
out, err := t.remoteGit(workdir, "rev-parse", "HEAD")
if err != nil {
return "", err
}
return strings.TrimSpace(out), nil
}