iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
AI Engineering

AI Agent 上線要想清楚的事:30 天拆解 Harness 的設計取捨系列 第 25 篇

Day 25|Agent 被 Webhook 叫起來,怎麼做到剛好一次?

  • 分享至 

  • xImage
  •  

📡 GitHub 不保證剛好一次,要自己做到。回「收到了」只代表 GitHub 不會再送,所以通知要先存進資料庫再回覆,才不會漏;GitHub 送失敗的不會自動補,要定期自己要求補送;補送回來的、重複的通知,開調查前先用編號認出收過的,才不會做兩次。

昨天在 Day 24|Prompt Injection 擋不完,怎麼辦?,我們讓被外部文字帶偏的 Agent 拿不到私有資料,或寫出去前先停下來讓人看。今天看 Agent 被叫起來的那一刻:改由 GitHub 的通知叫它,每件工作有沒有剛好開始一次?

快速回顧一下:Day 18 的 Agent 退款 timeout 後重送,帶同一把 idempotency key,Stripe 就認得出是同一筆。今天反過來,GitHub 送、我們收,要認出重複的是我們自己。

假設團隊做了一個幫忙查問題的 Agent。有人在 GitHub 開 Issue 回報問題,它就去讀 Issue、翻相關的 log,最後把結果留言在那張 Issue 底下。查一次要跑好幾分鐘、花一份模型的 token。

要讓 GitHub 上有人開 Issue 時叫起自己的 Agent,常見的接法是 webhook:在 repo 設定裡登記一個我們伺服器的網址,有人開 Issue,GitHub 就送一個 HTTP 請求到那個網址。團隊用 FastAPI 寫了接收服務收通知。GitHub 只等 10 秒,等 Agent 查完再回一定來不及,所以收到先回「收到了」,再用 FastAPI 內建的 BackgroundTasks,在同一支程式裡叫 Agent 去查。

第一次出事是在部署的時候。部署要把舊程式關掉,Agent 一查就好幾分鐘,還沒查完程式就被關了,正在查的和排在後面的都跟著不見。GitHub 收到的是成功,不會再送,這幾張 Issue 就一直沒人查;等有人發現已經過了三天,連 GitHub 讓你手動補送的期限都過了。

團隊照 GitHub 文件改了兩件事:通知先寫進資料庫,寫好才回「收到了」,再由 worker(在背景專門處理待辦的程式)拿出來查;另外寫一支排程,每小時查一次 GitHub 記成送失敗的通知,要求 GitHub 補送。

第二次出事,反而是補送造成的。有天資料庫很忙,Issue 731 的通知寫進去花了 12 秒。資料其實存好了,worker 也開始查了,可是超過 10 秒,GitHub 把這則記成送失敗。一小時後排程要求補送。補送的通知連 GitHub 給的編號都跟第一次相同,資料庫卻沒拿這個編號擋重複,又存了一筆,worker 又查了一次。同一張 Issue 留下兩則差不多的留言,花了兩份 token。

先回成功會漏,補送會重複

圖:左邊是第一次,先回 200、調查在同一支程式裡跑,部署把程式關掉就沒了;右邊是第二次,寫入花了 12 秒,GitHub 記成送失敗,排程補送同一則,又查了一次。這是示意,實作要照自己的工具和環境調整。

兩次出事,模型都沒做錯,錯在 Agent 被叫起來之前:一次是收到了沒存下來,一次是同一則通知被處理了兩次,而且是照文件補漏才帶出來的。下面先拆開一則通知從送出到叫起 Agent 經過哪幾步、GitHub 保證了什麼、用 Claude Code 的話誰在管,再講三個做法:先存再回、認出收過的、定期對帳。

Webhook 是什麼,怎麼把 GitHub 接到自己的 Harness?

Day 1 的 Agent loop 是有人送出一則訊息才開始跑。開頭這個 Agent 沒有人跟它聊天,要等 GitHub 上發生事情才開始,所以 Harness 得有辦法知道「GitHub 那邊有人開了 Issue」。

辦法有兩種。一種是輪詢(polling):每隔幾分鐘自己呼叫 GitHub 的 API,問有沒有新的 Issue。另一種是 webhook,GitHub 的文件這樣介紹:你訂閱某些事件,事件一發生,GitHub 就把資料送到你的伺服器,實際上是一個打到你事先登記網址的 HTTP POST。文件也拿兩者比過:webhook 不用一直呼叫 API,事件一發生就送來,接近即時;要盯的東西很多時,輪詢很快就會用完 API 的呼叫額度。

不只 GitHub,Stripe 有人付款、Shopify 有新訂單,也都用 webhook 通知你。要把一個現成的網站接進自己的 Harness,讓那邊發生的事叫起 Agent,最常見的就是這個接法:在那個網站登記一個網址,自己在網址後面寫一支程式收。一則通知從送出到叫起 Agent,會經過這幾步:

GitHub:有人開了 Issue 731
  │ 1. 送一個 HTTP POST 到我們登記的網址
  │    內容是哪個 repo、哪張 Issue、誰開的;header 帶這次送出的編號
  ▼
接收服務(跑在我們的伺服器上)
  │ 2. 驗證簽章,確認真的是 GitHub 送的
  │ 3. 把要查的事記成一筆待辦
  │ 4. 10 秒內回 GitHub「收到了」(HTTP 2XX)
  ▼
worker(在背景處理待辦的程式)
  │ 5. 拿出待辦,叫起 Agent
  ▼
Agent loop:讀 Issue、翻 log、在 Issue 底下留言

第 1 步是 GitHub 做的。登記網址時選好要聽哪些事件,例如 Issue 被開啟、被加標籤;事件一發生,GitHub 就送一則通知。每次送出都帶一個編號,放在 X-GitHub-Delivery header 裡,叫 delivery ID,後面認重複會用到它。

第 2 到 4 步是接收服務,這是我們自己寫的程式。網址是公開的,誰都打得到,所以先用登記時設定的 webhook secret 驗證簽章,確認通知真的來自 GitHub。GitHub 送出後只等 10 秒,Agent 查一次要好幾分鐘,接收服務不能等查完才回,只能先把待辦記下來就回「收到了」。

第 5 步交給 worker,它一筆一筆拿出待辦,叫起 Day 1 那個 Agent loop。從這裡開始,就是前面二十幾天在講的事。

接 webhook,等於 Harness 多了一個入口:以前是有人在畫面上送訊息,現在是 GitHub 送通知。第 1 到 5 步 Agent 都還沒開始跑,模型不知道這幾步發生什麼,通常也沒人看著。開頭兩次出事都在這幾步:第一次是第 3 步的待辦只放在接收服務那支程式裡,程式一關就沒了;第二次是第 3 步寫資料庫寫了 12 秒,拖到第 4 步超過 10 秒。

GitHub 保證每則通知都送到、只送一次嗎?

輪詢的時候,什麼時候查、查到哪裡都在自己手上,這次漏了下一輪再查就好。改成 webhook,什麼時候送、送失敗怎麼辦,就照 GitHub 的規則。GitHub 的規則在兩份文件裡:最佳實務要求接收端在十秒內回 2XX,沒做到這次就記成送失敗;處理送失敗通知的文件寫得很直接,GitHub 不會自動重送失敗的通知。要補,得自己在畫面上或用 API 要求重送,而且只能補最近三天的。

「送到沒」這件事,訊息系統有現成的分法。Kafka 是常用的訊息系統,它的設計文件分成三種:

  • at most once:最多送一次。可能漏,但不會重複。
  • at least once:至少送一次。不會漏,但可能重複。
  • exactly once:剛好一次,不漏也不重複。

同一段也提醒,很多系統號稱剛好一次,可是送的一方或收的一方會當掉、收的程式又有好幾個的時候,常常就做不到。

拿這三種去對 webhook。GitHub 自己不重送,比較接近 at most once,可能漏;你用 API 補送之後,同一則又可能收到第二次。Stripe 走另一邊,它的 webhook 文件寫正式環境會自動重送最多三天、間隔越拉越長,也寫明同一個事件偶爾會收到不只一次,接近 at least once。兩家並排:

GitHub
  要多快回      → 10 秒內回 2XX
  沒回成功      → 記成失敗,不會自動重送;3 天內可以手動或用 API 補送
  會不會重複    → 會,補送的那幾則
  怎麼認出重複  → 每次送都帶一個編號(X-GitHub-Delivery header),補送時沿用原本的

Stripe
  要多快回      → 在任何可能 timeout 的邏輯之前先回 2xx,文件沒寫幾秒
  沒回成功      → 正式環境自動重送,最多三天
  會不會重複    → 會,文件寫偶爾會收到同一個事件不只一次
  怎麼認出重複  → 事件 ID,記下處理過的,不再處理

兩家都沒有承諾剛好一次,要做到得靠我們自己:收到的通知先存下來,就不會漏;再用編號認出收過的,就不會重複。兩半合起來,效果上就是剛好一次。後一半在微服務的設計模式裡叫 idempotent consumer:收訊息的程式把處理過的訊息 ID 記在資料庫裡,同一個 ID 再來就丟掉。

用 Claude Code 的話,這段誰在管?

Claude Code 有兩種方式被 GitHub 事件叫起來,收 webhook 這一段都不用自己寫。

GitHub Actions 裡的 claude-code-action。 Claude Code 的文件寫,workflow 沒給 prompt 時,它等的是 Issue、PR 留言或新 Issue 內文裡的 @claude;給了 prompt,任何 GitHub 事件都能叫它起來。收 webhook 的是 GitHub 自己,每個符合條件的事件啟動一次 workflow。同一張 Issue 會帶出好幾則通知,GitHub Actions 的文件剛好舉過:帶兩個標籤開 Issue,會啟動三次 workflow,一次給開 Issue,兩次給加標籤。

Claude Code 的文件在控制花費那段,建議用 GitHub 的 concurrency 設定限制同時跑的數量。concurrency 的文件寫,它把 workflow 分組,同一組同時只會跑一個;已經有一個在跑,新來的就排隊;預設只留一個排隊的,再來一個就把它取代,設成 queue: max 最多能排 100 個,順序也不保證。用 Issue 編號分組,Issue 731 就不會同時跑兩個 Claude;可是開 Issue 那次跑完,排在後面的加標籤那次還是會再跑一遍,兩次不會併成一次。

Routines 的 GitHub 觸發。 Routines 是存在雲端的 Claude Code 設定,照排程、API 呼叫或 GitHub 事件自動跑,目前是 research preview,GitHub 這邊只支援 PR 和 Release 兩類事件。文件寫每個事件都開一個新的 session,同一個 PR 更新兩次,就是兩個各自獨立的 session;GitHub 事件超過每小時上限的部分會被丟掉,等時間窗重置才再收。

其他產品也讓事件叫起背景 Agent:Cursor Automations 可以接排程、Slack、Linear、GitHub、PagerDuty 和自訂 webhook;LangChain 講的 ambient agents,是聽著事件串流、照事件行動的 Agent。兩篇文章都沒提送達保證和去重。

用現成產品的話,收通知、存下來這段平台會做,但每則通知都會叫起一次新的 Claude,同一張 Issue 會不會查兩次,還是要自己顧。自己接通知的話,兩件事都要自己做,下面三節講的就是這種情況。

先回成功,工作為什麼會不見?

開頭那個接收服務先回應、再把工作交給背景處理,本身沒有錯,GitHub 和 Stripe 的文件都要你這樣做:先回應,耗時的工作排進 queue 再處理。問題在背景工作放在哪裡。FastAPI 的文件說 BackgroundTasks 在回應之後執行,適合寄通知信這種小工作;重的、要分到好幾支程式或好幾台機器跑的,建議改用 Celery 這類工具,背後要有 RabbitMQ 或 Redis 這種另外存著的 queue。開頭的調查一跑好幾分鐘,卻跟接收服務綁在同一支程式裡。用 Kubernetes 部署的話,它關掉舊程式時預設只給 30 秒收尾,時間到還在跑的就強制結束。

再加上「回應」和「存下來」的先後。回了 2XX,GitHub 就當作送到了,不會再送;待辦如果只在程式裡,程式一結束,就沒有任何地方記得這筆。改法就是開頭團隊後來做的:在資料庫開一張收件表,每收到一則通知就記一筆,寫進去了才回 GitHub;worker 從收件表一筆一筆拿出來處理。開頭第一次漏掉,改前和改後的順序並排是這樣:

改前:收到通知 → 回 200 → BackgroundTasks 在同一支程式裡叫 Agent 查
      部署關掉這支程式 → 正在查的、排在後面的都不見了,GitHub 認為已送達,不會補送

改後:收到通知 → 驗證簽章 → 寫進收件表(同一個編號只存一筆)→ 寫進去了才回 2XX
      → worker 從收件表拿出來 → 開調查
      部署發生在任何兩步之間 → 已經存進收件表的,重開後的 worker 接著處理;
                              還沒回 2XX 的,GitHub 記成送失敗,後面的對帳會補

改後唯一要守住的先後是「存好了才回成功」;回應和 worker 開始處理誰先誰後都沒關係。存好才回也有代價:寫入慢到超過 10 秒,資料明明存好了,GitHub 一樣記成送失敗,之後補送回來就是第二次。開頭第二次出事就是這樣,下一節講怎麼認出來。

收件表就是前面 idempotent consumer 說的那張表。那份模式說明的例子用訊息 ID 當主鍵,同一個 ID 第二次寫入會失敗,接收服務就知道這則收過了。

收件表和 queue 如果是兩套系統,可以照 transactional outbox 的做法接起來:在同一筆資料庫 transaction 裡寫進收件紀錄和一筆「待送進 queue」的紀錄,提交之後再由另一支程式送進 queue。用既有的資料庫和 queue 就好,不必在 Harness 裡重做一套訊息系統。不要只因為函式叫 enqueue(),就假設資料已經存好,要查那個服務什麼時候才算寫入成功。

同一張 Issue 又進來一次,要開幾次調查?

開頭第二次進來的是同一則通知。前面說過 GitHub 每次送出都帶一個 delivery ID,補送時會沿用原本的值。所以收件表拿來源和 delivery ID 當唯一鍵:補送回來的那則寫不進第二筆,接收服務就知道收過了,照樣回成功,不再交給 worker。

這就是 Day 18 講的冪等:同一件事做兩次,效果跟做一次一樣。Day 18 是 Agent 往外送、由 Stripe 認出重送;這裡是通知往內送,由我們的收件表認出重送。

還有一種重複,delivery ID 認不出來:兩則不同的通知,要做的是同一件事。前面 GitHub Actions 文件舉的例子就是這種,帶標籤開 Issue,GitHub 會送一則「開了 Issue」、再送一則「加了標籤」。假如團隊也想讓後來才補上 security 標籤的 Issue 被查,接收服務對兩種通知都會開調查;有人開 Issue 時順手貼上標籤,兩則幾乎同時到,delivery ID 不同,收件表各存一筆,兩個 worker 各拿一則,就會撞在「開調查」這一步。

這時要認的是「要做哪件事」,用 business key:由「哪個客戶、哪個 repo、哪張 Issue、做什麼」組成,例如 tenant_7/repo_a/issue_731/investigate。資料庫設計裡把這種從資料內容本身組出來的 key 叫 business key。要擋住第二筆,靠的是資料庫的 unique constraint:設定某幾個欄位的組合不能出現兩筆,第二筆寫入會直接失敗。改前和改後並排:

改前:先查「issue_731 有沒有進行中的調查?」→ 沒有 → 開一筆
      worker A 和 B 同時查,都看到沒有 → 開了 run_88 和 run_89

改後:資料庫設 unique constraint:同一個 business key 只能有一筆進行中的調查;worker 不先查,直接開
      worker A 開 run_88 成功
      worker B 寫入失敗 → 把「加標籤」這則當新資料附到 run_88,請它醒來看

先查再開,兩個 worker 同時查,都會看到「還沒有」;交給資料庫擋,誰先誰後都只會開出一筆。

business key 跟 Day 18 從「做哪一件事」算出 Operation ID 是同一個想法:從工作內容組出來,不管哪一則通知、哪個 worker 來算,都是同一個。key 怎麼組,決定擋的範圍:只用 issue_731,不同客戶的 repo 都可能有 Issue 731,會擋錯;只用 repo,同一個 repo 的所有 Issue 會擠成一筆調查。unique constraint 也只擋「同時有兩筆進行中的調查」。

調查做完之後才到的通知要另外處理。例如 run_88 已經查完,GitHub 晚送的一則通知才進來,這時沒有進行中的調查,unique constraint 擋不到,直接開就會把同一張 Issue 再查一遍。所以 worker 先看這件工作有沒有查完的調查:有就把通知記在那筆調查底下、標成忽略;只有明確要求重查,例如有人留言請 Agent 再查一次,才開新的一筆。

這一層只管「同一件工作只開一次」。調查途中寄信、開 PR 這類外部操作 timeout 之後 retry,靠的是 Day 18 的 Operation ID;worker 當掉、別的 worker 接手時怎麼擋住舊的那一個,是 Day 6 的執行權版本。

漏掉的通知,誰來補?

先存再回,擋得住「回了成功卻沒存」,擋不住「根本沒送到」:接收服務剛好停機,或回應超過十秒,GitHub 都記成送失敗,而且不會自己重送。GitHub 文件給的做法就是開頭那支排程:定期查出送失敗的通知,再要求補送。送失敗的清單裡,有些其實已經處理過,只是回得太慢,像開頭那則花了 12 秒的;補送沿用原本的 delivery ID,收件表會擋掉這幾則。只能補三天內的,這支排程至少要比三天跑得勤。

送的那一方自己也會出狀況。GitHub 2024 年 7 月的可用性報告寫,7 月 5 日一次設定變更讓 webhook 的背景工作被拒絕,通知平均晚到 24 分鐘、最久 71 分鐘。如果對帳已經從 GitHub API 查到這張 Issue、自己補了一筆收件紀錄,晚到的通知才進來,兩筆紀錄沒有共同的 delivery ID,要靠 business key 才認得出是同一件工作。

另一道是對帳:排程定期從 GitHub 查「還開著、卻沒有對應工作的 Issue」,用 business key 比對 GitHub 上該有的工作和收件表裡已經有的。Shopify 的 webhook 文件也這樣建議:送達不保證,app 不能只靠 webhook,要定期把資料拉回來對。比對出來的落差,我會照類型分開處理,這套分流是我的建議:

對帳看到的落差 下一步
GitHub 上有、收件表沒有 補建收件紀錄,走正常入口,不直接叫模型
有收件紀錄、沒有對應的調查 先看當時的決定:判定忽略的不開,判定要做的才補派
Issue 已經關閉 記下忽略理由;已經在查的,照政策取消
調查還在跑,很久沒進度 交給 Day 6 的執行權版本和 Day 7 的恢復流程接手

這張表由排程的對帳程式在查完 GitHub 之後使用,不讓模型猜是哪一種。

第二列要能判斷,收件紀錄就得存下當時的決定和理由;把「刻意忽略」和「沒查到」記成同一種結果,下一輪掃描還會再撞到同一張 Issue。

對帳查的是現在的狀態。一張 Issue 在兩次掃描之間開了又關,只查目前還開著的,就找不回它。要每個事件都不漏,得靠補送失敗的通知,或 GitHub 保存的事件歷史;換成別的來源,如果它的通知沒有固定的編號、也查不了目前狀態,就只能把限制講明,改成提高輪詢頻率或留人工對帳。

從收到通知到開始調查,最少要存什麼?

前面三節的做法,落到資料庫裡是兩張表。收件表記「收到了哪些通知」,一則通知一筆;調查表記「開了哪些調查」,一次調查一筆。補送回來的同一則通知,由收件表認出來;開 Issue、加標籤這種不同的通知要做同一件事,由調查表擋下第二筆。

收件表至少要有這幾欄,值用開頭 Issue 731 那則通知:

欄位 Issue 731 那則的值 為什麼要存
source、delivery_id GitHub、d-1 這則通知是誰送的、編號多少。兩個合起來設成不能重複,補送回來的 d-1 寫不進第二筆,接收服務就知道收過了。少了它,補送會被當成新的通知再查一次
payload 開 Issue 那則通知的原文 worker 要從裡面讀出是哪個客戶、哪張 Issue;事後查問題的人也要看當時收到什麼
business_key tenant_7/repo_a/issue_731/investigate worker 從原文組出來,代表「要做哪件事」;開 Issue 和加標籤兩則通知,算出來是同一個值。少了它,worker 認不出兩則通知要做同一件事
status、理由 剛存進來是 received;接上調查後改成 attached;決定不處理就是 ignored,附上理由 worker 靠 received 找出還沒處理的;對帳時靠它分出「刻意不查」和「漏了沒查」。少了它,刻意忽略的每次對帳都會再撞到
run_id run_88 這則通知接到哪一次調查。之後要查這次調查是被哪幾則通知叫起來的,就靠這欄

調查表至少要有這幾欄:

欄位 Issue 731 的值 為什麼要存
run_id run_88 這次調查的編號,Agent 查的每一步都記在它底下
business_key tenant_7/repo_a/issue_731/investigate 跟收件表那欄同一個值。資料庫設 unique constraint:同一個 business key 只能有一筆進行中的調查,兩個 worker 同時開,只有一個開得成
status 查的時候是 active,查完是 finished 上面那條規則只看進行中的,查完的擋不到,所以 worker 開之前要先看有沒有查完的

兩張表各由一支函式寫。receive_webhook 是接收服務收到每一則通知時跑的,只寫收件表;start_or_attach 是 worker 從收件表拿出一筆 received 時跑的,決定開新的調查、交給進行中的那一筆,還是忽略:

def receive_webhook(request, verify, inbox):
    event = verify(request)                          # 簽章不對、不是我們客戶的 repo,就拒絕
    if event is None:
        return 401
    inbox.insert_if_absent(event.source, event.delivery_id, event)  # 同一個來源和 delivery ID 已經有了,就不再寫
    return 200                                       # 存好了才回「收到了」


def start_or_attach(item, runs):
    done = runs.finished(item.business_key)          # 這件事已經查完了嗎?
    if done and not item.asks_rerun:
        item.mark("ignored", reason="已經查完", run_id=done.id)  # 晚到的通知不重開
        return
    try:
        run = runs.create_active(item.business_key)  # 開一筆進行中的調查
    except UniqueViolation:                          # 已經有進行中的,資料庫不讓開第二筆
        run = runs.active(item.business_key)
        run.attach(item)                             # 把這則通知交給它,正在查的 Agent 也看得到
    item.mark("attached", run_id=run.id)

用開頭 Issue 731 的幾則通知走一遍:

誰呼叫 什麼時候 結果
接收服務呼叫 receive_webhook GitHub 送來 Issue 731 被開啟的通知 d-1,資料庫很忙,寫了 12 秒 收件表存好 d-1,狀態是 received;回「收到了」時已經超過 10 秒,GitHub 記成送失敗
worker 呼叫 start_or_attach 拿到 d-1 沒有查完的、也沒有進行中的,開出 run_88;d-1 改成 attached,記下 run_88
接收服務呼叫 receive_webhook 一小時後排程要求補送,d-1 又進來 收件表已經有 d-1,什麼都沒寫,照樣回「收到了」;沒有新的待辦,worker 不會再查一次
接收服務和 worker 各跑一次 run_88 還在查,有人補上 security 標籤,GitHub 送來加標籤的通知 d-2(團隊也設定要處理這種通知) delivery ID 不同,收件表另存一筆;worker 算出的 business key 跟 d-1 一樣,開調查被資料庫擋下,d-2 交給 run_88
worker 呼叫 start_or_attach run_88 查完之後,GitHub 晚送的通知 d-3 才進來 查到 run_88 已經查完,d-3 也沒要求重查,標成 ignored,不開新的調查
對帳排程 每小時 查出 GitHub 記成送失敗的通知要求補送,第三列那次就是這樣來的;再比對 GitHub 上還開著的 Issue 和收件表,漏掉的補建一筆 received,照樣交給 worker

第三列就是開頭第二次出事少掉的那一步:當時收件表沒拿 delivery ID 擋,補送的 d-1 又存成一筆新的待辦,worker 就又查了一次。

先存再回,同一張 Issue 只開一次調查

圖:上方是改後的順序,存進收件表才回 2XX,worker 再照 business key 開新的調查,或附到進行中的那一筆;中間是 Issue 731 的幾則通知各自的結果;下方是對帳排程補回漏掉的。這是示意,實作要照自己的工具和環境調整。

可以先從哪裡做起?

假設你已經做到 Day 24:Agent 不會在同一輪讀到外人寫的內容、讀了私有資料,又寫到公開的地方。下一步是讓它被叫起來的這一段不漏、不重複。

第一步,先存再回成功。 接收服務驗證簽章之後,把通知寫進資料庫的收件表,用來源和 delivery ID 當唯一鍵,寫進去了才回 2XX。做到這步,部署和當機不再吃掉已經回過成功的通知。用現成 queue 服務的,先查它的文件,確認回應前資料已經寫入。

第二步,同一張 Issue 只開一次調查。 替 business key 設 unique constraint,讓 worker 直接開,被擋下就附到進行中的那一筆。沒做這步,同一張 Issue 的幾則通知會各叫起一個 Agent,費用和留言都會重複。

第三步,排程對帳。 定期查送失敗的通知要求補送,再比對 GitHub 上還開著的 Issue 和收件表。沒做這步,接收服務停機那段時間的 Issue 就永遠沒人處理。

用 claude-code-action 的話,收和存這段 GitHub 做掉了,第一步可以跳過;用 Issue 編號設 concurrency group,至少不會同時跑兩個 Claude。同一張 Issue 會不會被調查兩次,我會在 workflow 開頭先查這張 Issue 有沒有 Claude 的留言,有就停。

單一來源、流量不大,一張收件表加既有的 queue 就夠開始,不必先建事件平台。要跨好幾個來源、多個 worker 搶同一批工作,或來源經常停機的時候,再把對帳補齊。

Day 26 會控制工作被叫醒之後的總支出,Day 28 會讓入口的決定和後面的每一步都追得回去。

你可以怎麼確認自己讀懂了?

回到 Issue 731,可以試著問自己:webhook 回了 200、收件表有紀錄、run_88 開起來了,各代表走到哪一步?那則花了 12 秒、被記成送失敗的通知補送回來,能不能再開一次調查?

回 200 只代表 GitHub 不會再送,收件表有紀錄才代表通知存下來了,run_88 開起來才代表調查開始;第二題不能:補送沿用同一個 delivery ID,收件表寫不進第二筆,接收服務就知道收過了;就算是加標籤那種不同的通知,同一個 business key 也只能有一筆進行中的調查。

GitHub 不保證剛好一次,要自己做到。回「收到了」只代表 GitHub 不會再送,所以通知要先存進資料庫再回覆,才不會漏;GitHub 送失敗的不會自動補,要定期自己要求補送;補送回來的、重複的通知,開調查前先用編號認出收過的,才不會做兩次。

Agent 能被可靠叫醒之後,明天接著看它開始工作以後的資源:能花多少時間和 token,好幾個 Agent 共用同一份額度時,又由誰決定還能不能繼續。

參考資料

  1. GitHub Docs|About webhooks:webhook 讓你訂閱事件,事件發生時把資料送到你的伺服器;拿它跟隔一段時間呼叫 API 的輪詢比,webhook 比較省資源、接近即時,要盯的東西多時也不會很快用完 API 額度。
  2. GitHub Docs|Best practices for using webhooks:用 webhook secret 驗證通知來源、十秒內回 2XX、用 queue 在背景處理、X-GitHub-Delivery 每次送達唯一而且重送時沿用原值、伺服器恢復後要補送漏掉的。
  3. GitHub Docs|Handling failed webhook deliveries:GitHub 不自動重送失敗的通知,官方建議用排程查出送失敗的通知再要求補送。
  4. GitHub Docs|Redelivering webhooks:只能補送最近三天送過的通知。
  5. Apache Kafka|Design:Message Delivery Semantics 一段定義 at most once、at least once、exactly once,並提醒號稱 exactly once 的系統要看清楚細節。這是訊息系統的通用分類,GitHub 和 Stripe 的文件沒用這組詞,對應是我照兩家寫的行為歸的。
  6. Stripe Docs|Receive Stripe events in your webhook endpoint:正式環境自動重送最多三天、同一個事件偶爾會收到不只一次、用事件 ID 去重、在複雜邏輯之前先回 2xx。
  7. microservices.io|Idempotent Consumer:at least once 的訊息會讓 consumer 處理同一則好幾次,做法是把處理過的訊息 ID 存進資料庫,以 ID 當主鍵擋掉第二次。
  8. Claude Code|Claude Code GitHub Actions:沒給 prompt 時等 @claude,給了 prompt 就能由任何 GitHub 事件觸發;建議用 GitHub 的 concurrency 限制同時跑的數量。
  9. GitHub Docs|Triggering a workflow:帶兩個標籤開 Issue 會啟動三次 workflow,一次建立就會帶出好幾筆事件。
  10. GitHub Docs|Control the concurrency of workflows and jobs:同一組同時只跑一個,預設排隊中的會被新來的取代,queue: max 最多能排 100 個,順序不保證;它限制同時執行,不會把幾次觸發併成一次。
  11. Claude Code|Automate work with routines:GitHub 觸發只支援 PR 和 Release,每個事件開新的 session、不沿用,超過每小時上限的事件會被丟掉。功能目前是 research preview,行為和限制可能會變。
  12. Cursor|Build agents that run automatically:Cursor Automations 能從排程、Slack、Linear、GitHub、PagerDuty 和自訂 webhook 叫起背景 Agent;文章沒有談送達保證和去重。
  13. LangChain|Introducing ambient agents:聽著事件串流、照事件行動的背景 Agent,和 Agent inbox 的產品觀點;不拿來當送達保證的依據。
  14. FastAPI|Background Tasks:BackgroundTasks 在回應之後執行、跟 app 在同一支程式裡,適合寄通知信這種小工作;重的、要跑在好幾支程式或好幾台機器上的,建議改用 Celery 這類工具,背後要有 RabbitMQ 或 Redis。部署時正在跑的背景工作會不會被關掉,文件沒寫,是我照部署平台的行為推的。
  15. Kubernetes|Pod Lifecycle:Pod 結束時預設有 30 秒可以收尾,時間到還在跑的 process 會被強制結束。其他部署平台的等待時間各自不同。
  16. microservices.io|Transactional outbox:要送出的訊息和業務資料在同一個 transaction 寫進資料庫,再由另一個 process 送進訊息系統。
  17. GitHub Blog|GitHub Availability Report: July 2024:2024 年 7 月 5 日設定變更讓 webhook 的背景工作被拒絕,送達平均延遲 24 分鐘、最久 71 分鐘。
  18. Shopify Docs|Best practices for webhooks:送達不保證,app 不能只靠 webhook,要用對帳工作定期從 Shopify 拉資料;用 X-Shopify-Webhook-Id 擋重複。

上一篇
Day 24|Prompt Injection 擋不完,怎麼辦?
下一篇
Day 26|Agent 的錢花在哪,該盯哪個數字?詳解如何控制 Harness 成本
系列文
AI Agent 上線要想清楚的事:30 天拆解 Harness 的設計取捨 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言