Skip to main content

orch/engine/
mod.rs

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