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