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