Skip to main content

orch/engine/
mod.rs

1//! Engine — the core orchestration loop.
2//!
3//! The engine is the central coordinator. It owns the tick loop, backend
4//! connections, channel registry, transport layer, and tmux session manager.
5//!
6//! Submodules handle the heavy lifting:
7//! - [`tick`] — core tick phases (session polling, stuck recovery, routing, dispatch)
8//! - [`sync`] — periodic sync operations (cleanup, PR review, mentions, skills)
9//! - [`review`] — PR review pipeline (review agent, auto-merge, change handling)
10//! - [`cleanup`] — worktree cleanup and merged-PR detection
11//!
12//! This file contains struct definitions, project initialization, and the
13//! main event loop (`serve()`).
14
15pub 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
34/// Standard Orch attribution footer for issue bodies, PR bodies, and comments.
35///
36/// Append to every user-visible string posted to GitHub so activity is
37/// clearly attributed to orch rather than appearing as a human post.
38pub fn orch_footer() -> &'static str {
39    "\n\n---\n*Posted by [Orch](https://github.com/gabrielkoerich/orch)*"
40}
41
42/// Build a standard attribution footer for GitHub comments posted by orch bots.
43///
44/// `verb` is "Created", "Reviewed", or "Commented" depending on context.
45pub 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;
84// AtomicBool/Ordering removed — shutdown is now immediate (reset tasks + break)
85use std::sync::Arc;
86use tokio::process::Command;
87use tokio::sync::{mpsc, Notify, RwLock, Semaphore};
88
89use crate::backends::Status;
90
91/// Lightweight reference tuple for channel message handling.
92type EngineRef = (
93    String,
94    Arc<dyn ExternalBackend>,
95    Arc<TaskManager>,
96    Option<Arc<TaskStore>>,
97);
98
99/// Per-project engine state.
100///
101/// Each project has its own backend, task runner, and task manager,
102/// but they share the global tmux manager, transport, and semaphore.
103#[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
113/// Return the configured agent list from top-level `agents` in config.yml,
114/// falling back to [`router::config::DEFAULT_AGENTS`] when not set.
115///
116/// This is the canonical source of truth for "which agents does orch know
117/// about" — use it instead of `DEFAULT_AGENTS` directly so that
118/// Claude-compatible agents added via config (e.g. `olm`) are recognized
119/// without code changes.
120///
121/// The key is top-level (`agents:`) rather than nested (`engine.agents:`)
122/// because `config::get` serializes nested YAML arrays via `serde_norway::to_string`
123/// which produces YAML block format, not JSON — breaking downstream parsing.
124pub fn configured_agents() -> Vec<String> {
125    if let Ok(agents_str) = crate::config::get("agents") {
126        if !agents_str.is_empty() && agents_str != "[]" {
127            // config::get returns serde_norway::to_string for arrays, which is
128            // YAML block format ("- claude\n- codex\n..."). Parse as YAML first.
129            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            // Fallback: try JSON (inline arrays) or comma-separated
135            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/// Engine configuration.
149#[derive(Clone)]
150pub struct EngineConfig {
151    /// Main tick interval
152    pub tick_interval: std::time::Duration,
153    /// GitHub sync interval (cleanup, PR review, mentions)
154    pub sync_interval: std::time::Duration,
155    /// Webhook health check interval (seconds)
156    pub webhook_health_check_interval: Option<std::time::Duration>,
157    /// Maximum parallel task executions
158    pub max_parallel: usize,
159    /// Stuck task timeout for tasks with an active tmux session (seconds)
160    pub stuck_timeout: u64,
161    /// Stuck task timeout for tasks with no active tmux session (seconds).
162    /// Shorter than `stuck_timeout` because no session means the agent has already exited.
163    pub no_session_stuck_timeout: u64,
164    /// Stuck task timeout for in_review tasks with no active tmux session (seconds).
165    /// Longer than `no_session_stuck_timeout` because review agents exit their tmux session
166    /// on normal completion before the result is delivered to the engine. Using a short
167    /// no-session timeout for in_review tasks causes a race where a completed review result
168    /// arrives after the stuck recovery has already reset the task to needs_review, discarding
169    /// the review work.
170    pub in_review_no_session_stuck_timeout: u64,
171    /// Auto-create follow-up tasks when PR reviews request changes
172    pub auto_create_followup_on_changes: bool,
173    /// Graceful shutdown timeout — how long to wait for running agents before exiting.
174    pub graceful_shutdown_timeout: std::time::Duration,
175    /// Grace period before silence detection kicks in (seconds).
176    /// Allows time for agent startup, model download, API handshake.
177    pub silence_grace_period: u64,
178    /// Cooldown duration for a model detected as silent (seconds).
179    pub silence_cooldown: u64,
180    /// How often to check for a newer orch release and notify channels (seconds).
181    /// Set to 0 to disable. Default: 3600 (1 hour).
182    pub upgrade_check_interval: u64,
183    /// Seconds of continuous startup failure (GitHub unreachable / auth errors)
184    /// before the engine escalates from WARN to ERROR-level logging and sends a
185    /// one-time push notification to configured channels. Set to 0 to disable.
186    /// Default: 3600 (1 hour).
187    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    /// Load engine configuration from config files, falling back to defaults.
212    pub fn from_config() -> Self {
213        let mut config = Self::default();
214
215        if let Ok(val) = crate::config::get("engine.tick_interval") {
216            if let Ok(secs) = val.parse::<u64>() {
217                config.tick_interval = std::time::Duration::from_secs(secs);
218            } else {
219                tracing::warn!(key = "engine.tick_interval", value = %val, default_secs = config.tick_interval.as_secs(), "invalid value for engine.tick_interval, using default");
220            }
221        }
222
223        if let Ok(val) = crate::config::get("engine.sync_interval") {
224            if let Ok(secs) = val.parse::<u64>() {
225                config.sync_interval = std::time::Duration::from_secs(secs);
226            } else {
227                tracing::warn!(key = "engine.sync_interval", value = %val, default_secs = config.sync_interval.as_secs(), "invalid value for engine.sync_interval, using default");
228            }
229        }
230
231        if let Ok(val) = crate::config::get("engine.max_parallel") {
232            if let Ok(n) = val.parse::<usize>() {
233                config.max_parallel = n;
234            } else {
235                tracing::warn!(key = "engine.max_parallel", value = %val, default = config.max_parallel, "invalid value for engine.max_parallel, using default");
236            }
237        }
238
239        if let Ok(val) = crate::config::get("engine.stuck_timeout") {
240            if let Ok(secs) = val.parse::<u64>() {
241                config.stuck_timeout = secs;
242            } else {
243                tracing::warn!(key = "engine.stuck_timeout", value = %val, default_secs = config.stuck_timeout, "invalid value for engine.stuck_timeout, using default");
244            }
245        }
246
247        if let Ok(val) = crate::config::get("engine.no_session_stuck_timeout") {
248            if let Ok(secs) = val.parse::<u64>() {
249                config.no_session_stuck_timeout = secs;
250            } else {
251                tracing::warn!(key = "engine.no_session_stuck_timeout", value = %val, default_secs = config.no_session_stuck_timeout, "invalid value for engine.no_session_stuck_timeout, using default");
252            }
253        }
254
255        if let Ok(val) = crate::config::get("engine.in_review_no_session_stuck_timeout") {
256            if let Ok(secs) = val.parse::<u64>() {
257                config.in_review_no_session_stuck_timeout = secs;
258            } else {
259                tracing::warn!(key = "engine.in_review_no_session_stuck_timeout", value = %val, default_secs = config.in_review_no_session_stuck_timeout, "invalid value for engine.in_review_no_session_stuck_timeout, using default");
260            }
261        }
262
263        if let Ok(val) = crate::config::get("engine.webhook_health_check_interval") {
264            if let Ok(secs) = val.parse::<u64>() {
265                if secs > 0 {
266                    config.webhook_health_check_interval =
267                        Some(std::time::Duration::from_secs(secs));
268                } else {
269                    config.webhook_health_check_interval = None;
270                }
271            } else {
272                tracing::warn!(key = "engine.webhook_health_check_interval", value = %val, default = ?config.webhook_health_check_interval, "invalid value for engine.webhook_health_check_interval, using default");
273            }
274        }
275
276        if let Ok(val) = crate::config::get("workflow.auto_create_followup_on_changes") {
277            config.auto_create_followup_on_changes = !val.eq_ignore_ascii_case("false");
278        }
279
280        if let Ok(val) = crate::config::get("engine.graceful_shutdown_timeout") {
281            if let Ok(secs) = val.parse::<u64>() {
282                config.graceful_shutdown_timeout = std::time::Duration::from_secs(secs);
283            } else {
284                tracing::warn!(key = "engine.graceful_shutdown_timeout", value = %val, default_secs = config.graceful_shutdown_timeout.as_secs(), "invalid value for engine.graceful_shutdown_timeout, using default");
285            }
286        }
287
288        if let Ok(val) = crate::config::get("engine.silence_grace_period") {
289            if let Ok(secs) = val.parse::<u64>() {
290                config.silence_grace_period = secs;
291            } else {
292                tracing::warn!(key = "engine.silence_grace_period", value = %val, default = config.silence_grace_period, "invalid value for engine.silence_grace_period, using default");
293            }
294        }
295
296        if let Ok(val) = crate::config::get("engine.silence_cooldown") {
297            if let Ok(secs) = val.parse::<u64>() {
298                config.silence_cooldown = secs;
299            } else {
300                tracing::warn!(key = "engine.silence_cooldown", value = %val, default = config.silence_cooldown, "invalid value for engine.silence_cooldown, using default");
301            }
302        }
303
304        if let Ok(val) = crate::config::get("engine.upgrade_check_interval") {
305            if let Ok(secs) = val.parse::<u64>() {
306                config.upgrade_check_interval = secs;
307            } else {
308                tracing::warn!(key = "engine.upgrade_check_interval", value = %val, default = config.upgrade_check_interval, "invalid value for engine.upgrade_check_interval, using default");
309            }
310        }
311
312        if let Ok(val) = crate::config::get("engine.startup_failure_escalation_secs") {
313            if let Ok(secs) = val.parse::<u64>() {
314                config.startup_failure_escalation_secs = secs;
315            } else {
316                tracing::warn!(
317                    key = "engine.startup_failure_escalation_secs",
318                    value = %val,
319                    default_secs = config.startup_failure_escalation_secs,
320                    "invalid value for engine.startup_failure_escalation_secs, using default"
321                );
322            }
323        }
324
325        config
326    }
327}
328
329fn should_run_upgrade_check(
330    last_upgrade_check: Option<std::time::Instant>,
331    upgrade_check_interval: u64,
332) -> bool {
333    if upgrade_check_interval == 0 {
334        return false;
335    }
336
337    last_upgrade_check
338        .map(|last| last.elapsed() >= std::time::Duration::from_secs(upgrade_check_interval))
339        .unwrap_or(true)
340}
341
342/// Initialize all project engines from config.
343///
344/// Returns a vector of ProjectEngine, one for each configured project.
345async fn init_project_engines() -> anyhow::Result<Vec<ProjectEngine>> {
346    let projects = config::get_projects_with_paths()?;
347    let repos: Vec<&str> = projects.iter().map(|(r, _)| r.as_str()).collect();
348    tracing::info!(repos = ?repos, "loading projects from config");
349
350    if projects.is_empty() {
351        let config_path = crate::home::config_path()
352            .map(|p| p.display().to_string())
353            .unwrap_or_else(|_| "~/.orch/config.yml".to_string());
354        anyhow::bail!(
355            "no projects configured — add repos under `projects:` in {}",
356            config_path
357        );
358    }
359
360    let mut engines = Vec::new();
361    let mut auth_failures = 0usize;
362    let mut network_failures = 0usize;
363
364    // Initialize unified task store once — all project engines share the same SQLite file.
365    // Creating one pool here avoids N separate connection pools for the same database.
366    let store = Arc::new(TaskStore::open(&crate::store::default_db_path().await?).await?);
367
368    // Load persisted model cooldowns and register store for future writes.
369    // Runs once here so the global cooldown store is not overwritten per project.
370    crate::engine::cooldown::init_cooldown_store(store.clone()).await;
371
372    for (repo, project_dir) in projects {
373        tracing::info!(repo = %repo, "initializing project engine");
374
375        // Initialize backend
376        let backend: Arc<dyn ExternalBackend> =
377            Arc::new(crate::backends::github::GitHubBackend::new(repo.clone())?);
378
379        // Health check — verifies network and GitHub authentication
380        if let Err(e) = backend.health_check().await {
381            let err_str = e.to_string();
382            if err_str.contains("401")
383                || err_str.contains("Bad credentials")
384                || err_str.contains("authentication")
385                || err_str.contains("No GitHub token")
386            {
387                auth_failures += 1;
388                tracing::debug!(
389                    repo = %repo,
390                    error = %e,
391                    "GitHub auth failed for {repo} — run `gh auth login`"
392                );
393            } else {
394                network_failures += 1;
395                tracing::debug!(
396                    repo = %repo,
397                    error = %e,
398                    "GitHub unreachable for {repo} (network unavailable?)"
399                );
400            }
401            continue;
402        }
403        tracing::info!(repo = %repo, backend = backend.name(), "backend connected");
404
405        // Initialize task manager (with unified store)
406        let task_manager = Arc::new(TaskManager::with_store(
407            backend.clone(),
408            store.clone(),
409            repo.clone(),
410        ));
411
412        // Task runner (with store for metrics)
413        let runner = Arc::new(runner::TaskRunner::new(repo.clone()).with_store(store.clone()));
414
415        engines.push(ProjectEngine {
416            repo,
417            project_dir,
418            backend,
419            task_manager,
420            runner,
421            store: store.clone(),
422        });
423    }
424
425    if engines.is_empty() {
426        // Projects are configured but all health checks failed — this is a connectivity
427        // or auth issue, not a configuration problem.  The serve() retry loop will catch
428        // this error and retry with backoff; it must NOT propagate to main() or it would
429        // write "Error: ..." to brew's stderr (orch.error.log) on every restart attempt.
430        if auth_failures > 0 && network_failures == 0 {
431            anyhow::bail!(
432                "GitHub auth failed for all configured projects ({auth_failures} project(s)) — run `gh auth login`"
433            );
434        } else if network_failures > 0 && auth_failures == 0 {
435            anyhow::bail!(
436                "GitHub unreachable for all configured projects ({network_failures} project(s)) — check network connectivity"
437            );
438        } else {
439            anyhow::bail!(
440                "all configured project backends failed health checks ({auth_failures} auth failures, {network_failures} network failures)"
441            );
442        }
443    }
444
445    Ok(engines)
446}
447
448async fn reconcile_startup_worktrees(project_engines: &[ProjectEngine]) -> anyhow::Result<()> {
449    for engine in project_engines {
450        let repo_root =
451            crate::engine::runner::worktree::resolve_main_repo(&engine.project_dir).await;
452        let repo_root_path = std::path::PathBuf::from(&repo_root);
453        let default_branch =
454            crate::engine::runner::worktree::detect_default_branch(&repo_root_path).await;
455        let worktrees = list_project_worktrees(&repo_root_path).await?;
456
457        // Fetch refs once for all worktrees in this project, so rebase below
458        // operates on up-to-date origin/* refs without N sequential fetches.
459        if let Err(e) = Command::new("git")
460            .args(["-C", &repo_root.to_string_lossy(), "fetch", "origin"])
461            .output_with_context()
462            .await
463        {
464            tracing::warn!(repo = %engine.repo, err = %e, "startup git fetch origin failed, rebases may use stale refs");
465        }
466
467        let mut orphans_removed: u32 = 0;
468        let mut invalid_removed: u32 = 0;
469        let mut cleaned: u32 = 0;
470        let mut valid_kept: u32 = 0;
471
472        for worktree_dir in worktrees {
473            let Some(name) = worktree_dir.file_name().and_then(|n| n.to_str()) else {
474                continue;
475            };
476
477            // Validate gitdir link before doing anything else — a broken .git file
478            // means git commands on this worktree will fail with "not a git repository".
479            if !validate_worktree_gitdir(&worktree_dir).await {
480                tracing::warn!(repo = %engine.repo, worktree = %worktree_dir.display(), "worktree has invalid or missing gitdir, removing");
481                remove_worktree_and_branch(name, &worktree_dir, Some(name), &repo_root_path, false)
482                    .await;
483                invalid_removed += 1;
484                continue;
485            }
486
487            let Some(task_id) = task_id_from_worktree_name(name) else {
488                tracing::info!(repo = %engine.repo, worktree = %worktree_dir.display(), "orphan worktree without task id, removing");
489                remove_worktree_and_branch(name, &worktree_dir, Some(name), &repo_root_path, false)
490                    .await;
491                orphans_removed += 1;
492                continue;
493            };
494
495            let Some(store_id) = engine.store.resolve_task_id(&engine.repo, &task_id).await? else {
496                tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), "worktree has no matching task, removing");
497                remove_worktree_and_branch(
498                    &task_id,
499                    &worktree_dir,
500                    Some(name),
501                    &repo_root_path,
502                    false,
503                )
504                .await;
505                orphans_removed += 1;
506                continue;
507            };
508
509            let task = engine.store.get(store_id).await?;
510            let branch_name = if task.branch.is_empty() {
511                name
512            } else {
513                task.branch.as_str()
514            };
515
516            match task.status {
517                TaskStatus::New
518                | TaskStatus::Routed
519                | TaskStatus::InProgress
520                | TaskStatus::NeedsReview
521                | TaskStatus::InReview => {
522                    tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), "rebasing startup worktree");
523                    if let Err(e) =
524                        rebase_worktree_on_origin_main(&worktree_dir, &default_branch).await
525                    {
526                        // If rebase failed we already made efforts to stash and
527                        // restore changes safely. Log the error and reset the
528                        // task as before. The worktree removal path is used
529                        // to avoid leaving corrupted worktrees around.
530                        tracing::warn!(repo = %engine.repo, task_id = %task_id, err = %e, "startup rebase failed, resetting task");
531                        abort_worktree_rebase(&worktree_dir).await;
532                        let keep_remote = task.pr_number.is_some();
533                        remove_worktree_and_branch(
534                            &task_id,
535                            &worktree_dir,
536                            Some(branch_name),
537                            &repo_root_path,
538                            keep_remote,
539                        )
540                        .await;
541                        match engine.store.resolve_task_id(&engine.repo, &task_id).await {
542                            Ok(Some(reset_id)) => {
543                                if let Err(e) = engine.store.reset_to_new(reset_id).await {
544                                    tracing::warn!(task_id, err = %e, "failed to reset task to new after rebase failure");
545                                }
546                            }
547                            Ok(None) => {
548                                tracing::warn!(
549                                    task_id,
550                                    "task not found in store during reconciliation"
551                                );
552                            }
553                            Err(e) => {
554                                tracing::warn!(task_id, err = %e, "failed to resolve task ID during reconciliation");
555                            }
556                        }
557                        if let Err(e) = engine
558                            .task_manager
559                            .update_task_status(
560                                &ExternalId(task_id.clone()),
561                                crate::backends::Status::New,
562                            )
563                            .await
564                        {
565                            tracing::warn!(task_id, err = %e, "failed to update backend status during reconciliation");
566                        }
567                        cleaned += 1;
568                    } else {
569                        valid_kept += 1;
570                    }
571                }
572                _ => {
573                    let keep_remote =
574                        task.status == TaskStatus::Blocked && task.pr_number.is_some();
575                    tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), status = ?task.status, "terminal task worktree, removing");
576                    remove_worktree_and_branch(
577                        &task_id,
578                        &worktree_dir,
579                        Some(branch_name),
580                        &repo_root_path,
581                        keep_remote,
582                    )
583                    .await;
584                    cleaned += 1;
585                }
586            }
587        }
588
589        tracing::info!(
590            repo = %engine.repo,
591            orphans_removed,
592            invalid_removed,
593            cleaned,
594            valid_kept,
595            "worktree reconciliation: {orphans_removed} orphans removed, {invalid_removed} invalid removed, {cleaned} cleaned, {valid_kept} valid kept"
596        );
597
598        // Prune stale .git/worktrees/ entries whose directories no longer exist.
599        // This handles entries left behind by crashes or manual directory removal.
600        let prune = Command::new("git")
601            .args(["-C", &repo_root.to_string_lossy(), "worktree", "prune"])
602            .output_with_context()
603            .await;
604        match prune {
605            Ok(output) if output.status.success() => {
606                tracing::debug!(repo = %engine.repo, "startup worktree prune completed");
607            }
608            Ok(output) => {
609                let stderr = String::from_utf8_lossy(&output.stderr);
610                tracing::warn!(repo = %engine.repo, err = %stderr, "startup worktree prune failed");
611            }
612            Err(e) => {
613                tracing::warn!(repo = %engine.repo, err = %e, "startup worktree prune failed");
614            }
615        }
616    }
617
618    Ok(())
619}
620
621/// Sync Fibonacci estimates stored in SQLite to the GitHub Projects board for
622/// all tasks that have a positive estimate.
623///
624/// Runs once on startup to catch tasks that were routed before the estimate
625/// sync feature was available or before `project_estimate_field_id` was
626/// configured. The underlying mutations are idempotent, so re-syncing an
627/// already-correct estimate is harmless.
628async fn reconcile_startup_estimates(project_engines: &[ProjectEngine]) {
629    use crate::backends::ExternalId;
630
631    for engine in project_engines {
632        let tasks = match engine.store.list_external_tasks_with_estimates().await {
633            Ok(t) => t,
634            Err(e) => {
635                tracing::warn!(repo = %engine.repo, err = %e, "startup estimate reconciliation: store query failed");
636                continue;
637            }
638        };
639
640        if tasks.is_empty() {
641            continue;
642        }
643
644        let mut synced: u32 = 0;
645        let mut failed: u32 = 0;
646        for (external_id, estimate) in &tasks {
647            match engine
648                .backend
649                .sync_estimate_to_project(&ExternalId(external_id.clone()), *estimate)
650                .await
651            {
652                Ok(()) => synced += 1,
653                Err(e) => {
654                    tracing::debug!(
655                        repo = %engine.repo,
656                        task_id = %external_id,
657                        estimate,
658                        err = %e,
659                        "startup estimate reconciliation: sync failed"
660                    );
661                    failed += 1;
662                }
663            }
664        }
665
666        if synced > 0 || failed > 0 {
667            tracing::info!(
668                repo = %engine.repo,
669                synced,
670                failed,
671                "startup estimate reconciliation complete"
672            );
673        }
674    }
675}
676
677/// Read per-project channel configuration from `.orch.yml`.
678fn read_project_channel_config(project_dir: &std::path::Path) -> ProjectChannelConfig {
679    let config_path = project_dir.join(".orch.yml");
680    if !config_path.exists() {
681        return ProjectChannelConfig::default();
682    }
683    let content = match std::fs::read_to_string(&config_path) {
684        Ok(c) => c,
685        Err(e) => {
686            tracing::warn!(
687                path = %config_path.display(),
688                error = %e,
689                "failed to read .orch.yml; channel config will use defaults"
690            );
691            return ProjectChannelConfig::default();
692        }
693    };
694    let val: serde_norway::Value = match serde_norway::from_str(&content) {
695        Ok(v) => v,
696        Err(e) => {
697            tracing::warn!(
698                path = %config_path.display(),
699                error = %e,
700                "failed to parse .orch.yml; channel config will use defaults"
701            );
702            return ProjectChannelConfig::default();
703        }
704    };
705    let channels = match val.get("channels") {
706        Some(c) => c,
707        None => return ProjectChannelConfig::default(),
708    };
709    let get_str = |section: &str, key: &str| -> Option<String> {
710        channels
711            .get(section)
712            .and_then(|s| s.get(key))
713            .and_then(|v| v.as_str())
714            .map(String::from)
715    };
716    ProjectChannelConfig {
717        telegram_topic_id: get_str("telegram", "topic_id"),
718        telegram_bot_token: get_str("telegram", "bot_token"),
719        telegram_chat_id: get_str("telegram", "chat_id"),
720        discord_channel_id: get_str("discord", "channel_id"),
721        discord_bot_token: get_str("discord", "bot_token"),
722        discord_guild_id: get_str("discord", "guild_id"),
723    }
724}
725
726/// Fetch the latest orch release tag from GitHub via the `gh` CLI (async).
727///
728/// Returns the version string (without the leading "v"), or `None` when the
729/// check fails (no network, unauthenticated, `gh` not installed, etc.).
730async fn fetch_latest_release_version() -> Option<String> {
731    let output = Command::new("gh")
732        .args([
733            "api",
734            "repos/gabrielkoerich/orch/releases/latest",
735            "--jq",
736            ".tag_name",
737        ])
738        .output()
739        .await
740        .ok()?;
741    if !output.status.success() {
742        return None;
743    }
744    let tag = String::from_utf8(output.stdout).ok()?;
745    let version = tag.trim().trim_start_matches('v').to_string();
746    if version.is_empty() {
747        None
748    } else {
749        Some(version)
750    }
751}
752
753/// Check if a newer orch release is available and send channel notifications
754/// asking the operator to upgrade manually. Upgrading is operator-only — the
755/// service never runs `brew` itself.
756///
757/// Uses the store's KV to throttle checks (`upgrade:last_check_at`) and
758/// deduplicate notifications (`upgrade:last_notified_version`). Each distinct
759/// latest version is notified about once; the notification repeats only when
760/// an even newer version is released.
761async fn check_and_notify_upgrade(
762    store: &Arc<TaskStore>,
763    channels: &Arc<ChannelRegistry>,
764    current_version: &str,
765    check_interval: std::time::Duration,
766) {
767    let last_check_key = "upgrade:last_check_at";
768    let last_notified_key = "upgrade:last_notified_version";
769
770    // Throttle: only check once per interval.
771    let now = chrono::Utc::now().timestamp();
772    if let Ok(Some(last_check_str)) = store.kv_get(last_check_key).await {
773        if let Ok(last_check) = last_check_str.parse::<i64>() {
774            if now - last_check < check_interval.as_secs() as i64 {
775                return;
776            }
777        }
778    }
779
780    // Record check timestamp (best-effort).
781    let _ = store.kv_set(last_check_key, &now.to_string()).await;
782
783    let latest = match fetch_latest_release_version().await {
784        Some(v) => v,
785        None => {
786            tracing::debug!("upgrade check: failed to fetch latest release version");
787            return;
788        }
789    };
790
791    if latest == current_version {
792        tracing::debug!(current = %current_version, "orch is up to date");
793        return;
794    }
795
796    tracing::warn!(
797        current_version = %current_version,
798        latest_version = %latest,
799        "orch upgrade available"
800    );
801
802    // Operator-only upgrade: send channel notifications so the operator can
803    // upgrade on their own schedule. The service never runs brew itself.
804
805    // Deduplicate: don't re-notify for the same latest version.
806    if let Ok(Some(last_notified)) = store.kv_get(last_notified_key).await {
807        if last_notified == latest {
808            tracing::debug!(latest = %latest, "upgrade already notified");
809            return;
810        }
811    }
812
813    let msg_telegram = format!(
814        "⚠️ <b>Orch Upgrade Available</b>\n\n\
815         Service: <code>{current_version}</code>\n\
816         Latest:  <code>{latest}</code>\n\n\
817         Run: <code>brew update && brew upgrade orch && brew services restart orch</code>"
818    );
819
820    let msg_discord = format!(
821        "⚠️ **Orch Upgrade Available**\n\n\
822         Service: `{current_version}`\n\
823         Latest:  `{latest}`\n\n\
824         Run: `brew update && brew upgrade orch && brew services restart orch`"
825    );
826
827    let msg_slack = format!(
828        "⚠️ *Orch Upgrade Available*\n\n\
829         Service: `{current_version}`\n\
830         Latest:  `{latest}`\n\n\
831         Run: `brew update && brew upgrade orch && brew services restart orch`"
832    );
833
834    for channel in channels.iter() {
835        let (body, metadata, topic_id) = match channel.name() {
836            "telegram" => (
837                msg_telegram.clone(),
838                serde_json::json!({"preformatted_html": true}),
839                None,
840            ),
841            "discord" => (msg_discord.clone(), serde_json::json!({}), None),
842            "slack" => (msg_slack.clone(), serde_json::json!({}), None),
843            _ => continue,
844        };
845
846        let msg = OutgoingMessage {
847            thread_id: "upgrade".to_string(),
848            body,
849            reply_to: None,
850            metadata,
851            topic_id,
852        };
853
854        if let Err(e) = channel.send(&msg).await {
855            tracing::warn!(
856                channel = channel.name(),
857                ?e,
858                "failed to send upgrade notification"
859            );
860        }
861    }
862
863    // Record that we notified for this latest version.
864    let _ = store.kv_set(last_notified_key, &latest).await;
865}
866
867/// Send a one-time escalation notification when the startup retry loop has been
868/// spinning for longer than `startup_failure_escalation_secs`.
869///
870/// This is called *before* the channel registry is initialised (the retry loop
871/// sits above it in `serve()`), so we construct channel objects directly from
872/// config — mirroring the approach used during normal channel registration, but
873/// without relying on `ChannelRegistry`.
874async fn send_startup_escalation_notification(elapsed_secs: u64) {
875    let duration = crate::channels::notification::format_duration(elapsed_secs as f64);
876
877    let msg_telegram = format!(
878        "🚨 <b>Orch startup blocked — GitHub unreachable</b>\n\n\
879         The engine has been retrying backend connections for {}\n\
880         without success. All task processing, routing, and scheduled\n\
881         jobs are paused. Check service logs with `orch log -f`.",
882        duration
883    );
884
885    let msg_discord = format!(
886        "🚨 **Orch startup blocked — GitHub unreachable**\n\n\
887         The engine has been retrying backend connections for {}\n\
888         without success. All task processing, routing, and scheduled\n\
889         jobs are paused. Check service logs with `orch log -f`.",
890        duration
891    );
892
893    let msg_slack = format!(
894        "🚨 *Orch startup blocked — GitHub unreachable*\n\n\
895         The engine has been retrying backend connections for {}\n\
896         without success. All task processing, routing, and scheduled\n\
897         jobs are paused. Check service logs with `orch log -f`.",
898        duration
899    );
900
901    // Telegram
902    let tg_token = crate::config::get("channels.telegram.bot_token");
903    let tg_chat_id = crate::config::get("channels.telegram.chat_id");
904    if let (Ok(token), Ok(chat_id)) = (&tg_token, &tg_chat_id) {
905        if !token.is_empty() && !chat_id.is_empty() {
906            if let Ok(channel) = TelegramChannel::new(token.clone(), Some(chat_id.clone())) {
907                let msg = OutgoingMessage {
908                    thread_id: "startup_escalation".to_string(),
909                    body: msg_telegram.clone(),
910                    reply_to: None,
911                    metadata: serde_json::json!({ "preformatted_html": true }),
912                    topic_id: crate::config::get("channels.telegram.general_topic_id").ok(),
913                };
914                if let Err(e) = channel.send(&msg).await {
915                    tracing::warn!(?e, "failed to send startup escalation via telegram");
916                } else {
917                    tracing::info!("startup escalation notification sent to telegram");
918                }
919            }
920        }
921    }
922
923    // Discord
924    if let Ok(token) = crate::config::get("channels.discord.bot_token") {
925        let channel_id = crate::config::get("channels.discord.channel_id")
926            .or_else(|_| crate::config::get("channels.discord.general_channel_id"));
927        if !token.is_empty() {
928            if let Some(cid) = channel_id.ok().filter(|s| !s.is_empty()) {
929                let shard_id = crate::config::get("channels.discord.shard_id")
930                    .ok()
931                    .and_then(|v| v.parse::<u64>().ok())
932                    .unwrap_or(0);
933                let shard_count = crate::config::get("channels.discord.shard_count")
934                    .ok()
935                    .and_then(|v| v.parse::<u64>().ok())
936                    .unwrap_or(1);
937                if let Ok(gateway) = DiscordGateway::new(token, Some(cid), shard_id, shard_count) {
938                    let msg = OutgoingMessage {
939                        thread_id: "startup_escalation".to_string(),
940                        body: msg_discord.clone(),
941                        reply_to: None,
942                        metadata: serde_json::Value::Null,
943                        topic_id: None,
944                    };
945                    if let Err(e) = gateway.send(&msg).await {
946                        tracing::warn!(?e, "failed to send startup escalation via discord");
947                    } else {
948                        tracing::info!("startup escalation notification sent to discord");
949                    }
950                }
951            }
952        }
953    }
954
955    // Slack
956    if let Ok(token) = crate::config::get("channels.slack.bot_token") {
957        let channel_id = crate::config::get("channels.slack.channel_id");
958        if !token.is_empty() {
959            if let Ok(cid) = channel_id {
960                if let Ok(channel) = SlackChannel::new(token, Some(cid)) {
961                    let msg = OutgoingMessage {
962                        thread_id: "startup_escalation".to_string(),
963                        body: msg_slack.clone(),
964                        reply_to: None,
965                        metadata: serde_json::Value::Null,
966                        topic_id: None,
967                    };
968                    if let Err(e) = channel.send(&msg).await {
969                        tracing::warn!(?e, "failed to send startup escalation via slack");
970                    } else {
971                        tracing::info!("startup escalation notification sent to slack");
972                    }
973                }
974            }
975        }
976    }
977}
978
979/// Start the orch service.
980///
981/// This is the main entry point — called by `orch serve`.
982pub async fn serve() -> anyhow::Result<()> {
983    let killed = crate::home::kill_other_orch_serve_processes(std::time::Duration::from_secs(30));
984    if killed > 0 {
985        tracing::info!(count = killed, "terminated stale orch serve processes");
986    }
987
988    tracing::info!("orch engine starting");
989
990    // Pre-create standard directories with async I/O so subsequent synchronous
991    // callers (state_dir, orch_home, etc.) find them already present and their
992    // create_dir_all calls become fast no-ops.
993    if let Err(e) = crate::home::init_dirs().await {
994        tracing::warn!("failed to initialize orch directories: {e}");
995    }
996
997    // Pre-warm the config cache with async I/O so get() / get_list() calls in
998    // hot async paths never block a Tokio thread on std::fs::read_to_string.
999    if let Err(e) = crate::config::warm_cache().await {
1000        tracing::warn!("failed to warm config cache: {e}");
1001    }
1002
1003    let mut config = EngineConfig::from_config();
1004
1005    tracing::info!("internal database ready");
1006
1007    // Initialize project engines — retry with backoff so a network outage at
1008    // startup doesn't cause a crash-loop (launchd KeepAlive would restart us
1009    // immediately, filling the error log with 600+ identical messages).
1010    //
1011    // IMPORTANT: errors from init_project_engines() must NEVER propagate past
1012    // this loop to main().  If they did, anyhow would write "Error: …" to stderr,
1013    // which brew routes to orch.error.log — polluting it even when projects ARE
1014    // configured correctly but GitHub is temporarily unreachable.
1015    let startup_escalation_secs = config.startup_failure_escalation_secs;
1016    let mut project_engines = {
1017        let mut delay_secs = 5u64;
1018        let mut attempt = 0u32;
1019        let mut escalated = false;
1020        let start = std::time::Instant::now();
1021        loop {
1022            attempt += 1;
1023            match init_project_engines().await {
1024                Ok(engines) => break engines,
1025                Err(e) => {
1026                    let elapsed = start.elapsed().as_secs();
1027
1028                    // Short blips (under the escalation threshold) stay at WARN
1029                    // so they don't flood the operator's attention. Once the
1030                    // outage crosses the threshold, escalate to ERROR-level
1031                    // logging and send a one-time push notification to configured
1032                    // channels — "transient" has clearly become "prolonged."
1033                    if startup_escalation_secs > 0
1034                        && !escalated
1035                        && elapsed >= startup_escalation_secs
1036                    {
1037                        escalated = true;
1038                        tracing::error!(
1039                            delay_secs,
1040                            attempt,
1041                            elapsed_secs = elapsed,
1042                            threshold_secs = startup_escalation_secs,
1043                            "project backends unavailable for {} — escalating startup failure: {e}",
1044                            crate::channels::notification::format_duration(elapsed as f64)
1045                        );
1046                        // Send one-time notification to configured channels.
1047                        // Channels aren't registered yet at this stage, so we
1048                        // construct them directly from config (mirrors orch notify).
1049                        send_startup_escalation_notification(elapsed).await;
1050                    } else {
1051                        tracing::warn!(
1052                            delay_secs,
1053                            attempt,
1054                            "project backends unavailable, retrying: {e}"
1055                        );
1056                    }
1057
1058                    tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
1059                    delay_secs = (delay_secs * 2).min(120);
1060                }
1061            }
1062        }
1063    };
1064
1065    tracing::info!(
1066        project_count = project_engines.len(),
1067        "initialized project engines"
1068    );
1069
1070    // Full set of currently-configured project repos, used by the per-tick global
1071    // sweeps (e.g. `dispatch_routed_tasks_global`) to distinguish "orphaned" tasks
1072    // (repo no longer in config) from tasks belonging to some other active project.
1073    let active_repos: std::collections::HashSet<String> =
1074        project_engines.iter().map(|e| e.repo.clone()).collect();
1075
1076    // Create the event bus for task status transitions
1077    let event_bus = events::EventBus::new(256);
1078    if let Err(e) = event_bus.start_ws_server().await {
1079        tracing::warn!(
1080            ?e,
1081            "failed to start event websocket server, continuing without it"
1082        );
1083    }
1084
1085    // Check for a newer orch release in the background and log if the service is behind.
1086    // The periodic checker sends channel notifications after channels are initialized;
1087    // upgrading is operator-only and never runs automatically.
1088    {
1089        let current = env!("ORCH_VERSION").to_string();
1090        tokio::spawn(async move {
1091            if let Some(latest) = fetch_latest_release_version().await {
1092                if latest != current {
1093                    tracing::warn!(
1094                        current_version = %current,
1095                        latest_version = %latest,
1096                        "orch service is behind the latest release — run: brew update && brew upgrade orch && brew services restart orch"
1097                    );
1098                }
1099            }
1100        });
1101    }
1102
1103    // Re-create task managers with shared store and event bus
1104    for engine in &mut project_engines {
1105        engine.task_manager = Arc::new(TaskManager::with_events(
1106            engine.backend.clone(),
1107            engine.store.clone(),
1108            engine.repo.clone(),
1109            event_bus.sender(),
1110        ));
1111    }
1112
1113    if let Err(e) = reconcile_startup_worktrees(&project_engines).await {
1114        tracing::warn!(err = %e, "startup worktree reconciliation failed");
1115    }
1116
1117    // Clear incremental ingest cursors so the first sync after startup re-scans the last 24h.
1118    // This catches issues created during engine downtime that would otherwise be permanently
1119    // skipped (GitHub's `since` filter uses updated_at, not created_at).
1120    for engine in &project_engines {
1121        crate::engine::sync::clear_issues_last_ingested(&engine.store, &engine.repo).await;
1122    }
1123
1124    {
1125        // Run estimate reconciliation in the background so it never blocks startup.
1126        // Each GraphQL mutation takes ~0.8 s; with hundreds of tasks this would
1127        // otherwise delay the engine reaching its main loop by several minutes.
1128        let engines_snapshot: Vec<ProjectEngine> = project_engines.clone();
1129        tokio::spawn(async move {
1130            reconcile_startup_estimates(&engines_snapshot).await;
1131        });
1132    }
1133
1134    // Build ChannelRouter from global config + per-project configs
1135    let global_channel_config = GlobalChannelConfig {
1136        telegram_general_topic_id: crate::config::get("channels.telegram.general_topic_id").ok(),
1137        discord_general_channel_id: crate::config::get("channels.discord.general_channel_id").ok(),
1138        control_telegram_topic_id: crate::config::get("control.channels.telegram.topic_id").ok(),
1139        control_discord_channel_id: crate::config::get("control.channels.discord.channel_id").ok(),
1140    };
1141    let project_channel_configs: Vec<(String, ProjectChannelConfig)> = project_engines
1142        .iter()
1143        .map(|e| {
1144            let cfg = read_project_channel_config(&e.project_dir);
1145            (e.repo.clone(), cfg)
1146        })
1147        .collect();
1148    let channel_router = Arc::new(ChannelRouter::new(
1149        &global_channel_config,
1150        &project_channel_configs,
1151    ));
1152    tracing::info!(
1153        projects = ?channel_router.projects(),
1154        "channel router initialized"
1155    );
1156
1157    // Initialize tmux manager (shared across all projects)
1158    let tmux = Arc::new(TmuxManager::new());
1159
1160    // Initialize transport
1161    let transport = Arc::new(Transport::new());
1162
1163    // Initialize capture service and start background loop
1164    let capture = Arc::new(CaptureService::new(transport.clone()));
1165    let capture_for_tick = capture.clone();
1166    tokio::spawn(async move {
1167        capture.start().await;
1168    });
1169
1170    // Initialize channel registry
1171    let mut channel_registry = ChannelRegistry::new();
1172
1173    // Try to initialize Telegram channel
1174    if let Ok(token) = crate::config::get("channels.telegram.bot_token") {
1175        if !token.is_empty() {
1176            let chat_id = crate::config::get("channels.telegram.chat_id").ok();
1177            match TelegramChannel::new(token, chat_id) {
1178                Ok(telegram) => {
1179                    if let Err(e) = telegram.health_check().await {
1180                        tracing::warn!(?e, "telegram channel health check failed, skipping");
1181                    } else {
1182                        channel_registry.register(Box::new(telegram));
1183                        tracing::info!("telegram channel registered");
1184                    }
1185                }
1186                Err(e) => {
1187                    tracing::warn!(?e, "failed to create telegram channel, skipping");
1188                }
1189            }
1190        }
1191    }
1192
1193    // Try to initialize Discord Gateway channel (websocket)
1194    if let Ok(token) = crate::config::get("channels.discord.bot_token") {
1195        if !token.is_empty() {
1196            let channel_id = crate::config::get("channels.discord.channel_id").ok();
1197            let shard_id = crate::config::get("channels.discord.shard_id")
1198                .ok()
1199                .and_then(|v| v.parse::<u64>().ok())
1200                .unwrap_or(0);
1201            let shard_count = crate::config::get("channels.discord.shard_count")
1202                .ok()
1203                .and_then(|v| v.parse::<u64>().ok())
1204                .unwrap_or(1);
1205            match DiscordGateway::new(token, channel_id, shard_id, shard_count) {
1206                Ok(discord) => {
1207                    if let Err(e) = discord.health_check().await {
1208                        tracing::warn!(?e, "discord gateway health check failed, skipping");
1209                    } else {
1210                        channel_registry.register(Box::new(discord));
1211                        tracing::info!(shard_id, shard_count, "discord gateway registered");
1212                    }
1213                }
1214                Err(e) => {
1215                    tracing::warn!(?e, "failed to create discord gateway, skipping");
1216                }
1217            }
1218        }
1219    }
1220
1221    // Try to initialize Slack channel
1222    if let Ok(token) = crate::config::get("channels.slack.bot_token") {
1223        if !token.is_empty() {
1224            let channel_id = crate::config::get("channels.slack.channel_id").ok();
1225            match SlackChannel::new(token, channel_id) {
1226                Ok(slack) => {
1227                    if let Err(e) = slack.health_check().await {
1228                        tracing::warn!(?e, "slack channel health check failed, skipping");
1229                    } else {
1230                        channel_registry.register(Box::new(slack));
1231                        tracing::info!("slack channel registered");
1232                    }
1233                }
1234                Err(e) => {
1235                    tracing::warn!(?e, "failed to create slack channel, skipping");
1236                }
1237            }
1238        }
1239    }
1240
1241    // Initialize tmux channel with transport for output streaming
1242    let tmux_channel = TmuxChannel::with_transport(transport.clone());
1243    channel_registry.register(Box::new(tmux_channel));
1244    tracing::info!("tmux channel registered");
1245
1246    // Start all channels and collect their message receivers
1247    let mut channel_receivers: Vec<tokio::sync::mpsc::Receiver<IncomingMessage>> = Vec::new();
1248    for channel in channel_registry.iter() {
1249        match channel.start().await {
1250            Ok(rx) => {
1251                tracing::info!(channel = channel.name(), "channel started");
1252                channel_receivers.push(rx);
1253            }
1254            Err(e) => {
1255                tracing::warn!(channel = channel.name(), ?e, "failed to start channel");
1256            }
1257        }
1258    }
1259
1260    // Wrap channel registry in Arc for shared access (notification dispatcher needs it)
1261    let channel_registry = Arc::new(channel_registry);
1262
1263    // Spawn tasks to handle incoming channel messages (if any channels are active)
1264    let transport_for_messages = transport.clone();
1265    let tmux_for_messages = tmux.clone();
1266    let capture_for_messages = capture_for_tick.clone();
1267    let channels_for_messages = channel_registry.clone();
1268    // Lightweight engine references for command execution and task creation
1269    let engine_refs: Vec<EngineRef> = project_engines
1270        .iter()
1271        .map(|e| {
1272            (
1273                e.repo.clone(),
1274                e.backend.clone(),
1275                e.task_manager.clone(),
1276                Some(e.store.clone()),
1277            )
1278        })
1279        .collect();
1280    // Shared in-memory map for pending project picks (General channel multi-project picker).
1281    let pending_picks: channel_handler::PendingPicks =
1282        std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
1283    let router_for_messages = channel_router.clone();
1284    for mut rx in channel_receivers {
1285        let transport = transport_for_messages.clone();
1286        let tmux = tmux_for_messages.clone();
1287        let capture = capture_for_messages.clone();
1288        let channels = channels_for_messages.clone();
1289        let engine_refs = engine_refs.clone();
1290        let ch_router = router_for_messages.clone();
1291        let picks = pending_picks.clone();
1292        tokio::spawn(async move {
1293            while let Some(msg) = rx.recv().await {
1294                tracing::debug!(channel = %msg.channel, thread = %msg.thread_id, "received message from channel");
1295                channel_handler::handle_channel_message(
1296                    msg,
1297                    &transport,
1298                    &tmux,
1299                    &capture,
1300                    &channels,
1301                    &engine_refs,
1302                    &ch_router,
1303                    &picks,
1304                )
1305                .await;
1306            }
1307        });
1308    }
1309
1310    // Post-restart confirmation: if the service was restarted via the /restart control command,
1311    // a KV marker was stored before shutdown. Send a confirmation to the originating channel now
1312    // that we're back online. Use a short delay to let the channel connections re-establish.
1313    {
1314        let restart_channels = channel_registry.clone();
1315        let restart_store = project_engines.first().map(|e| e.store.clone());
1316        if let Some(store) = restart_store {
1317            tokio::spawn(async move {
1318                tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1319                match store.kv_get("control:restart_pending").await {
1320                    Ok(Some(conv_key)) => {
1321                        if let Some((ch_name, thread_id, topic_id)) =
1322                            crate::channels::transport::parse_conversation_key(&conv_key)
1323                        {
1324                            for ch in restart_channels.iter() {
1325                                if ch.name() == ch_name {
1326                                    let msg = OutgoingMessage {
1327                                        thread_id: thread_id.to_string(),
1328                                        body: "orch restarted successfully.".to_string(),
1329                                        reply_to: None,
1330                                        metadata: serde_json::json!({}),
1331                                        topic_id: topic_id.map(|t| t.to_string()),
1332                                    };
1333                                    if let Err(e) = ch.send(&msg).await {
1334                                        tracing::warn!(
1335                                            channel = ch_name,
1336                                            error = %e,
1337                                            "failed to send restart confirmation"
1338                                        );
1339                                    }
1340                                    break;
1341                                }
1342                            }
1343                        }
1344                        if let Err(e) = store.kv_delete("control:restart_pending").await {
1345                            tracing::warn!(error = %e, "failed to clear control:restart_pending");
1346                        }
1347                    }
1348                    Ok(None) => {} // no pending restart, nothing to do
1349                    Err(e) => {
1350                        tracing::warn!(error = %e, "failed to read control:restart_pending");
1351                    }
1352                }
1353            });
1354        }
1355    }
1356
1357    // Spawn notification dispatcher — reads task completion notifications
1358    // from transport and broadcasts to configured channels.
1359    //
1360    // Routing priority:
1361    // 1. Dedicated channel: if the notification has a `repo` and the
1362    //    ChannelRouter maps it to a topic/channel, send there with `topic_id`.
1363    // 2. Subscribed channels: send to each (channel, thread_id) subscribed to
1364    //    the repo, using `format_with_project()` so recipients know the source.
1365    // 3. Fallback: if no repo or no dedicated channel, broadcast to all
1366    //    channels without a topic_id (legacy behaviour).
1367    {
1368        let mut notification_rx = transport.subscribe_notifications();
1369        let channels = channel_registry.clone();
1370        let notif_router = channel_router.clone();
1371        // Grab a store reference for subscription lookups.
1372        let notif_store: Option<Arc<TaskStore>> = project_engines.first().map(|e| e.store.clone());
1373        tokio::spawn(async move {
1374            loop {
1375                match notification_rx.recv().await {
1376                    Ok(notification) => {
1377                        let level = NotificationLevel::from_config();
1378                        if !level.should_notify(&notification.status) {
1379                            tracing::debug!(
1380                                task_id = %notification.task_id,
1381                                status = %notification.status,
1382                                "notification suppressed by level={:?}",
1383                                level
1384                            );
1385                            continue;
1386                        }
1387
1388                        tracing::info!(
1389                            task_id = %notification.task_id,
1390                            status = %notification.status,
1391                            repo = ?notification.repo,
1392                            "routing notification to channels"
1393                        );
1394
1395                        // Track whether we sent to at least one dedicated/subscribed target.
1396                        let mut routed = false;
1397
1398                        // 0. Direct target: if notify_target is set, send straight to
1399                        //    that Telegram chat_id and skip all other routing.
1400                        if let Some(ref target) = notification.notify_target {
1401                            let telegram = channels.iter().find(|c| c.name() == "telegram");
1402                            if let Some(channel) = telegram {
1403                                let body = notification.format_telegram();
1404                                let metadata = serde_json::json!({
1405                                    "preformatted_html": true,
1406                                    "chat_id_override": target
1407                                });
1408                                let msg = OutgoingMessage {
1409                                    thread_id: notification.task_id.clone(),
1410                                    body,
1411                                    reply_to: None,
1412                                    metadata,
1413                                    topic_id: None,
1414                                };
1415                                if let Err(e) = channel.send(&msg).await {
1416                                    tracing::warn!(
1417                                        task_id = %notification.task_id,
1418                                        target,
1419                                        ?e,
1420                                        "failed to send to notify_target"
1421                                    );
1422                                } else {
1423                                    routed = true;
1424                                }
1425                            }
1426                        }
1427
1428                        if routed {
1429                            // notify_target handled — skip further routing
1430                        } else if let Some(repo) = notification.repo.as_deref() {
1431                            // Track destinations already sent to in this iteration so
1432                            // the dedicated (path 1) and subscribed (path 2) loops
1433                            // don't double-fire the same Telegram/Discord destination
1434                            // when a repo's configured topic and a `channel_subscriptions`
1435                            // row resolve to the same `(channel, topic_id)`.
1436                            let mut sent: std::collections::HashSet<(String, Option<String>)> =
1437                                std::collections::HashSet::new();
1438
1439                            // 1. Dedicated channel targets for this repo.
1440                            for channel in channels.iter() {
1441                                let ch_name = channel.name();
1442                                if let Some(topic_id) =
1443                                    notif_router.target_for_project(repo, ch_name)
1444                                {
1445                                    let body = match ch_name {
1446                                        "telegram" => notification.format_telegram(),
1447                                        "discord" => notification.format_discord(),
1448                                        "slack" => notification.format_slack(),
1449                                        _ => continue,
1450                                    };
1451                                    let metadata = if ch_name == "telegram" {
1452                                        serde_json::json!({"preformatted_html": true})
1453                                    } else {
1454                                        serde_json::json!({})
1455                                    };
1456                                    let resolved_topic = Some(topic_id.to_string());
1457                                    let msg = OutgoingMessage {
1458                                        thread_id: notification.task_id.clone(),
1459                                        body,
1460                                        reply_to: None,
1461                                        metadata,
1462                                        topic_id: resolved_topic.clone(),
1463                                    };
1464                                    if let Err(e) = channel.send(&msg).await {
1465                                        tracing::warn!(
1466                                            channel = ch_name,
1467                                            task_id = %notification.task_id,
1468                                            ?e,
1469                                            "failed to send to dedicated channel"
1470                                        );
1471                                    } else {
1472                                        sent.insert((ch_name.to_string(), resolved_topic));
1473                                        routed = true;
1474                                    }
1475                                }
1476                            }
1477
1478                            // 2. Subscribed channels for this repo.
1479                            if let Some(store) = &notif_store {
1480                                match store.list_subscribers_for_repo(repo).await {
1481                                    Ok(subscribers) => {
1482                                        for (ch_name, thread_id, topic_id) in subscribers {
1483                                            let channel =
1484                                                channels.iter().find(|c| c.name() == ch_name);
1485                                            let Some(channel) = channel else {
1486                                                continue;
1487                                            };
1488                                            let resolved_topic = if topic_id.is_empty() {
1489                                                None
1490                                            } else {
1491                                                Some(topic_id.clone())
1492                                            };
1493                                            // Skip if a dedicated send to the same
1494                                            // (channel, topic_id) already happened.
1495                                            if sent.contains(&(
1496                                                ch_name.clone(),
1497                                                resolved_topic.clone(),
1498                                            )) {
1499                                                tracing::debug!(
1500                                                    channel = %ch_name,
1501                                                    task_id = %notification.task_id,
1502                                                    topic_id = ?resolved_topic,
1503                                                    "skipping duplicate subscriber dispatch — destination already received from dedicated path"
1504                                                );
1505                                                continue;
1506                                            }
1507                                            let body = notification.format_with_project(&ch_name);
1508                                            let metadata = if ch_name == "telegram" {
1509                                                serde_json::json!({"preformatted_html": true})
1510                                            } else {
1511                                                serde_json::json!({})
1512                                            };
1513                                            let msg = OutgoingMessage {
1514                                                thread_id: thread_id.clone(),
1515                                                body,
1516                                                reply_to: None,
1517                                                metadata,
1518                                                topic_id: resolved_topic.clone(),
1519                                            };
1520                                            if let Err(e) = channel.send(&msg).await {
1521                                                tracing::warn!(
1522                                                    channel = %ch_name,
1523                                                    task_id = %notification.task_id,
1524                                                    ?e,
1525                                                    "failed to send to subscribed channel"
1526                                                );
1527                                            } else {
1528                                                sent.insert((ch_name.clone(), resolved_topic));
1529                                                routed = true;
1530                                            }
1531                                        }
1532                                    }
1533                                    Err(e) => {
1534                                        tracing::warn!(
1535                                            repo,
1536                                            ?e,
1537                                            "failed to list subscribers for repo"
1538                                        );
1539                                    }
1540                                }
1541                            }
1542                        }
1543
1544                        // 3. Fallback: broadcast to all channels when no routing happened.
1545                        if !routed {
1546                            for channel in channels.iter() {
1547                                let (body, should_send, is_telegram) = match channel.name() {
1548                                    "telegram" => (notification.format_telegram(), true, true),
1549                                    "discord" => (notification.format_discord(), true, false),
1550                                    "slack" => (notification.format_slack(), true, false),
1551                                    // GitHub is already handled by backend.post_comment()
1552                                    // tmux doesn't need task completion notifications
1553                                    _ => (String::new(), false, false),
1554                                };
1555
1556                                if !should_send {
1557                                    continue;
1558                                }
1559
1560                                let metadata = if is_telegram {
1561                                    serde_json::json!({"preformatted_html": true})
1562                                } else {
1563                                    serde_json::json!({})
1564                                };
1565                                let msg = OutgoingMessage {
1566                                    thread_id: notification.task_id.clone(),
1567                                    body,
1568                                    reply_to: None,
1569                                    metadata,
1570                                    topic_id: None,
1571                                };
1572
1573                                if let Err(e) = channel.send(&msg).await {
1574                                    tracing::warn!(
1575                                        channel = channel.name(),
1576                                        task_id = %notification.task_id,
1577                                        ?e,
1578                                        "failed to send notification"
1579                                    );
1580                                }
1581                            }
1582                        }
1583                    }
1584                    Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1585                        tracing::warn!(missed = n, "notification receiver lagged");
1586                    }
1587                    Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1588                        tracing::debug!("notification channel closed");
1589                        break;
1590                    }
1591                }
1592            }
1593        });
1594        tracing::info!("notification dispatcher started");
1595    }
1596
1597    // Notify used by webhook events to wake up the engine tick immediately
1598    let webhook_notify = Arc::new(Notify::new());
1599
1600    // Track webhook state for health checks and fallback.
1601    // When webhooks are disabled, `in_fallback_mode` stays true so sync
1602    // uses the faster fallback interval for polling.
1603    let webhook_port: Option<u16>;
1604    let mut webhook_healthy: bool;
1605    let mut last_webhook_health_check = std::time::Instant::now();
1606    // Start webhook server if configured
1607    let webhook_enabled = crate::config::get("webhook.enabled")
1608        .map(|v| v == "true")
1609        .unwrap_or(false);
1610
1611    // Shared status updated by the server-spawn task and the health-check loop.
1612    let webhook_status = Arc::new(tokio::sync::Mutex::new(
1613        crate::webhook_status::WebhookStatus::default(),
1614    ));
1615
1616    if webhook_enabled {
1617        let port: u16 = crate::config::get("webhook.port")
1618            .ok()
1619            .and_then(|v| v.parse().ok())
1620            .unwrap_or(8080);
1621        webhook_port = Some(port);
1622        let secret = crate::config::get("webhook.secret").unwrap_or_default();
1623        let webhook_repo = project_engines
1624            .first()
1625            .map(|e| e.repo.clone())
1626            .unwrap_or_default();
1627
1628        let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(64);
1629
1630        // Initialise status: configured, not yet healthy.
1631        let to_save = {
1632            let mut s = webhook_status.lock().await;
1633            s.configured = true;
1634            s.port = Some(port);
1635            s.healthy = false;
1636            s.fallback_mode = false;
1637            s.clone()
1638        };
1639        to_save.save().await;
1640
1641        // Spawn the HTTP server with exponential-backoff retry on transient
1642        // bind errors (e.g. EADDRINUSE).  After MAX_ATTEMPTS the engine falls
1643        // back to polling and updates the shared status accordingly.
1644        const WEBHOOK_MAX_ATTEMPTS: u32 = 5;
1645        let status_for_spawn = webhook_status.clone();
1646        tokio::spawn(async move {
1647            use crate::channels::github::{is_transient_bind_error, webhook_backoff_delay};
1648            let mut attempt = 0u32;
1649            loop {
1650                attempt += 1;
1651                tracing::info!(attempt, port, "attempting to start webhook server");
1652                match start_webhook_server(port, secret.clone(), webhook_repo.clone(), tx.clone())
1653                    .await
1654                {
1655                    Ok(_) => {
1656                        tracing::info!(attempt, "webhook server exited cleanly");
1657                        break;
1658                    }
1659                    Err(e) => {
1660                        let reason = e.to_string();
1661                        tracing::error!(attempt, %reason, "webhook server failed");
1662                        if attempt >= WEBHOOK_MAX_ATTEMPTS || !is_transient_bind_error(&e) {
1663                            let kind = if attempt >= WEBHOOK_MAX_ATTEMPTS {
1664                                "max attempts reached"
1665                            } else {
1666                                "non-transient error"
1667                            };
1668                            tracing::error!(
1669                                attempt,
1670                                %reason,
1671                                kind,
1672                                orch_webhook_in_fallback = true,
1673                                "webhook server giving up, switching to polling fallback"
1674                            );
1675                            let to_save = {
1676                                let mut s = status_for_spawn.lock().await;
1677                                s.fallback_mode = true;
1678                                s.healthy = false;
1679                                s.last_failure_reason = Some(reason);
1680                                s.startup_attempts = attempt;
1681                                s.clone()
1682                            };
1683                            to_save.save().await;
1684                            break;
1685                        }
1686                        let delay = webhook_backoff_delay(attempt);
1687                        tracing::warn!(
1688                            attempt,
1689                            delay_ms = delay.as_millis(),
1690                            %reason,
1691                            "webhook bind failed (transient), retrying with backoff"
1692                        );
1693                        {
1694                            let to_save = {
1695                                let mut s = status_for_spawn.lock().await;
1696                                s.startup_attempts = attempt;
1697                                s.last_failure_reason = Some(reason);
1698                                s.clone()
1699                            };
1700                            to_save.save().await;
1701                        }
1702                        tokio::time::sleep(delay).await;
1703                    }
1704                }
1705            }
1706        });
1707
1708        // Spawn the message forwarding task (reads from webhook channel)
1709        let notify = webhook_notify.clone();
1710        let transport_for_webhook = transport.clone();
1711        tokio::spawn(async move {
1712            while let Some(msg) = rx.recv().await {
1713                tracing::info!(
1714                    channel = %msg.channel,
1715                    thread = %msg.thread_id,
1716                    event = %msg.metadata.get("event").and_then(|v| v.as_str()).unwrap_or("unknown"),
1717                    action = %msg.metadata.get("action").and_then(|v| v.as_str()).unwrap_or("unknown"),
1718                    "webhook event received, triggering immediate tick"
1719                );
1720                let route = transport_for_webhook.route(&msg).await;
1721                tracing::info!(
1722                    channel = %msg.channel,
1723                    route = ?route,
1724                    "webhook event routed"
1725                );
1726                // Wake up the engine tick immediately instead of waiting up to 10s
1727                notify.notify_one();
1728            }
1729        });
1730
1731        webhook_healthy = true;
1732        tracing::info!(port, orch_webhook_up = true, "webhook server started");
1733    } else {
1734        webhook_port = None;
1735        webhook_healthy = false;
1736        {
1737            let to_save = {
1738                let mut s = webhook_status.lock().await;
1739                s.configured = false;
1740                s.fallback_mode = true;
1741                s.clone()
1742            };
1743            to_save.save().await;
1744        }
1745        tracing::info!(
1746            orch_webhook_in_fallback = true,
1747            "webhook server disabled, using polling fallback mode"
1748        );
1749    }
1750
1751    // Agent router (selects agent + model per task) - shared across projects.
1752    // Router::from_config() may run `opencode models` (a blocking subprocess) to
1753    // discover free models. Wrapping in spawn_blocking keeps the Tokio runtime
1754    // thread free during that I/O.
1755    // NOTE: Router::new() also calls prime_free_model_cache() (another blocking
1756    // subprocess), so the fallback must also run inside spawn_blocking.
1757    let router = Arc::new(RwLock::new(
1758        match tokio::task::spawn_blocking(Router::from_config).await {
1759            Ok(r) => r,
1760            Err(e) => {
1761                tracing::error!(?e, "router init panicked in spawn_blocking, using default");
1762                tokio::task::spawn_blocking(|| Router::new(RouterConfig::default()))
1763                    .await
1764                    .unwrap_or_else(|e2| {
1765                        panic!("router default init also panicked in spawn_blocking — cannot start engine without a router: {e2:?}");
1766                    })
1767            }
1768        },
1769    ));
1770    {
1771        let r = router.read().await;
1772        tracing::info!(
1773            mode = %r.config.mode,
1774            agents = ?r.available_agents,
1775            fallback = %r.config.fallback_executor,
1776            "router initialized"
1777        );
1778    }
1779
1780    // Concurrency limiter (shared across all projects)
1781    let semaphore = Arc::new(Semaphore::new(config.max_parallel));
1782
1783    // In-memory dispatch guard: tracks task IDs currently being dispatched.
1784    // Guards against GitHub API eventual consistency — after update_status(InProgress),
1785    // the label-removal webhook can trigger an immediate tick where list_by_status(Routed)
1786    // still returns the task (search index propagation delay). The tmux session does not
1787    // exist until the runner completes worktree setup (~10s later), so session_exists
1788    // alone is insufficient. Keyed by "{repo}/{task_id}".
1789    let dispatching: Arc<dashmap::DashMap<String, String>> = Arc::new(dashmap::DashMap::new());
1790
1791    // In-memory guard to prevent double-spawn of auto-merge background tasks.
1792    // Since review_open_prs runs every sync_tick (~10s) but CI polling can take
1793    // up to 10 minutes, we track which tasks already have an auto-merge in flight.
1794    let auto_merge_in_flight: Arc<dashmap::DashSet<String>> = Arc::new(dashmap::DashSet::new());
1795
1796    // Subscribe to config file changes for hot reload
1797    let mut config_rx = crate::config::subscribe();
1798
1799    // Track sync interval
1800    let mut last_sync = std::time::Instant::now();
1801
1802    // Track upgrade check interval
1803    let mut last_upgrade_check: Option<std::time::Instant> = None;
1804
1805    // Channel for weight signals from task runners back to the router
1806    let (weight_tx, mut weight_rx) = mpsc::channel::<WeightSignal>(64);
1807
1808    // Reset InReview tasks on startup. A task is reset to NeedsReview if:
1809    // 1. It was expecting a live review session (review_session_expected=true), OR
1810    // 2. It has been in InReview for >10 minutes (catches cases where the flag
1811    //    was never set due to crash-loops or lost events).
1812    // Tasks with a recent review comment on their PR are left alone.
1813    for engine in &project_engines {
1814        // Read from SQLite (source of truth) — GitHub labels may be out of sync.
1815        let in_review_from_store = engine
1816            .store
1817            .list_by_status(&engine.repo, crate::store::TaskStatus::InReview)
1818            .await
1819            .unwrap_or_default()
1820            .iter()
1821            .map(crate::engine::tasks::store_task_to_external)
1822            .collect::<Vec<_>>();
1823        if !in_review_from_store.is_empty() {
1824            let in_review = &in_review_from_store;
1825            for task in in_review {
1826                let session_expected =
1827                    review_session_expected(&engine.store, &engine.repo, &task.id.0).await;
1828                let age_minutes = chrono::DateTime::parse_from_rfc3339(&task.updated_at)
1829                    .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_minutes())
1830                    .unwrap_or(i64::MAX);
1831                if !session_expected && age_minutes < 10 {
1832                    continue;
1833                }
1834                if let Err(e) = engine
1835                    .task_manager
1836                    .update_task_status(&task.id, Status::NeedsReview)
1837                    .await
1838                {
1839                    tracing::warn!(
1840                        task_id = task.id.0,
1841                        err = %e,
1842                        "failed to reset stale InReview task on startup"
1843                    );
1844                } else {
1845                    set_review_session_expected(&engine.store, &engine.repo, &task.id.0, false)
1846                        .await;
1847                }
1848            }
1849            if !in_review.is_empty() {
1850                tracing::info!(
1851                    repo = %engine.repo,
1852                    count = in_review.len(),
1853                    "reset stale InReview tasks to NeedsReview on startup"
1854                );
1855            }
1856        }
1857
1858        // Also reset internal (SQLite) InReview tasks on startup.
1859        // Note: the block above already covers internal tasks from the store,
1860        // but this second pass catches any that were only in the old internal_tasks table.
1861        use crate::store::TaskStatus as DbStatus;
1862        if let Ok(internal_in_review) = engine
1863            .task_manager
1864            .list_internal_by_status(DbStatus::InReview)
1865            .await
1866        {
1867            for task in &internal_in_review {
1868                let task_id = task.id.0.clone();
1869                let session_expected =
1870                    review_session_expected(&engine.store, &engine.repo, &task_id).await;
1871                let age_minutes = chrono::DateTime::parse_from_rfc3339(&task.updated_at)
1872                    .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_minutes())
1873                    .unwrap_or(i64::MAX);
1874                if !session_expected && age_minutes < 10 {
1875                    continue;
1876                }
1877                if let Err(e) = engine
1878                    .task_manager
1879                    .update_task_status(&ExternalId(task_id.clone()), Status::NeedsReview)
1880                    .await
1881                {
1882                    tracing::warn!(
1883                        task_id,
1884                        err = %e,
1885                        "failed to reset stale internal InReview task on startup"
1886                    );
1887                } else {
1888                    set_review_session_expected(&engine.store, &engine.repo, &task_id, false).await;
1889                }
1890            }
1891            if !internal_in_review.is_empty() {
1892                tracing::info!(
1893                    repo = %engine.repo,
1894                    count = internal_in_review.len(),
1895                    "reset stale internal InReview tasks to NeedsReview on startup"
1896                );
1897            }
1898        }
1899    }
1900
1901    // Spawn dispatch subscribers — react to Routed events immediately
1902    for engine in &project_engines {
1903        subscribers::dispatch::spawn(
1904            event_bus.subscribe(),
1905            engine.backend.clone(),
1906            tmux.clone(),
1907            engine.runner.clone(),
1908            capture_for_tick.clone(),
1909            semaphore.clone(),
1910            engine.task_manager.clone(),
1911            weight_tx.clone(),
1912            router.clone(),
1913            dispatching.clone(),
1914            engine.store.clone(),
1915            engine.repo.clone(),
1916        );
1917    }
1918    tracing::info!("dispatch subscriber started");
1919
1920    // Spawn review subscribers — react to NeedsReview events immediately
1921    for engine in &project_engines {
1922        subscribers::review::spawn(
1923            event_bus.subscribe(),
1924            engine.backend.clone(),
1925            tmux.clone(),
1926            semaphore.clone(),
1927            engine.task_manager.clone(),
1928            router.clone(),
1929            dispatching.clone(),
1930            engine.store.clone(),
1931            engine.repo.clone(),
1932        );
1933    }
1934    tracing::info!("review subscriber started");
1935
1936    // Spawn unblock subscribers — react to Done events, unblock parent tasks immediately
1937    for engine in &project_engines {
1938        subscribers::unblock::spawn(
1939            event_bus.subscribe(),
1940            engine.backend.clone(),
1941            engine.task_manager.clone(),
1942            engine.store.clone(),
1943            engine.repo.clone(),
1944        );
1945    }
1946    tracing::info!("unblock subscriber started");
1947
1948    // Spawn notify subscriber — reacts to ALL events, pushes to transport.
1949    // Spawned once (not per-project) since the transport handles all repos.
1950    subscribers::notify::spawn(event_bus.subscribe(), transport.clone());
1951    tracing::info!("notify subscriber started");
1952
1953    // Main loop
1954    tracing::info!(
1955        tick = ?config.tick_interval,
1956        sync = ?config.sync_interval,
1957        parallel = config.max_parallel,
1958        "entering main loop"
1959    );
1960    let mut interval = tokio::time::interval(config.tick_interval);
1961
1962    // Signal handlers (launchd/systemd send SIGTERM to stop services, SIGHUP for reload/restart)
1963    let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
1964    let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
1965
1966    // Guard to prevent concurrent sync_tick runs (sync is spawned as a background task).
1967    let sync_in_progress = Arc::new(std::sync::atomic::AtomicBool::new(false));
1968
1969    // Tick watchdog: detects when the main loop hasn't completed a tick in > 60s.
1970    // Updates an atomic timestamp at the end of each tick iteration.
1971    let last_tick_epoch = Arc::new(std::sync::atomic::AtomicU64::new(
1972        std::time::SystemTime::now()
1973            .duration_since(std::time::UNIX_EPOCH)
1974            .unwrap_or_default()
1975            .as_secs(),
1976    ));
1977    {
1978        let last_tick_epoch = Arc::clone(&last_tick_epoch);
1979        let tick_interval_secs = config.tick_interval.as_secs();
1980        tokio::spawn(async move {
1981            // Check every 30s whether the main loop is still ticking.
1982            let mut watchdog_interval = tokio::time::interval(std::time::Duration::from_secs(30));
1983            // Own suspend baseline, independent of the main loop's checkpoint(). The
1984            // watchdog's interval timer also freezes during a host suspend, so this
1985            // detects the gap from its own wake-up instead of racing the main loop's
1986            // checkpoint() for the same GAP_LOG entry (see #3544).
1987            let mut suspend_detector = crate::engine::suspend::SuspendDetector::new();
1988            loop {
1989                watchdog_interval.tick().await;
1990                let own_gap = suspend_detector.check();
1991                let last = last_tick_epoch.load(std::sync::atomic::Ordering::Relaxed);
1992                let now = std::time::SystemTime::now()
1993                    .duration_since(std::time::UNIX_EPOCH)
1994                    .unwrap_or_default()
1995                    .as_secs();
1996                let stale_secs = now.saturating_sub(last);
1997                // Warn if the tick loop hasn't completed in > 6× the tick interval (at least 60s).
1998                let threshold = (tick_interval_secs * 6).max(60);
1999                if stale_secs > threshold {
2000                    // A host suspend/resume gap fully explains a stale window that
2001                    // isn't an actual stall — the process (and its timers) were
2002                    // simply not scheduled while the host was asleep. Prefer this
2003                    // watchdog's own detector (immune to the checkpoint race above);
2004                    // fall back to the shared log in case the main loop's checkpoint()
2005                    // already ran and recorded a larger gap.
2006                    let shared_gap = crate::engine::suspend::gap_detected_within(
2007                        std::time::Duration::from_secs(stale_secs),
2008                    );
2009                    let gap = match (own_gap, shared_gap) {
2010                        (Some(a), Some(b)) => Some(a.max(b)),
2011                        (Some(a), None) => Some(a),
2012                        (None, Some(b)) => Some(b),
2013                        (None, None) => None,
2014                    };
2015                    if let Some(gap) = gap {
2016                        let gap_secs = gap.num_seconds() as u64;
2017                        if gap_secs >= stale_secs {
2018                            tracing::info!(
2019                                stale_secs,
2020                                threshold,
2021                                suspend_gap_secs = gap_secs,
2022                                "tick loop stale window explained by host suspend/resume, not a stall"
2023                            );
2024                        } else {
2025                            tracing::error!(
2026                                stale_secs,
2027                                threshold,
2028                                suspend_gap_secs = gap_secs,
2029                                "WATCHDOG: tick loop has not completed a tick in {}s (threshold {}s) — suspend gap of {}s only partially explains the stall",
2030                                stale_secs,
2031                                threshold,
2032                                gap_secs,
2033                            );
2034                        }
2035                    } else {
2036                        tracing::error!(
2037                            stale_secs,
2038                            threshold,
2039                            "WATCHDOG: tick loop has not completed a tick in {}s (threshold {}s) — possible stall",
2040                            stale_secs,
2041                            threshold,
2042                        );
2043                    }
2044                }
2045            }
2046        });
2047    }
2048
2049    loop {
2050        tokio::select! {
2051            _ = interval.tick() => {
2052                let tick_start = std::time::Instant::now();
2053
2054                // Detect a host suspend/resume gap once per iteration, in one place.
2055                // The watchdog and stuck-task recovery both read from this instead of
2056                // re-deriving suspend information from their own wall-clock deltas.
2057                if let Some(gap) = crate::engine::suspend::checkpoint() {
2058                    tracing::info!(
2059                        gap_secs = gap.num_seconds(),
2060                        "host suspend/resume gap detected, discounting from stuck-task age checks"
2061                    );
2062                }
2063
2064                // Tick weight recovery for rate-limited agents
2065                {
2066                    let mut rw = router.write().await;
2067                    rw.tick_weight_recovery();
2068                }
2069
2070                // Skip tick/sync entirely if GitHub API is rate-limited
2071                if let Some(remaining) = GhHttp::is_rate_limited() {
2072                    let m = rate_limit_metrics();
2073                    tracing::warn!(
2074                        remaining_secs = remaining.as_secs(),
2075                        rate_limit_hits = m.hits,
2076                        five_xx_hits = m.five_xx_hits,
2077                        proactive_throttles = m.proactive_throttles,
2078                        wait_secs_total = m.wait_secs,
2079                        rest_remaining = ?m.rest_remaining,
2080                        graphql_remaining = ?m.graphql_remaining,
2081                        "GitHub API rate-limited, skipping tick"
2082                    );
2083                } else {
2084                    // Core tick: poll tasks for all projects
2085                    let mut router_guard = router.write().await;
2086                    let mut routing_quota = tick::RoutingTickQuota::new(
2087                        crate::engine::router::config::max_tasks_per_routing_tick(),
2088                    );
2089                    for engine in &project_engines {
2090                        let repo = engine.repo.clone();
2091                        let project_jobs_path = engine.project_dir.join(".orch.yml");
2092                        REPO_CONTEXT.scope(repo, async {
2093                            if let Err(e) = tick::tick(
2094                                &engine.backend,
2095                                &tmux,
2096                                &engine.repo,
2097                                &engine.runner,
2098                                &capture_for_tick,
2099                                &semaphore,
2100                                &config,
2101                                &project_jobs_path,
2102                                &mut router_guard,
2103                                &engine.task_manager,
2104                                &weight_tx,
2105                                &dispatching,
2106                                &engine.store,
2107                                Some(&transport),
2108                                &active_repos,
2109                                &auto_merge_in_flight,
2110                                &mut routing_quota,
2111                            ).await {
2112                                tracing::error!(repo = %engine.repo, ?e, "tick failed for project");
2113                            }
2114                        }).await;
2115                    }
2116                    drop(router_guard);
2117
2118                    // Drain pending weight signals after tick processing so the router
2119                    // learns from outcomes produced by this iteration's task dispatches.
2120                    while let Ok(signal) = weight_rx.try_recv() {
2121                        let mut rw = router.write().await;
2122                        match signal {
2123                            WeightSignal::RateLimited { ref agent } => {
2124                                rw.record_rate_limit(agent);
2125                            }
2126                            WeightSignal::Success { ref agent } => {
2127                                rw.record_success(agent);
2128                            }
2129                            WeightSignal::Rerouted | WeightSignal::Blocked | WeightSignal::None => {}
2130                        }
2131                    }
2132
2133                    // Periodic sync (less frequent) — spawned as a background task so it
2134                    // cannot block the main tick loop. A stalled sync (hung HTTP call, slow
2135                    // DB query, lock contention) previously parked all tokio workers for 12+
2136                    // minutes because it ran inline. See issue #2574.
2137                    if last_sync.elapsed() >= config.sync_interval {
2138                        if sync_in_progress.compare_exchange(
2139                            false,
2140                            true,
2141                            std::sync::atomic::Ordering::AcqRel,
2142                            std::sync::atomic::Ordering::Relaxed,
2143                        ).is_ok() {
2144                            // Clone everything sync_tick needs — it runs in a spawned task.
2145                            let sync_engines: Vec<_> = project_engines.iter().map(|e| {
2146                                (e.backend.clone(), e.repo.clone(),
2147                                 e.task_manager.clone(), e.store.clone())
2148                            }).collect();
2149                            let sync_tmux = Arc::clone(&tmux);
2150                            let sync_config = config.clone();
2151                            let sync_router = Arc::clone(&router);
2152                            let sync_dispatching = Arc::clone(&dispatching);
2153                            let sync_auto_merge = Arc::clone(&auto_merge_in_flight);
2154                            let sync_guard = Arc::clone(&sync_in_progress);
2155                            tokio::spawn(async move {
2156                                let sync_start = std::time::Instant::now();
2157                                for (backend, repo, task_manager, store) in &sync_engines {
2158                                    let repo = repo.clone();
2159                                    REPO_CONTEXT.scope(repo.clone(), async {
2160                                        if let Err(e) = sync::sync_tick(
2161                                            backend, &sync_tmux, &repo, &sync_config,
2162                                            &sync_router, task_manager, store,
2163                                            &sync_dispatching, &sync_auto_merge,
2164                                        ).await {
2165                                            tracing::error!(repo = %repo, ?e, "sync tick failed for project");
2166                                        }
2167                                    }).await;
2168                                }
2169                                // Emit degraded-agents metric/log once per sync cycle.
2170                                // Clone the needed data before releasing the read guard so
2171                                // the lock is not held across the async store writes.
2172                                if let Some((_, _, _, store)) = sync_engines.first() {
2173                                    let (available_agents, config) = {
2174                                        let r = sync_router.read().await;
2175                                        (r.available_agents.clone(), r.config.clone())
2176                                    };
2177                                    sync::emit_degraded_agents_if_needed(
2178                                        &available_agents,
2179                                        &config,
2180                                        Some(store),
2181                                    )
2182                                    .await;
2183                                }
2184                                let elapsed = sync_start.elapsed();
2185                                tracing::info!(elapsed_ms = elapsed.as_millis() as u64, "sync tick complete");
2186                                sync_guard.store(false, std::sync::atomic::Ordering::Release);
2187                            });
2188                        } else {
2189                            tracing::debug!("sync tick still in progress, skipping");
2190                        }
2191                        // Records sync *schedule* time (not completion). This prevents
2192                        // re-entering the sync branch every tick while one is in flight.
2193                        // The sync_in_progress guard handles actual concurrency control.
2194                        last_sync = std::time::Instant::now();
2195                    }
2196                }
2197
2198                // Periodic webhook health check
2199                if webhook_enabled {
2200                    let health_check_interval = config.webhook_health_check_interval
2201                        .unwrap_or(std::time::Duration::from_secs(60));
2202
2203                    if last_webhook_health_check.elapsed() >= health_check_interval {
2204                        if let Some(port) = webhook_port {
2205                            let (health, failure_reason) =
2206                                crate::channels::github::check_webhook_health(port).await;
2207                            if health != webhook_healthy {
2208                                webhook_healthy = health;
2209                                if webhook_healthy {
2210                                    tracing::info!(port, orch_webhook_up = true, "webhook health restored");
2211                                } else {
2212                                    tracing::warn!(
2213                                        port,
2214                                        orch_webhook_up = false,
2215                                        reason = failure_reason.as_deref().unwrap_or("unknown"),
2216                                        "webhook health check failed"
2217                                    );
2218                                }
2219                            }
2220                            // Persist updated status.
2221                            {
2222                                let to_save = {
2223                                    let mut s = webhook_status.lock().await;
2224                                    s.healthy = health;
2225                                    s.last_check_utc = Some(chrono::Utc::now());
2226                                    if health {
2227                                        s.last_failure_reason = None;
2228                                    } else if failure_reason.is_some() {
2229                                        s.last_failure_reason = failure_reason;
2230                                    }
2231                                    s.clone()
2232                                };
2233                                to_save.save().await;
2234                            }
2235                        }
2236                        last_webhook_health_check = std::time::Instant::now();
2237                    }
2238                }
2239
2240                // Periodic upgrade check — notify channels when a newer release is available.
2241                if should_run_upgrade_check(last_upgrade_check, config.upgrade_check_interval) {
2242                    if let Some(store) = project_engines.first().map(|e| e.store.clone()) {
2243                        let channels = channel_registry.clone();
2244                        let current = env!("ORCH_VERSION").to_string();
2245                        let check_interval =
2246                            std::time::Duration::from_secs(config.upgrade_check_interval);
2247                        tokio::spawn(async move {
2248                            check_and_notify_upgrade(&store, &channels, &current, check_interval)
2249                                .await;
2250                        });
2251                    }
2252                    last_upgrade_check = Some(std::time::Instant::now());
2253                }
2254
2255                // Update watchdog timestamp + log tick duration.
2256                let tick_elapsed = tick_start.elapsed();
2257                if tick_elapsed.as_secs() > 30 {
2258                    tracing::warn!(elapsed_ms = tick_elapsed.as_millis() as u64, "slow tick");
2259                } else {
2260                    tracing::debug!(elapsed_ms = tick_elapsed.as_millis() as u64, "tick complete");
2261                }
2262                last_tick_epoch.store(
2263                    std::time::SystemTime::now()
2264                        .duration_since(std::time::UNIX_EPOCH)
2265                        .unwrap_or_default()
2266                        .as_secs(),
2267                    std::sync::atomic::Ordering::Relaxed,
2268                );
2269            }
2270            // Webhook events trigger an immediate tick (bypass polling interval)
2271            _ = webhook_notify.notified() => {
2272                if let Some(remaining) = GhHttp::is_rate_limited() {
2273                    tracing::warn!(
2274                        remaining_secs = remaining.as_secs(),
2275                        "GitHub API rate-limited, skipping webhook-triggered tick"
2276                    );
2277                } else {
2278                    tracing::info!("webhook event triggered immediate tick");
2279
2280                    let mut router_guard = router.write().await;
2281                    let mut routing_quota = tick::RoutingTickQuota::new(
2282                        crate::engine::router::config::max_tasks_per_routing_tick(),
2283                    );
2284                    for engine in &project_engines {
2285                        let repo = engine.repo.clone();
2286                        let project_jobs_path = engine.project_dir.join(".orch.yml");
2287                        REPO_CONTEXT.scope(repo, async {
2288                            if let Err(e) = tick::tick(
2289                                &engine.backend,
2290                                &tmux,
2291                                &engine.repo,
2292                                &engine.runner,
2293                                &capture_for_tick,
2294                                &semaphore,
2295                                &config,
2296                                &project_jobs_path,
2297                                &mut router_guard,
2298                                &engine.task_manager,
2299                                &weight_tx,
2300                                &dispatching,
2301                                &engine.store,
2302                                Some(&transport),
2303                                &active_repos,
2304                                &auto_merge_in_flight,
2305                                &mut routing_quota,
2306                            ).await {
2307                                tracing::error!(repo = %engine.repo, ?e, "webhook-triggered tick failed");
2308                            }
2309                        }).await;
2310                    }
2311                    drop(router_guard);
2312
2313                // Drain pending weight signals after webhook-triggered tick processing.
2314                while let Ok(signal) = weight_rx.try_recv() {
2315                    let mut rw = router.write().await;
2316                    match signal {
2317                        WeightSignal::RateLimited { ref agent } => {
2318                            rw.record_rate_limit(agent);
2319                        }
2320                        WeightSignal::Success { ref agent } => {
2321                            rw.record_success(agent);
2322                        }
2323                        WeightSignal::Rerouted | WeightSignal::Blocked | WeightSignal::None => {}
2324                    }
2325                }
2326
2327                // Also attempt to run an immediate sync (ingest/cleanup) in the
2328                // background so webhook-driven events (e.g. new issues) are
2329                // picked up without waiting for the periodic sync interval.
2330                // Reuse the same sync_in_progress guard used by the periodic
2331                // branch to avoid concurrent syncs.
2332                if sync_in_progress.compare_exchange(
2333                    false,
2334                    true,
2335                    std::sync::atomic::Ordering::AcqRel,
2336                    std::sync::atomic::Ordering::Relaxed,
2337                ).is_ok() {
2338                    // Clone everything sync_tick needs — it runs in a spawned task.
2339                    let sync_engines: Vec<_> = project_engines.iter().map(|e| {
2340                        (e.backend.clone(), e.repo.clone(), e.task_manager.clone(), e.store.clone())
2341                    }).collect();
2342                    let sync_tmux = Arc::clone(&tmux);
2343                    let sync_config = config.clone();
2344                    let sync_router = Arc::clone(&router);
2345                    let sync_dispatching = Arc::clone(&dispatching);
2346                    let sync_auto_merge = Arc::clone(&auto_merge_in_flight);
2347                    let sync_guard = Arc::clone(&sync_in_progress);
2348                    tokio::spawn(async move {
2349                        let sync_start = std::time::Instant::now();
2350                        for (backend, repo, task_manager, store) in &sync_engines {
2351                            let repo = repo.clone();
2352                            REPO_CONTEXT.scope(repo.clone(), async {
2353                                if let Err(e) = sync::sync_tick(
2354                                    backend, &sync_tmux, &repo, &sync_config,
2355                                    &sync_router, task_manager, store,
2356                                    &sync_dispatching, &sync_auto_merge,
2357                                ).await {
2358                                    tracing::error!(repo = %repo, ?e, "webhook immediate sync tick failed for project");
2359                                }
2360                            }).await;
2361                        }
2362                        // Emit degraded-agents metric/log once per sync cycle.
2363                        if let Some((_, _, _, store)) = sync_engines.first() {
2364                            let (available_agents, config) = {
2365                                let r = sync_router.read().await;
2366                                (r.available_agents.clone(), r.config.clone())
2367                            };
2368                            sync::emit_degraded_agents_if_needed(
2369                                &available_agents,
2370                                &config,
2371                                Some(store),
2372                            ).await;
2373                        }
2374                        let elapsed = sync_start.elapsed();
2375                        tracing::info!(elapsed_ms = elapsed.as_millis() as u64, "webhook immediate sync tick complete");
2376                        sync_guard.store(false, std::sync::atomic::Ordering::Release);
2377                    });
2378                    // Record schedule time so periodic sync doesn't immediately re-run.
2379                    last_sync = std::time::Instant::now();
2380                } else {
2381                    tracing::debug!("sync tick still in progress, skipping webhook-triggered immediate sync");
2382                }
2383                }
2384
2385                // Also reset the interval so we don't get a redundant tick right after
2386                interval.reset();
2387            }
2388            result = config_rx.recv() => {
2389                match result {
2390                    Ok(path) => {
2391                        tracing::info!(path = %path.display(), "config file changed, reloading");
2392
2393                        // Reload engine config
2394                        let new_config = EngineConfig::from_config();
2395                        let tick_changed = new_config.tick_interval != config.tick_interval;
2396                        config = new_config;
2397
2398                        // Reset tick interval if it changed
2399                        if tick_changed {
2400                            interval = tokio::time::interval(config.tick_interval);
2401                            tracing::info!(tick = ?config.tick_interval, "tick interval updated");
2402                        }
2403
2404                        // Reload router config (async-safe: use reload_async to avoid blocking the runtime)
2405                        {
2406                            let mut router_guard = router.write().await;
2407                            router_guard.reload_async().await;
2408                        }
2409
2410                        tracing::info!(
2411                            tick = ?config.tick_interval,
2412                            sync = ?config.sync_interval,
2413                            parallel = config.max_parallel,
2414                            "config reloaded"
2415                        );
2416                    }
2417                    Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
2418                        tracing::warn!(missed = n, "config change receiver lagged, reloading");
2419                        // Reload everything since we missed events
2420                        config = EngineConfig::from_config();
2421                        interval = tokio::time::interval(config.tick_interval);
2422                        {
2423                            let mut router_guard = router.write().await;
2424                            router_guard.reload_async().await;
2425                        }
2426                    }
2427                    Err(tokio::sync::broadcast::error::RecvError::Closed) => {
2428                        tracing::warn!("config change channel closed");
2429                    }
2430                }
2431            }
2432            signal_name = async {
2433                tokio::select! {
2434                    _ = tokio::signal::ctrl_c() => "SIGINT",
2435                    _ = sigterm.recv() => "SIGTERM",
2436                    _ = sighup.recv() => "SIGHUP",
2437                }
2438            } => {
2439                tracing::info!(signal = signal_name, "beginning graceful shutdown");
2440
2441                // Wrap the entire shutdown sequence in a timeout to enforce the configured limit.
2442                // This prevents indefinite blocking if status updates or tmux operations hang.
2443                let shutdown_result = tokio::time::timeout(
2444                    config.graceful_shutdown_timeout,
2445                    async {
2446                        // Reset in_progress tasks to routed so they re-dispatch after restart.
2447                        // Also reset in_review tasks to needs_review — their review agent
2448                        // tmux sessions will be killed when the process exits.
2449                        let mut reset_count = 0u32;
2450                        let mut review_reset_count = 0u32;
2451                        for engine in &project_engines {
2452                            if let Ok(tasks) = engine.task_manager.list_external_by_status(Status::InProgress).await {
2453                                for task in &tasks {
2454                                    if let Err(e) = engine.task_manager.update_task_status(&task.id, Status::Routed).await {
2455                                        tracing::warn!(task_id = task.id.0, ?e, "failed to reset task on shutdown");
2456                                    } else {
2457                                        if let Ok(Some(store_id)) =
2458                                            engine.store.resolve_task_id(&engine.repo, &task.id.0).await
2459                                        {
2460                                            if let Err(e) = engine
2461                                                .store
2462                                                .finalize_incomplete_runs(
2463                                                    store_id,
2464                                                    "aborted",
2465                                                    "graceful shutdown: reset in_progress task to routed",
2466                                                )
2467                                                .await
2468                                            {
2469                                                tracing::warn!(
2470                                                    task_id = task.id.0,
2471                                                    ?e,
2472                                                    "failed to finalize incomplete runs on shutdown"
2473                                                );
2474                                            }
2475                                        }
2476                                        reset_count += 1;
2477                                    }
2478                                }
2479                            }
2480                            // Also reset internal in_progress tasks
2481                            if let Ok(tasks) = engine.store.list_internal_by_status(&engine.repo, crate::store::TaskStatus::InProgress).await {
2482                                for task in &tasks {
2483                                    let task_id = format!("internal:{}", task.id);
2484                                    if let Err(e) = engine.task_manager.update_task_status(
2485                                        &crate::backends::ExternalId(task_id.clone()),
2486                                        Status::Routed,
2487                                    ).await {
2488                                        tracing::warn!(task_id, ?e, "failed to reset internal task on shutdown");
2489                                    } else {
2490                                        if let Err(e) = engine
2491                                            .store
2492                                            .finalize_incomplete_runs(
2493                                                task.id,
2494                                                "aborted",
2495                                                "graceful shutdown: reset in_progress task to routed",
2496                                            )
2497                                            .await
2498                                        {
2499                                            tracing::warn!(
2500                                                task_id,
2501                                                ?e,
2502                                                "failed to finalize incomplete internal runs on shutdown"
2503                                            );
2504                                        }
2505                                        reset_count += 1;
2506                                    }
2507                                }
2508                            }
2509                            // Reset in_review tasks — review agent sessions die on shutdown
2510                            if let Ok(tasks) = engine.task_manager.list_external_by_status(Status::InReview).await {
2511                                for task in &tasks {
2512                                    if let Err(e) = engine.task_manager.update_task_status(&task.id, Status::NeedsReview).await {
2513                                        tracing::warn!(task_id = task.id.0, ?e, "failed to reset in_review task on shutdown");
2514                                    } else {
2515                                        set_review_session_expected(&engine.store, &engine.repo, &task.id.0, false).await;
2516                                        if let Ok(Some(store_id)) =
2517                                            engine.store.resolve_task_id(&engine.repo, &task.id.0).await
2518                                        {
2519                                            if let Err(e) = engine
2520                                                .store
2521                                                .finalize_incomplete_runs(
2522                                                    store_id,
2523                                                    "aborted",
2524                                                    "graceful shutdown: reset in_review task to needs_review",
2525                                                )
2526                                                .await
2527                                            {
2528                                                tracing::warn!(
2529                                                    task_id = task.id.0,
2530                                                    ?e,
2531                                                    "failed to finalize incomplete review runs on shutdown"
2532                                                );
2533                                            }
2534                                        }
2535                                        review_reset_count += 1;
2536                                    }
2537                                }
2538                            }
2539                            // Also reset internal in_review tasks
2540                            if let Ok(tasks) = engine.store.list_internal_by_status(&engine.repo, crate::store::TaskStatus::InReview).await {
2541                                for task in &tasks {
2542                                    let task_id = format!("internal:{}", task.id);
2543                                    if let Err(e) = engine.task_manager.update_task_status(
2544                                        &crate::backends::ExternalId(task_id.clone()),
2545                                        Status::NeedsReview,
2546                                    ).await {
2547                                        tracing::warn!(task_id, ?e, "failed to reset internal in_review task on shutdown");
2548                                    } else {
2549                                        set_review_session_expected(&engine.store, &engine.repo, &task_id, false).await;
2550                                        if let Err(e) = engine
2551                                            .store
2552                                            .finalize_incomplete_runs(
2553                                                task.id,
2554                                                "aborted",
2555                                                "graceful shutdown: reset in_review task to needs_review",
2556                                            )
2557                                            .await
2558                                        {
2559                                            tracing::warn!(
2560                                                task_id,
2561                                                ?e,
2562                                                "failed to finalize incomplete internal review runs on shutdown"
2563                                            );
2564                                        }
2565                                        review_reset_count += 1;
2566                                    }
2567                                }
2568                            }
2569                        }
2570                        if reset_count > 0 {
2571                            tracing::info!(reset_count, "reset in_progress tasks to routed for re-dispatch");
2572                        }
2573                        if review_reset_count > 0 {
2574                            tracing::info!(review_reset_count, "reset in_review tasks to needs_review for re-dispatch");
2575                        }
2576
2577                        // Kill all orch-managed tmux sessions so stale sessions don't
2578                        // block dispatch after restart (session_exists check).
2579                        let mut killed_sessions = Vec::new();
2580                        if let Ok(sessions) = tmux.list_sessions().await {
2581                            for session in &sessions {
2582                                if session.name.starts_with("orch-") {
2583                                    if let Err(e) = tmux.kill_session(&session.name).await {
2584                                        tracing::warn!(session = %session.name, error = %e, "failed to kill tmux session");
2585                                    } else {
2586                                        killed_sessions.push(session.name.clone());
2587                                    }
2588                                }
2589                            }
2590                            if !killed_sessions.is_empty() {
2591                                tracing::info!(killed = killed_sessions.len(), "killing orch tmux sessions on shutdown");
2592                            }
2593                        }
2594
2595                        // Wait for sessions to actually die before exiting.
2596                        // Prevents race where process exits before tmux finishes cleanup.
2597                        if !killed_sessions.is_empty() {
2598                            let still_alive = tmux
2599                                .wait_for_sessions_dead(
2600                                    &killed_sessions,
2601                                    std::time::Duration::from_millis(100),
2602                                    std::time::Duration::from_secs(5),
2603                                )
2604                                .await;
2605                            if still_alive > 0 {
2606                                tracing::warn!(
2607                                    still_alive,
2608                                    "some tmux sessions did not terminate cleanly - they will be cleaned up on startup"
2609                                );
2610                            }
2611                            tracing::info!(killed = killed_sessions.len() - still_alive, "confirmed tmux sessions terminated");
2612                        }
2613                    },
2614                ).await;
2615
2616                if shutdown_result.is_err() {
2617                    tracing::error!(
2618                        timeout_secs = config.graceful_shutdown_timeout.as_secs(),
2619                        "graceful shutdown timed out, forcing exit"
2620                    );
2621                }
2622
2623                break;
2624            }
2625        }
2626    }
2627
2628    // Clean up event bus port file and service version file
2629    events::cleanup_port_file().await;
2630    events::cleanup_version_file();
2631
2632    // transport and channels drop here at end of scope
2633    let _ = transport;
2634    tracing::info!("orch engine stopped");
2635    Ok(())
2636}
2637
2638#[cfg(test)]
2639mod tests {
2640    use super::*;
2641
2642    #[test]
2643    fn engine_config_defaults() {
2644        let config = EngineConfig::default();
2645        assert_eq!(config.tick_interval, std::time::Duration::from_secs(10));
2646        assert_eq!(config.sync_interval, std::time::Duration::from_secs(45));
2647        assert_eq!(config.max_parallel, 4);
2648        assert_eq!(config.stuck_timeout, 1800);
2649        assert_eq!(config.no_session_stuck_timeout, 600);
2650        assert!(
2651            config.no_session_stuck_timeout < config.stuck_timeout,
2652            "no_session_stuck_timeout must be shorter than stuck_timeout"
2653        );
2654        assert_eq!(
2655            config.graceful_shutdown_timeout,
2656            std::time::Duration::from_secs(600)
2657        );
2658        assert_eq!(config.upgrade_check_interval, 3600);
2659    }
2660
2661    #[test]
2662    fn engine_config_from_config_uses_defaults_when_no_config() {
2663        let config = EngineConfig::from_config();
2664        assert_eq!(config.tick_interval, std::time::Duration::from_secs(10));
2665        assert_eq!(config.sync_interval, std::time::Duration::from_secs(45));
2666        // max_parallel may be overridden by user config; just check it's reasonable
2667        assert!(config.max_parallel >= 1 && config.max_parallel <= 64);
2668        assert_eq!(config.stuck_timeout, 1800);
2669        assert_eq!(config.no_session_stuck_timeout, 600);
2670        assert_eq!(
2671            config.graceful_shutdown_timeout,
2672            std::time::Duration::from_secs(600)
2673        );
2674        assert_eq!(config.upgrade_check_interval, 3600);
2675    }
2676
2677    #[test]
2678    fn engine_config_no_session_timeout_is_under_15_minutes() {
2679        let config = EngineConfig::default();
2680        assert!(
2681            config.no_session_stuck_timeout <= 900,
2682            "no_session_stuck_timeout ({}) exceeds 15 minutes",
2683            config.no_session_stuck_timeout
2684        );
2685    }
2686
2687    #[test]
2688    fn upgrade_check_runs_immediately_when_never_checked() {
2689        assert!(should_run_upgrade_check(None, 3600));
2690    }
2691
2692    #[test]
2693    fn upgrade_check_waits_for_interval_after_first_run() {
2694        assert!(!should_run_upgrade_check(
2695            Some(std::time::Instant::now()),
2696            3600
2697        ));
2698    }
2699
2700    #[test]
2701    fn upgrade_check_can_be_disabled() {
2702        assert!(!should_run_upgrade_check(None, 0));
2703    }
2704
2705    #[test]
2706    fn configured_agents_parses_yaml_block_array() {
2707        // config::get returns serde_norway::to_string for arrays, which is YAML block format
2708        let yaml_str = "- claude\n- codex\n- opencode\n- olm\n";
2709        let parsed: Vec<String> = serde_norway::from_str(yaml_str).unwrap();
2710        assert_eq!(parsed, vec!["claude", "codex", "opencode", "olm"]);
2711    }
2712
2713    #[test]
2714    fn configured_agents_parses_json_inline_array() {
2715        let json_str = r#"["claude","codex","olm"]"#;
2716        let parsed: Vec<String> = serde_json::from_str(json_str).unwrap();
2717        assert_eq!(parsed, vec!["claude", "codex", "olm"]);
2718    }
2719
2720    #[test]
2721    fn configured_agents_fallback_returns_default_agents() {
2722        // When no config is present, configured_agents() returns DEFAULT_AGENTS
2723        let defaults = configured_agents();
2724        assert!(defaults.contains(&"claude".to_string()));
2725        assert!(defaults.contains(&"codex".to_string()));
2726        assert!(defaults.contains(&"opencode".to_string()));
2727    }
2728
2729    /// Integration test that requires a fully working tmux environment with
2730    /// reliable capture-pane timing. Runs locally but not in CI (GitHub Actions
2731    /// has tmux installed but capture-pane timing is unreliable).
2732    ///
2733    /// DO NOT REMOVE #[ignore] — this test was un-ignored in PR #608 which
2734    /// broke CI. The `tmux -V` skip check is insufficient because tmux CAN
2735    /// create sessions on CI runners, but capture-pane output is empty or
2736    /// delayed, causing the assertion to fail after a 10s timeout.
2737    #[tokio::test]
2738    #[ignore]
2739    async fn integration_channel_to_tmux_to_capture() {
2740        use crate::channels::capture::CaptureService;
2741        use crate::channels::transport::Transport;
2742        use crate::tmux::SessionGuard;
2743        use std::sync::Arc;
2744
2745        // Create transport and capture service
2746        let transport = Arc::new(Transport::new());
2747        let capture = Arc::new(CaptureService::new(transport.clone()));
2748
2749        let task_id = "testtask1".to_string();
2750        let session_name = format!("orch-test-{}", task_id);
2751
2752        // Start a detached tmux session
2753        let _ = tokio::process::Command::new("tmux")
2754            .args(["new-session", "-d", "-s", &session_name])
2755            .output()
2756            .await
2757            .expect("failed to start tmux session");
2758
2759        // Guard ensures session cleanup even on panic
2760        let _guard = SessionGuard(session_name.clone());
2761
2762        // Register session with capture service and transport binding
2763        capture
2764            .register_session("owner/repo", &task_id, &session_name)
2765            .await;
2766        transport
2767            .bind(
2768                "owner/repo",
2769                &task_id,
2770                &session_name,
2771                "telegram",
2772                "12345",
2773                None,
2774            )
2775            .await;
2776
2777        // Spawn capture.run() which exits when session is unregistered
2778        let capture_clone = capture.clone();
2779        let capture_handle = tokio::spawn(async move { capture_clone.run().await });
2780
2781        // Subscribe to transport output for this task
2782        let mut rx = transport
2783            .subscribe("owner/repo", &task_id)
2784            .await
2785            .expect("no subscription");
2786
2787        // Send a message into the tmux session via tmux send-keys (simulate channel input)
2788        let send = tokio::process::Command::new("tmux")
2789            .args([
2790                "send-keys",
2791                "-t",
2792                &session_name,
2793                "echo from-channel",
2794                "Enter",
2795            ])
2796            .output()
2797            .await
2798            .expect("failed to send keys");
2799        assert!(send.status.success());
2800
2801        // Wait for an output chunk from capture -> transport
2802        let mut got = false;
2803        for _ in 0..20 {
2804            if let Ok(Ok(c)) =
2805                tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await
2806            {
2807                if c.content.contains("from-channel") {
2808                    got = true;
2809                    break;
2810                }
2811            }
2812        }
2813
2814        // Clean up: unregister session (tmux kill handled by SessionGuard on drop)
2815        capture.unregister_session("owner/repo", &task_id).await;
2816
2817        // Wait for capture.run to finish
2818        let _ = capture_handle.await;
2819
2820        assert!(got, "did not observe tmux output via capture/transport");
2821    }
2822
2823    // ── read_project_channel_config tests ─────────────────────────────────
2824
2825    #[test]
2826    fn read_project_channel_config_returns_defaults_when_no_file() {
2827        let dir = tempfile::tempdir().expect("tempdir");
2828        let cfg = read_project_channel_config(dir.path());
2829        assert!(cfg.telegram_topic_id.is_none());
2830        assert!(cfg.discord_channel_id.is_none());
2831    }
2832
2833    #[test]
2834    fn read_project_channel_config_parses_telegram_and_discord() {
2835        let dir = tempfile::tempdir().expect("tempdir");
2836        let orch_yml = dir.path().join(".orch.yml");
2837        std::fs::write(
2838            &orch_yml,
2839            "channels:\n  telegram:\n    topic_id: \"42\"\n  discord:\n    channel_id: \"9999\"\n",
2840        )
2841        .expect("write .orch.yml");
2842        let cfg = read_project_channel_config(dir.path());
2843        assert_eq!(cfg.telegram_topic_id.as_deref(), Some("42"));
2844        assert_eq!(cfg.discord_channel_id.as_deref(), Some("9999"));
2845    }
2846
2847    #[test]
2848    fn read_project_channel_config_handles_partial_channels() {
2849        let dir = tempfile::tempdir().expect("tempdir");
2850        let orch_yml = dir.path().join(".orch.yml");
2851        std::fs::write(&orch_yml, "channels:\n  telegram:\n    topic_id: \"7\"\n")
2852            .expect("write .orch.yml");
2853        let cfg = read_project_channel_config(dir.path());
2854        assert_eq!(cfg.telegram_topic_id.as_deref(), Some("7"));
2855        assert!(cfg.discord_channel_id.is_none());
2856    }
2857
2858    #[test]
2859    fn read_project_channel_config_handles_malformed_yaml() {
2860        let dir = tempfile::tempdir().expect("tempdir");
2861        let orch_yml = dir.path().join(".orch.yml");
2862        std::fs::write(&orch_yml, "channels: [\nbad yaml").expect("write .orch.yml");
2863        let cfg = read_project_channel_config(dir.path());
2864        // Should return defaults (not panic) and emit a warn! log with the parse error.
2865        assert!(cfg.telegram_topic_id.is_none());
2866        assert!(cfg.discord_channel_id.is_none());
2867    }
2868
2869    #[test]
2870    #[cfg(unix)]
2871    fn read_project_channel_config_handles_unreadable_file() {
2872        let dir = tempfile::tempdir().expect("tempdir");
2873        // A directory fails to read for every user, root included, where chmod 000 does not
2874        std::fs::create_dir(dir.path().join(".orch.yml")).expect("create .orch.yml dir");
2875        let cfg = read_project_channel_config(dir.path());
2876        // Should return defaults (not panic) and emit a warn! log with the read error.
2877        assert!(cfg.telegram_topic_id.is_none());
2878        assert!(cfg.discord_channel_id.is_none());
2879    }
2880}