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
329fn should_run_upgrade_check(
330 last_upgrade_check: Option<std::time::Instant>,
331 upgrade_check_interval: u64,
332) -> bool {
333 if upgrade_check_interval == 0 {
334 return false;
335 }
336
337 last_upgrade_check
338 .map(|last| last.elapsed() >= std::time::Duration::from_secs(upgrade_check_interval))
339 .unwrap_or(true)
340}
341
342async fn init_project_engines() -> anyhow::Result<Vec<ProjectEngine>> {
346 let projects = config::get_projects_with_paths()?;
347 let repos: Vec<&str> = projects.iter().map(|(r, _)| r.as_str()).collect();
348 tracing::info!(repos = ?repos, "loading projects from config");
349
350 if projects.is_empty() {
351 let config_path = crate::home::config_path()
352 .map(|p| p.display().to_string())
353 .unwrap_or_else(|_| "~/.orch/config.yml".to_string());
354 anyhow::bail!(
355 "no projects configured — add repos under `projects:` in {}",
356 config_path
357 );
358 }
359
360 let mut engines = Vec::new();
361 let mut auth_failures = 0usize;
362 let mut network_failures = 0usize;
363
364 let store = Arc::new(TaskStore::open(&crate::store::default_db_path().await?).await?);
367
368 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
371
372 for (repo, project_dir) in projects {
373 tracing::info!(repo = %repo, "initializing project engine");
374
375 let backend: Arc<dyn ExternalBackend> =
377 Arc::new(crate::backends::github::GitHubBackend::new(repo.clone())?);
378
379 if let Err(e) = backend.health_check().await {
381 let err_str = e.to_string();
382 if err_str.contains("401")
383 || err_str.contains("Bad credentials")
384 || err_str.contains("authentication")
385 || err_str.contains("No GitHub token")
386 {
387 auth_failures += 1;
388 tracing::debug!(
389 repo = %repo,
390 error = %e,
391 "GitHub auth failed for {repo} — run `gh auth login`"
392 );
393 } else {
394 network_failures += 1;
395 tracing::debug!(
396 repo = %repo,
397 error = %e,
398 "GitHub unreachable for {repo} (network unavailable?)"
399 );
400 }
401 continue;
402 }
403 tracing::info!(repo = %repo, backend = backend.name(), "backend connected");
404
405 let task_manager = Arc::new(TaskManager::with_store(
407 backend.clone(),
408 store.clone(),
409 repo.clone(),
410 ));
411
412 let runner = Arc::new(runner::TaskRunner::new(repo.clone()).with_store(store.clone()));
414
415 engines.push(ProjectEngine {
416 repo,
417 project_dir,
418 backend,
419 task_manager,
420 runner,
421 store: store.clone(),
422 });
423 }
424
425 if engines.is_empty() {
426 if auth_failures > 0 && network_failures == 0 {
431 anyhow::bail!(
432 "GitHub auth failed for all configured projects ({auth_failures} project(s)) — run `gh auth login`"
433 );
434 } else if network_failures > 0 && auth_failures == 0 {
435 anyhow::bail!(
436 "GitHub unreachable for all configured projects ({network_failures} project(s)) — check network connectivity"
437 );
438 } else {
439 anyhow::bail!(
440 "all configured project backends failed health checks ({auth_failures} auth failures, {network_failures} network failures)"
441 );
442 }
443 }
444
445 Ok(engines)
446}
447
448async fn reconcile_startup_worktrees(project_engines: &[ProjectEngine]) -> anyhow::Result<()> {
449 for engine in project_engines {
450 let repo_root =
451 crate::engine::runner::worktree::resolve_main_repo(&engine.project_dir).await;
452 let repo_root_path = std::path::PathBuf::from(&repo_root);
453 let default_branch =
454 crate::engine::runner::worktree::detect_default_branch(&repo_root_path).await;
455 let worktrees = list_project_worktrees(&repo_root_path).await?;
456
457 if let Err(e) = Command::new("git")
460 .args(["-C", &repo_root.to_string_lossy(), "fetch", "origin"])
461 .output_with_context()
462 .await
463 {
464 tracing::warn!(repo = %engine.repo, err = %e, "startup git fetch origin failed, rebases may use stale refs");
465 }
466
467 let mut orphans_removed: u32 = 0;
468 let mut invalid_removed: u32 = 0;
469 let mut cleaned: u32 = 0;
470 let mut valid_kept: u32 = 0;
471
472 for worktree_dir in worktrees {
473 let Some(name) = worktree_dir.file_name().and_then(|n| n.to_str()) else {
474 continue;
475 };
476
477 if !validate_worktree_gitdir(&worktree_dir).await {
480 tracing::warn!(repo = %engine.repo, worktree = %worktree_dir.display(), "worktree has invalid or missing gitdir, removing");
481 remove_worktree_and_branch(name, &worktree_dir, Some(name), &repo_root_path, false)
482 .await;
483 invalid_removed += 1;
484 continue;
485 }
486
487 let Some(task_id) = task_id_from_worktree_name(name) else {
488 tracing::info!(repo = %engine.repo, worktree = %worktree_dir.display(), "orphan worktree without task id, removing");
489 remove_worktree_and_branch(name, &worktree_dir, Some(name), &repo_root_path, false)
490 .await;
491 orphans_removed += 1;
492 continue;
493 };
494
495 let Some(store_id) = engine.store.resolve_task_id(&engine.repo, &task_id).await? else {
496 tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), "worktree has no matching task, removing");
497 remove_worktree_and_branch(
498 &task_id,
499 &worktree_dir,
500 Some(name),
501 &repo_root_path,
502 false,
503 )
504 .await;
505 orphans_removed += 1;
506 continue;
507 };
508
509 let task = engine.store.get(store_id).await?;
510 let branch_name = if task.branch.is_empty() {
511 name
512 } else {
513 task.branch.as_str()
514 };
515
516 match task.status {
517 TaskStatus::New
518 | TaskStatus::Routed
519 | TaskStatus::InProgress
520 | TaskStatus::NeedsReview
521 | TaskStatus::InReview => {
522 tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), "rebasing startup worktree");
523 if let Err(e) =
524 rebase_worktree_on_origin_main(&worktree_dir, &default_branch).await
525 {
526 tracing::warn!(repo = %engine.repo, task_id = %task_id, err = %e, "startup rebase failed, resetting task");
531 abort_worktree_rebase(&worktree_dir).await;
532 let keep_remote = task.pr_number.is_some();
533 remove_worktree_and_branch(
534 &task_id,
535 &worktree_dir,
536 Some(branch_name),
537 &repo_root_path,
538 keep_remote,
539 )
540 .await;
541 match engine.store.resolve_task_id(&engine.repo, &task_id).await {
542 Ok(Some(reset_id)) => {
543 if let Err(e) = engine.store.reset_to_new(reset_id).await {
544 tracing::warn!(task_id, err = %e, "failed to reset task to new after rebase failure");
545 }
546 }
547 Ok(None) => {
548 tracing::warn!(
549 task_id,
550 "task not found in store during reconciliation"
551 );
552 }
553 Err(e) => {
554 tracing::warn!(task_id, err = %e, "failed to resolve task ID during reconciliation");
555 }
556 }
557 if let Err(e) = engine
558 .task_manager
559 .update_task_status(
560 &ExternalId(task_id.clone()),
561 crate::backends::Status::New,
562 )
563 .await
564 {
565 tracing::warn!(task_id, err = %e, "failed to update backend status during reconciliation");
566 }
567 cleaned += 1;
568 } else {
569 valid_kept += 1;
570 }
571 }
572 _ => {
573 let keep_remote =
574 task.status == TaskStatus::Blocked && task.pr_number.is_some();
575 tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), status = ?task.status, "terminal task worktree, removing");
576 remove_worktree_and_branch(
577 &task_id,
578 &worktree_dir,
579 Some(branch_name),
580 &repo_root_path,
581 keep_remote,
582 )
583 .await;
584 cleaned += 1;
585 }
586 }
587 }
588
589 tracing::info!(
590 repo = %engine.repo,
591 orphans_removed,
592 invalid_removed,
593 cleaned,
594 valid_kept,
595 "worktree reconciliation: {orphans_removed} orphans removed, {invalid_removed} invalid removed, {cleaned} cleaned, {valid_kept} valid kept"
596 );
597
598 let prune = Command::new("git")
601 .args(["-C", &repo_root.to_string_lossy(), "worktree", "prune"])
602 .output_with_context()
603 .await;
604 match prune {
605 Ok(output) if output.status.success() => {
606 tracing::debug!(repo = %engine.repo, "startup worktree prune completed");
607 }
608 Ok(output) => {
609 let stderr = String::from_utf8_lossy(&output.stderr);
610 tracing::warn!(repo = %engine.repo, err = %stderr, "startup worktree prune failed");
611 }
612 Err(e) => {
613 tracing::warn!(repo = %engine.repo, err = %e, "startup worktree prune failed");
614 }
615 }
616 }
617
618 Ok(())
619}
620
621async fn reconcile_startup_estimates(project_engines: &[ProjectEngine]) {
629 use crate::backends::ExternalId;
630
631 for engine in project_engines {
632 let tasks = match engine.store.list_external_tasks_with_estimates().await {
633 Ok(t) => t,
634 Err(e) => {
635 tracing::warn!(repo = %engine.repo, err = %e, "startup estimate reconciliation: store query failed");
636 continue;
637 }
638 };
639
640 if tasks.is_empty() {
641 continue;
642 }
643
644 let mut synced: u32 = 0;
645 let mut failed: u32 = 0;
646 for (external_id, estimate) in &tasks {
647 match engine
648 .backend
649 .sync_estimate_to_project(&ExternalId(external_id.clone()), *estimate)
650 .await
651 {
652 Ok(()) => synced += 1,
653 Err(e) => {
654 tracing::debug!(
655 repo = %engine.repo,
656 task_id = %external_id,
657 estimate,
658 err = %e,
659 "startup estimate reconciliation: sync failed"
660 );
661 failed += 1;
662 }
663 }
664 }
665
666 if synced > 0 || failed > 0 {
667 tracing::info!(
668 repo = %engine.repo,
669 synced,
670 failed,
671 "startup estimate reconciliation complete"
672 );
673 }
674 }
675}
676
677fn read_project_channel_config(project_dir: &std::path::Path) -> ProjectChannelConfig {
679 let config_path = project_dir.join(".orch.yml");
680 if !config_path.exists() {
681 return ProjectChannelConfig::default();
682 }
683 let content = match std::fs::read_to_string(&config_path) {
684 Ok(c) => c,
685 Err(e) => {
686 tracing::warn!(
687 path = %config_path.display(),
688 error = %e,
689 "failed to read .orch.yml; channel config will use defaults"
690 );
691 return ProjectChannelConfig::default();
692 }
693 };
694 let val: serde_norway::Value = match serde_norway::from_str(&content) {
695 Ok(v) => v,
696 Err(e) => {
697 tracing::warn!(
698 path = %config_path.display(),
699 error = %e,
700 "failed to parse .orch.yml; channel config will use defaults"
701 );
702 return ProjectChannelConfig::default();
703 }
704 };
705 let channels = match val.get("channels") {
706 Some(c) => c,
707 None => return ProjectChannelConfig::default(),
708 };
709 let get_str = |section: &str, key: &str| -> Option<String> {
710 channels
711 .get(section)
712 .and_then(|s| s.get(key))
713 .and_then(|v| v.as_str())
714 .map(String::from)
715 };
716 ProjectChannelConfig {
717 telegram_topic_id: get_str("telegram", "topic_id"),
718 telegram_bot_token: get_str("telegram", "bot_token"),
719 telegram_chat_id: get_str("telegram", "chat_id"),
720 discord_channel_id: get_str("discord", "channel_id"),
721 discord_bot_token: get_str("discord", "bot_token"),
722 discord_guild_id: get_str("discord", "guild_id"),
723 }
724}
725
726async fn fetch_latest_release_version() -> Option<String> {
731 let output = Command::new("gh")
732 .args([
733 "api",
734 "repos/gabrielkoerich/orch/releases/latest",
735 "--jq",
736 ".tag_name",
737 ])
738 .output()
739 .await
740 .ok()?;
741 if !output.status.success() {
742 return None;
743 }
744 let tag = String::from_utf8(output.stdout).ok()?;
745 let version = tag.trim().trim_start_matches('v').to_string();
746 if version.is_empty() {
747 None
748 } else {
749 Some(version)
750 }
751}
752
753async fn check_and_notify_upgrade(
762 store: &Arc<TaskStore>,
763 channels: &Arc<ChannelRegistry>,
764 current_version: &str,
765 check_interval: std::time::Duration,
766) {
767 let last_check_key = "upgrade:last_check_at";
768 let last_notified_key = "upgrade:last_notified_version";
769
770 let now = chrono::Utc::now().timestamp();
772 if let Ok(Some(last_check_str)) = store.kv_get(last_check_key).await {
773 if let Ok(last_check) = last_check_str.parse::<i64>() {
774 if now - last_check < check_interval.as_secs() as i64 {
775 return;
776 }
777 }
778 }
779
780 let _ = store.kv_set(last_check_key, &now.to_string()).await;
782
783 let latest = match fetch_latest_release_version().await {
784 Some(v) => v,
785 None => {
786 tracing::debug!("upgrade check: failed to fetch latest release version");
787 return;
788 }
789 };
790
791 if latest == current_version {
792 tracing::debug!(current = %current_version, "orch is up to date");
793 return;
794 }
795
796 tracing::warn!(
797 current_version = %current_version,
798 latest_version = %latest,
799 "orch upgrade available"
800 );
801
802 if let Ok(Some(last_notified)) = store.kv_get(last_notified_key).await {
807 if last_notified == latest {
808 tracing::debug!(latest = %latest, "upgrade already notified");
809 return;
810 }
811 }
812
813 let msg_telegram = format!(
814 "⚠️ <b>Orch Upgrade Available</b>\n\n\
815 Service: <code>{current_version}</code>\n\
816 Latest: <code>{latest}</code>\n\n\
817 Run: <code>brew update && brew upgrade orch && brew services restart orch</code>"
818 );
819
820 let msg_discord = format!(
821 "⚠️ **Orch Upgrade Available**\n\n\
822 Service: `{current_version}`\n\
823 Latest: `{latest}`\n\n\
824 Run: `brew update && brew upgrade orch && brew services restart orch`"
825 );
826
827 let msg_slack = format!(
828 "⚠️ *Orch Upgrade Available*\n\n\
829 Service: `{current_version}`\n\
830 Latest: `{latest}`\n\n\
831 Run: `brew update && brew upgrade orch && brew services restart orch`"
832 );
833
834 for channel in channels.iter() {
835 let (body, metadata, topic_id) = match channel.name() {
836 "telegram" => (
837 msg_telegram.clone(),
838 serde_json::json!({"preformatted_html": true}),
839 None,
840 ),
841 "discord" => (msg_discord.clone(), serde_json::json!({}), None),
842 "slack" => (msg_slack.clone(), serde_json::json!({}), None),
843 _ => continue,
844 };
845
846 let msg = OutgoingMessage {
847 thread_id: "upgrade".to_string(),
848 body,
849 reply_to: None,
850 metadata,
851 topic_id,
852 };
853
854 if let Err(e) = channel.send(&msg).await {
855 tracing::warn!(
856 channel = channel.name(),
857 ?e,
858 "failed to send upgrade notification"
859 );
860 }
861 }
862
863 let _ = store.kv_set(last_notified_key, &latest).await;
865}
866
867async fn send_startup_escalation_notification(elapsed_secs: u64) {
875 let duration = crate::channels::notification::format_duration(elapsed_secs as f64);
876
877 let msg_telegram = format!(
878 "🚨 <b>Orch startup blocked — GitHub unreachable</b>\n\n\
879 The engine has been retrying backend connections for {}\n\
880 without success. All task processing, routing, and scheduled\n\
881 jobs are paused. Check service logs with `orch log -f`.",
882 duration
883 );
884
885 let msg_discord = format!(
886 "🚨 **Orch startup blocked — GitHub unreachable**\n\n\
887 The engine has been retrying backend connections for {}\n\
888 without success. All task processing, routing, and scheduled\n\
889 jobs are paused. Check service logs with `orch log -f`.",
890 duration
891 );
892
893 let msg_slack = format!(
894 "🚨 *Orch startup blocked — GitHub unreachable*\n\n\
895 The engine has been retrying backend connections for {}\n\
896 without success. All task processing, routing, and scheduled\n\
897 jobs are paused. Check service logs with `orch log -f`.",
898 duration
899 );
900
901 let tg_token = crate::config::get("channels.telegram.bot_token");
903 let tg_chat_id = crate::config::get("channels.telegram.chat_id");
904 if let (Ok(token), Ok(chat_id)) = (&tg_token, &tg_chat_id) {
905 if !token.is_empty() && !chat_id.is_empty() {
906 if let Ok(channel) = TelegramChannel::new(token.clone(), Some(chat_id.clone())) {
907 let msg = OutgoingMessage {
908 thread_id: "startup_escalation".to_string(),
909 body: msg_telegram.clone(),
910 reply_to: None,
911 metadata: serde_json::json!({ "preformatted_html": true }),
912 topic_id: crate::config::get("channels.telegram.general_topic_id").ok(),
913 };
914 if let Err(e) = channel.send(&msg).await {
915 tracing::warn!(?e, "failed to send startup escalation via telegram");
916 } else {
917 tracing::info!("startup escalation notification sent to telegram");
918 }
919 }
920 }
921 }
922
923 if let Ok(token) = crate::config::get("channels.discord.bot_token") {
925 let channel_id = crate::config::get("channels.discord.channel_id")
926 .or_else(|_| crate::config::get("channels.discord.general_channel_id"));
927 if !token.is_empty() {
928 if let Some(cid) = channel_id.ok().filter(|s| !s.is_empty()) {
929 let shard_id = crate::config::get("channels.discord.shard_id")
930 .ok()
931 .and_then(|v| v.parse::<u64>().ok())
932 .unwrap_or(0);
933 let shard_count = crate::config::get("channels.discord.shard_count")
934 .ok()
935 .and_then(|v| v.parse::<u64>().ok())
936 .unwrap_or(1);
937 if let Ok(gateway) = DiscordGateway::new(token, Some(cid), shard_id, shard_count) {
938 let msg = OutgoingMessage {
939 thread_id: "startup_escalation".to_string(),
940 body: msg_discord.clone(),
941 reply_to: None,
942 metadata: serde_json::Value::Null,
943 topic_id: None,
944 };
945 if let Err(e) = gateway.send(&msg).await {
946 tracing::warn!(?e, "failed to send startup escalation via discord");
947 } else {
948 tracing::info!("startup escalation notification sent to discord");
949 }
950 }
951 }
952 }
953 }
954
955 if let Ok(token) = crate::config::get("channels.slack.bot_token") {
957 let channel_id = crate::config::get("channels.slack.channel_id");
958 if !token.is_empty() {
959 if let Ok(cid) = channel_id {
960 if let Ok(channel) = SlackChannel::new(token, Some(cid)) {
961 let msg = OutgoingMessage {
962 thread_id: "startup_escalation".to_string(),
963 body: msg_slack.clone(),
964 reply_to: None,
965 metadata: serde_json::Value::Null,
966 topic_id: None,
967 };
968 if let Err(e) = channel.send(&msg).await {
969 tracing::warn!(?e, "failed to send startup escalation via slack");
970 } else {
971 tracing::info!("startup escalation notification sent to slack");
972 }
973 }
974 }
975 }
976 }
977}
978
979pub async fn serve() -> anyhow::Result<()> {
983 let killed = crate::home::kill_other_orch_serve_processes(std::time::Duration::from_secs(30));
984 if killed > 0 {
985 tracing::info!(count = killed, "terminated stale orch serve processes");
986 }
987
988 tracing::info!("orch engine starting");
989
990 if let Err(e) = crate::home::init_dirs().await {
994 tracing::warn!("failed to initialize orch directories: {e}");
995 }
996
997 if let Err(e) = crate::config::warm_cache().await {
1000 tracing::warn!("failed to warm config cache: {e}");
1001 }
1002
1003 let mut config = EngineConfig::from_config();
1004
1005 tracing::info!("internal database ready");
1006
1007 let startup_escalation_secs = config.startup_failure_escalation_secs;
1016 let mut project_engines = {
1017 let mut delay_secs = 5u64;
1018 let mut attempt = 0u32;
1019 let mut escalated = false;
1020 let start = std::time::Instant::now();
1021 loop {
1022 attempt += 1;
1023 match init_project_engines().await {
1024 Ok(engines) => break engines,
1025 Err(e) => {
1026 let elapsed = start.elapsed().as_secs();
1027
1028 if startup_escalation_secs > 0
1034 && !escalated
1035 && elapsed >= startup_escalation_secs
1036 {
1037 escalated = true;
1038 tracing::error!(
1039 delay_secs,
1040 attempt,
1041 elapsed_secs = elapsed,
1042 threshold_secs = startup_escalation_secs,
1043 "project backends unavailable for {} — escalating startup failure: {e}",
1044 crate::channels::notification::format_duration(elapsed as f64)
1045 );
1046 send_startup_escalation_notification(elapsed).await;
1050 } else {
1051 tracing::warn!(
1052 delay_secs,
1053 attempt,
1054 "project backends unavailable, retrying: {e}"
1055 );
1056 }
1057
1058 tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
1059 delay_secs = (delay_secs * 2).min(120);
1060 }
1061 }
1062 }
1063 };
1064
1065 tracing::info!(
1066 project_count = project_engines.len(),
1067 "initialized project engines"
1068 );
1069
1070 let active_repos: std::collections::HashSet<String> =
1074 project_engines.iter().map(|e| e.repo.clone()).collect();
1075
1076 let event_bus = events::EventBus::new(256);
1078 if let Err(e) = event_bus.start_ws_server().await {
1079 tracing::warn!(
1080 ?e,
1081 "failed to start event websocket server, continuing without it"
1082 );
1083 }
1084
1085 {
1089 let current = env!("ORCH_VERSION").to_string();
1090 tokio::spawn(async move {
1091 if let Some(latest) = fetch_latest_release_version().await {
1092 if latest != current {
1093 tracing::warn!(
1094 current_version = %current,
1095 latest_version = %latest,
1096 "orch service is behind the latest release — run: brew update && brew upgrade orch && brew services restart orch"
1097 );
1098 }
1099 }
1100 });
1101 }
1102
1103 for engine in &mut project_engines {
1105 engine.task_manager = Arc::new(TaskManager::with_events(
1106 engine.backend.clone(),
1107 engine.store.clone(),
1108 engine.repo.clone(),
1109 event_bus.sender(),
1110 ));
1111 }
1112
1113 if let Err(e) = reconcile_startup_worktrees(&project_engines).await {
1114 tracing::warn!(err = %e, "startup worktree reconciliation failed");
1115 }
1116
1117 for engine in &project_engines {
1121 crate::engine::sync::clear_issues_last_ingested(&engine.store, &engine.repo).await;
1122 }
1123
1124 {
1125 let engines_snapshot: Vec<ProjectEngine> = project_engines.clone();
1129 tokio::spawn(async move {
1130 reconcile_startup_estimates(&engines_snapshot).await;
1131 });
1132 }
1133
1134 let global_channel_config = GlobalChannelConfig {
1136 telegram_general_topic_id: crate::config::get("channels.telegram.general_topic_id").ok(),
1137 discord_general_channel_id: crate::config::get("channels.discord.general_channel_id").ok(),
1138 control_telegram_topic_id: crate::config::get("control.channels.telegram.topic_id").ok(),
1139 control_discord_channel_id: crate::config::get("control.channels.discord.channel_id").ok(),
1140 };
1141 let project_channel_configs: Vec<(String, ProjectChannelConfig)> = project_engines
1142 .iter()
1143 .map(|e| {
1144 let cfg = read_project_channel_config(&e.project_dir);
1145 (e.repo.clone(), cfg)
1146 })
1147 .collect();
1148 let channel_router = Arc::new(ChannelRouter::new(
1149 &global_channel_config,
1150 &project_channel_configs,
1151 ));
1152 tracing::info!(
1153 projects = ?channel_router.projects(),
1154 "channel router initialized"
1155 );
1156
1157 let tmux = Arc::new(TmuxManager::new());
1159
1160 let transport = Arc::new(Transport::new());
1162
1163 let capture = Arc::new(CaptureService::new(transport.clone()));
1165 let capture_for_tick = capture.clone();
1166 tokio::spawn(async move {
1167 capture.start().await;
1168 });
1169
1170 let mut channel_registry = ChannelRegistry::new();
1172
1173 if let Ok(token) = crate::config::get("channels.telegram.bot_token") {
1175 if !token.is_empty() {
1176 let chat_id = crate::config::get("channels.telegram.chat_id").ok();
1177 match TelegramChannel::new(token, chat_id) {
1178 Ok(telegram) => {
1179 if let Err(e) = telegram.health_check().await {
1180 tracing::warn!(?e, "telegram channel health check failed, skipping");
1181 } else {
1182 channel_registry.register(Box::new(telegram));
1183 tracing::info!("telegram channel registered");
1184 }
1185 }
1186 Err(e) => {
1187 tracing::warn!(?e, "failed to create telegram channel, skipping");
1188 }
1189 }
1190 }
1191 }
1192
1193 if let Ok(token) = crate::config::get("channels.discord.bot_token") {
1195 if !token.is_empty() {
1196 let channel_id = crate::config::get("channels.discord.channel_id").ok();
1197 let shard_id = crate::config::get("channels.discord.shard_id")
1198 .ok()
1199 .and_then(|v| v.parse::<u64>().ok())
1200 .unwrap_or(0);
1201 let shard_count = crate::config::get("channels.discord.shard_count")
1202 .ok()
1203 .and_then(|v| v.parse::<u64>().ok())
1204 .unwrap_or(1);
1205 match DiscordGateway::new(token, channel_id, shard_id, shard_count) {
1206 Ok(discord) => {
1207 if let Err(e) = discord.health_check().await {
1208 tracing::warn!(?e, "discord gateway health check failed, skipping");
1209 } else {
1210 channel_registry.register(Box::new(discord));
1211 tracing::info!(shard_id, shard_count, "discord gateway registered");
1212 }
1213 }
1214 Err(e) => {
1215 tracing::warn!(?e, "failed to create discord gateway, skipping");
1216 }
1217 }
1218 }
1219 }
1220
1221 if let Ok(token) = crate::config::get("channels.slack.bot_token") {
1223 if !token.is_empty() {
1224 let channel_id = crate::config::get("channels.slack.channel_id").ok();
1225 match SlackChannel::new(token, channel_id) {
1226 Ok(slack) => {
1227 if let Err(e) = slack.health_check().await {
1228 tracing::warn!(?e, "slack channel health check failed, skipping");
1229 } else {
1230 channel_registry.register(Box::new(slack));
1231 tracing::info!("slack channel registered");
1232 }
1233 }
1234 Err(e) => {
1235 tracing::warn!(?e, "failed to create slack channel, skipping");
1236 }
1237 }
1238 }
1239 }
1240
1241 let tmux_channel = TmuxChannel::with_transport(transport.clone());
1243 channel_registry.register(Box::new(tmux_channel));
1244 tracing::info!("tmux channel registered");
1245
1246 let mut channel_receivers: Vec<tokio::sync::mpsc::Receiver<IncomingMessage>> = Vec::new();
1248 for channel in channel_registry.iter() {
1249 match channel.start().await {
1250 Ok(rx) => {
1251 tracing::info!(channel = channel.name(), "channel started");
1252 channel_receivers.push(rx);
1253 }
1254 Err(e) => {
1255 tracing::warn!(channel = channel.name(), ?e, "failed to start channel");
1256 }
1257 }
1258 }
1259
1260 let channel_registry = Arc::new(channel_registry);
1262
1263 let transport_for_messages = transport.clone();
1265 let tmux_for_messages = tmux.clone();
1266 let capture_for_messages = capture_for_tick.clone();
1267 let channels_for_messages = channel_registry.clone();
1268 let engine_refs: Vec<EngineRef> = project_engines
1270 .iter()
1271 .map(|e| {
1272 (
1273 e.repo.clone(),
1274 e.backend.clone(),
1275 e.task_manager.clone(),
1276 Some(e.store.clone()),
1277 )
1278 })
1279 .collect();
1280 let pending_picks: channel_handler::PendingPicks =
1282 std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
1283 let router_for_messages = channel_router.clone();
1284 for mut rx in channel_receivers {
1285 let transport = transport_for_messages.clone();
1286 let tmux = tmux_for_messages.clone();
1287 let capture = capture_for_messages.clone();
1288 let channels = channels_for_messages.clone();
1289 let engine_refs = engine_refs.clone();
1290 let ch_router = router_for_messages.clone();
1291 let picks = pending_picks.clone();
1292 tokio::spawn(async move {
1293 while let Some(msg) = rx.recv().await {
1294 tracing::debug!(channel = %msg.channel, thread = %msg.thread_id, "received message from channel");
1295 channel_handler::handle_channel_message(
1296 msg,
1297 &transport,
1298 &tmux,
1299 &capture,
1300 &channels,
1301 &engine_refs,
1302 &ch_router,
1303 &picks,
1304 )
1305 .await;
1306 }
1307 });
1308 }
1309
1310 {
1314 let restart_channels = channel_registry.clone();
1315 let restart_store = project_engines.first().map(|e| e.store.clone());
1316 if let Some(store) = restart_store {
1317 tokio::spawn(async move {
1318 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1319 match store.kv_get("control:restart_pending").await {
1320 Ok(Some(conv_key)) => {
1321 if let Some((ch_name, thread_id, topic_id)) =
1322 crate::channels::transport::parse_conversation_key(&conv_key)
1323 {
1324 for ch in restart_channels.iter() {
1325 if ch.name() == ch_name {
1326 let msg = OutgoingMessage {
1327 thread_id: thread_id.to_string(),
1328 body: "orch restarted successfully.".to_string(),
1329 reply_to: None,
1330 metadata: serde_json::json!({}),
1331 topic_id: topic_id.map(|t| t.to_string()),
1332 };
1333 if let Err(e) = ch.send(&msg).await {
1334 tracing::warn!(
1335 channel = ch_name,
1336 error = %e,
1337 "failed to send restart confirmation"
1338 );
1339 }
1340 break;
1341 }
1342 }
1343 }
1344 if let Err(e) = store.kv_delete("control:restart_pending").await {
1345 tracing::warn!(error = %e, "failed to clear control:restart_pending");
1346 }
1347 }
1348 Ok(None) => {} Err(e) => {
1350 tracing::warn!(error = %e, "failed to read control:restart_pending");
1351 }
1352 }
1353 });
1354 }
1355 }
1356
1357 {
1368 let mut notification_rx = transport.subscribe_notifications();
1369 let channels = channel_registry.clone();
1370 let notif_router = channel_router.clone();
1371 let notif_store: Option<Arc<TaskStore>> = project_engines.first().map(|e| e.store.clone());
1373 tokio::spawn(async move {
1374 loop {
1375 match notification_rx.recv().await {
1376 Ok(notification) => {
1377 let level = NotificationLevel::from_config();
1378 if !level.should_notify(¬ification.status) {
1379 tracing::debug!(
1380 task_id = %notification.task_id,
1381 status = %notification.status,
1382 "notification suppressed by level={:?}",
1383 level
1384 );
1385 continue;
1386 }
1387
1388 tracing::info!(
1389 task_id = %notification.task_id,
1390 status = %notification.status,
1391 repo = ?notification.repo,
1392 "routing notification to channels"
1393 );
1394
1395 let mut routed = false;
1397
1398 if let Some(ref target) = notification.notify_target {
1401 let telegram = channels.iter().find(|c| c.name() == "telegram");
1402 if let Some(channel) = telegram {
1403 let body = notification.format_telegram();
1404 let metadata = serde_json::json!({
1405 "preformatted_html": true,
1406 "chat_id_override": target
1407 });
1408 let msg = OutgoingMessage {
1409 thread_id: notification.task_id.clone(),
1410 body,
1411 reply_to: None,
1412 metadata,
1413 topic_id: None,
1414 };
1415 if let Err(e) = channel.send(&msg).await {
1416 tracing::warn!(
1417 task_id = %notification.task_id,
1418 target,
1419 ?e,
1420 "failed to send to notify_target"
1421 );
1422 } else {
1423 routed = true;
1424 }
1425 }
1426 }
1427
1428 if routed {
1429 } else if let Some(repo) = notification.repo.as_deref() {
1431 let mut sent: std::collections::HashSet<(String, Option<String>)> =
1437 std::collections::HashSet::new();
1438
1439 for channel in channels.iter() {
1441 let ch_name = channel.name();
1442 if let Some(topic_id) =
1443 notif_router.target_for_project(repo, ch_name)
1444 {
1445 let body = match ch_name {
1446 "telegram" => notification.format_telegram(),
1447 "discord" => notification.format_discord(),
1448 "slack" => notification.format_slack(),
1449 _ => continue,
1450 };
1451 let metadata = if ch_name == "telegram" {
1452 serde_json::json!({"preformatted_html": true})
1453 } else {
1454 serde_json::json!({})
1455 };
1456 let resolved_topic = Some(topic_id.to_string());
1457 let msg = OutgoingMessage {
1458 thread_id: notification.task_id.clone(),
1459 body,
1460 reply_to: None,
1461 metadata,
1462 topic_id: resolved_topic.clone(),
1463 };
1464 if let Err(e) = channel.send(&msg).await {
1465 tracing::warn!(
1466 channel = ch_name,
1467 task_id = %notification.task_id,
1468 ?e,
1469 "failed to send to dedicated channel"
1470 );
1471 } else {
1472 sent.insert((ch_name.to_string(), resolved_topic));
1473 routed = true;
1474 }
1475 }
1476 }
1477
1478 if let Some(store) = ¬if_store {
1480 match store.list_subscribers_for_repo(repo).await {
1481 Ok(subscribers) => {
1482 for (ch_name, thread_id, topic_id) in subscribers {
1483 let channel =
1484 channels.iter().find(|c| c.name() == ch_name);
1485 let Some(channel) = channel else {
1486 continue;
1487 };
1488 let resolved_topic = if topic_id.is_empty() {
1489 None
1490 } else {
1491 Some(topic_id.clone())
1492 };
1493 if sent.contains(&(
1496 ch_name.clone(),
1497 resolved_topic.clone(),
1498 )) {
1499 tracing::debug!(
1500 channel = %ch_name,
1501 task_id = %notification.task_id,
1502 topic_id = ?resolved_topic,
1503 "skipping duplicate subscriber dispatch — destination already received from dedicated path"
1504 );
1505 continue;
1506 }
1507 let body = notification.format_with_project(&ch_name);
1508 let metadata = if ch_name == "telegram" {
1509 serde_json::json!({"preformatted_html": true})
1510 } else {
1511 serde_json::json!({})
1512 };
1513 let msg = OutgoingMessage {
1514 thread_id: thread_id.clone(),
1515 body,
1516 reply_to: None,
1517 metadata,
1518 topic_id: resolved_topic.clone(),
1519 };
1520 if let Err(e) = channel.send(&msg).await {
1521 tracing::warn!(
1522 channel = %ch_name,
1523 task_id = %notification.task_id,
1524 ?e,
1525 "failed to send to subscribed channel"
1526 );
1527 } else {
1528 sent.insert((ch_name.clone(), resolved_topic));
1529 routed = true;
1530 }
1531 }
1532 }
1533 Err(e) => {
1534 tracing::warn!(
1535 repo,
1536 ?e,
1537 "failed to list subscribers for repo"
1538 );
1539 }
1540 }
1541 }
1542 }
1543
1544 if !routed {
1546 for channel in channels.iter() {
1547 let (body, should_send, is_telegram) = match channel.name() {
1548 "telegram" => (notification.format_telegram(), true, true),
1549 "discord" => (notification.format_discord(), true, false),
1550 "slack" => (notification.format_slack(), true, false),
1551 _ => (String::new(), false, false),
1554 };
1555
1556 if !should_send {
1557 continue;
1558 }
1559
1560 let metadata = if is_telegram {
1561 serde_json::json!({"preformatted_html": true})
1562 } else {
1563 serde_json::json!({})
1564 };
1565 let msg = OutgoingMessage {
1566 thread_id: notification.task_id.clone(),
1567 body,
1568 reply_to: None,
1569 metadata,
1570 topic_id: None,
1571 };
1572
1573 if let Err(e) = channel.send(&msg).await {
1574 tracing::warn!(
1575 channel = channel.name(),
1576 task_id = %notification.task_id,
1577 ?e,
1578 "failed to send notification"
1579 );
1580 }
1581 }
1582 }
1583 }
1584 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1585 tracing::warn!(missed = n, "notification receiver lagged");
1586 }
1587 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1588 tracing::debug!("notification channel closed");
1589 break;
1590 }
1591 }
1592 }
1593 });
1594 tracing::info!("notification dispatcher started");
1595 }
1596
1597 let webhook_notify = Arc::new(Notify::new());
1599
1600 let webhook_port: Option<u16>;
1604 let mut webhook_healthy: bool;
1605 let mut last_webhook_health_check = std::time::Instant::now();
1606 let webhook_enabled = crate::config::get("webhook.enabled")
1608 .map(|v| v == "true")
1609 .unwrap_or(false);
1610
1611 let webhook_status = Arc::new(tokio::sync::Mutex::new(
1613 crate::webhook_status::WebhookStatus::default(),
1614 ));
1615
1616 if webhook_enabled {
1617 let port: u16 = crate::config::get("webhook.port")
1618 .ok()
1619 .and_then(|v| v.parse().ok())
1620 .unwrap_or(8080);
1621 webhook_port = Some(port);
1622 let secret = crate::config::get("webhook.secret").unwrap_or_default();
1623 let webhook_repo = project_engines
1624 .first()
1625 .map(|e| e.repo.clone())
1626 .unwrap_or_default();
1627
1628 let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(64);
1629
1630 let to_save = {
1632 let mut s = webhook_status.lock().await;
1633 s.configured = true;
1634 s.port = Some(port);
1635 s.healthy = false;
1636 s.fallback_mode = false;
1637 s.clone()
1638 };
1639 to_save.save().await;
1640
1641 const WEBHOOK_MAX_ATTEMPTS: u32 = 5;
1645 let status_for_spawn = webhook_status.clone();
1646 tokio::spawn(async move {
1647 use crate::channels::github::{is_transient_bind_error, webhook_backoff_delay};
1648 let mut attempt = 0u32;
1649 loop {
1650 attempt += 1;
1651 tracing::info!(attempt, port, "attempting to start webhook server");
1652 match start_webhook_server(port, secret.clone(), webhook_repo.clone(), tx.clone())
1653 .await
1654 {
1655 Ok(_) => {
1656 tracing::info!(attempt, "webhook server exited cleanly");
1657 break;
1658 }
1659 Err(e) => {
1660 let reason = e.to_string();
1661 tracing::error!(attempt, %reason, "webhook server failed");
1662 if attempt >= WEBHOOK_MAX_ATTEMPTS || !is_transient_bind_error(&e) {
1663 let kind = if attempt >= WEBHOOK_MAX_ATTEMPTS {
1664 "max attempts reached"
1665 } else {
1666 "non-transient error"
1667 };
1668 tracing::error!(
1669 attempt,
1670 %reason,
1671 kind,
1672 orch_webhook_in_fallback = true,
1673 "webhook server giving up, switching to polling fallback"
1674 );
1675 let to_save = {
1676 let mut s = status_for_spawn.lock().await;
1677 s.fallback_mode = true;
1678 s.healthy = false;
1679 s.last_failure_reason = Some(reason);
1680 s.startup_attempts = attempt;
1681 s.clone()
1682 };
1683 to_save.save().await;
1684 break;
1685 }
1686 let delay = webhook_backoff_delay(attempt);
1687 tracing::warn!(
1688 attempt,
1689 delay_ms = delay.as_millis(),
1690 %reason,
1691 "webhook bind failed (transient), retrying with backoff"
1692 );
1693 {
1694 let to_save = {
1695 let mut s = status_for_spawn.lock().await;
1696 s.startup_attempts = attempt;
1697 s.last_failure_reason = Some(reason);
1698 s.clone()
1699 };
1700 to_save.save().await;
1701 }
1702 tokio::time::sleep(delay).await;
1703 }
1704 }
1705 }
1706 });
1707
1708 let notify = webhook_notify.clone();
1710 let transport_for_webhook = transport.clone();
1711 tokio::spawn(async move {
1712 while let Some(msg) = rx.recv().await {
1713 tracing::info!(
1714 channel = %msg.channel,
1715 thread = %msg.thread_id,
1716 event = %msg.metadata.get("event").and_then(|v| v.as_str()).unwrap_or("unknown"),
1717 action = %msg.metadata.get("action").and_then(|v| v.as_str()).unwrap_or("unknown"),
1718 "webhook event received, triggering immediate tick"
1719 );
1720 let route = transport_for_webhook.route(&msg).await;
1721 tracing::info!(
1722 channel = %msg.channel,
1723 route = ?route,
1724 "webhook event routed"
1725 );
1726 notify.notify_one();
1728 }
1729 });
1730
1731 webhook_healthy = true;
1732 tracing::info!(port, orch_webhook_up = true, "webhook server started");
1733 } else {
1734 webhook_port = None;
1735 webhook_healthy = false;
1736 {
1737 let to_save = {
1738 let mut s = webhook_status.lock().await;
1739 s.configured = false;
1740 s.fallback_mode = true;
1741 s.clone()
1742 };
1743 to_save.save().await;
1744 }
1745 tracing::info!(
1746 orch_webhook_in_fallback = true,
1747 "webhook server disabled, using polling fallback mode"
1748 );
1749 }
1750
1751 let router = Arc::new(RwLock::new(
1758 match tokio::task::spawn_blocking(Router::from_config).await {
1759 Ok(r) => r,
1760 Err(e) => {
1761 tracing::error!(?e, "router init panicked in spawn_blocking, using default");
1762 tokio::task::spawn_blocking(|| Router::new(RouterConfig::default()))
1763 .await
1764 .unwrap_or_else(|e2| {
1765 panic!("router default init also panicked in spawn_blocking — cannot start engine without a router: {e2:?}");
1766 })
1767 }
1768 },
1769 ));
1770 {
1771 let r = router.read().await;
1772 tracing::info!(
1773 mode = %r.config.mode,
1774 agents = ?r.available_agents,
1775 fallback = %r.config.fallback_executor,
1776 "router initialized"
1777 );
1778 }
1779
1780 let semaphore = Arc::new(Semaphore::new(config.max_parallel));
1782
1783 let dispatching: Arc<dashmap::DashMap<String, String>> = Arc::new(dashmap::DashMap::new());
1790
1791 let auto_merge_in_flight: Arc<dashmap::DashSet<String>> = Arc::new(dashmap::DashSet::new());
1795
1796 let mut config_rx = crate::config::subscribe();
1798
1799 let mut last_sync = std::time::Instant::now();
1801
1802 let mut last_upgrade_check: Option<std::time::Instant> = None;
1804
1805 let (weight_tx, mut weight_rx) = mpsc::channel::<WeightSignal>(64);
1807
1808 for engine in &project_engines {
1814 let in_review_from_store = engine
1816 .store
1817 .list_by_status(&engine.repo, crate::store::TaskStatus::InReview)
1818 .await
1819 .unwrap_or_default()
1820 .iter()
1821 .map(crate::engine::tasks::store_task_to_external)
1822 .collect::<Vec<_>>();
1823 if !in_review_from_store.is_empty() {
1824 let in_review = &in_review_from_store;
1825 for task in in_review {
1826 let session_expected =
1827 review_session_expected(&engine.store, &engine.repo, &task.id.0).await;
1828 let age_minutes = chrono::DateTime::parse_from_rfc3339(&task.updated_at)
1829 .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_minutes())
1830 .unwrap_or(i64::MAX);
1831 if !session_expected && age_minutes < 10 {
1832 continue;
1833 }
1834 if let Err(e) = engine
1835 .task_manager
1836 .update_task_status(&task.id, Status::NeedsReview)
1837 .await
1838 {
1839 tracing::warn!(
1840 task_id = task.id.0,
1841 err = %e,
1842 "failed to reset stale InReview task on startup"
1843 );
1844 } else {
1845 set_review_session_expected(&engine.store, &engine.repo, &task.id.0, false)
1846 .await;
1847 }
1848 }
1849 if !in_review.is_empty() {
1850 tracing::info!(
1851 repo = %engine.repo,
1852 count = in_review.len(),
1853 "reset stale InReview tasks to NeedsReview on startup"
1854 );
1855 }
1856 }
1857
1858 use crate::store::TaskStatus as DbStatus;
1862 if let Ok(internal_in_review) = engine
1863 .task_manager
1864 .list_internal_by_status(DbStatus::InReview)
1865 .await
1866 {
1867 for task in &internal_in_review {
1868 let task_id = task.id.0.clone();
1869 let session_expected =
1870 review_session_expected(&engine.store, &engine.repo, &task_id).await;
1871 let age_minutes = chrono::DateTime::parse_from_rfc3339(&task.updated_at)
1872 .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_minutes())
1873 .unwrap_or(i64::MAX);
1874 if !session_expected && age_minutes < 10 {
1875 continue;
1876 }
1877 if let Err(e) = engine
1878 .task_manager
1879 .update_task_status(&ExternalId(task_id.clone()), Status::NeedsReview)
1880 .await
1881 {
1882 tracing::warn!(
1883 task_id,
1884 err = %e,
1885 "failed to reset stale internal InReview task on startup"
1886 );
1887 } else {
1888 set_review_session_expected(&engine.store, &engine.repo, &task_id, false).await;
1889 }
1890 }
1891 if !internal_in_review.is_empty() {
1892 tracing::info!(
1893 repo = %engine.repo,
1894 count = internal_in_review.len(),
1895 "reset stale internal InReview tasks to NeedsReview on startup"
1896 );
1897 }
1898 }
1899 }
1900
1901 for engine in &project_engines {
1903 subscribers::dispatch::spawn(
1904 event_bus.subscribe(),
1905 engine.backend.clone(),
1906 tmux.clone(),
1907 engine.runner.clone(),
1908 capture_for_tick.clone(),
1909 semaphore.clone(),
1910 engine.task_manager.clone(),
1911 weight_tx.clone(),
1912 router.clone(),
1913 dispatching.clone(),
1914 engine.store.clone(),
1915 engine.repo.clone(),
1916 );
1917 }
1918 tracing::info!("dispatch subscriber started");
1919
1920 for engine in &project_engines {
1922 subscribers::review::spawn(
1923 event_bus.subscribe(),
1924 engine.backend.clone(),
1925 tmux.clone(),
1926 semaphore.clone(),
1927 engine.task_manager.clone(),
1928 router.clone(),
1929 dispatching.clone(),
1930 engine.store.clone(),
1931 engine.repo.clone(),
1932 );
1933 }
1934 tracing::info!("review subscriber started");
1935
1936 for engine in &project_engines {
1938 subscribers::unblock::spawn(
1939 event_bus.subscribe(),
1940 engine.backend.clone(),
1941 engine.task_manager.clone(),
1942 engine.store.clone(),
1943 engine.repo.clone(),
1944 );
1945 }
1946 tracing::info!("unblock subscriber started");
1947
1948 subscribers::notify::spawn(event_bus.subscribe(), transport.clone());
1951 tracing::info!("notify subscriber started");
1952
1953 tracing::info!(
1955 tick = ?config.tick_interval,
1956 sync = ?config.sync_interval,
1957 parallel = config.max_parallel,
1958 "entering main loop"
1959 );
1960 let mut interval = tokio::time::interval(config.tick_interval);
1961
1962 let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
1964 let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
1965
1966 let sync_in_progress = Arc::new(std::sync::atomic::AtomicBool::new(false));
1968
1969 let last_tick_epoch = Arc::new(std::sync::atomic::AtomicU64::new(
1972 std::time::SystemTime::now()
1973 .duration_since(std::time::UNIX_EPOCH)
1974 .unwrap_or_default()
1975 .as_secs(),
1976 ));
1977 {
1978 let last_tick_epoch = Arc::clone(&last_tick_epoch);
1979 let tick_interval_secs = config.tick_interval.as_secs();
1980 tokio::spawn(async move {
1981 let mut watchdog_interval = tokio::time::interval(std::time::Duration::from_secs(30));
1983 let mut suspend_detector = crate::engine::suspend::SuspendDetector::new();
1988 loop {
1989 watchdog_interval.tick().await;
1990 let own_gap = suspend_detector.check();
1991 let last = last_tick_epoch.load(std::sync::atomic::Ordering::Relaxed);
1992 let now = std::time::SystemTime::now()
1993 .duration_since(std::time::UNIX_EPOCH)
1994 .unwrap_or_default()
1995 .as_secs();
1996 let stale_secs = now.saturating_sub(last);
1997 let threshold = (tick_interval_secs * 6).max(60);
1999 if stale_secs > threshold {
2000 let shared_gap = crate::engine::suspend::gap_detected_within(
2007 std::time::Duration::from_secs(stale_secs),
2008 );
2009 let gap = match (own_gap, shared_gap) {
2010 (Some(a), Some(b)) => Some(a.max(b)),
2011 (Some(a), None) => Some(a),
2012 (None, Some(b)) => Some(b),
2013 (None, None) => None,
2014 };
2015 if let Some(gap) = gap {
2016 let gap_secs = gap.num_seconds() as u64;
2017 if gap_secs >= stale_secs {
2018 tracing::info!(
2019 stale_secs,
2020 threshold,
2021 suspend_gap_secs = gap_secs,
2022 "tick loop stale window explained by host suspend/resume, not a stall"
2023 );
2024 } else {
2025 tracing::error!(
2026 stale_secs,
2027 threshold,
2028 suspend_gap_secs = gap_secs,
2029 "WATCHDOG: tick loop has not completed a tick in {}s (threshold {}s) — suspend gap of {}s only partially explains the stall",
2030 stale_secs,
2031 threshold,
2032 gap_secs,
2033 );
2034 }
2035 } else {
2036 tracing::error!(
2037 stale_secs,
2038 threshold,
2039 "WATCHDOG: tick loop has not completed a tick in {}s (threshold {}s) — possible stall",
2040 stale_secs,
2041 threshold,
2042 );
2043 }
2044 }
2045 }
2046 });
2047 }
2048
2049 loop {
2050 tokio::select! {
2051 _ = interval.tick() => {
2052 let tick_start = std::time::Instant::now();
2053
2054 if let Some(gap) = crate::engine::suspend::checkpoint() {
2058 tracing::info!(
2059 gap_secs = gap.num_seconds(),
2060 "host suspend/resume gap detected, discounting from stuck-task age checks"
2061 );
2062 }
2063
2064 {
2066 let mut rw = router.write().await;
2067 rw.tick_weight_recovery();
2068 }
2069
2070 if let Some(remaining) = GhHttp::is_rate_limited() {
2072 let m = rate_limit_metrics();
2073 tracing::warn!(
2074 remaining_secs = remaining.as_secs(),
2075 rate_limit_hits = m.hits,
2076 five_xx_hits = m.five_xx_hits,
2077 proactive_throttles = m.proactive_throttles,
2078 wait_secs_total = m.wait_secs,
2079 rest_remaining = ?m.rest_remaining,
2080 graphql_remaining = ?m.graphql_remaining,
2081 "GitHub API rate-limited, skipping tick"
2082 );
2083 } else {
2084 let mut router_guard = router.write().await;
2086 let mut routing_quota = tick::RoutingTickQuota::new(
2087 crate::engine::router::config::max_tasks_per_routing_tick(),
2088 );
2089 for engine in &project_engines {
2090 let repo = engine.repo.clone();
2091 let project_jobs_path = engine.project_dir.join(".orch.yml");
2092 REPO_CONTEXT.scope(repo, async {
2093 if let Err(e) = tick::tick(
2094 &engine.backend,
2095 &tmux,
2096 &engine.repo,
2097 &engine.runner,
2098 &capture_for_tick,
2099 &semaphore,
2100 &config,
2101 &project_jobs_path,
2102 &mut router_guard,
2103 &engine.task_manager,
2104 &weight_tx,
2105 &dispatching,
2106 &engine.store,
2107 Some(&transport),
2108 &active_repos,
2109 &auto_merge_in_flight,
2110 &mut routing_quota,
2111 ).await {
2112 tracing::error!(repo = %engine.repo, ?e, "tick failed for project");
2113 }
2114 }).await;
2115 }
2116 drop(router_guard);
2117
2118 while let Ok(signal) = weight_rx.try_recv() {
2121 let mut rw = router.write().await;
2122 match signal {
2123 WeightSignal::RateLimited { ref agent } => {
2124 rw.record_rate_limit(agent);
2125 }
2126 WeightSignal::Success { ref agent } => {
2127 rw.record_success(agent);
2128 }
2129 WeightSignal::Rerouted | WeightSignal::Blocked | WeightSignal::None => {}
2130 }
2131 }
2132
2133 if last_sync.elapsed() >= config.sync_interval {
2138 if sync_in_progress.compare_exchange(
2139 false,
2140 true,
2141 std::sync::atomic::Ordering::AcqRel,
2142 std::sync::atomic::Ordering::Relaxed,
2143 ).is_ok() {
2144 let sync_engines: Vec<_> = project_engines.iter().map(|e| {
2146 (e.backend.clone(), e.repo.clone(),
2147 e.task_manager.clone(), e.store.clone())
2148 }).collect();
2149 let sync_tmux = Arc::clone(&tmux);
2150 let sync_config = config.clone();
2151 let sync_router = Arc::clone(&router);
2152 let sync_dispatching = Arc::clone(&dispatching);
2153 let sync_auto_merge = Arc::clone(&auto_merge_in_flight);
2154 let sync_guard = Arc::clone(&sync_in_progress);
2155 tokio::spawn(async move {
2156 let sync_start = std::time::Instant::now();
2157 for (backend, repo, task_manager, store) in &sync_engines {
2158 let repo = repo.clone();
2159 REPO_CONTEXT.scope(repo.clone(), async {
2160 if let Err(e) = sync::sync_tick(
2161 backend, &sync_tmux, &repo, &sync_config,
2162 &sync_router, task_manager, store,
2163 &sync_dispatching, &sync_auto_merge,
2164 ).await {
2165 tracing::error!(repo = %repo, ?e, "sync tick failed for project");
2166 }
2167 }).await;
2168 }
2169 if let Some((_, _, _, store)) = sync_engines.first() {
2173 let (available_agents, config) = {
2174 let r = sync_router.read().await;
2175 (r.available_agents.clone(), r.config.clone())
2176 };
2177 sync::emit_degraded_agents_if_needed(
2178 &available_agents,
2179 &config,
2180 Some(store),
2181 )
2182 .await;
2183 }
2184 let elapsed = sync_start.elapsed();
2185 tracing::info!(elapsed_ms = elapsed.as_millis() as u64, "sync tick complete");
2186 sync_guard.store(false, std::sync::atomic::Ordering::Release);
2187 });
2188 } else {
2189 tracing::debug!("sync tick still in progress, skipping");
2190 }
2191 last_sync = std::time::Instant::now();
2195 }
2196 }
2197
2198 if webhook_enabled {
2200 let health_check_interval = config.webhook_health_check_interval
2201 .unwrap_or(std::time::Duration::from_secs(60));
2202
2203 if last_webhook_health_check.elapsed() >= health_check_interval {
2204 if let Some(port) = webhook_port {
2205 let (health, failure_reason) =
2206 crate::channels::github::check_webhook_health(port).await;
2207 if health != webhook_healthy {
2208 webhook_healthy = health;
2209 if webhook_healthy {
2210 tracing::info!(port, orch_webhook_up = true, "webhook health restored");
2211 } else {
2212 tracing::warn!(
2213 port,
2214 orch_webhook_up = false,
2215 reason = failure_reason.as_deref().unwrap_or("unknown"),
2216 "webhook health check failed"
2217 );
2218 }
2219 }
2220 {
2222 let to_save = {
2223 let mut s = webhook_status.lock().await;
2224 s.healthy = health;
2225 s.last_check_utc = Some(chrono::Utc::now());
2226 if health {
2227 s.last_failure_reason = None;
2228 } else if failure_reason.is_some() {
2229 s.last_failure_reason = failure_reason;
2230 }
2231 s.clone()
2232 };
2233 to_save.save().await;
2234 }
2235 }
2236 last_webhook_health_check = std::time::Instant::now();
2237 }
2238 }
2239
2240 if should_run_upgrade_check(last_upgrade_check, config.upgrade_check_interval) {
2242 if let Some(store) = project_engines.first().map(|e| e.store.clone()) {
2243 let channels = channel_registry.clone();
2244 let current = env!("ORCH_VERSION").to_string();
2245 let check_interval =
2246 std::time::Duration::from_secs(config.upgrade_check_interval);
2247 tokio::spawn(async move {
2248 check_and_notify_upgrade(&store, &channels, ¤t, check_interval)
2249 .await;
2250 });
2251 }
2252 last_upgrade_check = Some(std::time::Instant::now());
2253 }
2254
2255 let tick_elapsed = tick_start.elapsed();
2257 if tick_elapsed.as_secs() > 30 {
2258 tracing::warn!(elapsed_ms = tick_elapsed.as_millis() as u64, "slow tick");
2259 } else {
2260 tracing::debug!(elapsed_ms = tick_elapsed.as_millis() as u64, "tick complete");
2261 }
2262 last_tick_epoch.store(
2263 std::time::SystemTime::now()
2264 .duration_since(std::time::UNIX_EPOCH)
2265 .unwrap_or_default()
2266 .as_secs(),
2267 std::sync::atomic::Ordering::Relaxed,
2268 );
2269 }
2270 _ = webhook_notify.notified() => {
2272 if let Some(remaining) = GhHttp::is_rate_limited() {
2273 tracing::warn!(
2274 remaining_secs = remaining.as_secs(),
2275 "GitHub API rate-limited, skipping webhook-triggered tick"
2276 );
2277 } else {
2278 tracing::info!("webhook event triggered immediate tick");
2279
2280 let mut router_guard = router.write().await;
2281 let mut routing_quota = tick::RoutingTickQuota::new(
2282 crate::engine::router::config::max_tasks_per_routing_tick(),
2283 );
2284 for engine in &project_engines {
2285 let repo = engine.repo.clone();
2286 let project_jobs_path = engine.project_dir.join(".orch.yml");
2287 REPO_CONTEXT.scope(repo, async {
2288 if let Err(e) = tick::tick(
2289 &engine.backend,
2290 &tmux,
2291 &engine.repo,
2292 &engine.runner,
2293 &capture_for_tick,
2294 &semaphore,
2295 &config,
2296 &project_jobs_path,
2297 &mut router_guard,
2298 &engine.task_manager,
2299 &weight_tx,
2300 &dispatching,
2301 &engine.store,
2302 Some(&transport),
2303 &active_repos,
2304 &auto_merge_in_flight,
2305 &mut routing_quota,
2306 ).await {
2307 tracing::error!(repo = %engine.repo, ?e, "webhook-triggered tick failed");
2308 }
2309 }).await;
2310 }
2311 drop(router_guard);
2312
2313 while let Ok(signal) = weight_rx.try_recv() {
2315 let mut rw = router.write().await;
2316 match signal {
2317 WeightSignal::RateLimited { ref agent } => {
2318 rw.record_rate_limit(agent);
2319 }
2320 WeightSignal::Success { ref agent } => {
2321 rw.record_success(agent);
2322 }
2323 WeightSignal::Rerouted | WeightSignal::Blocked | WeightSignal::None => {}
2324 }
2325 }
2326
2327 if sync_in_progress.compare_exchange(
2333 false,
2334 true,
2335 std::sync::atomic::Ordering::AcqRel,
2336 std::sync::atomic::Ordering::Relaxed,
2337 ).is_ok() {
2338 let sync_engines: Vec<_> = project_engines.iter().map(|e| {
2340 (e.backend.clone(), e.repo.clone(), e.task_manager.clone(), e.store.clone())
2341 }).collect();
2342 let sync_tmux = Arc::clone(&tmux);
2343 let sync_config = config.clone();
2344 let sync_router = Arc::clone(&router);
2345 let sync_dispatching = Arc::clone(&dispatching);
2346 let sync_auto_merge = Arc::clone(&auto_merge_in_flight);
2347 let sync_guard = Arc::clone(&sync_in_progress);
2348 tokio::spawn(async move {
2349 let sync_start = std::time::Instant::now();
2350 for (backend, repo, task_manager, store) in &sync_engines {
2351 let repo = repo.clone();
2352 REPO_CONTEXT.scope(repo.clone(), async {
2353 if let Err(e) = sync::sync_tick(
2354 backend, &sync_tmux, &repo, &sync_config,
2355 &sync_router, task_manager, store,
2356 &sync_dispatching, &sync_auto_merge,
2357 ).await {
2358 tracing::error!(repo = %repo, ?e, "webhook immediate sync tick failed for project");
2359 }
2360 }).await;
2361 }
2362 if let Some((_, _, _, store)) = sync_engines.first() {
2364 let (available_agents, config) = {
2365 let r = sync_router.read().await;
2366 (r.available_agents.clone(), r.config.clone())
2367 };
2368 sync::emit_degraded_agents_if_needed(
2369 &available_agents,
2370 &config,
2371 Some(store),
2372 ).await;
2373 }
2374 let elapsed = sync_start.elapsed();
2375 tracing::info!(elapsed_ms = elapsed.as_millis() as u64, "webhook immediate sync tick complete");
2376 sync_guard.store(false, std::sync::atomic::Ordering::Release);
2377 });
2378 last_sync = std::time::Instant::now();
2380 } else {
2381 tracing::debug!("sync tick still in progress, skipping webhook-triggered immediate sync");
2382 }
2383 }
2384
2385 interval.reset();
2387 }
2388 result = config_rx.recv() => {
2389 match result {
2390 Ok(path) => {
2391 tracing::info!(path = %path.display(), "config file changed, reloading");
2392
2393 let new_config = EngineConfig::from_config();
2395 let tick_changed = new_config.tick_interval != config.tick_interval;
2396 config = new_config;
2397
2398 if tick_changed {
2400 interval = tokio::time::interval(config.tick_interval);
2401 tracing::info!(tick = ?config.tick_interval, "tick interval updated");
2402 }
2403
2404 {
2406 let mut router_guard = router.write().await;
2407 router_guard.reload_async().await;
2408 }
2409
2410 tracing::info!(
2411 tick = ?config.tick_interval,
2412 sync = ?config.sync_interval,
2413 parallel = config.max_parallel,
2414 "config reloaded"
2415 );
2416 }
2417 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
2418 tracing::warn!(missed = n, "config change receiver lagged, reloading");
2419 config = EngineConfig::from_config();
2421 interval = tokio::time::interval(config.tick_interval);
2422 {
2423 let mut router_guard = router.write().await;
2424 router_guard.reload_async().await;
2425 }
2426 }
2427 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
2428 tracing::warn!("config change channel closed");
2429 }
2430 }
2431 }
2432 signal_name = async {
2433 tokio::select! {
2434 _ = tokio::signal::ctrl_c() => "SIGINT",
2435 _ = sigterm.recv() => "SIGTERM",
2436 _ = sighup.recv() => "SIGHUP",
2437 }
2438 } => {
2439 tracing::info!(signal = signal_name, "beginning graceful shutdown");
2440
2441 let shutdown_result = tokio::time::timeout(
2444 config.graceful_shutdown_timeout,
2445 async {
2446 let mut reset_count = 0u32;
2450 let mut review_reset_count = 0u32;
2451 for engine in &project_engines {
2452 if let Ok(tasks) = engine.task_manager.list_external_by_status(Status::InProgress).await {
2453 for task in &tasks {
2454 if let Err(e) = engine.task_manager.update_task_status(&task.id, Status::Routed).await {
2455 tracing::warn!(task_id = task.id.0, ?e, "failed to reset task on shutdown");
2456 } else {
2457 if let Ok(Some(store_id)) =
2458 engine.store.resolve_task_id(&engine.repo, &task.id.0).await
2459 {
2460 if let Err(e) = engine
2461 .store
2462 .finalize_incomplete_runs(
2463 store_id,
2464 "aborted",
2465 "graceful shutdown: reset in_progress task to routed",
2466 )
2467 .await
2468 {
2469 tracing::warn!(
2470 task_id = task.id.0,
2471 ?e,
2472 "failed to finalize incomplete runs on shutdown"
2473 );
2474 }
2475 }
2476 reset_count += 1;
2477 }
2478 }
2479 }
2480 if let Ok(tasks) = engine.store.list_internal_by_status(&engine.repo, crate::store::TaskStatus::InProgress).await {
2482 for task in &tasks {
2483 let task_id = format!("internal:{}", task.id);
2484 if let Err(e) = engine.task_manager.update_task_status(
2485 &crate::backends::ExternalId(task_id.clone()),
2486 Status::Routed,
2487 ).await {
2488 tracing::warn!(task_id, ?e, "failed to reset internal task on shutdown");
2489 } else {
2490 if let Err(e) = engine
2491 .store
2492 .finalize_incomplete_runs(
2493 task.id,
2494 "aborted",
2495 "graceful shutdown: reset in_progress task to routed",
2496 )
2497 .await
2498 {
2499 tracing::warn!(
2500 task_id,
2501 ?e,
2502 "failed to finalize incomplete internal runs on shutdown"
2503 );
2504 }
2505 reset_count += 1;
2506 }
2507 }
2508 }
2509 if let Ok(tasks) = engine.task_manager.list_external_by_status(Status::InReview).await {
2511 for task in &tasks {
2512 if let Err(e) = engine.task_manager.update_task_status(&task.id, Status::NeedsReview).await {
2513 tracing::warn!(task_id = task.id.0, ?e, "failed to reset in_review task on shutdown");
2514 } else {
2515 set_review_session_expected(&engine.store, &engine.repo, &task.id.0, false).await;
2516 if let Ok(Some(store_id)) =
2517 engine.store.resolve_task_id(&engine.repo, &task.id.0).await
2518 {
2519 if let Err(e) = engine
2520 .store
2521 .finalize_incomplete_runs(
2522 store_id,
2523 "aborted",
2524 "graceful shutdown: reset in_review task to needs_review",
2525 )
2526 .await
2527 {
2528 tracing::warn!(
2529 task_id = task.id.0,
2530 ?e,
2531 "failed to finalize incomplete review runs on shutdown"
2532 );
2533 }
2534 }
2535 review_reset_count += 1;
2536 }
2537 }
2538 }
2539 if let Ok(tasks) = engine.store.list_internal_by_status(&engine.repo, crate::store::TaskStatus::InReview).await {
2541 for task in &tasks {
2542 let task_id = format!("internal:{}", task.id);
2543 if let Err(e) = engine.task_manager.update_task_status(
2544 &crate::backends::ExternalId(task_id.clone()),
2545 Status::NeedsReview,
2546 ).await {
2547 tracing::warn!(task_id, ?e, "failed to reset internal in_review task on shutdown");
2548 } else {
2549 set_review_session_expected(&engine.store, &engine.repo, &task_id, false).await;
2550 if let Err(e) = engine
2551 .store
2552 .finalize_incomplete_runs(
2553 task.id,
2554 "aborted",
2555 "graceful shutdown: reset in_review task to needs_review",
2556 )
2557 .await
2558 {
2559 tracing::warn!(
2560 task_id,
2561 ?e,
2562 "failed to finalize incomplete internal review runs on shutdown"
2563 );
2564 }
2565 review_reset_count += 1;
2566 }
2567 }
2568 }
2569 }
2570 if reset_count > 0 {
2571 tracing::info!(reset_count, "reset in_progress tasks to routed for re-dispatch");
2572 }
2573 if review_reset_count > 0 {
2574 tracing::info!(review_reset_count, "reset in_review tasks to needs_review for re-dispatch");
2575 }
2576
2577 let mut killed_sessions = Vec::new();
2580 if let Ok(sessions) = tmux.list_sessions().await {
2581 for session in &sessions {
2582 if session.name.starts_with("orch-") {
2583 if let Err(e) = tmux.kill_session(&session.name).await {
2584 tracing::warn!(session = %session.name, error = %e, "failed to kill tmux session");
2585 } else {
2586 killed_sessions.push(session.name.clone());
2587 }
2588 }
2589 }
2590 if !killed_sessions.is_empty() {
2591 tracing::info!(killed = killed_sessions.len(), "killing orch tmux sessions on shutdown");
2592 }
2593 }
2594
2595 if !killed_sessions.is_empty() {
2598 let still_alive = tmux
2599 .wait_for_sessions_dead(
2600 &killed_sessions,
2601 std::time::Duration::from_millis(100),
2602 std::time::Duration::from_secs(5),
2603 )
2604 .await;
2605 if still_alive > 0 {
2606 tracing::warn!(
2607 still_alive,
2608 "some tmux sessions did not terminate cleanly - they will be cleaned up on startup"
2609 );
2610 }
2611 tracing::info!(killed = killed_sessions.len() - still_alive, "confirmed tmux sessions terminated");
2612 }
2613 },
2614 ).await;
2615
2616 if shutdown_result.is_err() {
2617 tracing::error!(
2618 timeout_secs = config.graceful_shutdown_timeout.as_secs(),
2619 "graceful shutdown timed out, forcing exit"
2620 );
2621 }
2622
2623 break;
2624 }
2625 }
2626 }
2627
2628 events::cleanup_port_file().await;
2630 events::cleanup_version_file();
2631
2632 let _ = transport;
2634 tracing::info!("orch engine stopped");
2635 Ok(())
2636}
2637
2638#[cfg(test)]
2639mod tests {
2640 use super::*;
2641
2642 #[test]
2643 fn engine_config_defaults() {
2644 let config = EngineConfig::default();
2645 assert_eq!(config.tick_interval, std::time::Duration::from_secs(10));
2646 assert_eq!(config.sync_interval, std::time::Duration::from_secs(45));
2647 assert_eq!(config.max_parallel, 4);
2648 assert_eq!(config.stuck_timeout, 1800);
2649 assert_eq!(config.no_session_stuck_timeout, 600);
2650 assert!(
2651 config.no_session_stuck_timeout < config.stuck_timeout,
2652 "no_session_stuck_timeout must be shorter than stuck_timeout"
2653 );
2654 assert_eq!(
2655 config.graceful_shutdown_timeout,
2656 std::time::Duration::from_secs(600)
2657 );
2658 assert_eq!(config.upgrade_check_interval, 3600);
2659 }
2660
2661 #[test]
2662 fn engine_config_from_config_uses_defaults_when_no_config() {
2663 let config = EngineConfig::from_config();
2664 assert_eq!(config.tick_interval, std::time::Duration::from_secs(10));
2665 assert_eq!(config.sync_interval, std::time::Duration::from_secs(45));
2666 assert!(config.max_parallel >= 1 && config.max_parallel <= 64);
2668 assert_eq!(config.stuck_timeout, 1800);
2669 assert_eq!(config.no_session_stuck_timeout, 600);
2670 assert_eq!(
2671 config.graceful_shutdown_timeout,
2672 std::time::Duration::from_secs(600)
2673 );
2674 assert_eq!(config.upgrade_check_interval, 3600);
2675 }
2676
2677 #[test]
2678 fn engine_config_no_session_timeout_is_under_15_minutes() {
2679 let config = EngineConfig::default();
2680 assert!(
2681 config.no_session_stuck_timeout <= 900,
2682 "no_session_stuck_timeout ({}) exceeds 15 minutes",
2683 config.no_session_stuck_timeout
2684 );
2685 }
2686
2687 #[test]
2688 fn upgrade_check_runs_immediately_when_never_checked() {
2689 assert!(should_run_upgrade_check(None, 3600));
2690 }
2691
2692 #[test]
2693 fn upgrade_check_waits_for_interval_after_first_run() {
2694 assert!(!should_run_upgrade_check(
2695 Some(std::time::Instant::now()),
2696 3600
2697 ));
2698 }
2699
2700 #[test]
2701 fn upgrade_check_can_be_disabled() {
2702 assert!(!should_run_upgrade_check(None, 0));
2703 }
2704
2705 #[test]
2706 fn configured_agents_parses_yaml_block_array() {
2707 let yaml_str = "- claude\n- codex\n- opencode\n- olm\n";
2709 let parsed: Vec<String> = serde_norway::from_str(yaml_str).unwrap();
2710 assert_eq!(parsed, vec!["claude", "codex", "opencode", "olm"]);
2711 }
2712
2713 #[test]
2714 fn configured_agents_parses_json_inline_array() {
2715 let json_str = r#"["claude","codex","olm"]"#;
2716 let parsed: Vec<String> = serde_json::from_str(json_str).unwrap();
2717 assert_eq!(parsed, vec!["claude", "codex", "olm"]);
2718 }
2719
2720 #[test]
2721 fn configured_agents_fallback_returns_default_agents() {
2722 let defaults = configured_agents();
2724 assert!(defaults.contains(&"claude".to_string()));
2725 assert!(defaults.contains(&"codex".to_string()));
2726 assert!(defaults.contains(&"opencode".to_string()));
2727 }
2728
2729 #[tokio::test]
2738 #[ignore]
2739 async fn integration_channel_to_tmux_to_capture() {
2740 use crate::channels::capture::CaptureService;
2741 use crate::channels::transport::Transport;
2742 use crate::tmux::SessionGuard;
2743 use std::sync::Arc;
2744
2745 let transport = Arc::new(Transport::new());
2747 let capture = Arc::new(CaptureService::new(transport.clone()));
2748
2749 let task_id = "testtask1".to_string();
2750 let session_name = format!("orch-test-{}", task_id);
2751
2752 let _ = tokio::process::Command::new("tmux")
2754 .args(["new-session", "-d", "-s", &session_name])
2755 .output()
2756 .await
2757 .expect("failed to start tmux session");
2758
2759 let _guard = SessionGuard(session_name.clone());
2761
2762 capture
2764 .register_session("owner/repo", &task_id, &session_name)
2765 .await;
2766 transport
2767 .bind(
2768 "owner/repo",
2769 &task_id,
2770 &session_name,
2771 "telegram",
2772 "12345",
2773 None,
2774 )
2775 .await;
2776
2777 let capture_clone = capture.clone();
2779 let capture_handle = tokio::spawn(async move { capture_clone.run().await });
2780
2781 let mut rx = transport
2783 .subscribe("owner/repo", &task_id)
2784 .await
2785 .expect("no subscription");
2786
2787 let send = tokio::process::Command::new("tmux")
2789 .args([
2790 "send-keys",
2791 "-t",
2792 &session_name,
2793 "echo from-channel",
2794 "Enter",
2795 ])
2796 .output()
2797 .await
2798 .expect("failed to send keys");
2799 assert!(send.status.success());
2800
2801 let mut got = false;
2803 for _ in 0..20 {
2804 if let Ok(Ok(c)) =
2805 tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await
2806 {
2807 if c.content.contains("from-channel") {
2808 got = true;
2809 break;
2810 }
2811 }
2812 }
2813
2814 capture.unregister_session("owner/repo", &task_id).await;
2816
2817 let _ = capture_handle.await;
2819
2820 assert!(got, "did not observe tmux output via capture/transport");
2821 }
2822
2823 #[test]
2826 fn read_project_channel_config_returns_defaults_when_no_file() {
2827 let dir = tempfile::tempdir().expect("tempdir");
2828 let cfg = read_project_channel_config(dir.path());
2829 assert!(cfg.telegram_topic_id.is_none());
2830 assert!(cfg.discord_channel_id.is_none());
2831 }
2832
2833 #[test]
2834 fn read_project_channel_config_parses_telegram_and_discord() {
2835 let dir = tempfile::tempdir().expect("tempdir");
2836 let orch_yml = dir.path().join(".orch.yml");
2837 std::fs::write(
2838 &orch_yml,
2839 "channels:\n telegram:\n topic_id: \"42\"\n discord:\n channel_id: \"9999\"\n",
2840 )
2841 .expect("write .orch.yml");
2842 let cfg = read_project_channel_config(dir.path());
2843 assert_eq!(cfg.telegram_topic_id.as_deref(), Some("42"));
2844 assert_eq!(cfg.discord_channel_id.as_deref(), Some("9999"));
2845 }
2846
2847 #[test]
2848 fn read_project_channel_config_handles_partial_channels() {
2849 let dir = tempfile::tempdir().expect("tempdir");
2850 let orch_yml = dir.path().join(".orch.yml");
2851 std::fs::write(&orch_yml, "channels:\n telegram:\n topic_id: \"7\"\n")
2852 .expect("write .orch.yml");
2853 let cfg = read_project_channel_config(dir.path());
2854 assert_eq!(cfg.telegram_topic_id.as_deref(), Some("7"));
2855 assert!(cfg.discord_channel_id.is_none());
2856 }
2857
2858 #[test]
2859 fn read_project_channel_config_handles_malformed_yaml() {
2860 let dir = tempfile::tempdir().expect("tempdir");
2861 let orch_yml = dir.path().join(".orch.yml");
2862 std::fs::write(&orch_yml, "channels: [\nbad yaml").expect("write .orch.yml");
2863 let cfg = read_project_channel_config(dir.path());
2864 assert!(cfg.telegram_topic_id.is_none());
2866 assert!(cfg.discord_channel_id.is_none());
2867 }
2868
2869 #[test]
2870 #[cfg(unix)]
2871 fn read_project_channel_config_handles_unreadable_file() {
2872 let dir = tempfile::tempdir().expect("tempdir");
2873 std::fs::create_dir(dir.path().join(".orch.yml")).expect("create .orch.yml dir");
2875 let cfg = read_project_channel_config(dir.path());
2876 assert!(cfg.telegram_topic_id.is_none());
2878 assert!(cfg.discord_channel_id.is_none());
2879 }
2880}