1use crate::agents::AgentRole;
8use crate::checkpoint::{
9 save_checkpoint_with_workspace, CheckpointBuilder, PipelinePhase as CheckpointPhase,
10};
11use crate::phases::{commit, development, get_primary_commit_agent, review, PhaseContext};
12use crate::pipeline::PipelineRuntime;
13use crate::prompts::ContextLevel;
14use crate::reducer::effect::{Effect, EffectHandler};
15use crate::reducer::event::{CheckpointTrigger, ConflictStrategy, PipelineEvent, RebasePhase};
16use crate::reducer::fault_tolerant_executor::{
17 execute_agent_fault_tolerantly, AgentExecutionConfig,
18};
19use crate::reducer::state::PipelineState;
20use anyhow::Result;
21use std::path::Path;
22
23pub struct MainEffectHandler {
28 pub state: PipelineState,
30 pub event_log: Vec<PipelineEvent>,
32}
33
34impl MainEffectHandler {
35 pub fn new(state: PipelineState) -> Self {
37 Self {
38 state,
39 event_log: Vec::new(),
40 }
41 }
42}
43
44impl<'ctx> EffectHandler<'ctx> for MainEffectHandler {
45 fn execute(&mut self, effect: Effect, ctx: &mut PhaseContext<'_>) -> Result<PipelineEvent> {
46 let event = self.execute_effect(effect, ctx)?;
47 self.event_log.push(event.clone());
48 Ok(event)
49 }
50}
51
52impl crate::app::event_loop::StatefulHandler for MainEffectHandler {
53 fn update_state(&mut self, state: PipelineState) {
54 self.state = state;
55 }
56}
57
58impl MainEffectHandler {
59 fn execute_effect(
60 &mut self,
61 effect: Effect,
62 ctx: &mut PhaseContext<'_>,
63 ) -> Result<PipelineEvent> {
64 match effect {
65 Effect::AgentInvocation {
66 role,
67 agent,
68 model,
69 prompt,
70 } => self.invoke_agent(ctx, role, agent, model, prompt),
71
72 Effect::InitializeAgentChain { role } => self.initialize_agent_chain(ctx, role),
73
74 Effect::GeneratePlan { iteration } => self.generate_plan(ctx, iteration),
75
76 Effect::RunDevelopmentIteration { iteration } => {
77 self.run_development_iteration(ctx, iteration)
78 }
79
80 Effect::RunReviewPass { pass } => self.run_review_pass(ctx, pass),
81
82 Effect::RunFixAttempt { pass } => self.run_fix_attempt(ctx, pass),
83
84 Effect::RunRebase {
85 phase,
86 target_branch,
87 } => self.run_rebase(ctx, phase, target_branch),
88
89 Effect::ResolveRebaseConflicts { strategy } => {
90 self.resolve_rebase_conflicts(ctx, strategy)
91 }
92
93 Effect::GenerateCommitMessage => self.generate_commit_message(ctx),
94
95 Effect::CreateCommit { message } => self.create_commit(ctx, message),
96
97 Effect::SkipCommit { reason } => self.skip_commit(ctx, reason),
98
99 Effect::ValidateFinalState => self.validate_final_state(ctx),
100
101 Effect::SaveCheckpoint { trigger } => self.save_checkpoint(ctx, trigger),
102
103 Effect::CleanupContext => self.cleanup_context(ctx),
104
105 Effect::RestorePromptPermissions => self.restore_prompt_permissions(ctx),
106 }
107 }
108
109 fn invoke_agent(
110 &mut self,
111 ctx: &mut PhaseContext<'_>,
112 role: AgentRole,
113 agent: String,
114 _model: Option<String>,
115 prompt: String,
116 ) -> Result<PipelineEvent> {
117 let effective_agent = self
119 .state
120 .agent_chain
121 .current_agent()
122 .unwrap_or(&agent)
123 .clone();
124
125 let model_name = self.state.agent_chain.current_model();
126
127 ctx.logger.info(&format!(
128 "Executing with agent: {}, model: {:?}",
129 effective_agent, model_name
130 ));
131
132 let agent_config = ctx
134 .registry
135 .resolve_config(&effective_agent)
136 .ok_or_else(|| anyhow::anyhow!("Agent not found: {}", effective_agent))?;
137
138 let logfile = format!(".agent/logs/{}.log", effective_agent.to_lowercase());
140
141 let mut runtime = PipelineRuntime {
143 timer: ctx.timer,
144 logger: ctx.logger,
145 colors: ctx.colors,
146 config: ctx.config,
147 executor: ctx.executor,
148 executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
149 workspace: ctx.workspace,
150 };
151
152 let config = AgentExecutionConfig {
154 role,
155 agent_name: &effective_agent,
156 cmd_str: &agent_config.cmd,
157 parser_type: agent_config.json_parser,
158 env_vars: &agent_config.env_vars,
159 prompt: &prompt,
160 display_name: &effective_agent,
161 logfile: &logfile,
162 };
163
164 execute_agent_fault_tolerantly(config, &mut runtime)
165 }
166
167 fn generate_plan(
168 &mut self,
169 ctx: &mut PhaseContext<'_>,
170 iteration: u32,
171 ) -> Result<PipelineEvent> {
172 match development::run_planning_step(ctx, iteration) {
173 Ok(_) => {
174 let plan_path = Path::new(".agent/PLAN.md");
176 let plan_exists = ctx.workspace.exists(plan_path);
177 let plan_content = if plan_exists {
178 ctx.workspace.read(plan_path).ok().unwrap_or_default()
179 } else {
180 String::new()
181 };
182
183 let is_valid = plan_exists && !plan_content.trim().is_empty();
184
185 Ok(PipelineEvent::PlanGenerationCompleted {
186 iteration,
187 valid: is_valid,
188 })
189 }
190 Err(_) => Ok(PipelineEvent::PlanGenerationCompleted {
191 iteration,
192 valid: false,
193 }),
194 }
195 }
196
197 fn run_development_iteration(
198 &mut self,
199 ctx: &mut PhaseContext<'_>,
200 iteration: u32,
201 ) -> Result<PipelineEvent> {
202 use crate::checkpoint::restore::ResumeContext;
203 let developer_context = ContextLevel::from(ctx.config.developer_context);
204
205 let dev_agent = self.state.agent_chain.current_agent().cloned();
207
208 let result = development::run_development_iteration_with_xml_retry(
210 ctx,
211 iteration,
212 developer_context,
213 false,
214 None::<&ResumeContext>,
215 dev_agent.as_deref(),
216 );
217
218 match result {
219 Ok(_dev_result) => Ok(PipelineEvent::DevelopmentIterationCompleted {
220 iteration,
221 output_valid: true,
222 }),
223 Err(_) => Ok(PipelineEvent::DevelopmentIterationCompleted {
224 iteration,
225 output_valid: false,
226 }),
227 }
228 }
229
230 fn run_review_pass(&mut self, ctx: &mut PhaseContext<'_>, pass: u32) -> Result<PipelineEvent> {
231 let review_label = format!("review_{}", pass);
232
233 let review_agent = self.state.agent_chain.current_agent().cloned();
235
236 match review::run_review_pass(ctx, pass, &review_label, "", review_agent.as_deref()) {
237 Ok(result) => Ok(PipelineEvent::ReviewCompleted {
238 pass,
239 issues_found: !result.early_exit,
240 }),
241 Err(_) => Ok(PipelineEvent::ReviewCompleted {
242 pass,
243 issues_found: false,
244 }),
245 }
246 }
247
248 fn run_fix_attempt(&mut self, ctx: &mut PhaseContext<'_>, pass: u32) -> Result<PipelineEvent> {
249 use crate::checkpoint::restore::ResumeContext;
250 let reviewer_context = ContextLevel::from(ctx.config.reviewer_context);
251
252 let fix_agent = self.state.agent_chain.current_agent().cloned();
254
255 match review::run_fix_pass(
256 ctx,
257 pass,
258 reviewer_context,
259 None::<&ResumeContext>,
260 fix_agent.as_deref(),
261 ) {
262 Ok(_) => Ok(PipelineEvent::FixAttemptCompleted {
263 pass,
264 changes_made: true,
265 }),
266 Err(_) => Ok(PipelineEvent::FixAttemptCompleted {
267 pass,
268 changes_made: false,
269 }),
270 }
271 }
272
273 fn run_rebase(
274 &mut self,
275 _ctx: &mut PhaseContext<'_>,
276 phase: RebasePhase,
277 target_branch: String,
278 ) -> Result<PipelineEvent> {
279 use crate::git_helpers::{get_conflicted_files, rebase_onto};
280
281 match rebase_onto(&target_branch, _ctx.executor) {
282 Ok(_) => {
283 let conflicted_files = get_conflicted_files().unwrap_or_default();
285
286 if !conflicted_files.is_empty() {
287 let files = conflicted_files.into_iter().map(|s| s.into()).collect();
288
289 Ok(PipelineEvent::RebaseConflictDetected { files })
290 } else {
291 let new_head = match git2::Repository::open(".") {
293 Ok(repo) => {
294 match repo.head().ok().and_then(|head| head.peel_to_commit().ok()) {
295 Some(commit) => commit.id().to_string(),
296 None => "unknown".to_string(),
297 }
298 }
299 Err(_) => "unknown".to_string(),
300 };
301
302 Ok(PipelineEvent::RebaseSucceeded { phase, new_head })
303 }
304 }
305 Err(e) => Ok(PipelineEvent::RebaseFailed {
306 phase,
307 reason: e.to_string(),
308 }),
309 }
310 }
311
312 fn resolve_rebase_conflicts(
313 &mut self,
314 _ctx: &mut PhaseContext<'_>,
315 strategy: ConflictStrategy,
316 ) -> Result<PipelineEvent> {
317 use crate::git_helpers::{abort_rebase, continue_rebase, get_conflicted_files};
318
319 match strategy {
320 ConflictStrategy::Continue => match continue_rebase(_ctx.executor) {
321 Ok(_) => {
322 let files = get_conflicted_files()
323 .unwrap_or_default()
324 .into_iter()
325 .map(|s| s.into())
326 .collect();
327
328 Ok(PipelineEvent::RebaseConflictResolved { files })
329 }
330 Err(e) => Ok(PipelineEvent::RebaseFailed {
331 phase: RebasePhase::PostReview,
332 reason: e.to_string(),
333 }),
334 },
335 ConflictStrategy::Abort => match abort_rebase(_ctx.executor) {
336 Ok(_) => {
337 let restored_to = match git2::Repository::open(".") {
338 Ok(repo) => {
339 match repo.head().ok().and_then(|head| head.peel_to_commit().ok()) {
340 Some(commit) => commit.id().to_string(),
341 None => "HEAD".to_string(),
342 }
343 }
344 Err(_) => "HEAD".to_string(),
345 };
346
347 Ok(PipelineEvent::RebaseAborted {
348 phase: RebasePhase::PostReview,
349 restored_to,
350 })
351 }
352 Err(e) => Ok(PipelineEvent::RebaseFailed {
353 phase: RebasePhase::PostReview,
354 reason: e.to_string(),
355 }),
356 },
357 ConflictStrategy::Skip => {
358 Ok(PipelineEvent::RebaseConflictResolved { files: Vec::new() })
359 }
360 }
361 }
362
363 fn generate_commit_message(&mut self, ctx: &mut PhaseContext<'_>) -> Result<PipelineEvent> {
364 let attempt = match &self.state.commit {
365 crate::reducer::state::CommitState::Generating { attempt, .. } => *attempt,
366 _ => 1,
367 };
368
369 let diff = crate::git_helpers::git_diff().unwrap_or_default();
371
372 if diff.trim().is_empty() {
375 ctx.logger
376 .info("No changes to commit (empty diff), skipping commit");
377 return Ok(PipelineEvent::CommitSkipped {
378 reason: "No changes to commit (empty diff)".to_string(),
379 });
380 }
381
382 let commit_agent = get_primary_commit_agent(ctx).unwrap_or_else(|| "commit".to_string());
384
385 let mut runtime = PipelineRuntime {
386 timer: ctx.timer,
387 logger: ctx.logger,
388 colors: ctx.colors,
389 config: ctx.config,
390 executor: ctx.executor,
391 executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
392 workspace: ctx.workspace,
393 };
394
395 match commit::generate_commit_message(
396 &diff,
397 ctx.registry,
398 &mut runtime,
399 &commit_agent,
400 ctx.template_context,
401 ctx.workspace,
402 &ctx.prompt_history,
403 ) {
404 Ok(result) => Ok(PipelineEvent::CommitMessageGenerated {
405 message: result.message.clone(),
406 attempt,
407 }),
408 Err(_) => Ok(PipelineEvent::CommitMessageGenerated {
409 message: "chore: automated commit".to_string(),
410 attempt,
411 }),
412 }
413 }
414
415 fn create_commit(
416 &mut self,
417 ctx: &mut PhaseContext<'_>,
418 message: String,
419 ) -> Result<PipelineEvent> {
420 use crate::git_helpers::{git_add_all, git_commit};
421
422 git_add_all()?;
424
425 match git_commit(&message, None, None, Some(ctx.executor)) {
427 Ok(Some(hash)) => Ok(PipelineEvent::CommitCreated {
428 hash: hash.to_string(),
429 message,
430 }),
431 Ok(None) => {
432 Ok(PipelineEvent::CommitSkipped {
435 reason: "No changes to commit".to_string(),
436 })
437 }
438 Err(e) => Ok(PipelineEvent::CommitGenerationFailed {
439 reason: e.to_string(),
440 }),
441 }
442 }
443
444 fn skip_commit(
445 &mut self,
446 _ctx: &mut PhaseContext<'_>,
447 reason: String,
448 ) -> Result<PipelineEvent> {
449 Ok(PipelineEvent::CommitSkipped { reason })
450 }
451
452 fn validate_final_state(&mut self, _ctx: &mut PhaseContext<'_>) -> Result<PipelineEvent> {
453 Ok(PipelineEvent::FinalizingStarted)
456 }
457
458 fn save_checkpoint(
459 &mut self,
460 ctx: &mut PhaseContext<'_>,
461 trigger: CheckpointTrigger,
462 ) -> Result<PipelineEvent> {
463 if ctx.config.features.checkpoint_enabled {
464 let _ = save_checkpoint_from_state(&self.state, ctx);
465 }
466
467 Ok(PipelineEvent::CheckpointSaved { trigger })
468 }
469
470 fn initialize_agent_chain(
471 &mut self,
472 ctx: &mut PhaseContext<'_>,
473 role: AgentRole,
474 ) -> Result<PipelineEvent> {
475 let agents = match role {
476 AgentRole::Developer => vec![ctx.developer_agent.to_string()],
477 AgentRole::Reviewer => vec![ctx.reviewer_agent.to_string()],
478 AgentRole::Commit => {
479 if let Some(commit_agent) = get_primary_commit_agent(ctx) {
480 vec![commit_agent]
481 } else {
482 vec![]
483 }
484 }
485 };
486
487 let _models_per_agent: Vec<Vec<String>> = agents.iter().map(|_| vec![]).collect();
488
489 let max_cycles = self.state.agent_chain.max_cycles;
490
491 ctx.logger.info(&format!(
492 "Initializing agent chain with {} cycles",
493 max_cycles
494 ));
495
496 Ok(PipelineEvent::AgentChainInitialized { role, agents })
497 }
498
499 fn cleanup_context(&mut self, ctx: &mut PhaseContext<'_>) -> Result<PipelineEvent> {
500 use std::path::Path;
501
502 ctx.logger
503 .info("Cleaning up context files to prevent pollution...");
504
505 let mut cleaned_count = 0;
506 let mut failed_count = 0;
507
508 let plan_path = Path::new(".agent/PLAN.md");
510 if ctx.workspace.exists(plan_path) {
511 if let Err(err) = ctx.workspace.remove(plan_path) {
512 ctx.logger.warn(&format!("Failed to delete PLAN.md: {err}"));
513 failed_count += 1;
514 } else {
515 cleaned_count += 1;
516 }
517 }
518
519 let issues_path = Path::new(".agent/ISSUES.md");
521 if ctx.workspace.exists(issues_path) {
522 if let Err(err) = ctx.workspace.remove(issues_path) {
523 ctx.logger
524 .warn(&format!("Failed to delete ISSUES.md: {err}"));
525 failed_count += 1;
526 } else {
527 cleaned_count += 1;
528 }
529 }
530
531 let tmp_dir = Path::new(".agent/tmp");
533 if ctx.workspace.exists(tmp_dir) {
534 if let Ok(entries) = ctx.workspace.read_dir(tmp_dir) {
535 for entry in entries {
536 let path = entry.path();
537 if path.extension().and_then(|s| s.to_str()) == Some("xml") {
538 if let Err(err) = ctx.workspace.remove(path) {
539 ctx.logger.warn(&format!(
540 "Failed to delete {}: {}",
541 path.display(),
542 err
543 ));
544 failed_count += 1;
545 } else {
546 cleaned_count += 1;
547 }
548 }
549 }
550 }
551 }
552
553 if cleaned_count > 0 {
554 ctx.logger.success(&format!(
555 "Context cleanup complete: {} files deleted{}",
556 cleaned_count,
557 if failed_count > 0 {
558 format!(", {} failures", failed_count)
559 } else {
560 String::new()
561 }
562 ));
563 } else {
564 ctx.logger.info("No context files to clean up");
565 }
566
567 Ok(PipelineEvent::ContextCleaned)
568 }
569
570 fn restore_prompt_permissions(&mut self, ctx: &mut PhaseContext<'_>) -> Result<PipelineEvent> {
571 use crate::files::make_prompt_writable_with_workspace;
572
573 ctx.logger.info("Restoring PROMPT.md write permissions...");
574
575 if let Some(warning) = make_prompt_writable_with_workspace(ctx.workspace) {
577 ctx.logger.warn(&warning);
578 }
579
580 Ok(PipelineEvent::PromptPermissionsRestored)
581 }
582}
583
584fn save_checkpoint_from_state(
586 state: &PipelineState,
587 ctx: &mut PhaseContext<'_>,
588) -> anyhow::Result<()> {
589 let builder = CheckpointBuilder::new()
590 .phase(
591 map_to_checkpoint_phase(state.phase),
592 state.iteration,
593 state.total_iterations,
594 )
595 .reviewer_pass(state.reviewer_pass, state.total_reviewer_passes)
596 .capture_from_context(
597 ctx.config,
598 ctx.registry,
599 ctx.developer_agent,
600 ctx.reviewer_agent,
601 ctx.logger,
602 &ctx.run_context,
603 )
604 .with_executor_from_context(std::sync::Arc::clone(&ctx.executor_arc))
605 .with_execution_history(ctx.execution_history.clone())
606 .with_prompt_history(ctx.clone_prompt_history());
607
608 if let Some(checkpoint) = builder.build() {
609 let _ = save_checkpoint_with_workspace(ctx.workspace, &checkpoint);
610 }
611
612 Ok(())
613}
614
615fn map_to_checkpoint_phase(phase: crate::reducer::event::PipelinePhase) -> CheckpointPhase {
617 match phase {
618 crate::reducer::event::PipelinePhase::Planning => CheckpointPhase::Planning,
619 crate::reducer::event::PipelinePhase::Development => CheckpointPhase::Development,
620 crate::reducer::event::PipelinePhase::Review => CheckpointPhase::Review,
621 crate::reducer::event::PipelinePhase::CommitMessage => CheckpointPhase::CommitMessage,
622 crate::reducer::event::PipelinePhase::FinalValidation => CheckpointPhase::FinalValidation,
623 crate::reducer::event::PipelinePhase::Finalizing => CheckpointPhase::FinalValidation,
624 crate::reducer::event::PipelinePhase::Complete => CheckpointPhase::Complete,
625 crate::reducer::event::PipelinePhase::Interrupted => CheckpointPhase::Complete,
626 }
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 #[test]
638 fn test_mock_handler_restore_prompt_permissions() {
639 use crate::reducer::mock_effect_handler::MockEffectHandler;
640
641 let state = PipelineState::initial(1, 0);
642 let mut handler = MockEffectHandler::new(state);
643
644 let event = handler.execute_mock(Effect::RestorePromptPermissions);
645
646 assert!(
647 matches!(event, PipelineEvent::PromptPermissionsRestored),
648 "RestorePromptPermissions effect should return PromptPermissionsRestored event"
649 );
650
651 assert!(
652 handler.was_effect_executed(|e| matches!(e, Effect::RestorePromptPermissions)),
653 "Effect should be captured"
654 );
655 }
656
657 #[test]
662 fn test_mock_handler_validate_final_state_goes_to_finalizing() {
663 use crate::reducer::mock_effect_handler::MockEffectHandler;
664
665 let state = PipelineState::initial(1, 0);
666 let mut handler = MockEffectHandler::new(state);
667
668 let event = handler.execute_mock(Effect::ValidateFinalState);
669
670 assert!(
671 matches!(event, PipelineEvent::FinalizingStarted),
672 "ValidateFinalState should return FinalizingStarted to trigger finalization phase, got: {:?}",
673 event
674 );
675 }
676
677 #[test]
684 fn test_cleanup_context_uses_workspace() {
685 use crate::agents::AgentRegistry;
686 use crate::checkpoint::{ExecutionHistory, RunContext};
687 use crate::config::Config;
688 use crate::executor::MockProcessExecutor;
689 use crate::logger::{Colors, Logger};
690 use crate::phases::context::PhaseContext;
691 use crate::pipeline::{Stats, Timer};
692 use crate::prompts::template_context::TemplateContext;
693 use crate::workspace::{MemoryWorkspace, Workspace};
694 use std::path::{Path, PathBuf};
695
696 let workspace = MemoryWorkspace::new_test()
698 .with_file(".agent/PLAN.md", "# Plan")
699 .with_file(".agent/ISSUES.md", "# Issues")
700 .with_dir(".agent/tmp")
701 .with_file(".agent/tmp/issues.xml", "<issues/>")
702 .with_file(".agent/tmp/development_result.xml", "<result/>")
703 .with_file(".agent/tmp/keep.txt", "not xml");
704
705 let config = Config::default();
707 let registry = AgentRegistry::new().unwrap();
708 let colors = Colors { enabled: false };
709 let logger = Logger::new(colors);
710 let mut timer = Timer::new();
711 let mut stats = Stats::default();
712 let template_context = TemplateContext::default();
713 let executor_arc = std::sync::Arc::new(MockProcessExecutor::new())
714 as std::sync::Arc<dyn crate::executor::ProcessExecutor>;
715 let repo_root = PathBuf::from("/test/repo");
716
717 let mut ctx = PhaseContext {
718 config: &config,
719 registry: ®istry,
720 logger: &logger,
721 colors: &colors,
722 timer: &mut timer,
723 stats: &mut stats,
724 developer_agent: "test-dev",
725 reviewer_agent: "test-reviewer",
726 review_guidelines: None,
727 template_context: &template_context,
728 run_context: RunContext::new(),
729 execution_history: ExecutionHistory::new(),
730 prompt_history: std::collections::HashMap::new(),
731 executor: &*executor_arc,
732 executor_arc: std::sync::Arc::clone(&executor_arc),
733 repo_root: &repo_root,
734 workspace: &workspace,
735 };
736
737 let state = PipelineState::initial(1, 0);
739 let mut handler = super::MainEffectHandler::new(state);
740 let result = handler.cleanup_context(&mut ctx);
741
742 assert!(result.is_ok(), "cleanup_context should succeed");
743
744 assert!(
746 !workspace.exists(Path::new(".agent/PLAN.md")),
747 "PLAN.md should be deleted via workspace"
748 );
749 assert!(
750 !workspace.exists(Path::new(".agent/ISSUES.md")),
751 "ISSUES.md should be deleted via workspace"
752 );
753 assert!(
754 !workspace.exists(Path::new(".agent/tmp/issues.xml")),
755 "issues.xml should be deleted via workspace"
756 );
757 assert!(
758 !workspace.exists(Path::new(".agent/tmp/development_result.xml")),
759 "development_result.xml should be deleted via workspace"
760 );
761 assert!(
763 workspace.exists(Path::new(".agent/tmp/keep.txt")),
764 "non-xml file should not be deleted"
765 );
766 }
767
768 #[test]
773 fn test_save_checkpoint_uses_workspace() {
774 use crate::agents::AgentRegistry;
775 use crate::checkpoint::{ExecutionHistory, RunContext};
776 use crate::config::Config;
777 use crate::executor::MockProcessExecutor;
778 use crate::logger::{Colors, Logger};
779 use crate::phases::context::PhaseContext;
780 use crate::pipeline::{Stats, Timer};
781 use crate::prompts::template_context::TemplateContext;
782 use crate::workspace::{MemoryWorkspace, Workspace};
783 use std::path::{Path, PathBuf};
784
785 let workspace = MemoryWorkspace::new_test();
787
788 assert!(
790 !workspace.exists(Path::new(".agent/checkpoint.json")),
791 "checkpoint should not exist initially"
792 );
793
794 let config = Config::default();
796 let registry = AgentRegistry::new().unwrap();
797 let colors = Colors { enabled: false };
798 let logger = Logger::new(colors);
799 let mut timer = Timer::new();
800 let mut stats = Stats::default();
801 let template_context = TemplateContext::default();
802 let executor_arc = std::sync::Arc::new(MockProcessExecutor::new())
803 as std::sync::Arc<dyn crate::executor::ProcessExecutor>;
804 let repo_root = PathBuf::from("/test/repo");
805
806 let developer_agent = "claude";
808 let reviewer_agent = "claude";
809
810 let mut ctx = PhaseContext {
811 config: &config,
812 registry: ®istry,
813 logger: &logger,
814 colors: &colors,
815 timer: &mut timer,
816 stats: &mut stats,
817 developer_agent,
818 reviewer_agent,
819 review_guidelines: None,
820 template_context: &template_context,
821 run_context: RunContext::new(),
822 execution_history: ExecutionHistory::new(),
823 prompt_history: std::collections::HashMap::new(),
824 executor: &*executor_arc,
825 executor_arc: std::sync::Arc::clone(&executor_arc),
826 repo_root: &repo_root,
827 workspace: &workspace,
828 };
829
830 let state = PipelineState::initial(1, 0);
832 let mut handler = super::MainEffectHandler::new(state);
833
834 let result = handler.save_checkpoint(&mut ctx, CheckpointTrigger::PhaseTransition);
836
837 assert!(result.is_ok(), "save_checkpoint should succeed");
838
839 assert!(
841 workspace.exists(Path::new(".agent/checkpoint.json")),
842 "checkpoint should be written via workspace"
843 );
844
845 let content = workspace.read(Path::new(".agent/checkpoint.json")).unwrap();
847 assert!(
848 content.contains("\"phase\""),
849 "checkpoint should contain phase field"
850 );
851 assert!(
852 content.contains("\"version\""),
853 "checkpoint should contain version field"
854 );
855 }
856}