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