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