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 crate::github::app::start_loader();
2135
2136 let router = Arc::new(RwLock::new(
2143 match tokio::task::spawn_blocking(Router::from_config).await {
2144 Ok(r) => r,
2145 Err(e) => {
2146 tracing::error!(?e, "router init panicked in spawn_blocking, using default");
2147 tokio::task::spawn_blocking(|| Router::new(RouterConfig::default()))
2148 .await
2149 .unwrap_or_else(|e2| {
2150 panic!("router default init also panicked in spawn_blocking — cannot start engine without a router: {e2:?}");
2151 })
2152 }
2153 },
2154 ));
2155 {
2156 let r = router.read().await;
2157 tracing::info!(
2158 mode = %r.config.mode,
2159 agents = ?r.available_agents,
2160 fallback = %r.config.fallback_executor,
2161 "router initialized"
2162 );
2163 }
2164
2165 let semaphore = Arc::new(Semaphore::new(config.max_parallel));
2169 let mut parallel_in_force = config.max_parallel;
2170
2171 let dispatching: Arc<dashmap::DashMap<String, String>> = Arc::new(dashmap::DashMap::new());
2178
2179 let auto_merge_in_flight: Arc<dashmap::DashSet<String>> = Arc::new(dashmap::DashSet::new());
2183
2184 let mut config_rx = crate::config::subscribe();
2186
2187 let mut last_sync = std::time::Instant::now();
2189
2190 let mut last_upgrade_check: Option<std::time::Instant> = None;
2192
2193 let mut rate_limit_reported: Option<i64> = None;
2196
2197 let (weight_tx, mut weight_rx) = mpsc::channel::<WeightSignal>(64);
2199
2200 for engine in project_engines.iter().filter(|_| leads) {
2206 let in_review_from_store = engine
2208 .store
2209 .list_by_status(&engine.repo, crate::store::TaskStatus::InReview)
2210 .await
2211 .unwrap_or_default()
2212 .iter()
2213 .map(crate::engine::tasks::store_task_to_external)
2214 .collect::<Vec<_>>();
2215 if !in_review_from_store.is_empty() {
2216 let in_review = &in_review_from_store;
2217 for task in in_review {
2218 let session_expected =
2219 review_session_expected(&engine.store, &engine.repo, &task.id.0).await;
2220 let age_minutes = chrono::DateTime::parse_from_rfc3339(&task.updated_at)
2221 .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_minutes())
2222 .unwrap_or(i64::MAX);
2223 if !session_expected && age_minutes < 10 {
2224 continue;
2225 }
2226 if let Err(e) = engine
2227 .task_manager
2228 .update_task_status(&task.id, Status::NeedsReview)
2229 .await
2230 {
2231 tracing::warn!(
2232 task_id = task.id.0,
2233 err = %e,
2234 "failed to reset stale InReview task on startup"
2235 );
2236 } else {
2237 set_review_session_expected(&engine.store, &engine.repo, &task.id.0, false)
2238 .await;
2239 }
2240 }
2241 if !in_review.is_empty() {
2242 tracing::info!(
2243 repo = %engine.repo,
2244 count = in_review.len(),
2245 "reset stale InReview tasks to NeedsReview on startup"
2246 );
2247 }
2248 }
2249
2250 use crate::store::TaskStatus as DbStatus;
2254 if let Ok(internal_in_review) = engine
2255 .task_manager
2256 .list_internal_by_status(DbStatus::InReview)
2257 .await
2258 {
2259 for task in &internal_in_review {
2260 let task_id = task.id.0.clone();
2261 let session_expected =
2262 review_session_expected(&engine.store, &engine.repo, &task_id).await;
2263 let age_minutes = chrono::DateTime::parse_from_rfc3339(&task.updated_at)
2264 .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_minutes())
2265 .unwrap_or(i64::MAX);
2266 if !session_expected && age_minutes < 10 {
2267 continue;
2268 }
2269 if let Err(e) = engine
2270 .task_manager
2271 .update_task_status(&ExternalId(task_id.clone()), Status::NeedsReview)
2272 .await
2273 {
2274 tracing::warn!(
2275 task_id,
2276 err = %e,
2277 "failed to reset stale internal InReview task on startup"
2278 );
2279 } else {
2280 set_review_session_expected(&engine.store, &engine.repo, &task_id, false).await;
2281 }
2282 }
2283 if !internal_in_review.is_empty() {
2284 tracing::info!(
2285 repo = %engine.repo,
2286 count = internal_in_review.len(),
2287 "reset stale internal InReview tasks to NeedsReview on startup"
2288 );
2289 }
2290 }
2291 }
2292
2293 for engine in &project_engines {
2295 subscribers::dispatch::spawn(
2296 event_bus.subscribe(),
2297 engine.backend.clone(),
2298 tmux.clone(),
2299 engine.runner.clone(),
2300 capture_for_tick.clone(),
2301 semaphore.clone(),
2302 engine.task_manager.clone(),
2303 weight_tx.clone(),
2304 router.clone(),
2305 dispatching.clone(),
2306 engine.store.clone(),
2307 engine.repo.clone(),
2308 );
2309 }
2310 tracing::info!("dispatch subscriber started");
2311
2312 for engine in &project_engines {
2314 subscribers::review::spawn(
2315 event_bus.subscribe(),
2316 engine.backend.clone(),
2317 tmux.clone(),
2318 semaphore.clone(),
2319 engine.task_manager.clone(),
2320 router.clone(),
2321 dispatching.clone(),
2322 engine.store.clone(),
2323 engine.repo.clone(),
2324 );
2325 }
2326 tracing::info!("review subscriber started");
2327
2328 for engine in &project_engines {
2330 subscribers::unblock::spawn(
2331 event_bus.subscribe(),
2332 engine.backend.clone(),
2333 engine.task_manager.clone(),
2334 engine.store.clone(),
2335 engine.repo.clone(),
2336 );
2337 }
2338 tracing::info!("unblock subscriber started");
2339
2340 subscribers::notify::spawn(event_bus.subscribe(), transport.clone());
2343 tracing::info!("notify subscriber started");
2344
2345 tracing::info!(
2347 tick = ?config.tick_interval,
2348 sync = ?config.sync_interval,
2349 parallel = config.max_parallel,
2350 "entering main loop"
2351 );
2352 let mut restart_only = restart_only_snapshot();
2355
2356 let mut interval = tokio::time::interval(config.tick_interval);
2357
2358 let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2360 let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
2361
2362 let sync_in_progress = Arc::new(std::sync::atomic::AtomicBool::new(false));
2364
2365 let last_tick_epoch = Arc::new(std::sync::atomic::AtomicU64::new(
2368 std::time::SystemTime::now()
2369 .duration_since(std::time::UNIX_EPOCH)
2370 .unwrap_or_default()
2371 .as_secs(),
2372 ));
2373 {
2374 let last_tick_epoch = Arc::clone(&last_tick_epoch);
2375 let tick_interval_secs = config.tick_interval.as_secs();
2376 tokio::spawn(async move {
2377 let mut watchdog_interval = tokio::time::interval(std::time::Duration::from_secs(30));
2379 let mut suspend_detector = crate::engine::suspend::SuspendDetector::new();
2384 loop {
2385 watchdog_interval.tick().await;
2386 let own_gap = suspend_detector.check();
2387 let last = last_tick_epoch.load(std::sync::atomic::Ordering::Relaxed);
2388 let now = std::time::SystemTime::now()
2389 .duration_since(std::time::UNIX_EPOCH)
2390 .unwrap_or_default()
2391 .as_secs();
2392 let stale_secs = now.saturating_sub(last);
2393 let threshold = (tick_interval_secs * 6).max(60);
2395 if stale_secs > threshold {
2396 let shared_gap = crate::engine::suspend::gap_detected_within(
2403 std::time::Duration::from_secs(stale_secs),
2404 );
2405 let gap = match (own_gap, shared_gap) {
2406 (Some(a), Some(b)) => Some(a.max(b)),
2407 (Some(a), None) => Some(a),
2408 (None, Some(b)) => Some(b),
2409 (None, None) => None,
2410 };
2411 if let Some(gap) = gap {
2412 let gap_secs = gap.num_seconds() as u64;
2413 if gap_secs >= stale_secs {
2414 tracing::info!(
2415 stale_secs,
2416 threshold,
2417 suspend_gap_secs = gap_secs,
2418 "tick loop stale window explained by host suspend/resume, not a stall"
2419 );
2420 } else {
2421 tracing::error!(
2422 stale_secs,
2423 threshold,
2424 suspend_gap_secs = gap_secs,
2425 "WATCHDOG: tick loop has not completed a tick in {}s (threshold {}s) — suspend gap of {}s only partially explains the stall",
2426 stale_secs,
2427 threshold,
2428 gap_secs,
2429 );
2430 }
2431 } else {
2432 tracing::error!(
2433 stale_secs,
2434 threshold,
2435 "WATCHDOG: tick loop has not completed a tick in {}s (threshold {}s) — possible stall",
2436 stale_secs,
2437 threshold,
2438 );
2439 }
2440 }
2441 }
2442 });
2443 }
2444
2445 loop {
2446 tokio::select! {
2447 _ = interval.tick() => {
2448 let tick_start = std::time::Instant::now();
2449
2450 if let Some(gap) = crate::engine::suspend::checkpoint() {
2454 tracing::info!(
2455 gap_secs = gap.num_seconds(),
2456 "host suspend/resume gap detected, discounting from stuck-task age checks"
2457 );
2458 }
2459
2460 if parallel_in_force != config.max_parallel {
2464 let resized =
2465 resize_dispatch_pool(&semaphore, parallel_in_force, config.max_parallel);
2466 if resized != parallel_in_force {
2467 parallel_in_force = resized;
2468 tracing::info!(
2469 in_force = parallel_in_force,
2470 target = config.max_parallel,
2471 "dispatch pool resized"
2472 );
2473 }
2474 }
2475
2476 {
2478 let mut rw = router.write().await;
2479 rw.tick_weight_recovery();
2480 }
2481
2482 if let Some(remaining) = GhHttp::is_rate_limited() {
2486 let reset_at = chrono::Utc::now()
2487 .timestamp()
2488 .saturating_add(i64::try_from(remaining.as_secs()).unwrap_or(i64::MAX));
2489 if rate_limit_reported.is_none_or(|prev| (reset_at - prev).abs() > 60) {
2491 rate_limit_reported = Some(reset_at);
2492 report_rate_limited(reset_at, project_engines.first().map(|e| &e.store), Some(&transport)).await;
2493 }
2494 } else if let Some(prev) = rate_limit_reported.take() {
2495 tracing::info!(
2496 limited_until = prev,
2497 "GitHub API rate limit cleared"
2498 );
2499 if let Some(engine) = project_engines.first() {
2500 let _ = engine.store.kv_delete(RATE_LIMITED_UNTIL_KEY).await;
2501 }
2502 }
2503
2504 tick::tick_all(
2506 &project_engines,
2507 &tmux,
2508 &capture_for_tick,
2509 &semaphore,
2510 &config,
2511 &router,
2512 &weight_tx,
2513 &dispatching,
2514 Some(&transport),
2515 &active_repos,
2516 &auto_merge_in_flight,
2517 ).await;
2518
2519 while let Ok(signal) = weight_rx.try_recv() {
2522 let mut rw = router.write().await;
2523 match signal {
2524 WeightSignal::RateLimited { ref agent } => {
2525 rw.record_rate_limit(agent);
2526 }
2527 WeightSignal::Success { ref agent } => {
2528 rw.record_success(agent);
2529 }
2530 WeightSignal::Rerouted | WeightSignal::Blocked | WeightSignal::None => {}
2531 }
2532 }
2533
2534 if last_sync.elapsed() >= config.sync_interval {
2539 if sync_in_progress.compare_exchange(
2540 false,
2541 true,
2542 std::sync::atomic::Ordering::AcqRel,
2543 std::sync::atomic::Ordering::Relaxed,
2544 ).is_ok() {
2545 spawn_sync_pass(
2546 &project_engines,
2547 &tmux,
2548 &config,
2549 &router,
2550 &dispatching,
2551 &auto_merge_in_flight,
2552 &sync_in_progress,
2553 "periodic",
2554 );
2555 } else {
2556 tracing::debug!("sync tick still in progress, skipping");
2557 }
2558 last_sync = std::time::Instant::now();
2562 }
2563
2564 if webhook_enabled {
2566 let health_check_interval = config.webhook_health_check_interval
2567 .unwrap_or(std::time::Duration::from_secs(60));
2568
2569 if last_webhook_health_check.elapsed() >= health_check_interval {
2570 if let Some(port) = webhook_port {
2571 let (health, failure_reason) =
2572 crate::channels::github::check_webhook_health(port).await;
2573 if health != webhook_healthy {
2574 webhook_healthy = health;
2575 if webhook_healthy {
2576 tracing::info!(port, orch_webhook_up = true, "webhook health restored");
2577 } else {
2578 tracing::warn!(
2579 port,
2580 orch_webhook_up = false,
2581 reason = failure_reason.as_deref().unwrap_or("unknown"),
2582 "webhook health check failed"
2583 );
2584 }
2585 }
2586 {
2588 let to_save = {
2589 let mut s = webhook_status.lock().await;
2590 s.healthy = health;
2591 s.last_check_utc = Some(chrono::Utc::now());
2592 if health {
2593 s.last_failure_reason = None;
2594 } else if failure_reason.is_some() {
2595 s.last_failure_reason = failure_reason;
2596 }
2597 s.clone()
2598 };
2599 to_save.save().await;
2600 }
2601 }
2602 last_webhook_health_check = std::time::Instant::now();
2603 }
2604 }
2605
2606 if should_run_upgrade_check(last_upgrade_check, config.upgrade_check_interval) {
2608 if let Some(store) = project_engines.first().map(|e| e.store.clone()) {
2609 let channels = channel_registry.clone();
2610 let current = env!("ORCH_VERSION").to_string();
2611 let check_interval =
2612 std::time::Duration::from_secs(config.upgrade_check_interval);
2613 tokio::spawn(async move {
2614 check_and_notify_upgrade(&store, &channels, ¤t, check_interval)
2615 .await;
2616 });
2617 }
2618 last_upgrade_check = Some(std::time::Instant::now());
2619 }
2620
2621 let tick_elapsed = tick_start.elapsed();
2623 if tick_elapsed.as_secs() > 30 {
2624 tracing::warn!(elapsed_ms = tick_elapsed.as_millis() as u64, "slow tick");
2625 } else {
2626 tracing::debug!(elapsed_ms = tick_elapsed.as_millis() as u64, "tick complete");
2627 }
2628 last_tick_epoch.store(
2629 std::time::SystemTime::now()
2630 .duration_since(std::time::UNIX_EPOCH)
2631 .unwrap_or_default()
2632 .as_secs(),
2633 std::sync::atomic::Ordering::Relaxed,
2634 );
2635 }
2636 _ = webhook_notify.notified() => {
2638 if let Some(remaining) = GhHttp::is_rate_limited() {
2639 tracing::warn!(
2640 remaining_secs = remaining.as_secs(),
2641 "GitHub API rate-limited, skipping webhook-triggered tick"
2642 );
2643 } else {
2644 tracing::info!("webhook event triggered immediate tick");
2645
2646 tick::tick_all(
2647 &project_engines,
2648 &tmux,
2649 &capture_for_tick,
2650 &semaphore,
2651 &config,
2652 &router,
2653 &weight_tx,
2654 &dispatching,
2655 Some(&transport),
2656 &active_repos,
2657 &auto_merge_in_flight,
2658 ).await;
2659
2660 while let Ok(signal) = weight_rx.try_recv() {
2662 let mut rw = router.write().await;
2663 match signal {
2664 WeightSignal::RateLimited { ref agent } => {
2665 rw.record_rate_limit(agent);
2666 }
2667 WeightSignal::Success { ref agent } => {
2668 rw.record_success(agent);
2669 }
2670 WeightSignal::Rerouted | WeightSignal::Blocked | WeightSignal::None => {}
2671 }
2672 }
2673
2674 if sync_in_progress.compare_exchange(
2680 false,
2681 true,
2682 std::sync::atomic::Ordering::AcqRel,
2683 std::sync::atomic::Ordering::Relaxed,
2684 ).is_ok() {
2685 spawn_sync_pass(
2686 &project_engines,
2687 &tmux,
2688 &config,
2689 &router,
2690 &dispatching,
2691 &auto_merge_in_flight,
2692 &sync_in_progress,
2693 "webhook",
2694 );
2695 last_sync = std::time::Instant::now();
2697 } else {
2698 tracing::debug!("sync tick still in progress, skipping webhook-triggered immediate sync");
2699 }
2700 }
2701
2702 interval.reset();
2704 }
2705 result = config_rx.recv() => {
2706 let reload = match result {
2709 Ok(path) => {
2710 tracing::info!(path = %path.display(), "config file changed, reloading");
2711 true
2712 }
2713 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
2714 tracing::warn!(missed = n, "config change receiver lagged, reloading");
2715 true
2716 }
2717 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
2718 tracing::warn!("config change channel closed");
2719 false
2720 }
2721 };
2722
2723 if reload {
2724 let new_config = EngineConfig::from_config();
2726 log_engine_config_changes(&config, &new_config);
2727
2728 let new_restart_only = restart_only_snapshot();
2729 log_restart_only_changes(&restart_only, &new_restart_only);
2730 restart_only = new_restart_only;
2731
2732 let tick_changed = new_config.tick_interval != config.tick_interval;
2733 config = new_config;
2734
2735 if tick_changed {
2737 interval = tokio::time::interval(config.tick_interval);
2738 tracing::info!(tick = ?config.tick_interval, "tick interval updated");
2739 }
2740
2741 if parallel_in_force != config.max_parallel {
2744 parallel_in_force = resize_dispatch_pool(
2745 &semaphore,
2746 parallel_in_force,
2747 config.max_parallel,
2748 );
2749 tracing::info!(
2750 in_force = parallel_in_force,
2751 target = config.max_parallel,
2752 "dispatch pool resized"
2753 );
2754 }
2755
2756 {
2758 let mut router_guard = router.write().await;
2759 router_guard.reload_async().await;
2760 }
2761
2762 sync_project_engines(
2764 &mut project_engines,
2765 &mut active_repos,
2766 &event_bus,
2767 &tmux,
2768 &capture_for_tick,
2769 &semaphore,
2770 &weight_tx,
2771 &router,
2772 &dispatching,
2773 ).await;
2774
2775 tracing::info!(
2776 tick = ?config.tick_interval,
2777 sync = ?config.sync_interval,
2778 parallel = config.max_parallel,
2779 projects = project_engines.len(),
2780 "config reloaded"
2781 );
2782 }
2783 }
2784 signal_name = async {
2785 tokio::select! {
2786 _ = tokio::signal::ctrl_c() => "SIGINT",
2787 _ = sigterm.recv() => "SIGTERM",
2788 _ = sighup.recv() => "SIGHUP",
2789 }
2790 } => {
2791 tracing::info!(signal = signal_name, "beginning graceful shutdown");
2792
2793 let shutdown_result = tokio::time::timeout(
2796 config.graceful_shutdown_timeout,
2797 async {
2798 let mut reset_count = 0u32;
2802 let mut review_reset_count = 0u32;
2803 for engine in &project_engines {
2804 if let Ok(tasks) = engine.task_manager.list_external_by_status(Status::InProgress).await {
2805 for task in &tasks {
2806 if let Err(e) = engine.task_manager.update_task_status(&task.id, Status::Routed).await {
2807 tracing::warn!(task_id = task.id.0, ?e, "failed to reset task on shutdown");
2808 } else {
2809 if let Ok(Some(store_id)) =
2810 engine.store.resolve_task_id(&engine.repo, &task.id.0).await
2811 {
2812 if let Err(e) = engine
2813 .store
2814 .finalize_incomplete_runs(
2815 store_id,
2816 "aborted",
2817 "graceful shutdown: reset in_progress task to routed",
2818 )
2819 .await
2820 {
2821 tracing::warn!(
2822 task_id = task.id.0,
2823 ?e,
2824 "failed to finalize incomplete runs on shutdown"
2825 );
2826 }
2827 }
2828 reset_count += 1;
2829 }
2830 }
2831 }
2832 if let Ok(tasks) = engine.store.list_internal_by_status(&engine.repo, crate::store::TaskStatus::InProgress).await {
2834 for task in &tasks {
2835 let task_id = format!("internal:{}", task.id);
2836 if let Err(e) = engine.task_manager.update_task_status(
2837 &crate::backends::ExternalId(task_id.clone()),
2838 Status::Routed,
2839 ).await {
2840 tracing::warn!(task_id, ?e, "failed to reset internal task on shutdown");
2841 } else {
2842 if let Err(e) = engine
2843 .store
2844 .finalize_incomplete_runs(
2845 task.id,
2846 "aborted",
2847 "graceful shutdown: reset in_progress task to routed",
2848 )
2849 .await
2850 {
2851 tracing::warn!(
2852 task_id,
2853 ?e,
2854 "failed to finalize incomplete internal runs on shutdown"
2855 );
2856 }
2857 reset_count += 1;
2858 }
2859 }
2860 }
2861 if let Ok(tasks) = engine.task_manager.list_external_by_status(Status::InReview).await {
2863 for task in &tasks {
2864 if let Err(e) = engine.task_manager.update_task_status(&task.id, Status::NeedsReview).await {
2865 tracing::warn!(task_id = task.id.0, ?e, "failed to reset in_review task on shutdown");
2866 } else {
2867 set_review_session_expected(&engine.store, &engine.repo, &task.id.0, false).await;
2868 if let Ok(Some(store_id)) =
2869 engine.store.resolve_task_id(&engine.repo, &task.id.0).await
2870 {
2871 if let Err(e) = engine
2872 .store
2873 .finalize_incomplete_runs(
2874 store_id,
2875 "aborted",
2876 "graceful shutdown: reset in_review task to needs_review",
2877 )
2878 .await
2879 {
2880 tracing::warn!(
2881 task_id = task.id.0,
2882 ?e,
2883 "failed to finalize incomplete review runs on shutdown"
2884 );
2885 }
2886 }
2887 review_reset_count += 1;
2888 }
2889 }
2890 }
2891 if let Ok(tasks) = engine.store.list_internal_by_status(&engine.repo, crate::store::TaskStatus::InReview).await {
2893 for task in &tasks {
2894 let task_id = format!("internal:{}", task.id);
2895 if let Err(e) = engine.task_manager.update_task_status(
2896 &crate::backends::ExternalId(task_id.clone()),
2897 Status::NeedsReview,
2898 ).await {
2899 tracing::warn!(task_id, ?e, "failed to reset internal in_review task on shutdown");
2900 } else {
2901 set_review_session_expected(&engine.store, &engine.repo, &task_id, false).await;
2902 if let Err(e) = engine
2903 .store
2904 .finalize_incomplete_runs(
2905 task.id,
2906 "aborted",
2907 "graceful shutdown: reset in_review task to needs_review",
2908 )
2909 .await
2910 {
2911 tracing::warn!(
2912 task_id,
2913 ?e,
2914 "failed to finalize incomplete internal review runs on shutdown"
2915 );
2916 }
2917 review_reset_count += 1;
2918 }
2919 }
2920 }
2921 }
2922 if reset_count > 0 {
2923 tracing::info!(reset_count, "reset in_progress tasks to routed for re-dispatch");
2924 }
2925 if review_reset_count > 0 {
2926 tracing::info!(review_reset_count, "reset in_review tasks to needs_review for re-dispatch");
2927 }
2928
2929 let mut killed_sessions = Vec::new();
2932 if let Ok(sessions) = tmux.list_sessions().await {
2933 for session in &sessions {
2934 if session.name.starts_with("orch-") {
2935 if let Err(e) = tmux.kill_session(&session.name).await {
2936 tracing::warn!(session = %session.name, error = %e, "failed to kill tmux session");
2937 } else {
2938 killed_sessions.push(session.name.clone());
2939 }
2940 }
2941 }
2942 if !killed_sessions.is_empty() {
2943 tracing::info!(killed = killed_sessions.len(), "killing orch tmux sessions on shutdown");
2944 }
2945 }
2946
2947 if !killed_sessions.is_empty() {
2950 let still_alive = tmux
2951 .wait_for_sessions_dead(
2952 &killed_sessions,
2953 std::time::Duration::from_millis(100),
2954 std::time::Duration::from_secs(5),
2955 )
2956 .await;
2957 if still_alive > 0 {
2958 tracing::warn!(
2959 still_alive,
2960 "some tmux sessions did not terminate cleanly - they will be cleaned up on startup"
2961 );
2962 }
2963 tracing::info!(killed = killed_sessions.len() - still_alive, "confirmed tmux sessions terminated");
2964 }
2965 },
2966 ).await;
2967
2968 if shutdown_result.is_err() {
2969 tracing::error!(
2970 timeout_secs = config.graceful_shutdown_timeout.as_secs(),
2971 "graceful shutdown timed out, forcing exit"
2972 );
2973 }
2974
2975 break;
2976 }
2977 }
2978 }
2979
2980 events::cleanup_port_file().await;
2982 crate::cluster::local::cleanup().await;
2983 events::cleanup_version_file();
2984
2985 let _ = transport;
2987 tracing::info!("orch engine stopped");
2988 Ok(())
2989}
2990
2991#[cfg(test)]
2992mod tests {
2993 use super::*;
2994
2995 #[test]
2996 fn growing_max_parallel_lets_more_tasks_run_without_a_restart() {
2997 let semaphore = Arc::new(Semaphore::new(4));
2998 let held: Vec<_> = (0..4)
2999 .map(|_| semaphore.clone().try_acquire_owned().unwrap())
3000 .collect();
3001 assert!(
3002 semaphore.clone().try_acquire_owned().is_err(),
3003 "pool is full"
3004 );
3005
3006 let in_force = resize_dispatch_pool(&semaphore, 4, 8);
3007 assert_eq!(in_force, 8);
3008
3009 let grown: Vec<_> = (0..4)
3010 .map(|_| semaphore.clone().try_acquire_owned().unwrap())
3011 .collect();
3012 assert_eq!(held.len() + grown.len(), 8, "8 tasks run at once");
3013 assert!(
3014 semaphore.clone().try_acquire_owned().is_err(),
3015 "pool is full"
3016 );
3017 }
3018
3019 #[test]
3020 fn shrinking_max_parallel_never_cancels_a_running_task() {
3021 let semaphore = Arc::new(Semaphore::new(8));
3022 let mut held: Vec<_> = (0..8)
3023 .map(|_| semaphore.clone().try_acquire_owned().unwrap())
3024 .collect();
3025
3026 let mut in_force = resize_dispatch_pool(&semaphore, 8, 4);
3028 assert_eq!(in_force, 8);
3029 assert_eq!(held.len(), 8);
3030
3031 held.truncate(5);
3033 in_force = resize_dispatch_pool(&semaphore, in_force, 4);
3034 assert_eq!(in_force, 5);
3035 assert!(semaphore.clone().try_acquire_owned().is_err());
3036
3037 held.truncate(4);
3039 in_force = resize_dispatch_pool(&semaphore, in_force, 4);
3040 assert_eq!(in_force, 4);
3041 assert!(semaphore.clone().try_acquire_owned().is_err());
3042
3043 held.truncate(3);
3045 assert!(semaphore.clone().try_acquire_owned().is_ok());
3046 }
3047
3048 #[test]
3049 fn a_project_added_to_the_config_is_picked_up_and_running_ones_are_not_respawned() {
3050 let configured = vec![
3051 (
3052 "owner/one".to_string(),
3053 std::path::PathBuf::from("/srv/one"),
3054 ),
3055 (
3056 "owner/two".to_string(),
3057 std::path::PathBuf::from("/srv/two"),
3058 ),
3059 ];
3060 let active: std::collections::HashSet<String> =
3061 ["owner/one".to_string()].into_iter().collect();
3062
3063 let (added, removed) = diff_configured_projects(&configured, &active);
3064 assert_eq!(
3065 added,
3066 vec![(
3067 "owner/two".to_string(),
3068 std::path::PathBuf::from("/srv/two")
3069 )],
3070 "only the new project is started, so owner/one keeps its single set of subscribers"
3071 );
3072 assert!(removed.is_empty());
3073 }
3074
3075 #[test]
3076 fn a_project_dropped_from_the_config_is_reported_and_not_restarted() {
3077 let configured = vec![(
3078 "owner/one".to_string(),
3079 std::path::PathBuf::from("/srv/one"),
3080 )];
3081 let active: std::collections::HashSet<String> =
3082 ["owner/one".to_string(), "owner/gone".to_string()]
3083 .into_iter()
3084 .collect();
3085
3086 let (added, removed) = diff_configured_projects(&configured, &active);
3087 assert!(added.is_empty(), "nothing new to start");
3088 assert_eq!(removed, vec!["owner/gone".to_string()]);
3089 }
3090
3091 #[test]
3092 fn an_unchanged_project_list_starts_nothing() {
3093 let configured = vec![(
3094 "owner/one".to_string(),
3095 std::path::PathBuf::from("/srv/one"),
3096 )];
3097 let active: std::collections::HashSet<String> =
3098 ["owner/one".to_string()].into_iter().collect();
3099
3100 assert_eq!(
3101 diff_configured_projects(&configured, &active),
3102 (vec![], vec![])
3103 );
3104 }
3105
3106 #[test]
3107 fn restart_only_keys_are_the_settings_read_once_at_startup() {
3108 assert_eq!(RESTART_ONLY_KEYS, ["channels", "webhook", "relay"]);
3111 }
3112
3113 #[test]
3114 fn a_changed_restart_only_key_is_reported_and_an_unchanged_one_is_not() {
3115 let before = vec![Some("bot_token: a\n".to_string()), None];
3116
3117 assert!(
3118 log_restart_only_changes(&before, &before.clone()).is_empty(),
3119 "a reload that did not touch these keys must stay quiet"
3120 );
3121
3122 let after = vec![
3124 Some("bot_token: b\n".to_string()),
3125 Some("port: 8080\n".to_string()),
3126 ];
3127 assert_eq!(
3128 log_restart_only_changes(&before, &after),
3129 ["channels", "webhook"]
3130 );
3131
3132 let token_only = vec![Some("bot_token: b\n".to_string()), None];
3134 assert_eq!(log_restart_only_changes(&before, &token_only), ["channels"]);
3135 }
3136
3137 #[test]
3138 fn resize_dispatch_pool_is_a_noop_at_the_current_limit() {
3139 let semaphore = Arc::new(Semaphore::new(4));
3140 assert_eq!(resize_dispatch_pool(&semaphore, 4, 4), 4);
3141 assert_eq!(semaphore.available_permits(), 4);
3142 }
3143
3144 #[test]
3145 fn engine_config_defaults() {
3146 let config = EngineConfig::default();
3147 assert_eq!(config.tick_interval, std::time::Duration::from_secs(10));
3148 assert_eq!(config.sync_interval, std::time::Duration::from_secs(45));
3149 assert_eq!(config.max_parallel, 4);
3150 assert_eq!(config.stuck_timeout, 1800);
3151 assert_eq!(config.no_session_stuck_timeout, 600);
3152 assert!(
3153 config.no_session_stuck_timeout < config.stuck_timeout,
3154 "no_session_stuck_timeout must be shorter than stuck_timeout"
3155 );
3156 assert_eq!(
3157 config.graceful_shutdown_timeout,
3158 std::time::Duration::from_secs(600)
3159 );
3160 assert_eq!(config.upgrade_check_interval, 3600);
3161 }
3162
3163 #[test]
3164 fn engine_config_from_config_uses_defaults_when_no_config() {
3165 let config = EngineConfig::from_config();
3166 assert_eq!(config.tick_interval, std::time::Duration::from_secs(10));
3167 assert_eq!(config.sync_interval, std::time::Duration::from_secs(45));
3168 assert!(config.max_parallel >= 1 && config.max_parallel <= 64);
3170 assert_eq!(config.stuck_timeout, 1800);
3171 assert_eq!(config.no_session_stuck_timeout, 600);
3172 assert_eq!(
3173 config.graceful_shutdown_timeout,
3174 std::time::Duration::from_secs(600)
3175 );
3176 assert_eq!(config.upgrade_check_interval, 3600);
3177 }
3178
3179 #[test]
3180 fn engine_config_no_session_timeout_is_under_15_minutes() {
3181 let config = EngineConfig::default();
3182 assert!(
3183 config.no_session_stuck_timeout <= 900,
3184 "no_session_stuck_timeout ({}) exceeds 15 minutes",
3185 config.no_session_stuck_timeout
3186 );
3187 }
3188
3189 #[test]
3190 fn upgrade_check_runs_immediately_when_never_checked() {
3191 assert!(should_run_upgrade_check(None, 3600));
3192 }
3193
3194 #[test]
3195 fn upgrade_check_waits_for_interval_after_first_run() {
3196 assert!(!should_run_upgrade_check(
3197 Some(std::time::Instant::now()),
3198 3600
3199 ));
3200 }
3201
3202 #[test]
3203 fn upgrade_check_can_be_disabled() {
3204 assert!(!should_run_upgrade_check(None, 0));
3205 }
3206
3207 #[test]
3208 fn configured_agents_parses_yaml_block_array() {
3209 let yaml_str = "- claude\n- codex\n- opencode\n- olm\n";
3211 let parsed: Vec<String> = serde_norway::from_str(yaml_str).unwrap();
3212 assert_eq!(parsed, vec!["claude", "codex", "opencode", "olm"]);
3213 }
3214
3215 #[test]
3216 fn configured_agents_parses_json_inline_array() {
3217 let json_str = r#"["claude","codex","olm"]"#;
3218 let parsed: Vec<String> = serde_json::from_str(json_str).unwrap();
3219 assert_eq!(parsed, vec!["claude", "codex", "olm"]);
3220 }
3221
3222 #[test]
3223 fn configured_agents_fallback_returns_default_agents() {
3224 let defaults = configured_agents();
3226 assert!(defaults.contains(&"claude".to_string()));
3227 assert!(defaults.contains(&"codex".to_string()));
3228 assert!(defaults.contains(&"opencode".to_string()));
3229 }
3230
3231 #[tokio::test]
3240 #[ignore]
3241 async fn integration_channel_to_tmux_to_capture() {
3242 use crate::channels::capture::CaptureService;
3243 use crate::channels::transport::Transport;
3244 use crate::tmux::{SessionGuard, TmuxManager};
3245 use std::sync::Arc;
3246
3247 let transport = Arc::new(Transport::new());
3249 let capture = Arc::new(CaptureService::new(transport.clone()));
3250 let tmux = TmuxManager::new();
3251
3252 let task_id = "testtask1".to_string();
3253 let session_name = format!("orch-test-{}", task_id);
3254
3255 let _ = tmux
3257 .command()
3258 .args(["new-session", "-d", "-s", &session_name])
3259 .output()
3260 .await
3261 .expect("failed to start tmux session");
3262
3263 let _guard = SessionGuard(session_name.clone());
3265
3266 capture
3268 .register_session("owner/repo", &task_id, &session_name)
3269 .await;
3270 transport
3271 .bind(
3272 "owner/repo",
3273 &task_id,
3274 &session_name,
3275 "telegram",
3276 "12345",
3277 None,
3278 )
3279 .await;
3280
3281 let capture_clone = capture.clone();
3283 let capture_handle = tokio::spawn(async move { capture_clone.run().await });
3284
3285 let mut rx = transport
3287 .subscribe("owner/repo", &task_id)
3288 .await
3289 .expect("no subscription");
3290
3291 let send = tmux
3293 .command()
3294 .args([
3295 "send-keys",
3296 "-t",
3297 &session_name,
3298 "echo from-channel",
3299 "Enter",
3300 ])
3301 .output()
3302 .await
3303 .expect("failed to send keys");
3304 assert!(send.status.success());
3305
3306 let mut got = false;
3308 for _ in 0..20 {
3309 if let Ok(Ok(c)) =
3310 tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await
3311 {
3312 if c.content.contains("from-channel") {
3313 got = true;
3314 break;
3315 }
3316 }
3317 }
3318
3319 capture.unregister_session("owner/repo", &task_id).await;
3321
3322 let _ = capture_handle.await;
3324
3325 assert!(got, "did not observe tmux output via capture/transport");
3326 }
3327
3328 #[test]
3331 fn read_project_channel_config_returns_defaults_when_no_file() {
3332 let dir = tempfile::tempdir().expect("tempdir");
3333 let cfg = read_project_channel_config(dir.path());
3334 assert!(cfg.telegram_topic_id.is_none());
3335 assert!(cfg.discord_channel_id.is_none());
3336 }
3337
3338 #[test]
3339 fn read_project_channel_config_parses_telegram_and_discord() {
3340 let dir = tempfile::tempdir().expect("tempdir");
3341 let orch_yml = dir.path().join(".orch.yml");
3342 std::fs::write(
3343 &orch_yml,
3344 "channels:\n telegram:\n topic_id: \"42\"\n discord:\n channel_id: \"9999\"\n",
3345 )
3346 .expect("write .orch.yml");
3347 let cfg = read_project_channel_config(dir.path());
3348 assert_eq!(cfg.telegram_topic_id.as_deref(), Some("42"));
3349 assert_eq!(cfg.discord_channel_id.as_deref(), Some("9999"));
3350 }
3351
3352 #[test]
3353 fn read_project_channel_config_handles_partial_channels() {
3354 let dir = tempfile::tempdir().expect("tempdir");
3355 let orch_yml = dir.path().join(".orch.yml");
3356 std::fs::write(&orch_yml, "channels:\n telegram:\n topic_id: \"7\"\n")
3357 .expect("write .orch.yml");
3358 let cfg = read_project_channel_config(dir.path());
3359 assert_eq!(cfg.telegram_topic_id.as_deref(), Some("7"));
3360 assert!(cfg.discord_channel_id.is_none());
3361 }
3362
3363 #[test]
3364 fn read_project_channel_config_handles_malformed_yaml() {
3365 let dir = tempfile::tempdir().expect("tempdir");
3366 let orch_yml = dir.path().join(".orch.yml");
3367 std::fs::write(&orch_yml, "channels: [\nbad yaml").expect("write .orch.yml");
3368 let cfg = read_project_channel_config(dir.path());
3369 assert!(cfg.telegram_topic_id.is_none());
3371 assert!(cfg.discord_channel_id.is_none());
3372 }
3373
3374 #[test]
3375 #[cfg(unix)]
3376 fn read_project_channel_config_handles_unreadable_file() {
3377 let dir = tempfile::tempdir().expect("tempdir");
3378 std::fs::create_dir(dir.path().join(".orch.yml")).expect("create .orch.yml dir");
3380 let cfg = read_project_channel_config(dir.path());
3381 assert!(cfg.telegram_topic_id.is_none());
3383 assert!(cfg.discord_channel_id.is_none());
3384 }
3385}