Skip to main content

orch/engine/
mod.rs

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