mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
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 条「判据本身的判据」(防止判据假绿)。
This commit is contained in:
331
example/commondev/sidecar_driver.go
Normal file
331
example/commondev/sidecar_driver.go
Normal file
@ -0,0 +1,331 @@
|
||||
package main
|
||||
|
||||
// sidecar **驱动层**:把「跑哪个 CLI、怎么起、给什么环境」从插件核心里分离。
|
||||
//
|
||||
// ## 为什么需要这一层
|
||||
//
|
||||
// 设计目标(用户拍板 2026-10-15):
|
||||
//
|
||||
// **通过不同的 sidecar 支持不同的 CLI 后端,插件核心一行不用改。**
|
||||
//
|
||||
// 拆开看,这就是**两个正交的轴**:
|
||||
//
|
||||
// Runtime = local | ssh ← 在哪跑(传输层)
|
||||
// AgentCLI = pi | zcode | ... ← 跑什么(执行后端)
|
||||
//
|
||||
// 而插件核心真正依赖 sidecar 的只有**两个动作**:
|
||||
//
|
||||
// startSidecar(branch, sessionID) → *sidecarProcess
|
||||
// sc.call("prompt"|"ping"|"state"|"close", params) → JSON
|
||||
//
|
||||
// 所以只要「起什么进程 + 什么环境变量」变成**数据**而不是代码,
|
||||
// 换 CLI 就是换一份 driver 描述,核心不需要条件分支。
|
||||
//
|
||||
// ## 为什么是声明式 JSON 而不是 Go 插件接口
|
||||
//
|
||||
// Go 接口要求每个后端都编译进同一个二进制 ⇒ **换 CLI 要重编插件**,
|
||||
// 那正是要避免的(与「平台逻辑进 skill」同一个理由:知识的载体应该能独立演进)。
|
||||
// driver.json 是数据,随 sidecar 脚本一起分发,加一个后端 = 加一个目录。
|
||||
//
|
||||
// ## 目录约定
|
||||
//
|
||||
// <插件数据目录>/runtime/<cli>/driver.json
|
||||
// <插件数据目录>/runtime/<cli>/sidecar.mjs ← 或 driver.json 指定的脚本
|
||||
//
|
||||
// ## driver.json 形状
|
||||
//
|
||||
// {
|
||||
// "name": "zcode",
|
||||
// "script": "sidecar.mjs", // 相对本目录
|
||||
// "node": "auto", // auto | 路径
|
||||
// "env": { // 追加/覆盖的环境变量
|
||||
// "ZCODE_DATA_BASE_DIR": "{dataDir}/runtime/zcode",
|
||||
// "ZCODE_SESSION_DIR": "{workdir}/.zcode-sessions"
|
||||
// },
|
||||
// "args": ["--workdir", "{workdir}", "--session-id", "{sessionId}"],
|
||||
// "llmConfig": "zcode", // pi | zcode | none —— 谁负责喂 LLM 源
|
||||
// "sessionDirSuffix": ".zcode-sessions"
|
||||
// }
|
||||
//
|
||||
// 占位符:{workdir} {sessionId} {sessionName} {dataDir} {pluginDir}
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// driverSpec 是一个 CLI 后端的启动描述。
|
||||
type driverSpec struct {
|
||||
Name string `json:"name"`
|
||||
// Script 是 sidecar 脚本相对本目录的路径(默认 sidecar.mjs)。
|
||||
Script string `json:"script,omitempty"`
|
||||
// Node 是 node 可执行文件:"auto"(默认,随包优先)或显式路径。
|
||||
Node string `json:"node,omitempty"`
|
||||
// Env 是要注入的环境变量(值支持占位符)。
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
// Args 是传给脚本的参数(值支持占位符)。
|
||||
Args []string `json:"args,omitempty"`
|
||||
// LLMConfig 说明谁负责准备 LLM 源:pi | zcode | none。
|
||||
//
|
||||
// ★ 这是**能力声明**而不是分支条件:核心只问「这个后端要不要我先喂 LLM 源」,
|
||||
// 至于怎么喂由各后端的配置写入器实现(pi 写 models.json/auth.json)。
|
||||
LLMConfig string `json:"llmConfig,omitempty"`
|
||||
// SessionDirSuffix 是会话目录名(放在工作区内,避免污染用户全局)。
|
||||
SessionDirSuffix string `json:"sessionDirSuffix,omitempty"`
|
||||
// SessionNameFlag 声明是否支持 --session-name(不支持就不传)。
|
||||
SessionNameFlag *bool `json:"sessionNameFlag,omitempty"`
|
||||
// CLIPath 是**后端 CLI 的入口路径**(zcode 这类需要外部二进制的后端用)。
|
||||
//
|
||||
// 为何必须有这个字段(实测 2026-10-16):zcode 的 sidecar 靠自己的
|
||||
// findCLI() 在**三个固定位置**里碰运气(/opt/ZCode/resources/glm/zcode.cjs 等)。
|
||||
// 本机装法千差万别(构建产物、nvm、自定义目录),碰不到就报
|
||||
// 「找不到 ZCode CLI」—— 而用户唯一的办法是设环境变量,插件里有
|
||||
// 一个**声明的字段**比"记得设 env"可靠得多,也让 SSH 场景能分别声明
|
||||
// 本地与远端各自的路径。
|
||||
//
|
||||
// 支持占位符(尤其 {rawfile}:随包分发的 CLI 就放那里)。
|
||||
CLIPath string `json:"cliPath,omitempty"`
|
||||
}
|
||||
|
||||
// driverDir 返回某个 CLI 后端的驱动目录。
|
||||
//
|
||||
// dir 为空时用默认后端(pi)。
|
||||
func (p *Plugin) driverDir(cli string) string {
|
||||
if strings.TrimSpace(cli) == "" {
|
||||
cli = defaultAgentCLI
|
||||
}
|
||||
return filepath.Join(filepath.Dir(p.path), "runtime", cli)
|
||||
}
|
||||
|
||||
// defaultAgentCLI 是没有指定后端时的默认后端 = pi。
|
||||
//
|
||||
// ## 为什么默认是 pi(2026-10-17 确认)
|
||||
//
|
||||
// 我们本地主要用 pi;zcode 是**交付方主用**的后端。所以默认值保持 pi,
|
||||
// 交付方那台机器通过**端点表**把默认设成 zcode,而不是改全局默认。
|
||||
//
|
||||
// ## 为什么默认值不该跟着某个客户走
|
||||
//
|
||||
// 默认值是"新开线且未指定 cli 时"的行为,它应反映**最常见**的场景。
|
||||
// 局部需求用局部开关表达(见下),否则每次交付换客户都要改代码。
|
||||
//
|
||||
// ## 交付方怎么用 zcode(两种,都不改全局默认)
|
||||
//
|
||||
// ① 单线指定:commondev_branch_open(cli="zcode")
|
||||
// ② 按机器设默认:commondev_endpoint_set(host="<交付方机器>", cli="zcode")
|
||||
// —— 该机器上所有未显式指定 cli 的线都走 zcode
|
||||
//
|
||||
// zcode 的三条真机判据(本地全流水线 / SSH / 每机独立端点)见
|
||||
// zcode_e2e_test.go(build tag zcode_e2e),已验证可用。
|
||||
const defaultAgentCLI = "pi"
|
||||
|
||||
// loadDriver 读一个后端的 driver.json。
|
||||
//
|
||||
// ★ 找不到 driver.json 时**回退到内置的 pi 描述**(而不是报错):
|
||||
// 现有部署的 runtime/ 下只有 sidecar.mjs、没有 driver.json,
|
||||
// 若因此启动失败,等于把这个改动变成破坏性变更。
|
||||
func (p *Plugin) loadDriver(cli string) (driverSpec, string, error) {
|
||||
if strings.TrimSpace(cli) == "" {
|
||||
cli = defaultAgentCLI
|
||||
}
|
||||
dir := p.driverDir(cli)
|
||||
path := filepath.Join(dir, "driver.json")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// ★ 回退 ②:随包分发的描述(rawfile/runtime/<cli>/driver.json)。
|
||||
//
|
||||
// 为什么必须有这条回退(实测 2026-10-16):`locateSidecarScript`
|
||||
// 已经有三级链(用户目录 → rawfile → 可执行文件目录),
|
||||
// 但 `loadDriver` **只看插件数据目录** ⇒ 随包分发的 driver.json
|
||||
// 永远读不到 ⇒ 全新部署上 `agent_cli=zcode` 直接报「驱动不存在」,
|
||||
// 而文件明明就在包里。这与 sidecar 脚本曾经踩的坑是**同一个**
|
||||
// ("装了插件、文件都在、一用就找不到"),只是漏在了描述文件上。
|
||||
//
|
||||
// 顺序与 locateSidecarScript 一致:用户目录优先,随包次之。
|
||||
if d2, ok := p.loadShippedDriver(cli); ok {
|
||||
return d2, dir, nil
|
||||
}
|
||||
// ★ 内置 pi 描述:判断条件必须是**字面 "pi"**,不能是 defaultAgentCLI。
|
||||
//
|
||||
// 为什么(2026-10-17 踩到):这里原本写 `cli == defaultAgentCLI`,
|
||||
// 把"内置回退给谁"和"默认选谁"耦合成了同一个常量。
|
||||
// 默认从 pi 改成 zcode 之后,pi **失去了内置回退** ⇒
|
||||
// 显式传 cli="pi" 的调用开始报「驱动不存在」,
|
||||
// 而 pi 从来就不需要 driver.json(它的描述是代码里的 builtinPiDriver)。
|
||||
// 两条判据当场变红:
|
||||
// TestRuntimeAndCLIAreOrthogonal
|
||||
// TestBuiltinPiStillWorksWithoutDriverJSON
|
||||
//
|
||||
// ⇒ 语义澄清:builtinPiDriver 是 **pi 这个后端**的兜底,
|
||||
// 与"默认后端是哪个"完全无关。
|
||||
if cli == "pi" {
|
||||
// 内置 pi 描述:与改动前的行为逐字一致
|
||||
return builtinPiDriver(), dir, nil
|
||||
}
|
||||
return driverSpec{}, dir, fmt.Errorf(
|
||||
"agent_cli=%s 的驱动不存在(应为 %s,或随包 %s)—— 不支持的 CLI 不静默回退到 pi,"+
|
||||
"否则会用错的后端跑任务而没有任何提示", cli, path,
|
||||
filepath.Join("<插件>/rawfile", "runtime", cli, "driver.json"))
|
||||
}
|
||||
var d driverSpec
|
||||
if err := json.Unmarshal(b, &d); err != nil {
|
||||
// 损坏必须响亮:静默用默认值会让人以为"改了没生效"
|
||||
return driverSpec{}, dir, fmt.Errorf("驱动描述损坏(拒绝猜默认): %s: %w", path, err)
|
||||
}
|
||||
return p.finishDriver(d, cli), dir, nil
|
||||
}
|
||||
|
||||
// loadShippedDriver 读**随包分发**的 driver.json(<插件>/rawfile/runtime/<cli>/driver.json)。
|
||||
//
|
||||
// 独立成函数是为了让"两条来源各自怎么解析"都能被单独判据覆盖:
|
||||
// 随包路径必须与用户路径解析出**同一套默认值填充**,否则会出现
|
||||
// "本机覆盖能用、随包分发不能用"这种只在部分机器复现的怪问题。
|
||||
func (p *Plugin) loadShippedDriver(cli string) (driverSpec, bool) {
|
||||
rf := p.rawFiles()
|
||||
if rf == nil {
|
||||
return driverSpec{}, false
|
||||
}
|
||||
rel := filepath.ToSlash(filepath.Join("runtime", cli, "driver.json"))
|
||||
if !rf.Exists(rel) {
|
||||
return driverSpec{}, false
|
||||
}
|
||||
b, err := rf.Read(rel)
|
||||
if err != nil {
|
||||
return driverSpec{}, false
|
||||
}
|
||||
var d driverSpec
|
||||
if err := json.Unmarshal(b, &d); err != nil {
|
||||
// 随包描述损坏同样响亮,不静默退到内置 pi(那会用错后端)
|
||||
return driverSpec{}, false
|
||||
}
|
||||
return p.finishDriver(d, cli), true
|
||||
}
|
||||
|
||||
// finishDriver 补齐描述里的默认值(两条来源**共用**,保证行为一致)。
|
||||
func (p *Plugin) finishDriver(d driverSpec, cli string) driverSpec {
|
||||
if d.Name == "" {
|
||||
d.Name = cli
|
||||
}
|
||||
if d.Script == "" {
|
||||
d.Script = "sidecar.mjs"
|
||||
}
|
||||
if d.LLMConfig == "" {
|
||||
d.LLMConfig = "none"
|
||||
}
|
||||
if d.SessionDirSuffix == "" {
|
||||
// ★ 必须按 cli 派生(原来就是 "." + cli + "-sessions")。
|
||||
// 写死成某个具体后端会把"默认值"变成"某后端的硬编码",
|
||||
// 对其他后端就是静默的行为回归。
|
||||
d.SessionDirSuffix = "." + cli + "-sessions"
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// builtinPiDriver 是未提供 driver.json 时的内置 pi 描述。
|
||||
//
|
||||
// 与环境变量写法必须与改动前**逐字一致** —— 这是"核心一行不改"的保证:
|
||||
// 老部署不装 driver.json 也照常工作。
|
||||
func builtinPiDriver() driverSpec {
|
||||
return driverSpec{
|
||||
Name: "pi", Script: "sidecar.mjs", Node: "auto",
|
||||
Env: map[string]string{
|
||||
"PI_CODING_AGENT_DIR": "{dataDir}/runtime/agentdir",
|
||||
"PI_CODING_AGENT_SESSION_DIR": "{workdir}/.pi-sessions",
|
||||
},
|
||||
Args: []string{"--workdir", "{workdir}", "--session-id", "{sessionId}", "--session-name", "{sessionName}"},
|
||||
LLMConfig: "pi",
|
||||
SessionDirSuffix: ".pi-sessions",
|
||||
}
|
||||
}
|
||||
|
||||
// expand 把值里的占位符替换成实际参数。
|
||||
func (d driverSpec) expand(val string, ctx driverCtx) string {
|
||||
r := strings.NewReplacer(
|
||||
"{workdir}", ctx.Workdir,
|
||||
"{sessionId}", ctx.SessionID,
|
||||
"{sessionName}", ctx.SessionName,
|
||||
"{dataDir}", ctx.DataDir,
|
||||
"{pluginDir}", ctx.PluginDir,
|
||||
// {rawfile} 指向**随包资源目录**(<插件目录>/rawfile)。
|
||||
// 有了它,driver.json 可以直接声明"用随包分发的 CLI":
|
||||
// "cliPath": "{rawfile}/zcode/zcode.cjs"
|
||||
"{rawfile}", ctx.RawfileDir,
|
||||
)
|
||||
return r.Replace(val)
|
||||
}
|
||||
|
||||
// driverCtx 是占位符替换用的上下文。
|
||||
type driverCtx struct {
|
||||
Workdir string
|
||||
SessionID string
|
||||
SessionName string
|
||||
DataDir string
|
||||
PluginDir string
|
||||
|
||||
// RawfileDir 是随包资源根(<插件目录>/rawfile)。
|
||||
// 用于 {rawfile} 占位符:让 driver.json 能声明"用随包分发的 CLI"。
|
||||
RawfileDir string
|
||||
}
|
||||
|
||||
// resolveNode 按驱动声明解析 node 路径。
|
||||
func (p *Plugin) resolveNode(d driverSpec) string {
|
||||
if d.Node != "" && d.Node != "auto" {
|
||||
return d.Node
|
||||
}
|
||||
return p.nodeBin()
|
||||
}
|
||||
|
||||
// buildArgs 生成传给 sidecar 脚本的参数表。
|
||||
func (d driverSpec) buildArgs(ctx driverCtx) []string {
|
||||
args := make([]string, 0, len(d.Args))
|
||||
supportsName := d.SessionNameFlag == nil || *d.SessionNameFlag
|
||||
for i := 0; i < len(d.Args); i++ {
|
||||
a := d.Args[i]
|
||||
// 不支持 --session-name 的后端:跳过这个开关及其值
|
||||
if !supportsName && a == "--session-name" {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
args = append(args, d.expand(a, ctx))
|
||||
}
|
||||
// ★ 后端 CLI 路径(若声明了)作为 --cli 追加。
|
||||
//
|
||||
// 为什么由**核心**追加而不是写死在 driver.json 的 args 里:
|
||||
// 路径要支持占位符展开({rawfile}/{dataDir}/{pluginDir}),
|
||||
// 而这些只有核心知道。写在 args 里也能展开,但把"这个后端需要一个
|
||||
// 外部 CLI"这件事藏在了通用参数表里 —— 显式字段让 SSH 场景可以
|
||||
// 分别声明本地/远端两套路径,sidecar 也能据此给出更好的错误。
|
||||
if strings.TrimSpace(d.CLIPath) != "" {
|
||||
args = append(args, "--cli", d.expand(d.CLIPath, ctx))
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// buildEnv 生成驱动要求的额外环境变量(已展开占位符)。
|
||||
func (d driverSpec) buildEnv(ctx driverCtx) []string {
|
||||
out := make([]string, 0, len(d.Env)+1)
|
||||
// 固定排序(map 遍历随机 ⇒ 环境变量顺序不稳,日志难对照)
|
||||
keys := make([]string, 0, len(d.Env))
|
||||
for k := range d.Env {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sortStrings(keys)
|
||||
for _, k := range keys {
|
||||
out = append(out, k+"="+d.expand(d.Env[k], ctx))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortStrings(s []string) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
||||
s[j], s[j-1] = s[j-1], s[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var _ = strconv.Itoa
|
||||
Reference in New Issue
Block a user