Skip to main content

apm_core/
start.rs

1use anyhow::{bail, Result};
2use crate::{config::{Config, WorkerProfileConfig, WorkersConfig}, git, ticket, ticket_fmt};
3use chrono::Utc;
4use std::os::unix::process::CommandExt;
5use std::path::{Path, PathBuf};
6
7pub struct EffectiveWorkerParams {
8    pub command: String,
9    pub args: Vec<String>,
10    pub model: Option<String>,
11    pub env: std::collections::HashMap<String, String>,
12    pub container: Option<String>,
13}
14
15fn resolve_profile<'a>(transition: &crate::config::TransitionConfig, config: &'a Config, warnings: &mut Vec<String>) -> Option<&'a WorkerProfileConfig> {
16    let name = transition.profile.as_deref()?;
17    match config.worker_profiles.get(name) {
18        Some(p) => Some(p),
19        None => {
20            warnings.push(format!("warning: worker profile {name:?} not found — using global [workers] config"));
21            None
22        }
23    }
24}
25
26pub fn effective_spawn_params(profile: Option<&WorkerProfileConfig>, workers: &WorkersConfig) -> EffectiveWorkerParams {
27    let command = profile.and_then(|p| p.command.clone()).unwrap_or_else(|| workers.command.clone());
28    let args = profile.and_then(|p| p.args.clone()).unwrap_or_else(|| workers.args.clone());
29    let model = profile.and_then(|p| p.model.clone()).or_else(|| workers.model.clone());
30    let container = profile.and_then(|p| p.container.clone()).or_else(|| workers.container.clone());
31    let mut env = workers.env.clone();
32    if let Some(p) = profile {
33        for (k, v) in &p.env {
34            env.insert(k.clone(), v.clone());
35        }
36    }
37    EffectiveWorkerParams { command, args, model, env, container }
38}
39
40pub struct StartOutput {
41    pub id: String,
42    pub old_state: String,
43    pub new_state: String,
44    pub agent_name: String,
45    pub branch: String,
46    pub worktree_path: PathBuf,
47    pub merge_message: Option<String>,
48    pub worker_pid: Option<u32>,
49    pub log_path: Option<PathBuf>,
50    pub worker_name: Option<String>,
51    pub warnings: Vec<String>,
52}
53
54pub struct RunNextOutput {
55    pub ticket_id: Option<String>,
56    pub messages: Vec<String>,
57    pub warnings: Vec<String>,
58    pub worker_pid: Option<u32>,
59    pub log_path: Option<PathBuf>,
60}
61
62fn git_config_value(root: &Path, key: &str) -> Option<String> {
63    crate::git_util::git_config_get(root, key)
64}
65
66fn check_output_format_supported(binary: &str) -> Result<()> {
67    let out = std::process::Command::new(binary)
68        .arg("--help")
69        .output()
70        .map_err(|e| anyhow::anyhow!(
71            "failed to run `{binary} --help` to check worker-driver compatibility: {e}"
72        ))?;
73    let combined = format!(
74        "{}{}",
75        String::from_utf8_lossy(&out.stdout),
76        String::from_utf8_lossy(&out.stderr)
77    );
78    if combined.contains("--output-format") {
79        Ok(())
80    } else {
81        bail!(
82            "worker binary `{binary}` does not advertise `--output-format` in its \
83             --help output; the flag `--output-format stream-json` is required for \
84             full transcript capture in .apm-worker.log.\n\
85             Upgrade the binary to a version that supports this flag, or configure \
86             an alternative worker command in your apm.toml [workers] section."
87        )
88    }
89}
90
91#[allow(clippy::too_many_arguments)]
92fn spawn_container_worker(
93    root: &Path,
94    wt: &Path,
95    image: &str,
96    params: &EffectiveWorkerParams,
97    keychain: &std::collections::HashMap<String, String>,
98    worker_name: &str,
99    worker_system: &str,
100    ticket_content: &str,
101    skip_permissions: bool,
102    log_path: &Path,
103) -> anyhow::Result<std::process::Child> {
104    check_output_format_supported(&params.command)?;
105
106    let api_key = crate::credentials::resolve(
107        "ANTHROPIC_API_KEY",
108        keychain.get("ANTHROPIC_API_KEY").map(|s| s.as_str()),
109    )?;
110
111    let author_name = std::env::var("GIT_AUTHOR_NAME").ok()
112        .filter(|v| !v.is_empty())
113        .or_else(|| git_config_value(root, "user.name"))
114        .unwrap_or_default();
115    let author_email = std::env::var("GIT_AUTHOR_EMAIL").ok()
116        .filter(|v| !v.is_empty())
117        .or_else(|| git_config_value(root, "user.email"))
118        .unwrap_or_default();
119    let committer_name = std::env::var("GIT_COMMITTER_NAME").ok()
120        .filter(|v| !v.is_empty())
121        .unwrap_or_else(|| author_name.clone());
122    let committer_email = std::env::var("GIT_COMMITTER_EMAIL").ok()
123        .filter(|v| !v.is_empty())
124        .unwrap_or_else(|| author_email.clone());
125
126    let mut cmd = std::process::Command::new("docker");
127    cmd.arg("run");
128    cmd.arg("--rm");
129    cmd.args(["--volume", &format!("{}:/workspace", wt.display())]);
130    cmd.args(["--workdir", "/workspace"]);
131    cmd.args(["--env", &format!("ANTHROPIC_API_KEY={api_key}")]);
132    if !author_name.is_empty() {
133        cmd.args(["--env", &format!("GIT_AUTHOR_NAME={author_name}")]);
134    }
135    if !author_email.is_empty() {
136        cmd.args(["--env", &format!("GIT_AUTHOR_EMAIL={author_email}")]);
137    }
138    if !committer_name.is_empty() {
139        cmd.args(["--env", &format!("GIT_COMMITTER_NAME={committer_name}")]);
140    }
141    if !committer_email.is_empty() {
142        cmd.args(["--env", &format!("GIT_COMMITTER_EMAIL={committer_email}")]);
143    }
144    cmd.args(["--env", &format!("APM_AGENT_NAME={worker_name}")]);
145    for (k, v) in &params.env {
146        cmd.args(["--env", &format!("{k}={v}")]);
147    }
148    cmd.arg(image);
149    cmd.arg(&params.command);
150    for arg in &params.args {
151        cmd.arg(arg);
152    }
153    if let Some(ref model) = params.model {
154        cmd.args(["--model", model]);
155    }
156    cmd.args(["--output-format", "stream-json"]);
157    // Claude CLI requires --verbose when --print is paired with
158    // --output-format=stream-json; without it the spawned process exits
159    // immediately with "When using --print, --output-format=stream-json
160    // requires --verbose". --print is in [workers] args by default.
161    cmd.arg("--verbose");
162    cmd.args(["--system-prompt", worker_system]);
163    if skip_permissions {
164        cmd.arg("--dangerously-skip-permissions");
165    }
166    cmd.arg(ticket_content);
167
168    let log_file = std::fs::File::create(log_path)?;
169    let log_clone = log_file.try_clone()?;
170    cmd.stdout(log_file);
171    cmd.stderr(log_clone);
172    cmd.process_group(0);
173
174    let child = cmd.spawn()?;
175    Ok(child)
176}
177
178fn build_spawn_command(
179    params: &EffectiveWorkerParams,
180    wt: &Path,
181    worker_name: &str,
182    worker_system: &str,
183    ticket_content: &str,
184    skip_permissions: bool,
185    log_path: &Path,
186) -> Result<std::process::Child> {
187    check_output_format_supported(&params.command)?;
188    let mut cmd = std::process::Command::new(&params.command);
189    for arg in &params.args {
190        cmd.arg(arg);
191    }
192    if let Some(ref model) = params.model {
193        cmd.args(["--model", model]);
194    }
195    cmd.args(["--output-format", "stream-json"]);
196    // Claude CLI requires --verbose when --print is paired with
197    // --output-format=stream-json; without it the spawned process exits
198    // immediately with "When using --print, --output-format=stream-json
199    // requires --verbose". --print is in [workers] args by default.
200    cmd.arg("--verbose");
201    cmd.args(["--system-prompt", worker_system]);
202    if skip_permissions {
203        cmd.arg("--dangerously-skip-permissions");
204    }
205    cmd.arg(ticket_content);
206    cmd.env("APM_AGENT_NAME", worker_name);
207    for (k, v) in &params.env {
208        cmd.env(k, v);
209    }
210    cmd.current_dir(wt);
211
212    let log_file = std::fs::File::create(log_path)?;
213    let log_clone = log_file.try_clone()?;
214    cmd.stdout(log_file);
215    cmd.stderr(log_clone);
216    cmd.process_group(0);
217
218    Ok(cmd.spawn()?)
219}
220
221pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_permissions: bool, agent_name: &str) -> Result<StartOutput> {
222    let mut warnings: Vec<String> = Vec::new();
223    let config = Config::load(root)?;
224    let aggressive = config.sync.aggressive && !no_aggressive;
225    let skip_permissions = skip_permissions || config.agents.skip_permissions;
226
227    let startable: Vec<&str> = config.workflow.states.iter()
228        .filter(|s| s.transitions.iter().any(|tr| tr.trigger == "command:start"))
229        .map(|s| s.id.as_str())
230        .collect();
231
232    let mut tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
233    let id = ticket::resolve_id_in_slice(&tickets, id_arg)?;
234
235    let Some(t) = tickets.iter_mut().find(|t| t.frontmatter.id == id) else {
236        bail!("ticket {id:?} not found");
237    };
238
239    let ticket_epic_id = t.frontmatter.epic.clone();
240    let ticket_depends_on = t.frontmatter.depends_on.clone().unwrap_or_default();
241    let fm = &t.frontmatter;
242    if !startable.is_empty() && !startable.contains(&fm.state.as_str()) {
243        bail!(
244            "ticket {id:?} is in state {:?} — not startable\n\
245             Use `apm start` only from: {}",
246            fm.state,
247            startable.join(", ")
248        );
249    }
250
251    let now = Utc::now();
252    let old_state = t.frontmatter.state.clone();
253
254    let triggering_transition = config.workflow.states.iter()
255        .find(|s| s.id == old_state)
256        .and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"));
257
258    let new_state = triggering_transition
259        .map(|tr| tr.to.clone())
260        .unwrap_or_else(|| "in_progress".into());
261
262    t.frontmatter.state = new_state.clone();
263    t.frontmatter.updated_at = Some(now);
264    let when = now.format("%Y-%m-%dT%H:%MZ").to_string();
265    crate::state::append_history(&mut t.body, &old_state, &new_state, &when, agent_name);
266
267    let content = t.serialize()?;
268    let rel_path = format!(
269        "{}/{}",
270        config.tickets.dir.to_string_lossy(),
271        t.path.file_name().unwrap().to_string_lossy()
272    );
273    let branch = t
274        .frontmatter
275        .branch
276        .clone()
277        .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
278        .unwrap_or_else(|| format!("ticket/{id}"));
279
280    let default_branch = &config.project.default_branch;
281    let merge_base = t.frontmatter.target_branch.clone()
282        .unwrap_or_else(|| default_branch.to_string());
283
284    if aggressive {
285        if let Err(e) = git::fetch_branch(root, &branch) {
286            warnings.push(format!("warning: fetch failed: {e:#}"));
287        }
288        if let Err(e) = git::fetch_branch(root, default_branch) {
289            warnings.push(format!("warning: fetch {} failed: {e:#}", default_branch));
290        }
291    }
292
293    git::commit_to_branch(root, &branch, &rel_path, &content, &format!("ticket({id}): start — {old_state} → {new_state}"))?;
294
295    let wt_display = crate::worktree::provision_worktree(root, &config, &branch, &mut warnings)?;
296
297    let ref_to_merge = if crate::git_util::remote_branch_tip(&wt_display, &merge_base).is_some() {
298        format!("origin/{merge_base}")
299    } else {
300        merge_base.to_string()
301    };
302    let merge_message = crate::git_util::merge_ref(&wt_display, &ref_to_merge, &mut warnings);
303
304    if !spawn {
305        return Ok(StartOutput {
306            id,
307            old_state,
308            new_state,
309            agent_name: agent_name.to_string(),
310            branch,
311            worktree_path: wt_display,
312            merge_message,
313            worker_pid: None,
314            log_path: None,
315            worker_name: None,
316            warnings,
317        });
318    }
319
320    let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string();
321    let worker_name = format!("claude-{}-{:04x}", now_str, rand_u16());
322
323    let profile = triggering_transition.and_then(|tr| resolve_profile(tr, &config, &mut warnings));
324    let state_instructions = config.workflow.states.iter()
325        .find(|s| s.id == old_state)
326        .and_then(|sc| sc.instructions.as_deref());
327    let worker_system = resolve_system_prompt(root, profile, state_instructions);
328    let raw_prompt = format!("{}\n\n{content}", agent_role_prefix(profile, &id));
329    let with_epic = with_epic_bundle(root, ticket_epic_id.as_deref(), &id, &config, raw_prompt);
330    let ticket_content = with_dependency_bundle(root, &ticket_depends_on, &config, with_epic);
331    let params = effective_spawn_params(profile, &config.workers);
332
333    let log_path = wt_display.join(".apm-worker.log");
334
335    let mut child = if let Some(ref image) = params.container.clone() {
336        spawn_container_worker(
337            root,
338            &wt_display,
339            image,
340            &params,
341            &config.workers.keychain,
342            &worker_name,
343            &worker_system,
344            &ticket_content,
345            skip_permissions,
346            &log_path,
347        )?
348    } else {
349        build_spawn_command(&params, &wt_display, &worker_name, &worker_system, &ticket_content, skip_permissions, &log_path)?
350    };
351    let pid = child.id();
352
353    let pid_path = wt_display.join(".apm-worker.pid");
354    write_pid_file(&pid_path, pid, &id)?;
355
356    std::thread::spawn(move || {
357        let _ = child.wait();
358    });
359
360    Ok(StartOutput {
361        id,
362        old_state,
363        new_state,
364        agent_name: agent_name.to_string(),
365        branch,
366        worktree_path: wt_display,
367        merge_message,
368        worker_pid: Some(pid),
369        log_path: Some(log_path),
370        worker_name: Some(worker_name),
371        warnings,
372    })
373}
374
375pub fn run_next(root: &Path, no_aggressive: bool, spawn: bool, skip_permissions: bool) -> Result<RunNextOutput> {
376    let mut messages: Vec<String> = Vec::new();
377    let mut warnings: Vec<String> = Vec::new();
378    let config = Config::load(root)?;
379    let skip_permissions = skip_permissions || config.agents.skip_permissions;
380    let p = &config.workflow.prioritization;
381    let startable: Vec<&str> = config.workflow.states.iter()
382        .filter(|s| s.transitions.iter().any(|tr| tr.trigger == "command:start"))
383        .map(|s| s.id.as_str())
384        .collect();
385    let actionable_owned = config.actionable_states_for("agent");
386    let actionable: Vec<&str> = actionable_owned.iter().map(|s| s.as_str()).collect();
387    let all_tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
388    let agent_name = crate::config::resolve_caller_name();
389    let current_user = crate::config::resolve_identity(root);
390
391    // Filter out tickets whose epic already has the max number of active workers.
392    let active_epic_ids: Vec<Option<String>> = all_tickets.iter()
393        .filter(|t| {
394            let s = t.frontmatter.state.as_str();
395            actionable.contains(&s) && !startable.contains(&s)
396        })
397        .map(|t| t.frontmatter.epic.clone())
398        .collect();
399    let blocked = config.blocked_epics(&active_epic_ids);
400    let default_blocked = config.is_default_branch_blocked(&active_epic_ids);
401    let tickets: Vec<_> = all_tickets.into_iter()
402        .filter(|t| match t.frontmatter.epic.as_deref() {
403            Some(eid) => !blocked.iter().any(|b| b == eid),
404            None => !default_blocked,
405        })
406        .collect();
407
408    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 {
409        messages.push("No actionable tickets.".to_string());
410        return Ok(RunNextOutput { ticket_id: None, messages, warnings, worker_pid: None, log_path: None });
411    };
412
413    let id = candidate.frontmatter.id.clone();
414    let old_state = candidate.frontmatter.state.clone();
415
416    let triggering_transition_owned = config.workflow.states.iter()
417        .find(|s| s.id == old_state)
418        .and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"))
419        .cloned();
420    let profile = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, &mut warnings));
421    let state_instructions = config.workflow.states.iter()
422        .find(|s| s.id == old_state)
423        .and_then(|sc| sc.instructions.as_deref())
424        .map(|s| s.to_string());
425    let instructions_text = profile
426        .and_then(|p| p.instructions.as_deref())
427        .map(|path| {
428            match std::fs::read_to_string(root.join(path)) {
429                Ok(s) => s,
430                Err(_) => { warnings.push("warning: instructions file not found".to_string()); String::new() }
431            }
432        })
433        .filter(|s| !s.is_empty())
434        .or_else(|| state_instructions.as_deref()
435            .and_then(|path| {
436                std::fs::read_to_string(root.join(path)).ok()
437                    .or_else(|| { warnings.push("warning: instructions file not found".to_string()); None })
438            }));
439    let start_out = run(root, &id, no_aggressive, false, false, &agent_name)?;
440    warnings.extend(start_out.warnings);
441
442    if let Some(ref msg) = start_out.merge_message {
443        messages.push(msg.clone());
444    }
445    messages.push(format!("{}: {} → {} (agent: {}, branch: {})", start_out.id, start_out.old_state, start_out.new_state, start_out.agent_name, start_out.branch));
446    messages.push(format!("Worktree: {}", start_out.worktree_path.display()));
447
448    let tickets2 = ticket::load_all_from_git(root, &config.tickets.dir)?;
449    let Some(t) = tickets2.iter().find(|t| t.frontmatter.id == id) else {
450        return Ok(RunNextOutput { ticket_id: Some(id), messages, warnings, worker_pid: None, log_path: None });
451    };
452
453    let focus_hint = if let Some(ref section) = t.frontmatter.focus_section {
454        let hint = format!("Pay special attention to section: {section}");
455        let rel_path = format!(
456            "{}/{}",
457            config.tickets.dir.to_string_lossy(),
458            t.path.file_name().unwrap().to_string_lossy()
459        );
460        let branch = t.frontmatter.branch.clone()
461            .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
462            .unwrap_or_else(|| format!("ticket/{id}"));
463        let mut t_mut = t.clone();
464        t_mut.frontmatter.focus_section = None;
465        let cleared = t_mut.serialize()?;
466        git::commit_to_branch(root, &branch, &rel_path, &cleared, &format!("ticket({id}): clear focus_section"))?;
467        Some(hint)
468    } else {
469        None
470    };
471
472    let mut prompt = String::new();
473    if let Some(ref instr) = instructions_text {
474        prompt.push_str(instr.trim());
475        prompt.push('\n');
476    }
477    if let Some(ref hint) = focus_hint {
478        if !prompt.is_empty() { prompt.push('\n'); }
479        prompt.push_str(hint);
480        prompt.push('\n');
481    }
482
483    if !spawn {
484        if !prompt.is_empty() {
485            messages.push(format!("Prompt:\n{prompt}"));
486        }
487        return Ok(RunNextOutput { ticket_id: Some(id), messages, warnings, worker_pid: None, log_path: None });
488    }
489
490    let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string();
491    let worker_name = format!("claude-{}-{:04x}", now_str, rand_u16());
492
493    let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, &mut warnings));
494    let state_instr2 = config.workflow.states.iter()
495        .find(|s| s.id == old_state)
496        .and_then(|sc| sc.instructions.as_deref());
497    let worker_system = resolve_system_prompt(root, profile2, state_instr2);
498
499    let raw = t.serialize()?;
500    let dep_ids_next = t.frontmatter.depends_on.clone().unwrap_or_default();
501    let raw_prompt_next = format!("{}\n\n{raw}", agent_role_prefix(profile2, &id));
502    let with_epic_next = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_next);
503    let ticket_content = with_dependency_bundle(root, &dep_ids_next, &config, with_epic_next);
504    let params = effective_spawn_params(profile2, &config.workers);
505
506    let branch = t.frontmatter.branch.clone()
507        .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
508        .unwrap_or_else(|| format!("ticket/{id}"));
509    let wt_name = branch.replace('/', "-");
510    let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
511    let wt_path = main_root.join(&config.worktrees.dir).join(&wt_name);
512    let wt_display = crate::worktree::find_worktree_for_branch(root, &branch).unwrap_or(wt_path);
513
514    let log_path = wt_display.join(".apm-worker.log");
515
516    let mut child = if let Some(ref image) = params.container.clone() {
517        spawn_container_worker(
518            root,
519            &wt_display,
520            image,
521            &params,
522            &config.workers.keychain,
523            &worker_name,
524            &worker_system,
525            &ticket_content,
526            skip_permissions,
527            &log_path,
528        )?
529    } else {
530        build_spawn_command(&params, &wt_display, &worker_name, &worker_system, &ticket_content, skip_permissions, &log_path)?
531    };
532    let pid = child.id();
533
534    let pid_path = wt_display.join(".apm-worker.pid");
535    write_pid_file(&pid_path, pid, &id)?;
536    std::thread::spawn(move || {
537        let _ = child.wait();
538    });
539
540    messages.push(format!("Worker spawned: PID={pid}, log={}", log_path.display()));
541    messages.push(format!("Agent name: {worker_name}"));
542
543    Ok(RunNextOutput { ticket_id: Some(id), messages, warnings, worker_pid: Some(pid), log_path: Some(log_path) })
544}
545
546#[allow(clippy::type_complexity)]
547pub fn spawn_next_worker(
548    root: &Path,
549    no_aggressive: bool,
550    skip_permissions: bool,
551    epic_filter: Option<&str>,
552    blocked_epics: &[String],
553    default_blocked: bool,
554    messages: &mut Vec<String>,
555    warnings: &mut Vec<String>,
556) -> Result<Option<(String, Option<String>, std::process::Child, PathBuf)>> {
557    let config = Config::load(root)?;
558    let skip_permissions = skip_permissions || config.agents.skip_permissions;
559    let p = &config.workflow.prioritization;
560    let startable: Vec<&str> = config.workflow.states.iter()
561        .filter(|s| s.transitions.iter().any(|tr| tr.trigger == "command:start"))
562        .map(|s| s.id.as_str())
563        .collect();
564    let actionable_owned = config.actionable_states_for("agent");
565    let actionable: Vec<&str> = actionable_owned.iter().map(|s| s.as_str()).collect();
566    let all_tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
567    let tickets: Vec<ticket::Ticket> = {
568        let epic_filtered: Vec<ticket::Ticket> = match epic_filter {
569            Some(epic_id) => all_tickets.into_iter()
570                .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
571                .collect(),
572            None => all_tickets,
573        };
574        epic_filtered.into_iter()
575            .filter(|t| match t.frontmatter.epic.as_deref() {
576                Some(eid) => !blocked_epics.iter().any(|b| b == eid),
577                None => !default_blocked,
578            })
579            .collect()
580    };
581    let agent_name = crate::config::resolve_caller_name();
582    let current_user = crate::config::resolve_identity(root);
583
584    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 {
585        return Ok(None);
586    };
587
588    let id = candidate.frontmatter.id.clone();
589    let epic_id = candidate.frontmatter.epic.clone();
590    let old_state = candidate.frontmatter.state.clone();
591
592    let triggering_transition_owned = config.workflow.states.iter()
593        .find(|s| s.id == old_state)
594        .and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"))
595        .cloned();
596    let profile = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, warnings));
597    let state_instructions = config.workflow.states.iter()
598        .find(|s| s.id == old_state)
599        .and_then(|sc| sc.instructions.as_deref())
600        .map(|s| s.to_string());
601    let instructions_text = profile
602        .and_then(|p| p.instructions.as_deref())
603        .map(|path| {
604            match std::fs::read_to_string(root.join(path)) {
605                Ok(s) => s,
606                Err(_) => { warnings.push("warning: instructions file not found".to_string()); String::new() }
607            }
608        })
609        .filter(|s| !s.is_empty())
610        .or_else(|| state_instructions.as_deref()
611            .and_then(|path| {
612                std::fs::read_to_string(root.join(path)).ok()
613                    .or_else(|| { warnings.push("warning: instructions file not found".to_string()); None })
614            }));
615    let start_out = run(root, &id, no_aggressive, false, false, &agent_name)?;
616    warnings.extend(start_out.warnings);
617
618    if let Some(ref msg) = start_out.merge_message {
619        messages.push(msg.clone());
620    }
621    messages.push(format!("{}: {} → {} (agent: {}, branch: {})", start_out.id, start_out.old_state, start_out.new_state, start_out.agent_name, start_out.branch));
622    messages.push(format!("Worktree: {}", start_out.worktree_path.display()));
623
624    let tickets2 = ticket::load_all_from_git(root, &config.tickets.dir)?;
625    let Some(t) = tickets2.iter().find(|t| t.frontmatter.id == id) else {
626        return Ok(None);
627    };
628
629    let focus_hint = if let Some(ref section) = t.frontmatter.focus_section {
630        let hint = format!("Pay special attention to section: {section}");
631        let rel_path = format!(
632            "{}/{}",
633            config.tickets.dir.to_string_lossy(),
634            t.path.file_name().unwrap().to_string_lossy()
635        );
636        let branch = t.frontmatter.branch.clone()
637            .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
638            .unwrap_or_else(|| format!("ticket/{id}"));
639        let mut t_mut = t.clone();
640        t_mut.frontmatter.focus_section = None;
641        let cleared = t_mut.serialize()?;
642        git::commit_to_branch(root, &branch, &rel_path, &cleared,
643            &format!("ticket({id}): clear focus_section"))?;
644        Some(hint)
645    } else {
646        None
647    };
648
649    let mut prompt = String::new();
650    if let Some(ref instr) = instructions_text {
651        prompt.push_str(instr.trim());
652        prompt.push('\n');
653    }
654    if let Some(ref hint) = focus_hint {
655        if !prompt.is_empty() { prompt.push('\n'); }
656        prompt.push_str(hint);
657        prompt.push('\n');
658    }
659    let _ = prompt; // prompt used only for run_next, not spawn_next_worker
660
661    let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string();
662    let worker_name = format!("claude-{}-{:04x}", now_str, rand_u16());
663
664    let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, warnings));
665    let state_instr2 = config.workflow.states.iter()
666        .find(|s| s.id == old_state)
667        .and_then(|sc| sc.instructions.as_deref());
668    let worker_system = resolve_system_prompt(root, profile2, state_instr2);
669
670    let raw = t.serialize()?;
671    let dep_ids_snw = t.frontmatter.depends_on.clone().unwrap_or_default();
672    let raw_prompt_snw = format!("{}\n\n{raw}", agent_role_prefix(profile2, &id));
673    let with_epic_snw = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_snw);
674    let ticket_content = with_dependency_bundle(root, &dep_ids_snw, &config, with_epic_snw);
675    let params = effective_spawn_params(profile2, &config.workers);
676    let branch = t.frontmatter.branch.clone()
677        .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
678        .unwrap_or_else(|| format!("ticket/{id}"));
679    let wt_name = branch.replace('/', "-");
680    let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
681    let wt_path = main_root.join(&config.worktrees.dir).join(&wt_name);
682    let wt_display = crate::worktree::find_worktree_for_branch(root, &branch).unwrap_or(wt_path);
683
684    let log_path = wt_display.join(".apm-worker.log");
685
686    let child = if let Some(ref image) = params.container.clone() {
687        spawn_container_worker(
688            root,
689            &wt_display,
690            image,
691            &params,
692            &config.workers.keychain,
693            &worker_name,
694            &worker_system,
695            &ticket_content,
696            skip_permissions,
697            &log_path,
698        )?
699    } else {
700        build_spawn_command(&params, &wt_display, &worker_name, &worker_system, &ticket_content, skip_permissions, &log_path)?
701    };
702    let pid = child.id();
703
704    let pid_path = wt_display.join(".apm-worker.pid");
705    write_pid_file(&pid_path, pid, &id)?;
706
707    messages.push(format!("Worker spawned: PID={pid}, log={}", log_path.display()));
708    messages.push(format!("Agent name: {worker_name}"));
709
710    Ok(Some((id, epic_id, child, pid_path)))
711}
712
713/// If the ticket has dependencies, prepend a dependency context bundle to the
714/// worker prompt content.  Tickets with no dependencies are unchanged.
715fn with_dependency_bundle(root: &Path, depends_on: &[String], config: &Config, content: String) -> String {
716    if depends_on.is_empty() {
717        return content;
718    }
719    let bundle = crate::context::build_dependency_bundle(root, depends_on, config);
720    if bundle.is_empty() {
721        return content;
722    }
723    format!("{bundle}\n{content}")
724}
725
726/// If the ticket belongs to an epic, prepend an epic context bundle to the
727/// worker prompt content.  Tickets without an epic are unchanged.
728fn with_epic_bundle(root: &Path, epic_id: Option<&str>, ticket_id: &str, config: &Config, content: String) -> String {
729    match epic_id {
730        Some(eid) => {
731            let bundle = crate::context::build_epic_bundle(root, eid, ticket_id, config);
732            format!("{bundle}\n{content}")
733        }
734        None => content,
735    }
736}
737
738fn resolve_system_prompt(root: &Path, profile: Option<&WorkerProfileConfig>, state_instructions: Option<&str>) -> String {
739    if let Some(p) = profile {
740        if let Some(ref instr_path) = p.instructions {
741            if let Ok(content) = std::fs::read_to_string(root.join(instr_path)) {
742                return content;
743            }
744        }
745    }
746    if let Some(path) = state_instructions {
747        if let Ok(content) = std::fs::read_to_string(root.join(path)) {
748            return content;
749        }
750    }
751    let p = root.join(".apm/apm.worker.md");
752    std::fs::read_to_string(p)
753        .unwrap_or_else(|_| "You are an APM worker agent.".to_string())
754}
755
756fn agent_role_prefix(profile: Option<&WorkerProfileConfig>, id: &str) -> String {
757    if let Some(p) = profile {
758        if let Some(ref prefix) = p.role_prefix {
759            return prefix.replace("<id>", id);
760        }
761    }
762    format!("You are a Worker agent assigned to ticket #{id}.")
763}
764
765fn write_pid_file(path: &Path, pid: u32, ticket_id: &str) -> Result<()> {
766    let started_at = chrono::Utc::now().format("%Y-%m-%dT%H:%MZ").to_string();
767    let content = serde_json::json!({
768        "pid": pid,
769        "ticket_id": ticket_id,
770        "started_at": started_at,
771    })
772    .to_string();
773    std::fs::write(path, content)?;
774    Ok(())
775}
776
777fn rand_u16() -> u16 {
778    use std::time::{SystemTime, UNIX_EPOCH};
779    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos() as u16
780}
781
782#[cfg(test)]
783mod tests {
784    use super::{resolve_system_prompt, agent_role_prefix, resolve_profile, effective_spawn_params, build_spawn_command, check_output_format_supported, EffectiveWorkerParams};
785    use crate::config::{WorkerProfileConfig, WorkersConfig, TransitionConfig, CompletionStrategy};
786    use std::collections::HashMap;
787
788    fn make_transition(profile: Option<&str>) -> TransitionConfig {
789        TransitionConfig {
790            to: "in_progress".into(),
791            trigger: "command:start".into(),
792            label: String::new(),
793            hint: String::new(),
794            completion: CompletionStrategy::None,
795            focus_section: None,
796            context_section: None,
797            warning: None,
798            profile: profile.map(|s| s.to_string()),
799            on_failure: None,
800        }
801    }
802
803    fn make_profile(instructions: Option<&str>, role_prefix: Option<&str>) -> WorkerProfileConfig {
804        WorkerProfileConfig {
805            instructions: instructions.map(|s| s.to_string()),
806            role_prefix: role_prefix.map(|s| s.to_string()),
807            ..Default::default()
808        }
809    }
810
811    fn make_workers(command: &str, model: Option<&str>) -> WorkersConfig {
812        WorkersConfig {
813            command: command.to_string(),
814            args: vec!["--print".to_string()],
815            model: model.map(|s| s.to_string()),
816            env: HashMap::new(),
817            container: None,
818            keychain: HashMap::new(),
819        }
820    }
821
822    // --- resolve_profile ---
823
824    #[test]
825    fn resolve_profile_returns_profile_when_found() {
826        let mut config = crate::config::Config {
827            project: crate::config::ProjectConfig {
828                name: "test".into(),
829                description: String::new(),
830                default_branch: "main".into(),
831                collaborators: vec![],
832            },
833            ticket: Default::default(),
834            tickets: Default::default(),
835            workflow: Default::default(),
836            agents: Default::default(),
837            worktrees: Default::default(),
838            sync: Default::default(),
839            logging: Default::default(),
840            workers: make_workers("claude", None),
841            work: Default::default(),
842            server: Default::default(),
843            git_host: Default::default(),
844            worker_profiles: HashMap::new(),
845            context: Default::default(),
846            load_warnings: vec![],
847        };
848        let profile = make_profile(Some(".apm/spec.md"), Some("Spec-Writer for #<id>"));
849        config.worker_profiles.insert("spec_agent".into(), profile);
850
851        let tr = make_transition(Some("spec_agent"));
852        let mut w = Vec::new();
853        assert!(resolve_profile(&tr, &config, &mut w).is_some());
854    }
855
856    #[test]
857    fn resolve_profile_returns_none_for_missing_profile() {
858        let config = crate::config::Config {
859            project: crate::config::ProjectConfig {
860                name: "test".into(),
861                description: String::new(),
862                default_branch: "main".into(),
863                collaborators: vec![],
864            },
865            ticket: Default::default(),
866            tickets: Default::default(),
867            workflow: Default::default(),
868            agents: Default::default(),
869            worktrees: Default::default(),
870            sync: Default::default(),
871            logging: Default::default(),
872            workers: make_workers("claude", None),
873            work: Default::default(),
874            server: Default::default(),
875            git_host: Default::default(),
876            worker_profiles: HashMap::new(),
877            context: Default::default(),
878            load_warnings: vec![],
879        };
880        let tr = make_transition(Some("nonexistent_profile"));
881        let mut w = Vec::new();
882        assert!(resolve_profile(&tr, &config, &mut w).is_none());
883    }
884
885    #[test]
886    fn resolve_profile_returns_none_when_no_profile_on_transition() {
887        let config = crate::config::Config {
888            project: crate::config::ProjectConfig {
889                name: "test".into(),
890                description: String::new(),
891                default_branch: "main".into(),
892                collaborators: vec![],
893            },
894            ticket: Default::default(),
895            tickets: Default::default(),
896            workflow: Default::default(),
897            agents: Default::default(),
898            worktrees: Default::default(),
899            sync: Default::default(),
900            logging: Default::default(),
901            workers: make_workers("claude", None),
902            work: Default::default(),
903            server: Default::default(),
904            git_host: Default::default(),
905            worker_profiles: HashMap::new(),
906            context: Default::default(),
907            load_warnings: vec![],
908        };
909        let tr = make_transition(None);
910        let mut w = Vec::new();
911        assert!(resolve_profile(&tr, &config, &mut w).is_none());
912    }
913
914    // --- effective_spawn_params ---
915
916    #[test]
917    fn effective_spawn_params_profile_command_overrides_global() {
918        let workers = make_workers("claude", Some("sonnet"));
919        let profile = WorkerProfileConfig {
920            command: Some("my-claude".into()),
921            ..Default::default()
922        };
923        let params = effective_spawn_params(Some(&profile), &workers);
924        assert_eq!(params.command, "my-claude");
925    }
926
927    #[test]
928    fn effective_spawn_params_falls_back_to_global_command() {
929        let workers = make_workers("claude", None);
930        let params = effective_spawn_params(None, &workers);
931        assert_eq!(params.command, "claude");
932    }
933
934    #[test]
935    fn effective_spawn_params_profile_model_overrides_global() {
936        let workers = make_workers("claude", Some("sonnet"));
937        let profile = WorkerProfileConfig {
938            model: Some("opus".into()),
939            ..Default::default()
940        };
941        let params = effective_spawn_params(Some(&profile), &workers);
942        assert_eq!(params.model.as_deref(), Some("opus"));
943    }
944
945    #[test]
946    fn effective_spawn_params_falls_back_to_global_model() {
947        let workers = make_workers("claude", Some("sonnet"));
948        let params = effective_spawn_params(None, &workers);
949        assert_eq!(params.model.as_deref(), Some("sonnet"));
950    }
951
952    #[test]
953    fn effective_spawn_params_profile_env_merged_over_global() {
954        let mut workers = make_workers("claude", None);
955        workers.env.insert("FOO".into(), "global".into());
956        workers.env.insert("BAR".into(), "bar".into());
957
958        let mut profile_env = HashMap::new();
959        profile_env.insert("FOO".into(), "profile".into());
960        let profile = WorkerProfileConfig {
961            env: profile_env,
962            ..Default::default()
963        };
964        let params = effective_spawn_params(Some(&profile), &workers);
965        assert_eq!(params.env.get("FOO").map(|s| s.as_str()), Some("profile"));
966        assert_eq!(params.env.get("BAR").map(|s| s.as_str()), Some("bar"));
967    }
968
969    #[test]
970    fn effective_spawn_params_profile_container_overrides_global() {
971        let mut workers = make_workers("claude", None);
972        workers.container = Some("global-image".into());
973        let profile = WorkerProfileConfig {
974            container: Some("profile-image".into()),
975            ..Default::default()
976        };
977        let params = effective_spawn_params(Some(&profile), &workers);
978        assert_eq!(params.container.as_deref(), Some("profile-image"));
979    }
980
981    // --- resolve_system_prompt ---
982
983    #[test]
984    fn resolve_system_prompt_uses_profile_instructions() {
985        let dir = tempfile::tempdir().unwrap();
986        let p = dir.path();
987        std::fs::create_dir_all(p.join(".apm")).unwrap();
988        std::fs::write(p.join(".apm/spec.md"), "SPEC WRITER").unwrap();
989        std::fs::write(p.join(".apm/apm.worker.md"), "WORKER").unwrap();
990        let profile = make_profile(Some(".apm/spec.md"), None);
991        assert_eq!(resolve_system_prompt(p, Some(&profile), None), "SPEC WRITER");
992    }
993
994    #[test]
995    fn resolve_system_prompt_falls_back_to_state_instructions() {
996        let dir = tempfile::tempdir().unwrap();
997        let p = dir.path();
998        std::fs::create_dir_all(p.join(".apm")).unwrap();
999        std::fs::write(p.join(".apm/state.md"), "STATE INSTRUCTIONS").unwrap();
1000        std::fs::write(p.join(".apm/apm.worker.md"), "WORKER").unwrap();
1001        assert_eq!(resolve_system_prompt(p, None, Some(".apm/state.md")), "STATE INSTRUCTIONS");
1002    }
1003
1004    #[test]
1005    fn resolve_system_prompt_falls_back_to_worker_when_no_profile_no_state() {
1006        let dir = tempfile::tempdir().unwrap();
1007        let p = dir.path();
1008        std::fs::create_dir_all(p.join(".apm")).unwrap();
1009        std::fs::write(p.join(".apm/apm.worker.md"), "WORKER").unwrap();
1010        assert_eq!(resolve_system_prompt(p, None, None), "WORKER");
1011    }
1012
1013    // --- agent_role_prefix ---
1014
1015    #[test]
1016    fn agent_role_prefix_uses_profile_role_prefix() {
1017        let profile = make_profile(None, Some("You are a Spec-Writer agent assigned to ticket #<id>."));
1018        assert_eq!(
1019            agent_role_prefix(Some(&profile), "abc123"),
1020            "You are a Spec-Writer agent assigned to ticket #abc123."
1021        );
1022    }
1023
1024    #[test]
1025    fn agent_role_prefix_falls_back_to_worker_default() {
1026        assert_eq!(
1027            agent_role_prefix(None, "abc123"),
1028            "You are a Worker agent assigned to ticket #abc123."
1029        );
1030    }
1031
1032
1033    #[test]
1034    fn epic_filter_keeps_only_matching_tickets() {
1035        use crate::ticket::Ticket;
1036        use std::path::Path;
1037
1038        let make_ticket = |id: &str, epic: Option<&str>| {
1039            let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
1040            let raw = format!(
1041                "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}+++\n"
1042            );
1043            Ticket::parse(Path::new("tickets/dummy.md"), &raw).unwrap()
1044        };
1045
1046        let all_tickets = vec![
1047            make_ticket("aaa", Some("epic1")),
1048            make_ticket("bbb", Some("epic2")),
1049            make_ticket("ccc", None),
1050        ];
1051
1052        let epic_id = "epic1";
1053        let filtered: Vec<Ticket> = all_tickets.into_iter()
1054            .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
1055            .collect();
1056
1057        assert_eq!(filtered.len(), 1);
1058        assert_eq!(filtered[0].frontmatter.id, "aaa");
1059    }
1060
1061    #[test]
1062    fn no_epic_filter_keeps_all_tickets() {
1063        use crate::ticket::Ticket;
1064        use std::path::Path;
1065
1066        let make_ticket = |id: &str, epic: Option<&str>| {
1067            let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
1068            let raw = format!(
1069                "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}+++\n"
1070            );
1071            Ticket::parse(Path::new("tickets/dummy.md"), &raw).unwrap()
1072        };
1073
1074        let all_tickets: Vec<Ticket> = vec![
1075            make_ticket("aaa", Some("epic1")),
1076            make_ticket("bbb", Some("epic2")),
1077            make_ticket("ccc", None),
1078        ];
1079
1080        let count = all_tickets.len();
1081        let epic_filter: Option<&str> = None;
1082        let filtered: Vec<Ticket> = match epic_filter {
1083            Some(eid) => all_tickets.into_iter()
1084                .filter(|t| t.frontmatter.epic.as_deref() == Some(eid))
1085                .collect(),
1086            None => all_tickets,
1087        };
1088        assert_eq!(filtered.len(), count);
1089    }
1090
1091    // --- spawn worker cwd ---
1092
1093    #[test]
1094    fn spawn_worker_cwd_is_ticket_worktree() {
1095        use std::os::unix::fs::PermissionsExt;
1096
1097        let wt = tempfile::tempdir().unwrap();
1098        let log_dir = tempfile::tempdir().unwrap();
1099        let script_dir = tempfile::tempdir().unwrap();
1100
1101        // Mock worker script:
1102        //   --help  → prints "--output-format stream-json" so the probe passes
1103        //   other   → writes pwd to $APM_TEST_CWD_FILE and exits
1104        let script_path = script_dir.path().join("mock-worker");
1105        let script = concat!(
1106            "#!/bin/sh\n",
1107            "if [ \"$1\" = \"--help\" ]; then\n",
1108            "    echo '--output-format stream-json'\n",
1109            "    exit 0\n",
1110            "fi\n",
1111            "pwd > \"$APM_TEST_CWD_FILE\"\n",
1112        );
1113        std::fs::write(&script_path, script).unwrap();
1114        std::fs::set_permissions(
1115            &script_path,
1116            std::fs::Permissions::from_mode(0o755),
1117        )
1118        .unwrap();
1119
1120        let cwd_file = wt.path().join("cwd-output.txt");
1121        let mut env = std::collections::HashMap::new();
1122        env.insert(
1123            "APM_TEST_CWD_FILE".to_string(),
1124            cwd_file.to_str().unwrap().to_string(),
1125        );
1126
1127        let params = EffectiveWorkerParams {
1128            command: script_path.to_str().unwrap().to_string(),
1129            args: vec![],
1130            model: None,
1131            env,
1132            container: None,
1133        };
1134
1135        let log_path = log_dir.path().join("worker.log");
1136        let mut child = build_spawn_command(
1137            &params,
1138            wt.path(),
1139            "test-worker",
1140            "system",
1141            "ticket content",
1142            false,
1143            &log_path,
1144        )
1145        .unwrap();
1146
1147        child.wait().unwrap();
1148
1149        let cwd_out = std::fs::read_to_string(&cwd_file)
1150            .expect("cwd-output.txt not written — mock worker did not run in expected cwd");
1151        let expected = wt.path().canonicalize().unwrap();
1152        assert_eq!(
1153            cwd_out.trim(),
1154            expected.to_str().unwrap(),
1155            "spawned worker CWD must equal the ticket worktree path"
1156        );
1157    }
1158
1159    // --- check_output_format_supported ---
1160
1161    #[test]
1162    fn check_output_format_supported_passes_when_flag_present() {
1163        use std::os::unix::fs::PermissionsExt;
1164        let dir = tempfile::tempdir().unwrap();
1165        let bin = dir.path().join("fake-claude");
1166        std::fs::write(&bin, "#!/bin/sh\necho '--output-format stream-json'\n").unwrap();
1167        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1168        assert!(check_output_format_supported(bin.to_str().unwrap()).is_ok());
1169    }
1170
1171    #[test]
1172    fn check_output_format_supported_errors_when_flag_absent() {
1173        use std::os::unix::fs::PermissionsExt;
1174        let dir = tempfile::tempdir().unwrap();
1175        let bin = dir.path().join("old-claude");
1176        std::fs::write(&bin, "#!/bin/sh\necho 'Usage: old-claude [options]'\n").unwrap();
1177        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1178        let err = check_output_format_supported(bin.to_str().unwrap()).unwrap_err();
1179        let msg = err.to_string();
1180        assert!(
1181            msg.contains("--output-format"),
1182            "error message must name the missing flag: {msg}"
1183        );
1184        assert!(
1185            msg.contains(bin.to_str().unwrap()),
1186            "error message must include binary path: {msg}"
1187        );
1188    }
1189}