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