mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
## 是什么
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 条「判据本身的判据」(防止判据假绿)。
155 lines
4.9 KiB
Go
155 lines
4.9 KiB
Go
package main
|
||
|
||
// 工作目录映射:工程分支 ↔ 目录 的**单一事实源**。
|
||
//
|
||
// 用户要求(2026-10-14):
|
||
//
|
||
// 「即使是同一个仓库,只要是不同的分支都拉到 hash-name 下使用 hash 区分,
|
||
// 插件通过哈希表映射工程分支与文件夹」
|
||
//
|
||
// 所以目录形态是 program/<hash>-<name>,其中:
|
||
//
|
||
// hash = H(项目 + 分支) → ★ 唯一性来源,保证同仓库不同分支**必然**是不同目录
|
||
// name = 分支名派生 → ★ 可读性来源,让人一眼看出这是哪条线
|
||
//
|
||
// ## 为什么 hash 必须含分支,且必须是唯一性来源
|
||
//
|
||
// 一个仓库可以并行开多条分支(这正是本插件的目标)。若只按仓库名建目录,
|
||
// 两条分支就会**共用同一个工作树** —— 那是灾难性的:agent A 的未提交改动会出现在
|
||
// agent B 的 `git status` 里,提交互相污染,且"独立 clone"的前提(设计 §4.3)不成立。
|
||
//
|
||
// 所以:目录唯一性由 hash 承担,**不依赖 name**。
|
||
// name 只做可读性,冲突了也无所谓(hash 已经不同)。
|
||
//
|
||
// ## 为什么不能只留 hash
|
||
//
|
||
// 清理孤儿目录、人工排查、审计时,纯 hash 目录名毫无信息量。所以保留 name 后缀。
|
||
//
|
||
// ## 映射是**双向**的
|
||
//
|
||
// branchKey(project, branch) → dirName(project, branch) → 目录路径
|
||
// 目录路径 → 可从目录名解析回 (hash, name),再经台账 branch.Workdir 反查工程分支
|
||
//
|
||
// ★ 绝对不要在这之外另算目录名:那会出现两处真相,改一处就漂移。
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
// identityHashLen 是目录名里 hash 的长度(8 位 16 进制,碰撞概率对本场景足够低)。
|
||
const identityHashLen = 8
|
||
|
||
// branchKey 是「工程 + 分支」的规范化键 —— 映射的输入。
|
||
//
|
||
// 用 "\x00" 分隔,避免 (a|b) 与 (ab|"") 之类的拼接歧义。
|
||
func branchKey(project, branch string) string {
|
||
return strings.TrimSpace(project) + "\x00" + strings.TrimSpace(branch)
|
||
}
|
||
|
||
// shortHash 是 FNV-1a 32 位哈希的 16 进制串(可重算 ⇒ 可清理孤儿)。
|
||
func shortHash(s string) string {
|
||
h := uint32(2166136261)
|
||
for i := 0; i < len(s); i++ {
|
||
h ^= uint32(s[i])
|
||
h *= 16777619
|
||
}
|
||
return fmt.Sprintf("%0*x", identityHashLen, h)
|
||
}
|
||
|
||
// dirName 返回某个工程分支对应的目录名(不含父目录)。
|
||
//
|
||
// 形态:<hash>-<name>。
|
||
func dirName(project, branch string) string {
|
||
h := shortHash(branchKey(project, branch))
|
||
name := safeNamePattern.ReplaceAllString(strings.TrimSpace(branch), "_")
|
||
name = strings.Trim(name, "_")
|
||
if name == "" {
|
||
name = "branch"
|
||
}
|
||
// 目录名总长有限制;分支名过长时截断(hash 已保证唯一,截断不损正确性)。
|
||
if len(name) > 48 {
|
||
name = name[:48]
|
||
}
|
||
return h + "-" + name
|
||
}
|
||
|
||
// branchDir 返回某个工程分支的工作目录绝对路径。
|
||
//
|
||
// programRoot 是 program/ 的父目录(插件数据目录)。
|
||
func branchDir(programRoot, project, branch string) string {
|
||
return filepath.Join(programRoot, dirName(project, branch))
|
||
}
|
||
|
||
// programRoot 返回插件的数据目录下的 program/ 根。
|
||
func (p *Plugin) programRoot() string {
|
||
return filepath.Join(filepath.Dir(p.path), "program")
|
||
}
|
||
|
||
// dirNamePattern 用于从目录名解析回 (hash, name)。
|
||
var dirNamePattern = regexp.MustCompile(`^([0-9a-f]{8})-(.+)$`)
|
||
|
||
// parseDirName 解析目录名。ok=false 表示不像本插件生成的目录。
|
||
func parseDirName(dir string) (hash, name string, ok bool) {
|
||
m := dirNamePattern.FindStringSubmatch(dir)
|
||
if m == nil {
|
||
return "", "", false
|
||
}
|
||
return m[1], m[2], true
|
||
}
|
||
|
||
// resolveBranchDir 是**反查**:给定目录名,在台账里找到它属于哪个工程分支。
|
||
//
|
||
// 用途:清理孤儿目录、审计"这个目录是谁的"。
|
||
func (p *Plugin) resolveBranchDir(dirName string) (*Branch, bool) {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
for _, b := range p.ledger.Branches {
|
||
if filepath.Base(b.Workdir) == dirName {
|
||
return b, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// listOrphanDirs 列出 program/ 下**台账里没有记录**的目录(孤儿)。
|
||
//
|
||
// 为什么需要它:目录会因崩溃/手工操作残留,而"合入即删"是本插件的纪律;
|
||
// 没有孤儿检测,残留会永久堆积、且下次同名分支可能与旧残留混淆。
|
||
func (p *Plugin) listOrphanDirs() ([]string, error) {
|
||
root := p.programRoot()
|
||
entries, err := os.ReadDir(root)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
known := map[string]bool{}
|
||
p.mu.Lock()
|
||
for _, b := range p.ledger.Branches {
|
||
if b.Workdir != "" {
|
||
known[filepath.Base(b.Workdir)] = true
|
||
}
|
||
}
|
||
p.mu.Unlock()
|
||
|
||
var orphans []string
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
continue
|
||
}
|
||
name := e.Name()
|
||
if _, _, ok := parseDirName(name); !ok {
|
||
continue // 不是本插件生成的目录,不碰
|
||
}
|
||
if !known[name] {
|
||
orphans = append(orphans, name)
|
||
}
|
||
}
|
||
return orphans, nil
|
||
}
|