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