mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 02:39:42 +02:00
fix(agent/claude): surface stderr tail on writeClaudeInput failure + lock with e2e test (#1698)
#1674 wired claude's post-handshake error path through withAgentStderr but left the writeClaudeInput failure branch returning a bare "broken pipe" error. That branch fires precisely when claude crashes during startup — exactly when the stderr tail is most useful for root-causing V8 aborts, Bun panics, or missing native modules. cmd.Wait() before sampling Tail() flushes os/exec's internal stderr copy goroutine, matching the Wait→Tail synchronization contract spelled out in stderr_tail.go. Adds TestClaudeExecuteSurfacesStderrWhenChildExitsEarly mirroring the codex test: a fake claude binary drains stdin, writes a V8-abort line to stderr, and exits 3. Locks in the contract that Result.Error carries the stderr tail in the post-handshake failure path on the claude backend too.
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user