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 if crate::cluster::enabled() {
671 crate::cluster::start(&store, &crate::home::orch_home()?.join("raft.db")).await?;
672 crate::cluster::local::serve((*store).clone()).await?;
673 }
674
675 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
678
679 for (repo, project_dir) in projects {
680 tracing::info!(repo = %repo, "initializing project engine");
681 config::watch(&project_dir.join(".orch.yml"));
682
683 engines.push(connect_project_engine(repo, project_dir, store.clone(), None).await?);
684 }
685
686 Ok(engines)
687}
688
689async fn reconcile_startup_worktrees(project_engines: &[ProjectEngine]) -> anyhow::Result<()> {
690 for engine in project_engines {
691 let repo_root =
692 crate::engine::runner::worktree::resolve_main_repo(&engine.project_dir).await;
693 let repo_root_path = std::path::PathBuf::from(&repo_root);
694 let default_branch =
695 crate::engine::runner::worktree::detect_default_branch(&repo_root_path).await;
696 let worktrees = list_project_worktrees(&repo_root_path).await?;
697
698 if let Err(e) = Command::new("git")
701 .args(["-C", &repo_root.to_string_lossy(), "fetch", "origin"])
702 .output_with_context()
703 .await
704 {
705 tracing::warn!(repo = %engine.repo, err = %e, "startup git fetch origin failed, rebases may use stale refs");
706 }
707
708 let mut orphans_removed: u32 = 0;
709 let mut invalid_removed: u32 = 0;
710 let mut cleaned: u32 = 0;
711 let mut valid_kept: u32 = 0;
712
713 for worktree_dir in worktrees {
714 let Some(name) = worktree_dir.file_name().and_then(|n| n.to_str()) else {
715 continue;
716 };
717
718 if !validate_worktree_gitdir(&worktree_dir).await {
721 tracing::warn!(repo = %engine.repo, worktree = %worktree_dir.display(), "worktree has invalid or missing gitdir, removing");
722 remove_worktree_and_branch(name, &worktree_dir, Some(name), &repo_root_path, false)
723 .await;
724 invalid_removed += 1;
725 continue;
726 }
727
728 let Some(task_id) = task_id_from_worktree_name(name) else {
729 tracing::info!(repo = %engine.repo, worktree = %worktree_dir.display(), "orphan worktree without task id, removing");
730 remove_worktree_and_branch(name, &worktree_dir, Some(name), &repo_root_path, false)
731 .await;
732 orphans_removed += 1;
733 continue;
734 };
735
736 let Some(store_id) = engine.store.resolve_task_id(&engine.repo, &task_id).await? else {
737 tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), "worktree has no matching task, removing");
738 remove_worktree_and_branch(
739 &task_id,
740 &worktree_dir,
741 Some(name),
742 &repo_root_path,
743 false,
744 )
745 .await;
746 orphans_removed += 1;
747 continue;
748 };
749
750 let task = engine.store.get(store_id).await?;
751 let branch_name = if task.branch.is_empty() {
752 name
753 } else {
754 task.branch.as_str()
755 };
756
757 match task.status {
758 TaskStatus::New
759 | TaskStatus::Routed
760 | TaskStatus::InProgress
761 | TaskStatus::NeedsReview
762 | TaskStatus::InReview => {
763 tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), "rebasing startup worktree");
764 if let Err(e) =
765 rebase_worktree_on_origin_main(&worktree_dir, &default_branch).await
766 {
767 tracing::warn!(repo = %engine.repo, task_id = %task_id, err = %e, "startup rebase failed, resetting task");
772 abort_worktree_rebase(&worktree_dir).await;
773 let keep_remote = task.pr_number.is_some();
774 remove_worktree_and_branch(
775 &task_id,
776 &worktree_dir,
777 Some(branch_name),
778 &repo_root_path,
779 keep_remote,
780 )
781 .await;
782 match engine.store.resolve_task_id(&engine.repo, &task_id).await {
783 Ok(Some(reset_id)) => {
784 if let Err(e) = engine.store.reset_to_new(reset_id).await {
785 tracing::warn!(task_id, err = %e, "failed to reset task to new after rebase failure");
786 }
787 }
788 Ok(None) => {
789 tracing::warn!(
790 task_id,
791 "task not found in store during reconciliation"
792 );
793 }
794 Err(e) => {
795 tracing::warn!(task_id, err = %e, "failed to resolve task ID during reconciliation");
796 }
797 }
798 if let Err(e) = engine
799 .task_manager
800 .update_task_status(
801 &ExternalId(task_id.clone()),
802 crate::backends::Status::New,
803 )
804 .await
805 {
806 tracing::warn!(task_id, err = %e, "failed to update backend status during reconciliation");
807 }
808 cleaned += 1;
809 } else {
810 valid_kept += 1;
811 }
812 }
813 TaskStatus::Blocked | TaskStatus::Waiting => {
816 valid_kept += 1;
817 }
818 TaskStatus::Done => {
819 tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), status = ?task.status, "terminal task worktree, removing");
820 remove_worktree_and_branch(
821 &task_id,
822 &worktree_dir,
823 Some(branch_name),
824 &repo_root_path,
825 false,
826 )
827 .await;
828 cleaned += 1;
829 }
830 }
831 }
832
833 tracing::info!(
834 repo = %engine.repo,
835 orphans_removed,
836 invalid_removed,
837 cleaned,
838 valid_kept,
839 "worktree reconciliation: {orphans_removed} orphans removed, {invalid_removed} invalid removed, {cleaned} cleaned, {valid_kept} valid kept"
840 );
841
842 let prune = Command::new("git")
845 .args(["-C", &repo_root.to_string_lossy(), "worktree", "prune"])
846 .output_with_context()
847 .await;
848 match prune {
849 Ok(output) if output.status.success() => {
850 tracing::debug!(repo = %engine.repo, "startup worktree prune completed");
851 }
852 Ok(output) => {
853 let stderr = String::from_utf8_lossy(&output.stderr);
854 tracing::warn!(repo = %engine.repo, err = %stderr, "startup worktree prune failed");
855 }
856 Err(e) => {
857 tracing::warn!(repo = %engine.repo, err = %e, "startup worktree prune failed");
858 }
859 }
860 }
861
862 Ok(())
863}
864
865async fn reconcile_startup_estimates(project_engines: &[ProjectEngine]) {
873 use crate::backends::ExternalId;
874
875 for engine in project_engines {
876 let tasks = match engine.store.list_external_tasks_with_estimates().await {
877 Ok(t) => t,
878 Err(e) => {
879 tracing::warn!(repo = %engine.repo, err = %e, "startup estimate reconciliation: store query failed");
880 continue;
881 }
882 };
883
884 if tasks.is_empty() {
885 continue;
886 }
887
888 let mut synced: u32 = 0;
889 let mut failed: u32 = 0;
890 for (external_id, estimate) in &tasks {
891 match engine
892 .backend
893 .sync_estimate_to_project(&ExternalId(external_id.clone()), *estimate)
894 .await
895 {
896 Ok(()) => synced += 1,
897 Err(e) => {
898 tracing::debug!(
899 repo = %engine.repo,
900 task_id = %external_id,
901 estimate,
902 err = %e,
903 "startup estimate reconciliation: sync failed"
904 );
905 failed += 1;
906 }
907 }
908 }
909
910 if synced > 0 || failed > 0 {
911 tracing::info!(
912 repo = %engine.repo,
913 synced,
914 failed,
915 "startup estimate reconciliation complete"
916 );
917 }
918 }
919}
920
921fn read_project_channel_config(project_dir: &std::path::Path) -> ProjectChannelConfig {
923 let config_path = project_dir.join(".orch.yml");
924 if !config_path.exists() {
925 return ProjectChannelConfig::default();
926 }
927 let content = match std::fs::read_to_string(&config_path) {
928 Ok(c) => c,
929 Err(e) => {
930 tracing::warn!(
931 path = %config_path.display(),
932 error = %e,
933 "failed to read .orch.yml; channel config will use defaults"
934 );
935 return ProjectChannelConfig::default();
936 }
937 };
938 let val: serde_norway::Value = match serde_norway::from_str(&content) {
939 Ok(v) => v,
940 Err(e) => {
941 tracing::warn!(
942 path = %config_path.display(),
943 error = %e,
944 "failed to parse .orch.yml; channel config will use defaults"
945 );
946 return ProjectChannelConfig::default();
947 }
948 };
949 let channels = match val.get("channels") {
950 Some(c) => c,
951 None => return ProjectChannelConfig::default(),
952 };
953 let get_str = |section: &str, key: &str| -> Option<String> {
954 channels
955 .get(section)
956 .and_then(|s| s.get(key))
957 .and_then(|v| v.as_str())
958 .map(String::from)
959 };
960 ProjectChannelConfig {
961 telegram_topic_id: get_str("telegram", "topic_id"),
962 telegram_bot_token: get_str("telegram", "bot_token"),
963 telegram_chat_id: get_str("telegram", "chat_id"),
964 discord_channel_id: get_str("discord", "channel_id"),
965 discord_bot_token: get_str("discord", "bot_token"),
966 discord_guild_id: get_str("discord", "guild_id"),
967 }
968}
969
970async fn fetch_latest_release_version() -> Option<String> {
975 let output = Command::new("gh")
976 .args([
977 "api",
978 "repos/gabrielkoerich/orch/releases/latest",
979 "--jq",
980 ".tag_name",
981 ])
982 .output()
983 .await
984 .ok()?;
985 if !output.status.success() {
986 return None;
987 }
988 let tag = String::from_utf8(output.stdout).ok()?;
989 let version = tag.trim().trim_start_matches('v').to_string();
990 if version.is_empty() {
991 None
992 } else {
993 Some(version)
994 }
995}
996
997async fn check_and_notify_upgrade(
1006 store: &Arc<TaskStore>,
1007 channels: &Arc<ChannelRegistry>,
1008 current_version: &str,
1009 check_interval: std::time::Duration,
1010) {
1011 let last_check_key = "upgrade:last_check_at";
1012 let last_notified_key = "upgrade:last_notified_version";
1013
1014 let now = chrono::Utc::now().timestamp();
1016 if let Ok(Some(last_check_str)) = store.kv_get(last_check_key).await {
1017 if let Ok(last_check) = last_check_str.parse::<i64>() {
1018 if now - last_check < check_interval.as_secs() as i64 {
1019 return;
1020 }
1021 }
1022 }
1023
1024 let _ = store.kv_set(last_check_key, &now.to_string()).await;
1026
1027 let latest = match fetch_latest_release_version().await {
1028 Some(v) => v,
1029 None => {
1030 tracing::debug!("upgrade check: failed to fetch latest release version");
1031 return;
1032 }
1033 };
1034
1035 if latest == current_version {
1036 tracing::debug!(current = %current_version, "orch is up to date");
1037 return;
1038 }
1039
1040 tracing::warn!(
1041 current_version = %current_version,
1042 latest_version = %latest,
1043 "orch upgrade available"
1044 );
1045
1046 if let Ok(Some(last_notified)) = store.kv_get(last_notified_key).await {
1051 if last_notified == latest {
1052 tracing::debug!(latest = %latest, "upgrade already notified");
1053 return;
1054 }
1055 }
1056
1057 let msg_telegram = format!(
1058 "⚠️ <b>Orch Upgrade Available</b>\n\n\
1059 Service: <code>{current_version}</code>\n\
1060 Latest: <code>{latest}</code>\n\n\
1061 Run: <code>brew update && brew upgrade orch && brew services restart orch</code>"
1062 );
1063
1064 let msg_discord = format!(
1065 "⚠️ **Orch Upgrade Available**\n\n\
1066 Service: `{current_version}`\n\
1067 Latest: `{latest}`\n\n\
1068 Run: `brew update && brew upgrade orch && brew services restart orch`"
1069 );
1070
1071 let msg_slack = format!(
1072 "⚠️ *Orch Upgrade Available*\n\n\
1073 Service: `{current_version}`\n\
1074 Latest: `{latest}`\n\n\
1075 Run: `brew update && brew upgrade orch && brew services restart orch`"
1076 );
1077
1078 for channel in channels.iter() {
1079 let (body, metadata, topic_id) = match channel.name() {
1080 "telegram" => (
1081 msg_telegram.clone(),
1082 serde_json::json!({"preformatted_html": true}),
1083 None,
1084 ),
1085 "discord" => (msg_discord.clone(), serde_json::json!({}), None),
1086 "slack" => (msg_slack.clone(), serde_json::json!({}), None),
1087 _ => continue,
1088 };
1089
1090 let msg = OutgoingMessage {
1091 thread_id: "upgrade".to_string(),
1092 body,
1093 reply_to: None,
1094 metadata,
1095 topic_id,
1096 };
1097
1098 if let Err(e) = channel.send(&msg).await {
1099 tracing::warn!(
1100 channel = channel.name(),
1101 ?e,
1102 "failed to send upgrade notification"
1103 );
1104 }
1105 }
1106
1107 let _ = store.kv_set(last_notified_key, &latest).await;
1109}
1110
1111async fn send_startup_escalation_notification(elapsed_secs: u64) {
1119 let duration = crate::channels::notification::format_duration(elapsed_secs as f64);
1120
1121 let msg_telegram = format!(
1122 "🚨 <b>Orch startup blocked — GitHub unreachable</b>\n\n\
1123 The engine has been retrying backend connections for {}\n\
1124 without success. All task processing, routing, and scheduled\n\
1125 jobs are paused. Check service logs with `orch log -f`.",
1126 duration
1127 );
1128
1129 let msg_discord = format!(
1130 "🚨 **Orch startup blocked — GitHub unreachable**\n\n\
1131 The engine has been retrying backend connections for {}\n\
1132 without success. All task processing, routing, and scheduled\n\
1133 jobs are paused. Check service logs with `orch log -f`.",
1134 duration
1135 );
1136
1137 let msg_slack = format!(
1138 "🚨 *Orch startup blocked — GitHub unreachable*\n\n\
1139 The engine has been retrying backend connections for {}\n\
1140 without success. All task processing, routing, and scheduled\n\
1141 jobs are paused. Check service logs with `orch log -f`.",
1142 duration
1143 );
1144
1145 let tg_token = crate::config::get("channels.telegram.bot_token");
1147 let tg_chat_id = crate::config::get("channels.telegram.chat_id");
1148 if let (Ok(token), Ok(chat_id)) = (&tg_token, &tg_chat_id) {
1149 if !token.is_empty() && !chat_id.is_empty() {
1150 if let Ok(channel) = TelegramChannel::new(token.clone(), Some(chat_id.clone())) {
1151 let msg = OutgoingMessage {
1152 thread_id: "startup_escalation".to_string(),
1153 body: msg_telegram.clone(),
1154 reply_to: None,
1155 metadata: serde_json::json!({ "preformatted_html": true }),
1156 topic_id: crate::config::get("channels.telegram.general_topic_id").ok(),
1157 };
1158 if let Err(e) = channel.send(&msg).await {
1159 tracing::warn!(?e, "failed to send startup escalation via telegram");
1160 } else {
1161 tracing::info!("startup escalation notification sent to telegram");
1162 }
1163 }
1164 }
1165 }
1166
1167 if let Ok(token) = crate::config::get("channels.discord.bot_token") {
1169 let channel_id = crate::config::get("channels.discord.channel_id")
1170 .or_else(|_| crate::config::get("channels.discord.general_channel_id"));
1171 if !token.is_empty() {
1172 if let Some(cid) = channel_id.ok().filter(|s| !s.is_empty()) {
1173 let shard_id = crate::config::get("channels.discord.shard_id")
1174 .ok()
1175 .and_then(|v| v.parse::<u64>().ok())
1176 .unwrap_or(0);
1177 let shard_count = crate::config::get("channels.discord.shard_count")
1178 .ok()
1179 .and_then(|v| v.parse::<u64>().ok())
1180 .unwrap_or(1);
1181 if let Ok(gateway) = DiscordGateway::new(token, Some(cid), shard_id, shard_count) {
1182 let msg = OutgoingMessage {
1183 thread_id: "startup_escalation".to_string(),
1184 body: msg_discord.clone(),
1185 reply_to: None,
1186 metadata: serde_json::Value::Null,
1187 topic_id: None,
1188 };
1189 if let Err(e) = gateway.send(&msg).await {
1190 tracing::warn!(?e, "failed to send startup escalation via discord");
1191 } else {
1192 tracing::info!("startup escalation notification sent to discord");
1193 }
1194 }
1195 }
1196 }
1197 }
1198
1199 if let Ok(token) = crate::config::get("channels.slack.bot_token") {
1201 let channel_id = crate::config::get("channels.slack.channel_id");
1202 if !token.is_empty() {
1203 if let Ok(cid) = channel_id {
1204 if let Ok(channel) = SlackChannel::new(token, Some(cid)) {
1205 let msg = OutgoingMessage {
1206 thread_id: "startup_escalation".to_string(),
1207 body: msg_slack.clone(),
1208 reply_to: None,
1209 metadata: serde_json::Value::Null,
1210 topic_id: None,
1211 };
1212 if let Err(e) = channel.send(&msg).await {
1213 tracing::warn!(?e, "failed to send startup escalation via slack");
1214 } else {
1215 tracing::info!("startup escalation notification sent to slack");
1216 }
1217 }
1218 }
1219 }
1220 }
1221}
1222
1223pub async fn serve() -> anyhow::Result<()> {
1227 let killed = crate::home::kill_other_orch_serve_processes(std::time::Duration::from_secs(30));
1228 if killed > 0 {
1229 tracing::info!(count = killed, "terminated stale orch serve processes");
1230 }
1231
1232 tracing::info!("orch engine starting");
1233
1234 if let Err(e) = crate::home::init_dirs().await {
1238 tracing::warn!("failed to initialize orch directories: {e}");
1239 }
1240
1241 if let Err(e) = crate::config::warm_cache().await {
1244 tracing::warn!("failed to warm config cache: {e}");
1245 }
1246
1247 let mut config = EngineConfig::from_config();
1248
1249 tracing::info!("internal database ready");
1250
1251 let startup_escalation_secs = config.startup_failure_escalation_secs;
1260 let mut project_engines = {
1261 let mut delay_secs = 5u64;
1262 let mut attempt = 0u32;
1263 let mut escalated = false;
1264 let start = std::time::Instant::now();
1265 loop {
1266 attempt += 1;
1267 match init_project_engines().await {
1268 Ok(engines) => break engines,
1269 Err(e) => {
1270 let elapsed = start.elapsed().as_secs();
1271
1272 if startup_escalation_secs > 0
1278 && !escalated
1279 && elapsed >= startup_escalation_secs
1280 {
1281 escalated = true;
1282 tracing::error!(
1283 delay_secs,
1284 attempt,
1285 elapsed_secs = elapsed,
1286 threshold_secs = startup_escalation_secs,
1287 "project backends unavailable for {} — escalating startup failure: {e}",
1288 crate::channels::notification::format_duration(elapsed as f64)
1289 );
1290 send_startup_escalation_notification(elapsed).await;
1294 } else {
1295 tracing::warn!(
1296 delay_secs,
1297 attempt,
1298 "project backends unavailable, retrying: {e}"
1299 );
1300 }
1301
1302 tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
1303 delay_secs = (delay_secs * 2).min(120);
1304 }
1305 }
1306 }
1307 };
1308
1309 tracing::info!(
1310 project_count = project_engines.len(),
1311 "initialized project engines"
1312 );
1313
1314 let mut active_repos: std::collections::HashSet<String> =
1318 project_engines.iter().map(|e| e.repo.clone()).collect();
1319
1320 let event_bus = events::EventBus::new(256);
1322 if let Err(e) = event_bus.start_ws_server().await {
1323 tracing::warn!(
1324 ?e,
1325 "failed to start event websocket server, continuing without it"
1326 );
1327 }
1328
1329 {
1333 let current = env!("ORCH_VERSION").to_string();
1334 tokio::spawn(async move {
1335 if let Some(latest) = fetch_latest_release_version().await {
1336 if latest != current {
1337 tracing::warn!(
1338 current_version = %current,
1339 latest_version = %latest,
1340 "orch service is behind the latest release — run: brew update && brew upgrade orch && brew services restart orch"
1341 );
1342 }
1343 }
1344 });
1345 }
1346
1347 for engine in &mut project_engines {
1349 engine.task_manager = Arc::new(TaskManager::with_events(
1350 engine.backend.clone(),
1351 engine.store.clone(),
1352 engine.repo.clone(),
1353 event_bus.sender(),
1354 ));
1355 }
1356
1357 let leads = project_engines.first().is_none_or(|e| e.store.is_leader());
1359 if leads {
1360 if let Err(e) = reconcile_startup_worktrees(&project_engines).await {
1361 tracing::warn!(err = %e, "startup worktree reconciliation failed");
1362 }
1363 }
1364
1365 if leads {
1366 let engines_snapshot: Vec<ProjectEngine> = project_engines.clone();
1370 tokio::spawn(async move {
1371 reconcile_startup_estimates(&engines_snapshot).await;
1372 });
1373 }
1374
1375 let global_channel_config = GlobalChannelConfig {
1377 telegram_general_topic_id: crate::config::get("channels.telegram.general_topic_id").ok(),
1378 discord_general_channel_id: crate::config::get("channels.discord.general_channel_id").ok(),
1379 control_telegram_topic_id: crate::config::get("control.channels.telegram.topic_id").ok(),
1380 control_discord_channel_id: crate::config::get("control.channels.discord.channel_id").ok(),
1381 };
1382 let project_channel_configs: Vec<(String, ProjectChannelConfig)> = project_engines
1383 .iter()
1384 .map(|e| {
1385 let cfg = read_project_channel_config(&e.project_dir);
1386 (e.repo.clone(), cfg)
1387 })
1388 .collect();
1389 let channel_router = Arc::new(ChannelRouter::new(
1390 &global_channel_config,
1391 &project_channel_configs,
1392 ));
1393 tracing::info!(
1394 projects = ?channel_router.projects(),
1395 "channel router initialized"
1396 );
1397
1398 let tmux = Arc::new(TmuxManager::new());
1400
1401 let transport = Arc::new(Transport::new());
1403
1404 let capture = Arc::new(CaptureService::new(transport.clone()));
1406 let capture_for_tick = capture.clone();
1407 tokio::spawn(async move {
1408 capture.start().await;
1409 });
1410
1411 let mut channel_registry = ChannelRegistry::new();
1413
1414 if let Ok(token) = crate::config::get("channels.telegram.bot_token") {
1416 if !token.is_empty() {
1417 let chat_id = crate::config::get("channels.telegram.chat_id").ok();
1418 match TelegramChannel::new(token, chat_id) {
1419 Ok(telegram) => {
1420 if let Err(e) = telegram.health_check().await {
1421 tracing::warn!(?e, "telegram channel health check failed, skipping");
1422 } else {
1423 channel_registry.register(Box::new(telegram));
1424 tracing::info!("telegram channel registered");
1425 }
1426 }
1427 Err(e) => {
1428 tracing::warn!(?e, "failed to create telegram channel, skipping");
1429 }
1430 }
1431 }
1432 }
1433
1434 if let Ok(token) = crate::config::get("channels.discord.bot_token") {
1436 if !token.is_empty() {
1437 let channel_id = crate::config::get("channels.discord.channel_id").ok();
1438 let shard_id = crate::config::get("channels.discord.shard_id")
1439 .ok()
1440 .and_then(|v| v.parse::<u64>().ok())
1441 .unwrap_or(0);
1442 let shard_count = crate::config::get("channels.discord.shard_count")
1443 .ok()
1444 .and_then(|v| v.parse::<u64>().ok())
1445 .unwrap_or(1);
1446 match DiscordGateway::new(token, channel_id, shard_id, shard_count) {
1447 Ok(discord) => {
1448 if let Err(e) = discord.health_check().await {
1449 tracing::warn!(?e, "discord gateway health check failed, skipping");
1450 } else {
1451 channel_registry.register(Box::new(discord));
1452 tracing::info!(shard_id, shard_count, "discord gateway registered");
1453 }
1454 }
1455 Err(e) => {
1456 tracing::warn!(?e, "failed to create discord gateway, skipping");
1457 }
1458 }
1459 }
1460 }
1461
1462 if let Ok(token) = crate::config::get("channels.slack.bot_token") {
1464 if !token.is_empty() {
1465 let channel_id = crate::config::get("channels.slack.channel_id").ok();
1466 match SlackChannel::new(token, channel_id) {
1467 Ok(slack) => {
1468 if let Err(e) = slack.health_check().await {
1469 tracing::warn!(?e, "slack channel health check failed, skipping");
1470 } else {
1471 channel_registry.register(Box::new(slack));
1472 tracing::info!("slack channel registered");
1473 }
1474 }
1475 Err(e) => {
1476 tracing::warn!(?e, "failed to create slack channel, skipping");
1477 }
1478 }
1479 }
1480 }
1481
1482 let tmux_channel = TmuxChannel::with_transport(transport.clone());
1484 channel_registry.register(Box::new(tmux_channel));
1485 tracing::info!("tmux channel registered");
1486
1487 let mut channel_receivers: Vec<tokio::sync::mpsc::Receiver<IncomingMessage>> = Vec::new();
1489 for channel in channel_registry.iter() {
1490 match channel.start().await {
1491 Ok(rx) => {
1492 tracing::info!(channel = channel.name(), "channel started");
1493 channel_receivers.push(rx);
1494 }
1495 Err(e) => {
1496 tracing::warn!(channel = channel.name(), ?e, "failed to start channel");
1497 }
1498 }
1499 }
1500
1501 let channel_registry = Arc::new(channel_registry);
1503
1504 let transport_for_messages = transport.clone();
1506 let tmux_for_messages = tmux.clone();
1507 let capture_for_messages = capture_for_tick.clone();
1508 let channels_for_messages = channel_registry.clone();
1509 let engine_refs: Vec<EngineRef> = project_engines
1511 .iter()
1512 .map(|e| {
1513 (
1514 e.repo.clone(),
1515 e.backend.clone(),
1516 e.task_manager.clone(),
1517 Some(e.store.clone()),
1518 )
1519 })
1520 .collect();
1521 let pending_picks: channel_handler::PendingPicks =
1523 std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
1524 let router_for_messages = channel_router.clone();
1525 for mut rx in channel_receivers {
1526 let transport = transport_for_messages.clone();
1527 let tmux = tmux_for_messages.clone();
1528 let capture = capture_for_messages.clone();
1529 let channels = channels_for_messages.clone();
1530 let engine_refs = engine_refs.clone();
1531 let ch_router = router_for_messages.clone();
1532 let picks = pending_picks.clone();
1533 tokio::spawn(async move {
1534 while let Some(msg) = rx.recv().await {
1535 tracing::debug!(channel = %msg.channel, thread = %msg.thread_id, "received message from channel");
1536 channel_handler::handle_channel_message(
1537 msg,
1538 &transport,
1539 &tmux,
1540 &capture,
1541 &channels,
1542 &engine_refs,
1543 &ch_router,
1544 &picks,
1545 )
1546 .await;
1547 }
1548 });
1549 }
1550
1551 {
1555 let restart_channels = channel_registry.clone();
1556 let restart_store = project_engines.first().map(|e| e.store.clone());
1557 if let Some(store) = restart_store {
1558 tokio::spawn(async move {
1559 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1560 match store.kv_get("control:restart_pending").await {
1561 Ok(Some(conv_key)) => {
1562 if let Some((ch_name, thread_id, topic_id)) =
1563 crate::channels::transport::parse_conversation_key(&conv_key)
1564 {
1565 for ch in restart_channels.iter() {
1566 if ch.name() == ch_name {
1567 let msg = OutgoingMessage {
1568 thread_id: thread_id.to_string(),
1569 body: "orch restarted successfully.".to_string(),
1570 reply_to: None,
1571 metadata: serde_json::json!({}),
1572 topic_id: topic_id.map(|t| t.to_string()),
1573 };
1574 if let Err(e) = ch.send(&msg).await {
1575 tracing::warn!(
1576 channel = ch_name,
1577 error = %e,
1578 "failed to send restart confirmation"
1579 );
1580 }
1581 break;
1582 }
1583 }
1584 }
1585 if let Err(e) = store.kv_delete("control:restart_pending").await {
1586 tracing::warn!(error = %e, "failed to clear control:restart_pending");
1587 }
1588 }
1589 Ok(None) => {} Err(e) => {
1591 tracing::warn!(error = %e, "failed to read control:restart_pending");
1592 }
1593 }
1594 });
1595 }
1596 }
1597
1598 {
1609 let mut notification_rx = transport.subscribe_notifications();
1610 let channels = channel_registry.clone();
1611 let notif_router = channel_router.clone();
1612 let notif_store: Option<Arc<TaskStore>> = project_engines.first().map(|e| e.store.clone());
1614 tokio::spawn(async move {
1615 loop {
1616 match notification_rx.recv().await {
1617 Ok(notification) => {
1618 let level = NotificationLevel::from_config();
1619 if !level.should_notify(¬ification.status) {
1620 tracing::debug!(
1621 task_id = %notification.task_id,
1622 status = %notification.status,
1623 "notification suppressed by level={:?}",
1624 level
1625 );
1626 continue;
1627 }
1628
1629 tracing::info!(
1630 task_id = %notification.task_id,
1631 status = %notification.status,
1632 repo = ?notification.repo,
1633 "routing notification to channels"
1634 );
1635
1636 let mut routed = false;
1638
1639 if let Some(ref target) = notification.notify_target {
1642 let telegram = channels.iter().find(|c| c.name() == "telegram");
1643 if let Some(channel) = telegram {
1644 let body = notification.format_telegram();
1645 let metadata = serde_json::json!({
1646 "preformatted_html": true,
1647 "chat_id_override": target
1648 });
1649 let msg = OutgoingMessage {
1650 thread_id: notification.task_id.clone(),
1651 body,
1652 reply_to: None,
1653 metadata,
1654 topic_id: None,
1655 };
1656 if let Err(e) = channel.send(&msg).await {
1657 tracing::warn!(
1658 task_id = %notification.task_id,
1659 target,
1660 ?e,
1661 "failed to send to notify_target"
1662 );
1663 } else {
1664 routed = true;
1665 }
1666 }
1667 }
1668
1669 if routed {
1670 } else if let Some(repo) = notification.repo.as_deref() {
1672 let mut sent: std::collections::HashSet<(String, Option<String>)> =
1678 std::collections::HashSet::new();
1679
1680 for channel in channels.iter() {
1682 let ch_name = channel.name();
1683 if let Some(topic_id) =
1684 notif_router.target_for_project(repo, ch_name)
1685 {
1686 let body = match ch_name {
1687 "telegram" => notification.format_telegram(),
1688 "discord" => notification.format_discord(),
1689 "slack" => notification.format_slack(),
1690 _ => continue,
1691 };
1692 let metadata = if ch_name == "telegram" {
1693 serde_json::json!({"preformatted_html": true})
1694 } else {
1695 serde_json::json!({})
1696 };
1697 let resolved_topic = Some(topic_id.to_string());
1698 let msg = OutgoingMessage {
1699 thread_id: notification.task_id.clone(),
1700 body,
1701 reply_to: None,
1702 metadata,
1703 topic_id: resolved_topic.clone(),
1704 };
1705 if let Err(e) = channel.send(&msg).await {
1706 tracing::warn!(
1707 channel = ch_name,
1708 task_id = %notification.task_id,
1709 ?e,
1710 "failed to send to dedicated channel"
1711 );
1712 } else {
1713 sent.insert((ch_name.to_string(), resolved_topic));
1714 routed = true;
1715 }
1716 }
1717 }
1718
1719 if let Some(store) = ¬if_store {
1721 match store.list_subscribers_for_repo(repo).await {
1722 Ok(subscribers) => {
1723 for (ch_name, thread_id, topic_id) in subscribers {
1724 let channel =
1725 channels.iter().find(|c| c.name() == ch_name);
1726 let Some(channel) = channel else {
1727 continue;
1728 };
1729 let resolved_topic = if topic_id.is_empty() {
1730 None
1731 } else {
1732 Some(topic_id.clone())
1733 };
1734 if sent.contains(&(
1737 ch_name.clone(),
1738 resolved_topic.clone(),
1739 )) {
1740 tracing::debug!(
1741 channel = %ch_name,
1742 task_id = %notification.task_id,
1743 topic_id = ?resolved_topic,
1744 "skipping duplicate subscriber dispatch — destination already received from dedicated path"
1745 );
1746 continue;
1747 }
1748 let body = notification.format_with_project(&ch_name);
1749 let metadata = if ch_name == "telegram" {
1750 serde_json::json!({"preformatted_html": true})
1751 } else {
1752 serde_json::json!({})
1753 };
1754 let msg = OutgoingMessage {
1755 thread_id: thread_id.clone(),
1756 body,
1757 reply_to: None,
1758 metadata,
1759 topic_id: resolved_topic.clone(),
1760 };
1761 if let Err(e) = channel.send(&msg).await {
1762 tracing::warn!(
1763 channel = %ch_name,
1764 task_id = %notification.task_id,
1765 ?e,
1766 "failed to send to subscribed channel"
1767 );
1768 } else {
1769 sent.insert((ch_name.clone(), resolved_topic));
1770 routed = true;
1771 }
1772 }
1773 }
1774 Err(e) => {
1775 tracing::warn!(
1776 repo,
1777 ?e,
1778 "failed to list subscribers for repo"
1779 );
1780 }
1781 }
1782 }
1783 }
1784
1785 if !routed {
1787 for channel in channels.iter() {
1788 let (body, should_send, is_telegram) = match channel.name() {
1789 "telegram" => (notification.format_telegram(), true, true),
1790 "discord" => (notification.format_discord(), true, false),
1791 "slack" => (notification.format_slack(), true, false),
1792 _ => (String::new(), false, false),
1795 };
1796
1797 if !should_send {
1798 continue;
1799 }
1800
1801 let metadata = if is_telegram {
1802 serde_json::json!({"preformatted_html": true})
1803 } else {
1804 serde_json::json!({})
1805 };
1806 let msg = OutgoingMessage {
1807 thread_id: notification.task_id.clone(),
1808 body,
1809 reply_to: None,
1810 metadata,
1811 topic_id: None,
1812 };
1813
1814 if let Err(e) = channel.send(&msg).await {
1815 tracing::warn!(
1816 channel = channel.name(),
1817 task_id = %notification.task_id,
1818 ?e,
1819 "failed to send notification"
1820 );
1821 }
1822 }
1823 }
1824 }
1825 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1826 tracing::warn!(missed = n, "notification receiver lagged");
1827 }
1828 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1829 tracing::debug!("notification channel closed");
1830 break;
1831 }
1832 }
1833 }
1834 });
1835 tracing::info!("notification dispatcher started");
1836 }
1837
1838 let webhook_notify = Arc::new(Notify::new());
1840
1841 let webhook_port: Option<u16>;
1845 let mut webhook_healthy: bool;
1846 let mut last_webhook_health_check = std::time::Instant::now();
1847 let webhook_enabled = crate::config::get("webhook.enabled")
1849 .map(|v| v == "true")
1850 .unwrap_or(false);
1851
1852 let webhook_status = Arc::new(tokio::sync::Mutex::new(
1854 crate::webhook_status::WebhookStatus::default(),
1855 ));
1856
1857 if webhook_enabled {
1858 let port: u16 = crate::config::get("webhook.port")
1859 .ok()
1860 .and_then(|v| v.parse().ok())
1861 .unwrap_or(8080);
1862 webhook_port = Some(port);
1863 let secret = crate::config::get("webhook.secret").unwrap_or_default();
1864 let webhook_repo = project_engines
1865 .first()
1866 .map(|e| e.repo.clone())
1867 .unwrap_or_default();
1868
1869 let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(64);
1870
1871 let to_save = {
1873 let mut s = webhook_status.lock().await;
1874 s.configured = true;
1875 s.port = Some(port);
1876 s.healthy = false;
1877 s.fallback_mode = false;
1878 s.clone()
1879 };
1880 to_save.save().await;
1881
1882 const WEBHOOK_MAX_ATTEMPTS: u32 = 5;
1886 let status_for_spawn = webhook_status.clone();
1887 tokio::spawn(async move {
1888 use crate::channels::github::{is_transient_bind_error, webhook_backoff_delay};
1889 let mut attempt = 0u32;
1890 loop {
1891 attempt += 1;
1892 tracing::info!(attempt, port, "attempting to start webhook server");
1893 match start_webhook_server(port, secret.clone(), webhook_repo.clone(), tx.clone())
1894 .await
1895 {
1896 Ok(_) => {
1897 tracing::info!(attempt, "webhook server exited cleanly");
1898 break;
1899 }
1900 Err(e) => {
1901 let reason = e.to_string();
1902 tracing::error!(attempt, %reason, "webhook server failed");
1903 if attempt >= WEBHOOK_MAX_ATTEMPTS || !is_transient_bind_error(&e) {
1904 let kind = if attempt >= WEBHOOK_MAX_ATTEMPTS {
1905 "max attempts reached"
1906 } else {
1907 "non-transient error"
1908 };
1909 tracing::error!(
1910 attempt,
1911 %reason,
1912 kind,
1913 orch_webhook_in_fallback = true,
1914 "webhook server giving up, switching to polling fallback"
1915 );
1916 let to_save = {
1917 let mut s = status_for_spawn.lock().await;
1918 s.fallback_mode = true;
1919 s.healthy = false;
1920 s.last_failure_reason = Some(reason);
1921 s.startup_attempts = attempt;
1922 s.clone()
1923 };
1924 to_save.save().await;
1925 break;
1926 }
1927 let delay = webhook_backoff_delay(attempt);
1928 tracing::warn!(
1929 attempt,
1930 delay_ms = delay.as_millis(),
1931 %reason,
1932 "webhook bind failed (transient), retrying with backoff"
1933 );
1934 {
1935 let to_save = {
1936 let mut s = status_for_spawn.lock().await;
1937 s.startup_attempts = attempt;
1938 s.last_failure_reason = Some(reason);
1939 s.clone()
1940 };
1941 to_save.save().await;
1942 }
1943 tokio::time::sleep(delay).await;
1944 }
1945 }
1946 }
1947 });
1948
1949 let notify = webhook_notify.clone();
1951 let transport_for_webhook = transport.clone();
1952 tokio::spawn(async move {
1953 while let Some(msg) = rx.recv().await {
1954 tracing::info!(
1955 channel = %msg.channel,
1956 thread = %msg.thread_id,
1957 event = %msg.metadata.get("event").and_then(|v| v.as_str()).unwrap_or("unknown"),
1958 action = %msg.metadata.get("action").and_then(|v| v.as_str()).unwrap_or("unknown"),
1959 "webhook event received, triggering immediate tick"
1960 );
1961 let route = transport_for_webhook.route(&msg).await;
1962 tracing::info!(
1963 channel = %msg.channel,
1964 route = ?route,
1965 "webhook event routed"
1966 );
1967 notify.notify_one();
1969 }
1970 });
1971
1972 webhook_healthy = true;
1973 tracing::info!(port, orch_webhook_up = true, "webhook server started");
1974 } else {
1975 webhook_port = None;
1976 webhook_healthy = false;
1977 {
1978 let to_save = {
1979 let mut s = webhook_status.lock().await;
1980 s.configured = false;
1981 s.fallback_mode = true;
1982 s.clone()
1983 };
1984 to_save.save().await;
1985 }
1986 tracing::info!(
1987 orch_webhook_in_fallback = true,
1988 "webhook server disabled, using polling fallback mode"
1989 );
1990 }
1991
1992 let router = Arc::new(RwLock::new(
1999 match tokio::task::spawn_blocking(Router::from_config).await {
2000 Ok(r) => r,
2001 Err(e) => {
2002 tracing::error!(?e, "router init panicked in spawn_blocking, using default");
2003 tokio::task::spawn_blocking(|| Router::new(RouterConfig::default()))
2004 .await
2005 .unwrap_or_else(|e2| {
2006 panic!("router default init also panicked in spawn_blocking — cannot start engine without a router: {e2:?}");
2007 })
2008 }
2009 },
2010 ));
2011 {
2012 let r = router.read().await;
2013 tracing::info!(
2014 mode = %r.config.mode,
2015 agents = ?r.available_agents,
2016 fallback = %r.config.fallback_executor,
2017 "router initialized"
2018 );
2019 }
2020
2021 let semaphore = Arc::new(Semaphore::new(config.max_parallel));
2025 let mut parallel_in_force = config.max_parallel;
2026
2027 let dispatching: Arc<dashmap::DashMap<String, String>> = Arc::new(dashmap::DashMap::new());
2034
2035 let auto_merge_in_flight: Arc<dashmap::DashSet<String>> = Arc::new(dashmap::DashSet::new());
2039
2040 let mut config_rx = crate::config::subscribe();
2042
2043 let mut last_sync = std::time::Instant::now();
2045
2046 let mut last_upgrade_check: Option<std::time::Instant> = None;
2048
2049 let mut rate_limit_reported: Option<i64> = None;
2052
2053 let (weight_tx, mut weight_rx) = mpsc::channel::<WeightSignal>(64);
2055
2056 for engine in project_engines.iter().filter(|_| leads) {
2062 let in_review_from_store = engine
2064 .store
2065 .list_by_status(&engine.repo, crate::store::TaskStatus::InReview)
2066 .await
2067 .unwrap_or_default()
2068 .iter()
2069 .map(crate::engine::tasks::store_task_to_external)
2070 .collect::<Vec<_>>();
2071 if !in_review_from_store.is_empty() {
2072 let in_review = &in_review_from_store;
2073 for task in in_review {
2074 let session_expected =
2075 review_session_expected(&engine.store, &engine.repo, &task.id.0).await;
2076 let age_minutes = chrono::DateTime::parse_from_rfc3339(&task.updated_at)
2077 .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_minutes())
2078 .unwrap_or(i64::MAX);
2079 if !session_expected && age_minutes < 10 {
2080 continue;
2081 }
2082 if let Err(e) = engine
2083 .task_manager
2084 .update_task_status(&task.id, Status::NeedsReview)
2085 .await
2086 {
2087 tracing::warn!(
2088 task_id = task.id.0,
2089 err = %e,
2090 "failed to reset stale InReview task on startup"
2091 );
2092 } else {
2093 set_review_session_expected(&engine.store, &engine.repo, &task.id.0, false)
2094 .await;
2095 }
2096 }
2097 if !in_review.is_empty() {
2098 tracing::info!(
2099 repo = %engine.repo,
2100 count = in_review.len(),
2101 "reset stale InReview tasks to NeedsReview on startup"
2102 );
2103 }
2104 }
2105
2106 use crate::store::TaskStatus as DbStatus;
2110 if let Ok(internal_in_review) = engine
2111 .task_manager
2112 .list_internal_by_status(DbStatus::InReview)
2113 .await
2114 {
2115 for task in &internal_in_review {
2116 let task_id = task.id.0.clone();
2117 let session_expected =
2118 review_session_expected(&engine.store, &engine.repo, &task_id).await;
2119 let age_minutes = chrono::DateTime::parse_from_rfc3339(&task.updated_at)
2120 .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_minutes())
2121 .unwrap_or(i64::MAX);
2122 if !session_expected && age_minutes < 10 {
2123 continue;
2124 }
2125 if let Err(e) = engine
2126 .task_manager
2127 .update_task_status(&ExternalId(task_id.clone()), Status::NeedsReview)
2128 .await
2129 {
2130 tracing::warn!(
2131 task_id,
2132 err = %e,
2133 "failed to reset stale internal InReview task on startup"
2134 );
2135 } else {
2136 set_review_session_expected(&engine.store, &engine.repo, &task_id, false).await;
2137 }
2138 }
2139 if !internal_in_review.is_empty() {
2140 tracing::info!(
2141 repo = %engine.repo,
2142 count = internal_in_review.len(),
2143 "reset stale internal InReview tasks to NeedsReview on startup"
2144 );
2145 }
2146 }
2147 }
2148
2149 for engine in &project_engines {
2151 subscribers::dispatch::spawn(
2152 event_bus.subscribe(),
2153 engine.backend.clone(),
2154 tmux.clone(),
2155 engine.runner.clone(),
2156 capture_for_tick.clone(),
2157 semaphore.clone(),
2158 engine.task_manager.clone(),
2159 weight_tx.clone(),
2160 router.clone(),
2161 dispatching.clone(),
2162 engine.store.clone(),
2163 engine.repo.clone(),
2164 );
2165 }
2166 tracing::info!("dispatch subscriber started");
2167
2168 for engine in &project_engines {
2170 subscribers::review::spawn(
2171 event_bus.subscribe(),
2172 engine.backend.clone(),
2173 tmux.clone(),
2174 semaphore.clone(),
2175 engine.task_manager.clone(),
2176 router.clone(),
2177 dispatching.clone(),
2178 engine.store.clone(),
2179 engine.repo.clone(),
2180 );
2181 }
2182 tracing::info!("review subscriber started");
2183
2184 for engine in &project_engines {
2186 subscribers::unblock::spawn(
2187 event_bus.subscribe(),
2188 engine.backend.clone(),
2189 engine.task_manager.clone(),
2190 engine.store.clone(),
2191 engine.repo.clone(),
2192 );
2193 }
2194 tracing::info!("unblock subscriber started");
2195
2196 subscribers::notify::spawn(event_bus.subscribe(), transport.clone());
2199 tracing::info!("notify subscriber started");
2200
2201 tracing::info!(
2203 tick = ?config.tick_interval,
2204 sync = ?config.sync_interval,
2205 parallel = config.max_parallel,
2206 "entering main loop"
2207 );
2208 let mut restart_only = restart_only_snapshot();
2211
2212 let mut interval = tokio::time::interval(config.tick_interval);
2213
2214 let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2216 let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
2217
2218 let sync_in_progress = Arc::new(std::sync::atomic::AtomicBool::new(false));
2220
2221 let last_tick_epoch = Arc::new(std::sync::atomic::AtomicU64::new(
2224 std::time::SystemTime::now()
2225 .duration_since(std::time::UNIX_EPOCH)
2226 .unwrap_or_default()
2227 .as_secs(),
2228 ));
2229 {
2230 let last_tick_epoch = Arc::clone(&last_tick_epoch);
2231 let tick_interval_secs = config.tick_interval.as_secs();
2232 tokio::spawn(async move {
2233 let mut watchdog_interval = tokio::time::interval(std::time::Duration::from_secs(30));
2235 let mut suspend_detector = crate::engine::suspend::SuspendDetector::new();
2240 loop {
2241 watchdog_interval.tick().await;
2242 let own_gap = suspend_detector.check();
2243 let last = last_tick_epoch.load(std::sync::atomic::Ordering::Relaxed);
2244 let now = std::time::SystemTime::now()
2245 .duration_since(std::time::UNIX_EPOCH)
2246 .unwrap_or_default()
2247 .as_secs();
2248 let stale_secs = now.saturating_sub(last);
2249 let threshold = (tick_interval_secs * 6).max(60);
2251 if stale_secs > threshold {
2252 let shared_gap = crate::engine::suspend::gap_detected_within(
2259 std::time::Duration::from_secs(stale_secs),
2260 );
2261 let gap = match (own_gap, shared_gap) {
2262 (Some(a), Some(b)) => Some(a.max(b)),
2263 (Some(a), None) => Some(a),
2264 (None, Some(b)) => Some(b),
2265 (None, None) => None,
2266 };
2267 if let Some(gap) = gap {
2268 let gap_secs = gap.num_seconds() as u64;
2269 if gap_secs >= stale_secs {
2270 tracing::info!(
2271 stale_secs,
2272 threshold,
2273 suspend_gap_secs = gap_secs,
2274 "tick loop stale window explained by host suspend/resume, not a stall"
2275 );
2276 } else {
2277 tracing::error!(
2278 stale_secs,
2279 threshold,
2280 suspend_gap_secs = gap_secs,
2281 "WATCHDOG: tick loop has not completed a tick in {}s (threshold {}s) — suspend gap of {}s only partially explains the stall",
2282 stale_secs,
2283 threshold,
2284 gap_secs,
2285 );
2286 }
2287 } else {
2288 tracing::error!(
2289 stale_secs,
2290 threshold,
2291 "WATCHDOG: tick loop has not completed a tick in {}s (threshold {}s) — possible stall",
2292 stale_secs,
2293 threshold,
2294 );
2295 }
2296 }
2297 }
2298 });
2299 }
2300
2301 loop {
2302 tokio::select! {
2303 _ = interval.tick() => {
2304 let tick_start = std::time::Instant::now();
2305
2306 if let Some(gap) = crate::engine::suspend::checkpoint() {
2310 tracing::info!(
2311 gap_secs = gap.num_seconds(),
2312 "host suspend/resume gap detected, discounting from stuck-task age checks"
2313 );
2314 }
2315
2316 if parallel_in_force != config.max_parallel {
2320 let resized =
2321 resize_dispatch_pool(&semaphore, parallel_in_force, config.max_parallel);
2322 if resized != parallel_in_force {
2323 parallel_in_force = resized;
2324 tracing::info!(
2325 in_force = parallel_in_force,
2326 target = config.max_parallel,
2327 "dispatch pool resized"
2328 );
2329 }
2330 }
2331
2332 {
2334 let mut rw = router.write().await;
2335 rw.tick_weight_recovery();
2336 }
2337
2338 if let Some(remaining) = GhHttp::is_rate_limited() {
2342 let reset_at = chrono::Utc::now()
2343 .timestamp()
2344 .saturating_add(i64::try_from(remaining.as_secs()).unwrap_or(i64::MAX));
2345 if rate_limit_reported.is_none_or(|prev| (reset_at - prev).abs() > 60) {
2347 rate_limit_reported = Some(reset_at);
2348 report_rate_limited(reset_at, project_engines.first().map(|e| &e.store), Some(&transport)).await;
2349 }
2350 } else if let Some(prev) = rate_limit_reported.take() {
2351 tracing::info!(
2352 limited_until = prev,
2353 "GitHub API rate limit cleared"
2354 );
2355 if let Some(engine) = project_engines.first() {
2356 let _ = engine.store.kv_delete(RATE_LIMITED_UNTIL_KEY).await;
2357 }
2358 }
2359
2360 let mut router_guard = router.write().await;
2362 let mut routing_quota = tick::RoutingTickQuota::new(
2363 crate::engine::router::config::max_tasks_per_routing_tick(),
2364 );
2365 for engine in &project_engines {
2366 let repo = engine.repo.clone();
2367 let project_jobs_path = engine.project_dir.join(".orch.yml");
2368 REPO_CONTEXT.scope(repo, async {
2369 if let Err(e) = tick::tick(
2370 &engine.backend,
2371 &tmux,
2372 &engine.repo,
2373 &engine.runner,
2374 &capture_for_tick,
2375 &semaphore,
2376 &config,
2377 &project_jobs_path,
2378 &mut router_guard,
2379 &engine.task_manager,
2380 &weight_tx,
2381 &dispatching,
2382 &engine.store,
2383 Some(&transport),
2384 &active_repos,
2385 &auto_merge_in_flight,
2386 &mut routing_quota,
2387 ).await {
2388 tracing::error!(repo = %engine.repo, ?e, "tick failed for project");
2389 }
2390 }).await;
2391 }
2392 drop(router_guard);
2393
2394 while let Ok(signal) = weight_rx.try_recv() {
2397 let mut rw = router.write().await;
2398 match signal {
2399 WeightSignal::RateLimited { ref agent } => {
2400 rw.record_rate_limit(agent);
2401 }
2402 WeightSignal::Success { ref agent } => {
2403 rw.record_success(agent);
2404 }
2405 WeightSignal::Rerouted | WeightSignal::Blocked | WeightSignal::None => {}
2406 }
2407 }
2408
2409 if last_sync.elapsed() >= config.sync_interval {
2414 if sync_in_progress.compare_exchange(
2415 false,
2416 true,
2417 std::sync::atomic::Ordering::AcqRel,
2418 std::sync::atomic::Ordering::Relaxed,
2419 ).is_ok() {
2420 let sync_engines: Vec<_> = project_engines.iter().map(|e| {
2422 (e.backend.clone(), e.repo.clone(),
2423 e.task_manager.clone(), e.store.clone())
2424 }).collect();
2425 let sync_tmux = Arc::clone(&tmux);
2426 let sync_config = config.clone();
2427 let sync_router = Arc::clone(&router);
2428 let sync_dispatching = Arc::clone(&dispatching);
2429 let sync_auto_merge = Arc::clone(&auto_merge_in_flight);
2430 let sync_guard = Arc::clone(&sync_in_progress);
2431 tokio::spawn(async move {
2432 let sync_start = std::time::Instant::now();
2433 for (backend, repo, task_manager, store) in &sync_engines {
2434 let repo = repo.clone();
2435 REPO_CONTEXT.scope(repo.clone(), async {
2436 if let Err(e) = sync::sync_tick(
2437 backend, &sync_tmux, &repo, &sync_config,
2438 &sync_router, task_manager, store,
2439 &sync_dispatching, &sync_auto_merge,
2440 ).await {
2441 tracing::error!(repo = %repo, ?e, "sync tick failed for project");
2442 }
2443 }).await;
2444 }
2445 if let Some((_, _, _, store)) = sync_engines.first() {
2449 let (available_agents, config) = {
2450 let r = sync_router.read().await;
2451 (r.available_agents.clone(), r.config.clone())
2452 };
2453 sync::emit_degraded_agents_if_needed(
2454 &available_agents,
2455 &config,
2456 Some(store),
2457 )
2458 .await;
2459 }
2460 let elapsed = sync_start.elapsed();
2461 tracing::info!(elapsed_ms = elapsed.as_millis() as u64, "sync tick complete");
2462 sync_guard.store(false, std::sync::atomic::Ordering::Release);
2463 });
2464 } else {
2465 tracing::debug!("sync tick still in progress, skipping");
2466 }
2467 last_sync = std::time::Instant::now();
2471 }
2472
2473 if webhook_enabled {
2475 let health_check_interval = config.webhook_health_check_interval
2476 .unwrap_or(std::time::Duration::from_secs(60));
2477
2478 if last_webhook_health_check.elapsed() >= health_check_interval {
2479 if let Some(port) = webhook_port {
2480 let (health, failure_reason) =
2481 crate::channels::github::check_webhook_health(port).await;
2482 if health != webhook_healthy {
2483 webhook_healthy = health;
2484 if webhook_healthy {
2485 tracing::info!(port, orch_webhook_up = true, "webhook health restored");
2486 } else {
2487 tracing::warn!(
2488 port,
2489 orch_webhook_up = false,
2490 reason = failure_reason.as_deref().unwrap_or("unknown"),
2491 "webhook health check failed"
2492 );
2493 }
2494 }
2495 {
2497 let to_save = {
2498 let mut s = webhook_status.lock().await;
2499 s.healthy = health;
2500 s.last_check_utc = Some(chrono::Utc::now());
2501 if health {
2502 s.last_failure_reason = None;
2503 } else if failure_reason.is_some() {
2504 s.last_failure_reason = failure_reason;
2505 }
2506 s.clone()
2507 };
2508 to_save.save().await;
2509 }
2510 }
2511 last_webhook_health_check = std::time::Instant::now();
2512 }
2513 }
2514
2515 if should_run_upgrade_check(last_upgrade_check, config.upgrade_check_interval) {
2517 if let Some(store) = project_engines.first().map(|e| e.store.clone()) {
2518 let channels = channel_registry.clone();
2519 let current = env!("ORCH_VERSION").to_string();
2520 let check_interval =
2521 std::time::Duration::from_secs(config.upgrade_check_interval);
2522 tokio::spawn(async move {
2523 check_and_notify_upgrade(&store, &channels, ¤t, check_interval)
2524 .await;
2525 });
2526 }
2527 last_upgrade_check = Some(std::time::Instant::now());
2528 }
2529
2530 let tick_elapsed = tick_start.elapsed();
2532 if tick_elapsed.as_secs() > 30 {
2533 tracing::warn!(elapsed_ms = tick_elapsed.as_millis() as u64, "slow tick");
2534 } else {
2535 tracing::debug!(elapsed_ms = tick_elapsed.as_millis() as u64, "tick complete");
2536 }
2537 last_tick_epoch.store(
2538 std::time::SystemTime::now()
2539 .duration_since(std::time::UNIX_EPOCH)
2540 .unwrap_or_default()
2541 .as_secs(),
2542 std::sync::atomic::Ordering::Relaxed,
2543 );
2544 }
2545 _ = webhook_notify.notified() => {
2547 if let Some(remaining) = GhHttp::is_rate_limited() {
2548 tracing::warn!(
2549 remaining_secs = remaining.as_secs(),
2550 "GitHub API rate-limited, skipping webhook-triggered tick"
2551 );
2552 } else {
2553 tracing::info!("webhook event triggered immediate tick");
2554
2555 let mut router_guard = router.write().await;
2556 let mut routing_quota = tick::RoutingTickQuota::new(
2557 crate::engine::router::config::max_tasks_per_routing_tick(),
2558 );
2559 for engine in &project_engines {
2560 let repo = engine.repo.clone();
2561 let project_jobs_path = engine.project_dir.join(".orch.yml");
2562 REPO_CONTEXT.scope(repo, async {
2563 if let Err(e) = tick::tick(
2564 &engine.backend,
2565 &tmux,
2566 &engine.repo,
2567 &engine.runner,
2568 &capture_for_tick,
2569 &semaphore,
2570 &config,
2571 &project_jobs_path,
2572 &mut router_guard,
2573 &engine.task_manager,
2574 &weight_tx,
2575 &dispatching,
2576 &engine.store,
2577 Some(&transport),
2578 &active_repos,
2579 &auto_merge_in_flight,
2580 &mut routing_quota,
2581 ).await {
2582 tracing::error!(repo = %engine.repo, ?e, "webhook-triggered tick failed");
2583 }
2584 }).await;
2585 }
2586 drop(router_guard);
2587
2588 while let Ok(signal) = weight_rx.try_recv() {
2590 let mut rw = router.write().await;
2591 match signal {
2592 WeightSignal::RateLimited { ref agent } => {
2593 rw.record_rate_limit(agent);
2594 }
2595 WeightSignal::Success { ref agent } => {
2596 rw.record_success(agent);
2597 }
2598 WeightSignal::Rerouted | WeightSignal::Blocked | WeightSignal::None => {}
2599 }
2600 }
2601
2602 if sync_in_progress.compare_exchange(
2608 false,
2609 true,
2610 std::sync::atomic::Ordering::AcqRel,
2611 std::sync::atomic::Ordering::Relaxed,
2612 ).is_ok() {
2613 let sync_engines: Vec<_> = project_engines.iter().map(|e| {
2615 (e.backend.clone(), e.repo.clone(), e.task_manager.clone(), e.store.clone())
2616 }).collect();
2617 let sync_tmux = Arc::clone(&tmux);
2618 let sync_config = config.clone();
2619 let sync_router = Arc::clone(&router);
2620 let sync_dispatching = Arc::clone(&dispatching);
2621 let sync_auto_merge = Arc::clone(&auto_merge_in_flight);
2622 let sync_guard = Arc::clone(&sync_in_progress);
2623 tokio::spawn(async move {
2624 let sync_start = std::time::Instant::now();
2625 for (backend, repo, task_manager, store) in &sync_engines {
2626 let repo = repo.clone();
2627 REPO_CONTEXT.scope(repo.clone(), async {
2628 if let Err(e) = sync::sync_tick(
2629 backend, &sync_tmux, &repo, &sync_config,
2630 &sync_router, task_manager, store,
2631 &sync_dispatching, &sync_auto_merge,
2632 ).await {
2633 tracing::error!(repo = %repo, ?e, "webhook immediate sync tick failed for project");
2634 }
2635 }).await;
2636 }
2637 if let Some((_, _, _, store)) = sync_engines.first() {
2639 let (available_agents, config) = {
2640 let r = sync_router.read().await;
2641 (r.available_agents.clone(), r.config.clone())
2642 };
2643 sync::emit_degraded_agents_if_needed(
2644 &available_agents,
2645 &config,
2646 Some(store),
2647 ).await;
2648 }
2649 let elapsed = sync_start.elapsed();
2650 tracing::info!(elapsed_ms = elapsed.as_millis() as u64, "webhook immediate sync tick complete");
2651 sync_guard.store(false, std::sync::atomic::Ordering::Release);
2652 });
2653 last_sync = std::time::Instant::now();
2655 } else {
2656 tracing::debug!("sync tick still in progress, skipping webhook-triggered immediate sync");
2657 }
2658 }
2659
2660 interval.reset();
2662 }
2663 result = config_rx.recv() => {
2664 let reload = match result {
2667 Ok(path) => {
2668 tracing::info!(path = %path.display(), "config file changed, reloading");
2669 true
2670 }
2671 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
2672 tracing::warn!(missed = n, "config change receiver lagged, reloading");
2673 true
2674 }
2675 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
2676 tracing::warn!("config change channel closed");
2677 false
2678 }
2679 };
2680
2681 if reload {
2682 let new_config = EngineConfig::from_config();
2684 log_engine_config_changes(&config, &new_config);
2685
2686 let new_restart_only = restart_only_snapshot();
2687 log_restart_only_changes(&restart_only, &new_restart_only);
2688 restart_only = new_restart_only;
2689
2690 let tick_changed = new_config.tick_interval != config.tick_interval;
2691 config = new_config;
2692
2693 if tick_changed {
2695 interval = tokio::time::interval(config.tick_interval);
2696 tracing::info!(tick = ?config.tick_interval, "tick interval updated");
2697 }
2698
2699 if parallel_in_force != config.max_parallel {
2702 parallel_in_force = resize_dispatch_pool(
2703 &semaphore,
2704 parallel_in_force,
2705 config.max_parallel,
2706 );
2707 tracing::info!(
2708 in_force = parallel_in_force,
2709 target = config.max_parallel,
2710 "dispatch pool resized"
2711 );
2712 }
2713
2714 {
2716 let mut router_guard = router.write().await;
2717 router_guard.reload_async().await;
2718 }
2719
2720 sync_project_engines(
2722 &mut project_engines,
2723 &mut active_repos,
2724 &event_bus,
2725 &tmux,
2726 &capture_for_tick,
2727 &semaphore,
2728 &weight_tx,
2729 &router,
2730 &dispatching,
2731 ).await;
2732
2733 tracing::info!(
2734 tick = ?config.tick_interval,
2735 sync = ?config.sync_interval,
2736 parallel = config.max_parallel,
2737 projects = project_engines.len(),
2738 "config reloaded"
2739 );
2740 }
2741 }
2742 signal_name = async {
2743 tokio::select! {
2744 _ = tokio::signal::ctrl_c() => "SIGINT",
2745 _ = sigterm.recv() => "SIGTERM",
2746 _ = sighup.recv() => "SIGHUP",
2747 }
2748 } => {
2749 tracing::info!(signal = signal_name, "beginning graceful shutdown");
2750
2751 let shutdown_result = tokio::time::timeout(
2754 config.graceful_shutdown_timeout,
2755 async {
2756 let mut reset_count = 0u32;
2760 let mut review_reset_count = 0u32;
2761 for engine in &project_engines {
2762 if let Ok(tasks) = engine.task_manager.list_external_by_status(Status::InProgress).await {
2763 for task in &tasks {
2764 if let Err(e) = engine.task_manager.update_task_status(&task.id, Status::Routed).await {
2765 tracing::warn!(task_id = task.id.0, ?e, "failed to reset task on shutdown");
2766 } else {
2767 if let Ok(Some(store_id)) =
2768 engine.store.resolve_task_id(&engine.repo, &task.id.0).await
2769 {
2770 if let Err(e) = engine
2771 .store
2772 .finalize_incomplete_runs(
2773 store_id,
2774 "aborted",
2775 "graceful shutdown: reset in_progress task to routed",
2776 )
2777 .await
2778 {
2779 tracing::warn!(
2780 task_id = task.id.0,
2781 ?e,
2782 "failed to finalize incomplete runs on shutdown"
2783 );
2784 }
2785 }
2786 reset_count += 1;
2787 }
2788 }
2789 }
2790 if let Ok(tasks) = engine.store.list_internal_by_status(&engine.repo, crate::store::TaskStatus::InProgress).await {
2792 for task in &tasks {
2793 let task_id = format!("internal:{}", task.id);
2794 if let Err(e) = engine.task_manager.update_task_status(
2795 &crate::backends::ExternalId(task_id.clone()),
2796 Status::Routed,
2797 ).await {
2798 tracing::warn!(task_id, ?e, "failed to reset internal task on shutdown");
2799 } else {
2800 if let Err(e) = engine
2801 .store
2802 .finalize_incomplete_runs(
2803 task.id,
2804 "aborted",
2805 "graceful shutdown: reset in_progress task to routed",
2806 )
2807 .await
2808 {
2809 tracing::warn!(
2810 task_id,
2811 ?e,
2812 "failed to finalize incomplete internal runs on shutdown"
2813 );
2814 }
2815 reset_count += 1;
2816 }
2817 }
2818 }
2819 if let Ok(tasks) = engine.task_manager.list_external_by_status(Status::InReview).await {
2821 for task in &tasks {
2822 if let Err(e) = engine.task_manager.update_task_status(&task.id, Status::NeedsReview).await {
2823 tracing::warn!(task_id = task.id.0, ?e, "failed to reset in_review task on shutdown");
2824 } else {
2825 set_review_session_expected(&engine.store, &engine.repo, &task.id.0, false).await;
2826 if let Ok(Some(store_id)) =
2827 engine.store.resolve_task_id(&engine.repo, &task.id.0).await
2828 {
2829 if let Err(e) = engine
2830 .store
2831 .finalize_incomplete_runs(
2832 store_id,
2833 "aborted",
2834 "graceful shutdown: reset in_review task to needs_review",
2835 )
2836 .await
2837 {
2838 tracing::warn!(
2839 task_id = task.id.0,
2840 ?e,
2841 "failed to finalize incomplete review runs on shutdown"
2842 );
2843 }
2844 }
2845 review_reset_count += 1;
2846 }
2847 }
2848 }
2849 if let Ok(tasks) = engine.store.list_internal_by_status(&engine.repo, crate::store::TaskStatus::InReview).await {
2851 for task in &tasks {
2852 let task_id = format!("internal:{}", task.id);
2853 if let Err(e) = engine.task_manager.update_task_status(
2854 &crate::backends::ExternalId(task_id.clone()),
2855 Status::NeedsReview,
2856 ).await {
2857 tracing::warn!(task_id, ?e, "failed to reset internal in_review task on shutdown");
2858 } else {
2859 set_review_session_expected(&engine.store, &engine.repo, &task_id, false).await;
2860 if let Err(e) = engine
2861 .store
2862 .finalize_incomplete_runs(
2863 task.id,
2864 "aborted",
2865 "graceful shutdown: reset in_review task to needs_review",
2866 )
2867 .await
2868 {
2869 tracing::warn!(
2870 task_id,
2871 ?e,
2872 "failed to finalize incomplete internal review runs on shutdown"
2873 );
2874 }
2875 review_reset_count += 1;
2876 }
2877 }
2878 }
2879 }
2880 if reset_count > 0 {
2881 tracing::info!(reset_count, "reset in_progress tasks to routed for re-dispatch");
2882 }
2883 if review_reset_count > 0 {
2884 tracing::info!(review_reset_count, "reset in_review tasks to needs_review for re-dispatch");
2885 }
2886
2887 let mut killed_sessions = Vec::new();
2890 if let Ok(sessions) = tmux.list_sessions().await {
2891 for session in &sessions {
2892 if session.name.starts_with("orch-") {
2893 if let Err(e) = tmux.kill_session(&session.name).await {
2894 tracing::warn!(session = %session.name, error = %e, "failed to kill tmux session");
2895 } else {
2896 killed_sessions.push(session.name.clone());
2897 }
2898 }
2899 }
2900 if !killed_sessions.is_empty() {
2901 tracing::info!(killed = killed_sessions.len(), "killing orch tmux sessions on shutdown");
2902 }
2903 }
2904
2905 if !killed_sessions.is_empty() {
2908 let still_alive = tmux
2909 .wait_for_sessions_dead(
2910 &killed_sessions,
2911 std::time::Duration::from_millis(100),
2912 std::time::Duration::from_secs(5),
2913 )
2914 .await;
2915 if still_alive > 0 {
2916 tracing::warn!(
2917 still_alive,
2918 "some tmux sessions did not terminate cleanly - they will be cleaned up on startup"
2919 );
2920 }
2921 tracing::info!(killed = killed_sessions.len() - still_alive, "confirmed tmux sessions terminated");
2922 }
2923 },
2924 ).await;
2925
2926 if shutdown_result.is_err() {
2927 tracing::error!(
2928 timeout_secs = config.graceful_shutdown_timeout.as_secs(),
2929 "graceful shutdown timed out, forcing exit"
2930 );
2931 }
2932
2933 break;
2934 }
2935 }
2936 }
2937
2938 events::cleanup_port_file().await;
2940 crate::cluster::local::cleanup().await;
2941 events::cleanup_version_file();
2942
2943 let _ = transport;
2945 tracing::info!("orch engine stopped");
2946 Ok(())
2947}
2948
2949#[cfg(test)]
2950mod tests {
2951 use super::*;
2952
2953 #[test]
2954 fn growing_max_parallel_lets_more_tasks_run_without_a_restart() {
2955 let semaphore = Arc::new(Semaphore::new(4));
2956 let held: Vec<_> = (0..4)
2957 .map(|_| semaphore.clone().try_acquire_owned().unwrap())
2958 .collect();
2959 assert!(
2960 semaphore.clone().try_acquire_owned().is_err(),
2961 "pool is full"
2962 );
2963
2964 let in_force = resize_dispatch_pool(&semaphore, 4, 8);
2965 assert_eq!(in_force, 8);
2966
2967 let grown: Vec<_> = (0..4)
2968 .map(|_| semaphore.clone().try_acquire_owned().unwrap())
2969 .collect();
2970 assert_eq!(held.len() + grown.len(), 8, "8 tasks run at once");
2971 assert!(
2972 semaphore.clone().try_acquire_owned().is_err(),
2973 "pool is full"
2974 );
2975 }
2976
2977 #[test]
2978 fn shrinking_max_parallel_never_cancels_a_running_task() {
2979 let semaphore = Arc::new(Semaphore::new(8));
2980 let mut held: Vec<_> = (0..8)
2981 .map(|_| semaphore.clone().try_acquire_owned().unwrap())
2982 .collect();
2983
2984 let mut in_force = resize_dispatch_pool(&semaphore, 8, 4);
2986 assert_eq!(in_force, 8);
2987 assert_eq!(held.len(), 8);
2988
2989 held.truncate(5);
2991 in_force = resize_dispatch_pool(&semaphore, in_force, 4);
2992 assert_eq!(in_force, 5);
2993 assert!(semaphore.clone().try_acquire_owned().is_err());
2994
2995 held.truncate(4);
2997 in_force = resize_dispatch_pool(&semaphore, in_force, 4);
2998 assert_eq!(in_force, 4);
2999 assert!(semaphore.clone().try_acquire_owned().is_err());
3000
3001 held.truncate(3);
3003 assert!(semaphore.clone().try_acquire_owned().is_ok());
3004 }
3005
3006 #[test]
3007 fn a_project_added_to_the_config_is_picked_up_and_running_ones_are_not_respawned() {
3008 let configured = vec![
3009 (
3010 "owner/one".to_string(),
3011 std::path::PathBuf::from("/srv/one"),
3012 ),
3013 (
3014 "owner/two".to_string(),
3015 std::path::PathBuf::from("/srv/two"),
3016 ),
3017 ];
3018 let active: std::collections::HashSet<String> =
3019 ["owner/one".to_string()].into_iter().collect();
3020
3021 let (added, removed) = diff_configured_projects(&configured, &active);
3022 assert_eq!(
3023 added,
3024 vec![(
3025 "owner/two".to_string(),
3026 std::path::PathBuf::from("/srv/two")
3027 )],
3028 "only the new project is started, so owner/one keeps its single set of subscribers"
3029 );
3030 assert!(removed.is_empty());
3031 }
3032
3033 #[test]
3034 fn a_project_dropped_from_the_config_is_reported_and_not_restarted() {
3035 let configured = vec![(
3036 "owner/one".to_string(),
3037 std::path::PathBuf::from("/srv/one"),
3038 )];
3039 let active: std::collections::HashSet<String> =
3040 ["owner/one".to_string(), "owner/gone".to_string()]
3041 .into_iter()
3042 .collect();
3043
3044 let (added, removed) = diff_configured_projects(&configured, &active);
3045 assert!(added.is_empty(), "nothing new to start");
3046 assert_eq!(removed, vec!["owner/gone".to_string()]);
3047 }
3048
3049 #[test]
3050 fn an_unchanged_project_list_starts_nothing() {
3051 let configured = vec![(
3052 "owner/one".to_string(),
3053 std::path::PathBuf::from("/srv/one"),
3054 )];
3055 let active: std::collections::HashSet<String> =
3056 ["owner/one".to_string()].into_iter().collect();
3057
3058 assert_eq!(
3059 diff_configured_projects(&configured, &active),
3060 (vec![], vec![])
3061 );
3062 }
3063
3064 #[test]
3065 fn restart_only_keys_are_the_settings_read_once_at_startup() {
3066 assert_eq!(RESTART_ONLY_KEYS, ["channels", "webhook"]);
3069 }
3070
3071 #[test]
3072 fn a_changed_restart_only_key_is_reported_and_an_unchanged_one_is_not() {
3073 let before = vec![Some("bot_token: a\n".to_string()), None];
3074
3075 assert!(
3076 log_restart_only_changes(&before, &before.clone()).is_empty(),
3077 "a reload that did not touch these keys must stay quiet"
3078 );
3079
3080 let after = vec![
3082 Some("bot_token: b\n".to_string()),
3083 Some("port: 8080\n".to_string()),
3084 ];
3085 assert_eq!(
3086 log_restart_only_changes(&before, &after),
3087 ["channels", "webhook"]
3088 );
3089
3090 let token_only = vec![Some("bot_token: b\n".to_string()), None];
3092 assert_eq!(log_restart_only_changes(&before, &token_only), ["channels"]);
3093 }
3094
3095 #[test]
3096 fn resize_dispatch_pool_is_a_noop_at_the_current_limit() {
3097 let semaphore = Arc::new(Semaphore::new(4));
3098 assert_eq!(resize_dispatch_pool(&semaphore, 4, 4), 4);
3099 assert_eq!(semaphore.available_permits(), 4);
3100 }
3101
3102 #[test]
3103 fn engine_config_defaults() {
3104 let config = EngineConfig::default();
3105 assert_eq!(config.tick_interval, std::time::Duration::from_secs(10));
3106 assert_eq!(config.sync_interval, std::time::Duration::from_secs(45));
3107 assert_eq!(config.max_parallel, 4);
3108 assert_eq!(config.stuck_timeout, 1800);
3109 assert_eq!(config.no_session_stuck_timeout, 600);
3110 assert!(
3111 config.no_session_stuck_timeout < config.stuck_timeout,
3112 "no_session_stuck_timeout must be shorter than stuck_timeout"
3113 );
3114 assert_eq!(
3115 config.graceful_shutdown_timeout,
3116 std::time::Duration::from_secs(600)
3117 );
3118 assert_eq!(config.upgrade_check_interval, 3600);
3119 }
3120
3121 #[test]
3122 fn engine_config_from_config_uses_defaults_when_no_config() {
3123 let config = EngineConfig::from_config();
3124 assert_eq!(config.tick_interval, std::time::Duration::from_secs(10));
3125 assert_eq!(config.sync_interval, std::time::Duration::from_secs(45));
3126 assert!(config.max_parallel >= 1 && config.max_parallel <= 64);
3128 assert_eq!(config.stuck_timeout, 1800);
3129 assert_eq!(config.no_session_stuck_timeout, 600);
3130 assert_eq!(
3131 config.graceful_shutdown_timeout,
3132 std::time::Duration::from_secs(600)
3133 );
3134 assert_eq!(config.upgrade_check_interval, 3600);
3135 }
3136
3137 #[test]
3138 fn engine_config_no_session_timeout_is_under_15_minutes() {
3139 let config = EngineConfig::default();
3140 assert!(
3141 config.no_session_stuck_timeout <= 900,
3142 "no_session_stuck_timeout ({}) exceeds 15 minutes",
3143 config.no_session_stuck_timeout
3144 );
3145 }
3146
3147 #[test]
3148 fn upgrade_check_runs_immediately_when_never_checked() {
3149 assert!(should_run_upgrade_check(None, 3600));
3150 }
3151
3152 #[test]
3153 fn upgrade_check_waits_for_interval_after_first_run() {
3154 assert!(!should_run_upgrade_check(
3155 Some(std::time::Instant::now()),
3156 3600
3157 ));
3158 }
3159
3160 #[test]
3161 fn upgrade_check_can_be_disabled() {
3162 assert!(!should_run_upgrade_check(None, 0));
3163 }
3164
3165 #[test]
3166 fn configured_agents_parses_yaml_block_array() {
3167 let yaml_str = "- claude\n- codex\n- opencode\n- olm\n";
3169 let parsed: Vec<String> = serde_norway::from_str(yaml_str).unwrap();
3170 assert_eq!(parsed, vec!["claude", "codex", "opencode", "olm"]);
3171 }
3172
3173 #[test]
3174 fn configured_agents_parses_json_inline_array() {
3175 let json_str = r#"["claude","codex","olm"]"#;
3176 let parsed: Vec<String> = serde_json::from_str(json_str).unwrap();
3177 assert_eq!(parsed, vec!["claude", "codex", "olm"]);
3178 }
3179
3180 #[test]
3181 fn configured_agents_fallback_returns_default_agents() {
3182 let defaults = configured_agents();
3184 assert!(defaults.contains(&"claude".to_string()));
3185 assert!(defaults.contains(&"codex".to_string()));
3186 assert!(defaults.contains(&"opencode".to_string()));
3187 }
3188
3189 #[tokio::test]
3198 #[ignore]
3199 async fn integration_channel_to_tmux_to_capture() {
3200 use crate::channels::capture::CaptureService;
3201 use crate::channels::transport::Transport;
3202 use crate::tmux::SessionGuard;
3203 use std::sync::Arc;
3204
3205 let transport = Arc::new(Transport::new());
3207 let capture = Arc::new(CaptureService::new(transport.clone()));
3208
3209 let task_id = "testtask1".to_string();
3210 let session_name = format!("orch-test-{}", task_id);
3211
3212 let _ = tokio::process::Command::new("tmux")
3214 .args(["new-session", "-d", "-s", &session_name])
3215 .output()
3216 .await
3217 .expect("failed to start tmux session");
3218
3219 let _guard = SessionGuard(session_name.clone());
3221
3222 capture
3224 .register_session("owner/repo", &task_id, &session_name)
3225 .await;
3226 transport
3227 .bind(
3228 "owner/repo",
3229 &task_id,
3230 &session_name,
3231 "telegram",
3232 "12345",
3233 None,
3234 )
3235 .await;
3236
3237 let capture_clone = capture.clone();
3239 let capture_handle = tokio::spawn(async move { capture_clone.run().await });
3240
3241 let mut rx = transport
3243 .subscribe("owner/repo", &task_id)
3244 .await
3245 .expect("no subscription");
3246
3247 let send = tokio::process::Command::new("tmux")
3249 .args([
3250 "send-keys",
3251 "-t",
3252 &session_name,
3253 "echo from-channel",
3254 "Enter",
3255 ])
3256 .output()
3257 .await
3258 .expect("failed to send keys");
3259 assert!(send.status.success());
3260
3261 let mut got = false;
3263 for _ in 0..20 {
3264 if let Ok(Ok(c)) =
3265 tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await
3266 {
3267 if c.content.contains("from-channel") {
3268 got = true;
3269 break;
3270 }
3271 }
3272 }
3273
3274 capture.unregister_session("owner/repo", &task_id).await;
3276
3277 let _ = capture_handle.await;
3279
3280 assert!(got, "did not observe tmux output via capture/transport");
3281 }
3282
3283 #[test]
3286 fn read_project_channel_config_returns_defaults_when_no_file() {
3287 let dir = tempfile::tempdir().expect("tempdir");
3288 let cfg = read_project_channel_config(dir.path());
3289 assert!(cfg.telegram_topic_id.is_none());
3290 assert!(cfg.discord_channel_id.is_none());
3291 }
3292
3293 #[test]
3294 fn read_project_channel_config_parses_telegram_and_discord() {
3295 let dir = tempfile::tempdir().expect("tempdir");
3296 let orch_yml = dir.path().join(".orch.yml");
3297 std::fs::write(
3298 &orch_yml,
3299 "channels:\n telegram:\n topic_id: \"42\"\n discord:\n channel_id: \"9999\"\n",
3300 )
3301 .expect("write .orch.yml");
3302 let cfg = read_project_channel_config(dir.path());
3303 assert_eq!(cfg.telegram_topic_id.as_deref(), Some("42"));
3304 assert_eq!(cfg.discord_channel_id.as_deref(), Some("9999"));
3305 }
3306
3307 #[test]
3308 fn read_project_channel_config_handles_partial_channels() {
3309 let dir = tempfile::tempdir().expect("tempdir");
3310 let orch_yml = dir.path().join(".orch.yml");
3311 std::fs::write(&orch_yml, "channels:\n telegram:\n topic_id: \"7\"\n")
3312 .expect("write .orch.yml");
3313 let cfg = read_project_channel_config(dir.path());
3314 assert_eq!(cfg.telegram_topic_id.as_deref(), Some("7"));
3315 assert!(cfg.discord_channel_id.is_none());
3316 }
3317
3318 #[test]
3319 fn read_project_channel_config_handles_malformed_yaml() {
3320 let dir = tempfile::tempdir().expect("tempdir");
3321 let orch_yml = dir.path().join(".orch.yml");
3322 std::fs::write(&orch_yml, "channels: [\nbad yaml").expect("write .orch.yml");
3323 let cfg = read_project_channel_config(dir.path());
3324 assert!(cfg.telegram_topic_id.is_none());
3326 assert!(cfg.discord_channel_id.is_none());
3327 }
3328
3329 #[test]
3330 #[cfg(unix)]
3331 fn read_project_channel_config_handles_unreadable_file() {
3332 let dir = tempfile::tempdir().expect("tempdir");
3333 std::fs::create_dir(dir.path().join(".orch.yml")).expect("create .orch.yml dir");
3335 let cfg = read_project_channel_config(dir.path());
3336 assert!(cfg.telegram_topic_id.is_none());
3338 assert!(cfg.discord_channel_id.is_none());
3339 }
3340}