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