Skip to main content

orch/engine/
mod.rs

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