use std::path::{Path, PathBuf};
use crate::contracts::evidence::{ArtifactKind, ArtifactRef, VerifierResult, VerifierStatus};
pub use crate::contracts::worker::{WorkerAssignment, WorkerAttempt, WorkerResult, WorkerStatus};
use imp_llm::ThinkingLevel;
use mana_core::api;
use mana_core::ops::close::{CloseOpts, CloseOutcome, VerifyFailureResult};
use mana_core::ops::verify as mana_verify;
use crate::context_prefill::{self, AssembledContext, FileSpec, PrefillConfig};
use crate::imp_session::{ImpSession, SessionChoice, SessionOptions};
use crate::mana_prompt_context;
use crate::system_prompt::{Attempt, Dependency, Fact, TaskContext};
use crate::tools::LuaToolLoader;
use crate::workflow::{AutonomyMode, VerificationGate};
pub fn load_assignment(
cwd: &Path,
unit_id: &str,
) -> Result<WorkerAssignment, Box<dyn std::error::Error>> {
load_assignment_with_mana_dir(cwd, unit_id, None)
}
pub fn load_assignment_with_mana_dir(
cwd: &Path,
unit_id: &str,
mana_dir_override: Option<&Path>,
) -> Result<WorkerAssignment, Box<dyn std::error::Error>> {
let mana_dir = match mana_dir_override {
Some(dir) => dir.to_path_buf(),
None => mana_core::discovery::find_mana_dir(cwd).map_err(|e| {
format!(
"Could not find .mana directory while walking up from {}: {e}",
cwd.display()
)
})?,
};
let workspace_root = mana_dir
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| cwd.to_path_buf());
let unit = api::get_unit(&mana_dir, unit_id)
.map_err(|e| format!("Failed to load mana unit {unit_id}: {e}"))?;
let description = unit.description.clone().unwrap_or_default();
let unit_path = mana_core::discovery::find_unit_file(&mana_dir, unit_id).ok();
let body = unit_path.as_ref().and_then(|path| {
let content = std::fs::read_to_string(path).ok()?;
let body = extract_markdown_body(&content)?;
if body.trim().is_empty() {
None
} else {
Some(body)
}
});
let full_description = match body {
Some(body_text) if !description.is_empty() => {
format!("{}\n\n{}", description.trim(), body_text.trim())
}
Some(body_text) => body_text.trim().to_string(),
None => description,
};
let attempts = unit
.attempt_log
.iter()
.map(|record| WorkerAttempt {
number: record.num,
outcome: format!("{:?}", record.outcome).to_lowercase(),
summary: record.notes.clone().unwrap_or_default(),
})
.collect();
let files: Vec<String> = Vec::new();
Ok(WorkerAssignment {
id: unit.id.clone(),
title: unit.title.clone(),
description: full_description,
design: unit.design.clone(),
acceptance: unit.acceptance.clone(),
verify: unit.verify.clone(),
verify_timeout_secs: unit.effective_verify_timeout(
mana_core::config::Config::load_with_extends(&mana_dir)
.ok()
.and_then(|config| config.verify_timeout),
),
fail_first: unit.fail_first,
notes: unit.notes.clone(),
decisions: unit.decisions.clone(),
dependencies: unit.dependencies.clone(),
paths: unit.paths.clone(),
files,
attempts,
workspace_root,
model: unit.model.clone(),
})
}
fn derive_task_constraints(assignment: &WorkerAssignment) -> Vec<String> {
let mut constraints = Vec::new();
if !assignment.paths.is_empty() || !assignment.files.is_empty() {
constraints.push(
"Scope changes to the declared file/path hints unless a clear dependency forces broader edits."
.to_string(),
);
}
if assignment.verify.is_some() {
constraints.push(
"Treat the verify command as the primary completion gate for this task.".to_string(),
);
} else {
constraints.push(
"Do not claim completion until the acceptance criteria are concretely satisfied."
.to_string(),
);
}
if !assignment.dependencies.is_empty() {
constraints.push(
"Respect dependency context and avoid reworking already-completed dependency work unless required."
.to_string(),
);
}
if assignment.fail_first {
constraints.push(
"Preserve the fail-first contract: do not weaken the verify gate or skip proving the intended behavior."
.to_string(),
);
}
if !assignment.decisions.is_empty() {
constraints.push(
"Treat unresolved decisions as real constraints; either resolve them explicitly or work around them honestly."
.to_string(),
);
}
constraints
}
pub fn build_task_context(assignment: &WorkerAssignment) -> TaskContext {
let description = assignment.description.trim().to_string();
let notes = assignment
.notes
.as_deref()
.map(str::trim)
.filter(|n| !n.is_empty())
.map(str::to_string);
let dependencies = if assignment.dependencies.is_empty() {
Vec::new()
} else {
let mana_dir = assignment.workspace_root.join(".mana");
match api::load_index(&mana_dir) {
Ok(index) => assignment
.dependencies
.iter()
.map(|dep_id| {
let entry = index.units.iter().find(|e| e.id == *dep_id);
Dependency {
name: dep_id.clone(),
status: entry
.map(|e| e.status.to_string())
.unwrap_or_else(|| "unknown".to_string()),
detail: entry
.map(|e| e.title.clone())
.unwrap_or_else(|| "not found in active index".to_string()),
}
})
.collect(),
Err(_) => assignment
.dependencies
.iter()
.map(|dep_id| Dependency {
name: dep_id.clone(),
status: "unknown".to_string(),
detail: "dependency status unavailable".to_string(),
})
.collect(),
}
};
let mut context_paths = assignment.paths.clone();
for file in &assignment.files {
if !context_paths.iter().any(|path| path == file) {
context_paths.push(file.clone());
}
}
TaskContext {
title: assignment.title.clone(),
description,
design: assignment.design.clone(),
acceptance: assignment.acceptance.clone(),
verify: assignment.verify.clone(),
verify_timeout_secs: assignment.verify_timeout_secs,
fail_first: assignment.fail_first,
notes,
attempts: assignment
.attempts
.iter()
.map(|a| Attempt {
number: a.number,
outcome: a.outcome.clone(),
summary: a.summary.clone(),
})
.collect(),
dependencies,
decisions: assignment.decisions.clone(),
context_paths,
constraints: derive_task_constraints(assignment),
}
}
pub struct WorkerRunOptions {
pub cwd: PathBuf,
pub model_override: Option<imp_llm::Model>,
pub model: Option<String>,
pub provider: Option<String>,
pub api_key: Option<String>,
pub thinking: Option<ThinkingLevel>,
pub max_turns: Option<u32>,
pub autonomy_mode: Option<AutonomyMode>,
pub verification_gates: Vec<VerificationGate>,
pub max_tokens: Option<u32>,
pub system_prompt: Option<String>,
pub no_tools: bool,
pub mana_dir_override: Option<PathBuf>,
pub defer_verify: bool,
pub lua_loader: Option<LuaToolLoader>,
}
pub struct PreparedWorkerRun {
pub assignment: WorkerAssignment,
pub task_context: TaskContext,
pub facts: Vec<Fact>,
pub prefilled_files: Vec<PathBuf>,
pub prefill_warnings: Vec<String>,
pub estimated_prefill_tokens: usize,
pub prompt: String,
pub session: ImpSession,
defer_verify: bool,
}
pub struct WorkerRunOutcome {
pub assignment: WorkerAssignment,
pub result: WorkerResult,
pub verify_passed: Option<bool>,
pub closed_after_verify: bool,
pub prefilled_files: Vec<PathBuf>,
pub prefill_warnings: Vec<String>,
pub estimated_prefill_tokens: usize,
pub verify_output: Option<String>,
pub verifier_result: Option<VerifierResult>,
}
struct MappedCloseOutcome {
status: WorkerStatus,
summary: String,
error: Option<String>,
closed_after_verify: bool,
verify_output: Option<String>,
verifier_result: Option<VerifierResult>,
}
pub async fn prepare_worker_run(
assignment: WorkerAssignment,
options: WorkerRunOptions,
) -> Result<PreparedWorkerRun, Box<dyn std::error::Error>> {
let assembled = assemble_prefill(&assignment, &options.cwd);
let task_context = build_task_context(&assignment);
let facts = options
.mana_dir_override
.clone()
.or_else(|| mana_prompt_context::nearest_mana_dir(&options.cwd))
.map(|mana_dir| {
mana_prompt_context::load_task_prompt_context(&mana_dir, &task_context.context_paths)
.facts
})
.unwrap_or_default();
let session_options = SessionOptions {
cwd: options.cwd,
model_override: options.model_override,
model: options.model,
provider: options.provider,
api_key: options.api_key,
thinking: options.thinking,
max_turns: options.max_turns,
autonomy_mode: options.autonomy_mode,
verification_gates: options.verification_gates.clone(),
max_tokens: options.max_tokens,
system_prompt: options.system_prompt,
no_tools: options.no_tools,
session: SessionChoice::InMemory,
task: Some(task_context.clone()),
facts: facts.clone(),
context_prefill: assembled.messages,
lua_loader: options.lua_loader,
..Default::default()
};
let session = ImpSession::create(session_options)
.await
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
let prompt = build_task_prompt(&assignment);
Ok(PreparedWorkerRun {
assignment,
task_context,
facts,
prefilled_files: assembled.included_files,
prefill_warnings: assembled.warnings,
estimated_prefill_tokens: assembled.estimated_tokens,
prompt,
session,
defer_verify: options.defer_verify,
})
}
pub async fn finalize_worker_run(
prepared: PreparedWorkerRun,
) -> Result<WorkerRunOutcome, Box<dyn std::error::Error>> {
let PreparedWorkerRun {
assignment,
prefilled_files,
prefill_warnings,
estimated_prefill_tokens,
session,
defer_verify,
..
} = prepared;
let model = Some(session.model().meta.id.clone());
let tool_count = session
.session_manager()
.get_active_messages()
.iter()
.filter(|message| matches!(message, imp_llm::Message::ToolResult(_)))
.count();
let turns = session
.session_manager()
.get_active_messages()
.iter()
.filter(|message| matches!(message, imp_llm::Message::Assistant(_)))
.count();
let batch_verify = defer_verify || std::env::var("MANA_BATCH_VERIFY").is_ok();
let mut verify_passed = None;
let mut closed_after_verify = false;
let mut status = if assignment
.verify
.as_deref()
.map(str::trim)
.filter(|verify| !verify.is_empty())
.is_some()
{
WorkerStatus::AwaitingVerify
} else {
WorkerStatus::Completed
};
let mut summary_override = None;
let mut verify_output = None;
let mut verifier_result = None;
let mut error = None;
if !batch_verify {
if let Some(verify) = assignment
.verify
.as_deref()
.map(str::trim)
.filter(|verify| !verify.is_empty())
{
let (passed, output) =
run_verify_command(&assignment.id, verify, &assignment.workspace_root).await?;
verify_passed = Some(passed);
verify_output = output;
if passed {
let mapped = close_unit_after_verify(&assignment)?;
status = mapped.status;
error = mapped.error;
closed_after_verify = mapped.closed_after_verify;
summary_override = Some(mapped.summary);
verify_output = mapped.verify_output.or(verify_output);
verifier_result = mapped.verifier_result;
} else {
status = WorkerStatus::Failed;
error = Some(format!("Verify command failed: {verify}"));
}
}
}
let summary = summary_override.or_else(|| {
Some(match status {
WorkerStatus::Completed => {
if closed_after_verify {
format!(
"Unit {} completed and closed after verify pass.",
assignment.id
)
} else if verify_passed == Some(true) {
format!("Unit {} completed successfully.", assignment.id)
} else {
format!("Unit {} completed.", assignment.id)
}
}
WorkerStatus::AwaitingVerify => {
format!("Unit {} completed and is awaiting verify.", assignment.id)
}
WorkerStatus::Failed => {
if verify_passed == Some(false) {
format!("Unit {} finished but verify failed.", assignment.id)
} else {
format!("Unit {} failed.", assignment.id)
}
}
WorkerStatus::Blocked => format!("Unit {} is blocked.", assignment.id),
WorkerStatus::Cancelled => format!("Unit {} was cancelled.", assignment.id),
})
});
let result = WorkerResult {
unit_id: assignment.id.clone(),
status,
summary,
error,
tool_count,
turns,
tokens: None,
cost: None,
model,
};
Ok(WorkerRunOutcome {
assignment,
result,
verify_passed,
closed_after_verify,
prefilled_files,
prefill_warnings,
estimated_prefill_tokens,
verify_output,
verifier_result,
})
}
fn close_unit_after_verify(
assignment: &WorkerAssignment,
) -> Result<MappedCloseOutcome, Box<dyn std::error::Error>> {
let mana_dir = assignment.workspace_root.join(".mana");
let outcome = api::close_unit(
&mana_dir,
&assignment.id,
CloseOpts {
reason: None,
force: false,
defer_verify: false,
},
)?;
Ok(map_close_outcome(&assignment.id, outcome))
}
fn map_close_outcome(unit_id: &str, outcome: CloseOutcome) -> MappedCloseOutcome {
match outcome {
CloseOutcome::Closed(_) => MappedCloseOutcome {
status: WorkerStatus::Completed,
summary: format!("Unit {unit_id} completed and closed after verify pass."),
error: None,
closed_after_verify: true,
verify_output: None,
verifier_result: None,
},
CloseOutcome::DeferredVerify { .. } => MappedCloseOutcome {
status: WorkerStatus::AwaitingVerify,
summary: format!("Unit {unit_id} completed and is awaiting verify."),
error: None,
closed_after_verify: false,
verify_output: None,
verifier_result: None,
},
CloseOutcome::VerifyFailed(result) => map_verify_failed_close_outcome(unit_id, result),
CloseOutcome::RejectedByHook { unit_id } => MappedCloseOutcome {
status: WorkerStatus::Blocked,
summary: format!("Unit {unit_id} is blocked by a pre-close hook."),
error: Some("Pre-close hook rejected close.".to_string()),
closed_after_verify: false,
verify_output: None,
verifier_result: None,
},
CloseOutcome::FeatureRequiresHuman { unit_id, title, .. } => MappedCloseOutcome {
status: WorkerStatus::Blocked,
summary: format!("Unit {unit_id} requires human review to close feature '{title}'."),
error: Some("Feature unit requires human close.".to_string()),
closed_after_verify: false,
verify_output: None,
verifier_result: None,
},
CloseOutcome::CircuitBreakerTripped {
unit_id,
total_attempts,
max,
..
} => MappedCloseOutcome {
status: WorkerStatus::Blocked,
summary: format!(
"Unit {unit_id} is blocked because the circuit breaker tripped ({total_attempts} >= {max})."
),
error: Some("Circuit breaker tripped during close.".to_string()),
closed_after_verify: false,
verify_output: None,
verifier_result: None,
},
CloseOutcome::MergeConflict { files, .. } => MappedCloseOutcome {
status: WorkerStatus::Blocked,
summary: format!(
"Unit {unit_id} is blocked by merge conflicts during close ({} file(s)).",
files.len()
),
error: Some(format!("Merge conflict during close: {}", files.join(", "))),
closed_after_verify: false,
verify_output: None,
verifier_result: None,
},
CloseOutcome::VerifyFrozenViolation { unit_id, .. } => MappedCloseOutcome {
status: WorkerStatus::Blocked,
summary: format!("Unit {unit_id} is blocked because the verify command changed since claim."),
error: Some("Verify frozen violation during close.".to_string()),
closed_after_verify: false,
verify_output: None,
verifier_result: None,
},
}
}
fn map_verify_failed_close_outcome(
unit_id: &str,
result: VerifyFailureResult,
) -> MappedCloseOutcome {
let summary = if result.timed_out {
format!("Unit {unit_id} failed during close because verify timed out.")
} else {
format!("Unit {unit_id} failed during close because verify failed.")
};
let error = if result.output.trim().is_empty() {
Some(format!(
"Verify command failed during close: {}",
result.verify_command
))
} else {
Some(format!(
"Verify command failed during close: {}\n{}",
result.verify_command, result.output
))
};
let verifier_result = build_verify_failure_verifier_result(unit_id, &result);
MappedCloseOutcome {
status: WorkerStatus::Failed,
summary,
error,
closed_after_verify: false,
verify_output: Some(result.output),
verifier_result: Some(verifier_result),
}
}
fn build_verify_failure_verifier_result(
unit_id: &str,
result: &VerifyFailureResult,
) -> VerifierResult {
let mut artifact_refs = Vec::new();
if !result.output.trim().is_empty() {
artifact_refs.push(ArtifactRef {
artifact_id: format!("{unit_id}:verify-output"),
kind: ArtifactKind::VerifyOutput,
locator: format!("verify-output://{unit_id}"),
run_id: None,
unit_id: Some(unit_id.to_string()),
stage: Some("verify".to_string()),
});
}
VerifierResult {
verifier_name: "unit.verify".to_string(),
status: VerifierStatus::Failed,
command: Some(result.verify_command.clone()),
exit_code: result.exit_code,
summary: Some(if result.timed_out {
"verify timed out".to_string()
} else {
"verify failed".to_string()
}),
artifact_refs,
started_at: None,
finished_at: None,
run_id: None,
unit_id: Some(unit_id.to_string()),
}
}
pub async fn run_worker_assignment(
assignment: WorkerAssignment,
options: WorkerRunOptions,
) -> Result<WorkerRunOutcome, Box<dyn std::error::Error>> {
let mut prepared = prepare_worker_run(assignment, options).await?;
prepared
.session
.prompt(&prepared.prompt)
.await
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
prepared
.session
.wait()
.await
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
finalize_worker_run(prepared).await
}
async fn run_verify_command(
unit_id: &str,
verify: &str,
cwd: &Path,
) -> Result<(bool, Option<String>), Box<dyn std::error::Error>> {
let verify = verify.trim();
let working_dir = cwd.to_path_buf();
let verify_cmd = verify.to_string();
let mana_dir = cwd.join(".mana");
let timeout_secs = if mana_dir.exists() {
match api::get_unit(&mana_dir, unit_id) {
Ok(unit) => {
let config = mana_core::config::Config::load_with_extends(&mana_dir).ok();
unit.effective_verify_timeout(config.as_ref().and_then(|c| c.verify_timeout))
}
Err(_) => None,
}
} else {
None
};
let result = tokio::task::spawn_blocking(move || {
mana_verify::run_verify_command(&verify_cmd, &working_dir, timeout_secs)
})
.await
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?
.map_err(|e| -> Box<dyn std::error::Error> {
Box::new(std::io::Error::other(e.to_string()))
})?;
let output = if result.passed {
None
} else if result.timed_out {
Some(match result.timeout_secs {
Some(secs) => format!("Verify timed out after {secs}s"),
None => "Verify timed out".to_string(),
})
} else {
let stderr = result.stderr.trim();
let stdout = result.stdout.trim();
if !stderr.is_empty() {
Some(stderr.to_string())
} else if !stdout.is_empty() {
Some(stdout.to_string())
} else {
None
}
};
Ok((result.passed, output))
}
const MAX_WORKER_PROMPT_FIELD_CHARS: usize = 4_000;
const MAX_WORKER_PROMPT_LIST_ITEMS: usize = 12;
fn bounded_field(value: &str) -> String {
let trimmed = value.trim();
if trimmed.len() <= MAX_WORKER_PROMPT_FIELD_CHARS {
return trimmed.to_string();
}
format!(
"{}\n[truncated: {} additional bytes]",
&trimmed[..MAX_WORKER_PROMPT_FIELD_CHARS],
trimmed.len() - MAX_WORKER_PROMPT_FIELD_CHARS
)
}
fn push_section(prompt: &mut String, heading: &str, body: &str) {
let body = body.trim();
if body.is_empty() {
return;
}
prompt.push_str("\n\n## ");
prompt.push_str(heading);
prompt.push('\n');
prompt.push_str(&bounded_field(body));
}
fn push_list_section<'a, I>(prompt: &mut String, heading: &str, items: I)
where
I: IntoIterator<Item = &'a str>,
{
let mut emitted = 0usize;
let mut omitted = 0usize;
let mut lines = String::new();
for item in items {
let item = item.trim();
if item.is_empty() {
continue;
}
if emitted < MAX_WORKER_PROMPT_LIST_ITEMS {
lines.push_str("- ");
lines.push_str(item);
lines.push('\n');
emitted += 1;
} else {
omitted += 1;
}
}
if omitted > 0 {
lines.push_str(&format!("- … {omitted} more omitted for prompt bounds\n"));
}
if !lines.trim().is_empty() {
push_section(prompt, heading, lines.trim_end());
}
}
pub fn build_task_prompt(assignment: &WorkerAssignment) -> String {
let mut prompt = format!(
"# Mana worker assignment\n\nUnit: {}\nTitle: {}\nWorkspace: {}",
assignment.id,
assignment.title,
assignment.workspace_root.display()
);
push_section(&mut prompt, "Task", &assignment.description);
if let Some(design) = assignment.design.as_deref() {
push_section(&mut prompt, "Design / architecture context", design);
}
if let Some(acceptance) = assignment.acceptance.as_deref() {
push_section(&mut prompt, "Acceptance criteria", acceptance);
}
if !assignment.paths.is_empty() || !assignment.files.is_empty() {
push_list_section(
&mut prompt,
"Relevant paths",
assignment
.paths
.iter()
.chain(assignment.files.iter())
.map(String::as_str),
);
}
if !assignment.dependencies.is_empty() {
push_list_section(
&mut prompt,
"Dependencies / blockers",
assignment.dependencies.iter().map(String::as_str),
);
}
if !assignment.decisions.is_empty() {
push_list_section(
&mut prompt,
"Decisions to respect",
assignment.decisions.iter().map(String::as_str),
);
}
if let Some(notes) = assignment.notes.as_deref() {
push_section(&mut prompt, "Current notes / prior context", notes);
}
if !assignment.attempts.is_empty() {
let attempts = assignment
.attempts
.iter()
.map(|attempt| {
format!(
"- Attempt {} ({}): {}",
attempt.number, attempt.outcome, attempt.summary
)
})
.collect::<Vec<_>>()
.join("\n");
push_section(&mut prompt, "Previous attempts", &attempts);
}
if let Some(verify) = assignment
.verify
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
let mut verify_section = format!("Command: {verify}");
if let Some(timeout_secs) = assignment.verify_timeout_secs {
verify_section.push_str(&format!("\nTimeout: {timeout_secs}s"));
}
if assignment.fail_first {
verify_section.push_str("\nFail-first: verify was expected to fail before implementation; preserve that contract.");
}
push_section(&mut prompt, "Verification contract", &verify_section);
} else if assignment.acceptance.is_some() {
push_section(
&mut prompt,
"Verification contract",
"No verify command is defined. Use the acceptance criteria as the completion gate and record concrete evidence before closing.",
);
}
push_section(
&mut prompt,
"Completion instructions",
"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.",
);
prompt
}
pub fn assemble_prefill(assignment: &WorkerAssignment, cwd: &Path) -> AssembledContext {
let file_specs = if !assignment.files.is_empty() {
assignment
.files
.iter()
.filter_map(|s| parse_file_spec(s))
.collect()
} else if !assignment.paths.is_empty() {
assignment
.paths
.iter()
.filter_map(|s| parse_file_spec(s))
.collect()
} else {
context_prefill::detect_file_paths(&assignment.description)
};
if file_specs.is_empty() {
return AssembledContext::empty();
}
let config = PrefillConfig::default();
context_prefill::assemble_context(&file_specs, cwd, &config)
}
fn parse_file_spec(s: &str) -> Option<FileSpec> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (path_str, suffix) = if let Some(dot_pos) = s.rfind('.') {
let after_ext = &s[dot_pos..];
if let Some(colon_pos) = after_ext.find(':') {
let split_at = dot_pos + colon_pos;
(&s[..split_at], Some(&s[split_at + 1..]))
} else {
(s, None)
}
} else {
(s, None)
};
let mode = match suffix {
Some(suf) if suf.starts_with("tail:") => suf[5..]
.parse::<usize>()
.ok()
.map(context_prefill::FileMode::Tail)
.unwrap_or(context_prefill::FileMode::Full),
Some(suf) if suf.contains('-') => {
let parts: Vec<&str> = suf.splitn(2, '-').collect();
match (
parts[0].parse::<usize>(),
parts.get(1).and_then(|p| p.parse::<usize>().ok()),
) {
(Ok(start), Some(end)) => context_prefill::FileMode::Range(start, end),
_ => context_prefill::FileMode::Full,
}
}
_ => context_prefill::FileMode::Full,
};
Some(FileSpec {
path: PathBuf::from(path_str),
mode,
})
}
fn extract_markdown_body(content: &str) -> Option<String> {
let lines: Vec<&str> = content.lines().collect();
if lines.first().copied() != Some("---") {
return None;
}
let end = lines
.iter()
.enumerate()
.skip(1)
.find_map(|(i, line)| (*line == "---").then_some(i))?;
let body = lines[end + 1..].join("\n");
Some(body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_task_prompt_basic() {
let assignment = WorkerAssignment {
id: "1".to_string(),
title: "Fix the bug".to_string(),
description: "There is a null pointer in foo.rs".to_string(),
design: None,
acceptance: None,
verify: Some("cargo test".to_string()),
verify_timeout_secs: None,
fail_first: false,
notes: None,
decisions: Vec::new(),
dependencies: Vec::new(),
paths: Vec::new(),
files: Vec::new(),
attempts: Vec::new(),
workspace_root: PathBuf::from("/tmp"),
model: None,
};
let prompt = build_task_prompt(&assignment);
assert!(prompt.contains("# Mana worker assignment"));
assert!(prompt.contains("Unit: 1"));
assert!(prompt.contains("Title: Fix the bug"));
assert!(prompt.contains("## Task"));
assert!(prompt.contains("null pointer"));
assert!(prompt.contains("## Verification contract"));
assert!(prompt.contains("Command: cargo test"));
assert!(prompt.contains("## Completion instructions"));
}
#[test]
fn build_task_prompt_with_attempts() {
let assignment = WorkerAssignment {
id: "2".to_string(),
title: "Add test".to_string(),
description: "Add a test for auth".to_string(),
design: Some(
"Follow the existing auth fixture setup instead of introducing a new harness"
.to_string(),
),
acceptance: Some(
"Auth regression test covers the fixture path failure mode".to_string(),
),
verify: None,
verify_timeout_secs: None,
fail_first: false,
notes: Some("Check the fixtures module".to_string()),
decisions: Vec::new(),
dependencies: Vec::new(),
paths: vec!["tests/auth.rs".to_string()],
files: vec!["src/fixtures.rs".to_string()],
attempts: vec![WorkerAttempt {
number: 1,
outcome: "fail".to_string(),
summary: "Wrong fixture path".to_string(),
}],
workspace_root: PathBuf::from("/tmp"),
model: None,
};
let prompt = build_task_prompt(&assignment);
assert!(prompt.contains("## Design / architecture context"));
assert!(prompt.contains("Follow the existing auth fixture setup"));
assert!(prompt.contains("## Acceptance criteria"));
assert!(prompt.contains("Auth regression test covers the fixture path failure mode"));
assert!(prompt.contains("## Current notes / prior context"));
assert!(prompt.contains("Check the fixtures module"));
assert!(prompt.contains("## Previous attempts"));
assert!(prompt.contains("## Relevant paths"));
assert!(prompt.contains("tests/auth.rs"));
assert!(prompt.contains("src/fixtures.rs"));
assert!(prompt.contains("Attempt 1 (fail): Wrong fixture path"));
}
#[test]
fn build_task_prompt_is_bounded_and_excludes_unrelated_noise() {
let many_paths = (0..20)
.map(|i| format!("src/file_{i}.rs"))
.collect::<Vec<_>>();
let assignment = WorkerAssignment {
id: "4".to_string(),
title: "Bound context".to_string(),
description: "x".repeat(MAX_WORKER_PROMPT_FIELD_CHARS + 100),
design: None,
acceptance: Some("Acceptance stays visible".to_string()),
verify: None,
verify_timeout_secs: None,
fail_first: false,
notes: Some("Current useful note".to_string()),
decisions: vec!["Decision A".to_string()],
dependencies: vec!["dep-1".to_string()],
paths: many_paths,
files: Vec::new(),
attempts: Vec::new(),
workspace_root: PathBuf::from("/tmp"),
model: None,
};
let prompt = build_task_prompt(&assignment);
assert!(prompt.contains("[truncated:"));
assert!(prompt.contains("… 8 more omitted for prompt bounds"));
assert!(prompt.contains("Acceptance stays visible"));
assert!(prompt.contains("Current useful note"));
assert!(prompt.contains("Decision A"));
assert!(prompt.contains("dep-1"));
assert!(prompt.contains("No verify command is defined"));
assert!(!prompt.contains("unrelated unit"));
}
#[test]
fn build_task_context_populates_fields() {
let assignment = WorkerAssignment {
id: "3".to_string(),
title: "Refactor module".to_string(),
description: "Split into submodules".to_string(),
design: Some("Keep parser extraction local to the current module boundary".to_string()),
acceptance: Some("All tests pass".to_string()),
verify: Some("cargo test".to_string()),
verify_timeout_secs: Some(45),
fail_first: true,
notes: Some("Prefer touching parser and module wiring first".to_string()),
decisions: vec!["Use mod.rs or inline?".to_string()],
dependencies: Vec::new(),
paths: vec!["src/lib.rs".to_string()],
files: vec!["src/parser.rs".to_string()],
attempts: Vec::new(),
workspace_root: PathBuf::from("/tmp"),
model: None,
};
let ctx = build_task_context(&assignment);
assert_eq!(ctx.title, "Refactor module");
assert_eq!(
ctx.design.as_deref(),
Some("Keep parser extraction local to the current module boundary")
);
assert_eq!(ctx.verify_timeout_secs, Some(45));
assert!(ctx.fail_first);
assert_eq!(ctx.verify.as_deref(), Some("cargo test"));
assert_eq!(
ctx.notes.as_deref(),
Some("Prefer touching parser and module wiring first")
);
assert_eq!(ctx.decisions, vec!["Use mod.rs or inline?"]);
assert_eq!(ctx.context_paths, vec!["src/lib.rs", "src/parser.rs"]);
assert!(ctx.constraints.iter().any(|c| c.contains("Scope changes")));
assert!(ctx
.constraints
.iter()
.any(|c| c.contains("fail-first contract")));
}
#[test]
fn parse_file_spec_plain() {
let spec = parse_file_spec("src/main.rs").unwrap();
assert_eq!(spec.path, PathBuf::from("src/main.rs"));
assert_eq!(spec.mode, context_prefill::FileMode::Full);
}
#[test]
fn parse_file_spec_tail() {
let spec = parse_file_spec("src/main.rs:tail:50").unwrap();
assert_eq!(spec.path, PathBuf::from("src/main.rs"));
assert_eq!(spec.mode, context_prefill::FileMode::Tail(50));
}
#[test]
fn parse_file_spec_range() {
let spec = parse_file_spec("src/main.rs:10-20").unwrap();
assert_eq!(spec.path, PathBuf::from("src/main.rs"));
assert_eq!(spec.mode, context_prefill::FileMode::Range(10, 20));
}
#[test]
fn parse_file_spec_empty() {
assert!(parse_file_spec("").is_none());
assert!(parse_file_spec(" ").is_none());
}
#[test]
fn extract_markdown_body_works() {
let content = "---\ntitle: Test\n---\n\nBody text here.";
let body = extract_markdown_body(content).unwrap();
assert!(body.contains("Body text here."));
}
#[tokio::test]
async fn run_verify_command_captures_stderr_without_printing() {
let dir = tempfile::tempdir().unwrap();
let (passed, output) =
run_verify_command("missing", "printf 'boom' >&2; exit 1", dir.path())
.await
.unwrap();
assert!(!passed);
assert_eq!(output.as_deref(), Some("boom"));
}
#[tokio::test]
async fn run_verify_command_reports_timeout_message() {
let dir = tempfile::tempdir().unwrap();
let mana_dir = dir.path().join(".mana");
std::fs::create_dir_all(&mana_dir).unwrap();
let unit = mana_core::unit::Unit {
verify_timeout: Some(1),
verify: Some("python3 -c 'import time; time.sleep(2)'".to_string()),
..mana_core::unit::Unit::new("11", "Slow verify")
};
unit.to_file(mana_dir.join("11-slow-verify.md")).unwrap();
let (passed, output) =
run_verify_command("11", "python3 -c 'import time; time.sleep(2)'", dir.path())
.await
.unwrap();
assert!(!passed);
assert_eq!(output.as_deref(), Some("Verify timed out after 1s"));
}
#[tokio::test]
async fn run_verify_command_falls_back_to_stdout_when_stderr_is_empty() {
let dir = tempfile::tempdir().unwrap();
let (passed, output) = run_verify_command("missing", "printf 'nope'; exit 1", dir.path())
.await
.unwrap();
assert!(!passed);
assert_eq!(output.as_deref(), Some("nope"));
}
#[test]
fn map_close_outcome_closed_is_completed() {
let outcome = CloseOutcome::Closed(mana_core::ops::close::CloseResult {
unit: mana_core::unit::Unit::new("1", "Task"),
archive_path: PathBuf::from("/tmp/archive"),
auto_closed_parents: Vec::new(),
on_close_results: Vec::new(),
warnings: Vec::new(),
auto_commit_result: None,
evidence: None,
});
let mapped = map_close_outcome("1", outcome);
assert_eq!(mapped.status, WorkerStatus::Completed);
assert!(mapped.closed_after_verify);
assert!(mapped.error.is_none());
}
#[test]
fn map_close_outcome_deferred_verify_is_awaiting_verify() {
let mapped = map_close_outcome(
"42",
CloseOutcome::DeferredVerify {
unit_id: "42".to_string(),
},
);
assert_eq!(mapped.status, WorkerStatus::AwaitingVerify);
assert!(!mapped.closed_after_verify);
}
#[test]
fn map_close_outcome_feature_requires_human_is_blocked() {
let mapped = map_close_outcome(
"7",
CloseOutcome::FeatureRequiresHuman {
unit_id: "7".to_string(),
title: "Feature work".to_string(),
warnings: Vec::new(),
},
);
assert_eq!(mapped.status, WorkerStatus::Blocked);
assert!(mapped
.summary
.contains("requires human review to close feature"));
assert!(mapped.error.is_some());
}
#[test]
fn map_close_outcome_verify_failed_is_failed() {
let mut unit = mana_core::unit::Unit::new("9", "Verify fail");
unit.verify = Some("cargo test".to_string());
let mapped = map_close_outcome(
"9",
CloseOutcome::VerifyFailed(VerifyFailureResult {
unit,
attempt_number: 1,
exit_code: Some(1),
output: "boom".to_string(),
timed_out: false,
on_fail_action_taken: None,
verify_command: "cargo test".to_string(),
timeout_secs: None,
warnings: Vec::new(),
}),
);
assert_eq!(mapped.status, WorkerStatus::Failed);
assert_eq!(mapped.verify_output.as_deref(), Some("boom"));
let verifier = mapped.verifier_result.expect("verifier result");
assert_eq!(verifier.status, VerifierStatus::Failed);
assert_eq!(verifier.command.as_deref(), Some("cargo test"));
assert_eq!(verifier.unit_id.as_deref(), Some("9"));
assert_eq!(verifier.artifact_refs.len(), 1);
assert_eq!(verifier.artifact_refs[0].kind, ArtifactKind::VerifyOutput);
assert!(mapped.error.unwrap().contains("cargo test"));
}
#[test]
fn build_verify_failure_verifier_result_omits_artifact_when_output_empty() {
let verifier = build_verify_failure_verifier_result(
"11",
&VerifyFailureResult {
unit: mana_core::unit::Unit::new("11", "Verify fail"),
attempt_number: 1,
exit_code: Some(124),
output: String::new(),
timed_out: true,
on_fail_action_taken: None,
verify_command: "cargo test slow".to_string(),
timeout_secs: Some(30),
warnings: Vec::new(),
},
);
assert_eq!(verifier.status, VerifierStatus::Failed);
assert_eq!(verifier.summary.as_deref(), Some("verify timed out"));
assert!(verifier.artifact_refs.is_empty());
}
#[test]
fn extract_markdown_body_no_frontmatter() {
assert!(extract_markdown_body("No frontmatter").is_none());
}
}