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