1#[allow(dead_code)]
2mod dispatch;
3mod journal;
4#[allow(dead_code)]
5mod merge;
6#[allow(dead_code)]
7mod mission;
8#[allow(dead_code)]
9mod monitor;
10mod prompt;
11#[allow(dead_code)]
12mod release;
13mod status;
14
15use std::path::{Path, PathBuf};
16use std::time::Duration;
17
18use anyhow::Context;
19
20use crate::config::Config;
21use crate::subprocess::Tool;
22
23use journal::Journal;
24use status::StatusSnapshot;
25
26pub fn run(
31 project_root: Option<&Path>,
32 agent_override: Option<&str>,
33 model_override: Option<&str>,
34) -> anyhow::Result<()> {
35 let project_root = resolve_project_root(project_root)?;
36 let (config, config_dir) = load_config(&project_root)?;
37
38 let agent = resolve_agent(&config, agent_override)?;
39
40 unsafe {
43 std::env::set_var("AGENT", &agent);
44 std::env::set_var("BOTBUS_AGENT", &agent);
45 }
46
47 for (k, v) in config.resolved_env() {
49 unsafe {
51 std::env::set_var(&k, &v);
52 }
53 }
54
55 let project = config.channel();
56 let model = resolve_model(&config, model_override);
57 let worker_model = resolve_worker_model(&config);
58
59 let dev_config = config.agents.dev.clone().unwrap_or_default();
60 let max_loops = dev_config.max_loops;
61 let pause_secs = dev_config.pause;
62 let timeout_secs = dev_config.timeout;
63 let review_enabled = config.review.enabled;
64 let push_main = config.push_main;
65
66 let missions_config = dev_config.missions.clone();
67 let missions_enabled = missions_config.as_ref().is_none_or(|m| m.enabled);
68 let multi_lead_config = dev_config.multi_lead.clone();
69 let multi_lead_enabled = multi_lead_config.as_ref().is_some_and(|m| m.enabled);
70
71 let check_command = config.project.check_command.clone();
72 let worker_timeout = config.agents.worker.as_ref().map_or(900, |w| w.timeout);
73
74 let spawn_env = config.resolved_env();
75 let worker_memory_limit = {
76 let configured = config
77 .agents
78 .worker
79 .as_ref()
80 .and_then(|w| w.memory_limit.clone());
81 if configured.is_some() && !is_systemd_dbus_available() {
82 eprintln!(
83 "Warning: worker memory limit configured but systemd D-Bus is not available \
84 (DBUS_SESSION_BUS_ADDRESS / XDG_RUNTIME_DIR not set) — skipping --memory-limit. \
85 To fix: add XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS to your project's \
86 [env] config so they are forwarded to spawned agents."
87 );
88 None
89 } else {
90 configured
91 }
92 };
93
94 let ctx = LoopContext {
95 agent: agent.clone(),
96 project: project.clone(),
97 model,
98 worker_model,
99 worker_timeout,
100 review_enabled,
101 push_main,
102 check_command,
103 missions_enabled,
104 missions_config,
105 multi_lead_enabled,
106 multi_lead_config,
107 project_dir: project_root.display().to_string(),
108 spawn_env,
109 worker_memory_limit,
110 };
111
112 eprintln!("Agent: {agent}");
113 eprintln!("Project: {project}");
114 eprintln!("Max loops: {max_loops}");
115 eprintln!("Pause: {pause_secs}s");
116 eprintln!(
117 "Model: {}",
118 if ctx.model.is_empty() {
119 "system default"
120 } else {
121 &ctx.model
122 }
123 );
124 eprintln!("Review: {review_enabled}");
125 if multi_lead_enabled {
126 let max_leads = ctx.multi_lead_config.as_ref().map_or(3, |c| c.max_leads);
127 eprintln!("Multi-lead: enabled (max {max_leads} slots)");
128 }
129
130 Tool::new("bus")
132 .args(&["whoami", "--agent", &agent])
133 .run_ok()
134 .context("confirming agent identity")?;
135
136 let _ = Tool::new("bus")
138 .args(&[
139 "claims",
140 "stake",
141 "--agent",
142 &agent,
143 &format!("agent://{agent}"),
144 "-m",
145 &format!("dev-loop for {project}"),
146 ])
147 .run();
148
149 Tool::new("bus")
151 .args(&[
152 "send",
153 "--agent",
154 &agent,
155 &project,
156 &format!("Dev agent {agent} online, starting dev loop"),
157 "-L",
158 "spawn-ack",
159 ])
160 .run_ok()?;
161
162 let _ = Tool::new("bus")
164 .args(&[
165 "statuses",
166 "set",
167 "--agent",
168 &agent,
169 "Starting loop",
170 "--ttl",
171 "10m",
172 ])
173 .run();
174
175 let baseline_commits = get_commits_since_origin();
177
178 let journal = Journal::new(&project_root);
180 journal.truncate();
181
182 let cleanup_agent = agent.clone();
184 let cleanup_project = project.clone();
185 let _ = ctrlc::set_handler(move || {
186 let _ = cleanup(&cleanup_agent, &cleanup_project);
188 std::process::exit(0);
189 });
190
191 let mut idle_count: u32 = 0;
192 let idle_delays = [10u64, 20, 40, 60, 60];
193 let max_idle: u32 = 5;
194
195 for i in 1..=max_loops {
197 eprintln!("\n--- Dev loop {i}/{max_loops} ---");
198 crate::telemetry::metrics::counter(
199 "edict.dev_loop.iterations_total",
200 1,
201 &[("agent", &agent), ("project", &project)],
202 );
203
204 let _ = Tool::new("bus")
206 .args(&[
207 "claims",
208 "refresh",
209 "--agent",
210 &agent,
211 &format!("agent://{agent}"),
212 ])
213 .run();
214
215 if !has_work(&agent, &project)? {
216 idle_count += 1;
217 if idle_count >= max_idle {
218 let _ = Tool::new("bus")
219 .args(&["statuses", "set", "--agent", &agent, "Idle"])
220 .run();
221 eprintln!("No work after {max_idle} idle checks. Exiting cleanly.");
222 let _ = Tool::new("bus")
223 .args(&[
224 "send", "--agent", &agent, &project,
225 &format!("No work remaining after {max_idle} checks. Dev agent {agent} signing off."),
226 "-L", "agent-idle",
227 ])
228 .run();
229 break;
230 }
231 let delay = idle_delays[idle_count.saturating_sub(1) as usize % idle_delays.len()];
232 eprintln!(
233 "No work available (idle {idle_count}/{max_idle}). Waiting {delay}s before retrying..."
234 );
235 let _ = Tool::new("bus")
236 .args(&[
237 "statuses",
238 "set",
239 "--agent",
240 &agent,
241 &format!("Idle ({idle_count}/{max_idle})"),
242 "--ttl",
243 &format!("{delay}s"),
244 ])
245 .run();
246 std::thread::sleep(Duration::from_secs(delay));
247 continue;
248 }
249 idle_count = 0;
250
251 if let Some(pending_bead) = has_pending_review(&agent)? {
253 eprintln!("Review pending for {pending_bead} — waiting (not running Claude)");
254 let _ = Tool::new("bus")
255 .args(&[
256 "statuses",
257 "set",
258 "--agent",
259 &agent,
260 &format!("Waiting: review for {pending_bead}"),
261 "--ttl",
262 "10m",
263 ])
264 .run();
265 std::thread::sleep(Duration::from_secs(30));
266 continue;
267 }
268
269 let last_iteration = journal.read_last();
271 let sibling_leads = if multi_lead_enabled {
272 discover_sibling_leads(&agent)?
273 } else {
274 Vec::new()
275 };
276 let status_snapshot = StatusSnapshot::gather(&agent, &project);
277
278 let prompt_text = prompt::build(
279 &ctx,
280 last_iteration.as_ref(),
281 &sibling_leads,
282 status_snapshot.as_deref(),
283 );
284
285 let agent_start = crate::telemetry::metrics::time_start();
286 match run_agent_subprocess(&prompt_text, &ctx.model, timeout_secs) {
287 Ok(output) => {
288 let signal_region = if output.len() > 1000 {
290 let start = output.floor_char_boundary(output.len() - 1000);
291 &output[start..]
292 } else {
293 &output
294 };
295
296 if signal_region.contains("<promise>COMPLETE</promise>") {
297 eprintln!("\u{2713} Dev cycle complete - no more work");
298 break;
299 } else if signal_region.contains("<promise>END_OF_STORY</promise>") {
300 eprintln!("\u{2713} Iteration complete - more work remains");
301 if !has_work(&agent, &project)? {
303 eprintln!("No remaining work found despite END_OF_STORY — exiting cleanly");
304 break;
305 }
306 } else {
307 eprintln!("Warning: No completion signal found in output");
308 }
309
310 if let Some(summary) = extract_iteration_summary(&output) {
312 journal.append(&summary);
313 }
314 }
315 Err(err) => {
316 eprintln!("Error running Claude: {err:#}");
317 let err_str = format!("{err:#}");
318 let is_fatal = err_str.contains("API Error")
319 || err_str.contains("rate limit")
320 || err_str.contains("overloaded");
321 if is_fatal {
322 eprintln!("Fatal error detected, posting to botbus and exiting...");
323 let _ = Tool::new("bus")
324 .args(&[
325 "send",
326 "--agent",
327 &agent,
328 &project,
329 &format!("Dev loop error: {err_str}. Agent {agent} going offline."),
330 "-L",
331 "agent-error",
332 ])
333 .run();
334 break;
335 }
336 }
338 }
339 crate::telemetry::metrics::time_record(
340 "edict.dev_loop.agent_run_duration_seconds",
341 agent_start,
342 &[("agent", &agent), ("project", &project)],
343 );
344
345 if i < max_loops {
346 std::thread::sleep(Duration::from_secs(pause_secs.into()));
347 }
348 }
349
350 let final_commits = get_commits_since_origin();
352 let new_commits: Vec<_> = final_commits
353 .iter()
354 .filter(|c| !baseline_commits.contains(c))
355 .collect();
356 if !new_commits.is_empty() {
357 eprintln!("\n--- Commits landed this session ---");
358 for commit in &new_commits {
359 eprintln!(" {commit}");
360 }
361 eprintln!("\nIf any are user-visible (feat/fix), consider a release.");
362 }
363
364 cleanup(&agent, &project)?;
365 Ok(())
366}
367
368pub struct LoopContext {
370 pub agent: String,
371 pub project: String,
372 pub model: String,
373 pub worker_model: String,
374 pub worker_timeout: u64,
375 pub review_enabled: bool,
376 pub push_main: bool,
377 pub check_command: Option<String>,
378 pub missions_enabled: bool,
379 pub missions_config: Option<crate::config::MissionsConfig>,
380 pub multi_lead_enabled: bool,
381 pub multi_lead_config: Option<crate::config::MultiLeadConfig>,
382 pub project_dir: String,
383 pub spawn_env: std::collections::HashMap<String, String>,
385 pub worker_memory_limit: Option<String>,
387}
388
389pub struct SiblingLead {
391 pub name: String,
392 pub memo: String,
393}
394
395fn resolve_project_root(explicit: Option<&Path>) -> anyhow::Result<PathBuf> {
397 if let Some(p) = explicit {
398 return Ok(p.to_path_buf());
399 }
400 std::env::current_dir().context("getting current directory")
401}
402
403fn load_config(project_root: &Path) -> anyhow::Result<(Config, PathBuf)> {
406 let (config_path, config_dir) = crate::config::find_config_in_project(project_root)?;
407 Ok((Config::load(&config_path)?, config_dir))
408}
409
410fn resolve_agent(config: &Config, agent_override: Option<&str>) -> anyhow::Result<String> {
412 if let Some(name) = agent_override {
413 return Ok(name.to_string());
414 }
415 let from_config = config.default_agent();
416 if !from_config.is_empty() {
417 return Ok(from_config);
418 }
419 let output = Tool::new("bus")
421 .arg("generate-name")
422 .run_ok()
423 .context("generating agent name")?;
424 Ok(output.stdout.trim().to_string())
425}
426
427fn resolve_model(config: &Config, model_override: Option<&str>) -> String {
429 let raw = if let Some(m) = model_override {
430 m.to_string()
431 } else {
432 config
433 .agents
434 .dev
435 .as_ref()
436 .map_or_else(String::new, |d| d.model.clone())
437 };
438 if raw.is_empty() {
439 raw
440 } else {
441 config.resolve_model(&raw)
442 }
443}
444
445fn resolve_worker_model(config: &Config) -> String {
451 config
452 .agents
453 .worker
454 .as_ref()
455 .map_or_else(String::new, |w| w.model.clone())
456}
457
458fn has_work(agent: &str, project: &str) -> anyhow::Result<bool> {
460 if let Ok(output) = Tool::new("bus")
462 .args(&[
463 "claims", "list", "--agent", agent, "--mine", "--format", "json",
464 ])
465 .run()
466 && output.success()
467 && let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&output.stdout)
468 {
469 let claims = parsed["claims"].as_array();
470 if let Some(claims) = claims {
471 let has_work_claims = claims.iter().any(|c| {
472 c["patterns"].as_array().is_some_and(|patterns| {
473 patterns.iter().any(|p| {
474 let s = p.as_str().unwrap_or("");
475 s.starts_with("bone://") || s.starts_with("workspace://")
476 })
477 })
478 });
479 if has_work_claims {
480 return Ok(true);
481 }
482 }
483 }
484
485 if let Ok(output) = Tool::new("bus")
487 .args(&[
488 "inbox",
489 "--agent",
490 agent,
491 "--channels",
492 project,
493 "--count-only",
494 "--format",
495 "json",
496 ])
497 .run()
498 && output.success()
499 {
500 let count = parse_inbox_count(&output.stdout);
501 if count > 0 {
502 return Ok(true);
503 }
504 }
505
506 if let Ok(output) = Tool::new("bn")
508 .args(&["next", "--json"])
509 .in_workspace("default")?
510 .run()
511 && output.success()
512 {
513 let count = parse_ready_count(&output.stdout);
514 if count > 0 {
515 return Ok(true);
516 }
517 }
518
519 Ok(false)
520}
521
522fn parse_inbox_count(json: &str) -> u64 {
524 if let Ok(v) = serde_json::from_str::<serde_json::Value>(json) {
525 if let Some(n) = v.as_u64() {
526 return n;
527 }
528 if let Some(n) = v["total_unread"].as_u64() {
529 return n;
530 }
531 }
532 0
533}
534
535fn parse_ready_count(json: &str) -> usize {
539 if let Ok(v) = serde_json::from_str::<serde_json::Value>(json) {
540 if let Some(arr) = v["assignments"].as_array() {
541 return arr.len();
542 }
543 if let Some(arr) = v.as_array() {
544 return arr.len();
545 }
546 if let Some(arr) = v["issues"].as_array() {
547 return arr.len();
548 }
549 if let Some(arr) = v["bones"].as_array() {
550 return arr.len();
551 }
552 }
553 0
554}
555
556fn has_pending_review(agent: &str) -> anyhow::Result<Option<String>> {
558 let output = Tool::new("bn")
560 .args(&["list", "--state", "doing", "--assignee", agent, "--json"])
561 .in_workspace("default")?
562 .run();
563
564 let output = match output {
565 Ok(o) if o.success() => o,
566 _ => return Ok(None),
567 };
568
569 let bones: Vec<serde_json::Value> = match serde_json::from_str(&output.stdout) {
570 Ok(v) => {
571 if let serde_json::Value::Array(arr) = v {
572 arr
573 } else {
574 Vec::new()
575 }
576 }
577 Err(_) => return Ok(None),
578 };
579
580 for bone in &bones {
581 let id = match bone["id"].as_str() {
582 Some(id) => id,
583 None => continue,
584 };
585
586 let comments_output = Tool::new("bn")
587 .args(&["bone", "comment", "list", id, "--json"])
588 .in_workspace("default")?
589 .run();
590
591 let comments_output = match comments_output {
592 Ok(o) if o.success() => o,
593 _ => continue,
594 };
595
596 let comments = parse_comments(&comments_output.stdout);
597 let has_review = comments
598 .iter()
599 .any(|c| c.contains("Review created:") || c.contains("Review requested:"));
600 if !has_review {
601 continue;
602 }
603
604 let has_completed = comments.iter().any(|c| c.contains("Completed by"));
605 if has_completed {
606 continue;
607 }
608
609 return Ok(Some(id.to_string()));
611 }
612
613 Ok(None)
614}
615
616fn parse_comments(json: &str) -> Vec<String> {
618 let mut bodies = Vec::new();
619 if let Ok(v) = serde_json::from_str::<serde_json::Value>(json) {
620 let arr = if let Some(a) = v.as_array() {
621 a.clone()
622 } else if let Some(a) = v["comments"].as_array() {
623 a.clone()
624 } else {
625 return bodies;
626 };
627 for item in &arr {
628 if let Some(body) = item["body"].as_str().or(item["content"].as_str()) {
629 bodies.push(body.to_string());
630 }
631 }
632 }
633 bodies
634}
635
636fn discover_sibling_leads(agent: &str) -> anyhow::Result<Vec<SiblingLead>> {
638 let output = Tool::new("bus")
639 .args(&["claims", "list", "--format", "json"])
640 .run()?;
641
642 if !output.success() {
643 return Ok(Vec::new());
644 }
645
646 let parsed: serde_json::Value = serde_json::from_str(&output.stdout).unwrap_or_default();
647 let claims = parsed["claims"].as_array().cloned().unwrap_or_default();
648
649 let base_agent = agent.rfind('/').map_or(agent, |pos| {
651 let suffix = &agent[pos + 1..];
652 if suffix.chars().all(|c| c.is_ascii_digit()) {
653 &agent[..pos]
654 } else {
655 agent
656 }
657 });
658
659 let prefix = format!("agent://{base_agent}/");
660 let mut siblings = Vec::new();
661
662 for claim in &claims {
663 let patterns = claim["patterns"].as_array().cloned().unwrap_or_default();
664 for p in &patterns {
665 let p_str = p.as_str().unwrap_or("");
666 if p_str.starts_with(&prefix) {
667 let lead_name_suffix = &p_str["agent://".len()..];
668 if lead_name_suffix != agent {
669 siblings.push(SiblingLead {
670 name: lead_name_suffix.to_string(),
671 memo: claim["memo"].as_str().unwrap_or("").to_string(),
672 });
673 }
674 }
675 }
676 }
677
678 Ok(siblings)
679}
680
681fn run_agent_subprocess(prompt: &str, model: &str, timeout_secs: u64) -> anyhow::Result<String> {
683 let mut args = vec!["run", "agent", prompt];
684
685 if !model.is_empty() {
687 args.push("-m");
688 args.push(model);
689 }
690
691 let timeout_str = timeout_secs.to_string();
692 args.push("-t");
693 args.push(&timeout_str);
694
695 use std::io::{BufRead, BufReader};
697 use std::process::{Command, Stdio};
698
699 let mut child = Command::new("edict")
700 .args(&args)
701 .stdin(Stdio::null())
702 .stdout(Stdio::piped())
703 .stderr(Stdio::inherit())
704 .spawn()
705 .context("spawning edict run agent")?;
706
707 let stdout = child.stdout.take().context("capturing stdout")?;
708 let reader = BufReader::new(stdout);
709 let mut output = String::new();
710
711 for line in reader.lines() {
712 let line = line.context("reading stdout line")?;
713 println!("{line}");
714 output.push_str(&line);
715 output.push('\n');
716 }
717
718 let status = child.wait().context("waiting for edict run agent")?;
719 if status.success() {
720 Ok(output)
721 } else {
722 let code = status.code().unwrap_or(-1);
723 anyhow::bail!("edict run agent exited with code {code}")
724 }
725}
726
727fn extract_iteration_summary(output: &str) -> Option<String> {
729 let start_tag = "<iteration-summary>";
730 let end_tag = "</iteration-summary>";
731 let start = output.find(start_tag)? + start_tag.len();
732 let end = output[start..].find(end_tag)? + start;
733 Some(output[start..end].trim().to_string())
734}
735
736fn get_commits_since_origin() -> Vec<String> {
738 let output = Tool::new("git")
739 .args(&["log", "--oneline", "origin/main..main"])
740 .in_workspace("default")
741 .ok()
742 .and_then(|t| t.run().ok());
743
744 match output {
745 Some(o) if o.success() => o
746 .stdout
747 .lines()
748 .filter(|l| !l.is_empty())
749 .map(String::from)
750 .collect(),
751 _ => Vec::new(),
752 }
753}
754
755fn cleanup(agent: &str, project: &str) -> anyhow::Result<()> {
757 eprintln!("Cleaning up...");
758
759 kill_child_workers(agent);
761
762 let _ = Tool::new("bus")
769 .args(&[
770 "send",
771 "--agent",
772 agent,
773 project,
774 &format!("Dev agent {agent} signing off."),
775 "-L",
776 "agent-idle",
777 ])
778 .new_process_group()
779 .run();
780
781 let _ = Tool::new("bus")
783 .args(&["statuses", "clear", "--agent", agent])
784 .new_process_group()
785 .run();
786
787 let _ = Tool::new("bus")
789 .args(&[
790 "claims",
791 "release",
792 "--agent",
793 agent,
794 &format!("workspace://{project}/default"),
795 ])
796 .new_process_group()
797 .run();
798
799 let _ = Tool::new("bus")
801 .args(&[
802 "claims",
803 "release",
804 "--agent",
805 agent,
806 &format!("agent://{agent}"),
807 ])
808 .new_process_group()
809 .run();
810
811 let _ = Tool::new("bus")
813 .args(&["claims", "release", "--agent", agent, "--all"])
814 .new_process_group()
815 .run();
816
817 eprintln!("Cleanup complete for {agent}.");
820 Ok(())
821}
822
823fn is_systemd_dbus_available() -> bool {
830 if std::env::var("DBUS_SESSION_BUS_ADDRESS").is_ok() {
831 return true;
832 }
833 if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
834 if std::path::Path::new(&xdg).join("bus").exists() {
835 return true;
836 }
837 }
838 false
839}
840
841fn kill_child_workers(agent: &str) {
843 let output = Tool::new("vessel").args(&["list", "--format", "json"]).run();
844
845 let output = match output {
846 Ok(o) if o.success() => o,
847 _ => return,
848 };
849
850 let parsed: serde_json::Value = serde_json::from_str(&output.stdout).unwrap_or_default();
851 let agents = parsed["agents"].as_array().cloned().unwrap_or_default();
852 let prefix = format!("{agent}/");
853
854 for a in &agents {
855 let name = a["id"].as_str().or(a["name"].as_str()).unwrap_or("");
856 if name.starts_with(&prefix) {
857 if let Err(_) = Tool::new("vessel").args(&["kill", name]).run() {
858 }
860 eprintln!("Killed child worker: {name}");
861 }
862 }
863}
864
865#[cfg(test)]
866mod tests {
867 use super::*;
868
869 #[test]
870 fn parse_ready_count_assignments_envelope() {
871 let json = r#"{"mode": "balanced", "assignments": [{"agent_slot": 1, "id": "bn-3smm"}]}"#;
873 assert_eq!(parse_ready_count(json), 1);
874 }
875
876 #[test]
877 fn parse_ready_count_assignments_multiple() {
878 let json = r#"{"mode": "balanced", "assignments": [{"agent_slot": 1, "id": "bn-abc"}, {"agent_slot": 2, "id": "bn-def"}]}"#;
879 assert_eq!(parse_ready_count(json), 2);
880 }
881
882 #[test]
883 fn parse_ready_count_empty() {
884 assert_eq!(parse_ready_count(r#"{"mode": "balanced", "assignments": []}"#), 0);
885 assert_eq!(parse_ready_count("{}"), 0);
886 assert_eq!(parse_ready_count("[]"), 0);
887 assert_eq!(parse_ready_count(""), 0);
888 assert_eq!(parse_ready_count("null"), 0);
889 }
890
891 #[test]
892 fn parse_inbox_count_total_unread() {
893 let json = r#"{"total_unread": 3}"#;
894 assert_eq!(parse_inbox_count(json), 3);
895 }
896
897 #[test]
898 fn parse_inbox_count_bare_number() {
899 assert_eq!(parse_inbox_count("5"), 5);
900 }
901}