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