• 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

85.19
/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 {
572✔
56
        return c.AppConfig != nil && c.AppConfig.Executor == config.ExecutorCodex
572✔
57
}
572✔
58

59
func toPhaseConfig(c Config) phase.Config {
56✔
60
        return phase.Config{
56✔
61
                PlanDescription:       c.PlanDescription,
56✔
62
                MaxIterations:         c.MaxIterations,
56✔
63
                MaxExternalIterations: c.MaxExternalIterations,
56✔
64
                ReviewPatience:        c.ReviewPatience,
56✔
65
                CodexEnabled:          c.CodexEnabled,
56✔
66
                ExternalReviewToolSet: c.ExternalReviewToolSet,
56✔
67
                FinalizeEnabled:       c.FinalizeEnabled,
56✔
68
                AppConfig:             c.AppConfig,
56✔
69
        }
56✔
70
}
56✔
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 {
56✔
171
        // determine iteration delay from config or default
56✔
172
        iterDelay := DefaultIterationDelay
56✔
173
        if cfg.IterationDelayMs > 0 {
71✔
174
                iterDelay = time.Duration(cfg.IterationDelayMs) * time.Millisecond
15✔
175
        }
15✔
176

177
        // determine task retry count from config
178
        // appConfig.TaskRetryCountSet means user explicitly set it (even to 0 for no retries)
179
        retryCount := 1
56✔
180
        if cfg.AppConfig != nil && cfg.AppConfig.TaskRetryCountSet {
106✔
181
                retryCount = cfg.TaskRetryCount
50✔
182
        } else if cfg.TaskRetryCount > 0 {
57✔
183
                retryCount = cfg.TaskRetryCount
1✔
184
        }
1✔
185

186
        // determine wait-on-limit duration from config
187
        var waitOnLimit time.Duration
56✔
188
        if cfg.AppConfig != nil {
109✔
189
                waitOnLimit = cfg.AppConfig.WaitOnLimit
53✔
190
        }
53✔
191

192
        // if no separate review executor, use the same as task executor
193
        review := execs.Review
56✔
194
        if review == nil {
110✔
195
                review = execs.Task
54✔
196
        }
54✔
197

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

56✔
229
        return &Runner{
56✔
230
                cfg:         cfg,
56✔
231
                log:         log,
56✔
232
                phaseHolder: holder,
56✔
233
                deps:        deps,
56✔
234
                phases:      phases,
56✔
235
        }
56✔
236
}
237

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

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

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

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

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

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

306
        // phase 1: task execution
307
        r.phaseHolder.Set(status.PhaseTask)
11✔
308
        r.log.PrintRaw("starting task execution phase\n")
11✔
309

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

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

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

328
        // phase 2.5+3: external review → post-external review → finalize
329
        if err := r.runExternalAndPostReview(ctx); err != nil {
6✔
330
                return err
×
331
        }
×
332

333
        r.log.Print("all phases completed successfully")
6✔
334
        return nil
6✔
335
}
336

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

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

349
        // phase 2+3: external review → post-external review → finalize
350
        if err := r.runExternalAndPostReview(ctx); err != nil {
9✔
351
                return err
1✔
352
        }
1✔
353

354
        r.log.Print("review phases completed successfully")
7✔
355
        return nil
7✔
356
}
357

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

364
        r.log.Print("codex phases completed successfully")
12✔
365
        return nil
12✔
366
}
367

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

380
        r.phaseHolder.Set(status.PhaseCodex)
17✔
381
        r.log.PrintSection(status.NewGenericSection(tool + " external review"))
17✔
382

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

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

396
        r.phaseHolder.Set(status.PhaseReview)
11✔
397

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

407
        if err := r.phases.finalize.Run(ctx); err != nil {
11✔
NEW
408
                return fmt.Errorf("finalize phase: %w", err)
×
NEW
409
        }
×
410
        return nil
11✔
411
}
412

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

422
        r.phaseHolder.Set(status.PhaseTask)
9✔
423
        r.log.PrintRaw("starting task execution phase\n")
9✔
424

9✔
425
        if err := r.phases.task.Run(ctx); err != nil {
15✔
426
                if errors.Is(err, ErrUserAborted) {
8✔
427
                        r.log.Print("task phase aborted by user")
2✔
428
                        return ErrUserAborted
2✔
429
                }
2✔
430
                return fmt.Errorf("task phase: %w", err)
4✔
431
        }
432

433
        r.log.Print("task execution completed successfully")
3✔
434
        return nil
3✔
435
}
436

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

442
// ErrUserRejectedPlan is returned when user rejects the plan draft.
443
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