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