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:
JianFeeeee
2026-10-17 16:16:04 +08:00
parent 65d2bd9e61
commit c2474f57a2
55 changed files with 16071 additions and 0 deletions

View File

@ -0,0 +1,273 @@
package main
// ★★★ 端到端:**真 pi 真的能接受工程并改代码**。
//
// ## 这条判据为什么最重要
//
// 覆盖度审计发现:此前**没有任何判据**验证过这条链路。所有测试都停在
// "工具存在" / "参数被记账" / "驱动被加载" 这一层,而**最根本的假设从未被验证**:
//
// 投一句话 → sidecar 转给 pi → pi 用工具**真的改了工作目录的文件**
//
// 如果这个假设不成立,整个插件就是空壳 —— 台账、通道、判据复核全都在
// 正确运转,但 agent 什么都没做。
//
// ## 为什么必须走 sidecar(不走 pi 的 API 直调)
//
// 直调 pi 的 `createAgentSession` 只能证明"pi 可用",证明不了
// **本插件的 sidecar 契约是对的**(stdin JSON-RPC、session_id 回报、
// 裸名模块解析)。实测中这三处每一处都可能单独失败:
// - 方法名不是 send 而是 prompt(我第一版就猜错了)
// - 裸名 `@earendil-works/pi-coding-agent` 解析不到会**静默回退到宿主机的 pi**
//
// ## 环境要求
//
// 需要插件目录下有内嵌运行时(node + pi):
//
// HA_PI_E2E=1 go test -run TestRealPiEditsFiles ./...
//
// 默认跳过(内嵌运行时是构建产物,不进版本库;且真跑一次要几十秒到几分钟)。
import (
"bufio"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// piE2EReady 检查内嵌运行时是否就绪。
func piE2EReady(t *testing.T) (nodeBin, sidecarPath string) {
t.Helper()
if os.Getenv("HA_PI_E2E") != "1" {
t.Skip("设 HA_PI_E2E=1 以运行(需要 rawfile/ 下有内嵌 node+pi,且会真调 LLM)")
}
root, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
nodeBin = filepath.Join(root, "rawfile", "node", "bin", "node")
if _, err := os.Stat(nodeBin); err != nil {
t.Skipf("内嵌 node 不存在: %v", err)
}
piMod := filepath.Join(root, "rawfile", "pi", "node_modules", "@earendil-works", "pi-coding-agent")
if _, err := os.Stat(piMod); err != nil {
t.Skipf("内嵌 pi 不存在: %v", err)
}
sidecarPath = filepath.Join(root, "sidecar.mjs")
if _, err := os.Stat(sidecarPath); err != nil {
t.Fatalf("sidecar.mjs 不存在: %v", err)
}
return nodeBin, sidecarPath
}
// rpcResponse 是 sidecar 的应答。
type rpcResponse struct {
ID int `json:"id"`
Result json.RawMessage `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// startSidecarForE2E 拉起真实 sidecar 并完成握手。
//
// 走**与 Go 侧完全一样的契约**:stdin 写 JSON-RPC 行,stdout 读应答行。
func startSidecarForE2E(t *testing.T, nodeBin, sidecarPath, workdir, sessionID string) (*exec.Cmd, *bufio.Writer, *bufio.Reader) {
t.Helper()
pluginDir := filepath.Dir(sidecarPath)
cmd := exec.Command(nodeBin, sidecarPath,
"--workdir", workdir,
"--session-id", sessionID,
"--session-name", "e2e-"+sessionID,
)
cmd.Dir = workdir
// ★ 与 driver.json 里声明的 env 一致(PI_CODING_AGENT_DIR/SESSION_DIR)。
// ★ PI_CODING_AGENT_DIR 指向**已配好 LLM 源**的 agentdir。
// 内嵌 pi 是干净的(无凭据),必须先把源导进去 —— 这正是
// `commondev_pi_source_import` 的职责。判据用现成的 agentdir,
// 顺带验证"导入后的配置能被内嵌 pi 直接使用"。
agentDir := os.Getenv("HA_PI_AGENTDIR")
if agentDir == "" {
agentDir = filepath.Join(pluginDir, "e2e-agentdir")
}
cmd.Env = append(os.Environ(),
"PI_CODING_AGENT_DIR="+agentDir,
"PI_CODING_AGENT_SESSION_DIR="+filepath.Join(workdir, ".pi-sessions"),
)
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = nil // 忽略 pi 的 a2a/acp 端口告警
if err := cmd.Start(); err != nil {
t.Fatalf("启动 sidecar 失败: %v", err)
}
t.Cleanup(func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
})
return cmd, bufio.NewWriter(stdin), bufio.NewReader(stdout)
}
// callSidecar 发一条 JSON-RPC 并读回对应 id 的应答。
func callSidecar(t *testing.T, w *bufio.Writer, r *bufio.Reader, id int, method string, params map[string]interface{}) *rpcResponse {
t.Helper()
req := map[string]interface{}{"jsonrpc": "2.0", "id": id, "method": method}
if params != nil {
req["params"] = params
}
b, _ := json.Marshal(req)
if _, err := w.WriteString(string(b) + "\n"); err != nil {
t.Fatalf("写 sidecar 失败: %v", err)
}
if err := w.Flush(); err != nil {
t.Fatalf("flush 失败: %v", err)
}
deadline := time.Now().Add(5 * time.Minute)
for time.Now().Before(deadline) {
line, err := r.ReadString('\n')
if err != nil {
t.Fatalf("读 sidecar 失败(method=%s): %v", method, err)
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
var resp rpcResponse
if err := json.Unmarshal([]byte(line), &resp); err != nil {
continue // 非 JSON 行(pi 的日志)跳过
}
if resp.ID == id {
return &resp
}
}
t.Fatalf("等 sidecar 应答超时(method=%s)", method)
return nil
}
// ★★★ 判据:**真 pi 真的按指令改了工作目录里的文件**。
//
// 这是「agent 能接受工程并更新」的地基。整条链:
//
// 真 sidecar 进程 → 真 pi 会话 → pi 调自己的写文件工具 → 工作目录出现文件
func TestRealPiEditsFiles(t *testing.T) {
nodeBin, sidecarPath := piE2EReady(t)
workdir := t.TempDir()
sessionID := "e2e-edit"
_, w, r := startSidecarForE2E(t, nodeBin, sidecarPath, workdir, sessionID)
// ① 先握手(sidecar 契约的第一环)
ping := callSidecar(t, w, r, 1, "ping", nil)
if ping.Error != nil {
t.Fatalf("ping 失败: %s", ping.Error.Message)
}
var pingRes map[string]interface{}
if err := json.Unmarshal(ping.Result, &pingRes); err != nil {
t.Fatalf("ping 应答不是对象: %s", ping.Result)
}
if ok, _ := pingRes["ok"].(bool); !ok {
t.Fatalf("ping 未返回 ok: %v", pingRes)
}
// ★ 必须回报 pi 版本(U8 要求可锁定内层 pi)——取不到要如实为 null,
// 不许编一个。这里是断言"字段存在",至于是否为空由实现诚实性决定。
if _, has := pingRes["pi_version"]; !has {
t.Error("ping 应回报 pi_version(用于锁定内层 pi 版本)")
} else {
t.Logf("内层 pi 版本: %v", pingRes["pi_version"])
}
// ② 给一个必须动文件的指令
const wantFile = "hello.txt"
const wantText = "hello from pi"
resp := callSidecar(t, w, r, 2, "prompt", map[string]interface{}{
"session_id": sessionID,
"text": "在当前工作目录创建文件 " + wantFile +
",内容恰好一行:" + wantText + "。只做这一件事,不要创建别的文件。",
})
if resp.Error != nil {
t.Fatalf("prompt 失败: %s", resp.Error.Message)
}
var pr map[string]interface{}
if err := json.Unmarshal(resp.Result, &pr); err != nil {
t.Fatalf("prompt 应答不是对象: %s", resp.Result)
}
// ★ 会话不得漂移(Go 侧 deliverToPi 会据此拒绝继续)
if got, _ := pr["session_id"].(string); got != "" && got != sessionID {
t.Fatalf("★ 会话漂移: 期望 %s,实际 %s", sessionID, got)
}
// ③ **关键断言**:文件真的出现在工作目录里,且内容正确
path := filepath.Join(workdir, wantFile)
b, err := os.ReadFile(path)
if err != nil {
// 列出目录帮助定位(是没创建,还是创建到了别处)
entries, _ := os.ReadDir(workdir)
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
t.Fatalf("★ pi 没有创建工作目录下的 %s —— 说明「接受工程并更新」不成立。目录内容: %v",
wantFile, names)
}
if !strings.Contains(string(b), wantText) {
t.Errorf("★ 文件内容不对: 期望含 %q,实际 %q", wantText, string(b))
}
t.Logf("✅ 真 pi 改文件成功: %s = %q", wantFile, strings.TrimSpace(string(b)))
}
// ★★★ 判据:**同一 session 的第二次投递续在同一会话**(不是新会话)。
//
// 「续投」是这个插件的核心能力(设计:session_id 是续投的唯一依据)。
// 若第二次投递另起会话,agent 就失去了上下文,工程对话不再连续。
func TestRealPiKeepsSessionAcrossPrompts(t *testing.T) {
nodeBin, sidecarPath := piE2EReady(t)
workdir := t.TempDir()
sessionID := "e2e-cont"
_, w, r := startSidecarForE2E(t, nodeBin, sidecarPath, workdir, sessionID)
callSidecar(t, w, r, 1, "ping", nil)
// 第一句:建立上下文(记住一个数)
resp1 := callSidecar(t, w, r, 2, "prompt", map[string]interface{}{
"session_id": sessionID,
"text": "请记住这个数字:7391。只回复「记住了」。",
})
if resp1.Error != nil {
t.Fatalf("第一次投递失败: %s", resp1.Error.Message)
}
var p1 map[string]interface{}
json.Unmarshal(resp1.Result, &p1)
got1, _ := p1["session_id"].(string)
if got1 != sessionID {
t.Fatalf("★ 首次投递会话不对: %s", got1)
}
// 第二句:问它那个数 —— 只有**同一会话**才答得出
resp2 := callSidecar(t, w, r, 3, "prompt", map[string]interface{}{
"session_id": sessionID,
"text": "我刚才让你记的数字是多少?只回复那个数字。",
})
if resp2.Error != nil {
t.Fatalf("第二次投递失败: %s", resp2.Error.Message)
}
var p2 map[string]interface{}
json.Unmarshal(resp2.Result, &p2)
got2, _ := p2["session_id"].(string)
if got2 != sessionID {
t.Fatalf("★ 第二次投递会话漂移: 期望 %s,实际 %s(续投失效)", sessionID, got2)
}
reply, _ := p2["reply"].(string)
if !strings.Contains(reply, "7391") {
t.Errorf("★ 第二次回复未体现会话连续性(期望含 7391): %q", reply)
}
t.Logf("✅ 会话连续性成立,第二次回复: %q", strings.TrimSpace(reply))
}