Skip to main content

orch/engine/
mod.rs

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