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