iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
AI Engineering

一天一個 PR,讓我告訴你嵌入式開發的殘酷:如何為 AI Agent 建立可信的工程閉環系列 第 9

Day 9 - 明明叫 Plugin,為什麼愈來愈像 Wi-Fi 專用工具?用拆 Repo 做到乾淨的權責分界

  • 分享至 

  • xImage
  •  

系列說明 >> 本系列大部分 PR 來自私人或公司專案,部分程式碼、環境設定與實作細節不便公開。

我只能保證文中提到的 PR 與問題皆為真實案例,但會經過必要的匿名化與內容調整。

本系列主要分享問題如何被發現、背後的思考方式,以及解法如何形成,不會深入討論完整實作與部署細節,敬請見諒。

Today’s Change

  • Base Repo: testpilot-core
  • Change Ref: Commit 0dd7733-chore: initial TestPilot core (split from hamanpaul/testpilot monorepo)
  • Issue: TestPilot Monorepo 同時持有框架本體、Plugin、testbed.yaml、整合測試與專案歷史。Wi-Fi Plugin 長大後,其環境假設開始回滲框架本體;舊 History 也無法直接成為可公開的 Repository Root。
  • Root Cause: plugins/ 只完成檔案分類,沒有切開 Ownership、Public API、Version、CI、Release 與 History Boundary;第一個 Plugin 因而成為框架的預設世界。
  • Solution: 先把共用執行能力收斂成 Public Plugin API、Execution Loop、RunBackend 與 Versioned Contract,再 Physical Split;拆分後,這一層才正式成為 testpilot-core。具體 Plugin、Cases 與整合測試則由各 Plugin Repo 負責。
  • Evidence: 0dd7733 記錄 Fresh-no-history Core Dist、移除具體 Plugin 與整合測試、Core CI 不安裝 Plugin,並以 479 passed 驗證 Core-only Baseline。

拆了 Normal 跟 Audit,再來呢?

上一篇最後,正式 Case 搬了位置,Provenance Hook 卻還守在舊路徑。

老Go看完那一段,先提了一個疑惑。

「你說 Repository 拆分,不是已經拆成 Normal Run 和 Audit 兩條路了嗎?為什麼還要再把整個 Repo 拆掉?」

「那不是同一種拆分。」我說。

D8 拆的是權限。

Normal Run 可以執行正式 Case,但不能順手改掉 YAML;Audit Mode 可以調查、提出修改、留下 Provenance,再經 Gate 寫回,但不負責產出 Normal Run 的正式 Pass/Fail Report。

簡單說,Normal Run 使用目前的正式判準跑測試;Audit 則檢查這套測法與判準是否值得相信。

TestPilot、Plugin、Case 當時仍可留在同一個 Repository。拆開的是「誰可以做什麼」。

今天要拆的是:

誰應該擁有什麼。

TestPilot 裡負責共用執行的部分、Wi-Fi Plugin、Firmware Upgrade Plugin、Case 與 Integration Tests,拆分後應該要真的被搬到不同 Repository。

老Go沒有因此比較信服。

「原本不是已經有 plugins/ 目錄了嗎?」

名字都叫 Plugin 了。

為什麼還要拆 Repo?

第一版 Monorepo 其實完全合理

TestPilot 最早只想解一件事:把既有的 Wi-Fi LLAPI Case 變成可以重複執行的測試。

所以第一版長得很自然:

hamanpaul/testpilot
├── src/testpilot/          # TestPilot 本體
├── plugins/
│   ├── _template/
│   ├── wifi_llapi/
│   └── brcm_fw_upgrade/
├── configs/testbed.yaml
├── tests/
└── docs/

TestPilot 本體改完,旁邊的 Wi-Fi Plugin 立刻能跑;Case 多一個欄位,Schema、Runner、Report 一起改;測試機換了 Serial Port,就調共用的 configs/testbed.yaml

老Go看了一眼。

「所以 Monorepo 根本沒做錯。」

「對。」我說,「如果重來一次,第一版大概還是會這樣做。」

第一個 Plugin、第一套 Testbed、第一批 Case 都在快速變動時,放一起就是最低成本。

問題不是 Monorepo。

是第一個 Plugin 待久了,開始把自己的生活習慣當成整棟房子的公共設施。

testbed.yaml 從設定檔變成了 TestPilot 的人生觀

最早讓我覺得不太對勁的,是 testbed.yaml

這個名字聽起來很中性,實際內容卻是某一套 Wi-Fi Lab 的 Runtime Facts:哪個 Session 是 DUT、哪個是 STA、COM Port 怎麼分、Serial 工具在哪裡,以及設備啟動後要做哪些準備。

概念上會像:

dut:
  session: COM0
sta:
  session: COM1

一開始沒有問題。

TestPilot 本來就是為 Wi-Fi 測試長出來的。TestPilot 載入 Testbed,Plugin 直接拿 DUT/STA 去跑,既快又直覺。

但用久之後,這些設定不再只是 Plugin 的 Input。

TestPilot 的啟動流程開始知道 Wi-Fi Testbed 要怎麼 Stage。

CLI 的主要路徑預設會有 DUT、STA 與 Full Run。

Integration Tests 也假設這兩個角色一定存在。

某些環境判斷與 Report Flow,則先配合 wifi_llapi 做完,再回頭想辦法抽象。

我把 testbed.yaml、CLI、Integration Tests 和 TestPilot Runtime 排在一起看了一輪。

「嘖~看來 Plugin 擴張邊界了。」

老Go抬頭看了一眼。

「它不是老實待在 plugins/ 裡嗎?」

「檔案有待在裡面。」我說,「它的工作區域沒有。」

TestPilot 裡總得有一層,負責載入 Plugin、交付 Config、執行 Lifecycle、收 Evidence 與形成 Verdict。

但這一層不該預設測試一定有 DUT/STA、Case 一定來自 Workbook,或每一種測試都要先走一次 Wi-Fi Full Run。

testbed.yaml 不是唯一問題。

它只是第一個讓我看見:wifi_llapi 雖然住在 plugins/,卻已經把自己的 Runtime Model 寫進整棟房子的變電箱裡。

Target

第二個 Plugin 一進門,第一個 Plugin 就成了預設世界

如果 TestPilot 永遠只服務 wifi_llapi,前面的問題其實未必值得大動干戈。

反正大家都在跑 Wi-Fi。

真正讓我開始重新看這條 Boundary 的,是 brcm_fw_upgrade

Wi-Fi LLAPI 的工作大致是:準備 DUT/STA、載入 Case、逐 Step 執行、Readback、判定 Criteria,再產生 Report。

Firmware Upgrade 卻比較像一條有風險的 State Machine:

檢查前置條件
→ 傳送 Image
→ Flash
→ Reboot
→ 等設備回來
→ 驗證版本
→ 必要時 Recovery/Rollback

兩個都叫測試。

但一個在意幾百條 Case 怎麼逐條判定;另一個在意一次完整生命週期能不能安全走完。

老Go看了一輪。

「不能把 Firmware Upgrade 也塞進 Case Runner?」

「可以。」我說,「問題是塞完之後,TestPilot 本體要不要開始知道 Image、Flash、Reboot 和 Version Verify?」

「知道了會怎樣?」

「下一個 Plugin 進來,再多知道一套。」

如果照原本的方法繼續長,configs/testbed.yaml 也會遇到同一個問題。

它現在放在共用位置,看起來像 TestPilot 的全域 Config;裡面的 DUT/STA、Serial 與 Lab Topology,實際上卻是 Wi-Fi Plugin 的 Domain Facts。

那 Firmware Upgrade 的 Image、Target 與 Recovery 設定要不要也放進去?

未來的 Long-running/Soak Test,可能只想跑八個小時,定期 Checkpoint,中斷後 Resume;它的 Testbed 又該長什麼樣子?

工程師臨時追問題時,也可能只想跑一條 Case,拿到 Evidence 就停,根本不需要把整套 Full Run 儀式請出來。

我把幾種工作寫在一起:

Wi-Fi LLAPI
= 多條 Case、DUT/STA、Readback、Criteria

Firmware Upgrade
= Image、Flash、Reboot、Version Verify、Recovery

Long-running
= Duration、Checkpoint、Resume、Time-window Verdict

老Go看完說:

「所以第二個 Plugin 不是不能跑。」

「對。」

「是每次進門前,都得先配合第一個 Plugin 的人生觀。」

差不多就是這樣。

Plugin 可以共用執行框架。

不能被迫共用第一個 Plugin 的生活方式。

Target

把 Interface 寫乾淨,不就不用拆 Repo?

老Go提出比較便宜的方案。

「既然問題是 TestPilot 本體知道太多,那把 Interface 寫乾淨就好。為什麼一定要拆 Repo?」

這次他沒有唱反調唱錯。

Monorepo 不等於沒有 Boundary。

只要共用執行層只暴露穩定 API,Plugin 不碰 Internal Module,Config、Case 與 Integration Test 也各自有 Owner,同一個 Repo 一樣可以維持乾淨架構。

反過來說,把 Plugin 搬到另一個 GitHub URL,卻還在裡面 Import TestPilot Internal,只是把耦合拉長了一點。

分家了。

網路線還從窗戶接回老家。

所以真正搬檔案以前,得先把責任切清楚。

共用執行層
= Plugin Discovery、Execution Lifecycle、Evidence、Verdict、Retry/Recovery

Plugin
= Domain Config、Case、Command、Criteria、Setup/Teardown、Domain Report

老Go指著 Firmware Upgrade。

「Reboot 算誰的?」

「要下哪一條 Reboot Command、設備回來後怎麼確認版本,是 Plugin。」我說。

「Timeout 呢?」

「怎麼等待、怎麼留下 Evidence、什麼時候轉進 Recovery,是共用執行層提供的 Lifecycle。」

「Wi-Fi 的 STA 斷線?」

「是 wifi_llapi 的 Domain Failure。共用執行層不該天生知道 STA 是什麼。」

後來,這些界線才被收進 Public Plugin API、Execution Loop、RunBackend 與 Versioned Contract。

名字很多。

實際上只是在要求一件事:

Plugin 從正門進來,不要搬了新家,還留著通往 TestPilot Internal 的地下道。

做到這裡,只能證明 TestPilot 已經可以拆。

還沒有回答老Go原本那句:

Logical Boundary 做好了,為什麼最後還是要拆 Repo?

Target

現在的檔案可以去敏,舊 History 不會跟著失憶

另一條壓力來自公開邊界。

我最早建立 TestPilot,是為了解決手上的工程問題,不是為了把整段開發歷史公開。

舊 Monorepo 裡曾經放過真實 Case、Testbed、設備識別、內部路徑,以及只在公司環境成立的操作方式。

目前 Tree 裡的內容,可以一個一個 Scrub。

問題是 Git 不只記得目前。

導入 paulsha-conventions 之後,去機敏、去識別化與 Public/Private Boundary,不能再只靠一句:

「上傳前我有看過。」

老Go很快接了一句。

「那就 Rewrite History。」

「可以。」

「不就結束了?」

「我可以改寫我掌握的 Branch 和 Tag。」我說,「但我不能拿這件事證明所有舊 Clone、PR Diff 或其他 Artifact 都一起被完整清除。」

老Go停了一下。

「所以不是 Git 技術上完全不能改。」

「對。」

「是你不能拿一段無法完整證明已經去敏的 History,當 Public Repo 的起點。」

「就是這個意思。」

我需要的不是一句「應該沒問題」。

而是一個範圍明確、可以重新檢查,也能重新套用 Policy 的 Public Root。

這件事只有 Logical Boundary 不夠。

它還需要一條新的 History Boundary。

拆完之後,才真的有了 testpilot-core

前面的責任切完,我們才知道哪些東西該留下、哪些東西該搬走。

Commit 0dd7733 做的,就是最後這一刀。

新的 testpilot-core 沒有把舊 Monorepo 的完整 History 原封不動搬過來,而是以 fresh-no-history 建立新的 Root。

0 parents
Fresh-no-history core dist

留下來的是 Core、Plugin Template、Public Contract 與 Core-only Tests。

具體的 wifi_llapibrcm_fw_upgrade、Cases、Testbed 和 Integration Tests,則回到各自的 Plugin Repo。

Core CI 也刻意不安裝具體 Plugin。

老Go又皺起眉頭。

「不裝 Plugin,不是少測一半?」

「是少測 Plugin。」我說,「但這一輪要回答的是:拔掉 Wi-Fi 之後,Core 還是不是一個 Core。」

Agent 把結果貼回來:

479 passed

老Go看著那行數字。

「所以證明什麼?」

「證明沒有安裝具體 Plugin,Core 自己的 Baseline 還能過。」

「Wi-Fi 和 Firmware Upgrade 呢?」

「那是各自 Plugin Repo 的 Integration Test 要回答。」

479 passed 不代表所有 Plugin 都已完成遷移,也不代表所有版本組合都通過真機。

它只證明這次拆分之後,Core 不需要先裝回第一個 Plugin,才能證明自己活著。

Core 的 Public API、Execution Loop 與相容性,由 Core Repo 守。

Plugin 的 Case、Testbed、Domain Behavior 與硬體 Integration,由 Plugin Repo 守。

以前只有一顆很大的綠燈。

紅的時候,大家先猜到底是 Core、Plugin、Testbed,還是 Lab。

現在至少每一層要先對自己的 Evidence 負責。

Case 搬走了,其他人還在寄舊地址

到這裡,上一篇最後那段才算有完整背景。

Target

正式 Wi-Fi Case 從:

plugins/wifi_llapi/cases/

跟著 Plugin 搬到:

wifi_llapi/cases/

方向沒有錯。

Core 不該擁有 Wi-Fi Case;Plugin 應該對自己的 Case、Testbed 與 Integration Evidence 負責。

然後老Go直接改了一條正式 YAML。

D8 才建立的 Provenance Hook,完全沒有出聲。

Agent 查完回了一句:

「Hook 還在看舊地址。」

老Go看向我。

「這次總該只要改一條 Path 了吧?」

「我當時也是這樣想。」

接著 audit init 找不到 Case。

pass12 也回頭找舊目錄。

偏偏 Tests 還是綠的,因為 Test Fixture 一直很盡責地替舊 Monorepo 蓋房子。

我這才發現,搬走的是 Source Code。

還沒搬走的,是所有依賴舊 Layout 的流程。

下一篇,我們來查 Repo Split 之後,為什麼 Audit Pipeline 明明 Tests 全綠,卻連正式 Case 的門都找不到。

Have a nice day.


上一篇
Day 8 - 汽油引擎做好了,誰保證灌進去的不是柴油?Case 也需要 Audit
下一篇
Day 10 - Case 已經搬家,Audit 還在舊 Repo 找:Tests 全綠也沒發現
系列文
一天一個 PR,讓我告訴你嵌入式開發的殘酷:如何為 AI Agent 建立可信的工程閉環10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言