summaryrefslogtreecommitdiff
path: root/internal/executor/claude.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor/claude.go')
-rw-r--r--internal/executor/claude.go88
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
}