Skip to content

Commit 530a121

Browse files
authored
fix: ensure evaluator triggers when resuming multi-pass planning sessions (#571)
Fixed a critical bug where resuming an ultraplan session in multi-pass mode would fail to trigger the plan evaluator. The issue occurred because: 1. The resume logic checked CoordinatorID (single-pass field) instead of PlanCoordinatorIDs (multi-pass field) 2. When GetInstance returned nil for a planner, it wasn't marked as processed, causing false negatives in the all-completed check The fix: - Added proper multi-pass handling in resumeMultiPassPlanning that checks existing planners, collects completed plans, and triggers the evaluator - Created multiPassResumeDeps struct for dependency injection to enable comprehensive testing without real orchestrator/tmux - Added 13 unit tests covering all code paths including edge cases
1 parent 053b60a commit 530a121

File tree

3 files changed

+988
-6
lines changed

3 files changed

+988
-6
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **Multi-Pass Planning Session Resume** - Fixed a critical bug where resuming an ultraplan session in multi-pass mode (`:ultraplan --multi-pass`) would fail to trigger the plan evaluator. When the TUI was closed while the 3 parallel planners were running, re-attaching to the session would incorrectly check `CoordinatorID` (which is not used in multi-pass mode) and restart planning from scratch, overwriting `PlanCoordinatorIDs` with new instance IDs. The original planners' completion events would then be orphaned, causing the evaluator to never kick off. The fix adds proper multi-pass handling in session resume: it now correctly checks for existing planners in `PlanCoordinatorIDs`, collects any completed plans from worktrees, and triggers the evaluator when all planners have finished. Also fixed an edge case where missing planner instances (GetInstance returning nil) would cause false negatives in the all-processed check, preventing the evaluator from being triggered.
13+
1014
### Changed
1115

1216
- **Instance Manager Callbacks Required at Construction** - Callbacks (OnStateChange, OnMetrics, OnTimeout, OnBell) are now passed via `ManagerCallbacks` struct in `ManagerOptions` at construction time, rather than being set separately via setter methods. This prevents the "leaky abstraction" bug where `Start()`/`Reconnect()` could be called without callbacks configured. The callback setter methods are now deprecated.

internal/cmd/session/start.go

Lines changed: 221 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -514,13 +514,20 @@ func resumeUltraplanSession(orch *orchestrator.Orchestrator, sess *orchestrator.
514514
// Resume based on current phase
515515
switch ultraSession.Phase {
516516
case orchestrator.PhasePlanning:
517-
// Check if planning coordinator instance exists and is still running
518-
if ultraSession.CoordinatorID != "" && orch.GetInstance(ultraSession.CoordinatorID) != nil {
519-
fmt.Println("Planning in progress...")
517+
// Handle multi-pass planning mode separately
518+
if ultraSession.Config.MultiPass {
519+
if err := resumeMultiPassPlanning(orch, coordinator, ultraSession, logger); err != nil {
520+
return fmt.Errorf("failed to resume multi-pass planning: %w", err)
521+
}
520522
} else {
521-
fmt.Println("Restarting planning...")
522-
if err := coordinator.RunPlanning(); err != nil {
523-
return fmt.Errorf("failed to restart planning: %w", err)
523+
// Single-pass planning: check if planning coordinator instance exists and is still running
524+
if ultraSession.CoordinatorID != "" && orch.GetInstance(ultraSession.CoordinatorID) != nil {
525+
fmt.Println("Planning in progress...")
526+
} else {
527+
fmt.Println("Restarting planning...")
528+
if err := coordinator.RunPlanning(); err != nil {
529+
return fmt.Errorf("failed to restart planning: %w", err)
530+
}
524531
}
525532
}
526533

@@ -592,6 +599,214 @@ func resumeUltraplanSession(orch *orchestrator.Orchestrator, sess *orchestrator.
592599
return app.Run()
593600
}
594601

602+
// multiPassResumeDeps encapsulates dependencies for resumeMultiPassPlanning
603+
// to enable testing with mocked implementations. This follows Go's dependency
604+
// injection pattern for testability.
605+
type multiPassResumeDeps struct {
606+
// getInstance returns the instance for the given ID, or nil if not found
607+
getInstance func(id string) *orchestrator.Instance
608+
// isTmuxRunning returns true if the tmux session for the given instance ID exists
609+
isTmuxRunning func(id string) bool
610+
// saveSession persists the current session state
611+
saveSession func() error
612+
// runPlanning starts fresh multi-pass planning
613+
runPlanning func() error
614+
// runPlanManager starts the plan evaluator
615+
runPlanManager func() error
616+
// parsePlan parses a plan from the given worktree path
617+
parsePlan func(worktreePath, objective string) (*orchestrator.PlanSpec, error)
618+
// logWarn logs a warning message
619+
logWarn func(msg string, args ...any)
620+
// logInfo logs an info message
621+
logInfo func(msg string, args ...any)
622+
}
623+
624+
// resumeMultiPassPlanning handles resuming multi-pass planning mode.
625+
// In multi-pass mode, 3 parallel planners generate candidate plans which are then
626+
// evaluated by a plan manager (evaluator). This function handles the case where
627+
// the TUI was closed while planners were running and needs to:
628+
// 1. Check if planners are still running
629+
// 2. Collect any plans from completed planners
630+
// 3. Trigger the evaluator if all planners have completed
631+
func resumeMultiPassPlanning(
632+
orch *orchestrator.Orchestrator,
633+
coordinator *orchestrator.Coordinator,
634+
ultraSession *orchestrator.UltraPlanSession,
635+
logger *logging.Logger,
636+
) error {
637+
// Create dependencies from concrete types
638+
deps := multiPassResumeDeps{
639+
getInstance: orch.GetInstance,
640+
isTmuxRunning: func(id string) bool {
641+
mgr := orch.GetInstanceManager(id)
642+
return mgr != nil && mgr.TmuxSessionExists()
643+
},
644+
saveSession: orch.SaveSession,
645+
runPlanning: coordinator.RunPlanning,
646+
runPlanManager: coordinator.RunPlanManager,
647+
parsePlan: func(worktreePath, objective string) (*orchestrator.PlanSpec, error) {
648+
planPath := orchestrator.PlanFilePath(worktreePath)
649+
return orchestrator.ParsePlanFromFile(planPath, objective)
650+
},
651+
logWarn: logger.Warn,
652+
logInfo: logger.Info,
653+
}
654+
return resumeMultiPassPlanningInternal(deps, ultraSession)
655+
}
656+
657+
// resumeMultiPassPlanningInternal is the testable core logic for resuming
658+
// multi-pass planning. It accepts dependencies explicitly to enable unit testing
659+
// without requiring real orchestrator, coordinator, or logger instances.
660+
func resumeMultiPassPlanningInternal(
661+
deps multiPassResumeDeps,
662+
ultraSession *orchestrator.UltraPlanSession,
663+
) error {
664+
numCoordinators := len(ultraSession.PlanCoordinatorIDs)
665+
666+
// No planners means we need to start fresh
667+
if numCoordinators == 0 {
668+
fmt.Println("No existing planners found, starting multi-pass planning...")
669+
return deps.runPlanning()
670+
}
671+
672+
// Check the status of each planner
673+
var runningPlanners []string
674+
var completedPlanners []int
675+
676+
for i, plannerID := range ultraSession.PlanCoordinatorIDs {
677+
inst := deps.getInstance(plannerID)
678+
if inst == nil {
679+
deps.logWarn("planner instance not found in session",
680+
"planner_index", i,
681+
"planner_id", plannerID,
682+
)
683+
// Treat missing instances as completed - they're not running and
684+
// we need to mark them as processed to avoid false negatives in
685+
// the all-processed check
686+
completedPlanners = append(completedPlanners, i)
687+
continue
688+
}
689+
690+
if deps.isTmuxRunning(plannerID) {
691+
runningPlanners = append(runningPlanners, plannerID)
692+
deps.logInfo("planner still running",
693+
"planner_index", i,
694+
"planner_id", plannerID,
695+
)
696+
} else {
697+
completedPlanners = append(completedPlanners, i)
698+
deps.logInfo("planner completed",
699+
"planner_index", i,
700+
"planner_id", plannerID,
701+
)
702+
}
703+
}
704+
705+
// If some planners are still running, just monitor them
706+
if len(runningPlanners) > 0 {
707+
fmt.Printf("Multi-pass planning in progress (%d/%d planners completed)...\n",
708+
len(completedPlanners), numCoordinators)
709+
return nil
710+
}
711+
712+
// All planners have completed - check if we need to collect plans and trigger evaluator
713+
fmt.Printf("All %d multi-pass planners completed. Checking plan collection...\n", numCoordinators)
714+
715+
// Ensure CandidatePlans is properly sized
716+
if len(ultraSession.CandidatePlans) < numCoordinators {
717+
newPlans := make([]*orchestrator.PlanSpec, numCoordinators)
718+
copy(newPlans, ultraSession.CandidatePlans)
719+
ultraSession.CandidatePlans = newPlans
720+
}
721+
722+
// Ensure ProcessedCoordinators is initialized
723+
if ultraSession.ProcessedCoordinators == nil {
724+
ultraSession.ProcessedCoordinators = make(map[int]bool)
725+
}
726+
727+
// Collect plans from completed planners that we haven't processed yet
728+
for _, idx := range completedPlanners {
729+
// Skip if already processed
730+
if ultraSession.ProcessedCoordinators[idx] {
731+
continue
732+
}
733+
734+
plannerID := ultraSession.PlanCoordinatorIDs[idx]
735+
inst := deps.getInstance(plannerID)
736+
if inst == nil {
737+
ultraSession.ProcessedCoordinators[idx] = true
738+
continue
739+
}
740+
741+
// Try to parse plan from the planner's worktree
742+
plan, err := deps.parsePlan(inst.WorktreePath, ultraSession.Objective)
743+
if err != nil {
744+
deps.logWarn("failed to parse plan from completed planner",
745+
"planner_index", idx,
746+
"planner_id", plannerID,
747+
"error", err.Error(),
748+
)
749+
// Mark as processed even if parsing failed - we don't want to retry forever
750+
ultraSession.ProcessedCoordinators[idx] = true
751+
continue
752+
}
753+
754+
// Store the plan
755+
ultraSession.CandidatePlans[idx] = plan
756+
ultraSession.ProcessedCoordinators[idx] = true
757+
deps.logInfo("collected plan from completed planner",
758+
"planner_index", idx,
759+
"planner_id", plannerID,
760+
"task_count", len(plan.Tasks),
761+
)
762+
}
763+
764+
// Count valid plans
765+
validPlans := 0
766+
for _, p := range ultraSession.CandidatePlans {
767+
if p != nil {
768+
validPlans++
769+
}
770+
}
771+
772+
// Check if all planners have been processed
773+
processedCount := len(ultraSession.ProcessedCoordinators)
774+
if processedCount < numCoordinators {
775+
// Some planners haven't been processed - this shouldn't happen if all tmux sessions are gone
776+
deps.logWarn("not all planners processed despite all tmux sessions being gone",
777+
"processed", processedCount,
778+
"total", numCoordinators,
779+
)
780+
fmt.Printf("Waiting for plan collection (%d/%d processed)...\n", processedCount, numCoordinators)
781+
return nil
782+
}
783+
784+
// All planners processed - check if evaluator already started or needs to be triggered
785+
if ultraSession.PlanManagerID != "" {
786+
fmt.Println("Plan evaluator already running...")
787+
return nil
788+
}
789+
790+
if validPlans == 0 {
791+
return fmt.Errorf("all multi-pass planners completed but no valid plans were produced")
792+
}
793+
794+
// Trigger the evaluator
795+
fmt.Printf("Starting plan evaluator with %d/%d valid plans...\n", validPlans, numCoordinators)
796+
if err := deps.runPlanManager(); err != nil {
797+
return fmt.Errorf("failed to start plan evaluator: %w", err)
798+
}
799+
800+
// Save session to persist the updated state
801+
if err := deps.saveSession(); err != nil {
802+
deps.logWarn("failed to save session after triggering evaluator",
803+
"error", err.Error(),
804+
)
805+
}
806+
807+
return nil
808+
}
809+
595810
// CreateLogger creates a logger if logging is enabled in config.
596811
// Returns a NopLogger if logging is disabled or if creation fails.
597812
// This function uses NewLoggerWithRotation to respect MaxSizeMB and MaxBackups config.

0 commit comments

Comments
 (0)