Files
Sakurasan d5dac5a6fc 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。
2026-10-26 00:54:46 +08:00

137 lines
4.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 这几条用例靠给进程组发信号验证"超时带走整棵树",是 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
}