Skip to main content

imp_core/
mana_worker.rs

1//! Canonical single-unit mana worker runtime.
2//!
3//! Provides the reusable substrate for executing one mana unit:
4//! loading the unit via canonical mana-core APIs, assembling execution
5//! context (task prompt, prefill, dependency summaries), and reporting
6//! structured outcomes.
7//!
8//! This module is consumed by:
9//! - `imp run <unit-id>` (imp-cli) — the preferred single-unit CLI path
10//! - legacy `mana run` compatibility flows — transitional dispatch into imp workers
11//! - imp's native mana tool — the first-class orchestration UX
12//!
13//! ## Architecture
14//!
15//! ```text
16//! imp native mana tool        = first-class orchestration UX
17//! imp run (this module)       = canonical single-unit worker runtime
18//! legacy mana run compatibility = transitional parallel dispatch into imp workers
19//! ```
20
21use std::path::{Path, PathBuf};
22
23use crate::contracts::evidence::{ArtifactKind, ArtifactRef, VerifierResult, VerifierStatus};
24pub use crate::contracts::worker::{WorkerAssignment, WorkerAttempt, WorkerResult, WorkerStatus};
25use imp_llm::ThinkingLevel;
26use mana_core::api;
27use mana_core::ops::close::{CloseOpts, CloseOutcome, VerifyFailureResult};
28use mana_core::ops::verify as mana_verify;
29
30use crate::context_prefill::{self, AssembledContext, FileSpec, PrefillConfig};
31use crate::imp_session::{ImpSession, SessionChoice, SessionOptions};
32use crate::mana_prompt_context;
33use crate::system_prompt::{Attempt, Dependency, Fact, TaskContext};
34use crate::tools::LuaToolLoader;
35
36// ---------------------------------------------------------------------------
37// Shared contract re-exports
38// ---------------------------------------------------------------------------
39
40// Canonical worker assignment/outcome vocabulary lives in imp-owned contracts
41// until mana also needs a versioned shared protocol crate.
42// Re-export it here to keep current imp-core call sites stable during migration.
43
44// ---------------------------------------------------------------------------
45// Loading
46// ---------------------------------------------------------------------------
47
48/// Load a worker assignment from a mana unit using canonical mana-core APIs.
49///
50/// This replaces the ad hoc markdown-scanning `load_mana_unit()` that lived
51/// in imp-cli. It uses `mana_core::api::get_unit()` for canonical resolution
52/// and `mana_core::discovery::find_mana_dir()` for `.mana/` discovery.
53pub fn load_assignment(
54    cwd: &Path,
55    unit_id: &str,
56) -> Result<WorkerAssignment, Box<dyn std::error::Error>> {
57    load_assignment_with_mana_dir(cwd, unit_id, None)
58}
59
60/// Load a worker assignment with an explicit mana dir override.
61pub fn load_assignment_with_mana_dir(
62    cwd: &Path,
63    unit_id: &str,
64    mana_dir_override: Option<&Path>,
65) -> Result<WorkerAssignment, Box<dyn std::error::Error>> {
66    let mana_dir = match mana_dir_override {
67        Some(dir) => dir.to_path_buf(),
68        None => mana_core::discovery::find_mana_dir(cwd).map_err(|e| {
69            format!(
70                "Could not find .mana directory while walking up from {}: {e}",
71                cwd.display()
72            )
73        })?,
74    };
75
76    let workspace_root = mana_dir
77        .parent()
78        .map(Path::to_path_buf)
79        .unwrap_or_else(|| cwd.to_path_buf());
80
81    let unit = api::get_unit(&mana_dir, unit_id)
82        .map_err(|e| format!("Failed to load mana unit {unit_id}: {e}"))?;
83
84    // Read the unit file body (description may be in the markdown body
85    // after the frontmatter, which mana-core merges into unit.description).
86    // mana-core's Unit already handles frontmatter+body merging in from_file(),
87    // so unit.description contains the full combined text.
88    let description = unit.description.clone().unwrap_or_default();
89
90    // Also read the markdown body from the unit file for any content
91    // after the frontmatter that mana-core stores separately.
92    let unit_path = mana_core::discovery::find_unit_file(&mana_dir, unit_id).ok();
93    let body = unit_path.as_ref().and_then(|path| {
94        let content = std::fs::read_to_string(path).ok()?;
95        let body = extract_markdown_body(&content)?;
96        if body.trim().is_empty() {
97            None
98        } else {
99            Some(body)
100        }
101    });
102
103    let full_description = match body {
104        Some(body_text) if !description.is_empty() => {
105            format!("{}\n\n{}", description.trim(), body_text.trim())
106        }
107        Some(body_text) => body_text.trim().to_string(),
108        None => description,
109    };
110
111    let attempts = unit
112        .attempt_log
113        .iter()
114        .map(|record| WorkerAttempt {
115            number: record.num,
116            outcome: format!("{:?}", record.outcome).to_lowercase(),
117            summary: record.notes.clone().unwrap_or_default(),
118        })
119        .collect();
120
121    // Extract explicit file references from paths field.
122    let files: Vec<String> = Vec::new(); // Unit doesn't have a separate `files` field in mana-core
123
124    Ok(WorkerAssignment {
125        id: unit.id.clone(),
126        title: unit.title.clone(),
127        description: full_description,
128        acceptance: unit.acceptance.clone(),
129        verify: unit.verify.clone(),
130        notes: unit.notes.clone(),
131        decisions: unit.decisions.clone(),
132        dependencies: unit.dependencies.clone(),
133        paths: unit.paths.clone(),
134        files,
135        attempts,
136        workspace_root,
137        model: unit.model.clone(),
138    })
139}
140
141// ---------------------------------------------------------------------------
142// Context assembly
143// ---------------------------------------------------------------------------
144
145/// Build a `TaskContext` from a worker assignment for system prompt Layer 5.
146fn derive_task_constraints(assignment: &WorkerAssignment) -> Vec<String> {
147    let mut constraints = Vec::new();
148
149    if !assignment.paths.is_empty() || !assignment.files.is_empty() {
150        constraints.push(
151            "Scope changes to the declared file/path hints unless a clear dependency forces broader edits."
152                .to_string(),
153        );
154    }
155
156    if assignment.verify.is_some() {
157        constraints.push(
158            "Treat the verify command as the primary completion gate for this task.".to_string(),
159        );
160    } else {
161        constraints.push(
162            "Do not claim completion until the acceptance criteria are concretely satisfied."
163                .to_string(),
164        );
165    }
166
167    if !assignment.dependencies.is_empty() {
168        constraints.push(
169            "Respect dependency context and avoid reworking already-completed dependency work unless required."
170                .to_string(),
171        );
172    }
173
174    if !assignment.decisions.is_empty() {
175        constraints.push(
176            "Treat unresolved decisions as real constraints; either resolve them explicitly or work around them honestly."
177                .to_string(),
178        );
179    }
180
181    constraints
182}
183
184pub fn build_task_context(assignment: &WorkerAssignment) -> TaskContext {
185    let description = assignment.description.trim().to_string();
186
187    let notes = assignment
188        .notes
189        .as_deref()
190        .map(str::trim)
191        .filter(|n| !n.is_empty())
192        .map(str::to_string);
193
194    let dependencies = if assignment.dependencies.is_empty() {
195        Vec::new()
196    } else {
197        // Try to load dependency summaries from the index
198        let mana_dir = assignment.workspace_root.join(".mana");
199        match api::load_index(&mana_dir) {
200            Ok(index) => assignment
201                .dependencies
202                .iter()
203                .map(|dep_id| {
204                    let entry = index.units.iter().find(|e| e.id == *dep_id);
205                    Dependency {
206                        name: dep_id.clone(),
207                        status: entry
208                            .map(|e| e.status.to_string())
209                            .unwrap_or_else(|| "unknown".to_string()),
210                        detail: entry
211                            .map(|e| e.title.clone())
212                            .unwrap_or_else(|| "not found in active index".to_string()),
213                    }
214                })
215                .collect(),
216            Err(_) => assignment
217                .dependencies
218                .iter()
219                .map(|dep_id| Dependency {
220                    name: dep_id.clone(),
221                    status: "unknown".to_string(),
222                    detail: "dependency status unavailable".to_string(),
223                })
224                .collect(),
225        }
226    };
227
228    let mut context_paths = assignment.paths.clone();
229    for file in &assignment.files {
230        if !context_paths.iter().any(|path| path == file) {
231            context_paths.push(file.clone());
232        }
233    }
234
235    TaskContext {
236        title: assignment.title.clone(),
237        description,
238        acceptance: assignment.acceptance.clone(),
239        verify: assignment.verify.clone(),
240        notes,
241        attempts: assignment
242            .attempts
243            .iter()
244            .map(|a| Attempt {
245                number: a.number,
246                outcome: a.outcome.clone(),
247                summary: a.summary.clone(),
248            })
249            .collect(),
250        dependencies,
251        decisions: assignment.decisions.clone(),
252        context_paths,
253        constraints: derive_task_constraints(assignment),
254    }
255}
256
257pub struct WorkerRunOptions {
258    pub cwd: PathBuf,
259    pub model_override: Option<imp_llm::Model>,
260    pub model: Option<String>,
261    pub provider: Option<String>,
262    pub api_key: Option<String>,
263    pub thinking: Option<ThinkingLevel>,
264    pub max_turns: Option<u32>,
265    pub max_tokens: Option<u32>,
266    pub system_prompt: Option<String>,
267    pub no_tools: bool,
268    pub mana_dir_override: Option<PathBuf>,
269    pub defer_verify: bool,
270    pub lua_loader: Option<LuaToolLoader>,
271}
272
273pub struct PreparedWorkerRun {
274    pub assignment: WorkerAssignment,
275    pub task_context: TaskContext,
276    pub facts: Vec<Fact>,
277    pub prefilled_files: Vec<PathBuf>,
278    pub prefill_warnings: Vec<String>,
279    pub estimated_prefill_tokens: usize,
280    pub prompt: String,
281    pub session: ImpSession,
282    defer_verify: bool,
283}
284
285pub struct WorkerRunOutcome {
286    pub assignment: WorkerAssignment,
287    pub result: WorkerResult,
288    pub verify_passed: Option<bool>,
289    pub closed_after_verify: bool,
290    pub prefilled_files: Vec<PathBuf>,
291    pub prefill_warnings: Vec<String>,
292    pub estimated_prefill_tokens: usize,
293    pub verify_output: Option<String>,
294    pub verifier_result: Option<VerifierResult>,
295}
296
297struct MappedCloseOutcome {
298    status: WorkerStatus,
299    summary: String,
300    error: Option<String>,
301    closed_after_verify: bool,
302    verify_output: Option<String>,
303    verifier_result: Option<VerifierResult>,
304}
305
306pub async fn prepare_worker_run(
307    assignment: WorkerAssignment,
308    options: WorkerRunOptions,
309) -> Result<PreparedWorkerRun, Box<dyn std::error::Error>> {
310    let assembled = assemble_prefill(&assignment, &options.cwd);
311    let task_context = build_task_context(&assignment);
312    let facts = options
313        .mana_dir_override
314        .clone()
315        .or_else(|| mana_prompt_context::nearest_mana_dir(&options.cwd))
316        .map(|mana_dir| {
317            mana_prompt_context::load_task_prompt_context(&mana_dir, &task_context.context_paths)
318                .facts
319        })
320        .unwrap_or_default();
321
322    let session_options = SessionOptions {
323        cwd: options.cwd,
324        model_override: options.model_override,
325        model: options.model,
326        provider: options.provider,
327        api_key: options.api_key,
328        thinking: options.thinking,
329        max_turns: options.max_turns,
330        max_tokens: options.max_tokens,
331        system_prompt: options.system_prompt,
332        no_tools: options.no_tools,
333        session: SessionChoice::InMemory,
334        task: Some(task_context.clone()),
335        facts: facts.clone(),
336        context_prefill: assembled.messages,
337        lua_loader: options.lua_loader,
338        ..Default::default()
339    };
340
341    let session = ImpSession::create(session_options)
342        .await
343        .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
344    let prompt = build_task_prompt(&assignment);
345
346    Ok(PreparedWorkerRun {
347        assignment,
348        task_context,
349        facts,
350        prefilled_files: assembled.included_files,
351        prefill_warnings: assembled.warnings,
352        estimated_prefill_tokens: assembled.estimated_tokens,
353        prompt,
354        session,
355        defer_verify: options.defer_verify,
356    })
357}
358
359pub async fn finalize_worker_run(
360    prepared: PreparedWorkerRun,
361) -> Result<WorkerRunOutcome, Box<dyn std::error::Error>> {
362    let PreparedWorkerRun {
363        assignment,
364        prefilled_files,
365        prefill_warnings,
366        estimated_prefill_tokens,
367        session,
368        defer_verify,
369        ..
370    } = prepared;
371
372    let model = Some(session.model().meta.id.clone());
373    let tool_count = session
374        .session_manager()
375        .get_active_messages()
376        .iter()
377        .filter(|message| matches!(message, imp_llm::Message::ToolResult(_)))
378        .count();
379    let turns = session
380        .session_manager()
381        .get_active_messages()
382        .iter()
383        .filter(|message| matches!(message, imp_llm::Message::Assistant(_)))
384        .count();
385
386    let batch_verify = defer_verify || std::env::var("MANA_BATCH_VERIFY").is_ok();
387    let mut verify_passed = None;
388    let mut closed_after_verify = false;
389    let mut status = if assignment
390        .verify
391        .as_deref()
392        .map(str::trim)
393        .filter(|verify| !verify.is_empty())
394        .is_some()
395    {
396        WorkerStatus::AwaitingVerify
397    } else {
398        WorkerStatus::Completed
399    };
400    let mut summary_override = None;
401    let mut verify_output = None;
402    let mut verifier_result = None;
403    let mut error = None;
404
405    if !batch_verify {
406        if let Some(verify) = assignment
407            .verify
408            .as_deref()
409            .map(str::trim)
410            .filter(|verify| !verify.is_empty())
411        {
412            let (passed, output) =
413                run_verify_command(&assignment.id, verify, &assignment.workspace_root).await?;
414            verify_passed = Some(passed);
415            verify_output = output;
416            if passed {
417                let mapped = close_unit_after_verify(&assignment)?;
418                status = mapped.status;
419                error = mapped.error;
420                closed_after_verify = mapped.closed_after_verify;
421                summary_override = Some(mapped.summary);
422                verify_output = mapped.verify_output.or(verify_output);
423                verifier_result = mapped.verifier_result;
424            } else {
425                status = WorkerStatus::Failed;
426                error = Some(format!("Verify command failed: {verify}"));
427            }
428        }
429    }
430
431    let summary = summary_override.or_else(|| {
432        Some(match status {
433            WorkerStatus::Completed => {
434                if closed_after_verify {
435                    format!(
436                        "Unit {} completed and closed after verify pass.",
437                        assignment.id
438                    )
439                } else if verify_passed == Some(true) {
440                    format!("Unit {} completed successfully.", assignment.id)
441                } else {
442                    format!("Unit {} completed.", assignment.id)
443                }
444            }
445            WorkerStatus::AwaitingVerify => {
446                format!("Unit {} completed and is awaiting verify.", assignment.id)
447            }
448            WorkerStatus::Failed => {
449                if verify_passed == Some(false) {
450                    format!("Unit {} finished but verify failed.", assignment.id)
451                } else {
452                    format!("Unit {} failed.", assignment.id)
453                }
454            }
455            WorkerStatus::Blocked => format!("Unit {} is blocked.", assignment.id),
456            WorkerStatus::Cancelled => format!("Unit {} was cancelled.", assignment.id),
457        })
458    });
459
460    let result = WorkerResult {
461        unit_id: assignment.id.clone(),
462        status,
463        summary,
464        error,
465        tool_count,
466        turns,
467        tokens: None,
468        cost: None,
469        model,
470    };
471
472    Ok(WorkerRunOutcome {
473        assignment,
474        result,
475        verify_passed,
476        closed_after_verify,
477        prefilled_files,
478        prefill_warnings,
479        estimated_prefill_tokens,
480        verify_output,
481        verifier_result,
482    })
483}
484
485fn close_unit_after_verify(
486    assignment: &WorkerAssignment,
487) -> Result<MappedCloseOutcome, Box<dyn std::error::Error>> {
488    let mana_dir = assignment.workspace_root.join(".mana");
489    let outcome = api::close_unit(
490        &mana_dir,
491        &assignment.id,
492        CloseOpts {
493            reason: None,
494            force: false,
495            defer_verify: false,
496        },
497    )?;
498    Ok(map_close_outcome(&assignment.id, outcome))
499}
500
501fn map_close_outcome(unit_id: &str, outcome: CloseOutcome) -> MappedCloseOutcome {
502    match outcome {
503        CloseOutcome::Closed(_) => MappedCloseOutcome {
504            status: WorkerStatus::Completed,
505            summary: format!("Unit {unit_id} completed and closed after verify pass."),
506            error: None,
507            closed_after_verify: true,
508            verify_output: None,
509            verifier_result: None,
510        },
511        CloseOutcome::DeferredVerify { .. } => MappedCloseOutcome {
512            status: WorkerStatus::AwaitingVerify,
513            summary: format!("Unit {unit_id} completed and is awaiting verify."),
514            error: None,
515            closed_after_verify: false,
516            verify_output: None,
517            verifier_result: None,
518        },
519        CloseOutcome::VerifyFailed(result) => map_verify_failed_close_outcome(unit_id, result),
520        CloseOutcome::RejectedByHook { unit_id } => MappedCloseOutcome {
521            status: WorkerStatus::Blocked,
522            summary: format!("Unit {unit_id} is blocked by a pre-close hook."),
523            error: Some("Pre-close hook rejected close.".to_string()),
524            closed_after_verify: false,
525            verify_output: None,
526            verifier_result: None,
527        },
528        CloseOutcome::FeatureRequiresHuman { unit_id, title, .. } => MappedCloseOutcome {
529            status: WorkerStatus::Blocked,
530            summary: format!("Unit {unit_id} requires human review to close feature '{title}'."),
531            error: Some("Feature unit requires human close.".to_string()),
532            closed_after_verify: false,
533            verify_output: None,
534            verifier_result: None,
535        },
536        CloseOutcome::CircuitBreakerTripped {
537            unit_id,
538            total_attempts,
539            max,
540            ..
541        } => MappedCloseOutcome {
542            status: WorkerStatus::Blocked,
543            summary: format!(
544                "Unit {unit_id} is blocked because the circuit breaker tripped ({total_attempts} >= {max})."
545            ),
546            error: Some("Circuit breaker tripped during close.".to_string()),
547            closed_after_verify: false,
548            verify_output: None,
549            verifier_result: None,
550        },
551        CloseOutcome::MergeConflict { files, .. } => MappedCloseOutcome {
552            status: WorkerStatus::Blocked,
553            summary: format!(
554                "Unit {unit_id} is blocked by merge conflicts during close ({} file(s)).",
555                files.len()
556            ),
557            error: Some(format!("Merge conflict during close: {}", files.join(", "))),
558            closed_after_verify: false,
559            verify_output: None,
560            verifier_result: None,
561        },
562        CloseOutcome::VerifyFrozenViolation { unit_id, .. } => MappedCloseOutcome {
563            status: WorkerStatus::Blocked,
564            summary: format!("Unit {unit_id} is blocked because the verify command changed since claim."),
565            error: Some("Verify frozen violation during close.".to_string()),
566            closed_after_verify: false,
567            verify_output: None,
568            verifier_result: None,
569        },
570    }
571}
572
573fn map_verify_failed_close_outcome(
574    unit_id: &str,
575    result: VerifyFailureResult,
576) -> MappedCloseOutcome {
577    let summary = if result.timed_out {
578        format!("Unit {unit_id} failed during close because verify timed out.")
579    } else {
580        format!("Unit {unit_id} failed during close because verify failed.")
581    };
582    let error = if result.output.trim().is_empty() {
583        Some(format!(
584            "Verify command failed during close: {}",
585            result.verify_command
586        ))
587    } else {
588        Some(format!(
589            "Verify command failed during close: {}\n{}",
590            result.verify_command, result.output
591        ))
592    };
593
594    let verifier_result = build_verify_failure_verifier_result(unit_id, &result);
595
596    MappedCloseOutcome {
597        status: WorkerStatus::Failed,
598        summary,
599        error,
600        closed_after_verify: false,
601        verify_output: Some(result.output),
602        verifier_result: Some(verifier_result),
603    }
604}
605
606fn build_verify_failure_verifier_result(
607    unit_id: &str,
608    result: &VerifyFailureResult,
609) -> VerifierResult {
610    let mut artifact_refs = Vec::new();
611    if !result.output.trim().is_empty() {
612        artifact_refs.push(ArtifactRef {
613            artifact_id: format!("{unit_id}:verify-output"),
614            kind: ArtifactKind::VerifyOutput,
615            locator: format!("verify-output://{unit_id}"),
616            run_id: None,
617            unit_id: Some(unit_id.to_string()),
618            stage: Some("verify".to_string()),
619        });
620    }
621
622    VerifierResult {
623        verifier_name: "unit.verify".to_string(),
624        status: VerifierStatus::Failed,
625        command: Some(result.verify_command.clone()),
626        exit_code: result.exit_code,
627        summary: Some(if result.timed_out {
628            "verify timed out".to_string()
629        } else {
630            "verify failed".to_string()
631        }),
632        artifact_refs,
633        started_at: None,
634        finished_at: None,
635        run_id: None,
636        unit_id: Some(unit_id.to_string()),
637    }
638}
639
640pub async fn run_worker_assignment(
641    assignment: WorkerAssignment,
642    options: WorkerRunOptions,
643) -> Result<WorkerRunOutcome, Box<dyn std::error::Error>> {
644    let mut prepared = prepare_worker_run(assignment, options).await?;
645    prepared
646        .session
647        .prompt(&prepared.prompt)
648        .await
649        .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
650    prepared
651        .session
652        .wait()
653        .await
654        .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
655    finalize_worker_run(prepared).await
656}
657
658async fn run_verify_command(
659    unit_id: &str,
660    verify: &str,
661    cwd: &Path,
662) -> Result<(bool, Option<String>), Box<dyn std::error::Error>> {
663    let verify = verify.trim();
664    let working_dir = cwd.to_path_buf();
665    let verify_cmd = verify.to_string();
666    let mana_dir = cwd.join(".mana");
667    let timeout_secs = if mana_dir.exists() {
668        match api::get_unit(&mana_dir, unit_id) {
669            Ok(unit) => {
670                let config = mana_core::config::Config::load_with_extends(&mana_dir).ok();
671                unit.effective_verify_timeout(config.as_ref().and_then(|c| c.verify_timeout))
672            }
673            Err(_) => None,
674        }
675    } else {
676        None
677    };
678
679    let result = tokio::task::spawn_blocking(move || {
680        mana_verify::run_verify_command(&verify_cmd, &working_dir, timeout_secs)
681    })
682    .await
683    .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?
684    .map_err(|e| -> Box<dyn std::error::Error> {
685        Box::new(std::io::Error::other(e.to_string()))
686    })?;
687
688    let output = if result.passed {
689        None
690    } else if result.timed_out {
691        Some(match result.timeout_secs {
692            Some(secs) => format!("Verify timed out after {secs}s"),
693            None => "Verify timed out".to_string(),
694        })
695    } else {
696        let stderr = result.stderr.trim();
697        let stdout = result.stdout.trim();
698        if !stderr.is_empty() {
699            Some(stderr.to_string())
700        } else if !stdout.is_empty() {
701            Some(stdout.to_string())
702        } else {
703            None
704        }
705    };
706
707    Ok((result.passed, output))
708}
709
710/// Build a task prompt string from a worker assignment.
711///
712/// This is the user-facing prompt that starts the agent's work.
713pub fn build_task_prompt(assignment: &WorkerAssignment) -> String {
714    let mut prompt = format!("Task: {}", assignment.title);
715
716    if !assignment.description.trim().is_empty() {
717        prompt.push_str("\n\n");
718        prompt.push_str(assignment.description.trim());
719    }
720
721    if let Some(notes) = assignment
722        .notes
723        .as_deref()
724        .map(str::trim)
725        .filter(|n| !n.is_empty())
726    {
727        prompt.push_str("\n\nNotes:\n");
728        prompt.push_str(notes);
729    }
730
731    if !assignment.files.is_empty() || !assignment.paths.is_empty() {
732        prompt.push_str("\n\nReferenced files:\n");
733        for path in assignment.paths.iter().chain(assignment.files.iter()) {
734            prompt.push_str("- ");
735            prompt.push_str(path);
736            prompt.push('\n');
737        }
738        while prompt.ends_with('\n') {
739            prompt.pop();
740        }
741    }
742
743    if !assignment.attempts.is_empty() {
744        prompt.push_str("\n\nPrevious attempts:\n");
745        for attempt in &assignment.attempts {
746            prompt.push_str(&format!(
747                "- Attempt {} ({}): {}\n",
748                attempt.number, attempt.outcome, attempt.summary
749            ));
750        }
751        while prompt.ends_with('\n') {
752            prompt.pop();
753        }
754    }
755
756    if let Some(verify) = assignment
757        .verify
758        .as_deref()
759        .map(str::trim)
760        .filter(|v| !v.is_empty())
761    {
762        prompt.push_str("\n\nVerify command: ");
763        prompt.push_str(verify);
764    }
765
766    prompt
767}
768
769/// Assemble context prefill messages from the assignment's file references.
770pub fn assemble_prefill(assignment: &WorkerAssignment, cwd: &Path) -> AssembledContext {
771    let file_specs = if !assignment.files.is_empty() {
772        assignment
773            .files
774            .iter()
775            .filter_map(|s| parse_file_spec(s))
776            .collect()
777    } else if !assignment.paths.is_empty() {
778        assignment
779            .paths
780            .iter()
781            .filter_map(|s| parse_file_spec(s))
782            .collect()
783    } else {
784        context_prefill::detect_file_paths(&assignment.description)
785    };
786
787    if file_specs.is_empty() {
788        return AssembledContext::empty();
789    }
790
791    let config = PrefillConfig::default();
792    context_prefill::assemble_context(&file_specs, cwd, &config)
793}
794
795// ---------------------------------------------------------------------------
796// Helpers
797// ---------------------------------------------------------------------------
798
799/// Parse a file spec string like "src/foo.rs" or "src/foo.rs:tail:50".
800fn parse_file_spec(s: &str) -> Option<FileSpec> {
801    let s = s.trim();
802    if s.is_empty() {
803        return None;
804    }
805
806    let (path_str, suffix) = if let Some(dot_pos) = s.rfind('.') {
807        let after_ext = &s[dot_pos..];
808        if let Some(colon_pos) = after_ext.find(':') {
809            let split_at = dot_pos + colon_pos;
810            (&s[..split_at], Some(&s[split_at + 1..]))
811        } else {
812            (s, None)
813        }
814    } else {
815        (s, None)
816    };
817
818    let mode = match suffix {
819        Some(suf) if suf.starts_with("tail:") => suf[5..]
820            .parse::<usize>()
821            .ok()
822            .map(context_prefill::FileMode::Tail)
823            .unwrap_or(context_prefill::FileMode::Full),
824        Some(suf) if suf.contains('-') => {
825            let parts: Vec<&str> = suf.splitn(2, '-').collect();
826            match (
827                parts[0].parse::<usize>(),
828                parts.get(1).and_then(|p| p.parse::<usize>().ok()),
829            ) {
830                (Ok(start), Some(end)) => context_prefill::FileMode::Range(start, end),
831                _ => context_prefill::FileMode::Full,
832            }
833        }
834        _ => context_prefill::FileMode::Full,
835    };
836
837    Some(FileSpec {
838        path: PathBuf::from(path_str),
839        mode,
840    })
841}
842
843/// Extract the markdown body after YAML frontmatter.
844fn extract_markdown_body(content: &str) -> Option<String> {
845    let lines: Vec<&str> = content.lines().collect();
846    if lines.first().copied() != Some("---") {
847        return None;
848    }
849    let end = lines
850        .iter()
851        .enumerate()
852        .skip(1)
853        .find_map(|(i, line)| (*line == "---").then_some(i))?;
854    let body = lines[end + 1..].join("\n");
855    Some(body)
856}
857
858// ---------------------------------------------------------------------------
859// Tests
860// ---------------------------------------------------------------------------
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865
866    #[test]
867    fn build_task_prompt_basic() {
868        let assignment = WorkerAssignment {
869            id: "1".to_string(),
870            title: "Fix the bug".to_string(),
871            description: "There is a null pointer in foo.rs".to_string(),
872            acceptance: None,
873            verify: Some("cargo test".to_string()),
874            notes: None,
875            decisions: Vec::new(),
876            dependencies: Vec::new(),
877            paths: Vec::new(),
878            files: Vec::new(),
879            attempts: Vec::new(),
880            workspace_root: PathBuf::from("/tmp"),
881            model: None,
882        };
883        let prompt = build_task_prompt(&assignment);
884        assert!(prompt.contains("Task: Fix the bug"));
885        assert!(prompt.contains("null pointer"));
886        assert!(prompt.contains("Verify command: cargo test"));
887    }
888
889    #[test]
890    fn build_task_prompt_with_attempts() {
891        let assignment = WorkerAssignment {
892            id: "2".to_string(),
893            title: "Add test".to_string(),
894            description: "Add a test for auth".to_string(),
895            acceptance: None,
896            verify: None,
897            notes: Some("Check the fixtures module".to_string()),
898            decisions: Vec::new(),
899            dependencies: Vec::new(),
900            paths: vec!["tests/auth.rs".to_string()],
901            files: vec!["src/fixtures.rs".to_string()],
902            attempts: vec![WorkerAttempt {
903                number: 1,
904                outcome: "fail".to_string(),
905                summary: "Wrong fixture path".to_string(),
906            }],
907            workspace_root: PathBuf::from("/tmp"),
908            model: None,
909        };
910        let prompt = build_task_prompt(&assignment);
911        assert!(prompt.contains("Notes:"));
912        assert!(prompt.contains("Check the fixtures module"));
913        assert!(prompt.contains("Previous attempts:"));
914        assert!(prompt.contains("Referenced files:"));
915        assert!(prompt.contains("tests/auth.rs"));
916        assert!(prompt.contains("src/fixtures.rs"));
917        assert!(prompt.contains("Attempt 1 (fail): Wrong fixture path"));
918    }
919
920    #[test]
921    fn build_task_context_populates_fields() {
922        let assignment = WorkerAssignment {
923            id: "3".to_string(),
924            title: "Refactor module".to_string(),
925            description: "Split into submodules".to_string(),
926            acceptance: Some("All tests pass".to_string()),
927            verify: Some("cargo test".to_string()),
928            notes: Some("Prefer touching parser and module wiring first".to_string()),
929            decisions: vec!["Use mod.rs or inline?".to_string()],
930            dependencies: Vec::new(),
931            paths: vec!["src/lib.rs".to_string()],
932            files: vec!["src/parser.rs".to_string()],
933            attempts: Vec::new(),
934            workspace_root: PathBuf::from("/tmp"),
935            model: None,
936        };
937        let ctx = build_task_context(&assignment);
938        assert_eq!(ctx.title, "Refactor module");
939        assert_eq!(ctx.acceptance.as_deref(), Some("All tests pass"));
940        assert_eq!(ctx.verify.as_deref(), Some("cargo test"));
941        assert_eq!(
942            ctx.notes.as_deref(),
943            Some("Prefer touching parser and module wiring first")
944        );
945        assert_eq!(ctx.decisions, vec!["Use mod.rs or inline?"]);
946        assert_eq!(ctx.context_paths, vec!["src/lib.rs", "src/parser.rs"]);
947        assert!(ctx.constraints.iter().any(|c| c.contains("Scope changes")));
948        assert!(ctx.constraints.iter().any(|c| c.contains("verify command")));
949    }
950
951    #[test]
952    fn parse_file_spec_plain() {
953        let spec = parse_file_spec("src/main.rs").unwrap();
954        assert_eq!(spec.path, PathBuf::from("src/main.rs"));
955        assert_eq!(spec.mode, context_prefill::FileMode::Full);
956    }
957
958    #[test]
959    fn parse_file_spec_tail() {
960        let spec = parse_file_spec("src/main.rs:tail:50").unwrap();
961        assert_eq!(spec.path, PathBuf::from("src/main.rs"));
962        assert_eq!(spec.mode, context_prefill::FileMode::Tail(50));
963    }
964
965    #[test]
966    fn parse_file_spec_range() {
967        let spec = parse_file_spec("src/main.rs:10-20").unwrap();
968        assert_eq!(spec.path, PathBuf::from("src/main.rs"));
969        assert_eq!(spec.mode, context_prefill::FileMode::Range(10, 20));
970    }
971
972    #[test]
973    fn parse_file_spec_empty() {
974        assert!(parse_file_spec("").is_none());
975        assert!(parse_file_spec("  ").is_none());
976    }
977
978    #[test]
979    fn extract_markdown_body_works() {
980        let content = "---\ntitle: Test\n---\n\nBody text here.";
981        let body = extract_markdown_body(content).unwrap();
982        assert!(body.contains("Body text here."));
983    }
984
985    #[tokio::test]
986    async fn run_verify_command_captures_stderr_without_printing() {
987        let dir = tempfile::tempdir().unwrap();
988        let (passed, output) =
989            run_verify_command("missing", "printf 'boom' >&2; exit 1", dir.path())
990                .await
991                .unwrap();
992        assert!(!passed);
993        assert_eq!(output.as_deref(), Some("boom"));
994    }
995
996    #[tokio::test]
997    async fn run_verify_command_reports_timeout_message() {
998        let dir = tempfile::tempdir().unwrap();
999        let mana_dir = dir.path().join(".mana");
1000        std::fs::create_dir_all(&mana_dir).unwrap();
1001        let unit = mana_core::unit::Unit {
1002            verify_timeout: Some(1),
1003            verify: Some("python3 -c 'import time; time.sleep(2)'".to_string()),
1004            ..mana_core::unit::Unit::new("11", "Slow verify")
1005        };
1006        unit.to_file(mana_dir.join("11-slow-verify.md")).unwrap();
1007        let (passed, output) =
1008            run_verify_command("11", "python3 -c 'import time; time.sleep(2)'", dir.path())
1009                .await
1010                .unwrap();
1011        assert!(!passed);
1012        assert_eq!(output.as_deref(), Some("Verify timed out after 1s"));
1013    }
1014
1015    #[tokio::test]
1016    async fn run_verify_command_falls_back_to_stdout_when_stderr_is_empty() {
1017        let dir = tempfile::tempdir().unwrap();
1018        let (passed, output) = run_verify_command("missing", "printf 'nope'; exit 1", dir.path())
1019            .await
1020            .unwrap();
1021        assert!(!passed);
1022        assert_eq!(output.as_deref(), Some("nope"));
1023    }
1024
1025    #[test]
1026    fn map_close_outcome_closed_is_completed() {
1027        let outcome = CloseOutcome::Closed(mana_core::ops::close::CloseResult {
1028            unit: mana_core::unit::Unit::new("1", "Task"),
1029            archive_path: PathBuf::from("/tmp/archive"),
1030            auto_closed_parents: Vec::new(),
1031            on_close_results: Vec::new(),
1032            warnings: Vec::new(),
1033            auto_commit_result: None,
1034            evidence: None,
1035        });
1036
1037        let mapped = map_close_outcome("1", outcome);
1038        assert_eq!(mapped.status, WorkerStatus::Completed);
1039        assert!(mapped.closed_after_verify);
1040        assert!(mapped.error.is_none());
1041    }
1042
1043    #[test]
1044    fn map_close_outcome_deferred_verify_is_awaiting_verify() {
1045        let mapped = map_close_outcome(
1046            "42",
1047            CloseOutcome::DeferredVerify {
1048                unit_id: "42".to_string(),
1049            },
1050        );
1051        assert_eq!(mapped.status, WorkerStatus::AwaitingVerify);
1052        assert!(!mapped.closed_after_verify);
1053    }
1054
1055    #[test]
1056    fn map_close_outcome_feature_requires_human_is_blocked() {
1057        let mapped = map_close_outcome(
1058            "7",
1059            CloseOutcome::FeatureRequiresHuman {
1060                unit_id: "7".to_string(),
1061                title: "Feature work".to_string(),
1062                warnings: Vec::new(),
1063            },
1064        );
1065        assert_eq!(mapped.status, WorkerStatus::Blocked);
1066        assert!(mapped
1067            .summary
1068            .contains("requires human review to close feature"));
1069        assert!(mapped.error.is_some());
1070    }
1071
1072    #[test]
1073    fn map_close_outcome_verify_failed_is_failed() {
1074        let mut unit = mana_core::unit::Unit::new("9", "Verify fail");
1075        unit.verify = Some("cargo test".to_string());
1076        let mapped = map_close_outcome(
1077            "9",
1078            CloseOutcome::VerifyFailed(VerifyFailureResult {
1079                unit,
1080                attempt_number: 1,
1081                exit_code: Some(1),
1082                output: "boom".to_string(),
1083                timed_out: false,
1084                on_fail_action_taken: None,
1085                verify_command: "cargo test".to_string(),
1086                timeout_secs: None,
1087                warnings: Vec::new(),
1088            }),
1089        );
1090        assert_eq!(mapped.status, WorkerStatus::Failed);
1091        assert_eq!(mapped.verify_output.as_deref(), Some("boom"));
1092        let verifier = mapped.verifier_result.expect("verifier result");
1093        assert_eq!(verifier.status, VerifierStatus::Failed);
1094        assert_eq!(verifier.command.as_deref(), Some("cargo test"));
1095        assert_eq!(verifier.unit_id.as_deref(), Some("9"));
1096        assert_eq!(verifier.artifact_refs.len(), 1);
1097        assert_eq!(verifier.artifact_refs[0].kind, ArtifactKind::VerifyOutput);
1098        assert!(mapped.error.unwrap().contains("cargo test"));
1099    }
1100
1101    #[test]
1102    fn build_verify_failure_verifier_result_omits_artifact_when_output_empty() {
1103        let verifier = build_verify_failure_verifier_result(
1104            "11",
1105            &VerifyFailureResult {
1106                unit: mana_core::unit::Unit::new("11", "Verify fail"),
1107                attempt_number: 1,
1108                exit_code: Some(124),
1109                output: String::new(),
1110                timed_out: true,
1111                on_fail_action_taken: None,
1112                verify_command: "cargo test slow".to_string(),
1113                timeout_secs: Some(30),
1114                warnings: Vec::new(),
1115            },
1116        );
1117        assert_eq!(verifier.status, VerifierStatus::Failed);
1118        assert_eq!(verifier.summary.as_deref(), Some("verify timed out"));
1119        assert!(verifier.artifact_refs.is_empty());
1120    }
1121
1122    #[test]
1123    fn extract_markdown_body_no_frontmatter() {
1124        assert!(extract_markdown_body("No frontmatter").is_none());
1125    }
1126}