1pub mod auto_merge;
16pub mod channel_handler;
17pub mod cleanup;
18pub mod commands;
19pub mod cooldown;
20pub mod dependencies;
21pub mod dispatch_guard;
22pub mod events;
23pub mod jobs;
24pub mod relay;
25pub mod review;
26pub mod review_poll;
27pub mod router;
28pub mod runner;
29pub mod subscribers;
30pub mod suspend;
31pub mod sync;
32pub mod tasks;
33pub mod tick;
34
35pub fn orch_footer() -> &'static str {
40 "\n\n---\n*Posted by [Orch](https://github.com/gabrielkoerich/orch)*"
41}
42
43pub fn attribution_footer(verb: &str, agent: &str, model: Option<&str>) -> String {
47 match model {
48 Some(m) => format!(
49 "\n\n---\n*{} by {}[bot] via [Orch](https://github.com/gabrielkoerich/orch) using `{}`*",
50 verb, agent, m
51 ),
52 None => format!(
53 "\n\n---\n*{} by {}[bot] via [Orch](https://github.com/gabrielkoerich/orch)*",
54 verb, agent
55 ),
56 }
57}
58
59use crate::backends::{ExternalBackend, ExternalId};
60use crate::channels::capture::CaptureService;
61use crate::channels::discord_ws::DiscordGateway;
62use crate::channels::github::start_webhook_server;
63use crate::channels::notification::NotificationLevel;
64use crate::channels::routing::{ChannelRouter, GlobalChannelConfig, ProjectChannelConfig};
65use crate::channels::slack::SlackChannel;
66use crate::channels::telegram::TelegramChannel;
67use crate::channels::tmux::TmuxChannel;
68use crate::channels::transport::Transport;
69use crate::channels::{Channel, ChannelRegistry, IncomingMessage, OutgoingMessage};
70use crate::cmd::CommandErrorContext;
71use crate::config;
72use crate::engine::cleanup::remove_worktree_and_branch;
73use crate::engine::router::{Router, RouterConfig};
74use crate::engine::runner::worktree::{
75 abort_worktree_rebase, list_project_worktrees, rebase_worktree_on_origin_main,
76 task_id_from_worktree_name, validate_worktree_gitdir,
77};
78use crate::engine::tasks::TaskManager;
79use crate::github::http::{rate_limit_metrics, GhHttp};
80use crate::repo_context::REPO_CONTEXT;
81use crate::store::{review_session_expected, set_review_session_expected};
82use crate::store::{TaskStatus, TaskStore};
83use crate::tmux::TmuxManager;
84use runner::WeightSignal;
85use std::sync::Arc;
87use tokio::process::Command;
88use tokio::sync::{mpsc, Notify, RwLock, Semaphore};
89
90use crate::backends::Status;
91
92type EngineRef = (
94 String,
95 Arc<dyn ExternalBackend>,
96 Arc<TaskManager>,
97 Option<Arc<TaskStore>>,
98);
99
100#[derive(Clone)]
105pub struct ProjectEngine {
106 pub repo: String,
107 pub project_dir: std::path::PathBuf,
108 pub backend: Arc<dyn ExternalBackend>,
109 pub task_manager: Arc<TaskManager>,
110 pub runner: Arc<runner::TaskRunner>,
111 pub store: Arc<TaskStore>,
112}
113
114fn start_relay_client(project_engines: &[ProjectEngine], notify: &Arc<Notify>) {
117 let url = crate::config::get("relay.url").unwrap_or_default();
118 let Some(store) = project_engines.first().map(|e| Arc::clone(&e.store)) else {
119 return;
120 };
121
122 if url.is_empty() {
123 return;
124 }
125
126 let token_ref = crate::config::get("relay.token").unwrap_or_default();
127 let notify = Arc::clone(notify);
128 tokio::spawn(async move {
129 let mut warned = false;
130 let token = loop {
131 let reference = token_ref.clone();
132 let read = tokio::task::spawn_blocking(move || crate::secret_ref::resolve(&reference))
133 .await
134 .map_err(anyhow::Error::from)
135 .and_then(|r| r);
136
137 match read {
138 Ok(token) if !token.is_empty() => break token,
139 Ok(_) => {
140 tracing::warn!("relay.token is empty, the relay client stays off");
141 return;
142 }
143 Err(e) if !warned => {
144 tracing::warn!(err = %e, "could not read relay.token, retrying every 5 minutes");
145 warned = true;
146 }
147 Err(_) => {}
148 }
149
150 tokio::time::sleep(std::time::Duration::from_secs(300)).await;
151 };
152
153 relay::run(url, token, store, notify).await;
154 });
155}
156
157#[allow(clippy::too_many_arguments)]
163fn spawn_sync_pass(
164 project_engines: &[ProjectEngine],
165 tmux: &Arc<TmuxManager>,
166 engine_config: &EngineConfig,
167 router: &Arc<RwLock<Router>>,
168 dispatching: &Arc<dashmap::DashMap<String, String>>,
169 auto_merge_in_flight: &Arc<dashmap::DashSet<String>>,
170 sync_in_progress: &Arc<std::sync::atomic::AtomicBool>,
171 trigger: &'static str,
172) {
173 let engines = project_engines.to_vec();
174 let tmux = Arc::clone(tmux);
175 let engine_config = engine_config.clone();
176 let router = Arc::clone(router);
177 let dispatching = Arc::clone(dispatching);
178 let auto_merge_in_flight = Arc::clone(auto_merge_in_flight);
179 let guard = Arc::clone(sync_in_progress);
180
181 tokio::spawn(async move {
182 let started = std::time::Instant::now();
183 let mut passes = Vec::with_capacity(engines.len());
184 for engine in &engines {
185 passes.push(REPO_CONTEXT.scope(
186 engine.repo.clone(),
187 sync_one_project(
188 engine,
189 &tmux,
190 &engine_config,
191 &router,
192 &dispatching,
193 &auto_merge_in_flight,
194 ),
195 ));
196 }
197 futures::future::join_all(passes).await;
198
199 sync::sync_shared_tail(&router, engines.first().map(|e| &e.store)).await;
200 tracing::info!(
201 trigger,
202 elapsed_ms = started.elapsed().as_millis() as u64,
203 "sync tick complete"
204 );
205 guard.store(false, std::sync::atomic::Ordering::Release);
206 });
207}
208
209async fn sync_one_project(
210 engine: &ProjectEngine,
211 tmux: &Arc<TmuxManager>,
212 engine_config: &EngineConfig,
213 router: &Arc<RwLock<Router>>,
214 dispatching: &Arc<dashmap::DashMap<String, String>>,
215 auto_merge_in_flight: &Arc<dashmap::DashSet<String>>,
216) {
217 if let Err(e) = sync::sync_tick(
218 &engine.backend,
219 tmux,
220 &engine.repo,
221 engine_config,
222 router,
223 &engine.task_manager,
224 &engine.store,
225 dispatching,
226 auto_merge_in_flight,
227 )
228 .await
229 {
230 tracing::error!(repo = %engine.repo, ?e, "sync tick failed for project");
231 }
232}
233
234const SLOW_PHASE_GROUP_MS: u128 = 1_000;
236
237pub(crate) fn log_phase_timings(group: &str, repo: &str, phases: &[(&str, u128)]) {
243 let total_ms: u128 = phases.iter().map(|(_, ms)| ms).sum();
244 if total_ms >= SLOW_PHASE_GROUP_MS {
245 tracing::info!(group, repo, total_ms, ?phases, "phase timings");
246 } else {
247 tracing::debug!(group, repo, total_ms, ?phases, "phase timings");
248 }
249}
250
251pub fn configured_agents() -> Vec<String> {
263 if let Ok(agents_str) = crate::config::get("agents") {
264 if !agents_str.is_empty() && agents_str != "[]" {
265 if let Ok(agents_arr) = serde_norway::from_str::<Vec<String>>(&agents_str) {
268 if !agents_arr.is_empty() {
269 return agents_arr;
270 }
271 }
272 if let Ok(agents_arr) = serde_json::from_str::<Vec<String>>(&agents_str) {
274 if !agents_arr.is_empty() {
275 return agents_arr;
276 }
277 }
278 }
279 }
280 router::config::DEFAULT_AGENTS
281 .iter()
282 .map(|s| s.to_string())
283 .collect()
284}
285
286#[derive(Clone)]
288pub struct EngineConfig {
289 pub tick_interval: std::time::Duration,
291 pub sync_interval: std::time::Duration,
293 pub webhook_health_check_interval: Option<std::time::Duration>,
295 pub max_parallel: usize,
297 pub stuck_timeout: u64,
299 pub no_session_stuck_timeout: u64,
302 pub in_review_no_session_stuck_timeout: u64,
309 pub auto_create_followup_on_changes: bool,
311 pub graceful_shutdown_timeout: std::time::Duration,
313 pub silence_grace_period: u64,
316 pub silence_cooldown: u64,
318 pub upgrade_check_interval: u64,
321 pub startup_failure_escalation_secs: u64,
326}
327
328impl Default for EngineConfig {
329 fn default() -> Self {
330 Self {
331 tick_interval: std::time::Duration::from_secs(10),
332 sync_interval: std::time::Duration::from_secs(45),
333 webhook_health_check_interval: Some(std::time::Duration::from_secs(60)),
334 max_parallel: 4,
335 stuck_timeout: 1800,
336 no_session_stuck_timeout: 600,
337 in_review_no_session_stuck_timeout: 1800,
338 auto_create_followup_on_changes: true,
339 graceful_shutdown_timeout: std::time::Duration::from_secs(600),
340 silence_grace_period: 300,
341 silence_cooldown: 3600,
342 upgrade_check_interval: 3600,
343 startup_failure_escalation_secs: 3600,
344 }
345 }
346}
347
348impl EngineConfig {
349 pub fn from_config() -> Self {
351 let mut config = Self::default();
352
353 if let Ok(val) = crate::config::get("engine.tick_interval") {
354 if let Ok(secs) = val.parse::<u64>() {
355 config.tick_interval = std::time::Duration::from_secs(secs);
356 } else {
357 tracing::warn!(key = "engine.tick_interval", value = %val, default_secs = config.tick_interval.as_secs(), "invalid value for engine.tick_interval, using default");
358 }
359 }
360
361 if let Ok(val) = crate::config::get("engine.sync_interval") {
362 if let Ok(secs) = val.parse::<u64>() {
363 config.sync_interval = std::time::Duration::from_secs(secs);
364 } else {
365 tracing::warn!(key = "engine.sync_interval", value = %val, default_secs = config.sync_interval.as_secs(), "invalid value for engine.sync_interval, using default");
366 }
367 }
368
369 if let Ok(val) = crate::config::get("engine.max_parallel") {
370 if let Ok(n) = val.parse::<usize>() {
371 config.max_parallel = n;
372 } else {
373 tracing::warn!(key = "engine.max_parallel", value = %val, default = config.max_parallel, "invalid value for engine.max_parallel, using default");
374 }
375 }
376
377 if let Ok(val) = crate::config::get("engine.stuck_timeout") {
378 if let Ok(secs) = val.parse::<u64>() {
379 config.stuck_timeout = secs;
380 } else {
381 tracing::warn!(key = "engine.stuck_timeout", value = %val, default_secs = config.stuck_timeout, "invalid value for engine.stuck_timeout, using default");
382 }
383 }
384
385 if let Ok(val) = crate::config::get("engine.no_session_stuck_timeout") {
386 if let Ok(secs) = val.parse::<u64>() {
387 config.no_session_stuck_timeout = secs;
388 } else {
389 tracing::warn!(key = "engine.no_session_stuck_timeout", value = %val, default_secs = config.no_session_stuck_timeout, "invalid value for engine.no_session_stuck_timeout, using default");
390 }
391 }
392
393 if let Ok(val) = crate::config::get("engine.in_review_no_session_stuck_timeout") {
394 if let Ok(secs) = val.parse::<u64>() {
395 config.in_review_no_session_stuck_timeout = secs;
396 } else {
397 tracing::warn!(key = "engine.in_review_no_session_stuck_timeout", value = %val, default_secs = config.in_review_no_session_stuck_timeout, "invalid value for engine.in_review_no_session_stuck_timeout, using default");
398 }
399 }
400
401 if let Ok(val) = crate::config::get("engine.webhook_health_check_interval") {
402 if let Ok(secs) = val.parse::<u64>() {
403 if secs > 0 {
404 config.webhook_health_check_interval =
405 Some(std::time::Duration::from_secs(secs));
406 } else {
407 config.webhook_health_check_interval = None;
408 }
409 } else {
410 tracing::warn!(key = "engine.webhook_health_check_interval", value = %val, default = ?config.webhook_health_check_interval, "invalid value for engine.webhook_health_check_interval, using default");
411 }
412 }
413
414 if let Ok(val) = crate::config::get("workflow.auto_create_followup_on_changes") {
415 config.auto_create_followup_on_changes = !val.eq_ignore_ascii_case("false");
416 }
417
418 if let Ok(val) = crate::config::get("engine.graceful_shutdown_timeout") {
419 if let Ok(secs) = val.parse::<u64>() {
420 config.graceful_shutdown_timeout = std::time::Duration::from_secs(secs);
421 } else {
422 tracing::warn!(key = "engine.graceful_shutdown_timeout", value = %val, default_secs = config.graceful_shutdown_timeout.as_secs(), "invalid value for engine.graceful_shutdown_timeout, using default");
423 }
424 }
425
426 if let Ok(val) = crate::config::get("engine.silence_grace_period") {
427 if let Ok(secs) = val.parse::<u64>() {
428 config.silence_grace_period = secs;
429 } else {
430 tracing::warn!(key = "engine.silence_grace_period", value = %val, default = config.silence_grace_period, "invalid value for engine.silence_grace_period, using default");
431 }
432 }
433
434 if let Ok(val) = crate::config::get("engine.silence_cooldown") {
435 if let Ok(secs) = val.parse::<u64>() {
436 config.silence_cooldown = secs;
437 } else {
438 tracing::warn!(key = "engine.silence_cooldown", value = %val, default = config.silence_cooldown, "invalid value for engine.silence_cooldown, using default");
439 }
440 }
441
442 if let Ok(val) = crate::config::get("engine.upgrade_check_interval") {
443 if let Ok(secs) = val.parse::<u64>() {
444 config.upgrade_check_interval = secs;
445 } else {
446 tracing::warn!(key = "engine.upgrade_check_interval", value = %val, default = config.upgrade_check_interval, "invalid value for engine.upgrade_check_interval, using default");
447 }
448 }
449
450 if let Ok(val) = crate::config::get("engine.startup_failure_escalation_secs") {
451 if let Ok(secs) = val.parse::<u64>() {
452 config.startup_failure_escalation_secs = secs;
453 } else {
454 tracing::warn!(
455 key = "engine.startup_failure_escalation_secs",
456 value = %val,
457 default_secs = config.startup_failure_escalation_secs,
458 "invalid value for engine.startup_failure_escalation_secs, using default"
459 );
460 }
461 }
462
463 config
464 }
465}
466
467pub const RATE_LIMITED_UNTIL_KEY: &str = "github:rate_limited_until";
470
471pub fn format_rate_limited_until(reset_at: i64) -> String {
473 chrono::DateTime::from_timestamp(reset_at, 0)
474 .map(|t| {
475 t.with_timezone(&chrono::Local)
476 .format("%H:%M:%S %Z")
477 .to_string()
478 })
479 .unwrap_or_else(|| reset_at.to_string())
480}
481
482async fn report_rate_limited(
485 reset_at: i64,
486 store: Option<&Arc<TaskStore>>,
487 transport: Option<&Arc<crate::channels::transport::Transport>>,
488) {
489 let m = rate_limit_metrics();
490 let until = format_rate_limited_until(reset_at);
491 tracing::warn!(
492 reset_at,
493 rate_limit_hits = m.hits,
494 skipped_calls = m.skipped_calls,
495 five_xx_hits = m.five_xx_hits,
496 rest_remaining = ?m.rest_remaining,
497 graphql_remaining = ?m.graphql_remaining,
498 "GitHub API rate limited until {until} — skipping GitHub calls, local work continues"
499 );
500
501 if let Some(store) = store {
502 if let Err(e) = store
503 .kv_set(RATE_LIMITED_UNTIL_KEY, &reset_at.to_string())
504 .await
505 {
506 tracing::warn!(err = %e, "failed to persist GitHub rate-limit flag");
507 }
508 }
509
510 if let Some(transport) = transport {
511 transport.push_notification(crate::channels::notification::TaskNotification {
512 task_id: "github".to_string(),
513 title: "GitHub rate limit".to_string(),
514 status: "blocked".to_string(),
515 agent: "orch".to_string(),
516 duration_seconds: 0.0,
517 summary: format!(
518 "rate limited until {until}. GitHub calls are skipped until then; running agents and local work continue."
519 ),
520 repo: None,
521 notify_target: None,
522 pr_number: None,
523 });
524 }
525}
526
527fn resize_dispatch_pool(semaphore: &Semaphore, in_force: usize, target: usize) -> usize {
533 if target > in_force {
534 semaphore.add_permits(target - in_force);
535 return target;
536 }
537
538 if target < in_force {
539 return in_force - semaphore.forget_permits(in_force - target);
540 }
541
542 in_force
543}
544
545const RESTART_ONLY_KEYS: [&str; 3] = ["channels", "webhook", "relay"];
548
549fn restart_only_snapshot() -> Vec<Option<String>> {
551 RESTART_ONLY_KEYS
552 .iter()
553 .map(|key| crate::config::get(key).ok())
554 .collect()
555}
556
557fn log_restart_only_changes(old: &[Option<String>], new: &[Option<String>]) -> Vec<&'static str> {
560 let changed: Vec<&'static str> = RESTART_ONLY_KEYS
561 .iter()
562 .zip(old)
563 .zip(new)
564 .filter(|((_, old), new)| old != new)
565 .map(|((key, _), _)| *key)
566 .collect();
567
568 for key in &changed {
569 tracing::info!(key = key, "config changed, takes effect after restart");
570 }
571
572 changed
573}
574
575fn log_engine_config_changes(old: &EngineConfig, new: &EngineConfig) {
578 macro_rules! log_changed {
579 ($($key:literal => $field:ident),+ $(,)?) => {
580 $(if old.$field != new.$field {
581 tracing::info!(key = $key, old = ?old.$field, new = ?new.$field, "config changed");
582 })+
583 };
584 }
585
586 log_changed!(
587 "engine.tick_interval" => tick_interval,
588 "engine.sync_interval" => sync_interval,
589 "engine.webhook_health_check_interval" => webhook_health_check_interval,
590 "engine.max_parallel" => max_parallel,
591 "engine.stuck_timeout" => stuck_timeout,
592 "engine.no_session_stuck_timeout" => no_session_stuck_timeout,
593 "engine.in_review_no_session_stuck_timeout" => in_review_no_session_stuck_timeout,
594 "engine.graceful_shutdown_timeout" => graceful_shutdown_timeout,
595 "engine.silence_grace_period" => silence_grace_period,
596 "engine.silence_cooldown" => silence_cooldown,
597 "engine.upgrade_check_interval" => upgrade_check_interval,
598 "engine.startup_failure_escalation_secs" => startup_failure_escalation_secs,
599 "workflow.auto_create_followup_on_changes" => auto_create_followup_on_changes,
600 );
601}
602
603fn should_run_upgrade_check(
604 last_upgrade_check: Option<std::time::Instant>,
605 upgrade_check_interval: u64,
606) -> bool {
607 if upgrade_check_interval == 0 {
608 return false;
609 }
610
611 last_upgrade_check
612 .map(|last| last.elapsed() >= std::time::Duration::from_secs(upgrade_check_interval))
613 .unwrap_or(true)
614}
615
616async fn connect_project_engine(
623 repo: String,
624 project_dir: std::path::PathBuf,
625 store: Arc<TaskStore>,
626 event_tx: Option<tokio::sync::broadcast::Sender<events::TaskEvent>>,
627) -> anyhow::Result<ProjectEngine> {
628 let backend: Arc<dyn ExternalBackend> =
629 Arc::new(crate::backends::github::GitHubBackend::new(repo.clone())?);
630
631 match backend.health_check().await {
635 Ok(()) => tracing::info!(repo = %repo, backend = backend.name(), "backend connected"),
636 Err(e) => tracing::warn!(
637 repo = %repo,
638 error = %e,
639 "GitHub health check failed for {repo}, starting engine anyway (rate limited, offline or bad credentials?)"
640 ),
641 }
642
643 let task_manager = Arc::new(match event_tx {
644 Some(tx) => TaskManager::with_events(backend.clone(), store.clone(), repo.clone(), tx),
645 None => TaskManager::with_store(backend.clone(), store.clone(), repo.clone()),
646 });
647 let runner = Arc::new(runner::TaskRunner::new(repo.clone()).with_store(store.clone()));
648
649 Ok(ProjectEngine {
650 repo,
651 project_dir,
652 backend,
653 task_manager,
654 runner,
655 store,
656 })
657}
658
659fn diff_configured_projects(
666 configured: &[(String, std::path::PathBuf)],
667 active_repos: &std::collections::HashSet<String>,
668) -> (Vec<(String, std::path::PathBuf)>, Vec<String>) {
669 let configured_repos: std::collections::HashSet<&str> =
670 configured.iter().map(|(r, _)| r.as_str()).collect();
671
672 let added = configured
673 .iter()
674 .filter(|(repo, _)| !active_repos.contains(repo))
675 .cloned()
676 .collect();
677 let removed = active_repos
678 .iter()
679 .filter(|repo| !configured_repos.contains(repo.as_str()))
680 .cloned()
681 .collect();
682
683 (added, removed)
684}
685
686#[allow(clippy::too_many_arguments)]
692async fn sync_project_engines(
693 project_engines: &mut Vec<ProjectEngine>,
694 active_repos: &mut std::collections::HashSet<String>,
695 event_bus: &events::EventBus,
696 tmux: &Arc<TmuxManager>,
697 capture: &Arc<CaptureService>,
698 semaphore: &Arc<Semaphore>,
699 weight_tx: &mpsc::Sender<WeightSignal>,
700 router: &Arc<RwLock<Router>>,
701 dispatching: &Arc<dashmap::DashMap<String, String>>,
702) {
703 let configured = match config::get_projects_with_paths() {
704 Ok(p) if !p.is_empty() => p,
705 Ok(_) => return,
706 Err(e) => {
707 tracing::warn!(err = %e, "could not re-read the project list, keeping the current one");
708 return;
709 }
710 };
711 let Some(store) = project_engines.first().map(|e| e.store.clone()) else {
712 return;
713 };
714
715 let (added, removed) = diff_configured_projects(&configured, active_repos);
716 for repo in removed {
717 tracing::info!(
718 repo = %repo,
719 "project removed from config, takes effect after restart"
720 );
721 }
722
723 for (repo, project_dir) in added {
724 config::watch(&project_dir.join(".orch.yml"));
725 let engine = match connect_project_engine(
726 repo.clone(),
727 project_dir,
728 store.clone(),
729 Some(event_bus.sender()),
730 )
731 .await
732 {
733 Ok(engine) => engine,
734 Err(e) => {
735 tracing::warn!(
738 repo = %repo,
739 err = %e,
740 "project added to config but its backend could not be built, retrying on the next config change"
741 );
742 continue;
743 }
744 };
745
746 subscribers::dispatch::spawn(
747 event_bus.subscribe(),
748 engine.backend.clone(),
749 tmux.clone(),
750 engine.runner.clone(),
751 capture.clone(),
752 semaphore.clone(),
753 engine.task_manager.clone(),
754 weight_tx.clone(),
755 router.clone(),
756 dispatching.clone(),
757 engine.store.clone(),
758 engine.repo.clone(),
759 );
760 subscribers::review::spawn(
761 event_bus.subscribe(),
762 engine.backend.clone(),
763 tmux.clone(),
764 semaphore.clone(),
765 engine.task_manager.clone(),
766 router.clone(),
767 dispatching.clone(),
768 engine.store.clone(),
769 engine.repo.clone(),
770 );
771 subscribers::unblock::spawn(
772 event_bus.subscribe(),
773 engine.backend.clone(),
774 engine.task_manager.clone(),
775 engine.store.clone(),
776 engine.repo.clone(),
777 );
778
779 active_repos.insert(repo.clone());
780 project_engines.push(engine);
781 tracing::info!(repo = %repo, "project added from config, now syncing");
782 }
783}
784
785async fn init_project_engines() -> anyhow::Result<Vec<ProjectEngine>> {
789 let projects = config::get_projects_with_paths()?;
790 let repos: Vec<&str> = projects.iter().map(|(r, _)| r.as_str()).collect();
791 tracing::info!(repos = ?repos, "loading projects from config");
792
793 if projects.is_empty() {
794 let config_path = crate::home::config_path()
795 .map(|p| p.display().to_string())
796 .unwrap_or_else(|_| "~/.orch/config.yml".to_string());
797 anyhow::bail!(
798 "no projects configured — add repos under `projects:` in {}",
799 config_path
800 );
801 }
802
803 let mut engines = Vec::new();
804
805 let store = Arc::new(TaskStore::open(&crate::store::default_db_path().await?).await?);
808 if crate::cluster::enabled() {
809 crate::cluster::start(&store, &crate::home::orch_home()?.join("raft.db")).await?;
810 crate::cluster::local::serve((*store).clone()).await?;
811 }
812
813 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
816
817 for (repo, project_dir) in projects {
818 tracing::info!(repo = %repo, "initializing project engine");
819 config::watch(&project_dir.join(".orch.yml"));
820
821 engines.push(connect_project_engine(repo, project_dir, store.clone(), None).await?);
822 }
823
824 Ok(engines)
825}
826
827async fn reconcile_startup_worktrees(project_engines: &[ProjectEngine]) -> anyhow::Result<()> {
828 for engine in project_engines {
829 let repo_root =
830 crate::engine::runner::worktree::resolve_main_repo(&engine.project_dir).await;
831 let repo_root_path = std::path::PathBuf::from(&repo_root);
832 let default_branch =
833 crate::engine::runner::worktree::detect_default_branch(&repo_root_path).await;
834 let worktrees = list_project_worktrees(&repo_root_path).await?;
835
836 if let Err(e) = Command::new("git")
839 .args(["-C", &repo_root.to_string_lossy(), "fetch", "origin"])
840 .output_with_context()
841 .await
842 {
843 tracing::warn!(repo = %engine.repo, err = %e, "startup git fetch origin failed, rebases may use stale refs");
844 }
845
846 let mut orphans_removed: u32 = 0;
847 let mut invalid_removed: u32 = 0;
848 let mut cleaned: u32 = 0;
849 let mut valid_kept: u32 = 0;
850
851 for worktree_dir in worktrees {
852 let Some(name) = worktree_dir.file_name().and_then(|n| n.to_str()) else {
853 continue;
854 };
855
856 if !validate_worktree_gitdir(&worktree_dir).await {
859 tracing::warn!(repo = %engine.repo, worktree = %worktree_dir.display(), "worktree has invalid or missing gitdir, removing");
860 remove_worktree_and_branch(name, &worktree_dir, Some(name), &repo_root_path, false)
861 .await;
862 invalid_removed += 1;
863 continue;
864 }
865
866 let Some(task_id) = task_id_from_worktree_name(name) else {
867 tracing::info!(repo = %engine.repo, worktree = %worktree_dir.display(), "orphan worktree without task id, removing");
868 remove_worktree_and_branch(name, &worktree_dir, Some(name), &repo_root_path, false)
869 .await;
870 orphans_removed += 1;
871 continue;
872 };
873
874 let Some(store_id) = engine.store.resolve_task_id(&engine.repo, &task_id).await? else {
875 tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), "worktree has no matching task, removing");
876 remove_worktree_and_branch(
877 &task_id,
878 &worktree_dir,
879 Some(name),
880 &repo_root_path,
881 false,
882 )
883 .await;
884 orphans_removed += 1;
885 continue;
886 };
887
888 let task = engine.store.get(store_id).await?;
889 let branch_name = if task.branch.is_empty() {
890 name
891 } else {
892 task.branch.as_str()
893 };
894
895 match task.status {
896 TaskStatus::New
897 | TaskStatus::Routed
898 | TaskStatus::InProgress
899 | TaskStatus::NeedsReview
900 | TaskStatus::InReview => {
901 tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), "rebasing startup worktree");
902 if let Err(e) =
903 rebase_worktree_on_origin_main(&worktree_dir, &default_branch).await
904 {
905 tracing::warn!(repo = %engine.repo, task_id = %task_id, err = %e, "startup rebase failed, resetting task");
910 abort_worktree_rebase(&worktree_dir).await;
911 let keep_remote = task.pr_number.is_some();
912 remove_worktree_and_branch(
913 &task_id,
914 &worktree_dir,
915 Some(branch_name),
916 &repo_root_path,
917 keep_remote,
918 )
919 .await;
920 match engine.store.resolve_task_id(&engine.repo, &task_id).await {
921 Ok(Some(reset_id)) => {
922 if let Err(e) = engine.store.reset_to_new(reset_id).await {
923 tracing::warn!(task_id, err = %e, "failed to reset task to new after rebase failure");
924 }
925 }
926 Ok(None) => {
927 tracing::warn!(
928 task_id,
929 "task not found in store during reconciliation"
930 );
931 }
932 Err(e) => {
933 tracing::warn!(task_id, err = %e, "failed to resolve task ID during reconciliation");
934 }
935 }
936 if let Err(e) = engine
937 .task_manager
938 .update_task_status(
939 &ExternalId(task_id.clone()),
940 crate::backends::Status::New,
941 )
942 .await
943 {
944 tracing::warn!(task_id, err = %e, "failed to update backend status during reconciliation");
945 }
946 cleaned += 1;
947 } else {
948 valid_kept += 1;
949 }
950 }
951 TaskStatus::Blocked | TaskStatus::Waiting => {
954 valid_kept += 1;
955 }
956 TaskStatus::Done => {
957 tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), status = ?task.status, "terminal task worktree, removing");
958 remove_worktree_and_branch(
959 &task_id,
960 &worktree_dir,
961 Some(branch_name),
962 &repo_root_path,
963 false,
964 )
965 .await;
966 cleaned += 1;
967 }
968 }
969 }
970
971 tracing::info!(
972 repo = %engine.repo,
973 orphans_removed,
974 invalid_removed,
975 cleaned,
976 valid_kept,
977 "worktree reconciliation: {orphans_removed} orphans removed, {invalid_removed} invalid removed, {cleaned} cleaned, {valid_kept} valid kept"
978 );
979
980 let prune = Command::new("git")
983 .args(["-C", &repo_root.to_string_lossy(), "worktree", "prune"])
984 .output_with_context()
985 .await;
986 match prune {
987 Ok(output) if output.status.success() => {
988 tracing::debug!(repo = %engine.repo, "startup worktree prune completed");
989 }
990 Ok(output) => {
991 let stderr = String::from_utf8_lossy(&output.stderr);
992 tracing::warn!(repo = %engine.repo, err = %stderr, "startup worktree prune failed");
993 }
994 Err(e) => {
995 tracing::warn!(repo = %engine.repo, err = %e, "startup worktree prune failed");
996 }
997 }
998 }
999
1000 Ok(())
1001}
1002
1003async fn reconcile_startup_estimates(project_engines: &[ProjectEngine]) {
1011 use crate::backends::ExternalId;
1012
1013 for engine in project_engines {
1014 let tasks = match engine.store.list_external_tasks_with_estimates().await {
1015 Ok(t) => t,
1016 Err(e) => {
1017 tracing::warn!(repo = %engine.repo, err = %e, "startup estimate reconciliation: store query failed");
1018 continue;
1019 }
1020 };
1021
1022 if tasks.is_empty() {
1023 continue;
1024 }
1025
1026 let mut synced: u32 = 0;
1027 let mut failed: u32 = 0;
1028 for (external_id, estimate) in &tasks {
1029 match engine
1030 .backend
1031 .sync_estimate_to_project(&ExternalId(external_id.clone()), *estimate)
1032 .await
1033 {
1034 Ok(()) => synced += 1,
1035 Err(e) => {
1036 tracing::debug!(
1037 repo = %engine.repo,
1038 task_id = %external_id,
1039 estimate,
1040 err = %e,
1041 "startup estimate reconciliation: sync failed"
1042 );
1043 failed += 1;
1044 }
1045 }
1046 }
1047
1048 if synced > 0 || failed > 0 {
1049 tracing::info!(
1050 repo = %engine.repo,
1051 synced,
1052 failed,
1053 "startup estimate reconciliation complete"
1054 );
1055 }
1056 }
1057}
1058
1059fn read_project_channel_config(project_dir: &std::path::Path) -> ProjectChannelConfig {
1061 let config_path = project_dir.join(".orch.yml");
1062 if !config_path.exists() {
1063 return ProjectChannelConfig::default();
1064 }
1065 let content = match std::fs::read_to_string(&config_path) {
1066 Ok(c) => c,
1067 Err(e) => {
1068 tracing::warn!(
1069 path = %config_path.display(),
1070 error = %e,
1071 "failed to read .orch.yml; channel config will use defaults"
1072 );
1073 return ProjectChannelConfig::default();
1074 }
1075 };
1076 let val: serde_norway::Value = match serde_norway::from_str(&content) {
1077 Ok(v) => v,
1078 Err(e) => {
1079 tracing::warn!(
1080 path = %config_path.display(),
1081 error = %e,
1082 "failed to parse .orch.yml; channel config will use defaults"
1083 );
1084 return ProjectChannelConfig::default();
1085 }
1086 };
1087 let channels = match val.get("channels") {
1088 Some(c) => c,
1089 None => return ProjectChannelConfig::default(),
1090 };
1091 let get_str = |section: &str, key: &str| -> Option<String> {
1092 channels
1093 .get(section)
1094 .and_then(|s| s.get(key))
1095 .and_then(|v| v.as_str())
1096 .map(String::from)
1097 };
1098 ProjectChannelConfig {
1099 telegram_topic_id: get_str("telegram", "topic_id"),
1100 telegram_bot_token: get_str("telegram", "bot_token"),
1101 telegram_chat_id: get_str("telegram", "chat_id"),
1102 discord_channel_id: get_str("discord", "channel_id"),
1103 discord_bot_token: get_str("discord", "bot_token"),
1104 discord_guild_id: get_str("discord", "guild_id"),
1105 }
1106}
1107
1108async fn fetch_latest_release_version() -> Option<String> {
1113 let output = Command::new("gh")
1114 .args([
1115 "api",
1116 "repos/gabrielkoerich/orch/releases/latest",
1117 "--jq",
1118 ".tag_name",
1119 ])
1120 .output()
1121 .await
1122 .ok()?;
1123 if !output.status.success() {
1124 return None;
1125 }
1126 let tag = String::from_utf8(output.stdout).ok()?;
1127 let version = tag.trim().trim_start_matches('v').to_string();
1128 if version.is_empty() {
1129 None
1130 } else {
1131 Some(version)
1132 }
1133}
1134
1135async fn check_and_notify_upgrade(
1144 store: &Arc<TaskStore>,
1145 channels: &Arc<ChannelRegistry>,
1146 current_version: &str,
1147 check_interval: std::time::Duration,
1148) {
1149 let last_check_key = "upgrade:last_check_at";
1150 let last_notified_key = "upgrade:last_notified_version";
1151
1152 let now = chrono::Utc::now().timestamp();
1154 if let Ok(Some(last_check_str)) = store.kv_get(last_check_key).await {
1155 if let Ok(last_check) = last_check_str.parse::<i64>() {
1156 if now - last_check < check_interval.as_secs() as i64 {
1157 return;
1158 }
1159 }
1160 }
1161
1162 let _ = store.kv_set(last_check_key, &now.to_string()).await;
1164
1165 let latest = match fetch_latest_release_version().await {
1166 Some(v) => v,
1167 None => {
1168 tracing::debug!("upgrade check: failed to fetch latest release version");
1169 return;
1170 }
1171 };
1172
1173 if latest == current_version {
1174 tracing::debug!(current = %current_version, "orch is up to date");
1175 return;
1176 }
1177
1178 tracing::warn!(
1179 current_version = %current_version,
1180 latest_version = %latest,
1181 "orch upgrade available"
1182 );
1183
1184 if let Ok(Some(last_notified)) = store.kv_get(last_notified_key).await {
1189 if last_notified == latest {
1190 tracing::debug!(latest = %latest, "upgrade already notified");
1191 return;
1192 }
1193 }
1194
1195 let msg_telegram = format!(
1196 "⚠️ <b>Orch Upgrade Available</b>\n\n\
1197 Service: <code>{current_version}</code>\n\
1198 Latest: <code>{latest}</code>\n\n\
1199 Run: <code>brew update && brew upgrade orch && brew services restart orch</code>"
1200 );
1201
1202 let msg_discord = format!(
1203 "⚠️ **Orch Upgrade Available**\n\n\
1204 Service: `{current_version}`\n\
1205 Latest: `{latest}`\n\n\
1206 Run: `brew update && brew upgrade orch && brew services restart orch`"
1207 );
1208
1209 let msg_slack = format!(
1210 "⚠️ *Orch Upgrade Available*\n\n\
1211 Service: `{current_version}`\n\
1212 Latest: `{latest}`\n\n\
1213 Run: `brew update && brew upgrade orch && brew services restart orch`"
1214 );
1215
1216 for channel in channels.iter() {
1217 let (body, metadata, topic_id) = match channel.name() {
1218 "telegram" => (
1219 msg_telegram.clone(),
1220 serde_json::json!({"preformatted_html": true}),
1221 None,
1222 ),
1223 "discord" => (msg_discord.clone(), serde_json::json!({}), None),
1224 "slack" => (msg_slack.clone(), serde_json::json!({}), None),
1225 _ => continue,
1226 };
1227
1228 let msg = OutgoingMessage {
1229 thread_id: "upgrade".to_string(),
1230 body,
1231 reply_to: None,
1232 metadata,
1233 topic_id,
1234 };
1235
1236 if let Err(e) = channel.send(&msg).await {
1237 tracing::warn!(
1238 channel = channel.name(),
1239 ?e,
1240 "failed to send upgrade notification"
1241 );
1242 }
1243 }
1244
1245 let _ = store.kv_set(last_notified_key, &latest).await;
1247}
1248
1249async fn send_startup_escalation_notification(elapsed_secs: u64) {
1257 let duration = crate::channels::notification::format_duration(elapsed_secs as f64);
1258
1259 let msg_telegram = format!(
1260 "🚨 <b>Orch startup blocked — GitHub unreachable</b>\n\n\
1261 The engine has been retrying backend connections for {}\n\
1262 without success. All task processing, routing, and scheduled\n\
1263 jobs are paused. Check service logs with `orch log -f`.",
1264 duration
1265 );
1266
1267 let msg_discord = format!(
1268 "🚨 **Orch startup blocked — GitHub unreachable**\n\n\
1269 The engine has been retrying backend connections for {}\n\
1270 without success. All task processing, routing, and scheduled\n\
1271 jobs are paused. Check service logs with `orch log -f`.",
1272 duration
1273 );
1274
1275 let msg_slack = format!(
1276 "🚨 *Orch startup blocked — GitHub unreachable*\n\n\
1277 The engine has been retrying backend connections for {}\n\
1278 without success. All task processing, routing, and scheduled\n\
1279 jobs are paused. Check service logs with `orch log -f`.",
1280 duration
1281 );
1282
1283 let tg_token = crate::config::get("channels.telegram.bot_token");
1285 let tg_chat_id = crate::config::get("channels.telegram.chat_id");
1286 if let (Ok(token), Ok(chat_id)) = (&tg_token, &tg_chat_id) {
1287 if !token.is_empty() && !chat_id.is_empty() {
1288 if let Ok(channel) = TelegramChannel::new(token.clone(), Some(chat_id.clone())) {
1289 let msg = OutgoingMessage {
1290 thread_id: "startup_escalation".to_string(),
1291 body: msg_telegram.clone(),
1292 reply_to: None,
1293 metadata: serde_json::json!({ "preformatted_html": true }),
1294 topic_id: crate::config::get("channels.telegram.general_topic_id").ok(),
1295 };
1296 if let Err(e) = channel.send(&msg).await {
1297 tracing::warn!(?e, "failed to send startup escalation via telegram");
1298 } else {
1299 tracing::info!("startup escalation notification sent to telegram");
1300 }
1301 }
1302 }
1303 }
1304
1305 if let Ok(token) = crate::config::get("channels.discord.bot_token") {
1307 let channel_id = crate::config::get("channels.discord.channel_id")
1308 .or_else(|_| crate::config::get("channels.discord.general_channel_id"));
1309 if !token.is_empty() {
1310 if let Some(cid) = channel_id.ok().filter(|s| !s.is_empty()) {
1311 let shard_id = crate::config::get("channels.discord.shard_id")
1312 .ok()
1313 .and_then(|v| v.parse::<u64>().ok())
1314 .unwrap_or(0);
1315 let shard_count = crate::config::get("channels.discord.shard_count")
1316 .ok()
1317 .and_then(|v| v.parse::<u64>().ok())
1318 .unwrap_or(1);
1319 if let Ok(gateway) = DiscordGateway::new(token, Some(cid), shard_id, shard_count) {
1320 let msg = OutgoingMessage {
1321 thread_id: "startup_escalation".to_string(),
1322 body: msg_discord.clone(),
1323 reply_to: None,
1324 metadata: serde_json::Value::Null,
1325 topic_id: None,
1326 };
1327 if let Err(e) = gateway.send(&msg).await {
1328 tracing::warn!(?e, "failed to send startup escalation via discord");
1329 } else {
1330 tracing::info!("startup escalation notification sent to discord");
1331 }
1332 }
1333 }
1334 }
1335 }
1336
1337 if let Ok(token) = crate::config::get("channels.slack.bot_token") {
1339 let channel_id = crate::config::get("channels.slack.channel_id");
1340 if !token.is_empty() {
1341 if let Ok(cid) = channel_id {
1342 if let Ok(channel) = SlackChannel::new(token, Some(cid)) {
1343 let msg = OutgoingMessage {
1344 thread_id: "startup_escalation".to_string(),
1345 body: msg_slack.clone(),
1346 reply_to: None,
1347 metadata: serde_json::Value::Null,
1348 topic_id: None,
1349 };
1350 if let Err(e) = channel.send(&msg).await {
1351 tracing::warn!(?e, "failed to send startup escalation via slack");
1352 } else {
1353 tracing::info!("startup escalation notification sent to slack");
1354 }
1355 }
1356 }
1357 }
1358 }
1359}
1360
1361pub async fn serve() -> anyhow::Result<()> {
1365 let killed = crate::home::kill_other_orch_serve_processes(std::time::Duration::from_secs(30));
1366 if killed > 0 {
1367 tracing::info!(count = killed, "terminated stale orch serve processes");
1368 }
1369
1370 tracing::info!("orch engine starting");
1371
1372 if let Err(e) = crate::home::init_dirs().await {
1376 tracing::warn!("failed to initialize orch directories: {e}");
1377 }
1378
1379 if let Err(e) = crate::config::warm_cache().await {
1382 tracing::warn!("failed to warm config cache: {e}");
1383 }
1384
1385 let mut config = EngineConfig::from_config();
1386
1387 tracing::info!("internal database ready");
1388
1389 let startup_escalation_secs = config.startup_failure_escalation_secs;
1398 let mut project_engines = {
1399 let mut delay_secs = 5u64;
1400 let mut attempt = 0u32;
1401 let mut escalated = false;
1402 let start = std::time::Instant::now();
1403 loop {
1404 attempt += 1;
1405 match init_project_engines().await {
1406 Ok(engines) => break engines,
1407 Err(e) => {
1408 let elapsed = start.elapsed().as_secs();
1409
1410 if startup_escalation_secs > 0
1416 && !escalated
1417 && elapsed >= startup_escalation_secs
1418 {
1419 escalated = true;
1420 tracing::error!(
1421 delay_secs,
1422 attempt,
1423 elapsed_secs = elapsed,
1424 threshold_secs = startup_escalation_secs,
1425 "project backends unavailable for {} — escalating startup failure: {e}",
1426 crate::channels::notification::format_duration(elapsed as f64)
1427 );
1428 send_startup_escalation_notification(elapsed).await;
1432 } else {
1433 tracing::warn!(
1434 delay_secs,
1435 attempt,
1436 "project backends unavailable, retrying: {e}"
1437 );
1438 }
1439
1440 tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
1441 delay_secs = (delay_secs * 2).min(120);
1442 }
1443 }
1444 }
1445 };
1446
1447 tracing::info!(
1448 project_count = project_engines.len(),
1449 "initialized project engines"
1450 );
1451
1452 let mut active_repos: std::collections::HashSet<String> =
1456 project_engines.iter().map(|e| e.repo.clone()).collect();
1457
1458 let event_bus = events::EventBus::new(256);
1460 if let Err(e) = event_bus.start_ws_server().await {
1461 tracing::warn!(
1462 ?e,
1463 "failed to start event websocket server, continuing without it"
1464 );
1465 }
1466
1467 {
1471 let current = env!("ORCH_VERSION").to_string();
1472 tokio::spawn(async move {
1473 if let Some(latest) = fetch_latest_release_version().await {
1474 if latest != current {
1475 tracing::warn!(
1476 current_version = %current,
1477 latest_version = %latest,
1478 "orch service is behind the latest release — run: brew update && brew upgrade orch && brew services restart orch"
1479 );
1480 }
1481 }
1482 });
1483 }
1484
1485 for engine in &mut project_engines {
1487 engine.task_manager = Arc::new(TaskManager::with_events(
1488 engine.backend.clone(),
1489 engine.store.clone(),
1490 engine.repo.clone(),
1491 event_bus.sender(),
1492 ));
1493 }
1494
1495 let leads = project_engines.first().is_none_or(|e| e.store.is_leader());
1497 if leads {
1498 if let Err(e) = reconcile_startup_worktrees(&project_engines).await {
1499 tracing::warn!(err = %e, "startup worktree reconciliation failed");
1500 }
1501 }
1502
1503 if leads {
1504 let engines_snapshot: Vec<ProjectEngine> = project_engines.clone();
1508 tokio::spawn(async move {
1509 reconcile_startup_estimates(&engines_snapshot).await;
1510 });
1511 }
1512
1513 let global_channel_config = GlobalChannelConfig {
1515 telegram_general_topic_id: crate::config::get("channels.telegram.general_topic_id").ok(),
1516 discord_general_channel_id: crate::config::get("channels.discord.general_channel_id").ok(),
1517 control_telegram_topic_id: crate::config::get("control.channels.telegram.topic_id").ok(),
1518 control_discord_channel_id: crate::config::get("control.channels.discord.channel_id").ok(),
1519 };
1520 let project_channel_configs: Vec<(String, ProjectChannelConfig)> = project_engines
1521 .iter()
1522 .map(|e| {
1523 let cfg = read_project_channel_config(&e.project_dir);
1524 (e.repo.clone(), cfg)
1525 })
1526 .collect();
1527 let channel_router = Arc::new(ChannelRouter::new(
1528 &global_channel_config,
1529 &project_channel_configs,
1530 ));
1531 tracing::info!(
1532 projects = ?channel_router.projects(),
1533 "channel router initialized"
1534 );
1535
1536 let tmux = Arc::new(TmuxManager::new());
1538 tick::startup_cleanup(&tmux).await;
1541
1542 let transport = Arc::new(Transport::new());
1544
1545 let capture = Arc::new(CaptureService::new(transport.clone()));
1547 let capture_for_tick = capture.clone();
1548 tokio::spawn(async move {
1549 capture.start().await;
1550 });
1551
1552 let mut channel_registry = ChannelRegistry::new();
1554
1555 if let Ok(token) = crate::config::get("channels.telegram.bot_token") {
1557 if !token.is_empty() {
1558 let chat_id = crate::config::get("channels.telegram.chat_id").ok();
1559 match TelegramChannel::new(token, chat_id) {
1560 Ok(telegram) => {
1561 if let Err(e) = telegram.health_check().await {
1562 tracing::warn!(?e, "telegram channel health check failed, skipping");
1563 } else {
1564 channel_registry.register(Box::new(telegram));
1565 tracing::info!("telegram channel registered");
1566 }
1567 }
1568 Err(e) => {
1569 tracing::warn!(?e, "failed to create telegram channel, skipping");
1570 }
1571 }
1572 }
1573 }
1574
1575 if let Ok(token) = crate::config::get("channels.discord.bot_token") {
1577 if !token.is_empty() {
1578 let channel_id = crate::config::get("channels.discord.channel_id").ok();
1579 let shard_id = crate::config::get("channels.discord.shard_id")
1580 .ok()
1581 .and_then(|v| v.parse::<u64>().ok())
1582 .unwrap_or(0);
1583 let shard_count = crate::config::get("channels.discord.shard_count")
1584 .ok()
1585 .and_then(|v| v.parse::<u64>().ok())
1586 .unwrap_or(1);
1587 match DiscordGateway::new(token, channel_id, shard_id, shard_count) {
1588 Ok(discord) => {
1589 if let Err(e) = discord.health_check().await {
1590 tracing::warn!(?e, "discord gateway health check failed, skipping");
1591 } else {
1592 channel_registry.register(Box::new(discord));
1593 tracing::info!(shard_id, shard_count, "discord gateway registered");
1594 }
1595 }
1596 Err(e) => {
1597 tracing::warn!(?e, "failed to create discord gateway, skipping");
1598 }
1599 }
1600 }
1601 }
1602
1603 if let Ok(token) = crate::config::get("channels.slack.bot_token") {
1605 if !token.is_empty() {
1606 let channel_id = crate::config::get("channels.slack.channel_id").ok();
1607 match SlackChannel::new(token, channel_id) {
1608 Ok(slack) => {
1609 if let Err(e) = slack.health_check().await {
1610 tracing::warn!(?e, "slack channel health check failed, skipping");
1611 } else {
1612 channel_registry.register(Box::new(slack));
1613 tracing::info!("slack channel registered");
1614 }
1615 }
1616 Err(e) => {
1617 tracing::warn!(?e, "failed to create slack channel, skipping");
1618 }
1619 }
1620 }
1621 }
1622
1623 let tmux_channel = TmuxChannel::with_transport(transport.clone());
1625 channel_registry.register(Box::new(tmux_channel));
1626 tracing::info!("tmux channel registered");
1627
1628 let mut channel_receivers: Vec<tokio::sync::mpsc::Receiver<IncomingMessage>> = Vec::new();
1630 for channel in channel_registry.iter() {
1631 match channel.start().await {
1632 Ok(rx) => {
1633 tracing::info!(channel = channel.name(), "channel started");
1634 channel_receivers.push(rx);
1635 }
1636 Err(e) => {
1637 tracing::warn!(channel = channel.name(), ?e, "failed to start channel");
1638 }
1639 }
1640 }
1641
1642 let channel_registry = Arc::new(channel_registry);
1644
1645 let transport_for_messages = transport.clone();
1647 let tmux_for_messages = tmux.clone();
1648 let capture_for_messages = capture_for_tick.clone();
1649 let channels_for_messages = channel_registry.clone();
1650 let engine_refs: Vec<EngineRef> = project_engines
1652 .iter()
1653 .map(|e| {
1654 (
1655 e.repo.clone(),
1656 e.backend.clone(),
1657 e.task_manager.clone(),
1658 Some(e.store.clone()),
1659 )
1660 })
1661 .collect();
1662 let pending_picks: channel_handler::PendingPicks =
1664 std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
1665 let router_for_messages = channel_router.clone();
1666 for mut rx in channel_receivers {
1667 let transport = transport_for_messages.clone();
1668 let tmux = tmux_for_messages.clone();
1669 let capture = capture_for_messages.clone();
1670 let channels = channels_for_messages.clone();
1671 let engine_refs = engine_refs.clone();
1672 let ch_router = router_for_messages.clone();
1673 let picks = pending_picks.clone();
1674 tokio::spawn(async move {
1675 while let Some(msg) = rx.recv().await {
1676 tracing::debug!(channel = %msg.channel, thread = %msg.thread_id, "received message from channel");
1677 channel_handler::handle_channel_message(
1678 msg,
1679 &transport,
1680 &tmux,
1681 &capture,
1682 &channels,
1683 &engine_refs,
1684 &ch_router,
1685 &picks,
1686 )
1687 .await;
1688 }
1689 });
1690 }
1691
1692 {
1696 let restart_channels = channel_registry.clone();
1697 let restart_store = project_engines.first().map(|e| e.store.clone());
1698 if let Some(store) = restart_store {
1699 tokio::spawn(async move {
1700 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1701 match store.kv_get("control:restart_pending").await {
1702 Ok(Some(conv_key)) => {
1703 if let Some((ch_name, thread_id, topic_id)) =
1704 crate::channels::transport::parse_conversation_key(&conv_key)
1705 {
1706 for ch in restart_channels.iter() {
1707 if ch.name() == ch_name {
1708 let msg = OutgoingMessage {
1709 thread_id: thread_id.to_string(),
1710 body: "orch restarted successfully.".to_string(),
1711 reply_to: None,
1712 metadata: serde_json::json!({}),
1713 topic_id: topic_id.map(|t| t.to_string()),
1714 };
1715 if let Err(e) = ch.send(&msg).await {
1716 tracing::warn!(
1717 channel = ch_name,
1718 error = %e,
1719 "failed to send restart confirmation"
1720 );
1721 }
1722 break;
1723 }
1724 }
1725 }
1726 if let Err(e) = store.kv_delete("control:restart_pending").await {
1727 tracing::warn!(error = %e, "failed to clear control:restart_pending");
1728 }
1729 }
1730 Ok(None) => {} Err(e) => {
1732 tracing::warn!(error = %e, "failed to read control:restart_pending");
1733 }
1734 }
1735 });
1736 }
1737 }
1738
1739 {
1750 let mut notification_rx = transport.subscribe_notifications();
1751 let channels = channel_registry.clone();
1752 let notif_router = channel_router.clone();
1753 let notif_store: Option<Arc<TaskStore>> = project_engines.first().map(|e| e.store.clone());
1755 tokio::spawn(async move {
1756 loop {
1757 match notification_rx.recv().await {
1758 Ok(notification) => {
1759 let level = NotificationLevel::from_config();
1760 if !level.should_notify(¬ification.status) {
1761 tracing::debug!(
1762 task_id = %notification.task_id,
1763 status = %notification.status,
1764 "notification suppressed by level={:?}",
1765 level
1766 );
1767 continue;
1768 }
1769
1770 tracing::info!(
1771 task_id = %notification.task_id,
1772 status = %notification.status,
1773 repo = ?notification.repo,
1774 "routing notification to channels"
1775 );
1776
1777 let mut routed = false;
1779
1780 if let Some(ref target) = notification.notify_target {
1783 let telegram = channels.iter().find(|c| c.name() == "telegram");
1784 if let Some(channel) = telegram {
1785 let body = notification.format_telegram();
1786 let metadata = serde_json::json!({
1787 "preformatted_html": true,
1788 "chat_id_override": target
1789 });
1790 let msg = OutgoingMessage {
1791 thread_id: notification.task_id.clone(),
1792 body,
1793 reply_to: None,
1794 metadata,
1795 topic_id: None,
1796 };
1797 if let Err(e) = channel.send(&msg).await {
1798 tracing::warn!(
1799 task_id = %notification.task_id,
1800 target,
1801 ?e,
1802 "failed to send to notify_target"
1803 );
1804 } else {
1805 routed = true;
1806 }
1807 }
1808 }
1809
1810 if routed {
1811 } else if let Some(repo) = notification.repo.as_deref() {
1813 let mut sent: std::collections::HashSet<(String, Option<String>)> =
1819 std::collections::HashSet::new();
1820
1821 for channel in channels.iter() {
1823 let ch_name = channel.name();
1824 if let Some(topic_id) =
1825 notif_router.target_for_project(repo, ch_name)
1826 {
1827 let body = match ch_name {
1828 "telegram" => notification.format_telegram(),
1829 "discord" => notification.format_discord(),
1830 "slack" => notification.format_slack(),
1831 _ => continue,
1832 };
1833 let metadata = if ch_name == "telegram" {
1834 serde_json::json!({"preformatted_html": true})
1835 } else {
1836 serde_json::json!({})
1837 };
1838 let resolved_topic = Some(topic_id.to_string());
1839 let msg = OutgoingMessage {
1840 thread_id: notification.task_id.clone(),
1841 body,
1842 reply_to: None,
1843 metadata,
1844 topic_id: resolved_topic.clone(),
1845 };
1846 if let Err(e) = channel.send(&msg).await {
1847 tracing::warn!(
1848 channel = ch_name,
1849 task_id = %notification.task_id,
1850 ?e,
1851 "failed to send to dedicated channel"
1852 );
1853 } else {
1854 sent.insert((ch_name.to_string(), resolved_topic));
1855 routed = true;
1856 }
1857 }
1858 }
1859
1860 if let Some(store) = ¬if_store {
1862 match store.list_subscribers_for_repo(repo).await {
1863 Ok(subscribers) => {
1864 for (ch_name, thread_id, topic_id) in subscribers {
1865 let channel =
1866 channels.iter().find(|c| c.name() == ch_name);
1867 let Some(channel) = channel else {
1868 continue;
1869 };
1870 let resolved_topic = if topic_id.is_empty() {
1871 None
1872 } else {
1873 Some(topic_id.clone())
1874 };
1875 if sent.contains(&(
1878 ch_name.clone(),
1879 resolved_topic.clone(),
1880 )) {
1881 tracing::debug!(
1882 channel = %ch_name,
1883 task_id = %notification.task_id,
1884 topic_id = ?resolved_topic,
1885 "skipping duplicate subscriber dispatch — destination already received from dedicated path"
1886 );
1887 continue;
1888 }
1889 let body = notification.format_with_project(&ch_name);
1890 let metadata = if ch_name == "telegram" {
1891 serde_json::json!({"preformatted_html": true})
1892 } else {
1893 serde_json::json!({})
1894 };
1895 let msg = OutgoingMessage {
1896 thread_id: thread_id.clone(),
1897 body,
1898 reply_to: None,
1899 metadata,
1900 topic_id: resolved_topic.clone(),
1901 };
1902 if let Err(e) = channel.send(&msg).await {
1903 tracing::warn!(
1904 channel = %ch_name,
1905 task_id = %notification.task_id,
1906 ?e,
1907 "failed to send to subscribed channel"
1908 );
1909 } else {
1910 sent.insert((ch_name.clone(), resolved_topic));
1911 routed = true;
1912 }
1913 }
1914 }
1915 Err(e) => {
1916 tracing::warn!(
1917 repo,
1918 ?e,
1919 "failed to list subscribers for repo"
1920 );
1921 }
1922 }
1923 }
1924 }
1925
1926 if !routed {
1928 for channel in channels.iter() {
1929 let (body, should_send, is_telegram) = match channel.name() {
1930 "telegram" => (notification.format_telegram(), true, true),
1931 "discord" => (notification.format_discord(), true, false),
1932 "slack" => (notification.format_slack(), true, false),
1933 _ => (String::new(), false, false),
1936 };
1937
1938 if !should_send {
1939 continue;
1940 }
1941
1942 let metadata = if is_telegram {
1943 serde_json::json!({"preformatted_html": true})
1944 } else {
1945 serde_json::json!({})
1946 };
1947 let msg = OutgoingMessage {
1948 thread_id: notification.task_id.clone(),
1949 body,
1950 reply_to: None,
1951 metadata,
1952 topic_id: None,
1953 };
1954
1955 if let Err(e) = channel.send(&msg).await {
1956 tracing::warn!(
1957 channel = channel.name(),
1958 task_id = %notification.task_id,
1959 ?e,
1960 "failed to send notification"
1961 );
1962 }
1963 }
1964 }
1965 }
1966 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1967 tracing::warn!(missed = n, "notification receiver lagged");
1968 }
1969 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1970 tracing::debug!("notification channel closed");
1971 break;
1972 }
1973 }
1974 }
1975 });
1976 tracing::info!("notification dispatcher started");
1977 }
1978
1979 let webhook_notify = Arc::new(Notify::new());
1981
1982 let webhook_port: Option<u16>;
1986 let mut webhook_healthy: bool;
1987 let mut last_webhook_health_check = std::time::Instant::now();
1988 let webhook_enabled = crate::config::get("webhook.enabled")
1990 .map(|v| v == "true")
1991 .unwrap_or(false);
1992
1993 let webhook_status = Arc::new(tokio::sync::Mutex::new(
1995 crate::webhook_status::WebhookStatus::default(),
1996 ));
1997
1998 if webhook_enabled {
1999 let port: u16 = crate::config::get("webhook.port")
2000 .ok()
2001 .and_then(|v| v.parse().ok())
2002 .unwrap_or(8080);
2003 webhook_port = Some(port);
2004 let secret = crate::config::get("webhook.secret").unwrap_or_default();
2005 let webhook_repo = project_engines
2006 .first()
2007 .map(|e| e.repo.clone())
2008 .unwrap_or_default();
2009
2010 let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(64);
2011
2012 let to_save = {
2014 let mut s = webhook_status.lock().await;
2015 s.configured = true;
2016 s.port = Some(port);
2017 s.healthy = false;
2018 s.fallback_mode = false;
2019 s.clone()
2020 };
2021 to_save.save().await;
2022
2023 const WEBHOOK_MAX_ATTEMPTS: u32 = 5;
2027 let status_for_spawn = webhook_status.clone();
2028 tokio::spawn(async move {
2029 use crate::channels::github::{is_transient_bind_error, webhook_backoff_delay};
2030 let mut attempt = 0u32;
2031 loop {
2032 attempt += 1;
2033 tracing::info!(attempt, port, "attempting to start webhook server");
2034 match start_webhook_server(port, secret.clone(), webhook_repo.clone(), tx.clone())
2035 .await
2036 {
2037 Ok(_) => {
2038 tracing::info!(attempt, "webhook server exited cleanly");
2039 break;
2040 }
2041 Err(e) => {
2042 let reason = e.to_string();
2043 tracing::error!(attempt, %reason, "webhook server failed");
2044 if attempt >= WEBHOOK_MAX_ATTEMPTS || !is_transient_bind_error(&e) {
2045 let kind = if attempt >= WEBHOOK_MAX_ATTEMPTS {
2046 "max attempts reached"
2047 } else {
2048 "non-transient error"
2049 };
2050 tracing::error!(
2051 attempt,
2052 %reason,
2053 kind,
2054 orch_webhook_in_fallback = true,
2055 "webhook server giving up, switching to polling fallback"
2056 );
2057 let to_save = {
2058 let mut s = status_for_spawn.lock().await;
2059 s.fallback_mode = true;
2060 s.healthy = false;
2061 s.last_failure_reason = Some(reason);
2062 s.startup_attempts = attempt;
2063 s.clone()
2064 };
2065 to_save.save().await;
2066 break;
2067 }
2068 let delay = webhook_backoff_delay(attempt);
2069 tracing::warn!(
2070 attempt,
2071 delay_ms = delay.as_millis(),
2072 %reason,
2073 "webhook bind failed (transient), retrying with backoff"
2074 );
2075 {
2076 let to_save = {
2077 let mut s = status_for_spawn.lock().await;
2078 s.startup_attempts = attempt;
2079 s.last_failure_reason = Some(reason);
2080 s.clone()
2081 };
2082 to_save.save().await;
2083 }
2084 tokio::time::sleep(delay).await;
2085 }
2086 }
2087 }
2088 });
2089
2090 let notify = webhook_notify.clone();
2092 let transport_for_webhook = transport.clone();
2093 tokio::spawn(async move {
2094 while let Some(msg) = rx.recv().await {
2095 tracing::info!(
2096 channel = %msg.channel,
2097 thread = %msg.thread_id,
2098 event = %msg.metadata.get("event").and_then(|v| v.as_str()).unwrap_or("unknown"),
2099 action = %msg.metadata.get("action").and_then(|v| v.as_str()).unwrap_or("unknown"),
2100 "webhook event received, triggering immediate tick"
2101 );
2102 let route = transport_for_webhook.route(&msg).await;
2103 tracing::info!(
2104 channel = %msg.channel,
2105 route = ?route,
2106 "webhook event routed"
2107 );
2108 notify.notify_one();
2110 }
2111 });
2112
2113 webhook_healthy = true;
2114 tracing::info!(port, orch_webhook_up = true, "webhook server started");
2115 } else {
2116 webhook_port = None;
2117 webhook_healthy = false;
2118 {
2119 let to_save = {
2120 let mut s = webhook_status.lock().await;
2121 s.configured = false;
2122 s.fallback_mode = true;
2123 s.clone()
2124 };
2125 to_save.save().await;
2126 }
2127 tracing::info!(
2128 orch_webhook_in_fallback = true,
2129 "webhook server disabled, using polling fallback mode"
2130 );
2131 }
2132
2133 start_relay_client(&project_engines, &webhook_notify);
2134
2135 let router = Arc::new(RwLock::new(
2142 match tokio::task::spawn_blocking(Router::from_config).await {
2143 Ok(r) => r,
2144 Err(e) => {
2145 tracing::error!(?e, "router init panicked in spawn_blocking, using default");
2146 tokio::task::spawn_blocking(|| Router::new(RouterConfig::default()))
2147 .await
2148 .unwrap_or_else(|e2| {
2149 panic!("router default init also panicked in spawn_blocking — cannot start engine without a router: {e2:?}");
2150 })
2151 }
2152 },
2153 ));
2154 {
2155 let r = router.read().await;
2156 tracing::info!(
2157 mode = %r.config.mode,
2158 agents = ?r.available_agents,
2159 fallback = %r.config.fallback_executor,
2160 "router initialized"
2161 );
2162 }
2163
2164 let semaphore = Arc::new(Semaphore::new(config.max_parallel));
2168 let mut parallel_in_force = config.max_parallel;
2169
2170 let dispatching: Arc<dashmap::DashMap<String, String>> = Arc::new(dashmap::DashMap::new());
2177
2178 let auto_merge_in_flight: Arc<dashmap::DashSet<String>> = Arc::new(dashmap::DashSet::new());
2182
2183 let mut config_rx = crate::config::subscribe();
2185
2186 let mut last_sync = std::time::Instant::now();
2188
2189 let mut last_upgrade_check: Option<std::time::Instant> = None;
2191
2192 let mut rate_limit_reported: Option<i64> = None;
2195
2196 let (weight_tx, mut weight_rx) = mpsc::channel::<WeightSignal>(64);
2198
2199 for engine in project_engines.iter().filter(|_| leads) {
2205 let in_review_from_store = engine
2207 .store
2208 .list_by_status(&engine.repo, crate::store::TaskStatus::InReview)
2209 .await
2210 .unwrap_or_default()
2211 .iter()
2212 .map(crate::engine::tasks::store_task_to_external)
2213 .collect::<Vec<_>>();
2214 if !in_review_from_store.is_empty() {
2215 let in_review = &in_review_from_store;
2216 for task in in_review {
2217 let session_expected =
2218 review_session_expected(&engine.store, &engine.repo, &task.id.0).await;
2219 let age_minutes = chrono::DateTime::parse_from_rfc3339(&task.updated_at)
2220 .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_minutes())
2221 .unwrap_or(i64::MAX);
2222 if !session_expected && age_minutes < 10 {
2223 continue;
2224 }
2225 if let Err(e) = engine
2226 .task_manager
2227 .update_task_status(&task.id, Status::NeedsReview)
2228 .await
2229 {
2230 tracing::warn!(
2231 task_id = task.id.0,
2232 err = %e,
2233 "failed to reset stale InReview task on startup"
2234 );
2235 } else {
2236 set_review_session_expected(&engine.store, &engine.repo, &task.id.0, false)
2237 .await;
2238 }
2239 }
2240 if !in_review.is_empty() {
2241 tracing::info!(
2242 repo = %engine.repo,
2243 count = in_review.len(),
2244 "reset stale InReview tasks to NeedsReview on startup"
2245 );
2246 }
2247 }
2248
2249 use crate::store::TaskStatus as DbStatus;
2253 if let Ok(internal_in_review) = engine
2254 .task_manager
2255 .list_internal_by_status(DbStatus::InReview)
2256 .await
2257 {
2258 for task in &internal_in_review {
2259 let task_id = task.id.0.clone();
2260 let session_expected =
2261 review_session_expected(&engine.store, &engine.repo, &task_id).await;
2262 let age_minutes = chrono::DateTime::parse_from_rfc3339(&task.updated_at)
2263 .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_minutes())
2264 .unwrap_or(i64::MAX);
2265 if !session_expected && age_minutes < 10 {
2266 continue;
2267 }
2268 if let Err(e) = engine
2269 .task_manager
2270 .update_task_status(&ExternalId(task_id.clone()), Status::NeedsReview)
2271 .await
2272 {
2273 tracing::warn!(
2274 task_id,
2275 err = %e,
2276 "failed to reset stale internal InReview task on startup"
2277 );
2278 } else {
2279 set_review_session_expected(&engine.store, &engine.repo, &task_id, false).await;
2280 }
2281 }
2282 if !internal_in_review.is_empty() {
2283 tracing::info!(
2284 repo = %engine.repo,
2285 count = internal_in_review.len(),
2286 "reset stale internal InReview tasks to NeedsReview on startup"
2287 );
2288 }
2289 }
2290 }
2291
2292 for engine in &project_engines {
2294 subscribers::dispatch::spawn(
2295 event_bus.subscribe(),
2296 engine.backend.clone(),
2297 tmux.clone(),
2298 engine.runner.clone(),
2299 capture_for_tick.clone(),
2300 semaphore.clone(),
2301 engine.task_manager.clone(),
2302 weight_tx.clone(),
2303 router.clone(),
2304 dispatching.clone(),
2305 engine.store.clone(),
2306 engine.repo.clone(),
2307 );
2308 }
2309 tracing::info!("dispatch subscriber started");
2310
2311 for engine in &project_engines {
2313 subscribers::review::spawn(
2314 event_bus.subscribe(),
2315 engine.backend.clone(),
2316 tmux.clone(),
2317 semaphore.clone(),
2318 engine.task_manager.clone(),
2319 router.clone(),
2320 dispatching.clone(),
2321 engine.store.clone(),
2322 engine.repo.clone(),
2323 );
2324 }
2325 tracing::info!("review subscriber started");
2326
2327 for engine in &project_engines {
2329 subscribers::unblock::spawn(
2330 event_bus.subscribe(),
2331 engine.backend.clone(),
2332 engine.task_manager.clone(),
2333 engine.store.clone(),
2334 engine.repo.clone(),
2335 );
2336 }
2337 tracing::info!("unblock subscriber started");
2338
2339 subscribers::notify::spawn(event_bus.subscribe(), transport.clone());
2342 tracing::info!("notify subscriber started");
2343
2344 tracing::info!(
2346 tick = ?config.tick_interval,
2347 sync = ?config.sync_interval,
2348 parallel = config.max_parallel,
2349 "entering main loop"
2350 );
2351 let mut restart_only = restart_only_snapshot();
2354
2355 let mut interval = tokio::time::interval(config.tick_interval);
2356
2357 let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2359 let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
2360
2361 let sync_in_progress = Arc::new(std::sync::atomic::AtomicBool::new(false));
2363
2364 let last_tick_epoch = Arc::new(std::sync::atomic::AtomicU64::new(
2367 std::time::SystemTime::now()
2368 .duration_since(std::time::UNIX_EPOCH)
2369 .unwrap_or_default()
2370 .as_secs(),
2371 ));
2372 {
2373 let last_tick_epoch = Arc::clone(&last_tick_epoch);
2374 let tick_interval_secs = config.tick_interval.as_secs();
2375 tokio::spawn(async move {
2376 let mut watchdog_interval = tokio::time::interval(std::time::Duration::from_secs(30));
2378 let mut suspend_detector = crate::engine::suspend::SuspendDetector::new();
2383 loop {
2384 watchdog_interval.tick().await;
2385 let own_gap = suspend_detector.check();
2386 let last = last_tick_epoch.load(std::sync::atomic::Ordering::Relaxed);
2387 let now = std::time::SystemTime::now()
2388 .duration_since(std::time::UNIX_EPOCH)
2389 .unwrap_or_default()
2390 .as_secs();
2391 let stale_secs = now.saturating_sub(last);
2392 let threshold = (tick_interval_secs * 6).max(60);
2394 if stale_secs > threshold {
2395 let shared_gap = crate::engine::suspend::gap_detected_within(
2402 std::time::Duration::from_secs(stale_secs),
2403 );
2404 let gap = match (own_gap, shared_gap) {
2405 (Some(a), Some(b)) => Some(a.max(b)),
2406 (Some(a), None) => Some(a),
2407 (None, Some(b)) => Some(b),
2408 (None, None) => None,
2409 };
2410 if let Some(gap) = gap {
2411 let gap_secs = gap.num_seconds() as u64;
2412 if gap_secs >= stale_secs {
2413 tracing::info!(
2414 stale_secs,
2415 threshold,
2416 suspend_gap_secs = gap_secs,
2417 "tick loop stale window explained by host suspend/resume, not a stall"
2418 );
2419 } else {
2420 tracing::error!(
2421 stale_secs,
2422 threshold,
2423 suspend_gap_secs = gap_secs,
2424 "WATCHDOG: tick loop has not completed a tick in {}s (threshold {}s) — suspend gap of {}s only partially explains the stall",
2425 stale_secs,
2426 threshold,
2427 gap_secs,
2428 );
2429 }
2430 } else {
2431 tracing::error!(
2432 stale_secs,
2433 threshold,
2434 "WATCHDOG: tick loop has not completed a tick in {}s (threshold {}s) — possible stall",
2435 stale_secs,
2436 threshold,
2437 );
2438 }
2439 }
2440 }
2441 });
2442 }
2443
2444 loop {
2445 tokio::select! {
2446 _ = interval.tick() => {
2447 let tick_start = std::time::Instant::now();
2448
2449 if let Some(gap) = crate::engine::suspend::checkpoint() {
2453 tracing::info!(
2454 gap_secs = gap.num_seconds(),
2455 "host suspend/resume gap detected, discounting from stuck-task age checks"
2456 );
2457 }
2458
2459 if parallel_in_force != config.max_parallel {
2463 let resized =
2464 resize_dispatch_pool(&semaphore, parallel_in_force, config.max_parallel);
2465 if resized != parallel_in_force {
2466 parallel_in_force = resized;
2467 tracing::info!(
2468 in_force = parallel_in_force,
2469 target = config.max_parallel,
2470 "dispatch pool resized"
2471 );
2472 }
2473 }
2474
2475 {
2477 let mut rw = router.write().await;
2478 rw.tick_weight_recovery();
2479 }
2480
2481 if let Some(remaining) = GhHttp::is_rate_limited() {
2485 let reset_at = chrono::Utc::now()
2486 .timestamp()
2487 .saturating_add(i64::try_from(remaining.as_secs()).unwrap_or(i64::MAX));
2488 if rate_limit_reported.is_none_or(|prev| (reset_at - prev).abs() > 60) {
2490 rate_limit_reported = Some(reset_at);
2491 report_rate_limited(reset_at, project_engines.first().map(|e| &e.store), Some(&transport)).await;
2492 }
2493 } else if let Some(prev) = rate_limit_reported.take() {
2494 tracing::info!(
2495 limited_until = prev,
2496 "GitHub API rate limit cleared"
2497 );
2498 if let Some(engine) = project_engines.first() {
2499 let _ = engine.store.kv_delete(RATE_LIMITED_UNTIL_KEY).await;
2500 }
2501 }
2502
2503 tick::tick_all(
2505 &project_engines,
2506 &tmux,
2507 &capture_for_tick,
2508 &semaphore,
2509 &config,
2510 &router,
2511 &weight_tx,
2512 &dispatching,
2513 Some(&transport),
2514 &active_repos,
2515 &auto_merge_in_flight,
2516 ).await;
2517
2518 while let Ok(signal) = weight_rx.try_recv() {
2521 let mut rw = router.write().await;
2522 match signal {
2523 WeightSignal::RateLimited { ref agent } => {
2524 rw.record_rate_limit(agent);
2525 }
2526 WeightSignal::Success { ref agent } => {
2527 rw.record_success(agent);
2528 }
2529 WeightSignal::Rerouted | WeightSignal::Blocked | WeightSignal::None => {}
2530 }
2531 }
2532
2533 if last_sync.elapsed() >= config.sync_interval {
2538 if sync_in_progress.compare_exchange(
2539 false,
2540 true,
2541 std::sync::atomic::Ordering::AcqRel,
2542 std::sync::atomic::Ordering::Relaxed,
2543 ).is_ok() {
2544 spawn_sync_pass(
2545 &project_engines,
2546 &tmux,
2547 &config,
2548 &router,
2549 &dispatching,
2550 &auto_merge_in_flight,
2551 &sync_in_progress,
2552 "periodic",
2553 );
2554 } else {
2555 tracing::debug!("sync tick still in progress, skipping");
2556 }
2557 last_sync = std::time::Instant::now();
2561 }
2562
2563 if webhook_enabled {
2565 let health_check_interval = config.webhook_health_check_interval
2566 .unwrap_or(std::time::Duration::from_secs(60));
2567
2568 if last_webhook_health_check.elapsed() >= health_check_interval {
2569 if let Some(port) = webhook_port {
2570 let (health, failure_reason) =
2571 crate::channels::github::check_webhook_health(port).await;
2572 if health != webhook_healthy {
2573 webhook_healthy = health;
2574 if webhook_healthy {
2575 tracing::info!(port, orch_webhook_up = true, "webhook health restored");
2576 } else {
2577 tracing::warn!(
2578 port,
2579 orch_webhook_up = false,
2580 reason = failure_reason.as_deref().unwrap_or("unknown"),
2581 "webhook health check failed"
2582 );
2583 }
2584 }
2585 {
2587 let to_save = {
2588 let mut s = webhook_status.lock().await;
2589 s.healthy = health;
2590 s.last_check_utc = Some(chrono::Utc::now());
2591 if health {
2592 s.last_failure_reason = None;
2593 } else if failure_reason.is_some() {
2594 s.last_failure_reason = failure_reason;
2595 }
2596 s.clone()
2597 };
2598 to_save.save().await;
2599 }
2600 }
2601 last_webhook_health_check = std::time::Instant::now();
2602 }
2603 }
2604
2605 if should_run_upgrade_check(last_upgrade_check, config.upgrade_check_interval) {
2607 if let Some(store) = project_engines.first().map(|e| e.store.clone()) {
2608 let channels = channel_registry.clone();
2609 let current = env!("ORCH_VERSION").to_string();
2610 let check_interval =
2611 std::time::Duration::from_secs(config.upgrade_check_interval);
2612 tokio::spawn(async move {
2613 check_and_notify_upgrade(&store, &channels, ¤t, check_interval)
2614 .await;
2615 });
2616 }
2617 last_upgrade_check = Some(std::time::Instant::now());
2618 }
2619
2620 let tick_elapsed = tick_start.elapsed();
2622 if tick_elapsed.as_secs() > 30 {
2623 tracing::warn!(elapsed_ms = tick_elapsed.as_millis() as u64, "slow tick");
2624 } else {
2625 tracing::debug!(elapsed_ms = tick_elapsed.as_millis() as u64, "tick complete");
2626 }
2627 last_tick_epoch.store(
2628 std::time::SystemTime::now()
2629 .duration_since(std::time::UNIX_EPOCH)
2630 .unwrap_or_default()
2631 .as_secs(),
2632 std::sync::atomic::Ordering::Relaxed,
2633 );
2634 }
2635 _ = webhook_notify.notified() => {
2637 if let Some(remaining) = GhHttp::is_rate_limited() {
2638 tracing::warn!(
2639 remaining_secs = remaining.as_secs(),
2640 "GitHub API rate-limited, skipping webhook-triggered tick"
2641 );
2642 } else {
2643 tracing::info!("webhook event triggered immediate tick");
2644
2645 tick::tick_all(
2646 &project_engines,
2647 &tmux,
2648 &capture_for_tick,
2649 &semaphore,
2650 &config,
2651 &router,
2652 &weight_tx,
2653 &dispatching,
2654 Some(&transport),
2655 &active_repos,
2656 &auto_merge_in_flight,
2657 ).await;
2658
2659 while let Ok(signal) = weight_rx.try_recv() {
2661 let mut rw = router.write().await;
2662 match signal {
2663 WeightSignal::RateLimited { ref agent } => {
2664 rw.record_rate_limit(agent);
2665 }
2666 WeightSignal::Success { ref agent } => {
2667 rw.record_success(agent);
2668 }
2669 WeightSignal::Rerouted | WeightSignal::Blocked | WeightSignal::None => {}
2670 }
2671 }
2672
2673 if sync_in_progress.compare_exchange(
2679 false,
2680 true,
2681 std::sync::atomic::Ordering::AcqRel,
2682 std::sync::atomic::Ordering::Relaxed,
2683 ).is_ok() {
2684 spawn_sync_pass(
2685 &project_engines,
2686 &tmux,
2687 &config,
2688 &router,
2689 &dispatching,
2690 &auto_merge_in_flight,
2691 &sync_in_progress,
2692 "webhook",
2693 );
2694 last_sync = std::time::Instant::now();
2696 } else {
2697 tracing::debug!("sync tick still in progress, skipping webhook-triggered immediate sync");
2698 }
2699 }
2700
2701 interval.reset();
2703 }
2704 result = config_rx.recv() => {
2705 let reload = match result {
2708 Ok(path) => {
2709 tracing::info!(path = %path.display(), "config file changed, reloading");
2710 true
2711 }
2712 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
2713 tracing::warn!(missed = n, "config change receiver lagged, reloading");
2714 true
2715 }
2716 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
2717 tracing::warn!("config change channel closed");
2718 false
2719 }
2720 };
2721
2722 if reload {
2723 let new_config = EngineConfig::from_config();
2725 log_engine_config_changes(&config, &new_config);
2726
2727 let new_restart_only = restart_only_snapshot();
2728 log_restart_only_changes(&restart_only, &new_restart_only);
2729 restart_only = new_restart_only;
2730
2731 let tick_changed = new_config.tick_interval != config.tick_interval;
2732 config = new_config;
2733
2734 if tick_changed {
2736 interval = tokio::time::interval(config.tick_interval);
2737 tracing::info!(tick = ?config.tick_interval, "tick interval updated");
2738 }
2739
2740 if parallel_in_force != config.max_parallel {
2743 parallel_in_force = resize_dispatch_pool(
2744 &semaphore,
2745 parallel_in_force,
2746 config.max_parallel,
2747 );
2748 tracing::info!(
2749 in_force = parallel_in_force,
2750 target = config.max_parallel,
2751 "dispatch pool resized"
2752 );
2753 }
2754
2755 {
2757 let mut router_guard = router.write().await;
2758 router_guard.reload_async().await;
2759 }
2760
2761 sync_project_engines(
2763 &mut project_engines,
2764 &mut active_repos,
2765 &event_bus,
2766 &tmux,
2767 &capture_for_tick,
2768 &semaphore,
2769 &weight_tx,
2770 &router,
2771 &dispatching,
2772 ).await;
2773
2774 tracing::info!(
2775 tick = ?config.tick_interval,
2776 sync = ?config.sync_interval,
2777 parallel = config.max_parallel,
2778 projects = project_engines.len(),
2779 "config reloaded"
2780 );
2781 }
2782 }
2783 signal_name = async {
2784 tokio::select! {
2785 _ = tokio::signal::ctrl_c() => "SIGINT",
2786 _ = sigterm.recv() => "SIGTERM",
2787 _ = sighup.recv() => "SIGHUP",
2788 }
2789 } => {
2790 tracing::info!(signal = signal_name, "beginning graceful shutdown");
2791
2792 let shutdown_result = tokio::time::timeout(
2795 config.graceful_shutdown_timeout,
2796 async {
2797 let mut reset_count = 0u32;
2801 let mut review_reset_count = 0u32;
2802 for engine in &project_engines {
2803 if let Ok(tasks) = engine.task_manager.list_external_by_status(Status::InProgress).await {
2804 for task in &tasks {
2805 if let Err(e) = engine.task_manager.update_task_status(&task.id, Status::Routed).await {
2806 tracing::warn!(task_id = task.id.0, ?e, "failed to reset task on shutdown");
2807 } else {
2808 if let Ok(Some(store_id)) =
2809 engine.store.resolve_task_id(&engine.repo, &task.id.0).await
2810 {
2811 if let Err(e) = engine
2812 .store
2813 .finalize_incomplete_runs(
2814 store_id,
2815 "aborted",
2816 "graceful shutdown: reset in_progress task to routed",
2817 )
2818 .await
2819 {
2820 tracing::warn!(
2821 task_id = task.id.0,
2822 ?e,
2823 "failed to finalize incomplete runs on shutdown"
2824 );
2825 }
2826 }
2827 reset_count += 1;
2828 }
2829 }
2830 }
2831 if let Ok(tasks) = engine.store.list_internal_by_status(&engine.repo, crate::store::TaskStatus::InProgress).await {
2833 for task in &tasks {
2834 let task_id = format!("internal:{}", task.id);
2835 if let Err(e) = engine.task_manager.update_task_status(
2836 &crate::backends::ExternalId(task_id.clone()),
2837 Status::Routed,
2838 ).await {
2839 tracing::warn!(task_id, ?e, "failed to reset internal task on shutdown");
2840 } else {
2841 if let Err(e) = engine
2842 .store
2843 .finalize_incomplete_runs(
2844 task.id,
2845 "aborted",
2846 "graceful shutdown: reset in_progress task to routed",
2847 )
2848 .await
2849 {
2850 tracing::warn!(
2851 task_id,
2852 ?e,
2853 "failed to finalize incomplete internal runs on shutdown"
2854 );
2855 }
2856 reset_count += 1;
2857 }
2858 }
2859 }
2860 if let Ok(tasks) = engine.task_manager.list_external_by_status(Status::InReview).await {
2862 for task in &tasks {
2863 if let Err(e) = engine.task_manager.update_task_status(&task.id, Status::NeedsReview).await {
2864 tracing::warn!(task_id = task.id.0, ?e, "failed to reset in_review task on shutdown");
2865 } else {
2866 set_review_session_expected(&engine.store, &engine.repo, &task.id.0, false).await;
2867 if let Ok(Some(store_id)) =
2868 engine.store.resolve_task_id(&engine.repo, &task.id.0).await
2869 {
2870 if let Err(e) = engine
2871 .store
2872 .finalize_incomplete_runs(
2873 store_id,
2874 "aborted",
2875 "graceful shutdown: reset in_review task to needs_review",
2876 )
2877 .await
2878 {
2879 tracing::warn!(
2880 task_id = task.id.0,
2881 ?e,
2882 "failed to finalize incomplete review runs on shutdown"
2883 );
2884 }
2885 }
2886 review_reset_count += 1;
2887 }
2888 }
2889 }
2890 if let Ok(tasks) = engine.store.list_internal_by_status(&engine.repo, crate::store::TaskStatus::InReview).await {
2892 for task in &tasks {
2893 let task_id = format!("internal:{}", task.id);
2894 if let Err(e) = engine.task_manager.update_task_status(
2895 &crate::backends::ExternalId(task_id.clone()),
2896 Status::NeedsReview,
2897 ).await {
2898 tracing::warn!(task_id, ?e, "failed to reset internal in_review task on shutdown");
2899 } else {
2900 set_review_session_expected(&engine.store, &engine.repo, &task_id, false).await;
2901 if let Err(e) = engine
2902 .store
2903 .finalize_incomplete_runs(
2904 task.id,
2905 "aborted",
2906 "graceful shutdown: reset in_review task to needs_review",
2907 )
2908 .await
2909 {
2910 tracing::warn!(
2911 task_id,
2912 ?e,
2913 "failed to finalize incomplete internal review runs on shutdown"
2914 );
2915 }
2916 review_reset_count += 1;
2917 }
2918 }
2919 }
2920 }
2921 if reset_count > 0 {
2922 tracing::info!(reset_count, "reset in_progress tasks to routed for re-dispatch");
2923 }
2924 if review_reset_count > 0 {
2925 tracing::info!(review_reset_count, "reset in_review tasks to needs_review for re-dispatch");
2926 }
2927
2928 let mut killed_sessions = Vec::new();
2931 if let Ok(sessions) = tmux.list_sessions().await {
2932 for session in &sessions {
2933 if session.name.starts_with("orch-") {
2934 if let Err(e) = tmux.kill_session(&session.name).await {
2935 tracing::warn!(session = %session.name, error = %e, "failed to kill tmux session");
2936 } else {
2937 killed_sessions.push(session.name.clone());
2938 }
2939 }
2940 }
2941 if !killed_sessions.is_empty() {
2942 tracing::info!(killed = killed_sessions.len(), "killing orch tmux sessions on shutdown");
2943 }
2944 }
2945
2946 if !killed_sessions.is_empty() {
2949 let still_alive = tmux
2950 .wait_for_sessions_dead(
2951 &killed_sessions,
2952 std::time::Duration::from_millis(100),
2953 std::time::Duration::from_secs(5),
2954 )
2955 .await;
2956 if still_alive > 0 {
2957 tracing::warn!(
2958 still_alive,
2959 "some tmux sessions did not terminate cleanly - they will be cleaned up on startup"
2960 );
2961 }
2962 tracing::info!(killed = killed_sessions.len() - still_alive, "confirmed tmux sessions terminated");
2963 }
2964 },
2965 ).await;
2966
2967 if shutdown_result.is_err() {
2968 tracing::error!(
2969 timeout_secs = config.graceful_shutdown_timeout.as_secs(),
2970 "graceful shutdown timed out, forcing exit"
2971 );
2972 }
2973
2974 break;
2975 }
2976 }
2977 }
2978
2979 events::cleanup_port_file().await;
2981 crate::cluster::local::cleanup().await;
2982 events::cleanup_version_file();
2983
2984 let _ = transport;
2986 tracing::info!("orch engine stopped");
2987 Ok(())
2988}
2989
2990#[cfg(test)]
2991mod tests {
2992 use super::*;
2993
2994 #[test]
2995 fn growing_max_parallel_lets_more_tasks_run_without_a_restart() {
2996 let semaphore = Arc::new(Semaphore::new(4));
2997 let held: Vec<_> = (0..4)
2998 .map(|_| semaphore.clone().try_acquire_owned().unwrap())
2999 .collect();
3000 assert!(
3001 semaphore.clone().try_acquire_owned().is_err(),
3002 "pool is full"
3003 );
3004
3005 let in_force = resize_dispatch_pool(&semaphore, 4, 8);
3006 assert_eq!(in_force, 8);
3007
3008 let grown: Vec<_> = (0..4)
3009 .map(|_| semaphore.clone().try_acquire_owned().unwrap())
3010 .collect();
3011 assert_eq!(held.len() + grown.len(), 8, "8 tasks run at once");
3012 assert!(
3013 semaphore.clone().try_acquire_owned().is_err(),
3014 "pool is full"
3015 );
3016 }
3017
3018 #[test]
3019 fn shrinking_max_parallel_never_cancels_a_running_task() {
3020 let semaphore = Arc::new(Semaphore::new(8));
3021 let mut held: Vec<_> = (0..8)
3022 .map(|_| semaphore.clone().try_acquire_owned().unwrap())
3023 .collect();
3024
3025 let mut in_force = resize_dispatch_pool(&semaphore, 8, 4);
3027 assert_eq!(in_force, 8);
3028 assert_eq!(held.len(), 8);
3029
3030 held.truncate(5);
3032 in_force = resize_dispatch_pool(&semaphore, in_force, 4);
3033 assert_eq!(in_force, 5);
3034 assert!(semaphore.clone().try_acquire_owned().is_err());
3035
3036 held.truncate(4);
3038 in_force = resize_dispatch_pool(&semaphore, in_force, 4);
3039 assert_eq!(in_force, 4);
3040 assert!(semaphore.clone().try_acquire_owned().is_err());
3041
3042 held.truncate(3);
3044 assert!(semaphore.clone().try_acquire_owned().is_ok());
3045 }
3046
3047 #[test]
3048 fn a_project_added_to_the_config_is_picked_up_and_running_ones_are_not_respawned() {
3049 let configured = vec![
3050 (
3051 "owner/one".to_string(),
3052 std::path::PathBuf::from("/srv/one"),
3053 ),
3054 (
3055 "owner/two".to_string(),
3056 std::path::PathBuf::from("/srv/two"),
3057 ),
3058 ];
3059 let active: std::collections::HashSet<String> =
3060 ["owner/one".to_string()].into_iter().collect();
3061
3062 let (added, removed) = diff_configured_projects(&configured, &active);
3063 assert_eq!(
3064 added,
3065 vec![(
3066 "owner/two".to_string(),
3067 std::path::PathBuf::from("/srv/two")
3068 )],
3069 "only the new project is started, so owner/one keeps its single set of subscribers"
3070 );
3071 assert!(removed.is_empty());
3072 }
3073
3074 #[test]
3075 fn a_project_dropped_from_the_config_is_reported_and_not_restarted() {
3076 let configured = vec![(
3077 "owner/one".to_string(),
3078 std::path::PathBuf::from("/srv/one"),
3079 )];
3080 let active: std::collections::HashSet<String> =
3081 ["owner/one".to_string(), "owner/gone".to_string()]
3082 .into_iter()
3083 .collect();
3084
3085 let (added, removed) = diff_configured_projects(&configured, &active);
3086 assert!(added.is_empty(), "nothing new to start");
3087 assert_eq!(removed, vec!["owner/gone".to_string()]);
3088 }
3089
3090 #[test]
3091 fn an_unchanged_project_list_starts_nothing() {
3092 let configured = vec![(
3093 "owner/one".to_string(),
3094 std::path::PathBuf::from("/srv/one"),
3095 )];
3096 let active: std::collections::HashSet<String> =
3097 ["owner/one".to_string()].into_iter().collect();
3098
3099 assert_eq!(
3100 diff_configured_projects(&configured, &active),
3101 (vec![], vec![])
3102 );
3103 }
3104
3105 #[test]
3106 fn restart_only_keys_are_the_settings_read_once_at_startup() {
3107 assert_eq!(RESTART_ONLY_KEYS, ["channels", "webhook", "relay"]);
3110 }
3111
3112 #[test]
3113 fn a_changed_restart_only_key_is_reported_and_an_unchanged_one_is_not() {
3114 let before = vec![Some("bot_token: a\n".to_string()), None];
3115
3116 assert!(
3117 log_restart_only_changes(&before, &before.clone()).is_empty(),
3118 "a reload that did not touch these keys must stay quiet"
3119 );
3120
3121 let after = vec![
3123 Some("bot_token: b\n".to_string()),
3124 Some("port: 8080\n".to_string()),
3125 ];
3126 assert_eq!(
3127 log_restart_only_changes(&before, &after),
3128 ["channels", "webhook"]
3129 );
3130
3131 let token_only = vec![Some("bot_token: b\n".to_string()), None];
3133 assert_eq!(log_restart_only_changes(&before, &token_only), ["channels"]);
3134 }
3135
3136 #[test]
3137 fn resize_dispatch_pool_is_a_noop_at_the_current_limit() {
3138 let semaphore = Arc::new(Semaphore::new(4));
3139 assert_eq!(resize_dispatch_pool(&semaphore, 4, 4), 4);
3140 assert_eq!(semaphore.available_permits(), 4);
3141 }
3142
3143 #[test]
3144 fn engine_config_defaults() {
3145 let config = EngineConfig::default();
3146 assert_eq!(config.tick_interval, std::time::Duration::from_secs(10));
3147 assert_eq!(config.sync_interval, std::time::Duration::from_secs(45));
3148 assert_eq!(config.max_parallel, 4);
3149 assert_eq!(config.stuck_timeout, 1800);
3150 assert_eq!(config.no_session_stuck_timeout, 600);
3151 assert!(
3152 config.no_session_stuck_timeout < config.stuck_timeout,
3153 "no_session_stuck_timeout must be shorter than stuck_timeout"
3154 );
3155 assert_eq!(
3156 config.graceful_shutdown_timeout,
3157 std::time::Duration::from_secs(600)
3158 );
3159 assert_eq!(config.upgrade_check_interval, 3600);
3160 }
3161
3162 #[test]
3163 fn engine_config_from_config_uses_defaults_when_no_config() {
3164 let config = EngineConfig::from_config();
3165 assert_eq!(config.tick_interval, std::time::Duration::from_secs(10));
3166 assert_eq!(config.sync_interval, std::time::Duration::from_secs(45));
3167 assert!(config.max_parallel >= 1 && config.max_parallel <= 64);
3169 assert_eq!(config.stuck_timeout, 1800);
3170 assert_eq!(config.no_session_stuck_timeout, 600);
3171 assert_eq!(
3172 config.graceful_shutdown_timeout,
3173 std::time::Duration::from_secs(600)
3174 );
3175 assert_eq!(config.upgrade_check_interval, 3600);
3176 }
3177
3178 #[test]
3179 fn engine_config_no_session_timeout_is_under_15_minutes() {
3180 let config = EngineConfig::default();
3181 assert!(
3182 config.no_session_stuck_timeout <= 900,
3183 "no_session_stuck_timeout ({}) exceeds 15 minutes",
3184 config.no_session_stuck_timeout
3185 );
3186 }
3187
3188 #[test]
3189 fn upgrade_check_runs_immediately_when_never_checked() {
3190 assert!(should_run_upgrade_check(None, 3600));
3191 }
3192
3193 #[test]
3194 fn upgrade_check_waits_for_interval_after_first_run() {
3195 assert!(!should_run_upgrade_check(
3196 Some(std::time::Instant::now()),
3197 3600
3198 ));
3199 }
3200
3201 #[test]
3202 fn upgrade_check_can_be_disabled() {
3203 assert!(!should_run_upgrade_check(None, 0));
3204 }
3205
3206 #[test]
3207 fn configured_agents_parses_yaml_block_array() {
3208 let yaml_str = "- claude\n- codex\n- opencode\n- olm\n";
3210 let parsed: Vec<String> = serde_norway::from_str(yaml_str).unwrap();
3211 assert_eq!(parsed, vec!["claude", "codex", "opencode", "olm"]);
3212 }
3213
3214 #[test]
3215 fn configured_agents_parses_json_inline_array() {
3216 let json_str = r#"["claude","codex","olm"]"#;
3217 let parsed: Vec<String> = serde_json::from_str(json_str).unwrap();
3218 assert_eq!(parsed, vec!["claude", "codex", "olm"]);
3219 }
3220
3221 #[test]
3222 fn configured_agents_fallback_returns_default_agents() {
3223 let defaults = configured_agents();
3225 assert!(defaults.contains(&"claude".to_string()));
3226 assert!(defaults.contains(&"codex".to_string()));
3227 assert!(defaults.contains(&"opencode".to_string()));
3228 }
3229
3230 #[tokio::test]
3239 #[ignore]
3240 async fn integration_channel_to_tmux_to_capture() {
3241 use crate::channels::capture::CaptureService;
3242 use crate::channels::transport::Transport;
3243 use crate::tmux::{SessionGuard, TmuxManager};
3244 use std::sync::Arc;
3245
3246 let transport = Arc::new(Transport::new());
3248 let capture = Arc::new(CaptureService::new(transport.clone()));
3249 let tmux = TmuxManager::new();
3250
3251 let task_id = "testtask1".to_string();
3252 let session_name = format!("orch-test-{}", task_id);
3253
3254 let _ = tmux
3256 .command()
3257 .args(["new-session", "-d", "-s", &session_name])
3258 .output()
3259 .await
3260 .expect("failed to start tmux session");
3261
3262 let _guard = SessionGuard(session_name.clone());
3264
3265 capture
3267 .register_session("owner/repo", &task_id, &session_name)
3268 .await;
3269 transport
3270 .bind(
3271 "owner/repo",
3272 &task_id,
3273 &session_name,
3274 "telegram",
3275 "12345",
3276 None,
3277 )
3278 .await;
3279
3280 let capture_clone = capture.clone();
3282 let capture_handle = tokio::spawn(async move { capture_clone.run().await });
3283
3284 let mut rx = transport
3286 .subscribe("owner/repo", &task_id)
3287 .await
3288 .expect("no subscription");
3289
3290 let send = tmux
3292 .command()
3293 .args([
3294 "send-keys",
3295 "-t",
3296 &session_name,
3297 "echo from-channel",
3298 "Enter",
3299 ])
3300 .output()
3301 .await
3302 .expect("failed to send keys");
3303 assert!(send.status.success());
3304
3305 let mut got = false;
3307 for _ in 0..20 {
3308 if let Ok(Ok(c)) =
3309 tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await
3310 {
3311 if c.content.contains("from-channel") {
3312 got = true;
3313 break;
3314 }
3315 }
3316 }
3317
3318 capture.unregister_session("owner/repo", &task_id).await;
3320
3321 let _ = capture_handle.await;
3323
3324 assert!(got, "did not observe tmux output via capture/transport");
3325 }
3326
3327 #[test]
3330 fn read_project_channel_config_returns_defaults_when_no_file() {
3331 let dir = tempfile::tempdir().expect("tempdir");
3332 let cfg = read_project_channel_config(dir.path());
3333 assert!(cfg.telegram_topic_id.is_none());
3334 assert!(cfg.discord_channel_id.is_none());
3335 }
3336
3337 #[test]
3338 fn read_project_channel_config_parses_telegram_and_discord() {
3339 let dir = tempfile::tempdir().expect("tempdir");
3340 let orch_yml = dir.path().join(".orch.yml");
3341 std::fs::write(
3342 &orch_yml,
3343 "channels:\n telegram:\n topic_id: \"42\"\n discord:\n channel_id: \"9999\"\n",
3344 )
3345 .expect("write .orch.yml");
3346 let cfg = read_project_channel_config(dir.path());
3347 assert_eq!(cfg.telegram_topic_id.as_deref(), Some("42"));
3348 assert_eq!(cfg.discord_channel_id.as_deref(), Some("9999"));
3349 }
3350
3351 #[test]
3352 fn read_project_channel_config_handles_partial_channels() {
3353 let dir = tempfile::tempdir().expect("tempdir");
3354 let orch_yml = dir.path().join(".orch.yml");
3355 std::fs::write(&orch_yml, "channels:\n telegram:\n topic_id: \"7\"\n")
3356 .expect("write .orch.yml");
3357 let cfg = read_project_channel_config(dir.path());
3358 assert_eq!(cfg.telegram_topic_id.as_deref(), Some("7"));
3359 assert!(cfg.discord_channel_id.is_none());
3360 }
3361
3362 #[test]
3363 fn read_project_channel_config_handles_malformed_yaml() {
3364 let dir = tempfile::tempdir().expect("tempdir");
3365 let orch_yml = dir.path().join(".orch.yml");
3366 std::fs::write(&orch_yml, "channels: [\nbad yaml").expect("write .orch.yml");
3367 let cfg = read_project_channel_config(dir.path());
3368 assert!(cfg.telegram_topic_id.is_none());
3370 assert!(cfg.discord_channel_id.is_none());
3371 }
3372
3373 #[test]
3374 #[cfg(unix)]
3375 fn read_project_channel_config_handles_unreadable_file() {
3376 let dir = tempfile::tempdir().expect("tempdir");
3377 std::fs::create_dir(dir.path().join(".orch.yml")).expect("create .orch.yml dir");
3379 let cfg = read_project_channel_config(dir.path());
3380 assert!(cfg.telegram_topic_id.is_none());
3382 assert!(cfg.discord_channel_id.is_none());
3383 }
3384}