Skip to main content

autom8/claude/
runner.rs

1//! Main Claude runner for story implementation.
2//!
3//! Handles running Claude to implement individual user stories.
4
5use std::io::{BufRead, BufReader, Write};
6use std::path::Path;
7use std::process::{Child, Command, Stdio};
8use std::sync::{Arc, Mutex};
9
10use crate::error::{Autom8Error, Result};
11use crate::knowledge::ProjectKnowledge;
12use crate::spec::{Spec, UserStory};
13use crate::state::IterationRecord;
14
15use super::stream::{extract_text_from_stream_line, extract_usage_from_result_line};
16use super::types::{ClaudeErrorInfo, ClaudeOutcome, ClaudeStoryResult, ClaudeUsage};
17use super::utils::{build_knowledge_context, build_previous_context, extract_work_summary};
18
19const COMPLETION_SIGNAL: &str = "<promise>COMPLETE</promise>";
20
21/// Manages a running Claude subprocess, allowing it to be killed for cleanup.
22///
23/// The `ClaudeRunner` stores the child process handle in a thread-safe manner,
24/// allowing the `kill()` method to be called from a signal handler while the
25/// main thread is reading output.
26#[derive(Clone)]
27pub struct ClaudeRunner {
28    child: Arc<Mutex<Option<Child>>>,
29}
30
31impl ClaudeRunner {
32    /// Creates a new `ClaudeRunner` with no active subprocess.
33    pub fn new() -> Self {
34        Self {
35            child: Arc::new(Mutex::new(None)),
36        }
37    }
38
39    /// Kills the subprocess if it is running.
40    ///
41    /// This method:
42    /// - Terminates the subprocess using SIGKILL
43    /// - Closes stdin/stdout/stderr handles by dropping the Child
44    /// - Is safe to call multiple times or when no subprocess is running
45    ///
46    /// Returns `Ok(true)` if a process was killed, `Ok(false)` if no process was running.
47    pub fn kill(&self) -> Result<bool> {
48        let mut child_guard = self.child.lock().map_err(|e| {
49            Autom8Error::ClaudeError(format!("Failed to acquire lock for kill: {}", e))
50        })?;
51
52        if let Some(mut child) = child_guard.take() {
53            // Kill the process
54            if let Err(e) = child.kill() {
55                // Process may have already exited - not an error
56                if e.kind() != std::io::ErrorKind::InvalidInput {
57                    return Err(Autom8Error::ClaudeError(format!(
58                        "Failed to kill Claude subprocess: {}",
59                        e
60                    )));
61                }
62            }
63            // Wait for the process to fully terminate to avoid zombie
64            let _ = child.wait();
65            Ok(true)
66        } else {
67            Ok(false)
68        }
69    }
70
71    /// Returns true if the runner currently has an active subprocess.
72    pub fn is_running(&self) -> bool {
73        self.child
74            .lock()
75            .map(|guard| guard.is_some())
76            .unwrap_or(false)
77    }
78
79    /// Stores a child process handle in the runner.
80    fn set_child(&self, child: Child) -> Result<()> {
81        let mut child_guard = self.child.lock().map_err(|e| {
82            Autom8Error::ClaudeError(format!("Failed to acquire lock for set_child: {}", e))
83        })?;
84        *child_guard = Some(child);
85        Ok(())
86    }
87
88    /// Takes the child process out of the runner, returning it.
89    /// Used when the process completes normally.
90    fn take_child(&self) -> Result<Option<Child>> {
91        let mut child_guard = self.child.lock().map_err(|e| {
92            Autom8Error::ClaudeError(format!("Failed to acquire lock for take_child: {}", e))
93        })?;
94        Ok(child_guard.take())
95    }
96}
97
98impl Default for ClaudeRunner {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104impl ClaudeRunner {
105    /// Runs Claude to implement a user story.
106    ///
107    /// This method stores the child process handle internally, allowing
108    /// `kill()` to be called from another thread (e.g., a signal handler)
109    /// to terminate the subprocess.
110    ///
111    /// # Arguments
112    ///
113    /// * `spec` - The spec containing project information
114    /// * `story` - The user story to implement
115    /// * `spec_path` - Path to the spec JSON file
116    /// * `previous_iterations` - Previous iteration records for context
117    /// * `knowledge` - Project knowledge for context
118    /// * `on_output` - Callback for streaming output
119    pub fn run<F>(
120        &self,
121        spec: &Spec,
122        story: &UserStory,
123        spec_path: &Path,
124        previous_iterations: &[IterationRecord],
125        knowledge: &ProjectKnowledge,
126        mut on_output: F,
127    ) -> Result<ClaudeStoryResult>
128    where
129        F: FnMut(&str),
130    {
131        let previous_context = build_previous_context(previous_iterations);
132        let knowledge_context = build_knowledge_context(knowledge);
133        let prompt = build_prompt(
134            spec,
135            story,
136            spec_path,
137            knowledge_context.as_deref(),
138            previous_context.as_deref(),
139        );
140
141        let mut child = Command::new("claude")
142            .args([
143                "--dangerously-skip-permissions",
144                "--print",
145                "--output-format",
146                "stream-json",
147                "--verbose",
148            ])
149            .stdin(Stdio::piped())
150            .stdout(Stdio::piped())
151            .stderr(Stdio::piped())
152            .spawn()
153            .map_err(|e| Autom8Error::ClaudeError(format!("Failed to spawn claude: {}", e)))?;
154
155        // Write prompt to stdin - take and drop stdin handle to close it
156        if let Some(mut stdin) = child.stdin.take() {
157            stdin.write_all(prompt.as_bytes()).map_err(|e| {
158                Autom8Error::ClaudeError(format!("Failed to write to stdin: {}", e))
159            })?;
160            // stdin is dropped here, closing the handle
161        }
162
163        // Take stderr handle before storing child
164        let stderr = child.stderr.take();
165
166        // Take stdout handle before storing child
167        let stdout = child
168            .stdout
169            .take()
170            .ok_or_else(|| Autom8Error::ClaudeError("Failed to capture stdout".into()))?;
171
172        // Store the child so kill() can access it
173        self.set_child(child)?;
174
175        // Stream stdout and check for completion
176        let reader = BufReader::new(stdout);
177        let mut found_complete = false;
178        let mut accumulated_text = String::new();
179        let mut usage: Option<ClaudeUsage> = None;
180
181        for line in reader.lines() {
182            let line = line.map_err(|e| Autom8Error::ClaudeError(format!("Read error: {}", e)))?;
183
184            // Parse stream-json output and extract text content
185            if let Some(text) = extract_text_from_stream_line(&line) {
186                on_output(&text);
187                accumulated_text.push_str(&text);
188
189                if text.contains(COMPLETION_SIGNAL) || accumulated_text.contains(COMPLETION_SIGNAL)
190                {
191                    found_complete = true;
192                }
193            }
194
195            // Try to extract usage from result events
196            if let Some(line_usage) = extract_usage_from_result_line(&line) {
197                usage = Some(line_usage);
198            }
199        }
200
201        // Take the child back to wait for completion
202        let child = self.take_child()?;
203
204        if let Some(mut child) = child {
205            // Wait for process to complete
206            let status = child
207                .wait()
208                .map_err(|e| Autom8Error::ClaudeError(format!("Wait error: {}", e)))?;
209
210            if !status.success() {
211                // Read stderr for error details
212                let stderr_content = stderr
213                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
214                    .unwrap_or_default();
215                let error_info = ClaudeErrorInfo::from_process_failure(
216                    status,
217                    if stderr_content.is_empty() {
218                        None
219                    } else {
220                        Some(stderr_content)
221                    },
222                );
223                return Err(Autom8Error::ClaudeError(error_info.message.clone()));
224            }
225        }
226
227        // Extract work summary from accumulated output
228        let work_summary = extract_work_summary(&accumulated_text);
229
230        let outcome = if found_complete {
231            ClaudeOutcome::AllStoriesComplete
232        } else {
233            ClaudeOutcome::IterationComplete
234        };
235
236        Ok(ClaudeStoryResult {
237            outcome,
238            work_summary,
239            full_output: accumulated_text,
240            usage,
241        })
242    }
243}
244
245/// Convenience function that creates a `ClaudeRunner` and runs Claude.
246///
247/// This maintains backwards compatibility with existing code that uses the
248/// standalone function. For new code that needs to kill the subprocess
249/// (e.g., on signal handling), use `ClaudeRunner` directly.
250pub fn run_claude<F>(
251    spec: &Spec,
252    story: &UserStory,
253    spec_path: &Path,
254    previous_iterations: &[IterationRecord],
255    knowledge: &ProjectKnowledge,
256    on_output: F,
257) -> Result<ClaudeStoryResult>
258where
259    F: FnMut(&str),
260{
261    let runner = ClaudeRunner::new();
262    runner.run(
263        spec,
264        story,
265        spec_path,
266        previous_iterations,
267        knowledge,
268        on_output,
269    )
270}
271
272fn build_prompt(
273    spec: &Spec,
274    story: &UserStory,
275    spec_path: &Path,
276    knowledge_context: Option<&str>,
277    previous_context: Option<&str>,
278) -> String {
279    let acceptance_criteria = story
280        .acceptance_criteria
281        .iter()
282        .map(|c| format!("- {}", c))
283        .collect::<Vec<_>>()
284        .join("\n");
285
286    let spec_path_str = spec_path.display();
287
288    // Build the project knowledge section if we have context
289    let knowledge_section = match knowledge_context {
290        Some(context) => format!(
291            r#"
292## Project Knowledge
293
294{}
295"#,
296            context
297        ),
298        None => String::new(),
299    };
300
301    // Build the previous work section if we have context
302    let previous_work_section = match previous_context {
303        Some(context) => format!(
304            r#"
305## Previous Work
306
307The following user stories have already been completed:
308
309{}
310"#,
311            context
312        ),
313        None => String::new(),
314    };
315
316    format!(
317        r#"You are working on project: {project}
318
319## Current Task
320
321Implement user story **{story_id}: {story_title}**
322
323### Description
324{story_description}
325
326### Acceptance Criteria
327{acceptance_criteria}
328
329## Instructions
330
3311. Implement the user story according to the acceptance criteria
3322. Write tests to verify the implementation if useful
3333. Run the related tests to ensure they pass
3344. After implementation, update `{spec_path}` to set `passes: true` for story {story_id}
335
336## Completion
337
338When ALL user stories in `{spec_path}` have `passes: true`, output exactly:
339<promise>COMPLETE</promise>
340
341This signals that the entire feature is done.
342
343## Work Summary
344
345After completing your implementation, output a brief summary (1-3 sentences) of what you accomplished in this format:
346
347<work-summary>
348Files changed: [list key files]. [Brief description of functionality added/changed].
349</work-summary>
350
351This helps provide context for subsequent tasks.
352
353## Structured Context (Optional)
354
355If helpful for future agents, include any of these optional context blocks:
356
357**Files worked with** (key files and their purpose):
358```
359<files-context>
360path/to/file.rs | Brief purpose description | [key_symbol1, key_symbol2]
361</files-context>
362```
363
364**Architectural decisions** (when you made significant choices):
365```
366<decisions>
367topic | choice made | rationale
368</decisions>
369```
370
371**Patterns established** (conventions future agents should follow):
372```
373<patterns>
374Description of pattern or convention
375</patterns>
376```
377
378These are optional - only include them when they add value for subsequent work.
379
380## Project Context
381
382{spec_description}{knowledge}{previous_work}
383
384## Notes
385{notes}
386"#,
387        project = spec.project,
388        story_id = story.id,
389        story_title = story.title,
390        story_description = story.description,
391        acceptance_criteria = acceptance_criteria,
392        spec_description = spec.description,
393        spec_path = spec_path_str,
394        knowledge = knowledge_section,
395        previous_work = previous_work_section,
396        notes = if story.notes.is_empty() {
397            "None"
398        } else {
399            &story.notes
400        }
401    )
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn test_build_prompt() {
410        let spec = Spec {
411            project: "TestProject".into(),
412            branch_name: "test-branch".into(),
413            description: "A test project".into(),
414            user_stories: vec![],
415        };
416        let story = UserStory {
417            id: "US-001".into(),
418            title: "Test Story".into(),
419            description: "A test story".into(),
420            acceptance_criteria: vec!["Criterion 1".into(), "Criterion 2".into()],
421            priority: 1,
422            passes: false,
423            notes: String::new(),
424        };
425        let spec_path = Path::new("/tmp/spec-test.json");
426
427        let prompt = build_prompt(&spec, &story, spec_path, None, None);
428        assert!(prompt.contains("TestProject"));
429        assert!(prompt.contains("US-001"));
430        assert!(prompt.contains("Criterion 1"));
431        assert!(!prompt.contains("Previous Work"));
432        assert!(!prompt.contains("Project Knowledge"));
433    }
434
435    #[test]
436    fn test_build_prompt_includes_structured_context_section() {
437        let spec = Spec {
438            project: "TestProject".into(),
439            branch_name: "test-branch".into(),
440            description: "A test project".into(),
441            user_stories: vec![],
442        };
443        let story = UserStory {
444            id: "US-001".into(),
445            title: "Test Story".into(),
446            description: "A test story".into(),
447            acceptance_criteria: vec!["Test criterion".into()],
448            priority: 1,
449            passes: false,
450            notes: String::new(),
451        };
452        let spec_path = Path::new("/tmp/spec-test.json");
453
454        let prompt = build_prompt(&spec, &story, spec_path, None, None);
455        assert!(prompt.contains("## Structured Context (Optional)"));
456    }
457
458    #[test]
459    fn test_build_prompt_includes_files_context_instructions() {
460        let spec = Spec {
461            project: "TestProject".into(),
462            branch_name: "test-branch".into(),
463            description: "A test project".into(),
464            user_stories: vec![],
465        };
466        let story = UserStory {
467            id: "US-001".into(),
468            title: "Test Story".into(),
469            description: "A test story".into(),
470            acceptance_criteria: vec!["Test criterion".into()],
471            priority: 1,
472            passes: false,
473            notes: String::new(),
474        };
475        let spec_path = Path::new("/tmp/spec-test.json");
476
477        let prompt = build_prompt(&spec, &story, spec_path, None, None);
478        assert!(prompt.contains("<files-context>"));
479        assert!(prompt.contains("</files-context>"));
480        assert!(prompt.contains("path/to/file.rs | Brief purpose description"));
481    }
482
483    #[test]
484    fn test_build_prompt_includes_decisions_instructions() {
485        let spec = Spec {
486            project: "TestProject".into(),
487            branch_name: "test-branch".into(),
488            description: "A test project".into(),
489            user_stories: vec![],
490        };
491        let story = UserStory {
492            id: "US-001".into(),
493            title: "Test Story".into(),
494            description: "A test story".into(),
495            acceptance_criteria: vec!["Test criterion".into()],
496            priority: 1,
497            passes: false,
498            notes: String::new(),
499        };
500        let spec_path = Path::new("/tmp/spec-test.json");
501
502        let prompt = build_prompt(&spec, &story, spec_path, None, None);
503        assert!(prompt.contains("<decisions>"));
504        assert!(prompt.contains("</decisions>"));
505        assert!(prompt.contains("topic | choice made | rationale"));
506    }
507
508    #[test]
509    fn test_build_prompt_includes_patterns_instructions() {
510        let spec = Spec {
511            project: "TestProject".into(),
512            branch_name: "test-branch".into(),
513            description: "A test project".into(),
514            user_stories: vec![],
515        };
516        let story = UserStory {
517            id: "US-001".into(),
518            title: "Test Story".into(),
519            description: "A test story".into(),
520            acceptance_criteria: vec!["Test criterion".into()],
521            priority: 1,
522            passes: false,
523            notes: String::new(),
524        };
525        let spec_path = Path::new("/tmp/spec-test.json");
526
527        let prompt = build_prompt(&spec, &story, spec_path, None, None);
528        assert!(prompt.contains("<patterns>"));
529        assert!(prompt.contains("</patterns>"));
530    }
531
532    #[test]
533    fn test_build_prompt_structured_context_is_optional() {
534        let spec = Spec {
535            project: "TestProject".into(),
536            branch_name: "test-branch".into(),
537            description: "A test project".into(),
538            user_stories: vec![],
539        };
540        let story = UserStory {
541            id: "US-001".into(),
542            title: "Test Story".into(),
543            description: "A test story".into(),
544            acceptance_criteria: vec!["Test criterion".into()],
545            priority: 1,
546            passes: false,
547            notes: String::new(),
548        };
549        let spec_path = Path::new("/tmp/spec-test.json");
550
551        let prompt = build_prompt(&spec, &story, spec_path, None, None);
552        // Instructions should make it clear that context is optional
553        assert!(prompt.contains("Optional"));
554        assert!(prompt.contains("optional"));
555        assert!(prompt.contains("only include them when they add value"));
556    }
557
558    #[test]
559    fn test_build_prompt_with_empty_knowledge_no_section() {
560        let spec = Spec {
561            project: "TestProject".into(),
562            branch_name: "test-branch".into(),
563            description: "A test project".into(),
564            user_stories: vec![],
565        };
566        let story = UserStory {
567            id: "US-001".into(),
568            title: "Test Story".into(),
569            description: "A test story".into(),
570            acceptance_criteria: vec!["Test criterion".into()],
571            priority: 1,
572            passes: false,
573            notes: String::new(),
574        };
575        let spec_path = Path::new("/tmp/spec-test.json");
576
577        // With None knowledge context, no Project Knowledge section should appear
578        let prompt = build_prompt(&spec, &story, spec_path, None, None);
579        assert!(!prompt.contains("## Project Knowledge"));
580    }
581
582    #[test]
583    fn test_build_prompt_with_knowledge_context() {
584        let spec = Spec {
585            project: "TestProject".into(),
586            branch_name: "test-branch".into(),
587            description: "A test project".into(),
588            user_stories: vec![],
589        };
590        let story = UserStory {
591            id: "US-002".into(),
592            title: "Second Story".into(),
593            description: "A second test story".into(),
594            acceptance_criteria: vec!["Test criterion".into()],
595            priority: 2,
596            passes: false,
597            notes: String::new(),
598        };
599        let spec_path = Path::new("/tmp/spec-test.json");
600
601        let knowledge_context = r#"## Files Modified in This Run
602
603| Path | Purpose | Key Symbols | Stories |
604|------|---------|-------------|---------|
605| s/main.rs | Entry point | main | US-001 |
606
607## Architectural Decisions
608
609- **Database**: SQLite — Embedded, no setup"#;
610
611        let prompt = build_prompt(&spec, &story, spec_path, Some(knowledge_context), None);
612
613        // Should include the Project Knowledge section
614        assert!(prompt.contains("## Project Knowledge"));
615        assert!(prompt.contains("## Files Modified in This Run"));
616        assert!(prompt.contains("s/main.rs"));
617        assert!(prompt.contains("## Architectural Decisions"));
618        assert!(prompt.contains("SQLite"));
619    }
620
621    #[test]
622    fn test_build_prompt_knowledge_appears_before_previous_work() {
623        let spec = Spec {
624            project: "TestProject".into(),
625            branch_name: "test-branch".into(),
626            description: "A test project".into(),
627            user_stories: vec![],
628        };
629        let story = UserStory {
630            id: "US-003".into(),
631            title: "Third Story".into(),
632            description: "A third story".into(),
633            acceptance_criteria: vec!["Test criterion".into()],
634            priority: 3,
635            passes: false,
636            notes: String::new(),
637        };
638        let spec_path = Path::new("/tmp/spec-test.json");
639
640        let knowledge_context = "Files modified: src/main.rs";
641        let previous_context = "US-001: Added feature X\nUS-002: Added feature Y";
642
643        let prompt = build_prompt(
644            &spec,
645            &story,
646            spec_path,
647            Some(knowledge_context),
648            Some(previous_context),
649        );
650
651        // Both sections should exist
652        assert!(prompt.contains("## Project Knowledge"));
653        assert!(prompt.contains("## Previous Work"));
654
655        // Knowledge should appear before Previous Work
656        let knowledge_pos = prompt.find("## Project Knowledge").unwrap();
657        let previous_work_pos = prompt.find("## Previous Work").unwrap();
658        assert!(
659            knowledge_pos < previous_work_pos,
660            "Project Knowledge section should appear before Previous Work section"
661        );
662    }
663
664    #[test]
665    fn test_build_prompt_with_previous_work_only() {
666        let spec = Spec {
667            project: "TestProject".into(),
668            branch_name: "test-branch".into(),
669            description: "A test project".into(),
670            user_stories: vec![],
671        };
672        let story = UserStory {
673            id: "US-002".into(),
674            title: "Second Story".into(),
675            description: "A second story".into(),
676            acceptance_criteria: vec!["Test criterion".into()],
677            priority: 2,
678            passes: false,
679            notes: String::new(),
680        };
681        let spec_path = Path::new("/tmp/spec-test.json");
682
683        let previous_context = "US-001: Added authentication module";
684
685        let prompt = build_prompt(&spec, &story, spec_path, None, Some(previous_context));
686
687        // Should include Previous Work but not Project Knowledge
688        assert!(!prompt.contains("## Project Knowledge"));
689        assert!(prompt.contains("## Previous Work"));
690        assert!(prompt.contains("US-001: Added authentication module"));
691    }
692
693    #[test]
694    fn test_build_prompt_with_both_knowledge_and_previous_work() {
695        let spec = Spec {
696            project: "TestProject".into(),
697            branch_name: "test-branch".into(),
698            description: "A test project".into(),
699            user_stories: vec![],
700        };
701        let story = UserStory {
702            id: "US-003".into(),
703            title: "Third Story".into(),
704            description: "Build on previous work".into(),
705            acceptance_criteria: vec!["Test criterion".into()],
706            priority: 3,
707            passes: false,
708            notes: String::new(),
709        };
710        let spec_path = Path::new("/tmp/spec-test.json");
711
712        let knowledge_context = r#"## Files Modified
713
714| Path | Purpose |
715|------|---------|
716| s/auth.rs | Authentication |"#;
717        let previous_context = "US-001: Added auth\nUS-002: Added config";
718
719        let prompt = build_prompt(
720            &spec,
721            &story,
722            spec_path,
723            Some(knowledge_context),
724            Some(previous_context),
725        );
726
727        // Both sections should exist
728        assert!(prompt.contains("## Project Knowledge"));
729        assert!(prompt.contains("## Previous Work"));
730        assert!(prompt.contains("s/auth.rs"));
731        assert!(prompt.contains("US-001: Added auth"));
732        assert!(prompt.contains("US-002: Added config"));
733    }
734
735    #[test]
736    fn test_build_prompt_knowledge_section_structure() {
737        let spec = Spec {
738            project: "TestProject".into(),
739            branch_name: "test-branch".into(),
740            description: "A test project".into(),
741            user_stories: vec![],
742        };
743        let story = UserStory {
744            id: "US-002".into(),
745            title: "Test Story".into(),
746            description: "Test description".into(),
747            acceptance_criteria: vec!["Test".into()],
748            priority: 1,
749            passes: false,
750            notes: String::new(),
751        };
752        let spec_path = Path::new("/tmp/spec-test.json");
753
754        let knowledge_context = "Test knowledge content";
755
756        let prompt = build_prompt(&spec, &story, spec_path, Some(knowledge_context), None);
757
758        // The knowledge section should have the ## Project Knowledge header
759        // followed by the content
760        assert!(prompt.contains("## Project Knowledge\n\nTest knowledge content"));
761    }
762}