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