Discord 非同步入站 Worker 計畫

目標

消除 Discord 監聽器逾時作為使用者面對的失敗模式,將入站 Discord 回合改為非同步:

  1. Gateway 監聽器快速接受並正規化入站事件。
  2. Discord 執行佇列以與目前相同的排序邊界為鍵儲存序列化的工作。
  3. Worker 在 Carbon 監聽器生命週期之外執行實際的代理回合。
  4. 執行完成後將回覆投遞回原始頻道或執行緒。

這是排隊的 Discord 執行在 channels.discord.eventQueue.listenerTimeout 逾時(但代理回合本身仍在進行)的長期修復方案。

目前狀態

此計畫已部分實作。

已完成:

  • Discord 監聽器逾時和 Discord 執行逾時現在是分開的設定。
  • 已接受的入站 Discord 回合入隊至 src/discord/monitor/inbound-worker.ts
  • Worker 現在掌管長時間執行的回合,而非 Carbon 監聽器。
  • 既有的逐路由排序透過佇列鍵保留。
  • Discord worker 路徑的逾時回歸覆蓋已存在。

白話說明:

  • 正式環境的逾時 bug 已修復
  • 長時間執行的回合不再僅因 Discord 監聽器預算到期而中止
  • worker 架構尚未完成

仍然缺少:

  • DiscordInboundJob 仍僅部分正規化,仍攜帶活躍的執行時參照
  • 指令語義(stopnewreset、未來的 session 控制)尚未完全 worker 原生
  • worker 可觀測性和操作者狀態仍很精簡
  • 尚未有重啟持久性

為何需要

目前的行為將完整的代理回合綁定在監聽器生命週期中:

  • src/discord/monitor/listeners.ts 套用逾時和 abort 邊界。
  • src/discord/monitor/message-handler.ts 將排隊的執行保持在該邊界內。
  • src/discord/monitor/message-handler.process.ts 在行內執行媒體載入、路由、分派、打字指示、草稿串流和最終回覆投遞。

該架構有兩個糟糕的特性:

  • 長時間但健康的回合可能被監聽器看門狗中止
  • 使用者看不到回覆,即使下游執行時已經可以產出

提高逾時有幫助但不改變失敗模式。

非目標

  • 本次不重新設計非 Discord 頻道。
  • 第一次實作不將其擴展為通用的全頻道 worker 框架。
  • 尚不抽取共用的跨頻道入站 worker 抽象;僅在重複明顯時共用低階原始元件。
  • 第一次不加入持久的崩潰復原,除非安全落地所需。
  • 本計畫不改變路由選擇、綁定語義或 ACP 策略。

目前的限制

目前的 Discord 處理路徑仍依賴一些不應留在長期工作 payload 中的活躍執行時物件:

  • Carbon Client
  • 原始 Discord 事件結構
  • 記憶體中的公會歷史對應
  • 執行緒綁定管理器回呼
  • 活躍的打字指示和草稿串流狀態

我們已將執行移至 worker 佇列,但正規化邊界仍不完整。目前 worker 是「稍後在同一行程中使用部分相同的活躍物件執行」,而非完全的純資料工作邊界。

目標架構

1. 監聽器階段

DiscordMessageListener 仍為進入點,但其工作變為:

  • 執行預檢和策略檢查
  • 將已接受的輸入正規化為可序列化的 DiscordInboundJob
  • 將工作入隊至逐 session 或逐頻道的非同步佇列
  • 入隊成功後立即回傳給 Carbon

監聽器不應再擁有端對端的 LLM 回合生命週期。

2. 正規化的工作 payload

引入可序列化的工作描述器,僅包含稍後執行回合所需的資料。

最小結構:

  • 路由身分
    • agentId
    • sessionKey
    • accountId
    • channel
  • 投遞身分
    • 目標頻道 id
    • 回覆目標訊息 id
    • 執行緒 id(若有)
  • 發送者身分
    • 發送者 id、標籤、使用者名稱、tag
  • 頻道上下文
    • 公會 id
    • 頻道名稱或 slug
    • 執行緒中繼資料
    • 解析的系統提示覆寫
  • 正規化的訊息內容
    • 基礎文字
    • 有效訊息文字
    • 附件描述器或解析的媒體參照
  • 閘門決策
    • 提及需求結果
    • 指令授權結果
    • 已綁定的 session 或代理中繼資料(若適用)

工作 payload 不得包含活躍的 Carbon 物件或可變閉包。

目前實作狀態:

  • 部分完成
  • src/discord/monitor/inbound-job.ts 已存在並定義 worker 交接
  • payload 仍包含活躍的 Discord 執行時上下文,應進一步精簡

3. Worker 階段

新增 Discord 專屬的 worker 執行器,負責:

  • DiscordInboundJob 重建回合上下文
  • 載入媒體和執行所需的額外頻道中繼資料
  • 分派代理回合
  • 投遞最終回覆 payload
  • 更新狀態和診斷

建議位置:

  • src/discord/monitor/inbound-worker.ts
  • src/discord/monitor/inbound-job.ts

4. 排序模型

對給定的路由邊界,排序必須與目前等同。

建議鍵值:

  • 使用與 resolveDiscordRunQueueKey(...) 相同的佇列鍵邏輯

這保留了既有行為:

  • 一個綁定的代理對話不會與自身交錯
  • 不同 Discord 頻道仍可獨立進行

5. 逾時模型

轉換後有兩個獨立的逾時類別:

  • 監聽器逾時
    • 僅涵蓋正規化和入隊
    • 應設定較短
  • 執行逾時
    • 選用、worker 擁有、明確且使用者可見
    • 不應意外繼承 Carbon 監聽器設定

這消除了目前「Discord gateway 監聽器保持存活」與「代理執行健康」之間的意外耦合。

建議的實作階段

第 1 階段:正規化邊界

  • 狀態:部分實作
  • 已完成:
    • 抽取 buildDiscordInboundJob(...)
    • 新增 worker 交接測試
  • 剩餘:
    • 使 DiscordInboundJob 為純資料
    • 將活躍的執行時依賴移至 worker 擁有的服務,而非逐工作 payload
    • 停止透過將活躍監聽器 ref 重新拼回工作來重建處理上下文

第 2 階段:記憶體內 worker 佇列

  • 狀態:已實作
  • 已完成:
    • 新增以解析的執行佇列鍵為鍵的 DiscordInboundWorkerQueue
    • 監聽器入隊工作而非直接等待 processDiscordMessage(...)
    • worker 在行程內、僅記憶體中執行工作

這是第一個功能性的轉換。

第 3 階段:行程分割

  • 狀態:尚未開始
  • 將投遞、打字指示和草稿串流的擁有權移至 worker 面向的轉接器後方。
  • 以 worker 上下文重建取代直接使用活躍預檢上下文。
  • 必要時暫時保留 processDiscordMessage(...) 作為外觀,然後分割。

第 4 階段:指令語義

  • 狀態:尚未開始 確保原生 Discord 指令在工作排隊時仍正常運作:

  • stop

  • new

  • reset

  • 任何未來的 session 控制指令

worker 佇列必須公開足夠的執行狀態,讓指令可以定位活躍或排隊的回合。

第 5 階段:可觀測性與操作者 UX

  • 狀態:尚未開始
  • 在監控狀態中發出佇列深度和活躍 worker 計數
  • 記錄入隊時間、開始時間、完成時間和逾時或取消原因
  • 在日誌中清楚呈現 worker 擁有的逾時或投遞失敗

第 6 階段:選用的持久性後續

  • 狀態:尚未開始 僅在記憶體版本穩定後:

  • 決定排隊的 Discord 工作是否應在 gateway 重啟後存活

  • 若是,持久化工作描述器和投遞檢查點

  • 若否,記載明確的記憶體邊界

除非落地需要重啟復原,否則應為獨立的後續工作。

檔案影響

目前的主要檔案:

  • src/discord/monitor/listeners.ts
  • src/discord/monitor/message-handler.ts
  • src/discord/monitor/message-handler.preflight.ts
  • src/discord/monitor/message-handler.process.ts
  • src/discord/monitor/status.ts

目前的 worker 檔案:

  • src/discord/monitor/inbound-job.ts
  • src/discord/monitor/inbound-worker.ts
  • src/discord/monitor/inbound-job.test.ts
  • src/discord/monitor/message-handler.queue.test.ts

可能的下一步接觸點:

  • src/auto-reply/dispatch.ts
  • src/discord/monitor/reply-delivery.ts
  • src/discord/monitor/thread-bindings.ts
  • src/discord/monitor/native-command.ts

目前的下一步

下一步是讓 worker 邊界成為真正的邊界,而非部分的。

接下來做:

  1. 將活躍的執行時依賴從 DiscordInboundJob 移出
  2. 改為保留在 Discord worker 實例上
  3. 將排隊的工作精簡為純 Discord 專屬資料:
    • 路由身分
    • 投遞目標
    • 發送者資訊
    • 正規化的訊息快照
    • 閘門和綁定決策
  4. 在 worker 內部從該純資料重建 worker 執行上下文

實務上這意味著:

  • client
  • threadBindings
  • guildHistories
  • discordRestFetch
  • 其他可變的僅執行時 handle

應停止存在於每個排隊的工作中,改為存在於 worker 本身或 worker 擁有的轉接器後方。

落地後,下一個後續工作應是 stopnewreset 的指令狀態清理。

測試計畫

保留既有的逾時重現覆蓋:

  • src/discord/monitor/message-handler.queue.test.ts

新增測試:

  1. 監聽器在入隊後即回傳,無需等待完整回合
  2. 逐路由排序得到保留
  3. 不同頻道仍可併發執行
  4. 回覆投遞至原始訊息目的地
  5. stop 取消 worker 擁有的活躍執行
  6. worker 失敗產生可見的診斷而不阻塞後續工作
  7. ACP 綁定的 Discord 頻道在 worker 執行下仍正確路由

風險與緩解

  • 風險:指令語義偏離目前的同步行為 緩解:在同一轉換中落地指令狀態串接,而非之後

  • 風險:回覆投遞遺失執行緒或回覆目標上下文 緩解:將投遞身分設為 DiscordInboundJob 中的一級欄位

  • 風險:重試或佇列重啟期間的重複傳送 緩解:第一版維持純記憶體,或在持久化前新增明確的投遞冪等性

  • 風險:遷移期間 message-handler.process.ts 變得更難推理 緩解:在 worker 轉換前或期間分割為正規化、執行和投遞輔助函式

驗收標準

本計畫完成的條件:

  1. Discord 監聽器逾時不再中止健康的長時間執行回合。
  2. 監聽器生命週期和代理回合生命週期在程式碼中是分開的概念。
  3. 既有的逐 session 排序得到保留。
  4. ACP 綁定的 Discord 頻道透過相同的 worker 路徑運作。
  5. stop 定位 worker 擁有的執行,而非舊有的監聽器擁有的呼叫堆疊。
  6. 逾時和投遞失敗成為明確的 worker 結果,而非靜默的監聽器丟棄。

剩餘的落地策略

在後續 PR 中完成:

  1. 使 DiscordInboundJob 為純資料並將活躍的執行時 ref 移至 worker
  2. 清理 stopnewreset 的指令狀態擁有權
  3. 新增 worker 可觀測性和操作者狀態
  4. 決定是否需要持久性或明確記載記憶體邊界

若保持 Discord 專屬且繼續避免過早的跨頻道 worker 抽象,這仍是有界的後續工作。