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

umputun / ralphex / 21343412266

26 Jan 2026 01:36AM UTC coverage: 78.401% (+0.2%) from 78.234%
21343412266

push

github

umputun
test: add tests for plan mode functions

improve coverage for plan creation mode:
- cmd/ralphex: add tests for setupRunnerLogger, getCurrentBranch,
  setupGitForExecution, handlePostExecution, validateFlags,
  printStartupInfo, findRecentPlan
- pkg/processor: add direct test for buildPlanPrompt

coverage improved from 37.6% to 50.4% for cmd/ralphex

3245 of 4139 relevant lines covered (78.4%)

67.54 hits per line

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

76.66
/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
        "os"
9
        "path/filepath"
10
        "strings"
11
        "time"
12

13
        "github.com/umputun/ralphex/pkg/config"
14
        "github.com/umputun/ralphex/pkg/executor"
15
)
16

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

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

23
const (
24
        ModeFull      Mode = "full"       // full execution: tasks + reviews + codex
25
        ModeReview    Mode = "review"     // skip tasks, run full review pipeline
26
        ModeCodexOnly Mode = "codex-only" // skip tasks and first review, run only codex loop
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
        Debug            bool           // enable debug output
38
        NoColor          bool           // disable color output
39
        IterationDelayMs int            // delay between iterations in milliseconds
40
        TaskRetryCount   int            // number of times to retry failed tasks
41
        CodexEnabled     bool           // whether codex review is enabled
42
        AppConfig        *config.Config // full application config (for executors and prompts)
43
}
44

45
//go:generate moq -out mocks/executor.go -pkg mocks -skip-ensure -fmt goimports . Executor
46
//go:generate moq -out mocks/logger.go -pkg mocks -skip-ensure -fmt goimports . Logger
47
//go:generate moq -out mocks/input_collector.go -pkg mocks -skip-ensure -fmt goimports . InputCollector
48

49
// Executor runs CLI commands and returns results.
50
type Executor interface {
51
        Run(ctx context.Context, prompt string) executor.Result
52
}
53

54
// Logger provides logging functionality.
55
type Logger interface {
56
        SetPhase(phase Phase)
57
        Print(format string, args ...any)
58
        PrintRaw(format string, args ...any)
59
        PrintSection(section Section)
60
        PrintAligned(text string)
61
        LogQuestion(question string, options []string)
62
        LogAnswer(answer string)
63
        Path() string
64
}
65

66
// InputCollector provides interactive input collection for plan creation.
67
type InputCollector interface {
68
        AskQuestion(ctx context.Context, question string, options []string) (string, error)
69
}
70

71
// Runner orchestrates the execution loop.
72
type Runner struct {
73
        cfg            Config
74
        log            Logger
75
        claude         Executor
76
        codex          Executor
77
        inputCollector InputCollector
78
        iterationDelay time.Duration
79
        taskRetryCount int
80
}
81

82
// New creates a new Runner with the given configuration.
83
func New(cfg Config, log Logger) *Runner {
×
84
        // build claude executor with config values
×
85
        claudeExec := &executor.ClaudeExecutor{
×
86
                OutputHandler: func(text string) {
×
87
                        log.PrintAligned(text)
×
88
                },
×
89
                Debug: cfg.Debug,
90
        }
91
        if cfg.AppConfig != nil {
×
92
                claudeExec.Command = cfg.AppConfig.ClaudeCommand
×
93
                claudeExec.Args = cfg.AppConfig.ClaudeArgs
×
94
        }
×
95

96
        // build codex executor with config values
97
        codexExec := &executor.CodexExecutor{
×
98
                OutputHandler: func(text string) {
×
99
                        log.PrintAligned(text)
×
100
                },
×
101
                Debug: cfg.Debug,
102
        }
103
        if cfg.AppConfig != nil {
×
104
                codexExec.Command = cfg.AppConfig.CodexCommand
×
105
                codexExec.Model = cfg.AppConfig.CodexModel
×
106
                codexExec.ReasoningEffort = cfg.AppConfig.CodexReasoningEffort
×
107
                codexExec.TimeoutMs = cfg.AppConfig.CodexTimeoutMs
×
108
                codexExec.Sandbox = cfg.AppConfig.CodexSandbox
×
109
        }
×
110

111
        return NewWithExecutors(cfg, log, claudeExec, codexExec)
×
112
}
113

114
// NewWithExecutors creates a new Runner with custom executors (for testing).
115
func NewWithExecutors(cfg Config, log Logger, claude, codex Executor) *Runner {
31✔
116
        // determine iteration delay from config or default
31✔
117
        iterDelay := DefaultIterationDelay
31✔
118
        if cfg.IterationDelayMs > 0 {
40✔
119
                iterDelay = time.Duration(cfg.IterationDelayMs) * time.Millisecond
9✔
120
        }
9✔
121

122
        // determine task retry count from config
123
        // appConfig.TaskRetryCountSet means user explicitly set it (even to 0 for no retries)
124
        retryCount := 1
31✔
125
        if cfg.AppConfig != nil && cfg.AppConfig.TaskRetryCountSet {
53✔
126
                retryCount = cfg.TaskRetryCount
22✔
127
        } else if cfg.TaskRetryCount > 0 {
32✔
128
                retryCount = cfg.TaskRetryCount
1✔
129
        }
1✔
130

131
        return &Runner{
31✔
132
                cfg:            cfg,
31✔
133
                log:            log,
31✔
134
                claude:         claude,
31✔
135
                codex:          codex,
31✔
136
                iterationDelay: iterDelay,
31✔
137
                taskRetryCount: retryCount,
31✔
138
        }
31✔
139
}
140

141
// SetInputCollector sets the input collector for plan creation mode.
142
func (r *Runner) SetInputCollector(c InputCollector) {
8✔
143
        r.inputCollector = c
8✔
144
}
8✔
145

146
// Run executes the main loop based on configured mode.
147
func (r *Runner) Run(ctx context.Context) error {
24✔
148
        switch r.cfg.Mode {
24✔
149
        case ModeFull:
8✔
150
                return r.runFull(ctx)
8✔
151
        case ModeReview:
3✔
152
                return r.runReviewOnly(ctx)
3✔
153
        case ModeCodexOnly:
3✔
154
                return r.runCodexOnly(ctx)
3✔
155
        case ModePlan:
9✔
156
                return r.runPlanCreation(ctx)
9✔
157
        default:
1✔
158
                return fmt.Errorf("unknown mode: %s", r.cfg.Mode)
1✔
159
        }
160
}
161

162
// runFull executes the complete pipeline: tasks → review → codex → review.
163
func (r *Runner) runFull(ctx context.Context) error {
8✔
164
        if r.cfg.PlanFile == "" {
9✔
165
                return errors.New("plan file required for full mode")
1✔
166
        }
1✔
167

168
        // phase 1: task execution
169
        r.log.SetPhase(PhaseTask)
7✔
170
        r.log.PrintRaw("starting task execution phase\n")
7✔
171

7✔
172
        if err := r.runTaskPhase(ctx); err != nil {
12✔
173
                return fmt.Errorf("task phase: %w", err)
5✔
174
        }
5✔
175

176
        // phase 2: first review pass - address ALL findings
177
        r.log.SetPhase(PhaseReview)
2✔
178
        r.log.PrintSection(NewGenericSection("claude review 0: all findings"))
2✔
179

2✔
180
        if err := r.runClaudeReview(ctx, r.buildFirstReviewPrompt()); err != nil {
2✔
181
                return fmt.Errorf("first review: %w", err)
×
182
        }
×
183

184
        // phase 2.1: claude review loop (critical/major) before codex
185
        if err := r.runClaudeReviewLoop(ctx); err != nil {
2✔
186
                return fmt.Errorf("pre-codex review loop: %w", err)
×
187
        }
×
188

189
        // phase 2.5: codex external review loop
190
        r.log.SetPhase(PhaseCodex)
2✔
191
        r.log.PrintSection(NewGenericSection("codex external review"))
2✔
192

2✔
193
        if err := r.runCodexLoop(ctx); err != nil {
2✔
194
                return fmt.Errorf("codex loop: %w", err)
×
195
        }
×
196

197
        // phase 3: claude review loop (critical/major) after codex
198
        r.log.SetPhase(PhaseReview)
2✔
199

2✔
200
        if err := r.runClaudeReviewLoop(ctx); err != nil {
2✔
201
                return fmt.Errorf("post-codex review loop: %w", err)
×
202
        }
×
203

204
        r.log.Print("all phases completed successfully")
2✔
205
        return nil
2✔
206
}
207

208
// runReviewOnly executes only the review pipeline: review → codex → review.
209
func (r *Runner) runReviewOnly(ctx context.Context) error {
3✔
210
        // phase 1: first review
3✔
211
        r.log.SetPhase(PhaseReview)
3✔
212
        r.log.PrintSection(NewGenericSection("claude review 0: all findings"))
3✔
213

3✔
214
        if err := r.runClaudeReview(ctx, r.buildFirstReviewPrompt()); err != nil {
4✔
215
                return fmt.Errorf("first review: %w", err)
1✔
216
        }
1✔
217

218
        // phase 1.1: claude review loop (critical/major) before codex
219
        if err := r.runClaudeReviewLoop(ctx); err != nil {
2✔
220
                return fmt.Errorf("pre-codex review loop: %w", err)
×
221
        }
×
222

223
        // phase 2: codex external review loop
224
        r.log.SetPhase(PhaseCodex)
2✔
225
        r.log.PrintSection(NewGenericSection("codex external review"))
2✔
226

2✔
227
        if err := r.runCodexLoop(ctx); err != nil {
3✔
228
                return fmt.Errorf("codex loop: %w", err)
1✔
229
        }
1✔
230

231
        // phase 3: claude review loop (critical/major) after codex
232
        r.log.SetPhase(PhaseReview)
1✔
233

1✔
234
        if err := r.runClaudeReviewLoop(ctx); err != nil {
1✔
235
                return fmt.Errorf("post-codex review loop: %w", err)
×
236
        }
×
237

238
        r.log.Print("review phases completed successfully")
1✔
239
        return nil
1✔
240
}
241

242
// runCodexOnly executes only the codex pipeline: codex → review.
243
func (r *Runner) runCodexOnly(ctx context.Context) error {
3✔
244
        // phase 1: codex external review loop
3✔
245
        r.log.SetPhase(PhaseCodex)
3✔
246
        r.log.PrintSection(NewGenericSection("codex external review"))
3✔
247

3✔
248
        if err := r.runCodexLoop(ctx); err != nil {
3✔
249
                return fmt.Errorf("codex loop: %w", err)
×
250
        }
×
251

252
        // phase 2: claude review loop (critical/major) after codex
253
        r.log.SetPhase(PhaseReview)
3✔
254

3✔
255
        if err := r.runClaudeReviewLoop(ctx); err != nil {
3✔
256
                return fmt.Errorf("post-codex review loop: %w", err)
×
257
        }
×
258

259
        r.log.Print("codex phases completed successfully")
3✔
260
        return nil
3✔
261
}
262

263
// runTaskPhase executes tasks until completion or max iterations.
264
// executes ONE Task section per iteration.
265
func (r *Runner) runTaskPhase(ctx context.Context) error {
7✔
266
        prompt := r.buildTaskPrompt()
7✔
267
        retryCount := 0
7✔
268

7✔
269
        for i := 1; i <= r.cfg.MaxIterations; i++ {
18✔
270
                select {
11✔
271
                case <-ctx.Done():
1✔
272
                        return fmt.Errorf("task phase: %w", ctx.Err())
1✔
273
                default:
10✔
274
                }
275

276
                r.log.PrintSection(NewTaskIterationSection(i))
10✔
277

10✔
278
                result := r.claude.Run(ctx, prompt)
10✔
279
                if result.Error != nil {
11✔
280
                        return fmt.Errorf("claude execution: %w", result.Error)
1✔
281
                }
1✔
282

283
                if result.Signal == SignalCompleted {
11✔
284
                        // verify plan actually has no uncompleted checkboxes
2✔
285
                        if r.hasUncompletedTasks() {
2✔
286
                                r.log.Print("warning: completion signal received but plan still has [ ] items, continuing...")
×
287
                                continue
×
288
                        }
289
                        r.log.PrintRaw("\nall tasks completed, starting code review...\n")
2✔
290
                        return nil
2✔
291
                }
292

293
                if result.Signal == SignalFailed {
11✔
294
                        if retryCount < r.taskRetryCount {
6✔
295
                                r.log.Print("task failed, retrying...")
2✔
296
                                retryCount++
2✔
297
                                time.Sleep(r.iterationDelay)
2✔
298
                                continue
2✔
299
                        }
300
                        return errors.New("task execution failed after retry (FAILED signal received)")
2✔
301
                }
302

303
                retryCount = 0
3✔
304
                // continue with same prompt - it reads from plan file each time
3✔
305
                time.Sleep(r.iterationDelay)
3✔
306
        }
307

308
        return fmt.Errorf("max iterations (%d) reached without completion", r.cfg.MaxIterations)
1✔
309
}
310

311
// runClaudeReview runs Claude review with the given prompt until REVIEW_DONE.
312
func (r *Runner) runClaudeReview(ctx context.Context, prompt string) error {
5✔
313
        result := r.claude.Run(ctx, prompt)
5✔
314
        if result.Error != nil {
5✔
315
                return fmt.Errorf("claude execution: %w", result.Error)
×
316
        }
×
317

318
        if result.Signal == SignalFailed {
6✔
319
                return errors.New("review failed (FAILED signal received)")
1✔
320
        }
1✔
321

322
        if !IsReviewDone(result.Signal) {
4✔
323
                r.log.Print("warning: first review pass did not complete cleanly, continuing...")
×
324
        }
×
325

326
        return nil
4✔
327
}
328

329
// runClaudeReviewLoop runs claude review iterations using second review prompt.
330
func (r *Runner) runClaudeReviewLoop(ctx context.Context) error {
10✔
331
        // review iterations = 10% of max_iterations (min 3)
10✔
332
        maxReviewIterations := max(3, r.cfg.MaxIterations/10)
10✔
333

10✔
334
        for i := 1; i <= maxReviewIterations; i++ {
20✔
335
                select {
10✔
336
                case <-ctx.Done():
×
337
                        return fmt.Errorf("review: %w", ctx.Err())
×
338
                default:
10✔
339
                }
340

341
                r.log.PrintSection(NewClaudeReviewSection(i, ": critical/major"))
10✔
342

10✔
343
                result := r.claude.Run(ctx, r.buildSecondReviewPrompt())
10✔
344
                if result.Error != nil {
10✔
345
                        return fmt.Errorf("claude execution: %w", result.Error)
×
346
                }
×
347

348
                if result.Signal == SignalFailed {
10✔
349
                        return errors.New("review failed (FAILED signal received)")
×
350
                }
×
351

352
                if IsReviewDone(result.Signal) {
20✔
353
                        r.log.Print("claude review complete - no more findings")
10✔
354
                        return nil
10✔
355
                }
10✔
356

357
                r.log.Print("issues fixed, running another review iteration...")
×
358
                time.Sleep(r.iterationDelay)
×
359
        }
360

361
        r.log.Print("max claude review iterations reached, continuing...")
×
362
        return nil
×
363
}
364

365
// runCodexLoop runs the codex-claude review loop until no findings.
366
func (r *Runner) runCodexLoop(ctx context.Context) error {
7✔
367
        // skip codex phase if disabled
7✔
368
        if !r.cfg.CodexEnabled {
8✔
369
                r.log.Print("codex review disabled, skipping...")
1✔
370
                return nil
1✔
371
        }
1✔
372

373
        // codex iterations = 20% of max_iterations (min 3)
374
        maxCodexIterations := max(3, r.cfg.MaxIterations/5)
6✔
375

6✔
376
        var claudeResponse string // first iteration has no prior response
6✔
377

6✔
378
        for i := 1; i <= maxCodexIterations; i++ {
12✔
379
                select {
6✔
380
                case <-ctx.Done():
×
381
                        return fmt.Errorf("codex loop: %w", ctx.Err())
×
382
                default:
6✔
383
                }
384

385
                r.log.PrintSection(NewCodexIterationSection(i))
6✔
386

6✔
387
                // run codex analysis
6✔
388
                codexResult := r.codex.Run(ctx, r.buildCodexPrompt(i == 1, claudeResponse))
6✔
389
                if codexResult.Error != nil {
7✔
390
                        return fmt.Errorf("codex execution: %w", codexResult.Error)
1✔
391
                }
1✔
392

393
                if codexResult.Output == "" {
7✔
394
                        r.log.Print("codex review returned no output, skipping...")
2✔
395
                        break
2✔
396
                }
397

398
                // show codex findings summary before Claude evaluation
399
                r.showCodexSummary(codexResult.Output)
3✔
400

3✔
401
                // pass codex output to claude for evaluation and fixing
3✔
402
                r.log.SetPhase(PhaseClaudeEval)
3✔
403
                r.log.PrintSection(NewClaudeEvalSection())
3✔
404
                claudeResult := r.claude.Run(ctx, r.buildCodexEvaluationPrompt(codexResult.Output))
3✔
405

3✔
406
                // restore codex phase for next iteration
3✔
407
                r.log.SetPhase(PhaseCodex)
3✔
408
                if claudeResult.Error != nil {
3✔
409
                        return fmt.Errorf("claude execution: %w", claudeResult.Error)
×
410
                }
×
411

412
                claudeResponse = claudeResult.Output
3✔
413

3✔
414
                // exit only when claude sees "no findings" from codex
3✔
415
                if IsCodexDone(claudeResult.Signal) {
6✔
416
                        r.log.Print("codex review complete - no more findings")
3✔
417
                        return nil
3✔
418
                }
3✔
419

420
                time.Sleep(r.iterationDelay)
×
421
        }
422

423
        r.log.Print("max codex iterations reached, continuing to next phase...")
2✔
424
        return nil
2✔
425
}
426

427
// buildCodexPrompt creates the prompt for codex review.
428
func (r *Runner) buildCodexPrompt(isFirst bool, claudeResponse string) string {
6✔
429
        // build plan context if available
6✔
430
        planContext := ""
6✔
431
        if r.cfg.PlanFile != "" {
8✔
432
                planContext = fmt.Sprintf(`
2✔
433
## Plan Context
2✔
434
The code implements the plan at: %s
2✔
435

2✔
436
---
2✔
437
`, r.cfg.PlanFile)
2✔
438
        }
2✔
439

440
        // different diff command based on iteration
441
        var diffInstruction, diffDescription string
6✔
442
        if isFirst {
12✔
443
                diffInstruction = "Run: git diff master...HEAD"
6✔
444
                diffDescription = "code changes between master and HEAD branch"
6✔
445
        } else {
6✔
446
                diffInstruction = "Run: git diff"
×
447
                diffDescription = "uncommitted changes (Claude's fixes from previous iteration)"
×
448
        }
×
449

450
        basePrompt := fmt.Sprintf(`%sReview the %s.
6✔
451

6✔
452
%s
6✔
453

6✔
454
Analyze for:
6✔
455
- Bugs and logic errors
6✔
456
- Security vulnerabilities
6✔
457
- Race conditions
6✔
458
- Error handling gaps
6✔
459
- Code quality issues
6✔
460

6✔
461
Report findings with file:line references. If no issues found, say "NO ISSUES FOUND".`, planContext, diffDescription, diffInstruction)
6✔
462

6✔
463
        if claudeResponse != "" {
6✔
464
                return fmt.Sprintf(`%s
×
465

×
466
---
×
467
PREVIOUS REVIEW CONTEXT:
×
468
Claude (previous reviewer) responded to your findings:
×
469

×
470
%s
×
471

×
472
Re-evaluate considering Claude's arguments. If Claude's fixes are correct, acknowledge them.
×
473
If Claude's arguments are invalid, explain why the issues still exist.`, basePrompt, claudeResponse)
×
474
        }
×
475

476
        return basePrompt
6✔
477
}
478

479
// hasUncompletedTasks checks if plan file has any uncompleted checkboxes.
480
// Checks both original path and completed/ subdirectory.
481
func (r *Runner) hasUncompletedTasks() bool {
6✔
482
        // try original path first
6✔
483
        content, err := os.ReadFile(r.cfg.PlanFile)
6✔
484
        if err != nil {
6✔
485
                // try completed/ subdirectory as fallback
×
486
                completedPath := filepath.Join(filepath.Dir(r.cfg.PlanFile), "completed", filepath.Base(r.cfg.PlanFile))
×
487
                content, err = os.ReadFile(completedPath) //nolint:gosec // planFile from CLI args
×
488
                if err != nil {
×
489
                        return true // assume incomplete if can't read from either location
×
490
                }
×
491
        }
492

493
        // look for uncompleted checkbox pattern: [ ] (not [x])
494
        for line := range strings.SplitSeq(string(content), "\n") {
21✔
495
                trimmed := strings.TrimSpace(line)
15✔
496
                if strings.HasPrefix(trimmed, "- [ ]") {
17✔
497
                        return true
2✔
498
                }
2✔
499
        }
500
        return false
4✔
501
}
502

503
// showCodexSummary displays a condensed summary of codex output before Claude evaluation.
504
// extracts text until first code block or 500 chars, whichever is shorter.
505
func (r *Runner) showCodexSummary(output string) {
3✔
506
        summary := output
3✔
507

3✔
508
        // trim to first code block if present
3✔
509
        if idx := strings.Index(summary, "```"); idx > 0 {
3✔
510
                summary = summary[:idx]
×
511
        }
×
512

513
        // limit to 5000 chars
514
        if len(summary) > 5000 {
3✔
515
                summary = summary[:5000] + "..."
×
516
        }
×
517

518
        summary = strings.TrimSpace(summary)
3✔
519
        if summary == "" {
3✔
520
                return
×
521
        }
×
522

523
        r.log.Print("codex findings:")
3✔
524
        for line := range strings.SplitSeq(summary, "\n") {
6✔
525
                if strings.TrimSpace(line) == "" {
3✔
526
                        continue
×
527
                }
528
                r.log.PrintAligned("  " + line)
3✔
529
        }
530
}
531

532
// runPlanCreation executes the interactive plan creation loop.
533
// the loop continues until PLAN_READY signal or max iterations reached.
534
func (r *Runner) runPlanCreation(ctx context.Context) error {
9✔
535
        if r.cfg.PlanDescription == "" {
10✔
536
                return errors.New("plan description required for plan mode")
1✔
537
        }
1✔
538
        if r.inputCollector == nil {
9✔
539
                return errors.New("input collector required for plan mode")
1✔
540
        }
1✔
541

542
        r.log.SetPhase(PhasePlan)
7✔
543
        r.log.PrintRaw("starting interactive plan creation\n")
7✔
544
        r.log.Print("plan request: %s", r.cfg.PlanDescription)
7✔
545

7✔
546
        // plan iterations use 20% of max_iterations (min 5)
7✔
547
        maxPlanIterations := max(5, r.cfg.MaxIterations/5)
7✔
548

7✔
549
        for i := 1; i <= maxPlanIterations; i++ {
19✔
550
                select {
12✔
551
                case <-ctx.Done():
1✔
552
                        return fmt.Errorf("plan creation: %w", ctx.Err())
1✔
553
                default:
11✔
554
                }
555

556
                r.log.PrintSection(NewPlanIterationSection(i))
11✔
557

11✔
558
                prompt := r.buildPlanPrompt()
11✔
559
                result := r.claude.Run(ctx, prompt)
11✔
560
                if result.Error != nil {
12✔
561
                        return fmt.Errorf("claude execution: %w", result.Error)
1✔
562
                }
1✔
563

564
                if result.Signal == SignalFailed {
11✔
565
                        return errors.New("plan creation failed (FAILED signal received)")
1✔
566
                }
1✔
567

568
                // check for PLAN_READY signal
569
                if IsPlanReady(result.Signal) {
11✔
570
                        r.log.Print("plan creation completed")
2✔
571
                        return nil
2✔
572
                }
2✔
573

574
                // check for QUESTION signal
575
                question, err := ParseQuestionPayload(result.Output)
7✔
576
                if err == nil {
9✔
577
                        // got a question - ask user and log answer
2✔
578
                        r.log.LogQuestion(question.Question, question.Options)
2✔
579

2✔
580
                        answer, askErr := r.inputCollector.AskQuestion(ctx, question.Question, question.Options)
2✔
581
                        if askErr != nil {
3✔
582
                                return fmt.Errorf("collect answer: %w", askErr)
1✔
583
                        }
1✔
584

585
                        r.log.LogAnswer(answer)
1✔
586

1✔
587
                        time.Sleep(r.iterationDelay)
1✔
588
                        continue
1✔
589
                }
590

591
                // log malformed question signals (but not "no question signal" which is expected)
592
                if !errors.Is(err, ErrNoQuestionSignal) {
5✔
593
                        r.log.Print("warning: %v", err)
×
594
                }
×
595

596
                // no question and no completion - continue
597
                time.Sleep(r.iterationDelay)
5✔
598
        }
599

600
        return fmt.Errorf("max plan iterations (%d) reached without completion", maxPlanIterations)
1✔
601
}
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