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