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