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