系列背景:我在用 Rust 打造一套私有、自持的 AI agent 網格 spectyn-mesh——
單一 binary、CLI / TUI / App / HTTP 多介面、本地與雲端模型混編、多機聯邦。
Day 03 推導了「一份命令表、五個 renderer」的終局架構。
今天是把它逼到能用的一天:從清晨的 CLI 硬化,到深夜操作者親手串上
免費雲端 API、再順藤摸瓜治好一個潛伏的串流 bug。
這篇是正式整理版;1.4 萬字的原始流水帳(含每一次紅測與外部複檢原文)
存在 repo 的docs/ironman-2026/day04-cli-must-not-guess.md。
今天的目標用一句話說:「一個不准猜的 CLI」。
不准猜使用者要什麼——求助就是求助,不准趁機做事;
不准猜狀態——退出碼要說真話,倉庫不見了不准說成倉庫是空的;
不准猜環境——資料根覆寫要全程有效,免費層的宣稱要標日期;
也不准我自己猜——每個結論都要先示範它能被推翻,才准寫進紀錄。
一整天下來,這個「不准猜」在四個地方反覆兌現:CLI 的表面行為、
測試的有效性、供應商的真實狀態、以及串流協定裡藏著的謊。
| 戰場 | 開場 | 收場 |
|---|---|---|
| CLI 硬化(Q1–Q3) | --help 有副作用、退出碼亂報 |
求助唯讀、0/1/2 誠實、命名空間統一 |
| 資料根統一(#322) | 16 個繞過 resolver 的呼叫點 | 0,棘輪測試守住不回退 |
| 供應商 failover | 一輪重試 75 次、cold-start 亂寫設定 | circuit breaker per-runtime,3 次封頂 |
| selftest | 69 過 / 16 掛 | 95 過 / 1 flake |
| 本地 LLM(G2) | — | 零設定 15 秒出首答 |
| App 介面(G5) | e2e 全紅(打的是三年前的 API) | 15/15 綠,測今天的 API |
| API key 導入(G1) | 沒有安全入口 | set-key --key-stdin,今晚接住真實使用者 |
| 免費雲端 API | 沒串過 | nemotron 550B 上線當主力 |
| 串流層 | 空流謊稱 blank stream、過急棄家 | 引上游原話、同家重試,紅測釘死 |
貫穿全部的是一條當天定下的紀律,我叫它母規則。

一切要從一個難看的數字說起:75。修之前,一次對話裡若有供應商
掛掉,agent 的多輪迴圈會在每一輪重新支付全額重試預算——
單輪對一家死供應商接觸 75 次,使用者看到的是 TUI 卡死在轉圈,
沒有任何解釋。root cause 有兩層:斷路器是 process-global 的
假斷路(誰都能重置誰),而每輪迴圈都天真地重新走完整條鏈。
修法是把 circuit breaker 掛到 AgentRuntime 上:同一次執行
共享一個斷路器,allow() 先問、開路直接跳過、跳過的原因寫進
errors 陣列。單輪封頂 3 次接觸。配套兩件事:
agents.toml 一個位元組都不動——「第一次跑」[gemini] circuit open — skipped without contact
[groq] network error ×3, exhausted retries
[local-ollama] 200 ✓
這章沒有戲劇性,但晚上每一場戲都站在它上面:
失敗不可怕,貴才可怕。 把失敗的代價壓到一次重試,
後面才敢把免費雲端擺到鏈頭。

規則只有一句:任何檢查,必須先示範它抓得到壞,才算數。
寫了測試?先弄壞一次程式,看它紅;紅的原因還得正確,才准修綠。
測試如此、腳本如此、監控斷言如此、我自己的口頭結論也如此。
聽起來像常識。但光是今天,它就抓到我自己三次:
#[cfg(test)] 之後。 我以為我看見了紅,if !is_read_tool(t) { continue }。 清單一旦是空的,READ_TOOLS 清單驅動測試案例,三次的共同點:看起來在檢查,實際上在裝飾。 我把這叫「空綠」
(vacuous green)。空綠比沒測試更毒——它給你信心,而信心是假的。
工具鏈本身也會說謊,今天收集到兩枚標本:macOS 沒有 timeout
指令,包在 pipeline 裡連退出碼都誤報成功,害 selftest 的限時
機制整段是裝飾(改成純 bash 的背景執行 + kill,並把「這台機器
沒有 timeout」寫進長期記憶);zsh 不對未加引號的變數做分詞,
一段在 bash 下正常的測試迴圈在 zsh 下悄悄變成單一參數。
selftest 從 69 過 / 16 掛修到 95 過 / 1 flake,
一半的功勞屬於「先讓它紅」。
展開講那個 timeout:selftest 需要「限時執行,逾時判掛」,
原始寫法 timeout 30 some_check 在 macOS 上直接command not found——但因為包在 pipeline 裡,退出碼被吃成 0,
十六條檢查全數「通過」,通過的原因是根本沒跑。純 bash 替代品
也不是一次到位:背景執行會把 stdin 掛到 /dev/null,
靠 "$@" 0<&0 & 把呼叫端的 stdin 重新綁回去,再 export -f
讓子 shell 看得見函式。三個坑,每個都先寫一個「會紅的示範」
才敢說修好。
配套是外部複檢:每完成一項,派 codex / agy / opencode 三個
外部 AI 對抗式審查,指令是「拆掉它」,不同意處原文照登進紀錄。
今天最痛的一刀來自 codex。我原本的結論是:「拿掉 launchd plist 的--host 0.0.0.0,那 20 條未收斂的 API 就碰不到了。」codex 的回答:
作為立即 containment 合理;作為深度防禦,是自我安慰。
agents.toml寫127.0.0.1不足以證明 launchd 實際讀的是那份設定;
CLI default 永遠是 loopback;IPv6 沒有監聽::;
未來改 bind address 時會被阻止嗎?
最後那句是重點——我修的是「現在」,不是「以後」。照它的方案改:
只要 KNOWN_UNGATED 名單非空,程序嘗試 bind 非 loopback
就直接拒絕啟動,把「依賴一份 plist 保持正確」換成
「執行檔自己拒絕曝露這筆債」。
$ spectyn serve --host 0.0.0.0
error: refusing to bind non-loopback while 20 API routes remain ungated
被外部複檢拆掉的結論不只這條:「13/3 分類」被 agy 判定是
粉飾性的歸類、file_read 的一條宣稱被抓到把「介面正常」與
「模型正常」焊在一起講。全部原文照登。 紀錄的價值不在
「我都對」,在「錯的地方看得見」。

母規則管「檢查是不是真的」,外部複檢管「結論是不是真的」。
規則:每完成一項,派 codex / agy / opencode 三個外部 AI
對抗式審查,指令是「拆掉它」;複檢的產出不是「通過/不通過」,
是文字——同意要說為什麼,不同意要說哪裡,兩者都原文進紀錄。
今天總共被拆掉六條:
| 誰 | 我的宣稱 | 複檢裁決 |
|---|---|---|
| codex | 拿掉 --host 0.0.0.0 就安全 |
自我安慰;改為執行檔拒絕啟動(見第二章) |
| codex | help_flag_present 判定完備 |
不認 -- 分隔符;修判定+紅測 |
| codex | is_served 手寫清單可靠 |
手寫必漂移;改由 routes() 對帳 |
| agy | 「13 過 / 3 掛」的分類 | 粉飾;掛的那 3 條才是主線功能 |
| agy | dispatched 參數有用 |
從未被讀取的假介面;刪除 |
| opencode | file_read 驗證通過 | 「介面正常」與「模型正常」被焊在一起講;拆開驗 |
幾條值得展開。dispatched 假參數:一個函式簽名裡躺了很久的
布林,看起來像控制什麼,實際上沒有任何讀取點——這種「裝飾性介面」
比缺功能更糟,因為它讓呼叫者以為自己控制了什麼。刪掉,
編譯器替我們找出所有以為自己在用它的呼叫點。
13/3 粉飾:我把 16 項驗收寫成「13 過 / 3 掛」,語氣像及格。
agy 指出那 3 條掛的分別是 exec、repl、serve——主線中的主線;
及格的 13 條多是周邊。同一組數字,誠實的寫法是
「主線 3 條全掛,周邊 13 條通過」。數字不會說謊,
但分類會。
file_read 焊接:我宣稱「file_read 驗證通過」,證據是一次
成功的端到端呼叫。opencode 拆解:那次成功同時依賴「工具介面正確」
與「模型願意配合」,兩者只要有一個碰巧成立,測試就綠——
必須分開:介面用固定 mock 驗,模型行為用 salvage 邏輯驗。
三個外部 AI 的視角明顯互補:codex 擅長邊界與安全推理、
agy 擅長拆穿修辭、opencode 擅長分離變因。這不是儀式——
是這個專案品質最好的部分的直接來源。

Q1:求助永不變異。 spectyn doctor --help 先前會真的跑一輪診斷,spectyn -q --help 會安靜地做事——求助是新手打的第一個指令,
它若有副作用,信任在第一秒就毀了。修完後任何位置的 --help/-h
都唯讀。邊角是 codex 抓的:run_tool -- --help 裡 -- 之後的--help 是參數,不是求助;分辨兩者的 help_flag_present
必須尊重 -- 分隔符。配套一個母規則式的測試:help_never_mutates 對整張命令表掃一輪 --help,
任何一條產生檔案寫入就紅——而它上線前,先示範過它抓得到
「doctor --help 會寫診斷快取」的舊行為。
Q2:退出碼誠實。 腳本作者靠 $? 分流,先前 Error:、Unknown tool、[denied] 一律 exit 0——腳本眼裡全是成功。
現在:0 = 成功;1 = 執行期錯誤;2 = 用法錯誤(打錯字、未知工具、
權限被拒)。「你打錯了」跟「程式錯了」從此走不同的溝,
cron 與 CI 才有辦法對症下藥。
Q3:命名空間路由。 spectyn provider list 與spectyn providers list 歷史上是兩套實作,輸出各自漂移。
現在同路由、同輸出,舊名保留相容,並在 --help 印出正名提示。
三題共用同一個地基:一張命令表(commands/mod.rs)。
CLI、TUI、HTTP、App、文件五個 renderer 都從這張表長出來;
HTTP 那側再加一條 routes() 單一來源與 is_served 對帳測試——
表上有的路由若沒被實際掛載,測試先紅。文件也不例外:documented_scripts_are_runnable 把文件裡每一段可執行片段
真的跑一遍,文件寫錯跟程式寫錯同罪。

SPECTYN_HOME 是資料根覆寫——測試隔離、多例並行、備援還原全靠它。
但實查發現全 repo 有 16 個呼叫點繞過 resolver 直呼dirs::home_dir():覆寫形同虛設,設了等於沒設。
這件事的起點很難堪:每日回顧看板連續 15 天顯示 events = 0。
交接文件言之鑿鑿說根因是 camelCase 欄位名——是錯的。
真正的根因是事件目錄不存在時,讀取函式回 Ok(vec![]):
把「倉庫不見了」說成「倉庫是空的」。修法是引入三態StoreState { Missing, Empty, Populated },Missing 時看板直接
掛出「SUPPLY SEVERED」橫幅——寧可刺眼,不准安靜地錯。
(附帶修掉一路上的計數烏龍:我先報 66、再報 24、最後才數對 16
——連「數債」這件事本身都要被複檢。)
16 個違規點全數歸零之後,留一個棘輪測試:data_root_baseline.tsv 記錄目前違規清單(現在是空的),
任何人新增一筆直呼,測試立刻紅;數字只准變小,不准變大。
不要求英雄式一次清零,但保證債務單向遞減。
附贈一個羞恥時刻:清理過程中,某個 in-process 測試把 4 筆探針事件
寫進了操作者的真實生活資料庫——被操作者自己的日報看板抓到。
刪除、道歉、然後把「所有 in-process 測試一律 SPECTYN_HOME 隔離
16 個違規點不是均勻散布,盤點起來有四類:設定載入(config 自己
繞過自己)、診斷與事件寫入(diag/events 各自拼路徑)、
成本帳本(costs.json 硬編 home)、排程與看板(scheduler 讀
另一套)。同一個「資料根在哪」的問題,四個子系統各答各的
——這正是 resolver 要當單一入口的理由:問題只准有一個答案,
答案只准住一個地方。
當晚 21:00 的排程回顧被現場盯著跑完:Events captured: 1,
一筆真實事件、沒有橫幅——供應鏈從斷裂到接通,前後兩天。

G2 的驗收標準寫得很俗氣:一台有 ollama 的全新機器,
裝完之後第一條指令就能開始工作。實測(全新沙盒 HOME):
$ ./install.sh
installed: ~/.local/bin/spectyn
$ spectyn exec "Reply with exactly: FRESH-LOCAL-OK"
FRESH-LOCAL-OK # 15 秒 · 自動偵測 ollama · 工作目錄零寫入
「零設定能用」與「零設定亂來」只有一線之隔,差別在預設值選在哪一側:
cold-start 時 trust policy 自動夾制到 Observe(唯讀),
沒有人選過的權限一律從最小開始;某個目錄要讓 agent 動手,spectyn project trust add 明白授權。今天還把一個洩洪口堵上:
「enforcement 未設而目錄未信任」的警告,先前只在互動模式印——
腳本裡的 exec 用同一雙手寫檔,卻一聲不吭。移出互動判斷,
現在誰都躲不掉那行黃字。
同場加映:MCP server 模式今天第一次被真的測了(專案開張以來
它一直躺在功能表裡沒人打過)——stdio 握手、tools/list、
一發 file_read,通。沒測過的功能不算存在,現在它存在了。
selftest 收在 95 過 / 1 flake:那 1 條是負載相關的計時斷言,
在冷機上穩綠、在編譯併發時偶紅——它被標記為 flake 而不是
被調鬆,因為把斷言調鬆到永遠綠,就是親手製造下一個空綠。
寧可紅得誠實,不要綠得心虛。

G5 開場很難看:Playwright e2e 全紅。追下去發現紅得冤枉——
測試打的是三年前的端點(/api/status、message 欄位),
後端早就長成 /healthz 與 SSE 的 prompt。
介面測試的天敵不是 flake,是「測著一個已經不存在的後端」還綠著;
這次它至少誠實地紅了,只是紅錯了方向。
修的是測試的世界觀,不是程式:改打 /healthz、/api/provider/list、/api/life/stats、/api/chat(SSE 流式斷言),
加一個 reachDashboard() 雙路 helper——Tauri 原生視窗與瀏覽器
web-shell 各走各的登入動線,測試兩邊都要會走。
相容層 tauri-compat 補上 life_stats 的 HTTP fallback
(原生 invoke 有就走原生,沒有就退 HTTP,欄位名 camelCase 與
snake_case 互轉)。另一個絆腳石藏在兩層安全機制的縫裡:
CORS 白名單(dev 埠 5173/1420)與 auth_gate 的回環豁免
必須是同一句話——一層放行、另一層 401,使用者看到的是
「隨機壞掉」。收場:15/15 綠,而且綠得有意義——
它們現在測的是今天的產品。

G1 要做的是「從安裝到導入 LLM API」的入口。設計先想清楚
key 會經過哪裡:argv 會進 ps 輸出與 shell 歷史;
回顯會進終端機 scrollback 與螢幕錄影;整體序列化會讓任何一個
debug endpoint 變成洩漏點。所以:
$ pbpaste | spectyn provider set-key cerebras --key-stdin --model gpt-oss-120b
cerebras: set-key ✓ [csk-…(52字元)]
$ spectyn provider set-key groq --key sk-xxx
error: --key is refused; pipe via --key-stdin, or record an env var with --env
三個永不,各自有測試守著:key 永不落 argv(明給 --key
直接拒收,連「不小心」的路都封死);永不回顯(畫面只有前綴
與長度);永不整體序列化(AgentsConfig 不實作 Serialize,
所有端點只回報 key 的「存在」與 env 變數名)。寫入走 toml_edit
原地編修——你手寫的註解、排版、順序,一個都不會動;
永不「讀進來再整份吐回去」,那條路上每一步都可能把 key 帶出門。
另一個容易忘的細節:TOML 欄位是 type 不是 provider_type
(serde rename),寫錯欄位的設定「看起來有存,讀回來是空」
——又是一種安靜的錯,靠紅測釘死。
今晚它第一次接住真實使用者:操作者本人,拿著兩把剛申請的免費 key
存在桌面的 key.txt。我從檔案把 key 餵進 stdin,全程畫面上
只出現 csk-…(52字元)。然後,免費層給我們上了一課。

註冊表裡有四家「免信用卡、真免費」的供應商,六月十四日全數
實測驗證過。今晚(8/27)重演同一套流程。先把四家的現況一次攤開:
| 供應商 | 六月狀態 | 今晚實測 | 判定 |
|---|---|---|---|
| Groq | ✓ 驗證過 | 網路層 403(疑地區/線路) | 換網路再試 |
| Cerebras | ✓ 驗證過 | 402 Payment required | 免費推理已變 |
| OpenRouter | ✓ 驗證過 | key 有效,註冊表模型退役 | 掃現役救回 |
| Gemini | ✓ 驗證過 | 429(當日額度盡) | 明日重置 |
兩家變臉的細節:
Cerebras:/models 還大方列出 gemma-4-31b 與 gpt-oss-120b,
聊天端點卻對兩個都回 402 Payment required「去帳務頁」。
免信用卡註冊仍然為真——但推理已經不免費(或需要去主控台
多按一步啟用)。中間還插了一個小插曲:用 urllib 探測被 Cloudflare
1010 擋下,換瀏覽器 UA 才看到真相——同一個 API,對不同 client
說不同的話,查證時連 UA 都是變因。
OpenRouter:key 完全有效(auth/key 回 200、usage 0),
但註冊表裡的 meta-llama/llama-3.3-70b-instruct:free
上游已退役,404。拉現役清單:17 個 :free 模型,
跟兩個月前比幾乎換了一輪。
處置不是查文件,是逐一實打:拿真 key 掃現役清單,
第一發就中——
✅ nvidia/nemotron-3-ultra-550b-a55b:free: 200 WIRED-OK
550B 的推理模型,免費。寫進設定、更新註冊表,並補上一行註解:
// :free 會流動 — default 是種子,不是承諾
同一晚的小案例還有一枚:操作者日常設定裡的 gemini 一直 404,
不是 key 壞——是 gemini-2.0-flash 這個 model 名退役了,
沒有任何公告到達使用者。改成 2.5-flash,404 變 429(額度,
明天重置)。連 401 都沒出現過:壞的從頭到尾只是一個名字。
這章的教訓:免費層的宣稱要標日期。 六月為真不等於八月為真;
註冊表寫的不是事實,是「某天曾經為真」的快照。所以 onboarding
必須原文轉述上游錯誤——使用者該看見今天的真相,
而不是我們的舊記憶。

串上 nemotron 之後做穩定度巡檢:6 發 daemon SSE + 2 發 CLI。
全部拿到答案,但第 1 發答了一坨 JSON 亂碼——空流 fallback
掉到本地 8B 模型的傑作。把數據攤開更詭:
| 路徑 | 發數 | 空流 | 延遲 |
|---|---|---|---|
| daemon SSE | 6 | 3(集中在前兩發) | 1.3–15.8s |
| CLI exec | 2 | 0 | 4.4s / 38.5s(思考長) |
| 累計(今晚) | 9 | 4,全在 daemon | 下一發皆恢復 |
第一個假設「推理模型把 max_tokens 燒完在思考上」——查證:
水位 8192,否決。第二輪直接抓現行犯:連打串流、逐 frame 統計
content 與 reasoning 字元數,第 5 發逮到:
HTTP/2 200 OK
: OPENROUTER PROCESSING
data: {"choices":[],
"error":{"code":502,
"message":"Upstream error from Nvidia: Service temporarily overloaded",
"metadata":{"error_type":"provider_unavailable"}}}
運輸層說 200,流裡躺著一具 502。 choices 是空陣列,
delta 解析的每個分支都以 choices[0] 開頭——一個都不會進;
error 物件被無聲丟棄;然後判空邏輯理直氣壯地宣稱:
「model returned blank stream」。上游明明把死因寫在流裡,
我們沒讀,還替它編了一個。這是今天第三次撞見同一型失敗,
我已經有名字了:訊號說謊。
(前兩案:Ok(vec![]) 把倉庫不見說成倉庫是空的;timeout 不存在卻回報全數通過。)
log 時間線把「路徑差異」釘死——四筆 Empty stream result
全掛著 daemon 的 tag,兩發 CLI 探測一次都沒中。同一個迴圈、
同一家供應商,差在採樣運氣:免費池的抖動是陣發的,
誰在抖動窗口裡打誰中獎。這也是為什麼「重試同家一次」有效:
實測 9 發 4 空,每一次的下一發都成功。
兩宗罪:謊報死因(上游原話被改編成 blank stream)、
過急棄家(共享池抖動被當成供應商死亡,一次抖動就把 550B
換成 8B,答案品質肉眼可見地掉)。
母規則走完整套。先寫兩發紅測,wiremock 灌實錄的 error frame:
in_stream_error_message_is_propagated——斷言 run 的錯誤訊息empty_errored_stream_retries_same_provider_once——mock 第一發再動四處手術,全在 agent.rs 的串流迴圈:provider 迭代改
worklist(VecDeque,同家可推回隊頭)、加 stream_error
累積器、frame 解析補讀 error 物件、判空分支改為
「引上游原話 + 同家重試一次,再空才 failover」。
紅轉綠;回歸 lib 2688 + 34 整合全綠;部署重驗 6/6 直服。
加映一個尷尬的發現:追殺過程中發現串流消費迴圈竟有
兩套並行實作(agent.rs 與 streaming.rs)——而本來就有
error-frame 偵測的那套,daemon 沒在用。Day 03 才寫過
「一個核心、多個 renderer」,自家後院就趴著一對分叉。
病人已治;兩套收斂為一套,列入長期債,寫明償還條件。

整理部署文件時發現三份文件對 curl | sh 各說各話:
一說「FUTURE item,未接線」;一說是主要安裝路徑,指向spectynmesh.io;一說一行搞定。實測是第四種:
spectynmesh.io 根本沒有 DNS——從來就 resolve 不了spectynmesh.com 上,/install.sh 回 200,382 行完整腳本,管線從頭到尾是活的,活在另一個域名上。 沙盒 HOME 實測
一鍵安裝:腳本跑、平台偵測對、下載卡在 503——盤點 R2 桶:
/install.sh 200 ✓ /install.ps1 200 ✓
/dist/spectyn-darwin-arm64 503 ✗ /dist/…-linux-x86_64 503 ✗
/dist/…-darwin-x86_64 503 ✗ /dist/…-windows-x86_64 503 ✗
兩支安裝腳本都活著,桶裡一顆 binary 都沒有。 差的最後一步是
Cloudflare 憑證,只在操作者手上;上傳指令已備好在 runbook,wrangler login 之後一鍵即發。
沒有做的事同樣重要:120+ 個文件引用 .io,不盲掃。
那是命名決策,不是 typo——本專案改名史上已經兩次證明「整片掃」
會把刻意保留的東西一起掃掉(連 sha256("phantom") 這種衍生值
都藏得過字串搜尋),這是教訓第三次生效。只修了「照著跑會卡住」
的四處,其餘列成一頁決策單等裁定。
補完最後一塊拼圖的指令已備好在 runbook,操作者登入即發:
$ cd spectynmesh-io && ./node_modules/.bin/wrangler login
$ wrangler r2 object put phantom-binaries/spectyn-darwin-arm64 \
--file ../dist/spectyn-aarch64-apple-darwin
$ curl -s -o /dev/null -w '%{http_code}' \
https://spectynmesh.com/dist/spectyn-darwin-arm64 # 期望 200

$ spectyn providers priority master openrouter local-ollama gemini groq
✓ agent.master.providers = [openrouter, local-ollama, gemini, groq]
第一章的地基在這裡收割:失敗變便宜了,這條鏈才敢把
免費雲端擺第一、把重試當日常。今晚它被實戰打了三種劇本:
web_search、查不到、誠實說查不到——韌性不是沒有失敗,是失敗的那一發使用者也拿到正確答案——
而且 log 說得出為什麼。

G1 到 G5 的佇列裡,G4 寫著「操作者複測」。自動化今天交出了
它能交的一切:89 項 golden master 全綠、selftest 95/1、
e2e 15/15、六項外部複檢、以及一份 10 條指令的複測腳本。
但那一格我不能勾。
自動化能證明「它會動」;只有使用者能證明「它好用」——
輸出看不看得懂、錯誤訊息知不知道下一步、體感延遲能不能接受,
這三個問題的答案不在任何斷言裡,在操作者的手上。
10 條指令、約五分鐘:--help 讀一遍、doctor 跑一遍、provider list 對一遍、exec 問一句、退出碼驗一顆……
腳本存在 plan/G4-操作者複測腳本.md,格式是「指令 + 你該看到什麼
系統的最後一個測試永遠是人。這一格空著,day04 就還沒有真正結束
——這不是謙虛,是驗收定義。
流水帳的規則跟程式的規則同一套:邊做邊寫、當場寫、寫難看的。
每一段都在事件發生的十分鐘內落盤;複檢的反對意見原文照登;
數錯的數字(66→24→16)不塗改、追加更正。一天下來
1.4 萬字、80+ 個 commit 推上 dev 分支,紀錄與程式在同一個
repo 裡同一批 commit——紀錄本身也走版本控制,也吃母規則
(文件裡的可執行片段有測試真的跑它們)。
工程之外的兩枚長期記憶也值得記:macOS 沒有 timeout
(撞了三次才學會寫進記憶);「改名債不可整片掃」
(第三次生效,這次擋下的是 120+ 處 .io)。
好的教訓不是記在腦裡,是記在下次一定會被再看到的地方。
還欠一件事:操作者本人的 G4 複測——10 條指令的親手驗收,
只有操作者能勾那一格。自動化能證明「它會動」,
只有使用者能證明「它好用」。
明天(Day 05):review gate——兩個 AI 互相對審的合流閘門。
完整流水帳(1.4 萬字,含每一次紅測、每一句外部複檢、
與被拆掉的結論原文)在 repo:docs/ironman-2026/day04-cli-must-not-guess.md。
完整流水帳有 1.4 萬字,超過平台單篇 64KB 上限——以下按「當天
最重要的現場」優先收錄原文段落,一字未改;完整版永存 repo:docs/ironman-2026/day04-cli-must-not-guess.md。
Day 03 把「一份命令表,五個 renderer」推導完,也走完了 provider 那一組的 0→5 步。
今天要處理的是同一棵樹上更難堪的一件事:這個 CLI 會猜。
你打 spectyn capture --help,它不會告訴你沒有這個命令,
它會把整串字丟給語言模型,然後印出一段幻覺出來的專案文件。
一次 API 呼叫。你只是想看說明。
這棵樹上已經有兩道防線,都是我前幾天才加的:
| 防線 | 擋什麼 |
|---|---|
DeclaredButUndispatched |
名字在指令表裡,但沒有任何處理常式接它 —— 那是我自己的派工缺陷 |
Typo |
編輯距離夠近的近似字,例如 servee → serve |
capture 兩道都不中。它不在指令表裡(真正的命令是 spectyn event capture,有命名空間),
編輯距離也不夠近。於是它穿過兩道防線,變成一句提示送進模型。
這不是漏了一個字,是這種擋法本身有問題。 兩道防線都依賴一份要有人記得更新的名單。
名單漏掉的下一個是誰,你事前不會知道——capture 就是那個「下一個」。
所以我加的第三條刻意與名單無關:
argv 裡出現
--help或-h,而那個詞沒有被任何處理常式接走 → 直接拒絕,exit 2。
不管那個詞是已知指令、打錯的字,還是完全的亂碼。要說明的人不是在問問題,
而沒有人會寫一句以 --help 結尾的提示。
spectyn servee --help 同時命中 typo 和 help 兩條。哪一條該贏?
我讓 typo 贏。「你是不是要打 serve?」比「這不是指令」有用——使用者要的是下一步,
不是分類。所以 help 那條放在最後當兜底:上面每一條都靠名單,只有它不靠。
不是「我覺得修好了」,是數字:43 個宣告的子命令逐個加 --help 跑一遍,
燒掉模型呼叫的次數 = 0。
單元測試四條全綠。我拿真的執行檔一跑:
$ spectyn capture --help
◆ capture --help
本檔案描述了 Spectyn Mesh 的架構和工具的詳細設計……
還在燒。
我當下的第一句話是「我的修正沒有生效」。錯了——壞掉的是我的驗證腳本:
for c in "capture --help" ...; do
./spectyn $c # ← zsh 不對未加引號的變數做斷詞
done
bash 會把 $c 拆成兩個參數,zsh 不會。所以真正跑的是spectyn "capture --help" —— 一個參數,那確實是一句提示,模型該接它。
改成 ${=c}(zsh 的顯式斷詞)之後全部正確。
這件事值得留著:我差點根據一個壞掉的驗證,去改一段其實正確的程式碼。
測試綠、真機紅的時候,先懷疑量測。
Q1 的出口條件之一是「golden master 全綠」。跑下去紅了兩項,而我那天根本沒碰那些路徑:
< ✓ version: spectyn 0.6.0 (GITHASH, macos-aarch64, built 2026-08-26)
> ✓ version: spectyn 0.6.0 (GITHASH, macos-aarch64, built 2026-08-27)
< ✓ Life Node: 尚未擷取任何事件 — 試試 `spectyn note "<文字>"`
> (doctor --json 多出一個 key: life_node.by_kind.text)
第一項是建置日期,跨日就紅——天生會變的東西沒被遮蔽。小事。
第二項才是問題。 前一晚我為了驗證另一件事,用 spectyn note 寫了一筆真的筆記。
於是 golden 紅了。
也就是說:這支釘住「行為有沒有變」的腳本,跑在我的活資料上。
「我昨天用了這個工具」跟「程式行為變了」在它眼裡長得一模一樣。
特徵化測試釘的是程式的行為,不是這台機器今天的狀態。這條我寫在腳本開頭,
自己卻沒做到。
第一版我用 mktemp -d 開一個一次性資料根。結果還是紅:
< ✓ events log: /var/folders/.../tmp.v7QtmwGBO5/.spectyn-mesh/events.jsonl
> ✓ events log: GOLDENHOME/.spectyn-mesh/events.jsonl
--update 把當次的隨機路徑烤進了快照,而比對時遮的是「這一次」的隨機路徑。
兩個不同的隨機值,永遠對不上。
改成固定路徑,再把 repo 根遮成 REPO(快照要能跨機器重現)。連跑三次:
✓ golden-cli:89 項全部相符
✓ golden-cli:89 項全部相符
✓ golden-cli:89 項全部相符
釘住資料根之後,舊快照(在有資料的根上錄的)全部要重錄。
腳本自己寫著「行為真的要改的話,跑 --update 並在同一個 commit 說明為什麼」——
所以理由寫進 commit 訊息:
新快照釘的是「乾淨機器上的行為」。 那正是冷啟動要看的那一面,
比釘住我個人的資料狀態有價值得多。
而 selftest 仍然證明它會紅:汙染任何一張快照,比對照樣失敗。
今天到目前為止,三件事是同一個形狀:
| 看起來 | 實際上 | |
|---|---|---|
capture --help |
CLI 在回答你 | 它在猜,而且收費 |
| 我的驗證腳本 | 修正沒生效 | 量測壞了 |
| golden master | 行為變了 | 是我用了那個工具 |
三次都是「訊號本身在說謊」。 而昨天 S0 修的那條——回顧把「儲存區不見了」
印成「今天沒事發生」——是同一個病的第四個實例。
我開始覺得這不是一串巧合,是這個專案當前最主要的失效模式。
規矩是每做完一項就派一家外部 AI 複檢。第一次派失敗了——腳本給了 codex--dangerously-bypass-approvals-and-sandbox,它就真的去執行 spectyn,
觸發了 onboarding,輸出被 hook 日誌洗掉。
複檢只需要讀 diff,不需要執行權限。改成 --sandbox read-only 之後拿到了有用的東西。
---- 是 POSIX 的慣例:它之後的東西全是資料,不是選項。所以這句應該是一個問題,不是求助:
$ spectyn -- compare -h
我的 help_flag_present 掃整個 argv,於是把它拒絕了。
而我那條「防止這件事」的測試,是綠的。
因為我挑的例子是 spectyn -- "compare -h and --help"——加了引號,
整句變成一個 argv token,--help 從來沒有獨立存在過。規則根本沒被觸發。
它不是通過了,它是沒有被考到。
改成未加引號之後立刻紅,修完再綠。
我有一條測試長這樣:
#[test]
fn dispatched_commands_handle_their_own_help() {
assert_eq!(refuse_implicit_prompt("serve", true, true), None);
}
看起來在證明「spectyn serve --help 不歸這條規則管」。
codex 指出:正式呼叫點永遠傳 dispatched: false。
match refuse_implicit_prompt(word, false, help_requested) {
只有那條測試自己傳 true。它測的是一個生產環境不存在的輸入。
我今天整篇文章在講「訊號說謊」,然後在自己剛寫的測試裡放了一個。
刪掉了,原地留註解說明為什麼,並把主張改成用真執行檔驗證:
core/tests/cli_help_never_costs_a_model_call.rs
✓ 非指令的 --help → 退出 2 且無模型呼叫
✓ 已派工的指令 → 印自己的說明、退出 0
✓ `--` 之後是資料
✓ 43 個子命令全掃 → 燒錢 0 次
最後那條把我手動跑的清掃變成可重複的測試。手動跑一次證明的是今天;
測試證明的是每一次。
codex 說 spectyn compare -h and --help(未加引號的多詞提示)會被誤擋。
沒採納,理由是:parser 本來就只取第一個 bareword 當提示,
所以那個形式在我改動之前就已經是有損的——它會拿 compare 一個字去問模型。
而現在的錯誤訊息會指向 -c,那才是送多詞提示的正確方式。
把一個本來就壞的形式「修復」成能運作,是擴大而不是收斂。
兩個洞我自己都不會發現。第一個是因為我寫測試時挑了對自己有利的例子,
第二個是因為那條測試看起來就很像在做事。
外部複檢不是禮貌流程,它是唯一能看穿「我以為我測了」的東西。
Day 03 把 provider 那一組走完了 0→5 步。今天要證明那不是一次性的巧合,
所以再收兩組。
先盤點,逐個從程式碼數出來——這是同一個能力:
spectyn event capture / event show ← event 命名空間
spectyn note / recall / review ← 頂層散落
spectyn data delete / export / stats ← data 命名空間
八個命令、一個能力、三個不相干的命名空間。 而 HTTP 只有 /api/events 一條。
沒有人能回答「生活軌道能做什麼」,除非去讀 CLI 的 parser。
這一組還有另一層意義:它就是我昨天 S0 修好的那條供料線。
讓 app 搆得到它,才會從「我可以去終端機做」變成「我會做」。
這是這張表最重要的一條紀律:HTTP handler 只能呼叫 CLI 用的同一個函式。
life.stats → data_cli::compute_stats
life.review → daily_review_wire::load_daily_review
life.recall → recall::search_events
life.show → data_cli::resolve_event_id + search_events
life.note → note_capture::capture_note
life.capture→ note_capture::capture_text_event
最後一個需要一點手術。capture_note 把 kind 寫死成 "note",
而 life.capture 要能帶種類。我沒有另外寫一個——
把它抽成 capture_text_event(dir, kind, text, tags),
讓 capture_note 變成 kind = "note" 的那個呼叫。
多一套「事件怎麼落到磁碟」的實作,正是這張表要防的東西。
life.export 和 life.delete 我沒有開 HTTP,理由寫在宣告的正上方:
serve綁0.0.0.0:7878(launchd 的設定檔就寫死--host 0.0.0.0,lsof實測*:7878 LISTEN),而/api/*不需要任何憑證。
匯出端點在那上面,等於一次請求就把整份生活紀錄交給同網段的任何人。
/api/events已經在一頁一頁地漏,那是它自己的待辦——
但**「門已經半開」不是在旁邊加裝卸貨區的理由。**
life.delete --all 更直接:無憑證的遠端抹除。等單機可信那一關過了再說。
provider 和 life 都是 CLI 多、HTTP 少。mesh 剛好相反:
| HTTP | /api/mesh、/api/mesh/activity、/api/mesh/run、/api/mesh/failover、/api/mesh/stress、/api/mesh/dev、/rpc/peers |
| CLI | cluster、nodes、peer、doctor --mesh、swarm —— 五個不相干的頂層名字 |
同一個能力,五個名字,兩邊都不知道對方存在。
宣告成一組之後,「同一件事」才看起來像一件事,而不是五個意外。
五個唯讀端點開,兩個不開:
mesh.ping 讓 daemon 對呼叫者指定的 URL 發出站請求。mesh.assign 派真的 agent 工作到別台機器,花的是我的模型預算。/api/mesh/run 已經是同樣的風險。但那是待辦,不是再加一個的許可。
mesh.status 第一版我這樣寫:
"node": crate::node_manifest::resolve_node("").map(|n| n.name),
resolve_node 是依名稱查找。傳空字串當然找不到。所以每一台機器都回:
{"caps":[],"known_nodes":1,"node":null,"peer_count":0}
它回 200。它通過了全部九條表測試。
因為 null 在 JSON 裡看起來就像「這個欄位沒有資料」,而不是「我壞了」。
我今天寫了一整篇在講這件事,然後在自己剛寫的 handler 裡做了一個。
修法不只是改那一行。我把選擇邏輯抽成 local_node_summary(nodes),
讓它可以被測,並且讓「找不到本機節點」回傳一個帶 error 欄位的物件,
而不是 null——因為那是故障,不是空欄位。
配兩條守衛,實測先紅後綠:把 find(|n| n.is_local) 改回錯的判斷,測試立刻失敗。
不是「表看起來對」,是端到端打真的:
16 條 canonical GET 路徑 → 全部 200
這條是 Day 03 學到的:那天 provider.health 的 is_served() 說有,
canonical 路徑實際回 404,因為只註冊了 legacy 的複數路徑。
表說有,不代表路由存在。
Q2 我改用 ask.sh review——agy 和 opencode 並行,兩份獨立的回答。
兩家都說:is_served 那個名單本身就是手動維護的旗標。
我的 http.rs 開頭寫著:
缺口由「有沒有 handler」決定,不由任何需要人手更新的欄位決定 ——
手動維護的旗標會像它要取代的那四個手寫表面一樣漂走。
然後我在同一個檔案裡寫了:
pub fn is_served(name: &str) -> bool {
matches!(name, "provider.list" | "provider.health" | ... ) // 手寫名單
}
pub fn register(router) -> Router {
router.route(&canonical_path("provider.list"), get(list)) // 另一份手寫名單
...
}
兩份清單,描述同一個事實,沒有任何東西讓它們保持一致。
我在消滅手動旗標的同時,造了一個。
改法是讓 routes() 成為唯一真相:register 走它,is_served 問它。
這個改動讓另一條測試自然失效了——every_served_name_gets_a_canonical_route
用 include_str! 扒自己的原始碼,找 canonical_path("名字") 的字面。register 改成 fold 之後那些字面消失了,測試紅了,而行為是正確的。
opencode 在複檢裡正好預測了這件事:「如果 register() 的呼叫改成迴圈或宏,這個測試就壞了。」
它壞得剛剛好。舊的主張現在由建構方式保證,所以我把守衛換成仍然會咬人的反向:
沒有宣告的路由不准存在、宣告了 HTTP 的命令不准沒有路由。
我擋掉了 mesh.ping 和 mesh.assign,理由是「對外動作」。
agy 說:那 mesh.nodes 和 mesh.peers 呢?
{"nodes":[{"hostname":"...","os":"macos","arch":"aarch64","version":"0.6.0","base_url":"..."}]}
在一個綁 0.0.0.0、不要憑證的 daemon 上,這是一張帶指紋的叢集地圖——
哪些 CVE 適用、下一扇門在哪。
我擋的是「動作」,開的是「偵察」。線畫錯了。
修法不是把整組關掉(app 真的需要顯示叢集),而是在 HTTP 投影層去掉指紋欄位:
身分與健康留下,OS/架構/版本/位址拿掉。完整細節留在沒有綁 0.0.0.0 的原生面。
去指紋要配一條守衛,不然下次有人覺得「儀表板顯示版本比較好看」就加回去了。
我寫的第一版:
for f in FINGERPRINT_FIELDS {
assert!(v.get(*f).is_none(), "`{f}` 洩漏到 LAN 了");
}
依母規則,我去證明它會紅——把 "version" 從 FINGERPRINT_FIELDS 拿掉。
測試通過了。
因為那條測試遍歷的就是 FINGERPRINT_FIELDS 本身。把欄位從清單拿掉,
檢查也一起消失了。檢查跟被檢查的是同一份清單。
改成白名單——「LAN 投影只准出現這些鍵」——再試一次:
`version` reached the LAN surface and is not on the allowlist
這次會紅了。
這是今天第三次。而這一次是母規則自己抓到的——如果我沒有堅持
「每條守衛都要示範它會紅」,那條空的測試會一直在那裡,
而且它看起來非常像在保護我。
重寫 register 的時候,我順手把 legacy 的複數路徑也註冊進來:
let r = r.route("/api/providers/health", get(health));
原本的註解寫著「Register the canonical path too」——意思是複數路徑
已經由 serve.rs 註冊了,這裡只補 canonical。
我讀反了。結果:
thread 'main' panicked at axum/routing/path_router.rs:70:
Overlapping method route. Handler for `GET /api/providers/health` already exists
daemon 開機即死,56 個建 router 的測試連帶倒。
修完之後回到 2673 passed / 2 failed,端到端 16 條路徑全部 200。
一個註解裡的「too」,代價是一次啟動崩潰。
修完自己的洞,順手問一個我一直沒問的問題:其他的呢?
沒有任何東西列得出「哪些端點有閘門」,因為那是每個 handler 各自的性質。
所以我寫了一支——用非 loopback 位址,逐條打真的 router:
=== /api auth inventory ===
routes probed : 30
gated (401/403) : 10
answered without credentials: 20
二十條。 而且不是無害的清單:
/api/todos /api/brief /api/sessions
/api/governance/log /api/cost
/api/activity /api/projects /api/rank
待辦、每日簡報、對話 session、治理稽核軌跡、花費。
同網段任何裝置可讀,不需要任何憑證。
理由寫在檔案裡:其中幾條(/api/version、/api/status)可能是節點探索與
監控的承重牆。直接上閘門可能弄壞艦隊,而這裡沒有任何測試會發現。
那是一個決定,不是順手。
所以我做今天安全且有用的那一半——棘輪:
/// 2026-08-27 量到的曝光。這份清單只准變短,不准變長。
/// 名字出現在這裡不是背書,是一筆有日期的債。
const KNOWN_UNGATED: &[&str] = &[ ... 20 條 ... ];
加入第 21 條會紅。而修好其中一條卻沒把它從清單刪掉,也會紅——
所以這個基準不會在債還完之後悄悄過期。
兩個方向我都實測過會紅。
我的結論是:「拿掉 plist 的 --host 0.0.0.0,那 20 條就碰不到了。」
codex 的回答:
作為立即 containment 合理;作為深度防禦,是自我安慰。
拿掉 launchd 的
--host 0.0.0.0只是在依賴另一層設定繼續保持正確。agents.toml寫127.0.0.1不足以證明:launchd 實際讀的是那份設定;
CLI default 永遠是 loopback;IPv6 沒有監聽::;沒有其他 listener、
proxy、tunnel 或設定 override;未來改 bind address 時會被阻止。
最後那句是重點。我修的是「現在」,不是「以後」。
它給的替代方案很直接:只要 KNOWN_UNGATED 非空,程序若嘗試 bind 非 loopback
就直接拒絕啟動。
我照做了。這把「依賴一份 plist 保持正確」換成「執行檔自己拒絕曝露這筆債」:
$ spectyn serve --host 0.0.0.0
spectyn serve: refusing to bind ["0.0.0.0"]: 20 /api routes still answer a
remote caller with no credentials, so binding off loopback would publish them.
Fix the routes, or set SPECTYN_ALLOW_UNGATED_LAN=1 to accept the exposure
deliberately.
那份債的清單因此從測試檔搬進了正式碼——它現在是程式看得見的事實,不是文件。
"1"。service unit 裡誤寫成 =0 或 =false,一律 fail closed。SPECTYN_ALLOW_EMPTY_CLUSTER_SECRET 姿態,不發明新慣例。我把檢查放在 bind_http_listener 旁邊。看起來很合理——就在綁定之前嘛。
實際跑起來:
WebSocket : ws://0.0.0.0:7923/ws ← 先印了
Health : http://0.0.0.0:7923/healthz
spectyn serve: refusing to bind ["0.0.0.0"] ← 才拒絕
而且更早的地方已經發出了 mDNS 廣播。節點先在區網宣告自己,然後才拒絕綁定——
廣告一個死端點,這棵樹在別的地方就警告過這個失效模式。
移到 --host 解析完的下一行,任何廣播與輸出之前。
我為了釘住「Observe 仍然可用」寫的那條:
for t in ["file_read", "grep", "list_dir"] {
if !is_read_tool(t) { continue; } // 「不是 read tool 就跳過」
assert!(...);
}
那個 continue 我寫的時候覺得是防禦性的——萬一某個名字在這個 build
裡不是 read tool,跳過就好,不要因為猜錯名字而誤報。
複檢說:三個名稱若全都不是真的工具名,這個迴圈零斷言通過。
它是對的。而且更難堪的是——這正是同一個檔案裡的守衛要防的形狀。
改成用真的 READ_TOOLS 清單驅動,並斷言清單非空。
deny 那半也先斷言那些名字確實不是 read tool,
否則「被擋」只是「不認得」。
今天第四次「我為了讓檢查更穩健而加的東西,讓它變成空的」。
前三次:附加在#[cfg(test)]之後、去指紋守衛遍歷自己的清單、
紅示範沒進到執行檔。規律很清楚:防禦性的
continue/skip/unwrap_or(true),
是空綠最常見的入口。
出口條件有兩半:拿到第一個回應(做完了),和裝得起來。
README 的第一個指令:
$ ./install.sh
zsh: permission denied: ./install.sh
那個檔是 mode 0644。新使用者的第一個動作,是一個 permission error。
執行位不花任何成本,git 也追蹤它。擋住這件事的只有「有沒有人注意到」。
倉庫裡有 96 個有 shebang 卻沒有執行位的 .sh。
但多數是別的腳本用 bash foo.sh 叫的,mode 完全不影響。
如果我全部要求執行位,那條檢查會每天對 90 幾個無害的檔案叫,
然後被靜音——跟不存在一樣。
所以守衛只管一件事:文件叫使用者用 ./ 跑的腳本,必須跑得動。
掃 README 和 docs/ 之後,真正壞掉的是 4 個:
install.sh · build-linux.sh · setup-oci.sh · tests/e2e/run_tier1.sh
四個。不是九十六個。
一條會對正常狀況叫的檢查,跟一條不會叫的檢查,最後效果一樣。
./x.sh 引用 → 紅。那代表掃描器壞了,不是倉庫乾淨。
G2(本地 LLM,release 版):全新根零設定,Reply with exactly: 三連發
3/3 正確、零寫入、7 秒級。S0→S2 的鏈在正式安裝的執行檔上成立。
G5(app 端)最曲折。重寫 app 的 Playwright e2e 對準現行系統,
一路挖出三個「只有真的去看畫面才會發現」的缺陷:
e2e 的 daemon 探測打 /health,daemon 答的是 /healthz——
整組 daemon 測試在每台機器上永遠 skip,組內斷言還是上古方言
(/tools、/task、Bearer)。探測一修,化石當場現形。
今天第四個「探測壞掉→整組從沒被測」。
dashboard 的生活紀錄卡寫著 Unknown command: life_stats——
web 模式的 tauri-compat 不認識這個指令,而 daemon 明明今天早上
就有了 /api/life/stats。補 HTTP fallback(鍵名 camelCase↔snake 映射),
並把「那張卡不得再出現這個錯誤」寫成斷言。
同一個請求,CORS 說可以、gate 說不行——開了SPECTYN_CORS_ALLOW_LOCALHOST 後,CORS 允許 5173,auth gate 卻因
跨源 Origin 剝奪 loopback 豁免回 403,卡上只剩 Failed to fetch。
一個開關必須是一個政策:gate 在同一 env 下放行同一組 dev origins,
預設行為逐 byte 不變。
修完:app e2e 15/15 全綠——provider 清單(含「不得回傳 key 材料」)、
life stats 真資料上卡、mesh status、chat 一輪(SSE)、dashboard 可達
(自檢的 proceed 按鈕文案隨結果變:「跳過,強制」vs「繼續」,測試認兩種)。
# Daily review — 2026-08-27
**Events captured:** 1
### s0 (1)
- S0 驗收:封存舊資料根、修好空轉哨兵、重建事件儲存區
21:00 準時、引用真實事件、沒有 SUPPLY SEVERED、沒有 tool-call JSON。
S0 那條「隔天早上的回顧是根據真實事件寫的」——今晚提前驗收了。
昨天這個時間,同一個排程寫的是 events=0,而且沒有人知道。
指令變成「先 push,接著一個一個弄完」。push 上去 84 個 commit,然後逐項:
修好日常設定的 gemini。 一行:gemini-2.0-flash → gemini-2.5-flash
(備份先行)。修後 404 消失、換成 429 —— 名字對了,免費額度今天用完,
明天自動恢復。連 401 都沒有,key 一直是有效的——壞的只是一個
沒有人告訴你已經退役的 model 名。
exec 也要警告 silent-off。 驗證途中,一發走日常鏈的 probe 讓本機模型
真的把檔案寫進了工作目錄(enforcement=off 是你裁定保留的現況)。政策不動;
但那句「沒有人選過這個」的警告先前只在互動啟動印——腳本裡的 exec
用同一雙手寫檔,卻一聲不吭。移出互動判斷,現在都印。
然後是 curl|sh 的大翻案。 兩處文件寫著它是「FUTURE item,未接線」,
runbook 卻把它列為主要安裝路徑,指向 spectynmesh.io。實測:
.io 根本無法解析——沒有 DNS.com 上,/install.sh 回 200,382 行完整安裝腳本,Worker 路由一直都在管線從頭到尾是活的,活在另一個域名上。 「未接線」的宣稱、指錯的域名、
和一個從沒被打過的 URL,三份文件各說各話,而真相是第四種。
沙盒 HOME 實測一鍵安裝:腳本跑、平台偵測對、下載卡在 503——
R2 桶裡一顆 binary 都沒有(五個平台全缺,兩支安裝腳本倒是都活著)。
差的最後一步是 Cloudflare 憑證,只在操作者手上。上傳指令已備好在
PUBLISHING-BINARIES,登入即發。
沒有做的: 120+ 個文件把 .io 當正式域名。這不是 typo 是決策,
不盲掃(改名債的教訓第三次派上用場)——只修了照著跑會卡住的四處。
G4 還沒跑,操作者先出了另一道更真實的題:「直接運作安裝,然後我找一個
免費的 LLM API 直接串起來。」這正是 G1-G5 整條鏈的實戰首演——
而且劇本不是我寫的。
安裝:真機跑 ./install.sh,binary 進 ~/.local/bin,原設定完好。
15 秒後零設定 exec 經 ollama 正常回答——路 A 無話可說。
拿 key:我開了 Cerebras 和 OpenRouter 的註冊頁,操作者自己登入、
自己建 key(帳密永遠不經過我),存成桌面一個 key.txt。我從檔案把 key
餵進 set-key --key-stdin——G1 做的那張臉第一次接住真實使用者:
key 全程不落 argv、不回顯,我只看得到 csk-…(52字元)。
然後免費層給我們上了一課。
/models 顯示兩個模型,聊天端點卻對兩個都回auth/key 200),但註冊表裡的llama-3.3-70b-instruct:free 上游已退役(404)。拉現役清單::free 模型,跟兩個月前幾乎換了一輪。掃描:拿真 key 逐一打現役 :free 清單,第一發就中——nvidia/nemotron-3-ultra-550b-a55b:free,550B 的推理模型,精確回了WIRED-OK。寫進設定,重排 failover:openrouter → local-ollama → gemini → groq。
三發驗收,三種結局,全部正確:
exec:openrouter 直接服務,模型還主動 web_search 查了修掉的:註冊表兩處過期宣稱(cerebras 402 註記、openrouter 換
現役模型 + 「:free 會流動,default 是種子不是承諾」)。8 測試綠,
重建部署三處。
一句話總結今晚:接線的每一步都活著,會死的是免費層的承諾。
所以 set-key 的臉、failover 的鏈、和誠實的錯誤訊息,才是真正的產品。
串好雲端 API 後,操作者一聲令下開查。6 發 SSE + 2 發 CLI:全部有答案,
但第 1 發答了一坨 JSON——空流 fallback 掉到 8B 本地模型的傑作。
統計:今天累計 4 次「Empty stream result」,全在 daemon 路徑,CLI 零次。
第一個假設:推理模型把 max_tokens 燒完在思考上。查證:水位 8192,否決。
第二輪:直接抓現行犯——連打串流統計 content/reasoning 字元,第 5 發逮到:
HTTP 200
: OPENROUTER PROCESSING
data: {"choices":[], "error":{"code":502,
"message":"Upstream error from Nvidia: Service temporarily overloaded",
"metadata":{"error_type":"provider_unavailable"}}}
HTTP 說 200,流裡卻是一具 502。 choices 是空陣列,所以 delta 解析
一個分支都不會進;錯誤物件被無聲丟棄;然後判空分支宣稱
「model returned blank stream」。上游明明把死因寫在流裡,我們沒讀,
還替它編了一個。這是 day04 第三次撞見同一型失敗:訊號說謊。
第二宗罪:一次瞬時打嗝就把 550B 的健康供應商整個放棄,掉到下一家
(往往更弱)。實測 9 發裡 4 次空流、下一發即恢復——這是共享池抖動,
不是供應商死亡。
母規則,紅測先行,兩發:
in_stream_error_message_is_propagated — mock 吐實錄的 error frame,empty_errored_stream_retries_same_provider_once — 第一發 error frame、手術四處(全在 agent.rs 串流迴圈):迭代器改 worklist(VecDeque,
可把同家推回隊頭)、stream_error 累積器、frame 解析補讀 error 物件、
判空分支改為「引上游原話 + 同家重試一次再 failover」。
紅轉綠,回歸 lib 2688 + 34 整合全綠,部署重驗 6/6 直服。
加映的反轉:追殺過程發現串流消費迴圈有兩套並行實作
(agent.rs 與 streaming.rs)。day03 才寫「一個核心多個 renderer」,
自家後院就趴著一對分叉——而且 streaming.rs 那套本來就有
error-frame 偵測,daemon 卻走沒有的那套。病人已治;
兩套收斂為一套,列入長期債。
教訓濃縮:200 不是平安,流裡的 error 物件才是死因;
判空之前,先問流有沒有話要說。
(以上為精選;完整版見 repo。)