"""ColonyOS Daemon — fully autonomous 24/7 engineering agent.

Unifies Slack listening, GitHub Issue polling, CEO idle-fill scheduling,
cleanup scheduling, and priority queue execution into a single supervised
long-running process.  Designed for systemd deployment on a VM.

Architecture:
    Single process, multiple threads.  One pipeline runs at a time
    (sequential execution).  Shared state protected by ``threading.Lock``.
"""
from __future__ import annotations

import json
import logging
import os
import signal
import subprocess
import sys
import threading
import time
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
from pathlib import Path
from typing import Any

from colonyos.agent import (
    active_phase_controller_count as active_phase_controller_count,
    request_active_phase_cancel,
    set_heartbeat_path,
)
from colonyos.cancellation import cancellation_scope, request_cancel
from colonyos.config import ColonyConfig, load_config
from colonyos.maintenance import (
    build_ci_fix_queue_items,
    find_branches_with_failing_ci,
    format_branch_sync_report,
    pull_and_check_update,
    read_last_good_commit,
    record_last_good_commit,
    run_self_update,
    scan_diverged_branches,
    should_rollback,
)
from colonyos.daemon_state import (
    DaemonState as DaemonState,
    atomic_write_json,
    load_daemon_state,
    save_daemon_state,
)
from colonyos.models import (
    PreflightError,
    QueueItem,
    QueueItemStatus,
    QueueState,
    QueueStatus as QueueStatus,
    RunLog,
    RunStatus,
    compute_priority,
)
from colonyos.queue_runtime import (
    archive_terminal_queue_items,
    attach_demand_signal,
    find_related_history_items,
    find_similar_queue_item,
    notification_targets,
    pending_queue_snapshot,
    reprioritize_queue as reprioritize_queue,
    reprioritize_queue_item,
    select_next_pending_item,
)
from colonyos.runtime_lock import RepoRuntimeGuard, RuntimeBusyError
from colonyos.tui.monitor_protocol import encode_monitor_event as encode_monitor_event

from colonyos.daemon._ui import CombinedUI, DaemonError, DaemonMonitorEventUI
from colonyos.daemon._watchdog import WatchdogMixin
from colonyos.daemon._resilience import ResilienceMixin
from colonyos.daemon._helpers import HelpersMixin

# Backward-compatible alias (tests and legacy imports).
_CombinedUI = CombinedUI

logger = logging.getLogger(__name__)

# Sentinel file used as a single-instance lock
_PID_FILE = ".colonyos/daemon.pid"

# How often the main loop checks for work (seconds)
_MAIN_LOOP_INTERVAL = 5
_DAEMON_WATCH_ID = "daemon"


class Daemon(WatchdogMixin, ResilienceMixin, HelpersMixin):
    """Core daemon orchestrator.

    Parameters
    ----------
    repo_root:
        Absolute path to the repository root.
    config:
        Pre-loaded ColonyConfig (if None, loaded from repo_root).
    max_budget:
        CLI override for daily budget cap (overrides config).
    max_hours:
        Maximum wall-clock hours before daemon exits.
    dry_run:
        If True, do not execute pipelines — just log what would run.
    verbose:
        Enable verbose logging.
    """

    def __init__(
        self,
        repo_root: Path,
        config: ColonyConfig | None = None,
        *,
        max_budget: float | None = None,
        unlimited_budget: bool = False,
        max_hours: float | None = None,
        dry_run: bool = False,
        verbose: bool = False,
    ) -> None:
        self.repo_root = repo_root
        self.config = config or load_config(repo_root)
        self.daemon_config = self.config.daemon
        self.dry_run = dry_run
        self.verbose = verbose
        self._monitor_mode = os.environ.get("COLONYOS_DAEMON_MONITOR") == "1"
        self._agent_lock = threading.Lock()

        # CLI overrides
        self.daily_budget = (
            None
            if unlimited_budget
            else max_budget if max_budget is not None else self.daemon_config.daily_budget_usd
        )
        self.max_hours = max_hours

        # Shared mutable state — protected by _lock
        self._lock = threading.Lock()
        self._notification_thread_locks: dict[str, threading.Lock] = {}
        self._notification_thread_locks_guard = threading.Lock()
        self._state = load_daemon_state(repo_root)
        self._queue_state = self._load_or_create_queue()
        self._pipeline_running = False
        self._pipeline_started_at: float | None = None
        self._pipeline_stalled: bool = False
        self._current_running_item: QueueItem | None = None
        self._watchdog_thread: threading.Thread | None = None
        self._watchdog_suppress_count: int = 0

        # Shutdown coordination
        self._stop_event = threading.Event()
        self._started_at = time.monotonic()

        # Track last schedule times
        self._last_ceo_time: float = 0.0
        self._last_cleanup_time: float = 0.0
        self._last_heartbeat_time: float = 0.0
        self._last_github_poll_time: float = 0.0
        self._last_reprioritize_time: float = 0.0

        # Repo runtime guard
        self._runtime_guard: RepoRuntimeGuard | None = None

        # Outcome polling timestamp
        self._last_outcome_poll_time: float = 0.0
        self._last_pr_sync_time: float = 0.0
        self._last_digest_date: str | None = None

        # Budget alert flags (reset on daily budget reset)
        self._budget_80_alerted: bool = False
        self._budget_100_alerted: bool = False
        self._last_budget_incident_date: str | None = None

        self._slack_watch_state: Any | None = None
        self._slack_client: Any | None = None
        self._slack_client_ready = threading.Event()

        # Systemic failure tracking — recent error codes for pattern detection
        self._recent_failure_codes: list[str] = []

        # Maintenance cycle (task 5.0)
        self._startup_time: float = time.time()
        self._good_commit_recorded: bool = False
        self._last_branch_sync_post: float = 0.0

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def start(self) -> None:
        """Start the daemon — main entry point.

        Acquires PID lock, performs crash recovery, installs signal
        handlers, starts background threads, and enters the main
        scheduling loop.  Blocks until ``stop()`` is called or a
        signal is received.
        """
        self._acquire_pid_lock()
        with cancellation_scope(lambda _reason: self.stop()):
            try:
                self._recover_from_crash()
                self._check_startup_rollback()
                self._install_signal_handlers()
                self._state.daemon_started_at = datetime.now(timezone.utc).isoformat()
                self._persist_state()

                logger.info(
                    "ColonyOS daemon started (budget=%s, dry_run=%s)",
                    self._budget_cap_label(),
                    self.dry_run,
                )

                # Start embedded dashboard server
                self._start_dashboard_server()

                logger.info(
                    "Watchdog enabled: stall threshold=%ds",
                    self.daemon_config.watchdog_stall_seconds,
                )

                # Start background threads
                threads = self._start_threads()

                # Main scheduling loop
                try:
                    self._main_loop()
                except KeyboardInterrupt:
                    logger.warning("Daemon interrupted, shutting down immediately")

                # Wait for threads to finish
                self._stop_event.set()
                for t in threads:
                    t.join(timeout=10)

            finally:
                self._release_pid_lock()
                logger.info("ColonyOS daemon stopped")

    def stop(self) -> None:
        """Request graceful shutdown."""
        logger.info("Daemon shutdown requested")
        self._stop_event.set()

    def pause(self) -> None:
        """Pause the daemon — stop picking up new queue items."""
        with self._lock:
            self._state.paused = True
            self._persist_state()
        logger.info("Daemon paused via API")

    def resume(self) -> None:
        """Resume the daemon — start picking up queue items again."""
        with self._lock:
            self._state.paused = False
            self._persist_state()
        logger.info("Daemon resumed via API")

    def _start_dashboard_server(self) -> None:
        """Start the embedded web dashboard on a daemon thread.

        Uses uvicorn to serve the FastAPI app. The dashboard provides
        live daemon health via ``app.state.daemon_instance``. Errors
        during server startup or runtime are logged but never crash
        the daemon.
        """
        if not self.daemon_config.dashboard_enabled:
            logger.info("Dashboard disabled in config, skipping")
            return

        try:
            import uvicorn
            from colonyos.server import create_app
        except ImportError:
            logger.info(
                "Dashboard dependencies not installed (pip install colonyos[ui]), skipping"
            )
            return

        port = self.daemon_config.dashboard_port

        write_enabled = self.daemon_config.dashboard_write_enabled

        def _serve() -> None:
            try:
                app, auth_token = create_app(
                    self.repo_root, write_enabled=write_enabled
                )
                app.state.daemon_instance = self
                masked = auth_token[-4:] if len(auth_token) >= 4 else "****"
                logger.info(
                    "Dashboard started on http://127.0.0.1:%d (write=%s, token: ...%s)",
                    port,
                    write_enabled,
                    masked,
                )
                config = uvicorn.Config(
                    app,
                    host="127.0.0.1",
                    port=port,
                    log_level="warning",
                )
                server = uvicorn.Server(config)
                server.run()
            except Exception:
                logger.warning("Dashboard server failed", exc_info=True)

        thread = threading.Thread(target=_serve, daemon=True, name="dashboard-server")
        thread.start()

    def _check_time_exceeded(self) -> bool:
        if not self.max_hours:
            return False
        elapsed = (time.monotonic() - self._started_at) / 3600
        return elapsed >= self.max_hours

    def _check_budget_exceeded(self) -> bool:
        allowed, _remaining = self._state.check_daily_budget(self.daily_budget)
        return not allowed

    def _check_daily_budget_exceeded(self) -> bool:
        if self.config.slack.daily_budget_usd is None:
            return False
        today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        if self._state.daily_reset_date != today:
            return False
        return self._state.daily_spend_usd >= self.config.slack.daily_budget_usd

    # ------------------------------------------------------------------
    # Main loop
    # ------------------------------------------------------------------

    def _main_loop(self) -> None:
        """Core scheduling loop — runs until stop is requested."""
        while not self._stop_event.is_set():
            # Check max_hours
            if self.max_hours:
                elapsed = (time.monotonic() - self._started_at) / 3600
                if elapsed >= self.max_hours:
                    logger.info(
                        "Max hours (%.1f) reached, shutting down", self.max_hours
                    )
                    break

            try:
                self._tick()
            except Exception:
                logger.exception("Error in daemon main loop tick")

            self._stop_event.wait(timeout=_MAIN_LOOP_INTERVAL)

    def _tick(self) -> None:
        """Single iteration of the main scheduling loop."""
        now = time.monotonic()
        executed_item = False

        # 0. Record good commit after uptime threshold (task 5.7)
        self._maybe_record_uptime_good_commit()

        # 1. Try to execute next queue item
        if not self._pipeline_running:
            executed_item = self._try_execute_next()

        # 2. GitHub polling
        poll_interval = self.daemon_config.github_poll_interval_seconds
        if now - self._last_github_poll_time >= poll_interval:
            self._poll_github_issues()
            self._last_github_poll_time = now

        # 3. CEO idle-fill (only when healthy, queue is empty, and no pipeline running)
        ceo_cooldown = self.daemon_config.ceo_cooldown_minutes * 60
        if (
            not executed_item
            and not self._pipeline_running
            and not self._state.is_circuit_breaker_active()
            and not self._state.paused
            and self._pending_count() == 0
            and now - self._last_ceo_time >= ceo_cooldown
        ):
            self._schedule_ceo()
            self._last_ceo_time = now

        # 4. Cleanup scheduling (skip when degraded)
        cleanup_interval = self.daemon_config.cleanup_interval_hours * 3600
        if (
            not self._state.is_circuit_breaker_active()
            and not self._state.paused
            and now - self._last_cleanup_time >= cleanup_interval
        ):
            self._schedule_cleanup()
            self._last_cleanup_time = now

        # 4.5 Active reprioritization
        if now - self._last_reprioritize_time >= 900:
            self._reprioritize_queue()
            self._last_reprioritize_time = now

        # 5. Heartbeat
        heartbeat_interval = self.daemon_config.heartbeat_interval_minutes * 60
        if now - self._last_heartbeat_time >= heartbeat_interval:
            self._post_heartbeat()
            self._last_heartbeat_time = now
        self._post_daily_digest_if_due()

        # 5.5 Daily thread rotation (create/rotate daily thread if due)
        if self.config.slack.notification_mode == "daily" and self._should_rotate_daily_thread():
            self._ensure_daily_thread()

        # 6. PR outcome polling
        outcome_interval = self.daemon_config.outcome_poll_interval_minutes * 60
        if now - self._last_outcome_poll_time >= outcome_interval:
            self._poll_pr_outcomes()
            self._last_outcome_poll_time = now

        # 7. PR sync — keep ColonyOS PRs up-to-date with main
        pr_sync_cfg = self.daemon_config.pr_sync
        if (
            pr_sync_cfg.enabled
            and not self._state.paused
            and not self._pipeline_running
            and now - self._last_pr_sync_time >= pr_sync_cfg.interval_minutes * 60
        ):
            if self._sync_stale_prs():
                self._last_pr_sync_time = now

    # ------------------------------------------------------------------
    # Queue execution
    # ------------------------------------------------------------------

    def _try_execute_next(self) -> bool:
        """Pop the highest-priority pending item and execute it."""
        with self._lock:
            if self._state.paused:
                return False

            # Budget check (reset alert flags if daily counters were reset)
            today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
            if self._state.daily_reset_date != today:
                self._budget_80_alerted = False
                self._budget_100_alerted = False
                self._last_budget_incident_date = None

            allowed, _remaining = self._state.check_daily_budget(self.daily_budget)

            # Budget threshold alerts
            spend = self._state.daily_spend_usd
            if self.daily_budget is not None and self.daily_budget > 0:
                pct = spend / self.daily_budget
                if pct >= 1.0 and not self._budget_100_alerted:
                    self._budget_100_alerted = True
                    self._post_slack_message(
                        f":red_circle: Budget exhausted — ${spend:.2f}/${self.daily_budget:.2f} "
                        f"(100%). Daemon will not execute further items today.",
                        critical=True,
                    )
                elif pct >= 0.8 and not self._budget_80_alerted:
                    self._budget_80_alerted = True
                    self._post_slack_message(
                        f":warning: Budget warning — ${spend:.2f}/${self.daily_budget:.2f} "
                        f"({pct:.0%} used). Approaching daily limit."
                    )

            if not allowed:
                pending = sum(
                    1
                    for queued_item in self._queue_state.items
                    if queued_item.status == QueueItemStatus.PENDING
                )
                incident_path = self._maybe_record_budget_incident(today=today, pending=pending)
                guidance = self._budget_exhaustion_guidance(incident_path=incident_path)
                logger.warning(
                    "Daily budget exhausted; queue remains idle (spent=$%.2f, cap=%s, pending=%d). %s",
                    self._state.daily_spend_usd,
                    self._budget_cap_label(),
                    pending,
                    guidance,
                )
                return False

            # Circuit breaker check
            if self._state.is_circuit_breaker_active():
                logger.debug("Circuit breaker active, skipping execution")
                return False

            # Find highest-priority pending item
            item = self._next_pending_item()
            if item is None:
                return False

        # Only inspect or recover git state when there is actual runnable work.
        # This avoids mutating the repo while the daemon is paused, cooling down,
        # or simply idle with an empty queue.
        worktree_state, worktree_detail = self._preexec_worktree_state()
        if worktree_state == "indeterminate":
            logger.error(
                "Pre-execution worktree check failed (fail-closed); blocking execution: %s",
                worktree_detail,
            )
            self._pause_for_pre_execution_blocker(
                item,
                "Could not run `git status` to verify the worktree (fail-closed). "
                f"{worktree_detail} "
                "Fix git access from the daemon's repo root (permissions, install, or hung/broken repo), "
                "confirm `git status --porcelain` succeeds, then resume.",
            )
            return False
        if worktree_state == "dirty":
            if self.daemon_config.auto_recover_dirty_worktree:
                logger.warning("Dirty worktree detected pre-execution, auto-recovering")
                if not self._recover_dirty_worktree_preemptive():
                    self._pause_for_pre_execution_blocker(
                        item,
                        "Dirty worktree before execution: auto-recovery failed. "
                        "Execution is blocked until the worktree is fixed; then resume the daemon.",
                    )
                    return False
            else:
                logger.error(
                    "Dirty worktree detected, skipping execution. "
                    "Enable auto_recover_dirty_worktree or clean manually."
                )
                self._pause_for_pre_execution_blocker(
                    item,
                    "Dirty worktree before execution while `daemon.auto_recover_dirty_worktree` "
                    "is disabled. Execution is blocked until the worktree is clean or auto "
                    "recovery is enabled; then resume the daemon.",
                )
                return False

        with self._lock:
            if self._state.paused or self._state.is_circuit_breaker_active():
                return False
            if item.status != QueueItemStatus.PENDING:
                return False
            item.status = QueueItemStatus.RUNNING
            item.started_at = datetime.now(timezone.utc).isoformat()
            self._pipeline_running = True
            self._pipeline_started_at = time.monotonic()
            # Reset stall flag — it stays True after recovery until the next
            # pipeline run so /healthz can report the stall that just happened.
            self._pipeline_stalled = False
            self._watchdog_suppress_count = 0
            self._current_running_item = item
            self._persist_queue()

        # Execute outside the lock
        try:
            log = self._execute_item(item)
            with self._lock:
                item.run_id = log.run_id
                item.cost_usd = log.total_cost_usd
                item.pr_url = log.pr_url
                if log.branch_name:
                    item.branch_name = log.branch_name
                if log.preflight and log.preflight.head_sha:
                    item.head_sha = log.preflight.head_sha
                if log.status == RunStatus.COMPLETED:
                    item.status = QueueItemStatus.COMPLETED
                    self._state.record_success()
                    self._recent_failure_codes.clear()
                else:
                    item.status = QueueItemStatus.FAILED
                    item.error = (
                        log.phases[-1].error[:200]
                        if log.phases and log.phases[-1].error
                        else "Pipeline failed"
                    )
                self._state.record_spend(log.total_cost_usd)
                # Track CI-fix spend against maintenance budget (FR-5)
                if item.source_type == "ci-fix":
                    self._state.daily_maintenance_spend_usd += log.total_cost_usd
                self._pipeline_running = False
                self._pipeline_started_at = None
                self._current_running_item = None
                set_heartbeat_path(None)
                self._persist_state()
                self._persist_queue()
                logger.info(
                    "Finished item %s (status=%s, cost=$%.4f, spend=%s, pending=%d)",
                    item.id,
                    item.status.value,
                    log.total_cost_usd,
                    self._spent_summary(),
                    sum(
                        1
                        for queued_item in self._queue_state.items
                        if queued_item.status == QueueItemStatus.PENDING
                    ),
                )
            self._cleanup_notification_lock(item.id)
        except KeyboardInterrupt:
            logger.warning("Run interrupted while executing item %s", item.id)
            with self._lock:
                item.status = QueueItemStatus.FAILED
                item.error = "Run interrupted by user (Ctrl+C)"
                self._pipeline_running = False
                self._pipeline_started_at = None
                self._current_running_item = None
                set_heartbeat_path(None)
                self._persist_state()
                self._persist_queue()
            self._cleanup_notification_lock(item.id)
            raise
        except Exception as exc:
            logger.exception("Pipeline failed for item %s", item.id)
            failure_message = self._failure_summary(exc)
            incident_path = self._record_runtime_incident(
                label_prefix=f"daemon-item-{item.source_type}",
                summary=(
                    f"Queue item {item.id} failed while executing in daemon mode.\n\n"
                    f"Source type: {item.source_type}\n"
                    f"Source value: {item.source_value[:500]}\n"
                    f"Failure: {failure_message}\n"
                    f"Hint: {self._failure_guidance(exc)}"
                ),
                metadata={
                    "item_id": item.id,
                    "source_type": item.source_type,
                    "source_value": item.source_value[:500],
                    "error": failure_message,
                    "daily_spend_usd": self._state.daily_spend_usd,
                    "daily_budget": self.daily_budget,
                },
            )
            operator_hint = self._failure_guidance(exc)
            error_code = getattr(exc, "code", None) or type(exc).__name__
            systemic_slack: tuple[str, int] | None = None
            escalation_slack: tuple[int, int] | None = None
            cb_cooldown_slack: tuple[int, int, str] | None = None
            with self._lock:
                item.status = QueueItemStatus.FAILED
                item.error = self._format_item_error(
                    failure_message,
                    incident_path=incident_path,
                )
                self._recent_failure_codes.append(error_code)
                max_tracked = self.daemon_config.max_consecutive_failures
                if len(self._recent_failure_codes) > max_tracked:
                    self._recent_failure_codes = self._recent_failure_codes[-max_tracked:]

                failures = self._state.record_failure()
                if failures >= max_tracked:
                    if self._is_systemic_failure():
                        self._state.paused = True
                        logger.error(
                            "Auto-paused: %d consecutive failures with same error (%s). "
                            "Send 'resume' in the configured Slack channel to unpause.",
                            failures,
                            error_code,
                        )
                        self._state.circuit_breaker_until = None
                        systemic_slack = (error_code, failures)
                    else:
                        cb_expiry = self._state.activate_circuit_breaker(
                            self.daemon_config.circuit_breaker_cooldown_minutes
                        )
                        activation_n = self._state.circuit_breaker_activations
                        if cb_expiry is None:
                            self._state.paused = True
                            logger.error(
                                "Auto-paused: circuit breaker escalated after %d activations",
                                activation_n,
                            )
                            self._state.circuit_breaker_until = None
                            escalation_slack = (activation_n, failures)
                        else:
                            logger.warning(
                                "Circuit breaker activated after %d consecutive failures "
                                "(activation #%d, cooldown until %s)",
                                failures,
                                activation_n,
                                cb_expiry,
                            )
                            cb_cooldown_slack = (failures, activation_n, cb_expiry)
                self._pipeline_running = False
                self._pipeline_started_at = None
                self._current_running_item = None
                set_heartbeat_path(None)
                self._persist_state()
                self._persist_queue()
            if systemic_slack is not None:
                self._post_systemic_failure_alert(*systemic_slack)
            if escalation_slack is not None:
                self._post_circuit_breaker_escalation_pause_alert(*escalation_slack)
            if cb_cooldown_slack is not None:
                self._post_circuit_breaker_cooldown_notice(*cb_cooldown_slack)
            logger.error(
                "Daemon item %s failed: %s. %s",
                item.id,
                failure_message,
                operator_hint,
            )
            self._post_execution_failure(
                item,
                failure_message=failure_message,
                incident_path=str(incident_path) if incident_path is not None else None,
            )
            self._cleanup_notification_lock(item.id)
        return True

    def _make_monitor_ui(
        self,
        prefix: str = "",
        *,
        badge: Any | None = None,
        task_id: str | None = None,
    ) -> Any | None:
        if not self._monitor_mode:
            return None
        if badge is None and task_id is not None:
            from colonyos.ui import make_task_badge

            badge = make_task_badge(task_id)
        return DaemonMonitorEventUI(
            prefix=prefix,
            badge_text=getattr(badge, "text", ""),
            badge_style=getattr(badge, "style", ""),
        )

    def _next_pending_item(self) -> QueueItem | None:
        """Return the highest-priority pending item (lowest priority number, FIFO within tier)."""
        item = select_next_pending_item(self._queue_state)
        if item is not None:
            self._persist_queue()
        return item

    def _execute_item(self, item: QueueItem) -> RunLog:
        """Execute a single queue item through the pipeline.

        Returns the full run log.
        """
        if self.dry_run:
            logger.info("[DRY RUN] Would execute item %s: %s", item.id, item.source_value[:100])
            log = RunLog(run_id=item.id, prompt=item.source_value, status=RunStatus.COMPLETED)
            log.mark_finished()
            return log

        logger.info(
            "Executing item %s (type=%s, priority=%d)",
            item.id,
            item.source_type,
            item.priority,
        )

        # Import here to avoid circular imports — cli.py imports from many modules
        from colonyos.cli import _queue_item_branch_name_override  # pyright: ignore[reportPrivateUsage]
        from colonyos.slack import (
            FanoutSlackUI,
            SlackUI,
            format_phase_breakdown_line,
            generate_plain_summary,
            post_message,
            post_run_summary,
        )
        from colonyos.ui import NullUI

        branch_override = _queue_item_branch_name_override(item, self.config)
        if branch_override and item.branch_name != branch_override:
            with self._lock:
                item.branch_name = branch_override
                self._persist_queue()

        notification = self._ensure_notification_thread(
            item,
            f":gear: *Starting {item.source_type} work*\n{item.summary or item.source_value[:160]}",
        )
        targets = notification_targets(item)
        ui_factory = None
        if notification is not None and targets:
            client, _channel, _thread_ts = notification

            def _slack_ui_factory(
                prefix: str = "",
                *,
                badge: Any | None = None,
                task_id: str | None = None,
            ) -> Any:
                is_nested_stream = badge is not None or task_id is not None or bool(prefix)
                slack_targets = [SlackUI(client, target_channel, target_ts) for target_channel, target_ts in targets]
                slack_ui: Any
                if len(slack_targets) == 1:
                    slack_ui = slack_targets[0]
                else:
                    slack_ui = FanoutSlackUI(*slack_targets)
                monitor_ui = self._make_monitor_ui(prefix, badge=badge, task_id=task_id)
                if is_nested_stream:
                    return monitor_ui if monitor_ui is not None else NullUI()
                if monitor_ui is not None:
                    return CombinedUI(monitor_ui, slack_ui)
                return slack_ui

            ui_factory = _slack_ui_factory
            try:
                for target_channel, target_ts in targets:
                    post_message(
                        client,
                        target_channel,
                        (
                            f":rocket: Working on *{item.source_type}* request\n"
                            f"{item.summary or item.issue_title or item.source_value[:200]}"
                        ),
                        thread_ts=target_ts,
                    )
            except Exception:
                logger.debug("Failed to post queue execution start", exc_info=True)
        elif self._monitor_mode:
            ui_factory = self._make_monitor_ui

        log = self._run_pipeline_for_item(item, ui_factory=ui_factory)
        item.run_id = log.run_id
        item.pr_url = log.pr_url
        if log.branch_name:
            item.branch_name = log.branch_name
        if log.preflight and log.preflight.head_sha:
            item.head_sha = log.preflight.head_sha

        if notification is not None and targets:
            client, _channel, _thread_ts = notification
            try:
                breakdown = [format_phase_breakdown_line(phase) for phase in log.phases]
                plain = generate_plain_summary(
                    f"Status: {log.status.value}. "
                    f"Request: {item.summary or item.raw_prompt or ''}. "
                    f"Cost: ${log.total_cost_usd:.2f}. "
                    f"Phases: {'; '.join(breakdown)}",
                    repo_root=self.repo_root,
                )
                for target_channel, target_ts in targets:
                    post_run_summary(
                        client,
                        target_channel,
                        target_ts,
                        status=log.status.value,
                        total_cost=log.total_cost_usd,
                        branch_name=log.branch_name,
                        pr_url=log.pr_url,
                        summary=item.summary,
                        phase_breakdown=breakdown,
                        demand_count=item.demand_count,
                        plain_summary=plain,
                    )
            except Exception:
                logger.debug("Failed to post final queue summary", exc_info=True)
        return log

    # ------------------------------------------------------------------
    # GitHub Issue Polling (FR-2)
    # ------------------------------------------------------------------

    def _poll_github_issues(self) -> None:
        """Poll for new GitHub issues and enqueue them."""
        try:
            from colonyos.github import fetch_open_issues

            issues = fetch_open_issues(self.repo_root)
            label_filter = set(self.daemon_config.issue_labels)

            from colonyos.sanitize import sanitize_untrusted_content

            for issue in issues:
                # Label filtering
                if label_filter:
                    issue_labels = {lbl.lower() for lbl in issue.labels}
                    if not label_filter.intersection(issue_labels):
                        continue

                # Dedup check
                if self._is_duplicate("issue", str(issue.number)):
                    continue
                similar = find_similar_queue_item(
                    self._queue_state,
                    source_type="issue",
                    prompt_text=issue.title,
                )
                if similar is not None:
                    with self._lock:
                        attach_demand_signal(
                            similar.item,
                            source_type="issue",
                            source_value=str(issue.number),
                            summary=issue.title,
                        )
                        reprioritize_queue_item(similar.item)
                        self._persist_queue()
                    logger.info(
                        "Merged GitHub issue #%d into existing queue item %s",
                        issue.number,
                        similar.item.id,
                    )
                    continue

                priority = compute_priority("issue", issue.labels)
                item = QueueItem(
                    id=f"issue-{issue.number}-{int(time.time())}",
                    source_type="issue",
                    source_value=str(issue.number),
                    status=QueueItemStatus.PENDING,
                    priority=priority,
                    issue_title=sanitize_untrusted_content(issue.title),
                    summary=sanitize_untrusted_content(issue.title),
                    priority_reason="base:issue",
                    notification_channel=self._default_notification_channel(),
                    related_item_ids=[
                        related.id
                        for related in find_related_history_items(
                            self._queue_state,
                            prompt_text=issue.title,
                        )
                    ],
                )
                reprioritize_queue_item(item)

                with self._lock:
                    self._queue_state.items.append(item)
                    self._persist_queue()
                self._post_queue_enqueued(item)

                logger.info(
                    "Enqueued GitHub issue #%d (P%d): %s",
                    issue.number,
                    priority,
                    issue.title,
                )

        except Exception:
            logger.exception("Error polling GitHub issues")

    def _poll_pr_outcomes(self) -> None:
        """Poll GitHub for PR outcome updates.

        Wraps :func:`~colonyos.outcomes.poll_outcomes` in try/except so
        a failure never crashes the daemon.  The next scheduled poll is
        the implicit retry — no explicit retry logic.
        """
        try:
            from colonyos.outcomes import poll_outcomes

            poll_outcomes(self.repo_root)
            logger.debug("PR outcome poll completed")
        except Exception:
            logger.warning("Error polling PR outcomes", exc_info=True)

    def _sync_stale_prs(self) -> bool:
        """Sync a stale ColonyOS PR with main (concern #7).

        Wraps :func:`~colonyos.pr_sync.sync_stale_prs` in try/except so
        a failure never crashes the daemon.  Follows the same pattern as
        :meth:`_poll_pr_outcomes`.

        Returns ``True`` if the call completed without exception (regardless
        of whether a sync was performed), ``False`` on exception.
        """
        try:
            from colonyos.pr_sync import sync_stale_prs

            sync_stale_prs(
                repo_root=self.repo_root,
                config=self.config,
                queue_state_items=self._queue_state.items,
                post_slack_fn=self._post_slack_message,
                write_enabled=self.daemon_config.dashboard_write_enabled,
            )
            logger.debug("PR sync check completed")
            return True
        except Exception:
            logger.warning("Error during PR sync", exc_info=True)
            return False

    # ------------------------------------------------------------------
    # CEO Idle-Fill (FR-4)
    # ------------------------------------------------------------------

    def _schedule_ceo(self) -> None:
        """Run CEO cycle to propose work when queue is idle."""
        if self.dry_run:
            logger.info("[DRY RUN] Would run CEO idle-fill cycle")
            return

        try:
            logger.info("Running CEO idle-fill cycle")
            from colonyos.orchestrator import run_ceo
            ui = self._make_monitor_ui("CEO ")
            if ui is None:
                from colonyos.ui import PhaseUI
                ui = PhaseUI(verbose=self.verbose, prefix="CEO ")

            with self._agent_lock:
                proposal_prompt, phase_result = run_ceo(
                    repo_root=self.repo_root,
                    config=self.config,
                    ui=ui,
                )

            if not phase_result.success:
                logger.warning(
                    "CEO idle-fill failed: %s",
                    phase_result.error or "unknown CEO error",
                )
                return

            if proposal_prompt:
                similar = find_similar_queue_item(
                    self._queue_state,
                    source_type="ceo",
                    prompt_text=proposal_prompt,
                )
                if similar is not None:
                    with self._lock:
                        attach_demand_signal(
                            similar.item,
                            source_type="ceo",
                            source_value=proposal_prompt,
                            summary=proposal_prompt.splitlines()[0][:180],
                        )
                        reprioritize_queue_item(similar.item)
                        self._persist_queue()
                    logger.info(
                        "Merged CEO proposal into existing queue item %s",
                        similar.item.id,
                    )
                    return
                item = QueueItem(
                    id=f"ceo-{int(time.time())}",
                    source_type="ceo",
                    source_value=proposal_prompt,
                    status=QueueItemStatus.PENDING,
                    priority=compute_priority("ceo"),
                    summary=proposal_prompt.splitlines()[0][:180],
                    priority_reason="base:ceo",
                    notification_channel=self._default_notification_channel(),
                    related_item_ids=[
                        related.id
                        for related in find_related_history_items(
                            self._queue_state,
                            prompt_text=proposal_prompt,
                        )
                    ],
                )
                reprioritize_queue_item(item)
                with self._lock:
                    self._queue_state.items.append(item)
                    self._persist_queue()
                self._post_queue_enqueued(item)
                logger.info("CEO proposed work enqueued (P%d): %s", item.priority, proposal_prompt[:120])
            else:
                logger.info("CEO idle-fill produced no actionable proposal")

        except Exception:
            logger.exception("Error in CEO idle-fill cycle")

    # ------------------------------------------------------------------
    # Cleanup Scheduling (FR-5)
    # ------------------------------------------------------------------

    def _schedule_cleanup(self) -> None:
        """Schedule cleanup tasks."""
        try:
            from colonyos.cleanup import list_merged_branches, scan_directory

            # Run branch cleanup directly (non-queued, fast operation)
            branches = list_merged_branches(
                self.repo_root, prefix=self.config.branch_prefix
            )
            if branches:
                logger.info("Found %d merged branches for cleanup", len(branches))

            # Scan for complexity candidates → enqueue as cleanup items
            candidates = scan_directory(
                self.repo_root / "src",
                max_lines=self.config.cleanup.scan_max_lines,
                max_functions=self.config.cleanup.scan_max_functions,
            )

            enqueued = 0
            max_items = self.daemon_config.max_cleanup_items
            for candidate in candidates[:max_items]:
                source_val = str(candidate.path)
                if self._is_duplicate("cleanup", source_val):
                    continue

                item = QueueItem(
                    id=f"cleanup-{int(time.time())}-{enqueued}",
                    source_type="cleanup",
                    source_value=source_val,
                    status=QueueItemStatus.PENDING,
                    priority=compute_priority("cleanup"),
                    summary=f"Cleanup/refactor {Path(candidate.path).name}",
                    priority_reason="base:cleanup",
                    notification_channel=self._default_notification_channel(),
                )
                reprioritize_queue_item(item)
                with self._lock:
                    self._queue_state.items.append(item)
                    enqueued += 1

            if enqueued:
                with self._lock:
                    self._persist_queue()
                    items_to_notify = [
                        item
                        for item in self._queue_state.items
                        if item.source_type == "cleanup"
                        and item.status == QueueItemStatus.PENDING
                        and not item.notification_thread_ts
                    ]
                for item in items_to_notify:
                    self._post_queue_enqueued(item)
                logger.info("Enqueued %d cleanup items", enqueued)

        except Exception:
            logger.exception("Error in cleanup scheduling")

    # ------------------------------------------------------------------
    # Health & Observability (FR-10)
    # ------------------------------------------------------------------

    def _reprioritize_queue(self) -> None:
        try:
            from colonyos.models import QueueState
            from colonyos.prioritizer import apply_priority_decisions, score_queue_with_agent

            with self._lock:
                snapshot = QueueState.from_dict(self._queue_state.to_dict())
            with self._agent_lock:
                decisions = score_queue_with_agent(
                    self.repo_root,
                    self.config,
                    snapshot,
                    ui=self._make_monitor_ui("Priority "),
                )
            if not decisions:
                return
            with self._lock:
                changed = apply_priority_decisions(self._queue_state, decisions)
                if changed:
                    self._persist_queue()
        except Exception:
            logger.warning("Queue reprioritization failed", exc_info=True)

    def _post_daily_digest_if_due(self) -> None:
        now = datetime.now(timezone.utc)
        today = now.strftime("%Y-%m-%d")
        if now.hour < self.daemon_config.digest_hour_utc:
            return
        if self._last_digest_date == today:
            return

        top_items = pending_queue_snapshot(self._queue_state, limit=3)
        pending = sum(
            1 for item in self._queue_state.items
            if item.status == QueueItemStatus.PENDING
        )
        recent_completed = [
            item for item in reversed(self._queue_state.items)
            if item.status == QueueItemStatus.COMPLETED
        ][:3]
        lines = [
            ":spiral_note_pad: *Daily ColonyOS Queue Digest*",
            f"Queue depth: {pending}",
            "Top 3 pending:",
        ]
        if top_items:
            for item in top_items:
                lines.append(
                    f"- P{item.priority} {item.source_type}: "
                    f"{item.summary or item.issue_title or item.source_value[:140]}"
                )
                if item.priority_reason:
                    lines.append(f"  reason: {item.priority_reason}")
        else:
            lines.append("- No pending work.")
        if recent_completed:
            lines.append("Recent completions:")
            for item in recent_completed:
                lines.append(
                    f"- {item.source_type}: {item.summary or item.issue_title or item.source_value[:100]}"
                )
        lines.append(f"Daily spend: {self._spent_summary()}")
        self._post_slack_message("\n".join(lines))
        self._last_digest_date = today

    def _post_heartbeat(self) -> None:
        """Post heartbeat to Slack and update state."""
        with self._lock:
            self._state.touch_heartbeat()
            self._persist_state()
            items_today = self._state.total_items_today
            spend = self._state.daily_spend_usd

        logger.info(
            "Heartbeat: %d items today, %s, pending=%d",
            items_today,
            self._spent_summary(spend=spend),
            sum(
                1 for i in self._queue_state.items
                if i.status == QueueItemStatus.PENDING
            ),
        )

        pending = sum(
            1 for i in self._queue_state.items
            if i.status == QueueItemStatus.PENDING
        )
        spend_label = (
            f"${spend:.2f}/unlimited"
            if self.daily_budget is None
            else f"${spend:.2f}/${self.daily_budget:.2f}"
        )
        msg = (
            f":heartbeat: *ColonyOS Heartbeat*\n"
            f"Items today: {items_today} | Spend: {spend_label} | "
            f"Queue depth: {pending} | Paused: {self._state.paused}"
        )
        self._post_slack_message(msg)

    def _run_pipeline_for_item(
        self,
        item: QueueItem,
        *,
        ui_factory: Any = None,
    ) -> RunLog:
        from colonyos.cli import run_pipeline_for_queue_item

        timeout = self.daemon_config.pipeline_timeout_seconds
        timed_out = threading.Event()

        def _timeout_watchdog() -> None:
            logger.warning(
                "Pipeline timeout (%ds) reached for item %s, requesting cancellation",
                timeout,
                item.id,
            )
            timed_out.set()

            cancelled = request_active_phase_cancel(
                f"Pipeline exceeded {timeout}s wall-clock timeout"
            )
            if not cancelled:
                from colonyos.cancellation import request_cancel

                request_cancel(
                    f"Pipeline exceeded {timeout}s wall-clock timeout"
                )

        # Capture the current branch so we can restore after the pipeline
        # finishes (success or failure).  This prevents a failed pipeline
        # from leaving the daemon stranded on a feature branch.
        branch_before: str | None = None
        try:
            br = subprocess.run(
                ["git", "rev-parse", "--abbrev-ref", "HEAD"],
                capture_output=True, text=True, cwd=self.repo_root, timeout=10,
            )
            name = br.stdout.strip() if br.returncode == 0 else ""
            # "HEAD" means detached -- not a real branch we can restore to.
            if name and name != "HEAD":
                branch_before = name
        except Exception:
            logger.debug("Could not determine current branch before pipeline run")

        attempted_dirty_worktree_recovery = False
        attempted_branch_exists_recovery = False
        watchdog = threading.Timer(timeout, _timeout_watchdog)
        watchdog.daemon = True
        watchdog.start()
        try:
            while True:
                if timed_out.is_set():
                    raise RuntimeError(
                        f"Pipeline for item {item.id} exceeded "
                        f"{timeout}s wall-clock timeout"
                    )
                try:
                    with self._agent_lock:
                        result = run_pipeline_for_queue_item(
                            item=item,
                            repo_root=self.repo_root,
                            config=self.config,
                            verbose=self.verbose,
                            quiet=False,
                            ui_factory=ui_factory,
                            queue_state=self._queue_state,
                        )
                    if timed_out.is_set():
                        raise RuntimeError(
                            f"Pipeline for item {item.id} exceeded "
                            f"{timeout}s wall-clock timeout"
                        )
                    return result
                except PreflightError as exc:
                    if (
                        not attempted_dirty_worktree_recovery
                        and self._should_auto_recover_dirty_worktree(exc)
                    ):
                        attempted_dirty_worktree_recovery = True
                        self._recover_dirty_worktree_and_retry(item, exc)
                        continue
                    if (
                        not attempted_branch_exists_recovery
                        and self._should_auto_recover_existing_branch(exc)
                    ):
                        attempted_branch_exists_recovery = True
                        self._recover_existing_branch_and_retry(item, exc)
                        continue
                    raise
        finally:
            watchdog.cancel()
            restored_ok = False
            if branch_before:
                try:
                    from colonyos.recovery import restore_to_branch

                    restored = restore_to_branch(self.repo_root, branch_before)
                    if restored:
                        logger.info("Post-pipeline: %s", restored)
                    restored_ok = True
                except Exception:
                    logger.warning(
                        "Failed to restore to %s after pipeline run",
                        branch_before,
                        exc_info=True,
                    )
            else:
                restored_ok = True  # No branch to restore — assume on main
            # Run maintenance cycle between queue items (task 5.4)
            # Only safe when we're confirmed back on the expected branch.
            if restored_ok:
                try:
                    self._run_maintenance_cycle()
                except Exception:
                    logger.warning(
                        "Maintenance cycle failed (non-fatal)",
                        exc_info=True,
                    )
            else:
                logger.warning(
                    "Skipping maintenance cycle — branch restore failed"
                )

    # ------------------------------------------------------------------
    # Background threads
    # ------------------------------------------------------------------

    def _start_threads(self) -> list[threading.Thread]:
        """Start and return daemon background threads."""
        threads: list[threading.Thread] = []

        # Slack listener thread (if enabled)
        if self.config.slack.enabled:
            t = threading.Thread(
                target=self._slack_listener_thread,
                name="daemon-slack",
                daemon=True,
            )
            t.start()
            threads.append(t)

        # Watchdog thread (always enabled)
        self._start_watchdog_thread()
        if self._watchdog_thread is not None:
            threads.append(self._watchdog_thread)

        return threads

    def _slack_listener_thread(self) -> None:
        """Run Slack Socket Mode listener."""
        try:
            from colonyos.slack import (
                create_slack_app,
                resolve_channel_names,
                save_watch_state,
                start_socket_mode,
            )
            from colonyos.slack_queue import SlackQueueEngine

            self._slack_watch_state = self._load_or_create_daemon_watch_state()
            slack_app = create_slack_app(self.config.slack)
            auth_response = slack_app.client.auth_test()
            bot_user_id = auth_response["user_id"]
            resolved_channels = resolve_channel_names(
                slack_app.client,
                self.config.slack.channels,
            )
            self.config.slack.channels = [ch.id for ch in resolved_channels]

            def _persist_watch_state() -> None:
                assert self._slack_watch_state is not None
                save_watch_state(self.repo_root, self._slack_watch_state)

            def _publish_client(client: Any) -> None:
                self._slack_client = client

            assert self._slack_watch_state is not None
            slack_engine = SlackQueueEngine(
                repo_root=self.repo_root,
                config=self.config,
                queue_state=self._queue_state,
                watch_state=self._slack_watch_state,
                state_lock=self._lock,
                shutdown_event=self._stop_event,
                bot_user_id=bot_user_id,
                slack_client_ready=self._slack_client_ready,
                publish_client=_publish_client,
                persist_queue=self._persist_queue,
                persist_watch_state=_persist_watch_state,
                is_time_exceeded=self._check_time_exceeded,
                is_budget_exceeded=self._check_budget_exceeded,
                is_daily_budget_exceeded=self._check_daily_budget_exceeded,
                dry_run=self.dry_run,
                # agent_lock is passed for potential future use (e.g., multi-worker
                # triage serialization) but is NOT acquired during triage — triage
                # runs lock-free so Slack intake is never blocked by pipeline execution.
                agent_lock=self._agent_lock,
            )
            logger.info("Slack listener thread started")
            self._warn_all_mode_safety(self.config)
            self._register_daemon_commands(slack_app)
            slack_engine.register(slack_app)
            _persist_watch_state()
            handler = start_socket_mode(slack_app)
            handler.connect()
            while not self._stop_event.is_set():
                self._stop_event.wait(timeout=5.0)
            try:
                handler.close()
            except Exception:
                logger.debug("Failed to close daemon Slack handler", exc_info=True)
        except Exception:
            logger.exception("Slack listener thread failed")

    def _make_daemon_watch_state(self) -> Any:
        from colonyos.slack import SlackWatchState

        return SlackWatchState(watch_id=_DAEMON_WATCH_ID)

    def _load_or_create_daemon_watch_state(self) -> Any:
        from colonyos.slack import load_watch_state

        return load_watch_state(self.repo_root, _DAEMON_WATCH_ID) or self._make_daemon_watch_state()

    def _register_daemon_commands(self, slack_app: Any) -> None:
        """Register message handler on the Slack app for daemon control commands."""
        control_keywords = {"pause", "stop", "halt", "resume", "start", "status"}

        @slack_app.event("message")
        def _handle_message(event: dict[str, object], say: Any) -> None:  # noqa: ANN401  # pyright: ignore[reportUnusedFunction]
            text = str(event.get("text") or "").strip().lower()
            user_id = str(event.get("user") or "")
            if text not in control_keywords:
                return
            result = self._handle_control_command(user_id, text)
            if result:
                say(result)

    # ------------------------------------------------------------------
    # Slack helpers & kill switch (FR-11)
    # ------------------------------------------------------------------

    def _post_slack_message(self, text: str, *, critical: bool = False) -> None:
        """Post a message to the first configured Slack channel.

        Errors are logged and swallowed so Slack failures never block the daemon.

        When ``notification_mode == "daily"`` and ``critical`` is False and a
        daily thread exists, the message is posted as a reply to the daily
        thread.  Critical alerts and per-item mode always post top-level.
        """
        try:
            client = self._get_notification_client()
            channel = self._default_notification_channel()
            if client is None or not channel:
                logger.debug("No Slack client or channel available, skipping Slack message")
                return

            kwargs: dict[str, Any] = {"channel": channel, "text": text}

            # In daily mode with a daily thread, route non-critical messages there
            if (
                not critical
                and self.config.slack.notification_mode == "daily"
                and self._state.daily_thread_ts
            ):
                kwargs["thread_ts"] = self._state.daily_thread_ts

            client.chat_postMessage(**kwargs)
        except Exception:
            logger.exception("Failed to post Slack message")

    def _default_notification_channel(self) -> str | None:
        channels = self.config.slack.channels
        return channels[0] if channels else None

    def _get_notification_client(self) -> Any | None:
        if self._slack_client is not None:
            return self._slack_client
        token = os.environ.get("COLONYOS_SLACK_BOT_TOKEN")
        if not token:
            return None
        try:
            from slack_sdk import WebClient

            return WebClient(token=token)
        except Exception:
            logger.debug("Failed to create Slack WebClient", exc_info=True)
            return None

    def _notification_thread_lock_for(self, item_id: str) -> threading.Lock:
        with self._notification_thread_locks_guard:
            lock = self._notification_thread_locks.get(item_id)
            if lock is None:
                lock = threading.Lock()
                self._notification_thread_locks[item_id] = lock
            return lock

    def _cleanup_notification_lock(self, item_id: str) -> None:
        """Remove the notification thread lock for a terminal item to prevent unbounded growth."""
        with self._notification_thread_locks_guard:
            self._notification_thread_locks.pop(item_id, None)

    def _ensure_notification_thread(self, item: QueueItem, intro_text: str) -> tuple[Any, str, str] | None:
        channel = item.notification_channel or self._default_notification_channel()
        if item.notification_thread_ts:
            client = self._get_notification_client()
            if client is None or not channel:
                return None
            return client, channel, item.notification_thread_ts
        item_lock = self._notification_thread_lock_for(item.id)
        with item_lock:
            with self._lock:
                if item.notification_thread_ts:
                    client = self._get_notification_client()
                    if client is None or not channel:
                        return None
                    return client, channel, item.notification_thread_ts
            try:
                from colonyos.slack import post_message

                # In daily mode, post the per-item intro as a reply to the daily thread
                if self.config.slack.notification_mode == "daily":
                    daily = self._ensure_daily_thread()
                    if daily is not None:
                        client, channel, daily_thread_ts = daily
                        response = post_message(
                            client, channel, intro_text,
                            thread_ts=daily_thread_ts,
                        )
                    else:
                        client = self._get_notification_client()
                        if client is None or not channel:
                            return None
                        response = post_message(client, channel, intro_text)
                else:
                    client = self._get_notification_client()
                    if client is None or not channel:
                        return None
                    response = post_message(client, channel, intro_text)
            except Exception:
                logger.debug("Failed to create notification thread", exc_info=True)
                return None
            thread_ts = response.get("ts")
            if not thread_ts:
                return None
            with self._lock:
                if item.notification_thread_ts:
                    return client, channel, item.notification_thread_ts
                item.notification_channel = channel
                item.notification_thread_ts = thread_ts
                self._persist_queue()
            return client, channel, thread_ts

    def _should_rotate_daily_thread(self) -> bool:
        """Return True if the daily thread should be rotated.

        Rotation occurs when the current time in the configured timezone is at
        or past ``daily_thread_hour`` on a date later than the stored thread
        date.  If no thread exists yet (``daily_thread_date is None``), rotation
        is always required.
        """
        tz = ZoneInfo(self.config.slack.daily_thread_timezone)
        now = datetime.now(tz)
        today_str = now.strftime("%Y-%m-%d")

        if self._state.daily_thread_date is None:
            return True

        if self._state.daily_thread_date >= today_str:
            return False

        try:
            last_thread_date = date.fromisoformat(self._state.daily_thread_date)
        except ValueError:
            logger.warning(
                "Invalid persisted daily thread date %r; rotating immediately",
                self._state.daily_thread_date,
            )
            return True

        today = now.date()
        days_stale = (today - last_thread_date).days
        if days_stale > 1:
            return True

        # For the immediately previous day, wait until the configured hour
        # before rotating so the overnight thread remains stable.
        return now.hour >= self.config.slack.daily_thread_hour

    def _create_daily_summary(self) -> str:
        """Build a formatted summary of items completed/failed since the last daily thread.

        Filters ``self._queue_state.items`` for items whose ``added_at``
        timestamp falls on or after the previous daily thread date (stored in
        ``self._state.daily_thread_date``).  When no previous date exists, all
        terminal items are included.

        Returns the formatted summary string produced by
        :func:`colonyos.slack.format_daily_summary`.
        """
        from colonyos.slack import format_daily_summary

        tz = ZoneInfo(self.config.slack.daily_thread_timezone)
        now = datetime.now(tz)
        period_label = f"{now.strftime('%B')} {now.day}, {now.year}"

        # Determine the cutoff: only include items added since the last rotation
        cutoff_iso: str | None = self._state.daily_thread_date  # e.g. "2026-04-01"

        def _since_cutoff(item: QueueItem) -> bool:
            if cutoff_iso is None:
                return True
            # added_at is an ISO-8601 datetime string; compare date portion
            return item.added_at[:10] >= cutoff_iso

        completed = [
            item for item in self._queue_state.items
            if item.status == QueueItemStatus.COMPLETED and _since_cutoff(item)
        ]
        failed = [
            item for item in self._queue_state.items
            if item.status == QueueItemStatus.FAILED and _since_cutoff(item)
        ]
        pending_count = sum(
            1 for item in self._queue_state.items
            if item.status == QueueItemStatus.PENDING
        )
        total_cost = sum(item.cost_usd for item in completed + failed)

        return format_daily_summary(
            completed_items=completed,
            failed_items=failed,
            total_cost=total_cost,
            queue_depth=pending_count,
            period_label=period_label,
        )

    def _ensure_daily_thread(self) -> tuple[Any, str, str] | None:
        """Ensure a daily thread exists for today, creating one if needed.

        Returns ``(client, channel, daily_thread_ts)`` or ``None`` if daily
        mode is disabled or Slack is unavailable.

        In ``per_item`` notification mode this always returns ``None``.
        When the persisted daily thread date matches today (in the configured
        timezone), the cached thread is returned without posting a new message.
        Otherwise a new daily thread is created with a summary opening post.
        """
        if self.config.slack.notification_mode != "daily":
            return None

        channel = self._default_notification_channel()
        if not channel:
            return None

        client = self._get_notification_client()
        if client is None:
            return None

        # Check if existing thread is still valid for today
        if not self._should_rotate_daily_thread() and self._state.daily_thread_ts:
            return client, self._state.daily_thread_channel or channel, self._state.daily_thread_ts

        # Need to create a new daily thread
        from colonyos.slack import post_message

        summary_text = self._create_daily_summary()

        try:
            response = post_message(client, channel, summary_text)
        except Exception:
            logger.debug("Failed to create daily thread", exc_info=True)
            return None

        thread_ts = response.get("ts")
        if not thread_ts:
            return None

        # Persist the new daily thread info
        tz = ZoneInfo(self.config.slack.daily_thread_timezone)
        today_str = datetime.now(tz).strftime("%Y-%m-%d")
        with self._lock:
            prev_ts = self._state.daily_thread_ts
            if prev_ts:
                logger.debug(
                    "Rotating daily thread: previous ts=%s date=%s",
                    prev_ts,
                    self._state.daily_thread_date,
                )
            self._state.daily_thread_ts = thread_ts
            self._state.daily_thread_date = today_str
            self._state.daily_thread_channel = channel
            self._persist_state()

        logger.info("Created daily thread %s for %s in %s", thread_ts, today_str, channel)
        return client, channel, thread_ts

    def _format_phase_breakdown(self, log: Any) -> list[str]:
        from colonyos.slack import format_phase_breakdown_line

        return [format_phase_breakdown_line(phase) for phase in log.phases]

    def _post_execution_failure(
        self,
        item: QueueItem,
        *,
        failure_message: str,
        incident_path: str | None = None,
    ) -> None:
        notification = self._ensure_notification_thread(
            item,
            f":gear: *Starting {item.source_type} work*\n{item.summary or item.source_value[:160]}",
        )
        if notification is None:
            details = failure_message[:500]
            if incident_path:
                details = f"{details}\nIncident: `{incident_path}`"
            self._post_slack_message(
                f":x: *{item.source_type} failed* (`{item.id}`)\n{details}"
            )
            return
        client, channel, thread_ts = notification
        details = failure_message[:500]
        if incident_path:
            details = f"{details}\nIncident: `{incident_path}`"
        try:
            from colonyos.slack import post_message

            for target_channel, target_ts in notification_targets(item) or [(channel, thread_ts)]:
                post_message(
                    client,
                    target_channel,
                    f":x: *{item.source_type} execution failed*\n{details}",
                    thread_ts=target_ts,
                )
        except Exception:
            logger.debug("Failed to post daemon failure notification", exc_info=True)

    def _post_queue_enqueued(self, item: QueueItem) -> None:
        if item.notification_thread_ts:
            return
        summary = item.summary or item.issue_title or item.source_value[:160]
        intro = (
            f":inbox_tray: *Queued {item.source_type} work*\n"
            f"{summary}\n"
            f"Priority: P{item.priority}"
        )
        self._ensure_notification_thread(item, intro)

    def _handle_control_command(self, user_id: str, text: str) -> str | None:
        """Handle a Slack kill-switch control command.

        Returns a response string to send back, or None if the user is not
        authorized or the command is unrecognized.
        """
        allowed_ids = self.daemon_config.allowed_control_user_ids
        if not self.daemon_config.allow_all_control_users and (
            not allowed_ids or user_id not in allowed_ids
        ):
            logger.warning(
                "Unauthorized control command '%s' from user %s", text, user_id
            )
            return None

        cmd = text.strip().lower()

        if cmd in ("pause", "stop", "halt"):
            with self._lock:
                self._state.paused = True
                self._persist_state()
            logger.info("Daemon paused via Slack by user %s", user_id)
            return f"Daemon paused by <@{user_id}>."

        if cmd in ("resume", "start"):
            with self._lock:
                self._state.paused = False
                self._state.consecutive_failures = 0
                self._state.circuit_breaker_until = None
                self._state.circuit_breaker_activations = 0
                self._recent_failure_codes.clear()
                self._persist_state()
            logger.info("Daemon resumed via Slack by user %s", user_id)
            return f"Daemon resumed by <@{user_id}>."

        if cmd == "status":
            health = self.get_health()
            remaining = health["daily_budget_remaining_usd"]
            spend_line = (
                f"Spend: ${health['daily_spend_usd']:.2f} (unlimited budget)\n"
                if remaining is None
                else f"Spend: ${health['daily_spend_usd']:.2f} "
                f"(${remaining:.2f} remaining)\n"
            )
            return (
                f"*Daemon Status*\n"
                f"Status: {health['status']} | Paused: {health['paused']}\n"
                f"Queue depth: {health['queue_depth']} | Pipeline running: {health['pipeline_running']}\n"
                f"{spend_line}"
                f"Failures: {health['consecutive_failures']} | "
                f"Circuit breaker: {health['circuit_breaker_active']}"
            )

        return None

    # ------------------------------------------------------------------
    # Signal handling
    # ------------------------------------------------------------------

    def _install_signal_handlers(self) -> None:
        """Install SIGINT/SIGTERM handlers for graceful shutdown."""
        def _handler(signum: int, frame: Any) -> None:
            sig_name = signal.Signals(signum).name
            logger.info("Received %s, initiating shutdown", sig_name)
            cancelled = request_cancel(f"Interrupted active work because {sig_name} was received")
            if self._pipeline_running:
                if cancelled:
                    logger.warning(
                        "Requested shared cancellation for %d active task(s) due to %s",
                        cancelled,
                        sig_name,
                    )
                logger.warning("Interrupting active pipeline due to %s", sig_name)
                raise KeyboardInterrupt(f"{sig_name} received")

        signal.signal(signal.SIGINT, _handler)
        signal.signal(signal.SIGTERM, _handler)

    # ------------------------------------------------------------------
    # PID lock (FR — prevent multiple instances)
    # ------------------------------------------------------------------

    def _acquire_pid_lock(self) -> None:
        """Acquire repo runtime lock and write daemon PID metadata."""
        pid_path = self.repo_root / _PID_FILE
        pid_path.parent.mkdir(parents=True, exist_ok=True)
        try:
            self._runtime_guard = RepoRuntimeGuard(self.repo_root, "daemon").acquire()
        except RuntimeBusyError as exc:
            raise DaemonError(
                f"Another daemon instance is already running ({exc})."
            ) from exc

        pid_path.write_text(f"{os.getpid()}\n", encoding="utf-8")

    def _release_pid_lock(self) -> None:
        """Release daemon PID metadata and repo runtime lock."""
        pid_path = self.repo_root / _PID_FILE
        try:
            pid_path.unlink(missing_ok=True)
        except OSError:
            pass
        if self._runtime_guard is not None:
            self._runtime_guard.release()
            self._runtime_guard = None

    # ------------------------------------------------------------------
    # Maintenance cycle (task 5.0)
    # ------------------------------------------------------------------

    _UPTIME_GOOD_COMMIT_SECONDS = 60

    def _run_maintenance_cycle(self) -> None:
        """Run the inter-queue maintenance cycle.

        Sequence: (1) pull & check for self-update, (2) branch sync scan,
        (3) CI-fix enqueueing.  Called from ``_run_pipeline_for_item``
        finally block after ``restore_to_branch()`` returns to main.
        """
        # 1. Pull latest and check for code changes
        changed, old_sha, new_sha = pull_and_check_update(self.repo_root)

        if changed and self.daemon_config.self_update:
            logger.info(
                "Self-update: code changed %s → %s, running install",
                old_sha[:10] if old_sha else "?",
                new_sha[:10] if new_sha else "?",
            )
            # Record the pre-update SHA so we can roll back on crash
            if old_sha:
                record_last_good_commit(self.repo_root, old_sha)

            if run_self_update(self.repo_root, self.daemon_config.self_update_command):
                # Persist all state before exec-replacing
                self._persist_state()
                self._persist_queue()
                logger.info("Self-update installed, restarting via os.execv")
                os.execv(sys.executable, [sys.executable] + sys.argv)
            else:
                logger.error("Self-update install failed, continuing with old code")

        # 2. Branch sync scan (with 1-hour cooldown on Slack posts)
        _BRANCH_SYNC_COOLDOWN = 3600
        if self.daemon_config.branch_sync_enabled:
            branches = scan_diverged_branches(self.repo_root)
            report = format_branch_sync_report(branches)
            if report and (time.time() - self._last_branch_sync_post >= _BRANCH_SYNC_COOLDOWN):
                self._post_slack_message(report)
                self._last_branch_sync_post = time.time()

        # 3. CI-fix enqueueing (gated on maintenance budget)
        if self._check_maintenance_budget():
            candidates = find_branches_with_failing_ci(self.repo_root)
            items = build_ci_fix_queue_items(
                candidates,
                max_items=self.daemon_config.max_ci_fix_items,
                existing_queue=list(self._queue_state.items),
            )
            with self._lock:
                for item in items:
                    self._queue_state.items.append(item)
                if items:
                    self._persist_queue()
                    logger.info("Enqueued %d CI-fix item(s)", len(items))

    def _check_startup_rollback(self) -> None:
        """Check if the daemon should roll back after a failed self-update.

        Called once on startup, before entering the main loop.
        If ``should_rollback()`` returns True and self-update is enabled,
        attempt to roll back to the last-good commit.  After 2 consecutive
        rollback failures, disable self-update and alert via Slack.
        """
        if not self.daemon_config.self_update:
            return

        if not should_rollback(self.repo_root, self._startup_time):
            return

        # Circuit breaker: 2+ consecutive failures → disable and alert
        if self._state.self_update_consecutive_failures >= 2:
            logger.error(
                "Self-update circuit breaker tripped (%d consecutive failures). "
                "Disabling self-update.",
                self._state.self_update_consecutive_failures,
            )
            self._post_slack_message(
                ":rotating_light: *Self-update circuit breaker tripped* — "
                f"{self._state.self_update_consecutive_failures} consecutive rollback "
                "failures. Self-update has been disabled. Manual intervention required.",
                critical=True,
            )
            return

        # Increment failure count and attempt rollback
        self._state.self_update_consecutive_failures += 1
        self._persist_state()

        last_good = read_last_good_commit(self.repo_root)
        if not last_good:
            logger.warning("Rollback needed but no last_good_commit found")
            return

        logger.warning("Rolling back to last good commit: %s", last_good[:10])
        self._post_slack_message(
            f":warning: Self-update rollback — reverting to `{last_good[:10]}` "
            f"(failure #{self._state.self_update_consecutive_failures})",
            critical=True,
        )

        # Checkout the last good commit and reinstall
        try:
            subprocess.run(
                ["git", "checkout", last_good],
                cwd=self.repo_root,
                capture_output=True,
                text=True,
                timeout=30,
                check=True,
            )
        except Exception:
            logger.error("Failed to checkout last good commit %s", last_good[:10], exc_info=True)
            return

        if run_self_update(self.repo_root, self.daemon_config.self_update_command):
            self._persist_state()
            self._persist_queue()
            os.execv(sys.executable, [sys.executable] + sys.argv)
        else:
            logger.error("Failed to reinstall from last good commit %s", last_good[:10])

    def _maybe_record_uptime_good_commit(self) -> None:
        """Record current SHA as last-good after surviving the uptime threshold.

        Called from ``_tick()``.  Once recorded, this method is a no-op
        for the rest of the daemon's lifetime.  Also resets the
        consecutive failure counter on successful uptime.
        """
        if not self.daemon_config.self_update:
            return
        if self._good_commit_recorded:
            return
        if time.time() - self._startup_time < self._UPTIME_GOOD_COMMIT_SECONDS:
            return

        try:
            result = subprocess.run(
                ["git", "rev-parse", "HEAD"],
                cwd=self.repo_root,
                capture_output=True,
                text=True,
                timeout=10,
            )
            if result.returncode == 0:
                sha = result.stdout.strip()
                # Only reset failure counter if running a genuinely new version
                # (not a rollback to last_good_commit). This prevents the
                # circuit breaker from resetting between rollback cycles.
                previous_good = read_last_good_commit(self.repo_root)
                record_last_good_commit(self.repo_root, sha)
                if previous_good is None or sha != previous_good:
                    self._state.self_update_consecutive_failures = 0
                self._persist_state()
                self._good_commit_recorded = True
                logger.info("Recorded good commit after uptime: %s", sha[:10])
        except Exception:
            logger.debug("Failed to record good commit", exc_info=True)

    def _check_maintenance_budget(self) -> bool:
        """Return True if CI-fix operations are within the maintenance budget.

        Resets the maintenance spend counter if the date has rolled over.
        """
        today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        if self._state.maintenance_reset_date != today:
            self._state.daily_maintenance_spend_usd = 0.0
            self._state.maintenance_reset_date = today

        budget = self.daemon_config.maintenance_budget_usd
        if self._state.daily_maintenance_spend_usd >= budget:
            logger.info(
                "Maintenance budget exhausted ($%.2f/$%.2f), skipping CI-fix",
                self._state.daily_maintenance_spend_usd,
                budget,
            )
            return False
        return True

    # ------------------------------------------------------------------
    # State helpers
    # ------------------------------------------------------------------

    def _persist_state(self) -> None:
        """Persist daemon state atomically."""
        save_daemon_state(self.repo_root, self._state)

    def _persist_queue(self) -> None:
        """Persist queue state atomically."""
        queue_path = self.repo_root / ".colonyos" / "queue.json"
        archive_terminal_queue_items(self.repo_root, self._queue_state)
        atomic_write_json(queue_path, self._queue_state.to_dict())

    def _load_or_create_queue(self) -> QueueState:
        """Load existing queue state or create a fresh one."""
        queue_path = self.repo_root / ".colonyos" / "queue.json"
        if queue_path.exists():
            try:
                data = json.loads(queue_path.read_text(encoding="utf-8"))
                return QueueState.from_dict(data)
            except (json.JSONDecodeError, KeyError):
                logger.warning("Corrupt queue.json, starting fresh")
        return QueueState(queue_id=f"daemon-{int(time.time())}")

    def _pending_count(self) -> int:
        """Count pending items (user-sourced only, excludes CEO/cleanup)."""
        return sum(
            1
            for item in self._queue_state.items
            if item.status == QueueItemStatus.PENDING
            and item.source_type not in ("ceo", "cleanup")
        )

    def _is_duplicate(self, source_type: str, source_value: str) -> bool:
        """Check if an item with the same source already exists in a non-terminal state."""
        terminal = {QueueItemStatus.COMPLETED, QueueItemStatus.FAILED, QueueItemStatus.REJECTED}
        for item in self._queue_state.items:
            if (
                item.source_type == source_type
                and item.source_value == source_value
                and item.status not in terminal
            ):
                return True
        return False

    def get_health(self) -> dict[str, Any]:
        """Return health status for the /healthz endpoint."""
        with self._lock:
            allowed, remaining = self._state.check_daily_budget(self.daily_budget)
            cb_active = self._state.is_circuit_breaker_active()

            # Determine status
            if not allowed:
                status = "stopped"
            elif cb_active or self._state.paused:
                status = "degraded"
            else:
                status = "healthy"

            # Heartbeat age
            hb_age: float | None = None
            if self._state.last_heartbeat:
                try:
                    hb = datetime.fromisoformat(self._state.last_heartbeat)
                    if hb.tzinfo is None:
                        hb = hb.replace(tzinfo=timezone.utc)
                    hb_age = (datetime.now(timezone.utc) - hb).total_seconds()
                except (ValueError, TypeError):
                    pass

            pending = sum(
                1 for i in self._queue_state.items
                if i.status == QueueItemStatus.PENDING
            )

            # Pipeline duration and stall info
            running_item = self._current_running_item
            pipeline_started_at_iso: str | None = None
            pipeline_duration: float | None = None
            if running_item is not None and running_item.started_at is not None:
                pipeline_started_at_iso = running_item.started_at
            if self._pipeline_started_at is not None:
                pipeline_duration = time.monotonic() - self._pipeline_started_at

            return {
                "status": status,
                "heartbeat_age_seconds": hb_age,
                "queue_depth": pending,
                "daily_spend_usd": self._state.daily_spend_usd,
                "daily_budget_remaining_usd": remaining,
                "circuit_breaker_active": cb_active,
                "paused": self._state.paused,
                "pipeline_running": self._pipeline_running,
                "pipeline_started_at": pipeline_started_at_iso,
                "pipeline_duration_seconds": pipeline_duration,
                "pipeline_stalled": self._pipeline_stalled,
                "total_items_today": self._state.total_items_today,
                "consecutive_failures": self._state.consecutive_failures,
            }

    def _post_systemic_failure_alert(self, error_code: str, failures: int) -> None:
        """Post a Slack alert when the daemon auto-pauses due to systemic failures."""
        self._post_slack_message(
            f":rotating_light: *Daemon auto-paused (same error)*\n"
            f"{failures} consecutive failures with the same error: `{error_code}`\n"
            f"The daemon will not process any more items until manually resumed.\n"
            "Send `resume` in this channel to unpause.",
            critical=True,
        )

    def _post_circuit_breaker_cooldown_notice(
        self,
        consecutive_failures: int,
        activation_count: int,
        cooldown_until_iso: str,
    ) -> None:
        """Slack when the breaker trips into cooldown (not auto-pause)."""
        self._post_slack_message(
            f":electric_plug: *Circuit breaker cooldown*\n"
            f"After {consecutive_failures} consecutive failures, activation "
            f"#{activation_count}. Cooldown until `{cooldown_until_iso}` (ISO UTC). "
            f"Execution resumes automatically after that unless the daemon is paused.",
            critical=True,
        )

    def _post_circuit_breaker_escalation_pause_alert(
        self,
        activation_count: int,
        consecutive_failures: int,
    ) -> None:
        """Auto-pause after repeated breaker activations without a success (mixed errors)."""
        self._post_slack_message(
            f":rotating_light: *Daemon auto-paused (circuit breaker escalation)*\n"
            f"Breaker activation #{activation_count} without an intervening success "
            f"(last trip after {consecutive_failures} consecutive failures). "
            f"This is not the same-error systemic case.\n"
            "Send `resume` in this channel to unpause.",
            critical=True,
        )

    def _pause_for_pre_execution_blocker(self, item: QueueItem, reason: str) -> None:
        """Pause the daemon when pre-execution repo health blocks forward progress."""
        with self._lock:
            if self._state.paused or self._state.is_circuit_breaker_active():
                return
            self._state.paused = True
            self._state.circuit_breaker_until = None
            self._persist_state()

        incident_path = self._record_runtime_incident(
            label_prefix="daemon-preexec-blocked",
            summary=(
                f"{reason}\n\n"
                f"Pending item: {item.id}\n"
                f"Source type: {item.source_type}\n"
                f"Source value: {item.source_value[:500]}\n"
                "The daemon paused itself to avoid retrying the same blocked state indefinitely."
            ),
            metadata={
                "item_id": item.id,
                "source_type": item.source_type,
                "source_value": item.source_value[:500],
                "reason": reason,
            },
        )
        self._post_slack_message(
            ":rotating_light: *Daemon auto-paused before execution*\n"
            f"{reason}\n"
            f"Pending item: `{item.id}`\n"
            f"Incident summary: `{incident_path}`\n"
            "Fix the repo state, then send `resume` in Slack.",
            critical=True,
        )
