• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / ralphex / 21493195690

29 Jan 2026 08:13PM UTC coverage: 79.83% (+0.2%) from 79.632%
21493195690

push

github

umputun
docs: document error pattern detection feature

Update CLAUDE.md and README.md with error pattern detection configuration.
Move completed plan to docs/plans/completed/.

4049 of 5072 relevant lines covered (79.83%)

123.57 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.25
/pkg/executor/codex.go
1
package executor
2

3
import (
4
        "bufio"
5
        "context"
6
        "errors"
7
        "fmt"
8
        "io"
9
        "os/exec"
10
        "strings"
11
)
12

13
// CodexStreams holds both stderr and stdout from codex command.
14
type CodexStreams struct {
15
        Stderr io.Reader
16
        Stdout io.Reader
17
}
18

19
// CodexRunner abstracts command execution for codex.
20
// Returns both stderr (streaming progress) and stdout (final response).
21
type CodexRunner interface {
22
        Run(ctx context.Context, name string, args ...string) (streams CodexStreams, wait func() error, err error)
23
}
24

25
// execCodexRunner is the default command runner using os/exec for codex.
26
// codex outputs streaming progress to stderr, final response to stdout.
27
type execCodexRunner struct{}
28

29
func (r *execCodexRunner) Run(ctx context.Context, name string, args ...string) (CodexStreams, func() error, error) {
2✔
30
        // check context before starting to avoid spawning a process that will be immediately killed
2✔
31
        if err := ctx.Err(); err != nil {
2✔
32
                return CodexStreams{}, nil, fmt.Errorf("context already canceled: %w", err)
×
33
        }
×
34

35
        // use exec.Command (not CommandContext) because we handle cancellation ourselves
36
        // to ensure the entire process group is killed, not just the direct child
37
        cmd := exec.Command(name, args...) //nolint:noctx // intentional: we handle context cancellation via process group kill
2✔
38

2✔
39
        // create new process group so we can kill all descendants on cleanup
2✔
40
        setupProcessGroup(cmd)
2✔
41

2✔
42
        stderr, err := cmd.StderrPipe()
2✔
43
        if err != nil {
2✔
44
                return CodexStreams{}, nil, fmt.Errorf("stderr pipe: %w", err)
×
45
        }
×
46

47
        stdout, err := cmd.StdoutPipe()
2✔
48
        if err != nil {
2✔
49
                return CodexStreams{}, nil, fmt.Errorf("stdout pipe: %w", err)
×
50
        }
×
51

52
        if err := cmd.Start(); err != nil {
3✔
53
                return CodexStreams{}, nil, fmt.Errorf("start command: %w", err)
1✔
54
        }
1✔
55

56
        // setup process group cleanup with graceful shutdown on context cancellation
57
        cleanup := newProcessGroupCleanup(cmd, ctx.Done())
1✔
58

1✔
59
        return CodexStreams{Stderr: stderr, Stdout: stdout}, cleanup.Wait, nil
1✔
60
}
61

62
// CodexExecutor runs codex CLI commands and filters output.
63
type CodexExecutor struct {
64
        Command         string            // command to execute, defaults to "codex"
65
        Model           string            // model to use, defaults to gpt-5.2-codex
66
        ReasoningEffort string            // reasoning effort level, defaults to "xhigh"
67
        TimeoutMs       int               // stream idle timeout in ms, defaults to 3600000
68
        Sandbox         string            // sandbox mode, defaults to "read-only"
69
        ProjectDoc      string            // path to project documentation file
70
        OutputHandler   func(text string) // called for each filtered output line in real-time
71
        Debug           bool              // enable debug output
72
        ErrorPatterns   []string          // patterns to detect in output (e.g., rate limit messages)
73
        runner          CodexRunner       // for testing, nil uses default
74
}
75

76
// codexFilterState tracks header separator count for filtering.
77
type codexFilterState struct {
78
        headerCount int             // tracks "--------" separators seen (show content between first two)
79
        seen        map[string]bool // track all shown lines for deduplication
80
}
81

82
// Run executes codex CLI with the given prompt and returns filtered output.
83
// stderr is streamed line-by-line to OutputHandler for progress indication.
84
// stdout is captured entirely as the final response (returned in Result.Output).
85
func (e *CodexExecutor) Run(ctx context.Context, prompt string) Result {
17✔
86
        cmd := e.Command
17✔
87
        if cmd == "" {
33✔
88
                cmd = "codex"
16✔
89
        }
16✔
90

91
        model := e.Model
17✔
92
        if model == "" {
33✔
93
                model = "gpt-5.2-codex"
16✔
94
        }
16✔
95

96
        reasoningEffort := e.ReasoningEffort
17✔
97
        if reasoningEffort == "" {
33✔
98
                reasoningEffort = "xhigh"
16✔
99
        }
16✔
100

101
        timeoutMs := e.TimeoutMs
17✔
102
        if timeoutMs <= 0 {
33✔
103
                timeoutMs = 3600000
16✔
104
        }
16✔
105

106
        sandbox := e.Sandbox
17✔
107
        if sandbox == "" {
33✔
108
                sandbox = "read-only"
16✔
109
        }
16✔
110

111
        args := []string{
17✔
112
                "exec",
17✔
113
                "--sandbox", sandbox,
17✔
114
                "-c", fmt.Sprintf("model=%q", model),
17✔
115
                "-c", "model_reasoning_effort=" + reasoningEffort,
17✔
116
                "-c", fmt.Sprintf("stream_idle_timeout_ms=%d", timeoutMs),
17✔
117
        }
17✔
118

17✔
119
        if e.ProjectDoc != "" {
18✔
120
                args = append(args, "-c", fmt.Sprintf("project_doc=%q", e.ProjectDoc))
1✔
121
        }
1✔
122

123
        args = append(args, prompt)
17✔
124

17✔
125
        runner := e.runner
17✔
126
        if runner == nil {
17✔
127
                runner = &execCodexRunner{}
×
128
        }
×
129

130
        streams, wait, err := runner.Run(ctx, cmd, args...)
17✔
131
        if err != nil {
18✔
132
                return Result{Error: fmt.Errorf("start codex: %w", err)}
1✔
133
        }
1✔
134

135
        // process stderr for progress display (header block + bold summaries)
136
        stderrDone := make(chan error, 1)
16✔
137
        go func() {
32✔
138
                stderrDone <- e.processStderr(ctx, streams.Stderr)
16✔
139
        }()
16✔
140

141
        // read stdout entirely as final response
142
        stdoutContent, stdoutErr := e.readStdout(streams.Stdout)
16✔
143

16✔
144
        // wait for stderr processing to complete
16✔
145
        stderrErr := <-stderrDone
16✔
146

16✔
147
        // wait for command completion
16✔
148
        waitErr := wait()
16✔
149

16✔
150
        // determine final error (prefer stderr/stdout errors over wait error)
16✔
151
        var finalErr error
16✔
152
        switch {
16✔
153
        case stderrErr != nil && !errors.Is(stderrErr, context.Canceled):
1✔
154
                finalErr = stderrErr
1✔
155
        case stdoutErr != nil:
×
156
                finalErr = stdoutErr
×
157
        case waitErr != nil:
2✔
158
                if ctx.Err() != nil {
3✔
159
                        finalErr = fmt.Errorf("context error: %w", ctx.Err())
1✔
160
                } else {
2✔
161
                        finalErr = fmt.Errorf("codex exited with error: %w", waitErr)
1✔
162
                }
1✔
163
        }
164

165
        // detect signal in stdout (the actual response)
166
        signal := detectSignal(stdoutContent)
16✔
167

16✔
168
        // check for error patterns in output
16✔
169
        if pattern := checkErrorPatterns(stdoutContent, e.ErrorPatterns); pattern != "" {
20✔
170
                return Result{
4✔
171
                        Output: stdoutContent,
4✔
172
                        Signal: signal,
4✔
173
                        Error:  &PatternMatchError{Pattern: pattern, HelpCmd: "codex /status"},
4✔
174
                }
4✔
175
        }
4✔
176

177
        // return stdout content as the result (the actual answer from codex)
178
        return Result{Output: stdoutContent, Signal: signal, Error: finalErr}
12✔
179
}
180

181
// processStderr reads stderr line-by-line, filters for progress display.
182
// shows header block (between first two "--------" separators) and bold summaries.
183
func (e *CodexExecutor) processStderr(ctx context.Context, r io.Reader) error {
21✔
184
        state := &codexFilterState{}
21✔
185
        scanner := bufio.NewScanner(r)
21✔
186
        // increase buffer size for large output lines
21✔
187
        buf := make([]byte, 0, 64*1024)
21✔
188
        scanner.Buffer(buf, maxScannerBuffer)
21✔
189

21✔
190
        for scanner.Scan() {
56✔
191
                select {
35✔
192
                case <-ctx.Done():
1✔
193
                        return fmt.Errorf("context done: %w", ctx.Err())
1✔
194
                default:
34✔
195
                }
196

197
                line := scanner.Text()
34✔
198
                if show, filtered := e.shouldDisplay(line, state); show {
63✔
199
                        if e.OutputHandler != nil {
49✔
200
                                e.OutputHandler(filtered + "\n")
20✔
201
                        }
20✔
202
                }
203
        }
204

205
        if err := scanner.Err(); err != nil {
22✔
206
                return fmt.Errorf("read stderr: %w", err)
2✔
207
        }
2✔
208
        return nil
18✔
209
}
210

211
// readStdout reads the entire stdout content as the final response.
212
func (e *CodexExecutor) readStdout(r io.Reader) (string, error) {
18✔
213
        data, err := io.ReadAll(r)
18✔
214
        if err != nil {
19✔
215
                return "", fmt.Errorf("read stdout: %w", err)
1✔
216
        }
1✔
217
        return string(data), nil
17✔
218
}
219

220
// shouldDisplay implements a simple filter for codex stderr output.
221
// shows: header block (between first two "--------" separators) and bold summaries.
222
// also deduplicates lines to avoid non-consecutive repeats.
223
func (e *CodexExecutor) shouldDisplay(line string, state *codexFilterState) (bool, string) {
71✔
224
        s := strings.TrimSpace(line)
71✔
225
        if s == "" {
75✔
226
                return false, ""
4✔
227
        }
4✔
228

229
        var show bool
67✔
230
        var filtered string
67✔
231
        var skipDedup bool // separators are not deduplicated
67✔
232

67✔
233
        switch {
67✔
234
        case strings.HasPrefix(s, "--------"):
26✔
235
                // track "--------" separators for header block
26✔
236
                state.headerCount++
26✔
237
                show = state.headerCount <= 2 // show first two separators
26✔
238
                filtered = line
26✔
239
                skipDedup = true // don't deduplicate separators
26✔
240
        case state.headerCount == 1:
17✔
241
                // show everything between first two separators (header block)
17✔
242
                show = true
17✔
243
                filtered = line
17✔
244
        case strings.HasPrefix(s, "**"):
16✔
245
                // show bold summaries after header (progress indication)
16✔
246
                show = true
16✔
247
                filtered = e.stripBold(s)
16✔
248
        }
249

250
        // check for duplicates before returning (except separators)
251
        if show && !skipDedup {
100✔
252
                if state.seen == nil {
48✔
253
                        state.seen = make(map[string]bool)
15✔
254
                }
15✔
255
                if state.seen[filtered] {
37✔
256
                        return false, "" // skip duplicate
4✔
257
                }
4✔
258
                state.seen[filtered] = true
29✔
259
        }
260

261
        return show, filtered
63✔
262
}
263

264
// stripBold removes markdown bold markers (**text**) from text.
265
func (e *CodexExecutor) stripBold(s string) string {
22✔
266
        // replace **text** with text
22✔
267
        result := s
22✔
268
        for {
65✔
269
                start := strings.Index(result, "**")
43✔
270
                if start == -1 {
64✔
271
                        break
21✔
272
                }
273
                end := strings.Index(result[start+2:], "**")
22✔
274
                if end == -1 {
23✔
275
                        break
1✔
276
                }
277
                // remove both markers
278
                result = result[:start] + result[start+2:start+2+end] + result[start+2+end+2:]
21✔
279
        }
280
        return result
22✔
281
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc