Skip to main content

apm_core/
start.rs

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