diff --git a/server/pkg/agent/claude.go b/server/pkg/agent/claude.go index 82cdd4e25a..f89c146203 100644 --- a/server/pkg/agent/claude.go +++ b/server/pkg/agent/claude.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -97,10 +98,16 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt return nil, fmt.Errorf("start claude: %w", err) } if err := writeClaudeInput(stdin, prompt); err != nil { + // claude almost certainly died during startup (broken pipe). The + // real reason is sitting in stderrBuf — surface it the same way the + // post-handshake error path does, otherwise the daemon log is the + // only place that knows whether it was a V8 abort, a missing native + // module, or anything else. cmd.Wait() flushes os/exec's stderr + // copy goroutine, so stderrBuf.Tail() is safe to read. closeStdin() cancel() _ = cmd.Wait() - return nil, fmt.Errorf("write claude input: %w", err) + return nil, errors.New(withAgentStderr(fmt.Sprintf("write claude input: %v", err), "claude", stderrBuf.Tail())) } closeStdin() diff --git a/server/pkg/agent/claude_test.go b/server/pkg/agent/claude_test.go index 015e6ce811..ef1b9feb77 100644 --- a/server/pkg/agent/claude_test.go +++ b/server/pkg/agent/claude_test.go @@ -2,11 +2,15 @@ package agent import ( "bytes" + "context" "encoding/json" "log/slog" "os" + "path/filepath" + "runtime" "strings" "testing" + "time" ) func TestClaudeHandleAssistantText(t *testing.T) { @@ -533,6 +537,63 @@ func TestResolveSessionID(t *testing.T) { } } +func TestClaudeExecuteSurfacesStderrWhenChildExitsEarly(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("shell-script fixture is POSIX-only") + } + + // Fake claude binary: drains stdin so writeClaudeInput succeeds, writes a + // canonical V8-abort line to stderr, then exits non-zero before emitting + // any stream-json to stdout. This is the exact failure mode that motivated + // PR #1674 — without sampling stderrBuf.Tail() after cmd.Wait() returns, + // Result.Error would be a useless "exit status 3". + fakePath := filepath.Join(t.TempDir(), "claude") + script := "#!/bin/sh\n" + + "cat >/dev/null\n" + + "echo \"FATAL ERROR: V8 abort: assertion failed\" >&2\n" + + "exit 3\n" + writeTestExecutable(t, fakePath, []byte(script)) + + backend, err := New("claude", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + if err != nil { + t.Fatalf("new claude backend: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("execute: %v", err) + } + // Drain message stream so the lifecycle goroutine can progress. + go func() { + for range session.Messages { + } + }() + + select { + case result, ok := <-session.Result: + if !ok { + t.Fatal("result channel closed without a value") + } + if result.Status != "failed" { + t.Fatalf("expected status=failed, got %q (error=%q)", result.Status, result.Error) + } + if !strings.Contains(result.Error, "claude exited with error") { + t.Fatalf("expected error to mention exit, got %q", result.Error) + } + if !strings.Contains(result.Error, "V8 abort: assertion failed") { + t.Fatalf("expected error to include stderr hint, got %q", result.Error) + } + if !strings.Contains(result.Error, "claude stderr:") { + t.Fatalf("expected stderr label in error, got %q", result.Error) + } + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for result") + } +} + func mustMarshal(t *testing.T, v any) json.RawMessage { t.Helper() data, err := json.Marshal(v)