package main import ( "bytes" "context" "fmt" "log" "os" "os/exec" "path/filepath" "strings" "time" ) type Job struct { EventType string Event *GiteaEvent Trigger *Trigger } // Worker runs jobs serially from an in-memory FIFO queue. One Claude session // at a time — keeps logs readable, avoids racing on git workdirs, and bounds // API spend. type Worker struct { cfg Config queue chan Job } func NewWorker(cfg Config) *Worker { w := &Worker{cfg: cfg, queue: make(chan Job, 128)} go w.run() return w } func (w *Worker) Enqueue(j Job) bool { select { case w.queue <- j: return true default: log.Printf("[queue] FULL — dropping %s on %s", j.EventType, j.Event.Repository.FullName) return false } } func (w *Worker) run() { for job := range w.queue { start := time.Now() tag := fmt.Sprintf("%s#%d", job.Event.Repository.FullName, job.Trigger.Number) log.Printf("[job %s] starting (event=%s reason=%s)", tag, job.EventType, job.Trigger.Reason) if err := w.handle(job); err != nil { log.Printf("[job %s] FAILED after %s: %v", tag, time.Since(start), err) } else { log.Printf("[job %s] done in %s", tag, time.Since(start)) } } } func (w *Worker) handle(job Job) error { dir, err := os.MkdirTemp(w.cfg.WorkspaceRoot, "klaus-*") if err != nil { return fmt.Errorf("mkdtemp: %w", err) } preserve := false defer func() { if preserve { log.Printf("[workspace] preserved at %s", dir) return } _ = os.RemoveAll(dir) }() repoDir := filepath.Join(dir, "repo") sshURL := job.Event.Repository.SSHURL if sshURL == "" { return fmt.Errorf("repository has no ssh_url; cannot clone") } if err := runShell(dir, "git", "clone", sshURL, repoDir); err != nil { return fmt.Errorf("clone: %w", err) } // For PR-related triggers, check out the PR head so Klaus sees the right code. if job.Trigger.IsPR && job.Trigger.BranchHint != "" { if err := runShell(repoDir, "git", "fetch", "origin", job.Trigger.BranchHint); err != nil { log.Printf("[job] warn: fetch %s: %v", job.Trigger.BranchHint, err) } else if err := runShell(repoDir, "git", "checkout", job.Trigger.BranchHint); err != nil { log.Printf("[job] warn: checkout %s: %v", job.Trigger.BranchHint, err) } } prompt := buildPrompt(job, repoDir) ctx, cancel := context.WithTimeout(context.Background(), w.cfg.JobTimeout) defer cancel() cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--permission-mode", "bypassPermissions", ) cmd.Dir = repoDir cmd.Env = append(os.Environ(), // Don't let Klaus's per-job sessions pollute the klaus-bot project memory. "CLAUDE_CODE_AUTO_MEMORY=0", ) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { preserve = true return fmt.Errorf("claude: %w", err) } return nil } // runShell runs a command with logged invocation; on failure the combined // output is returned in the error so the operator can see what went wrong. func runShell(dir, name string, args ...string) error { log.Printf("$ (cwd=%s) %s %s", dir, name, strings.Join(args, " ")) cmd := exec.Command(name, args...) cmd.Dir = dir var out bytes.Buffer cmd.Stdout = &out cmd.Stderr = &out if err := cmd.Run(); err != nil { return fmt.Errorf("%s %s: %w\n%s", name, strings.Join(args, " "), err, out.String()) } return nil }