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.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(¶ms.command)?;
183 let mut cmd = std::process::Command::new(¶ms.command);
184 for arg in ¶ms.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 ¶ms.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 ¶ms,
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(¶ms, &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 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(¤t_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 ¶ms,
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(¶ms, &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(¤t_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; 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 ¶ms,
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(¶ms, &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
703fn 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
716fn 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 #[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 #[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 #[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 #[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 #[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 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 ¶ms,
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 #[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}