mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-05 02:30:26 +08:00
修复 PR #53 中发现的问题,实现完整的多后端功能: **多后端功能完整性** - Claude/Gemini 后端支持 workdir (-C) 和 resume (--session-id) 参数 - 并行模式支持全局 --backend 参数和任务级 backend 配置 - 后端参数映射统一,支持 new/resume 两种模式 **安全控制** - Claude 后端默认启用 --dangerously-skip-permissions 以支持自动化 - 通过 CODEAGENT_SKIP_PERMISSIONS 环境变量控制权限检查 - 不同后端行为区分:Claude 默认跳过,Codex/Gemini 默认启用 **并发控制** - 新增 CODEAGENT_MAX_PARALLEL_WORKERS 环境变量限制并发数 - 实现 fail-fast context 取消机制 - Worker pool 防止资源耗尽,支持并发监控日志 **向后兼容** - 版本号统一管理,提供 codex-wrapper 兼容脚本 - 所有默认行为保持不变 - 支持渐进式迁移 **测试覆盖** - 总体覆盖率 93.4%(超过 90% 要求) - 新增后端参数、并行模式、并发控制测试用例 - 核心模块覆盖率:backend.go 100%, config.go 97.8%, executor.go 96.4% **文档更新** - 更新 skills/codeagent/SKILL.md 反映多后端和安全控制 - 添加 CHANGELOG.md 记录重要变更 - 更新 README 版本说明和安装脚本 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
461 lines
11 KiB
Go
461 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"reflect"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
version = "5.2.0"
|
|
defaultWorkdir = "."
|
|
defaultTimeout = 7200 // seconds
|
|
codexLogLineLimit = 1000
|
|
stdinSpecialChars = "\n\\\"'`$"
|
|
stderrCaptureLimit = 4 * 1024
|
|
defaultBackendName = "codex"
|
|
defaultCodexCommand = "codex"
|
|
|
|
// stdout close reasons
|
|
stdoutCloseReasonWait = "wait-done"
|
|
stdoutCloseReasonDrain = "drain-timeout"
|
|
stdoutCloseReasonCtx = "context-cancel"
|
|
stdoutDrainTimeout = 100 * time.Millisecond
|
|
)
|
|
|
|
// Test hooks for dependency injection
|
|
var (
|
|
stdinReader io.Reader = os.Stdin
|
|
isTerminalFn = defaultIsTerminal
|
|
codexCommand = defaultCodexCommand
|
|
cleanupHook func()
|
|
loggerPtr atomic.Pointer[Logger]
|
|
|
|
buildCodexArgsFn = buildCodexArgs
|
|
selectBackendFn = selectBackend
|
|
commandContext = exec.CommandContext
|
|
jsonMarshal = json.Marshal
|
|
cleanupLogsFn = cleanupOldLogs
|
|
signalNotifyFn = signal.Notify
|
|
signalStopFn = signal.Stop
|
|
terminateCommandFn = terminateCommand
|
|
defaultBuildArgsFn = buildCodexArgs
|
|
runTaskFn = runCodexTask
|
|
exitFn = os.Exit
|
|
)
|
|
|
|
var forceKillDelay atomic.Int32
|
|
|
|
func init() {
|
|
forceKillDelay.Store(5) // seconds - default value
|
|
}
|
|
|
|
func runStartupCleanup() {
|
|
if cleanupLogsFn == nil {
|
|
return
|
|
}
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
logWarn(fmt.Sprintf("cleanupOldLogs panic: %v", r))
|
|
}
|
|
}()
|
|
if _, err := cleanupLogsFn(); err != nil {
|
|
logWarn(fmt.Sprintf("cleanupOldLogs error: %v", err))
|
|
}
|
|
}
|
|
|
|
func runCleanupMode() int {
|
|
if cleanupLogsFn == nil {
|
|
fmt.Fprintln(os.Stderr, "Cleanup failed: log cleanup function not configured")
|
|
return 1
|
|
}
|
|
|
|
stats, err := cleanupLogsFn()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Cleanup failed: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
fmt.Println("Cleanup completed")
|
|
fmt.Printf("Files scanned: %d\n", stats.Scanned)
|
|
fmt.Printf("Files deleted: %d\n", stats.Deleted)
|
|
if len(stats.DeletedFiles) > 0 {
|
|
for _, f := range stats.DeletedFiles {
|
|
fmt.Printf(" - %s\n", f)
|
|
}
|
|
}
|
|
fmt.Printf("Files kept: %d\n", stats.Kept)
|
|
if len(stats.KeptFiles) > 0 {
|
|
for _, f := range stats.KeptFiles {
|
|
fmt.Printf(" - %s\n", f)
|
|
}
|
|
}
|
|
if stats.Errors > 0 {
|
|
fmt.Printf("Deletion errors: %d\n", stats.Errors)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func main() {
|
|
exitCode := run()
|
|
exitFn(exitCode)
|
|
}
|
|
|
|
// run is the main logic, returns exit code for testability
|
|
func run() (exitCode int) {
|
|
name := currentWrapperName()
|
|
// Handle --version and --help first (no logger needed)
|
|
if len(os.Args) > 1 {
|
|
switch os.Args[1] {
|
|
case "--version", "-v":
|
|
fmt.Printf("%s version %s\n", name, version)
|
|
return 0
|
|
case "--help", "-h":
|
|
printHelp()
|
|
return 0
|
|
case "--cleanup":
|
|
return runCleanupMode()
|
|
}
|
|
}
|
|
|
|
// Initialize logger for all other commands
|
|
logger, err := NewLogger()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "ERROR: failed to initialize logger: %v\n", err)
|
|
return 1
|
|
}
|
|
setLogger(logger)
|
|
|
|
defer func() {
|
|
logger := activeLogger()
|
|
if logger != nil {
|
|
logger.Flush()
|
|
}
|
|
if err := closeLogger(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "ERROR: failed to close logger: %v\n", err)
|
|
}
|
|
// Always remove log file after completion
|
|
if logger != nil {
|
|
if err := logger.RemoveLogFile(); err != nil && !os.IsNotExist(err) {
|
|
// Silently ignore removal errors
|
|
}
|
|
}
|
|
}()
|
|
defer runCleanupHook()
|
|
|
|
// Clean up stale logs from previous runs.
|
|
runStartupCleanup()
|
|
|
|
// Handle remaining commands
|
|
if len(os.Args) > 1 {
|
|
args := os.Args[1:]
|
|
parallelIndex := -1
|
|
for i, arg := range args {
|
|
if arg == "--parallel" {
|
|
parallelIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
if parallelIndex != -1 {
|
|
backendName := defaultBackendName
|
|
var extras []string
|
|
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
switch {
|
|
case arg == "--parallel":
|
|
continue
|
|
case arg == "--backend":
|
|
if i+1 >= len(args) {
|
|
fmt.Fprintln(os.Stderr, "ERROR: --backend flag requires a value")
|
|
return 1
|
|
}
|
|
backendName = args[i+1]
|
|
i++
|
|
case strings.HasPrefix(arg, "--backend="):
|
|
value := strings.TrimPrefix(arg, "--backend=")
|
|
if value == "" {
|
|
fmt.Fprintln(os.Stderr, "ERROR: --backend flag requires a value")
|
|
return 1
|
|
}
|
|
backendName = value
|
|
default:
|
|
extras = append(extras, arg)
|
|
}
|
|
}
|
|
|
|
if len(extras) > 0 {
|
|
fmt.Fprintln(os.Stderr, "ERROR: --parallel reads its task configuration from stdin; only --backend is allowed.")
|
|
fmt.Fprintln(os.Stderr, "Usage examples:")
|
|
fmt.Fprintf(os.Stderr, " %s --parallel < tasks.txt\n", name)
|
|
fmt.Fprintf(os.Stderr, " echo '...' | %s --parallel\n", name)
|
|
fmt.Fprintf(os.Stderr, " %s --parallel <<'EOF'\n", name)
|
|
return 1
|
|
}
|
|
|
|
backend, err := selectBackendFn(backendName)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
|
return 1
|
|
}
|
|
backendName = backend.Name()
|
|
|
|
data, err := io.ReadAll(stdinReader)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "ERROR: failed to read stdin: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
cfg, err := parseParallelConfig(data)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
cfg.GlobalBackend = backendName
|
|
for i := range cfg.Tasks {
|
|
if strings.TrimSpace(cfg.Tasks[i].Backend) == "" {
|
|
cfg.Tasks[i].Backend = backendName
|
|
}
|
|
}
|
|
|
|
timeoutSec := resolveTimeout()
|
|
layers, err := topologicalSort(cfg.Tasks)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
results := executeConcurrent(layers, timeoutSec)
|
|
fmt.Println(generateFinalOutput(results))
|
|
|
|
exitCode = 0
|
|
for _, res := range results {
|
|
if res.ExitCode != 0 {
|
|
exitCode = res.ExitCode
|
|
}
|
|
}
|
|
|
|
return exitCode
|
|
}
|
|
}
|
|
|
|
logInfo("Script started")
|
|
|
|
cfg, err := parseArgs()
|
|
if err != nil {
|
|
logError(err.Error())
|
|
return 1
|
|
}
|
|
logInfo(fmt.Sprintf("Parsed args: mode=%s, task_len=%d, backend=%s", cfg.Mode, len(cfg.Task), cfg.Backend))
|
|
|
|
backend, err := selectBackendFn(cfg.Backend)
|
|
if err != nil {
|
|
logError(err.Error())
|
|
return 1
|
|
}
|
|
cfg.Backend = backend.Name()
|
|
|
|
cmdInjected := codexCommand != defaultCodexCommand
|
|
argsInjected := buildCodexArgsFn != nil && reflect.ValueOf(buildCodexArgsFn).Pointer() != reflect.ValueOf(defaultBuildArgsFn).Pointer()
|
|
|
|
// Wire selected backend into runtime hooks for the rest of the execution,
|
|
// but preserve any injected test hooks for the default backend.
|
|
if backend.Name() != defaultBackendName || !cmdInjected {
|
|
codexCommand = backend.Command()
|
|
}
|
|
if backend.Name() != defaultBackendName || !argsInjected {
|
|
buildCodexArgsFn = backend.BuildArgs
|
|
}
|
|
logInfo(fmt.Sprintf("Selected backend: %s", backend.Name()))
|
|
|
|
timeoutSec := resolveTimeout()
|
|
logInfo(fmt.Sprintf("Timeout: %ds", timeoutSec))
|
|
cfg.Timeout = timeoutSec
|
|
|
|
var taskText string
|
|
var piped bool
|
|
|
|
if cfg.ExplicitStdin {
|
|
logInfo("Explicit stdin mode: reading task from stdin")
|
|
data, err := io.ReadAll(stdinReader)
|
|
if err != nil {
|
|
logError("Failed to read stdin: " + err.Error())
|
|
return 1
|
|
}
|
|
taskText = string(data)
|
|
if taskText == "" {
|
|
logError("Explicit stdin mode requires task input from stdin")
|
|
return 1
|
|
}
|
|
piped = !isTerminal()
|
|
} else {
|
|
pipedTask, err := readPipedTask()
|
|
if err != nil {
|
|
logError("Failed to read piped stdin: " + err.Error())
|
|
return 1
|
|
}
|
|
piped = pipedTask != ""
|
|
if piped {
|
|
taskText = pipedTask
|
|
} else {
|
|
taskText = cfg.Task
|
|
}
|
|
}
|
|
|
|
useStdin := cfg.ExplicitStdin || shouldUseStdin(taskText, piped)
|
|
|
|
targetArg := taskText
|
|
if useStdin {
|
|
targetArg = "-"
|
|
}
|
|
codexArgs := buildCodexArgsFn(cfg, targetArg)
|
|
|
|
// Print startup information to stderr
|
|
fmt.Fprintf(os.Stderr, "[%s]\n", name)
|
|
fmt.Fprintf(os.Stderr, " Backend: %s\n", cfg.Backend)
|
|
fmt.Fprintf(os.Stderr, " Command: %s %s\n", codexCommand, strings.Join(codexArgs, " "))
|
|
fmt.Fprintf(os.Stderr, " PID: %d\n", os.Getpid())
|
|
fmt.Fprintf(os.Stderr, " Log: %s\n", logger.Path())
|
|
|
|
if useStdin {
|
|
var reasons []string
|
|
if piped {
|
|
reasons = append(reasons, "piped input")
|
|
}
|
|
if cfg.ExplicitStdin {
|
|
reasons = append(reasons, "explicit \"-\"")
|
|
}
|
|
if strings.Contains(taskText, "\n") {
|
|
reasons = append(reasons, "newline")
|
|
}
|
|
if strings.Contains(taskText, "\\") {
|
|
reasons = append(reasons, "backslash")
|
|
}
|
|
if strings.Contains(taskText, "\"") {
|
|
reasons = append(reasons, "double-quote")
|
|
}
|
|
if strings.Contains(taskText, "'") {
|
|
reasons = append(reasons, "single-quote")
|
|
}
|
|
if strings.Contains(taskText, "`") {
|
|
reasons = append(reasons, "backtick")
|
|
}
|
|
if strings.Contains(taskText, "$") {
|
|
reasons = append(reasons, "dollar")
|
|
}
|
|
if len(taskText) > 800 {
|
|
reasons = append(reasons, "length>800")
|
|
}
|
|
if len(reasons) > 0 {
|
|
logWarn(fmt.Sprintf("Using stdin mode for task due to: %s", strings.Join(reasons, ", ")))
|
|
}
|
|
}
|
|
|
|
logInfo(fmt.Sprintf("%s running...", cfg.Backend))
|
|
|
|
taskSpec := TaskSpec{
|
|
Task: taskText,
|
|
WorkDir: cfg.WorkDir,
|
|
Mode: cfg.Mode,
|
|
SessionID: cfg.SessionID,
|
|
UseStdin: useStdin,
|
|
}
|
|
|
|
result := runTaskFn(taskSpec, false, cfg.Timeout)
|
|
|
|
if result.ExitCode != 0 {
|
|
return result.ExitCode
|
|
}
|
|
|
|
fmt.Println(result.Message)
|
|
if result.SessionID != "" {
|
|
fmt.Printf("\n---\nSESSION_ID: %s\n", result.SessionID)
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
func setLogger(l *Logger) {
|
|
loggerPtr.Store(l)
|
|
}
|
|
|
|
func closeLogger() error {
|
|
logger := loggerPtr.Swap(nil)
|
|
if logger == nil {
|
|
return nil
|
|
}
|
|
return logger.Close()
|
|
}
|
|
|
|
func activeLogger() *Logger {
|
|
return loggerPtr.Load()
|
|
}
|
|
|
|
func logInfo(msg string) {
|
|
if logger := activeLogger(); logger != nil {
|
|
logger.Info(msg)
|
|
}
|
|
}
|
|
|
|
func logWarn(msg string) {
|
|
if logger := activeLogger(); logger != nil {
|
|
logger.Warn(msg)
|
|
}
|
|
}
|
|
|
|
func logError(msg string) {
|
|
if logger := activeLogger(); logger != nil {
|
|
logger.Error(msg)
|
|
}
|
|
}
|
|
|
|
func runCleanupHook() {
|
|
if logger := activeLogger(); logger != nil {
|
|
logger.Flush()
|
|
}
|
|
if cleanupHook != nil {
|
|
cleanupHook()
|
|
}
|
|
}
|
|
|
|
func printHelp() {
|
|
name := currentWrapperName()
|
|
help := fmt.Sprintf(`%[1]s - Go wrapper for AI CLI backends
|
|
|
|
Usage:
|
|
%[1]s "task" [workdir]
|
|
%[1]s --backend claude "task" [workdir]
|
|
%[1]s - [workdir] Read task from stdin
|
|
%[1]s resume <session_id> "task" [workdir]
|
|
%[1]s resume <session_id> - [workdir]
|
|
%[1]s --parallel Run tasks in parallel (config from stdin)
|
|
%[1]s --version
|
|
%[1]s --help
|
|
|
|
Parallel mode examples:
|
|
%[1]s --parallel < tasks.txt
|
|
echo '...' | %[1]s --parallel
|
|
%[1]s --parallel <<'EOF'
|
|
|
|
Environment Variables:
|
|
CODEX_TIMEOUT Timeout in milliseconds (default: 7200000)
|
|
|
|
Exit Codes:
|
|
0 Success
|
|
1 General error (missing args, no output)
|
|
124 Timeout
|
|
127 backend command not found
|
|
130 Interrupted (Ctrl+C)
|
|
* Passthrough from backend process`, name)
|
|
fmt.Println(help)
|
|
}
|