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 review;
25pub mod review_poll;
26pub mod router;
27pub mod runner;
28pub mod subscribers;
29pub mod suspend;
30pub mod sync;
31pub mod tasks;
32pub mod tick;
33
34pub fn orch_footer() -> &'static str {
39 "\n\n---\n*Posted by [Orch](https://github.com/gabrielkoerich/orch)*"
40}
41
42pub fn attribution_footer(verb: &str, agent: &str, model: Option<&str>) -> String {
46 match model {
47 Some(m) => format!(
48 "\n\n---\n*{} by {}[bot] via [Orch](https://github.com/gabrielkoerich/orch) using `{}`*",
49 verb, agent, m
50 ),
51 None => format!(
52 "\n\n---\n*{} by {}[bot] via [Orch](https://github.com/gabrielkoerich/orch)*",
53 verb, agent
54 ),
55 }
56}
57
58use crate::backends::{ExternalBackend, ExternalId};
59use crate::channels::capture::CaptureService;
60use crate::channels::discord_ws::DiscordGateway;
61use crate::channels::github::start_webhook_server;
62use crate::channels::notification::NotificationLevel;
63use crate::channels::routing::{ChannelRouter, GlobalChannelConfig, ProjectChannelConfig};
64use crate::channels::slack::SlackChannel;
65use crate::channels::telegram::TelegramChannel;
66use crate::channels::tmux::TmuxChannel;
67use crate::channels::transport::Transport;
68use crate::channels::{Channel, ChannelRegistry, IncomingMessage, OutgoingMessage};
69use crate::cmd::CommandErrorContext;
70use crate::config;
71use crate::engine::cleanup::remove_worktree_and_branch;
72use crate::engine::router::{Router, RouterConfig};
73use crate::engine::runner::worktree::{
74 abort_worktree_rebase, list_project_worktrees, rebase_worktree_on_origin_main,
75 task_id_from_worktree_name, validate_worktree_gitdir,
76};
77use crate::engine::tasks::TaskManager;
78use crate::github::http::{rate_limit_metrics, GhHttp};
79use crate::repo_context::REPO_CONTEXT;
80use crate::store::{review_session_expected, set_review_session_expected};
81use crate::store::{TaskStatus, TaskStore};
82use crate::tmux::TmuxManager;
83use runner::WeightSignal;
84use std::sync::Arc;
86use tokio::process::Command;
87use tokio::sync::{mpsc, Notify, RwLock, Semaphore};
88
89use crate::backends::Status;
90
91type EngineRef = (
93 String,
94 Arc<dyn ExternalBackend>,
95 Arc<TaskManager>,
96 Option<Arc<TaskStore>>,
97);
98
99#[derive(Clone)]
104pub struct ProjectEngine {
105 pub repo: String,
106 pub project_dir: std::path::PathBuf,
107 pub backend: Arc<dyn ExternalBackend>,
108 pub task_manager: Arc<TaskManager>,
109 pub runner: Arc<runner::TaskRunner>,
110 pub store: Arc<TaskStore>,
111}
112
113pub fn configured_agents() -> Vec<String> {
125 if let Ok(agents_str) = crate::config::get("agents") {
126 if !agents_str.is_empty() && agents_str != "[]" {
127 if let Ok(agents_arr) = serde_norway::from_str::<Vec<String>>(&agents_str) {
130 if !agents_arr.is_empty() {
131 return agents_arr;
132 }
133 }
134 if let Ok(agents_arr) = serde_json::from_str::<Vec<String>>(&agents_str) {
136 if !agents_arr.is_empty() {
137 return agents_arr;
138 }
139 }
140 }
141 }
142 router::config::DEFAULT_AGENTS
143 .iter()
144 .map(|s| s.to_string())
145 .collect()
146}
147
148#[derive(Clone)]
150pub struct EngineConfig {
151 pub tick_interval: std::time::Duration,
153 pub sync_interval: std::time::Duration,
155 pub webhook_health_check_interval: Option<std::time::Duration>,
157 pub max_parallel: usize,
159 pub stuck_timeout: u64,
161 pub no_session_stuck_timeout: u64,
164 pub in_review_no_session_stuck_timeout: u64,
171 pub auto_create_followup_on_changes: bool,
173 pub graceful_shutdown_timeout: std::time::Duration,
175 pub silence_grace_period: u64,
178 pub silence_cooldown: u64,
180 pub upgrade_check_interval: u64,
183 pub startup_failure_escalation_secs: u64,
188}
189
190impl Default for EngineConfig {
191 fn default() -> Self {
192 Self {
193 tick_interval: std::time::Duration::from_secs(10),
194 sync_interval: std::time::Duration::from_secs(45),
195 webhook_health_check_interval: Some(std::time::Duration::from_secs(60)),
196 max_parallel: 4,
197 stuck_timeout: 1800,
198 no_session_stuck_timeout: 600,
199 in_review_no_session_stuck_timeout: 1800,
200 auto_create_followup_on_changes: true,
201 graceful_shutdown_timeout: std::time::Duration::from_secs(600),
202 silence_grace_period: 300,
203 silence_cooldown: 3600,
204 upgrade_check_interval: 3600,
205 startup_failure_escalation_secs: 3600,
206 }
207 }
208}
209
210impl EngineConfig {
211 pub fn from_config() -> Self {
213 let mut config = Self::default();
214
215 if let Ok(val) = crate::config::get("engine.tick_interval") {
216 if let Ok(secs) = val.parse::<u64>() {
217 config.tick_interval = std::time::Duration::from_secs(secs);
218 } else {
219 tracing::warn!(key = "engine.tick_interval", value = %val, default_secs = config.tick_interval.as_secs(), "invalid value for engine.tick_interval, using default");
220 }
221 }
222
223 if let Ok(val) = crate::config::get("engine.sync_interval") {
224 if let Ok(secs) = val.parse::<u64>() {
225 config.sync_interval = std::time::Duration::from_secs(secs);
226 } else {
227 tracing::warn!(key = "engine.sync_interval", value = %val, default_secs = config.sync_interval.as_secs(), "invalid value for engine.sync_interval, using default");
228 }
229 }
230
231 if let Ok(val) = crate::config::get("engine.max_parallel") {
232 if let Ok(n) = val.parse::<usize>() {
233 config.max_parallel = n;
234 } else {
235 tracing::warn!(key = "engine.max_parallel", value = %val, default = config.max_parallel, "invalid value for engine.max_parallel, using default");
236 }
237 }
238
239 if let Ok(val) = crate::config::get("engine.stuck_timeout") {
240 if let Ok(secs) = val.parse::<u64>() {
241 config.stuck_timeout = secs;
242 } else {
243 tracing::warn!(key = "engine.stuck_timeout", value = %val, default_secs = config.stuck_timeout, "invalid value for engine.stuck_timeout, using default");
244 }
245 }
246
247 if let Ok(val) = crate::config::get("engine.no_session_stuck_timeout") {
248 if let Ok(secs) = val.parse::<u64>() {
249 config.no_session_stuck_timeout = secs;
250 } else {
251 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");
252 }
253 }
254
255 if let Ok(val) = crate::config::get("engine.in_review_no_session_stuck_timeout") {
256 if let Ok(secs) = val.parse::<u64>() {
257 config.in_review_no_session_stuck_timeout = secs;
258 } else {
259 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");
260 }
261 }
262
263 if let Ok(val) = crate::config::get("engine.webhook_health_check_interval") {
264 if let Ok(secs) = val.parse::<u64>() {
265 if secs > 0 {
266 config.webhook_health_check_interval =
267 Some(std::time::Duration::from_secs(secs));
268 } else {
269 config.webhook_health_check_interval = None;
270 }
271 } else {
272 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");
273 }
274 }
275
276 if let Ok(val) = crate::config::get("workflow.auto_create_followup_on_changes") {
277 config.auto_create_followup_on_changes = !val.eq_ignore_ascii_case("false");
278 }
279
280 if let Ok(val) = crate::config::get("engine.graceful_shutdown_timeout") {
281 if let Ok(secs) = val.parse::<u64>() {
282 config.graceful_shutdown_timeout = std::time::Duration::from_secs(secs);
283 } else {
284 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");
285 }
286 }
287
288 if let Ok(val) = crate::config::get("engine.silence_grace_period") {
289 if let Ok(secs) = val.parse::<u64>() {
290 config.silence_grace_period = secs;
291 } else {
292 tracing::warn!(key = "engine.silence_grace_period", value = %val, default = config.silence_grace_period, "invalid value for engine.silence_grace_period, using default");
293 }
294 }
295
296 if let Ok(val) = crate::config::get("engine.silence_cooldown") {
297 if let Ok(secs) = val.parse::<u64>() {
298 config.silence_cooldown = secs;
299 } else {
300 tracing::warn!(key = "engine.silence_cooldown", value = %val, default = config.silence_cooldown, "invalid value for engine.silence_cooldown, using default");
301 }
302 }
303
304 if let Ok(val) = crate::config::get("engine.upgrade_check_interval") {
305 if let Ok(secs) = val.parse::<u64>() {
306 config.upgrade_check_interval = secs;
307 } else {
308 tracing::warn!(key = "engine.upgrade_check_interval", value = %val, default = config.upgrade_check_interval, "invalid value for engine.upgrade_check_interval, using default");
309 }
310 }
311
312 if let Ok(val) = crate::config::get("engine.startup_failure_escalation_secs") {
313 if let Ok(secs) = val.parse::<u64>() {
314 config.startup_failure_escalation_secs = secs;
315 } else {
316 tracing::warn!(
317 key = "engine.startup_failure_escalation_secs",
318 value = %val,
319 default_secs = config.startup_failure_escalation_secs,
320 "invalid value for engine.startup_failure_escalation_secs, using default"
321 );
322 }
323 }
324
325 config
326 }
327}
328
329pub const RATE_LIMITED_UNTIL_KEY: &str = "github:rate_limited_until";
332
333pub fn format_rate_limited_until(reset_at: i64) -> String {
335 chrono::DateTime::from_timestamp(reset_at, 0)
336 .map(|t| {
337 t.with_timezone(&chrono::Local)
338 .format("%H:%M:%S %Z")
339 .to_string()
340 })
341 .unwrap_or_else(|| reset_at.to_string())
342}
343
344async fn report_rate_limited(
347 reset_at: i64,
348 store: Option<&Arc<TaskStore>>,
349 transport: Option<&Arc<crate::channels::transport::Transport>>,
350) {
351 let m = rate_limit_metrics();
352 let until = format_rate_limited_until(reset_at);
353 tracing::warn!(
354 reset_at,
355 rate_limit_hits = m.hits,
356 skipped_calls = m.skipped_calls,
357 five_xx_hits = m.five_xx_hits,
358 rest_remaining = ?m.rest_remaining,
359 graphql_remaining = ?m.graphql_remaining,
360 "GitHub API rate limited until {until} — skipping GitHub calls, local work continues"
361 );
362
363 if let Some(store) = store {
364 if let Err(e) = store
365 .kv_set(RATE_LIMITED_UNTIL_KEY, &reset_at.to_string())
366 .await
367 {
368 tracing::warn!(err = %e, "failed to persist GitHub rate-limit flag");
369 }
370 }
371
372 if let Some(transport) = transport {
373 transport.push_notification(crate::channels::notification::TaskNotification {
374 task_id: "github".to_string(),
375 title: "GitHub rate limit".to_string(),
376 status: "blocked".to_string(),
377 agent: "orch".to_string(),
378 duration_seconds: 0.0,
379 summary: format!(
380 "rate limited until {until}. GitHub calls are skipped until then; running agents and local work continue."
381 ),
382 repo: None,
383 notify_target: None,
384 pr_number: None,
385 });
386 }
387}
388
389fn resize_dispatch_pool(semaphore: &Semaphore, in_force: usize, target: usize) -> usize {
395 if target > in_force {
396 semaphore.add_permits(target - in_force);
397 return target;
398 }
399
400 if target < in_force {
401 return in_force - semaphore.forget_permits(in_force - target);
402 }
403
404 in_force
405}
406
407const RESTART_ONLY_KEYS: [&str; 2] = ["channels", "webhook"];
410
411fn restart_only_snapshot() -> Vec<Option<String>> {
413 RESTART_ONLY_KEYS
414 .iter()
415 .map(|key| crate::config::get(key).ok())
416 .collect()
417}
418
419fn log_restart_only_changes(old: &[Option<String>], new: &[Option<String>]) -> Vec<&'static str> {
422 let changed: Vec<&'static str> = RESTART_ONLY_KEYS
423 .iter()
424 .zip(old)
425 .zip(new)
426 .filter(|((_, old), new)| old != new)
427 .map(|((key, _), _)| *key)
428 .collect();
429
430 for key in &changed {
431 tracing::info!(key = key, "config changed, takes effect after restart");
432 }
433
434 changed
435}
436
437fn log_engine_config_changes(old: &EngineConfig, new: &EngineConfig) {
440 macro_rules! log_changed {
441 ($($key:literal => $field:ident),+ $(,)?) => {
442 $(if old.$field != new.$field {
443 tracing::info!(key = $key, old = ?old.$field, new = ?new.$field, "config changed");
444 })+
445 };
446 }
447
448 log_changed!(
449 "engine.tick_interval" => tick_interval,
450 "engine.sync_interval" => sync_interval,
451 "engine.webhook_health_check_interval" => webhook_health_check_interval,
452 "engine.max_parallel" => max_parallel,
453 "engine.stuck_timeout" => stuck_timeout,
454 "engine.no_session_stuck_timeout" => no_session_stuck_timeout,
455 "engine.in_review_no_session_stuck_timeout" => in_review_no_session_stuck_timeout,
456 "engine.graceful_shutdown_timeout" => graceful_shutdown_timeout,
457 "engine.silence_grace_period" => silence_grace_period,
458 "engine.silence_cooldown" => silence_cooldown,
459 "engine.upgrade_check_interval" => upgrade_check_interval,
460 "engine.startup_failure_escalation_secs" => startup_failure_escalation_secs,
461 "workflow.auto_create_followup_on_changes" => auto_create_followup_on_changes,
462 );
463}
464
465fn should_run_upgrade_check(
466 last_upgrade_check: Option<std::time::Instant>,
467 upgrade_check_interval: u64,
468) -> bool {
469 if upgrade_check_interval == 0 {
470 return false;
471 }
472
473 last_upgrade_check
474 .map(|last| last.elapsed() >= std::time::Duration::from_secs(upgrade_check_interval))
475 .unwrap_or(true)
476}
477
478async fn connect_project_engine(
485 repo: String,
486 project_dir: std::path::PathBuf,
487 store: Arc<TaskStore>,
488 event_tx: Option<tokio::sync::broadcast::Sender<events::TaskEvent>>,
489) -> anyhow::Result<ProjectEngine> {
490 let backend: Arc<dyn ExternalBackend> =
491 Arc::new(crate::backends::github::GitHubBackend::new(repo.clone())?);
492
493 match backend.health_check().await {
497 Ok(()) => tracing::info!(repo = %repo, backend = backend.name(), "backend connected"),
498 Err(e) => tracing::warn!(
499 repo = %repo,
500 error = %e,
501 "GitHub health check failed for {repo}, starting engine anyway (rate limited, offline or bad credentials?)"
502 ),
503 }
504
505 let task_manager = Arc::new(match event_tx {
506 Some(tx) => TaskManager::with_events(backend.clone(), store.clone(), repo.clone(), tx),
507 None => TaskManager::with_store(backend.clone(), store.clone(), repo.clone()),
508 });
509 let runner = Arc::new(runner::TaskRunner::new(repo.clone()).with_store(store.clone()));
510
511 Ok(ProjectEngine {
512 repo,
513 project_dir,
514 backend,
515 task_manager,
516 runner,
517 store,
518 })
519}
520
521fn diff_configured_projects(
528 configured: &[(String, std::path::PathBuf)],
529 active_repos: &std::collections::HashSet<String>,
530) -> (Vec<(String, std::path::PathBuf)>, Vec<String>) {
531 let configured_repos: std::collections::HashSet<&str> =
532 configured.iter().map(|(r, _)| r.as_str()).collect();
533
534 let added = configured
535 .iter()
536 .filter(|(repo, _)| !active_repos.contains(repo))
537 .cloned()
538 .collect();
539 let removed = active_repos
540 .iter()
541 .filter(|repo| !configured_repos.contains(repo.as_str()))
542 .cloned()
543 .collect();
544
545 (added, removed)
546}
547
548#[allow(clippy::too_many_arguments)]
554async fn sync_project_engines(
555 project_engines: &mut Vec<ProjectEngine>,
556 active_repos: &mut std::collections::HashSet<String>,
557 event_bus: &events::EventBus,
558 tmux: &Arc<TmuxManager>,
559 capture: &Arc<CaptureService>,
560 semaphore: &Arc<Semaphore>,
561 weight_tx: &mpsc::Sender<WeightSignal>,
562 router: &Arc<RwLock<Router>>,
563 dispatching: &Arc<dashmap::DashMap<String, String>>,
564) {
565 let configured = match config::get_projects_with_paths() {
566 Ok(p) if !p.is_empty() => p,
567 Ok(_) => return,
568 Err(e) => {
569 tracing::warn!(err = %e, "could not re-read the project list, keeping the current one");
570 return;
571 }
572 };
573 let Some(store) = project_engines.first().map(|e| e.store.clone()) else {
574 return;
575 };
576
577 let (added, removed) = diff_configured_projects(&configured, active_repos);
578 for repo in removed {
579 tracing::info!(
580 repo = %repo,
581 "project removed from config, takes effect after restart"
582 );
583 }
584
585 for (repo, project_dir) in added {
586 config::watch(&project_dir.join(".orch.yml"));
587 let engine = match connect_project_engine(
588 repo.clone(),
589 project_dir,
590 store.clone(),
591 Some(event_bus.sender()),
592 )
593 .await
594 {
595 Ok(engine) => engine,
596 Err(e) => {
597 tracing::warn!(
600 repo = %repo,
601 err = %e,
602 "project added to config but its backend could not be built, retrying on the next config change"
603 );
604 continue;
605 }
606 };
607
608 subscribers::dispatch::spawn(
609 event_bus.subscribe(),
610 engine.backend.clone(),
611 tmux.clone(),
612 engine.runner.clone(),
613 capture.clone(),
614 semaphore.clone(),
615 engine.task_manager.clone(),
616 weight_tx.clone(),
617 router.clone(),
618 dispatching.clone(),
619 engine.store.clone(),
620 engine.repo.clone(),
621 );
622 subscribers::review::spawn(
623 event_bus.subscribe(),
624 engine.backend.clone(),
625 tmux.clone(),
626 semaphore.clone(),
627 engine.task_manager.clone(),
628 router.clone(),
629 dispatching.clone(),
630 engine.store.clone(),
631 engine.repo.clone(),
632 );
633 subscribers::unblock::spawn(
634 event_bus.subscribe(),
635 engine.backend.clone(),
636 engine.task_manager.clone(),
637 engine.store.clone(),
638 engine.repo.clone(),
639 );
640
641 active_repos.insert(repo.clone());
642 project_engines.push(engine);
643 tracing::info!(repo = %repo, "project added from config, now syncing");
644 }
645}
646
647async fn init_project_engines() -> anyhow::Result<Vec<ProjectEngine>> {
651 let projects = config::get_projects_with_paths()?;
652 let repos: Vec<&str> = projects.iter().map(|(r, _)| r.as_str()).collect();
653 tracing::info!(repos = ?repos, "loading projects from config");
654
655 if projects.is_empty() {
656 let config_path = crate::home::config_path()
657 .map(|p| p.display().to_string())
658 .unwrap_or_else(|_| "~/.orch/config.yml".to_string());
659 anyhow::bail!(
660 "no projects configured — add repos under `projects:` in {}",
661 config_path
662 );
663 }
664
665 let mut engines = Vec::new();
666
667 let store = Arc::new(TaskStore::open(&crate::store::default_db_path().await?).await?);
670
671 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
674
675 for (repo, project_dir) in projects {
676 tracing::info!(repo = %repo, "initializing project engine");
677 config::watch(&project_dir.join(".orch.yml"));
678
679 engines.push(connect_project_engine(repo, project_dir, store.clone(), None).await?);
680 }
681
682 Ok(engines)
683}
684
685async fn reconcile_startup_worktrees(project_engines: &[ProjectEngine]) -> anyhow::Result<()> {
686 for engine in project_engines {
687 let repo_root =
688 crate::engine::runner::worktree::resolve_main_repo(&engine.project_dir).await;
689 let repo_root_path = std::path::PathBuf::from(&repo_root);
690 let default_branch =
691 crate::engine::runner::worktree::detect_default_branch(&repo_root_path).await;
692 let worktrees = list_project_worktrees(&repo_root_path).await?;
693
694 if let Err(e) = Command::new("git")
697 .args(["-C", &repo_root.to_string_lossy(), "fetch", "origin"])
698 .output_with_context()
699 .await
700 {
701 tracing::warn!(repo = %engine.repo, err = %e, "startup git fetch origin failed, rebases may use stale refs");
702 }
703
704 let mut orphans_removed: u32 = 0;
705 let mut invalid_removed: u32 = 0;
706 let mut cleaned: u32 = 0;
707 let mut valid_kept: u32 = 0;
708
709 for worktree_dir in worktrees {
710 let Some(name) = worktree_dir.file_name().and_then(|n| n.to_str()) else {
711 continue;
712 };
713
714 if !validate_worktree_gitdir(&worktree_dir).await {
717 tracing::warn!(repo = %engine.repo, worktree = %worktree_dir.display(), "worktree has invalid or missing gitdir, removing");
718 remove_worktree_and_branch(name, &worktree_dir, Some(name), &repo_root_path, false)
719 .await;
720 invalid_removed += 1;
721 continue;
722 }
723
724 let Some(task_id) = task_id_from_worktree_name(name) else {
725 tracing::info!(repo = %engine.repo, worktree = %worktree_dir.display(), "orphan worktree without task id, removing");
726 remove_worktree_and_branch(name, &worktree_dir, Some(name), &repo_root_path, false)
727 .await;
728 orphans_removed += 1;
729 continue;
730 };
731
732 let Some(store_id) = engine.store.resolve_task_id(&engine.repo, &task_id).await? else {
733 tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), "worktree has no matching task, removing");
734 remove_worktree_and_branch(
735 &task_id,
736 &worktree_dir,
737 Some(name),
738 &repo_root_path,
739 false,
740 )
741 .await;
742 orphans_removed += 1;
743 continue;
744 };
745
746 let task = engine.store.get(store_id).await?;
747 let branch_name = if task.branch.is_empty() {
748 name
749 } else {
750 task.branch.as_str()
751 };
752
753 match task.status {
754 TaskStatus::New
755 | TaskStatus::Routed
756 | TaskStatus::InProgress
757 | TaskStatus::NeedsReview
758 | TaskStatus::InReview => {
759 tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), "rebasing startup worktree");
760 if let Err(e) =
761 rebase_worktree_on_origin_main(&worktree_dir, &default_branch).await
762 {
763 tracing::warn!(repo = %engine.repo, task_id = %task_id, err = %e, "startup rebase failed, resetting task");
768 abort_worktree_rebase(&worktree_dir).await;
769 let keep_remote = task.pr_number.is_some();
770 remove_worktree_and_branch(
771 &task_id,
772 &worktree_dir,
773 Some(branch_name),
774 &repo_root_path,
775 keep_remote,
776 )
777 .await;
778 match engine.store.resolve_task_id(&engine.repo, &task_id).await {
779 Ok(Some(reset_id)) => {
780 if let Err(e) = engine.store.reset_to_new(reset_id).await {
781 tracing::warn!(task_id, err = %e, "failed to reset task to new after rebase failure");
782 }
783 }
784 Ok(None) => {
785 tracing::warn!(
786 task_id,
787 "task not found in store during reconciliation"
788 );
789 }
790 Err(e) => {
791 tracing::warn!(task_id, err = %e, "failed to resolve task ID during reconciliation");
792 }
793 }
794 if let Err(e) = engine
795 .task_manager
796 .update_task_status(
797 &ExternalId(task_id.clone()),
798 crate::backends::Status::New,
799 )
800 .await
801 {
802 tracing::warn!(task_id, err = %e, "failed to update backend status during reconciliation");
803 }
804 cleaned += 1;
805 } else {
806 valid_kept += 1;
807 }
808 }
809 TaskStatus::Blocked | TaskStatus::Waiting => {
812 valid_kept += 1;
813 }
814 TaskStatus::Done => {
815 tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), status = ?task.status, "terminal task worktree, removing");
816 remove_worktree_and_branch(
817 &task_id,
818 &worktree_dir,
819 Some(branch_name),
820 &repo_root_path,
821 false,
822 )
823 .await;
824 cleaned += 1;
825 }
826 }
827 }
828
829 tracing::info!(
830 repo = %engine.repo,
831 orphans_removed,
832 invalid_removed,
833 cleaned,
834 valid_kept,
835 "worktree reconciliation: {orphans_removed} orphans removed, {invalid_removed} invalid removed, {cleaned} cleaned, {valid_kept} valid kept"
836 );
837
838 let prune = Command::new("git")
841 .args(["-C", &repo_root.to_string_lossy(), "worktree", "prune"])
842 .output_with_context()
843 .await;
844 match prune {
845 Ok(output) if output.status.success() => {
846 tracing::debug!(repo = %engine.repo, "startup worktree prune completed");
847 }
848 Ok(output) => {
849 let stderr = String::from_utf8_lossy(&output.stderr);
850 tracing::warn!(repo = %engine.repo, err = %stderr, "startup worktree prune failed");
851 }
852 Err(e) => {
853 tracing::warn!(repo = %engine.repo, err = %e, "startup worktree prune failed");
854 }
855 }
856 }
857
858 Ok(())
859}
860
861async fn reconcile_startup_estimates(project_engines: &[ProjectEngine]) {
869 use crate::backends::ExternalId;
870
871 for engine in project_engines {
872 let tasks = match engine.store.list_external_tasks_with_estimates().await {
873 Ok(t) => t,
874 Err(e) => {
875 tracing::warn!(repo = %engine.repo, err = %e, "startup estimate reconciliation: store query failed");
876 continue;
877 }
878 };
879
880 if tasks.is_empty() {
881 continue;
882 }
883
884 let mut synced: u32 = 0;
885 let mut failed: u32 = 0;
886 for (external_id, estimate) in &tasks {
887 match engine
888 .backend
889 .sync_estimate_to_project(&ExternalId(external_id.clone()), *estimate)
890 .await
891 {
892 Ok(()) => synced += 1,
893 Err(e) => {
894 tracing::debug!(
895 repo = %engine.repo,
896 task_id = %external_id,
897 estimate,
898 err = %e,
899 "startup estimate reconciliation: sync failed"
900 );
901 failed += 1;
902 }
903 }
904 }
905
906 if synced > 0 || failed > 0 {
907 tracing::info!(
908 repo = %engine.repo,
909 synced,
910 failed,
911 "startup estimate reconciliation complete"
912 );
913 }
914 }
915}
916
917fn read_project_channel_config(project_dir: &std::path::Path) -> ProjectChannelConfig {
919 let config_path = project_dir.join(".orch.yml");
920 if !config_path.exists() {
921 return ProjectChannelConfig::default();
922 }
923 let content = match std::fs::read_to_string(&config_path) {
924 Ok(c) => c,
925 Err(e) => {
926 tracing::warn!(
927 path = %config_path.display(),
928 error = %e,
929 "failed to read .orch.yml; channel config will use defaults"
930 );
931 return ProjectChannelConfig::default();
932 }
933 };
934 let val: serde_norway::Value = match serde_norway::from_str(&content) {
935 Ok(v) => v,
936 Err(e) => {
937 tracing::warn!(
938 path = %config_path.display(),
939 error = %e,
940 "failed to parse .orch.yml; channel config will use defaults"
941 );
942 return ProjectChannelConfig::default();
943 }
944 };
945 let channels = match val.get("channels") {
946 Some(c) => c,
947 None => return ProjectChannelConfig::default(),
948 };
949 let get_str = |section: &str, key: &str| -> Option<String> {
950 channels
951 .get(section)
952 .and_then(|s| s.get(key))
953 .and_then(|v| v.as_str())
954 .map(String::from)
955 };
956 ProjectChannelConfig {
957 telegram_topic_id: get_str("telegram", "topic_id"),
958 telegram_bot_token: get_str("telegram", "bot_token"),
959 telegram_chat_id: get_str("telegram", "chat_id"),
960 discord_channel_id: get_str("discord", "channel_id"),
961 discord_bot_token: get_str("discord", "bot_token"),
962 discord_guild_id: get_str("discord", "guild_id"),
963 }
964}
965
966async fn fetch_latest_release_version() -> Option<String> {
971 let output = Command::new("gh")
972 .args([
973 "api",
974 "repos/gabrielkoerich/orch/releases/latest",
975 "--jq",
976 ".tag_name",
977 ])
978 .output()
979 .await
980 .ok()?;
981 if !output.status.success() {
982 return None;
983 }
984 let tag = String::from_utf8(output.stdout).ok()?;
985 let version = tag.trim().trim_start_matches('v').to_string();
986 if version.is_empty() {
987 None
988 } else {
989 Some(version)
990 }
991}
992
993async fn check_and_notify_upgrade(
1002 store: &Arc<TaskStore>,
1003 channels: &Arc<ChannelRegistry>,
1004 current_version: &str,
1005 check_interval: std::time::Duration,
1006) {
1007 let last_check_key = "upgrade:last_check_at";
1008 let last_notified_key = "upgrade:last_notified_version";
1009
1010 let now = chrono::Utc::now().timestamp();
1012 if let Ok(Some(last_check_str)) = store.kv_get(last_check_key).await {
1013 if let Ok(last_check) = last_check_str.parse::<i64>() {
1014 if now - last_check < check_interval.as_secs() as i64 {
1015 return;
1016 }
1017 }
1018 }
1019
1020 let _ = store.kv_set(last_check_key, &now.to_string()).await;
1022
1023 let latest = match fetch_latest_release_version().await {
1024 Some(v) => v,
1025 None => {
1026 tracing::debug!("upgrade check: failed to fetch latest release version");
1027 return;
1028 }
1029 };
1030
1031 if latest == current_version {
1032 tracing::debug!(current = %current_version, "orch is up to date");
1033 return;
1034 }
1035
1036 tracing::warn!(
1037 current_version = %current_version,
1038 latest_version = %latest,
1039 "orch upgrade available"
1040 );
1041
1042 if let Ok(Some(last_notified)) = store.kv_get(last_notified_key).await {
1047 if last_notified == latest {
1048 tracing::debug!(latest = %latest, "upgrade already notified");
1049 return;
1050 }
1051 }
1052
1053 let msg_telegram = format!(
1054 "⚠️ <b>Orch Upgrade Available</b>\n\n\
1055 Service: <code>{current_version}</code>\n\
1056 Latest: <code>{latest}</code>\n\n\
1057 Run: <code>brew update && brew upgrade orch && brew services restart orch</code>"
1058 );
1059
1060 let msg_discord = format!(
1061 "⚠️ **Orch Upgrade Available**\n\n\
1062 Service: `{current_version}`\n\
1063 Latest: `{latest}`\n\n\
1064 Run: `brew update && brew upgrade orch && brew services restart orch`"
1065 );
1066
1067 let msg_slack = format!(
1068 "⚠️ *Orch Upgrade Available*\n\n\
1069 Service: `{current_version}`\n\
1070 Latest: `{latest}`\n\n\
1071 Run: `brew update && brew upgrade orch && brew services restart orch`"
1072 );
1073
1074 for channel in channels.iter() {
1075 let (body, metadata, topic_id) = match channel.name() {
1076 "telegram" => (
1077 msg_telegram.clone(),
1078 serde_json::json!({"preformatted_html": true}),
1079 None,
1080 ),
1081 "discord" => (msg_discord.clone(), serde_json::json!({}), None),
1082 "slack" => (msg_slack.clone(), serde_json::json!({}), None),
1083 _ => continue,
1084 };
1085
1086 let msg = OutgoingMessage {
1087 thread_id: "upgrade".to_string(),
1088 body,
1089 reply_to: None,
1090 metadata,
1091 topic_id,
1092 };
1093
1094 if let Err(e) = channel.send(&msg).await {
1095 tracing::warn!(
1096 channel = channel.name(),
1097 ?e,
1098 "failed to send upgrade notification"
1099 );
1100 }
1101 }
1102
1103 let _ = store.kv_set(last_notified_key, &latest).await;
1105}
1106
1107async fn send_startup_escalation_notification(elapsed_secs: u64) {
1115 let duration = crate::channels::notification::format_duration(elapsed_secs as f64);
1116
1117 let msg_telegram = format!(
1118 "🚨 <b>Orch startup blocked — GitHub unreachable</b>\n\n\
1119 The engine has been retrying backend connections for {}\n\
1120 without success. All task processing, routing, and scheduled\n\
1121 jobs are paused. Check service logs with `orch log -f`.",
1122 duration
1123 );
1124
1125 let msg_discord = format!(
1126 "🚨 **Orch startup blocked — GitHub unreachable**\n\n\
1127 The engine has been retrying backend connections for {}\n\
1128 without success. All task processing, routing, and scheduled\n\
1129 jobs are paused. Check service logs with `orch log -f`.",
1130 duration
1131 );
1132
1133 let msg_slack = format!(
1134 "🚨 *Orch startup blocked — GitHub unreachable*\n\n\
1135 The engine has been retrying backend connections for {}\n\
1136 without success. All task processing, routing, and scheduled\n\
1137 jobs are paused. Check service logs with `orch log -f`.",
1138 duration
1139 );
1140
1141 let tg_token = crate::config::get("channels.telegram.bot_token");
1143 let tg_chat_id = crate::config::get("channels.telegram.chat_id");
1144 if let (Ok(token), Ok(chat_id)) = (&tg_token, &tg_chat_id) {
1145 if !token.is_empty() && !chat_id.is_empty() {
1146 if let Ok(channel) = TelegramChannel::new(token.clone(), Some(chat_id.clone())) {
1147 let msg = OutgoingMessage {
1148 thread_id: "startup_escalation".to_string(),
1149 body: msg_telegram.clone(),
1150 reply_to: None,
1151 metadata: serde_json::json!({ "preformatted_html": true }),
1152 topic_id: crate::config::get("channels.telegram.general_topic_id").ok(),
1153 };
1154 if let Err(e) = channel.send(&msg).await {
1155 tracing::warn!(?e, "failed to send startup escalation via telegram");
1156 } else {
1157 tracing::info!("startup escalation notification sent to telegram");
1158 }
1159 }
1160 }
1161 }
1162
1163 if let Ok(token) = crate::config::get("channels.discord.bot_token") {
1165 let channel_id = crate::config::get("channels.discord.channel_id")
1166 .or_else(|_| crate::config::get("channels.discord.general_channel_id"));
1167 if !token.is_empty() {
1168 if let Some(cid) = channel_id.ok().filter(|s| !s.is_empty()) {
1169 let shard_id = crate::config::get("channels.discord.shard_id")
1170 .ok()
1171 .and_then(|v| v.parse::<u64>().ok())
1172 .unwrap_or(0);
1173 let shard_count = crate::config::get("channels.discord.shard_count")
1174 .ok()
1175 .and_then(|v| v.parse::<u64>().ok())
1176 .unwrap_or(1);
1177 if let Ok(gateway) = DiscordGateway::new(token, Some(cid), shard_id, shard_count) {
1178 let msg = OutgoingMessage {
1179 thread_id: "startup_escalation".to_string(),
1180 body: msg_discord.clone(),
1181 reply_to: None,
1182 metadata: serde_json::Value::Null,
1183 topic_id: None,
1184 };
1185 if let Err(e) = gateway.send(&msg).await {
1186 tracing::warn!(?e, "failed to send startup escalation via discord");
1187 } else {
1188 tracing::info!("startup escalation notification sent to discord");
1189 }
1190 }
1191 }
1192 }
1193 }
1194
1195 if let Ok(token) = crate::config::get("channels.slack.bot_token") {
1197 let channel_id = crate::config::get("channels.slack.channel_id");
1198 if !token.is_empty() {
1199 if let Ok(cid) = channel_id {
1200 if let Ok(channel) = SlackChannel::new(token, Some(cid)) {
1201 let msg = OutgoingMessage {
1202 thread_id: "startup_escalation".to_string(),
1203 body: msg_slack.clone(),
1204 reply_to: None,
1205 metadata: serde_json::Value::Null,
1206 topic_id: None,
1207 };
1208 if let Err(e) = channel.send(&msg).await {
1209 tracing::warn!(?e, "failed to send startup escalation via slack");
1210 } else {
1211 tracing::info!("startup escalation notification sent to slack");
1212 }
1213 }
1214 }
1215 }
1216 }
1217}
1218
1219pub async fn serve() -> anyhow::Result<()> {
1223 let killed = crate::home::kill_other_orch_serve_processes(std::time::Duration::from_secs(30));
1224 if killed > 0 {
1225 tracing::info!(count = killed, "terminated stale orch serve processes");
1226 }
1227
1228 tracing::info!("orch engine starting");
1229
1230 if let Err(e) = crate::home::init_dirs().await {
1234 tracing::warn!("failed to initialize orch directories: {e}");
1235 }
1236
1237 if let Err(e) = crate::config::warm_cache().await {
1240 tracing::warn!("failed to warm config cache: {e}");
1241 }
1242
1243 let mut config = EngineConfig::from_config();
1244
1245 tracing::info!("internal database ready");
1246
1247 let startup_escalation_secs = config.startup_failure_escalation_secs;
1256 let mut project_engines = {
1257 let mut delay_secs = 5u64;
1258 let mut attempt = 0u32;
1259 let mut escalated = false;
1260 let start = std::time::Instant::now();
1261 loop {
1262 attempt += 1;
1263 match init_project_engines().await {
1264 Ok(engines) => break engines,
1265 Err(e) => {
1266 let elapsed = start.elapsed().as_secs();
1267
1268 if startup_escalation_secs > 0
1274 && !escalated
1275 && elapsed >= startup_escalation_secs
1276 {
1277 escalated = true;
1278 tracing::error!(
1279 delay_secs,
1280 attempt,
1281 elapsed_secs = elapsed,
1282 threshold_secs = startup_escalation_secs,
1283 "project backends unavailable for {} — escalating startup failure: {e}",
1284 crate::channels::notification::format_duration(elapsed as f64)
1285 );
1286 send_startup_escalation_notification(elapsed).await;
1290 } else {
1291 tracing::warn!(
1292 delay_secs,
1293 attempt,
1294 "project backends unavailable, retrying: {e}"
1295 );
1296 }
1297
1298 tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
1299 delay_secs = (delay_secs * 2).min(120);
1300 }
1301 }
1302 }
1303 };
1304
1305 tracing::info!(
1306 project_count = project_engines.len(),
1307 "initialized project engines"
1308 );
1309
1310 let mut active_repos: std::collections::HashSet<String> =
1314 project_engines.iter().map(|e| e.repo.clone()).collect();
1315
1316 let event_bus = events::EventBus::new(256);
1318 if let Err(e) = event_bus.start_ws_server().await {
1319 tracing::warn!(
1320 ?e,
1321 "failed to start event websocket server, continuing without it"
1322 );
1323 }
1324
1325 {
1329 let current = env!("ORCH_VERSION").to_string();
1330 tokio::spawn(async move {
1331 if let Some(latest) = fetch_latest_release_version().await {
1332 if latest != current {
1333 tracing::warn!(
1334 current_version = %current,
1335 latest_version = %latest,
1336 "orch service is behind the latest release — run: brew update && brew upgrade orch && brew services restart orch"
1337 );
1338 }
1339 }
1340 });
1341 }
1342
1343 for engine in &mut project_engines {
1345 engine.task_manager = Arc::new(TaskManager::with_events(
1346 engine.backend.clone(),
1347 engine.store.clone(),
1348 engine.repo.clone(),
1349 event_bus.sender(),
1350 ));
1351 }
1352
1353 if let Err(e) = reconcile_startup_worktrees(&project_engines).await {
1354 tracing::warn!(err = %e, "startup worktree reconciliation failed");
1355 }
1356
1357 {
1358 let engines_snapshot: Vec<ProjectEngine> = project_engines.clone();
1362 tokio::spawn(async move {
1363 reconcile_startup_estimates(&engines_snapshot).await;
1364 });
1365 }
1366
1367 let global_channel_config = GlobalChannelConfig {
1369 telegram_general_topic_id: crate::config::get("channels.telegram.general_topic_id").ok(),
1370 discord_general_channel_id: crate::config::get("channels.discord.general_channel_id").ok(),
1371 control_telegram_topic_id: crate::config::get("control.channels.telegram.topic_id").ok(),
1372 control_discord_channel_id: crate::config::get("control.channels.discord.channel_id").ok(),
1373 };
1374 let project_channel_configs: Vec<(String, ProjectChannelConfig)> = project_engines
1375 .iter()
1376 .map(|e| {
1377 let cfg = read_project_channel_config(&e.project_dir);
1378 (e.repo.clone(), cfg)
1379 })
1380 .collect();
1381 let channel_router = Arc::new(ChannelRouter::new(
1382 &global_channel_config,
1383 &project_channel_configs,
1384 ));
1385 tracing::info!(
1386 projects = ?channel_router.projects(),
1387 "channel router initialized"
1388 );
1389
1390 let tmux = Arc::new(TmuxManager::new());
1392
1393 let transport = Arc::new(Transport::new());
1395
1396 let capture = Arc::new(CaptureService::new(transport.clone()));
1398 let capture_for_tick = capture.clone();
1399 tokio::spawn(async move {
1400 capture.start().await;
1401 });
1402
1403 let mut channel_registry = ChannelRegistry::new();
1405
1406 if let Ok(token) = crate::config::get("channels.telegram.bot_token") {
1408 if !token.is_empty() {
1409 let chat_id = crate::config::get("channels.telegram.chat_id").ok();
1410 match TelegramChannel::new(token, chat_id) {
1411 Ok(telegram) => {
1412 if let Err(e) = telegram.health_check().await {
1413 tracing::warn!(?e, "telegram channel health check failed, skipping");
1414 } else {
1415 channel_registry.register(Box::new(telegram));
1416 tracing::info!("telegram channel registered");
1417 }
1418 }
1419 Err(e) => {
1420 tracing::warn!(?e, "failed to create telegram channel, skipping");
1421 }
1422 }
1423 }
1424 }
1425
1426 if let Ok(token) = crate::config::get("channels.discord.bot_token") {
1428 if !token.is_empty() {
1429 let channel_id = crate::config::get("channels.discord.channel_id").ok();
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 match DiscordGateway::new(token, channel_id, shard_id, shard_count) {
1439 Ok(discord) => {
1440 if let Err(e) = discord.health_check().await {
1441 tracing::warn!(?e, "discord gateway health check failed, skipping");
1442 } else {
1443 channel_registry.register(Box::new(discord));
1444 tracing::info!(shard_id, shard_count, "discord gateway registered");
1445 }
1446 }
1447 Err(e) => {
1448 tracing::warn!(?e, "failed to create discord gateway, skipping");
1449 }
1450 }
1451 }
1452 }
1453
1454 if let Ok(token) = crate::config::get("channels.slack.bot_token") {
1456 if !token.is_empty() {
1457 let channel_id = crate::config::get("channels.slack.channel_id").ok();
1458 match SlackChannel::new(token, channel_id) {
1459 Ok(slack) => {
1460 if let Err(e) = slack.health_check().await {
1461 tracing::warn!(?e, "slack channel health check failed, skipping");
1462 } else {
1463 channel_registry.register(Box::new(slack));
1464 tracing::info!("slack channel registered");
1465 }
1466 }
1467 Err(e) => {
1468 tracing::warn!(?e, "failed to create slack channel, skipping");
1469 }
1470 }
1471 }
1472 }
1473
1474 let tmux_channel = TmuxChannel::with_transport(transport.clone());
1476 channel_registry.register(Box::new(tmux_channel));
1477 tracing::info!("tmux channel registered");
1478
1479 let mut channel_receivers: Vec<tokio::sync::mpsc::Receiver<IncomingMessage>> = Vec::new();
1481 for channel in channel_registry.iter() {
1482 match channel.start().await {
1483 Ok(rx) => {
1484 tracing::info!(channel = channel.name(), "channel started");
1485 channel_receivers.push(rx);
1486 }
1487 Err(e) => {
1488 tracing::warn!(channel = channel.name(), ?e, "failed to start channel");
1489 }
1490 }
1491 }
1492
1493 let channel_registry = Arc::new(channel_registry);
1495
1496 let transport_for_messages = transport.clone();
1498 let tmux_for_messages = tmux.clone();
1499 let capture_for_messages = capture_for_tick.clone();
1500 let channels_for_messages = channel_registry.clone();
1501 let engine_refs: Vec<EngineRef> = project_engines
1503 .iter()
1504 .map(|e| {
1505 (
1506 e.repo.clone(),
1507 e.backend.clone(),
1508 e.task_manager.clone(),
1509 Some(e.store.clone()),
1510 )
1511 })
1512 .collect();
1513 let pending_picks: channel_handler::PendingPicks =
1515 std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
1516 let router_for_messages = channel_router.clone();
1517 for mut rx in channel_receivers {
1518 let transport = transport_for_messages.clone();
1519 let tmux = tmux_for_messages.clone();
1520 let capture = capture_for_messages.clone();
1521 let channels = channels_for_messages.clone();
1522 let engine_refs = engine_refs.clone();
1523 let ch_router = router_for_messages.clone();
1524 let picks = pending_picks.clone();
1525 tokio::spawn(async move {
1526 while let Some(msg) = rx.recv().await {
1527 tracing::debug!(channel = %msg.channel, thread = %msg.thread_id, "received message from channel");
1528 channel_handler::handle_channel_message(
1529 msg,
1530 &transport,
1531 &tmux,
1532 &capture,
1533 &channels,
1534 &engine_refs,
1535 &ch_router,
1536 &picks,
1537 )
1538 .await;
1539 }
1540 });
1541 }
1542
1543 {
1547 let restart_channels = channel_registry.clone();
1548 let restart_store = project_engines.first().map(|e| e.store.clone());
1549 if let Some(store) = restart_store {
1550 tokio::spawn(async move {
1551 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1552 match store.kv_get("control:restart_pending").await {
1553 Ok(Some(conv_key)) => {
1554 if let Some((ch_name, thread_id, topic_id)) =
1555 crate::channels::transport::parse_conversation_key(&conv_key)
1556 {
1557 for ch in restart_channels.iter() {
1558 if ch.name() == ch_name {
1559 let msg = OutgoingMessage {
1560 thread_id: thread_id.to_string(),
1561 body: "orch restarted successfully.".to_string(),
1562 reply_to: None,
1563 metadata: serde_json::json!({}),
1564 topic_id: topic_id.map(|t| t.to_string()),
1565 };
1566 if let Err(e) = ch.send(&msg).await {
1567 tracing::warn!(
1568 channel = ch_name,
1569 error = %e,
1570 "failed to send restart confirmation"
1571 );
1572 }
1573 break;
1574 }
1575 }
1576 }
1577 if let Err(e) = store.kv_delete("control:restart_pending").await {
1578 tracing::warn!(error = %e, "failed to clear control:restart_pending");
1579 }
1580 }
1581 Ok(None) => {} Err(e) => {
1583 tracing::warn!(error = %e, "failed to read control:restart_pending");
1584 }
1585 }
1586 });
1587 }
1588 }
1589
1590 {
1601 let mut notification_rx = transport.subscribe_notifications();
1602 let channels = channel_registry.clone();
1603 let notif_router = channel_router.clone();
1604 let notif_store: Option<Arc<TaskStore>> = project_engines.first().map(|e| e.store.clone());
1606 tokio::spawn(async move {
1607 loop {
1608 match notification_rx.recv().await {
1609 Ok(notification) => {
1610 let level = NotificationLevel::from_config();
1611 if !level.should_notify(¬ification.status) {
1612 tracing::debug!(
1613 task_id = %notification.task_id,
1614 status = %notification.status,
1615 "notification suppressed by level={:?}",
1616 level
1617 );
1618 continue;
1619 }
1620
1621 tracing::info!(
1622 task_id = %notification.task_id,
1623 status = %notification.status,
1624 repo = ?notification.repo,
1625 "routing notification to channels"
1626 );
1627
1628 let mut routed = false;
1630
1631 if let Some(ref target) = notification.notify_target {
1634 let telegram = channels.iter().find(|c| c.name() == "telegram");
1635 if let Some(channel) = telegram {
1636 let body = notification.format_telegram();
1637 let metadata = serde_json::json!({
1638 "preformatted_html": true,
1639 "chat_id_override": target
1640 });
1641 let msg = OutgoingMessage {
1642 thread_id: notification.task_id.clone(),
1643 body,
1644 reply_to: None,
1645 metadata,
1646 topic_id: None,
1647 };
1648 if let Err(e) = channel.send(&msg).await {
1649 tracing::warn!(
1650 task_id = %notification.task_id,
1651 target,
1652 ?e,
1653 "failed to send to notify_target"
1654 );
1655 } else {
1656 routed = true;
1657 }
1658 }
1659 }
1660
1661 if routed {
1662 } else if let Some(repo) = notification.repo.as_deref() {
1664 let mut sent: std::collections::HashSet<(String, Option<String>)> =
1670 std::collections::HashSet::new();
1671
1672 for channel in channels.iter() {
1674 let ch_name = channel.name();
1675 if let Some(topic_id) =
1676 notif_router.target_for_project(repo, ch_name)
1677 {
1678 let body = match ch_name {
1679 "telegram" => notification.format_telegram(),
1680 "discord" => notification.format_discord(),
1681 "slack" => notification.format_slack(),
1682 _ => continue,
1683 };
1684 let metadata = if ch_name == "telegram" {
1685 serde_json::json!({"preformatted_html": true})
1686 } else {
1687 serde_json::json!({})
1688 };
1689 let resolved_topic = Some(topic_id.to_string());
1690 let msg = OutgoingMessage {
1691 thread_id: notification.task_id.clone(),
1692 body,
1693 reply_to: None,
1694 metadata,
1695 topic_id: resolved_topic.clone(),
1696 };
1697 if let Err(e) = channel.send(&msg).await {
1698 tracing::warn!(
1699 channel = ch_name,
1700 task_id = %notification.task_id,
1701 ?e,
1702 "failed to send to dedicated channel"
1703 );
1704 } else {
1705 sent.insert((ch_name.to_string(), resolved_topic));
1706 routed = true;
1707 }
1708 }
1709 }
1710
1711 if let Some(store) = ¬if_store {
1713 match store.list_subscribers_for_repo(repo).await {
1714 Ok(subscribers) => {
1715 for (ch_name, thread_id, topic_id) in subscribers {
1716 let channel =
1717 channels.iter().find(|c| c.name() == ch_name);
1718 let Some(channel) = channel else {
1719 continue;
1720 };
1721 let resolved_topic = if topic_id.is_empty() {
1722 None
1723 } else {
1724 Some(topic_id.clone())
1725 };
1726 if sent.contains(&(
1729 ch_name.clone(),
1730 resolved_topic.clone(),
1731 )) {
1732 tracing::debug!(
1733 channel = %ch_name,
1734 task_id = %notification.task_id,
1735 topic_id = ?resolved_topic,
1736 "skipping duplicate subscriber dispatch — destination already received from dedicated path"
1737 );
1738 continue;
1739 }
1740 let body = notification.format_with_project(&ch_name);
1741 let metadata = if ch_name == "telegram" {
1742 serde_json::json!({"preformatted_html": true})
1743 } else {
1744 serde_json::json!({})
1745 };
1746 let msg = OutgoingMessage {
1747 thread_id: thread_id.clone(),
1748 body,
1749 reply_to: None,
1750 metadata,
1751 topic_id: resolved_topic.clone(),
1752 };
1753 if let Err(e) = channel.send(&msg).await {
1754 tracing::warn!(
1755 channel = %ch_name,
1756 task_id = %notification.task_id,
1757 ?e,
1758 "failed to send to subscribed channel"
1759 );
1760 } else {
1761 sent.insert((ch_name.clone(), resolved_topic));
1762 routed = true;
1763 }
1764 }
1765 }
1766 Err(e) => {
1767 tracing::warn!(
1768 repo,
1769 ?e,
1770 "failed to list subscribers for repo"
1771 );
1772 }
1773 }
1774 }
1775 }
1776
1777 if !routed {
1779 for channel in channels.iter() {
1780 let (body, should_send, is_telegram) = match channel.name() {
1781 "telegram" => (notification.format_telegram(), true, true),
1782 "discord" => (notification.format_discord(), true, false),
1783 "slack" => (notification.format_slack(), true, false),
1784 _ => (String::new(), false, false),
1787 };
1788
1789 if !should_send {
1790 continue;
1791 }
1792
1793 let metadata = if is_telegram {
1794 serde_json::json!({"preformatted_html": true})
1795 } else {
1796 serde_json::json!({})
1797 };
1798 let msg = OutgoingMessage {
1799 thread_id: notification.task_id.clone(),
1800 body,
1801 reply_to: None,
1802 metadata,
1803 topic_id: None,
1804 };
1805
1806 if let Err(e) = channel.send(&msg).await {
1807 tracing::warn!(
1808 channel = channel.name(),
1809 task_id = %notification.task_id,
1810 ?e,
1811 "failed to send notification"
1812 );
1813 }
1814 }
1815 }
1816 }
1817 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1818 tracing::warn!(missed = n, "notification receiver lagged");
1819 }
1820 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1821 tracing::debug!("notification channel closed");
1822 break;
1823 }
1824 }
1825 }
1826 });
1827 tracing::info!("notification dispatcher started");
1828 }
1829
1830 let webhook_notify = Arc::new(Notify::new());
1832
1833 let webhook_port: Option<u16>;
1837 let mut webhook_healthy: bool;
1838 let mut last_webhook_health_check = std::time::Instant::now();
1839 let webhook_enabled = crate::config::get("webhook.enabled")
1841 .map(|v| v == "true")
1842 .unwrap_or(false);
1843
1844 let webhook_status = Arc::new(tokio::sync::Mutex::new(
1846 crate::webhook_status::WebhookStatus::default(),
1847 ));
1848
1849 if webhook_enabled {
1850 let port: u16 = crate::config::get("webhook.port")
1851 .ok()
1852 .and_then(|v| v.parse().ok())
1853 .unwrap_or(8080);
1854 webhook_port = Some(port);
1855 let secret = crate::config::get("webhook.secret").unwrap_or_default();
1856 let webhook_repo = project_engines
1857 .first()
1858 .map(|e| e.repo.clone())
1859 .unwrap_or_default();
1860
1861 let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(64);
1862
1863 let to_save = {
1865 let mut s = webhook_status.lock().await;
1866 s.configured = true;
1867 s.port = Some(port);
1868 s.healthy = false;
1869 s.fallback_mode = false;
1870 s.clone()
1871 };
1872 to_save.save().await;
1873
1874 const WEBHOOK_MAX_ATTEMPTS: u32 = 5;
1878 let status_for_spawn = webhook_status.clone();
1879 tokio::spawn(async move {
1880 use crate::channels::github::{is_transient_bind_error, webhook_backoff_delay};
1881 let mut attempt = 0u32;
1882 loop {
1883 attempt += 1;
1884 tracing::info!(attempt, port, "attempting to start webhook server");
1885 match start_webhook_server(port, secret.clone(), webhook_repo.clone(), tx.clone())
1886 .await
1887 {
1888 Ok(_) => {
1889 tracing::info!(attempt, "webhook server exited cleanly");
1890 break;
1891 }
1892 Err(e) => {
1893 let reason = e.to_string();
1894 tracing::error!(attempt, %reason, "webhook server failed");
1895 if attempt >= WEBHOOK_MAX_ATTEMPTS || !is_transient_bind_error(&e) {
1896 let kind = if attempt >= WEBHOOK_MAX_ATTEMPTS {
1897 "max attempts reached"
1898 } else {
1899 "non-transient error"
1900 };
1901 tracing::error!(
1902 attempt,
1903 %reason,
1904 kind,
1905 orch_webhook_in_fallback = true,
1906 "webhook server giving up, switching to polling fallback"
1907 );
1908 let to_save = {
1909 let mut s = status_for_spawn.lock().await;
1910 s.fallback_mode = true;
1911 s.healthy = false;
1912 s.last_failure_reason = Some(reason);
1913 s.startup_attempts = attempt;
1914 s.clone()
1915 };
1916 to_save.save().await;
1917 break;
1918 }
1919 let delay = webhook_backoff_delay(attempt);
1920 tracing::warn!(
1921 attempt,
1922 delay_ms = delay.as_millis(),
1923 %reason,
1924 "webhook bind failed (transient), retrying with backoff"
1925 );
1926 {
1927 let to_save = {
1928 let mut s = status_for_spawn.lock().await;
1929 s.startup_attempts = attempt;
1930 s.last_failure_reason = Some(reason);
1931 s.clone()
1932 };
1933 to_save.save().await;
1934 }
1935 tokio::time::sleep(delay).await;
1936 }
1937 }
1938 }
1939 });
1940
1941 let notify = webhook_notify.clone();
1943 let transport_for_webhook = transport.clone();
1944 tokio::spawn(async move {
1945 while let Some(msg) = rx.recv().await {
1946 tracing::info!(
1947 channel = %msg.channel,
1948 thread = %msg.thread_id,
1949 event = %msg.metadata.get("event").and_then(|v| v.as_str()).unwrap_or("unknown"),
1950 action = %msg.metadata.get("action").and_then(|v| v.as_str()).unwrap_or("unknown"),
1951 "webhook event received, triggering immediate tick"
1952 );
1953 let route = transport_for_webhook.route(&msg).await;
1954 tracing::info!(
1955 channel = %msg.channel,
1956 route = ?route,
1957 "webhook event routed"
1958 );
1959 notify.notify_one();
1961 }
1962 });
1963
1964 webhook_healthy = true;
1965 tracing::info!(port, orch_webhook_up = true, "webhook server started");
1966 } else {
1967 webhook_port = None;
1968 webhook_healthy = false;
1969 {
1970 let to_save = {
1971 let mut s = webhook_status.lock().await;
1972 s.configured = false;
1973 s.fallback_mode = true;
1974 s.clone()
1975 };
1976 to_save.save().await;
1977 }
1978 tracing::info!(
1979 orch_webhook_in_fallback = true,
1980 "webhook server disabled, using polling fallback mode"
1981 );
1982 }
1983
1984 let router = Arc::new(RwLock::new(
1991 match tokio::task::spawn_blocking(Router::from_config).await {
1992 Ok(r) => r,
1993 Err(e) => {
1994 tracing::error!(?e, "router init panicked in spawn_blocking, using default");
1995 tokio::task::spawn_blocking(|| Router::new(RouterConfig::default()))
1996 .await
1997 .unwrap_or_else(|e2| {
1998 panic!("router default init also panicked in spawn_blocking — cannot start engine without a router: {e2:?}");
1999 })
2000 }
2001 },
2002 ));
2003 {
2004 let r = router.read().await;
2005 tracing::info!(
2006 mode = %r.config.mode,
2007 agents = ?r.available_agents,
2008 fallback = %r.config.fallback_executor,
2009 "router initialized"
2010 );
2011 }
2012
2013 let semaphore = Arc::new(Semaphore::new(config.max_parallel));
2017 let mut parallel_in_force = config.max_parallel;
2018
2019 let dispatching: Arc<dashmap::DashMap<String, String>> = Arc::new(dashmap::DashMap::new());
2026
2027 let auto_merge_in_flight: Arc<dashmap::DashSet<String>> = Arc::new(dashmap::DashSet::new());
2031
2032 let mut config_rx = crate::config::subscribe();
2034
2035 let mut last_sync = std::time::Instant::now();
2037
2038 let mut last_upgrade_check: Option<std::time::Instant> = None;
2040
2041 let mut rate_limit_reported: Option<i64> = None;
2044
2045 let (weight_tx, mut weight_rx) = mpsc::channel::<WeightSignal>(64);
2047
2048 for engine in &project_engines {
2054 let in_review_from_store = engine
2056 .store
2057 .list_by_status(&engine.repo, crate::store::TaskStatus::InReview)
2058 .await
2059 .unwrap_or_default()
2060 .iter()
2061 .map(crate::engine::tasks::store_task_to_external)
2062 .collect::<Vec<_>>();
2063 if !in_review_from_store.is_empty() {
2064 let in_review = &in_review_from_store;
2065 for task in in_review {
2066 let session_expected =
2067 review_session_expected(&engine.store, &engine.repo, &task.id.0).await;
2068 let age_minutes = chrono::DateTime::parse_from_rfc3339(&task.updated_at)
2069 .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_minutes())
2070 .unwrap_or(i64::MAX);
2071 if !session_expected && age_minutes < 10 {
2072 continue;
2073 }
2074 if let Err(e) = engine
2075 .task_manager
2076 .update_task_status(&task.id, Status::NeedsReview)
2077 .await
2078 {
2079 tracing::warn!(
2080 task_id = task.id.0,
2081 err = %e,
2082 "failed to reset stale InReview task on startup"
2083 );
2084 } else {
2085 set_review_session_expected(&engine.store, &engine.repo, &task.id.0, false)
2086 .await;
2087 }
2088 }
2089 if !in_review.is_empty() {
2090 tracing::info!(
2091 repo = %engine.repo,
2092 count = in_review.len(),
2093 "reset stale InReview tasks to NeedsReview on startup"
2094 );
2095 }
2096 }
2097
2098 use crate::store::TaskStatus as DbStatus;
2102 if let Ok(internal_in_review) = engine
2103 .task_manager
2104 .list_internal_by_status(DbStatus::InReview)
2105 .await
2106 {
2107 for task in &internal_in_review {
2108 let task_id = task.id.0.clone();
2109 let session_expected =
2110 review_session_expected(&engine.store, &engine.repo, &task_id).await;
2111 let age_minutes = chrono::DateTime::parse_from_rfc3339(&task.updated_at)
2112 .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_minutes())
2113 .unwrap_or(i64::MAX);
2114 if !session_expected && age_minutes < 10 {
2115 continue;
2116 }
2117 if let Err(e) = engine
2118 .task_manager
2119 .update_task_status(&ExternalId(task_id.clone()), Status::NeedsReview)
2120 .await
2121 {
2122 tracing::warn!(
2123 task_id,
2124 err = %e,
2125 "failed to reset stale internal InReview task on startup"
2126 );
2127 } else {
2128 set_review_session_expected(&engine.store, &engine.repo, &task_id, false).await;
2129 }
2130 }
2131 if !internal_in_review.is_empty() {
2132 tracing::info!(
2133 repo = %engine.repo,
2134 count = internal_in_review.len(),
2135 "reset stale internal InReview tasks to NeedsReview on startup"
2136 );
2137 }
2138 }
2139 }
2140
2141 for engine in &project_engines {
2143 subscribers::dispatch::spawn(
2144 event_bus.subscribe(),
2145 engine.backend.clone(),
2146 tmux.clone(),
2147 engine.runner.clone(),
2148 capture_for_tick.clone(),
2149 semaphore.clone(),
2150 engine.task_manager.clone(),
2151 weight_tx.clone(),
2152 router.clone(),
2153 dispatching.clone(),
2154 engine.store.clone(),
2155 engine.repo.clone(),
2156 );
2157 }
2158 tracing::info!("dispatch subscriber started");
2159
2160 for engine in &project_engines {
2162 subscribers::review::spawn(
2163 event_bus.subscribe(),
2164 engine.backend.clone(),
2165 tmux.clone(),
2166 semaphore.clone(),
2167 engine.task_manager.clone(),
2168 router.clone(),
2169 dispatching.clone(),
2170 engine.store.clone(),
2171 engine.repo.clone(),
2172 );
2173 }
2174 tracing::info!("review subscriber started");
2175
2176 for engine in &project_engines {
2178 subscribers::unblock::spawn(
2179 event_bus.subscribe(),
2180 engine.backend.clone(),
2181 engine.task_manager.clone(),
2182 engine.store.clone(),
2183 engine.repo.clone(),
2184 );
2185 }
2186 tracing::info!("unblock subscriber started");
2187
2188 subscribers::notify::spawn(event_bus.subscribe(), transport.clone());
2191 tracing::info!("notify subscriber started");
2192
2193 tracing::info!(
2195 tick = ?config.tick_interval,
2196 sync = ?config.sync_interval,
2197 parallel = config.max_parallel,
2198 "entering main loop"
2199 );
2200 let mut restart_only = restart_only_snapshot();
2203
2204 let mut interval = tokio::time::interval(config.tick_interval);
2205
2206 let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2208 let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
2209
2210 let sync_in_progress = Arc::new(std::sync::atomic::AtomicBool::new(false));
2212
2213 let last_tick_epoch = Arc::new(std::sync::atomic::AtomicU64::new(
2216 std::time::SystemTime::now()
2217 .duration_since(std::time::UNIX_EPOCH)
2218 .unwrap_or_default()
2219 .as_secs(),
2220 ));
2221 {
2222 let last_tick_epoch = Arc::clone(&last_tick_epoch);
2223 let tick_interval_secs = config.tick_interval.as_secs();
2224 tokio::spawn(async move {
2225 let mut watchdog_interval = tokio::time::interval(std::time::Duration::from_secs(30));
2227 let mut suspend_detector = crate::engine::suspend::SuspendDetector::new();
2232 loop {
2233 watchdog_interval.tick().await;
2234 let own_gap = suspend_detector.check();
2235 let last = last_tick_epoch.load(std::sync::atomic::Ordering::Relaxed);
2236 let now = std::time::SystemTime::now()
2237 .duration_since(std::time::UNIX_EPOCH)
2238 .unwrap_or_default()
2239 .as_secs();
2240 let stale_secs = now.saturating_sub(last);
2241 let threshold = (tick_interval_secs * 6).max(60);
2243 if stale_secs > threshold {
2244 let shared_gap = crate::engine::suspend::gap_detected_within(
2251 std::time::Duration::from_secs(stale_secs),
2252 );
2253 let gap = match (own_gap, shared_gap) {
2254 (Some(a), Some(b)) => Some(a.max(b)),
2255 (Some(a), None) => Some(a),
2256 (None, Some(b)) => Some(b),
2257 (None, None) => None,
2258 };
2259 if let Some(gap) = gap {
2260 let gap_secs = gap.num_seconds() as u64;
2261 if gap_secs >= stale_secs {
2262 tracing::info!(
2263 stale_secs,
2264 threshold,
2265 suspend_gap_secs = gap_secs,
2266 "tick loop stale window explained by host suspend/resume, not a stall"
2267 );
2268 } else {
2269 tracing::error!(
2270 stale_secs,
2271 threshold,
2272 suspend_gap_secs = gap_secs,
2273 "WATCHDOG: tick loop has not completed a tick in {}s (threshold {}s) — suspend gap of {}s only partially explains the stall",
2274 stale_secs,
2275 threshold,
2276 gap_secs,
2277 );
2278 }
2279 } else {
2280 tracing::error!(
2281 stale_secs,
2282 threshold,
2283 "WATCHDOG: tick loop has not completed a tick in {}s (threshold {}s) — possible stall",
2284 stale_secs,
2285 threshold,
2286 );
2287 }
2288 }
2289 }
2290 });
2291 }
2292
2293 loop {
2294 tokio::select! {
2295 _ = interval.tick() => {
2296 let tick_start = std::time::Instant::now();
2297
2298 if let Some(gap) = crate::engine::suspend::checkpoint() {
2302 tracing::info!(
2303 gap_secs = gap.num_seconds(),
2304 "host suspend/resume gap detected, discounting from stuck-task age checks"
2305 );
2306 }
2307
2308 if parallel_in_force != config.max_parallel {
2312 let resized =
2313 resize_dispatch_pool(&semaphore, parallel_in_force, config.max_parallel);
2314 if resized != parallel_in_force {
2315 parallel_in_force = resized;
2316 tracing::info!(
2317 in_force = parallel_in_force,
2318 target = config.max_parallel,
2319 "dispatch pool resized"
2320 );
2321 }
2322 }
2323
2324 {
2326 let mut rw = router.write().await;
2327 rw.tick_weight_recovery();
2328 }
2329
2330 if let Some(remaining) = GhHttp::is_rate_limited() {
2334 let reset_at = chrono::Utc::now()
2335 .timestamp()
2336 .saturating_add(i64::try_from(remaining.as_secs()).unwrap_or(i64::MAX));
2337 if rate_limit_reported.is_none_or(|prev| (reset_at - prev).abs() > 60) {
2339 rate_limit_reported = Some(reset_at);
2340 report_rate_limited(reset_at, project_engines.first().map(|e| &e.store), Some(&transport)).await;
2341 }
2342 } else if let Some(prev) = rate_limit_reported.take() {
2343 tracing::info!(
2344 limited_until = prev,
2345 "GitHub API rate limit cleared"
2346 );
2347 if let Some(engine) = project_engines.first() {
2348 let _ = engine.store.kv_delete(RATE_LIMITED_UNTIL_KEY).await;
2349 }
2350 }
2351
2352 let mut router_guard = router.write().await;
2354 let mut routing_quota = tick::RoutingTickQuota::new(
2355 crate::engine::router::config::max_tasks_per_routing_tick(),
2356 );
2357 for engine in &project_engines {
2358 let repo = engine.repo.clone();
2359 let project_jobs_path = engine.project_dir.join(".orch.yml");
2360 REPO_CONTEXT.scope(repo, async {
2361 if let Err(e) = tick::tick(
2362 &engine.backend,
2363 &tmux,
2364 &engine.repo,
2365 &engine.runner,
2366 &capture_for_tick,
2367 &semaphore,
2368 &config,
2369 &project_jobs_path,
2370 &mut router_guard,
2371 &engine.task_manager,
2372 &weight_tx,
2373 &dispatching,
2374 &engine.store,
2375 Some(&transport),
2376 &active_repos,
2377 &auto_merge_in_flight,
2378 &mut routing_quota,
2379 ).await {
2380 tracing::error!(repo = %engine.repo, ?e, "tick failed for project");
2381 }
2382 }).await;
2383 }
2384 drop(router_guard);
2385
2386 while let Ok(signal) = weight_rx.try_recv() {
2389 let mut rw = router.write().await;
2390 match signal {
2391 WeightSignal::RateLimited { ref agent } => {
2392 rw.record_rate_limit(agent);
2393 }
2394 WeightSignal::Success { ref agent } => {
2395 rw.record_success(agent);
2396 }
2397 WeightSignal::Rerouted | WeightSignal::Blocked | WeightSignal::None => {}
2398 }
2399 }
2400
2401 if last_sync.elapsed() >= config.sync_interval {
2406 if sync_in_progress.compare_exchange(
2407 false,
2408 true,
2409 std::sync::atomic::Ordering::AcqRel,
2410 std::sync::atomic::Ordering::Relaxed,
2411 ).is_ok() {
2412 let sync_engines: Vec<_> = project_engines.iter().map(|e| {
2414 (e.backend.clone(), e.repo.clone(),
2415 e.task_manager.clone(), e.store.clone())
2416 }).collect();
2417 let sync_tmux = Arc::clone(&tmux);
2418 let sync_config = config.clone();
2419 let sync_router = Arc::clone(&router);
2420 let sync_dispatching = Arc::clone(&dispatching);
2421 let sync_auto_merge = Arc::clone(&auto_merge_in_flight);
2422 let sync_guard = Arc::clone(&sync_in_progress);
2423 tokio::spawn(async move {
2424 let sync_start = std::time::Instant::now();
2425 for (backend, repo, task_manager, store) in &sync_engines {
2426 let repo = repo.clone();
2427 REPO_CONTEXT.scope(repo.clone(), async {
2428 if let Err(e) = sync::sync_tick(
2429 backend, &sync_tmux, &repo, &sync_config,
2430 &sync_router, task_manager, store,
2431 &sync_dispatching, &sync_auto_merge,
2432 ).await {
2433 tracing::error!(repo = %repo, ?e, "sync tick failed for project");
2434 }
2435 }).await;
2436 }
2437 if let Some((_, _, _, store)) = sync_engines.first() {
2441 let (available_agents, config) = {
2442 let r = sync_router.read().await;
2443 (r.available_agents.clone(), r.config.clone())
2444 };
2445 sync::emit_degraded_agents_if_needed(
2446 &available_agents,
2447 &config,
2448 Some(store),
2449 )
2450 .await;
2451 }
2452 let elapsed = sync_start.elapsed();
2453 tracing::info!(elapsed_ms = elapsed.as_millis() as u64, "sync tick complete");
2454 sync_guard.store(false, std::sync::atomic::Ordering::Release);
2455 });
2456 } else {
2457 tracing::debug!("sync tick still in progress, skipping");
2458 }
2459 last_sync = std::time::Instant::now();
2463 }
2464
2465 if webhook_enabled {
2467 let health_check_interval = config.webhook_health_check_interval
2468 .unwrap_or(std::time::Duration::from_secs(60));
2469
2470 if last_webhook_health_check.elapsed() >= health_check_interval {
2471 if let Some(port) = webhook_port {
2472 let (health, failure_reason) =
2473 crate::channels::github::check_webhook_health(port).await;
2474 if health != webhook_healthy {
2475 webhook_healthy = health;
2476 if webhook_healthy {
2477 tracing::info!(port, orch_webhook_up = true, "webhook health restored");
2478 } else {
2479 tracing::warn!(
2480 port,
2481 orch_webhook_up = false,
2482 reason = failure_reason.as_deref().unwrap_or("unknown"),
2483 "webhook health check failed"
2484 );
2485 }
2486 }
2487 {
2489 let to_save = {
2490 let mut s = webhook_status.lock().await;
2491 s.healthy = health;
2492 s.last_check_utc = Some(chrono::Utc::now());
2493 if health {
2494 s.last_failure_reason = None;
2495 } else if failure_reason.is_some() {
2496 s.last_failure_reason = failure_reason;
2497 }
2498 s.clone()
2499 };
2500 to_save.save().await;
2501 }
2502 }
2503 last_webhook_health_check = std::time::Instant::now();
2504 }
2505 }
2506
2507 if should_run_upgrade_check(last_upgrade_check, config.upgrade_check_interval) {
2509 if let Some(store) = project_engines.first().map(|e| e.store.clone()) {
2510 let channels = channel_registry.clone();
2511 let current = env!("ORCH_VERSION").to_string();
2512 let check_interval =
2513 std::time::Duration::from_secs(config.upgrade_check_interval);
2514 tokio::spawn(async move {
2515 check_and_notify_upgrade(&store, &channels, ¤t, check_interval)
2516 .await;
2517 });
2518 }
2519 last_upgrade_check = Some(std::time::Instant::now());
2520 }
2521
2522 let tick_elapsed = tick_start.elapsed();
2524 if tick_elapsed.as_secs() > 30 {
2525 tracing::warn!(elapsed_ms = tick_elapsed.as_millis() as u64, "slow tick");
2526 } else {
2527 tracing::debug!(elapsed_ms = tick_elapsed.as_millis() as u64, "tick complete");
2528 }
2529 last_tick_epoch.store(
2530 std::time::SystemTime::now()
2531 .duration_since(std::time::UNIX_EPOCH)
2532 .unwrap_or_default()
2533 .as_secs(),
2534 std::sync::atomic::Ordering::Relaxed,
2535 );
2536 }
2537 _ = webhook_notify.notified() => {
2539 if let Some(remaining) = GhHttp::is_rate_limited() {
2540 tracing::warn!(
2541 remaining_secs = remaining.as_secs(),
2542 "GitHub API rate-limited, skipping webhook-triggered tick"
2543 );
2544 } else {
2545 tracing::info!("webhook event triggered immediate tick");
2546
2547 let mut router_guard = router.write().await;
2548 let mut routing_quota = tick::RoutingTickQuota::new(
2549 crate::engine::router::config::max_tasks_per_routing_tick(),
2550 );
2551 for engine in &project_engines {
2552 let repo = engine.repo.clone();
2553 let project_jobs_path = engine.project_dir.join(".orch.yml");
2554 REPO_CONTEXT.scope(repo, async {
2555 if let Err(e) = tick::tick(
2556 &engine.backend,
2557 &tmux,
2558 &engine.repo,
2559 &engine.runner,
2560 &capture_for_tick,
2561 &semaphore,
2562 &config,
2563 &project_jobs_path,
2564 &mut router_guard,
2565 &engine.task_manager,
2566 &weight_tx,
2567 &dispatching,
2568 &engine.store,
2569 Some(&transport),
2570 &active_repos,
2571 &auto_merge_in_flight,
2572 &mut routing_quota,
2573 ).await {
2574 tracing::error!(repo = %engine.repo, ?e, "webhook-triggered tick failed");
2575 }
2576 }).await;
2577 }
2578 drop(router_guard);
2579
2580 while let Ok(signal) = weight_rx.try_recv() {
2582 let mut rw = router.write().await;
2583 match signal {
2584 WeightSignal::RateLimited { ref agent } => {
2585 rw.record_rate_limit(agent);
2586 }
2587 WeightSignal::Success { ref agent } => {
2588 rw.record_success(agent);
2589 }
2590 WeightSignal::Rerouted | WeightSignal::Blocked | WeightSignal::None => {}
2591 }
2592 }
2593
2594 if sync_in_progress.compare_exchange(
2600 false,
2601 true,
2602 std::sync::atomic::Ordering::AcqRel,
2603 std::sync::atomic::Ordering::Relaxed,
2604 ).is_ok() {
2605 let sync_engines: Vec<_> = project_engines.iter().map(|e| {
2607 (e.backend.clone(), e.repo.clone(), e.task_manager.clone(), e.store.clone())
2608 }).collect();
2609 let sync_tmux = Arc::clone(&tmux);
2610 let sync_config = config.clone();
2611 let sync_router = Arc::clone(&router);
2612 let sync_dispatching = Arc::clone(&dispatching);
2613 let sync_auto_merge = Arc::clone(&auto_merge_in_flight);
2614 let sync_guard = Arc::clone(&sync_in_progress);
2615 tokio::spawn(async move {
2616 let sync_start = std::time::Instant::now();
2617 for (backend, repo, task_manager, store) in &sync_engines {
2618 let repo = repo.clone();
2619 REPO_CONTEXT.scope(repo.clone(), async {
2620 if let Err(e) = sync::sync_tick(
2621 backend, &sync_tmux, &repo, &sync_config,
2622 &sync_router, task_manager, store,
2623 &sync_dispatching, &sync_auto_merge,
2624 ).await {
2625 tracing::error!(repo = %repo, ?e, "webhook immediate sync tick failed for project");
2626 }
2627 }).await;
2628 }
2629 if let Some((_, _, _, store)) = sync_engines.first() {
2631 let (available_agents, config) = {
2632 let r = sync_router.read().await;
2633 (r.available_agents.clone(), r.config.clone())
2634 };
2635 sync::emit_degraded_agents_if_needed(
2636 &available_agents,
2637 &config,
2638 Some(store),
2639 ).await;
2640 }
2641 let elapsed = sync_start.elapsed();
2642 tracing::info!(elapsed_ms = elapsed.as_millis() as u64, "webhook immediate sync tick complete");
2643 sync_guard.store(false, std::sync::atomic::Ordering::Release);
2644 });
2645 last_sync = std::time::Instant::now();
2647 } else {
2648 tracing::debug!("sync tick still in progress, skipping webhook-triggered immediate sync");
2649 }
2650 }
2651
2652 interval.reset();
2654 }
2655 result = config_rx.recv() => {
2656 let reload = match result {
2659 Ok(path) => {
2660 tracing::info!(path = %path.display(), "config file changed, reloading");
2661 true
2662 }
2663 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
2664 tracing::warn!(missed = n, "config change receiver lagged, reloading");
2665 true
2666 }
2667 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
2668 tracing::warn!("config change channel closed");
2669 false
2670 }
2671 };
2672
2673 if reload {
2674 let new_config = EngineConfig::from_config();
2676 log_engine_config_changes(&config, &new_config);
2677
2678 let new_restart_only = restart_only_snapshot();
2679 log_restart_only_changes(&restart_only, &new_restart_only);
2680 restart_only = new_restart_only;
2681
2682 let tick_changed = new_config.tick_interval != config.tick_interval;
2683 config = new_config;
2684
2685 if tick_changed {
2687 interval = tokio::time::interval(config.tick_interval);
2688 tracing::info!(tick = ?config.tick_interval, "tick interval updated");
2689 }
2690
2691 if parallel_in_force != config.max_parallel {
2694 parallel_in_force = resize_dispatch_pool(
2695 &semaphore,
2696 parallel_in_force,
2697 config.max_parallel,
2698 );
2699 tracing::info!(
2700 in_force = parallel_in_force,
2701 target = config.max_parallel,
2702 "dispatch pool resized"
2703 );
2704 }
2705
2706 {
2708 let mut router_guard = router.write().await;
2709 router_guard.reload_async().await;
2710 }
2711
2712 sync_project_engines(
2714 &mut project_engines,
2715 &mut active_repos,
2716 &event_bus,
2717 &tmux,
2718 &capture_for_tick,
2719 &semaphore,
2720 &weight_tx,
2721 &router,
2722 &dispatching,
2723 ).await;
2724
2725 tracing::info!(
2726 tick = ?config.tick_interval,
2727 sync = ?config.sync_interval,
2728 parallel = config.max_parallel,
2729 projects = project_engines.len(),
2730 "config reloaded"
2731 );
2732 }
2733 }
2734 signal_name = async {
2735 tokio::select! {
2736 _ = tokio::signal::ctrl_c() => "SIGINT",
2737 _ = sigterm.recv() => "SIGTERM",
2738 _ = sighup.recv() => "SIGHUP",
2739 }
2740 } => {
2741 tracing::info!(signal = signal_name, "beginning graceful shutdown");
2742
2743 let shutdown_result = tokio::time::timeout(
2746 config.graceful_shutdown_timeout,
2747 async {
2748 let mut reset_count = 0u32;
2752 let mut review_reset_count = 0u32;
2753 for engine in &project_engines {
2754 if let Ok(tasks) = engine.task_manager.list_external_by_status(Status::InProgress).await {
2755 for task in &tasks {
2756 if let Err(e) = engine.task_manager.update_task_status(&task.id, Status::Routed).await {
2757 tracing::warn!(task_id = task.id.0, ?e, "failed to reset task on shutdown");
2758 } else {
2759 if let Ok(Some(store_id)) =
2760 engine.store.resolve_task_id(&engine.repo, &task.id.0).await
2761 {
2762 if let Err(e) = engine
2763 .store
2764 .finalize_incomplete_runs(
2765 store_id,
2766 "aborted",
2767 "graceful shutdown: reset in_progress task to routed",
2768 )
2769 .await
2770 {
2771 tracing::warn!(
2772 task_id = task.id.0,
2773 ?e,
2774 "failed to finalize incomplete runs on shutdown"
2775 );
2776 }
2777 }
2778 reset_count += 1;
2779 }
2780 }
2781 }
2782 if let Ok(tasks) = engine.store.list_internal_by_status(&engine.repo, crate::store::TaskStatus::InProgress).await {
2784 for task in &tasks {
2785 let task_id = format!("internal:{}", task.id);
2786 if let Err(e) = engine.task_manager.update_task_status(
2787 &crate::backends::ExternalId(task_id.clone()),
2788 Status::Routed,
2789 ).await {
2790 tracing::warn!(task_id, ?e, "failed to reset internal task on shutdown");
2791 } else {
2792 if let Err(e) = engine
2793 .store
2794 .finalize_incomplete_runs(
2795 task.id,
2796 "aborted",
2797 "graceful shutdown: reset in_progress task to routed",
2798 )
2799 .await
2800 {
2801 tracing::warn!(
2802 task_id,
2803 ?e,
2804 "failed to finalize incomplete internal runs on shutdown"
2805 );
2806 }
2807 reset_count += 1;
2808 }
2809 }
2810 }
2811 if let Ok(tasks) = engine.task_manager.list_external_by_status(Status::InReview).await {
2813 for task in &tasks {
2814 if let Err(e) = engine.task_manager.update_task_status(&task.id, Status::NeedsReview).await {
2815 tracing::warn!(task_id = task.id.0, ?e, "failed to reset in_review task on shutdown");
2816 } else {
2817 set_review_session_expected(&engine.store, &engine.repo, &task.id.0, false).await;
2818 if let Ok(Some(store_id)) =
2819 engine.store.resolve_task_id(&engine.repo, &task.id.0).await
2820 {
2821 if let Err(e) = engine
2822 .store
2823 .finalize_incomplete_runs(
2824 store_id,
2825 "aborted",
2826 "graceful shutdown: reset in_review task to needs_review",
2827 )
2828 .await
2829 {
2830 tracing::warn!(
2831 task_id = task.id.0,
2832 ?e,
2833 "failed to finalize incomplete review runs on shutdown"
2834 );
2835 }
2836 }
2837 review_reset_count += 1;
2838 }
2839 }
2840 }
2841 if let Ok(tasks) = engine.store.list_internal_by_status(&engine.repo, crate::store::TaskStatus::InReview).await {
2843 for task in &tasks {
2844 let task_id = format!("internal:{}", task.id);
2845 if let Err(e) = engine.task_manager.update_task_status(
2846 &crate::backends::ExternalId(task_id.clone()),
2847 Status::NeedsReview,
2848 ).await {
2849 tracing::warn!(task_id, ?e, "failed to reset internal in_review task on shutdown");
2850 } else {
2851 set_review_session_expected(&engine.store, &engine.repo, &task_id, false).await;
2852 if let Err(e) = engine
2853 .store
2854 .finalize_incomplete_runs(
2855 task.id,
2856 "aborted",
2857 "graceful shutdown: reset in_review task to needs_review",
2858 )
2859 .await
2860 {
2861 tracing::warn!(
2862 task_id,
2863 ?e,
2864 "failed to finalize incomplete internal review runs on shutdown"
2865 );
2866 }
2867 review_reset_count += 1;
2868 }
2869 }
2870 }
2871 }
2872 if reset_count > 0 {
2873 tracing::info!(reset_count, "reset in_progress tasks to routed for re-dispatch");
2874 }
2875 if review_reset_count > 0 {
2876 tracing::info!(review_reset_count, "reset in_review tasks to needs_review for re-dispatch");
2877 }
2878
2879 let mut killed_sessions = Vec::new();
2882 if let Ok(sessions) = tmux.list_sessions().await {
2883 for session in &sessions {
2884 if session.name.starts_with("orch-") {
2885 if let Err(e) = tmux.kill_session(&session.name).await {
2886 tracing::warn!(session = %session.name, error = %e, "failed to kill tmux session");
2887 } else {
2888 killed_sessions.push(session.name.clone());
2889 }
2890 }
2891 }
2892 if !killed_sessions.is_empty() {
2893 tracing::info!(killed = killed_sessions.len(), "killing orch tmux sessions on shutdown");
2894 }
2895 }
2896
2897 if !killed_sessions.is_empty() {
2900 let still_alive = tmux
2901 .wait_for_sessions_dead(
2902 &killed_sessions,
2903 std::time::Duration::from_millis(100),
2904 std::time::Duration::from_secs(5),
2905 )
2906 .await;
2907 if still_alive > 0 {
2908 tracing::warn!(
2909 still_alive,
2910 "some tmux sessions did not terminate cleanly - they will be cleaned up on startup"
2911 );
2912 }
2913 tracing::info!(killed = killed_sessions.len() - still_alive, "confirmed tmux sessions terminated");
2914 }
2915 },
2916 ).await;
2917
2918 if shutdown_result.is_err() {
2919 tracing::error!(
2920 timeout_secs = config.graceful_shutdown_timeout.as_secs(),
2921 "graceful shutdown timed out, forcing exit"
2922 );
2923 }
2924
2925 break;
2926 }
2927 }
2928 }
2929
2930 events::cleanup_port_file().await;
2932 events::cleanup_version_file();
2933
2934 let _ = transport;
2936 tracing::info!("orch engine stopped");
2937 Ok(())
2938}
2939
2940#[cfg(test)]
2941mod tests {
2942 use super::*;
2943
2944 #[test]
2945 fn growing_max_parallel_lets_more_tasks_run_without_a_restart() {
2946 let semaphore = Arc::new(Semaphore::new(4));
2947 let held: Vec<_> = (0..4)
2948 .map(|_| semaphore.clone().try_acquire_owned().unwrap())
2949 .collect();
2950 assert!(
2951 semaphore.clone().try_acquire_owned().is_err(),
2952 "pool is full"
2953 );
2954
2955 let in_force = resize_dispatch_pool(&semaphore, 4, 8);
2956 assert_eq!(in_force, 8);
2957
2958 let grown: Vec<_> = (0..4)
2959 .map(|_| semaphore.clone().try_acquire_owned().unwrap())
2960 .collect();
2961 assert_eq!(held.len() + grown.len(), 8, "8 tasks run at once");
2962 assert!(
2963 semaphore.clone().try_acquire_owned().is_err(),
2964 "pool is full"
2965 );
2966 }
2967
2968 #[test]
2969 fn shrinking_max_parallel_never_cancels_a_running_task() {
2970 let semaphore = Arc::new(Semaphore::new(8));
2971 let mut held: Vec<_> = (0..8)
2972 .map(|_| semaphore.clone().try_acquire_owned().unwrap())
2973 .collect();
2974
2975 let mut in_force = resize_dispatch_pool(&semaphore, 8, 4);
2977 assert_eq!(in_force, 8);
2978 assert_eq!(held.len(), 8);
2979
2980 held.truncate(5);
2982 in_force = resize_dispatch_pool(&semaphore, in_force, 4);
2983 assert_eq!(in_force, 5);
2984 assert!(semaphore.clone().try_acquire_owned().is_err());
2985
2986 held.truncate(4);
2988 in_force = resize_dispatch_pool(&semaphore, in_force, 4);
2989 assert_eq!(in_force, 4);
2990 assert!(semaphore.clone().try_acquire_owned().is_err());
2991
2992 held.truncate(3);
2994 assert!(semaphore.clone().try_acquire_owned().is_ok());
2995 }
2996
2997 #[test]
2998 fn a_project_added_to_the_config_is_picked_up_and_running_ones_are_not_respawned() {
2999 let configured = vec![
3000 (
3001 "owner/one".to_string(),
3002 std::path::PathBuf::from("/srv/one"),
3003 ),
3004 (
3005 "owner/two".to_string(),
3006 std::path::PathBuf::from("/srv/two"),
3007 ),
3008 ];
3009 let active: std::collections::HashSet<String> =
3010 ["owner/one".to_string()].into_iter().collect();
3011
3012 let (added, removed) = diff_configured_projects(&configured, &active);
3013 assert_eq!(
3014 added,
3015 vec![(
3016 "owner/two".to_string(),
3017 std::path::PathBuf::from("/srv/two")
3018 )],
3019 "only the new project is started, so owner/one keeps its single set of subscribers"
3020 );
3021 assert!(removed.is_empty());
3022 }
3023
3024 #[test]
3025 fn a_project_dropped_from_the_config_is_reported_and_not_restarted() {
3026 let configured = vec![(
3027 "owner/one".to_string(),
3028 std::path::PathBuf::from("/srv/one"),
3029 )];
3030 let active: std::collections::HashSet<String> =
3031 ["owner/one".to_string(), "owner/gone".to_string()]
3032 .into_iter()
3033 .collect();
3034
3035 let (added, removed) = diff_configured_projects(&configured, &active);
3036 assert!(added.is_empty(), "nothing new to start");
3037 assert_eq!(removed, vec!["owner/gone".to_string()]);
3038 }
3039
3040 #[test]
3041 fn an_unchanged_project_list_starts_nothing() {
3042 let configured = vec![(
3043 "owner/one".to_string(),
3044 std::path::PathBuf::from("/srv/one"),
3045 )];
3046 let active: std::collections::HashSet<String> =
3047 ["owner/one".to_string()].into_iter().collect();
3048
3049 assert_eq!(
3050 diff_configured_projects(&configured, &active),
3051 (vec![], vec![])
3052 );
3053 }
3054
3055 #[test]
3056 fn restart_only_keys_are_the_settings_read_once_at_startup() {
3057 assert_eq!(RESTART_ONLY_KEYS, ["channels", "webhook"]);
3060 }
3061
3062 #[test]
3063 fn a_changed_restart_only_key_is_reported_and_an_unchanged_one_is_not() {
3064 let before = vec![Some("bot_token: a\n".to_string()), None];
3065
3066 assert!(
3067 log_restart_only_changes(&before, &before.clone()).is_empty(),
3068 "a reload that did not touch these keys must stay quiet"
3069 );
3070
3071 let after = vec![
3073 Some("bot_token: b\n".to_string()),
3074 Some("port: 8080\n".to_string()),
3075 ];
3076 assert_eq!(
3077 log_restart_only_changes(&before, &after),
3078 ["channels", "webhook"]
3079 );
3080
3081 let token_only = vec![Some("bot_token: b\n".to_string()), None];
3083 assert_eq!(log_restart_only_changes(&before, &token_only), ["channels"]);
3084 }
3085
3086 #[test]
3087 fn resize_dispatch_pool_is_a_noop_at_the_current_limit() {
3088 let semaphore = Arc::new(Semaphore::new(4));
3089 assert_eq!(resize_dispatch_pool(&semaphore, 4, 4), 4);
3090 assert_eq!(semaphore.available_permits(), 4);
3091 }
3092
3093 #[test]
3094 fn engine_config_defaults() {
3095 let config = EngineConfig::default();
3096 assert_eq!(config.tick_interval, std::time::Duration::from_secs(10));
3097 assert_eq!(config.sync_interval, std::time::Duration::from_secs(45));
3098 assert_eq!(config.max_parallel, 4);
3099 assert_eq!(config.stuck_timeout, 1800);
3100 assert_eq!(config.no_session_stuck_timeout, 600);
3101 assert!(
3102 config.no_session_stuck_timeout < config.stuck_timeout,
3103 "no_session_stuck_timeout must be shorter than stuck_timeout"
3104 );
3105 assert_eq!(
3106 config.graceful_shutdown_timeout,
3107 std::time::Duration::from_secs(600)
3108 );
3109 assert_eq!(config.upgrade_check_interval, 3600);
3110 }
3111
3112 #[test]
3113 fn engine_config_from_config_uses_defaults_when_no_config() {
3114 let config = EngineConfig::from_config();
3115 assert_eq!(config.tick_interval, std::time::Duration::from_secs(10));
3116 assert_eq!(config.sync_interval, std::time::Duration::from_secs(45));
3117 assert!(config.max_parallel >= 1 && config.max_parallel <= 64);
3119 assert_eq!(config.stuck_timeout, 1800);
3120 assert_eq!(config.no_session_stuck_timeout, 600);
3121 assert_eq!(
3122 config.graceful_shutdown_timeout,
3123 std::time::Duration::from_secs(600)
3124 );
3125 assert_eq!(config.upgrade_check_interval, 3600);
3126 }
3127
3128 #[test]
3129 fn engine_config_no_session_timeout_is_under_15_minutes() {
3130 let config = EngineConfig::default();
3131 assert!(
3132 config.no_session_stuck_timeout <= 900,
3133 "no_session_stuck_timeout ({}) exceeds 15 minutes",
3134 config.no_session_stuck_timeout
3135 );
3136 }
3137
3138 #[test]
3139 fn upgrade_check_runs_immediately_when_never_checked() {
3140 assert!(should_run_upgrade_check(None, 3600));
3141 }
3142
3143 #[test]
3144 fn upgrade_check_waits_for_interval_after_first_run() {
3145 assert!(!should_run_upgrade_check(
3146 Some(std::time::Instant::now()),
3147 3600
3148 ));
3149 }
3150
3151 #[test]
3152 fn upgrade_check_can_be_disabled() {
3153 assert!(!should_run_upgrade_check(None, 0));
3154 }
3155
3156 #[test]
3157 fn configured_agents_parses_yaml_block_array() {
3158 let yaml_str = "- claude\n- codex\n- opencode\n- olm\n";
3160 let parsed: Vec<String> = serde_norway::from_str(yaml_str).unwrap();
3161 assert_eq!(parsed, vec!["claude", "codex", "opencode", "olm"]);
3162 }
3163
3164 #[test]
3165 fn configured_agents_parses_json_inline_array() {
3166 let json_str = r#"["claude","codex","olm"]"#;
3167 let parsed: Vec<String> = serde_json::from_str(json_str).unwrap();
3168 assert_eq!(parsed, vec!["claude", "codex", "olm"]);
3169 }
3170
3171 #[test]
3172 fn configured_agents_fallback_returns_default_agents() {
3173 let defaults = configured_agents();
3175 assert!(defaults.contains(&"claude".to_string()));
3176 assert!(defaults.contains(&"codex".to_string()));
3177 assert!(defaults.contains(&"opencode".to_string()));
3178 }
3179
3180 #[tokio::test]
3189 #[ignore]
3190 async fn integration_channel_to_tmux_to_capture() {
3191 use crate::channels::capture::CaptureService;
3192 use crate::channels::transport::Transport;
3193 use crate::tmux::SessionGuard;
3194 use std::sync::Arc;
3195
3196 let transport = Arc::new(Transport::new());
3198 let capture = Arc::new(CaptureService::new(transport.clone()));
3199
3200 let task_id = "testtask1".to_string();
3201 let session_name = format!("orch-test-{}", task_id);
3202
3203 let _ = tokio::process::Command::new("tmux")
3205 .args(["new-session", "-d", "-s", &session_name])
3206 .output()
3207 .await
3208 .expect("failed to start tmux session");
3209
3210 let _guard = SessionGuard(session_name.clone());
3212
3213 capture
3215 .register_session("owner/repo", &task_id, &session_name)
3216 .await;
3217 transport
3218 .bind(
3219 "owner/repo",
3220 &task_id,
3221 &session_name,
3222 "telegram",
3223 "12345",
3224 None,
3225 )
3226 .await;
3227
3228 let capture_clone = capture.clone();
3230 let capture_handle = tokio::spawn(async move { capture_clone.run().await });
3231
3232 let mut rx = transport
3234 .subscribe("owner/repo", &task_id)
3235 .await
3236 .expect("no subscription");
3237
3238 let send = tokio::process::Command::new("tmux")
3240 .args([
3241 "send-keys",
3242 "-t",
3243 &session_name,
3244 "echo from-channel",
3245 "Enter",
3246 ])
3247 .output()
3248 .await
3249 .expect("failed to send keys");
3250 assert!(send.status.success());
3251
3252 let mut got = false;
3254 for _ in 0..20 {
3255 if let Ok(Ok(c)) =
3256 tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await
3257 {
3258 if c.content.contains("from-channel") {
3259 got = true;
3260 break;
3261 }
3262 }
3263 }
3264
3265 capture.unregister_session("owner/repo", &task_id).await;
3267
3268 let _ = capture_handle.await;
3270
3271 assert!(got, "did not observe tmux output via capture/transport");
3272 }
3273
3274 #[test]
3277 fn read_project_channel_config_returns_defaults_when_no_file() {
3278 let dir = tempfile::tempdir().expect("tempdir");
3279 let cfg = read_project_channel_config(dir.path());
3280 assert!(cfg.telegram_topic_id.is_none());
3281 assert!(cfg.discord_channel_id.is_none());
3282 }
3283
3284 #[test]
3285 fn read_project_channel_config_parses_telegram_and_discord() {
3286 let dir = tempfile::tempdir().expect("tempdir");
3287 let orch_yml = dir.path().join(".orch.yml");
3288 std::fs::write(
3289 &orch_yml,
3290 "channels:\n telegram:\n topic_id: \"42\"\n discord:\n channel_id: \"9999\"\n",
3291 )
3292 .expect("write .orch.yml");
3293 let cfg = read_project_channel_config(dir.path());
3294 assert_eq!(cfg.telegram_topic_id.as_deref(), Some("42"));
3295 assert_eq!(cfg.discord_channel_id.as_deref(), Some("9999"));
3296 }
3297
3298 #[test]
3299 fn read_project_channel_config_handles_partial_channels() {
3300 let dir = tempfile::tempdir().expect("tempdir");
3301 let orch_yml = dir.path().join(".orch.yml");
3302 std::fs::write(&orch_yml, "channels:\n telegram:\n topic_id: \"7\"\n")
3303 .expect("write .orch.yml");
3304 let cfg = read_project_channel_config(dir.path());
3305 assert_eq!(cfg.telegram_topic_id.as_deref(), Some("7"));
3306 assert!(cfg.discord_channel_id.is_none());
3307 }
3308
3309 #[test]
3310 fn read_project_channel_config_handles_malformed_yaml() {
3311 let dir = tempfile::tempdir().expect("tempdir");
3312 let orch_yml = dir.path().join(".orch.yml");
3313 std::fs::write(&orch_yml, "channels: [\nbad yaml").expect("write .orch.yml");
3314 let cfg = read_project_channel_config(dir.path());
3315 assert!(cfg.telegram_topic_id.is_none());
3317 assert!(cfg.discord_channel_id.is_none());
3318 }
3319
3320 #[test]
3321 #[cfg(unix)]
3322 fn read_project_channel_config_handles_unreadable_file() {
3323 let dir = tempfile::tempdir().expect("tempdir");
3324 std::fs::create_dir(dir.path().join(".orch.yml")).expect("create .orch.yml dir");
3326 let cfg = read_project_channel_config(dir.path());
3327 assert!(cfg.telegram_topic_id.is_none());
3329 assert!(cfg.discord_channel_id.is_none());
3330 }
3331}