mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-05 02:30:26 +08:00
* fix(logger): 修复多 backend 并行日志 PID 混乱并移除包装格式 **问题:** - logger.go:288 使用 os.Getpid() 导致并行任务日志 PID 混乱 - 日志文件添加时间戳/PID/级别前缀包装,应输出 backend 原始内容 **修复:** 1. Logger 结构体添加 pid 字段,创建时捕获 PID 2. 日志写入使用固定 l.pid 替代 os.Getpid() 3. 移除日志输出格式包装,直接写入原始消息 4. 添加内存缓存 ERROR/WARN 条目,ExtractRecentErrors 从缓存读取 5. 优化 executor.go context 初始化顺序,避免重复创建 logger **测试:** - 所有测试通过(23.7s) - 更新相关测试用例匹配新格式 Closes #74 * fix(logger): 增强并发日志隔离和 task ID 清理 ## 核心修复 ### 1. Task ID Sanitization (logger.go) - 新增 sanitizeLogSuffix(): 清理非法字符 (/, \, :, 等) - 新增 fallbackLogSuffix(): 为空/非法 ID 生成唯一后备名 - 新增 isSafeLogRune(): 仅允许 [A-Za-z0-9._-] - 路径穿越防护: ../../../etc/passwd → etc-passwd-{hash}.log - 超长 ID 处理: 截断到 64 字符 + hash 确保唯一性 - 自动创建 TMPDIR (MkdirAll) ### 2. 共享日志标识 (executor.go) - 新增 taskLoggerHandle 结构: 封装 logger、路径、共享标志 - 新增 newTaskLoggerHandle(): 统一处理 logger 创建和回退 - printTaskStart(): 显示 "Log (shared)" 标识 - generateFinalOutput(): 在 summary 中标记共享日志 - 并发失败时明确标识所有任务使用共享主日志 ### 3. 内部标志 (config.go) - TaskResult.sharedLog: 非导出字段,标识共享日志状态 ### 4. Race Detector 修复 (logger.go:209-219) - Close() 在关闭 channel 前先等待 pendingWG - 消除 Logger.Close() 与 Logger.log() 之间的竞态条件 ## 测试覆盖 ### 新增测试 (logger_suffix_test.go) - TestLoggerWithSuffixSanitizesUnsafeSuffix: 非法字符清理 - TestLoggerWithSuffixReturnsErrorWhenTempDirNotWritable: 只读目录处理 ### 新增测试 (executor_concurrent_test.go) - TestConcurrentTaskLoggerFailure: 多任务失败时共享日志标识 - TestSanitizeTaskID: 并发场景下 task ID 清理验证 ## 验证结果 ✅ 所有单元测试通过 ✅ Race detector 无竞态 (65.4s) ✅ 路径穿越攻击防护 ✅ 并发日志完全隔离 ✅ 边界情况正确处理 Resolves: PR #76 review feedback Co-Authored-By: Codex Review <codex@anthropic.ai> Generated with swe-agent-bot Co-Authored-By: swe-agent-bot <agent@swe-agent.ai> * fix(logger): 修复关键 bug 并优化日志系统 (v5.2.5) 修复 P0 级别问题: - sanitizeLogSuffix 的 trim 碰撞(防止多 task 日志文件名冲突) - ExtractRecentErrors 边界检查(防止 slice 越界) - Logger.Close 阻塞风险(新增可配置超时机制) 代码质量改进: - 删除无用字段 Logger.pid 和 logEntry.level - 优化 sharedLog 标记绑定到最终 LogPath - 移除日志前缀,直接输出 backend 原始内容 测试覆盖增强: - 新增 4 个测试用例(碰撞防护、边界检查、缓存上限、shared 判定) - 优化测试注释和逻辑 版本更新:5.2.4 → 5.2.5 Generated with swe-agent-bot Co-Authored-By: swe-agent-bot <agent@swe-agent.ai> --------- Co-authored-by: swe-agent-bot <agent@swe-agent.ai>
274 lines
6.7 KiB
Go
274 lines
6.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Config holds CLI configuration
|
|
type Config struct {
|
|
Mode string // "new" or "resume"
|
|
Task string
|
|
SessionID string
|
|
WorkDir string
|
|
ExplicitStdin bool
|
|
Timeout int
|
|
Backend string
|
|
SkipPermissions bool
|
|
MaxParallelWorkers int
|
|
}
|
|
|
|
// ParallelConfig defines the JSON schema for parallel execution
|
|
type ParallelConfig struct {
|
|
Tasks []TaskSpec `json:"tasks"`
|
|
GlobalBackend string `json:"backend,omitempty"`
|
|
}
|
|
|
|
// TaskSpec describes an individual task entry in the parallel config
|
|
type TaskSpec struct {
|
|
ID string `json:"id"`
|
|
Task string `json:"task"`
|
|
WorkDir string `json:"workdir,omitempty"`
|
|
Dependencies []string `json:"dependencies,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
Backend string `json:"backend,omitempty"`
|
|
Mode string `json:"-"`
|
|
UseStdin bool `json:"-"`
|
|
Context context.Context `json:"-"`
|
|
}
|
|
|
|
// TaskResult captures the execution outcome of a task
|
|
type TaskResult struct {
|
|
TaskID string `json:"task_id"`
|
|
ExitCode int `json:"exit_code"`
|
|
Message string `json:"message"`
|
|
SessionID string `json:"session_id"`
|
|
Error string `json:"error"`
|
|
LogPath string `json:"log_path"`
|
|
sharedLog bool
|
|
}
|
|
|
|
var backendRegistry = map[string]Backend{
|
|
"codex": CodexBackend{},
|
|
"claude": ClaudeBackend{},
|
|
"gemini": GeminiBackend{},
|
|
}
|
|
|
|
func selectBackend(name string) (Backend, error) {
|
|
key := strings.ToLower(strings.TrimSpace(name))
|
|
if key == "" {
|
|
key = defaultBackendName
|
|
}
|
|
if backend, ok := backendRegistry[key]; ok {
|
|
return backend, nil
|
|
}
|
|
return nil, fmt.Errorf("unsupported backend %q", name)
|
|
}
|
|
|
|
func envFlagEnabled(key string) bool {
|
|
val, ok := os.LookupEnv(key)
|
|
if !ok {
|
|
return false
|
|
}
|
|
val = strings.TrimSpace(strings.ToLower(val))
|
|
switch val {
|
|
case "", "0", "false", "no", "off":
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func parseBoolFlag(val string, defaultValue bool) bool {
|
|
val = strings.TrimSpace(strings.ToLower(val))
|
|
switch val {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
case "0", "false", "no", "off":
|
|
return false
|
|
default:
|
|
return defaultValue
|
|
}
|
|
}
|
|
|
|
func parseParallelConfig(data []byte) (*ParallelConfig, error) {
|
|
trimmed := bytes.TrimSpace(data)
|
|
if len(trimmed) == 0 {
|
|
return nil, fmt.Errorf("parallel config is empty")
|
|
}
|
|
|
|
tasks := strings.Split(string(trimmed), "---TASK---")
|
|
var cfg ParallelConfig
|
|
seen := make(map[string]struct{})
|
|
|
|
taskIndex := 0
|
|
for _, taskBlock := range tasks {
|
|
taskBlock = strings.TrimSpace(taskBlock)
|
|
if taskBlock == "" {
|
|
continue
|
|
}
|
|
taskIndex++
|
|
|
|
parts := strings.SplitN(taskBlock, "---CONTENT---", 2)
|
|
if len(parts) != 2 {
|
|
return nil, fmt.Errorf("task block #%d missing ---CONTENT--- separator", taskIndex)
|
|
}
|
|
|
|
meta := strings.TrimSpace(parts[0])
|
|
content := strings.TrimSpace(parts[1])
|
|
|
|
task := TaskSpec{WorkDir: defaultWorkdir}
|
|
for _, line := range strings.Split(meta, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
kv := strings.SplitN(line, ":", 2)
|
|
if len(kv) != 2 {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(kv[0])
|
|
value := strings.TrimSpace(kv[1])
|
|
|
|
switch key {
|
|
case "id":
|
|
task.ID = value
|
|
case "workdir":
|
|
task.WorkDir = value
|
|
case "session_id":
|
|
task.SessionID = value
|
|
task.Mode = "resume"
|
|
case "backend":
|
|
task.Backend = value
|
|
case "dependencies":
|
|
for _, dep := range strings.Split(value, ",") {
|
|
dep = strings.TrimSpace(dep)
|
|
if dep != "" {
|
|
task.Dependencies = append(task.Dependencies, dep)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if task.Mode == "" {
|
|
task.Mode = "new"
|
|
}
|
|
|
|
if task.ID == "" {
|
|
return nil, fmt.Errorf("task block #%d missing id field", taskIndex)
|
|
}
|
|
if content == "" {
|
|
return nil, fmt.Errorf("task block #%d (%q) missing content", taskIndex, task.ID)
|
|
}
|
|
if _, exists := seen[task.ID]; exists {
|
|
return nil, fmt.Errorf("task block #%d has duplicate id: %s", taskIndex, task.ID)
|
|
}
|
|
|
|
task.Task = content
|
|
cfg.Tasks = append(cfg.Tasks, task)
|
|
seen[task.ID] = struct{}{}
|
|
}
|
|
|
|
if len(cfg.Tasks) == 0 {
|
|
return nil, fmt.Errorf("no tasks found")
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
func parseArgs() (*Config, error) {
|
|
args := os.Args[1:]
|
|
if len(args) == 0 {
|
|
return nil, fmt.Errorf("task required")
|
|
}
|
|
|
|
backendName := defaultBackendName
|
|
skipPermissions := envFlagEnabled("CODEAGENT_SKIP_PERMISSIONS")
|
|
filtered := make([]string, 0, len(args))
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
switch {
|
|
case arg == "--backend":
|
|
if i+1 >= len(args) {
|
|
return nil, fmt.Errorf("--backend flag requires a value")
|
|
}
|
|
backendName = args[i+1]
|
|
i++
|
|
continue
|
|
case strings.HasPrefix(arg, "--backend="):
|
|
value := strings.TrimPrefix(arg, "--backend=")
|
|
if value == "" {
|
|
return nil, fmt.Errorf("--backend flag requires a value")
|
|
}
|
|
backendName = value
|
|
continue
|
|
case arg == "--skip-permissions", arg == "--dangerously-skip-permissions":
|
|
skipPermissions = true
|
|
continue
|
|
case strings.HasPrefix(arg, "--skip-permissions="):
|
|
skipPermissions = parseBoolFlag(strings.TrimPrefix(arg, "--skip-permissions="), skipPermissions)
|
|
continue
|
|
case strings.HasPrefix(arg, "--dangerously-skip-permissions="):
|
|
skipPermissions = parseBoolFlag(strings.TrimPrefix(arg, "--dangerously-skip-permissions="), skipPermissions)
|
|
continue
|
|
}
|
|
filtered = append(filtered, arg)
|
|
}
|
|
|
|
if len(filtered) == 0 {
|
|
return nil, fmt.Errorf("task required")
|
|
}
|
|
args = filtered
|
|
|
|
cfg := &Config{WorkDir: defaultWorkdir, Backend: backendName, SkipPermissions: skipPermissions}
|
|
cfg.MaxParallelWorkers = resolveMaxParallelWorkers()
|
|
|
|
if args[0] == "resume" {
|
|
if len(args) < 3 {
|
|
return nil, fmt.Errorf("resume mode requires: resume <session_id> <task>")
|
|
}
|
|
cfg.Mode = "resume"
|
|
cfg.SessionID = args[1]
|
|
cfg.Task = args[2]
|
|
cfg.ExplicitStdin = (args[2] == "-")
|
|
if len(args) > 3 {
|
|
cfg.WorkDir = args[3]
|
|
}
|
|
} else {
|
|
cfg.Mode = "new"
|
|
cfg.Task = args[0]
|
|
cfg.ExplicitStdin = (args[0] == "-")
|
|
if len(args) > 1 {
|
|
cfg.WorkDir = args[1]
|
|
}
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
const maxParallelWorkersLimit = 100
|
|
|
|
func resolveMaxParallelWorkers() int {
|
|
raw := strings.TrimSpace(os.Getenv("CODEAGENT_MAX_PARALLEL_WORKERS"))
|
|
if raw == "" {
|
|
return 0
|
|
}
|
|
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 0 {
|
|
logWarn(fmt.Sprintf("Invalid CODEAGENT_MAX_PARALLEL_WORKERS=%q, falling back to unlimited", raw))
|
|
return 0
|
|
}
|
|
|
|
if value > maxParallelWorkersLimit {
|
|
logWarn(fmt.Sprintf("CODEAGENT_MAX_PARALLEL_WORKERS=%d exceeds limit, capping at %d", value, maxParallelWorkersLimit))
|
|
return maxParallelWorkersLimit
|
|
}
|
|
|
|
return value
|
|
}
|