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 all_tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
348 let agent_name = crate::config::resolve_caller_name();
349 let current_user = crate::config::resolve_identity(root);
350
351 let active_epic_ids: Vec<Option<String>> = all_tickets.iter()
353 .filter(|t| {
354 let s = t.frontmatter.state.as_str();
355 actionable.contains(&s) && !startable.contains(&s)
356 })
357 .map(|t| t.frontmatter.epic.clone())
358 .collect();
359 let blocked = config.blocked_epics(&active_epic_ids);
360 let default_blocked = config.is_default_branch_blocked(&active_epic_ids);
361 let tickets: Vec<_> = all_tickets.into_iter()
362 .filter(|t| match t.frontmatter.epic.as_deref() {
363 Some(eid) => !blocked.iter().any(|b| b == eid),
364 None => !default_blocked,
365 })
366 .collect();
367
368 let Some(candidate) = ticket::pick_next(&tickets, &actionable, &startable, p.priority_weight, p.effort_weight, p.risk_weight, &config, Some(&agent_name), Some(¤t_user)) else {
369 messages.push("No actionable tickets.".to_string());
370 return Ok(RunNextOutput { ticket_id: None, messages, warnings, worker_pid: None, log_path: None });
371 };
372
373 let id = candidate.frontmatter.id.clone();
374 let old_state = candidate.frontmatter.state.clone();
375
376 let triggering_transition_owned = config.workflow.states.iter()
377 .find(|s| s.id == old_state)
378 .and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"))
379 .cloned();
380 let profile = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, &mut warnings));
381 let state_instructions = config.workflow.states.iter()
382 .find(|s| s.id == old_state)
383 .and_then(|sc| sc.instructions.as_deref())
384 .map(|s| s.to_string());
385 let instructions_text = profile
386 .and_then(|p| p.instructions.as_deref())
387 .map(|path| {
388 match std::fs::read_to_string(root.join(path)) {
389 Ok(s) => s,
390 Err(_) => { warnings.push("warning: instructions file not found".to_string()); String::new() }
391 }
392 })
393 .filter(|s| !s.is_empty())
394 .or_else(|| state_instructions.as_deref()
395 .and_then(|path| {
396 std::fs::read_to_string(root.join(path)).ok()
397 .or_else(|| { warnings.push("warning: instructions file not found".to_string()); None })
398 }));
399 let start_out = run(root, &id, no_aggressive, false, false, &agent_name)?;
400 warnings.extend(start_out.warnings);
401
402 if let Some(ref msg) = start_out.merge_message {
403 messages.push(msg.clone());
404 }
405 messages.push(format!("{}: {} → {} (agent: {}, branch: {})", start_out.id, start_out.old_state, start_out.new_state, start_out.agent_name, start_out.branch));
406 messages.push(format!("Worktree: {}", start_out.worktree_path.display()));
407
408 let tickets2 = ticket::load_all_from_git(root, &config.tickets.dir)?;
409 let Some(t) = tickets2.iter().find(|t| t.frontmatter.id == id) else {
410 return Ok(RunNextOutput { ticket_id: Some(id), messages, warnings, worker_pid: None, log_path: None });
411 };
412
413 let focus_hint = if let Some(ref section) = t.frontmatter.focus_section {
414 let hint = format!("Pay special attention to section: {section}");
415 let rel_path = format!(
416 "{}/{}",
417 config.tickets.dir.to_string_lossy(),
418 t.path.file_name().unwrap().to_string_lossy()
419 );
420 let branch = t.frontmatter.branch.clone()
421 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
422 .unwrap_or_else(|| format!("ticket/{id}"));
423 let mut t_mut = t.clone();
424 t_mut.frontmatter.focus_section = None;
425 let cleared = t_mut.serialize()?;
426 git::commit_to_branch(root, &branch, &rel_path, &cleared, &format!("ticket({id}): clear focus_section"))?;
427 Some(hint)
428 } else {
429 None
430 };
431
432 let mut prompt = String::new();
433 if let Some(ref instr) = instructions_text {
434 prompt.push_str(instr.trim());
435 prompt.push('\n');
436 }
437 if let Some(ref hint) = focus_hint {
438 if !prompt.is_empty() { prompt.push('\n'); }
439 prompt.push_str(hint);
440 prompt.push('\n');
441 }
442
443 if !spawn {
444 if !prompt.is_empty() {
445 messages.push(format!("Prompt:\n{prompt}"));
446 }
447 return Ok(RunNextOutput { ticket_id: Some(id), messages, warnings, worker_pid: None, log_path: None });
448 }
449
450 let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string();
451 let worker_name = format!("claude-{}-{:04x}", now_str, rand_u16());
452
453 let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, &mut warnings));
454 let state_instr2 = config.workflow.states.iter()
455 .find(|s| s.id == old_state)
456 .and_then(|sc| sc.instructions.as_deref());
457 let worker_system = resolve_system_prompt(root, profile2, state_instr2);
458
459 let raw = t.serialize()?;
460 let dep_ids_next = t.frontmatter.depends_on.clone().unwrap_or_default();
461 let raw_prompt_next = format!("{}\n\n{raw}", agent_role_prefix(profile2, &id));
462 let with_epic_next = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_next);
463 let ticket_content = with_dependency_bundle(root, &dep_ids_next, &config, with_epic_next);
464 let params = effective_spawn_params(profile2, &config.workers);
465
466 let branch = t.frontmatter.branch.clone()
467 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
468 .unwrap_or_else(|| format!("ticket/{id}"));
469 let wt_name = branch.replace('/', "-");
470 let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
471 let wt_path = main_root.join(&config.worktrees.dir).join(&wt_name);
472 let wt_display = crate::worktree::find_worktree_for_branch(root, &branch).unwrap_or(wt_path);
473
474 let log_path = wt_display.join(".apm-worker.log");
475
476 let mut child = if let Some(ref image) = params.container.clone() {
477 spawn_container_worker(
478 root,
479 &wt_display,
480 image,
481 ¶ms,
482 &config.workers.keychain,
483 &worker_name,
484 &worker_system,
485 &ticket_content,
486 skip_permissions,
487 &log_path,
488 )?
489 } else {
490 build_spawn_command(¶ms, &wt_display, &worker_name, &worker_system, &ticket_content, skip_permissions, &log_path)?
491 };
492 let pid = child.id();
493
494 let pid_path = wt_display.join(".apm-worker.pid");
495 write_pid_file(&pid_path, pid, &id)?;
496 std::thread::spawn(move || {
497 let _ = child.wait();
498 });
499
500 messages.push(format!("Worker spawned: PID={pid}, log={}", log_path.display()));
501 messages.push(format!("Agent name: {worker_name}"));
502
503 Ok(RunNextOutput { ticket_id: Some(id), messages, warnings, worker_pid: Some(pid), log_path: Some(log_path) })
504}
505
506#[allow(clippy::type_complexity)]
507pub fn spawn_next_worker(
508 root: &Path,
509 no_aggressive: bool,
510 skip_permissions: bool,
511 epic_filter: Option<&str>,
512 blocked_epics: &[String],
513 default_blocked: bool,
514 messages: &mut Vec<String>,
515 warnings: &mut Vec<String>,
516) -> Result<Option<(String, Option<String>, std::process::Child, PathBuf)>> {
517 let config = Config::load(root)?;
518 let skip_permissions = skip_permissions || config.agents.skip_permissions;
519 let p = &config.workflow.prioritization;
520 let startable: Vec<&str> = config.workflow.states.iter()
521 .filter(|s| s.transitions.iter().any(|tr| tr.trigger == "command:start"))
522 .map(|s| s.id.as_str())
523 .collect();
524 let actionable_owned = config.actionable_states_for("agent");
525 let actionable: Vec<&str> = actionable_owned.iter().map(|s| s.as_str()).collect();
526 let all_tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
527 let tickets: Vec<ticket::Ticket> = {
528 let epic_filtered: Vec<ticket::Ticket> = match epic_filter {
529 Some(epic_id) => all_tickets.into_iter()
530 .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
531 .collect(),
532 None => all_tickets,
533 };
534 epic_filtered.into_iter()
535 .filter(|t| match t.frontmatter.epic.as_deref() {
536 Some(eid) => !blocked_epics.iter().any(|b| b == eid),
537 None => !default_blocked,
538 })
539 .collect()
540 };
541 let agent_name = crate::config::resolve_caller_name();
542 let current_user = crate::config::resolve_identity(root);
543
544 let Some(candidate) = ticket::pick_next(&tickets, &actionable, &startable, p.priority_weight, p.effort_weight, p.risk_weight, &config, Some(&agent_name), Some(¤t_user)) else {
545 return Ok(None);
546 };
547
548 let id = candidate.frontmatter.id.clone();
549 let epic_id = candidate.frontmatter.epic.clone();
550 let old_state = candidate.frontmatter.state.clone();
551
552 let triggering_transition_owned = config.workflow.states.iter()
553 .find(|s| s.id == old_state)
554 .and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"))
555 .cloned();
556 let profile = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, warnings));
557 let state_instructions = config.workflow.states.iter()
558 .find(|s| s.id == old_state)
559 .and_then(|sc| sc.instructions.as_deref())
560 .map(|s| s.to_string());
561 let instructions_text = profile
562 .and_then(|p| p.instructions.as_deref())
563 .map(|path| {
564 match std::fs::read_to_string(root.join(path)) {
565 Ok(s) => s,
566 Err(_) => { warnings.push("warning: instructions file not found".to_string()); String::new() }
567 }
568 })
569 .filter(|s| !s.is_empty())
570 .or_else(|| state_instructions.as_deref()
571 .and_then(|path| {
572 std::fs::read_to_string(root.join(path)).ok()
573 .or_else(|| { warnings.push("warning: instructions file not found".to_string()); None })
574 }));
575 let start_out = run(root, &id, no_aggressive, false, false, &agent_name)?;
576 warnings.extend(start_out.warnings);
577
578 if let Some(ref msg) = start_out.merge_message {
579 messages.push(msg.clone());
580 }
581 messages.push(format!("{}: {} → {} (agent: {}, branch: {})", start_out.id, start_out.old_state, start_out.new_state, start_out.agent_name, start_out.branch));
582 messages.push(format!("Worktree: {}", start_out.worktree_path.display()));
583
584 let tickets2 = ticket::load_all_from_git(root, &config.tickets.dir)?;
585 let Some(t) = tickets2.iter().find(|t| t.frontmatter.id == id) else {
586 return Ok(None);
587 };
588
589 let focus_hint = if let Some(ref section) = t.frontmatter.focus_section {
590 let hint = format!("Pay special attention to section: {section}");
591 let rel_path = format!(
592 "{}/{}",
593 config.tickets.dir.to_string_lossy(),
594 t.path.file_name().unwrap().to_string_lossy()
595 );
596 let branch = t.frontmatter.branch.clone()
597 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
598 .unwrap_or_else(|| format!("ticket/{id}"));
599 let mut t_mut = t.clone();
600 t_mut.frontmatter.focus_section = None;
601 let cleared = t_mut.serialize()?;
602 git::commit_to_branch(root, &branch, &rel_path, &cleared,
603 &format!("ticket({id}): clear focus_section"))?;
604 Some(hint)
605 } else {
606 None
607 };
608
609 let mut prompt = String::new();
610 if let Some(ref instr) = instructions_text {
611 prompt.push_str(instr.trim());
612 prompt.push('\n');
613 }
614 if let Some(ref hint) = focus_hint {
615 if !prompt.is_empty() { prompt.push('\n'); }
616 prompt.push_str(hint);
617 prompt.push('\n');
618 }
619 let _ = prompt; let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string();
622 let worker_name = format!("claude-{}-{:04x}", now_str, rand_u16());
623
624 let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, warnings));
625 let state_instr2 = config.workflow.states.iter()
626 .find(|s| s.id == old_state)
627 .and_then(|sc| sc.instructions.as_deref());
628 let worker_system = resolve_system_prompt(root, profile2, state_instr2);
629
630 let raw = t.serialize()?;
631 let dep_ids_snw = t.frontmatter.depends_on.clone().unwrap_or_default();
632 let raw_prompt_snw = format!("{}\n\n{raw}", agent_role_prefix(profile2, &id));
633 let with_epic_snw = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_snw);
634 let ticket_content = with_dependency_bundle(root, &dep_ids_snw, &config, with_epic_snw);
635 let params = effective_spawn_params(profile2, &config.workers);
636 let branch = t.frontmatter.branch.clone()
637 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
638 .unwrap_or_else(|| format!("ticket/{id}"));
639 let wt_name = branch.replace('/', "-");
640 let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
641 let wt_path = main_root.join(&config.worktrees.dir).join(&wt_name);
642 let wt_display = crate::worktree::find_worktree_for_branch(root, &branch).unwrap_or(wt_path);
643
644 let log_path = wt_display.join(".apm-worker.log");
645
646 let child = if let Some(ref image) = params.container.clone() {
647 spawn_container_worker(
648 root,
649 &wt_display,
650 image,
651 ¶ms,
652 &config.workers.keychain,
653 &worker_name,
654 &worker_system,
655 &ticket_content,
656 skip_permissions,
657 &log_path,
658 )?
659 } else {
660 build_spawn_command(¶ms, &wt_display, &worker_name, &worker_system, &ticket_content, skip_permissions, &log_path)?
661 };
662 let pid = child.id();
663
664 let pid_path = wt_display.join(".apm-worker.pid");
665 write_pid_file(&pid_path, pid, &id)?;
666
667 messages.push(format!("Worker spawned: PID={pid}, log={}", log_path.display()));
668 messages.push(format!("Agent name: {worker_name}"));
669
670 Ok(Some((id, epic_id, child, pid_path)))
671}
672
673fn with_dependency_bundle(root: &Path, depends_on: &[String], config: &Config, content: String) -> String {
676 if depends_on.is_empty() {
677 return content;
678 }
679 let bundle = crate::context::build_dependency_bundle(root, depends_on, config);
680 if bundle.is_empty() {
681 return content;
682 }
683 format!("{bundle}\n{content}")
684}
685
686fn with_epic_bundle(root: &Path, epic_id: Option<&str>, ticket_id: &str, config: &Config, content: String) -> String {
689 match epic_id {
690 Some(eid) => {
691 let bundle = crate::context::build_epic_bundle(root, eid, ticket_id, config);
692 format!("{bundle}\n{content}")
693 }
694 None => content,
695 }
696}
697
698fn resolve_system_prompt(root: &Path, profile: Option<&WorkerProfileConfig>, state_instructions: Option<&str>) -> String {
699 if let Some(p) = profile {
700 if let Some(ref instr_path) = p.instructions {
701 if let Ok(content) = std::fs::read_to_string(root.join(instr_path)) {
702 return content;
703 }
704 }
705 }
706 if let Some(path) = state_instructions {
707 if let Ok(content) = std::fs::read_to_string(root.join(path)) {
708 return content;
709 }
710 }
711 let p = root.join(".apm/apm.worker.md");
712 std::fs::read_to_string(p)
713 .unwrap_or_else(|_| "You are an APM worker agent.".to_string())
714}
715
716fn agent_role_prefix(profile: Option<&WorkerProfileConfig>, id: &str) -> String {
717 if let Some(p) = profile {
718 if let Some(ref prefix) = p.role_prefix {
719 return prefix.replace("<id>", id);
720 }
721 }
722 format!("You are a Worker agent assigned to ticket #{id}.")
723}
724
725fn write_pid_file(path: &Path, pid: u32, ticket_id: &str) -> Result<()> {
726 let started_at = chrono::Utc::now().format("%Y-%m-%dT%H:%MZ").to_string();
727 let content = serde_json::json!({
728 "pid": pid,
729 "ticket_id": ticket_id,
730 "started_at": started_at,
731 })
732 .to_string();
733 std::fs::write(path, content)?;
734 Ok(())
735}
736
737fn rand_u16() -> u16 {
738 use std::time::{SystemTime, UNIX_EPOCH};
739 SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos() as u16
740}
741
742#[cfg(test)]
743mod tests {
744 use super::{resolve_system_prompt, agent_role_prefix, resolve_profile, effective_spawn_params};
745 use crate::config::{WorkerProfileConfig, WorkersConfig, TransitionConfig, CompletionStrategy};
746 use std::collections::HashMap;
747
748 fn make_transition(profile: Option<&str>) -> TransitionConfig {
749 TransitionConfig {
750 to: "in_progress".into(),
751 trigger: "command:start".into(),
752 label: String::new(),
753 hint: String::new(),
754 completion: CompletionStrategy::None,
755 focus_section: None,
756 context_section: None,
757 warning: None,
758 profile: profile.map(|s| s.to_string()),
759 }
760 }
761
762 fn make_profile(instructions: Option<&str>, role_prefix: Option<&str>) -> WorkerProfileConfig {
763 WorkerProfileConfig {
764 instructions: instructions.map(|s| s.to_string()),
765 role_prefix: role_prefix.map(|s| s.to_string()),
766 ..Default::default()
767 }
768 }
769
770 fn make_workers(command: &str, model: Option<&str>) -> WorkersConfig {
771 WorkersConfig {
772 command: command.to_string(),
773 args: vec!["--print".to_string()],
774 model: model.map(|s| s.to_string()),
775 env: HashMap::new(),
776 container: None,
777 keychain: HashMap::new(),
778 }
779 }
780
781 #[test]
784 fn resolve_profile_returns_profile_when_found() {
785 let mut config = crate::config::Config {
786 project: crate::config::ProjectConfig {
787 name: "test".into(),
788 description: String::new(),
789 default_branch: "main".into(),
790 collaborators: vec![],
791 },
792 ticket: Default::default(),
793 tickets: Default::default(),
794 workflow: Default::default(),
795 agents: Default::default(),
796 worktrees: Default::default(),
797 sync: Default::default(),
798 logging: Default::default(),
799 workers: make_workers("claude", None),
800 work: Default::default(),
801 server: Default::default(),
802 git_host: Default::default(),
803 worker_profiles: HashMap::new(),
804 context: Default::default(),
805 load_warnings: vec![],
806 };
807 let profile = make_profile(Some(".apm/spec.md"), Some("Spec-Writer for #<id>"));
808 config.worker_profiles.insert("spec_agent".into(), profile);
809
810 let tr = make_transition(Some("spec_agent"));
811 let mut w = Vec::new();
812 assert!(resolve_profile(&tr, &config, &mut w).is_some());
813 }
814
815 #[test]
816 fn resolve_profile_returns_none_for_missing_profile() {
817 let config = crate::config::Config {
818 project: crate::config::ProjectConfig {
819 name: "test".into(),
820 description: String::new(),
821 default_branch: "main".into(),
822 collaborators: vec![],
823 },
824 ticket: Default::default(),
825 tickets: Default::default(),
826 workflow: Default::default(),
827 agents: Default::default(),
828 worktrees: Default::default(),
829 sync: Default::default(),
830 logging: Default::default(),
831 workers: make_workers("claude", None),
832 work: Default::default(),
833 server: Default::default(),
834 git_host: Default::default(),
835 worker_profiles: HashMap::new(),
836 context: Default::default(),
837 load_warnings: vec![],
838 };
839 let tr = make_transition(Some("nonexistent_profile"));
840 let mut w = Vec::new();
841 assert!(resolve_profile(&tr, &config, &mut w).is_none());
842 }
843
844 #[test]
845 fn resolve_profile_returns_none_when_no_profile_on_transition() {
846 let config = crate::config::Config {
847 project: crate::config::ProjectConfig {
848 name: "test".into(),
849 description: String::new(),
850 default_branch: "main".into(),
851 collaborators: vec![],
852 },
853 ticket: Default::default(),
854 tickets: Default::default(),
855 workflow: Default::default(),
856 agents: Default::default(),
857 worktrees: Default::default(),
858 sync: Default::default(),
859 logging: Default::default(),
860 workers: make_workers("claude", None),
861 work: Default::default(),
862 server: Default::default(),
863 git_host: Default::default(),
864 worker_profiles: HashMap::new(),
865 context: Default::default(),
866 load_warnings: vec![],
867 };
868 let tr = make_transition(None);
869 let mut w = Vec::new();
870 assert!(resolve_profile(&tr, &config, &mut w).is_none());
871 }
872
873 #[test]
876 fn effective_spawn_params_profile_command_overrides_global() {
877 let workers = make_workers("claude", Some("sonnet"));
878 let profile = WorkerProfileConfig {
879 command: Some("my-claude".into()),
880 ..Default::default()
881 };
882 let params = effective_spawn_params(Some(&profile), &workers);
883 assert_eq!(params.command, "my-claude");
884 }
885
886 #[test]
887 fn effective_spawn_params_falls_back_to_global_command() {
888 let workers = make_workers("claude", None);
889 let params = effective_spawn_params(None, &workers);
890 assert_eq!(params.command, "claude");
891 }
892
893 #[test]
894 fn effective_spawn_params_profile_model_overrides_global() {
895 let workers = make_workers("claude", Some("sonnet"));
896 let profile = WorkerProfileConfig {
897 model: Some("opus".into()),
898 ..Default::default()
899 };
900 let params = effective_spawn_params(Some(&profile), &workers);
901 assert_eq!(params.model.as_deref(), Some("opus"));
902 }
903
904 #[test]
905 fn effective_spawn_params_falls_back_to_global_model() {
906 let workers = make_workers("claude", Some("sonnet"));
907 let params = effective_spawn_params(None, &workers);
908 assert_eq!(params.model.as_deref(), Some("sonnet"));
909 }
910
911 #[test]
912 fn effective_spawn_params_profile_env_merged_over_global() {
913 let mut workers = make_workers("claude", None);
914 workers.env.insert("FOO".into(), "global".into());
915 workers.env.insert("BAR".into(), "bar".into());
916
917 let mut profile_env = HashMap::new();
918 profile_env.insert("FOO".into(), "profile".into());
919 let profile = WorkerProfileConfig {
920 env: profile_env,
921 ..Default::default()
922 };
923 let params = effective_spawn_params(Some(&profile), &workers);
924 assert_eq!(params.env.get("FOO").map(|s| s.as_str()), Some("profile"));
925 assert_eq!(params.env.get("BAR").map(|s| s.as_str()), Some("bar"));
926 }
927
928 #[test]
929 fn effective_spawn_params_profile_container_overrides_global() {
930 let mut workers = make_workers("claude", None);
931 workers.container = Some("global-image".into());
932 let profile = WorkerProfileConfig {
933 container: Some("profile-image".into()),
934 ..Default::default()
935 };
936 let params = effective_spawn_params(Some(&profile), &workers);
937 assert_eq!(params.container.as_deref(), Some("profile-image"));
938 }
939
940 #[test]
943 fn resolve_system_prompt_uses_profile_instructions() {
944 let dir = tempfile::tempdir().unwrap();
945 let p = dir.path();
946 std::fs::create_dir_all(p.join(".apm")).unwrap();
947 std::fs::write(p.join(".apm/spec.md"), "SPEC WRITER").unwrap();
948 std::fs::write(p.join(".apm/apm.worker.md"), "WORKER").unwrap();
949 let profile = make_profile(Some(".apm/spec.md"), None);
950 assert_eq!(resolve_system_prompt(p, Some(&profile), None), "SPEC WRITER");
951 }
952
953 #[test]
954 fn resolve_system_prompt_falls_back_to_state_instructions() {
955 let dir = tempfile::tempdir().unwrap();
956 let p = dir.path();
957 std::fs::create_dir_all(p.join(".apm")).unwrap();
958 std::fs::write(p.join(".apm/state.md"), "STATE INSTRUCTIONS").unwrap();
959 std::fs::write(p.join(".apm/apm.worker.md"), "WORKER").unwrap();
960 assert_eq!(resolve_system_prompt(p, None, Some(".apm/state.md")), "STATE INSTRUCTIONS");
961 }
962
963 #[test]
964 fn resolve_system_prompt_falls_back_to_worker_when_no_profile_no_state() {
965 let dir = tempfile::tempdir().unwrap();
966 let p = dir.path();
967 std::fs::create_dir_all(p.join(".apm")).unwrap();
968 std::fs::write(p.join(".apm/apm.worker.md"), "WORKER").unwrap();
969 assert_eq!(resolve_system_prompt(p, None, None), "WORKER");
970 }
971
972 #[test]
975 fn agent_role_prefix_uses_profile_role_prefix() {
976 let profile = make_profile(None, Some("You are a Spec-Writer agent assigned to ticket #<id>."));
977 assert_eq!(
978 agent_role_prefix(Some(&profile), "abc123"),
979 "You are a Spec-Writer agent assigned to ticket #abc123."
980 );
981 }
982
983 #[test]
984 fn agent_role_prefix_falls_back_to_worker_default() {
985 assert_eq!(
986 agent_role_prefix(None, "abc123"),
987 "You are a Worker agent assigned to ticket #abc123."
988 );
989 }
990
991
992 #[test]
993 fn epic_filter_keeps_only_matching_tickets() {
994 use crate::ticket::Ticket;
995 use std::path::Path;
996
997 let make_ticket = |id: &str, epic: Option<&str>| {
998 let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
999 let raw = format!(
1000 "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}+++\n"
1001 );
1002 Ticket::parse(Path::new("tickets/dummy.md"), &raw).unwrap()
1003 };
1004
1005 let all_tickets = vec![
1006 make_ticket("aaa", Some("epic1")),
1007 make_ticket("bbb", Some("epic2")),
1008 make_ticket("ccc", None),
1009 ];
1010
1011 let epic_id = "epic1";
1012 let filtered: Vec<Ticket> = all_tickets.into_iter()
1013 .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
1014 .collect();
1015
1016 assert_eq!(filtered.len(), 1);
1017 assert_eq!(filtered[0].frontmatter.id, "aaa");
1018 }
1019
1020 #[test]
1021 fn no_epic_filter_keeps_all_tickets() {
1022 use crate::ticket::Ticket;
1023 use std::path::Path;
1024
1025 let make_ticket = |id: &str, epic: Option<&str>| {
1026 let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
1027 let raw = format!(
1028 "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}+++\n"
1029 );
1030 Ticket::parse(Path::new("tickets/dummy.md"), &raw).unwrap()
1031 };
1032
1033 let all_tickets: Vec<Ticket> = vec![
1034 make_ticket("aaa", Some("epic1")),
1035 make_ticket("bbb", Some("epic2")),
1036 make_ticket("ccc", None),
1037 ];
1038
1039 let count = all_tickets.len();
1040 let epic_filter: Option<&str> = None;
1041 let filtered: Vec<Ticket> = match epic_filter {
1042 Some(eid) => all_tickets.into_iter()
1043 .filter(|t| t.frontmatter.epic.as_deref() == Some(eid))
1044 .collect(),
1045 None => all_tickets,
1046 };
1047 assert_eq!(filtered.len(), count);
1048 }
1049}