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
389/// Grow or shrink the dispatch pool toward `target` without cancelling running work.
390///
391/// Growing adds permits at once. Shrinking can only take back the permits that
392/// are free right now, so the return value is the limit actually in force; call
393/// again as running tasks release theirs
394fn resize_dispatch_pool(semaphore: &Semaphore, in_force: usize, target: usize) -> usize {
395    if target > in_force {
396        semaphore.add_permits(target - in_force);
397        return target;
398    }
399
400    if target < in_force {
401        return in_force - semaphore.forget_permits(in_force - target);
402    }
403
404    in_force
405}
406
407/// Config subtrees the engine only reads at startup: the channel gateways hold
408/// long-lived websockets and the webhook listener binds its port once.
409const RESTART_ONLY_KEYS: [&str; 2] = ["channels", "webhook"];
410
411/// Read the restart-only subtrees so a later reload can tell they changed.
412fn restart_only_snapshot() -> Vec<Option<String>> {
413    RESTART_ONLY_KEYS
414        .iter()
415        .map(|key| crate::config::get(key).ok())
416        .collect()
417}
418
419/// Report the restart-only keys an edit changed. Only the key is logged, never
420/// the value, because `channels.*` holds bot tokens
421fn log_restart_only_changes(old: &[Option<String>], new: &[Option<String>]) -> Vec<&'static str> {
422    let changed: Vec<&'static str> = RESTART_ONLY_KEYS
423        .iter()
424        .zip(old)
425        .zip(new)
426        .filter(|((_, old), new)| old != new)
427        .map(|((key, _), _)| *key)
428        .collect();
429
430    for key in &changed {
431        tracing::info!(key = key, "config changed, takes effect after restart");
432    }
433
434    changed
435}
436
437/// Log every engine setting whose value changed across a config reload, so
438/// `orch log` shows what the operator's edit actually did.
439fn log_engine_config_changes(old: &EngineConfig, new: &EngineConfig) {
440    macro_rules! log_changed {
441        ($($key:literal => $field:ident),+ $(,)?) => {
442            $(if old.$field != new.$field {
443                tracing::info!(key = $key, old = ?old.$field, new = ?new.$field, "config changed");
444            })+
445        };
446    }
447
448    log_changed!(
449        "engine.tick_interval" => tick_interval,
450        "engine.sync_interval" => sync_interval,
451        "engine.webhook_health_check_interval" => webhook_health_check_interval,
452        "engine.max_parallel" => max_parallel,
453        "engine.stuck_timeout" => stuck_timeout,
454        "engine.no_session_stuck_timeout" => no_session_stuck_timeout,
455        "engine.in_review_no_session_stuck_timeout" => in_review_no_session_stuck_timeout,
456        "engine.graceful_shutdown_timeout" => graceful_shutdown_timeout,
457        "engine.silence_grace_period" => silence_grace_period,
458        "engine.silence_cooldown" => silence_cooldown,
459        "engine.upgrade_check_interval" => upgrade_check_interval,
460        "engine.startup_failure_escalation_secs" => startup_failure_escalation_secs,
461        "workflow.auto_create_followup_on_changes" => auto_create_followup_on_changes,
462    );
463}
464
465fn should_run_upgrade_check(
466    last_upgrade_check: Option<std::time::Instant>,
467    upgrade_check_interval: u64,
468) -> bool {
469    if upgrade_check_interval == 0 {
470        return false;
471    }
472
473    last_upgrade_check
474        .map(|last| last.elapsed() >= std::time::Duration::from_secs(upgrade_check_interval))
475        .unwrap_or(true)
476}
477
478/// Connect one project's backend and build its task manager and runner over
479/// the shared store.
480///
481/// `event_tx` wires status transitions into the event bus; pass `None` during
482/// startup, where the bus does not exist yet and task managers are rebuilt
483/// once it does
484async fn connect_project_engine(
485    repo: String,
486    project_dir: std::path::PathBuf,
487    store: Arc<TaskStore>,
488    event_tx: Option<tokio::sync::broadcast::Sender<events::TaskEvent>>,
489) -> anyhow::Result<ProjectEngine> {
490    let backend: Arc<dyn ExternalBackend> =
491        Arc::new(crate::backends::github::GitHubBackend::new(repo.clone())?);
492
493    // A failed health check (rate limit, network, auth) must not drop the project: it would have
494    // no review subscriber until the next restart. Later ticks hit the API again and retry on
495    // their own, so build the engine and warn while the check keeps failing.
496    match backend.health_check().await {
497        Ok(()) => tracing::info!(repo = %repo, backend = backend.name(), "backend connected"),
498        Err(e) => tracing::warn!(
499            repo = %repo,
500            error = %e,
501            "GitHub health check failed for {repo}, starting engine anyway (rate limited, offline or bad credentials?)"
502        ),
503    }
504
505    let task_manager = Arc::new(match event_tx {
506        Some(tx) => TaskManager::with_events(backend.clone(), store.clone(), repo.clone(), tx),
507        None => TaskManager::with_store(backend.clone(), store.clone(), repo.clone()),
508    });
509    let runner = Arc::new(runner::TaskRunner::new(repo.clone()).with_store(store.clone()));
510
511    Ok(ProjectEngine {
512        repo,
513        project_dir,
514        backend,
515        task_manager,
516        runner,
517        store,
518    })
519}
520
521/**
522 * Split the configured project list into the ones not running yet and the names of the ones that
523 * went away. Kept separate from `sync_project_engines` so the comparison can be tested without a
524 * GitHub backend: getting it wrong either spawns a second set of subscribers for a project already
525 * running, or silently never picks a new one up
526 **/
527fn diff_configured_projects(
528    configured: &[(String, std::path::PathBuf)],
529    active_repos: &std::collections::HashSet<String>,
530) -> (Vec<(String, std::path::PathBuf)>, Vec<String>) {
531    let configured_repos: std::collections::HashSet<&str> =
532        configured.iter().map(|(r, _)| r.as_str()).collect();
533
534    let added = configured
535        .iter()
536        .filter(|(repo, _)| !active_repos.contains(repo))
537        .cloned()
538        .collect();
539    let removed = active_repos
540        .iter()
541        .filter(|repo| !configured_repos.contains(repo.as_str()))
542        .cloned()
543        .collect();
544
545    (added, removed)
546}
547
548/// Pick up projects added to `projects:` since startup, and report the ones
549/// that were removed.
550///
551/// A removed project keeps running until the next restart: its subscriber
552/// tasks and any agent it already dispatched outlive the config read
553#[allow(clippy::too_many_arguments)]
554async fn sync_project_engines(
555    project_engines: &mut Vec<ProjectEngine>,
556    active_repos: &mut std::collections::HashSet<String>,
557    event_bus: &events::EventBus,
558    tmux: &Arc<TmuxManager>,
559    capture: &Arc<CaptureService>,
560    semaphore: &Arc<Semaphore>,
561    weight_tx: &mpsc::Sender<WeightSignal>,
562    router: &Arc<RwLock<Router>>,
563    dispatching: &Arc<dashmap::DashMap<String, String>>,
564) {
565    let configured = match config::get_projects_with_paths() {
566        Ok(p) if !p.is_empty() => p,
567        Ok(_) => return,
568        Err(e) => {
569            tracing::warn!(err = %e, "could not re-read the project list, keeping the current one");
570            return;
571        }
572    };
573    let Some(store) = project_engines.first().map(|e| e.store.clone()) else {
574        return;
575    };
576
577    let (added, removed) = diff_configured_projects(&configured, active_repos);
578    for repo in removed {
579        tracing::info!(
580            repo = %repo,
581            "project removed from config, takes effect after restart"
582        );
583    }
584
585    for (repo, project_dir) in added {
586        config::watch(&project_dir.join(".orch.yml"));
587        let engine = match connect_project_engine(
588            repo.clone(),
589            project_dir,
590            store.clone(),
591            Some(event_bus.sender()),
592        )
593        .await
594        {
595            Ok(engine) => engine,
596            Err(e) => {
597                // A failed health check no longer lands here, so this is a backend that could
598                // not be built at all, such as a repo slug that is not `owner/name`
599                tracing::warn!(
600                    repo = %repo,
601                    err = %e,
602                    "project added to config but its backend could not be built, retrying on the next config change"
603                );
604                continue;
605            }
606        };
607
608        subscribers::dispatch::spawn(
609            event_bus.subscribe(),
610            engine.backend.clone(),
611            tmux.clone(),
612            engine.runner.clone(),
613            capture.clone(),
614            semaphore.clone(),
615            engine.task_manager.clone(),
616            weight_tx.clone(),
617            router.clone(),
618            dispatching.clone(),
619            engine.store.clone(),
620            engine.repo.clone(),
621        );
622        subscribers::review::spawn(
623            event_bus.subscribe(),
624            engine.backend.clone(),
625            tmux.clone(),
626            semaphore.clone(),
627            engine.task_manager.clone(),
628            router.clone(),
629            dispatching.clone(),
630            engine.store.clone(),
631            engine.repo.clone(),
632        );
633        subscribers::unblock::spawn(
634            event_bus.subscribe(),
635            engine.backend.clone(),
636            engine.task_manager.clone(),
637            engine.store.clone(),
638            engine.repo.clone(),
639        );
640
641        active_repos.insert(repo.clone());
642        project_engines.push(engine);
643        tracing::info!(repo = %repo, "project added from config, now syncing");
644    }
645}
646
647/// Initialize all project engines from config.
648///
649/// Returns a vector of ProjectEngine, one for each configured project.
650async fn init_project_engines() -> anyhow::Result<Vec<ProjectEngine>> {
651    let projects = config::get_projects_with_paths()?;
652    let repos: Vec<&str> = projects.iter().map(|(r, _)| r.as_str()).collect();
653    tracing::info!(repos = ?repos, "loading projects from config");
654
655    if projects.is_empty() {
656        let config_path = crate::home::config_path()
657            .map(|p| p.display().to_string())
658            .unwrap_or_else(|_| "~/.orch/config.yml".to_string());
659        anyhow::bail!(
660            "no projects configured — add repos under `projects:` in {}",
661            config_path
662        );
663    }
664
665    let mut engines = Vec::new();
666
667    // Initialize unified task store once — all project engines share the same SQLite file.
668    // Creating one pool here avoids N separate connection pools for the same database.
669    let store = Arc::new(TaskStore::open(&crate::store::default_db_path().await?).await?);
670    if crate::cluster::enabled() {
671        crate::cluster::start(&store, &crate::home::orch_home()?.join("raft.db")).await?;
672        crate::cluster::local::serve((*store).clone()).await?;
673    }
674
675    // Load persisted model cooldowns and register store for future writes.
676    // Runs once here so the global cooldown store is not overwritten per project.
677    crate::engine::cooldown::init_cooldown_store(store.clone()).await;
678
679    for (repo, project_dir) in projects {
680        tracing::info!(repo = %repo, "initializing project engine");
681        config::watch(&project_dir.join(".orch.yml"));
682
683        engines.push(connect_project_engine(repo, project_dir, store.clone(), None).await?);
684    }
685
686    Ok(engines)
687}
688
689async fn reconcile_startup_worktrees(project_engines: &[ProjectEngine]) -> anyhow::Result<()> {
690    for engine in project_engines {
691        let repo_root =
692            crate::engine::runner::worktree::resolve_main_repo(&engine.project_dir).await;
693        let repo_root_path = std::path::PathBuf::from(&repo_root);
694        let default_branch =
695            crate::engine::runner::worktree::detect_default_branch(&repo_root_path).await;
696        let worktrees = list_project_worktrees(&repo_root_path).await?;
697
698        // Fetch refs once for all worktrees in this project, so rebase below
699        // operates on up-to-date origin/* refs without N sequential fetches.
700        if let Err(e) = Command::new("git")
701            .args(["-C", &repo_root.to_string_lossy(), "fetch", "origin"])
702            .output_with_context()
703            .await
704        {
705            tracing::warn!(repo = %engine.repo, err = %e, "startup git fetch origin failed, rebases may use stale refs");
706        }
707
708        let mut orphans_removed: u32 = 0;
709        let mut invalid_removed: u32 = 0;
710        let mut cleaned: u32 = 0;
711        let mut valid_kept: u32 = 0;
712
713        for worktree_dir in worktrees {
714            let Some(name) = worktree_dir.file_name().and_then(|n| n.to_str()) else {
715                continue;
716            };
717
718            // Validate gitdir link before doing anything else — a broken .git file
719            // means git commands on this worktree will fail with "not a git repository".
720            if !validate_worktree_gitdir(&worktree_dir).await {
721                tracing::warn!(repo = %engine.repo, worktree = %worktree_dir.display(), "worktree has invalid or missing gitdir, removing");
722                remove_worktree_and_branch(name, &worktree_dir, Some(name), &repo_root_path, false)
723                    .await;
724                invalid_removed += 1;
725                continue;
726            }
727
728            let Some(task_id) = task_id_from_worktree_name(name) else {
729                tracing::info!(repo = %engine.repo, worktree = %worktree_dir.display(), "orphan worktree without task id, removing");
730                remove_worktree_and_branch(name, &worktree_dir, Some(name), &repo_root_path, false)
731                    .await;
732                orphans_removed += 1;
733                continue;
734            };
735
736            let Some(store_id) = engine.store.resolve_task_id(&engine.repo, &task_id).await? else {
737                tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), "worktree has no matching task, removing");
738                remove_worktree_and_branch(
739                    &task_id,
740                    &worktree_dir,
741                    Some(name),
742                    &repo_root_path,
743                    false,
744                )
745                .await;
746                orphans_removed += 1;
747                continue;
748            };
749
750            let task = engine.store.get(store_id).await?;
751            let branch_name = if task.branch.is_empty() {
752                name
753            } else {
754                task.branch.as_str()
755            };
756
757            match task.status {
758                TaskStatus::New
759                | TaskStatus::Routed
760                | TaskStatus::InProgress
761                | TaskStatus::NeedsReview
762                | TaskStatus::InReview => {
763                    tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), "rebasing startup worktree");
764                    if let Err(e) =
765                        rebase_worktree_on_origin_main(&worktree_dir, &default_branch).await
766                    {
767                        // If rebase failed we already made efforts to stash and
768                        // restore changes safely. Log the error and reset the
769                        // task as before. The worktree removal path is used
770                        // to avoid leaving corrupted worktrees around.
771                        tracing::warn!(repo = %engine.repo, task_id = %task_id, err = %e, "startup rebase failed, resetting task");
772                        abort_worktree_rebase(&worktree_dir).await;
773                        let keep_remote = task.pr_number.is_some();
774                        remove_worktree_and_branch(
775                            &task_id,
776                            &worktree_dir,
777                            Some(branch_name),
778                            &repo_root_path,
779                            keep_remote,
780                        )
781                        .await;
782                        match engine.store.resolve_task_id(&engine.repo, &task_id).await {
783                            Ok(Some(reset_id)) => {
784                                if let Err(e) = engine.store.reset_to_new(reset_id).await {
785                                    tracing::warn!(task_id, err = %e, "failed to reset task to new after rebase failure");
786                                }
787                            }
788                            Ok(None) => {
789                                tracing::warn!(
790                                    task_id,
791                                    "task not found in store during reconciliation"
792                                );
793                            }
794                            Err(e) => {
795                                tracing::warn!(task_id, err = %e, "failed to resolve task ID during reconciliation");
796                            }
797                        }
798                        if let Err(e) = engine
799                            .task_manager
800                            .update_task_status(
801                                &ExternalId(task_id.clone()),
802                                crate::backends::Status::New,
803                            )
804                            .await
805                        {
806                            tracing::warn!(task_id, err = %e, "failed to update backend status during reconciliation");
807                        }
808                        cleaned += 1;
809                    } else {
810                        valid_kept += 1;
811                    }
812                }
813                // A blocked or waiting task resumes from this work, and without a PR the branch
814                // may hold the only copy of it
815                TaskStatus::Blocked | TaskStatus::Waiting => {
816                    valid_kept += 1;
817                }
818                TaskStatus::Done => {
819                    tracing::info!(repo = %engine.repo, task_id = %task_id, worktree = %worktree_dir.display(), status = ?task.status, "terminal task worktree, removing");
820                    remove_worktree_and_branch(
821                        &task_id,
822                        &worktree_dir,
823                        Some(branch_name),
824                        &repo_root_path,
825                        false,
826                    )
827                    .await;
828                    cleaned += 1;
829                }
830            }
831        }
832
833        tracing::info!(
834            repo = %engine.repo,
835            orphans_removed,
836            invalid_removed,
837            cleaned,
838            valid_kept,
839            "worktree reconciliation: {orphans_removed} orphans removed, {invalid_removed} invalid removed, {cleaned} cleaned, {valid_kept} valid kept"
840        );
841
842        // Prune stale .git/worktrees/ entries whose directories no longer exist.
843        // This handles entries left behind by crashes or manual directory removal.
844        let prune = Command::new("git")
845            .args(["-C", &repo_root.to_string_lossy(), "worktree", "prune"])
846            .output_with_context()
847            .await;
848        match prune {
849            Ok(output) if output.status.success() => {
850                tracing::debug!(repo = %engine.repo, "startup worktree prune completed");
851            }
852            Ok(output) => {
853                let stderr = String::from_utf8_lossy(&output.stderr);
854                tracing::warn!(repo = %engine.repo, err = %stderr, "startup worktree prune failed");
855            }
856            Err(e) => {
857                tracing::warn!(repo = %engine.repo, err = %e, "startup worktree prune failed");
858            }
859        }
860    }
861
862    Ok(())
863}
864
865/// Sync Fibonacci estimates stored in SQLite to the GitHub Projects board for
866/// all tasks that have a positive estimate.
867///
868/// Runs once on startup to catch tasks that were routed before the estimate
869/// sync feature was available or before `project_estimate_field_id` was
870/// configured. The underlying mutations are idempotent, so re-syncing an
871/// already-correct estimate is harmless.
872async fn reconcile_startup_estimates(project_engines: &[ProjectEngine]) {
873    use crate::backends::ExternalId;
874
875    for engine in project_engines {
876        let tasks = match engine.store.list_external_tasks_with_estimates().await {
877            Ok(t) => t,
878            Err(e) => {
879                tracing::warn!(repo = %engine.repo, err = %e, "startup estimate reconciliation: store query failed");
880                continue;
881            }
882        };
883
884        if tasks.is_empty() {
885            continue;
886        }
887
888        let mut synced: u32 = 0;
889        let mut failed: u32 = 0;
890        for (external_id, estimate) in &tasks {
891            match engine
892                .backend
893                .sync_estimate_to_project(&ExternalId(external_id.clone()), *estimate)
894                .await
895            {
896                Ok(()) => synced += 1,
897                Err(e) => {
898                    tracing::debug!(
899                        repo = %engine.repo,
900                        task_id = %external_id,
901                        estimate,
902                        err = %e,
903                        "startup estimate reconciliation: sync failed"
904                    );
905                    failed += 1;
906                }
907            }
908        }
909
910        if synced > 0 || failed > 0 {
911            tracing::info!(
912                repo = %engine.repo,
913                synced,
914                failed,
915                "startup estimate reconciliation complete"
916            );
917        }
918    }
919}
920
921/// Read per-project channel configuration from `.orch.yml`.
922fn read_project_channel_config(project_dir: &std::path::Path) -> ProjectChannelConfig {
923    let config_path = project_dir.join(".orch.yml");
924    if !config_path.exists() {
925        return ProjectChannelConfig::default();
926    }
927    let content = match std::fs::read_to_string(&config_path) {
928        Ok(c) => c,
929        Err(e) => {
930            tracing::warn!(
931                path = %config_path.display(),
932                error = %e,
933                "failed to read .orch.yml; channel config will use defaults"
934            );
935            return ProjectChannelConfig::default();
936        }
937    };
938    let val: serde_norway::Value = match serde_norway::from_str(&content) {
939        Ok(v) => v,
940        Err(e) => {
941            tracing::warn!(
942                path = %config_path.display(),
943                error = %e,
944                "failed to parse .orch.yml; channel config will use defaults"
945            );
946            return ProjectChannelConfig::default();
947        }
948    };
949    let channels = match val.get("channels") {
950        Some(c) => c,
951        None => return ProjectChannelConfig::default(),
952    };
953    let get_str = |section: &str, key: &str| -> Option<String> {
954        channels
955            .get(section)
956            .and_then(|s| s.get(key))
957            .and_then(|v| v.as_str())
958            .map(String::from)
959    };
960    ProjectChannelConfig {
961        telegram_topic_id: get_str("telegram", "topic_id"),
962        telegram_bot_token: get_str("telegram", "bot_token"),
963        telegram_chat_id: get_str("telegram", "chat_id"),
964        discord_channel_id: get_str("discord", "channel_id"),
965        discord_bot_token: get_str("discord", "bot_token"),
966        discord_guild_id: get_str("discord", "guild_id"),
967    }
968}
969
970/// Fetch the latest orch release tag from GitHub via the `gh` CLI (async).
971///
972/// Returns the version string (without the leading "v"), or `None` when the
973/// check fails (no network, unauthenticated, `gh` not installed, etc.).
974async fn fetch_latest_release_version() -> Option<String> {
975    let output = Command::new("gh")
976        .args([
977            "api",
978            "repos/gabrielkoerich/orch/releases/latest",
979            "--jq",
980            ".tag_name",
981        ])
982        .output()
983        .await
984        .ok()?;
985    if !output.status.success() {
986        return None;
987    }
988    let tag = String::from_utf8(output.stdout).ok()?;
989    let version = tag.trim().trim_start_matches('v').to_string();
990    if version.is_empty() {
991        None
992    } else {
993        Some(version)
994    }
995}
996
997/// Check if a newer orch release is available and send channel notifications
998/// asking the operator to upgrade manually. Upgrading is operator-only — the
999/// service never runs `brew` itself.
1000///
1001/// Uses the store's KV to throttle checks (`upgrade:last_check_at`) and
1002/// deduplicate notifications (`upgrade:last_notified_version`). Each distinct
1003/// latest version is notified about once; the notification repeats only when
1004/// an even newer version is released.
1005async fn check_and_notify_upgrade(
1006    store: &Arc<TaskStore>,
1007    channels: &Arc<ChannelRegistry>,
1008    current_version: &str,
1009    check_interval: std::time::Duration,
1010) {
1011    let last_check_key = "upgrade:last_check_at";
1012    let last_notified_key = "upgrade:last_notified_version";
1013
1014    // Throttle: only check once per interval.
1015    let now = chrono::Utc::now().timestamp();
1016    if let Ok(Some(last_check_str)) = store.kv_get(last_check_key).await {
1017        if let Ok(last_check) = last_check_str.parse::<i64>() {
1018            if now - last_check < check_interval.as_secs() as i64 {
1019                return;
1020            }
1021        }
1022    }
1023
1024    // Record check timestamp (best-effort).
1025    let _ = store.kv_set(last_check_key, &now.to_string()).await;
1026
1027    let latest = match fetch_latest_release_version().await {
1028        Some(v) => v,
1029        None => {
1030            tracing::debug!("upgrade check: failed to fetch latest release version");
1031            return;
1032        }
1033    };
1034
1035    if latest == current_version {
1036        tracing::debug!(current = %current_version, "orch is up to date");
1037        return;
1038    }
1039
1040    tracing::warn!(
1041        current_version = %current_version,
1042        latest_version = %latest,
1043        "orch upgrade available"
1044    );
1045
1046    // Operator-only upgrade: send channel notifications so the operator can
1047    // upgrade on their own schedule. The service never runs brew itself.
1048
1049    // Deduplicate: don't re-notify for the same latest version.
1050    if let Ok(Some(last_notified)) = store.kv_get(last_notified_key).await {
1051        if last_notified == latest {
1052            tracing::debug!(latest = %latest, "upgrade already notified");
1053            return;
1054        }
1055    }
1056
1057    let msg_telegram = format!(
1058        "⚠️ <b>Orch Upgrade Available</b>\n\n\
1059         Service: <code>{current_version}</code>\n\
1060         Latest:  <code>{latest}</code>\n\n\
1061         Run: <code>brew update && brew upgrade orch && brew services restart orch</code>"
1062    );
1063
1064    let msg_discord = format!(
1065        "⚠️ **Orch Upgrade Available**\n\n\
1066         Service: `{current_version}`\n\
1067         Latest:  `{latest}`\n\n\
1068         Run: `brew update && brew upgrade orch && brew services restart orch`"
1069    );
1070
1071    let msg_slack = format!(
1072        "⚠️ *Orch Upgrade Available*\n\n\
1073         Service: `{current_version}`\n\
1074         Latest:  `{latest}`\n\n\
1075         Run: `brew update && brew upgrade orch && brew services restart orch`"
1076    );
1077
1078    for channel in channels.iter() {
1079        let (body, metadata, topic_id) = match channel.name() {
1080            "telegram" => (
1081                msg_telegram.clone(),
1082                serde_json::json!({"preformatted_html": true}),
1083                None,
1084            ),
1085            "discord" => (msg_discord.clone(), serde_json::json!({}), None),
1086            "slack" => (msg_slack.clone(), serde_json::json!({}), None),
1087            _ => continue,
1088        };
1089
1090        let msg = OutgoingMessage {
1091            thread_id: "upgrade".to_string(),
1092            body,
1093            reply_to: None,
1094            metadata,
1095            topic_id,
1096        };
1097
1098        if let Err(e) = channel.send(&msg).await {
1099            tracing::warn!(
1100                channel = channel.name(),
1101                ?e,
1102                "failed to send upgrade notification"
1103            );
1104        }
1105    }
1106
1107    // Record that we notified for this latest version.
1108    let _ = store.kv_set(last_notified_key, &latest).await;
1109}
1110
1111/// Send a one-time escalation notification when the startup retry loop has been
1112/// spinning for longer than `startup_failure_escalation_secs`.
1113///
1114/// This is called *before* the channel registry is initialised (the retry loop
1115/// sits above it in `serve()`), so we construct channel objects directly from
1116/// config — mirroring the approach used during normal channel registration, but
1117/// without relying on `ChannelRegistry`.
1118async fn send_startup_escalation_notification(elapsed_secs: u64) {
1119    let duration = crate::channels::notification::format_duration(elapsed_secs as f64);
1120
1121    let msg_telegram = format!(
1122        "🚨 <b>Orch startup blocked — GitHub unreachable</b>\n\n\
1123         The engine has been retrying backend connections for {}\n\
1124         without success. All task processing, routing, and scheduled\n\
1125         jobs are paused. Check service logs with `orch log -f`.",
1126        duration
1127    );
1128
1129    let msg_discord = format!(
1130        "🚨 **Orch startup blocked — GitHub unreachable**\n\n\
1131         The engine has been retrying backend connections for {}\n\
1132         without success. All task processing, routing, and scheduled\n\
1133         jobs are paused. Check service logs with `orch log -f`.",
1134        duration
1135    );
1136
1137    let msg_slack = format!(
1138        "🚨 *Orch startup blocked — GitHub unreachable*\n\n\
1139         The engine has been retrying backend connections for {}\n\
1140         without success. All task processing, routing, and scheduled\n\
1141         jobs are paused. Check service logs with `orch log -f`.",
1142        duration
1143    );
1144
1145    // Telegram
1146    let tg_token = crate::config::get("channels.telegram.bot_token");
1147    let tg_chat_id = crate::config::get("channels.telegram.chat_id");
1148    if let (Ok(token), Ok(chat_id)) = (&tg_token, &tg_chat_id) {
1149        if !token.is_empty() && !chat_id.is_empty() {
1150            if let Ok(channel) = TelegramChannel::new(token.clone(), Some(chat_id.clone())) {
1151                let msg = OutgoingMessage {
1152                    thread_id: "startup_escalation".to_string(),
1153                    body: msg_telegram.clone(),
1154                    reply_to: None,
1155                    metadata: serde_json::json!({ "preformatted_html": true }),
1156                    topic_id: crate::config::get("channels.telegram.general_topic_id").ok(),
1157                };
1158                if let Err(e) = channel.send(&msg).await {
1159                    tracing::warn!(?e, "failed to send startup escalation via telegram");
1160                } else {
1161                    tracing::info!("startup escalation notification sent to telegram");
1162                }
1163            }
1164        }
1165    }
1166
1167    // Discord
1168    if let Ok(token) = crate::config::get("channels.discord.bot_token") {
1169        let channel_id = crate::config::get("channels.discord.channel_id")
1170            .or_else(|_| crate::config::get("channels.discord.general_channel_id"));
1171        if !token.is_empty() {
1172            if let Some(cid) = channel_id.ok().filter(|s| !s.is_empty()) {
1173                let shard_id = crate::config::get("channels.discord.shard_id")
1174                    .ok()
1175                    .and_then(|v| v.parse::<u64>().ok())
1176                    .unwrap_or(0);
1177                let shard_count = crate::config::get("channels.discord.shard_count")
1178                    .ok()
1179                    .and_then(|v| v.parse::<u64>().ok())
1180                    .unwrap_or(1);
1181                if let Ok(gateway) = DiscordGateway::new(token, Some(cid), shard_id, shard_count) {
1182                    let msg = OutgoingMessage {
1183                        thread_id: "startup_escalation".to_string(),
1184                        body: msg_discord.clone(),
1185                        reply_to: None,
1186                        metadata: serde_json::Value::Null,
1187                        topic_id: None,
1188                    };
1189                    if let Err(e) = gateway.send(&msg).await {
1190                        tracing::warn!(?e, "failed to send startup escalation via discord");
1191                    } else {
1192                        tracing::info!("startup escalation notification sent to discord");
1193                    }
1194                }
1195            }
1196        }
1197    }
1198
1199    // Slack
1200    if let Ok(token) = crate::config::get("channels.slack.bot_token") {
1201        let channel_id = crate::config::get("channels.slack.channel_id");
1202        if !token.is_empty() {
1203            if let Ok(cid) = channel_id {
1204                if let Ok(channel) = SlackChannel::new(token, Some(cid)) {
1205                    let msg = OutgoingMessage {
1206                        thread_id: "startup_escalation".to_string(),
1207                        body: msg_slack.clone(),
1208                        reply_to: None,
1209                        metadata: serde_json::Value::Null,
1210                        topic_id: None,
1211                    };
1212                    if let Err(e) = channel.send(&msg).await {
1213                        tracing::warn!(?e, "failed to send startup escalation via slack");
1214                    } else {
1215                        tracing::info!("startup escalation notification sent to slack");
1216                    }
1217                }
1218            }
1219        }
1220    }
1221}
1222
1223/// Start the orch service.
1224///
1225/// This is the main entry point — called by `orch serve`.
1226pub async fn serve() -> anyhow::Result<()> {
1227    let killed = crate::home::kill_other_orch_serve_processes(std::time::Duration::from_secs(30));
1228    if killed > 0 {
1229        tracing::info!(count = killed, "terminated stale orch serve processes");
1230    }
1231
1232    tracing::info!("orch engine starting");
1233
1234    // Pre-create standard directories with async I/O so subsequent synchronous
1235    // callers (state_dir, orch_home, etc.) find them already present and their
1236    // create_dir_all calls become fast no-ops.
1237    if let Err(e) = crate::home::init_dirs().await {
1238        tracing::warn!("failed to initialize orch directories: {e}");
1239    }
1240
1241    // Pre-warm the config cache with async I/O so get() / get_list() calls in
1242    // hot async paths never block a Tokio thread on std::fs::read_to_string.
1243    if let Err(e) = crate::config::warm_cache().await {
1244        tracing::warn!("failed to warm config cache: {e}");
1245    }
1246
1247    let mut config = EngineConfig::from_config();
1248
1249    tracing::info!("internal database ready");
1250
1251    // Initialize project engines — retry with backoff so a network outage at
1252    // startup doesn't cause a crash-loop (launchd KeepAlive would restart us
1253    // immediately, filling the error log with 600+ identical messages).
1254    //
1255    // IMPORTANT: errors from init_project_engines() must NEVER propagate past
1256    // this loop to main().  If they did, anyhow would write "Error: …" to stderr,
1257    // which brew routes to orch.error.log — polluting it even when projects ARE
1258    // configured correctly but GitHub is temporarily unreachable.
1259    let startup_escalation_secs = config.startup_failure_escalation_secs;
1260    let mut project_engines = {
1261        let mut delay_secs = 5u64;
1262        let mut attempt = 0u32;
1263        let mut escalated = false;
1264        let start = std::time::Instant::now();
1265        loop {
1266            attempt += 1;
1267            match init_project_engines().await {
1268                Ok(engines) => break engines,
1269                Err(e) => {
1270                    let elapsed = start.elapsed().as_secs();
1271
1272                    // Short blips (under the escalation threshold) stay at WARN
1273                    // so they don't flood the operator's attention. Once the
1274                    // outage crosses the threshold, escalate to ERROR-level
1275                    // logging and send a one-time push notification to configured
1276                    // channels — "transient" has clearly become "prolonged."
1277                    if startup_escalation_secs > 0
1278                        && !escalated
1279                        && elapsed >= startup_escalation_secs
1280                    {
1281                        escalated = true;
1282                        tracing::error!(
1283                            delay_secs,
1284                            attempt,
1285                            elapsed_secs = elapsed,
1286                            threshold_secs = startup_escalation_secs,
1287                            "project backends unavailable for {} — escalating startup failure: {e}",
1288                            crate::channels::notification::format_duration(elapsed as f64)
1289                        );
1290                        // Send one-time notification to configured channels.
1291                        // Channels aren't registered yet at this stage, so we
1292                        // construct them directly from config (mirrors orch notify).
1293                        send_startup_escalation_notification(elapsed).await;
1294                    } else {
1295                        tracing::warn!(
1296                            delay_secs,
1297                            attempt,
1298                            "project backends unavailable, retrying: {e}"
1299                        );
1300                    }
1301
1302                    tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
1303                    delay_secs = (delay_secs * 2).min(120);
1304                }
1305            }
1306        }
1307    };
1308
1309    tracing::info!(
1310        project_count = project_engines.len(),
1311        "initialized project engines"
1312    );
1313
1314    // Full set of currently-configured project repos, used by the per-tick global
1315    // sweeps (e.g. `dispatch_routed_tasks_global`) to distinguish "orphaned" tasks
1316    // (repo no longer in config) from tasks belonging to some other active project.
1317    let mut active_repos: std::collections::HashSet<String> =
1318        project_engines.iter().map(|e| e.repo.clone()).collect();
1319
1320    // Create the event bus for task status transitions
1321    let event_bus = events::EventBus::new(256);
1322    if let Err(e) = event_bus.start_ws_server().await {
1323        tracing::warn!(
1324            ?e,
1325            "failed to start event websocket server, continuing without it"
1326        );
1327    }
1328
1329    // Check for a newer orch release in the background and log if the service is behind.
1330    // The periodic checker sends channel notifications after channels are initialized;
1331    // upgrading is operator-only and never runs automatically.
1332    {
1333        let current = env!("ORCH_VERSION").to_string();
1334        tokio::spawn(async move {
1335            if let Some(latest) = fetch_latest_release_version().await {
1336                if latest != current {
1337                    tracing::warn!(
1338                        current_version = %current,
1339                        latest_version = %latest,
1340                        "orch service is behind the latest release — run: brew update && brew upgrade orch && brew services restart orch"
1341                    );
1342                }
1343            }
1344        });
1345    }
1346
1347    // Re-create task managers with shared store and event bus
1348    for engine in &mut project_engines {
1349        engine.task_manager = Arc::new(TaskManager::with_events(
1350            engine.backend.clone(),
1351            engine.store.clone(),
1352            engine.repo.clone(),
1353            event_bus.sender(),
1354        ));
1355    }
1356
1357    // Startup reconciliation decides task status, so only the leader runs it
1358    let leads = project_engines.first().is_none_or(|e| e.store.is_leader());
1359    if leads {
1360        if let Err(e) = reconcile_startup_worktrees(&project_engines).await {
1361            tracing::warn!(err = %e, "startup worktree reconciliation failed");
1362        }
1363    }
1364
1365    if leads {
1366        // Run estimate reconciliation in the background so it never blocks startup.
1367        // Each GraphQL mutation takes ~0.8 s; with hundreds of tasks this would
1368        // otherwise delay the engine reaching its main loop by several minutes.
1369        let engines_snapshot: Vec<ProjectEngine> = project_engines.clone();
1370        tokio::spawn(async move {
1371            reconcile_startup_estimates(&engines_snapshot).await;
1372        });
1373    }
1374
1375    // Build ChannelRouter from global config + per-project configs
1376    let global_channel_config = GlobalChannelConfig {
1377        telegram_general_topic_id: crate::config::get("channels.telegram.general_topic_id").ok(),
1378        discord_general_channel_id: crate::config::get("channels.discord.general_channel_id").ok(),
1379        control_telegram_topic_id: crate::config::get("control.channels.telegram.topic_id").ok(),
1380        control_discord_channel_id: crate::config::get("control.channels.discord.channel_id").ok(),
1381    };
1382    let project_channel_configs: Vec<(String, ProjectChannelConfig)> = project_engines
1383        .iter()
1384        .map(|e| {
1385            let cfg = read_project_channel_config(&e.project_dir);
1386            (e.repo.clone(), cfg)
1387        })
1388        .collect();
1389    let channel_router = Arc::new(ChannelRouter::new(
1390        &global_channel_config,
1391        &project_channel_configs,
1392    ));
1393    tracing::info!(
1394        projects = ?channel_router.projects(),
1395        "channel router initialized"
1396    );
1397
1398    // Initialize tmux manager (shared across all projects)
1399    let tmux = Arc::new(TmuxManager::new());
1400
1401    // Initialize transport
1402    let transport = Arc::new(Transport::new());
1403
1404    // Initialize capture service and start background loop
1405    let capture = Arc::new(CaptureService::new(transport.clone()));
1406    let capture_for_tick = capture.clone();
1407    tokio::spawn(async move {
1408        capture.start().await;
1409    });
1410
1411    // Initialize channel registry
1412    let mut channel_registry = ChannelRegistry::new();
1413
1414    // Try to initialize Telegram channel
1415    if let Ok(token) = crate::config::get("channels.telegram.bot_token") {
1416        if !token.is_empty() {
1417            let chat_id = crate::config::get("channels.telegram.chat_id").ok();
1418            match TelegramChannel::new(token, chat_id) {
1419                Ok(telegram) => {
1420                    if let Err(e) = telegram.health_check().await {
1421                        tracing::warn!(?e, "telegram channel health check failed, skipping");
1422                    } else {
1423                        channel_registry.register(Box::new(telegram));
1424                        tracing::info!("telegram channel registered");
1425                    }
1426                }
1427                Err(e) => {
1428                    tracing::warn!(?e, "failed to create telegram channel, skipping");
1429                }
1430            }
1431        }
1432    }
1433
1434    // Try to initialize Discord Gateway channel (websocket)
1435    if let Ok(token) = crate::config::get("channels.discord.bot_token") {
1436        if !token.is_empty() {
1437            let channel_id = crate::config::get("channels.discord.channel_id").ok();
1438            let shard_id = crate::config::get("channels.discord.shard_id")
1439                .ok()
1440                .and_then(|v| v.parse::<u64>().ok())
1441                .unwrap_or(0);
1442            let shard_count = crate::config::get("channels.discord.shard_count")
1443                .ok()
1444                .and_then(|v| v.parse::<u64>().ok())
1445                .unwrap_or(1);
1446            match DiscordGateway::new(token, channel_id, shard_id, shard_count) {
1447                Ok(discord) => {
1448                    if let Err(e) = discord.health_check().await {
1449                        tracing::warn!(?e, "discord gateway health check failed, skipping");
1450                    } else {
1451                        channel_registry.register(Box::new(discord));
1452                        tracing::info!(shard_id, shard_count, "discord gateway registered");
1453                    }
1454                }
1455                Err(e) => {
1456                    tracing::warn!(?e, "failed to create discord gateway, skipping");
1457                }
1458            }
1459        }
1460    }
1461
1462    // Try to initialize Slack channel
1463    if let Ok(token) = crate::config::get("channels.slack.bot_token") {
1464        if !token.is_empty() {
1465            let channel_id = crate::config::get("channels.slack.channel_id").ok();
1466            match SlackChannel::new(token, channel_id) {
1467                Ok(slack) => {
1468                    if let Err(e) = slack.health_check().await {
1469                        tracing::warn!(?e, "slack channel health check failed, skipping");
1470                    } else {
1471                        channel_registry.register(Box::new(slack));
1472                        tracing::info!("slack channel registered");
1473                    }
1474                }
1475                Err(e) => {
1476                    tracing::warn!(?e, "failed to create slack channel, skipping");
1477                }
1478            }
1479        }
1480    }
1481
1482    // Initialize tmux channel with transport for output streaming
1483    let tmux_channel = TmuxChannel::with_transport(transport.clone());
1484    channel_registry.register(Box::new(tmux_channel));
1485    tracing::info!("tmux channel registered");
1486
1487    // Start all channels and collect their message receivers
1488    let mut channel_receivers: Vec<tokio::sync::mpsc::Receiver<IncomingMessage>> = Vec::new();
1489    for channel in channel_registry.iter() {
1490        match channel.start().await {
1491            Ok(rx) => {
1492                tracing::info!(channel = channel.name(), "channel started");
1493                channel_receivers.push(rx);
1494            }
1495            Err(e) => {
1496                tracing::warn!(channel = channel.name(), ?e, "failed to start channel");
1497            }
1498        }
1499    }
1500
1501    // Wrap channel registry in Arc for shared access (notification dispatcher needs it)
1502    let channel_registry = Arc::new(channel_registry);
1503
1504    // Spawn tasks to handle incoming channel messages (if any channels are active)
1505    let transport_for_messages = transport.clone();
1506    let tmux_for_messages = tmux.clone();
1507    let capture_for_messages = capture_for_tick.clone();
1508    let channels_for_messages = channel_registry.clone();
1509    // Lightweight engine references for command execution and task creation
1510    let engine_refs: Vec<EngineRef> = project_engines
1511        .iter()
1512        .map(|e| {
1513            (
1514                e.repo.clone(),
1515                e.backend.clone(),
1516                e.task_manager.clone(),
1517                Some(e.store.clone()),
1518            )
1519        })
1520        .collect();
1521    // Shared in-memory map for pending project picks (General channel multi-project picker).
1522    let pending_picks: channel_handler::PendingPicks =
1523        std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
1524    let router_for_messages = channel_router.clone();
1525    for mut rx in channel_receivers {
1526        let transport = transport_for_messages.clone();
1527        let tmux = tmux_for_messages.clone();
1528        let capture = capture_for_messages.clone();
1529        let channels = channels_for_messages.clone();
1530        let engine_refs = engine_refs.clone();
1531        let ch_router = router_for_messages.clone();
1532        let picks = pending_picks.clone();
1533        tokio::spawn(async move {
1534            while let Some(msg) = rx.recv().await {
1535                tracing::debug!(channel = %msg.channel, thread = %msg.thread_id, "received message from channel");
1536                channel_handler::handle_channel_message(
1537                    msg,
1538                    &transport,
1539                    &tmux,
1540                    &capture,
1541                    &channels,
1542                    &engine_refs,
1543                    &ch_router,
1544                    &picks,
1545                )
1546                .await;
1547            }
1548        });
1549    }
1550
1551    // Post-restart confirmation: if the service was restarted via the /restart control command,
1552    // a KV marker was stored before shutdown. Send a confirmation to the originating channel now
1553    // that we're back online. Use a short delay to let the channel connections re-establish.
1554    {
1555        let restart_channels = channel_registry.clone();
1556        let restart_store = project_engines.first().map(|e| e.store.clone());
1557        if let Some(store) = restart_store {
1558            tokio::spawn(async move {
1559                tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1560                match store.kv_get("control:restart_pending").await {
1561                    Ok(Some(conv_key)) => {
1562                        if let Some((ch_name, thread_id, topic_id)) =
1563                            crate::channels::transport::parse_conversation_key(&conv_key)
1564                        {
1565                            for ch in restart_channels.iter() {
1566                                if ch.name() == ch_name {
1567                                    let msg = OutgoingMessage {
1568                                        thread_id: thread_id.to_string(),
1569                                        body: "orch restarted successfully.".to_string(),
1570                                        reply_to: None,
1571                                        metadata: serde_json::json!({}),
1572                                        topic_id: topic_id.map(|t| t.to_string()),
1573                                    };
1574                                    if let Err(e) = ch.send(&msg).await {
1575                                        tracing::warn!(
1576                                            channel = ch_name,
1577                                            error = %e,
1578                                            "failed to send restart confirmation"
1579                                        );
1580                                    }
1581                                    break;
1582                                }
1583                            }
1584                        }
1585                        if let Err(e) = store.kv_delete("control:restart_pending").await {
1586                            tracing::warn!(error = %e, "failed to clear control:restart_pending");
1587                        }
1588                    }
1589                    Ok(None) => {} // no pending restart, nothing to do
1590                    Err(e) => {
1591                        tracing::warn!(error = %e, "failed to read control:restart_pending");
1592                    }
1593                }
1594            });
1595        }
1596    }
1597
1598    // Spawn notification dispatcher — reads task completion notifications
1599    // from transport and broadcasts to configured channels.
1600    //
1601    // Routing priority:
1602    // 1. Dedicated channel: if the notification has a `repo` and the
1603    //    ChannelRouter maps it to a topic/channel, send there with `topic_id`.
1604    // 2. Subscribed channels: send to each (channel, thread_id) subscribed to
1605    //    the repo, using `format_with_project()` so recipients know the source.
1606    // 3. Fallback: if no repo or no dedicated channel, broadcast to all
1607    //    channels without a topic_id (legacy behaviour).
1608    {
1609        let mut notification_rx = transport.subscribe_notifications();
1610        let channels = channel_registry.clone();
1611        let notif_router = channel_router.clone();
1612        // Grab a store reference for subscription lookups.
1613        let notif_store: Option<Arc<TaskStore>> = project_engines.first().map(|e| e.store.clone());
1614        tokio::spawn(async move {
1615            loop {
1616                match notification_rx.recv().await {
1617                    Ok(notification) => {
1618                        let level = NotificationLevel::from_config();
1619                        if !level.should_notify(&notification.status) {
1620                            tracing::debug!(
1621                                task_id = %notification.task_id,
1622                                status = %notification.status,
1623                                "notification suppressed by level={:?}",
1624                                level
1625                            );
1626                            continue;
1627                        }
1628
1629                        tracing::info!(
1630                            task_id = %notification.task_id,
1631                            status = %notification.status,
1632                            repo = ?notification.repo,
1633                            "routing notification to channels"
1634                        );
1635
1636                        // Track whether we sent to at least one dedicated/subscribed target.
1637                        let mut routed = false;
1638
1639                        // 0. Direct target: if notify_target is set, send straight to
1640                        //    that Telegram chat_id and skip all other routing.
1641                        if let Some(ref target) = notification.notify_target {
1642                            let telegram = channels.iter().find(|c| c.name() == "telegram");
1643                            if let Some(channel) = telegram {
1644                                let body = notification.format_telegram();
1645                                let metadata = serde_json::json!({
1646                                    "preformatted_html": true,
1647                                    "chat_id_override": target
1648                                });
1649                                let msg = OutgoingMessage {
1650                                    thread_id: notification.task_id.clone(),
1651                                    body,
1652                                    reply_to: None,
1653                                    metadata,
1654                                    topic_id: None,
1655                                };
1656                                if let Err(e) = channel.send(&msg).await {
1657                                    tracing::warn!(
1658                                        task_id = %notification.task_id,
1659                                        target,
1660                                        ?e,
1661                                        "failed to send to notify_target"
1662                                    );
1663                                } else {
1664                                    routed = true;
1665                                }
1666                            }
1667                        }
1668
1669                        if routed {
1670                            // notify_target handled — skip further routing
1671                        } else if let Some(repo) = notification.repo.as_deref() {
1672                            // Track destinations already sent to in this iteration so
1673                            // the dedicated (path 1) and subscribed (path 2) loops
1674                            // don't double-fire the same Telegram/Discord destination
1675                            // when a repo's configured topic and a `channel_subscriptions`
1676                            // row resolve to the same `(channel, topic_id)`.
1677                            let mut sent: std::collections::HashSet<(String, Option<String>)> =
1678                                std::collections::HashSet::new();
1679
1680                            // 1. Dedicated channel targets for this repo.
1681                            for channel in channels.iter() {
1682                                let ch_name = channel.name();
1683                                if let Some(topic_id) =
1684                                    notif_router.target_for_project(repo, ch_name)
1685                                {
1686                                    let body = match ch_name {
1687                                        "telegram" => notification.format_telegram(),
1688                                        "discord" => notification.format_discord(),
1689                                        "slack" => notification.format_slack(),
1690                                        _ => continue,
1691                                    };
1692                                    let metadata = if ch_name == "telegram" {
1693                                        serde_json::json!({"preformatted_html": true})
1694                                    } else {
1695                                        serde_json::json!({})
1696                                    };
1697                                    let resolved_topic = Some(topic_id.to_string());
1698                                    let msg = OutgoingMessage {
1699                                        thread_id: notification.task_id.clone(),
1700                                        body,
1701                                        reply_to: None,
1702                                        metadata,
1703                                        topic_id: resolved_topic.clone(),
1704                                    };
1705                                    if let Err(e) = channel.send(&msg).await {
1706                                        tracing::warn!(
1707                                            channel = ch_name,
1708                                            task_id = %notification.task_id,
1709                                            ?e,
1710                                            "failed to send to dedicated channel"
1711                                        );
1712                                    } else {
1713                                        sent.insert((ch_name.to_string(), resolved_topic));
1714                                        routed = true;
1715                                    }
1716                                }
1717                            }
1718
1719                            // 2. Subscribed channels for this repo.
1720                            if let Some(store) = &notif_store {
1721                                match store.list_subscribers_for_repo(repo).await {
1722                                    Ok(subscribers) => {
1723                                        for (ch_name, thread_id, topic_id) in subscribers {
1724                                            let channel =
1725                                                channels.iter().find(|c| c.name() == ch_name);
1726                                            let Some(channel) = channel else {
1727                                                continue;
1728                                            };
1729                                            let resolved_topic = if topic_id.is_empty() {
1730                                                None
1731                                            } else {
1732                                                Some(topic_id.clone())
1733                                            };
1734                                            // Skip if a dedicated send to the same
1735                                            // (channel, topic_id) already happened.
1736                                            if sent.contains(&(
1737                                                ch_name.clone(),
1738                                                resolved_topic.clone(),
1739                                            )) {
1740                                                tracing::debug!(
1741                                                    channel = %ch_name,
1742                                                    task_id = %notification.task_id,
1743                                                    topic_id = ?resolved_topic,
1744                                                    "skipping duplicate subscriber dispatch — destination already received from dedicated path"
1745                                                );
1746                                                continue;
1747                                            }
1748                                            let body = notification.format_with_project(&ch_name);
1749                                            let metadata = if ch_name == "telegram" {
1750                                                serde_json::json!({"preformatted_html": true})
1751                                            } else {
1752                                                serde_json::json!({})
1753                                            };
1754                                            let msg = OutgoingMessage {
1755                                                thread_id: thread_id.clone(),
1756                                                body,
1757                                                reply_to: None,
1758                                                metadata,
1759                                                topic_id: resolved_topic.clone(),
1760                                            };
1761                                            if let Err(e) = channel.send(&msg).await {
1762                                                tracing::warn!(
1763                                                    channel = %ch_name,
1764                                                    task_id = %notification.task_id,
1765                                                    ?e,
1766                                                    "failed to send to subscribed channel"
1767                                                );
1768                                            } else {
1769                                                sent.insert((ch_name.clone(), resolved_topic));
1770                                                routed = true;
1771                                            }
1772                                        }
1773                                    }
1774                                    Err(e) => {
1775                                        tracing::warn!(
1776                                            repo,
1777                                            ?e,
1778                                            "failed to list subscribers for repo"
1779                                        );
1780                                    }
1781                                }
1782                            }
1783                        }
1784
1785                        // 3. Fallback: broadcast to all channels when no routing happened.
1786                        if !routed {
1787                            for channel in channels.iter() {
1788                                let (body, should_send, is_telegram) = match channel.name() {
1789                                    "telegram" => (notification.format_telegram(), true, true),
1790                                    "discord" => (notification.format_discord(), true, false),
1791                                    "slack" => (notification.format_slack(), true, false),
1792                                    // GitHub is already handled by backend.post_comment()
1793                                    // tmux doesn't need task completion notifications
1794                                    _ => (String::new(), false, false),
1795                                };
1796
1797                                if !should_send {
1798                                    continue;
1799                                }
1800
1801                                let metadata = if is_telegram {
1802                                    serde_json::json!({"preformatted_html": true})
1803                                } else {
1804                                    serde_json::json!({})
1805                                };
1806                                let msg = OutgoingMessage {
1807                                    thread_id: notification.task_id.clone(),
1808                                    body,
1809                                    reply_to: None,
1810                                    metadata,
1811                                    topic_id: None,
1812                                };
1813
1814                                if let Err(e) = channel.send(&msg).await {
1815                                    tracing::warn!(
1816                                        channel = channel.name(),
1817                                        task_id = %notification.task_id,
1818                                        ?e,
1819                                        "failed to send notification"
1820                                    );
1821                                }
1822                            }
1823                        }
1824                    }
1825                    Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1826                        tracing::warn!(missed = n, "notification receiver lagged");
1827                    }
1828                    Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1829                        tracing::debug!("notification channel closed");
1830                        break;
1831                    }
1832                }
1833            }
1834        });
1835        tracing::info!("notification dispatcher started");
1836    }
1837
1838    // Notify used by webhook events to wake up the engine tick immediately
1839    let webhook_notify = Arc::new(Notify::new());
1840
1841    // Track webhook state for health checks and fallback.
1842    // When webhooks are disabled, `in_fallback_mode` stays true so sync
1843    // uses the faster fallback interval for polling.
1844    let webhook_port: Option<u16>;
1845    let mut webhook_healthy: bool;
1846    let mut last_webhook_health_check = std::time::Instant::now();
1847    // Start webhook server if configured
1848    let webhook_enabled = crate::config::get("webhook.enabled")
1849        .map(|v| v == "true")
1850        .unwrap_or(false);
1851
1852    // Shared status updated by the server-spawn task and the health-check loop.
1853    let webhook_status = Arc::new(tokio::sync::Mutex::new(
1854        crate::webhook_status::WebhookStatus::default(),
1855    ));
1856
1857    if webhook_enabled {
1858        let port: u16 = crate::config::get("webhook.port")
1859            .ok()
1860            .and_then(|v| v.parse().ok())
1861            .unwrap_or(8080);
1862        webhook_port = Some(port);
1863        let secret = crate::config::get("webhook.secret").unwrap_or_default();
1864        let webhook_repo = project_engines
1865            .first()
1866            .map(|e| e.repo.clone())
1867            .unwrap_or_default();
1868
1869        let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(64);
1870
1871        // Initialise status: configured, not yet healthy.
1872        let to_save = {
1873            let mut s = webhook_status.lock().await;
1874            s.configured = true;
1875            s.port = Some(port);
1876            s.healthy = false;
1877            s.fallback_mode = false;
1878            s.clone()
1879        };
1880        to_save.save().await;
1881
1882        // Spawn the HTTP server with exponential-backoff retry on transient
1883        // bind errors (e.g. EADDRINUSE).  After MAX_ATTEMPTS the engine falls
1884        // back to polling and updates the shared status accordingly.
1885        const WEBHOOK_MAX_ATTEMPTS: u32 = 5;
1886        let status_for_spawn = webhook_status.clone();
1887        tokio::spawn(async move {
1888            use crate::channels::github::{is_transient_bind_error, webhook_backoff_delay};
1889            let mut attempt = 0u32;
1890            loop {
1891                attempt += 1;
1892                tracing::info!(attempt, port, "attempting to start webhook server");
1893                match start_webhook_server(port, secret.clone(), webhook_repo.clone(), tx.clone())
1894                    .await
1895                {
1896                    Ok(_) => {
1897                        tracing::info!(attempt, "webhook server exited cleanly");
1898                        break;
1899                    }
1900                    Err(e) => {
1901                        let reason = e.to_string();
1902                        tracing::error!(attempt, %reason, "webhook server failed");
1903                        if attempt >= WEBHOOK_MAX_ATTEMPTS || !is_transient_bind_error(&e) {
1904                            let kind = if attempt >= WEBHOOK_MAX_ATTEMPTS {
1905                                "max attempts reached"
1906                            } else {
1907                                "non-transient error"
1908                            };
1909                            tracing::error!(
1910                                attempt,
1911                                %reason,
1912                                kind,
1913                                orch_webhook_in_fallback = true,
1914                                "webhook server giving up, switching to polling fallback"
1915                            );
1916                            let to_save = {
1917                                let mut s = status_for_spawn.lock().await;
1918                                s.fallback_mode = true;
1919                                s.healthy = false;
1920                                s.last_failure_reason = Some(reason);
1921                                s.startup_attempts = attempt;
1922                                s.clone()
1923                            };
1924                            to_save.save().await;
1925                            break;
1926                        }
1927                        let delay = webhook_backoff_delay(attempt);
1928                        tracing::warn!(
1929                            attempt,
1930                            delay_ms = delay.as_millis(),
1931                            %reason,
1932                            "webhook bind failed (transient), retrying with backoff"
1933                        );
1934                        {
1935                            let to_save = {
1936                                let mut s = status_for_spawn.lock().await;
1937                                s.startup_attempts = attempt;
1938                                s.last_failure_reason = Some(reason);
1939                                s.clone()
1940                            };
1941                            to_save.save().await;
1942                        }
1943                        tokio::time::sleep(delay).await;
1944                    }
1945                }
1946            }
1947        });
1948
1949        // Spawn the message forwarding task (reads from webhook channel)
1950        let notify = webhook_notify.clone();
1951        let transport_for_webhook = transport.clone();
1952        tokio::spawn(async move {
1953            while let Some(msg) = rx.recv().await {
1954                tracing::info!(
1955                    channel = %msg.channel,
1956                    thread = %msg.thread_id,
1957                    event = %msg.metadata.get("event").and_then(|v| v.as_str()).unwrap_or("unknown"),
1958                    action = %msg.metadata.get("action").and_then(|v| v.as_str()).unwrap_or("unknown"),
1959                    "webhook event received, triggering immediate tick"
1960                );
1961                let route = transport_for_webhook.route(&msg).await;
1962                tracing::info!(
1963                    channel = %msg.channel,
1964                    route = ?route,
1965                    "webhook event routed"
1966                );
1967                // Wake up the engine tick immediately instead of waiting up to 10s
1968                notify.notify_one();
1969            }
1970        });
1971
1972        webhook_healthy = true;
1973        tracing::info!(port, orch_webhook_up = true, "webhook server started");
1974    } else {
1975        webhook_port = None;
1976        webhook_healthy = false;
1977        {
1978            let to_save = {
1979                let mut s = webhook_status.lock().await;
1980                s.configured = false;
1981                s.fallback_mode = true;
1982                s.clone()
1983            };
1984            to_save.save().await;
1985        }
1986        tracing::info!(
1987            orch_webhook_in_fallback = true,
1988            "webhook server disabled, using polling fallback mode"
1989        );
1990    }
1991
1992    // Agent router (selects agent + model per task) - shared across projects.
1993    // Router::from_config() may run `opencode models` (a blocking subprocess) to
1994    // discover free models. Wrapping in spawn_blocking keeps the Tokio runtime
1995    // thread free during that I/O.
1996    // NOTE: Router::new() also calls prime_free_model_cache() (another blocking
1997    // subprocess), so the fallback must also run inside spawn_blocking.
1998    let router = Arc::new(RwLock::new(
1999        match tokio::task::spawn_blocking(Router::from_config).await {
2000            Ok(r) => r,
2001            Err(e) => {
2002                tracing::error!(?e, "router init panicked in spawn_blocking, using default");
2003                tokio::task::spawn_blocking(|| Router::new(RouterConfig::default()))
2004                    .await
2005                    .unwrap_or_else(|e2| {
2006                        panic!("router default init also panicked in spawn_blocking — cannot start engine without a router: {e2:?}");
2007                    })
2008            }
2009        },
2010    ));
2011    {
2012        let r = router.read().await;
2013        tracing::info!(
2014            mode = %r.config.mode,
2015            agents = ?r.available_agents,
2016            fallback = %r.config.fallback_executor,
2017            "router initialized"
2018        );
2019    }
2020
2021    // Concurrency limiter (shared across all projects). `parallel_in_force` is the
2022    // limit actually handed out, which lags `config.max_parallel` after a shrink
2023    // until the running tasks give their permits back.
2024    let semaphore = Arc::new(Semaphore::new(config.max_parallel));
2025    let mut parallel_in_force = config.max_parallel;
2026
2027    // In-memory dispatch guard: tracks task IDs currently being dispatched.
2028    // Guards against GitHub API eventual consistency — after update_status(InProgress),
2029    // the label-removal webhook can trigger an immediate tick where list_by_status(Routed)
2030    // still returns the task (search index propagation delay). The tmux session does not
2031    // exist until the runner completes worktree setup (~10s later), so session_exists
2032    // alone is insufficient. Keyed by "{repo}/{task_id}".
2033    let dispatching: Arc<dashmap::DashMap<String, String>> = Arc::new(dashmap::DashMap::new());
2034
2035    // In-memory guard to prevent double-spawn of auto-merge background tasks.
2036    // Since review_open_prs runs every sync_tick (~10s) but CI polling can take
2037    // up to 10 minutes, we track which tasks already have an auto-merge in flight.
2038    let auto_merge_in_flight: Arc<dashmap::DashSet<String>> = Arc::new(dashmap::DashSet::new());
2039
2040    // Subscribe to config file changes for hot reload
2041    let mut config_rx = crate::config::subscribe();
2042
2043    // Track sync interval
2044    let mut last_sync = std::time::Instant::now();
2045
2046    // Track upgrade check interval
2047    let mut last_upgrade_check: Option<std::time::Instant> = None;
2048
2049    // Reset epoch of the GitHub rate-limit window already reported, so the log line,
2050    // the KV flag and the channel notification fire once per window instead of per tick
2051    let mut rate_limit_reported: Option<i64> = None;
2052
2053    // Channel for weight signals from task runners back to the router
2054    let (weight_tx, mut weight_rx) = mpsc::channel::<WeightSignal>(64);
2055
2056    // Reset InReview tasks on startup. A task is reset to NeedsReview if:
2057    // 1. It was expecting a live review session (review_session_expected=true), OR
2058    // 2. It has been in InReview for >10 minutes (catches cases where the flag
2059    //    was never set due to crash-loops or lost events).
2060    // Tasks with a recent review comment on their PR are left alone.
2061    for engine in project_engines.iter().filter(|_| leads) {
2062        // Read from SQLite (source of truth) — GitHub labels may be out of sync.
2063        let in_review_from_store = engine
2064            .store
2065            .list_by_status(&engine.repo, crate::store::TaskStatus::InReview)
2066            .await
2067            .unwrap_or_default()
2068            .iter()
2069            .map(crate::engine::tasks::store_task_to_external)
2070            .collect::<Vec<_>>();
2071        if !in_review_from_store.is_empty() {
2072            let in_review = &in_review_from_store;
2073            for task in in_review {
2074                let session_expected =
2075                    review_session_expected(&engine.store, &engine.repo, &task.id.0).await;
2076                let age_minutes = chrono::DateTime::parse_from_rfc3339(&task.updated_at)
2077                    .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_minutes())
2078                    .unwrap_or(i64::MAX);
2079                if !session_expected && age_minutes < 10 {
2080                    continue;
2081                }
2082                if let Err(e) = engine
2083                    .task_manager
2084                    .update_task_status(&task.id, Status::NeedsReview)
2085                    .await
2086                {
2087                    tracing::warn!(
2088                        task_id = task.id.0,
2089                        err = %e,
2090                        "failed to reset stale InReview task on startup"
2091                    );
2092                } else {
2093                    set_review_session_expected(&engine.store, &engine.repo, &task.id.0, false)
2094                        .await;
2095                }
2096            }
2097            if !in_review.is_empty() {
2098                tracing::info!(
2099                    repo = %engine.repo,
2100                    count = in_review.len(),
2101                    "reset stale InReview tasks to NeedsReview on startup"
2102                );
2103            }
2104        }
2105
2106        // Also reset internal (SQLite) InReview tasks on startup.
2107        // Note: the block above already covers internal tasks from the store,
2108        // but this second pass catches any that were only in the old internal_tasks table.
2109        use crate::store::TaskStatus as DbStatus;
2110        if let Ok(internal_in_review) = engine
2111            .task_manager
2112            .list_internal_by_status(DbStatus::InReview)
2113            .await
2114        {
2115            for task in &internal_in_review {
2116                let task_id = task.id.0.clone();
2117                let session_expected =
2118                    review_session_expected(&engine.store, &engine.repo, &task_id).await;
2119                let age_minutes = chrono::DateTime::parse_from_rfc3339(&task.updated_at)
2120                    .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_minutes())
2121                    .unwrap_or(i64::MAX);
2122                if !session_expected && age_minutes < 10 {
2123                    continue;
2124                }
2125                if let Err(e) = engine
2126                    .task_manager
2127                    .update_task_status(&ExternalId(task_id.clone()), Status::NeedsReview)
2128                    .await
2129                {
2130                    tracing::warn!(
2131                        task_id,
2132                        err = %e,
2133                        "failed to reset stale internal InReview task on startup"
2134                    );
2135                } else {
2136                    set_review_session_expected(&engine.store, &engine.repo, &task_id, false).await;
2137                }
2138            }
2139            if !internal_in_review.is_empty() {
2140                tracing::info!(
2141                    repo = %engine.repo,
2142                    count = internal_in_review.len(),
2143                    "reset stale internal InReview tasks to NeedsReview on startup"
2144                );
2145            }
2146        }
2147    }
2148
2149    // Spawn dispatch subscribers — react to Routed events immediately
2150    for engine in &project_engines {
2151        subscribers::dispatch::spawn(
2152            event_bus.subscribe(),
2153            engine.backend.clone(),
2154            tmux.clone(),
2155            engine.runner.clone(),
2156            capture_for_tick.clone(),
2157            semaphore.clone(),
2158            engine.task_manager.clone(),
2159            weight_tx.clone(),
2160            router.clone(),
2161            dispatching.clone(),
2162            engine.store.clone(),
2163            engine.repo.clone(),
2164        );
2165    }
2166    tracing::info!("dispatch subscriber started");
2167
2168    // Spawn review subscribers — react to NeedsReview events immediately
2169    for engine in &project_engines {
2170        subscribers::review::spawn(
2171            event_bus.subscribe(),
2172            engine.backend.clone(),
2173            tmux.clone(),
2174            semaphore.clone(),
2175            engine.task_manager.clone(),
2176            router.clone(),
2177            dispatching.clone(),
2178            engine.store.clone(),
2179            engine.repo.clone(),
2180        );
2181    }
2182    tracing::info!("review subscriber started");
2183
2184    // Spawn unblock subscribers — react to Done events, unblock parent tasks immediately
2185    for engine in &project_engines {
2186        subscribers::unblock::spawn(
2187            event_bus.subscribe(),
2188            engine.backend.clone(),
2189            engine.task_manager.clone(),
2190            engine.store.clone(),
2191            engine.repo.clone(),
2192        );
2193    }
2194    tracing::info!("unblock subscriber started");
2195
2196    // Spawn notify subscriber — reacts to ALL events, pushes to transport.
2197    // Spawned once (not per-project) since the transport handles all repos.
2198    subscribers::notify::spawn(event_bus.subscribe(), transport.clone());
2199    tracing::info!("notify subscriber started");
2200
2201    // Main loop
2202    tracing::info!(
2203        tick = ?config.tick_interval,
2204        sync = ?config.sync_interval,
2205        parallel = config.max_parallel,
2206        "entering main loop"
2207    );
2208    // Values of the settings that only take effect at startup, so a reload can
2209    // tell the operator their edit needs a restart.
2210    let mut restart_only = restart_only_snapshot();
2211
2212    let mut interval = tokio::time::interval(config.tick_interval);
2213
2214    // Signal handlers (launchd/systemd send SIGTERM to stop services, SIGHUP for reload/restart)
2215    let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2216    let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
2217
2218    // Guard to prevent concurrent sync_tick runs (sync is spawned as a background task).
2219    let sync_in_progress = Arc::new(std::sync::atomic::AtomicBool::new(false));
2220
2221    // Tick watchdog: detects when the main loop hasn't completed a tick in > 60s.
2222    // Updates an atomic timestamp at the end of each tick iteration.
2223    let last_tick_epoch = Arc::new(std::sync::atomic::AtomicU64::new(
2224        std::time::SystemTime::now()
2225            .duration_since(std::time::UNIX_EPOCH)
2226            .unwrap_or_default()
2227            .as_secs(),
2228    ));
2229    {
2230        let last_tick_epoch = Arc::clone(&last_tick_epoch);
2231        let tick_interval_secs = config.tick_interval.as_secs();
2232        tokio::spawn(async move {
2233            // Check every 30s whether the main loop is still ticking.
2234            let mut watchdog_interval = tokio::time::interval(std::time::Duration::from_secs(30));
2235            // Own suspend baseline, independent of the main loop's checkpoint(). The
2236            // watchdog's interval timer also freezes during a host suspend, so this
2237            // detects the gap from its own wake-up instead of racing the main loop's
2238            // checkpoint() for the same GAP_LOG entry (see #3544).
2239            let mut suspend_detector = crate::engine::suspend::SuspendDetector::new();
2240            loop {
2241                watchdog_interval.tick().await;
2242                let own_gap = suspend_detector.check();
2243                let last = last_tick_epoch.load(std::sync::atomic::Ordering::Relaxed);
2244                let now = std::time::SystemTime::now()
2245                    .duration_since(std::time::UNIX_EPOCH)
2246                    .unwrap_or_default()
2247                    .as_secs();
2248                let stale_secs = now.saturating_sub(last);
2249                // Warn if the tick loop hasn't completed in > 6× the tick interval (at least 60s).
2250                let threshold = (tick_interval_secs * 6).max(60);
2251                if stale_secs > threshold {
2252                    // A host suspend/resume gap fully explains a stale window that
2253                    // isn't an actual stall — the process (and its timers) were
2254                    // simply not scheduled while the host was asleep. Prefer this
2255                    // watchdog's own detector (immune to the checkpoint race above);
2256                    // fall back to the shared log in case the main loop's checkpoint()
2257                    // already ran and recorded a larger gap.
2258                    let shared_gap = crate::engine::suspend::gap_detected_within(
2259                        std::time::Duration::from_secs(stale_secs),
2260                    );
2261                    let gap = match (own_gap, shared_gap) {
2262                        (Some(a), Some(b)) => Some(a.max(b)),
2263                        (Some(a), None) => Some(a),
2264                        (None, Some(b)) => Some(b),
2265                        (None, None) => None,
2266                    };
2267                    if let Some(gap) = gap {
2268                        let gap_secs = gap.num_seconds() as u64;
2269                        if gap_secs >= stale_secs {
2270                            tracing::info!(
2271                                stale_secs,
2272                                threshold,
2273                                suspend_gap_secs = gap_secs,
2274                                "tick loop stale window explained by host suspend/resume, not a stall"
2275                            );
2276                        } else {
2277                            tracing::error!(
2278                                stale_secs,
2279                                threshold,
2280                                suspend_gap_secs = gap_secs,
2281                                "WATCHDOG: tick loop has not completed a tick in {}s (threshold {}s) — suspend gap of {}s only partially explains the stall",
2282                                stale_secs,
2283                                threshold,
2284                                gap_secs,
2285                            );
2286                        }
2287                    } else {
2288                        tracing::error!(
2289                            stale_secs,
2290                            threshold,
2291                            "WATCHDOG: tick loop has not completed a tick in {}s (threshold {}s) — possible stall",
2292                            stale_secs,
2293                            threshold,
2294                        );
2295                    }
2296                }
2297            }
2298        });
2299    }
2300
2301    loop {
2302        tokio::select! {
2303            _ = interval.tick() => {
2304                let tick_start = std::time::Instant::now();
2305
2306                // Detect a host suspend/resume gap once per iteration, in one place.
2307                // The watchdog and stuck-task recovery both read from this instead of
2308                // re-deriving suspend information from their own wall-clock deltas.
2309                if let Some(gap) = crate::engine::suspend::checkpoint() {
2310                    tracing::info!(
2311                        gap_secs = gap.num_seconds(),
2312                        "host suspend/resume gap detected, discounting from stuck-task age checks"
2313                    );
2314                }
2315
2316                // Finish a pending shrink of the dispatch pool as running tasks
2317                // release their permits. Only logs when a permit was actually
2318                // taken back, so a long-running task does not log every tick.
2319                if parallel_in_force != config.max_parallel {
2320                    let resized =
2321                        resize_dispatch_pool(&semaphore, parallel_in_force, config.max_parallel);
2322                    if resized != parallel_in_force {
2323                        parallel_in_force = resized;
2324                        tracing::info!(
2325                            in_force = parallel_in_force,
2326                            target = config.max_parallel,
2327                            "dispatch pool resized"
2328                        );
2329                    }
2330                }
2331
2332                // Tick weight recovery for rate-limited agents
2333                {
2334                    let mut rw = router.write().await;
2335                    rw.tick_weight_recovery();
2336                }
2337
2338                // Report the GitHub rate limit once per window, then tick anyway. Calls
2339                // into GitHub now fail fast instead of sleeping until the reset, so the
2340                // local phases keep moving and the watchdog does not see a stalled loop.
2341                if let Some(remaining) = GhHttp::is_rate_limited() {
2342                    let reset_at = chrono::Utc::now()
2343                        .timestamp()
2344                        .saturating_add(i64::try_from(remaining.as_secs()).unwrap_or(i64::MAX));
2345                    // Tolerate jitter in the header so one window reports once
2346                    if rate_limit_reported.is_none_or(|prev| (reset_at - prev).abs() > 60) {
2347                        rate_limit_reported = Some(reset_at);
2348                        report_rate_limited(reset_at, project_engines.first().map(|e| &e.store), Some(&transport)).await;
2349                    }
2350                } else if let Some(prev) = rate_limit_reported.take() {
2351                    tracing::info!(
2352                        limited_until = prev,
2353                        "GitHub API rate limit cleared"
2354                    );
2355                    if let Some(engine) = project_engines.first() {
2356                        let _ = engine.store.kv_delete(RATE_LIMITED_UNTIL_KEY).await;
2357                    }
2358                }
2359
2360                // Core tick: poll tasks for all projects
2361                let mut router_guard = router.write().await;
2362                let mut routing_quota = tick::RoutingTickQuota::new(
2363                    crate::engine::router::config::max_tasks_per_routing_tick(),
2364                );
2365                for engine in &project_engines {
2366                    let repo = engine.repo.clone();
2367                    let project_jobs_path = engine.project_dir.join(".orch.yml");
2368                    REPO_CONTEXT.scope(repo, async {
2369                        if let Err(e) = tick::tick(
2370                            &engine.backend,
2371                            &tmux,
2372                            &engine.repo,
2373                            &engine.runner,
2374                            &capture_for_tick,
2375                            &semaphore,
2376                            &config,
2377                            &project_jobs_path,
2378                            &mut router_guard,
2379                            &engine.task_manager,
2380                            &weight_tx,
2381                            &dispatching,
2382                            &engine.store,
2383                            Some(&transport),
2384                            &active_repos,
2385                            &auto_merge_in_flight,
2386                            &mut routing_quota,
2387                        ).await {
2388                            tracing::error!(repo = %engine.repo, ?e, "tick failed for project");
2389                        }
2390                    }).await;
2391                }
2392                drop(router_guard);
2393
2394                // Drain pending weight signals after tick processing so the router
2395                // learns from outcomes produced by this iteration's task dispatches.
2396                while let Ok(signal) = weight_rx.try_recv() {
2397                    let mut rw = router.write().await;
2398                    match signal {
2399                        WeightSignal::RateLimited { ref agent } => {
2400                            rw.record_rate_limit(agent);
2401                        }
2402                        WeightSignal::Success { ref agent } => {
2403                            rw.record_success(agent);
2404                        }
2405                        WeightSignal::Rerouted | WeightSignal::Blocked | WeightSignal::None => {}
2406                    }
2407                }
2408
2409                // Periodic sync (less frequent) — spawned as a background task so it
2410                // cannot block the main tick loop. A stalled sync (hung HTTP call, slow
2411                // DB query, lock contention) previously parked all tokio workers for 12+
2412                // minutes because it ran inline. See issue #2574.
2413                if last_sync.elapsed() >= config.sync_interval {
2414                    if sync_in_progress.compare_exchange(
2415                        false,
2416                        true,
2417                        std::sync::atomic::Ordering::AcqRel,
2418                        std::sync::atomic::Ordering::Relaxed,
2419                    ).is_ok() {
2420                        // Clone everything sync_tick needs — it runs in a spawned task.
2421                        let sync_engines: Vec<_> = project_engines.iter().map(|e| {
2422                            (e.backend.clone(), e.repo.clone(),
2423                             e.task_manager.clone(), e.store.clone())
2424                        }).collect();
2425                        let sync_tmux = Arc::clone(&tmux);
2426                        let sync_config = config.clone();
2427                        let sync_router = Arc::clone(&router);
2428                        let sync_dispatching = Arc::clone(&dispatching);
2429                        let sync_auto_merge = Arc::clone(&auto_merge_in_flight);
2430                        let sync_guard = Arc::clone(&sync_in_progress);
2431                        tokio::spawn(async move {
2432                            let sync_start = std::time::Instant::now();
2433                            for (backend, repo, task_manager, store) in &sync_engines {
2434                                let repo = repo.clone();
2435                                REPO_CONTEXT.scope(repo.clone(), async {
2436                                    if let Err(e) = sync::sync_tick(
2437                                        backend, &sync_tmux, &repo, &sync_config,
2438                                        &sync_router, task_manager, store,
2439                                        &sync_dispatching, &sync_auto_merge,
2440                                    ).await {
2441                                        tracing::error!(repo = %repo, ?e, "sync tick failed for project");
2442                                    }
2443                                }).await;
2444                            }
2445                            // Emit degraded-agents metric/log once per sync cycle.
2446                            // Clone the needed data before releasing the read guard so
2447                            // the lock is not held across the async store writes.
2448                            if let Some((_, _, _, store)) = sync_engines.first() {
2449                                let (available_agents, config) = {
2450                                    let r = sync_router.read().await;
2451                                    (r.available_agents.clone(), r.config.clone())
2452                                };
2453                                sync::emit_degraded_agents_if_needed(
2454                                    &available_agents,
2455                                    &config,
2456                                    Some(store),
2457                                )
2458                                .await;
2459                            }
2460                            let elapsed = sync_start.elapsed();
2461                            tracing::info!(elapsed_ms = elapsed.as_millis() as u64, "sync tick complete");
2462                            sync_guard.store(false, std::sync::atomic::Ordering::Release);
2463                        });
2464                    } else {
2465                        tracing::debug!("sync tick still in progress, skipping");
2466                    }
2467                    // Records sync *schedule* time (not completion). This prevents
2468                    // re-entering the sync branch every tick while one is in flight.
2469                    // The sync_in_progress guard handles actual concurrency control.
2470                    last_sync = std::time::Instant::now();
2471                }
2472
2473                // Periodic webhook health check
2474                if webhook_enabled {
2475                    let health_check_interval = config.webhook_health_check_interval
2476                        .unwrap_or(std::time::Duration::from_secs(60));
2477
2478                    if last_webhook_health_check.elapsed() >= health_check_interval {
2479                        if let Some(port) = webhook_port {
2480                            let (health, failure_reason) =
2481                                crate::channels::github::check_webhook_health(port).await;
2482                            if health != webhook_healthy {
2483                                webhook_healthy = health;
2484                                if webhook_healthy {
2485                                    tracing::info!(port, orch_webhook_up = true, "webhook health restored");
2486                                } else {
2487                                    tracing::warn!(
2488                                        port,
2489                                        orch_webhook_up = false,
2490                                        reason = failure_reason.as_deref().unwrap_or("unknown"),
2491                                        "webhook health check failed"
2492                                    );
2493                                }
2494                            }
2495                            // Persist updated status.
2496                            {
2497                                let to_save = {
2498                                    let mut s = webhook_status.lock().await;
2499                                    s.healthy = health;
2500                                    s.last_check_utc = Some(chrono::Utc::now());
2501                                    if health {
2502                                        s.last_failure_reason = None;
2503                                    } else if failure_reason.is_some() {
2504                                        s.last_failure_reason = failure_reason;
2505                                    }
2506                                    s.clone()
2507                                };
2508                                to_save.save().await;
2509                            }
2510                        }
2511                        last_webhook_health_check = std::time::Instant::now();
2512                    }
2513                }
2514
2515                // Periodic upgrade check — notify channels when a newer release is available.
2516                if should_run_upgrade_check(last_upgrade_check, config.upgrade_check_interval) {
2517                    if let Some(store) = project_engines.first().map(|e| e.store.clone()) {
2518                        let channels = channel_registry.clone();
2519                        let current = env!("ORCH_VERSION").to_string();
2520                        let check_interval =
2521                            std::time::Duration::from_secs(config.upgrade_check_interval);
2522                        tokio::spawn(async move {
2523                            check_and_notify_upgrade(&store, &channels, &current, check_interval)
2524                                .await;
2525                        });
2526                    }
2527                    last_upgrade_check = Some(std::time::Instant::now());
2528                }
2529
2530                // Update watchdog timestamp + log tick duration.
2531                let tick_elapsed = tick_start.elapsed();
2532                if tick_elapsed.as_secs() > 30 {
2533                    tracing::warn!(elapsed_ms = tick_elapsed.as_millis() as u64, "slow tick");
2534                } else {
2535                    tracing::debug!(elapsed_ms = tick_elapsed.as_millis() as u64, "tick complete");
2536                }
2537                last_tick_epoch.store(
2538                    std::time::SystemTime::now()
2539                        .duration_since(std::time::UNIX_EPOCH)
2540                        .unwrap_or_default()
2541                        .as_secs(),
2542                    std::sync::atomic::Ordering::Relaxed,
2543                );
2544            }
2545            // Webhook events trigger an immediate tick (bypass polling interval)
2546            _ = webhook_notify.notified() => {
2547                if let Some(remaining) = GhHttp::is_rate_limited() {
2548                    tracing::warn!(
2549                        remaining_secs = remaining.as_secs(),
2550                        "GitHub API rate-limited, skipping webhook-triggered tick"
2551                    );
2552                } else {
2553                    tracing::info!("webhook event triggered immediate tick");
2554
2555                    let mut router_guard = router.write().await;
2556                    let mut routing_quota = tick::RoutingTickQuota::new(
2557                        crate::engine::router::config::max_tasks_per_routing_tick(),
2558                    );
2559                    for engine in &project_engines {
2560                        let repo = engine.repo.clone();
2561                        let project_jobs_path = engine.project_dir.join(".orch.yml");
2562                        REPO_CONTEXT.scope(repo, async {
2563                            if let Err(e) = tick::tick(
2564                                &engine.backend,
2565                                &tmux,
2566                                &engine.repo,
2567                                &engine.runner,
2568                                &capture_for_tick,
2569                                &semaphore,
2570                                &config,
2571                                &project_jobs_path,
2572                                &mut router_guard,
2573                                &engine.task_manager,
2574                                &weight_tx,
2575                                &dispatching,
2576                                &engine.store,
2577                                Some(&transport),
2578                                &active_repos,
2579                                &auto_merge_in_flight,
2580                                &mut routing_quota,
2581                            ).await {
2582                                tracing::error!(repo = %engine.repo, ?e, "webhook-triggered tick failed");
2583                            }
2584                        }).await;
2585                    }
2586                    drop(router_guard);
2587
2588                // Drain pending weight signals after webhook-triggered tick processing.
2589                while let Ok(signal) = weight_rx.try_recv() {
2590                    let mut rw = router.write().await;
2591                    match signal {
2592                        WeightSignal::RateLimited { ref agent } => {
2593                            rw.record_rate_limit(agent);
2594                        }
2595                        WeightSignal::Success { ref agent } => {
2596                            rw.record_success(agent);
2597                        }
2598                        WeightSignal::Rerouted | WeightSignal::Blocked | WeightSignal::None => {}
2599                    }
2600                }
2601
2602                // Also attempt to run an immediate sync (ingest/cleanup) in the
2603                // background so webhook-driven events (e.g. new issues) are
2604                // picked up without waiting for the periodic sync interval.
2605                // Reuse the same sync_in_progress guard used by the periodic
2606                // branch to avoid concurrent syncs.
2607                if sync_in_progress.compare_exchange(
2608                    false,
2609                    true,
2610                    std::sync::atomic::Ordering::AcqRel,
2611                    std::sync::atomic::Ordering::Relaxed,
2612                ).is_ok() {
2613                    // Clone everything sync_tick needs — it runs in a spawned task.
2614                    let sync_engines: Vec<_> = project_engines.iter().map(|e| {
2615                        (e.backend.clone(), e.repo.clone(), e.task_manager.clone(), e.store.clone())
2616                    }).collect();
2617                    let sync_tmux = Arc::clone(&tmux);
2618                    let sync_config = config.clone();
2619                    let sync_router = Arc::clone(&router);
2620                    let sync_dispatching = Arc::clone(&dispatching);
2621                    let sync_auto_merge = Arc::clone(&auto_merge_in_flight);
2622                    let sync_guard = Arc::clone(&sync_in_progress);
2623                    tokio::spawn(async move {
2624                        let sync_start = std::time::Instant::now();
2625                        for (backend, repo, task_manager, store) in &sync_engines {
2626                            let repo = repo.clone();
2627                            REPO_CONTEXT.scope(repo.clone(), async {
2628                                if let Err(e) = sync::sync_tick(
2629                                    backend, &sync_tmux, &repo, &sync_config,
2630                                    &sync_router, task_manager, store,
2631                                    &sync_dispatching, &sync_auto_merge,
2632                                ).await {
2633                                    tracing::error!(repo = %repo, ?e, "webhook immediate sync tick failed for project");
2634                                }
2635                            }).await;
2636                        }
2637                        // Emit degraded-agents metric/log once per sync cycle.
2638                        if let Some((_, _, _, store)) = sync_engines.first() {
2639                            let (available_agents, config) = {
2640                                let r = sync_router.read().await;
2641                                (r.available_agents.clone(), r.config.clone())
2642                            };
2643                            sync::emit_degraded_agents_if_needed(
2644                                &available_agents,
2645                                &config,
2646                                Some(store),
2647                            ).await;
2648                        }
2649                        let elapsed = sync_start.elapsed();
2650                        tracing::info!(elapsed_ms = elapsed.as_millis() as u64, "webhook immediate sync tick complete");
2651                        sync_guard.store(false, std::sync::atomic::Ordering::Release);
2652                    });
2653                    // Record schedule time so periodic sync doesn't immediately re-run.
2654                    last_sync = std::time::Instant::now();
2655                } else {
2656                    tracing::debug!("sync tick still in progress, skipping webhook-triggered immediate sync");
2657                }
2658                }
2659
2660                // Also reset the interval so we don't get a redundant tick right after
2661                interval.reset();
2662            }
2663            result = config_rx.recv() => {
2664                // A notification only arrives for a file that parsed, so there is no
2665                // broken-config case to guard against here (see `config::reload_file`).
2666                let reload = match result {
2667                    Ok(path) => {
2668                        tracing::info!(path = %path.display(), "config file changed, reloading");
2669                        true
2670                    }
2671                    Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
2672                        tracing::warn!(missed = n, "config change receiver lagged, reloading");
2673                        true
2674                    }
2675                    Err(tokio::sync::broadcast::error::RecvError::Closed) => {
2676                        tracing::warn!("config change channel closed");
2677                        false
2678                    }
2679                };
2680
2681                if reload {
2682                    // Reload engine config
2683                    let new_config = EngineConfig::from_config();
2684                    log_engine_config_changes(&config, &new_config);
2685
2686                    let new_restart_only = restart_only_snapshot();
2687                    log_restart_only_changes(&restart_only, &new_restart_only);
2688                    restart_only = new_restart_only;
2689
2690                    let tick_changed = new_config.tick_interval != config.tick_interval;
2691                    config = new_config;
2692
2693                    // Reset tick interval if it changed
2694                    if tick_changed {
2695                        interval = tokio::time::interval(config.tick_interval);
2696                        tracing::info!(tick = ?config.tick_interval, "tick interval updated");
2697                    }
2698
2699                    // Resize the dispatch pool. A shrink only takes the permits that are
2700                    // free right now; the rest are taken as running tasks finish.
2701                    if parallel_in_force != config.max_parallel {
2702                        parallel_in_force = resize_dispatch_pool(
2703                            &semaphore,
2704                            parallel_in_force,
2705                            config.max_parallel,
2706                        );
2707                        tracing::info!(
2708                            in_force = parallel_in_force,
2709                            target = config.max_parallel,
2710                            "dispatch pool resized"
2711                        );
2712                    }
2713
2714                    // Reload router config (async-safe: use reload_async to avoid blocking the runtime)
2715                    {
2716                        let mut router_guard = router.write().await;
2717                        router_guard.reload_async().await;
2718                    }
2719
2720                    // Pick up projects added to `projects:`
2721                    sync_project_engines(
2722                        &mut project_engines,
2723                        &mut active_repos,
2724                        &event_bus,
2725                        &tmux,
2726                        &capture_for_tick,
2727                        &semaphore,
2728                        &weight_tx,
2729                        &router,
2730                        &dispatching,
2731                    ).await;
2732
2733                    tracing::info!(
2734                        tick = ?config.tick_interval,
2735                        sync = ?config.sync_interval,
2736                        parallel = config.max_parallel,
2737                        projects = project_engines.len(),
2738                        "config reloaded"
2739                    );
2740                }
2741            }
2742            signal_name = async {
2743                tokio::select! {
2744                    _ = tokio::signal::ctrl_c() => "SIGINT",
2745                    _ = sigterm.recv() => "SIGTERM",
2746                    _ = sighup.recv() => "SIGHUP",
2747                }
2748            } => {
2749                tracing::info!(signal = signal_name, "beginning graceful shutdown");
2750
2751                // Wrap the entire shutdown sequence in a timeout to enforce the configured limit.
2752                // This prevents indefinite blocking if status updates or tmux operations hang.
2753                let shutdown_result = tokio::time::timeout(
2754                    config.graceful_shutdown_timeout,
2755                    async {
2756                        // Reset in_progress tasks to routed so they re-dispatch after restart.
2757                        // Also reset in_review tasks to needs_review — their review agent
2758                        // tmux sessions will be killed when the process exits.
2759                        let mut reset_count = 0u32;
2760                        let mut review_reset_count = 0u32;
2761                        for engine in &project_engines {
2762                            if let Ok(tasks) = engine.task_manager.list_external_by_status(Status::InProgress).await {
2763                                for task in &tasks {
2764                                    if let Err(e) = engine.task_manager.update_task_status(&task.id, Status::Routed).await {
2765                                        tracing::warn!(task_id = task.id.0, ?e, "failed to reset task on shutdown");
2766                                    } else {
2767                                        if let Ok(Some(store_id)) =
2768                                            engine.store.resolve_task_id(&engine.repo, &task.id.0).await
2769                                        {
2770                                            if let Err(e) = engine
2771                                                .store
2772                                                .finalize_incomplete_runs(
2773                                                    store_id,
2774                                                    "aborted",
2775                                                    "graceful shutdown: reset in_progress task to routed",
2776                                                )
2777                                                .await
2778                                            {
2779                                                tracing::warn!(
2780                                                    task_id = task.id.0,
2781                                                    ?e,
2782                                                    "failed to finalize incomplete runs on shutdown"
2783                                                );
2784                                            }
2785                                        }
2786                                        reset_count += 1;
2787                                    }
2788                                }
2789                            }
2790                            // Also reset internal in_progress tasks
2791                            if let Ok(tasks) = engine.store.list_internal_by_status(&engine.repo, crate::store::TaskStatus::InProgress).await {
2792                                for task in &tasks {
2793                                    let task_id = format!("internal:{}", task.id);
2794                                    if let Err(e) = engine.task_manager.update_task_status(
2795                                        &crate::backends::ExternalId(task_id.clone()),
2796                                        Status::Routed,
2797                                    ).await {
2798                                        tracing::warn!(task_id, ?e, "failed to reset internal task on shutdown");
2799                                    } else {
2800                                        if let Err(e) = engine
2801                                            .store
2802                                            .finalize_incomplete_runs(
2803                                                task.id,
2804                                                "aborted",
2805                                                "graceful shutdown: reset in_progress task to routed",
2806                                            )
2807                                            .await
2808                                        {
2809                                            tracing::warn!(
2810                                                task_id,
2811                                                ?e,
2812                                                "failed to finalize incomplete internal runs on shutdown"
2813                                            );
2814                                        }
2815                                        reset_count += 1;
2816                                    }
2817                                }
2818                            }
2819                            // Reset in_review tasks — review agent sessions die on shutdown
2820                            if let Ok(tasks) = engine.task_manager.list_external_by_status(Status::InReview).await {
2821                                for task in &tasks {
2822                                    if let Err(e) = engine.task_manager.update_task_status(&task.id, Status::NeedsReview).await {
2823                                        tracing::warn!(task_id = task.id.0, ?e, "failed to reset in_review task on shutdown");
2824                                    } else {
2825                                        set_review_session_expected(&engine.store, &engine.repo, &task.id.0, false).await;
2826                                        if let Ok(Some(store_id)) =
2827                                            engine.store.resolve_task_id(&engine.repo, &task.id.0).await
2828                                        {
2829                                            if let Err(e) = engine
2830                                                .store
2831                                                .finalize_incomplete_runs(
2832                                                    store_id,
2833                                                    "aborted",
2834                                                    "graceful shutdown: reset in_review task to needs_review",
2835                                                )
2836                                                .await
2837                                            {
2838                                                tracing::warn!(
2839                                                    task_id = task.id.0,
2840                                                    ?e,
2841                                                    "failed to finalize incomplete review runs on shutdown"
2842                                                );
2843                                            }
2844                                        }
2845                                        review_reset_count += 1;
2846                                    }
2847                                }
2848                            }
2849                            // Also reset internal in_review tasks
2850                            if let Ok(tasks) = engine.store.list_internal_by_status(&engine.repo, crate::store::TaskStatus::InReview).await {
2851                                for task in &tasks {
2852                                    let task_id = format!("internal:{}", task.id);
2853                                    if let Err(e) = engine.task_manager.update_task_status(
2854                                        &crate::backends::ExternalId(task_id.clone()),
2855                                        Status::NeedsReview,
2856                                    ).await {
2857                                        tracing::warn!(task_id, ?e, "failed to reset internal in_review task on shutdown");
2858                                    } else {
2859                                        set_review_session_expected(&engine.store, &engine.repo, &task_id, false).await;
2860                                        if let Err(e) = engine
2861                                            .store
2862                                            .finalize_incomplete_runs(
2863                                                task.id,
2864                                                "aborted",
2865                                                "graceful shutdown: reset in_review task to needs_review",
2866                                            )
2867                                            .await
2868                                        {
2869                                            tracing::warn!(
2870                                                task_id,
2871                                                ?e,
2872                                                "failed to finalize incomplete internal review runs on shutdown"
2873                                            );
2874                                        }
2875                                        review_reset_count += 1;
2876                                    }
2877                                }
2878                            }
2879                        }
2880                        if reset_count > 0 {
2881                            tracing::info!(reset_count, "reset in_progress tasks to routed for re-dispatch");
2882                        }
2883                        if review_reset_count > 0 {
2884                            tracing::info!(review_reset_count, "reset in_review tasks to needs_review for re-dispatch");
2885                        }
2886
2887                        // Kill all orch-managed tmux sessions so stale sessions don't
2888                        // block dispatch after restart (session_exists check).
2889                        let mut killed_sessions = Vec::new();
2890                        if let Ok(sessions) = tmux.list_sessions().await {
2891                            for session in &sessions {
2892                                if session.name.starts_with("orch-") {
2893                                    if let Err(e) = tmux.kill_session(&session.name).await {
2894                                        tracing::warn!(session = %session.name, error = %e, "failed to kill tmux session");
2895                                    } else {
2896                                        killed_sessions.push(session.name.clone());
2897                                    }
2898                                }
2899                            }
2900                            if !killed_sessions.is_empty() {
2901                                tracing::info!(killed = killed_sessions.len(), "killing orch tmux sessions on shutdown");
2902                            }
2903                        }
2904
2905                        // Wait for sessions to actually die before exiting.
2906                        // Prevents race where process exits before tmux finishes cleanup.
2907                        if !killed_sessions.is_empty() {
2908                            let still_alive = tmux
2909                                .wait_for_sessions_dead(
2910                                    &killed_sessions,
2911                                    std::time::Duration::from_millis(100),
2912                                    std::time::Duration::from_secs(5),
2913                                )
2914                                .await;
2915                            if still_alive > 0 {
2916                                tracing::warn!(
2917                                    still_alive,
2918                                    "some tmux sessions did not terminate cleanly - they will be cleaned up on startup"
2919                                );
2920                            }
2921                            tracing::info!(killed = killed_sessions.len() - still_alive, "confirmed tmux sessions terminated");
2922                        }
2923                    },
2924                ).await;
2925
2926                if shutdown_result.is_err() {
2927                    tracing::error!(
2928                        timeout_secs = config.graceful_shutdown_timeout.as_secs(),
2929                        "graceful shutdown timed out, forcing exit"
2930                    );
2931                }
2932
2933                break;
2934            }
2935        }
2936    }
2937
2938    // Clean up event bus port file and service version file
2939    events::cleanup_port_file().await;
2940    crate::cluster::local::cleanup().await;
2941    events::cleanup_version_file();
2942
2943    // transport and channels drop here at end of scope
2944    let _ = transport;
2945    tracing::info!("orch engine stopped");
2946    Ok(())
2947}
2948
2949#[cfg(test)]
2950mod tests {
2951    use super::*;
2952
2953    #[test]
2954    fn growing_max_parallel_lets_more_tasks_run_without_a_restart() {
2955        let semaphore = Arc::new(Semaphore::new(4));
2956        let held: Vec<_> = (0..4)
2957            .map(|_| semaphore.clone().try_acquire_owned().unwrap())
2958            .collect();
2959        assert!(
2960            semaphore.clone().try_acquire_owned().is_err(),
2961            "pool is full"
2962        );
2963
2964        let in_force = resize_dispatch_pool(&semaphore, 4, 8);
2965        assert_eq!(in_force, 8);
2966
2967        let grown: Vec<_> = (0..4)
2968            .map(|_| semaphore.clone().try_acquire_owned().unwrap())
2969            .collect();
2970        assert_eq!(held.len() + grown.len(), 8, "8 tasks run at once");
2971        assert!(
2972            semaphore.clone().try_acquire_owned().is_err(),
2973            "pool is full"
2974        );
2975    }
2976
2977    #[test]
2978    fn shrinking_max_parallel_never_cancels_a_running_task() {
2979        let semaphore = Arc::new(Semaphore::new(8));
2980        let mut held: Vec<_> = (0..8)
2981            .map(|_| semaphore.clone().try_acquire_owned().unwrap())
2982            .collect();
2983
2984        // Nothing is free, so all 8 tasks keep their permits and keep running.
2985        let mut in_force = resize_dispatch_pool(&semaphore, 8, 4);
2986        assert_eq!(in_force, 8);
2987        assert_eq!(held.len(), 8);
2988
2989        // Three finish. Their permits are taken back instead of handed out again.
2990        held.truncate(5);
2991        in_force = resize_dispatch_pool(&semaphore, in_force, 4);
2992        assert_eq!(in_force, 5);
2993        assert!(semaphore.clone().try_acquire_owned().is_err());
2994
2995        // One more finishes and the pool settles at the new limit.
2996        held.truncate(4);
2997        in_force = resize_dispatch_pool(&semaphore, in_force, 4);
2998        assert_eq!(in_force, 4);
2999        assert!(semaphore.clone().try_acquire_owned().is_err());
3000
3001        // A slot freed after that is handed out, since 4 is now the real limit.
3002        held.truncate(3);
3003        assert!(semaphore.clone().try_acquire_owned().is_ok());
3004    }
3005
3006    #[test]
3007    fn a_project_added_to_the_config_is_picked_up_and_running_ones_are_not_respawned() {
3008        let configured = vec![
3009            (
3010                "owner/one".to_string(),
3011                std::path::PathBuf::from("/srv/one"),
3012            ),
3013            (
3014                "owner/two".to_string(),
3015                std::path::PathBuf::from("/srv/two"),
3016            ),
3017        ];
3018        let active: std::collections::HashSet<String> =
3019            ["owner/one".to_string()].into_iter().collect();
3020
3021        let (added, removed) = diff_configured_projects(&configured, &active);
3022        assert_eq!(
3023            added,
3024            vec![(
3025                "owner/two".to_string(),
3026                std::path::PathBuf::from("/srv/two")
3027            )],
3028            "only the new project is started, so owner/one keeps its single set of subscribers"
3029        );
3030        assert!(removed.is_empty());
3031    }
3032
3033    #[test]
3034    fn a_project_dropped_from_the_config_is_reported_and_not_restarted() {
3035        let configured = vec![(
3036            "owner/one".to_string(),
3037            std::path::PathBuf::from("/srv/one"),
3038        )];
3039        let active: std::collections::HashSet<String> =
3040            ["owner/one".to_string(), "owner/gone".to_string()]
3041                .into_iter()
3042                .collect();
3043
3044        let (added, removed) = diff_configured_projects(&configured, &active);
3045        assert!(added.is_empty(), "nothing new to start");
3046        assert_eq!(removed, vec!["owner/gone".to_string()]);
3047    }
3048
3049    #[test]
3050    fn an_unchanged_project_list_starts_nothing() {
3051        let configured = vec![(
3052            "owner/one".to_string(),
3053            std::path::PathBuf::from("/srv/one"),
3054        )];
3055        let active: std::collections::HashSet<String> =
3056            ["owner/one".to_string()].into_iter().collect();
3057
3058        assert_eq!(
3059            diff_configured_projects(&configured, &active),
3060            (vec![], vec![])
3061        );
3062    }
3063
3064    #[test]
3065    fn restart_only_keys_are_the_settings_read_once_at_startup() {
3066        // Guards the docs table: every key listed here is documented as needing
3067        // `orch service restart`, and nothing reloadable may be added to it.
3068        assert_eq!(RESTART_ONLY_KEYS, ["channels", "webhook"]);
3069    }
3070
3071    #[test]
3072    fn a_changed_restart_only_key_is_reported_and_an_unchanged_one_is_not() {
3073        let before = vec![Some("bot_token: a\n".to_string()), None];
3074
3075        assert!(
3076            log_restart_only_changes(&before, &before.clone()).is_empty(),
3077            "a reload that did not touch these keys must stay quiet"
3078        );
3079
3080        // An edited token and a newly added webhook block are both changes
3081        let after = vec![
3082            Some("bot_token: b\n".to_string()),
3083            Some("port: 8080\n".to_string()),
3084        ];
3085        assert_eq!(
3086            log_restart_only_changes(&before, &after),
3087            ["channels", "webhook"]
3088        );
3089
3090        // Only the edited key is reported when the other is untouched
3091        let token_only = vec![Some("bot_token: b\n".to_string()), None];
3092        assert_eq!(log_restart_only_changes(&before, &token_only), ["channels"]);
3093    }
3094
3095    #[test]
3096    fn resize_dispatch_pool_is_a_noop_at_the_current_limit() {
3097        let semaphore = Arc::new(Semaphore::new(4));
3098        assert_eq!(resize_dispatch_pool(&semaphore, 4, 4), 4);
3099        assert_eq!(semaphore.available_permits(), 4);
3100    }
3101
3102    #[test]
3103    fn engine_config_defaults() {
3104        let config = EngineConfig::default();
3105        assert_eq!(config.tick_interval, std::time::Duration::from_secs(10));
3106        assert_eq!(config.sync_interval, std::time::Duration::from_secs(45));
3107        assert_eq!(config.max_parallel, 4);
3108        assert_eq!(config.stuck_timeout, 1800);
3109        assert_eq!(config.no_session_stuck_timeout, 600);
3110        assert!(
3111            config.no_session_stuck_timeout < config.stuck_timeout,
3112            "no_session_stuck_timeout must be shorter than stuck_timeout"
3113        );
3114        assert_eq!(
3115            config.graceful_shutdown_timeout,
3116            std::time::Duration::from_secs(600)
3117        );
3118        assert_eq!(config.upgrade_check_interval, 3600);
3119    }
3120
3121    #[test]
3122    fn engine_config_from_config_uses_defaults_when_no_config() {
3123        let config = EngineConfig::from_config();
3124        assert_eq!(config.tick_interval, std::time::Duration::from_secs(10));
3125        assert_eq!(config.sync_interval, std::time::Duration::from_secs(45));
3126        // max_parallel may be overridden by user config; just check it's reasonable
3127        assert!(config.max_parallel >= 1 && config.max_parallel <= 64);
3128        assert_eq!(config.stuck_timeout, 1800);
3129        assert_eq!(config.no_session_stuck_timeout, 600);
3130        assert_eq!(
3131            config.graceful_shutdown_timeout,
3132            std::time::Duration::from_secs(600)
3133        );
3134        assert_eq!(config.upgrade_check_interval, 3600);
3135    }
3136
3137    #[test]
3138    fn engine_config_no_session_timeout_is_under_15_minutes() {
3139        let config = EngineConfig::default();
3140        assert!(
3141            config.no_session_stuck_timeout <= 900,
3142            "no_session_stuck_timeout ({}) exceeds 15 minutes",
3143            config.no_session_stuck_timeout
3144        );
3145    }
3146
3147    #[test]
3148    fn upgrade_check_runs_immediately_when_never_checked() {
3149        assert!(should_run_upgrade_check(None, 3600));
3150    }
3151
3152    #[test]
3153    fn upgrade_check_waits_for_interval_after_first_run() {
3154        assert!(!should_run_upgrade_check(
3155            Some(std::time::Instant::now()),
3156            3600
3157        ));
3158    }
3159
3160    #[test]
3161    fn upgrade_check_can_be_disabled() {
3162        assert!(!should_run_upgrade_check(None, 0));
3163    }
3164
3165    #[test]
3166    fn configured_agents_parses_yaml_block_array() {
3167        // config::get returns serde_norway::to_string for arrays, which is YAML block format
3168        let yaml_str = "- claude\n- codex\n- opencode\n- olm\n";
3169        let parsed: Vec<String> = serde_norway::from_str(yaml_str).unwrap();
3170        assert_eq!(parsed, vec!["claude", "codex", "opencode", "olm"]);
3171    }
3172
3173    #[test]
3174    fn configured_agents_parses_json_inline_array() {
3175        let json_str = r#"["claude","codex","olm"]"#;
3176        let parsed: Vec<String> = serde_json::from_str(json_str).unwrap();
3177        assert_eq!(parsed, vec!["claude", "codex", "olm"]);
3178    }
3179
3180    #[test]
3181    fn configured_agents_fallback_returns_default_agents() {
3182        // When no config is present, configured_agents() returns DEFAULT_AGENTS
3183        let defaults = configured_agents();
3184        assert!(defaults.contains(&"claude".to_string()));
3185        assert!(defaults.contains(&"codex".to_string()));
3186        assert!(defaults.contains(&"opencode".to_string()));
3187    }
3188
3189    /// Integration test that requires a fully working tmux environment with
3190    /// reliable capture-pane timing. Runs locally but not in CI (GitHub Actions
3191    /// has tmux installed but capture-pane timing is unreliable).
3192    ///
3193    /// DO NOT REMOVE #[ignore] — this test was un-ignored in PR #608 which
3194    /// broke CI. The `tmux -V` skip check is insufficient because tmux CAN
3195    /// create sessions on CI runners, but capture-pane output is empty or
3196    /// delayed, causing the assertion to fail after a 10s timeout.
3197    #[tokio::test]
3198    #[ignore]
3199    async fn integration_channel_to_tmux_to_capture() {
3200        use crate::channels::capture::CaptureService;
3201        use crate::channels::transport::Transport;
3202        use crate::tmux::SessionGuard;
3203        use std::sync::Arc;
3204
3205        // Create transport and capture service
3206        let transport = Arc::new(Transport::new());
3207        let capture = Arc::new(CaptureService::new(transport.clone()));
3208
3209        let task_id = "testtask1".to_string();
3210        let session_name = format!("orch-test-{}", task_id);
3211
3212        // Start a detached tmux session
3213        let _ = tokio::process::Command::new("tmux")
3214            .args(["new-session", "-d", "-s", &session_name])
3215            .output()
3216            .await
3217            .expect("failed to start tmux session");
3218
3219        // Guard ensures session cleanup even on panic
3220        let _guard = SessionGuard(session_name.clone());
3221
3222        // Register session with capture service and transport binding
3223        capture
3224            .register_session("owner/repo", &task_id, &session_name)
3225            .await;
3226        transport
3227            .bind(
3228                "owner/repo",
3229                &task_id,
3230                &session_name,
3231                "telegram",
3232                "12345",
3233                None,
3234            )
3235            .await;
3236
3237        // Spawn capture.run() which exits when session is unregistered
3238        let capture_clone = capture.clone();
3239        let capture_handle = tokio::spawn(async move { capture_clone.run().await });
3240
3241        // Subscribe to transport output for this task
3242        let mut rx = transport
3243            .subscribe("owner/repo", &task_id)
3244            .await
3245            .expect("no subscription");
3246
3247        // Send a message into the tmux session via tmux send-keys (simulate channel input)
3248        let send = tokio::process::Command::new("tmux")
3249            .args([
3250                "send-keys",
3251                "-t",
3252                &session_name,
3253                "echo from-channel",
3254                "Enter",
3255            ])
3256            .output()
3257            .await
3258            .expect("failed to send keys");
3259        assert!(send.status.success());
3260
3261        // Wait for an output chunk from capture -> transport
3262        let mut got = false;
3263        for _ in 0..20 {
3264            if let Ok(Ok(c)) =
3265                tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await
3266            {
3267                if c.content.contains("from-channel") {
3268                    got = true;
3269                    break;
3270                }
3271            }
3272        }
3273
3274        // Clean up: unregister session (tmux kill handled by SessionGuard on drop)
3275        capture.unregister_session("owner/repo", &task_id).await;
3276
3277        // Wait for capture.run to finish
3278        let _ = capture_handle.await;
3279
3280        assert!(got, "did not observe tmux output via capture/transport");
3281    }
3282
3283    // ── read_project_channel_config tests ─────────────────────────────────
3284
3285    #[test]
3286    fn read_project_channel_config_returns_defaults_when_no_file() {
3287        let dir = tempfile::tempdir().expect("tempdir");
3288        let cfg = read_project_channel_config(dir.path());
3289        assert!(cfg.telegram_topic_id.is_none());
3290        assert!(cfg.discord_channel_id.is_none());
3291    }
3292
3293    #[test]
3294    fn read_project_channel_config_parses_telegram_and_discord() {
3295        let dir = tempfile::tempdir().expect("tempdir");
3296        let orch_yml = dir.path().join(".orch.yml");
3297        std::fs::write(
3298            &orch_yml,
3299            "channels:\n  telegram:\n    topic_id: \"42\"\n  discord:\n    channel_id: \"9999\"\n",
3300        )
3301        .expect("write .orch.yml");
3302        let cfg = read_project_channel_config(dir.path());
3303        assert_eq!(cfg.telegram_topic_id.as_deref(), Some("42"));
3304        assert_eq!(cfg.discord_channel_id.as_deref(), Some("9999"));
3305    }
3306
3307    #[test]
3308    fn read_project_channel_config_handles_partial_channels() {
3309        let dir = tempfile::tempdir().expect("tempdir");
3310        let orch_yml = dir.path().join(".orch.yml");
3311        std::fs::write(&orch_yml, "channels:\n  telegram:\n    topic_id: \"7\"\n")
3312            .expect("write .orch.yml");
3313        let cfg = read_project_channel_config(dir.path());
3314        assert_eq!(cfg.telegram_topic_id.as_deref(), Some("7"));
3315        assert!(cfg.discord_channel_id.is_none());
3316    }
3317
3318    #[test]
3319    fn read_project_channel_config_handles_malformed_yaml() {
3320        let dir = tempfile::tempdir().expect("tempdir");
3321        let orch_yml = dir.path().join(".orch.yml");
3322        std::fs::write(&orch_yml, "channels: [\nbad yaml").expect("write .orch.yml");
3323        let cfg = read_project_channel_config(dir.path());
3324        // Should return defaults (not panic) and emit a warn! log with the parse error.
3325        assert!(cfg.telegram_topic_id.is_none());
3326        assert!(cfg.discord_channel_id.is_none());
3327    }
3328
3329    #[test]
3330    #[cfg(unix)]
3331    fn read_project_channel_config_handles_unreadable_file() {
3332        let dir = tempfile::tempdir().expect("tempdir");
3333        // A directory fails to read for every user, root included, where chmod 000 does not
3334        std::fs::create_dir(dir.path().join(".orch.yml")).expect("create .orch.yml dir");
3335        let cfg = read_project_channel_config(dir.path());
3336        // Should return defaults (not panic) and emit a warn! log with the read error.
3337        assert!(cfg.telegram_topic_id.is_none());
3338        assert!(cfg.discord_channel_id.is_none());
3339    }
3340}