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