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
50fn 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
114fn 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 agent_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, agent_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, agent_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 agent_name: agent_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 now_str = chrono::Utc::now().format("%m%d-%H%M").to_string();
351 let worker_name = format!("claude-{}-{:04x}", now_str, rand_u16());
352
353 let profile_name = triggering_transition
354 .and_then(|tr| tr.profile.as_deref())
355 .unwrap_or("")
356 .to_string();
357 let profile = triggering_transition.and_then(|tr| resolve_profile(tr, &config, &mut warnings));
358 let role = profile.and_then(|p| p.role.as_deref()).unwrap_or("worker");
359 let mut params = effective_spawn_params(triggering_transition.and_then(|tr| tr.agent.as_deref()), profile, &config.workers);
360 apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name);
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 = resolve_system_prompt(root, tr_instructions, profile, &config.workers, ¶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 ticket_id: id.clone(),
378 ticket_branch: branch.clone(),
379 worktree_path: wt_display.clone(),
380 system_prompt_file: sys_file.clone(),
381 user_message_file: msg_file.clone(),
382 skip_permissions,
383 profile: profile_name,
384 role_prefix,
385 options: params.options.clone(),
386 model: params.model.clone(),
387 log_path: log_path.clone(),
388 container: params.container.clone(),
389 extra_env: params.env.clone(),
390 root: root.to_path_buf(),
391 keychain: config.workers.keychain.clone(),
392 current_state: new_state.clone(),
393 command: Some(params.command.clone()),
394 };
395 if should_check_claude_compat(root, ¶ms.agent) {
396 check_output_format_supported(¶ms.command)?;
397 }
398 let mut child = spawn_worker(&ctx, ¶ms.agent, root)?;
399 let pid = child.id();
400
401 let pid_path = wt_display.join(".apm-worker.pid");
402 write_pid_file(&pid_path, pid, &id)?;
403
404 let enforce_isolation = skip_permissions || config.isolation.enforce_worktree_isolation;
405 let wt_for_cleanup = wt_display.clone();
406 let denial_log_path = log_path.clone();
407 let denial_worktree = wt_display.clone();
408 let denial_ticket_id = id.clone();
409 let agent_for_diag = params.agent.clone();
410 std::thread::spawn(move || {
411 let _ = child.wait();
412 let _ = std::fs::remove_file(&sys_file);
413 let _ = std::fs::remove_file(&msg_file);
414 if agent_for_diag == "claude" {
415 run_denial_scan(&denial_log_path, &denial_worktree, &denial_ticket_id);
416 }
417 if enforce_isolation {
418 let _ = crate::wrapper::hook_config::remove_hook_config(&wt_for_cleanup);
419 }
420 });
421
422 Ok(StartOutput {
423 id,
424 old_state,
425 new_state,
426 agent_name: agent_name.to_string(),
427 branch,
428 worktree_path: wt_display,
429 merge_message,
430 worker_pid: Some(pid),
431 log_path: Some(log_path),
432 worker_name: Some(worker_name),
433 warnings,
434 })
435}
436
437pub fn run_next(root: &Path, no_aggressive: bool, spawn: bool, skip_permissions: bool) -> Result<RunNextOutput> {
438 let mut messages: Vec<String> = Vec::new();
439 let mut warnings: Vec<String> = Vec::new();
440 let config = Config::load(root)?;
441 let skip_permissions = skip_permissions || config.agents.skip_permissions;
442 let p = &config.workflow.prioritization;
443 let startable: Vec<&str> = config.workflow.states.iter()
444 .filter(|s| s.transitions.iter().any(|tr| tr.trigger == "command:start"))
445 .map(|s| s.id.as_str())
446 .collect();
447 let actionable_owned = config.actionable_states_for("agent");
448 let actionable: Vec<&str> = actionable_owned.iter().map(|s| s.as_str()).collect();
449 let all_tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
450 let agent_name = crate::config::resolve_caller_name();
451 let current_user = crate::config::resolve_identity(root);
452
453 let active_epic_ids: Vec<Option<String>> = all_tickets.iter()
455 .filter(|t| {
456 let s = t.frontmatter.state.as_str();
457 actionable.contains(&s) && !startable.contains(&s)
458 })
459 .map(|t| t.frontmatter.epic.clone())
460 .collect();
461 let blocked = config.blocked_epics(&active_epic_ids);
462 let default_blocked = config.is_default_branch_blocked(&active_epic_ids);
463 let tickets: Vec<_> = all_tickets.into_iter()
464 .filter(|t| match t.frontmatter.epic.as_deref() {
465 Some(eid) => !blocked.iter().any(|b| b == eid),
466 None => !default_blocked,
467 })
468 .collect();
469
470 let Some(candidate) = ticket::pick_next(&tickets, &actionable, &startable, p.priority_weight, p.effort_weight, p.risk_weight, &config, Some(&agent_name), Some(¤t_user)) else {
471 messages.push("No actionable tickets.".to_string());
472 return Ok(RunNextOutput { ticket_id: None, messages, warnings, worker_pid: None, log_path: None });
473 };
474
475 let id = candidate.frontmatter.id.clone();
476 let old_state = candidate.frontmatter.state.clone();
477
478 let triggering_transition_owned = config.workflow.states.iter()
479 .find(|s| s.id == old_state)
480 .and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"))
481 .cloned();
482 let profile = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, &mut warnings));
483 let state_instructions = config.workflow.states.iter()
484 .find(|s| s.id == old_state)
485 .and_then(|sc| sc.instructions.as_deref())
486 .map(|s| s.to_string());
487 let instructions_text = profile
488 .and_then(|p| p.instructions.as_deref())
489 .map(|path| {
490 match std::fs::read_to_string(root.join(path)) {
491 Ok(s) => s,
492 Err(_) => { warnings.push("warning: instructions file not found".to_string()); String::new() }
493 }
494 })
495 .filter(|s| !s.is_empty())
496 .or_else(|| state_instructions.as_deref()
497 .and_then(|path| {
498 std::fs::read_to_string(root.join(path)).ok()
499 .or_else(|| { warnings.push("warning: instructions file not found".to_string()); None })
500 }));
501 let start_out = run(root, &id, no_aggressive, false, false, &agent_name)?;
502 warnings.extend(start_out.warnings);
503
504 if let Some(ref msg) = start_out.merge_message {
505 messages.push(msg.clone());
506 }
507 messages.push(format!("{}: {} → {} (agent: {}, branch: {})", start_out.id, start_out.old_state, start_out.new_state, start_out.agent_name, start_out.branch));
508 messages.push(format!("Worktree: {}", start_out.worktree_path.display()));
509
510 let tickets2 = ticket::load_all_from_git(root, &config.tickets.dir)?;
511 let Some(t) = tickets2.iter().find(|t| t.frontmatter.id == id) else {
512 return Ok(RunNextOutput { ticket_id: Some(id), messages, warnings, worker_pid: None, log_path: None });
513 };
514
515 let focus_hint = if let Some(ref section) = t.frontmatter.focus_section {
516 let hint = format!("Pay special attention to section: {section}");
517 let rel_path = format!(
518 "{}/{}",
519 config.tickets.dir.to_string_lossy(),
520 t.path.file_name().unwrap().to_string_lossy()
521 );
522 let branch = t.frontmatter.branch.clone()
523 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
524 .unwrap_or_else(|| format!("ticket/{id}"));
525 let mut t_mut = t.clone();
526 t_mut.frontmatter.focus_section = None;
527 let cleared = t_mut.serialize()?;
528 git::commit_to_branch(root, &branch, &rel_path, &cleared, &format!("ticket({id}): clear focus_section"))?;
529 Some(hint)
530 } else {
531 None
532 };
533
534 let mut prompt = String::new();
535 if let Some(ref instr) = instructions_text {
536 prompt.push_str(instr.trim());
537 prompt.push('\n');
538 }
539 if let Some(ref hint) = focus_hint {
540 if !prompt.is_empty() { prompt.push('\n'); }
541 prompt.push_str(hint);
542 prompt.push('\n');
543 }
544
545 if !spawn {
546 if !prompt.is_empty() {
547 messages.push(format!("Prompt:\n{prompt}"));
548 }
549 return Ok(RunNextOutput { ticket_id: Some(id), messages, warnings, worker_pid: None, log_path: None });
550 }
551
552 let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string();
553 let worker_name = format!("claude-{}-{:04x}", now_str, rand_u16());
554
555 let profile_name2 = triggering_transition_owned.as_ref()
556 .and_then(|tr| tr.profile.as_deref())
557 .unwrap_or("")
558 .to_string();
559 let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, &mut warnings));
560 let role2 = profile2.and_then(|p| p.role.as_deref()).unwrap_or("worker");
561 let mut params = effective_spawn_params(triggering_transition_owned.as_ref().and_then(|tr| tr.agent.as_deref()), profile2, &config.workers);
562 apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name2);
563 let tr_instructions2 = triggering_transition_owned.as_ref().and_then(|tr| tr.instructions.as_deref());
564 let tr_role_prefix2 = triggering_transition_owned.as_ref().and_then(|tr| tr.role_prefix.as_deref());
565 let worker_system = resolve_system_prompt(root, tr_instructions2, profile2, &config.workers, ¶ms.agent, role2)?;
566
567 let raw = t.serialize()?;
568 let dep_ids_next = t.frontmatter.depends_on.clone().unwrap_or_default();
569 let raw_prompt_next = format!("{}\n\n{raw}", agent_role_prefix(tr_role_prefix2, profile2, &id));
570 let with_epic_next = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_next);
571 let ticket_content = with_dependency_bundle(root, &dep_ids_next, &config, with_epic_next);
572 let role_prefix2 = tr_role_prefix2
573 .map(|p| p.replace("<id>", &id))
574 .or_else(|| profile2.and_then(|p| p.role_prefix.clone()));
575
576 let branch = t.frontmatter.branch.clone()
577 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
578 .unwrap_or_else(|| format!("ticket/{id}"));
579 let wt_name = branch.replace('/', "-");
580 let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
581 let wt_path = main_root.join(&config.worktrees.dir).join(&wt_name);
582 let wt_display = crate::worktree::find_worktree_for_branch(root, &branch).unwrap_or(wt_path);
583
584 let log_path = wt_display.join(".apm-worker.log");
585
586 let sys_file = write_temp_file("sys", &worker_system)?;
587 let msg_file = write_temp_file("msg", &ticket_content)?;
588 let ctx = WrapperContext {
589 worker_name: worker_name.clone(),
590 ticket_id: id.clone(),
591 ticket_branch: branch.clone(),
592 worktree_path: wt_display.clone(),
593 system_prompt_file: sys_file.clone(),
594 user_message_file: msg_file.clone(),
595 skip_permissions,
596 profile: profile_name2,
597 role_prefix: role_prefix2,
598 options: params.options.clone(),
599 model: params.model.clone(),
600 log_path: log_path.clone(),
601 container: params.container.clone(),
602 extra_env: params.env.clone(),
603 root: root.to_path_buf(),
604 keychain: config.workers.keychain.clone(),
605 current_state: t.frontmatter.state.clone(),
606 command: Some(params.command.clone()),
607 };
608 if should_check_claude_compat(root, ¶ms.agent) {
609 check_output_format_supported(¶ms.command)?;
610 }
611 let mut child = spawn_worker(&ctx, ¶ms.agent, root)?;
612 let pid = child.id();
613
614 let pid_path = wt_display.join(".apm-worker.pid");
615 write_pid_file(&pid_path, pid, &id)?;
616 let enforce_isolation_next = skip_permissions || config.isolation.enforce_worktree_isolation;
617 let wt_for_cleanup_next = wt_display.clone();
618 let denial_log_path2 = log_path.clone();
619 let denial_worktree2 = wt_display.clone();
620 let denial_ticket_id2 = id.clone();
621 let agent_for_diag2 = params.agent.clone();
622 std::thread::spawn(move || {
623 let _ = child.wait();
624 let _ = std::fs::remove_file(&sys_file);
625 let _ = std::fs::remove_file(&msg_file);
626 if agent_for_diag2 == "claude" {
627 run_denial_scan(&denial_log_path2, &denial_worktree2, &denial_ticket_id2);
628 }
629 if enforce_isolation_next {
630 let _ = crate::wrapper::hook_config::remove_hook_config(&wt_for_cleanup_next);
631 }
632 });
633
634 messages.push(format!("Worker spawned: PID={pid}, log={}", log_path.display()));
635 messages.push(format!("Agent name: {worker_name}"));
636
637 Ok(RunNextOutput { ticket_id: Some(id), messages, warnings, worker_pid: Some(pid), log_path: Some(log_path) })
638}
639
640#[allow(clippy::type_complexity)]
641pub fn spawn_next_worker(
642 root: &Path,
643 no_aggressive: bool,
644 skip_permissions: bool,
645 epic_filter: Option<&str>,
646 blocked_epics: &[String],
647 default_blocked: bool,
648 messages: &mut Vec<String>,
649 warnings: &mut Vec<String>,
650) -> Result<Option<(String, Option<String>, ManagedChild, PathBuf)>> {
651 let config = Config::load(root)?;
652 let skip_permissions = skip_permissions || config.agents.skip_permissions;
653 let p = &config.workflow.prioritization;
654 let startable: Vec<&str> = config.workflow.states.iter()
655 .filter(|s| s.transitions.iter().any(|tr| tr.trigger == "command:start"))
656 .map(|s| s.id.as_str())
657 .collect();
658 let actionable_owned = config.actionable_states_for("agent");
659 let actionable: Vec<&str> = actionable_owned.iter().map(|s| s.as_str()).collect();
660 let all_tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
661 let tickets: Vec<ticket::Ticket> = {
662 let epic_filtered: Vec<ticket::Ticket> = match epic_filter {
663 Some(epic_id) => all_tickets.into_iter()
664 .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
665 .collect(),
666 None => all_tickets,
667 };
668 epic_filtered.into_iter()
669 .filter(|t| match t.frontmatter.epic.as_deref() {
670 Some(eid) => !blocked_epics.iter().any(|b| b == eid),
671 None => !default_blocked,
672 })
673 .collect()
674 };
675 let agent_name = crate::config::resolve_caller_name();
676 let current_user = crate::config::resolve_identity(root);
677
678 let Some(candidate) = ticket::pick_next(&tickets, &actionable, &startable, p.priority_weight, p.effort_weight, p.risk_weight, &config, Some(&agent_name), Some(¤t_user)) else {
679 return Ok(None);
680 };
681
682 let id = candidate.frontmatter.id.clone();
683 let epic_id = candidate.frontmatter.epic.clone();
684 let old_state = candidate.frontmatter.state.clone();
685
686 let triggering_transition_owned = config.workflow.states.iter()
687 .find(|s| s.id == old_state)
688 .and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"))
689 .cloned();
690 let profile = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, warnings));
691 let state_instructions = config.workflow.states.iter()
692 .find(|s| s.id == old_state)
693 .and_then(|sc| sc.instructions.as_deref())
694 .map(|s| s.to_string());
695 let instructions_text = profile
696 .and_then(|p| p.instructions.as_deref())
697 .map(|path| {
698 match std::fs::read_to_string(root.join(path)) {
699 Ok(s) => s,
700 Err(_) => { warnings.push("warning: instructions file not found".to_string()); String::new() }
701 }
702 })
703 .filter(|s| !s.is_empty())
704 .or_else(|| state_instructions.as_deref()
705 .and_then(|path| {
706 std::fs::read_to_string(root.join(path)).ok()
707 .or_else(|| { warnings.push("warning: instructions file not found".to_string()); None })
708 }));
709 let start_out = run(root, &id, no_aggressive, false, false, &agent_name)?;
710 warnings.extend(start_out.warnings);
711
712 if let Some(ref msg) = start_out.merge_message {
713 messages.push(msg.clone());
714 }
715 messages.push(format!("{}: {} → {} (agent: {}, branch: {})", start_out.id, start_out.old_state, start_out.new_state, start_out.agent_name, start_out.branch));
716 messages.push(format!("Worktree: {}", start_out.worktree_path.display()));
717
718 let tickets2 = ticket::load_all_from_git(root, &config.tickets.dir)?;
719 let Some(t) = tickets2.iter().find(|t| t.frontmatter.id == id) else {
720 return Ok(None);
721 };
722
723 let focus_hint = if let Some(ref section) = t.frontmatter.focus_section {
724 let hint = format!("Pay special attention to section: {section}");
725 let rel_path = format!(
726 "{}/{}",
727 config.tickets.dir.to_string_lossy(),
728 t.path.file_name().unwrap().to_string_lossy()
729 );
730 let branch = t.frontmatter.branch.clone()
731 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
732 .unwrap_or_else(|| format!("ticket/{id}"));
733 let mut t_mut = t.clone();
734 t_mut.frontmatter.focus_section = None;
735 let cleared = t_mut.serialize()?;
736 git::commit_to_branch(root, &branch, &rel_path, &cleared,
737 &format!("ticket({id}): clear focus_section"))?;
738 Some(hint)
739 } else {
740 None
741 };
742
743 let mut prompt = String::new();
744 if let Some(ref instr) = instructions_text {
745 prompt.push_str(instr.trim());
746 prompt.push('\n');
747 }
748 if let Some(ref hint) = focus_hint {
749 if !prompt.is_empty() { prompt.push('\n'); }
750 prompt.push_str(hint);
751 prompt.push('\n');
752 }
753 let _ = prompt; let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string();
756 let worker_name = format!("claude-{}-{:04x}", now_str, rand_u16());
757
758 let profile_name2 = triggering_transition_owned.as_ref()
759 .and_then(|tr| tr.profile.as_deref())
760 .unwrap_or("")
761 .to_string();
762 let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, warnings));
763 let role2 = profile2.and_then(|p| p.role.as_deref()).unwrap_or("worker");
764 let mut params = effective_spawn_params(triggering_transition_owned.as_ref().and_then(|tr| tr.agent.as_deref()), profile2, &config.workers);
765 apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name2);
766 let tr_instructions_snw = triggering_transition_owned.as_ref().and_then(|tr| tr.instructions.as_deref());
767 let tr_role_prefix_snw = triggering_transition_owned.as_ref().and_then(|tr| tr.role_prefix.as_deref());
768 let worker_system = resolve_system_prompt(root, tr_instructions_snw, profile2, &config.workers, ¶ms.agent, role2)?;
769
770 let raw = t.serialize()?;
771 let dep_ids_snw = t.frontmatter.depends_on.clone().unwrap_or_default();
772 let raw_prompt_snw = format!("{}\n\n{raw}", agent_role_prefix(tr_role_prefix_snw, profile2, &id));
773 let with_epic_snw = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_snw);
774 let ticket_content = with_dependency_bundle(root, &dep_ids_snw, &config, with_epic_snw);
775 let role_prefix2 = tr_role_prefix_snw
776 .map(|p| p.replace("<id>", &id))
777 .or_else(|| profile2.and_then(|p| p.role_prefix.clone()));
778
779 let branch = t.frontmatter.branch.clone()
780 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
781 .unwrap_or_else(|| format!("ticket/{id}"));
782 let wt_name = branch.replace('/', "-");
783 let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
784 let wt_path = main_root.join(&config.worktrees.dir).join(&wt_name);
785 let wt_display = crate::worktree::find_worktree_for_branch(root, &branch).unwrap_or(wt_path);
786
787 let log_path = wt_display.join(".apm-worker.log");
788
789 let sys_file = write_temp_file("sys", &worker_system)?;
790 let msg_file = write_temp_file("msg", &ticket_content)?;
791 let ctx = WrapperContext {
792 worker_name: worker_name.clone(),
793 ticket_id: id.clone(),
794 ticket_branch: branch.clone(),
795 worktree_path: wt_display.clone(),
796 system_prompt_file: sys_file.clone(),
797 user_message_file: msg_file.clone(),
798 skip_permissions,
799 profile: profile_name2,
800 role_prefix: role_prefix2,
801 options: params.options.clone(),
802 model: params.model.clone(),
803 log_path: log_path.clone(),
804 container: params.container.clone(),
805 extra_env: params.env.clone(),
806 root: root.to_path_buf(),
807 keychain: config.workers.keychain.clone(),
808 current_state: t.frontmatter.state.clone(),
809 command: Some(params.command.clone()),
810 };
811 if should_check_claude_compat(root, ¶ms.agent) {
812 check_output_format_supported(¶ms.command)?;
813 }
814 let child = spawn_worker(&ctx, ¶ms.agent, root)?;
815 let pid = child.id();
816
817 let denial_ctx = if params.agent == "claude" {
818 Some((log_path.clone(), wt_display.clone(), id.clone()))
819 } else {
820 None
821 };
822 let managed = ManagedChild {
823 inner: child,
824 temp_files: vec![sys_file, msg_file],
825 denial_ctx,
826 };
827
828 let pid_path = wt_display.join(".apm-worker.pid");
829 write_pid_file(&pid_path, pid, &id)?;
830
831 messages.push(format!("Worker spawned: PID={pid}, log={}", log_path.display()));
832 messages.push(format!("Agent name: {worker_name}"));
833
834 Ok(Some((id, epic_id, managed, pid_path)))
835}
836
837fn with_dependency_bundle(root: &Path, depends_on: &[String], config: &Config, content: String) -> String {
840 if depends_on.is_empty() {
841 return content;
842 }
843 let bundle = crate::context::build_dependency_bundle(root, depends_on, config);
844 if bundle.is_empty() {
845 return content;
846 }
847 format!("{bundle}\n{content}")
848}
849
850fn with_epic_bundle(root: &Path, epic_id: Option<&str>, ticket_id: &str, config: &Config, content: String) -> String {
853 match epic_id {
854 Some(eid) => {
855 let bundle = crate::context::build_epic_bundle(root, eid, ticket_id, config);
856 format!("{bundle}\n{content}")
857 }
858 None => content,
859 }
860}
861
862pub(crate) fn resolve_builtin_instructions(agent: &str, role: &str) -> Option<&'static str> {
863 match (agent, role) {
864 ("claude", "worker") => Some(CLAUDE_WORKER_DEFAULT),
865 ("claude", "spec-writer") => Some(CLAUDE_SPEC_WRITER_DEFAULT),
866 ("mock-happy", "worker") => Some(MOCK_HAPPY_WORKER_DEFAULT),
867 ("mock-happy", "spec-writer") => Some(MOCK_HAPPY_SPEC_WRITER_DEFAULT),
868 ("mock-sad", "worker") => Some(MOCK_SAD_WORKER_DEFAULT),
869 ("mock-sad", "spec-writer") => Some(MOCK_SAD_SPEC_WRITER_DEFAULT),
870 ("mock-random", "worker") => Some(MOCK_RANDOM_WORKER_DEFAULT),
871 ("mock-random", "spec-writer") => Some(MOCK_RANDOM_SPEC_WRITER_DEFAULT),
872 ("debug", "worker") => Some(DEBUG_WORKER_DEFAULT),
873 ("debug", "spec-writer") => Some(DEBUG_SPEC_WRITER_DEFAULT),
874 _ => None,
875 }
876}
877
878fn resolve_system_prompt(
879 root: &Path,
880 transition_instructions: Option<&str>,
881 profile: Option<&WorkerProfileConfig>,
882 workers: &WorkersConfig,
883 agent: &str,
884 role: &str,
885) -> Result<String> {
886 if let Some(path) = transition_instructions {
888 return std::fs::read_to_string(root.join(path))
889 .with_context(|| format!("transition.instructions: file not found: {path}"));
890 }
891 if let Some(p) = profile {
893 if let Some(ref instr_path) = p.instructions {
894 match std::fs::read_to_string(root.join(instr_path)) {
895 Ok(content) => return Ok(content),
896 Err(_) => bail!("[worker_profiles.*].instructions: file not found: {instr_path}"),
897 }
898 }
899 }
900 if let Some(ref instr_path) = workers.instructions {
902 match std::fs::read_to_string(root.join(instr_path)) {
903 Ok(content) => return Ok(content),
904 Err(_) => bail!("[workers].instructions: file not found: {instr_path}"),
905 }
906 }
907 let per_agent = root.join(format!(".apm/agents/{agent}/apm.{role}.md"));
909 if per_agent.exists() {
910 if let Ok(content) = std::fs::read_to_string(&per_agent) {
911 return Ok(content);
912 }
913 }
914 if let Some(s) = resolve_builtin_instructions(agent, role) {
916 return Ok(s.to_string());
917 }
918 bail!(
920 "no instructions found for agent '{agent}' role '{role}': \
921 set [workers].instructions in .apm/config.toml or add \
922 .apm/agents/{agent}/apm.{role}.md"
923 )
924}
925
926fn agent_role_prefix(transition_role_prefix: Option<&str>, profile: Option<&WorkerProfileConfig>, id: &str) -> String {
927 if let Some(prefix) = transition_role_prefix {
928 return prefix.replace("<id>", id);
929 }
930 if let Some(p) = profile {
931 if let Some(ref prefix) = p.role_prefix {
932 return prefix.replace("<id>", id);
933 }
934 }
935 format!("You are a Worker agent assigned to ticket #{id}.")
936}
937
938fn write_pid_file(path: &Path, pid: u32, ticket_id: &str) -> Result<()> {
939 let started_at = chrono::Utc::now().format("%Y-%m-%dT%H:%MZ").to_string();
940 let content = serde_json::json!({
941 "pid": pid,
942 "ticket_id": ticket_id,
943 "started_at": started_at,
944 })
945 .to_string();
946 std::fs::write(path, content)?;
947 Ok(())
948}
949
950fn rand_u16() -> u16 {
951 use std::time::{SystemTime, UNIX_EPOCH};
952 SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos() as u16
953}
954
955#[cfg(test)]
956mod tests {
957 use super::{resolve_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};
958 use crate::config::{WorkerProfileConfig, WorkersConfig, TransitionConfig, CompletionStrategy};
959 use std::collections::HashMap;
960
961 fn make_transition(profile: Option<&str>) -> TransitionConfig {
962 TransitionConfig {
963 to: "in_progress".into(),
964 trigger: "command:start".into(),
965 label: String::new(),
966 hint: String::new(),
967 completion: CompletionStrategy::None,
968 focus_section: None,
969 context_section: None,
970 warning: None,
971 profile: profile.map(|s| s.to_string()),
972 instructions: None,
973 role_prefix: None,
974 agent: None,
975 on_failure: None,
976 outcome: None,
977 }
978 }
979
980 fn make_profile(instructions: Option<&str>, role_prefix: Option<&str>) -> WorkerProfileConfig {
981 WorkerProfileConfig {
982 instructions: instructions.map(|s| s.to_string()),
983 role_prefix: role_prefix.map(|s| s.to_string()),
984 ..Default::default()
985 }
986 }
987
988 fn make_workers(command: &str, model: Option<&str>) -> WorkersConfig {
989 WorkersConfig {
990 command: Some(command.to_string()),
991 args: None,
992 model: model.map(|s| s.to_string()),
993 env: HashMap::new(),
994 container: None,
995 keychain: HashMap::new(),
996 agent: None,
997 options: HashMap::new(),
998 instructions: None,
999 }
1000 }
1001
1002 #[test]
1005 fn resolve_profile_returns_profile_when_found() {
1006 let mut config = crate::config::Config {
1007 project: crate::config::ProjectConfig {
1008 name: "test".into(),
1009 description: String::new(),
1010 default_branch: "main".into(),
1011 collaborators: vec![],
1012 },
1013 ticket: Default::default(),
1014 tickets: Default::default(),
1015 workflow: Default::default(),
1016 agents: Default::default(),
1017 worktrees: Default::default(),
1018 sync: Default::default(),
1019 logging: Default::default(),
1020 workers: make_workers("claude", None),
1021 work: Default::default(),
1022 server: Default::default(),
1023 git_host: Default::default(),
1024 worker_profiles: HashMap::new(),
1025 context: Default::default(),
1026 isolation: Default::default(),
1027 load_warnings: vec![],
1028 };
1029 let profile = make_profile(Some(".apm/spec.md"), Some("Spec-Writer for #<id>"));
1030 config.worker_profiles.insert("spec_agent".into(), profile);
1031
1032 let tr = make_transition(Some("spec_agent"));
1033 let mut w = Vec::new();
1034 assert!(resolve_profile(&tr, &config, &mut w).is_some());
1035 }
1036
1037 #[test]
1038 fn resolve_profile_returns_none_for_missing_profile() {
1039 let config = crate::config::Config {
1040 project: crate::config::ProjectConfig {
1041 name: "test".into(),
1042 description: String::new(),
1043 default_branch: "main".into(),
1044 collaborators: vec![],
1045 },
1046 ticket: Default::default(),
1047 tickets: Default::default(),
1048 workflow: Default::default(),
1049 agents: Default::default(),
1050 worktrees: Default::default(),
1051 sync: Default::default(),
1052 logging: Default::default(),
1053 workers: make_workers("claude", None),
1054 work: Default::default(),
1055 server: Default::default(),
1056 git_host: Default::default(),
1057 worker_profiles: HashMap::new(),
1058 context: Default::default(),
1059 isolation: Default::default(),
1060 load_warnings: vec![],
1061 };
1062 let tr = make_transition(Some("nonexistent_profile"));
1063 let mut w = Vec::new();
1064 assert!(resolve_profile(&tr, &config, &mut w).is_none());
1065 }
1066
1067 #[test]
1068 fn resolve_profile_returns_none_when_no_profile_on_transition() {
1069 let config = crate::config::Config {
1070 project: crate::config::ProjectConfig {
1071 name: "test".into(),
1072 description: String::new(),
1073 default_branch: "main".into(),
1074 collaborators: vec![],
1075 },
1076 ticket: Default::default(),
1077 tickets: Default::default(),
1078 workflow: Default::default(),
1079 agents: Default::default(),
1080 worktrees: Default::default(),
1081 sync: Default::default(),
1082 logging: Default::default(),
1083 workers: make_workers("claude", None),
1084 work: Default::default(),
1085 server: Default::default(),
1086 git_host: Default::default(),
1087 worker_profiles: HashMap::new(),
1088 context: Default::default(),
1089 isolation: Default::default(),
1090 load_warnings: vec![],
1091 };
1092 let tr = make_transition(None);
1093 let mut w = Vec::new();
1094 assert!(resolve_profile(&tr, &config, &mut w).is_none());
1095 }
1096
1097 #[test]
1100 fn effective_spawn_params_profile_command_overrides_global() {
1101 let workers = make_workers("claude", Some("sonnet"));
1102 let profile = WorkerProfileConfig {
1103 command: Some("my-claude".into()),
1104 ..Default::default()
1105 };
1106 let params = effective_spawn_params(None, Some(&profile), &workers);
1107 assert_eq!(params.command, "my-claude");
1108 }
1109
1110 #[test]
1111 fn effective_spawn_params_falls_back_to_global_command() {
1112 let workers = make_workers("claude", None);
1113 let params = effective_spawn_params(None, None, &workers);
1114 assert_eq!(params.command, "claude");
1115 }
1116
1117 #[test]
1118 fn effective_spawn_params_profile_model_overrides_global() {
1119 let workers = make_workers("claude", Some("sonnet"));
1120 let profile = WorkerProfileConfig {
1121 model: Some("opus".into()),
1122 ..Default::default()
1123 };
1124 let params = effective_spawn_params(None, Some(&profile), &workers);
1125 assert_eq!(params.model.as_deref(), Some("opus"));
1126 }
1127
1128 #[test]
1129 fn effective_spawn_params_falls_back_to_global_model() {
1130 let workers = make_workers("claude", Some("sonnet"));
1131 let params = effective_spawn_params(None, None, &workers);
1132 assert_eq!(params.model.as_deref(), Some("sonnet"));
1133 }
1134
1135 #[test]
1136 fn effective_spawn_params_profile_env_merged_over_global() {
1137 let mut workers = make_workers("claude", None);
1138 workers.env.insert("FOO".into(), "global".into());
1139 workers.env.insert("BAR".into(), "bar".into());
1140
1141 let mut profile_env = HashMap::new();
1142 profile_env.insert("FOO".into(), "profile".into());
1143 let profile = WorkerProfileConfig {
1144 env: profile_env,
1145 ..Default::default()
1146 };
1147 let params = effective_spawn_params(None, Some(&profile), &workers);
1148 assert_eq!(params.env.get("FOO").map(|s| s.as_str()), Some("profile"));
1149 assert_eq!(params.env.get("BAR").map(|s| s.as_str()), Some("bar"));
1150 }
1151
1152 #[test]
1153 fn effective_spawn_params_profile_container_overrides_global() {
1154 let mut workers = make_workers("claude", None);
1155 workers.container = Some("global-image".into());
1156 let profile = WorkerProfileConfig {
1157 container: Some("profile-image".into()),
1158 ..Default::default()
1159 };
1160 let params = effective_spawn_params(None, Some(&profile), &workers);
1161 assert_eq!(params.container.as_deref(), Some("profile-image"));
1162 }
1163
1164 #[test]
1165 fn transition_agent_takes_precedence_over_profile() {
1166 let workers = WorkersConfig::default();
1167 let profile = WorkerProfileConfig { agent: Some("other".into()), ..Default::default() };
1168 let params = effective_spawn_params(Some("custom"), Some(&profile), &workers);
1169 assert_eq!(params.agent, "custom");
1170 }
1171
1172 #[test]
1173 fn effective_agent_defaults_to_claude() {
1174 let workers = WorkersConfig::default();
1175 let params = effective_spawn_params(None, None, &workers);
1176 assert_eq!(params.agent, "claude");
1177 }
1178
1179 #[test]
1182 fn resolve_system_prompt_uses_profile_instructions() {
1183 let dir = tempfile::tempdir().unwrap();
1184 let p = dir.path();
1185 std::fs::create_dir_all(p.join(".apm")).unwrap();
1186 std::fs::write(p.join(".apm/spec.md"), "SPEC WRITER").unwrap();
1187 let profile = make_profile(Some(".apm/spec.md"), None);
1188 let workers = WorkersConfig::default();
1189 assert_eq!(
1190 resolve_system_prompt(p, None, Some(&profile), &workers, "claude", "worker").unwrap(),
1191 "SPEC WRITER"
1192 );
1193 }
1194
1195 #[test]
1196 fn resolve_system_prompt_uses_workers_instructions_when_no_profile() {
1197 let dir = tempfile::tempdir().unwrap();
1198 let p = dir.path();
1199 std::fs::create_dir_all(p.join(".apm")).unwrap();
1200 std::fs::write(p.join(".apm/global.md"), "GLOBAL INSTRUCTIONS").unwrap();
1201 let workers = WorkersConfig {
1202 instructions: Some(".apm/global.md".to_string()),
1203 ..WorkersConfig::default()
1204 };
1205 assert_eq!(
1206 resolve_system_prompt(p, None, None, &workers, "claude", "worker").unwrap(),
1207 "GLOBAL INSTRUCTIONS"
1208 );
1209 }
1210
1211 #[test]
1212 fn resolve_system_prompt_uses_per_agent_file() {
1213 let dir = tempfile::tempdir().unwrap();
1214 let p = dir.path();
1215 std::fs::create_dir_all(p.join(".apm/agents/claude")).unwrap();
1216 std::fs::write(p.join(".apm/agents/claude/apm.worker.md"), "PER AGENT WORKER").unwrap();
1217 let workers = WorkersConfig::default();
1218 assert_eq!(
1219 resolve_system_prompt(p, None, None, &workers, "claude", "worker").unwrap(),
1220 "PER AGENT WORKER"
1221 );
1222 }
1223
1224 #[test]
1225 fn resolve_system_prompt_falls_back_to_builtin_default() {
1226 let dir = tempfile::tempdir().unwrap();
1227 let p = dir.path();
1228 let workers = WorkersConfig::default();
1229 let result = resolve_system_prompt(p, None, None, &workers, "claude", "worker").unwrap();
1230 assert_eq!(result, super::CLAUDE_WORKER_DEFAULT);
1231 }
1232
1233 #[test]
1234 fn resolve_system_prompt_falls_back_to_builtin_spec_writer() {
1235 let dir = tempfile::tempdir().unwrap();
1236 let p = dir.path();
1237 let workers = WorkersConfig::default();
1238 let result = resolve_system_prompt(p, None, None, &workers, "claude", "spec-writer").unwrap();
1239 assert_eq!(result, super::CLAUDE_SPEC_WRITER_DEFAULT);
1240 }
1241
1242 #[test]
1243 fn resolve_system_prompt_errors_for_unknown_agent() {
1244 let dir = tempfile::tempdir().unwrap();
1245 let p = dir.path();
1246 let workers = WorkersConfig::default();
1247 let result = resolve_system_prompt(p, None, None, &workers, "custom-bot", "worker");
1248 assert!(result.is_err());
1249 let msg = result.unwrap_err().to_string();
1250 assert!(msg.contains("custom-bot"), "error should name the agent: {msg}");
1251 assert!(msg.contains("worker"), "error should name the role: {msg}");
1252 }
1253
1254 #[test]
1255 fn resolve_system_prompt_profile_instructions_missing_file_is_error() {
1256 let dir = tempfile::tempdir().unwrap();
1257 let p = dir.path();
1258 let profile = make_profile(Some(".apm/nonexistent.md"), None);
1259 let workers = WorkersConfig::default();
1260 let result = resolve_system_prompt(p, None, Some(&profile), &workers, "claude", "worker");
1261 assert!(result.is_err());
1262 let msg = result.unwrap_err().to_string();
1263 assert!(msg.contains("nonexistent.md"), "error should name the file: {msg}");
1264 }
1265
1266 #[test]
1267 fn resolve_system_prompt_backward_compat() {
1268 let dir = tempfile::tempdir().unwrap();
1269 let p = dir.path();
1270 std::fs::create_dir_all(p.join(".apm")).unwrap();
1271 std::fs::write(p.join(".apm/apm.worker.md"), "LEGACY WORKER CONTENT").unwrap();
1272 let profile = make_profile(Some(".apm/apm.worker.md"), None);
1273 let workers = WorkersConfig::default();
1274 assert_eq!(
1275 resolve_system_prompt(p, None, Some(&profile), &workers, "claude", "worker").unwrap(),
1276 "LEGACY WORKER CONTENT"
1277 );
1278 }
1279
1280 #[test]
1283 fn agent_role_prefix_uses_profile_role_prefix() {
1284 let profile = make_profile(None, Some("You are a Spec-Writer agent assigned to ticket #<id>."));
1285 assert_eq!(
1286 agent_role_prefix(None, Some(&profile), "abc123"),
1287 "You are a Spec-Writer agent assigned to ticket #abc123."
1288 );
1289 }
1290
1291 #[test]
1292 fn agent_role_prefix_falls_back_to_worker_default() {
1293 assert_eq!(
1294 agent_role_prefix(None, None, "abc123"),
1295 "You are a Worker agent assigned to ticket #abc123."
1296 );
1297 }
1298
1299 #[test]
1300 fn agent_role_prefix_transition_takes_precedence_over_profile() {
1301 let profile = make_profile(None, Some("You are a Spec-Writer agent assigned to ticket #<id>."));
1302 assert_eq!(
1303 agent_role_prefix(Some("You are a Custom agent for ticket #<id>."), Some(&profile), "abc123"),
1304 "You are a Custom agent for ticket #abc123."
1305 );
1306 }
1307
1308 #[test]
1311 fn transition_instructions_takes_precedence_over_profile() {
1312 let dir = tempfile::tempdir().unwrap();
1313 let p = dir.path();
1314 std::fs::create_dir_all(p.join(".apm")).unwrap();
1315 std::fs::write(p.join(".apm/transition.md"), "TRANSITION CONTENT").unwrap();
1316 std::fs::write(p.join(".apm/profile.md"), "PROFILE CONTENT").unwrap();
1317 let profile = make_profile(Some(".apm/profile.md"), None);
1318 let workers = WorkersConfig::default();
1319 assert_eq!(
1320 resolve_system_prompt(p, Some(".apm/transition.md"), Some(&profile), &workers, "claude", "worker").unwrap(),
1321 "TRANSITION CONTENT"
1322 );
1323 }
1324
1325 #[test]
1326 fn transition_instructions_no_profile_required() {
1327 let dir = tempfile::tempdir().unwrap();
1328 let p = dir.path();
1329 std::fs::create_dir_all(p.join(".apm")).unwrap();
1330 std::fs::write(p.join(".apm/transition.md"), "TRANSITION ONLY").unwrap();
1331 let workers = WorkersConfig::default();
1332 assert_eq!(
1333 resolve_system_prompt(p, Some(".apm/transition.md"), None, &workers, "claude", "worker").unwrap(),
1334 "TRANSITION ONLY"
1335 );
1336 }
1337
1338 #[test]
1339 fn epic_filter_keeps_only_matching_tickets() {
1340 use crate::ticket::Ticket;
1341 use std::path::Path;
1342
1343 let make_ticket = |id: &str, epic: Option<&str>| {
1344 let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
1345 let raw = format!(
1346 "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}+++\n"
1347 );
1348 Ticket::parse(Path::new("tickets/dummy.md"), &raw).unwrap()
1349 };
1350
1351 let all_tickets = vec![
1352 make_ticket("aaa", Some("epic1")),
1353 make_ticket("bbb", Some("epic2")),
1354 make_ticket("ccc", None),
1355 ];
1356
1357 let epic_id = "epic1";
1358 let filtered: Vec<Ticket> = all_tickets.into_iter()
1359 .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
1360 .collect();
1361
1362 assert_eq!(filtered.len(), 1);
1363 assert_eq!(filtered[0].frontmatter.id, "aaa");
1364 }
1365
1366 #[test]
1367 fn no_epic_filter_keeps_all_tickets() {
1368 use crate::ticket::Ticket;
1369 use std::path::Path;
1370
1371 let make_ticket = |id: &str, epic: Option<&str>| {
1372 let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
1373 let raw = format!(
1374 "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}+++\n"
1375 );
1376 Ticket::parse(Path::new("tickets/dummy.md"), &raw).unwrap()
1377 };
1378
1379 let all_tickets: Vec<Ticket> = vec![
1380 make_ticket("aaa", Some("epic1")),
1381 make_ticket("bbb", Some("epic2")),
1382 make_ticket("ccc", None),
1383 ];
1384
1385 let count = all_tickets.len();
1386 let epic_filter: Option<&str> = None;
1387 let filtered: Vec<Ticket> = match epic_filter {
1388 Some(eid) => all_tickets.into_iter()
1389 .filter(|t| t.frontmatter.epic.as_deref() == Some(eid))
1390 .collect(),
1391 None => all_tickets,
1392 };
1393 assert_eq!(filtered.len(), count);
1394 }
1395
1396 #[test]
1399 fn spawn_worker_cwd_is_ticket_worktree() {
1400 use std::os::unix::fs::PermissionsExt;
1401
1402 let wt = tempfile::tempdir().unwrap();
1403 let log_dir = tempfile::tempdir().unwrap();
1404 let mock_dir = tempfile::tempdir().unwrap();
1405
1406 let mock_claude = mock_dir.path().join("claude");
1408 let cwd_file = wt.path().join("cwd-output.txt");
1409 let script = format!(concat!(
1410 "#!/bin/sh\n",
1411 "pwd > \"{}\"\n",
1412 ), cwd_file.display());
1413 std::fs::write(&mock_claude, &script).unwrap();
1414 std::fs::set_permissions(&mock_claude, std::fs::Permissions::from_mode(0o755)).unwrap();
1415
1416 let sys_file = crate::wrapper::write_temp_file("sys", "system").unwrap();
1417 let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap();
1418
1419 let mut extra_env = HashMap::new();
1420 extra_env.insert(
1421 "PATH".to_string(),
1422 format!("{}:{}", mock_dir.path().display(), std::env::var("PATH").unwrap_or_default()),
1423 );
1424
1425 let ctx = crate::wrapper::WrapperContext {
1426 worker_name: "test-worker".to_string(),
1427 ticket_id: "test-id".to_string(),
1428 ticket_branch: "ticket/test-id".to_string(),
1429 worktree_path: wt.path().to_path_buf(),
1430 system_prompt_file: sys_file.clone(),
1431 user_message_file: msg_file.clone(),
1432 skip_permissions: false,
1433 profile: "default".to_string(),
1434 role_prefix: None,
1435 options: HashMap::new(),
1436 model: None,
1437 log_path: log_dir.path().join("worker.log"),
1438 container: None,
1439 extra_env,
1440 root: wt.path().to_path_buf(),
1441 keychain: HashMap::new(),
1442 current_state: "in_progress".to_string(),
1443 command: None,
1444 };
1445
1446 let wrapper = crate::wrapper::resolve_builtin("claude").unwrap();
1447 let mut child = wrapper.spawn(&ctx).unwrap();
1448 child.wait().unwrap();
1449 let _ = std::fs::remove_file(&sys_file);
1450 let _ = std::fs::remove_file(&msg_file);
1451
1452 let cwd_out = std::fs::read_to_string(&cwd_file)
1453 .expect("cwd-output.txt not written — mock claude did not run in expected cwd");
1454 let expected = wt.path().canonicalize().unwrap();
1455 assert_eq!(
1456 cwd_out.trim(),
1457 expected.to_str().unwrap(),
1458 "spawned worker CWD must equal the ticket worktree path"
1459 );
1460 }
1461
1462 #[test]
1465 fn check_output_format_supported_passes_when_flag_present() {
1466 use std::os::unix::fs::PermissionsExt;
1467 let dir = tempfile::tempdir().unwrap();
1468 let bin = dir.path().join("fake-claude");
1469 std::fs::write(&bin, "#!/bin/sh\necho '--output-format stream-json'\n").unwrap();
1470 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1471 assert!(check_output_format_supported(bin.to_str().unwrap()).is_ok());
1472 }
1473
1474 #[test]
1475 fn check_output_format_supported_errors_when_flag_absent() {
1476 use std::os::unix::fs::PermissionsExt;
1477 let dir = tempfile::tempdir().unwrap();
1478 let bin = dir.path().join("old-claude");
1479 std::fs::write(&bin, "#!/bin/sh\necho 'Usage: old-claude [options]'\n").unwrap();
1480 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1481 let err = check_output_format_supported(bin.to_str().unwrap()).unwrap_err();
1482 let msg = err.to_string();
1483 assert!(
1484 msg.contains("--output-format"),
1485 "error message must name the missing flag: {msg}"
1486 );
1487 assert!(
1488 msg.contains(bin.to_str().unwrap()),
1489 "error message must include binary path: {msg}"
1490 );
1491 }
1492
1493 #[test]
1496 fn claude_wrapper_sets_apm_env_vars() {
1497 use std::os::unix::fs::PermissionsExt;
1498
1499 let wt = tempfile::tempdir().unwrap();
1500 let log_dir = tempfile::tempdir().unwrap();
1501 let mock_dir = tempfile::tempdir().unwrap();
1502 let env_output = wt.path().join("env-output.txt");
1503
1504 let mock_claude = mock_dir.path().join("claude");
1506 let script = format!(
1507 "#!/bin/sh\nprintenv > \"{}\"\n",
1508 env_output.display()
1509 );
1510 std::fs::write(&mock_claude, &script).unwrap();
1511 std::fs::set_permissions(&mock_claude, std::fs::Permissions::from_mode(0o755)).unwrap();
1512
1513 let sys_file = crate::wrapper::write_temp_file("sys", "system prompt").unwrap();
1514 let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap();
1515
1516 let mut extra_env = HashMap::new();
1517 extra_env.insert(
1518 "PATH".to_string(),
1519 format!("{}:{}", mock_dir.path().display(), std::env::var("PATH").unwrap_or_default()),
1520 );
1521
1522 let ctx = crate::wrapper::WrapperContext {
1523 worker_name: "test-worker".to_string(),
1524 ticket_id: "abc123".to_string(),
1525 ticket_branch: "ticket/abc123-some-feature".to_string(),
1526 worktree_path: wt.path().to_path_buf(),
1527 system_prompt_file: sys_file.clone(),
1528 user_message_file: msg_file.clone(),
1529 skip_permissions: false,
1530 profile: "my-profile".to_string(),
1531 role_prefix: None,
1532 options: HashMap::new(),
1533 model: None,
1534 log_path: log_dir.path().join("worker.log"),
1535 container: None,
1536 extra_env,
1537 root: wt.path().to_path_buf(),
1538 keychain: HashMap::new(),
1539 current_state: "in_progress".to_string(),
1540 command: None,
1541 };
1542
1543 let wrapper = crate::wrapper::resolve_builtin("claude").unwrap();
1544 let mut child = wrapper.spawn(&ctx).unwrap();
1545 child.wait().unwrap();
1546 let _ = std::fs::remove_file(&sys_file);
1547 let _ = std::fs::remove_file(&msg_file);
1548
1549 let env_content = std::fs::read_to_string(&env_output)
1550 .expect("env-output.txt not written — mock claude did not run");
1551
1552 assert!(env_content.contains("APM_AGENT_NAME=test-worker"), "missing APM_AGENT_NAME\n{env_content}");
1553 assert!(env_content.contains("APM_TICKET_ID=abc123"), "missing APM_TICKET_ID\n{env_content}");
1554 assert!(env_content.contains("APM_TICKET_BRANCH=ticket/abc123-some-feature"), "missing APM_TICKET_BRANCH\n{env_content}");
1555 assert!(env_content.contains("APM_TICKET_WORKTREE="), "missing APM_TICKET_WORKTREE\n{env_content}");
1556 assert!(env_content.contains("APM_SYSTEM_PROMPT_FILE="), "missing APM_SYSTEM_PROMPT_FILE\n{env_content}");
1557 assert!(env_content.contains("APM_USER_MESSAGE_FILE="), "missing APM_USER_MESSAGE_FILE\n{env_content}");
1558 assert!(env_content.contains("APM_SKIP_PERMISSIONS=0"), "missing APM_SKIP_PERMISSIONS\n{env_content}");
1559 assert!(env_content.contains("APM_PROFILE=my-profile"), "missing APM_PROFILE\n{env_content}");
1560 assert!(env_content.contains("APM_WRAPPER_VERSION=1"), "missing APM_WRAPPER_VERSION\n{env_content}");
1561 assert!(env_content.contains("APM_BIN="), "missing APM_BIN\n{env_content}");
1562
1563 if let Some(line) = env_content.lines().find(|l| l.starts_with("APM_BIN=")) {
1565 let path = line.trim_start_matches("APM_BIN=");
1566 assert!(std::path::Path::new(path).exists(), "APM_BIN path does not exist: {path}");
1567 }
1568 }
1569
1570 #[test]
1573 fn temp_files_removed_after_child_exits() {
1574 use std::os::unix::fs::PermissionsExt;
1575
1576 let wt = tempfile::tempdir().unwrap();
1577 let log_dir = tempfile::tempdir().unwrap();
1578 let mock_dir = tempfile::tempdir().unwrap();
1579
1580 let mock_claude = mock_dir.path().join("claude");
1582 std::fs::write(&mock_claude, "#!/bin/sh\nexit 0\n").unwrap();
1583 std::fs::set_permissions(&mock_claude, std::fs::Permissions::from_mode(0o755)).unwrap();
1584
1585 let sys_file = crate::wrapper::write_temp_file("sys", "system").unwrap();
1586 let msg_file = crate::wrapper::write_temp_file("msg", "message").unwrap();
1587
1588 assert!(sys_file.exists(), "sys_file should exist before spawn");
1589 assert!(msg_file.exists(), "msg_file should exist before spawn");
1590
1591 let mut extra_env = HashMap::new();
1592 extra_env.insert(
1593 "PATH".to_string(),
1594 format!("{}:{}", mock_dir.path().display(), std::env::var("PATH").unwrap_or_default()),
1595 );
1596
1597 let ctx = crate::wrapper::WrapperContext {
1598 worker_name: "test".to_string(),
1599 ticket_id: "test123".to_string(),
1600 ticket_branch: "ticket/test123".to_string(),
1601 worktree_path: wt.path().to_path_buf(),
1602 system_prompt_file: sys_file.clone(),
1603 user_message_file: msg_file.clone(),
1604 skip_permissions: false,
1605 profile: "default".to_string(),
1606 role_prefix: None,
1607 options: HashMap::new(),
1608 model: None,
1609 log_path: log_dir.path().join("worker.log"),
1610 container: None,
1611 extra_env,
1612 root: wt.path().to_path_buf(),
1613 keychain: HashMap::new(),
1614 current_state: "in_progress".to_string(),
1615 command: None,
1616 };
1617
1618 let wrapper = crate::wrapper::resolve_builtin("claude").unwrap();
1619 let child = wrapper.spawn(&ctx).unwrap();
1620
1621 let mut managed = ManagedChild {
1622 inner: child,
1623 temp_files: vec![sys_file.clone(), msg_file.clone()],
1624 denial_ctx: None,
1625 };
1626 managed.inner.wait().unwrap();
1627 drop(managed);
1628
1629 assert!(!sys_file.exists(), "sys_file should be removed after ManagedChild is dropped");
1630 assert!(!msg_file.exists(), "msg_file should be removed after ManagedChild is dropped");
1631 }
1632
1633 #[test]
1636 fn resolution_agent_profile_overrides_global() {
1637 let workers = WorkersConfig { agent: Some("codex".into()), ..Default::default() };
1638 let profile = WorkerProfileConfig { agent: Some("mock-happy".into()), ..Default::default() };
1639 let params = effective_spawn_params(None, Some(&profile), &workers);
1640 assert_eq!(params.agent, "mock-happy");
1641 }
1642
1643 #[test]
1644 fn resolution_agent_falls_back_to_claude() {
1645 let params = effective_spawn_params(None, None, &WorkersConfig::default());
1646 assert_eq!(params.agent, "claude");
1647 }
1648
1649 #[test]
1650 fn resolution_options_merge() {
1651 let mut workers = WorkersConfig { agent: Some("claude".into()), ..Default::default() };
1652 workers.options.insert("model".into(), "opus".into());
1653 workers.options.insert("timeout".into(), "30".into());
1654 let mut profile_opts = HashMap::new();
1655 profile_opts.insert("model".into(), "sonnet".into());
1656 let profile = WorkerProfileConfig { options: profile_opts, ..Default::default() };
1657 let params = effective_spawn_params(None, Some(&profile), &workers);
1658 assert_eq!(params.options.get("model").map(|s| s.as_str()), Some("sonnet"), "profile model should override workers model");
1659 assert_eq!(params.options.get("timeout").map(|s| s.as_str()), Some("30"), "non-overlapping key should survive");
1660 }
1661
1662 #[test]
1663 fn deprecation_warning_writes_to_stream_once() {
1664 let _guard = DEPRECATION_TEST_LOCK.lock().unwrap();
1665 DEPRECATION_WARNED.store(false, std::sync::atomic::Ordering::SeqCst);
1666
1667 let mut buf: Vec<u8> = Vec::new();
1670 emit_deprecation_warning_to(&mut buf);
1671 emit_deprecation_warning_to(&mut buf);
1672
1673 let captured = String::from_utf8(buf).unwrap();
1674 let count = captured.matches(DEPRECATION_MSG).count();
1675 assert_eq!(count, 1, "deprecated message should appear exactly once on the writer, found {count}\n{captured}");
1676 }
1677
1678 #[test]
1679 fn deprecation_warning_triggered_by_legacy_workers_config() {
1680 let _guard = DEPRECATION_TEST_LOCK.lock().unwrap();
1681 DEPRECATION_WARNED.store(false, std::sync::atomic::Ordering::SeqCst);
1682
1683 let workers = WorkersConfig { command: Some("claude".into()), ..Default::default() };
1684 effective_spawn_params(None, None, &workers);
1685
1686 assert!(
1687 DEPRECATION_WARNED.load(std::sync::atomic::Ordering::SeqCst),
1688 "legacy [workers].command must trigger the deprecation warning"
1689 );
1690 }
1691
1692 #[test]
1693 fn legacy_model_forwarded_to_ctx() {
1694 let workers = WorkersConfig { model: Some("opus".into()), ..Default::default() };
1695 let params = effective_spawn_params(None, None, &workers);
1696 assert_eq!(params.model.as_deref(), Some("opus"));
1697 }
1698
1699 #[test]
1700 fn options_model_takes_precedence_over_legacy() {
1701 let mut workers = WorkersConfig { model: Some("opus".into()), agent: Some("claude".into()), ..Default::default() };
1702 workers.options.insert("model".into(), "sonnet".into());
1703 let params = effective_spawn_params(None, None, &workers);
1704 assert_eq!(params.model.as_deref(), Some("sonnet"));
1705 }
1706
1707 #[test]
1710 fn apm_opt_env_vars_set() {
1711 use std::os::unix::fs::PermissionsExt;
1712
1713 let wt = tempfile::tempdir().unwrap();
1714 let log_dir = tempfile::tempdir().unwrap();
1715 let mock_dir = tempfile::tempdir().unwrap();
1716 let env_output = wt.path().join("env-output.txt");
1717
1718 let mock_claude = mock_dir.path().join("claude");
1719 let script = format!("#!/bin/sh\nprintenv > \"{}\"\n", env_output.display());
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 mut options = HashMap::new();
1733 options.insert("model".to_string(), "sonnet".to_string());
1734
1735 let ctx = crate::wrapper::WrapperContext {
1736 worker_name: "test-worker".to_string(),
1737 ticket_id: "abc123".to_string(),
1738 ticket_branch: "ticket/abc123".to_string(),
1739 worktree_path: wt.path().to_path_buf(),
1740 system_prompt_file: sys_file.clone(),
1741 user_message_file: msg_file.clone(),
1742 skip_permissions: false,
1743 profile: "default".to_string(),
1744 role_prefix: None,
1745 options,
1746 model: None,
1747 log_path: log_dir.path().join("worker.log"),
1748 container: None,
1749 extra_env,
1750 root: wt.path().to_path_buf(),
1751 keychain: HashMap::new(),
1752 current_state: "in_progress".to_string(),
1753 command: None,
1754 };
1755
1756 let wrapper = crate::wrapper::resolve_builtin("claude").unwrap();
1757 let mut child = wrapper.spawn(&ctx).unwrap();
1758 child.wait().unwrap();
1759 let _ = std::fs::remove_file(&sys_file);
1760 let _ = std::fs::remove_file(&msg_file);
1761
1762 let env_content = std::fs::read_to_string(&env_output)
1763 .expect("env-output.txt not written");
1764
1765 assert!(env_content.contains("APM_OPT_MODEL=sonnet"), "APM_OPT_MODEL=sonnet must be set\n{env_content}");
1766 }
1767
1768 fn make_frontmatter_with_agent(agent: Option<&str>, overrides: &[(&str, &str)]) -> crate::ticket_fmt::Frontmatter {
1771 let agent_line = agent.map(|a| format!("agent = \"{a}\"\n")).unwrap_or_default();
1772 let overrides_section = if overrides.is_empty() {
1773 String::new()
1774 } else {
1775 let pairs: Vec<String> = overrides.iter()
1776 .map(|(k, v)| format!("{k} = \"{v}\""))
1777 .collect();
1778 format!("[agent_overrides]\n{}\n", pairs.join("\n"))
1779 };
1780 let toml_str = format!("id = \"t\"\ntitle = \"T\"\nstate = \"new\"\n{agent_line}{overrides_section}");
1781 toml::from_str(&toml_str).unwrap()
1782 }
1783
1784 #[test]
1785 fn apply_fm_profile_override_wins() {
1786 let fm = make_frontmatter_with_agent(Some("mock-sad"), &[("impl_agent", "mock-happy")]);
1787 let mut agent = "claude".to_string();
1788 apply_frontmatter_agent(&mut agent, &fm, "impl_agent");
1789 assert_eq!(agent, "mock-happy");
1790 }
1791
1792 #[test]
1793 fn apply_fm_agent_field_wins_when_no_profile_match() {
1794 let fm = make_frontmatter_with_agent(Some("mock-sad"), &[]);
1795 let mut agent = "claude".to_string();
1796 apply_frontmatter_agent(&mut agent, &fm, "impl_agent");
1797 assert_eq!(agent, "mock-sad");
1798 }
1799
1800 #[test]
1801 fn apply_fm_profile_override_beats_agent_field() {
1802 let fm = make_frontmatter_with_agent(Some("mock-random"), &[("impl_agent", "claude")]);
1803 let mut agent = "other".to_string();
1804 apply_frontmatter_agent(&mut agent, &fm, "impl_agent");
1805 assert_eq!(agent, "claude");
1806 }
1807
1808 #[test]
1809 fn apply_fm_no_fields_unchanged() {
1810 let fm = make_frontmatter_with_agent(None, &[]);
1811 let mut agent = "claude".to_string();
1812 apply_frontmatter_agent(&mut agent, &fm, "impl_agent");
1813 assert_eq!(agent, "claude");
1814 }
1815
1816 fn find_apm_bin() -> Option<String> {
1819 if let Ok(v) = std::env::var("APM_BIN") {
1821 if !v.is_empty() && std::path::Path::new(&v).exists() {
1822 return Some(v);
1823 }
1824 }
1825 if let Ok(exe) = std::env::current_exe() {
1830 if let Some(target_dir) = exe.parent().and_then(|p| p.parent()) {
1831 let candidate = target_dir.join("apm");
1832 if candidate.is_file() {
1833 return Some(candidate.to_string_lossy().into_owned());
1834 }
1835 }
1836 }
1837 None
1838 }
1839
1840 fn make_mock_project(root: &std::path::Path, ticket_state: &str, ticket_id: &str) {
1841 use std::fs;
1842
1843 fs::create_dir_all(root.join(".apm/agents/claude")).unwrap();
1844 fs::create_dir_all(root.join("tickets")).unwrap();
1845
1846 fs::write(root.join(".apm/config.toml"), r#"
1847[project]
1848name = "test-project"
1849default_branch = "main"
1850
1851[workers]
1852agent = "mock-happy"
1853
1854[tickets]
1855dir = "tickets"
1856"#).unwrap();
1857
1858 fs::write(root.join(".apm/workflow.toml"), r#"
1859[[workflow.states]]
1860id = "in_design"
1861label = "In Design"
1862actionable = ["agent"]
1863instructions = ".apm/apm.spec-writer.md"
1864
1865 [[workflow.states.transitions]]
1866 to = "specd"
1867 trigger = "manual"
1868 outcome = "success"
1869
1870 [[workflow.states.transitions]]
1871 to = "closed"
1872 trigger = "manual"
1873 outcome = "cancelled"
1874
1875[[workflow.states]]
1876id = "specd"
1877label = "Specd"
1878actionable = ["supervisor"]
1879satisfies_deps = true
1880worker_end = true
1881
1882 [[workflow.states.transitions]]
1883 to = "in_progress"
1884 trigger = "manual"
1885 outcome = "success"
1886
1887 [[workflow.states.transitions]]
1888 to = "closed"
1889 trigger = "manual"
1890 outcome = "cancelled"
1891
1892[[workflow.states]]
1893id = "in_progress"
1894label = "In Progress"
1895instructions = ".apm/apm.worker.md"
1896
1897 [[workflow.states.transitions]]
1898 to = "implemented"
1899 trigger = "manual"
1900 outcome = "success"
1901
1902 [[workflow.states.transitions]]
1903 to = "closed"
1904 trigger = "manual"
1905 outcome = "cancelled"
1906
1907[[workflow.states]]
1908id = "implemented"
1909label = "Implemented"
1910actionable = ["supervisor"]
1911satisfies_deps = true
1912worker_end = true
1913terminal = false
1914
1915 [[workflow.states.transitions]]
1916 to = "closed"
1917 trigger = "manual"
1918 outcome = "cancelled"
1919
1920[[workflow.states]]
1921id = "closed"
1922label = "Closed"
1923terminal = true
1924"#).unwrap();
1925
1926 fs::write(root.join(".apm/apm.worker.md"), "Worker instructions.").unwrap();
1927 fs::write(root.join(".apm/apm.spec-writer.md"), "Spec writer instructions.").unwrap();
1928
1929 let ticket_content = format!(r#"+++
1930id = "{ticket_id}"
1931title = "Test Ticket"
1932state = "{ticket_state}"
1933priority = 0
1934effort = 5
1935risk = 3
1936author = "test"
1937owner = "test"
1938branch = "ticket/{ticket_id}-test"
1939created_at = "2026-01-01T00:00:00Z"
1940updated_at = "2026-01-01T00:00:00Z"
1941+++
1942
1943## Spec
1944
1945### Problem
1946
1947Original problem.
1948
1949### Acceptance criteria
1950
1951- [ ] Some criterion
1952
1953### Out of scope
1954
1955Nothing.
1956
1957### Approach
1958
1959Some approach.
1960
1961### Open questions
1962
1963### Amendment requests
1964
1965### Code review
1966
1967## History
1968
1969| When | From | To | By |
1970|------|------|----|----|
1971"#);
1972 fs::write(root.join(format!("tickets/{ticket_id}-test.md")), ticket_content).unwrap();
1973
1974 std::process::Command::new("git")
1975 .arg("init")
1976 .current_dir(root)
1977 .output()
1978 .unwrap();
1979 std::process::Command::new("git")
1980 .args(["config", "user.email", "test@test.com"])
1981 .current_dir(root)
1982 .output()
1983 .unwrap();
1984 std::process::Command::new("git")
1985 .args(["config", "user.name", "Test"])
1986 .current_dir(root)
1987 .output()
1988 .unwrap();
1989 std::process::Command::new("git")
1991 .args(["add", ".apm"])
1992 .current_dir(root)
1993 .output()
1994 .unwrap();
1995 std::process::Command::new("git")
1996 .args(["commit", "-m", "initial commit", "--allow-empty"])
1997 .current_dir(root)
1998 .output()
1999 .unwrap();
2000 let branch_name = format!("ticket/{ticket_id}-test");
2002 std::process::Command::new("git")
2003 .args(["checkout", "-b", &branch_name])
2004 .current_dir(root)
2005 .output()
2006 .unwrap();
2007 std::process::Command::new("git")
2008 .args(["add", &format!("tickets/{ticket_id}-test.md")])
2009 .current_dir(root)
2010 .output()
2011 .unwrap();
2012 std::process::Command::new("git")
2013 .args(["commit", "-m", &format!("ticket({ticket_id}): created")])
2014 .current_dir(root)
2015 .output()
2016 .unwrap();
2017 std::process::Command::new("git")
2019 .args(["checkout", "main"])
2020 .current_dir(root)
2021 .output()
2022 .unwrap();
2023 }
2024
2025 fn make_wrapper_ctx_for_mock(
2026 project_root: &std::path::Path,
2027 ticket_id: &str,
2028 ticket_state: &str,
2029 apm_bin: &str,
2030 log_path: std::path::PathBuf,
2031 ) -> crate::wrapper::WrapperContext {
2032 let sys_file = crate::wrapper::write_temp_file("sys", "system prompt").unwrap();
2033 let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap();
2034 let mut options = HashMap::new();
2035 options.insert("apm_bin".to_string(), apm_bin.to_string());
2036 crate::wrapper::WrapperContext {
2037 worker_name: "test-worker".to_string(),
2038 ticket_id: ticket_id.to_string(),
2039 ticket_branch: format!("ticket/{ticket_id}-test"),
2040 worktree_path: project_root.to_path_buf(),
2041 system_prompt_file: sys_file,
2042 user_message_file: msg_file,
2043 skip_permissions: false,
2044 profile: "default".to_string(),
2045 role_prefix: None,
2046 options,
2047 model: None,
2048 log_path,
2049 container: None,
2050 extra_env: HashMap::new(),
2051 root: project_root.to_path_buf(),
2052 keychain: HashMap::new(),
2053 current_state: ticket_state.to_string(),
2054 command: None,
2055 }
2056 }
2057
2058 #[test]
2059 fn mock_happy_spec_mode_transitions_to_specd() {
2060 let apm_bin = match find_apm_bin() { Some(b) => b, None => return };
2061 let dir = tempfile::tempdir().unwrap();
2062 let root = dir.path();
2063 make_mock_project(root, "in_design", "aaaa0001");
2064 let log_path = root.join("test-worker.log");
2065 let ctx = make_wrapper_ctx_for_mock(root, "aaaa0001", "in_design", &apm_bin, log_path.clone());
2066 let wrapper = crate::wrapper::resolve_builtin("mock-happy").unwrap();
2067 let mut child = wrapper.spawn(&ctx).unwrap();
2068 child.wait().unwrap();
2069
2070 let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
2071 let ticket_from_branch = {
2073 let out = std::process::Command::new("git")
2074 .args(["show", "ticket/aaaa0001-test:tickets/aaaa0001-test.md"])
2075 .current_dir(root)
2076 .output()
2077 .unwrap();
2078 String::from_utf8_lossy(&out.stdout).to_string()
2079 };
2080 assert!(ticket_from_branch.contains("state = \"specd\""),
2081 "ticket should be in specd state\nticket_from_branch: {ticket_from_branch}\nlog: {log_content}");
2082 assert!(ticket_from_branch.contains("### Problem"),
2083 "ticket should have Problem section\n{ticket_from_branch}");
2084 assert!(ticket_from_branch.contains("effort = 1"),
2085 "effort should be 1\n{ticket_from_branch}");
2086 assert!(ticket_from_branch.contains("risk = 1"),
2087 "risk should be 1\n{ticket_from_branch}");
2088 }
2089
2090 #[test]
2091 fn mock_happy_zero_success_transitions_returns_err() {
2092 use std::fs;
2093 let dir = tempfile::tempdir().unwrap();
2094 let root = dir.path();
2095
2096 fs::create_dir_all(root.join(".apm/agents/claude")).unwrap();
2097 fs::create_dir_all(root.join("tickets")).unwrap();
2098 fs::write(root.join(".apm/config.toml"), r#"
2099[project]
2100name = "test"
2101default_branch = "main"
2102[workers]
2103agent = "mock-happy"
2104[tickets]
2105dir = "tickets"
2106"#).unwrap();
2107 fs::write(root.join(".apm/workflow.toml"), r#"
2108[[workflow.states]]
2109id = "in_design"
2110label = "In Design"
2111actionable = ["agent"]
2112
2113 [[workflow.states.transitions]]
2114 to = "closed"
2115 trigger = "manual"
2116 outcome = "needs_input"
2117
2118[[workflow.states]]
2119id = "closed"
2120label = "Closed"
2121terminal = true
2122"#).unwrap();
2123 fs::write(root.join(".apm/apm.worker.md"), "instructions").unwrap();
2124 fs::write(root.join(".apm/apm.spec-writer.md"), "instructions").unwrap();
2125 let ticket_content = r#"+++
2126id = "aaaa0002"
2127title = "Test"
2128state = "in_design"
2129priority = 0
2130effort = 5
2131risk = 3
2132author = "test"
2133owner = "test"
2134branch = "ticket/aaaa0002-test"
2135created_at = "2026-01-01T00:00:00Z"
2136updated_at = "2026-01-01T00:00:00Z"
2137+++
2138
2139## Spec
2140
2141### Problem
2142
2143### Acceptance criteria
2144
2145### Out of scope
2146
2147### Approach
2148
2149## History
2150
2151| When | From | To | By |
2152|------|------|----|----|
2153"#;
2154 fs::write(root.join("tickets/aaaa0002-test.md"), ticket_content).unwrap();
2155 std::process::Command::new("git").args(["init"]).current_dir(root).output().unwrap();
2156 std::process::Command::new("git").args(["config", "user.email", "t@t.com"]).current_dir(root).output().unwrap();
2157 std::process::Command::new("git").args(["config", "user.name", "T"]).current_dir(root).output().unwrap();
2158 std::process::Command::new("git").args(["add", "."]).current_dir(root).output().unwrap();
2159 std::process::Command::new("git").args(["commit", "-m", "init"]).current_dir(root).output().unwrap();
2160
2161 let log_path = root.join("test.log");
2162 let sys_file = crate::wrapper::write_temp_file("sys", "sys").unwrap();
2163 let msg_file = crate::wrapper::write_temp_file("msg", "msg").unwrap();
2164 let ctx = crate::wrapper::WrapperContext {
2165 worker_name: "test".to_string(),
2166 ticket_id: "aaaa0002".to_string(),
2167 ticket_branch: "ticket/aaaa0002-test".to_string(),
2168 worktree_path: root.to_path_buf(),
2169 system_prompt_file: sys_file,
2170 user_message_file: msg_file,
2171 skip_permissions: false,
2172 profile: "default".to_string(),
2173 role_prefix: None,
2174 options: HashMap::new(),
2175 model: None,
2176 log_path,
2177 container: None,
2178 extra_env: HashMap::new(),
2179 root: root.to_path_buf(),
2180 keychain: HashMap::new(),
2181 current_state: "in_design".to_string(),
2182 command: None,
2183 };
2184 let wrapper = crate::wrapper::resolve_builtin("mock-happy").unwrap();
2185 let result = wrapper.spawn(&ctx);
2186 assert!(result.is_err(), "mock-happy should return Err when no success transitions");
2187 let msg = result.unwrap_err().to_string();
2188 assert!(msg.contains("no success-outcome transition"), "error should mention no success transition: {msg}");
2189 }
2190
2191 #[test]
2192 fn mock_sad_transitions_to_non_success_state() {
2193 let apm_bin = match find_apm_bin() { Some(b) => b, None => return };
2194 let dir = tempfile::tempdir().unwrap();
2195 let root = dir.path();
2196 make_mock_project(root, "in_design", "aaaa0003");
2197 let log_path = root.join("test.log");
2198 let ctx = make_wrapper_ctx_for_mock(root, "aaaa0003", "in_design", &apm_bin, log_path.clone());
2199 let wrapper = crate::wrapper::resolve_builtin("mock-sad").unwrap();
2200 let mut child = wrapper.spawn(&ctx).unwrap();
2201 child.wait().unwrap();
2202
2203 let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
2204 let out = std::process::Command::new("git")
2205 .args(["show", "ticket/aaaa0003-test:tickets/aaaa0003-test.md"])
2206 .current_dir(root)
2207 .output()
2208 .unwrap();
2209 let ticket_from_branch = String::from_utf8_lossy(&out.stdout).to_string();
2210 assert!(!ticket_from_branch.contains("state = \"specd\""),
2211 "mock-sad should NOT transition to specd\n{ticket_from_branch}\nlog: {log_content}");
2212 assert!(ticket_from_branch.contains("state = \"closed\"") || ticket_from_branch.contains("state = \"in_design\""),
2214 "mock-sad should transition to a non-success state\n{ticket_from_branch}\nlog: {log_content}");
2215 }
2216
2217 #[test]
2218 fn mock_sad_seed_reproducibility() {
2219 let apm_bin = match find_apm_bin() { Some(b) => b, None => return };
2220
2221 let run_mock_sad = |ticket_id: &str, seed: &str| -> String {
2222 let dir = tempfile::tempdir().unwrap();
2223 let root = dir.path();
2224 make_mock_project(root, "in_design", ticket_id);
2225 let log_path = root.join("test.log");
2226 let mut options = HashMap::new();
2227 options.insert("apm_bin".to_string(), apm_bin.clone());
2228 options.insert("seed".to_string(), seed.to_string());
2229 let sys_file = crate::wrapper::write_temp_file("sys", "sys").unwrap();
2230 let msg_file = crate::wrapper::write_temp_file("msg", "msg").unwrap();
2231 let ctx = crate::wrapper::WrapperContext {
2232 worker_name: "test".to_string(),
2233 ticket_id: ticket_id.to_string(),
2234 ticket_branch: format!("ticket/{ticket_id}-test"),
2235 worktree_path: root.to_path_buf(),
2236 system_prompt_file: sys_file,
2237 user_message_file: msg_file,
2238 skip_permissions: false,
2239 profile: "default".to_string(),
2240 role_prefix: None,
2241 options,
2242 model: None,
2243 log_path,
2244 container: None,
2245 extra_env: HashMap::new(),
2246 root: root.to_path_buf(),
2247 keychain: HashMap::new(),
2248 current_state: "in_design".to_string(),
2249 command: None,
2250 };
2251 let wrapper = crate::wrapper::resolve_builtin("mock-sad").unwrap();
2252 let mut child = wrapper.spawn(&ctx).unwrap();
2253 child.wait().unwrap();
2254
2255 let git_content = {
2257 let o = std::process::Command::new("git")
2258 .args(["show", &format!("ticket/{ticket_id}-test:tickets/{ticket_id}-test.md")])
2259 .current_dir(root)
2260 .output()
2261 .unwrap();
2262 String::from_utf8_lossy(&o.stdout).to_string()
2263 };
2264 for line in git_content.lines() {
2265 if line.starts_with("state = ") {
2266 return line.to_string();
2267 }
2268 }
2269 "unknown".to_string()
2270 };
2271
2272 let state1 = run_mock_sad("aaaa000a", "42");
2273 let state2 = run_mock_sad("aaaa000b", "42");
2274 assert_eq!(state1, state2, "mock-sad with same seed should pick same target state");
2275 }
2276
2277 #[test]
2278 fn debug_does_not_change_state() {
2279 let dir = tempfile::tempdir().unwrap();
2280 let root = dir.path();
2281 make_mock_project(root, "in_design", "aaaa0005");
2282 let log_path = root.join("test.log");
2283 let sys_file = crate::wrapper::write_temp_file("sys", "debug-system-prompt-unique-text").unwrap();
2284 let msg_file = crate::wrapper::write_temp_file("msg", "debug-message").unwrap();
2285 let ctx = crate::wrapper::WrapperContext {
2286 worker_name: "test-worker".to_string(),
2287 ticket_id: "aaaa0005".to_string(),
2288 ticket_branch: "ticket/aaaa0005-test".to_string(),
2289 worktree_path: root.to_path_buf(),
2290 system_prompt_file: sys_file,
2291 user_message_file: msg_file,
2292 skip_permissions: false,
2293 profile: "default".to_string(),
2294 role_prefix: None,
2295 options: HashMap::new(),
2296 model: None,
2297 log_path: log_path.clone(),
2298 container: None,
2299 extra_env: HashMap::new(),
2300 root: root.to_path_buf(),
2301 keychain: HashMap::new(),
2302 current_state: "in_design".to_string(),
2303 command: None,
2304 };
2305 let wrapper = crate::wrapper::resolve_builtin("debug").unwrap();
2306 let mut child = wrapper.spawn(&ctx).unwrap();
2307 child.wait().unwrap();
2308
2309 let git_content = {
2312 let o = std::process::Command::new("git")
2313 .args(["show", "ticket/aaaa0005-test:tickets/aaaa0005-test.md"])
2314 .current_dir(root)
2315 .output()
2316 .unwrap();
2317 String::from_utf8_lossy(&o.stdout).to_string()
2318 };
2319 assert!(git_content.contains("state = \"in_design\""),
2320 "debug should not change ticket state\n{git_content}");
2321
2322 let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
2324 assert!(log_content.contains("APM_TICKET_ID"),
2325 "log should contain APM_TICKET_ID\n{log_content}");
2326 assert!(log_content.contains("debug-system-prompt-unique-text"),
2327 "log should contain system prompt text\n{log_content}");
2328 assert!(log_content.contains("\"type\":\"tool_use\""),
2329 "log should contain tool_use JSONL\n{log_content}");
2330 }
2331}