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