swarm-engine-core 0.1.6

Core types and orchestration for SwarmEngine
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
//! Blue-Green Deployment for LoRA Model Switching
//!
//! ## 概要
//!
//! 新しい LoRA アダプタを適用する際、ダウンタイムなしでサーバーを切り替える。
//!
//! ## フロー
//!
//! ```text
//! 1. 学習完了 → 新 LoRA 生成
//! 2. Standby サーバー起動(既存 LoRA + 新 LoRA)
//! 3. Standby が ready → active_endpoint 切り替え
//! 4. 旧 Active サーバー停止
//! ```
//!
//! ## 使用例
//!
//! ```ignore
//! use swarm_engine_core::learn::lora::{BlueGreenManager, BlueGreenConfig};
//!
//! let config = BlueGreenConfig::new("/path/to/model.gguf")
//!     .blue_port(8080)
//!     .green_port(8081);
//!
//! let manager = BlueGreenManager::new(config)?;
//!
//! // 新しい LoRA で切り替え
//! manager.switch_with_new_lora("/path/to/new_lora.gguf").await?;
//!
//! // 現在のエンドポイントを取得
//! let endpoint = manager.active_endpoint();
//! ```

use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::RwLock;
use std::time::Duration;

use tokio::process::Command;

use super::applicator::ApplicatorError;
use super::trainer::TrainedModel;

// ============================================================================
// ServerState
// ============================================================================

/// サーバーの状態
///
/// ## 状態遷移
///
/// ```text
/// Down → Active → Starting → Active → Releasing → Active → ...
///                    ↑                    ↓
///                    └────────────────────┘
/// ```
///
/// ## リクエスト可能性
///
/// | 状態 | リクエスト先 | is_available |
/// |------|-------------|--------------|
/// | Active | アクティブサーバー | true |
/// | Starting | 旧サーバー(まだ稼働中) | true |
/// | Releasing | 新サーバー(既に稼働中) | true |
/// | Down | なし | false |
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServerState {
    /// 正常稼働中
    Active,
    /// 新サーバー起動中(旧サーバーはまだ稼働中、リクエスト可能)
    Starting,
    /// 旧サーバー停止中(新サーバーは既に稼働中、リクエスト可能)
    Releasing,
    /// 両方停止
    Down,
}

impl ServerState {
    /// リクエスト可能かどうか
    pub fn is_available(&self) -> bool {
        matches!(self, Self::Active | Self::Starting | Self::Releasing)
    }
}

// ============================================================================
// SwitchingBehavior
// ============================================================================

/// 切り替え中のリクエスト挙動
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum SwitchingBehavior {
    /// 即座に失敗を返す(デフォルト)
    ///
    /// 切り替え中は通常の障害と同様に扱い、即座にエラーを返す。
    /// Swarm 側でリトライや代替処理を行う。
    #[default]
    FailImmediately,

    /// 新サーバーが ready になるまで待機
    ///
    /// 切り替え中のリクエストは、新サーバーが ready になるまでブロックする。
    /// タイムアウトを超えた場合はエラーを返す。
    WaitForReady {
        /// 待機タイムアウト(秒)
        timeout_secs: u64,
    },
}

impl SwitchingBehavior {
    /// 待機モードを作成(デフォルト: 30秒)
    pub fn wait() -> Self {
        Self::WaitForReady { timeout_secs: 30 }
    }

    /// 待機モードを作成(タイムアウト指定)
    pub fn wait_with_timeout(timeout_secs: u64) -> Self {
        Self::WaitForReady { timeout_secs }
    }
}

// ============================================================================
// BlueGreenConfig
// ============================================================================

/// Blue-Green 設定
#[derive(Debug, Clone)]
pub struct BlueGreenConfig {
    /// ベースモデルのパス (GGUF)
    pub base_model_path: PathBuf,
    /// ホスト
    pub host: String,
    /// Blue サーバーのポート
    pub blue_port: u16,
    /// Green サーバーのポート
    pub green_port: u16,
    /// GPU レイヤー数
    pub n_gpu_layers: u32,
    /// コンテキストサイズ
    pub ctx_size: u32,
    /// 並列スロット数
    pub parallel: u32,
    /// データディレクトリ(PID/ログファイル用)
    pub data_dir: PathBuf,
    /// llama-server コマンドパス
    pub server_path: String,
    /// サーバー起動タイムアウト(秒)
    pub startup_timeout_secs: u64,
    /// 切り替え中のリクエスト挙動
    pub switching_behavior: SwitchingBehavior,
}

impl Default for BlueGreenConfig {
    fn default() -> Self {
        let data_dir = dirs::data_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join("swarm-engine")
            .join("blue-green");

        Self {
            base_model_path: PathBuf::new(),
            host: "127.0.0.1".to_string(),
            blue_port: 8080,
            green_port: 8081,
            n_gpu_layers: 99,
            ctx_size: 4096,
            parallel: 4,
            data_dir,
            server_path: "llama-server".to_string(),
            startup_timeout_secs: 60,
            switching_behavior: SwitchingBehavior::default(),
        }
    }
}

impl BlueGreenConfig {
    /// 新しい設定を作成
    pub fn new(base_model_path: impl Into<PathBuf>) -> Self {
        Self {
            base_model_path: base_model_path.into(),
            ..Default::default()
        }
    }

    /// ホストを設定
    pub fn host(mut self, host: impl Into<String>) -> Self {
        self.host = host.into();
        self
    }

    /// Blue ポートを設定
    pub fn blue_port(mut self, port: u16) -> Self {
        self.blue_port = port;
        self
    }

    /// Green ポートを設定
    pub fn green_port(mut self, port: u16) -> Self {
        self.green_port = port;
        self
    }

    /// GPU レイヤー数を設定
    pub fn n_gpu_layers(mut self, n: u32) -> Self {
        self.n_gpu_layers = n;
        self
    }

    /// 並列スロット数を設定
    pub fn parallel(mut self, n: u32) -> Self {
        self.parallel = n;
        self
    }

    /// データディレクトリを設定
    pub fn data_dir(mut self, path: impl Into<PathBuf>) -> Self {
        self.data_dir = path.into();
        self
    }

    /// サーバーパスを設定
    pub fn server_path(mut self, path: impl Into<String>) -> Self {
        self.server_path = path.into();
        self
    }

    /// 切り替え中の挙動を設定
    pub fn switching_behavior(mut self, behavior: SwitchingBehavior) -> Self {
        self.switching_behavior = behavior;
        self
    }

    /// 切り替え中は待機するモードに設定
    pub fn wait_during_switch(mut self) -> Self {
        self.switching_behavior = SwitchingBehavior::wait();
        self
    }

    /// 切り替え中は待機するモードに設定(タイムアウト指定)
    pub fn wait_during_switch_with_timeout(mut self, timeout_secs: u64) -> Self {
        self.switching_behavior = SwitchingBehavior::wait_with_timeout(timeout_secs);
        self
    }

    /// Blue の PID ファイルパス
    fn blue_pid_file(&self) -> PathBuf {
        self.data_dir.join("blue.pid")
    }

    /// Green の PID ファイルパス
    fn green_pid_file(&self) -> PathBuf {
        self.data_dir.join("green.pid")
    }

    /// Blue のログファイルパス
    fn blue_log_file(&self) -> PathBuf {
        self.data_dir.join("blue.log")
    }

    /// Green のログファイルパス
    fn green_log_file(&self) -> PathBuf {
        self.data_dir.join("green.log")
    }
}

// ============================================================================
// BlueGreenManager
// ============================================================================

/// Blue-Green デプロイメントマネージャ
pub struct BlueGreenManager {
    /// 設定
    config: BlueGreenConfig,
    /// 現在 Blue がアクティブか(false = Green)
    active_is_blue: AtomicBool,
    /// サーバー状態
    state: RwLock<ServerState>,
    /// 現在ロード済みの LoRA パス一覧
    loaded_loras: RwLock<Vec<PathBuf>>,
}

impl BlueGreenManager {
    /// 新しい Manager を作成
    pub fn new(config: BlueGreenConfig) -> Result<Self, ApplicatorError> {
        // データディレクトリ作成
        std::fs::create_dir_all(&config.data_dir)?;

        Ok(Self {
            config,
            active_is_blue: AtomicBool::new(true),
            state: RwLock::new(ServerState::Down),
            loaded_loras: RwLock::new(Vec::new()),
        })
    }

    /// 設定を取得
    pub fn config(&self) -> &BlueGreenConfig {
        &self.config
    }

    /// 現在のサーバー状態を取得
    pub fn state(&self) -> ServerState {
        self.state.read().unwrap().clone()
    }

    /// 現在リクエストを送るべきエンドポイントを取得
    ///
    /// 状態に応じて適切なサーバーを返す:
    /// - Active: アクティブサーバー
    /// - Starting: 旧サーバー(まだ稼働中)
    /// - Releasing: 新サーバー(既に稼働中)
    /// - Down: アクティブサーバー(実際は利用不可)
    pub fn active_endpoint(&self) -> String {
        let state = self.state();
        let is_blue_active = self.active_is_blue.load(Ordering::SeqCst);

        let port = match state {
            ServerState::Active | ServerState::Down => {
                // 通常時: アクティブサーバー
                if is_blue_active {
                    self.config.blue_port
                } else {
                    self.config.green_port
                }
            }
            ServerState::Starting => {
                // 新サーバー起動中: 旧サーバーにリクエスト
                // active_is_blue はまだ切り替わっていない
                if is_blue_active {
                    self.config.blue_port
                } else {
                    self.config.green_port
                }
            }
            ServerState::Releasing => {
                // 旧サーバー停止中: 新サーバーにリクエスト
                // active_is_blue は既に切り替わっている
                if is_blue_active {
                    self.config.blue_port
                } else {
                    self.config.green_port
                }
            }
        };
        format!("http://{}:{}", self.config.host, port)
    }

    /// Standby(次に起動する側)のエンドポイントを取得
    pub fn standby_endpoint(&self) -> String {
        let port = if self.active_is_blue.load(Ordering::SeqCst) {
            self.config.green_port
        } else {
            self.config.blue_port
        };
        format!("http://{}:{}", self.config.host, port)
    }

    /// 初期起動(Blue を起動)
    pub async fn start(&self, loras: &[PathBuf]) -> Result<(), ApplicatorError> {
        tracing::info!(
            loras = ?loras,
            port = self.config.blue_port,
            "Starting Blue server"
        );

        self.start_server(true, loras).await?;
        self.active_is_blue.store(true, Ordering::SeqCst);
        *self.state.write().unwrap() = ServerState::Active;
        *self.loaded_loras.write().unwrap() = loras.to_vec();

        Ok(())
    }

    /// 新しい LoRA を追加して切り替え
    pub async fn switch_with_new_lora(&self, new_lora: &Path) -> Result<(), ApplicatorError> {
        // 現在の LoRA リストに新しいものを追加
        let mut new_loras = self.loaded_loras.read().unwrap().clone();
        if !new_loras.iter().any(|p| p == new_lora) {
            new_loras.push(new_lora.to_path_buf());
        }

        self.switch_with_loras(&new_loras).await
    }

    /// 指定した LoRA セットで切り替え
    ///
    /// ## 状態遷移
    ///
    /// ```text
    /// Active → Starting → Active → Releasing → Active
    ///          (新起動)   (切替)   (旧停止)
    /// ```
    ///
    /// Starting/Releasing 中もリクエストは処理可能(ダウンタイムなし)
    pub async fn switch_with_loras(&self, loras: &[PathBuf]) -> Result<(), ApplicatorError> {
        let is_blue_active = self.active_is_blue.load(Ordering::SeqCst);
        let standby_name = if is_blue_active { "Green" } else { "Blue" };
        let old_name = if is_blue_active { "Blue" } else { "Green" };

        tracing::info!(
            standby = standby_name,
            loras = ?loras,
            "Starting standby server for switch"
        );

        // Phase 1: Starting(新サーバー起動中、旧サーバーはまだ稼働)
        *self.state.write().unwrap() = ServerState::Starting;

        // Standby サーバーを起動(新しい LoRA セットで)
        if let Err(e) = self.start_server(!is_blue_active, loras).await {
            // 起動失敗 → Active に戻す
            *self.state.write().unwrap() = ServerState::Active;
            tracing::error!(error = %e, "Failed to start standby server");
            return Err(e);
        }

        // Phase 2: エンドポイント切り替え(アトミック)
        tracing::info!(
            new_active = standby_name,
            "Standby ready, switching active endpoint"
        );
        self.active_is_blue.store(!is_blue_active, Ordering::SeqCst);
        *self.state.write().unwrap() = ServerState::Active;

        // Phase 3: Releasing(旧サーバー停止中、新サーバーは稼働中)
        *self.state.write().unwrap() = ServerState::Releasing;
        tracing::info!(old = old_name, "Stopping old server");

        if let Err(e) = self.stop_server(is_blue_active).await {
            tracing::warn!(error = %e, "Failed to stop old server (continuing anyway)");
        }

        // Phase 4: 完了
        *self.loaded_loras.write().unwrap() = loras.to_vec();
        *self.state.write().unwrap() = ServerState::Active;

        tracing::info!(
            active_endpoint = %self.active_endpoint(),
            "Switch completed"
        );

        Ok(())
    }

    /// TrainedModel を使って切り替え
    pub async fn switch_with_model(&self, model: &TrainedModel) -> Result<(), ApplicatorError> {
        self.switch_with_new_lora(&model.adapter_path).await
    }

    /// 両サーバーを停止
    pub async fn stop_all(&self) -> Result<(), ApplicatorError> {
        tracing::info!("Stopping all servers");

        let _ = self.stop_server(true).await;
        let _ = self.stop_server(false).await;

        *self.state.write().unwrap() = ServerState::Down;
        Ok(())
    }

    /// サーバーを起動(内部)
    async fn start_server(&self, is_blue: bool, loras: &[PathBuf]) -> Result<(), ApplicatorError> {
        let (port, pid_file, log_file, name) = if is_blue {
            (
                self.config.blue_port,
                self.config.blue_pid_file(),
                self.config.blue_log_file(),
                "Blue",
            )
        } else {
            (
                self.config.green_port,
                self.config.green_pid_file(),
                self.config.green_log_file(),
                "Green",
            )
        };

        // ベースモデル存在確認
        if !self.config.base_model_path.exists() {
            return Err(ApplicatorError::Other(format!(
                "Base model not found: {}",
                self.config.base_model_path.display()
            )));
        }

        // LoRA アダプタ存在確認
        for lora in loras {
            if !lora.exists() {
                return Err(ApplicatorError::AdapterNotFound(lora.clone()));
            }
        }

        // コマンド構築
        let mut cmd = Command::new(&self.config.server_path);
        cmd.args([
            "-m",
            self.config.base_model_path.to_str().unwrap(),
            "--host",
            &self.config.host,
            "--port",
            &port.to_string(),
            "-ngl",
            &self.config.n_gpu_layers.to_string(),
            "-c",
            &self.config.ctx_size.to_string(),
            "-np",
            &self.config.parallel.to_string(),
            "--cont-batching",
        ]);

        // LoRA アダプタを追加(--lora-init-without-apply で scale=0 初期化)
        if !loras.is_empty() {
            cmd.arg("--lora-init-without-apply");
            for lora in loras {
                cmd.args(["--lora", lora.to_str().unwrap()]);
            }
        }

        // ログファイル
        let log = std::fs::File::create(&log_file)?;
        let log_err = log.try_clone()?;

        cmd.stdout(Stdio::from(log));
        cmd.stderr(Stdio::from(log_err));

        // 起動
        match cmd.spawn() {
            Ok(child) => {
                let pid = child.id().unwrap_or(0);
                tokio::fs::write(&pid_file, pid.to_string()).await?;

                tracing::info!(
                    name,
                    pid,
                    port,
                    loras = loras.len(),
                    "Server process started, waiting for ready"
                );

                // Ready 待機
                self.wait_for_ready(port).await?;

                tracing::info!(name, port, "Server is ready");
                Ok(())
            }
            Err(e) => Err(ApplicatorError::ServerStartFailed(format!(
                "{} server start failed: {}",
                name, e
            ))),
        }
    }

    /// サーバーを停止(内部)
    async fn stop_server(&self, is_blue: bool) -> Result<(), ApplicatorError> {
        let (pid_file, name) = if is_blue {
            (self.config.blue_pid_file(), "Blue")
        } else {
            (self.config.green_pid_file(), "Green")
        };

        if !pid_file.exists() {
            return Ok(());
        }

        let pid_str = tokio::fs::read_to_string(&pid_file).await?;
        let pid: u32 = pid_str
            .trim()
            .parse()
            .map_err(|_| ApplicatorError::ServerStopFailed("Invalid PID".to_string()))?;

        // SIGTERM 送信
        let status = Command::new("kill").arg(pid.to_string()).status().await?;

        if !status.success() {
            tracing::debug!(name, pid, "Process already stopped or kill failed");
        }

        // 少し待機
        tokio::time::sleep(Duration::from_millis(500)).await;

        // PID ファイル削除
        let _ = tokio::fs::remove_file(&pid_file).await;

        tracing::debug!(name, pid, "Server stopped");
        Ok(())
    }

    /// サーバーが ready になるまで待機(TCP 接続 + HTTP health check)
    async fn wait_for_ready(&self, port: u16) -> Result<(), ApplicatorError> {
        let addr = format!("{}:{}", self.config.host, port);

        let max_attempts = (self.config.startup_timeout_secs * 2) as usize; // 500ms間隔
        let delay = Duration::from_millis(500);

        // Phase 1: TCP 接続待機
        let mut tcp_connected = false;
        for attempt in 1..=max_attempts {
            tokio::time::sleep(delay).await;

            match tokio::net::TcpStream::connect(&addr).await {
                Ok(_) => {
                    tracing::debug!(attempt, port, "Server TCP connection established");
                    tcp_connected = true;
                    break;
                }
                Err(_) => {
                    tracing::trace!(attempt, port, "Waiting for server TCP...");
                }
            }
        }

        if !tcp_connected {
            return Err(ApplicatorError::ServerStartFailed(format!(
                "Timeout waiting for TCP connection on port {} ({}s)",
                port, self.config.startup_timeout_secs
            )));
        }

        // Phase 2: HTTP /health エンドポイント待機(warmup 完了まで)
        let health_url = format!("http://{}:{}/health", self.config.host, port);
        for attempt in 1..=max_attempts {
            tokio::time::sleep(delay).await;

            // 簡易 HTTP GET リクエスト(reqwest なしで実装)
            match Self::simple_http_get(&health_url).await {
                Ok(200) => {
                    tracing::debug!(attempt, port, "Server health check passed");
                    return Ok(());
                }
                Ok(status) => {
                    tracing::trace!(attempt, port, status, "Health check returned non-200");
                }
                Err(_) => {
                    tracing::trace!(attempt, port, "Health check failed");
                }
            }
        }

        Err(ApplicatorError::ServerStartFailed(format!(
            "Timeout waiting for health check on port {} ({}s)",
            port, self.config.startup_timeout_secs
        )))
    }

    /// 簡易 HTTP GET リクエスト(ステータスコードのみ取得)
    async fn simple_http_get(url: &str) -> Result<u16, std::io::Error> {
        use tokio::io::{AsyncReadExt, AsyncWriteExt};

        // URL をパース
        let url = url.strip_prefix("http://").unwrap_or(url);
        let (host_port, path) = url.split_once('/').unwrap_or((url, "health"));
        let path = format!("/{}", path);

        // TCP 接続
        let mut stream = tokio::net::TcpStream::connect(host_port).await?;

        // HTTP リクエスト送信
        let request = format!(
            "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
            path, host_port
        );
        stream.write_all(request.as_bytes()).await?;

        // レスポンス読み取り(最初の行だけ)
        let mut buf = [0u8; 256];
        let n = stream.read(&mut buf).await?;
        let response = String::from_utf8_lossy(&buf[..n]);

        // ステータスコード抽出: "HTTP/1.1 200 OK"
        if let Some(line) = response.lines().next() {
            let parts: Vec<&str> = line.split_whitespace().collect();
            if parts.len() >= 2 {
                if let Ok(status) = parts[1].parse::<u16>() {
                    return Ok(status);
                }
            }
        }

        Err(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            "Invalid HTTP response",
        ))
    }

    /// 現在ロード済みの LoRA 一覧を取得
    pub fn loaded_loras(&self) -> Vec<PathBuf> {
        self.loaded_loras.read().unwrap().clone()
    }

    /// Blue がアクティブかどうか
    pub fn is_blue_active(&self) -> bool {
        self.active_is_blue.load(Ordering::SeqCst)
    }

    /// 切り替え中の挙動設定を取得
    pub fn switching_behavior(&self) -> &SwitchingBehavior {
        &self.config.switching_behavior
    }

    /// サーバーが利用可能になるまで待機(Waiting モード用)
    ///
    /// Starting/Releasing 状態でもリクエスト可能なので、この関数は
    /// 主に Down 状態からの回復待機に使用。
    ///
    /// `SwitchingBehavior::WaitForReady` の場合、利用可能になるまで待機。
    /// `SwitchingBehavior::FailImmediately` の場合は即座に結果を返す。
    pub async fn wait_until_available(&self) -> Result<(), ApplicatorError> {
        match &self.config.switching_behavior {
            SwitchingBehavior::FailImmediately => {
                // 即座に現在の状態をチェック
                if self.state().is_available() {
                    Ok(())
                } else {
                    Err(ApplicatorError::Other(
                        "Server not available (down)".to_string(),
                    ))
                }
            }
            SwitchingBehavior::WaitForReady { timeout_secs } => {
                let max_attempts = (*timeout_secs * 2) as usize; // 500ms 間隔
                let delay = Duration::from_millis(500);

                for attempt in 1..=max_attempts {
                    if self.state().is_available() {
                        tracing::debug!(attempt, "Server is available");
                        return Ok(());
                    }
                    tracing::trace!(attempt, "Waiting for server to become available...");
                    tokio::time::sleep(delay).await;
                }

                Err(ApplicatorError::Other(format!(
                    "Timeout waiting for server availability ({}s)",
                    timeout_secs
                )))
            }
        }
    }
}

// ============================================================================
// EndpointResolver trait
// ============================================================================

/// 動的にエンドポイントを解決する trait
pub trait EndpointResolver: Send + Sync {
    /// 現在リクエストを送るべきエンドポイントを取得
    ///
    /// 状態に応じて適切なサーバーを返す(Starting 時は旧、Releasing 時は新)
    fn current_endpoint(&self) -> String;

    /// サーバーが利用可能かどうか
    ///
    /// Active, Starting, Releasing のいずれかであれば true(リクエスト処理可能)
    fn is_available(&self) -> bool;

    /// 切り替え中の挙動を取得
    fn switching_behavior(&self) -> SwitchingBehavior;
}

impl EndpointResolver for BlueGreenManager {
    fn current_endpoint(&self) -> String {
        self.active_endpoint()
    }

    fn is_available(&self) -> bool {
        self.state().is_available()
    }

    fn switching_behavior(&self) -> SwitchingBehavior {
        self.config.switching_behavior.clone()
    }
}

// ============================================================================
// Tests
// ============================================================================

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_config_builder() {
        let config = BlueGreenConfig::new("/path/to/model.gguf")
            .host("0.0.0.0")
            .blue_port(9000)
            .green_port(9001)
            .n_gpu_layers(50);

        assert_eq!(config.base_model_path, PathBuf::from("/path/to/model.gguf"));
        assert_eq!(config.host, "0.0.0.0");
        assert_eq!(config.blue_port, 9000);
        assert_eq!(config.green_port, 9001);
        assert_eq!(config.n_gpu_layers, 50);
    }

    #[test]
    fn test_pid_log_paths() {
        let config = BlueGreenConfig::new("/model.gguf").data_dir("/tmp/test");

        assert_eq!(config.blue_pid_file(), PathBuf::from("/tmp/test/blue.pid"));
        assert_eq!(
            config.green_pid_file(),
            PathBuf::from("/tmp/test/green.pid")
        );
        assert_eq!(config.blue_log_file(), PathBuf::from("/tmp/test/blue.log"));
        assert_eq!(
            config.green_log_file(),
            PathBuf::from("/tmp/test/green.log")
        );
    }

    #[test]
    fn test_endpoint_switching() {
        let config = BlueGreenConfig::new("/model.gguf")
            .host("127.0.0.1")
            .blue_port(8080)
            .green_port(8081);

        let manager = BlueGreenManager::new(config).unwrap();

        // 初期状態: Blue がアクティブ
        assert!(manager.is_blue_active());
        assert_eq!(manager.active_endpoint(), "http://127.0.0.1:8080");
        assert_eq!(manager.standby_endpoint(), "http://127.0.0.1:8081");

        // 手動で切り替え(テスト用)
        manager.active_is_blue.store(false, Ordering::SeqCst);

        assert!(!manager.is_blue_active());
        assert_eq!(manager.active_endpoint(), "http://127.0.0.1:8081");
        assert_eq!(manager.standby_endpoint(), "http://127.0.0.1:8080");
    }

    #[test]
    fn test_server_state() {
        let config = BlueGreenConfig::new("/model.gguf");
        let manager = BlueGreenManager::new(config).unwrap();

        // 初期状態: Down
        assert_eq!(manager.state(), ServerState::Down);
        assert!(!manager.state().is_available());

        // 手動で状態変更(テスト用)
        *manager.state.write().unwrap() = ServerState::Active;
        assert_eq!(manager.state(), ServerState::Active);
        assert!(manager.state().is_available());

        *manager.state.write().unwrap() = ServerState::Starting;
        assert_eq!(manager.state(), ServerState::Starting);
        assert!(manager.state().is_available()); // Starting でもリクエスト可能

        *manager.state.write().unwrap() = ServerState::Releasing;
        assert_eq!(manager.state(), ServerState::Releasing);
        assert!(manager.state().is_available()); // Releasing でもリクエスト可能
    }
}