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