feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
This commit is contained in:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
@@ -0,0 +1,83 @@
|
||||
// Package execx 执行子进程,并保证「超时/取消」真的生效。
|
||||
//
|
||||
// 标准库的 exec.CommandContext 在取消时只 Kill **直接子进程**。而
|
||||
// `sh -c "git clone …"` 里 git 还要派生 git-remote-https,孙子继承了 stdout
|
||||
// 管道的写端;只要写端还被人拿着,Go 的输出拷贝 goroutine 就不会结束,
|
||||
// cmd.Run() 也一直不返回——于是"120 秒超时"实测能挂住 23 分钟
|
||||
// (真实事故:session 20261008-214717,一条 git clone 之后整回合静止)。
|
||||
//
|
||||
// 本包用两件事收口,缺一不可:
|
||||
// - 子进程自成进程组,取消时 kill(-pgid) 带走整棵树(治孤儿进程);
|
||||
// - WaitDelay 兜底:即使还有别的挂住的 fd 占着管道,Wait 也必定在
|
||||
// 超时后有限时间内返回(治调用方被钉死)。
|
||||
package execx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WaitGrace 是命令已退出/已被杀之后,再等在途输出拷贝收尾的宽限期。
|
||||
// 超过它就放弃等待直接返回,绝不让调用方被孙进程占住的管道钉死。
|
||||
const WaitGrace = 2 * time.Second
|
||||
|
||||
// Options 是一次执行的可选参数;零值即用默认(继承环境、继承 cwd、无输入)。
|
||||
type Options struct {
|
||||
Dir string // 工作目录
|
||||
Env []string // 环境表;nil = 继承当前进程环境
|
||||
// Stdin 标准输入。用 []byte 而不是 io.Reader:调用方大多已经手握着内容,
|
||||
// 而 io.Reader 会让 Run 无法重试语义之外的复用(当前没有复用需求)。
|
||||
Stdin []byte
|
||||
}
|
||||
|
||||
// Command 构造一条命令:ctx 取消即杀掉整棵进程树,Wait 有有限上界。
|
||||
// 需要自定义 Stdout/Stderr 为别的 writer 时用它;一次性取输出用 Run。
|
||||
func Command(ctx context.Context, name string, args ...string) *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
SeparateGroup(cmd)
|
||||
// 默认 Cancel 只 Process.Kill(),正是孤儿问题的来源。
|
||||
cmd.Cancel = func() error { return KillTree(cmd) }
|
||||
cmd.WaitDelay = WaitGrace
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Run 执行命令并返回合并的 stdout+stderr。超时由 ctx 决定。
|
||||
// 输出需要截断时配合 Truncate(它不会把一个 UTF-8 字符切两半)。
|
||||
func Run(ctx context.Context, opts Options, name string, args ...string) ([]byte, error) {
|
||||
cmd := Command(ctx, name, args...)
|
||||
if opts.Dir != "" {
|
||||
cmd.Dir = opts.Dir
|
||||
}
|
||||
if opts.Env != nil {
|
||||
cmd.Env = opts.Env
|
||||
}
|
||||
if opts.Stdin != nil {
|
||||
cmd.Stdin = bytes.NewReader(opts.Stdin)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &buf, &buf
|
||||
err := cmd.Run()
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
|
||||
// Shell 执行一段 shell 脚本(等价 sh -c script)。
|
||||
func Shell(ctx context.Context, opts Options, script string) ([]byte, error) {
|
||||
return Run(ctx, opts, "sh", "-c", script)
|
||||
}
|
||||
|
||||
// Truncate 把输出截到 maxBytes 以内,并报告是否发生了截断。
|
||||
//
|
||||
// 必须退到字符边界:中文一个字占 3 字节,按字节硬切会把最后一个字劈开,
|
||||
// 尾部留下非法 UTF-8——它进 JSON 会变成替换字符,也可能被模型网关直接拒掉。
|
||||
func Truncate(b []byte, maxBytes int) ([]byte, bool) {
|
||||
if maxBytes <= 0 || len(b) <= maxBytes {
|
||||
return b, false
|
||||
}
|
||||
cut := maxBytes
|
||||
for cut > 0 && b[cut]&0xC0 == 0x80 {
|
||||
cut-- // b[cut] 是 UTF-8 续字节:退到本字符的首字节
|
||||
}
|
||||
return b[:cut], true
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// 这几条用例靠给进程组发信号验证"超时带走整棵树",是 Unix 的把戏;
|
||||
// Windows 没有 syscall.Kill,编译期就该排除(实现本身在 execx_windows.go)。
|
||||
//go:build !windows
|
||||
|
||||
package execx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TestRunTimeoutKillsDescendant 是这次事故的回归守卫:超时设 1 秒,
|
||||
// 实际挂了 23 分钟——因为只杀了直接子进程,孙子仍握着 stdout 管道写端,
|
||||
// cmd.Run() 卡在输出拷贝上出不来。
|
||||
func TestRunTimeoutKillsDescendant(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("依赖 POSIX 进程组语义")
|
||||
}
|
||||
const limit = time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), limit)
|
||||
defer cancel()
|
||||
|
||||
pidFile := filepath.Join(t.TempDir(), "grandchild.pid")
|
||||
// `sleep 60 &` 是关键:它继承命令的 stdout 管道,且不是直接子进程。
|
||||
script := fmt.Sprintf("sleep 60 & echo $! > %s && sleep 60", strconv.Quote(pidFile))
|
||||
|
||||
start := time.Now()
|
||||
out, err := Run(ctx, Options{}, "sh", "-c", script)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("超时本该报错,却成功了(out=%q)", out)
|
||||
}
|
||||
// WaitGrace(2s) 是给"杀完还在收尾"的余量;3 倍预算足够宽松,
|
||||
// 又能稳定抓住"完全没生效"那种几十秒/几十分钟的挂法。
|
||||
if elapsed > 6*limit {
|
||||
t.Errorf("超时没有生效:预算 %v,实际阻塞 %v", limit, elapsed.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
// 孙子必须一起走:留着它就是孤儿进程,会一直吃 CPU/网络直到自己结束。
|
||||
if pid := readPID(t, pidFile); pid > 0 && syscall.Kill(pid, 0) == nil {
|
||||
t.Errorf("孙子进程 %d 仍在运行(进程树没被带走)", pid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunNoTimeoutOnFastCommand:修超时不能把正常路径改坏。
|
||||
func TestRunNoTimeoutOnFastCommand(t *testing.T) {
|
||||
out, err := Run(context.Background(), Options{}, "sh", "-c", "printf a; printf b 1>&2")
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v out=%q", err, out)
|
||||
}
|
||||
if got := string(out); got != "ab" {
|
||||
t.Errorf("stdout/stderr 未合并,got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunHonorsDirAndStdin:接线点(bash 工具的工作区语义、git 的 -C 目录)依赖这两项。
|
||||
func TestRunHonorsDirAndStdin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out, err := Run(context.Background(), Options{Dir: dir, Stdin: []byte("hello")}, "sh", "-c", "cat && pwd")
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := strings.TrimSpace(string(out))
|
||||
// macOS 的 TempDir 常带 /private 前缀,只比尾名不比拼路径。
|
||||
if !strings.HasPrefix(got, "hello") || filepath.Base(got) != filepath.Base(dir) {
|
||||
t.Errorf("Dir/Stdin 没生效,got=%q want=hello + %q", got, dir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunExitErrorSurvives:非零退出要既拿到输出又拿到错误(bash 工具靠它回灌失败块)。
|
||||
func TestRunExitErrorSurvives(t *testing.T) {
|
||||
out, err := Run(context.Background(), Options{}, "sh", "-c", "printf oops; exit 3")
|
||||
if err == nil {
|
||||
t.Fatal("exit 3 必须报错")
|
||||
}
|
||||
if !strings.Contains(string(out), "oops") {
|
||||
t.Errorf("失败时的 stdout 丢了:out=%q", out)
|
||||
}
|
||||
if code := exitCode(err); code != 3 {
|
||||
t.Errorf("退出码应为 3,got=%d err=%v", code, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateStaysOnRuneBoundary(t *testing.T) {
|
||||
in := []byte("中文测试") // 4 个汉字 = 12 字节
|
||||
for _, max := range []int{1, 2, 4, 7, 10, 11, 13, 12} {
|
||||
out, cut := Truncate(in, max)
|
||||
if !utf8.Valid(out) {
|
||||
t.Errorf("max=%d 截出非法 UTF-8:%q", max, out)
|
||||
}
|
||||
if want := max < len(in); cut != want {
|
||||
t.Errorf("max=%d 截断标记应为 %v,got=%v", max, want, cut)
|
||||
}
|
||||
}
|
||||
// max=10 落在「试」的第 2 个字节上:必须退回到 9 字节(3 个完整汉字)。
|
||||
if out, _ := Truncate(in, 10); string(out) != "中文测" {
|
||||
t.Errorf("got=%q want=中文测", out)
|
||||
}
|
||||
if _, cut := Truncate(in, 0); cut {
|
||||
t.Error("maxBytes<=0 应表示不截断")
|
||||
}
|
||||
}
|
||||
|
||||
func readPID(t *testing.T, path string) int {
|
||||
t.Helper()
|
||||
// 命令组被整体杀掉时,pid 文件可能压根没写出来(那是更彻底的清理),
|
||||
// 稍等一次再判定,避免把这个合法竞态读成失败。
|
||||
for i := 0; i < 20; i++ {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
pid, _ := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
return pid
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func exitCode(err error) int {
|
||||
var ee interface{ ExitCode() int }
|
||||
if errors.As(err, &ee) {
|
||||
return ee.ExitCode()
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build !windows
|
||||
|
||||
package execx
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// SeparateGroup 让子进程自成进程组(pgid == 子进程 pid),必须在 Start 之前调用。
|
||||
// 自成组之后 kill(-pgid) 才能把它自己起的孙子一并带走。
|
||||
//
|
||||
// 用 Setpgid 而不是 Setsid:本包的工具子进程不需要控制终端,而 setsid 会顺带
|
||||
// 脱离当前会话,交互性场景(终端里 Ctrl+C)行为会变。常驻 PTY 终端那条路径
|
||||
// 要的是会话+控制终端,见 webui/terminal_unix.go 的三层回退。
|
||||
func SeparateGroup(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setpgid = true
|
||||
}
|
||||
|
||||
// KillTree 杀掉整个进程组。用于自己管生命周期的常驻子进程(MCP stdio server:
|
||||
// 只 Kill 包装进程会把真正的 server 留成孤儿,还占着 stdout 让读循环永不结束)。
|
||||
//
|
||||
// 错误一律吞掉并返回 nil:ESRCH 在这里意味着"进程已经没了",正是我们想要的结果;
|
||||
// 把它抛给 exec 反而会盖掉真正的失败原因(signal: killed / 退出码),
|
||||
// 让上层看到的错误信息变成一句无意义的 "no such process"。
|
||||
func KillTree(cmd *exec.Cmd) error {
|
||||
if cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
|
||||
_ = syscall.Kill(pid, syscall.SIGKILL) // 没成组(或组已空):退化为只杀本体
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build windows
|
||||
|
||||
package execx
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// SeparateGroup 在 Windows 上是空操作:杀进程树需要 CREATE_NEW_PROCESS_GROUP +
|
||||
// taskkill /T(或 Toolhelp 快照遍历子进程),代价与收益不匹配。Windows 下
|
||||
// "调用方不被钉死"由 Command 的 WaitDelay 保证;孤儿进程留给系统回收。
|
||||
func SeparateGroup(cmd *exec.Cmd) {}
|
||||
|
||||
// KillTree 退化为杀本体(见 SeparateGroup 的说明)。
|
||||
func KillTree(cmd *exec.Cmd) error {
|
||||
if cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
_ = cmd.Process.Kill()
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user