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