diff options
Diffstat (limited to 'internal/executor/claude.go')
| -rw-r--r-- | internal/executor/claude.go | 88 |
1 files changed, 75 insertions, 13 deletions
diff --git a/internal/executor/claude.go b/internal/executor/claude.go index 0029331..815c21f 100644 --- a/internal/executor/claude.go +++ b/internal/executor/claude.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "sync" "syscall" "time" @@ -123,31 +124,35 @@ func (r *ClaudeRunner) execOnce(ctx context.Context, t *task.Task, args []string } }() - // Stream stdout to the log file and parse cost. - // wg ensures costUSD is fully written before we read it after cmd.Wait(). + // Stream stdout to the log file and parse cost/errors. + // wg ensures costUSD and streamErr are fully written before we read them after cmd.Wait(). var costUSD float64 + var streamErr error var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() - costUSD = streamAndParseCost(stdoutR, stdoutFile, r.Logger) + costUSD, streamErr = parseStream(stdoutR, stdoutFile, r.Logger) stdoutR.Close() }() waitErr := cmd.Wait() close(killDone) // stop the pgid-kill goroutine - wg.Wait() // drain remaining stdout before reading costUSD + wg.Wait() // drain remaining stdout before reading costUSD/streamErr + + e.CostUSD = costUSD if waitErr != nil { if exitErr, ok := waitErr.(*exec.ExitError); ok { e.ExitCode = exitErr.ExitCode() } - e.CostUSD = costUSD return fmt.Errorf("claude exited with error: %w", waitErr) } e.ExitCode = 0 - e.CostUSD = costUSD + if streamErr != nil { + return streamErr + } return nil } @@ -202,26 +207,83 @@ func (r *ClaudeRunner) buildArgs(t *task.Task) []string { return args } -// streamAndParseCost reads streaming JSON from claude and writes to the log file, -// extracting cost data from the stream. -func streamAndParseCost(r io.Reader, w io.Writer, logger *slog.Logger) float64 { +// parseStream reads streaming JSON from claude, writes to w, and returns +// (costUSD, error). error is non-nil if the stream signals task failure: +// - result message has is_error:true +// - a tool_result was denied due to missing permissions +func parseStream(r io.Reader, w io.Writer, logger *slog.Logger) (float64, error) { tee := io.TeeReader(r, w) scanner := bufio.NewScanner(tee) scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB buffer for large lines var totalCost float64 + var streamErr error + for scanner.Scan() { line := scanner.Bytes() var msg map[string]interface{} if err := json.Unmarshal(line, &msg); err != nil { continue } - // Extract cost from result messages. - if costData, ok := msg["cost_usd"]; ok { - if cost, ok := costData.(float64); ok { + + msgType, _ := msg["type"].(string) + switch msgType { + case "result": + if isErr, _ := msg["is_error"].(bool); isErr { + result, _ := msg["result"].(string) + if result != "" { + streamErr = fmt.Errorf("claude task failed: %s", result) + } else { + streamErr = fmt.Errorf("claude task failed (is_error=true in result)") + } + } + // Prefer total_cost_usd from result message; fall through to legacy check below. + if cost, ok := msg["total_cost_usd"].(float64); ok { totalCost = cost } + case "user": + // Detect permission-denial tool_results. These occur when permission_mode + // is not bypassPermissions and claude exits 0 without completing its task. + if err := permissionDenialError(msg); err != nil && streamErr == nil { + streamErr = err + } + } + + // Legacy cost field used by older claude versions. + if cost, ok := msg["cost_usd"].(float64); ok { + totalCost = cost + } + } + + return totalCost, streamErr +} + +// permissionDenialError inspects a "user" stream message for tool_result entries +// that were denied due to missing permissions. Returns an error if found. +func permissionDenialError(msg map[string]interface{}) error { + message, ok := msg["message"].(map[string]interface{}) + if !ok { + return nil + } + content, ok := message["content"].([]interface{}) + if !ok { + return nil + } + for _, item := range content { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + if itemMap["type"] != "tool_result" { + continue + } + if isErr, _ := itemMap["is_error"].(bool); !isErr { + continue + } + text, _ := itemMap["content"].(string) + if strings.Contains(text, "requested permissions") || strings.Contains(text, "haven't granted") { + return fmt.Errorf("permission denied by host: %s", text) } } - return totalCost + return nil } |
