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

umputun / ralphex / 26432277551

26 May 2026 04:27AM UTC coverage: 83.252% (-0.1%) from 83.363%
26432277551

Pull #364

github

umputun
fix(processor): guard external review phase holder
Pull Request #364: Refactor processor runner into phase engines

1114 of 1228 new or added lines in 15 files covered. (90.72%)

22 existing lines in 5 files now uncovered.

7680 of 9225 relevant lines covered (83.25%)

222.69 hits per line

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

85.32
/pkg/processor/runner.go
1
// Package processor provides the main orchestration loop for ralphex execution.
2
package processor
3

4
import (
5
        "context"
6
        "errors"
7
        "fmt"
8
        "time"
9

10
        "github.com/umputun/ralphex/pkg/config"
11
        "github.com/umputun/ralphex/pkg/executor"
12
        "github.com/umputun/ralphex/pkg/processor/phase"
13
        "github.com/umputun/ralphex/pkg/status"
14
)
15

16
// DefaultIterationDelay is the pause between iterations to allow system to settle.
17
const DefaultIterationDelay = 2 * time.Second
18

19
// Mode represents the execution mode.
20
type Mode string
21

22
const (
23
        ModeFull      Mode = "full"       // full execution: tasks + reviews + codex
24
        ModeReview    Mode = "review"     // skip tasks, run full review pipeline
25
        ModeCodexOnly Mode = "codex-only" // skip tasks and first review, run only codex loop
26
        ModeTasksOnly Mode = "tasks-only" // run only task phase, skip all reviews
27
        ModePlan      Mode = "plan"       // interactive plan creation mode
28
)
29

30
// Config holds runner configuration.
31
type Config struct {
32
        PlanFile              string         // path to plan file (required for full mode)
33
        PlanDescription       string         // plan description for interactive plan creation mode
34
        ProgressPath          string         // path to progress file
35
        Mode                  Mode           // execution mode
36
        MaxIterations         int            // maximum iterations for task phase
37
        MaxExternalIterations int            // override external review iteration limit (0 = auto)
38
        ReviewPatience        int            // terminate external review after N unchanged rounds (0 = disabled)
39
        Debug                 bool           // enable debug output
40
        NoColor               bool           // disable color output
41
        IterationDelayMs      int            // delay between iterations in milliseconds
42
        TaskRetryCount        int            // number of times to retry failed tasks
43
        TaskModel             string         // model[:effort] spec for task execution; parsed via ParseModelEffort (empty = CLI defaults)
44
        ReviewModel           string         // model[:effort] spec for review phases; empty falls back to TaskModel
45
        CodexEnabled          bool           // whether codex review is enabled
46
        ExternalReviewToolSet bool           // when true, AppConfig.ExternalReviewTool is an explicit choice that overrides codex_enabled=false back-compat
47
        FinalizeEnabled       bool           // whether finalize step is enabled
48
        DefaultBranch         string         // default branch name (detected from repo)
49
        AppConfig             *config.Config // full application config (for executors and prompts)
50
}
51

52
// isCodexExecutor reports whether the configured task/review executor is codex
53
// (the --codex first-class mode). returns false when AppConfig is nil or the
54
// executor is anything else (claude is the default).
55
func (c Config) isCodexExecutor() bool {
576✔
56
        return c.AppConfig != nil && c.AppConfig.Executor == config.ExecutorCodex
576✔
57
}
576✔
58

59
func toPhaseConfig(c Config) phase.Config {
57✔
60
        return phase.Config{
57✔
61
                PlanDescription:       c.PlanDescription,
57✔
62
                MaxIterations:         c.MaxIterations,
57✔
63
                MaxExternalIterations: c.MaxExternalIterations,
57✔
64
                ReviewPatience:        c.ReviewPatience,
57✔
65
                CodexEnabled:          c.CodexEnabled,
57✔
66
                ExternalReviewToolSet: c.ExternalReviewToolSet,
57✔
67
                FinalizeEnabled:       c.FinalizeEnabled,
57✔
68
                AppConfig:             c.AppConfig,
57✔
69
        }
57✔
70
}
57✔
71

72
//go:generate moq -out mocks/executor.go -pkg mocks -skip-ensure -fmt goimports . Executor
73
//go:generate moq -out mocks/logger.go -pkg mocks -skip-ensure -fmt goimports . Logger
74
//go:generate moq -out mocks/input_collector.go -pkg mocks -skip-ensure -fmt goimports . InputCollector
75
//go:generate moq -out mocks/git_checker.go -pkg mocks -skip-ensure -fmt goimports . GitChecker
76

77
// Executor runs CLI commands and returns results.
78
type Executor interface {
79
        Run(ctx context.Context, prompt string) executor.Result
80
}
81

82
// Logger provides logging functionality.
83
type Logger interface {
84
        Print(format string, args ...any)
85
        PrintRaw(format string, args ...any)
86
        PrintSection(section status.Section)
87
        PrintAligned(text string)
88
        LogQuestion(question string, options []string)
89
        LogAnswer(answer string)
90
        LogDraftReview(action string, feedback string)
91
        Path() string
92
}
93

94
// InputCollector provides interactive input collection for plan creation.
95
type InputCollector interface {
96
        AskQuestion(ctx context.Context, question string, options []string) (string, error)
97
        AskDraftReview(ctx context.Context, question string, planContent string) (action string, feedback string, err error)
98
}
99

100
// GitChecker provides git state inspection for the review loop.
101
type GitChecker interface {
102
        HeadHash() (string, error)
103
        DiffFingerprint() (string, error)
104
}
105

106
// Executors groups the executor dependencies for the Runner.
107
// Role-named: Task is used for the task phase, Review for review phases (nil = use Task),
108
// External for the external review phase (nil = no external review), Custom is the
109
// custom external review script executor.
110
type Executors struct {
111
        Task     Executor
112
        Review   Executor // optional: separate executor for review phases (nil = use Task)
113
        External Executor // external review executor (codex or wrapper); nil when Executor=codex or external review disabled
114
        Custom   *executor.CustomExecutor
115
}
116

117
// Runner orchestrates the execution loop.
118
type Runner struct {
119
        cfg         Config
120
        log         Logger
121
        phaseHolder *status.PhaseHolder
122
        deps        *phase.Deps
123
        phases      runnerPhases
124
}
125

126
type taskPhaseRunner interface {
127
        Run(ctx context.Context) error
128
}
129

130
type taskPlanValidator interface {
131
        ValidatePlanHasTasks() error
132
}
133

134
type reviewPhaseRunner interface {
135
        First(ctx context.Context) error
136
        Loop(ctx context.Context, prefix string) error
137
}
138

139
type externalReviewPhaseRunner interface {
140
        Tool() string
141
        Run(ctx context.Context) (phase.ExternalReviewOutcome, error)
142
}
143

144
type finalizePhaseRunner interface {
145
        Run(ctx context.Context) error
146
}
147

148
type planCreationPhaseRunner interface {
149
        Run(ctx context.Context) error
150
}
151

152
type runnerPhases struct {
153
        task          taskPhaseRunner
154
        taskValidator taskPlanValidator
155
        review        reviewPhaseRunner
156
        external      externalReviewPhaseRunner
157
        finalize      finalizePhaseRunner
158
        planCreation  planCreationPhaseRunner
159
}
160

161
// New creates a new Runner with the given configuration and shared phase holder.
162
// If codex is enabled but the binary is not found in PATH, it is automatically disabled with a warning.
163
func New(cfg Config, log Logger, holder *status.PhaseHolder) *Runner {
7✔
164
        factory := &executorFactory{}
7✔
165
        resolvedCfg, execs := factory.Build(cfg, log)
7✔
166
        return NewWithExecutors(resolvedCfg, log, execs, holder)
7✔
167
}
7✔
168

169
// NewWithExecutors creates a new Runner with custom executors (for testing).
170
func NewWithExecutors(cfg Config, log Logger, execs Executors, holder *status.PhaseHolder) *Runner {
57✔
171
        if holder == nil {
58✔
172
                holder = &status.PhaseHolder{}
1✔
173
        }
1✔
174

175
        // determine iteration delay from config or default
176
        iterDelay := DefaultIterationDelay
57✔
177
        if cfg.IterationDelayMs > 0 {
72✔
178
                iterDelay = time.Duration(cfg.IterationDelayMs) * time.Millisecond
15✔
179
        }
15✔
180

181
        // determine task retry count from config
182
        // appConfig.TaskRetryCountSet means user explicitly set it (even to 0 for no retries)
183
        retryCount := 1
57✔
184
        if cfg.AppConfig != nil && cfg.AppConfig.TaskRetryCountSet {
108✔
185
                retryCount = cfg.TaskRetryCount
51✔
186
        } else if cfg.TaskRetryCount > 0 {
58✔
187
                retryCount = cfg.TaskRetryCount
1✔
188
        }
1✔
189

190
        // determine wait-on-limit duration from config
191
        var waitOnLimit time.Duration
57✔
192
        if cfg.AppConfig != nil {
111✔
193
                waitOnLimit = cfg.AppConfig.WaitOnLimit
54✔
194
        }
54✔
195

196
        // if no separate review executor, use the same as task executor
197
        review := execs.Review
57✔
198
        if review == nil {
112✔
199
                review = execs.Task
55✔
200
        }
55✔
201

202
        locator := newPlanLocator(cfg)
57✔
203
        policy := newExecutionPolicy(executionPolicyOpts{cfg: cfg, log: log, waitOnLimit: waitOnLimit})
57✔
204
        prompts := newPromptBuilder(promptBuilderOpts{cfg: cfg, log: log, locator: locator})
57✔
205
        phaseCfg := toPhaseConfig(cfg)
57✔
206
        deps := &phase.Deps{}
57✔
207
        breaks := phase.NewBreakController(deps)
57✔
208
        git := phase.NewGitState(deps, log)
57✔
209
        taskPhase := phase.NewTaskPhase(phase.TaskPhaseOpts{
57✔
210
                Cfg: phaseCfg, Log: log, Exec: execs.Task, Policy: policy, Prompts: prompts,
57✔
211
                Locator: locator, Deps: deps, Breaks: breaks, IterationDelay: iterDelay, RetryCount: retryCount,
57✔
212
        })
57✔
213
        reviewPhase := phase.NewReviewPhase(phase.ReviewPhaseOpts{
57✔
214
                Cfg: phaseCfg, Log: log, Exec: review, Policy: policy, Prompts: prompts,
57✔
215
                Git: git, PhaseHolder: holder, IterationDelay: iterDelay,
57✔
216
        })
57✔
217
        externalPhase := phase.NewExternalReviewPhase(phase.ExternalReviewPhaseOpts{
57✔
218
                Cfg: phaseCfg, Log: log, External: execs.External, Custom: execs.Custom, Review: review,
57✔
219
                Policy: policy, Prompts: prompts, Breaks: breaks, Git: git, PhaseHolder: holder, IterationDelay: iterDelay,
57✔
220
        })
57✔
221
        finalizePhase := phase.NewFinalizePhase(phase.FinalizePhaseOpts{
57✔
222
                Cfg: phaseCfg, Log: log, Exec: review, Policy: policy, Prompts: prompts, PhaseHolder: holder,
57✔
223
        })
57✔
224
        planCreationPhase := phase.NewPlanCreationPhase(phase.PlanCreationPhaseOpts{
57✔
225
                Cfg: phaseCfg, Log: log, Exec: execs.Task, Policy: policy, Prompts: prompts,
57✔
226
                Deps: deps, PhaseHolder: holder, IterationDelay: iterDelay,
57✔
227
        })
57✔
228
        phases := runnerPhases{
57✔
229
                task: taskPhase, taskValidator: taskPhase, review: reviewPhase,
57✔
230
                external: externalPhase, finalize: finalizePhase, planCreation: planCreationPhase,
57✔
231
        }
57✔
232

57✔
233
        return &Runner{
57✔
234
                cfg:         cfg,
57✔
235
                log:         log,
57✔
236
                phaseHolder: holder,
57✔
237
                deps:        deps,
57✔
238
                phases:      phases,
57✔
239
        }
57✔
240
}
241

242
// SetInputCollector sets the input collector for plan creation mode.
243
func (r *Runner) SetInputCollector(c InputCollector) {
1✔
244
        if r.deps == nil {
1✔
NEW
245
                r.deps = &phase.Deps{}
×
NEW
246
        }
×
247
        r.deps.InputCollector = c
1✔
248
}
249

250
// SetGitChecker sets the git checker for no-commit detection in review loops.
UNCOV
251
func (r *Runner) SetGitChecker(g GitChecker) {
×
NEW
252
        if r.deps == nil {
×
NEW
253
                r.deps = &phase.Deps{}
×
NEW
254
        }
×
NEW
255
        r.deps.Git = g
×
256
}
257

258
// SetBreakCh sets the break channel for manual termination of review and task loops.
259
// each value sent on the channel triggers one break event (repeatable, not close-based).
260
func (r *Runner) SetBreakCh(ch <-chan struct{}) {
1✔
261
        if r.deps == nil {
1✔
NEW
262
                r.deps = &phase.Deps{}
×
NEW
263
        }
×
264
        r.deps.BreakCh = ch
1✔
265
}
266

267
// SetPauseHandler sets the callback invoked when a break signal is received during task iteration.
268
// the handler should prompt the user and return true to resume or false to abort.
269
// if nil, break during task phase returns ErrUserAborted immediately.
270
func (r *Runner) SetPauseHandler(fn func(ctx context.Context) bool) {
1✔
271
        if r.deps == nil {
1✔
NEW
272
                r.deps = &phase.Deps{}
×
NEW
273
        }
×
274
        r.deps.PauseHandler = fn
1✔
275
}
276

277
// Run executes the main loop based on configured mode.
278
func (r *Runner) Run(ctx context.Context) error {
48✔
279
        switch r.cfg.Mode {
48✔
280
        case ModeFull:
13✔
281
                return r.runFull(ctx)
13✔
282
        case ModeReview:
8✔
283
                return r.runReviewOnly(ctx)
8✔
284
        case ModeCodexOnly:
12✔
285
                return r.runCodexOnly(ctx)
12✔
286
        case ModeTasksOnly:
12✔
287
                return r.runTasksOnly(ctx)
12✔
288
        case ModePlan:
2✔
289
                if err := r.phases.planCreation.Run(ctx); err != nil {
3✔
290
                        if errors.Is(err, ErrUserRejectedPlan) {
2✔
291
                                return ErrUserRejectedPlan
1✔
292
                        }
1✔
NEW
293
                        return fmt.Errorf("plan creation phase: %w", err)
×
294
                }
295
                return nil
1✔
296
        default:
1✔
297
                return fmt.Errorf("unknown mode: %s", r.cfg.Mode)
1✔
298
        }
299
}
300

301
// runFull executes the complete pipeline: tasks → review → codex → review.
302
func (r *Runner) runFull(ctx context.Context) error {
13✔
303
        if r.cfg.PlanFile == "" {
14✔
304
                return errors.New("plan file required for full mode")
1✔
305
        }
1✔
306
        if err := r.phases.taskValidator.ValidatePlanHasTasks(); err != nil {
13✔
307
                return fmt.Errorf("validate task plan: %w", err)
1✔
308
        }
1✔
309

310
        // phase 1: task execution
311
        r.phaseHolder.Set(status.PhaseTask)
11✔
312
        r.log.PrintRaw("starting task execution phase\n")
11✔
313

11✔
314
        if err := r.phases.task.Run(ctx); err != nil {
16✔
315
                if errors.Is(err, ErrUserAborted) {
6✔
316
                        r.log.Print("task phase aborted by user")
1✔
317
                        return ErrUserAborted
1✔
318
                }
1✔
319
                return fmt.Errorf("task phase: %w", err)
4✔
320
        }
321

322
        // phase 2: first review pass - address ALL findings
323
        if err := r.phases.review.First(ctx); err != nil {
6✔
UNCOV
324
                return fmt.Errorf("first review: %w", err)
×
325
        }
×
326

327
        // phase 2.1: review loop (critical/major) before external review
328
        if err := r.phases.review.Loop(ctx, ""); err != nil {
6✔
NEW
329
                return fmt.Errorf("pre-external review loop: %w", err)
×
UNCOV
330
        }
×
331

332
        // phase 2.5+3: external review → post-external review → finalize
333
        if err := r.runExternalAndPostReview(ctx); err != nil {
6✔
334
                return err
×
335
        }
×
336

337
        r.log.Print("all phases completed successfully")
6✔
338
        return nil
6✔
339
}
340

341
// runReviewOnly executes only the review pipeline: review → external review → review.
342
func (r *Runner) runReviewOnly(ctx context.Context) error {
8✔
343
        // phase 1: first review
8✔
344
        if err := r.phases.review.First(ctx); err != nil {
8✔
UNCOV
345
                return fmt.Errorf("first review: %w", err)
×
UNCOV
346
        }
×
347

348
        // phase 1.1: review loop (critical/major) before external review
349
        if err := r.phases.review.Loop(ctx, ""); err != nil {
8✔
NEW
350
                return fmt.Errorf("pre-external review loop: %w", err)
×
UNCOV
351
        }
×
352

353
        // phase 2+3: external review → post-external review → finalize
354
        if err := r.runExternalAndPostReview(ctx); err != nil {
9✔
355
                return err
1✔
356
        }
1✔
357

358
        r.log.Print("review phases completed successfully")
7✔
359
        return nil
7✔
360
}
361

362
// runCodexOnly executes only the external-review pipeline: external review → review → finalize.
363
func (r *Runner) runCodexOnly(ctx context.Context) error {
12✔
364
        if err := r.runExternalAndPostReview(ctx); err != nil {
12✔
UNCOV
365
                return err
×
UNCOV
366
        }
×
367

368
        r.log.Print("codex phases completed successfully")
12✔
369
        return nil
12✔
370
}
371

372
// runExternalAndPostReview runs the shared external-review → post-review → finalize pipeline.
373
// used by runFull, runReviewOnly, and runCodexOnly to avoid duplicating this sequence.
374
func (r *Runner) runExternalAndPostReview(ctx context.Context) error {
26✔
375
        tool := r.phases.external.Tool()
26✔
376
        if tool == "none" {
35✔
377
                r.log.Print("external review disabled, skipping...")
9✔
378
                if err := r.phases.finalize.Run(ctx); err != nil {
9✔
NEW
379
                        return fmt.Errorf("finalize phase: %w", err)
×
NEW
380
                }
×
381
                return nil
9✔
382
        }
383

384
        r.phaseHolder.Set(status.PhaseCodex)
17✔
385
        r.log.PrintSection(status.NewGenericSection(tool + " external review"))
17✔
386

17✔
387
        outcome, err := r.phases.external.Run(ctx)
17✔
388
        if err != nil {
18✔
389
                return fmt.Errorf("%s loop: %w", tool, err)
1✔
390
        }
1✔
391

392
        if !outcome.HadFindings {
21✔
393
                r.log.Print("external review found no issues, skipping post-%s claude review", tool)
5✔
394
                if err := r.phases.finalize.Run(ctx); err != nil {
5✔
NEW
395
                        return fmt.Errorf("finalize phase: %w", err)
×
NEW
396
                }
×
397
                return nil
5✔
398
        }
399

400
        r.phaseHolder.Set(status.PhaseReview)
11✔
401

11✔
402
        commitPrefix := "IMPORTANT: Before starting the review, run `git status`. " +
11✔
403
                "If there are uncommitted changes from previous review phases, " +
11✔
404
                "stage and commit them with message: " +
11✔
405
                "`fix: address code review findings`\n" +
11✔
406
                "Then continue with the sequence below.\n\n"
11✔
407
        if err := r.phases.review.Loop(ctx, commitPrefix); err != nil {
11✔
NEW
408
                return fmt.Errorf("post-external review loop: %w", err)
×
UNCOV
409
        }
×
410

411
        if err := r.phases.finalize.Run(ctx); err != nil {
11✔
NEW
412
                return fmt.Errorf("finalize phase: %w", err)
×
NEW
413
        }
×
414
        return nil
11✔
415
}
416

417
// runTasksOnly executes only task phase, skipping all reviews.
418
func (r *Runner) runTasksOnly(ctx context.Context) error {
12✔
419
        if r.cfg.PlanFile == "" {
13✔
420
                return errors.New("plan file required for tasks-only mode")
1✔
421
        }
1✔
422
        if err := r.phases.taskValidator.ValidatePlanHasTasks(); err != nil {
12✔
423
                return fmt.Errorf("validate task plan: %w", err)
1✔
424
        }
1✔
425

426
        r.phaseHolder.Set(status.PhaseTask)
10✔
427
        r.log.PrintRaw("starting task execution phase\n")
10✔
428

10✔
429
        if err := r.phases.task.Run(ctx); err != nil {
16✔
430
                if errors.Is(err, ErrUserAborted) {
8✔
431
                        r.log.Print("task phase aborted by user")
2✔
432
                        return ErrUserAborted
2✔
433
                }
2✔
434
                return fmt.Errorf("task phase: %w", err)
4✔
435
        }
436

437
        r.log.Print("task execution completed successfully")
4✔
438
        return nil
4✔
439
}
440

441
// ErrUserAborted is a sentinel error returned when the user aborts or declines to resume after a break
442
// signal (Ctrl+\). it is propagated as a non-nil error so that callers (including mode entrypoints) can
443
// detect it and treat it as a clean user-initiated exit, avoiding further review/finalize steps.
444
var ErrUserAborted = phase.ErrUserAborted
445

446
// ErrUserRejectedPlan is returned when user rejects the plan draft.
447
var ErrUserRejectedPlan = phase.ErrUserRejectedPlan
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