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