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