iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
AI Engineering

從單一agent 到多agent 集群的開發流水帳以及應用系列 第 11

Day 08|打勾,和真的成立是兩件事:五個翻案,和一支三天沒送到的 binary

  • 分享至 

  • xImage
  •  

系列背景:私有自持的 AI agent 網格 spectyn-mesh
Day 06 把單機版裝進 .app,並收掉了「停止說謊」到「權限批准」六個階段 ——
收工時的帳很漂亮:+891 條測試、全量 3,528 全綠、四個 commit

然後這兩天我做了兩件事,而它們指向同一個問題:
打勾,和真的成立,是兩件事。

交付缺口

第一件事發生在昨天:動手之前,我先把佇列上每一項的現況實際量一次。
12 個題目,五個翻案 —— 佇列是我自己前一天寫的,而一天之後它有五格已經不對了。

第二件事發生在今天:我量了一件從來沒量過的事 —— 操作者實際會跑的那支 binary
是哪一版
。答案是 8 月 28 日的。

那些修掉的謊 —— exit 0 卻什麼都沒做、doctor 謊報沒有身分、--config 被靜默
忽略、半截答案被當成完成 —— 對他的機器來說,今天全部還在。

而且沒有任何東西會告訴他:spectyn --version 印得出 commit,但從來沒有人拿它跟
repo 比對過。

母規則同前:任何檢查必須先示範它會紅才算數。這兩天各補了一條:
佇列也不是證據;以及 打了勾的出口條件,不代表有人走過它


上半:先量再修

一、五個翻案

翻案表

翻案的不是「我做錯了」。是題目是一天前寫的,而那一天之後東西動過了 ——
其中兩項在我自己前一天的修復裡就已經好了,我卻還在佇列上留著它們。

照字面往下做的代價很具體:兩項會去修沒壞的東西,一項會刪掉一道刻意的安全閘,
一項會為不存在的缺口補測試。

二、翻案一:「成本永遠 $0.00」——修一個完全正確的東西

成本

測量 agent 做了一次 A/B:同一棵樹、同一個 mock provider、同一個模型,
線上餵同一份 usage(1,000,000 prompt + 500,000 completion,gpt-4o 費率 = $7.50)。

非串流路徑算出 $7.50,ledger 有寫。 CostTracker、價格表、持久化、
四處顯示——全部正確。

壞的只有串流路徑的 usage 傳遞,而且斷在三個地方:每個 synthetic return
寫死 "usage": {};SSE 解析迴圈只看 choices / delta / type,而 usage frame
帶的是 choices: [];include_usage 只在有工具時才向供應商索取。

成本餵食點的守衛是 if prompt_tokens > 0 || completion_tokens > 0 ——
兩個都是 0,所以 record() 從來沒有被呼叫過。不是「記成 0」,
是連 ledger 檔都不會被建立。

如果照佇列去「修成本追蹤」,我會在一段完全正確的程式碼裡找一個不存在的 bug。

三、翻案二:「serve 要能綁 tailnet」——照字面修會開一個洞

它現在拒絕,而那是刻意的:外部複查之後加的 fail-closed 閘。
照字面「修好」——把 guard 刪掉——會把 22 條未驗證的 /api/rpc 路由
(/api/todos/api/sessions/api/cost/api/governance/log/rpc/peers……)
publish 到 tailnet 或區網上。

這一項我沒有動,列成裁定點交回去。這是安全決策,不是我該替操作者做的。

一個佇列項目寫成「今天拒絕」,讀起來像缺陷。實際上「拒絕」就是它的功能。

四、翻案三、四、五:兩項早就好了,一項題目寫錯

macOS 換錯目錄uninstall 移不掉舊 label,測量都回報
already_fixed——前者在 commit 40219efe,後者兩個 label 都處理。
照佇列做會把已經修好的程式碼改回舊 bug。

「唯一碰精靈的測試斷言精靈不要跑」 這一條更直接:題目本身是錯的。
現在有六個檔在測那個精靈,其中 onboarding_asks_before_it_acts 在真的 PTY 上
驅動它並斷言正向行為。那是前一天才補的——題目比修復舊了一天。

五、真正壞的那條:半截答案

半截答案

這是今天主題最純粹的一次。同一份內容,伺服器兩種死法:

stdoutstderr、退出碼——逐位元無法區分,兩次 stderr 都是空的。
同一個埠上 curlexit 56 Connection reset by peer

一條 CI 流水線讀那份 JSONL,收到的是一個肯定的完成事件,而且沒有任何東西
可以據以判斷。

原因是五個離開串流迴圈的出口——[DONE]、body EOF、傳輸錯誤、閒置逾時、
使用者中斷——全部是一個裸的 break。等到有人去看,一條被 RST 砍斷的連線
和一次乾淨的完成已經是同一個事件。唯一的守衛 if full_content.is_empty(),
被一個位元組的內容解除

修法的三個陷阱,測量都先講了:

不能「沒有 [DONE] 就算斷線」。 Anthropic 的串流就是靠 body EOF 結束的——
這個迴圈根本不解析 message_stop。所以我只標記兩個無歧義的死法:
傳輸錯誤、閒置逾時。EOF 刻意不標,理由寫進程式碼。

不能轉成 Err 讓 provider 鏈重試。 這個函式同時位在 failover 迴圈
多輪 agent 迴圈裡。那一輪若已發出 tool_calls,重跑會再執行一次
file_writeshell一次網路抖動不可以變成操作者磁碟上的重複副作用。

這段程式碼就是已安裝的 daemon 在跑的。 所以全程沒有重建到已安裝的產物。

順帶:閒置逾時從寫死 30 秒改成可設定。不是為了彈性——最常見的死法
(wifi 斷了但 socket 沒關)走的就是那條路,而沒有測試付得起半分鐘
,
所以行為最誤導的那條路完全沒有覆蓋。

六、另一條:一個填了 R2 也修不好的 404

404

self-update 沒有可用來源。但比題目更糟的是——就算操作者把 R2 填滿,
CLI 要的那個名字仍然會 404

線上伺服器把兩種失敗分得很清楚:404 是 manifest 的答案,503 才是 bucket 的。
大家會去做的那個修法(上傳 binary),根本不可能生效。而
scripts/publish-mac.sh 用 OS-ARCH 名字 curl 驗證自己的上傳,會回報成功,
同時 self-update 依然壞著。

第三個獨立斷點:.sha256 在 Worker 裡出現零次。更新器沒有 digest 就
fail-closed——那個拒絕是對的,它是下載唯一的完整性檢查——所以名字對了、
bucket 填了,還是裝不起來。

修法是把 Worker 的 manifest 補上 triple 形式別名、加上 sidecar 服務,
並新增一條從 Rust 讀 TypeScript 的測試:CLI 與 install.sh 能請求的每個名字,
都必須在 manifest 裡。請求名與儲存名住在不同語言,先前沒有任何東西檢查它們一致。

危險提示也很具體:別放寬 https 守衛、別讓 sidecar 變成可選。
來源是空的,守衛是對的。

七、我發表的文章裡有一句不真的話

前一天我寫了一個 mDNS advertiser 的回收器,並在 commit 訊息、當日實錄、
以及發出去的兩篇文章裡都說它「關閉時會收掉」。

測量回報:呼叫點零個。

保留 Child 那半落地了,收掉那半從來沒接上。而守衛本身就是空綠:

assert!(src.contains("pub fn stop_mdns_advertising"), ...);

它斷言那個字串存在。一個沒有人呼叫的函式完全滿足它——而那正是實際出貨的狀態。

能力不是行為。

實機證據:那台機器上有三天前(8/26、8/27)開始廣播的 dns-sd,到今天還活著。

八、紅證的第二種用途

紅證

母規則我用了六天:任何檢查先示範它會紅才算數。今天學到它還有第二種用途。

修完串流成本之後,我跑了六條紅證,五條精準命中。第六條——把 cli_session
回傳點改回丟棄 usage——每一條測試都還是綠的

那不是空綠。那是那個位置根本沒有東西守著。 紅證沒有證明檢查有效,
它指出了哪裡從來沒有檢查。

同一天還有一次更繞的:停滯測試的紅證是「把逾時改回寫死 30 秒」,結果測試照樣綠。
我以為抓到空綠,細看才發現——「寫死 30 秒」根本不是缺陷,它只是讓測試變慢,
行為仍然正確。正確的隔離證明是把 Timeout 從判定裡拿掉。

紅證選錯標的,本身也是一種空綠。


下半:交付,以及走完從沒走過的路

六、最大的缺口不是程式碼

前一天收工的帳很漂亮。然後我量了一件從沒量過的事:

~/.local/bin/spectyn    0.6.0 (c2bf11e6fd+, built 2026-08-28)   ← PATH 上贏的
~/.cargo/bin/spectyn    0.6.0 (c2bf11e6fd+, built 2026-08-28)
~/.spectyn-mesh/bin/    不存在(install.sh 的正式目標)
repo HEAD               c342076f

兩份獨立的複本、內容相同、都停在三天前。而 ~/.local/bin/spectyn
本來應該是 symlink —— install.shln -sf 建它,指向
~/.spectyn-mesh/bin/spectyn。現在它是一般檔案,而它該指向的目標不存在。

這台機器的安裝狀態不符合任何一支安裝器的模型。

而這正是前一天的測量預測過的殘留缺陷:cluster upgrade 走的 handler 沒有
canonicalize current_exe(),所以會把 symlink 換成一般檔案,同時
install.sh 的真正目標保持舊的。預測的形狀,和機器上看到的形狀一致。

修法是讓 doctor 把它說出來 —— 一個純函式決定「有沒有話可說」:

執行檔
  ✓ version: spectyn 0.6.0 (52bfc1325a+, macos-aarch64, built 2026-08-31)
  ✓ path: …/core/target/debug/spectyn
  ⚠ provenance: 2 spectyn binaries on PATH — the first one wins:
                    → /Users/markl/.local/bin/spectyn
                      /Users/markl/.cargo/bin/spectyn

三條刻意的沉默:不在 repo 裡不比對(沒有東西可比)、不是這個專案的 repo
不比對(它的 HEAD 與這支 binary 無關)、重複的 PATH 條目不算第二份安裝 ——
操作者的 PATH 裡 ~/.local/bin 出現三次,報「3 份安裝」會是它自己的小謊。

七、打了勾的出口條件,沒有人走過

打勾 vs 走過

前一天 Phase 4 的三條出口條件都打了勾。支撐它們的是:一條直接呼叫 slash
handler
的單元測試、一條雜湊函式跟自己比的單元測試、一條 grep 原始碼
找呼叫點
的守衛。

沒有任何一條啟動過出貨的 binary、做那件事、然後看結果。

這是這個專案自己那句話指回自己身上:沒有跑過的路,不算路。

走完之後,第一件事是我的測試錯了。第二條(兩介面同一個 id)一跑就紅:

REPL said pm_3fa52f4dd
TUI  said pm_e9cf42f9e

看起來像前一天的統一沒生效。但前綴是對的,只有雜湊不同 —— 它們在算不同的目錄
PtyRun::spawncmd.cwd(home),把 TUI 放在沙箱目錄;而我的 REPL 用管線驅動,
繼承的是測試行程的 cwd。兩個不同的目錄,本來就該算出不同的 id —— 產品是對的。

差一點就報了一個假的產品缺陷。而且形狀很熟:一個看起來像證據的紅燈,
證明的其實是別的東西。

八、怎麼走一條會動到操作者機器的路

不碰現役怎麼走

service uninstall 會跑 launchctl bootout 並刪 ~/Library/LaunchAgents 裡的檔案。
端對端測試看起來就等於要卸掉操作者自己的服務 —— 所以前一天這條是讀程式碼打的勾。

兩件事讓它其實可以走,而兩件事都得先驗證:

一、先證明沙箱真的是沙箱。 plist 路徑來自 dirs::home_dir(),整個策略靠
「改 $HOME 會移動那個目錄」。而在 macOS 上這不是安全假設 —— 前一天才因為
NSHomeDirectory() 不理會 $HOME,一個以為自己被隔離的測試其實在讀操作者的
真實設定。所以先寫一條單獨的測試證明它,而且它自己的失敗訊息就寫著:
在這條通過之前,不要走安裝路徑。

二、假的 launchctl 程式碼呼叫的是 Command::new("launchctl") —— 走 PATH
查找。把一個會記錄呼叫的替身放在 PATH 前面,就能驅動真正的程式碼路徑,
而操作者的 launchd 從頭到尾沒有被定址。

走出來的序列對兩個 label 各做 print → bootout → print,這正是
「遷移過的機器」那句話的重點:這台 Mac 跑的是改名前的 label,只 bootout 新的
等於什麼都沒動,job 每次登入照樣復活,而指令還是印 ✓。

而且第一次手動跑時,我那個粗糙的替身永遠回 exit 0,結果指令正確地判定
「Uninstall INCOMPLETE」並 exit 1
—— 它真的在重新查證,不是相信 bootout 的退出碼。

九、今天第二次:測試證明的不是它自己的名字

「bootout 要在刪 plist 之前」那條,第一版寫成「整份記錄裡最後一個 print
排在第一個 bootout 之後」。

紅證把驗證那次查詢整個拿掉 —— 測試照樣綠

因為有兩個 label:第二個 label 的開場 print 本來就排在第一個 label 的
bootout 後面。斷言被另一個 label 的存在滿足了,跟它要證明的行為無關。

改成逐 label 檢查:對每一個 label,它自己的 bootout 之後必須還有一次 print
再跑紅證,紅。

這幾天同一個形狀已經第四次:一條測試的名字承諾一件事,而滿足它的是另一件事。
每一次都是紅證抓的,一次都不是綠燈抓的。

收尾:這兩天帶走的幾句話

  1. 佇列不是證據。 它是某一天的觀察,而觀察會過期。修之前先量。
  2. 問「照字面做會發生什麼」。 五個翻案裡,這個問題擋下了兩次修沒壞的東西、
    一次刪掉安全閘。
  3. 打了勾的出口條件,不代表有人走過它。 單元測試證明函式做了事;
    出口條件要的是啟動出貨的 binary、做那件事、看結果
  4. 能力不是行為。 一個函式存在,不代表有人呼叫它。
  5. 做完不等於送到。 一整天的修法可以全部躺在 repo 裡,而操作者的機器上
    每一個缺陷都還在 —— 而且沒有任何東西會說。
  6. 走不了的路,先問「為什麼走不了」。 兩個假設($HOME 有沒有用、
    launchctl 怎麼被呼叫)一驗證,原本以為不可能的路就通了。
  7. 紅證全綠不一定是空綠 —— 也可能是那裡從來沒有檢查。
  8. 選錯紅證標的,和沒做紅證一樣。

以下是這兩天的實錄,原文按時序未改


附:當日實錄(原文)

前一天(Phase 4/5)

Phase 4 —— 日常使用的保真度,以及兩個被翻案的題目

這一階段做法換了:先用 14 個 agent 平行測量七項現況,再動手
每一項一個唯讀 agent(不准改檔、不准碰真實資料根、不准在區網放 daemon),
發現再交給一組獨立的懷疑者駁倒。17 分鐘。

值得的地方不是省時間,是兩項被翻案——照題目字面去修,一項會修一個沒壞的東西,
另一項會開一個安全洞。

翻案一:「成本顯示不得結構性永遠 $0.00」——題目寫錯了

量測 agent 做了一次 A/B:同一棵樹、同一個 mock provider、同一個模型、
線上同一份 usage(1,000,000 prompt + 500,000 completion,gpt-4o 費率 = $7.50)。

路徑 結果
非串流(serverun_tracked) cost_usd: 7.5,ledger 有寫
串流(run_with_callbacks,TUI 與 exec 走的那條) $0.00,而且連 ledger 檔都沒產生

也就是說 CostTracker、價格表、持久化、四處顯示全部是對的
照題目去「修成本追蹤」,會去修一個沒壞的東西。

壞的只有一條鏈,而且斷在兩個地方:

  1. call_with_streaming 的每個 synthetic return 都寫死 "usage": {},
    而 SSE 解析迴圈只看 choices / delta / type ——
    usage frame 帶的是 choices: [],所以它到了線上,被走過去了。
  2. stream_options.include_usage 只在有工具時才要。純聊天串流從一開始就
    沒跟供應商要那個 frame,所以解析得再對也沒東西可讀。

成本餵食點的守衛是 if prompt_tokens > 0 || completion_tokens > 0 ——
兩個都是 0,所以 record() 從來沒有被呼叫過。不是「記成 0」,是根本沒呼叫:
連檔案都不會被建立。

修的時候還撞到第三個:cli_session 那條(codex/opencode/agy)回的是
{input_tokens, output_tokens},而成本讀的是 {prompt_tokens, completion_tokens}
兩種拼法、一個讀取端 —— 跟這一整天其他缺陷同一個形狀。所以光把
Ok((text, _usage)) 改成不丟掉還不夠,得先正規化。收斂成一個
normalize_usage(),是唯一調和它們的地方。

翻案二:「serve 要能在有 Tailscale 的機器上啟動」——這個修法是危險的

它現在拒絕,而那是刻意的:外部複查後加的 fail-closed 閘。照字面「修好」——
把 guard 刪掉——會把 22 條未驗證的 /api/rpc 路由(/api/todos
/api/sessions/api/cost/api/governance/log/rpc/peers……)publish
到 tailnet 或區網上。

列為新裁定點,不猜。可選:維持拒絕但把訊息改成可行動的、加驗證後才允許非 loopback、
或明確的 --unsafe-lan 旗標加大聲警告。這是安全決策,不是我該替操作者做的。

我今天發表的文章裡有一句不真的話

第六項「mDNS 廣播改為明示,且 advertiser 關閉時要回收」被判只做一半

stop_mdns_advertising() 早上寫好了。呼叫點零個

commit 訊息寫著「保留 Child 並在關閉時收掉」——保留那半落地了,收掉那半
從來沒接上。而 Day 6 實錄、以及今天發出去的兩篇文章,都重複了那個說法。

更難看的是守衛本身就是空綠:

assert!(src.contains("pub fn stop_mdns_advertising"), ...);

它斷言那個字串存在。一個沒有人呼叫的函式完全滿足它 —— 而那正是實際出貨的狀態。
能力不是行為。

實機證據:這台機器上有 8/26、8/27 起就在廣播的 dns-sd -R spectyn-spectyn,
活到 8/30 還在。

修法:接進 serve 的關閉序列(兩條分支都經過,且在 serve_result? 之前,
所以 serve 出錯也不會跳過),守衛改成要求 spectyn.rs非註解的呼叫。
紅證兩條:拿掉呼叫會紅、只留被註解掉的呼叫會紅。

一條自我打臉的註解

TUI 的 /tasks 會 Tab 補全,然後回「unknown command」。而 SLASH_COMMANDS_TUI
正上方的註解白紙黑字寫著:

Keep this list in sync with the match arms in handle_tui_slash. Adding a
command here without a handler makes Tab completion suggest it, then submit
it, then have the handler bail with 'unknown command' — which is the worst of
both worlds.

/tasks唯一違反它自己這條註解的項目。

而既有的 ratchet 反過來保護這個壞狀態:它斷言補全清單等於註冊表,
卻從不斷言 arm 存在。所以測試套件保證了 /tasks 會被補全,而沒有任何東西
保證它會被派發。

新守衛切出 handle_tui_slash 的函式體,對每個 TUI 指令斷言帶引號的字面值
帶引號是刻意的:contains("/task") 會被 "/tasks" 滿足 ——
那種斷言會被它該抓的東西滿足。紅證兩條:拿掉 arm 紅、把 arm 改名成前綴 /task 也紅。

同一個目錄,兩個 session 檔

session id 不是一個 bug,是三個疊在一起:

tui.rs bin/spectyn.rs
前綴 cwd- pm_
雜湊的東西 Path(cwd.hash()) 字串(cwd.to_string_lossy().hash())
寬度 {:x} {:016x}

Pathstr 的 Hash 實作不同,所以只對齊前綴會看起來修好了而仍然是壞的

而兩個介面寫進同一個命名空間。實測在操作者真實的 store 上:同一個 repo 目錄
有兩個各自長大的檔 —— cwd-f09d… 20 行、pm_6bbe… 88 行。在 REPL 停下的地方
打開 TUI,歷史就是不在那裡;而模型從該介面挑到的那個檔被餵得好好的。

pm_ 格式,因為既有歷史都在那(36 個檔 vs 2 個)。舊 id 的 session 不會失聯 ——
/resume 走前綴比對 —— 只是不再自動被撿起。

空白畫面,而模型記得一切

重啟、/resume/fork,三個都是空白畫面。而下一輪把完整歷史送給模型。

量測 agent 把這件事用實驗證明了,不是讀程式碼推的:它架了一個假的
OpenAI-compat provider,攔下外送的 POST body —— 裡面有 8 則訊息,含四個
marker 字串,逐字都在。同一輪,螢幕上一個都沒有。

所以 agent 表現得像記得一段操作者看不見的對話,而空 session 和 88 輪的 session
長得一模一樣

共用一個 replay_view() 做「該顯示什麼」的決定(過濾 tool 流量與空白、
取最後 20 則、回報略過幾則),兩個介面都讀它 —— 否則它們會對同一個 session
顯示不同的過去。

ws://auto:PORT/ws

banner 印的是設定檔裡那個字,而 host 預設就是字面字串 "auto",從不被重新賦值。
量測 agent 直接用 curl 證明:

$ curl -sS -m 5 "http://auto:39873/healthz"
curl: (6) Could not resolve host: auto
$ curl -sS -m 5 "http://127.0.0.1:39873/healthz"
ok

同一個行程,一個死一個活。從 banner 複製一行出去的人,拿到的是死的端點。
而且還有第二處:spectyn coordinatorlisten : http://auto:PORT

修的時候我自己引入了一個問題,被守衛抓到:resolve_serve_bind_hosts 會在
stderr 印警告,而 serve 路徑呼叫了三次(LAN 閘、banner、bind)——
那句「只綁 loopback、對所有 peer 隱形」的警告會印三次。改成整條路徑解析一次,
banner 印的就是 bind 用的那一份

守衛數了代理指標,而不是那件事本身

banner 那條的守衛我改寫過一次。第一版數「視窗內呼叫 resolve_serve_bind_hosts
幾次」——而修好之後視窗內本來就只剩一次,所以把 banner 改回獨立解析,測試照樣綠

它量的是代理指標。改成直接斷言 banner_host(&host, &bind_hosts) ——
把 banner 綁在 bind 用的那個值上 —— 才會紅。

紅證抓到一個覆蓋缺口

cost 那批六條紅證,五條精準命中,第六條沒有:我把 cli_session 回傳點改回丟棄 usage,
每一條測試都還是綠的

不是空綠,是那個位置根本沒有東西守著。那條路要 shell out 到外部 CLI,
CI 上不存在,所以沒有 hermetic 的驅動方式。補了一條 ratchet 並老實標明它是 ratchet:
斷言那個 arm 沒有 Ok((text, _usage))、而且有 normalize_usage(&usage)

紅證的用途不只是「證明這條檢查有效」,還包括指出哪裡根本沒有檢查

順手量到一個沒人量過的稅:45 分鐘的 Gatekeeper

今天全量測試跑了幾次,每次都覺得「卡住了」。實際量:

編譯        7m39s
--list     ~45m      ← 這裡
實跑         98s

nextest 在真正跑測試之前,會對每個測試 binary 執行一次 --list。而這個專案有
180 個測試 binary。列舉階段每個行程都是 0% CPU 在等 —— 我一度以為是磁碟 IO。

ps 給了答案:

%CPU   COMMAND
37.3   /usr/libexec/syspolicyd

macOS 的 Gatekeeper 正在對每個剛連結出來的 binary 做首次執行評估。
180 個 × 十幾秒 ≈ 45 分鐘。這也解釋了為什麼列舉之後真正跑測試只要 98 秒:
那時每個 binary 都已經評估過了。

換句話說,一次完整重建之後的全量測試,有 95% 的時間不在編譯也不在測試

修法在系統設定那邊(把終端機加進「開發者工具」豁免),不是我能替操作者按的 ——
但值得記下來,因為它比今天早上那個 55 GB 的建置稅貴得多,而且完全隱形:
沒有任何輸出會告訴你它在等 Gatekeeper。


Phase 5 —— 又翻案兩項,和今天最純粹的一次說謊

同樣先平行測量五項,再動手。五項裡兩項早就修好了、一項題目本身是錯的。

項目 裁決
self-update 有可用來源 確認,而且比題目更糟
macOS 上換對目錄 早就修好了(commit 40219efe)
service uninstall 移得掉舊 label 早就修好了
「唯一碰精靈的測試斷言它不要跑」 題目寫錯——今天已經有六個檔在測,其中一個在 PTY 上真的驅動它
網路中途斷線把半截答案當完整回傳 確認,而且機器可讀的那條通道更糟

如果照佇列字面往下做,五項有三項會做白工或做錯。

spectyn exec --json 對一個被砍斷的串流說「done」

量測 agent 架了一個假的 OpenAI-compat SSE 伺服器,兩次執行只差在伺服器怎麼結束:

乾淨:  {"type":"done","output":"…1) open the file, 2) edit the line, 3) save."}   exit 0
砍斷:  {"type":"done","output":"…1) open the file, 2) edit the li"}               exit 0
                                                                ↑ 斷在字中間

stdout、stderr、退出碼逐位元無法區分,而且兩次 stderr 都是空的。
同一個埠上 curl 說:

curl: (56) Recv failure: Connection reset by peer

curl 說 56,spectyn 說 exit 0,還發了一個 done 事件。

一條 CI 流水線讀那份 JSONL,收到的是一個肯定的完成事件。

原因在五個離開串流迴圈的出口——[DONE]、body EOF、傳輸錯誤、閒置逾時、
使用者中斷——全部是一個裸的 break。等到有人去看的時候,一條被 RST 砍斷的連線
和一次乾淨的完成已經是同一個事件了。唯一的守衛是
if full_content.is_empty(),一個位元組的內容就把它解除了

這一條的修法有三個陷阱,測量都先講了

一、「沒有 [DONE] 就算斷線」會弄壞每一次 Anthropic 執行。
Anthropic 的串流就是靠 body EOF 結束的——這個迴圈根本不解析 message_stop
所以對那個格式而言,健康的結束和被關掉的 socket 逐位元相同

所以我只標記兩個無歧義的死法:傳輸錯誤、閒置逾時。EOF 刻意不標,
並在程式碼裡把理由寫清楚。兩次量到的失敗都屬於這兩種。

二、不能轉成 Err 讓 provider 鏈重試。 這個函式同時位在 failover 迴圈
多輪 agent 迴圈裡面。如果那一輪已經發出了 tool_calls,重跑會再執行一次
file_writeshell一次網路抖動不可以變成操作者磁碟上的重複副作用。

所以是「標記」不是「重試」:走既有的 Notice 通道(detect_truncation_notice
早就在用它報 max_tokens 截斷),再把旗標帶到 AgentResultDone 事件上,
exec 拒絕回 exit 0。內容照樣印出來——那是真的工作成果——只是退出碼
不再宣稱它完成了。

三、這段程式碼就是已安裝的 daemon 在跑的。 所以整個過程沒有重建到已安裝的
產物、沒有跑 self-update、沒有碰真實 prefix。

一個 30 秒的逾時,讓最常見的死法無法被測

「wifi 斷掉但 socket 沒關」是最常見的一種,而它走的是 30 秒閒置逾時——
寫死的。沒有測試付得起半分鐘,所以行為最誤導的那條路完全沒有覆蓋
改成可設定之後才寫得出測試。

今天第四次:測試證明的不是它自己的名字

停滯那條測試第一版斷言的是「truncated 為真」。紅證把逾時改回寫死 30 秒,
測試照樣綠——只是花了 20 秒。

因為我的假伺服器睡 20 秒之後執行緒回到 accept 迴圈、socket 被丟掉,
所以它是從傳輸錯誤那條路過的,只是晚了 20 秒。名字說它在測逾時,
實際上它在測另一個機制。

改成斷言理由(notice 裡要出現 "no data for"),並讓假伺服器把連線
一直握著,逾時才是唯一的出路。

然後我發現「寫死 30 秒」這個變異本來就不該紅——那不是缺陷,只是讓測試變慢,
行為仍然正確。正確的隔離證明是把 Timeoutis_truncation 拿掉:
只有停滯那條紅,另外兩條照常綠。紅證選錯標的,本身也是一種空綠。

self-update:即使操作者去補了檔案,它還是會 404

這一項確認壞掉,而且比題目寫的更糟——有三套互相矛盾的命名:

要的檔名
spectyn.rs / install.sh spectyn-aarch64-apple-darwin
線上 Worker 的 manifest spectyn-darwin-arm64
serve.rs 的 allowlist 兩個都不是

線上伺服器把兩種失敗分得很清楚:

GET /dist/spectyn-aarch64-apple-darwin  →  404  {"error":"unknown binary …"}
GET /dist/spectyn-darwin-arm64          →  503  {"error":"binary … missing in R2 bucket"}

也就是說,就算操作者去把 R2 填滿,CLI 要的那個名字仍然會 404。

而且 --dry-run假綠:它 exit 0 說「would download but not install」,
卻從來不去探那個來源——任何人用 --dry-run 驗證修法,都會在一個仍然壞掉的
來源上拿到通過。

危險提示也很具體:別把 https 守衛放寬、別讓 sha256 sidecar 變成可選——
那些是 plain-http fallback 上唯一擋住 MITM 換掉 binary 的東西。來源是空的,
守衛是對的。

命名收斂可以在程式碼裡做完;填 R2 需要 wrangler login,是操作者的事。


這一天(交付與走完)

開場:最大的缺口不是程式碼

前一天收工時的帳很漂亮:六個階段、+891 條測試、全量 3,528 全綠、四個 commit。

然後我量了一件從來沒量過的事 —— 操作者實際會跑的那支 binary 是哪一版

~/.local/bin/spectyn    0.6.0 (c2bf11e6fd+, built 2026-08-28)
~/.cargo/bin/spectyn    0.6.0 (c2bf11e6fd+, built 2026-08-28)
repo HEAD               c342076f

Day 06 的每一個修法,一項都不在他實際會跑的那支裡。

那些修掉的謊 —— exit 0 卻什麼都沒做、doctor 謊報沒有身分、--config 被靜默忽略、
三個介面對同一個 Ask 三種意思、半截答案被當成完成 —— 對操作者的機器來說,
今天仍然全部還在

而且是兩份,內容相同,都停在 8/28。~/.local/bin 在他的 PATH 裡出現三次。

這是這個專案自己那句話的最新一案:沒有跑過的路,不算路 ——
只是這次沒跑過的是「把成果交出去」。

也沒有任何東西會告訴他這件事:spectyn --version 印得出 commit,
但沒有人拿它跟 repo 比對過。

Phase A:交付

先量:機器上到底裝了幾份

路徑 實況
~/.local/bin/spectyn 一般檔案,PATH 上贏的那支
~/.cargo/bin/spectyn 一般檔案,另一個 inode,內容相同
~/.spectyn-mesh/bin/ 不存在
~/Library/Application Support/spectyn-mesh/bin/ 存在但空的

兩份獨立的複本、內容相同、都停在 8/28。而 ~/.local/bin/spectyn
本來應該是 symlink —— scripts/install.sh:265ln -sf 建它,指向
~/.spectyn-mesh/bin/spectyn。現在它是一般檔案,而它該指向的目標不存在。

也就是說:這台機器的安裝狀態不符合任何一支安裝器的模型。

而且這正是 Phase 5 測量預測過的殘留缺陷 —— cluster upgrade
serve.rs 的 handler,那裡的 current_exe() 沒有 canonicalize,
所以會把 symlink 換成一般檔案,同時 install.sh 的真正目標保持舊的。
預測的形狀和機器上看到的形狀一致。

doctor 現在會說出來

原本 doctor 印版本、印 commit、印路徑 —— 然後沒有拿其中任何一項跟任何東西比對
加上 provenance 之後:

執行檔
  ✓ version: spectyn 0.6.0 (52bfc1325a+, macos-aarch64, built 2026-08-31)
  ✓ path: …/core/target/debug/spectyn
  ⚠ provenance: 2 spectyn binaries on PATH — the first one wins:
                    → /Users/markl/.local/bin/spectyn
                      /Users/markl/.cargo/bin/spectyn

判定邏輯是純函式(diagnostics::provenance_lines),只在有話可說時出聲:
單一安裝、且與所在 repo 同步 → 一行都不印。

三條刻意的沉默:不在 repo 裡不比對(沒有東西可比)、不是這個專案的 repo 不比對
(它的 HEAD 與這支 binary 無關)、重複的 PATH 條目不算第二份安裝
(操作者的 PATH 裡 ~/.local/bin 出現三次,報「3 份安裝」會是它自己的小謊)。

端對端測試三條:在一個臨時 git repo 裡跑真的 binary,斷言它說得出落後、
且在無可比對時保持安靜。紅證五條全數命中。

Phase B:走完 Phase 4 的出口條件

昨天 Phase 4 收工時三條出口條件都打了勾:

重啟看得到真逐字稿;兩介面 id 一致;關閉後無殘留 advertiser

而支撐它們的是:一條直接呼叫 slash handler 的單元測試、一條雜湊函式跟自己比
的單元測試、和一條 grep 原始碼找呼叫點的守衛。

沒有任何一條啟動過出貨的 binary、做那件事、然後看結果。

這正是這個專案自己那句話指回自己身上:沒有跑過的路,不算路。

走完之後,第一件事是我的測試錯了

第二條(兩介面同一個 id)一跑就紅:

REPL said pm_3fa52f4dd
TUI  said pm_e9cf42f9e

看起來像昨天的統一沒生效。但前綴是對的 —— pm_ —— 只有雜湊不同,
也就是說它們在算不同的目錄

查下去:PtyRun::spawncmd.cwd(home),把 TUI 放在沙箱目錄跑;
而我的 REPL 用管線驅動,繼承的是測試行程的 cwd(core/)。
兩個不同的目錄,本來就該算出不同的 id —— 產品是對的,測試是錯的。

差一點就報了一個假的產品缺陷。而且形狀很熟:一個看起來像證據的紅燈,
證明的其實是別的東西。

補上 current_dir(home) 之後三條全綠。

第三條:LAN 那半刻意不走

「關閉後無殘留 advertiser」的真實 advertiser 是 dns-sd,而啟動一個
就是在操作者的網路上放廣播 —— 這個專案自己的規則禁止測試這樣做。

所以拆成兩半:機制(註冊表 + 殺掉)用一個替身子行程端對端測;
LAN 廣播那半明說不走,理由寫在測試的文件註解裡。

替身測試證明的是原本沒有任何東西證明的事:回收器真的會殺掉它註冊的東西,
而且第二次呼叫時註冊表是空的(關閉路徑有兩條分支都會經過它)。

原本的來源守衛留著 —— 它擋的是另一件事:那個函式曾經零個呼叫點,
而 commit 訊息、實錄、兩篇發表的文章都說它接上了。
守衛證明呼叫存在,替身測試證明呼叫有用。 兩條都需要。

Phase C:走完 Phase 5 的 uninstall 出口條件

uninstall 之後 plist 不存在、行程不再重生

昨天這條打勾的依據是讀程式碼,因為走它看起來不可能:service uninstall 會跑
launchctl bootout gui/<uid>/<label> 並刪 ~/Library/LaunchAgents 裡的檔案 ——
端對端測試看起來就等於要卸掉操作者自己的服務。

有兩件事讓它其實可以走,而兩件事都得先驗證:

一、先證明沙箱真的是沙箱

plist 路徑來自 dirs::home_dir()。整個沙箱策略都靠「改 $HOME 會移動那個目錄」
這個假設 —— 而在 macOS 上這不是安全的假設:今天稍早才因為
NSHomeDirectory() 不理會 $HOME,一個以為自己被隔離的測試其實在讀操作者的真實
設定檔。

所以先寫一條單獨的測試證明 dirs::home_dir() 會跟隨 $HOME,而且它自己的
失敗訊息就寫著:在這條通過之前,不要走安裝路徑。它通過了。

二、假的 launchctl

程式碼呼叫的是 Command::new("launchctl") —— 走 PATH 查找。所以把一個會記錄
呼叫的替身放在 PATH 前面,就能驅動真正的程式碼路徑,而操作者的 launchd
從頭到尾沒有被定址。

走出來的序列正是這一條要證明的:

print   gui/501/ai.spectynmesh.serve
bootout gui/501/ai.spectynmesh.serve
print   gui/501/ai.spectynmesh.serve      ← 驗證,不是相信
print   gui/501/ai.phantommesh.serve
bootout gui/501/ai.phantommesh.serve
print   gui/501/ai.phantommesh.serve

兩個 label 都掃,這正是「遷移過的機器」那句話的重點 —— 這台 Mac 上跑的是改名前的
ai.phantommesh.serve,只 bootout 新 label 等於什麼都沒動,job 每次登入照樣復活,
而指令還是印 ✓。

而且第一次手動跑的時候,我那個粗糙的替身永遠回 exit 0,結果指令正確地判定
「Uninstall INCOMPLETE」並 exit 1
—— 它真的有在重新查證,而不是相信 bootout 的
退出碼。

今天第二次:測試證明的不是它自己的名字

「bootout 要在刪 plist 之前」那條,第一版寫成「整份記錄裡最後一個 print
排在第一個 bootout 之後」。

紅證把驗證那次查詢整個拿掉 —— 測試照樣綠

因為有兩個 label:第二個 label 的開場 print 本來就排在第一個 label 的
bootout 後面。斷言被另一個 label 的存在滿足了,跟它要證明的行為無關。

改成逐 label 檢查:對每一個 label,它自己的 bootout 之後必須還有一次
print。再跑紅證,紅。

這是今天第二次、也是這個專案這幾天第四次同一個形狀:一條測試的名字承諾一件事,
而滿足它的是另一件事。
每一次都是紅證抓的。

一件測試自己的坑

第一版測試把 PATH 縮成只剩替身加 /usr/bin:/bin,結果 60 秒逾時、
launchctl 零次呼叫 —— 看起來像「uninstall 根本沒跑」。實際上是啟動時某個探測
在最小 PATH 下卡住。改成前置替身而不是取代 PATH 就好了。

差一點又報一個假的產品缺陷 —— 今天第二次。


上一篇
Day 07|先量再修:一天之內,我自己的佇列有五格是錯的
下一篇
Day 9:把門關上之後,才發現鑰匙早就作廢了
系列文
從單一agent 到多agent 集群的開發流水帳以及應用18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言