系列說明 >> 本系列大部分 PR 來自私人或公司專案,部分程式碼、環境設定與實作細節不便公開。
我只能保證文中提到的 PR 與問題皆為真實案例,但會經過必要的匿名化與內容調整。
本系列主要分享問題如何被發現、背後的思考方式,以及解法如何形成,不會深入討論完整實作與部署細節,敬請見諒。
Change Ref: [wifi_llapi PR #46-feat(audit): 落地 Audit Mode 工作流與治理]
Issue: 校正 415 個 Case 時,Agent 可以直接修改一般測試使用的正式 YAML;過程中反覆出現剛修好一條 Case,下一輪另一條卻讀到原始測試表的錯誤列,甚至把功能失敗時的固定現象寫成 PASS 判準。
Root Cause: 同一份 Case YAML 同時是正式測試規格,也是校正過程的實驗草稿。只要存檔,一個尚未確認的判斷就可能直接升格成下一輪測試相信的事實。
Solution: 將一般測試與 Audit 拆成不同入口;每次校正先建立唯一的 Runner ID(RID),綁定當時使用的原始測試表、程式版本、Case 與證據。候選修改必須先檢查範圍、留下依據、經過決定,才能寫回正式 YAML;提交前再由來源門禁確認紀錄。
Evidence: Audit Mode Design 記錄了共用正式 YAML 寫入權限造成的 False-positive,並留下 D366/D369 的具體案例;TestPilot v0.2.1 Changelog 則記錄了 Audit CLI、案件工作區、YAML 來源檢查與操作指南。
Base Repo: testpilot-core
「D366,PASS。」
Agent 把結果貼到畫面上,Report 又多了一格綠色。
坐在旁邊的老Go看了一眼。老Go不是特定同事,而是我把專案裡幾種務實的反對意見揉在一起後,替他取的代稱。看到一件原本改一行 YAML 就能解決的事,他通常先問成本。
「所以現在要為了一條 PASS,再做一套系統?」老Go開口。
「先別急。」我把 Driver 的結果拉到旁邊,「這條 PASS 跟下面這個 0,看起來不太對勁。」
D366: PASS
SRG BSS Color Bitmap: 0
我打開當時的 Case。省略不相關欄位後,判斷大致長這樣:
verification_command: >
grep -c '^he_spr_srg_bss_colors=' /tmp/wl0_hapd.conf
pass_criteria:
- operator: equals
value: "0"
hostapd 是 Linux/OpenWrt 上常見的 Wi-Fi AP 管理程式,負責讀取設定、啟動 AP,並處理連線與驗證。這裡檢查的 wl0_hapd.conf,就是這個 Radio 啟動時會使用的 AP 設定檔。
設定檔裡找不到 he_spr_srg_bss_colors,grep -c 就回 0。Case 看到 0,判定 PASS。
這條 Criteria 沒有偶爾誤判,也沒有 timing 問題。那個欄位每次都不出現,所以它每次都能非常穩定地得到 0。
非常穩定。
非常綠。
也非常不對。
D366 真正要驗證的是 SRG BSS Color Bitmap。Bitmap 可以先想成一排開關;輸入像 5,9,13 這樣的值,正確行為應該把第 5、9、13 個 bit 打開,再到 Driver 層確認結果。
問題出在資料往 Driver 傳遞的那一層。它沒有把 5,9,13 轉成 Bitmap,而是把幾個數字依序塞進欄位;Driver 最後讀到的 Bitmap 仍然是 0。
功能沒有成功。
hostapd 設定檔裡沒有那一行,只是失敗後留下來的症狀。但在我和 Agent 一邊看結果、一邊校正 Case 的過程裡,這個症狀最後竟然被寫成了成功條件。
「所以不是產品修好了。」老Go說。
「沒有。」我搖頭。
「只是把答案卡改成它會答的樣子。」老Go接著說。
這句我本來想反駁,後來發現沒有什麼好反駁的。
我把 D369 也拉出來。它測的是另一組 SRG Bitmap,Criteria 的方向幾乎相同。執行後同樣 PASS,往 Driver 看,Bitmap 仍然是 0。
Agent 比對兩份 Case 後補了一句:
「這不是單一 Case 的 Typo。兩份 Criteria 都把『功能未生效時看不到欄位』當成成功證據。」
這次它說得對。
更早之前,Case 和原始測試表的對應也一再串錯。
有時候我剛把一條 Case 修到和原始測試表對上,下一輪另一條卻因為行號對應錯位,拿到別列的規格;等我把對應關係修回去,前面已經確認過的相似 Case 又出現新的差異。測試表只要插入或移動一列,後面的 Case 就可能整排錯位,然後大家非常認真地拿別人的答案校正自己的題目。
問題不只是一條 Criteria 寫錯。
是「直接改正式 Case,再重跑看看」這個工作方式,已經開始反覆製造新的錯誤。
從 Day 1 開始,serialwrap 是地基,負責讓 Agent 可靠碰到設備;TestPilot 是執行引擎;Case 則是它要燒的燃料。
前七天先把 UART、Evidence、Human/Agent 共用與 Repository 規則站穩。那些東西還不穩時,一條 Case 跑出 FAIL,誰都有嫌疑:執行引擎、傳輸工具、測試環境、Case,或者 DUT 本身。
現在引擎終於能穩定運轉,才輪到我們回頭確認:灌進去的到底是不是對的油。
一具吃 95 無鉛的汽油引擎,供油系統即使完全正常,也照樣能把柴油送進汽缸。若油路裡還殘留汽油,它甚至可能先勉強運轉;等柴油比例升高,燃料特性和原本的點火方式對不上,才開始失火、抖動、冒煙,最後熄火。
供油流程正常,只能證明油有送到;不能證明送進去的是對的燃料。TestPilot 也一樣:YAML 能解析、Command 能送出、Evidence 能收回,只代表執行路徑正常。一份錯誤 Case,仍然可能被非常完整、非常穩定地執行。
Case YAML 可以先簡單看成:
# 結構示意,不是完整 Schema
id: Dxxx
source: # 測誰、來自哪一項規格
object: ...
topology: # 哪些設備參與,以及彼此怎麼連
dut: ...
sta: ...
environment: # 開跑前要把設備準備成什麼狀態
- ...
steps: # 實際操作與 Readback
- ...
expected: ...
pass_criteria: # 看到什麼才算成功
- ...
簡單說,source 決定要測哪一項規格;topology 決定誰參與、彼此怎麼連;environment 描述開跑前的狀態;steps 負責實際操作;最後再由 expected 與 pass_criteria 判斷看到什麼才算成功。
格式完整,只代表 TestPilot 跑得動。
source 對錯人、topology 把 DUT/STA、介面或連線關係指定錯、environment 沒把設備準備到正確狀態、steps 做錯事、expected 抄錯值,或 pass_criteria 把失敗症狀當成成功,引擎都會忠實執行一份錯誤測試。
真正讓 Audit Flow 必須獨立的,是這些事情一再發生後,我們終於承認:
「校正時小心一點」不是 Boundary。
正式 Case 不能再同時兼任草稿紙。
「那就複製一份 YAML。」老Go說,「草稿一份、正式檔一份,確認後再 Copy 回去。」
Agent 提出稍微工程化的版本:
「也可以用 Git Branch 隔離。修改留在另一個 Branch,確認後再 Merge。」
兩個方法都比直接改正式 Case 好。
但我還是問了幾個問題。
「這次是根據哪一份 Workbook 修改的?」我先問。
我說的 Workbook,就是當時拿來校正 Case 的原始人工測試表。
Agent 列出目前開著的檔案。
「修改前的 Case 是哪一版?」我接著問。
它再列出 Git Commit。
「剛才看到的 Driver 結果,又是哪一次測試留下來的?」我又問。
Agent 這次停了一下。
檔案找得到,Commit 找得到,Log 也找得到。只是它們各自躺在不同地方,沒有任何東西能證明大家講的是同一次校正。
Git Branch 可以回答哪幾行變了。
卻不能自己回答:
為什麼要變?
根據哪一份規格?
看過哪一次實機結果?
誰決定這份修改已經可以成為正式 Case?
如果最後仍由同一個 Agent 修改、重跑、宣布完成,再把 Branch Merge 回去,我們只是把「直接 Save」換成「直接 Merge」。
動詞比較 Git。
責任沒有比較清楚。
所以每次開始校正前,系統會先替這次 Audit Runner 建立一個唯一識別碼,也就是 Runner ID,簡稱 RID。
testpilot audit init wifi_llapi --workbook ~/0401.xlsx
「RID 又是什麼?」老Go看著那個縮寫問。
「可以把它想成 PID。」Agent 指著畫面說,「Linux 裡兩個 Process 都叫 python 很正常,但要送 Signal、看 Log 或查資源時,還是得靠 PID 分清楚。每次 Audit Runner 也有自己的 RID,讓 Workbook、Case、Commit 與 Evidence 都掛在同一次校正底下。」
「所以它不保證 Process 沒 Bug,只保證我們沒有抓錯 Process。」老Go說。
「Exactly。」Agent 回答。
「這就夠了。」我說,「RID 不會讓 Criteria 自動變正確,但至少三個人講『這份 Case』時,終於是在講同一份。」
沒有 RID 時,Agent 說「依 Workbook 修改」,可能指桌面上剛下載的版本;我說「剛才那次 Run」,可能是半小時前;老Go說「原本的 Case」,可能是 Git 裡尚未被前一次修改過的內容。
三個人都沒說謊。
三個人也可能在談三件不同的東西。
RID 建好後,也不是立刻把 415 條 Case 全部丟給 Agent 重新研究。
第一輪先用普通程式做自動篩選:能和原始測試表直接對上的先收起來,只有不一致、缺資料或語意不清的 Case,才交給後面的人工與 Agent。工具裡把這兩輪機械比對叫做 pass12。
「所以不是 415 條都從頭審?」老Go問。
「不是。」我回答,「能用程式排掉的,先不要浪費人和模型。」
Agent 補充:
「這一層不需要 LLM,而且可以重複執行。真正需要查原始碼、看實機結果和判斷語意的,才往後留。」
「這句可以留下。」老Go說。
他在意的不是 Flow 漂不漂亮。
是不要把 415 條 Case 做成 415 場宗教儀式。
D366 當然沒辦法在第一輪被收起來。
Agent 根據原始碼與 Driver 結果提出一份修改。除了修正驗證方式,它還順手改了這條 Case 在原始測試表裡對應的位置。
理由也很合理:
「既然目前的對應位置有問題,一起修正會比較完整。」
老Go看著 Diff。
「它說得沒錯。一起改比較乾淨。」
我先讓系統檢查這份修改有沒有超出本次允許的範圍。這個檢查在工具裡叫做 verify-edit。
結果被擋下來:
REJECTED
field outside audit edit scope: source.row
「明明有問題,為什麼不能改?」老Go問。
「因為這一次要回答的是『怎麼驗證這個功能』,不是順便改成『這條 Case 到底在測哪一題』。」我指著被擋下來的 source.row 回答。
驗證方式可能有錯,Case 對應的題目也可能有錯。
但不能因為同一個 Agent 剛好同時看到了,就把兩件事塞進同一份修改。原本只是修答案卡,修到最後連題目都換掉,通過率通常會非常好看。
穩定到不太需要測試。
Agent 把越界的修改拿掉,再檢查一次。
VERIFIED
「現在能寫回正式 Case 了吧?」老Go問。
「還不行。」我說。
後面只剩三步:
留下證據
→ 做出決定
→ 寫回正式 Case
工具裡分別叫做 record、decide 與 apply。
第一步只記錄我們看到了什麼:原始規格、原始碼、實機結果,以及準備修改的內容。
第二步才是承認:根據這些東西,我們決定接受、暫緩,還是要求補更多證據。
最後一步才會真的碰正式 YAML,再透過 PR 進入原本的 Review。
以前一次 save 同時代表:
我提出這個修改
我認為證據足夠
它現在可以成為正式 Case
三件事黏在一起,只要存檔,暫時判斷就直接升格成正式規格。
現在只是把它們拆開。
老Go沉默了一下。
「流程真的變慢了。」老Go說。
「對。」我承認。
「最後還是可能判錯。」老Go繼續追問。
「也對。」我說。
PR #46 買到的不是免費的正確答案。
它只是不再允許一個尚未確認的修改,在 Agent 按下存檔後,悄悄變成所有後續測試共同相信的事實。
老Go還提出過一個更便宜的方法。
「把規則寫進 AGENTS.md,叫 Agent 不准直接改正式 YAML,不就好了?」老Go問。
Agent 立刻表示可以遵守:
「後續校正將統一走 Audit Flow。」
聽起來沒有問題。
但 D7 才剛講完文件會漂移。如果 D8 馬上又把最重要的寫入邊界只放進文件,這個系列大概可以改名叫《人類如何在七天內忘記自己學過什麼》。
文件負責告訴 Agent 應該怎麼做。
門禁則負責在它沒有這樣做時,把動作停下來。
所以正式 Case 要被提交時,系統會檢查這次修改有沒有對應的稽核紀錄。這個提交前檢查,工具裡叫做 Provenance Hook。
老Go直接改了一個 Typo,Commit 被擋住:
audit-yaml-provenance: FAILED
no matching audit record
「我只是修 Typo。」老Go抗議。
「那就走一次簡化流程,或者留下明確的例外理由。」我回答。
老Go立刻抓到問題。
「既然可以例外,這個 Gate 有什麼用?」老Go追問。
Agent 先回答:
「例外不會被偽裝成完整 Audit。系統會留下是誰、為什麼,以及用什麼理由繞過。」
「所以它沒有阻止我。」老Go說。
「沒有。」Agent 回答。
「也沒有證明 Criteria 一定正確。」老Go再問。
「沒有。」Agent 又回答。
老Go轉過來看我。
「那你做這麼多,到底保證了什麼?」老Go問。
這次不能靠一句漂亮的話帶過。
這條 Flow 能證明的是:
修改根據哪一份資料
看過哪些證據
誰做了決定
最後哪一份內容被寫回正式 Case
它也能讓一個刻意繞過流程的人留下理由。
但它不能證明:
證據一定完整
原始碼一定看對
實機結果一定解讀正確
最後的 Criteria 一定沒有漏洞
Gate 能證明你有沒有走過入口。
不能保證走進來的人一定沒有拿錯東西。
PR #46 完成的,就是先把這個入口、正式 Case 的寫入權限,以及修改紀錄建立起來。
相關測試可以證明:超出範圍的欄位會被拒絕、只有選定的修改能寫回、沒有稽核紀錄的正式 Case 變更會被擋下。
它不能替實驗室證明 415 條 Case 已經全部校正正確。
老Go看完結果,沒有點頭。
「所以現在完成了?」他問。
「可以說 Flow 落地了。」我說,「不能說真理已經被做成一條 CLI Command。」
一般測試與 Audit 也沒有各養一套執行引擎。兩邊仍然共用 TestPilot:一般測試照正式 Case 執行;Audit 則負責提出修改、留下證據與決定,最後才允許寫回。
老Go這次反而先搖頭。
「為了校正測試,再養一套需要被校正的測試系統,聽起來很有工作保障。」他說。
這點他又是對的。
同一顆引擎,兩種權限。
差別在誰可以拿紅筆,以及紅筆落下前必須留下什麼。
幾天後,Repository 拆分,正式 Case 搬到新的位置。老Go又直接改了一條 YAML。
這次 Hook 沒有出聲。
他等了兩秒。
「不是說會擋?」老Go問。
Agent 檢查設定與 Case 的實際位置,幾秒後抬起頭。
「Hook 還在監看舊的位置。」它回答。
Script 正常,Regex 正常,Hook 也正常。每一層都很認真地完成自己的工作。
只是正式 Case 已經搬家。
「Gate 沒壞。」我看著那條已經沒有人經過的舊目錄說,「它只是很忠誠地守著舊地址。」
下一篇,搬家沒有通知警衛。
Have a nice day.