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