1use 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
36pub 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
60pub 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 let description = unit.description.clone().unwrap_or_default();
89
90 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 let files: Vec<String> = Vec::new(); 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
141fn 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 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
710pub 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
769pub 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
795fn 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
843fn 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#[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}