# 自選 Relay — 詳細設計（Phase 1 實作規格）

- 日期：2026-06-15
- 配套高階文件：`Floating_Relay_Election_Design_20260615.md`
- 狀態：詳細設計（**待你 review，未動碼**）

## 採用的預設（可改）
| 參數 | 值 | 說明 |
|---|---|---|
| `LEASE_TTL` | 10s | mtime 凍結超過此值 → 判 leader 死 |
| `LEASE_RENEW` | 3s | leader 續租間隔（rewrite 租約） |
| `LEASE_POLL` | 2s | client 輪詢間隔 |
| `LOCK_STALE` | 15s | 鎖檔殘留超過此值 → 視為前手崩潰，可清掉 |
| token | 做 | stk.ini `RELAY_TOKEN`，保護 relay socket + 選舉 |
| 內嵌 relay | spawn `relay_server.exe` | 複用現成成品 |
| 租約檔 | `W:\socket\relay_leader.json` | 與既有 socket 檔同目錄 |

---

## 0. 實作環境調整（依實際碼修正）

- **Agent 是單執行緒**（hbp 未連 hbmt、無 `hb_threadStart`）→ **不開背景執行緒**。租約 tick **搭 `RelayListenLoop()` 既有迴圈**（`DO WHILE` + `RelayRecv(2000)` 每 ~2s 一拍，已在做 keepalive/heartbeat/tray-exit）。`LEASE_POLL=2` 對齊 RelayRecv timeout。
- **開關 `FLOATING_RELAY=1`**：opt-in。關閉時維持原有固定 RELAY_HOST / LAN 行為，零回歸。
- **重用 `GetLocalIP()`**（line 1728，ipconfig 取區網 IP）當租約 `ip`，不另寫 IP 列舉。
- 租約路徑 `W:\socket\relay_leader.json`（`SyncSocketDir` 既有 `W:\socket`）。
- **bootstrap/搶租約的 leader 活性判斷用 TCP ping**（不比絕對時間，免時鐘偏差）；**mtime-凍結偵測只用於執行中 client 偵測 leader 暴斃**（那時才有時間樣本，§2.3）。
- token 已存在 STATIC `s_cToken`（預設 "stk2026"）→ 直接接到 `RelayConnect` 註冊封包。
- Main() 重構：`RelayBootstrap()` 放 `DO WHILE` 迴圈內、`RelayListenLoop()` 前；角色變動時 `RelayListenLoop()` RETURN，迴圈重跑 bootstrap（非 tray-exit 不結束）。

## 1. 角色狀態機

```
        +-------------+
        |  BOOTSTRAP  |  啟動：讀租約決定走向
        +------+------+
               |
       租約有效且ping得到 ───────────────► CLIENT
               |                              ▲
       租約無/過期/ping不到                   │ leader健康(mtime在動)
               ▼                              │
        +-------------+   搶租約成功     +-----+------+
        |   CONTEND   |───────────────► |   LEADER   |
        +------+------+                 +-----+------+
               │ 搶輸(別人剛當選)              │ 無法續租 / 被搶 / 收到 SHUTDOWN
               └──────────► CLIENT            ▼
                                         降級+停 relay → BOOTSTRAP
```

- **CLIENT**：當一般 Agent，連現任 leader（區網 IP）。背景跑 `ClientPoll()` 監看租約。
- **LEADER**：本機跑 `relay_server.exe`，自己連 `127.0.0.1`；背景跑 `LeaseRenew()` 續租。
- **CONTEND**：短暫競爭態，搶租約。

---

## 2. 租約檔與鎖檔

### 2.1 兩個檔，職責分離
- `W:\socket\relay_leader.json` —— **已發布的租約**（誰是 leader / ip / port / epoch / renew_count）。leader 每 `LEASE_RENEW` **rewrite**（mtime 前進＝心跳）。
- `W:\socket\relay_leader.lock` —— **只在搶租約的臨界區短暫持有**，寫完租約立刻刪。**不長期持鎖**（避免 leader 崩潰後鎖永留）。

### 2.2 租約 JSON
```json
{ "kmsn":"9999-9999-99-99", "station":"S001",
  "ip":"192.168.1.10", "port":9530,
  "epoch":7, "renew_count":142 }
```
- `epoch`：每次**重新當選** +1（單調遞增）。client 用它判斷 leader 換人。
- `renew_count`：每次續租 +1。保證即使內容相同也讓檔案 mtime 前進（hb_MemoWrit 會更新 mtime）。

### 2.3 為何用 mtime 當新鮮度（跨機時鐘偏差解法）⭐
- 多台工作站時鐘不同步 → **不能**用「leader 寫絕對到期時間、client 比自己時鐘」。
- 改用：`relay_leader.json` 的 **mtime 由 W:\ 檔案伺服器蓋章（單一時鐘源）**。
- client 只判斷「**mtime 有沒有在前進**」+ 自己的**單調經過時間**：
  - 每次 poll 讀 mtime。mtime 變了 → leader 活著，重置本地計時。
  - mtime 連續凍結，且**本地經過時間** > `LEASE_TTL` → 判 leader 死。
- 全程不跨機比絕對時間 → 免疫時鐘偏差。
- 本地經過時間用 `Seconds()`，跨午夜用 `Abs()` 防護（沿用 memory 既有教訓）。

mtime 取得：`hb_vfTimeGet()` 或 `hb_FGetDateTime( cFile, @dDate, @cTime )`。

---

## 3. 核心流程（虛擬碼）

### 3.1 RelayBootstrap()（插在 Main() 讀 RELAY_HOST 後、RelayListenLoop() 前）
```harbour
STATIC FUNCTION RelayBootstrap()
   LOCAL hL, lOk
   DO WHILE .T.
      hL := ReadLeaderLease()                       // 讀 json，失敗回 NIL
      IF hL != NIL .AND. LeaseFresh( hL ) .AND. PingLeader( hL["ip"], hL["port"] )
         s_nRole        := ROLE_CLIENT
         s_cRelayHost   := hL["ip"]                  // 連 leader 區網 IP
         s_nLeaderEpoch := hL["epoch"]
         StartClientPoll()                           // 背景監看
         RETURN ROLE_CLIENT
      ENDIF
      // 無 leader / 過期 / ping 不到 → 搶
      IF AcquireLeaderLease()
         SpawnLocalRelay()
         s_nRole        := ROLE_LEADER
         s_cRelayHost   := "127.0.0.1"
         StartLeaseRenew()                           // 背景續租
         RETURN ROLE_LEADER
      ENDIF
      // 搶輸 → 別人剛當選，隨機退讓重讀
      hb_idleSleep( 0.2 + ( s_nStationSeq % 8 ) * 0.1 )   // 用 station 號散開,免驚群
   ENDDO
```

### 3.2 AcquireLeaderLease() — 原子搶租約
```harbour
STATIC FUNCTION AcquireLeaderLease()
   LOCAL hLock, hL
   // (1) 互斥建鎖檔：FO_CREAT|FO_EXCL，已存在就失敗
   hLock := hb_vfOpen( s_cLockPath, FO_CREAT + FO_EXCL + FO_WRITE )
   IF hLock == NIL
      // 鎖被持有 → 看是不是前手崩潰留下的殘鎖
      IF LockAge( s_cLockPath ) > LOCK_STALE
         hb_vfErase( s_cLockPath )                   // 清殘鎖,下輪重試
      ENDIF
      RETURN .F.
   ENDIF
   // (2) 進臨界區後再讀一次租約,確認沒人剛當上
   hL := ReadLeaderLease()
   IF hL != NIL .AND. LeaseFresh( hL ) .AND. PingLeader( hL["ip"], hL["port"] )
      hb_vfClose( hLock ) ; hb_vfErase( s_cLockPath )
      RETURN .F.                                     // 已有健康 leader,我退出
   ENDIF
   // (3) 我當選：寫租約 epoch+1
   WriteLeaderLease( ;
        IIF( hL == NIL, 1, hL["epoch"] + 1 ), ;      // epoch 單調遞增
        GetLocalLanIP(), s_nRelayPort, 0 )
   hb_vfClose( hLock ) ; hb_vfErase( s_cLockPath )    // 立刻放鎖(不長期持有)
   RETURN .T.
```

### 3.3 LeaseRenew()（LEADER 背景）
```harbour
STATIC FUNCTION LeaseRenew()
   DO WHILE s_nRole == ROLE_LEADER
      IF !RelayAlive()                               // 本機 relay_server.exe 還活?
         SpawnLocalRelay()                           // 死了重拉
      ENDIF
      IF !WriteLeaderLease( s_nLeaderEpoch, s_cLanIP, s_nRelayPort, ++s_nRenew )
         // W:\ 寫不進 → 失去資格,降級
         StopLocalRelay() ; s_nRole := ROLE_DEMOTE ; EXIT
      ENDIF
      hb_idleSleep( LEASE_RENEW )
   ENDDO
   IF s_nRole == ROLE_DEMOTE
      RelayBootstrap()                               // 重新決定角色
   ENDIF
```

### 3.4 ClientPoll()（CLIENT 背景）
```harbour
STATIC FUNCTION ClientPoll()
   LOCAL hL, cMtime
   s_cLastMtime  := LeaseMtime()
   s_nLastChgSec := Seconds()
   DO WHILE s_nRole == ROLE_CLIENT
      hb_idleSleep( LEASE_POLL )
      hL := ReadLeaderLease()
      IF hL == NIL ; LOOP ; ENDIF
      IF hL["epoch"] != s_nLeaderEpoch               // leader 換人
         s_nLeaderEpoch := hL["epoch"]
         s_cRelayHost   := hL["ip"]
         ReconnectRelay()                            // 斷舊連新
         s_cLastMtime   := LeaseMtime() ; s_nLastChgSec := Seconds()
         LOOP
      ENDIF
      cMtime := LeaseMtime()
      IF cMtime != s_cLastMtime                       // 心跳在動 → 健康
         s_cLastMtime := cMtime ; s_nLastChgSec := Seconds()
      ELSEIF Abs( Seconds() - s_nLastChgSec ) > LEASE_TTL   // 凍結逾時 → leader 死
         s_nRole := ROLE_CONTEND
         RelayBootstrap()                            // 自己可能當選
         EXIT
      ENDIF
   ENDDO
```

---

## 4. 內嵌 Relay 生命週期

- **SpawnLocalRelay()**：`hb_processOpen()` 啟動 `start_relay.bat`（它自動挑 A/B 最新版），detached。記住回傳 handle / 用 image 名追蹤。
  - 不用 `hb_run`（引號被 cmd 吃，memory 既有坑）→ 用 `hb_processOpen`/`hb_processRun`。
- **RelayAlive()**：TCP connect `127.0.0.1:9530` 成功即視為活。
- **StopLocalRelay()**：`hb_processClose(handle)`；保險再 `taskkill /IM relay_server*.exe /F`（**只殺本機**）。
- 降級時務必先停 relay 再讓出租約，避免雙 leader。

---

## 5. Token 握手（保護 relay socket）

- `relay_client.prg` 的 `RelayConnect()` 組註冊封包 `hReg` 時加 `"token" => cToken`（取自 stk.ini `RELAY_TOKEN`）。
- `relay_server.py` 的 register 處理（約 line 504 附近）：比對 `data.get('token')`，不符 → 回 `{cmd:'REJECT', reason:'bad token'}` 並關連線。
- 選舉本身靠 W:\ 寫入權（站內信任）；token 主要擋「區網冒充 Agent 連 relay socket」。

---

## 6. 邊界與處理

| 情況 | 處理 |
|---|---|
| leader 崩潰（沒優雅降級） | 租約 mtime 凍結 → client 經 `LEASE_TTL` 判死 → 重選 |
| 搶租約者崩潰（持鎖中掛） | `relay_leader.lock` 殘留 → `LOCK_STALE`(15s) 後被清 |
| W:\ 短暫斷 | client poll 讀失敗→LOOP 重試；leader 寫失敗→降級重判 |
| split-brain（網路分區雙 leader） | Phase 1：epoch 單調＋W:\ 單一真相，分區復原後低 epoch 那方續租時發現被超越→降級；Phase 2 UDP 補強即時偵測 |
| 時鐘偏差 | 全用 mtime 變化＋本地經過時間，不跨機比絕對時間（見 §2.3） |
| pid 重用 | 不靠 pid 判活，靠 TCP ping `127.0.0.1:9530` |
| 驚群（多台同時搶） | station 號散列退讓 0.2~1.0s + 互斥鎖只一人贏 |

---

## 7. 新增/修改清單

### 7.1 stk_agent.prg
新增 STATIC：`s_nRole, s_nLeaderEpoch, s_cLeasePath, s_cLockPath, s_cLanIP, s_cLastMtime, s_nLastChgSec, s_nRenew, s_nStationSeq, s_cToken`
新增函式：`RelayBootstrap / AcquireLeaderLease / ReadLeaderLease / WriteLeaderLease / LeaseFresh / LeaseMtime / LockAge / PingLeader / GetLocalLanIP / SpawnLocalRelay / StopLocalRelay / RelayAlive / LeaseRenew / ClientPoll / ReconnectRelay`
`Main()`：讀完設定後呼叫 `RelayBootstrap()` 決定角色，再進原 `RelayListenLoop()`（client/leader 都仍連 `s_cRelayHost`，差別只是 host 值）。

### 7.2 relay_client.prg
`RelayConnect()`：註冊封包加 `token`；（選配）加「優先直連 leader、失敗 fallback 雲端」外層。

### 7.3 relay_server.py
register 處理加 token 驗證（不符即 REJECT）。其餘 Phase 1 不動。

### 7.4 stk.ini（+ INI_EDITOR 模板同步）
`RELAY_TOKEN=`、`LEASE_TTL=10`、`LEASE_RENEW=3`、`LEASE_POLL=2`、`LOCK_STALE=15`（PRG 端 `Val(GetEnv)` + `<=0 預設保護`）。

### 7.5 佈署
`relay_server.exe`(A/B) + `start_relay.bat` 隨 Agent 佈到每台（stk_uploader 群組）。

---

## 8. 時序圖（文字）

### 8.1 冷啟動（全站剛開機、無 leader）
```
S001  S002  S003     W:\relay_leader.json
 |     |     |        (不存在)
 |--讀→無, 搶鎖(贏)----- create lock
 |--寫租約 epoch=1, ip=S001 ---------►  {S001,epoch1}
 |--放鎖, spawn relay, 續租 -------►(mtime↑每3s)
       |--讀→S001健康, ping OK → CLIENT 連 S001
             |--讀→S001健康 → CLIENT 連 S001
```

### 8.2 leader 死亡 → 自動轉移
```
S001(leader)挂   S002  S003        relay_leader.json
   X             |     |           {S001,epoch1} mtime 凍結
                 |--poll: mtime 不動… 累積到 TTL(10s) → 判死
                 |--搶鎖(贏)→ 寫 epoch=2, ip=S002 → spawn relay
                       |--poll: epoch 1→2 → 重連 S002
```

### 8.3 優雅降級（leader 收到 SHUTDOWN / 手動）
```
S001(leader): StopLocalRelay() → 不再續租 → 讓出
其他台: mtime 凍結 TTL 後重選（或 Phase2 UDP 立即通知縮短空窗）
```

---

## 9. 待你確認後即開工
1. §1 預設參數 OK？
2. 內嵌用 spawn `start_relay.bat`（挑 A/B）OK，或要 Agent 直接指定某 exe？
3. `GetLocalLanIP()` 取區網 IP 的方式：列舉網卡取 192.168/10./172.16 私網段第一個；多網段要不要 INI 指定優先網段？
