1use anyhow::{bail, Context, Result};
2use crate::{config::{Config, WorkerProfileConfig, WorkersConfig}, git, ticket, ticket_fmt};
3use crate::wrapper::{WrapperContext, write_temp_file};
4use chrono::Utc;
5use std::path::{Path, PathBuf};
6
7const CLAUDE_WORKER_DEFAULT: &str = include_str!("default/agents/claude/apm.worker.md");
8const CLAUDE_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/claude/apm.spec-writer.md");
9const MOCK_HAPPY_WORKER_DEFAULT: &str = include_str!("default/agents/mock-happy/apm.worker.md");
10const MOCK_HAPPY_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/mock-happy/apm.spec-writer.md");
11const MOCK_SAD_WORKER_DEFAULT: &str = include_str!("default/agents/mock-sad/apm.worker.md");
12const MOCK_SAD_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/mock-sad/apm.spec-writer.md");
13const MOCK_RANDOM_WORKER_DEFAULT: &str = include_str!("default/agents/mock-random/apm.worker.md");
14const MOCK_RANDOM_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/mock-random/apm.spec-writer.md");
15const DEBUG_WORKER_DEFAULT: &str = include_str!("default/agents/debug/apm.worker.md");
16const DEBUG_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/debug/apm.spec-writer.md");
17
18static DEPRECATION_WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
19
20#[cfg(test)]
21static DEPRECATION_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
22
23const DEPRECATION_MSG: &str = "apm: deprecated: `[workers] command`, `args`, and `model` fields are deprecated — migrate to `agent` and `[workers.options]`";
24
25const POST_FETCH_SETTLE_MS: u64 = 1_000;
28
29fn emit_deprecation_warning_to(out: &mut dyn std::io::Write) {
30 use std::sync::atomic::Ordering;
31 if DEPRECATION_WARNED.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_ok() {
32 let _ = writeln!(out, "{DEPRECATION_MSG}");
33 }
34}
35
36fn emit_deprecation_warning() {
37 emit_deprecation_warning_to(&mut std::io::stderr().lock());
38}
39
40pub struct EffectiveWorkerParams {
41 pub command: String,
42 pub args: Vec<String>,
43 pub model: Option<String>,
44 pub env: std::collections::HashMap<String, String>,
45 pub container: Option<String>,
46 pub agent: String,
47 pub options: std::collections::HashMap<String, String>,
48}
49
50pub(crate) fn resolve_profile<'a>(transition: &crate::config::TransitionConfig, config: &'a Config, warnings: &mut Vec<String>) -> Option<&'a WorkerProfileConfig> {
51 let name = transition.profile.as_deref()?;
52 match config.worker_profiles.get(name) {
53 Some(p) => Some(p),
54 None => {
55 warnings.push(format!("warning: worker profile {name:?} not found — using global [workers] config"));
56 None
57 }
58 }
59}
60
61pub fn effective_spawn_params(transition_agent: Option<&str>, profile: Option<&WorkerProfileConfig>, workers: &WorkersConfig) -> EffectiveWorkerParams {
62 let command = profile.and_then(|p| p.command.clone())
64 .or_else(|| workers.command.clone())
65 .unwrap_or_else(|| "claude".to_string());
66 let args = profile.and_then(|p| p.args.clone())
67 .or_else(|| workers.args.clone())
68 .unwrap_or_else(|| vec!["--print".to_string()]);
69
70 let raw_agent = transition_agent
72 .map(str::to_owned)
73 .or_else(|| profile.and_then(|p| p.agent.clone()))
74 .or_else(|| workers.agent.clone());
75
76 let has_legacy = workers.command.is_some()
78 || workers.args.is_some()
79 || workers.model.is_some()
80 || profile.map(|p| p.command.is_some() || p.args.is_some() || p.model.is_some()).unwrap_or(false);
81 if raw_agent.is_none() && has_legacy {
82 emit_deprecation_warning();
83 }
84
85 let agent = raw_agent.unwrap_or_else(|| "claude".to_string());
86
87 let mut options = workers.options.clone();
89 if let Some(p) = profile {
90 for (k, v) in &p.options {
91 options.insert(k.clone(), v.clone());
92 }
93 }
94
95 let model = options.get("model").cloned()
97 .or_else(|| profile.and_then(|p| p.model.clone()))
98 .or_else(|| workers.model.clone());
99
100 let mut env = workers.env.clone();
102 if let Some(p) = profile {
103 for (k, v) in &p.env {
104 env.insert(k.clone(), v.clone());
105 }
106 }
107
108 let container = profile.and_then(|p| p.container.clone())
109 .or_else(|| workers.container.clone());
110
111 EffectiveWorkerParams { command, args, model, env, container, agent, options }
112}
113
114pub(crate) fn apply_frontmatter_agent(
115 agent: &mut String,
116 frontmatter: &ticket_fmt::Frontmatter,
117 profile_name: &str,
118) {
119 if let Some(ov) = frontmatter.agent_overrides.get(profile_name) {
120 *agent = ov.clone();
121 } else if let Some(a) = &frontmatter.agent {
122 *agent = a.clone();
123 }
124 }
126
127pub struct StartOutput {
128 pub id: String,
129 pub old_state: String,
130 pub new_state: String,
131 pub caller_name: String,
132 pub branch: String,
133 pub worktree_path: PathBuf,
134 pub merge_message: Option<String>,
135 pub worker_pid: Option<u32>,
136 pub log_path: Option<PathBuf>,
137 pub worker_name: Option<String>,
138 pub warnings: Vec<String>,
139}
140
141pub struct RunNextOutput {
142 pub ticket_id: Option<String>,
143 pub messages: Vec<String>,
144 pub warnings: Vec<String>,
145 pub worker_pid: Option<u32>,
146 pub log_path: Option<PathBuf>,
147}
148
149pub(crate) fn should_check_claude_compat(root: &Path, agent: &str) -> bool {
152 if agent != "claude" { return false; }
153 matches!(
154 crate::wrapper::resolve_wrapper(root, "claude"),
155 Ok(Some(crate::wrapper::WrapperKind::Builtin(_)))
156 )
157}
158
159pub(crate) fn check_output_format_supported(binary: &str) -> Result<()> {
160 let out = std::process::Command::new(binary)
161 .arg("--help")
162 .output()
163 .map_err(|e| anyhow::anyhow!(
164 "failed to run `{binary} --help` to check worker-driver compatibility: {e}"
165 ))?;
166 let combined = format!(
167 "{}{}",
168 String::from_utf8_lossy(&out.stdout),
169 String::from_utf8_lossy(&out.stderr)
170 );
171 if combined.contains("--output-format") {
172 Ok(())
173 } else {
174 bail!(
175 "worker binary `{binary}` does not advertise `--output-format` in its \
176 --help output; the flag `--output-format stream-json` is required for \
177 full transcript capture in .apm-worker.log.\n\
178 Upgrade the binary to a version that supports this flag, or configure \
179 an alternative worker command in your .apm/config.toml [workers] section."
180 )
181 }
182}
183
184pub struct ManagedChild {
185 pub inner: std::process::Child,
186 temp_files: Vec<PathBuf>,
187 denial_ctx: Option<(PathBuf, PathBuf, String)>,
190}
191
192impl std::ops::Deref for ManagedChild {
193 type Target = std::process::Child;
194 fn deref(&self) -> &std::process::Child { &self.inner }
195}
196
197impl std::ops::DerefMut for ManagedChild {
198 fn deref_mut(&mut self) -> &mut std::process::Child { &mut self.inner }
199}
200
201impl Drop for ManagedChild {
202 fn drop(&mut self) {
203 for f in &self.temp_files {
204 let _ = std::fs::remove_file(f);
205 }
206 if let Some((log_path, worktree_path, ticket_id)) = &self.denial_ctx {
207 run_denial_scan(log_path, worktree_path, ticket_id);
208 }
209 }
210}
211
212fn spawn_worker(ctx: &WrapperContext, agent: &str, project_root: &Path) -> Result<std::process::Child> {
213 use crate::wrapper::{resolve_wrapper, resolve_builtin, WrapperKind, Wrapper};
214 use crate::wrapper::custom::CustomWrapper;
215
216 match resolve_wrapper(project_root, agent)? {
217 Some(WrapperKind::Custom { script_path, manifest }) => {
218 CustomWrapper { script_path, manifest }.spawn(ctx)
219 }
220 Some(WrapperKind::Builtin(name)) => {
221 resolve_builtin(&name).expect("known built-in").spawn(ctx)
222 }
223 None => anyhow::bail!(
224 "agent {:?} not found: checked built-ins {{{}}} and '.apm/agents/{agent}/'",
225 agent,
226 crate::wrapper::list_builtin_names().join(", ")
227 ),
228 }
229}
230
231fn run_denial_scan(log_path: &Path, worktree: &Path, ticket_id: &str) {
234 let summary = crate::denial::scan_transcript(log_path, worktree, ticket_id);
235 let summary_path = crate::denial::summary_path_for(log_path);
236 crate::denial::write_summary(&summary_path, &summary);
237 let unique_cmds = crate::denial::collect_unique_apm_commands(&summary);
238 if !unique_cmds.is_empty() {
239 crate::logger::log(
240 "worker-diag",
241 &format!(
242 "apm_command_denial ticket {} denied apm commands: {}",
243 ticket_id,
244 unique_cmds.join(", ")
245 ),
246 );
247 }
248}
249
250pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_permissions: bool, caller_name: &str) -> Result<StartOutput> {
251 let mut warnings: Vec<String> = Vec::new();
252 let config = Config::load(root)?;
253 let aggressive = config.sync.aggressive && !no_aggressive;
254 let skip_permissions = skip_permissions || config.agents.skip_permissions;
255
256 let startable: Vec<&str> = config.workflow.states.iter()
257 .filter(|s| s.transitions.iter().any(|tr| tr.trigger == "command:start"))
258 .map(|s| s.id.as_str())
259 .collect();
260
261 let mut tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
262 let id = ticket::resolve_id_in_slice(&tickets, id_arg)?;
263
264 let Some(t) = tickets.iter_mut().find(|t| t.frontmatter.id == id) else {
265 bail!("ticket {id:?} not found");
266 };
267
268 let ticket_epic_id = t.frontmatter.epic.clone();
269 let ticket_depends_on = t.frontmatter.depends_on.clone().unwrap_or_default();
270 let fm = &t.frontmatter;
271 if !startable.is_empty() && !startable.contains(&fm.state.as_str()) {
272 bail!(
273 "ticket {id:?} is in state {:?} — not startable\n\
274 Use `apm start` only from: {}",
275 fm.state,
276 startable.join(", ")
277 );
278 }
279
280 let now = Utc::now();
281 let old_state = t.frontmatter.state.clone();
282
283 let triggering_transition = config.workflow.states.iter()
284 .find(|s| s.id == old_state)
285 .and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"));
286
287 let new_state = triggering_transition
288 .map(|tr| tr.to.clone())
289 .unwrap_or_else(|| "in_progress".into());
290
291 t.frontmatter.state = new_state.clone();
292 t.frontmatter.updated_at = Some(now);
293 let when = now.format("%Y-%m-%dT%H:%MZ").to_string();
294 crate::state::append_history(&mut t.body, &old_state, &new_state, &when, caller_name);
295
296 let content = t.serialize()?;
297 let rel_path = format!(
298 "{}/{}",
299 config.tickets.dir.to_string_lossy(),
300 t.path.file_name().unwrap().to_string_lossy()
301 );
302 let branch = t
303 .frontmatter
304 .branch
305 .clone()
306 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
307 .unwrap_or_else(|| format!("ticket/{id}"));
308
309 let default_branch = &config.project.default_branch;
310 let merge_base = t.frontmatter.target_branch.clone()
311 .unwrap_or_else(|| default_branch.to_string());
312
313 if aggressive {
314 if let Err(e) = git::fetch_branch(root, &branch) {
315 warnings.push(format!("warning: fetch failed: {e:#}"));
316 }
317 if let Err(e) = git::fetch_branch(root, default_branch) {
318 warnings.push(format!("warning: fetch {} failed: {e:#}", default_branch));
319 }
320 std::thread::sleep(std::time::Duration::from_millis(POST_FETCH_SETTLE_MS));
321 }
322
323 git::commit_to_branch(root, &branch, &rel_path, &content, &format!("ticket({id}): start — {old_state} → {new_state}"))?;
324
325 let wt_display = crate::worktree::provision_worktree(root, &config, &branch, &mut warnings)?;
326
327 let ref_to_merge = if crate::git_util::remote_branch_tip(&wt_display, &merge_base).is_some() {
328 format!("origin/{merge_base}")
329 } else {
330 merge_base.to_string()
331 };
332 let merge_message = crate::git_util::merge_ref(&wt_display, &ref_to_merge, &mut warnings);
333
334 if !spawn {
335 return Ok(StartOutput {
336 id,
337 old_state,
338 new_state,
339 caller_name: caller_name.to_string(),
340 branch,
341 worktree_path: wt_display,
342 merge_message,
343 worker_pid: None,
344 log_path: None,
345 worker_name: None,
346 warnings,
347 });
348 }
349
350 let profile_name = triggering_transition
351 .and_then(|tr| tr.profile.as_deref())
352 .unwrap_or("")
353 .to_string();
354 let profile = triggering_transition.and_then(|tr| resolve_profile(tr, &config, &mut warnings));
355 let role = profile.and_then(|p| p.role.as_deref()).unwrap_or("worker");
356 let mut params = effective_spawn_params(triggering_transition.and_then(|tr| tr.agent.as_deref()), profile, &config.workers);
357 apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name);
358
359 let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string();
360 let worker_name = format!("{}-{}-{:04x}", params.agent, now_str, rand_u16());
361 let tr_instructions = triggering_transition.and_then(|tr| tr.instructions.as_deref());
362 let tr_role_prefix = triggering_transition.and_then(|tr| tr.role_prefix.as_deref());
363 let worker_system = build_system_prompt(root, tr_instructions, profile, &config.workers, config.agents.instructions.as_deref(), ¶ms.agent, role)?;
364 let raw_prompt = format!("{}\n\n{content}", agent_role_prefix(tr_role_prefix, profile, &id));
365 let with_epic = with_epic_bundle(root, ticket_epic_id.as_deref(), &id, &config, raw_prompt);
366 let ticket_content = with_dependency_bundle(root, &ticket_depends_on, &config, with_epic);
367 let role_prefix = tr_role_prefix
368 .map(|p| p.replace("<id>", &id))
369 .or_else(|| profile.and_then(|p| p.role_prefix.clone()));
370
371 let log_path = wt_display.join(".apm-worker.log");
372
373 let sys_file = write_temp_file("sys", &worker_system)?;
374 let msg_file = write_temp_file("msg", &ticket_content)?;
375 let ctx = WrapperContext {
376 worker_name: worker_name.clone(),
377 agent_type: params.agent.clone(),
378 ticket_id: id.clone(),
379 ticket_branch: branch.clone(),
380 worktree_path: wt_display.clone(),
381 system_prompt_file: sys_file.clone(),
382 user_message_file: msg_file.clone(),
383 skip_permissions,
384 profile: profile_name,
385 role_prefix,
386 options: params.options.clone(),
387 model: params.model.clone(),
388 log_path: log_path.clone(),
389 container: params.container.clone(),
390 extra_env: params.env.clone(),
391 root: root.to_path_buf(),
392 keychain: config.workers.keychain.clone(),
393 current_state: new_state.clone(),
394 command: Some(params.command.clone()),
395 };
396 if should_check_claude_compat(root, ¶ms.agent) {
397 check_output_format_supported(¶ms.command)?;
398 }
399 let mut child = spawn_worker(&ctx, ¶ms.agent, root)?;
400 let pid = child.id();
401
402 let pid_path = wt_display.join(".apm-worker.pid");
403 write_pid_file(&pid_path, pid, &id)?;
404
405 let enforce_isolation = skip_permissions || config.isolation.enforce_worktree_isolation;
406 let wt_for_cleanup = wt_display.clone();
407 let denial_log_path = log_path.clone();
408 let denial_worktree = wt_display.clone();
409 let denial_ticket_id = id.clone();
410 let agent_for_diag = params.agent.clone();
411 std::thread::spawn(move || {
412 let _ = child.wait();
413 let _ = std::fs::remove_file(&sys_file);
414 let _ = std::fs::remove_file(&msg_file);
415 if agent_for_diag == "claude" {
416 run_denial_scan(&denial_log_path, &denial_worktree, &denial_ticket_id);
417 }
418 if enforce_isolation {
419 let _ = crate::wrapper::hook_config::remove_hook_config(&wt_for_cleanup);
420 }
421 });
422
423 Ok(StartOutput {
424 id,
425 old_state,
426 new_state,
427 caller_name: caller_name.to_string(),
428 branch,
429 worktree_path: wt_display,
430 merge_message,
431 worker_pid: Some(pid),
432 log_path: Some(log_path),
433 worker_name: Some(worker_name),
434 warnings,
435 })
436}
437
438pub fn run_next(root: &Path, no_aggressive: bool, spawn: bool, skip_permissions: bool) -> Result<RunNextOutput> {
439 let mut messages: Vec<String> = Vec::new();
440 let mut warnings: Vec<String> = Vec::new();
441 let config = Config::load(root)?;
442 let skip_permissions = skip_permissions || config.agents.skip_permissions;
443 let p = &config.workflow.prioritization;
444 let startable: Vec<&str> = config.workflow.states.iter()
445 .filter(|s| s.transitions.iter().any(|tr| tr.trigger == "command:start"))
446 .map(|s| s.id.as_str())
447 .collect();
448 let actionable_owned = config.actionable_states_for("agent");
449 let actionable: Vec<&str> = actionable_owned.iter().map(|s| s.as_str()).collect();
450 let all_tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
451 let caller_name = crate::config::resolve_caller_name();
452 let current_user = crate::config::resolve_identity(root);
453
454 let active_epic_ids: Vec<Option<String>> = all_tickets.iter()
456 .filter(|t| {
457 let s = t.frontmatter.state.as_str();
458 actionable.contains(&s) && !startable.contains(&s)
459 })
460 .map(|t| t.frontmatter.epic.clone())
461 .collect();
462 let blocked = config.blocked_epics(&active_epic_ids);
463 let default_blocked = config.is_default_branch_blocked(&active_epic_ids);
464 let tickets: Vec<_> = all_tickets.into_iter()
465 .filter(|t| match t.frontmatter.epic.as_deref() {
466 Some(eid) => !blocked.iter().any(|b| b == eid),
467 None => !default_blocked,
468 })
469 .collect();
470
471 let Some(candidate) = ticket::pick_next(&tickets, &actionable, &startable, p.priority_weight, p.effort_weight, p.risk_weight, &config, Some(&caller_name), Some(¤t_user)) else {
472 messages.push("No actionable tickets.".to_string());
473 return Ok(RunNextOutput { ticket_id: None, messages, warnings, worker_pid: None, log_path: None });
474 };
475
476 let id = candidate.frontmatter.id.clone();
477 let old_state = candidate.frontmatter.state.clone();
478
479 let triggering_transition_owned = config.workflow.states.iter()
480 .find(|s| s.id == old_state)
481 .and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"))
482 .cloned();
483 let profile = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, &mut warnings));
484 let state_instructions = config.workflow.states.iter()
485 .find(|s| s.id == old_state)
486 .and_then(|sc| sc.instructions.as_deref())
487 .map(|s| s.to_string());
488 let instructions_text = profile
489 .and_then(|p| p.instructions.as_deref())
490 .map(|path| {
491 match std::fs::read_to_string(root.join(path)) {
492 Ok(s) => s,
493 Err(_) => { warnings.push("warning: instructions file not found".to_string()); String::new() }
494 }
495 })
496 .filter(|s| !s.is_empty())
497 .or_else(|| state_instructions.as_deref()
498 .and_then(|path| {
499 std::fs::read_to_string(root.join(path)).ok()
500 .or_else(|| { warnings.push("warning: instructions file not found".to_string()); None })
501 }));
502 let start_out = run(root, &id, no_aggressive, false, false, &caller_name)?;
503 warnings.extend(start_out.warnings);
504
505 if let Some(ref msg) = start_out.merge_message {
506 messages.push(msg.clone());
507 }
508 messages.push(format!("{}: {} → {} (caller: {}, branch: {})", start_out.id, start_out.old_state, start_out.new_state, start_out.caller_name, start_out.branch));
509 messages.push(format!("Worktree: {}", start_out.worktree_path.display()));
510
511 let tickets2 = ticket::load_all_from_git(root, &config.tickets.dir)?;
512 let Some(t) = tickets2.iter().find(|t| t.frontmatter.id == id) else {
513 return Ok(RunNextOutput { ticket_id: Some(id), messages, warnings, worker_pid: None, log_path: None });
514 };
515
516 let focus_hint = if let Some(ref section) = t.frontmatter.focus_section {
517 let hint = format!("Pay special attention to section: {section}");
518 let rel_path = format!(
519 "{}/{}",
520 config.tickets.dir.to_string_lossy(),
521 t.path.file_name().unwrap().to_string_lossy()
522 );
523 let branch = t.frontmatter.branch.clone()
524 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
525 .unwrap_or_else(|| format!("ticket/{id}"));
526 let mut t_mut = t.clone();
527 t_mut.frontmatter.focus_section = None;
528 let cleared = t_mut.serialize()?;
529 git::commit_to_branch(root, &branch, &rel_path, &cleared, &format!("ticket({id}): clear focus_section"))?;
530 Some(hint)
531 } else {
532 None
533 };
534
535 let mut prompt = String::new();
536 if let Some(ref instr) = instructions_text {
537 prompt.push_str(instr.trim());
538 prompt.push('\n');
539 }
540 if let Some(ref hint) = focus_hint {
541 if !prompt.is_empty() { prompt.push('\n'); }
542 prompt.push_str(hint);
543 prompt.push('\n');
544 }
545
546 if !spawn {
547 if !prompt.is_empty() {
548 messages.push(format!("Prompt:\n{prompt}"));
549 }
550 return Ok(RunNextOutput { ticket_id: Some(id), messages, warnings, worker_pid: None, log_path: None });
551 }
552
553 let profile_name2 = triggering_transition_owned.as_ref()
554 .and_then(|tr| tr.profile.as_deref())
555 .unwrap_or("")
556 .to_string();
557 let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, &mut warnings));
558 let role2 = profile2.and_then(|p| p.role.as_deref()).unwrap_or("worker");
559 let mut params = effective_spawn_params(triggering_transition_owned.as_ref().and_then(|tr| tr.agent.as_deref()), profile2, &config.workers);
560 apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name2);
561
562 let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string();
563 let worker_name = format!("{}-{}-{:04x}", params.agent, now_str, rand_u16());
564 let tr_instructions2 = triggering_transition_owned.as_ref().and_then(|tr| tr.instructions.as_deref());
565 let tr_role_prefix2 = triggering_transition_owned.as_ref().and_then(|tr| tr.role_prefix.as_deref());
566 let worker_system = build_system_prompt(root, tr_instructions2, profile2, &config.workers, config.agents.instructions.as_deref(), ¶ms.agent, role2)?;
567
568 let raw = t.serialize()?;
569 let dep_ids_next = t.frontmatter.depends_on.clone().unwrap_or_default();
570 let raw_prompt_next = format!("{}\n\n{raw}", agent_role_prefix(tr_role_prefix2, profile2, &id));
571 let with_epic_next = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_next);
572 let ticket_content = with_dependency_bundle(root, &dep_ids_next, &config, with_epic_next);
573 let role_prefix2 = tr_role_prefix2
574 .map(|p| p.replace("<id>", &id))
575 .or_else(|| profile2.and_then(|p| p.role_prefix.clone()));
576
577 let branch = t.frontmatter.branch.clone()
578 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
579 .unwrap_or_else(|| format!("ticket/{id}"));
580 let wt_name = branch.replace('/', "-");
581 let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
582 let wt_path = main_root.join(&config.worktrees.dir).join(&wt_name);
583 let wt_display = crate::worktree::find_worktree_for_branch(root, &branch).unwrap_or(wt_path);
584
585 let log_path = wt_display.join(".apm-worker.log");
586
587 let sys_file = write_temp_file("sys", &worker_system)?;
588 let msg_file = write_temp_file("msg", &ticket_content)?;
589 let ctx = WrapperContext {
590 worker_name: worker_name.clone(),
591 agent_type: params.agent.clone(),
592 ticket_id: id.clone(),
593 ticket_branch: branch.clone(),
594 worktree_path: wt_display.clone(),
595 system_prompt_file: sys_file.clone(),
596 user_message_file: msg_file.clone(),
597 skip_permissions,
598 profile: profile_name2,
599 role_prefix: role_prefix2,
600 options: params.options.clone(),
601 model: params.model.clone(),
602 log_path: log_path.clone(),
603 container: params.container.clone(),
604 extra_env: params.env.clone(),
605 root: root.to_path_buf(),
606 keychain: config.workers.keychain.clone(),
607 current_state: t.frontmatter.state.clone(),
608 command: Some(params.command.clone()),
609 };
610 if should_check_claude_compat(root, ¶ms.agent) {
611 check_output_format_supported(¶ms.command)?;
612 }
613 let mut child = spawn_worker(&ctx, ¶ms.agent, root)?;
614 let pid = child.id();
615
616 let pid_path = wt_display.join(".apm-worker.pid");
617 write_pid_file(&pid_path, pid, &id)?;
618 let enforce_isolation_next = skip_permissions || config.isolation.enforce_worktree_isolation;
619 let wt_for_cleanup_next = wt_display.clone();
620 let denial_log_path2 = log_path.clone();
621 let denial_worktree2 = wt_display.clone();
622 let denial_ticket_id2 = id.clone();
623 let agent_for_diag2 = params.agent.clone();
624 std::thread::spawn(move || {
625 let _ = child.wait();
626 let _ = std::fs::remove_file(&sys_file);
627 let _ = std::fs::remove_file(&msg_file);
628 if agent_for_diag2 == "claude" {
629 run_denial_scan(&denial_log_path2, &denial_worktree2, &denial_ticket_id2);
630 }
631 if enforce_isolation_next {
632 let _ = crate::wrapper::hook_config::remove_hook_config(&wt_for_cleanup_next);
633 }
634 });
635
636 messages.push(format!("Worker spawned: PID={pid}, log={}", log_path.display()));
637 messages.push(format!("Agent name: {worker_name}"));
638
639 Ok(RunNextOutput { ticket_id: Some(id), messages, warnings, worker_pid: Some(pid), log_path: Some(log_path) })
640}
641
642#[allow(clippy::type_complexity)]
643pub fn spawn_next_worker(
644 root: &Path,
645 no_aggressive: bool,
646 skip_permissions: bool,
647 epic_filter: Option<&str>,
648 blocked_epics: &[String],
649 default_blocked: bool,
650 messages: &mut Vec<String>,
651 warnings: &mut Vec<String>,
652) -> Result<Option<(String, Option<String>, ManagedChild, PathBuf)>> {
653 let config = Config::load(root)?;
654 let skip_permissions = skip_permissions || config.agents.skip_permissions;
655 let p = &config.workflow.prioritization;
656 let startable: Vec<&str> = config.workflow.states.iter()
657 .filter(|s| s.transitions.iter().any(|tr| tr.trigger == "command:start"))
658 .map(|s| s.id.as_str())
659 .collect();
660 let actionable_owned = config.actionable_states_for("agent");
661 let actionable: Vec<&str> = actionable_owned.iter().map(|s| s.as_str()).collect();
662 let all_tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
663 let tickets: Vec<ticket::Ticket> = {
664 let epic_filtered: Vec<ticket::Ticket> = match epic_filter {
665 Some(epic_id) => all_tickets.into_iter()
666 .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
667 .collect(),
668 None => all_tickets,
669 };
670 epic_filtered.into_iter()
671 .filter(|t| match t.frontmatter.epic.as_deref() {
672 Some(eid) => !blocked_epics.iter().any(|b| b == eid),
673 None => !default_blocked,
674 })
675 .collect()
676 };
677 let caller_name = crate::config::resolve_caller_name();
678 let current_user = crate::config::resolve_identity(root);
679
680 let Some(candidate) = ticket::pick_next(&tickets, &actionable, &startable, p.priority_weight, p.effort_weight, p.risk_weight, &config, Some(&caller_name), Some(¤t_user)) else {
681 return Ok(None);
682 };
683
684 let id = candidate.frontmatter.id.clone();
685 let epic_id = candidate.frontmatter.epic.clone();
686 let old_state = candidate.frontmatter.state.clone();
687
688 let triggering_transition_owned = config.workflow.states.iter()
689 .find(|s| s.id == old_state)
690 .and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"))
691 .cloned();
692 let profile = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, warnings));
693 let state_instructions = config.workflow.states.iter()
694 .find(|s| s.id == old_state)
695 .and_then(|sc| sc.instructions.as_deref())
696 .map(|s| s.to_string());
697 let instructions_text = profile
698 .and_then(|p| p.instructions.as_deref())
699 .map(|path| {
700 match std::fs::read_to_string(root.join(path)) {
701 Ok(s) => s,
702 Err(_) => { warnings.push("warning: instructions file not found".to_string()); String::new() }
703 }
704 })
705 .filter(|s| !s.is_empty())
706 .or_else(|| state_instructions.as_deref()
707 .and_then(|path| {
708 std::fs::read_to_string(root.join(path)).ok()
709 .or_else(|| { warnings.push("warning: instructions file not found".to_string()); None })
710 }));
711 let start_out = run(root, &id, no_aggressive, false, false, &caller_name)?;
712 warnings.extend(start_out.warnings);
713
714 if let Some(ref msg) = start_out.merge_message {
715 messages.push(msg.clone());
716 }
717 messages.push(format!("{}: {} → {} (caller: {}, branch: {})", start_out.id, start_out.old_state, start_out.new_state, start_out.caller_name, start_out.branch));
718 messages.push(format!("Worktree: {}", start_out.worktree_path.display()));
719
720 let tickets2 = ticket::load_all_from_git(root, &config.tickets.dir)?;
721 let Some(t) = tickets2.iter().find(|t| t.frontmatter.id == id) else {
722 return Ok(None);
723 };
724
725 let focus_hint = if let Some(ref section) = t.frontmatter.focus_section {
726 let hint = format!("Pay special attention to section: {section}");
727 let rel_path = format!(
728 "{}/{}",
729 config.tickets.dir.to_string_lossy(),
730 t.path.file_name().unwrap().to_string_lossy()
731 );
732 let branch = t.frontmatter.branch.clone()
733 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
734 .unwrap_or_else(|| format!("ticket/{id}"));
735 let mut t_mut = t.clone();
736 t_mut.frontmatter.focus_section = None;
737 let cleared = t_mut.serialize()?;
738 git::commit_to_branch(root, &branch, &rel_path, &cleared,
739 &format!("ticket({id}): clear focus_section"))?;
740 Some(hint)
741 } else {
742 None
743 };
744
745 let mut prompt = String::new();
746 if let Some(ref instr) = instructions_text {
747 prompt.push_str(instr.trim());
748 prompt.push('\n');
749 }
750 if let Some(ref hint) = focus_hint {
751 if !prompt.is_empty() { prompt.push('\n'); }
752 prompt.push_str(hint);
753 prompt.push('\n');
754 }
755 let _ = prompt; let profile_name2 = triggering_transition_owned.as_ref()
758 .and_then(|tr| tr.profile.as_deref())
759 .unwrap_or("")
760 .to_string();
761 let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, warnings));
762 let role2 = profile2.and_then(|p| p.role.as_deref()).unwrap_or("worker");
763 let mut params = effective_spawn_params(triggering_transition_owned.as_ref().and_then(|tr| tr.agent.as_deref()), profile2, &config.workers);
764 apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name2);
765
766 let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string();
767 let worker_name = format!("{}-{}-{:04x}", params.agent, now_str, rand_u16());
768 let tr_instructions_snw = triggering_transition_owned.as_ref().and_then(|tr| tr.instructions.as_deref());
769 let tr_role_prefix_snw = triggering_transition_owned.as_ref().and_then(|tr| tr.role_prefix.as_deref());
770 let worker_system = build_system_prompt(root, tr_instructions_snw, profile2, &config.workers, config.agents.instructions.as_deref(), ¶ms.agent, role2)?;
771
772 let raw = t.serialize()?;
773 let dep_ids_snw = t.frontmatter.depends_on.clone().unwrap_or_default();
774 let raw_prompt_snw = format!("{}\n\n{raw}", agent_role_prefix(tr_role_prefix_snw, profile2, &id));
775 let with_epic_snw = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_snw);
776 let ticket_content = with_dependency_bundle(root, &dep_ids_snw, &config, with_epic_snw);
777 let role_prefix2 = tr_role_prefix_snw
778 .map(|p| p.replace("<id>", &id))
779 .or_else(|| profile2.and_then(|p| p.role_prefix.clone()));
780
781 let branch = t.frontmatter.branch.clone()
782 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
783 .unwrap_or_else(|| format!("ticket/{id}"));
784 let wt_name = branch.replace('/', "-");
785 let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
786 let wt_path = main_root.join(&config.worktrees.dir).join(&wt_name);
787 let wt_display = crate::worktree::find_worktree_for_branch(root, &branch).unwrap_or(wt_path);
788
789 let log_path = wt_display.join(".apm-worker.log");
790
791 let sys_file = write_temp_file("sys", &worker_system)?;
792 let msg_file = write_temp_file("msg", &ticket_content)?;
793 let ctx = WrapperContext {
794 worker_name: worker_name.clone(),
795 agent_type: params.agent.clone(),
796 ticket_id: id.clone(),
797 ticket_branch: branch.clone(),
798 worktree_path: wt_display.clone(),
799 system_prompt_file: sys_file.clone(),
800 user_message_file: msg_file.clone(),
801 skip_permissions,
802 profile: profile_name2,
803 role_prefix: role_prefix2,
804 options: params.options.clone(),
805 model: params.model.clone(),
806 log_path: log_path.clone(),
807 container: params.container.clone(),
808 extra_env: params.env.clone(),
809 root: root.to_path_buf(),
810 keychain: config.workers.keychain.clone(),
811 current_state: t.frontmatter.state.clone(),
812 command: Some(params.command.clone()),
813 };
814 if should_check_claude_compat(root, ¶ms.agent) {
815 check_output_format_supported(¶ms.command)?;
816 }
817 let child = spawn_worker(&ctx, ¶ms.agent, root)?;
818 let pid = child.id();
819
820 let denial_ctx = if params.agent == "claude" {
821 Some((log_path.clone(), wt_display.clone(), id.clone()))
822 } else {
823 None
824 };
825 let managed = ManagedChild {
826 inner: child,
827 temp_files: vec![sys_file, msg_file],
828 denial_ctx,
829 };
830
831 let pid_path = wt_display.join(".apm-worker.pid");
832 write_pid_file(&pid_path, pid, &id)?;
833
834 messages.push(format!("Worker spawned: PID={pid}, log={}", log_path.display()));
835 messages.push(format!("Agent name: {worker_name}"));
836
837 Ok(Some((id, epic_id, managed, pid_path)))
838}
839
840fn with_dependency_bundle(root: &Path, depends_on: &[String], config: &Config, content: String) -> String {
843 if depends_on.is_empty() {
844 return content;
845 }
846 let bundle = crate::context::build_dependency_bundle(root, depends_on, config);
847 if bundle.is_empty() {
848 return content;
849 }
850 format!("{bundle}\n{content}")
851}
852
853fn with_epic_bundle(root: &Path, epic_id: Option<&str>, ticket_id: &str, config: &Config, content: String) -> String {
856 match epic_id {
857 Some(eid) => {
858 let bundle = crate::context::build_epic_bundle(root, eid, ticket_id, config);
859 format!("{bundle}\n{content}")
860 }
861 None => content,
862 }
863}
864
865pub(crate) fn resolve_builtin_instructions(agent: &str, role: &str) -> Option<&'static str> {
866 match (agent, role) {
867 ("claude", "worker") => Some(CLAUDE_WORKER_DEFAULT),
868 ("claude", "spec-writer") => Some(CLAUDE_SPEC_WRITER_DEFAULT),
869 ("mock-happy", "worker") => Some(MOCK_HAPPY_WORKER_DEFAULT),
870 ("mock-happy", "spec-writer") => Some(MOCK_HAPPY_SPEC_WRITER_DEFAULT),
871 ("mock-sad", "worker") => Some(MOCK_SAD_WORKER_DEFAULT),
872 ("mock-sad", "spec-writer") => Some(MOCK_SAD_SPEC_WRITER_DEFAULT),
873 ("mock-random", "worker") => Some(MOCK_RANDOM_WORKER_DEFAULT),
874 ("mock-random", "spec-writer") => Some(MOCK_RANDOM_SPEC_WRITER_DEFAULT),
875 ("debug", "worker") => Some(DEBUG_WORKER_DEFAULT),
876 ("debug", "spec-writer") => Some(DEBUG_SPEC_WRITER_DEFAULT),
877 _ => None,
878 }
879}
880
881pub(crate) struct PromptProvenance {
882 pub prefix_path: Option<String>,
883 pub winner: ProvenanceEntry,
884 pub skipped: Vec<ProvenanceEntry>,
885}
886
887pub(crate) struct ProvenanceEntry {
888 pub level: u8,
889 pub label: &'static str,
890 pub source: String,
891}
892
893const LEVEL_LABELS: [&str; 5] = [
894 "per-agent file",
895 "transition.instructions",
896 "profile.instructions",
897 "workers.instructions",
898 "built-in default",
899];
900
901pub(crate) fn build_system_prompt(
902 root: &Path,
903 transition_instructions: Option<&str>,
904 profile: Option<&WorkerProfileConfig>,
905 workers: &WorkersConfig,
906 agents_instructions: Option<&Path>,
907 agent: &str,
908 role: &str,
909) -> Result<String> {
910 let base = build_system_prompt_body(root, transition_instructions, profile, workers, agent, role)?;
911
912 let Some(path) = agents_instructions else { return Ok(base); };
914 if path.as_os_str().is_empty() {
915 return Ok(base);
916 }
917 let prefix = std::fs::read_to_string(root.join(path))
918 .map_err(|_| anyhow::anyhow!("agents.instructions: file not found: {}", path.display()))?;
919 let prefix = prefix.trim_end();
920 Ok(format!("{prefix}\n\n{base}"))
921}
922
923fn build_system_prompt_body(
924 root: &Path,
925 transition_instructions: Option<&str>,
926 profile: Option<&WorkerProfileConfig>,
927 workers: &WorkersConfig,
928 agent: &str,
929 role: &str,
930) -> Result<String> {
931 let per_agent = root.join(format!(".apm/agents/{agent}/apm.{role}.md"));
933 if per_agent.exists() {
934 if let Ok(content) = std::fs::read_to_string(&per_agent) {
935 return Ok(content);
936 }
937 }
938 if let Some(path) = transition_instructions {
940 return std::fs::read_to_string(root.join(path))
941 .with_context(|| format!("transition.instructions: file not found: {path}"));
942 }
943 if let Some(p) = profile {
945 if let Some(ref instr_path) = p.instructions {
946 match std::fs::read_to_string(root.join(instr_path)) {
947 Ok(content) => return Ok(content),
948 Err(_) => bail!("[worker_profiles.*].instructions: file not found: {instr_path}"),
949 }
950 }
951 }
952 if let Some(ref instr_path) = workers.instructions {
954 match std::fs::read_to_string(root.join(instr_path)) {
955 Ok(content) => return Ok(content),
956 Err(_) => bail!("[workers].instructions: file not found: {instr_path}"),
957 }
958 }
959 if let Some(s) = resolve_builtin_instructions(agent, role) {
961 return Ok(s.to_string());
962 }
963 bail!(
965 "no instructions found for agent '{agent}' role '{role}': \
966 set [workers].instructions in .apm/config.toml or add \
967 .apm/agents/{agent}/apm.{role}.md"
968 )
969}
970
971pub(crate) fn explain_system_prompt(
972 root: &Path,
973 transition_instructions: Option<&str>,
974 profile: Option<&WorkerProfileConfig>,
975 workers: &WorkersConfig,
976 agents_instructions: Option<&Path>,
977 agent: &str,
978 role: &str,
979) -> Result<PromptProvenance> {
980 let prefix_path = match agents_instructions {
981 None => None,
982 Some(path) if path.as_os_str().is_empty() => None,
983 Some(path) => Some(path.display().to_string()),
984 };
985
986 let mut skipped: Vec<ProvenanceEntry> = Vec::new();
987
988 let per_agent_rel = format!(".apm/agents/{agent}/apm.{role}.md");
990 let per_agent = root.join(&per_agent_rel);
991 if per_agent.exists() {
992 let winner = ProvenanceEntry { level: 0, label: LEVEL_LABELS[0], source: per_agent_rel };
993 for i in 1usize..=3 {
994 skipped.push(ProvenanceEntry { level: i as u8, label: LEVEL_LABELS[i], source: "not reached".to_string() });
995 }
996 return Ok(PromptProvenance { prefix_path, winner, skipped });
997 }
998 skipped.push(ProvenanceEntry {
999 level: 0,
1000 label: LEVEL_LABELS[0],
1001 source: format!("file absent: {per_agent_rel}"),
1002 });
1003
1004 if let Some(path) = transition_instructions {
1006 let winner = ProvenanceEntry { level: 1, label: LEVEL_LABELS[1], source: path.to_string() };
1007 for i in 2usize..=3 {
1008 skipped.push(ProvenanceEntry { level: i as u8, label: LEVEL_LABELS[i], source: "not reached".to_string() });
1009 }
1010 return Ok(PromptProvenance { prefix_path, winner, skipped });
1011 }
1012 skipped.push(ProvenanceEntry { level: 1, label: LEVEL_LABELS[1], source: "none set".to_string() });
1013
1014 if let Some(p) = profile {
1016 if let Some(ref instr_path) = p.instructions {
1017 let winner = ProvenanceEntry { level: 2, label: LEVEL_LABELS[2], source: instr_path.to_string() };
1018 skipped.push(ProvenanceEntry { level: 3, label: LEVEL_LABELS[3], source: "not reached".to_string() });
1019 return Ok(PromptProvenance { prefix_path, winner, skipped });
1020 }
1021 }
1022 skipped.push(ProvenanceEntry { level: 2, label: LEVEL_LABELS[2], source: "none set".to_string() });
1023
1024 if let Some(ref instr_path) = workers.instructions {
1026 let winner = ProvenanceEntry { level: 3, label: LEVEL_LABELS[3], source: instr_path.to_string() };
1027 return Ok(PromptProvenance { prefix_path, winner, skipped });
1028 }
1029 skipped.push(ProvenanceEntry { level: 3, label: LEVEL_LABELS[3], source: "none set".to_string() });
1030
1031 if resolve_builtin_instructions(agent, role).is_some() {
1033 let winner = ProvenanceEntry {
1034 level: 4,
1035 label: LEVEL_LABELS[4],
1036 source: format!("built-in default ({agent}/{role})"),
1037 };
1038 return Ok(PromptProvenance { prefix_path, winner, skipped });
1039 }
1040
1041 bail!(
1043 "no instructions found for agent '{agent}' role '{role}': \
1044 set [workers].instructions in .apm/config.toml or add \
1045 .apm/agents/{agent}/apm.{role}.md"
1046 )
1047}
1048
1049fn agent_role_prefix(transition_role_prefix: Option<&str>, profile: Option<&WorkerProfileConfig>, id: &str) -> String {
1050 if let Some(prefix) = transition_role_prefix {
1051 return prefix.replace("<id>", id);
1052 }
1053 if let Some(p) = profile {
1054 if let Some(ref prefix) = p.role_prefix {
1055 return prefix.replace("<id>", id);
1056 }
1057 }
1058 format!("You are a Worker agent assigned to ticket #{id}.")
1059}
1060
1061fn write_pid_file(path: &Path, pid: u32, ticket_id: &str) -> Result<()> {
1062 let started_at = chrono::Utc::now().format("%Y-%m-%dT%H:%MZ").to_string();
1063 let content = serde_json::json!({
1064 "pid": pid,
1065 "ticket_id": ticket_id,
1066 "started_at": started_at,
1067 })
1068 .to_string();
1069 std::fs::write(path, content)?;
1070 Ok(())
1071}
1072
1073fn rand_u16() -> u16 {
1074 use std::time::{SystemTime, UNIX_EPOCH};
1075 SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos() as u16
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::{build_system_prompt, agent_role_prefix, resolve_profile, effective_spawn_params, check_output_format_supported, apply_frontmatter_agent, ManagedChild, DEPRECATION_WARNED, DEPRECATION_MSG, DEPRECATION_TEST_LOCK, emit_deprecation_warning_to};
1081 use crate::config::{WorkerProfileConfig, WorkersConfig, TransitionConfig, CompletionStrategy};
1082 use std::collections::HashMap;
1083
1084 fn make_transition(profile: Option<&str>) -> TransitionConfig {
1085 TransitionConfig {
1086 to: "in_progress".into(),
1087 trigger: "command:start".into(),
1088 label: String::new(),
1089 hint: String::new(),
1090 completion: CompletionStrategy::None,
1091 focus_section: None,
1092 context_section: None,
1093 warning: None,
1094 profile: profile.map(|s| s.to_string()),
1095 instructions: None,
1096 role_prefix: None,
1097 agent: None,
1098 on_failure: None,
1099 outcome: None,
1100 }
1101 }
1102
1103 fn make_profile(instructions: Option<&str>, role_prefix: Option<&str>) -> WorkerProfileConfig {
1104 WorkerProfileConfig {
1105 instructions: instructions.map(|s| s.to_string()),
1106 role_prefix: role_prefix.map(|s| s.to_string()),
1107 ..Default::default()
1108 }
1109 }
1110
1111 fn make_workers(command: &str, model: Option<&str>) -> WorkersConfig {
1112 WorkersConfig {
1113 command: Some(command.to_string()),
1114 args: None,
1115 model: model.map(|s| s.to_string()),
1116 env: HashMap::new(),
1117 container: None,
1118 keychain: HashMap::new(),
1119 agent: None,
1120 options: HashMap::new(),
1121 instructions: None,
1122 }
1123 }
1124
1125 #[test]
1128 fn resolve_profile_returns_profile_when_found() {
1129 let mut config = crate::config::Config {
1130 project: crate::config::ProjectConfig {
1131 name: "test".into(),
1132 description: String::new(),
1133 default_branch: "main".into(),
1134 collaborators: vec![],
1135 },
1136 ticket: Default::default(),
1137 tickets: Default::default(),
1138 workflow: Default::default(),
1139 agents: Default::default(),
1140 worktrees: Default::default(),
1141 sync: Default::default(),
1142 logging: Default::default(),
1143 workers: make_workers("claude", None),
1144 work: Default::default(),
1145 server: Default::default(),
1146 git_host: Default::default(),
1147 worker_profiles: HashMap::new(),
1148 context: Default::default(),
1149 isolation: Default::default(),
1150 load_warnings: vec![],
1151 };
1152 let profile = make_profile(Some(".apm/spec.md"), Some("Spec-Writer for #<id>"));
1153 config.worker_profiles.insert("spec_agent".into(), profile);
1154
1155 let tr = make_transition(Some("spec_agent"));
1156 let mut w = Vec::new();
1157 assert!(resolve_profile(&tr, &config, &mut w).is_some());
1158 }
1159
1160 #[test]
1161 fn resolve_profile_returns_none_for_missing_profile() {
1162 let config = crate::config::Config {
1163 project: crate::config::ProjectConfig {
1164 name: "test".into(),
1165 description: String::new(),
1166 default_branch: "main".into(),
1167 collaborators: vec![],
1168 },
1169 ticket: Default::default(),
1170 tickets: Default::default(),
1171 workflow: Default::default(),
1172 agents: Default::default(),
1173 worktrees: Default::default(),
1174 sync: Default::default(),
1175 logging: Default::default(),
1176 workers: make_workers("claude", None),
1177 work: Default::default(),
1178 server: Default::default(),
1179 git_host: Default::default(),
1180 worker_profiles: HashMap::new(),
1181 context: Default::default(),
1182 isolation: Default::default(),
1183 load_warnings: vec![],
1184 };
1185 let tr = make_transition(Some("nonexistent_profile"));
1186 let mut w = Vec::new();
1187 assert!(resolve_profile(&tr, &config, &mut w).is_none());
1188 }
1189
1190 #[test]
1191 fn resolve_profile_returns_none_when_no_profile_on_transition() {
1192 let config = crate::config::Config {
1193 project: crate::config::ProjectConfig {
1194 name: "test".into(),
1195 description: String::new(),
1196 default_branch: "main".into(),
1197 collaborators: vec![],
1198 },
1199 ticket: Default::default(),
1200 tickets: Default::default(),
1201 workflow: Default::default(),
1202 agents: Default::default(),
1203 worktrees: Default::default(),
1204 sync: Default::default(),
1205 logging: Default::default(),
1206 workers: make_workers("claude", None),
1207 work: Default::default(),
1208 server: Default::default(),
1209 git_host: Default::default(),
1210 worker_profiles: HashMap::new(),
1211 context: Default::default(),
1212 isolation: Default::default(),
1213 load_warnings: vec![],
1214 };
1215 let tr = make_transition(None);
1216 let mut w = Vec::new();
1217 assert!(resolve_profile(&tr, &config, &mut w).is_none());
1218 }
1219
1220 #[test]
1223 fn effective_spawn_params_profile_command_overrides_global() {
1224 let workers = make_workers("claude", Some("sonnet"));
1225 let profile = WorkerProfileConfig {
1226 command: Some("my-claude".into()),
1227 ..Default::default()
1228 };
1229 let params = effective_spawn_params(None, Some(&profile), &workers);
1230 assert_eq!(params.command, "my-claude");
1231 }
1232
1233 #[test]
1234 fn effective_spawn_params_falls_back_to_global_command() {
1235 let workers = make_workers("claude", None);
1236 let params = effective_spawn_params(None, None, &workers);
1237 assert_eq!(params.command, "claude");
1238 }
1239
1240 #[test]
1241 fn effective_spawn_params_profile_model_overrides_global() {
1242 let workers = make_workers("claude", Some("sonnet"));
1243 let profile = WorkerProfileConfig {
1244 model: Some("opus".into()),
1245 ..Default::default()
1246 };
1247 let params = effective_spawn_params(None, Some(&profile), &workers);
1248 assert_eq!(params.model.as_deref(), Some("opus"));
1249 }
1250
1251 #[test]
1252 fn effective_spawn_params_falls_back_to_global_model() {
1253 let workers = make_workers("claude", Some("sonnet"));
1254 let params = effective_spawn_params(None, None, &workers);
1255 assert_eq!(params.model.as_deref(), Some("sonnet"));
1256 }
1257
1258 #[test]
1259 fn effective_spawn_params_profile_env_merged_over_global() {
1260 let mut workers = make_workers("claude", None);
1261 workers.env.insert("FOO".into(), "global".into());
1262 workers.env.insert("BAR".into(), "bar".into());
1263
1264 let mut profile_env = HashMap::new();
1265 profile_env.insert("FOO".into(), "profile".into());
1266 let profile = WorkerProfileConfig {
1267 env: profile_env,
1268 ..Default::default()
1269 };
1270 let params = effective_spawn_params(None, Some(&profile), &workers);
1271 assert_eq!(params.env.get("FOO").map(|s| s.as_str()), Some("profile"));
1272 assert_eq!(params.env.get("BAR").map(|s| s.as_str()), Some("bar"));
1273 }
1274
1275 #[test]
1276 fn effective_spawn_params_profile_container_overrides_global() {
1277 let mut workers = make_workers("claude", None);
1278 workers.container = Some("global-image".into());
1279 let profile = WorkerProfileConfig {
1280 container: Some("profile-image".into()),
1281 ..Default::default()
1282 };
1283 let params = effective_spawn_params(None, Some(&profile), &workers);
1284 assert_eq!(params.container.as_deref(), Some("profile-image"));
1285 }
1286
1287 #[test]
1288 fn transition_agent_takes_precedence_over_profile() {
1289 let workers = WorkersConfig::default();
1290 let profile = WorkerProfileConfig { agent: Some("other".into()), ..Default::default() };
1291 let params = effective_spawn_params(Some("custom"), Some(&profile), &workers);
1292 assert_eq!(params.agent, "custom");
1293 }
1294
1295 #[test]
1296 fn effective_agent_defaults_to_claude() {
1297 let workers = WorkersConfig::default();
1298 let params = effective_spawn_params(None, None, &workers);
1299 assert_eq!(params.agent, "claude");
1300 }
1301
1302 #[test]
1305 fn build_system_prompt_uses_profile_instructions() {
1306 let dir = tempfile::tempdir().unwrap();
1307 let p = dir.path();
1308 std::fs::create_dir_all(p.join(".apm")).unwrap();
1309 std::fs::write(p.join(".apm/spec.md"), "SPEC WRITER").unwrap();
1310 let profile = make_profile(Some(".apm/spec.md"), None);
1311 let workers = WorkersConfig::default();
1312 assert_eq!(
1313 build_system_prompt(p, None, Some(&profile), &workers, None, "claude", "worker").unwrap(),
1314 "SPEC WRITER"
1315 );
1316 }
1317
1318 #[test]
1319 fn build_system_prompt_uses_workers_instructions_when_no_profile() {
1320 let dir = tempfile::tempdir().unwrap();
1321 let p = dir.path();
1322 std::fs::create_dir_all(p.join(".apm")).unwrap();
1323 std::fs::write(p.join(".apm/global.md"), "GLOBAL INSTRUCTIONS").unwrap();
1324 let workers = WorkersConfig {
1325 instructions: Some(".apm/global.md".to_string()),
1326 ..WorkersConfig::default()
1327 };
1328 assert_eq!(
1329 build_system_prompt(p, None, None, &workers, None, "claude", "worker").unwrap(),
1330 "GLOBAL INSTRUCTIONS"
1331 );
1332 }
1333
1334 #[test]
1335 fn build_system_prompt_uses_per_agent_file() {
1336 let dir = tempfile::tempdir().unwrap();
1337 let p = dir.path();
1338 std::fs::create_dir_all(p.join(".apm/agents/claude")).unwrap();
1339 std::fs::write(p.join(".apm/agents/claude/apm.worker.md"), "PER AGENT WORKER").unwrap();
1340 let workers = WorkersConfig::default();
1341 assert_eq!(
1342 build_system_prompt(p, None, None, &workers, None, "claude", "worker").unwrap(),
1343 "PER AGENT WORKER"
1344 );
1345 }
1346
1347 #[test]
1348 fn build_system_prompt_falls_back_to_builtin_default() {
1349 let dir = tempfile::tempdir().unwrap();
1350 let p = dir.path();
1351 let workers = WorkersConfig::default();
1352 let result = build_system_prompt(p, None, None, &workers, None, "claude", "worker").unwrap();
1353 assert_eq!(result, super::CLAUDE_WORKER_DEFAULT);
1354 }
1355
1356 #[test]
1357 fn build_system_prompt_falls_back_to_builtin_spec_writer() {
1358 let dir = tempfile::tempdir().unwrap();
1359 let p = dir.path();
1360 let workers = WorkersConfig::default();
1361 let result = build_system_prompt(p, None, None, &workers, None, "claude", "spec-writer").unwrap();
1362 assert_eq!(result, super::CLAUDE_SPEC_WRITER_DEFAULT);
1363 }
1364
1365 #[test]
1366 fn build_system_prompt_errors_for_unknown_agent() {
1367 let dir = tempfile::tempdir().unwrap();
1368 let p = dir.path();
1369 let workers = WorkersConfig::default();
1370 let result = build_system_prompt(p, None, None, &workers, None, "custom-bot", "worker");
1371 assert!(result.is_err());
1372 let msg = result.unwrap_err().to_string();
1373 assert!(msg.contains("custom-bot"), "error should name the agent: {msg}");
1374 assert!(msg.contains("worker"), "error should name the role: {msg}");
1375 }
1376
1377 #[test]
1378 fn build_system_prompt_profile_instructions_missing_file_is_error() {
1379 let dir = tempfile::tempdir().unwrap();
1380 let p = dir.path();
1381 let profile = make_profile(Some(".apm/nonexistent.md"), None);
1382 let workers = WorkersConfig::default();
1383 let result = build_system_prompt(p, None, Some(&profile), &workers, None, "claude", "worker");
1384 assert!(result.is_err());
1385 let msg = result.unwrap_err().to_string();
1386 assert!(msg.contains("nonexistent.md"), "error should name the file: {msg}");
1387 }
1388
1389 #[test]
1390 fn build_system_prompt_backward_compat() {
1391 let dir = tempfile::tempdir().unwrap();
1392 let p = dir.path();
1393 std::fs::create_dir_all(p.join(".apm")).unwrap();
1394 std::fs::write(p.join(".apm/apm.worker.md"), "LEGACY WORKER CONTENT").unwrap();
1395 let profile = make_profile(Some(".apm/apm.worker.md"), None);
1396 let workers = WorkersConfig::default();
1397 assert_eq!(
1398 build_system_prompt(p, None, Some(&profile), &workers, None, "claude", "worker").unwrap(),
1399 "LEGACY WORKER CONTENT"
1400 );
1401 }
1402
1403 #[test]
1406 fn agents_instructions_prepended_with_blank_line() {
1407 let dir = tempfile::tempdir().unwrap();
1408 let p = dir.path();
1409 std::fs::write(p.join("prefix.md"), "PREFIX CONTENT\n").unwrap();
1410 let workers = WorkersConfig::default();
1411 let result = build_system_prompt(
1412 p, None, None, &workers,
1413 Some(std::path::Path::new("prefix.md")),
1414 "claude", "worker",
1415 ).unwrap();
1416 let expected = format!("PREFIX CONTENT\n\n{}", super::CLAUDE_WORKER_DEFAULT);
1417 assert_eq!(result, expected);
1418 }
1419
1420 #[test]
1421 fn agents_instructions_none_is_no_op() {
1422 let dir = tempfile::tempdir().unwrap();
1423 let p = dir.path();
1424 let workers = WorkersConfig::default();
1425 let without_prefix = build_system_prompt(p, None, None, &workers, None, "claude", "worker").unwrap();
1426 assert_eq!(without_prefix, super::CLAUDE_WORKER_DEFAULT);
1427 }
1428
1429 #[test]
1430 fn agents_instructions_empty_path_is_no_op() {
1431 let dir = tempfile::tempdir().unwrap();
1432 let p = dir.path();
1433 let workers = WorkersConfig::default();
1434 let result = build_system_prompt(
1435 p, None, None, &workers,
1436 Some(std::path::Path::new("")),
1437 "claude", "worker",
1438 ).unwrap();
1439 assert_eq!(result, super::CLAUDE_WORKER_DEFAULT);
1440 }
1441
1442 #[test]
1443 fn agents_instructions_missing_file_is_hard_error() {
1444 let dir = tempfile::tempdir().unwrap();
1445 let p = dir.path();
1446 let workers = WorkersConfig::default();
1447 let result = build_system_prompt(
1448 p, None, None, &workers,
1449 Some(std::path::Path::new("no-such-file.md")),
1450 "claude", "worker",
1451 );
1452 assert!(result.is_err());
1453 let msg = result.unwrap_err().to_string();
1454 assert!(msg.contains("agents.instructions"), "error should mention agents.instructions: {msg}");
1455 assert!(msg.contains("no-such-file.md"), "error should name the file: {msg}");
1456 }
1457
1458 #[test]
1459 fn agents_instructions_trailing_whitespace_trimmed() {
1460 let dir = tempfile::tempdir().unwrap();
1461 let p = dir.path();
1462 std::fs::write(p.join("prefix.md"), "PREFIX\n\n\n").unwrap();
1464 let workers = WorkersConfig::default();
1465 let result = build_system_prompt(
1466 p, None, None, &workers,
1467 Some(std::path::Path::new("prefix.md")),
1468 "claude", "worker",
1469 ).unwrap();
1470 let expected = format!("PREFIX\n\n{}", super::CLAUDE_WORKER_DEFAULT);
1472 assert_eq!(result, expected);
1473 }
1474
1475 #[test]
1478 fn agent_role_prefix_uses_profile_role_prefix() {
1479 let profile = make_profile(None, Some("You are a Spec-Writer agent assigned to ticket #<id>."));
1480 assert_eq!(
1481 agent_role_prefix(None, Some(&profile), "abc123"),
1482 "You are a Spec-Writer agent assigned to ticket #abc123."
1483 );
1484 }
1485
1486 #[test]
1487 fn agent_role_prefix_falls_back_to_worker_default() {
1488 assert_eq!(
1489 agent_role_prefix(None, None, "abc123"),
1490 "You are a Worker agent assigned to ticket #abc123."
1491 );
1492 }
1493
1494 #[test]
1495 fn agent_role_prefix_transition_takes_precedence_over_profile() {
1496 let profile = make_profile(None, Some("You are a Spec-Writer agent assigned to ticket #<id>."));
1497 assert_eq!(
1498 agent_role_prefix(Some("You are a Custom agent for ticket #<id>."), Some(&profile), "abc123"),
1499 "You are a Custom agent for ticket #abc123."
1500 );
1501 }
1502
1503 #[test]
1506 fn transition_instructions_takes_precedence_over_profile() {
1507 let dir = tempfile::tempdir().unwrap();
1508 let p = dir.path();
1509 std::fs::create_dir_all(p.join(".apm")).unwrap();
1510 std::fs::write(p.join(".apm/transition.md"), "TRANSITION CONTENT").unwrap();
1511 std::fs::write(p.join(".apm/profile.md"), "PROFILE CONTENT").unwrap();
1512 let profile = make_profile(Some(".apm/profile.md"), None);
1513 let workers = WorkersConfig::default();
1514 assert_eq!(
1515 build_system_prompt(p, Some(".apm/transition.md"), Some(&profile), &workers, None, "claude", "worker").unwrap(),
1516 "TRANSITION CONTENT"
1517 );
1518 }
1519
1520 #[test]
1521 fn transition_instructions_no_profile_required() {
1522 let dir = tempfile::tempdir().unwrap();
1523 let p = dir.path();
1524 std::fs::create_dir_all(p.join(".apm")).unwrap();
1525 std::fs::write(p.join(".apm/transition.md"), "TRANSITION ONLY").unwrap();
1526 let workers = WorkersConfig::default();
1527 assert_eq!(
1528 build_system_prompt(p, Some(".apm/transition.md"), None, &workers, None, "claude", "worker").unwrap(),
1529 "TRANSITION ONLY"
1530 );
1531 }
1532
1533 #[test]
1534 fn per_agent_file_beats_transition_instructions() {
1535 let dir = tempfile::tempdir().unwrap();
1536 let p = dir.path();
1537 std::fs::create_dir_all(p.join(".apm/agents/claude")).unwrap();
1538 std::fs::write(p.join(".apm/agents/claude/apm.worker.md"), "PER AGENT WINS").unwrap();
1539 std::fs::write(p.join(".apm/transition.md"), "TRANSITION CONTENT").unwrap();
1540 let workers = WorkersConfig::default();
1541 assert_eq!(
1542 build_system_prompt(p, Some(".apm/transition.md"), None, &workers, None, "claude", "worker").unwrap(),
1543 "PER AGENT WINS"
1544 );
1545 }
1546
1547 #[test]
1548 fn epic_filter_keeps_only_matching_tickets() {
1549 use crate::ticket::Ticket;
1550 use std::path::Path;
1551
1552 let make_ticket = |id: &str, epic: Option<&str>| {
1553 let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
1554 let raw = format!(
1555 "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}+++\n"
1556 );
1557 Ticket::parse(Path::new("tickets/dummy.md"), &raw).unwrap()
1558 };
1559
1560 let all_tickets = vec![
1561 make_ticket("aaa", Some("epic1")),
1562 make_ticket("bbb", Some("epic2")),
1563 make_ticket("ccc", None),
1564 ];
1565
1566 let epic_id = "epic1";
1567 let filtered: Vec<Ticket> = all_tickets.into_iter()
1568 .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
1569 .collect();
1570
1571 assert_eq!(filtered.len(), 1);
1572 assert_eq!(filtered[0].frontmatter.id, "aaa");
1573 }
1574
1575 #[test]
1576 fn no_epic_filter_keeps_all_tickets() {
1577 use crate::ticket::Ticket;
1578 use std::path::Path;
1579
1580 let make_ticket = |id: &str, epic: Option<&str>| {
1581 let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
1582 let raw = format!(
1583 "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}+++\n"
1584 );
1585 Ticket::parse(Path::new("tickets/dummy.md"), &raw).unwrap()
1586 };
1587
1588 let all_tickets: Vec<Ticket> = vec![
1589 make_ticket("aaa", Some("epic1")),
1590 make_ticket("bbb", Some("epic2")),
1591 make_ticket("ccc", None),
1592 ];
1593
1594 let count = all_tickets.len();
1595 let epic_filter: Option<&str> = None;
1596 let filtered: Vec<Ticket> = match epic_filter {
1597 Some(eid) => all_tickets.into_iter()
1598 .filter(|t| t.frontmatter.epic.as_deref() == Some(eid))
1599 .collect(),
1600 None => all_tickets,
1601 };
1602 assert_eq!(filtered.len(), count);
1603 }
1604
1605 #[test]
1608 fn spawn_worker_cwd_is_ticket_worktree() {
1609 use std::os::unix::fs::PermissionsExt;
1610
1611 let wt = tempfile::tempdir().unwrap();
1612 let log_dir = tempfile::tempdir().unwrap();
1613 let mock_dir = tempfile::tempdir().unwrap();
1614
1615 let mock_claude = mock_dir.path().join("claude");
1617 let cwd_file = wt.path().join("cwd-output.txt");
1618 let script = format!(concat!(
1619 "#!/bin/sh\n",
1620 "pwd > \"{}\"\n",
1621 ), cwd_file.display());
1622 std::fs::write(&mock_claude, &script).unwrap();
1623 std::fs::set_permissions(&mock_claude, std::fs::Permissions::from_mode(0o755)).unwrap();
1624
1625 let sys_file = crate::wrapper::write_temp_file("sys", "system").unwrap();
1626 let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap();
1627
1628 let mut extra_env = HashMap::new();
1629 extra_env.insert(
1630 "PATH".to_string(),
1631 format!("{}:{}", mock_dir.path().display(), std::env::var("PATH").unwrap_or_default()),
1632 );
1633
1634 let ctx = crate::wrapper::WrapperContext {
1635 worker_name: "test-worker".to_string(),
1636 agent_type: "test".to_string(),
1637 ticket_id: "test-id".to_string(),
1638 ticket_branch: "ticket/test-id".to_string(),
1639 worktree_path: wt.path().to_path_buf(),
1640 system_prompt_file: sys_file.clone(),
1641 user_message_file: msg_file.clone(),
1642 skip_permissions: false,
1643 profile: "default".to_string(),
1644 role_prefix: None,
1645 options: HashMap::new(),
1646 model: None,
1647 log_path: log_dir.path().join("worker.log"),
1648 container: None,
1649 extra_env,
1650 root: wt.path().to_path_buf(),
1651 keychain: HashMap::new(),
1652 current_state: "in_progress".to_string(),
1653 command: None,
1654 };
1655
1656 let wrapper = crate::wrapper::resolve_builtin("claude").unwrap();
1657 let mut child = wrapper.spawn(&ctx).unwrap();
1658 child.wait().unwrap();
1659 let _ = std::fs::remove_file(&sys_file);
1660 let _ = std::fs::remove_file(&msg_file);
1661
1662 let cwd_out = std::fs::read_to_string(&cwd_file)
1663 .expect("cwd-output.txt not written — mock claude did not run in expected cwd");
1664 let expected = wt.path().canonicalize().unwrap();
1665 assert_eq!(
1666 cwd_out.trim(),
1667 expected.to_str().unwrap(),
1668 "spawned worker CWD must equal the ticket worktree path"
1669 );
1670 }
1671
1672 #[test]
1675 fn check_output_format_supported_passes_when_flag_present() {
1676 use std::os::unix::fs::PermissionsExt;
1677 let dir = tempfile::tempdir().unwrap();
1678 let bin = dir.path().join("fake-claude");
1679 std::fs::write(&bin, "#!/bin/sh\necho '--output-format stream-json'\n").unwrap();
1680 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1681 assert!(check_output_format_supported(bin.to_str().unwrap()).is_ok());
1682 }
1683
1684 #[test]
1685 fn check_output_format_supported_errors_when_flag_absent() {
1686 use std::os::unix::fs::PermissionsExt;
1687 let dir = tempfile::tempdir().unwrap();
1688 let bin = dir.path().join("old-claude");
1689 std::fs::write(&bin, "#!/bin/sh\necho 'Usage: old-claude [options]'\n").unwrap();
1690 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1691 let err = check_output_format_supported(bin.to_str().unwrap()).unwrap_err();
1692 let msg = err.to_string();
1693 assert!(
1694 msg.contains("--output-format"),
1695 "error message must name the missing flag: {msg}"
1696 );
1697 assert!(
1698 msg.contains(bin.to_str().unwrap()),
1699 "error message must include binary path: {msg}"
1700 );
1701 }
1702
1703 #[test]
1706 fn claude_wrapper_sets_apm_env_vars() {
1707 use std::os::unix::fs::PermissionsExt;
1708
1709 let wt = tempfile::tempdir().unwrap();
1710 let log_dir = tempfile::tempdir().unwrap();
1711 let mock_dir = tempfile::tempdir().unwrap();
1712 let env_output = wt.path().join("env-output.txt");
1713
1714 let mock_claude = mock_dir.path().join("claude");
1716 let script = format!(
1717 "#!/bin/sh\nprintenv > \"{}\"\n",
1718 env_output.display()
1719 );
1720 std::fs::write(&mock_claude, &script).unwrap();
1721 std::fs::set_permissions(&mock_claude, std::fs::Permissions::from_mode(0o755)).unwrap();
1722
1723 let sys_file = crate::wrapper::write_temp_file("sys", "system prompt").unwrap();
1724 let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap();
1725
1726 let mut extra_env = HashMap::new();
1727 extra_env.insert(
1728 "PATH".to_string(),
1729 format!("{}:{}", mock_dir.path().display(), std::env::var("PATH").unwrap_or_default()),
1730 );
1731
1732 let ctx = crate::wrapper::WrapperContext {
1733 worker_name: "test-worker".to_string(),
1734 agent_type: "test".to_string(),
1735 ticket_id: "abc123".to_string(),
1736 ticket_branch: "ticket/abc123-some-feature".to_string(),
1737 worktree_path: wt.path().to_path_buf(),
1738 system_prompt_file: sys_file.clone(),
1739 user_message_file: msg_file.clone(),
1740 skip_permissions: false,
1741 profile: "my-profile".to_string(),
1742 role_prefix: None,
1743 options: HashMap::new(),
1744 model: None,
1745 log_path: log_dir.path().join("worker.log"),
1746 container: None,
1747 extra_env,
1748 root: wt.path().to_path_buf(),
1749 keychain: HashMap::new(),
1750 current_state: "in_progress".to_string(),
1751 command: None,
1752 };
1753
1754 let wrapper = crate::wrapper::resolve_builtin("claude").unwrap();
1755 let mut child = wrapper.spawn(&ctx).unwrap();
1756 child.wait().unwrap();
1757 let _ = std::fs::remove_file(&sys_file);
1758 let _ = std::fs::remove_file(&msg_file);
1759
1760 let env_content = std::fs::read_to_string(&env_output)
1761 .expect("env-output.txt not written — mock claude did not run");
1762
1763 assert!(env_content.contains("APM_AGENT_NAME=test-worker"), "missing APM_AGENT_NAME\n{env_content}");
1764 assert!(env_content.contains("APM_TICKET_ID=abc123"), "missing APM_TICKET_ID\n{env_content}");
1765 assert!(env_content.contains("APM_TICKET_BRANCH=ticket/abc123-some-feature"), "missing APM_TICKET_BRANCH\n{env_content}");
1766 assert!(env_content.contains("APM_TICKET_WORKTREE="), "missing APM_TICKET_WORKTREE\n{env_content}");
1767 assert!(env_content.contains("APM_SYSTEM_PROMPT_FILE="), "missing APM_SYSTEM_PROMPT_FILE\n{env_content}");
1768 assert!(env_content.contains("APM_USER_MESSAGE_FILE="), "missing APM_USER_MESSAGE_FILE\n{env_content}");
1769 assert!(env_content.contains("APM_SKIP_PERMISSIONS=0"), "missing APM_SKIP_PERMISSIONS\n{env_content}");
1770 assert!(env_content.contains("APM_PROFILE=my-profile"), "missing APM_PROFILE\n{env_content}");
1771 assert!(env_content.contains("APM_WRAPPER_VERSION=1"), "missing APM_WRAPPER_VERSION\n{env_content}");
1772 assert!(env_content.contains("APM_BIN="), "missing APM_BIN\n{env_content}");
1773
1774 if let Some(line) = env_content.lines().find(|l| l.starts_with("APM_BIN=")) {
1776 let path = line.trim_start_matches("APM_BIN=");
1777 assert!(std::path::Path::new(path).exists(), "APM_BIN path does not exist: {path}");
1778 }
1779 }
1780
1781 #[test]
1784 fn temp_files_removed_after_child_exits() {
1785 use std::os::unix::fs::PermissionsExt;
1786
1787 let wt = tempfile::tempdir().unwrap();
1788 let log_dir = tempfile::tempdir().unwrap();
1789 let mock_dir = tempfile::tempdir().unwrap();
1790
1791 let mock_claude = mock_dir.path().join("claude");
1793 std::fs::write(&mock_claude, "#!/bin/sh\nexit 0\n").unwrap();
1794 std::fs::set_permissions(&mock_claude, std::fs::Permissions::from_mode(0o755)).unwrap();
1795
1796 let sys_file = crate::wrapper::write_temp_file("sys", "system").unwrap();
1797 let msg_file = crate::wrapper::write_temp_file("msg", "message").unwrap();
1798
1799 assert!(sys_file.exists(), "sys_file should exist before spawn");
1800 assert!(msg_file.exists(), "msg_file should exist before spawn");
1801
1802 let mut extra_env = HashMap::new();
1803 extra_env.insert(
1804 "PATH".to_string(),
1805 format!("{}:{}", mock_dir.path().display(), std::env::var("PATH").unwrap_or_default()),
1806 );
1807
1808 let ctx = crate::wrapper::WrapperContext {
1809 worker_name: "test".to_string(),
1810 agent_type: "test".to_string(),
1811 ticket_id: "test123".to_string(),
1812 ticket_branch: "ticket/test123".to_string(),
1813 worktree_path: wt.path().to_path_buf(),
1814 system_prompt_file: sys_file.clone(),
1815 user_message_file: msg_file.clone(),
1816 skip_permissions: false,
1817 profile: "default".to_string(),
1818 role_prefix: None,
1819 options: HashMap::new(),
1820 model: None,
1821 log_path: log_dir.path().join("worker.log"),
1822 container: None,
1823 extra_env,
1824 root: wt.path().to_path_buf(),
1825 keychain: HashMap::new(),
1826 current_state: "in_progress".to_string(),
1827 command: None,
1828 };
1829
1830 let wrapper = crate::wrapper::resolve_builtin("claude").unwrap();
1831 let child = wrapper.spawn(&ctx).unwrap();
1832
1833 let mut managed = ManagedChild {
1834 inner: child,
1835 temp_files: vec![sys_file.clone(), msg_file.clone()],
1836 denial_ctx: None,
1837 };
1838 managed.inner.wait().unwrap();
1839 drop(managed);
1840
1841 assert!(!sys_file.exists(), "sys_file should be removed after ManagedChild is dropped");
1842 assert!(!msg_file.exists(), "msg_file should be removed after ManagedChild is dropped");
1843 }
1844
1845 #[test]
1848 fn resolution_agent_profile_overrides_global() {
1849 let workers = WorkersConfig { agent: Some("codex".into()), ..Default::default() };
1850 let profile = WorkerProfileConfig { agent: Some("mock-happy".into()), ..Default::default() };
1851 let params = effective_spawn_params(None, Some(&profile), &workers);
1852 assert_eq!(params.agent, "mock-happy");
1853 }
1854
1855 #[test]
1856 fn resolution_agent_falls_back_to_claude() {
1857 let params = effective_spawn_params(None, None, &WorkersConfig::default());
1858 assert_eq!(params.agent, "claude");
1859 }
1860
1861 #[test]
1862 fn resolution_options_merge() {
1863 let mut workers = WorkersConfig { agent: Some("claude".into()), ..Default::default() };
1864 workers.options.insert("model".into(), "opus".into());
1865 workers.options.insert("timeout".into(), "30".into());
1866 let mut profile_opts = HashMap::new();
1867 profile_opts.insert("model".into(), "sonnet".into());
1868 let profile = WorkerProfileConfig { options: profile_opts, ..Default::default() };
1869 let params = effective_spawn_params(None, Some(&profile), &workers);
1870 assert_eq!(params.options.get("model").map(|s| s.as_str()), Some("sonnet"), "profile model should override workers model");
1871 assert_eq!(params.options.get("timeout").map(|s| s.as_str()), Some("30"), "non-overlapping key should survive");
1872 }
1873
1874 #[test]
1875 fn deprecation_warning_writes_to_stream_once() {
1876 let _guard = DEPRECATION_TEST_LOCK.lock().unwrap();
1877 DEPRECATION_WARNED.store(false, std::sync::atomic::Ordering::SeqCst);
1878
1879 let mut buf: Vec<u8> = Vec::new();
1882 emit_deprecation_warning_to(&mut buf);
1883 emit_deprecation_warning_to(&mut buf);
1884
1885 let captured = String::from_utf8(buf).unwrap();
1886 let count = captured.matches(DEPRECATION_MSG).count();
1887 assert_eq!(count, 1, "deprecated message should appear exactly once on the writer, found {count}\n{captured}");
1888 }
1889
1890 #[test]
1891 fn deprecation_warning_triggered_by_legacy_workers_config() {
1892 let _guard = DEPRECATION_TEST_LOCK.lock().unwrap();
1893 DEPRECATION_WARNED.store(false, std::sync::atomic::Ordering::SeqCst);
1894
1895 let workers = WorkersConfig { command: Some("claude".into()), ..Default::default() };
1896 effective_spawn_params(None, None, &workers);
1897
1898 assert!(
1899 DEPRECATION_WARNED.load(std::sync::atomic::Ordering::SeqCst),
1900 "legacy [workers].command must trigger the deprecation warning"
1901 );
1902 }
1903
1904 #[test]
1905 fn legacy_model_forwarded_to_ctx() {
1906 let workers = WorkersConfig { model: Some("opus".into()), ..Default::default() };
1907 let params = effective_spawn_params(None, None, &workers);
1908 assert_eq!(params.model.as_deref(), Some("opus"));
1909 }
1910
1911 #[test]
1912 fn options_model_takes_precedence_over_legacy() {
1913 let mut workers = WorkersConfig { model: Some("opus".into()), agent: Some("claude".into()), ..Default::default() };
1914 workers.options.insert("model".into(), "sonnet".into());
1915 let params = effective_spawn_params(None, None, &workers);
1916 assert_eq!(params.model.as_deref(), Some("sonnet"));
1917 }
1918
1919 #[test]
1922 fn apm_opt_env_vars_set() {
1923 use std::os::unix::fs::PermissionsExt;
1924
1925 let wt = tempfile::tempdir().unwrap();
1926 let log_dir = tempfile::tempdir().unwrap();
1927 let mock_dir = tempfile::tempdir().unwrap();
1928 let env_output = wt.path().join("env-output.txt");
1929
1930 let mock_claude = mock_dir.path().join("claude");
1931 let script = format!("#!/bin/sh\nprintenv > \"{}\"\n", env_output.display());
1932 std::fs::write(&mock_claude, &script).unwrap();
1933 std::fs::set_permissions(&mock_claude, std::fs::Permissions::from_mode(0o755)).unwrap();
1934
1935 let sys_file = crate::wrapper::write_temp_file("sys", "system prompt").unwrap();
1936 let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap();
1937
1938 let mut extra_env = HashMap::new();
1939 extra_env.insert(
1940 "PATH".to_string(),
1941 format!("{}:{}", mock_dir.path().display(), std::env::var("PATH").unwrap_or_default()),
1942 );
1943
1944 let mut options = HashMap::new();
1945 options.insert("model".to_string(), "sonnet".to_string());
1946
1947 let ctx = crate::wrapper::WrapperContext {
1948 worker_name: "test-worker".to_string(),
1949 agent_type: "test".to_string(),
1950 ticket_id: "abc123".to_string(),
1951 ticket_branch: "ticket/abc123".to_string(),
1952 worktree_path: wt.path().to_path_buf(),
1953 system_prompt_file: sys_file.clone(),
1954 user_message_file: msg_file.clone(),
1955 skip_permissions: false,
1956 profile: "default".to_string(),
1957 role_prefix: None,
1958 options,
1959 model: None,
1960 log_path: log_dir.path().join("worker.log"),
1961 container: None,
1962 extra_env,
1963 root: wt.path().to_path_buf(),
1964 keychain: HashMap::new(),
1965 current_state: "in_progress".to_string(),
1966 command: None,
1967 };
1968
1969 let wrapper = crate::wrapper::resolve_builtin("claude").unwrap();
1970 let mut child = wrapper.spawn(&ctx).unwrap();
1971 child.wait().unwrap();
1972 let _ = std::fs::remove_file(&sys_file);
1973 let _ = std::fs::remove_file(&msg_file);
1974
1975 let env_content = std::fs::read_to_string(&env_output)
1976 .expect("env-output.txt not written");
1977
1978 assert!(env_content.contains("APM_OPT_MODEL=sonnet"), "APM_OPT_MODEL=sonnet must be set\n{env_content}");
1979 }
1980
1981 fn make_frontmatter_with_agent(agent: Option<&str>, overrides: &[(&str, &str)]) -> crate::ticket_fmt::Frontmatter {
1984 let agent_line = agent.map(|a| format!("agent = \"{a}\"\n")).unwrap_or_default();
1985 let overrides_section = if overrides.is_empty() {
1986 String::new()
1987 } else {
1988 let pairs: Vec<String> = overrides.iter()
1989 .map(|(k, v)| format!("{k} = \"{v}\""))
1990 .collect();
1991 format!("[agent_overrides]\n{}\n", pairs.join("\n"))
1992 };
1993 let toml_str = format!("id = \"t\"\ntitle = \"T\"\nstate = \"new\"\n{agent_line}{overrides_section}");
1994 toml::from_str(&toml_str).unwrap()
1995 }
1996
1997 #[test]
1998 fn apply_fm_profile_override_wins() {
1999 let fm = make_frontmatter_with_agent(Some("mock-sad"), &[("impl_agent", "mock-happy")]);
2000 let mut agent = "claude".to_string();
2001 apply_frontmatter_agent(&mut agent, &fm, "impl_agent");
2002 assert_eq!(agent, "mock-happy");
2003 }
2004
2005 #[test]
2006 fn apply_fm_agent_field_wins_when_no_profile_match() {
2007 let fm = make_frontmatter_with_agent(Some("mock-sad"), &[]);
2008 let mut agent = "claude".to_string();
2009 apply_frontmatter_agent(&mut agent, &fm, "impl_agent");
2010 assert_eq!(agent, "mock-sad");
2011 }
2012
2013 #[test]
2014 fn apply_fm_profile_override_beats_agent_field() {
2015 let fm = make_frontmatter_with_agent(Some("mock-random"), &[("impl_agent", "claude")]);
2016 let mut agent = "other".to_string();
2017 apply_frontmatter_agent(&mut agent, &fm, "impl_agent");
2018 assert_eq!(agent, "claude");
2019 }
2020
2021 #[test]
2022 fn apply_fm_no_fields_unchanged() {
2023 let fm = make_frontmatter_with_agent(None, &[]);
2024 let mut agent = "claude".to_string();
2025 apply_frontmatter_agent(&mut agent, &fm, "impl_agent");
2026 assert_eq!(agent, "claude");
2027 }
2028
2029 fn find_apm_bin() -> Option<String> {
2032 if let Ok(v) = std::env::var("APM_BIN") {
2034 if !v.is_empty() && std::path::Path::new(&v).exists() {
2035 return Some(v);
2036 }
2037 }
2038 if let Ok(exe) = std::env::current_exe() {
2043 if let Some(target_dir) = exe.parent().and_then(|p| p.parent()) {
2044 let candidate = target_dir.join("apm");
2045 if candidate.is_file() {
2046 return Some(candidate.to_string_lossy().into_owned());
2047 }
2048 }
2049 }
2050 None
2051 }
2052
2053 fn make_mock_project(root: &std::path::Path, ticket_state: &str, ticket_id: &str) {
2054 use std::fs;
2055
2056 fs::create_dir_all(root.join(".apm/agents/claude")).unwrap();
2057 fs::create_dir_all(root.join("tickets")).unwrap();
2058
2059 fs::write(root.join(".apm/config.toml"), r#"
2060[project]
2061name = "test-project"
2062default_branch = "main"
2063
2064[workers]
2065agent = "mock-happy"
2066
2067[tickets]
2068dir = "tickets"
2069"#).unwrap();
2070
2071 fs::write(root.join(".apm/workflow.toml"), r#"
2072[[workflow.states]]
2073id = "in_design"
2074label = "In Design"
2075actionable = ["agent"]
2076instructions = ".apm/apm.spec-writer.md"
2077
2078 [[workflow.states.transitions]]
2079 to = "specd"
2080 trigger = "manual"
2081 outcome = "success"
2082
2083 [[workflow.states.transitions]]
2084 to = "closed"
2085 trigger = "manual"
2086 outcome = "cancelled"
2087
2088[[workflow.states]]
2089id = "specd"
2090label = "Specd"
2091actionable = ["supervisor"]
2092satisfies_deps = true
2093worker_end = true
2094
2095 [[workflow.states.transitions]]
2096 to = "in_progress"
2097 trigger = "manual"
2098 outcome = "success"
2099
2100 [[workflow.states.transitions]]
2101 to = "closed"
2102 trigger = "manual"
2103 outcome = "cancelled"
2104
2105[[workflow.states]]
2106id = "in_progress"
2107label = "In Progress"
2108instructions = ".apm/apm.worker.md"
2109
2110 [[workflow.states.transitions]]
2111 to = "implemented"
2112 trigger = "manual"
2113 outcome = "success"
2114
2115 [[workflow.states.transitions]]
2116 to = "closed"
2117 trigger = "manual"
2118 outcome = "cancelled"
2119
2120[[workflow.states]]
2121id = "implemented"
2122label = "Implemented"
2123actionable = ["supervisor"]
2124satisfies_deps = true
2125worker_end = true
2126terminal = false
2127
2128 [[workflow.states.transitions]]
2129 to = "closed"
2130 trigger = "manual"
2131 outcome = "cancelled"
2132
2133[[workflow.states]]
2134id = "closed"
2135label = "Closed"
2136terminal = true
2137"#).unwrap();
2138
2139 fs::write(root.join(".apm/apm.worker.md"), "Worker instructions.").unwrap();
2140 fs::write(root.join(".apm/apm.spec-writer.md"), "Spec writer instructions.").unwrap();
2141
2142 let ticket_content = format!(r#"+++
2143id = "{ticket_id}"
2144title = "Test Ticket"
2145state = "{ticket_state}"
2146priority = 0
2147effort = 5
2148risk = 3
2149author = "test"
2150owner = "test"
2151branch = "ticket/{ticket_id}-test"
2152created_at = "2026-01-01T00:00:00Z"
2153updated_at = "2026-01-01T00:00:00Z"
2154+++
2155
2156## Spec
2157
2158### Problem
2159
2160Original problem.
2161
2162### Acceptance criteria
2163
2164- [ ] Some criterion
2165
2166### Out of scope
2167
2168Nothing.
2169
2170### Approach
2171
2172Some approach.
2173
2174### Open questions
2175
2176### Amendment requests
2177
2178### Code review
2179
2180## History
2181
2182| When | From | To | By |
2183|------|------|----|----|
2184"#);
2185 fs::write(root.join(format!("tickets/{ticket_id}-test.md")), ticket_content).unwrap();
2186
2187 std::process::Command::new("git")
2188 .arg("init")
2189 .current_dir(root)
2190 .output()
2191 .unwrap();
2192 std::process::Command::new("git")
2193 .args(["config", "user.email", "test@test.com"])
2194 .current_dir(root)
2195 .output()
2196 .unwrap();
2197 std::process::Command::new("git")
2198 .args(["config", "user.name", "Test"])
2199 .current_dir(root)
2200 .output()
2201 .unwrap();
2202 std::process::Command::new("git")
2204 .args(["add", ".apm"])
2205 .current_dir(root)
2206 .output()
2207 .unwrap();
2208 std::process::Command::new("git")
2209 .args(["commit", "-m", "initial commit", "--allow-empty"])
2210 .current_dir(root)
2211 .output()
2212 .unwrap();
2213 let branch_name = format!("ticket/{ticket_id}-test");
2215 std::process::Command::new("git")
2216 .args(["checkout", "-b", &branch_name])
2217 .current_dir(root)
2218 .output()
2219 .unwrap();
2220 std::process::Command::new("git")
2221 .args(["add", &format!("tickets/{ticket_id}-test.md")])
2222 .current_dir(root)
2223 .output()
2224 .unwrap();
2225 std::process::Command::new("git")
2226 .args(["commit", "-m", &format!("ticket({ticket_id}): created")])
2227 .current_dir(root)
2228 .output()
2229 .unwrap();
2230 std::process::Command::new("git")
2232 .args(["checkout", "main"])
2233 .current_dir(root)
2234 .output()
2235 .unwrap();
2236 }
2237
2238 fn make_wrapper_ctx_for_mock(
2239 project_root: &std::path::Path,
2240 ticket_id: &str,
2241 ticket_state: &str,
2242 apm_bin: &str,
2243 log_path: std::path::PathBuf,
2244 ) -> crate::wrapper::WrapperContext {
2245 let sys_file = crate::wrapper::write_temp_file("sys", "system prompt").unwrap();
2246 let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap();
2247 let mut options = HashMap::new();
2248 options.insert("apm_bin".to_string(), apm_bin.to_string());
2249 crate::wrapper::WrapperContext {
2250 worker_name: "test-worker".to_string(),
2251 agent_type: "test".to_string(),
2252 ticket_id: ticket_id.to_string(),
2253 ticket_branch: format!("ticket/{ticket_id}-test"),
2254 worktree_path: project_root.to_path_buf(),
2255 system_prompt_file: sys_file,
2256 user_message_file: msg_file,
2257 skip_permissions: false,
2258 profile: "default".to_string(),
2259 role_prefix: None,
2260 options,
2261 model: None,
2262 log_path,
2263 container: None,
2264 extra_env: HashMap::new(),
2265 root: project_root.to_path_buf(),
2266 keychain: HashMap::new(),
2267 current_state: ticket_state.to_string(),
2268 command: None,
2269 }
2270 }
2271
2272 #[test]
2273 fn mock_happy_spec_mode_transitions_to_specd() {
2274 let apm_bin = match find_apm_bin() { Some(b) => b, None => return };
2275 let dir = tempfile::tempdir().unwrap();
2276 let root = dir.path();
2277 make_mock_project(root, "in_design", "aaaa0001");
2278 let log_path = root.join("test-worker.log");
2279 let ctx = make_wrapper_ctx_for_mock(root, "aaaa0001", "in_design", &apm_bin, log_path.clone());
2280 let wrapper = crate::wrapper::resolve_builtin("mock-happy").unwrap();
2281 let mut child = wrapper.spawn(&ctx).unwrap();
2282 child.wait().unwrap();
2283
2284 let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
2285 let ticket_from_branch = {
2287 let out = std::process::Command::new("git")
2288 .args(["show", "ticket/aaaa0001-test:tickets/aaaa0001-test.md"])
2289 .current_dir(root)
2290 .output()
2291 .unwrap();
2292 String::from_utf8_lossy(&out.stdout).to_string()
2293 };
2294 assert!(ticket_from_branch.contains("state = \"specd\""),
2295 "ticket should be in specd state\nticket_from_branch: {ticket_from_branch}\nlog: {log_content}");
2296 assert!(ticket_from_branch.contains("### Problem"),
2297 "ticket should have Problem section\n{ticket_from_branch}");
2298 assert!(ticket_from_branch.contains("effort = 1"),
2299 "effort should be 1\n{ticket_from_branch}");
2300 assert!(ticket_from_branch.contains("risk = 1"),
2301 "risk should be 1\n{ticket_from_branch}");
2302 }
2303
2304 #[test]
2305 fn mock_happy_zero_success_transitions_returns_err() {
2306 use std::fs;
2307 let dir = tempfile::tempdir().unwrap();
2308 let root = dir.path();
2309
2310 fs::create_dir_all(root.join(".apm/agents/claude")).unwrap();
2311 fs::create_dir_all(root.join("tickets")).unwrap();
2312 fs::write(root.join(".apm/config.toml"), r#"
2313[project]
2314name = "test"
2315default_branch = "main"
2316[workers]
2317agent = "mock-happy"
2318[tickets]
2319dir = "tickets"
2320"#).unwrap();
2321 fs::write(root.join(".apm/workflow.toml"), r#"
2322[[workflow.states]]
2323id = "in_design"
2324label = "In Design"
2325actionable = ["agent"]
2326
2327 [[workflow.states.transitions]]
2328 to = "closed"
2329 trigger = "manual"
2330 outcome = "needs_input"
2331
2332[[workflow.states]]
2333id = "closed"
2334label = "Closed"
2335terminal = true
2336"#).unwrap();
2337 fs::write(root.join(".apm/apm.worker.md"), "instructions").unwrap();
2338 fs::write(root.join(".apm/apm.spec-writer.md"), "instructions").unwrap();
2339 let ticket_content = r#"+++
2340id = "aaaa0002"
2341title = "Test"
2342state = "in_design"
2343priority = 0
2344effort = 5
2345risk = 3
2346author = "test"
2347owner = "test"
2348branch = "ticket/aaaa0002-test"
2349created_at = "2026-01-01T00:00:00Z"
2350updated_at = "2026-01-01T00:00:00Z"
2351+++
2352
2353## Spec
2354
2355### Problem
2356
2357### Acceptance criteria
2358
2359### Out of scope
2360
2361### Approach
2362
2363## History
2364
2365| When | From | To | By |
2366|------|------|----|----|
2367"#;
2368 fs::write(root.join("tickets/aaaa0002-test.md"), ticket_content).unwrap();
2369 std::process::Command::new("git").args(["init"]).current_dir(root).output().unwrap();
2370 std::process::Command::new("git").args(["config", "user.email", "t@t.com"]).current_dir(root).output().unwrap();
2371 std::process::Command::new("git").args(["config", "user.name", "T"]).current_dir(root).output().unwrap();
2372 std::process::Command::new("git").args(["add", "."]).current_dir(root).output().unwrap();
2373 std::process::Command::new("git").args(["commit", "-m", "init"]).current_dir(root).output().unwrap();
2374
2375 let log_path = root.join("test.log");
2376 let sys_file = crate::wrapper::write_temp_file("sys", "sys").unwrap();
2377 let msg_file = crate::wrapper::write_temp_file("msg", "msg").unwrap();
2378 let ctx = crate::wrapper::WrapperContext {
2379 worker_name: "test".to_string(),
2380 agent_type: "test".to_string(),
2381 ticket_id: "aaaa0002".to_string(),
2382 ticket_branch: "ticket/aaaa0002-test".to_string(),
2383 worktree_path: root.to_path_buf(),
2384 system_prompt_file: sys_file,
2385 user_message_file: msg_file,
2386 skip_permissions: false,
2387 profile: "default".to_string(),
2388 role_prefix: None,
2389 options: HashMap::new(),
2390 model: None,
2391 log_path,
2392 container: None,
2393 extra_env: HashMap::new(),
2394 root: root.to_path_buf(),
2395 keychain: HashMap::new(),
2396 current_state: "in_design".to_string(),
2397 command: None,
2398 };
2399 let wrapper = crate::wrapper::resolve_builtin("mock-happy").unwrap();
2400 let result = wrapper.spawn(&ctx);
2401 assert!(result.is_err(), "mock-happy should return Err when no success transitions");
2402 let msg = result.unwrap_err().to_string();
2403 assert!(msg.contains("no success-outcome transition"), "error should mention no success transition: {msg}");
2404 }
2405
2406 #[test]
2407 fn mock_sad_transitions_to_non_success_state() {
2408 let apm_bin = match find_apm_bin() { Some(b) => b, None => return };
2409 let dir = tempfile::tempdir().unwrap();
2410 let root = dir.path();
2411 make_mock_project(root, "in_design", "aaaa0003");
2412 let log_path = root.join("test.log");
2413 let ctx = make_wrapper_ctx_for_mock(root, "aaaa0003", "in_design", &apm_bin, log_path.clone());
2414 let wrapper = crate::wrapper::resolve_builtin("mock-sad").unwrap();
2415 let mut child = wrapper.spawn(&ctx).unwrap();
2416 child.wait().unwrap();
2417
2418 let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
2419 let out = std::process::Command::new("git")
2420 .args(["show", "ticket/aaaa0003-test:tickets/aaaa0003-test.md"])
2421 .current_dir(root)
2422 .output()
2423 .unwrap();
2424 let ticket_from_branch = String::from_utf8_lossy(&out.stdout).to_string();
2425 assert!(!ticket_from_branch.contains("state = \"specd\""),
2426 "mock-sad should NOT transition to specd\n{ticket_from_branch}\nlog: {log_content}");
2427 assert!(ticket_from_branch.contains("state = \"closed\"") || ticket_from_branch.contains("state = \"in_design\""),
2429 "mock-sad should transition to a non-success state\n{ticket_from_branch}\nlog: {log_content}");
2430 }
2431
2432 #[test]
2433 fn mock_sad_seed_reproducibility() {
2434 let apm_bin = match find_apm_bin() { Some(b) => b, None => return };
2435
2436 let run_mock_sad = |ticket_id: &str, seed: &str| -> String {
2437 let dir = tempfile::tempdir().unwrap();
2438 let root = dir.path();
2439 make_mock_project(root, "in_design", ticket_id);
2440 let log_path = root.join("test.log");
2441 let mut options = HashMap::new();
2442 options.insert("apm_bin".to_string(), apm_bin.clone());
2443 options.insert("seed".to_string(), seed.to_string());
2444 let sys_file = crate::wrapper::write_temp_file("sys", "sys").unwrap();
2445 let msg_file = crate::wrapper::write_temp_file("msg", "msg").unwrap();
2446 let ctx = crate::wrapper::WrapperContext {
2447 worker_name: "test".to_string(),
2448 agent_type: "test".to_string(),
2449 ticket_id: ticket_id.to_string(),
2450 ticket_branch: format!("ticket/{ticket_id}-test"),
2451 worktree_path: root.to_path_buf(),
2452 system_prompt_file: sys_file,
2453 user_message_file: msg_file,
2454 skip_permissions: false,
2455 profile: "default".to_string(),
2456 role_prefix: None,
2457 options,
2458 model: None,
2459 log_path,
2460 container: None,
2461 extra_env: HashMap::new(),
2462 root: root.to_path_buf(),
2463 keychain: HashMap::new(),
2464 current_state: "in_design".to_string(),
2465 command: None,
2466 };
2467 let wrapper = crate::wrapper::resolve_builtin("mock-sad").unwrap();
2468 let mut child = wrapper.spawn(&ctx).unwrap();
2469 child.wait().unwrap();
2470
2471 let git_content = {
2473 let o = std::process::Command::new("git")
2474 .args(["show", &format!("ticket/{ticket_id}-test:tickets/{ticket_id}-test.md")])
2475 .current_dir(root)
2476 .output()
2477 .unwrap();
2478 String::from_utf8_lossy(&o.stdout).to_string()
2479 };
2480 for line in git_content.lines() {
2481 if line.starts_with("state = ") {
2482 return line.to_string();
2483 }
2484 }
2485 "unknown".to_string()
2486 };
2487
2488 let state1 = run_mock_sad("aaaa000a", "42");
2489 let state2 = run_mock_sad("aaaa000b", "42");
2490 assert_eq!(state1, state2, "mock-sad with same seed should pick same target state");
2491 }
2492
2493 #[test]
2494 fn debug_does_not_change_state() {
2495 let dir = tempfile::tempdir().unwrap();
2496 let root = dir.path();
2497 make_mock_project(root, "in_design", "aaaa0005");
2498 let log_path = root.join("test.log");
2499 let sys_file = crate::wrapper::write_temp_file("sys", "debug-system-prompt-unique-text").unwrap();
2500 let msg_file = crate::wrapper::write_temp_file("msg", "debug-message").unwrap();
2501 let ctx = crate::wrapper::WrapperContext {
2502 worker_name: "test-worker".to_string(),
2503 agent_type: "test".to_string(),
2504 ticket_id: "aaaa0005".to_string(),
2505 ticket_branch: "ticket/aaaa0005-test".to_string(),
2506 worktree_path: root.to_path_buf(),
2507 system_prompt_file: sys_file,
2508 user_message_file: msg_file,
2509 skip_permissions: false,
2510 profile: "default".to_string(),
2511 role_prefix: None,
2512 options: HashMap::new(),
2513 model: None,
2514 log_path: log_path.clone(),
2515 container: None,
2516 extra_env: HashMap::new(),
2517 root: root.to_path_buf(),
2518 keychain: HashMap::new(),
2519 current_state: "in_design".to_string(),
2520 command: None,
2521 };
2522 let wrapper = crate::wrapper::resolve_builtin("debug").unwrap();
2523 let mut child = wrapper.spawn(&ctx).unwrap();
2524 child.wait().unwrap();
2525
2526 let git_content = {
2529 let o = std::process::Command::new("git")
2530 .args(["show", "ticket/aaaa0005-test:tickets/aaaa0005-test.md"])
2531 .current_dir(root)
2532 .output()
2533 .unwrap();
2534 String::from_utf8_lossy(&o.stdout).to_string()
2535 };
2536 assert!(git_content.contains("state = \"in_design\""),
2537 "debug should not change ticket state\n{git_content}");
2538
2539 let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
2541 assert!(log_content.contains("APM_TICKET_ID"),
2542 "log should contain APM_TICKET_ID\n{log_content}");
2543 assert!(log_content.contains("debug-system-prompt-unique-text"),
2544 "log should contain system prompt text\n{log_content}");
2545 assert!(log_content.contains("\"type\":\"tool_use\""),
2546 "log should contain tool_use JSONL\n{log_content}");
2547 }
2548}