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