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