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

umputun / ralphex / 26431227500

26 May 2026 03:44AM UTC coverage: 83.252% (-0.1%) from 83.363%
26431227500

Pull #364

github

umputun
docs: document processor phase architecture

Archive the completed implementation plan and update project guidance with the phase package boundaries.
Pull Request #364: Refactor processor runner into phase engines

1101 of 1215 new or added lines in 15 files covered. (90.62%)

20 existing lines in 4 files now uncovered.

7670 of 9213 relevant lines covered (83.25%)

222.9 hits per line

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

91.46
/pkg/processor/executor_factory.go
1
package processor
2

3
import (
4
        "os"
5
        "os/exec"
6
        "path/filepath"
7
        "strings"
8
        "sync"
9

10
        "github.com/umputun/ralphex/pkg/config"
11
        "github.com/umputun/ralphex/pkg/executor"
12
)
13

14
type executorFactory struct{}
15

16
func (f *executorFactory) Build(cfg Config, log Logger) (Config, Executors) {
27✔
17
        customExec := cfg.buildCustomExecutor(log)
27✔
18

27✔
19
        if cfg.isCodexExecutor() {
42✔
20
                if cfg.AppConfig.PassClaudeMd {
18✔
21
                        maybeEmitClaudeMdSetupHint(log)
3✔
22
                }
3✔
23
                codexTask, codexReview := cfg.buildCodexExecutors(log)
15✔
24
                return cfg, Executors{Task: codexTask, Review: codexReview, Custom: customExec}
15✔
25
        }
26

27
        claudeExec, reviewExec := cfg.buildClaudeExecutors(log)
12✔
28
        codexExec := cfg.buildExternalCodexExecutor(log)
12✔
29

12✔
30
        if cfg.CodexEnabled && f.needsCodexBinary(cfg.AppConfig) {
13✔
31
                codexCmd := codexExec.Command
1✔
32
                if codexCmd == "" {
1✔
NEW
33
                        codexCmd = "codex"
×
NEW
34
                }
×
35
                if _, err := exec.LookPath(codexCmd); err != nil {
2✔
36
                        log.Print("warning: codex not found (%s: %v), disabling codex review phase", codexCmd, err)
1✔
37
                        cfg.CodexEnabled = false
1✔
38
                }
1✔
39
        }
40

41
        return cfg, Executors{Task: claudeExec, Review: reviewExec, External: codexExec, Custom: customExec}
12✔
42
}
43

44
// buildClaudeExecutors constructs the claude executors for task and review phases.
45
// returns a single executor in the Review slot only when review_model differs from
46
// the task executor model — otherwise the task executor handles both roles.
47
func (cfg Config) buildClaudeExecutors(log Logger) (*executor.ClaudeExecutor, Executor) {
12✔
48
        claudeExec := &executor.ClaudeExecutor{
12✔
49
                OutputHandler: func(text string) {
12✔
NEW
50
                        log.PrintAligned(text)
×
NEW
51
                },
×
52
                Debug: cfg.Debug,
53
        }
54
        cfg.applyClaudeAppConfig(claudeExec)
12✔
55

12✔
56
        taskModel, taskEffort := ParseModelEffort(cfg.TaskModel)
12✔
57
        claudeExec.Model, claudeExec.Effort = taskModel, taskEffort
12✔
58

12✔
59
        reviewSpec := cfg.ReviewModel
12✔
60
        if reviewSpec == "" {
21✔
61
                reviewSpec = cfg.TaskModel
9✔
62
        }
9✔
63
        reviewModel, reviewEffort := ParseModelEffort(reviewSpec)
12✔
64
        if reviewModel == taskModel && reviewEffort == taskEffort {
22✔
65
                return claudeExec, nil
10✔
66
        }
10✔
67

68
        reviewExec := &executor.ClaudeExecutor{
2✔
69
                OutputHandler: claudeExec.OutputHandler,
2✔
70
                Debug:         cfg.Debug,
2✔
71
                Model:         reviewModel,
2✔
72
                Effort:        reviewEffort,
2✔
73
        }
2✔
74
        cfg.applyClaudeAppConfig(reviewExec)
2✔
75
        return claudeExec, reviewExec
2✔
76
}
77

78
// applyClaudeAppConfig copies AppConfig-sourced fields onto a claude executor.
79
// no-op when AppConfig is nil.
80
func (cfg Config) applyClaudeAppConfig(e *executor.ClaudeExecutor) {
14✔
81
        if cfg.AppConfig == nil {
14✔
NEW
82
                return
×
NEW
83
        }
×
84
        e.Command = cfg.AppConfig.ClaudeCommand
14✔
85
        e.Args = cfg.AppConfig.ClaudeArgs
14✔
86
        e.ArgsSet = cfg.AppConfig.ClaudeArgsSet
14✔
87
        e.ErrorPatterns = cfg.AppConfig.ClaudeErrorPatterns
14✔
88
        e.LimitPatterns = cfg.AppConfig.ClaudeLimitPatterns
14✔
89
        e.IdleTimeout = cfg.AppConfig.IdleTimeout
14✔
90
        e.PreserveAPIKey = cfg.AppConfig.PreserveAnthropicAPIKey
14✔
91
}
92

93
// buildExternalCodexExecutor builds the codex executor used for the external review
94
// phase in claude mode. MultiAgent stays off (the external review prompt does not use
95
// spawn_agent) and PassClaudeMd stays off (rejected for claude mode by applyCodexOverrides).
96
func (cfg Config) buildExternalCodexExecutor(log Logger) *executor.CodexExecutor {
12✔
97
        e := cfg.newBaseCodexExecutor(log)
12✔
98
        if cfg.AppConfig != nil {
24✔
99
                e.Sandbox = cfg.AppConfig.CodexSandbox
12✔
100
        }
12✔
101
        return e
12✔
102
}
103

104
// buildCodexExecutor builds the codex executor used for first-class --codex mode.
105
// MultiAgent is always enabled so any phase (task, review, finalize) can spawn sub-agents,
106
// and PassClaudeMd is sourced from config. IdleTimeout is wired here (and only here)
107
// because the user explicitly opted into --codex; the external-review codex used in
108
// claude mode keeps master semantics with no idle timeout.
109
func (cfg Config) buildCodexExecutor(log Logger) *executor.CodexExecutor {
18✔
110
        e := cfg.newBaseCodexExecutor(log)
18✔
111
        e.MultiAgent = true
18✔
112
        if cfg.AppConfig != nil {
36✔
113
                e.Sandbox = cfg.AppConfig.CodexExecutorSandbox()
18✔
114
                e.PassClaudeMd = cfg.AppConfig.PassClaudeMd
18✔
115
                e.IdleTimeout = cfg.AppConfig.IdleTimeout
18✔
116
        }
18✔
117
        return e
18✔
118
}
119

120
// buildCodexExecutors constructs the codex executors for the task and review phases
121
// in first-class --codex mode. the review slot is non-nil only when the resolved
122
// review model/effort differs from task — otherwise the task executor handles review
123
// and finalize too. --task-model / --review-model (and their config equivalents) are
124
// resolved against codex_model / codex_reasoning_effort: review_model falls back to
125
// task_model when unset, and an unset spec inherits the codex config defaults.
126
func (cfg Config) buildCodexExecutors(log Logger) (*executor.CodexExecutor, Executor) {
15✔
127
        var defModel, defEffort string
15✔
128
        if cfg.AppConfig != nil {
30✔
129
                defModel, defEffort = cfg.AppConfig.CodexModel, cfg.AppConfig.CodexReasoningEffort
15✔
130
        }
15✔
131
        taskModel, taskEffort, _ := ResolveCodexModelEffort(cfg.TaskModel, defModel, defEffort)
15✔
132
        reviewModel, reviewEffort := taskModel, taskEffort
15✔
133
        if cfg.ReviewModel != "" {
18✔
134
                reviewModel, reviewEffort, _ = ResolveCodexModelEffort(cfg.ReviewModel, defModel, defEffort)
3✔
135
        }
3✔
136

137
        taskExec := cfg.buildCodexExecutor(log)
15✔
138
        taskExec.Model, taskExec.ReasoningEffort = taskModel, taskEffort
15✔
139
        if reviewModel == taskModel && reviewEffort == taskEffort {
27✔
140
                return taskExec, nil
12✔
141
        }
12✔
142
        reviewExec := cfg.buildCodexExecutor(log)
3✔
143
        reviewExec.Model, reviewExec.ReasoningEffort = reviewModel, reviewEffort
3✔
144
        return taskExec, reviewExec
3✔
145
}
146

147
// newBaseCodexExecutor returns a CodexExecutor populated with the fields shared
148
// between the external-review and first-class --codex builders. Callers layer on
149
// Sandbox, MultiAgent, PassClaudeMd, and IdleTimeout as appropriate for their
150
// role — see buildCodexExecutor (first-class) and buildExternalCodexExecutor
151
// (claude mode). IdleTimeout is intentionally NOT set here: applying it to the
152
// external codex review path silently shortened previously-idle-tolerant
153
// review sessions for default-claude users, so it is wired only by
154
// buildCodexExecutor where the user opted into --codex.
155
func (cfg Config) newBaseCodexExecutor(log Logger) *executor.CodexExecutor {
30✔
156
        e := &executor.CodexExecutor{
30✔
157
                OutputHandler: func(text string) { log.PrintAligned(text) },
30✔
158
                Debug:         cfg.Debug,
159
        }
160
        if cfg.AppConfig == nil {
30✔
NEW
161
                return e
×
NEW
162
        }
×
163
        e.Command = cfg.AppConfig.CodexCommand
30✔
164
        e.Model = cfg.AppConfig.CodexModel
30✔
165
        e.ReasoningEffort = cfg.AppConfig.CodexReasoningEffort
30✔
166
        e.TimeoutMs = cfg.AppConfig.CodexTimeoutMs
30✔
167
        e.ErrorPatterns = cfg.AppConfig.CodexErrorPatterns
30✔
168
        e.LimitPatterns = cfg.AppConfig.CodexLimitPatterns
30✔
169
        return e
30✔
170
}
171

172
// buildCustomExecutor returns the optional custom external review executor.
173
// returns nil when no custom_review_script is configured.
174
func (cfg Config) buildCustomExecutor(log Logger) *executor.CustomExecutor {
27✔
175
        if cfg.AppConfig == nil || cfg.AppConfig.CustomReviewScript == "" {
53✔
176
                return nil
26✔
177
        }
26✔
178
        return &executor.CustomExecutor{
1✔
179
                Script: cfg.AppConfig.CustomReviewScript,
1✔
180
                OutputHandler: func(text string) {
1✔
NEW
181
                        log.PrintAligned(text)
×
NEW
182
                },
×
183
                ErrorPatterns: cfg.AppConfig.CodexErrorPatterns,
184
                LimitPatterns: cfg.AppConfig.CodexLimitPatterns,
185
        }
186
}
187

188
// claudeMdHintOnce ensures the user-level CLAUDE.md setup hint emits at most once
189
// per process, regardless of how many runners or phases are constructed.
190
var claudeMdHintOnce sync.Once
191

192
// maybeEmitClaudeMdSetupHint prints a one-time hint when ~/.claude/CLAUDE.md exists
193
// but ~/.codex/AGENTS.md does not. ralphex never creates the symlink itself; the
194
// user owns ~/.codex/. probing errors are swallowed so a missing or unreadable
195
// home directory simply suppresses the hint.
196
func maybeEmitClaudeMdSetupHint(log Logger) {
7✔
197
        claudeMdHintOnce.Do(func() {
12✔
198
                home, err := os.UserHomeDir()
5✔
199
                if err != nil || home == "" {
5✔
NEW
200
                        return
×
NEW
201
                }
×
202
                claudeMd := filepath.Join(home, ".claude", "CLAUDE.md")
5✔
203
                codexAgents := filepath.Join(home, ".codex", "AGENTS.md")
5✔
204
                if !fileExists(claudeMd) {
7✔
205
                        return
2✔
206
                }
2✔
207
                if fileExists(codexAgents) {
4✔
208
                        return
1✔
209
                }
1✔
210
                log.Print("hint: ~/.claude/CLAUDE.md exists but ~/.codex/AGENTS.md does not. " +
2✔
211
                        "to get user-level CLAUDE.md content into codex, link it: " +
2✔
212
                        "ln -s ~/.claude/CLAUDE.md ~/.codex/AGENTS.md")
2✔
213
        })
214
}
215

216
func fileExists(path string) bool {
8✔
217
        _, err := os.Stat(path)
8✔
218
        return err == nil
8✔
219
}
8✔
220

221
// needsCodexBinary returns true when external codex review needs the codex binary.
222
// first-class codex executor dependency checks happen in cmd/ralphex before runner construction.
223
func (*executorFactory) needsCodexBinary(appConfig *config.Config) bool {
3✔
224
        if appConfig == nil {
3✔
NEW
225
                return true
×
NEW
226
        }
×
227
        switch appConfig.ExternalReviewTool {
3✔
228
        case "custom", "none":
2✔
229
                return false
2✔
230
        default:
1✔
231
                return true
1✔
232
        }
233
}
234

235
// ParseModelEffort splits a "model[:effort]" spec into separate parts.
236
// Used by New to parse phase model config values into the ClaudeExecutor.Model
237
// and ClaudeExecutor.Effort fields.
238
// Empty input returns ("", ""). Missing colon returns (s, "").
239
// A leading colon (":high") returns ("", "high"); a trailing colon ("opus:") returns ("opus", "").
240
// Only the first colon is treated as the separator; anything after is passed through as effort.
241
func ParseModelEffort(s string) (model, effort string) {
47✔
242
        model, effort, _ = strings.Cut(s, ":")
47✔
243
        return model, effort
47✔
244
}
47✔
245

246
// ResolveCodexModelEffort resolves a "model[:effort]" spec against codex default
247
// model and effort. an empty spec returns the defaults unchanged. each populated
248
// half of the spec overrides its default. the claude-only "max" effort is not valid
249
// for codex: maxDropped reports that the spec requested it (the caller surfaces the
250
// warning) and the default effort is kept.
251
func ResolveCodexModelEffort(spec, defModel, defEffort string) (model, effort string, maxDropped bool) {
28✔
252
        model, effort = defModel, defEffort
28✔
253
        if spec == "" {
40✔
254
                return model, effort, false
12✔
255
        }
12✔
256
        m, e := ParseModelEffort(spec)
16✔
257
        if m != "" {
28✔
258
                model = m
12✔
259
        }
12✔
260
        if e == "" {
21✔
261
                return model, effort, false
5✔
262
        }
5✔
263
        if strings.EqualFold(e, "max") {
13✔
264
                return model, effort, true
2✔
265
        }
2✔
266
        return model, e, false
9✔
267
}
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