Skip to content

Commit d0771f7

Browse files
authored
Merge pull request #2860 from fullsend-ai/agent/2786-surface-api-errors
fix(#2786): surface agent API errors when Claude Code exits 0
2 parents dcd3b43 + 5f1462d commit d0771f7

4 files changed

Lines changed: 113 additions & 12 deletions

File tree

internal/cli/run.go

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,7 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
625625
// registered before the post-script and cleanup defers so that — by LIFO
626626
// order — it runs last and the summary captures the whole run.
627627
var lastExitCode int
628+
var transcriptErrorOverride bool
628629
rec := telemetry.New(runDir, wTraceID, rootSpanID, agentName, workItemID, runStart)
629630
defer func() { rec.Finalize(telemetryExitCode(lastExitCode, runErr)) }()
630631

@@ -666,6 +667,10 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
666667
printer.StepWarn("Skipping post-script: agent run failed")
667668
return
668669
}
670+
if transcriptErrorOverride {
671+
printer.StepWarn("Skipping post-script: agent reported error via transcript")
672+
return
673+
}
669674
postStart := time.Now()
670675
printer.StepStart("Running post-script: " + h.PostScript)
671676
postCmd := exec.Command(h.PostScript)
@@ -950,6 +955,7 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
950955

951956
for iteration := 1; iteration <= maxIterations; iteration++ {
952957
runCount = iteration
958+
transcriptErrorOverride = false
953959

954960
// Each iteration gets its own subdirectory for output and transcripts.
955961
iterDir := filepath.Join(runDir, fmt.Sprintf("iteration-%d", iteration))
@@ -1019,12 +1025,26 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
10191025
}
10201026
lastExitCode = exitCode
10211027

1028+
// Check the tee'd output.jsonl for is_error:true result events.
1029+
// Claude Code may exit 0 on API/infrastructure failures (e.g.,
1030+
// invalid_grant, quota exhaustion) while setting is_error:true in
1031+
// the transcript. Treat these as failures so downstream gating
1032+
// (transcript surfacing, post-script skip) can act. See #2786.
1033+
if exitCode == 0 {
1034+
outputJSONL := filepath.Join(iterDir, "output.jsonl")
1035+
if te, ok := tx.ParseTranscriptFile(outputJSONL); ok && te.IsError {
1036+
printer.StepWarn(fmt.Sprintf("Agent exited with code 0 but transcript contains error: %s", te.ErrorMessage))
1037+
lastExitCode = 1
1038+
transcriptErrorOverride = true
1039+
}
1040+
}
1041+
10221042
printer.Blank()
10231043
// Non-zero exit is a warning, not a failure — the validation loop is the success gate.
1024-
if exitCode == 0 {
1044+
if lastExitCode == 0 {
10251045
printer.StepDone(fmt.Sprintf("Agent exited with code %d (%.1fs)", exitCode, time.Since(agentStart).Seconds()))
10261046
} else {
1027-
printer.StepWarn(fmt.Sprintf("Agent exited with code %d", exitCode))
1047+
printer.StepWarn(fmt.Sprintf("Agent exited with code %d", lastExitCode))
10281048
}
10291049

10301050
// 9b. Extract output files.
@@ -1114,16 +1134,15 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
11141134
rec.SetModel(aggMetrics.Model)
11151135

11161136
// 9e-bis. Surface transcript errors in workflow logs (GitHub Actions).
1117-
// When the agent exits non-zero, parse transcript JSONL files and emit
1118-
// ::error:: annotations so operators can diagnose failures without
1119-
// downloading artifacts. See #704.
1120-
if lastExitCode != 0 {
1121-
lastIterDir := filepath.Join(runDir, fmt.Sprintf("iteration-%d", runCount))
1122-
lastTranscriptDir := filepath.Join(lastIterDir, "transcripts")
1123-
if errorSummaries := tx.ParseTranscriptErrors(lastTranscriptDir); len(errorSummaries) > 0 {
1124-
printer.StepWarn(fmt.Sprintf("Found %d transcript error(s) — emitting to workflow log", len(errorSummaries)))
1125-
tx.EmitTranscriptErrors(os.Stderr, errorSummaries)
1126-
}
1137+
// Parse transcript JSONL files and emit ::error:: annotations so operators
1138+
// can diagnose failures without downloading artifacts. This runs
1139+
// regardless of exit code because Claude Code may exit 0 with
1140+
// is_error:true on API/infrastructure failures. See #704, #2786.
1141+
lastIterDir := filepath.Join(runDir, fmt.Sprintf("iteration-%d", runCount))
1142+
lastTranscriptDir := filepath.Join(lastIterDir, "transcripts")
1143+
if errorSummaries := tx.ParseTranscriptErrors(lastTranscriptDir); len(errorSummaries) > 0 {
1144+
printer.StepWarn(fmt.Sprintf("Found %d transcript error(s) — emitting to workflow log", len(errorSummaries)))
1145+
tx.EmitTranscriptErrors(os.Stderr, errorSummaries)
11271146
}
11281147

11291148
// 9f. Post-agent output scan — redact secrets from extracted output.

internal/runtime/claude.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ func (ClaudeRuntime) ParseTranscriptErrors(transcriptDir string) []TranscriptErr
194194
return parseTranscriptErrors(transcriptDir)
195195
}
196196

197+
func (ClaudeRuntime) ParseTranscriptFile(path string) (TranscriptError, bool) {
198+
return parseTranscriptFile(path)
199+
}
200+
197201
func (ClaudeRuntime) EmitTranscriptErrors(w io.Writer, summaries []TranscriptError) {
198202
emitTranscriptErrors(w, summaries)
199203
}

internal/runtime/claude_transcript_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,80 @@ func TestEmitTranscriptErrors_NoSummaries(t *testing.T) {
246246
}
247247
}
248248

249+
// TestParseTranscriptFile_APIErrorExitZero covers the scenario from #2786:
250+
// Claude Code exits 0 with is_error:true and subtype "success" on API errors
251+
// (e.g., invalid_grant from a stale OIDC token).
252+
func TestParseTranscriptFile_APIErrorExitZero(t *testing.T) {
253+
dir := t.TempDir()
254+
// Real-world transcript shape: subtype is "success" but is_error is true.
255+
content := `{"type":"system","subtype":"init","session_id":"abc123"}
256+
{"type":"result","subtype":"success","is_error":true,"result":"API Error: Error code invalid_grant: ID Token issued at 1782810237 is stale to sign-in.","session_id":"abc123"}
257+
`
258+
path := filepath.Join(dir, "output.jsonl")
259+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
260+
t.Fatal(err)
261+
}
262+
263+
summary, ok := parseTranscriptFile(path)
264+
if !ok {
265+
t.Fatal("expected result event to be found")
266+
}
267+
if !summary.IsError {
268+
t.Error("expected IsError to be true for API error with exit 0")
269+
}
270+
if summary.Subtype != "success" {
271+
t.Errorf("expected subtype 'success', got %q", summary.Subtype)
272+
}
273+
if !strings.Contains(summary.ErrorMessage, "invalid_grant") {
274+
t.Errorf("expected error message to contain 'invalid_grant', got %q", summary.ErrorMessage)
275+
}
276+
}
277+
278+
// TestParseTranscriptErrors_SurfacesErrorRegardlessOfExitCode verifies that
279+
// parseTranscriptErrors returns errors from transcripts where is_error:true,
280+
// which is the key fix from #2786 — errors must be surfaced even when the
281+
// process exit code was 0.
282+
func TestParseTranscriptErrors_SurfacesErrorRegardlessOfExitCode(t *testing.T) {
283+
dir := t.TempDir()
284+
285+
// Transcript with is_error:true but subtype "success" (API error scenario).
286+
content := `{"type":"result","subtype":"success","is_error":true,"result":"API Error: quota exhausted"}`
287+
if err := os.WriteFile(filepath.Join(dir, "agent.jsonl"), []byte(content), 0o644); err != nil {
288+
t.Fatal(err)
289+
}
290+
291+
summaries := parseTranscriptErrors(dir)
292+
if len(summaries) != 1 {
293+
t.Fatalf("expected 1 error summary, got %d", len(summaries))
294+
}
295+
if !summaries[0].IsError {
296+
t.Error("expected IsError to be true")
297+
}
298+
if !strings.Contains(summaries[0].ErrorMessage, "quota exhausted") {
299+
t.Errorf("unexpected error message: %q", summaries[0].ErrorMessage)
300+
}
301+
}
302+
303+
// TestClaudeRuntime_ParseTranscriptFile verifies the exported method on
304+
// ClaudeRuntime satisfies the TranscriptHandler interface.
305+
func TestClaudeRuntime_ParseTranscriptFile(t *testing.T) {
306+
dir := t.TempDir()
307+
content := `{"type":"result","subtype":"success","is_error":true,"result":"infrastructure error"}`
308+
path := filepath.Join(dir, "output.jsonl")
309+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
310+
t.Fatal(err)
311+
}
312+
313+
var handler TranscriptHandler = ClaudeRuntime{}
314+
summary, ok := handler.ParseTranscriptFile(path)
315+
if !ok {
316+
t.Fatal("expected result event to be found")
317+
}
318+
if !summary.IsError {
319+
t.Error("expected IsError to be true")
320+
}
321+
}
322+
249323
func TestIsResultLine(t *testing.T) {
250324
tests := []struct {
251325
line string

internal/runtime/transcript.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,9 @@ type TranscriptHandler interface {
99
ExtractTranscripts(sandboxName, agentLabel, outputDir string) error
1010
ExtractDebugLog(sandboxName, localPath, debug string) error
1111
ParseTranscriptErrors(transcriptDir string) []TranscriptError
12+
// ParseTranscriptFile parses a single JSONL transcript or output file
13+
// and returns the last result event, if any. Use this to check a tee'd
14+
// output.jsonl for is_error:true without scanning an entire directory.
15+
ParseTranscriptFile(path string) (TranscriptError, bool)
1216
EmitTranscriptErrors(w io.Writer, summaries []TranscriptError)
1317
}

0 commit comments

Comments
 (0)