系列背景:私有自持的 AI agent 網格 spectyn-mesh。
Day 06 把單機版裝進.app,並收掉了「停止說謊」到「權限批准」四個階段。
今天接著跑最後兩個階段 —— 而這一天真正的主題不是那些修法,是修之前先量。母規則同前:任何檢查必須先示範它會紅才算數。綠燈不是證據。
今天多學到一條:佇列也不是證據。

佇列是我前一天寫的,基於當天的觀察。今天照它往下做之前,先把每一項的現況
實際量一次——每項一個唯讀 agent,發現再交給一組獨立的懷疑者駁倒。
兩輪、24 個 agent、12 個題目。五個翻案。

翻案的不是「我做錯了」。是題目是一天前寫的,而那一天之後東西動過了 ——
其中兩項在我自己前一天的修復裡就已經好了,我卻還在佇列上留著它們。
照字面往下做的代價很具體:兩項會去修沒壞的東西,一項會刪掉一道刻意的安全閘,
一項會為不存在的缺口補測試。

測量 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。
它現在拒絕,而那是刻意的:外部複查之後加的 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 上
驅動它並斷言正向行為。那是前一天才補的——題目比修復舊了一天。

這是今天主題最純粹的一次。同一份內容,伺服器兩種死法:
stdout、stderr、退出碼——逐位元無法區分,兩次 stderr 都是空的。
同一個埠上 curl 說 exit 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_write 和 shell。一次網路抖動不可以變成操作者磁碟上的重複副作用。
這段程式碼就是已安裝的 daemon 在跑的。 所以全程沒有重建到已安裝的產物。
順帶:閒置逾時從寫死 30 秒改成可設定。不是為了彈性——最常見的死法
(wifi 斷了但 socket 沒關)走的就是那條路,而沒有測試付得起半分鐘,
所以行為最誤導的那條路完全沒有覆蓋。

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 從判定裡拿掉。
紅證選錯標的,本身也是一種空綠。
以下是當天實錄,原文按時序未改。
這一階段做法換了:先用 14 個 agent 平行測量七項現況,再動手。
每一項一個唯讀 agent(不准改檔、不准碰真實資料根、不准在區網放 daemon),
發現再交給一組獨立的懷疑者駁倒。17 分鐘。
值得的地方不是省時間,是兩項被翻案——照題目字面去修,一項會修一個沒壞的東西,
另一項會開一個安全洞。
量測 agent 做了一次 A/B:同一棵樹、同一個 mock provider、同一個模型、
線上同一份 usage(1,000,000 prompt + 500,000 completion,gpt-4o 費率 = $7.50)。
| 路徑 | 結果 |
|---|---|
非串流(serve → run_tracked) |
cost_usd: 7.5,ledger 有寫 |
串流(run_with_callbacks,TUI 與 exec 走的那條) |
$0.00,而且連 ledger 檔都沒產生 |
也就是說 CostTracker、價格表、持久化、四處顯示全部是對的。
照題目去「修成本追蹤」,會去修一個沒壞的東西。
壞的只有一條鏈,而且斷在兩個地方:
call_with_streaming 的每個 synthetic return 都寫死 "usage": {},choices / delta / type ——choices: [],所以它到了線上,被走過去了。stream_options.include_usage 只在有工具時才要。純聊天串流從一開始就成本餵食點的守衛是 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(),是唯一調和它們的地方。
它現在拒絕,而那是刻意的:外部複查後加的 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 id 不是一個 bug,是三個疊在一起:
| tui.rs | bin/spectyn.rs | |
|---|---|---|
| 前綴 | cwd- |
pm_ |
| 雜湊的東西 | Path(cwd.hash()) |
字串(cwd.to_string_lossy().hash()) |
| 寬度 | {:x} |
{:016x} |
Path 和 str 的 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/wsbanner 印的是設定檔裡那個字,而 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 coordinator 印 listen : 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)。
紅證的用途不只是「證明這條檢查有效」,還包括指出哪裡根本沒有檢查。
今天全量測試跑了幾次,每次都覺得「卡住了」。實際量:
編譯 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。
同樣先平行測量五項,再動手。五項裡兩項早就修好了、一項題目本身是錯的。
| 項目 | 裁決 |
|---|---|
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_write 和 shell。一次網路抖動不可以變成操作者磁碟上的重複副作用。
所以是「標記」不是「重試」:走既有的 Notice 通道(detect_truncation_notice
早就在用它報 max_tokens 截斷),再把旗標帶到 AgentResult 與 Done 事件上,
讓 exec 拒絕回 exit 0。內容照樣印出來——那是真的工作成果——只是退出碼
不再宣稱它完成了。
三、這段程式碼就是已安裝的 daemon 在跑的。 所以整個過程沒有重建到已安裝的
產物、沒有跑 self-update、沒有碰真實 prefix。
「wifi 斷掉但 socket 沒關」是最常見的一種,而它走的是 30 秒閒置逾時——
寫死的。沒有測試付得起半分鐘,所以行為最誤導的那條路完全沒有覆蓋。
改成可設定之後才寫得出測試。
停滯那條測試第一版斷言的是「truncated 為真」。紅證把逾時改回寫死 30 秒,
測試照樣綠——只是花了 20 秒。
因為我的假伺服器睡 20 秒之後執行緒回到 accept 迴圈、socket 被丟掉,
所以它是從傳輸錯誤那條路過的,只是晚了 20 秒。名字說它在測逾時,
實際上它在測另一個機制。
改成斷言理由(notice 裡要出現 "no data for"),並讓假伺服器把連線
一直握著,逾時才是唯一的出路。
然後我發現「寫死 30 秒」這個變異本來就不該紅——那不是缺陷,只是讓測試變慢,
行為仍然正確。正確的隔離證明是把 Timeout 從 is_truncation 拿掉:
只有停滯那條紅,另外兩條照常綠。紅證選錯標的,本身也是一種空綠。
這一項確認壞掉,而且比題目寫的更糟——有三套互相矛盾的命名:
| 誰 | 要的檔名 |
|---|---|
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,是操作者的事。