本卡屬 FR-181(母卡 FR-181 母案(同清單上一張)),卡 A-1,批 4。只改 主 repo。依賴:E-1。
整案最難的一張:「等回答插槽」。使用者按「測試連線」時,瀏覽器那條請求要吊在主系統上等 Agent 的答案;但 Agent 的回覆可能打到另一個 gunicorn worker(不同進程),進程內的喚醒喚不到對方——所以用 Redis pub/sub 當鈴。三個方法:ask(寫 agent_requests → 敲 Agent 的鈴 → 訂閱回覆頻道吊著 → 鈴響讀 DB 結果回去;逾時 30 秒回 504;同時等的超過 8 條直接 503 不吊)、fire(寫表敲鈴不等)、reply(Agent 回覆進來:落 DB、敲回覆的鈴)。另外實作 F-3 定義的四個 port(Redis 版 waiter 含長輪詢配額 ≤ 8、/tmp/agent-outbound 暫存、查待辦的 callable、收回覆),注入給套件;加兩支背景排程(每分鐘標過期、每小時清孤兒暫存)。🔴 結果本身永遠落 DB,Redis 只傳「好了」——Redis 掉訊息最壞 30 秒逾時再查一次 DB 也查得到,不能讓 Redis 變成第二份真相。後面測試連線、帳密、證據檔、查日誌四種用途都只呼叫這三個方法,不准各寫一套等待。
new:app/remote_agent/service/agent_request_service.py——ask(agent, kind, payload, timeout=30):寫 agent_requests(expires_at=now+5 分,D3)、publish agent:<uid>、subscribe reply:<request_id> 吊著等(配額:同時等回答 ≤ 8 條,滿了直接 503 GRC_503_AGENT_BUSY)、喚醒後讀 DB result 回、逾時 504 GRC_504_AGENT_NO_REPLY 並留 waiting;fire(agent, kind, payload):寫表+publish 不等;reply(request_id, status, result):落 DB+publish reply:<id>;taken 只改狀態不喚醒。new:infra/remote_agent/redis_command_waiter.py 實作 F-3 的 waiter port(wait 用 Redis pub/sub 加 threading.Event 本地鏡像、notify、preempt,長輪詢配額 ≤ 8 條超過回 204);infra/remote_agent/file_spool.py 實作 spool(/tmp/agent-outbound/<request_id>、取走即刪、reap_orphans(1h));agent_requests repo(domain entity/query entity/repo impl 照 agent_tasks 形狀);command_sources 實作(restart/update 兩個 callable 包既有 restart_requested 與 rollout_service().update_for_agent)。extend:di_containers/remote_agent/ 註冊;core/plugins/remote_agent.py mount 時把四個 port 注入套件(本卡只注入,不動 _agent_commands.py——那是 A-2);背景排程加 expire_requests(每分鐘標 expired)與 reap_orphans。新 error code 兩個進 common/code/grc_error_code.py+ FE error-code.json 同步
repo:~/Projects/Billows/Audit-Manager/compliance-manager-be
改既有:di_containers/remote_agent/remote_agent_containers.py
改既有:core/plugins/remote_agent.py(mount 注入四 port)
改既有:common/code/grc_error_code.py
改既有:core/scheduler_agent.py(加 expire_requests 每分鐘、reap_orphans 每小時)
新開:app/remote_agent/service/agent_request_service.py
新開:domain/remote_agent/entity/agent_request_entity.py
新開:domain/remote_agent/repository/agent_request_repo.py
新開:domain/remote_agent/service/agent_request_domain_service.py
新開:infra/remote_agent/model/agent_request.py
新開:infra/remote_agent/repository/agent_request_repo_impl.py
新開:infra/remote_agent/redis_command_waiter.py
新開:infra/remote_agent/file_spool.py
新開:test/test_agent_request_service.py
agent_tasks 的形狀(在套件 jedi_remote_agent/infra/agent_task/ 找範本,但本表是主系統自己的,放主專案 domain/remote_agent/+infra/remote_agent/);repo impl 繼承 BaseRepositoryImpl,__init__ 不開 session。app/remote_agent/service/agent_request_service.py:ask(agent, kind, payload, timeout=30)——@transaction 寫一列(uid、expires_at=now+5m)、取 request_id;交易提交後(after_commit 形狀照既有 AgentCommandService.after_commit)waiter.notify(agent.uid)、pubsub.subscribe(f"reply:{request_id}")、本地 Event.wait(timeout);醒來另開短交易讀該列 status/result:done→回 result;failed→raise BadRequestError(GRC_AGENT_REPLY_FAILED, reason);逾時→raise PreconditionFailedError→HTTP 504 用新碼 GRC_504_AGENT_NO_REPLY(jedi_common 沒有 504 例外的話用 InternalServerError 子類+自訂 status,看 register_error_handlers 怎麼對映,寫進回寫)。配額:模組層 threading.Semaphore(8),拿不到→ConflictError→503 GRC_503_AGENT_BUSY。🔴 吊著等的時候不持有 DB session。fire(agent, kind, payload):寫列+after_commit notify。reply(request_id, status, result, file_path=None):@transaction 更新列(taken→taken_at;done/failed→done_at、result、reason、file_path 記進 result);done/failed 時 after_commit redis.publish(f"reply:{request_id}", status);taken 不 publish。infra/remote_agent/redis_command_waiter.py 實作 CommandWaiter:每個 gunicorn worker 一條 pubsub 監聽執行緒訂閱 agent:*(pattern)與 reply:*,收到就 set 對應的本地 Event;wait(uid, timeout):長輪詢配額 Semaphore(8) 拿不到直接回 False(端點回 204);preempt(uid):set 該 uid 既有的 Event 並換新 Event。🔴 Redis 連線掉了:監聽執行緒重連退避,wait 照逾時回 False,不 raise。infra/remote_agent/file_spool.py 實作 FileSpool:目錄 /tmp/agent-outbound/(env AGENT_SPOOL_DIR 可改)、save 原子寫、open 回檔案物件且在 close 時 discard、reap_orphans(max_age=3600)。command_sources 實作(infra/remote_agent/command_sources_impl.py):restart_for=讀 agent.restart_requested in RESTART_MODES;update_for=rollout_service().update_for_agent(agent);requests_for=查 agent_requests waiting 且 expires_at > now 轉 Command。ReplySink.on_reply=agent_request_service.reply。core/plugins/remote_agent.py mount():把四個 port 與 payload_builder 注入套件 handle(只注入,不動 _agent_commands.py,那是 A-2);di_containers/remote_agent/ 註冊 service/repo/waiter/spool(waiter 與 spool Singleton)。core/scheduler_agent.py 加 expire_requests(interval 1 分:waiting 且過期→expired)與 reap_orphans(interval 1 小時)。common/code/grc_error_code.py 加 GRC_503_AGENT_BUSY、GRC_504_AGENT_NO_REPLY、GRC_400_AGENT_REPLY_FAILED(序號照檔內規則);FE src/locales/*/error-code.json 同步三個 key(🔴 這是跨 repo 的登記型檔,本卡只加這三個 key、commit 到 FE repo 另一個 commit)。test/test_agent_request_service.py(patch logger、用 EventCommandWaiter+假 pubsub):另一執行緒 reply done → ask 200 ms 內回 result;reply 晚於逾時 → ask 已 raise、DB 仍 done;第 9 條 ask → 503;expire_requests 把 6 分鐘前 waiting 標 expired;reap_orphans 刪 2 小時前的檔。USE_GUNICORN=true GUNICORN_WORKERS=2 GUNICORN_WORKER_CLASS=gthread 起(🔴 確切 PID kill);兩個終端各打一支會呼叫 ask 的臨時測試端點(或 python shell 直接叫 service)、redis-cli PUBLISH reply:<id> done → 兩條各在自己的 worker 醒來(log 印 pid);redis-cli SHUTDOWN NOSAVE(DEV 本機 Redis)→ ask 30 秒 504 不 500、Redis 起回來後監聽執行緒自己接上。pytest:ask 在另一執行緒 reply done 後 200 ms 內回 result;reply 晚於逾時 → ask 已 504、DB 仍落 done;第 9 條 ask → 503;過期 reaper 把 6 分鐘前的 waiting 標 expired。gunicorn 2 worker 實打:兩個終端各打一個 ask(假 kind)、用 redis-cli PUBLISH 模擬回覆 → 兩條都在正確的 worker 醒來(log 印 pid);redis-cli 殺掉 Redis → ask 30 秒後 504 不 500;ls /tmp/agent-outbound 取走後為空