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