---
title: FR-181 Agent 只出站通道 — 設計定版 v1.0
status: 定案，待開卡
lede: "主系統不再連 Agent；Agent 常駐一條長輪詢向主系統取指令，同步問答（測試連線、證據檔、查日誌）走同一條加「等回答插槽」；直推程式留著用開關關掉、客戶不用開 8443；心跳維持只表存活。"
---

# FR-181 Agent 只出站通道 — 設計定版 v1.0

> 🔴 本版已凍結。現行版本是 [design-v1.1.md](design-v1.1.md)（無人值守調整：批次 7→6、P-0 判據寫死、驗收改本機模擬）。

| 項目 | 內容 |
|---|---|
| 狀態 | 定案（決策者 2026-11-07 逐項裁示），待開卡 |
| 日期 | 2026-11-07 |
| 討論稿 | [discussion.md](discussion.md)（D1～D8 的推導與選項比較） |
| 推翻 | FR-170 §3.x「落地版同內網直推一定通」前提；FR-171 A 指令通道（D2、D3、D11） |
| 保留 | FR-171 B 設定通道、C 自動換證、K 金鑰登記簿、E 事件通道、DPoP；11-01「心跳只表存活」裁示 |
| 拆卡底表 | [handoff/cards.py](handoff/cards.py)（13 張 7 批，`python cards.py` 驗批） |

## 變更紀錄

| 版本 | 日期 | 內容 |
|---|---|---|
| v1.0 | 2026-11-07 | 定案 |

---

## 1. 這案在做什麼

客戶網路**確定**不讓主系統連進 Agent 主機。現況八種「主系統主動連 Agent 8443」的功能在這種客戶一個都送不到：派工、重啟、升級、設定變更通知、立即換證、取消檢測（六種指令）；測試連線與工具帳密、證據檔四操作與遠端查日誌（同步問答）。心跳補推也是連 8443，救不了。

做完後的狀態：

| 項目 | 現況 | 目標 |
|---|---|---|
| 主系統 → Agent 的連線 | 直推 8443（mTLS＋JWT） | **零**。主系統不主動連 Agent |
| Agent → 主系統 | 心跳（300 秒）、事件、設定拉取、ack／結果 | 同上，**加一條常駐長輪詢** `GET /agents/commands?wait=30` |
| 六種指令怎麼到 Agent | 直推，打不通等心跳補推 | 主系統寫 DB 正本＋敲 Redis 鈴 → 長輪詢立刻回 → Agent 做 |
| 同步問答（7、8） | 主系統直連當場拿回答 | 主系統排請求吊著等 → Agent 長輪詢取走做 → 回覆上來 → Redis 喚醒等著的那條 → 回瀏覽器 |
| 證據檔 bytes | 主系統直連 Agent 串流 | 經主系統暫存中轉（Agent 送上來／Agent 來拿），取走即刪 |
| 直推程式 | 主路徑 | 留著，主系統與 Agent 各一個開關預設關 |
| Agent 8443 | 六個指令端點＋health | 照起、只剩 `/health`／`/agent-info`；**客戶不用開埠** |
| 心跳 | 只表存活 | 不變；長輪詢不算心跳，離線判定不動 |
| gunicorn | `sync` worker | `gthread` 16 執行緒＋長輪詢／等回答各配額 8 條（批 0 驗過才翻） |
| 舊 Agent | — | 全部重裝，不留相容路徑 |

**整案一句話**：Agent 永遠是發起方；主系統有事就讓掛著的那條請求立刻回去。

---

## 2. 分工概述（30 秒版）

| 批 | 做什麼 | 卡 | 完成怎麼判定（決策者檢查法） |
|---|---|---|---|
| 1 | **先驗 gunicorn 撐不撐得住**：`sync` 重現卡死、`gthread`＋配額證明一般 API 不受影響 | P-0 | 看證據檔三組數字：`sync` 吊 2 條 health p95 > 25 秒；`gthread` 吊 20 條 p95 與沒吊時差 < 50 ms。通過走長輪詢，不通過退定時輪詢（其餘卡不變） |
| 2 | 地基三張：契約、Agent 長輪詢元件、主系統長輪詢端點 | F-1 F-2 F-3 | 123 Agent 裝 F-2 版、DEV 起 F-3 工作區的主系統：DB 插一張 pending 單，Agent 主控台 3 秒內看到開跑 |
| 3 | 環境卡：主系統釘新版套件、gunicorn 翻預設、`agent_requests` 表 | E-1 | `USE_GUNICORN=true` 起 log 印 `gthread`；`\d compliance.agent_requests` 有表 |
| 4 | 等回答插槽（跨 worker 喚醒、配額、逾時、過期） | A-1 | gunicorn 2 worker 實打：兩條 `ask` 各在自己的 worker 醒來；殺 Redis → 30 秒 504 不 500 |
| 5 | 指令六種（主系統側）、回答九種（Agent 側）、jedi-detection 三 client 換底 | A-2 A-3 A-4 | **123 Agent 主機 iptables 擋 8443 入站**：派工、重啟、升級、改授權清冊、立即換證、取消檢測各 3 秒內反應；按「測試連線」2 秒內看到結果 |
| 6 | 證據檔四操作、遠端查日誌、FE 連線欄 | B-1 B-2 B-3 | 同上防火牆：證據存 Agent 的租戶上傳／預覽／下載／轉 PDF／刪除都正常、`/tmp/agent-outbound` 取走後為空；支援頁查 Agent 日誌查得到；連線欄只剩在線／離線 |
| 7 | 守衛測試、FR-170／171 指路行、手冊與資安報告 | C-1 | 守衛三支綠；手冊 grep 不到「8443 要對主系統開放」 |

**依賴**：批 1 的結論決定 E-1 翻不翻 gunicorn 預設；批 2 三張互不依賴；批 5 三張分屬三個 repo。**決策者功課**：批 1 驗完看一眼數字拍板 D2；批 5 驗收前把 123 Agent 主機 8443 入站擋掉（或告訴首腦擋）。

---

## 3. 決策定案表

| # | 決策 | 定案 | 理由 | 被排除 |
|---|---|---|---|---|
| D1 | 長輪詢一條請求吊多久 | **30 秒** | 低於多數 proxy／LB 的 60 秒閒置上限；GitHub runner 50 秒、SQS 上限 20 秒，取中間；gunicorn `timeout` 120 秒不用動 | 5 秒（Agent 一直重掛、log 塞滿）；60 秒（撞 proxy 閒置切線） |
| D2 | 長輪詢吊住 gunicorn worker | **批 0 驗過才定**。通過→`gthread` 16 執行緒＋配額（長輪詢 ≤ 8、等回答 ≤ 8，超過立刻回不吊）＋每台 Agent 只准一條（第二條踢第一條） | 出貨 `sync` worker 數＝nproc 封頂 4，1 台 Agent 就佔 1 個 worker、按「測試連線」再佔 1 個，2 核機當場吊死。三道：容量／防線／衛生。本機 DEV 看不出來（dev server 本來多執行緒） | 另起 `RUN_MODE=agent-poll` 獨立進程（多一個行程進 compose／installer／升級鏈，幾十台 Agent 不值得；上百台再換）；不驗直接上（多執行緒下 DI／session 沒實打過） |
| D2 退路 | 批 0 不通過 | 退定時輪詢：同一支 `GET /agents/commands` 不帶 `wait`、每 10～15 秒問一次 | 兩條路只差端點吊不吊，Agent 側不改一行；代價同步問答最壞多等一個間隔 | — |
| D3 | 指令與請求的過期 | **`agent_requests` 預設 5 分鐘過期**，過期的 Agent 拿不到、主系統標 `expired`；工單／重啟／升級不過期 | Agent 離線一週回來拿到一週前的「測試連線」沒意義；派工與升級要保留。取消檢測過期沒關係，還在跑就再按 | 全部不過期（Agent 回來做一堆沒人收的事）；全部過期（派工會丟） |
| D4 | 證據檔怎麼拿 | **A 經主系統中轉**：下載排 `send_file` Agent 上傳到主系統暫存再串給瀏覽器；上傳主系統收檔暫存排 `fetch_file` Agent 來拿；暫存 `/tmp/agent-outbound/<request_id>`、取走即刪、孤兒 1 小時清 | 任何網路都通。代價：大檔等待約翻倍；暫存短暫落主系統磁碟（FR-039「不落雲端」是 SaaS 承諾，落地版磁碟是客戶的）。若客戶在意「磁碟曾經有過」，暫存加密另開卡 | B 瀏覽器拿票直連 Agent 8444（零暫存、單趟，但前提與 D5 同，等 D5 有答案再看） |
| D5 | 源碼包瀏覽器直傳 8444 | **擱置**，本案不動 | 那是瀏覽器連 Agent，不是主系統連 Agent；防火牆擋的是主系統↔Agent | — |
| D6 | 主系統直推程式 | **留著、開關關**：`AGENT_DIRECT_PUSH_ENABLED` 預設 False；`AgentCommandService.send()` 開頭擋；`AgentDataPlaneClient` 與主系統 mTLS client 憑證簽發留著 | 決策者「怕未來又說要開」。未來要開＝主系統改一值、Agent 改一值、客戶開埠，程式不改。實查 8444 上傳票簽發在 `agent_ticket_service.py` 與它無關 | 整支刪（留殼怕被當成還能直推——用守衛測試擋） |
| D7 | Agent 8443 | **照起、六個指令 blueprint 用開關 `data_plane_commands_enabled` 預設不掛**；只剩 `/health`、`/agent-info`；**客戶不用開埠** | 同 D6。8443 對外通不通功能完全不受影響；安裝手冊刪「8443 要對主系統開放」；資安報告 8443 仍列、註明「預設只有健檢」 | 改聽 localhost（未來要開又得改）；整個收掉 |
| D8 | FR-170／171 文件 | 各檔頭加一行指向本案，不改 v1.0 內文 | 「定稿後不改 v1.0」規範 | 改寫內文 |
| — | 舊 Agent | **全部重裝**，不留直推→長輪詢的兩段升級 | 決策者裁「舊的先不管」；123、POC 本案出貨後重裝 | 主系統先收長輪詢暫不刪直推→Agent 升→再關直推（多一棒） |

---

## 4. 端到端流程

### 4.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 command_pull
  participant M as 主系統 /agents/commands
  participant DB as DB 正本
  participant R as Redis
  participant U as 管理員
  A->>M: GET /agents/commands?wait=30（通行證＋DPoP）
  M->>DB: 查這台待辦（pending 單／restart／update／agent_requests waiting）
  Note over M: 沒有 → waiter.wait(uid, 30) 訂閱 agent:<uid>
  U->>M: 按「派工」
  M->>DB: 寫正本（after_commit）
  M->>R: publish agent:<uid>
  R-->>M: 喚醒
  M->>DB: 再查一次
  M-->>A: 200 {commands:[{kind:"task", task_uid, payload}]}
  A->>A: 分派表 → task_intake → task_queue.submit
  A->>M: POST /agents/tasks/<uid>/ack（既有）
  M->>DB: status=dispatched
  A->>M: GET /agents/commands?wait=30（立刻再掛）
  Note over M,A: 30 秒沒事 → 204 → Agent 立刻再掛
```

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

**防撞**：task uid 冪等、ack 即 `dispatched`、Agent 本機單執行緒佇列（沿用 FR-171 三道）；`restart` 被 Agent 取走時主系統立刻清 `restart_requested`（不然每條長輪詢都回 restart）。

### 4.2 同步問答怎麼走

```{.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 主系統 worker X
  participant R as Redis
  participant M2 as 主系統 worker Y
  participant A as Agent
  U->>M: POST 測試連線 {agent_uid, target}
  M->>M: ask()：agent_requests(kind=probe, waiting, expires=+5m)
  M->>R: publish agent:<uid>；subscribe reply:<request_id>
  Note over M: 吊著等，最多 30 秒（配額 ≤ 8 條，滿了 503）
  A->>M2: GET /agents/commands（掛在 worker Y）
  M2-->>A: {commands:[{kind:"probe", request_id, payload}]}
  A->>M2: POST /agents/replies/<id> {status:"taken"}
  A->>A: 跑 probe
  A->>M2: POST /agents/replies/<id> {status:"done", result}
  M2->>M2: agent_requests 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 喚不醒對方。結果本身落 DB，Redis 只傳「好了」。

**逾時**：30 秒沒等到回 504 `GRC_504_AGENT_NO_REPLY`，`agent_requests` 留 waiting，Agent 晚到的回覆照樣落 DB。**`taken` 與 `failed`**：Agent 拿到要回答的先回 `taken`（主系統據此分辨「沒拿到」與「在做」）；做失敗回 `failed reason=`，主系統立刻喚醒瀏覽器回錯誤，不讓它等到逾時。

### 4.3 證據檔四操作（D4 A 案）

| 操作 | 流程 |
|---|---|
| 上傳到 Agent | 主系統收檔 → `spool.save(request_id)` → `ask(fetch_file, {request_id, name, sha256})` → Agent `GET /agents/files/<request_id>` 下載、驗 sha、`FileUploadService.save` → reply done `{file_uid, sha256}` → 主系統寫參照 row、刪暫存 |
| 下載／預覽 | `ask(send_file, {file_uid})` → Agent 讀本機、`POST /agents/replies/<id>` multipart → 主系統 `spool.save` → 喚醒 → `spool.open` 串給瀏覽器 → 讀完刪 |
| 轉 PDF | 同下載，kind `send_pdf`，Agent 側先走既有轉檔 |
| 刪除 | `fire(delete_file, {file_uid})`，不等回答 |

錯誤碼不變：504→`FILE_AGENT_UNREACHABLE`、Agent `failed`→`FILE_AGENT_BAD_RESPONSE`（FE 依碼顯示）。

---

## 5. 詳細設計

### 5.1 契約（jedi-agent-protocol 0.3.0，F-1）

```
GET  /api/1.0/agents/commands?wait=30        → 200 CommandsResponse | 204
POST /api/1.0/agents/replies/<request_id>    body Reply（JSON）或 multipart（file + Reply 欄位）
GET  /api/1.0/agents/files/<request_id>      → bytes（Agent 來拿主系統暫存的檔）

Command        {kind, request_id?, task_uid?, payload, expires_at?}
CommandsResponse {commands: [Command], server_time_utc}
Reply          {status: taken|done|failed, result?, reason?}
```

十三個 `kind`：

| kind | 誰寫正本 | 要回答 | Agent 接哪支 |
|---|---|---|---|
| `task` | `agent_tasks`（既有） | ack（既有端點） | `task_intake` → `task_queue.submit` |
| `restart` | `restart_requested`（既有） | 否 | `ops.request_restart` |
| `update` | rollout 表（既有） | 否（升級事件既有） | `update_sync.trigger` |
| `config_changed` | `agent_requests` | 否 | `config_pull.request()` |
| `credentials_renew` | `agent_requests` | 否 | `cert_renew.request_now()` |
| `detection_cancel` | `agent_requests` | 否 | `scan/app/cancel.py` |
| `probe` | `agent_requests` | **是** | `scan/app/probe.py` |
| `credentials_set` | `agent_requests` | **是** | `scan/app/credential_store` |
| `fetch_file` | `agent_requests` | **是** | `files/app/blob_ops.fetch` |
| `send_file` | `agent_requests` | **是**（multipart） | `files/app/blob_ops.send` |
| `send_pdf` | `agent_requests` | **是**（multipart） | `files/app/blob_ops.send_pdf` |
| `delete_file` | `agent_requests` | 否 | `files/app/blob_ops.delete` |
| `query_logs` | `agent_requests` | **是** | `platform/audit_log.list_logs` |

既有直推路徑常數與 body dataclass 不刪（D6），檔頭註解改「直推預設關，長輪詢是主路徑」。

### 5.2 主系統長輪詢端點（jedi-remote-agent 1.13.0，F-3）

套件只做端點與組裝，**不認識 Redis、不建表**，四個 port 由宿主注入（`app/ports/command_channel.py`）：

| port | 方法 | 主系統實作（A-1） |
|---|---|---|
| `waiter` | `wait(uid, timeout) -> bool`／`notify(uid)`／`preempt(uid)` | `infra/remote_agent/redis_command_waiter.py`：Redis pub/sub ＋ 本地 `threading.Event` 鏡像；長輪詢配額 ≤ 8 |
| `spool` | `save(request_id, stream)`／`open(request_id)`／`discard(request_id)` | `infra/remote_agent/file_spool.py`：`/tmp/agent-outbound/` |
| `command_sources` | `restart_for(agent) -> mode?`／`update_for(agent) -> info?`／`requests_for(agent) -> [Command]` | 包既有 `restart_requested`、`rollout_service().update_for_agent`、`agent_requests` waiting 未過期列 |
| `on_reply` | `(request_id, status, result)` | `agent_request_service.reply` |

端點組裝順序：pending 且 `_due` 的工單（搬 `redispatch_pending` 的查單邏輯）→ restart → update → `agent_requests`；一次最多 20 條；有東西立刻回，沒有 `waiter.wait` 後再查一次；同台第二條進來 `preempt` 讓第一條回 204。

### 5.3 等回答插槽（A-1）

`app/remote_agent/service/agent_request_service.py` 三個方法，**7a、7b、8a、8b 都只呼叫這三個**，不各寫等待邏輯：

- `ask(agent, kind, payload, timeout=30)`：寫表（`expires_at`＝+5 分）→ `notify` → `subscribe reply:<id>` 吊著 → 喚醒讀 DB result → 回；配額滿 503 `GRC_503_AGENT_BUSY`；逾時 504 `GRC_504_AGENT_NO_REPLY`。
- `fire(agent, kind, payload)`：寫表＋`notify`，不等。
- `reply(request_id, status, result)`：落 DB＋`publish reply:<id>`；`taken` 只改狀態不喚醒。

背景排程（`core/scheduler_agent.py`）：`expire_requests` 每分鐘、`reap_orphans` 每小時。

### 5.4 `agent_requests` 表（E-1）

`compliance.agent_requests`：`id`／`uid`／`agent_id` FK／`tenant_id`／`kind`／`payload` jsonb／`status` waiting|taken|done|failed|expired／`result` jsonb／`reason`／`expires_at`／`taken_at`／`done_at`／created 系列。索引 `(agent_id, status, expires_at)`。GRANT `cm_app`、RLS 照 `agent_tasks`。主線 `phase=active` → 出貨基線待重產。

**為什麼只加一張表**：取消、換證（沒正本）與測試連線、帳密、證據檔、查日誌（要回答）性質相同——「一次性、短命、要麼做完要麼過期」；工單／重啟／升級各有自己更複雜的狀態機，硬併等於重寫。

### 5.5 Agent 長輪詢元件（F-2、A-3）

`guidant_agent/sync/app/command_pull.py`，照 `config_pull.py` 的 `WorkerComponent` 骨架：

- 註冊成功後起背景執行緒無限迴圈；204 立刻再掛；2xx 逐條分派；連線錯退避 5→10→20→40→60 秒。
- 分派表 `dict[kind -> handler]`，不是 if 鏈；F-2 接四個（task／restart／update／config_changed）、其餘留 no-op 給 A-3 填。
- 要回答的先 `taken` 再做，做完 `done`，失敗 `failed reason=`。
- 升級中／重啟已排定拿到 `task` → 不進佇列、reply `failed reason=upgrading`（現況直推 409 語意）。
- 回覆走 `reply_sender.py`，送不到進 `event_sender` 同一條 outbox（新 kind `reply`）。
- 🔴 長輪詢與心跳是兩條獨立出站連線；離線判定仍看心跳 `last_seen`。

抽 app 層：`cmd_tasks._parse` → `task_intake.py`；`detection_route` 的 cancel／probe → `scan/app/cancel.py`／`probe.py`；`blob_route` 四操作 → `files/app/blob_ops.py`。route 殼改薄、開關開時仍呼叫同一支，不複製。

### 5.6 gunicorn（P-0、E-1）

`main.py` gunicorn options 加 `worker_class`（env `GUNICORN_WORKER_CLASS`）、`threads`（`GUNICORN_THREADS`）；P-0 只加入口不翻預設，E-1 依 P-0 結論翻成 `gthread`／16。`install.sh` 寫 `.env` 補這兩個變數。

### 5.7 開關（D6、D7）

| 側 | 變數 | 預設 | 擋在哪 |
|---|---|---|---|
| 主系統 | `AGENT_DIRECT_PUSH_ENABLED`（`config/config.py`） | False | `AgentCommandService.send()` 開頭回 `CommandResult.disabled()` |
| Agent | `data_plane_commands_enabled`（`config/config.py` `AgentAuthConfig`） | False | `data_plane_server.register_command_blueprints` 開了才掛六個 `cmd_*` |

守衛（C-1）：預設皆 False；主系統開關 False 時 patch `AgentDataPlaneClient.command` 成 raise，跑派工／重啟／升級／換證／設定變更五條全綠。

---

## 6. 現況接入點盤點

| 元件 | 現況 | 本案動作 |
|---|---|---|
| Agent `config_pull.py` | 設定通道 WorkerComponent | **reuse 形狀**：`command_pull.py` 照它寫 |
| Agent `cmd_*` 的 app 層函式、`task_queue`、`event_sender`＋`outbox`、`cert_renew.request_now`、`update_sync.trigger`、`ops.request_restart` | 直推 handler 呼叫的本體 | **reuse**：分派表直接呼叫；route 殼留著用開關 |
| BE `AgentCommandService.redispatch_pending`／`_due` | 心跳回來查 pending 單 | **extend**：查單邏輯搬進套件端點組裝；推送部分留著加開關 |
| BE `rollout_service().update_for_agent` | 算該不該升 | **reuse**：`command_sources.update_for` |
| BE `_agent_config_notify` | 直推 `/config/notify` | **extend**：改寫 `agent_requests(config_changed)`＋notify |
| jedi-remote-agent `AgentHeartbeatRoute` 守門 | `AUTH_AGENT` | **reuse**：三條新端點掛同一組 |
| `core/extensions.redis_client` | 已有 | **reuse**：pub/sub |
| jedi-detection 三支 client | 直推 HTTP | **extend**：改收 `channel` port |
| `remote_agent_adapter` | 直連 blob | **extend**：四操作改 `ask`／`fire`，錯誤碼不變 |
| `agent_requests` 表、`agent_request_service`、`redis_command_waiter`、`file_spool`、`command_pull`、`reply_sender`、`task_intake` | 無 | **new** |

---

## 7. 拆分表

批＝可同時跑的一組；登記型檔各只讓一張卡碰（契約 F-1、`routing.py` F-3、Agent `registry.py`／`data_plane_server.py` F-2、`remote_agent.py` plugin 掛點 A-1、`main.py`／migration E-1、jedi-detection 三 client A-4）。底表 [handoff/cards.py](handoff/cards.py)，`python cards.py` 斷言：依賴在更早的批、同批同 repo 不改同一檔。

| 批 | 卡 | repo | 標題 | reuse／extend／new | 依賴 | model／effort |
|---|---|---|---|---|---|---|
| 1 | P-0 | main | gunicorn 長輪詢併發驗證：sync 重現卡死、gthread＋配額證明一般 API 不受影響 | new 驗證腳本＋假端點；extend `main.py` 加 worker_class／threads 入口 | — | opus／medium |
| 2 | F-1 | jedi-packages | 契約 0.3.0：長輪詢取指令與回答的形狀 | extend `commands.py`（登記型 owner） | — | opus／medium |
| 2 | F-2 | agent | Agent 長輪詢元件 command_pull、分派表、退避、回覆 outbox、8443 指令開關 | new `command_pull`／`reply_sender`／`task_intake`；extend `registry.py`／`data_plane_server.py`（登記型 owner） | — | opus／high |
| 2 | F-3 | jedi-packages | 主系統長輪詢端點、replies、files，四 port | new route＋ports；extend `routing.py`（登記型 owner） | — | opus／high |
| 3 | E-1 | main | 環境卡：釘新版套件、gunicorn 翻預設、`agent_requests` migration | extend `pyproject`／`main.py`／`install.sh`；new migration | F-1 F-3 P-0 | sonnet／medium |
| 4 | A-1 | main | 等回答插槽：ask／fire／reply、Redis 跨 worker、配額、過期；實作四 port | new service＋entity／repo／waiter／spool；extend DI／plugin mount／排程 | E-1 | opus／high |
| 5 | A-2 | main | 指令六種改「寫正本＋喚醒」，取消與換證寫 `agent_requests`，直推加開關 | extend `_agent_commands`／`_agent_config_notify`／`_agent_keys`／`AgentCommandService` | A-1 | opus／medium |
| 5 | A-3 | agent | 回答九種接線、taken→done／failed、抽 app 層 | extend 分派表；new `scan/app/cancel`／`probe`、`files/app/blob_ops` | F-2 | opus／high |
| 5 | A-4 | jedi-packages | jedi-detection 三 client 改 channel port | extend 三 client；new port | A-1 F-1 | sonnet／medium |
| 6 | B-1 | main | 證據檔四操作改走插槽 | extend `remote_agent_adapter` | A-1 A-3 | opus／medium |
| 6 | B-2 | main | 遠端查日誌改 ask | extend `agent_log_query_app_service` | A-1 A-3 | sonnet／low |
| 6 | B-3 | fe | 連線欄三態改二態 | extend `RemoteAgentManage.vue` | A-2 | sonnet／low |
| 7 | C-1 | main | 守衛測試、FR-170／171 指路行、手冊與資安報告 | new 守衛；extend 文件 | A-2 B-1 B-2 B-3 | sonnet／medium |

各卡的改哪些檔、新開哪些檔、驗收條件以 `cards.py` 為準（Notion 卡從它產）。

---

## 8. 端到端驗收

全部在 **123 Agent 主機 iptables 擋 8443 入站**（`iptables -I INPUT -p tcp --dport 8443 ! -s 127.0.0.1 -j DROP`）的狀態下做：

| # | 動作 | 預期 |
|---|---|---|
| 1 | 主系統派一張源碼掃描單 | Agent 主控台 3 秒內看到開跑；完成後主系統看到結果 |
| 2 | 管理頁按重啟 | Agent 重啟一次，不重複重啟 |
| 3 | 分批升級轉成下發 | Agent 3 秒內開始下載、升級事件回主系統 |
| 4 | 改一條授權清冊 | Agent 3 秒內拉到 |
| 5 | 按立即換證 | Agent log 有 renew、`agent_requests` 該列 done |
| 6 | 派一張檢測再按取消 | Agent 3 秒內中止 |
| 7 | 檢測工具頁按「測試連線」 | 2 秒內看到結果 |
| 8 | 檢測工具頁存帳密 | Agent 側帳密庫有該筆 |
| 9 | 證據存 Agent 的租戶：上傳／預覽／下載／轉 PDF／刪除 | 全部正常；`/tmp/agent-outbound` 取走後為空；100 MB 檔時間記錄 |
| 10 | 支援頁查 Agent 日誌 | 查得到 |
| 11 | Agent 停掉再做 1、7、9 | 1 的單留 pending、Agent 回來 3 秒內開跑；7、9 30 秒內回「Agent 無回應」不 500 |
| 12 | Redis 停掉再做 7 | 30 秒 504 不 500；Redis 回來後 7 正常 |
| 13 | 20 條假長輪詢吊著 | 一般頁面回應時間無差（P-0 腳本） |
| 14 | 兩個開關 | 預設 False；`grep` 主系統 `.send(` 呼叫只剩開關後那一處；Agent 8443 `POST /tasks` 404 |
| 15 | 連線欄 | 只剩在線／離線 |

---

## 9. 不做什麼

- WebSocket／gRPC 長連線（反悔條件：單一主系統 Agent 數 > 100 或 `gthread` 執行緒常態吃滿）。
- 心跳改變、離線判定改變。
- SaaS 版「binary 不落雲端磁碟」（D4 暫存加密另開卡）。
- 源碼包瀏覽器直傳 8444（D5 擱置）。
- 舊 Agent 兩段升級（全部重裝）。
- FR-171 身分、換證、金鑰登記簿、設定通道、事件通道、DPoP 全部不動。
