Skip to main content

edict/commands/dev_loop/
mod.rs

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
26/// Run the dev-loop (lead agent).
27///
28/// Triages work, dispatches parallel workers, monitors progress,
29/// merges completed work, and manages releases.
30pub 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    // Set AGENT and BOTBUS_AGENT env so spawned tools resolve identity correctly
41    // SAFETY: single-threaded at this point in startup, before spawning any threads
42    unsafe {
43        std::env::set_var("AGENT", &agent);
44        std::env::set_var("BOTBUS_AGENT", &agent);
45    }
46
47    // Apply config [env] vars to our own process so tools we invoke (cargo, etc.) inherit them
48    for (k, v) in config.resolved_env() {
49        // SAFETY: single-threaded at startup
50        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    // Confirm identity
131    Tool::new("bus")
132        .args(&["whoami", "--agent", &agent])
133        .run_ok()
134        .context("confirming agent identity")?;
135
136    // Stake agent claim (ignore failure — may already be held)
137    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    // Announce
150    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    // Set starting status
163    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    // Capture baseline commits for release tracking
176    let baseline_commits = get_commits_since_origin();
177
178    // Initialize journal
179    let journal = Journal::new(&project_root);
180    journal.truncate();
181
182    // Install signal handler for cleanup
183    let cleanup_agent = agent.clone();
184    let cleanup_project = project.clone();
185    let _ = ctrlc::set_handler(move || {
186        // Best-effort cleanup on signal
187        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    // Main loop
196    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        // Refresh agent claim TTL
205        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        // Guard: if a review is pending, don't run Claude — just wait
252        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        // Build prompt and run Claude
270        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                // Check completion signals in the tail of the output
289                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                    // Verify work actually remains
302                    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                // Extract and append iteration summary to journal
311                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                // Continue on non-fatal errors
337            }
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    // Show commits that landed this session
351    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
368/// Context shared across the dev-loop iteration.
369pub 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    /// Pre-resolved env vars from config [env] section.
384    pub spawn_env: std::collections::HashMap<String, String>,
385    /// Memory limit for worker agents (e.g. "4G"). Passed as --memory-limit to vessel spawn.
386    pub worker_memory_limit: Option<String>,
387}
388
389/// Info about a sibling lead agent.
390pub struct SiblingLead {
391    pub name: String,
392    pub memo: String,
393}
394
395/// Resolve the project root directory.
396fn 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
403/// Load config from .edict.toml/.botbox.toml (checking both project root and ws/default/).
404/// Returns (config, config_dir) where config_dir is the directory containing the config file.
405fn 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
410/// Resolve the agent name from config or generate one.
411fn 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    // Generate a name via bus
420    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
427/// Resolve the model for the lead dev, expanding tier names.
428fn 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
445/// Get the raw worker model config value (tier name or explicit model).
446///
447/// Returns the unresolved value so the lead prompt can show tier names
448/// like "fast"/"balanced"/"strong". The worker loop resolves them at runtime
449/// through the tier pool for cross-provider load balancing.
450fn 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
458/// Check if there is any work to do (inbox, claims, ready bones).
459fn has_work(agent: &str, project: &str) -> anyhow::Result<bool> {
460    // Check claims (bone:// or workspace:// means active work)
461    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    // Check inbox
486    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    // Check ready bones
507    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
522/// Parse inbox count from JSON response.
523fn 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
535/// Parse ready bones count from JSON response.
536///
537/// `bn next --json` returns `{"mode": "...", "assignments": [...]}` (bones v0.17.5+).
538fn 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
556/// Check if there's a pending review that should block running Claude.
557fn has_pending_review(agent: &str) -> anyhow::Result<Option<String>> {
558    // Get in-progress bones owned by this agent
559    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        // Has a review but no completion — pending
610        return Ok(Some(id.to_string()));
611    }
612
613    Ok(None)
614}
615
616/// Parse comment bodies from JSON output.
617fn 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
636/// Discover sibling lead agents (multi-lead mode).
637fn 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    // Extract base agent name (strip /N suffix)
650    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
681/// Run agent via `edict run agent` (Pi by default).
682fn run_agent_subprocess(prompt: &str, model: &str, timeout_secs: u64) -> anyhow::Result<String> {
683    let mut args = vec!["run", "agent", prompt];
684
685    // Pass the full model string (e.g. "anthropic/claude-sonnet-4-6:medium") — Pi handles :suffix natively
686    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    // Spawn the process, streaming stdout through
696    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
727/// Extract iteration summary from Claude output.
728fn 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
736/// Get commits on main since origin (for release tracking).
737fn 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
755/// Cleanup: kill child workers, release claims.
756fn cleanup(agent: &str, project: &str) -> anyhow::Result<()> {
757    eprintln!("Cleaning up...");
758
759    // Kill child workers
760    kill_child_workers(agent);
761
762    // All subprocess spawns below use .new_process_group() so they run in their
763    // own process group and survive the SIGTERM that triggered this cleanup
764    // (vessel kill sends SIGTERM to the parent's process group, which would
765    // otherwise kill these children before they complete).
766
767    // Sign off
768    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    // Clear status
782    let _ = Tool::new("bus")
783        .args(&["statuses", "clear", "--agent", agent])
784        .new_process_group()
785        .run();
786
787    // Release merge mutex if held
788    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    // Release agent claim
800    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    // Release all remaining claims
812    let _ = Tool::new("bus")
813        .args(&["claims", "release", "--agent", agent, "--all"])
814        .new_process_group()
815        .run();
816
817    // bn is event-sourced — no sync step needed
818
819    eprintln!("Cleanup complete for {agent}.");
820    Ok(())
821}
822
823/// Check whether the systemd user session D-Bus is available.
824///
825/// `--memory-limit` passes resource limits via systemd transient scopes, which requires
826/// D-Bus. When vessel-spawned agents don't inherit the session D-Bus address (e.g. because
827/// `$DBUS_SESSION_BUS_ADDRESS` / `$XDG_RUNTIME_DIR` were not forwarded), the spawn fails
828/// immediately with a "Failed to connect to user scope bus" error.
829fn 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
841/// Kill child workers spawned by this dev-loop (hierarchical name pattern: AGENT/suffix).
842fn 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                // Worker may have already exited
859            }
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        // bn next --json format since bones v0.17.5
872        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}