Files
homeagent-sdk/example/commondev/sidecar.go
JianFeeeee c2474f57a2 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 条「判据本身的判据」(防止判据假绿)。
2026-10-17 16:16:04 +08:00

591 lines
19 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
// sidecar:每个工程对话一份的 pi 运行时外壳。
//
// 协议照抄 internal/plugins/clawhubadapter/sidecar.go(本仓已验证的成熟做法):
// JSON-RPC over stdin/stdout + pending-id 配对 + readLoop + 优雅 Close。
// 不自创协议 —— 那边踩过的坑(长行截断、pending 表泄漏、退出时唤醒等待者)不必再踩。
//
// ## 为什么需要 sidecar
//
// pi 是 Node 程序。我们**不把 pi 的 RPC 循环搬进 Go**(那是重复实现),
// 只用一个薄薄的 Node 脚本把 pi 包成一个「可反复 prompt 的会话」,Go 侧用
// JSON-RPC 跟它说话。这样:
//
// Go 插件 ──JSON-RPC/stdio──► sidecar(node) ──► pi 会话(内嵌 createAgentSession)
//
// ## 刀 1 的形态
//
// 本地内嵌。远端 ssh 是后续刀次(同一份 sidecar,只换传输层)。
// sidecar 脚本路径:优先随包分发的 runtime/sidecar.mjs,其次插件目录下的 sidecar.mjs。
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
"sync"
"time"
)
type sidecarRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Method string `json:"method"`
Params interface{} `json:"params,omitempty"`
}
type sidecarResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Result *json.RawMessage `json:"result,omitempty"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
type sidecarProcess struct {
branchID string
workdir string
cmd *exec.Cmd
stdin *bufio.Writer
stdout io.Reader
mu sync.Mutex
nextID int
pending map[int]chan []byte
closed bool
stopped bool
exited chan struct{}
waitErr error
timeout time.Duration
// handshakeTimeout 用于 ping 握手(比普通调用短得多)。
// 为何单列:握手撞上工具调用的 60s 上限会退化成“超时但进程已起”。
handshakeTimeout time.Duration
}
func newSidecarProcess(branchID, workdir string, cmd *exec.Cmd,
stdin *bufio.Writer, stdout io.Reader, timeout time.Duration) *sidecarProcess {
sp := &sidecarProcess{
branchID: branchID,
workdir: workdir,
cmd: cmd,
stdin: stdin,
stdout: stdout,
pending: map[int]chan []byte{},
exited: make(chan struct{}),
timeout: timeout,
}
go sp.readLoop(stdout)
go func() {
sp.waitErr = cmd.Wait()
close(sp.exited)
}()
return sp
}
// readLoop 持续读 stdout。
//
// ⚠️ 必须持续读:不读会因管道缓冲填满而**堵住 pi**(这是 RPC 类协议最常见的死法)。
func (s *sidecarProcess) readLoop(r io.Reader) {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var resp sidecarResponse
if err := json.Unmarshal(line, &resp); err != nil {
// 不是协议记录:当诊断信息,不打断
log.Printf("[commondev] sidecar %s 非协议输出: %s", s.branchID, truncate(string(line), 200))
continue
}
if resp.ID == 0 {
// 主动通知(暂未使用,保留扩展位)
continue
}
s.mu.Lock()
ch := s.pending[resp.ID]
delete(s.pending, resp.ID)
s.mu.Unlock()
if ch != nil {
cp := make([]byte, len(line))
copy(cp, line)
ch <- cp // 不 close:避免 close/send 竞态
}
}
// stdout 结束 ⇒ 进程已亡,唤醒所有等待者
s.mu.Lock()
for id, ch := range s.pending {
delete(s.pending, id)
ch <- nil
}
s.mu.Unlock()
}
func (s *sidecarProcess) call(method string, params interface{}) (interface{}, error) {
if s.handshakeTimeout > 0 && method == "ping" {
return s.callWithTimeout(method, params, s.handshakeTimeout)
}
return s.callWithTimeout(method, params, s.timeout)
}
func (s *sidecarProcess) callWithTimeout(method string, params interface{}, timeout time.Duration) (interface{}, error) {
raw, err := s.callRawTimeout(method, params, timeout)
if err != nil {
return nil, err
}
if raw == nil {
return nil, nil
}
var resp sidecarResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("sidecar %s 应答解析失败: %w", s.branchID, err)
}
if resp.Error != nil {
return nil, fmt.Errorf("sidecar %s: %s", s.branchID, resp.Error.Message)
}
if resp.Result == nil {
return nil, nil
}
var out interface{}
if err := json.Unmarshal(*resp.Result, &out); err != nil {
return nil, err
}
return out, nil
}
func (s *sidecarProcess) callRaw(method string, params interface{}) ([]byte, error) {
return s.callRawTimeout(method, params, s.timeout)
}
func (s *sidecarProcess) callRawTimeout(method string, params interface{}, timeout time.Duration) ([]byte, error) {
ch := make(chan []byte, 1)
s.mu.Lock()
if s.closed || s.stopped {
s.mu.Unlock()
return nil, fmt.Errorf("sidecar %s 已关闭", s.branchID)
}
s.nextID++
id := s.nextID
s.pending[id] = ch
req := sidecarRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}
data, err := json.Marshal(req)
if err != nil {
delete(s.pending, id)
s.mu.Unlock()
return nil, err
}
if _, err := s.stdin.Write(data); err != nil {
delete(s.pending, id)
s.mu.Unlock()
return nil, err
}
if _, err := s.stdin.Write([]byte("\n")); err != nil {
delete(s.pending, id)
s.mu.Unlock()
return nil, err
}
if err := s.stdin.Flush(); err != nil {
delete(s.pending, id)
s.mu.Unlock()
return nil, err
}
s.mu.Unlock()
select {
case raw := <-ch:
if raw == nil {
return nil, fmt.Errorf("sidecar %s 进程已退出", s.branchID)
}
return raw, nil
case <-time.After(timeout):
s.mu.Lock()
delete(s.pending, id)
s.mu.Unlock()
return nil, fmt.Errorf("sidecar %s 调用 %s 超时", s.branchID, method)
case <-s.exited:
return nil, fmt.Errorf("sidecar %s 进程已退出", s.branchID)
}
}
// Alive 报告进程是否还活着。
func (s *sidecarProcess) Alive() bool {
if s == nil {
return false
}
select {
case <-s.exited:
return false
default:
return true
}
}
// Close 优雅收尾:先关 stdin 请求有序退出,超时则强杀。
//
// ★ 孤儿清理是插件的责任:绝不允许留下 pi 进程。
func (s *sidecarProcess) Close() {
if s == nil {
return
}
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
s.mu.Unlock()
// ★ 关 stdin = 请求有序退出(与 clawhubadapter 同纪律)。
//
// 这里的 nil 判定不只是防御式编程:Close() 的签名承诺了 nil-safe
// (上面 `if s == nil`),但**未完整初始化的对象**(stdin 为 nil
// interface)同样会在这里 panic —— 而调用方看不到差别
// (两者都是 *sidecarProcess)。
//
// 实测来源:`deliverToPi` / `handleBranchClose` 都可能在 startSidecar
// 中途失败时拿到半初始化对象并调 Close;空指针 panic 会盖住真正的错误。
if s.stdin != nil {
_ = s.stdin.Flush()
}
// ★ 若从未真正启动(cmd/exited 为空),没有可等、可杀的东西 —— 直接收尾。
//
// 为何必须显式判:Close() 在 `if s == nil` 之后就不再检查内部字段,
// 而 nil-safe 的承诺对**半初始化对象**同样适用(调用方只看到
// *sidecarProcess,分不出"从未启动"与"已退出")。
// 实测:startSidecar 中途失败时,handleBranchClose 会拿到这种对象;
// 不判则 `s.cmd.Process` 解引用 nil 指针 —— panic 会盖住真正的错误。
if s.cmd == nil || s.exited == nil {
s.markStopped()
return
}
select {
case <-s.exited:
// 已退
case <-time.After(3 * time.Second):
if s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
}
select {
case <-s.exited:
case <-time.After(2 * time.Second):
log.Printf("[commondev] sidecar %s 强杀后仍未退出", s.branchID)
}
}
s.markStopped()
}
// markStopped 置停止标记并唤醒所有挂起的调用(幂等)。
func (s *sidecarProcess) markStopped() {
s.mu.Lock()
s.stopped = true
for id, ch := range s.pending {
delete(s.pending, id)
ch <- nil
}
s.mu.Unlock()
}
// ---------------- 启动 ----------------
// startSidecar 拉起某个工程对话的运行时(**按 agent_cli 选驱动**)。
//
// ★ 本函数**不知道 pi 的存在**:它只读 driver.json(或内置 pi 描述),
// 按声明起进程、注环境、传参数。换 CLI = 换一份 driver.json,这里一行不改。
//
// 契约(sidecar 脚本必须实现,driver 只负责把进程拉起来):
//
// ping → {ok, session_id, ...}
// prompt → {ok, session_id, reply} ← session_id 必须回,Go 侧据此断言会话未漂移
// state → {ok, session_id, messages}
// close → {ok}
func (p *Plugin) startSidecar(b *Branch, sessionID string) (*sidecarProcess, error) {
p.mu.Lock()
existing := p.sidecars[b.ID]
p.mu.Unlock()
if existing != nil && existing.Alive() {
return existing, nil // 复用
}
cli := strings.TrimSpace(b.AgentCLI)
if cli == "" {
cli = defaultAgentCLI
}
drv, dir, err := p.loadDriver(cli)
if err != nil {
return nil, err
}
// 脚本查找链(按优先级):
//
// ① <插件数据>/runtime/<cli>/<script> 用户自备/覆盖(最高优先)
// ② <插件目录>/rawfile/runtime/<cli>/<script> ★ 随包分发(见下)
// ③ <可执行文件同目录>/<script> 开发期(hmapdev debug)
//
// ★ ② 是必需的,不是锦上添花:sidecar 脚本此前**没有随包分发**
// (打包只收 rawfile/ 与 skill/),而 ① 在部署时没人创建 ⇒
// `agent_up` 在全新部署上**必然失败**(报「找不到 sidecar 脚本」),
// 表现为"插件装了、工具在、但一拉 agent 就错"。
//
// 放在 rawfile/ 而不是新建约定目录:rawfile 已是"随包任意资源"的通道,
// 且 nodeBin 已经用同一机制定位内嵌 node —— 复用同一条路,不新增概念。
script, scriptErr := p.locateSidecarScript(drv, dir, cli)
if scriptErr != nil {
return nil, scriptErr
}
dataDir := filepath.Dir(p.path)
rawfileDir := ""
if rf := p.rawFiles(); rf != nil {
rawfileDir = rf.Dir()
}
ctx := driverCtx{
Workdir: b.Workdir, SessionID: sessionID,
SessionName: "br-" + b.ID, DataDir: dataDir, PluginDir: dir,
RawfileDir: rawfileDir,
}
// ★ 传输层分派:local(exec)| ssh(远端同一份 sidecar)。
// 驱动声明(跑什么)与传输(在哪跑)是正交的 —— 这里只分两路,
// 不随 CLI/后端数量增长。
if b.Runtime == "ssh" {
return p.startSSHSidecar(b, drv, ctx, cli)
}
node := p.resolveNode(drv)
if node == "" {
return nil, fmt.Errorf("找不到 node(随包分发应为 <插件>/rawfile/node/bin/node;也可设 node_bin 或用 PATH)")
}
// 会话目录放工作区内,避免污染用户全局会话库。
if drv.SessionDirSuffix != "" {
if err := os.MkdirAll(filepath.Join(b.Workdir, drv.SessionDirSuffix), 0o700); err != nil {
return nil, err
}
}
// LLM 源由**声明**决定谁准备(不是 if cli == "pi" 这种分支)。
if err := p.prepareLLMConfig(drv, ctx); err != nil {
return nil, err
}
args := append([]string{script}, drv.buildArgs(ctx)...)
cmd := exec.Command(node, args...)
cmd.Dir = b.Workdir
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), drv.buildEnv(ctx)...)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("启动 sidecar 失败: %w", err)
}
timeout := defaultSidecarTimeout
if v, _ := p.sdk.Settings().Get("sidecar_timeout_sec"); v != nil {
if n, err := strconv.Atoi(fmt.Sprint(v)); err == nil && n > 0 {
timeout = time.Duration(n) * time.Second
}
}
sp := newSidecarProcess(b.ID, b.Workdir, cmd, bufio.NewWriter(stdin), stdout, timeout)
// ★ 握手必须短且确定 —— 绝不能用默认 timeout(那是 60s,正好撞上内核的
// 工具调用 60s 硬上限 ⇒ 工具超时但进程已起,状态"做了一半")。
sp.handshakeTimeout = 10 * time.Second
if ping, err := sp.call("ping", nil); err != nil {
sp.Close()
return nil, fmt.Errorf("%s sidecar 握手失败(10s): %w", cli, err)
} else if m, ok := ping.(map[string]interface{}); ok {
// 版本字段名按后端而异,两者都认(不要求后端改名)
for _, k := range []string{"pi_version", "cli_version", "version"} {
if v, ok := m[k].(string); ok && v != "" {
b.PiVersion = v
break
}
}
}
return sp, nil
}
// prepareLLMConfig 按驱动的 LLMConfig 声明准备 LLM 源。
//
// ★ 用**声明**而不是分支:核心只说「这个后端需要我先喂源」,
// 至于怎么喂(写哪些文件、什么格式)由各后端的实现决定。
// none = 后端自己管(如 ZCode 用内置 provider 配置)。
func (p *Plugin) prepareLLMConfig(drv driverSpec, ctx driverCtx) error {
switch drv.LLMConfig {
case "", "none":
return nil
case "pi":
agentDir := filepath.Join(ctx.DataDir, "runtime", "agentdir")
if err := os.MkdirAll(agentDir, 0o700); err != nil {
return err
}
// ★ 导入内核的 LLM 源(用户要求:默认用 homeagent 内部配置的源)。
// 不导入的话 pi 会报 `No API key found for the selected model`
//(实测:agentdir/auth.json 只有 2 字节)。
if err := p.ensurePiLLMConfig(agentDir); err != nil {
return fmt.Errorf("导入内核 LLM 源失败(不静默回退到宿主凭据): %w", err)
}
return nil
default:
// ★ 内嵌只实现 pi 的 LLM 喂法。别的后端要么自己管(none),
// 要么由用户在自己的 sidecar 里处理 —— 插件不替未知 CLI 猜格式。
return fmt.Errorf(
"未知的 llmConfig 声明 %q(内嵌只实现 pi | none)。"+
"自定义后端请用 none 并在自己的 sidecar 里准备配置 —— "+
"插件不替未知 CLI 猜配置文件格式(猜错会表现为「模型不回复」)",
drv.LLMConfig)
}
}
// ensurePiLLMConfig 确保内置 pi 有可用的 LLM 配置。
//
// 已配置就**不覆盖**(用户可能手工调过、或用了 commondev_pi_source_add 加的源)。
func (p *Plugin) ensurePiLLMConfig(agentDir string) error {
modelsPath := filepath.Join(agentDir, "models.json")
if b, err := os.ReadFile(modelsPath); err == nil {
var f piModelsFile
if json.Unmarshal(b, &f) == nil && len(f.Providers) > 0 {
return nil // 已有配置
}
}
n, def, err := p.importKernelSources(agentDir)
if err != nil {
return err
}
log.Printf("[commondev] 已导入内核 LLM 源:%d 个,默认=%s", n, def)
return nil
}
// sidecarScript 定位 sidecar 脚本。
func (p *Plugin) sidecarScript() string {
cands := []string{
filepath.Join(filepath.Dir(p.path), "runtime", "sidecar.mjs"),
filepath.Join(filepath.Dir(p.path), "sidecar.mjs"),
}
// 开发期:与插件源码同目录(hmapdev debug 场景)
if exe, err := os.Executable(); err == nil {
cands = append(cands, filepath.Join(filepath.Dir(exe), "sidecar.mjs"))
}
for _, c := range cands {
if _, err := os.Stat(c); err == nil {
return c
}
}
return ""
}
// nodeBin 定位 node:随包分发优先,其次 PATH。
// nodeBin 定位 node,**随包内嵌优先**。
//
// 优先级(用户要求:白板机器也能用):
//
// ① setting node_bin(显式覆盖,排查用)
// ② <插件目录>/rawfile/node/bin/node ← 随包内嵌(白板机器靠这条)
// ③ PATH 里的 node ← 回退(老部署/开发机)
//
// ★ ② 的路径由 rawfile 机制提供:hmapdev 把工程根 `rawfile/` 打进包,
// 内核解包保留结构 ⇒ 落在插件目录下。所以插件"内嵌 node"只需把
// node 放进工程的 rawfile/ 并从 plg.json 声明(或直接放 rawfile/ 下)。
func (p *Plugin) nodeBin() string {
if v, _ := p.sdk.Settings().Get("node_bin"); v != nil {
if s, _ := v.(string); strings.TrimSpace(s) != "" {
return s
}
}
// ② 随包内嵌(rawfile 机制)
if rf := p.rawFiles(); rf != nil && rf.Exists("node/bin/node") {
if cand := filepath.Join(rf.Dir(), "node", "bin", "node"); fileExists(cand) {
return cand
}
}
// 兼容旧路径(rawfile 之前的约定)
if cand := filepath.Join(filepath.Dir(p.path), "runtime", "node", "bin", "node"); fileExists(cand) {
return cand
}
if cand, err := exec.LookPath("node"); err == nil {
return cand
}
return ""
}
// rawFiles 取随包资源接口(可能为 nil —— 旧内核未注入)。
func (p *Plugin) rawFiles() sdk.RawFilesAPI {
if p.sdk == nil {
return nil
}
return p.sdk.RawFiles()
}
func fileExists(p string) bool {
st, err := os.Stat(p)
return err == nil && !st.IsDir()
}
// locateSidecarScript 按优先级链定位 sidecar 脚本。
//
// 查找链(按优先级):
//
// ① <插件数据>/runtime/<cli>/<script> 用户自备/覆盖(最高优先)
// ② <插件目录>/rawfile/runtime/<cli>/<script> ★ 随包分发
// ③ <可执行文件同目录>/<script> 开发期(hmapdev debug)
//
// ★ ② 是必需的,不是锦上添花。实测(2026-10-15)发现:sidecar 脚本此前
// **没有随包分发** —— 打包只收 rawfile/ 与 skill/,而 ① 在部署时没人创建。
// 于是全新部署上 `agent_up` **必然失败**(「找不到 pi 的 sidecar 脚本」),
// 表现为"插件装了、工具都在、但一拉 agent 就错",且错误信息指向一个
// 用户从没听说过的目录。
//
// 为什么放在 rawfile/ 而不是新开一个约定目录:rawfile 已经是
// "随包任意资源"的通道,且 nodeBin 已经用**同一机制**定位内嵌 node ——
// 复用同一条路,不新增概念、不新增内核理解负担。
func (p *Plugin) locateSidecarScript(drv driverSpec, cliDir, cli string) (string, error) {
// ① 用户自备(插件数据目录)——允许覆盖随包脚本
if cand := filepath.Join(cliDir, drv.Script); fileExists(cand) {
return cand, nil
}
// ② 随包分发(rawfile/runtime/<cli>/)
if rf := p.rawFiles(); rf != nil {
rel := filepath.ToSlash(filepath.Join("runtime", cli, drv.Script))
if rf.Exists(rel) {
return filepath.Join(rf.Dir(), "runtime", cli, drv.Script), nil
}
}
// ③ 开发期:与可执行文件同目录
if exe, e := os.Executable(); e == nil {
if alt := filepath.Join(filepath.Dir(exe), drv.Script); fileExists(alt) {
return alt, nil
}
}
// 全部落空:错误必须**列出找过哪里**,否则用户只能猜
return "", fmt.Errorf(
"找不到 %s 的 sidecar 脚本 %s;已找过:① %s ② <插件>/rawfile/runtime/%s/%s ③ <可执行文件目录>/%s。"+
"若要自带 CLI,请把 driver.json 与脚本放到 <插件数据>/runtime/%s/",
cli, drv.Script, filepath.Join(cliDir, drv.Script), cli, drv.Script, drv.Script, cli)
}