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