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