Skip to main content

dial/iteration/
mod.rs

1pub mod context;
2pub mod orchestrator;
3pub mod validation;
4
5use crate::db::{get_db, get_dial_dir};
6use crate::errors::{DialError, Result};
7use crate::failure::{find_trusted_solutions, record_failure};
8use crate::git::{git_commit, git_has_changes, git_is_repo, git_revert_to};
9use crate::output::{bold, dim, green, print_success, red, yellow};
10use crate::task::models::Task;
11use crate::MAX_FIX_ATTEMPTS;
12use chrono::Local;
13use rusqlite::Connection;
14use std::fs;
15
16pub use context::{gather_context, generate_subagent_prompt};
17pub use orchestrator::auto_run;
18pub use validation::run_validation;
19
20pub fn create_iteration(conn: &Connection, task_id: i64, attempt_number: i32) -> Result<i64> {
21    let now = Local::now().to_rfc3339();
22
23    conn.execute(
24        "INSERT INTO iterations (task_id, attempt_number, started_at)
25         VALUES (?1, ?2, ?3)",
26        rusqlite::params![task_id, attempt_number, now],
27    )?;
28
29    let iteration_id = conn.last_insert_rowid();
30
31    // Update task status
32    conn.execute(
33        "UPDATE tasks SET status = 'in_progress', started_at = ?1 WHERE id = ?2",
34        rusqlite::params![now, task_id],
35    )?;
36
37    Ok(iteration_id)
38}
39
40pub fn complete_iteration(
41    conn: &Connection,
42    iteration_id: i64,
43    status: &str,
44    commit_hash: Option<&str>,
45    notes: Option<&str>,
46) -> Result<()> {
47    let started_at: String = conn.query_row(
48        "SELECT started_at FROM iterations WHERE id = ?1",
49        [iteration_id],
50        |row| row.get(0),
51    )?;
52
53    let started = chrono::DateTime::parse_from_rfc3339(&started_at)
54        .map(|dt| dt.with_timezone(&chrono::Local))
55        .unwrap_or_else(|_| Local::now());
56
57    let duration = (Local::now() - started).num_milliseconds() as f64 / 1000.0;
58    let now = Local::now().to_rfc3339();
59
60    conn.execute(
61        "UPDATE iterations
62         SET status = ?1, ended_at = ?2, duration_seconds = ?3, commit_hash = ?4, notes = ?5
63         WHERE id = ?6",
64        rusqlite::params![status, now, duration, commit_hash, notes, iteration_id],
65    )?;
66
67    Ok(())
68}
69
70pub fn iterate_once() -> Result<(bool, String)> {
71    let conn = get_db(None)?;
72
73    // Get next task
74    let mut stmt = conn.prepare(
75        "SELECT id, description, status, priority, blocked_by, spec_section_id, created_at, started_at, completed_at
76         FROM tasks WHERE status = 'pending'
77         ORDER BY priority, id LIMIT 1",
78    )?;
79
80    let task = stmt.query_row([], |row| Task::from_row(row)).ok();
81
82    let task = match task {
83        Some(t) => t,
84        None => {
85            println!("{}", dim("No pending tasks. Task queue empty."));
86            return Ok((false, "empty_queue".to_string()));
87        }
88    };
89
90    println!("{}", bold(&"=".repeat(60)));
91    println!("{}", bold(&format!("Iteration: Task #{}", task.id)));
92    println!("Description: {}", task.description);
93    println!("{}", bold(&"=".repeat(60)));
94    println!();
95
96    // Check for existing failed iterations
97    let max_attempt: Option<i32> = conn
98        .query_row(
99            "SELECT MAX(attempt_number) FROM iterations WHERE task_id = ?1 AND status = 'failed'",
100            [task.id],
101            |row| row.get(0),
102        )
103        .ok()
104        .flatten();
105
106    let attempt_number = max_attempt.unwrap_or(0) + 1;
107
108    if attempt_number > MAX_FIX_ATTEMPTS as i32 {
109        println!(
110            "{}",
111            red(&format!(
112                "Task #{} has failed {} times. Skipping.",
113                task.id, MAX_FIX_ATTEMPTS
114            ))
115        );
116
117        conn.execute(
118            "UPDATE tasks SET status = 'blocked', blocked_by = ?1 WHERE id = ?2",
119            rusqlite::params![format!("Failed {} times", MAX_FIX_ATTEMPTS), task.id],
120        )?;
121
122        return Ok((true, "max_attempts".to_string()));
123    }
124
125    // Create iteration
126    let _iteration_id = create_iteration(&conn, task.id, attempt_number)?;
127    println!("Attempt {} of {}", attempt_number, MAX_FIX_ATTEMPTS);
128
129    // Gather context
130    let context = gather_context(&conn, &task)?;
131    if !context.is_empty() {
132        println!("{}", dim("\nContext gathered. Relevant specs and solutions loaded."));
133    }
134
135    // Store context for the agent
136    let context_file = get_dial_dir().join("current_context.md");
137    let context_content = format!("# Task: {}\n\n{}", task.description, context);
138    fs::write(&context_file, context_content)?;
139    println!("Context written to: {}", context_file.display());
140
141    println!("{}", yellow("\n>>> Agent should now implement the task <<<"));
142    println!("{}", yellow(">>> Run 'dial validate' when ready to validate <<<"));
143    println!("{}", yellow(">>> Or 'dial complete' to mark complete without validation <<<\n"));
144
145    Ok((true, "awaiting_work".to_string()))
146}
147
148pub fn validate_current() -> Result<bool> {
149    let conn = get_db(None)?;
150
151    // Find current in-progress iteration
152    let iteration: Option<(i64, i64, String)> = conn
153        .query_row(
154            "SELECT i.id, i.task_id, t.description
155             FROM iterations i
156             INNER JOIN tasks t ON i.task_id = t.id
157             WHERE i.status = 'in_progress'
158             ORDER BY i.id DESC LIMIT 1",
159            [],
160            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
161        )
162        .ok();
163
164    let (iteration_id, task_id, task_description) = match iteration {
165        Some(i) => i,
166        None => {
167            return Err(DialError::NoIterationInProgress);
168        }
169    };
170
171    println!("Validating iteration #{} for task #{}", iteration_id, task_id);
172
173    // Run validation
174    let (success, error_output) = run_validation(&conn, iteration_id)?;
175
176    if success {
177        // Commit changes
178        let commit_hash = if git_is_repo() && git_has_changes() {
179            let message = format!("DIAL: {}", task_description);
180            if let Some(hash) = git_commit(&message)? {
181                println!("{}", green(&format!("Committed: {}", &hash[..8])));
182                Some(hash)
183            } else {
184                None
185            }
186        } else {
187            None
188        };
189
190        // Complete iteration
191        complete_iteration(&conn, iteration_id, "completed", commit_hash.as_deref(), None)?;
192
193        // Complete task
194        let now = Local::now().to_rfc3339();
195        conn.execute(
196            "UPDATE tasks SET status = 'completed', completed_at = ?1 WHERE id = ?2",
197            rusqlite::params![now, task_id],
198        )?;
199
200        println!("{}", green(&format!("\nIteration #{} completed successfully!", iteration_id)));
201        println!("{}", green(&format!("Task #{} marked as completed.", task_id)));
202
203        // Prompt for learning capture after success
204        println!();
205        println!("{}", bold("📝 Learning Capture"));
206        println!("{}", dim("Did you learn something during this task? Record it now:"));
207        println!("{}", yellow("  dial learn \"what you learned\" -c <category>"));
208        println!("{}", dim("Categories: build, test, setup, gotcha, pattern, tool, other"));
209        println!();
210
211        Ok(true)
212    } else {
213        // Record failure
214        let (failure_id, pattern_id) = record_failure(&conn, iteration_id, &error_output, None, None)?;
215        println!("{}", red(&format!("Recorded failure #{}", failure_id)));
216
217        // Check for trusted solutions
218        let solutions = find_trusted_solutions(&conn, pattern_id)?;
219        if !solutions.is_empty() {
220            println!("{}", yellow("\nTrusted solutions available:"));
221            for sol in solutions {
222                println!("  - {}", sol.description);
223            }
224        }
225
226        // Complete iteration as failed
227        let notes = if error_output.len() > 500 {
228            &error_output[..500]
229        } else {
230            &error_output
231        };
232        complete_iteration(&conn, iteration_id, "failed", None, Some(notes))?;
233
234        // Check if we should revert
235        let fail_count: i64 = conn.query_row(
236            "SELECT COUNT(*) FROM iterations WHERE task_id = ?1 AND status = 'failed'",
237            [task_id],
238            |row| row.get(0),
239        )?;
240
241        if fail_count >= MAX_FIX_ATTEMPTS as i64 {
242            println!("{}", red(&format!("\nMax attempts ({}) reached.", MAX_FIX_ATTEMPTS)));
243
244            // Find last successful commit
245            let last_good_commit: Option<String> = conn
246                .query_row(
247                    "SELECT commit_hash FROM iterations
248                     WHERE status = 'completed' AND commit_hash IS NOT NULL
249                     ORDER BY id DESC LIMIT 1",
250                    [],
251                    |row| row.get(0),
252                )
253                .ok();
254
255            if let Some(hash) = last_good_commit {
256                if git_is_repo() {
257                    println!("{}", yellow(&format!("Reverting to last good commit: {}", &hash[..8])));
258                    git_revert_to(&hash)?;
259                }
260            }
261
262            // Block the task
263            conn.execute(
264                "UPDATE tasks SET status = 'blocked', blocked_by = ?1 WHERE id = ?2",
265                rusqlite::params![format!("Failed {} attempts", MAX_FIX_ATTEMPTS), task_id],
266            )?;
267        } else {
268            // Reset task to pending for retry
269            conn.execute(
270                "UPDATE tasks SET status = 'pending' WHERE id = ?1",
271                [task_id],
272            )?;
273            let remaining = MAX_FIX_ATTEMPTS as i64 - fail_count;
274            println!("{}", yellow(&format!("\nTask reset to pending. {} attempts remaining.", remaining)));
275        }
276
277        Ok(false)
278    }
279}
280
281pub fn run_loop(max_iterations: Option<u32>) -> Result<()> {
282    let dial_dir = get_dial_dir();
283    let stop_file = dial_dir.join("stop");
284
285    // Remove any existing stop file
286    if stop_file.exists() {
287        fs::remove_file(&stop_file)?;
288    }
289
290    println!("{}", bold("Starting DIAL run loop..."));
291    println!("{}", dim("Create .dial/stop file to stop gracefully.\n"));
292
293    let mut iteration_count = 0u32;
294
295    loop {
296        // Check stop flag
297        if stop_file.exists() {
298            println!("{}", yellow("\nStop flag detected. Stopping gracefully."));
299            fs::remove_file(&stop_file)?;
300            break;
301        }
302
303        // Check iteration limit
304        if let Some(max) = max_iterations {
305            if iteration_count >= max {
306                println!("{}", yellow(&format!("\nReached max iterations ({}). Stopping.", max)));
307                break;
308            }
309        }
310
311        // Run one iteration
312        let (_success, result) = iterate_once()?;
313
314        if result == "empty_queue" {
315            println!("\n{}", bold(&"=".repeat(60)));
316            println!("{}", bold("Task queue empty. DIAL run complete."));
317            show_run_summary()?;
318            break;
319        }
320
321        if result == "awaiting_work" {
322            println!("{}", dim("\nWaiting for work. Run 'dial validate' after implementing."));
323            break;
324        }
325
326        iteration_count += 1;
327    }
328
329    Ok(())
330}
331
332fn show_run_summary() -> Result<()> {
333    let conn = get_db(None)?;
334
335    let (_total, completed, failed): (i64, i64, i64) = conn.query_row(
336        "SELECT
337            COUNT(*),
338            SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END),
339            SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END)
340         FROM iterations",
341        [],
342        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
343    )?;
344
345    println!("\nCompleted: {}", completed);
346    println!("Failed: {}", failed);
347
348    let solutions_count: i64 = conn.query_row(
349        "SELECT COUNT(*) FROM solutions WHERE confidence >= ?1",
350        [crate::TRUST_THRESHOLD],
351        |row| row.get(0),
352    )?;
353
354    println!("Solutions learned: {}", solutions_count);
355
356    Ok(())
357}
358
359pub fn revert_to_last_good() -> Result<bool> {
360    if !git_is_repo() {
361        return Err(DialError::NotGitRepo);
362    }
363
364    let conn = get_db(None)?;
365
366    let commit_hash: Option<String> = conn
367        .query_row(
368            "SELECT commit_hash FROM iterations
369             WHERE status = 'completed' AND commit_hash IS NOT NULL
370             ORDER BY id DESC LIMIT 1",
371            [],
372            |row| row.get(0),
373        )
374        .ok();
375
376    match commit_hash {
377        Some(hash) => {
378            println!("{}", yellow(&format!("Reverting to: {}", hash)));
379            if git_revert_to(&hash)? {
380                print_success("Reverted successfully.");
381                Ok(true)
382            } else {
383                println!("{}", red("Revert failed."));
384                Ok(false)
385            }
386        }
387        None => {
388            println!("{}", red("No successful commits found."));
389            Ok(false)
390        }
391    }
392}
393
394pub fn reset_current() -> Result<()> {
395    let conn = get_db(None)?;
396
397    let iteration: Option<(i64, i64)> = conn
398        .query_row(
399            "SELECT id, task_id FROM iterations WHERE status = 'in_progress' ORDER BY id DESC LIMIT 1",
400            [],
401            |row| Ok((row.get(0)?, row.get(1)?)),
402        )
403        .ok();
404
405    match iteration {
406        Some((iteration_id, task_id)) => {
407            let now = Local::now().to_rfc3339();
408
409            // Mark iteration as reverted
410            conn.execute(
411                "UPDATE iterations SET status = 'reverted', ended_at = ?1 WHERE id = ?2",
412                rusqlite::params![now, iteration_id],
413            )?;
414
415            // Reset task to pending
416            conn.execute(
417                "UPDATE tasks SET status = 'pending' WHERE id = ?1",
418                [task_id],
419            )?;
420
421            print_success(&format!(
422                "Reset iteration #{}. Task returned to pending.",
423                iteration_id
424            ));
425        }
426        None => {
427            println!("{}", dim("No iteration in progress."));
428        }
429    }
430
431    Ok(())
432}
433
434pub fn stop_loop() -> Result<()> {
435    let stop_file = get_dial_dir().join("stop");
436    fs::write(&stop_file, "")?;
437    println!("{}", yellow("Stop flag created. DIAL will stop after current iteration."));
438    Ok(())
439}
440
441/// Show fresh context for current or next task
442pub fn show_context() -> Result<()> {
443    let conn = get_db(None)?;
444
445    // Try to find current in-progress task first
446    let task: Option<Task> = conn
447        .query_row(
448            "SELECT t.id, t.description, t.status, t.priority, t.blocked_by, t.spec_section_id, t.created_at, t.started_at, t.completed_at
449             FROM tasks t
450             INNER JOIN iterations i ON i.task_id = t.id
451             WHERE i.status = 'in_progress'
452             ORDER BY i.id DESC LIMIT 1",
453            [],
454            |row| Task::from_row(row),
455        )
456        .ok();
457
458    // If no in-progress task, get next pending task
459    let task = match task {
460        Some(t) => t,
461        None => {
462            let mut stmt = conn.prepare(
463                "SELECT id, description, status, priority, blocked_by, spec_section_id, created_at, started_at, completed_at
464                 FROM tasks WHERE status = 'pending'
465                 ORDER BY priority, id LIMIT 1",
466            )?;
467
468            match stmt.query_row([], |row| Task::from_row(row)).ok() {
469                Some(t) => t,
470                None => {
471                    println!("{}", dim("No pending tasks. Task queue empty."));
472                    return Ok(());
473                }
474            }
475        }
476    };
477
478    println!("{}", bold(&"=".repeat(60)));
479    println!("{}", bold(&format!("Fresh Context: Task #{}", task.id)));
480    println!("{}", bold(&"=".repeat(60)));
481    println!();
482
483    let context = gather_context(&conn, &task)?;
484    let full_context = format!("# Task: {}\n\n{}", task.description, context);
485
486    println!("{}", full_context);
487
488    // Also write to file
489    let context_file = get_dial_dir().join("current_context.md");
490    fs::write(&context_file, &full_context)?;
491    println!("\n{}", dim(&format!("Context written to: {}", context_file.display())));
492
493    Ok(())
494}
495
496/// Generate orchestrator prompt for running tasks with fresh sub-agents
497pub fn orchestrate() -> Result<()> {
498    let conn = get_db(None)?;
499
500    // Get next pending task
501    let mut stmt = conn.prepare(
502        "SELECT id, description, status, priority, blocked_by, spec_section_id, created_at, started_at, completed_at
503         FROM tasks WHERE status = 'pending'
504         ORDER BY priority, id LIMIT 1",
505    )?;
506
507    let task = match stmt.query_row([], |row| Task::from_row(row)).ok() {
508        Some(t) => t,
509        None => {
510            println!("{}", green("All tasks completed! Nothing to orchestrate."));
511            return Ok(());
512        }
513    };
514
515    // Generate the sub-agent prompt
516    let prompt = generate_subagent_prompt(&conn, &task)?;
517
518    println!("{}", bold(&"=".repeat(70)));
519    println!("{}", bold("DIAL Orchestrator Mode"));
520    println!("{}", bold(&"=".repeat(70)));
521    println!();
522    println!("{}", dim("Copy the prompt below to spawn a fresh sub-agent for this task."));
523    println!("{}", dim("After the sub-agent completes, run `dial validate` to commit."));
524    println!();
525    println!("{}", bold("--- SUB-AGENT PROMPT START ---"));
526    println!();
527    println!("{}", prompt);
528    println!("{}", bold("--- SUB-AGENT PROMPT END ---"));
529    println!();
530
531    // Write prompt to file for easy access
532    let prompt_file = get_dial_dir().join("subagent_prompt.md");
533    fs::write(&prompt_file, &prompt)?;
534    println!("{}", dim(&format!("Prompt also saved to: {}", prompt_file.display())));
535
536    // Platform hints
537    println!();
538    println!("{}", bold("Platform Commands:"));
539    println!("  {}", dim("Claude Code: claude -p \"$(cat .dial/subagent_prompt.md)\""));
540    println!("  {}", dim("Codex CLI:   codex --task \"$(cat .dial/subagent_prompt.md)\""));
541    println!("  {}", dim("Gemini:      Copy prompt to new Gemini session"));
542    println!();
543
544    Ok(())
545}