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

umputun / ralphex / 26409766960

25 May 2026 04:15PM UTC coverage: 83.319% (-0.01%) from 83.33%
26409766960

Pull #362

github

umputun
fix(config): match Claude Code "session limit" wording in default patterns

Claude Code emits "You've hit your session limit · resets …" which does not
substring-match the existing "You've hit your limit" default — "session" sits
between "your" and "limit", so the limit detection misses it. Result: without
--wait, ralphex exited with a raw runner error; with --wait, the retry loop
never engaged.

Add "You've hit your session limit" to both claude_error_patterns and
claude_limit_patterns defaults so --wait retries through the reset window
and users without --wait still get a graceful exit (follows the #317
precedent for new Anthropic-wording additions).

Related to #361
Pull Request #362: fix(config): detect Claude session-limit message

7497 of 8998 relevant lines covered (83.32%)

231.34 hits per line

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

85.29
/pkg/executor/custom.go
1
package executor
2

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

12
// CustomRunner abstracts command execution for custom review scripts.
13
// Returns stdout reader and a wait function for completion.
14
type CustomRunner interface {
15
        Run(ctx context.Context, script, promptFile string) (stdout io.Reader, wait func() error, err error)
16
}
17

18
// execCustomRunner is the default command runner using os/exec.
19
type execCustomRunner struct{}
20

21
func (r *execCustomRunner) Run(ctx context.Context, script, promptFile string) (io.Reader, func() error, error) {
3✔
22
        // check context before starting to avoid spawning a process that will be immediately killed
3✔
23
        if err := ctx.Err(); err != nil {
4✔
24
                return nil, nil, fmt.Errorf("context already canceled: %w", err)
1✔
25
        }
1✔
26

27
        // use exec.Command (not CommandContext) because we handle cancellation ourselves
28
        // to ensure the entire process group is killed, not just the direct child
29
        cmd := exec.Command(script, promptFile) //nolint:noctx // intentional: we handle context cancellation via process group kill
2✔
30

2✔
31
        // create new process group so we can kill all descendants on cleanup
2✔
32
        setupProcessGroup(cmd)
2✔
33

2✔
34
        stdout, err := cmd.StdoutPipe()
2✔
35
        if err != nil {
2✔
36
                return nil, nil, fmt.Errorf("stdout pipe: %w", err)
×
37
        }
×
38
        // merge stderr into stdout
39
        cmd.Stderr = cmd.Stdout
2✔
40

2✔
41
        if err := cmd.Start(); err != nil {
3✔
42
                return nil, nil, fmt.Errorf("start command: %w", err)
1✔
43
        }
1✔
44

45
        // setup process group cleanup with graceful shutdown on context cancellation
46
        cleanup := newProcessGroupCleanup(cmd, ctx.Done())
1✔
47

1✔
48
        return stdout, cleanup.Wait, nil
1✔
49
}
50

51
// CustomExecutor runs custom review scripts and streams output.
52
type CustomExecutor struct {
53
        Script        string            // path to the custom review script
54
        OutputHandler func(text string) // called for each output line, can be nil
55
        ErrorPatterns []string          // patterns to detect in output (e.g., rate limit messages)
56
        LimitPatterns []string          // patterns to detect rate limits (checked before error patterns)
57
        runner        CustomRunner      // for testing, nil uses default
58
}
59

60
// SetRunner sets the custom runner for testing purposes.
61
func (e *CustomExecutor) SetRunner(r CustomRunner) {
×
62
        e.runner = r
×
63
}
×
64

65
// Run executes the custom review script with the prompt content written to a temp file.
66
// The script receives the path to the prompt file as its single argument.
67
// Output is streamed line-by-line to OutputHandler.
68
func (e *CustomExecutor) Run(ctx context.Context, promptContent string) Result {
27✔
69
        if e.Script == "" {
28✔
70
                return Result{Error: errors.New("custom review script not configured")}
1✔
71
        }
1✔
72

73
        // write prompt to temp file
74
        promptFile, err := os.CreateTemp("", "ralphex-custom-prompt-*.txt")
26✔
75
        if err != nil {
26✔
76
                return Result{Error: fmt.Errorf("create prompt file: %w", err)}
×
77
        }
×
78
        promptPath := promptFile.Name()
26✔
79
        defer os.Remove(promptPath) //nolint:errcheck // cleanup temp file
26✔
80

26✔
81
        if _, writeErr := promptFile.WriteString(promptContent); writeErr != nil {
26✔
82
                promptFile.Close()
×
83
                return Result{Error: fmt.Errorf("write prompt file: %w", writeErr)}
×
84
        }
×
85
        if closeErr := promptFile.Close(); closeErr != nil {
26✔
86
                return Result{Error: fmt.Errorf("close prompt file: %w", closeErr)}
×
87
        }
×
88

89
        runner := e.runner
26✔
90
        if runner == nil {
26✔
91
                runner = &execCustomRunner{}
×
92
        }
×
93

94
        stdout, wait, err := runner.Run(ctx, e.Script, promptPath)
26✔
95
        if err != nil {
27✔
96
                return Result{Error: fmt.Errorf("start custom script: %w", err)}
1✔
97
        }
1✔
98

99
        // process stdout for output and signal detection
100
        output, signal, streamErr := e.processOutput(ctx, stdout)
25✔
101

25✔
102
        // wait for command completion
25✔
103
        waitErr := wait()
25✔
104

25✔
105
        // determine final error
25✔
106
        var finalErr error
25✔
107
        switch {
25✔
108
        case streamErr != nil:
2✔
109
                finalErr = streamErr
2✔
110
        case waitErr != nil:
9✔
111
                if ctx.Err() != nil {
9✔
112
                        finalErr = fmt.Errorf("context error: %w", ctx.Err())
×
113
                } else {
9✔
114
                        finalErr = fmt.Errorf("custom script exited with error: %w", waitErr)
9✔
115
                }
9✔
116
        }
117

118
        // only check error/limit patterns when the process failed (non-zero exit or stream error).
119
        // when script exits cleanly, pattern matches in output are false positives from findings.
120
        // skip pattern checks on context cancellation — cancellation must propagate as-is.
121
        if finalErr != nil && ctx.Err() == nil {
34✔
122
                // check limit patterns first (higher priority)
9✔
123
                if pattern := matchPattern(output, e.LimitPatterns); pattern != "" {
11✔
124
                        return Result{
2✔
125
                                Output: output,
2✔
126
                                Signal: signal,
2✔
127
                                Error:  &LimitPatternError{Pattern: pattern, HelpCmd: e.Script + " --help"},
2✔
128
                        }
2✔
129
                }
2✔
130

131
                // check for error patterns in output
132
                if pattern := matchPattern(output, e.ErrorPatterns); pattern != "" {
10✔
133
                        return Result{
3✔
134
                                Output: output,
3✔
135
                                Signal: signal,
3✔
136
                                Error:  &PatternMatchError{Pattern: pattern, HelpCmd: e.Script + " --help"},
3✔
137
                        }
3✔
138
                }
3✔
139
        }
140

141
        return Result{Output: output, Signal: signal, Error: finalErr}
20✔
142
}
143

144
// processOutput reads stdout line-by-line, streams to OutputHandler, and detects signals.
145
func (e *CustomExecutor) processOutput(ctx context.Context, r io.Reader) (output, signal string, err error) {
27✔
146
        var outputBuf []byte
27✔
147
        var sig string
27✔
148

27✔
149
        readErr := readLines(ctx, r, func(line string) {
64✔
150
                outputBuf = append(outputBuf, line...)
37✔
151
                outputBuf = append(outputBuf, '\n')
37✔
152

37✔
153
                if e.OutputHandler != nil {
44✔
154
                        e.OutputHandler(line + "\n")
7✔
155
                }
7✔
156

157
                // check for signals in each line
158
                if s := detectSignal(line); s != "" {
46✔
159
                        sig = s
9✔
160
                }
9✔
161
        })
162

163
        if readErr != nil {
31✔
164
                return string(outputBuf), sig, fmt.Errorf("read output: %w", readErr)
4✔
165
        }
4✔
166
        return string(outputBuf), sig, nil
23✔
167
}
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