mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
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:
590
example/commondev/sidecar.go
Normal file
590
example/commondev/sidecar.go
Normal file
@ -0,0 +1,590 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user