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。
84 lines
3.3 KiB
Go
84 lines
3.3 KiB
Go
// 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
|
|
}
|