---
title: "Agent 只出站：主系統不再連 Agent，所有指令與檔案改由 Agent 長輪詢取回 — 需求討論稿 (FR-181)"
brand: "Guidant AI · **FR-181** Agent 只出站通道"
eyebrow: "FR-181 · 需求討論稿 v1 · 2026-11-07（D1～D8 已裁，定案在 design.md）"
h1: "Agent 只出站，客戶一個埠都不用開"
subtitle: "直推 8443 退役；Agent 用長輪詢向主系統取指令，同步問答（測試連線、證據檔、查日誌）也走同一條；心跳維持只表存活"
lede: "決策者 11-07 確認：**客戶網路不可能讓主系統連進 Agent**。FR-171（11-02 合回）把派工、重啟、升級、取消、換證、設定通知全改成主系統直推 Agent 8443，而證據檔與遠端查日誌從 FR-039 起就是直推——在這種客戶，**主系統叫 Agent 做事的功能一個都送不到**。本稿提出：主系統不再主動連 Agent，Agent 用**長輪詢**（一個請求吊著最多 30 秒，有事立刻回）向主系統取指令，八種用途全部改走這條；直推整個退役，不留兩套。這不是回到 7 月的「心跳夾帶工單」——心跳維持 11-01 裁定的只表存活，指令是獨立端點。"
chips: [
  {text: "決策者已裁：客戶不開入站埠，確定", kind: crit},
  {text: "決策者已裁：直推退役、不留兩套", kind: ok},
  {text: "決策者已裁：同步問答（證據檔、查日誌）也走輪詢", kind: ok},
  {text: "D1～D8 已裁，定案見 design.md", kind: ok},
  {text: "心跳只表存活（11-01 裁示）不翻", kind: plain},
  {text: "跨 4 repo：BE／jedi-packages／agent／FE", kind: accent},
]
footer: "FR-181 需求討論稿 v1 · 首腦撰寫 · 2026-11-07 · 推翻 FR-170 §3.x「落地版直推一定通」前提與 FR-171 A 指令通道；FR-171 其餘（身分、換證、金鑰登記簿、設定通道）全部保留"
---

## 0. 分工概述（30 秒版） {#overview nav="概述"}

**整案一句話**：主系統不再連 Agent；Agent 開一條長輪詢去主系統問「有事嗎」，有事主系統立刻回、Agent 做完把結果送回來；原本八種「主系統連 Agent」的用途全部改走這條；直推與 Agent 8443 的指令端點退役。

| 階段 | 做什麼 | 產出 | 完成怎麼判定（決策者檢查法） |
|---|---|---|---|
| P1 地基 | 契約定「取指令」與「回結果」的形狀；主系統開 `GET /agents/commands` 長輪詢端點與「等回答插槽」；Agent 開長輪詢背景元件 | jedi-agent-protocol 0.3.0、jedi-remote-agent、Agent `command_pull.py` | 在 123 Agent 主機把防火牆設成**只准出站**（`iptables` 擋 8443 入站），主系統派一張掃描單，Agent 主控台 3 秒內看到開跑 |
| P2 指令類搬家 | 派工、重啟、升級、取消檢測、立即換證、設定變更通知六種改成從長輪詢回應觸發 | 主系統側 `_agent_commands.py` 改寫、Agent 側六個 handler 改接 | 同上防火牆設定，管理頁按重啟／升級／取消／立即換證／改授權清冊，Agent 各在 3 秒內反應 |
| P3 同步問答搬家 | 測試連線、工具帳密、證據檔上傳下載轉 PDF、遠端查日誌改成「排請求 → Agent 取走做 → 結果回來 → 喚醒等著的瀏覽器請求」 | 等回答插槽接上四種用途 | 同上防火牆設定，按「測試連線」2 秒內看到結果；證據存 Agent 側的租戶上傳一支檔、預覽、下載都正常；支援頁查 Agent 日誌查得到 |
| P4 退役 | 刪 `AgentDataPlaneClient` 直推路徑、Agent 8443 的六個指令 blueprint、`last_command_status`、FE「連線」欄的「僅單向」狀態 | 守衛測試：主系統程式碼沒有任何地方連 Agent 8443 | `grep -rn "8443\|data_plane_client\|AgentDataPlaneClient"` 主系統與套件為 0（8444 瀏覽器直傳除外）；Agent 8443 只剩 `/health`、`/agent-info` |

**依賴一句**：P1 是全部的前提；P2 與 P3 可平行；P4 等 P2、P3 全部驗完才做。**決策者功課**：D1～D8。

---

## 1. 為什麼要做：直推的前提被推翻了 {#why nav="為什麼"}

### 1.1 時間軸：三個月內換了方向，前提卻是錯的

| 日期 | 事件 | 主系統 → Agent 的連線方向 |
|---|---|---|
| 2026-07-26 | FR-056 T-3.2：心跳回應夾帶 `pending_tasks`，Agent 每 60 秒來拿 | **只有 Agent 出站** |
| 2026-06～10 | FR-039 證據檔 Agent、FR-056.5 測試連線、FR-058 工具帳密：主系統直連 Agent 8443 | 檔案與同步問答**要主系統連進來**；指令仍出站 |
| 2026-10-29 | FR-170 §3.x 決策者裁：「落地版主系統與 Agent 同在客戶內網，直推一定通；SaaS 版才需要 WebSocket」 | 以「同內網一定通」為前提 |
| 2026-11-01 | 決策者裁「心跳不再帶任何訊息」 | — |
| 2026-11-02 | FR-171 E-0b（`844b273c2`）：指令全改直推 8443，心跳指令鍵拔掉、套件加守衛「宿主不得借 extras 加回來」 | **六種指令全部要主系統連進來** |
| 2026-11-07 | 決策者確認：客戶網路**不可能**讓主系統連進 Agent | 前提推翻 |

結論：FR-171 把一個本來在單向網路能跑的機制，換成了不能跑的。而且 FR-039 以來的檔案與同步問答路徑，從來沒在單向網路跑過。

### 1.2 現在單向網路下壞掉的八種功能

主系統主動連 Agent 8443 的用途，開檔逐支盤出來共八種：

| # | 用途 | 主系統側出口 | Agent 側入口 | 性質 |
|---|---|---|---|---|
| 1 | 派工 | `core/plugins/_agent_commands.py` → `AgentCommandService.dispatch_new_tasks` | `sync/api/cmd_tasks.py` `POST /tasks` | 指令，主系統有正本（`agent_tasks` pending） |
| 2 | 重啟 | `push_restart_after` | `cmd_restart.py` `POST /restart` | 指令，有正本（`restart_requested`） |
| 3 | 升級 | `push_update_soon` | `cmd_update.py` `POST /update` | 指令，有正本（rollout 表） |
| 4 | 設定變更通知 | `_agent_config_notify.py` | `cmd_config.py` `POST /config/notify` | 通知，Agent 本來就會拉 `GET /agents/config` |
| 5 | 立即換證 | `_agent_keys.py:118` 直接 `send` | `cmd_credentials.py` `POST /credentials/renew` | 指令，**沒有正本**（只發不記） |
| 6 | 取消檢測 | jedi-detection `agent_cancel_client.py` | `scan/api` `POST /detection/cancel` | 指令，**沒有正本** |
| 7 | 測試連線、工具帳密 | jedi-detection `agent_probe_client.py`、`agent_credential_client.py` | `/detection/probe`、`/detection/credentials` | **同步問答**：管理員按鈕要當下看到結果 |
| 8 | 證據檔上傳／下載／轉 PDF、遠端查日誌 | `infra/upload_file/remote_agent_adapter.py`、FR-178 `/logs` | `files/api/blob.py`、`sync/api/cmd_logs.py` | **同步資料往返**：瀏覽器要當下拿到 bytes |

1～6 改成「Agent 定時來拉」就通；7、8 要「主系統當下拿到回答」，固定間隔的輪詢不夠——這就是為什麼本稿提**長輪詢**而不是定時輪詢（§3）。

### 1.3 心跳補推為什麼救不了

FR-171 D3 設計「直推打不通就等心跳回來補推」，很多人以為這代表單向網路可以用。實查 `AgentCommandService.redispatch_pending()`：補推走的是**同一支 `AgentDataPlaneClient.command()`，同樣連 Agent 8443**。單向網路下每一跳的補推都失敗，單永遠 pending，管理頁「連線」欄永遠顯示「僅單向」。設計當時給的解法是 hover 提示「請確認 Agent 主機的 8443 埠對主系統開放」——也就是把問題交給客戶開埠，而客戶不會開。

---

## 2. 可以選的路，為什麼選長輪詢 {#options nav="選項"}

| 做法 | 怎麼運作 | 7、8 同步問答 | 新元件 | 判斷 |
|---|---|---|---|---|
| A 固定間隔輪詢 | Agent 每 N 秒問一次 | ❌ 最壞等 N 秒，使用者早以為壞了 | 無 | 只解 1～6，決策者已裁 7、8 也要通，**不採** |
| **B 長輪詢** | Agent 打 `GET /agents/commands`，主系統沒事就吊著最多 30 秒、有事立刻回；Agent 處理完馬上再打下一個 | ✅ 1 秒內送達 | 主系統一個「等回答插槽」（Redis pub/sub） | **採用**。HTTP 一條請求、穿 proxy 沒問題、Flask／gunicorn 直接撐、Agent 側 `config_pull.py` 骨架可延伸 |
| C WebSocket 出站長連線 | Agent 連出來掛著，主系統往下推 | ✅ | BE 要多一個連線管理元件（FR-170 列第二期）、Agent 要自建保活與重連、穿 proxy 常斷 | 比 B 多一點即時、多很多元件；B 能做到 C 八成功能，**不採** |
| D 回到 7 月的心跳夾帶 | 把 `pending_tasks` 塞回心跳回應 | ❌ 同 A | 無 | 違反 11-01 裁示「心跳只表存活」，且當時 8 KB 全量重送的病會回來，**不採** |
| E MQTT／message broker | 客戶機上多跑一個 broker | ✅ | broker 行程、身分體系另接 | 沒有 IT 的客戶多一個會壞的東西，**不採**（FR-171 §3.1 已排除） |

業界對照：Datadog、CrowdStrike、Tailscale 的 agent 全部只出站；GitHub Actions self-hosted runner 就是長輪詢（runner 打 GitHub 問「有 job 嗎」，吊 50 秒）；Chef／Puppet 定時 pull。長輪詢是「客戶不開埠」這個前提下最少元件的正規做法。

---

## 3. 設計：一條長輪詢、一個等回答插槽 {#design nav="設計"}

### 3.1 端到端：指令怎麼到 Agent

```{.mermaid cap="圖 1 — 長輪詢取指令。Agent 永遠是發起方；主系統沒事就吊著，有事立刻回。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
  autonumber
  participant A as Agent（長輪詢元件）
  participant M as 主系統 API
  participant Q as 指令佇列（DB 正本＋Redis 喚醒）
  participant U as 管理員瀏覽器
  A->>M: GET /agents/commands?wait=30（通行證＋DPoP）
  M->>Q: 查這台有沒有待辦
  Note over M: 沒有 → 訂閱 Redis 頻道 agent:<uid>，吊著
  U->>M: 按「派工」（或重啟／升級／取消／換證／改設定）
  M->>Q: 寫 DB 正本（工單 pending／restart_requested／…）＋ publish agent:<uid>
  Q-->>M: 喚醒
  M-->>A: 200 {commands:[{kind:"task", task_uid, payload}, …]}
  Note over A: 逐條交給既有 handler（cmd_tasks／cmd_restart／…的 app 層函式）
  A->>M: POST /agents/tasks/<uid>/ack（既有）
  M->>Q: status=dispatched
  A->>M: GET /agents/commands?wait=30（立刻再掛一條）
  Note over M,A: 30 秒沒事 → 204，Agent 立刻再掛
```

**正本仍在主系統 DB**（FR-171 D3 的原則不變）：工單表、`restart_requested`、rollout 表。Redis 只是「有新東西，醒來查表」的鈴，鈴沒響（Redis 重啟、publish 漏了）最壞 30 秒逾時後 Agent 再掛一條時查表也會查到。**沒有第二個佇列。**

**三道防撞**沿用 FR-171：task uid 冪等、ack 即 `dispatched`、Agent 本機單執行緒佇列。長輪詢只有一個觸發點（不像直推＋補推兩個），反而更簡單。

### 3.2 端到端：同步問答怎麼走（7、8）

```{.mermaid cap="圖 2 — 等回答插槽。瀏覽器那條請求吊著等，Agent 的結果一進來就喚醒它。測試連線、證據檔、查日誌都用這同一個插槽。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
  autonumber
  participant U as 管理員瀏覽器
  participant M as 主系統 API（worker X）
  participant R as Redis
  participant M2 as 主系統 API（worker Y）
  participant A as Agent
  U->>M: POST 測試連線 {agent_uid, target}
  M->>M: 寫 agent_requests（kind=probe, status=waiting, request_id）
  M->>R: publish agent:<uid>；subscribe reply:<request_id>
  Note over M: 吊著等，最多 30 秒
  A->>M2: GET /agents/commands（剛好掛在 worker Y）
  M2-->>A: {commands:[{kind:"probe", request_id, target}]}
  A->>A: 跑 probe
  A->>M2: POST /agents/replies/<request_id> {result}
  M2->>M2: agent_requests status=done, result 落 DB
  M2->>R: publish reply:<request_id>
  R-->>M: 喚醒
  M->>M: 讀 agent_requests.result
  M-->>U: 200 {reachable: true, …}
```

**為什麼要 Redis**：gunicorn 四個 worker 是獨立進程，瀏覽器的請求吊在 worker X、Agent 的回覆打到 worker Y，進程內的 Event 喚不醒對方。專案已有 Redis（`core/extensions.py` `redis_client`），用它的 pub/sub 跨進程喚醒。**結果本身落 DB**（`agent_requests.result`），Redis 只傳「好了」——Redis 掉訊息最壞 30 秒逾時時再查一次 DB。

**逾時**：瀏覽器那條吊 30 秒沒等到就回 504「Agent 無回應」，`agent_requests` 留 `waiting`，Agent 晚到的回覆照樣落 DB（可查，不浪費）。

**證據檔（8a）的特例**：bytes 不能走 JSON 回覆。

| 操作 | 現況（主系統直連） | 改法 |
|---|---|---|
| 上傳到 Agent | 主系統收檔 → `POST /blob` 推給 Agent | 主系統收檔**暫存本機**（`/tmp/agent-outbound/<request_id>`）→ 排 `fetch_file` 指令 → Agent 取走（`GET /agents/files/<request_id>`，既有通行證）→ 回覆已存（含 agent file uid、sha256）→ 主系統寫參照 row、刪暫存 |
| 下載／預覽 | 主系統 `GET /blob/<uid>` 拿回 bytes 串給瀏覽器 | 排 `send_file` 指令 → Agent `POST /agents/replies/<request_id>`（multipart）把檔上傳 → 主系統落暫存 → 喚醒 → 串給瀏覽器 → 刪暫存 |
| 轉 PDF | 主系統 `GET /blob/<uid>/pdf` | 同下載，Agent 側先轉再送 |
| 刪除 | `DELETE /blob/<uid>` | 排 `delete_file` 指令，不等回答（fire-and-forget，Agent 回覆只記 log） |

暫存落主系統磁碟是 FR-039「binary 不落雲端」的例外——那是 SaaS 的要求，落地版主系統本來就在客戶內網（D4 待裁）。

### 3.3 八種用途的歸宿

| # | 用途 | 長輪詢回應的 `kind` | Agent 側接哪支既有函式（extend，不新寫） | 要不要回答 |
|---|---|---|---|---|
| 1 | 派工 | `task` | `cmd_tasks._parse` 後的 `task_queue.enqueue` | ack（既有端點） |
| 2 | 重啟 | `restart` | `ops.request_restart` | 不用 |
| 3 | 升級 | `update` | `update_sync.trigger` | 升級事件（既有） |
| 4 | 設定變更 | `config_changed` | `config_pull.pull_now` | 不用 |
| 5 | 立即換證 | `credentials_renew` | `cert_renew.run_now` | 不用（換證結果走既有事件） |
| 6 | 取消檢測 | `detection_cancel` | 既有 cancel route 的 app 層函式 | 不用 |
| 7a | 測試連線 | `probe` | 既有 probe route 的 app 層函式 | **要**，`agent_requests` |
| 7b | 工具帳密 | `credentials_set` | 既有 credentials route 的 app 層函式 | **要**（存好才算） |
| 8a | 證據檔四操作 | `fetch_file`／`send_file`／`send_pdf`／`delete_file` | `jedi-file-upload` 的 service（blob route 現在呼叫的那幾支） | **要**（刪除除外） |
| 8b | 遠端查日誌 | `query_logs` | `audit_log.list_logs` | **要** |

**主系統側的正本**：1～4 用既有表；5、6 沒有正本，**併進新表 `agent_requests`**（kind、agent_id、payload、status waiting／taken／done／expired、result、request_id、建立時間）——7、8 也用這張表。也就是說**只新增一張表**，五種「沒正本的指令」與「要回答的請求」共用；1～4 不搬進去（它們的狀態機各自更複雜，硬併等於重寫）。

### 3.4 長輪詢回應的形狀（契約草案）

```json
GET /api/1.0/agents/commands?wait=30
→ 200 {
  "commands": [
    {"kind": "task", "task_uid": "…", "payload": {…}},
    {"kind": "probe", "request_id": "…", "payload": {"target": "…"}},
    {"kind": "config_changed"}
  ],
  "server_time_utc": "…"
}
→ 204 （30 秒沒事）
```

- 一次最多回 20 條（沿用 `REDISPATCH_LIMIT`），剩下的 Agent 下一條請求再拿。
- `task` 的 payload 可能帶明文工具帳密（現況直推 body 就是），長輪詢回應走的是**同一條 TLS＋通行證＋DPoP** 的 Agent 出站連線，守門強度不低於直推（直推是 mTLS＋JWT；出站是主系統 HTTPS 憑證指紋釘死＋通行證＋DPoP，FR-171 D 已定）。
- Agent 拿到 `probe`／`credentials_set`／`query_logs` 等要回答的，**先回 `taken`** 再做（`POST /agents/replies/<request_id>` 帶 `status: taken`），主系統據此分辨「Agent 沒拿到」與「Agent 拿到了在做」。

### 3.5 主系統側的「等回答插槽」是共用元件

`app/remote_agent/service/agent_request_service.py`（新）：

- `ask(agent, kind, payload, timeout=30) -> result`：寫 `agent_requests`、publish 喚醒 Agent 的長輪詢、subscribe 回覆頻道、吊著等、回結果或 raise 逾時。
- `fire(agent, kind, payload)`：寫表＋喚醒，不等。
- `reply(request_id, status, result)`：Agent 回覆端點呼叫，落 DB＋publish。

7a、7b、8a、8b **都只呼叫 `ask`**，不各寫一套等待邏輯。jedi-detection 的 `agent_probe_client`／`agent_credential_client`／`agent_cancel_client` 三支 client 改成呼叫主系統注入的 `ask`／`fire`（套件不認識 Redis，透過 port 注入）。

### 3.6 Agent 側的長輪詢元件

`guidant_agent/sync/app/command_pull.py`（新，照 `config_pull.py` 的 `WorkerComponent` 形狀）：

- 註冊成功後起一條背景執行緒，無限迴圈 `GET /agents/commands?wait=30`；逾時 204 立刻再掛；連線錯誤退避（5 秒起、最多 60 秒）後再掛。
- 拿到 commands 逐條分派到 §3.3 的既有函式；分派表是模組內一張 dict，不是 if 鏈。
- 要回答的：先 `taken`，做完 `done`；做失敗回 `failed` 帶原因（主系統照樣喚醒瀏覽器回錯誤，不讓它等到逾時）。
- 升級中、重啟已排定：拿到 `task` 回 `failed reason=upgrading`（現況直推回 409 的語意搬過來），主系統單留 pending。
- 🔴 **長輪詢與心跳是兩條獨立的出站連線**：心跳照舊每 300 秒一跳只表存活；長輪詢掛著不算心跳。Agent 離線判定仍看心跳 `last_seen`（不動 `offline_threshold_sec`）。

### 3.7 退役清單

| 退什麼 | 在哪 |
|---|---|
| `AgentDataPlaneClient.command()` 直推路徑（保留 class 給 8444 上傳票簽發？→ D6） | jedi-remote-agent |
| `AgentCommandService`（直推服務整支）、`_agent_commands.py` 的推送與補推、`with_command_push` | BE `app/remote_agent/`、`core/plugins/` |
| `last_command_status`／`last_command_at` 欄與 FE「連線」欄三態 | jedi-remote-agent、FE `RemoteAgentManage.vue` |
| `agent_probe_client`／`agent_credential_client`／`agent_cancel_client` 的 HTTP 部分 | jedi-detection |
| `remote_agent_adapter._via_data_plane` | BE `infra/upload_file/` |
| Agent 8443 六個 `cmd_*` blueprint、`/detection/probe`／`cancel`／`credentials`、`/blob` 的主系統入口、`/logs` | Agent `sync/api/`、`scan/api/`、`files/api/blob.py` |
| Agent 8443 的 mTLS client 憑證驗證（`verify_mode=CERT_REQUIRED`） | 8443 只剩 `/health`、`/agent-info`，要不要連埠一起收 → D7 |
| FR-171 契約 `commands.py` 的直推路徑常數 | jedi-agent-protocol（改成長輪詢 `kind` 常數） |

---

## 4. 待決策 {#decisions nav="待決策"}

::: {.callout .decided}
**D1 長輪詢的吊住時間（決策者 11-07 裁 30 秒）**

Agent 一條請求最多吊多久。太短＝Agent 一直重掛、主系統 access log 塞滿；太長＝客戶的 proxy／負載平衡可能把閒置連線切掉（常見 60 秒）。

[**建議**：30 秒。GitHub runner 用 50 秒、AWS SQS 長輪詢上限 20 秒，30 秒在中間，低於多數 proxy 的 60 秒閒置上限。gunicorn `timeout` 現在是 120 秒，不用動。]{.rec}
:::

::: {.callout .decided}
**D2 長輪詢佔 gunicorn worker 的問題——批 0 驗過才定（決策者 11-07 裁）**

出貨主機走 gunicorn 預設 `sync` worker（`main.py` 未設 `worker_class`），worker 數＝`nproc` 封頂 4（`install.sh:2667`）；2 核客戶機就是 2 個 worker，一個 worker 同時只服務一個請求。長輪詢吊著的請求在 gunicorn 眼裡就是「還沒回完的請求」——**1 台 Agent 佔掉 1 個 worker，使用者按「測試連線」再佔 1 個，2 核機主系統在那 2 秒對所有人不回應；第 2 台 Agent 上線就整個吊死**。這不是規模問題，是上線第一天的問題。現在沒事是因為沒有任何請求會故意吊著（心跳幾十毫秒就回、直推是往外打佔背景執行緒）。本機 DEV 看不出來——`DEBUG=true` 走 Flask dev server 本來就多執行緒（`main.py:313` `threaded=True`）。

三道防線一起上：①`gthread`（`main.py` gunicorn options 加 `worker_class="gthread"`、`threads=16`，2 核 32 條、4 核 64 條；不動 installer／compose）；②**配額**——長輪詢與等回答各設上限（各 8 條），超過立刻回 204／503 不吊，保證一般 API 永遠有一半執行緒；③每台 Agent 同時只准一條，第二條進來踢掉第一條。①是容量、②是防線、③是衛生。

**批 0 先獨立驗**（決策者 11-07 裁）：DEV 用 `USE_GUNICORN=true GUNICORN_WORKERS=2` 起，一支假端點吊 30 秒，腳本開 20 條吊它、同時打一般 API 量回應時間。`sync` 要重現卡死（證明問題存在）；`gthread`＋配額要一般 API 回應時間與沒吊時無差，並跑一輪既有測試確認多執行緒沒炸 DI／session。**通過**→長輪詢；**不通過**→退回定時輪詢：同一支 `GET /agents/commands` 不帶 `wait`、每 10～15 秒問一次，Agent 側不改一行；代價是同步問答最壞多等一個間隔。兩條路其餘全部相同，批 0 驗完再定不會白做。

[**建議**：批 0 通過就 ①＋②＋③ 全上；D1 的 30 秒不動。]{.rec}
:::

::: {.callout .decided}
**D3 指令與請求的過期（決策者 11-07 裁照建議）**

Agent 離線一週回來，長輪詢拿到一週前的「測試連線」「查日誌」請求沒有意義；但「派工」「升級」要保留。

[**建議**：`agent_requests`（5、6、7、8）一律帶 `expires_at`，預設 5 分鐘，過期的 Agent 拿不到、主系統標 `expired`；工單（1）、重啟（2）、升級（3）照既有邏輯不過期。取消檢測（6）過期沒關係——檢測本身 5 分鐘內要嘛跑完要嘛還在跑，還在跑的話管理員再按一次。]{.rec}
:::

::: {.callout .decided}
**D4 證據檔怎麼拿：經主系統中轉（決策者 11-07 裁 A）**

證據存 Agent 的租戶，主系統只有參照（`upload_files.storage_type=remote_agent`、`path`＝Agent 位址、`save_file_name`＝Agent 側檔案 uid），bytes 全在 Agent。現況下載是主系統主動連 Agent 8443 `GET /blob/<uid>` 串給瀏覽器——單向網路下斷在這。

兩個選項：**A 經主系統中轉**（排 `send_file` → Agent 上傳到主系統暫存 → 串給瀏覽器 → 刪暫存；任何網路都通、多等一趟、暫存短暫落主系統磁碟）；**B 瀏覽器拿票直連 Agent 8444 取檔**（零暫存、單趟串流，但瀏覽器要連得到 Agent 主機，與 D5 同前提）。

**定案 A**。暫存目錄 `/tmp/agent-outbound/<request_id>`、容器重啟清空、取走即刪、孤兒 1 小時清。代價兩點記在案：①大檔（百 MB）等待約翻倍（Agent 整檔傳完主系統才開始回瀏覽器）；②該租戶當初選「證據存 Agent」是不想讓主系統碰檔，若客戶在意「主系統磁碟曾經有過」，暫存要加密（一次性鑰加密落盤、串出去時解）——到時加一張卡，本案先不做。B 等 D5 有答案再看。
:::

::: {.callout .plain}
**D5 瀏覽器直傳 8444（源碼掃描上傳）——擱置（決策者 11-07 裁先不管）**

源碼包上傳是**使用者瀏覽器**憑主系統簽的票直連 Agent 8444，不是主系統連 Agent，防火牆擋的是主系統↔Agent 之間。本案不動。若日後客戶回報瀏覽器也連不到 Agent 主機，把源碼包改成「瀏覽器上傳主系統暫存 → Agent 取走」（與 D4 上傳同一條路）另開卡。
:::

::: {.callout .decided}
**D6／D7 直推程式留著、用開關關掉；8443 照起、客戶不用開埠（決策者 11-07 裁「怕未來又說要開」）**

兩項合併裁。**Agent 側**：8443 照舊起、照舊 mTLS，六個指令 blueprint（`cmd_*`、`/detection/*`、`/blob` 主系統入口、`/logs`）**程式碼留著、預設不掛**——`register_command_blueprints` 加一個設定開關 `data_plane_commands_enabled`（預設 `false`），開了才掛；8443 常態只剩 `/health`、`/agent-info`。**主系統側**：`AgentDataPlaneClient` 與 mTLS client 憑證簽發流程留著（不然未來要開時 Agent 認不得主系統），`AgentCommandService` 直推路徑留著但沒有任何呼叫者，同樣加開關 `AGENT_DIRECT_PUSH_ENABLED`（預設 `false`）。**客戶防火牆不用開 8443**：本案之後主系統不會主動連 Agent，8443 對外通不通功能完全不受影響；安裝手冊刪掉「8443 要對主系統開放」那句；資安報告 8443 仍列對外埠，註明「預設不提供指令功能，只有健檢」。

未來要開＝Agent 改一個設定值、主系統改一個設定值、客戶開埠，程式不用改。代價：守衛測試不能寫「主系統不連 8443」，改寫「兩個開關預設關；開關關閉時沒有任何路徑會呼叫 `AgentDataPlaneClient.command()`」。實查補充：8444 上傳票簽發在 `agent_ticket_service.py`，與 `AgentDataPlaneClient` 無關。
:::

::: {.callout .decided}
**D8 FR-171 討論稿與 design 補指路行（決策者 11-07 裁照建議）**

FR-171 design v1.0 的 A 指令通道、D3、D11 都被本案推翻；FR-170 §3.x「落地版直推一定通」也是。

[**建議**：兩份各開頭加一行指向本案（照「定稿後不改 v1.0」規範，只加指路行不改內文）；FR-171 其餘（B 設定通道、C 換證、K 金鑰登記簿、E 事件通道、DPoP）全部保留不動。]{.rec}
:::

---

## 5. 現況接入點：哪些 reuse、哪些 extend、哪些 new {#reuse nav="接入點"}

| 元件 | 現況 | 本案動作 |
|---|---|---|
| Agent `config_pull.py`（WorkerComponent 背景拉、版本快取） | 設定通道的 Agent 側 | **reuse 形狀**：`command_pull.py` 照它的 WorkerComponent 骨架寫 |
| Agent `cmd_tasks`／`cmd_restart`／`cmd_update`／`cmd_config`／`cmd_credentials` 的 app 層函式 | 直推 handler 呼叫的本體 | **reuse**：長輪詢分派表直接呼叫同一支；只刪 blueprint 殼 |
| Agent `event_sender.py`＋`outbox.py` | 事件送不到進 SQLite outbox 補送 | **reuse**：回覆（`POST /agents/replies`）送不到也進 outbox |
| Agent `task_queue.py` | 本機單執行緒工單佇列 | **reuse** 不動 |
| BE `AgentCommandService.redispatch_pending()`、`_due()` | 心跳回來查 pending 單 | **extend**：查單邏輯搬進長輪詢端點的「這台有什麼待辦」組裝；推送部分刪 |
| BE `_agent_update.rollout_service().update_for_agent()` | 算這台該不該升、升哪版 | **reuse**：長輪詢組裝時呼叫 |
| BE `agent_offline_reaper`、心跳 `last_seen` | 離線判定 | **reuse** 不動（長輪詢不算心跳） |
| jedi-remote-agent `AgentHeartbeatRoute` 的守門（通行證＋DPoP） | Agent 出站端點的認證 | **reuse**：`/agents/commands`、`/agents/replies`、`/agents/files` 掛同一組 `AUTH_AGENT` |
| `core/extensions.redis_client` | 已有 | **reuse**：pub/sub 喚醒 |
| jedi-detection 三支 agent client | 直推 HTTP | **extend**：改成呼叫注入的 `ask`／`fire` port，套件不認識 Redis |
| `remote_agent_adapter` | 直連 Agent blob | **extend**：四個操作改走 `ask`，錯誤碼不變（FE 依碼顯示） |
| `agent_requests` 表 | 無 | **new**：一張表承接 5、6、7、8 |
| `agent_request_service.py` | 無 | **new**：等回答插槽，三個方法 |

---

## 6. 分棒草案 {#cards nav="分棒"}

批＝可同時跑的一組；登記型共用檔（契約 `commands.py`、`routing.py`、Agent `registry.py`、事件碼表）由批 1 一張卡當 owner 一次掛齊。

| 批 | 卡 | repo | 做什麼 | 依賴 |
|---|---|---|---|---|
| 1 | F-1 契約 | jedi-packages | `jedi-agent-protocol` 0.3.0：`commands.py` 改成長輪詢 `kind` 常數與各 kind 的 payload dataclass、`CommandsResponse`、`ReplyRequest`；刪直推路徑常數 | — |
| 1 | F-2 Agent 長輪詢殼 | agent | `command_pull.py` WorkerComponent、分派表（先只接 `task`／`restart`／`update`／`config_changed` 四個既有函式）、`registry.py` 登記、退避 | — |
| 1 | F-3 主系統端點殼 | jedi-packages | `jedi-remote-agent`：`GET /agents/commands` 長輪詢（組裝 pending 單／restart／update／config 版本）、`POST /agents/replies/<id>`、`GET /agents/files/<id>`，`routing.py` 一次掛齊；Redis 喚醒走注入的 port | — |
| 2 | E-1 環境卡 | main | 主專案改走三支套件 path dependency、gunicorn 改 `gthread`、`agent_requests` migration | F-1 F-3 |
| 2 | A-1 等回答插槽 | main | `agent_request_service.py`（ask／fire／reply）、Redis pub/sub、逾時、過期 reaper；接上 F-3 的 port | F-3 |
| 3 | A-2 指令六種改接 | main | `_agent_commands.py` 改成「寫正本＋publish」；5、6 寫 `agent_requests`；刪 `AgentCommandService` 推送 | A-1 |
| 3 | A-3 Agent 回答類接線 | agent | 分派表接 `probe`／`credentials_set`／`detection_cancel`／`credentials_renew`／`query_logs`／證據檔四種；`taken`／`done`／`failed` 回覆；回覆進 outbox | F-2 |
| 3 | A-4 jedi-detection 三 client 改 port | jedi-packages | probe／credential／cancel 改呼叫 `ask`／`fire` | A-1 |
| 4 | B-1 證據檔四操作 | main | `remote_agent_adapter` 改走 `ask`＋暫存目錄＋孤兒清理 | A-1 A-3 |
| 4 | B-2 遠端查日誌 | main | FR-178 的 `/logs` 直連改走 `ask(query_logs)` | A-1 A-3 |
| 4 | B-3 FE 連線欄 | fe | 「連線」欄三態改二態（在線／離線），刪「僅單向」與 hover | A-2 |
| 5 | C-1 退役 | main＋jedi-packages | §3.7 清單主系統側；守衛測試「主系統不連 8443」 | 批 4 全驗 |
| 5 | C-2 退役 | agent | 8443 六 blueprint、probe／cancel／credentials／blob 主系統入口／logs 刪；8443 改聽 localhost（D7） | 批 4 全驗 |
| 5 | C-3 文件 | main | FR-170／171 指路行（D8）、資安報告連線清單更新、手冊「Agent 只出站」 | C-1 C-2 |

批 1 三張互不依賴可同跑（契約是登記型檔，F-2、F-3 開工時契約還沒發版，各自先用本機 path 指 F-1 工作區——這正是環境卡 E-1 要在批 2 收的）。design.md 會用 `handoff/cards.py` 驗批。

---

## 7. 不做什麼 {#scope-out nav="不做"}

- **WebSocket／gRPC 長連線**：長輪詢做到八成功能、零新元件；真有上百台 Agent 再評估（反悔條件：單一主系統 Agent 數 > 100 或 `gthread` 執行緒常態吃滿）。
- **心跳改變**：心跳維持 11-01 裁示只表存活，長輪詢是獨立連線；離線判定不動。
- **SaaS 版「binary 不落雲端磁碟」**：D4 接受暫存；SaaS 到時另案。
- **Agent 主控台（8444）**：不動。
- **FR-171 的身分、換證、金鑰登記簿、設定通道、事件通道、DPoP**：全部保留，本案只換「指令怎麼到 Agent」與「同步問答怎麼走」。
- **gunicorn 不驗直接上**：批 0（P-0）先驗 `sync` 卡死與 `gthread`＋配額，結論決定 D2。
- **舊版 Agent 升級**：決策者 11-07 裁「舊的先不管，全部重新安裝」——已裝的 Agent（123、POC）本案出貨後重裝，不走「主系統先收長輪詢再刪直推」的兩段升級，不留相容路徑。
