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;
35use crate::workflow::{AutonomyMode, VerificationGate};
36
37pub 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
61pub 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 let description = unit.description.clone().unwrap_or_default();
90
91 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 let files: Vec<String> = Vec::new(); 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
149fn 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 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
787pub 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
886pub 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
912fn 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
960fn 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#[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}