系列背景:私有自持的 AI agent 網格 spectyn-mesh。
本篇是 Day 06 的後半段,接續上一篇
〈Day 06(上):把單機版裝進 .app —— 九關生命週期,和一個騙人的 exit 0〉。上半場在修 macOS 的殼:打包、簽章、原生視窗 E2E、本地引擎、PTY 測試。
下半場換了尺度 —— 操作者要的是:「讓這個版本的 spectyn CLI 真正內嵌進 app 運作,並且 app 的
spectyn / mesh / setting 三個介面的全生命週期都能順暢運作。」母規則同前:任何檢查必須先示範它會紅才算數。綠燈不是證據。

一句話的目標不能執行。這半天的做法是:先做一次生命週期分析,把同一份簡報餵給
三支本機 AI CLI 各自裁決,再把結論收斂成一條有出口條件的階段佇列——然後
先釘兩支釘子,證明最貴的兩個假設到底成不成立,才開始蓋。

先跑了一次 14 階段的生命週期分析(70 個 agent、56 條缺口經對抗式挑戰,
收斂成 46 條確立、19 個 blocker),然後把同一份簡報分別餵給
codex、opencode、agy,要它們各自給出順序、分工與最大風險。
三家的「最大風險」各說一個,而且彼此不重疊。 這比它們講同一件事有價值得多——
如果三家都指同一處,我只是拿到一個我早就知道的答案。
三家一致的三件事信號最強:設定探索的統一要早做且必須單一負責人;
R2/安裝器不是第一優先(agy 說得最直白:「先修引擎,再修送貨的卡車」);
分工是 codex 逐檔機械改、opencode 跨檔綜合、agy 對抗式稽核。
最後我採 opencode 的順序(先停止說謊)、codex 的產物(生命週期契約,
但以一支會紅的 harness 存在,不是一份 markdown)、agy 的測試網路圍籬。

S1(權限批准往返)成立,而且問題比報告更根本。
不是缺一個對話框。批准只有一種實作,它從 stdin 讀、往 stderr 印。
而 TUI 用 raw mode 佔著 stdin 並且自己畫整個畫面——這個機制在那個介面上
不可能運作,不是尚未實作。 app 的 in-process runtime 更乾脆:grep tool_gate
整個目錄是空的。
操作者裁定 D1 = 兩者都要(TUI 內建 modal + daemon 批准佇列,手機也能批)。
兩者其實是同一條 seam 的不同實作,所以先花最少的力氣證明 seam 本身成立。
五條斷言裡最重要的是最後一條:未安裝通道時仍 fail-closed,與 seam 之前
一位元不差——否則這支釘子等於在每個 daemon 上悄悄開了一個洞。
S2(同根寫入競爭)不成立,而且我先前說錯了。
48 個獨立行程同時對同一個 spectyn.db 做真實寫入,全部成功。我原本的判斷是
「沒設任何 pragma,所以 SQLite 預設 busy_timeout = 0,第二個寫入者會立刻拿到database is locked」。那是錯的——根因在驅動層,rusqlite 每次開連線都給
5 秒 timeout。
我第一版的姿態測試斷言「必須有 busy_timeout」,理由就是那個錯的前提。
一條理由錯誤的測試,比沒有測試更糟,因為下一個讀到它的人會相信那個理由。
改成只斷言真正還缺的 journal_mode=WAL,並從磁碟實測而不是只看原始碼有那行。

第一階段不新增功能,只做一件事:讓工具停止說不真的話。
打錯一個字的後果被量了出來。spectyn priority show 不是拒絕,是一個回答——
只是回答了另一個問題,而且帶著完整的 provider 呼叫鏈(會花錢)。spectyn tell me a joke 四個字裡三個被靜默丟掉。spectyn --verison 在乾淨機器上
會進 TUI,而 TUI 的首次執行路徑會鑄裝置身分、啟動一個在區網廣播的 daemon——
由一個打錯的字觸發。
值得注意的是:單一未知字本來就有很好的守衛(拒絕、rc=2、還告訴你明確形式),
只是那道 backstop 寫著 args.len() == 2,多一個 token 就繞過。
這不是「沒人想到」,是守衛的範圍寫窄了。
測試的網路圍籬本來只是習慣。量到的現況:8 個測試檔會啟動 daemon,而mdns_advertise 完全沒有拒絕的方式;spawn 結果只檢查錯誤,成功時Child 直接被丟掉,advertiser 就此脫離。本機 ps 找到三天前的 dns-sd
仍在廣播。
--config PATH 是指令,不是提示
實測分佈很乾淨:serve --config 一直都是對的,exec 與裸 spectyn 靜默忽略。.ok() 丟掉讀取錯誤,.or_else 再靜默換成另一個檔——操作者指著自己剛寫好的設定,
拿到的卻是一個提到他從未設定過的環境變數的 provider 錯誤,而且沒有任何線索
指向「你指名的那個檔案根本沒被打開」。
測試的 fixture 有個刻意的設計:家目錄自己放一份完全正常的 agents.toml。
如果 --config 被忽略,那次執行會靠這個檔成功並看起來一切正常——缺陷就是
這樣一直沒被看見的。
接著是設定探索。五套實作,先讀完再動手,結果第一個結論是不該全部合併:load_home_only 刻意只讀 HOME tier,因為安全策略若從 cwd 讀,一個惡意專案
自己的 agents.toml 就能關掉用來管住它自己的保護。那是邊界,不是漂移,
所以不併,並新增一條測試守著它不被併進來。
真正的分歧在另外三套(24 個呼叫點),全都漏掉 cwd/SPECTYN.toml 與 XDG。
後果不是「少支援一種檔名」,是同一台機器上兩個答案:doctor 說設定沒問題,
而 exec/repl/TUI 拿內建預設在跑。
三條缺陷,都在新手的同一個五分鐘裡:
spectyn keys set 打不到。 身分金鑰的 handler 佔著 keys 這個字,
認 init|show|path|reset|add|remove|list|test——就是沒有 set。而這正是
專案自己的黃金路徑 runbook 教操作者打的指令,真正的實作一直在同一個
binary 裡,只是掛在另一個名詞下面。修法是路由而不是重寫:一條金鑰匯入
路徑,就只有一個地方拒絕把金鑰放上 argv。
免金鑰的 provider 被標成缺金鑰。 訂閱制 CLI 帶著自己的 token,本地引擎
根本不需要金鑰,三者全都顯示「⚠ no key configured」。設定完全正確的操作者,
看到的是每個 provider 一個警告,然後去找一個不存在的問題。
set-key 產出沒有 default_model 的 provider。 runtime 會靜默跳過
一個解析不出模型的 provider,而列表顯示金鑰是 ✓。第一個徵兆是對話進行到
一半的「all providers failed」。
而我在寫第三條的修復訊息時,差點教使用者一個叫 spectyn provider set-model
的指令。它不存在。 那正是 Phase 0 剛從 doctor 移除的那類缺陷,被我用來修
另一個缺陷時重新引入。

設一個 confirm-first 的 profile,然後看同一個 Ask 在三個地方分別發生什麼。
三個介面,三種意思,而且錯的那兩個往相反方向錯:旗艦終端什麼都不准,
app 則完全不理會你設的 profile。
第二個更難看:那句「請改用互動式的 spectyn repl」是說給一個正坐在互動式
終端前面的人聽的。
障礙是真的。解法是一條佇列:閘停在上面 block,render loop 取走給人看,
答案回來續行。block 是刻意的——回「暫時拒絕」讓模型重試,會把一個問題
變成操作者贏不了的迴圈。三處 fail closed:逾時是拒絕、關掉介面把還停著的
全部釋放、佇列爆量是拒絕而不是堆積。
最重要的一條測試是:呼叫已逾時被拒絕之後才按 y,畫面必須說「太遲了」。
在「軟體說謊」這個主題裡,那會是最有說服力的一次——使用者親手按了核准,
畫面回他核准了,而那個工具從來沒跑。
同一階段還有兩條同形狀的:SPECTYN_PERM=diff 承諾「核准前先看到改動」,
而它只對一個工具兌現,其他三種編輯形狀全掉進只有名字的提示——操作者選的
模式說他在批准一份 diff,他實際在批准一個工具名稱;以及 /perm 在 REPL
認六個詞、在 TUI 認三個,/plan 在 TUI 印出「agents wait for 'go'」而
TUI 裡沒有任何東西實作那個 go。

這是今天最該記的一張。母規則抓到七次「空綠」——測試通過,但它證明的不是
它宣稱的東西。七次全部由紅證抓到,零次由綠燈抓到。
最後三次尤其值得寫,因為它們是同一個形狀的三種變體:
"$@",參數contains("closed"),還是綠——因為(fail-closed) 結尾。修法不只是補 "$@",而是讓變異失敗變成致命:因為它失敗的樣子,跟
「這條檢查沒用」長得一模一樣。母規則的規則本身,也需要一條母規則。
以下是當天實錄的後半段,原文按時序未改。
操作者要的東西換了尺度:「讓這個版本的 spectyn CLI 真正內嵌進 app 運作,並且 app 的
spectyn / mesh / setting 三個介面的全生命週期都能順暢運作。」
先做了一次 14 階段的生命週期分析(70 個 agent,56 條缺口經對抗式挑戰,46 條確立、
19 個 blocker),然後把同一份簡報分別餵給 codex、opencode、agy 三支本機 CLI,
要它們各自給出順序、分工與最大風險。
三家的「最大風險」各說一個,而且彼此不重疊 —— 這比它們講同一件事有價值得多:
| 最大風險 | 建議的最便宜驗證 | |
|---|---|---|
| codex | split-brain:安裝器、parser、runtime 各用一套真相 | 對 temp $HOME 跑黑箱生命週期矩陣 |
| opencode | 權限批准往返:Ask 在內嵌終端被批准後,同一呼叫能否續行 | 只證明最薄的一次 Ask→approve→resume |
| agy | 同根競爭:app 的 in-process runtime 與 out-of-process CLI | app + 並行 CLI 打同一資料根,看 DB 會不會鎖死 |
三家一致的三件事,信號最強:設定探索的統一要早做且必須單一負責人;
R2/安裝器不是第一優先(agy 說得最直白:「先修引擎,再修送貨的卡車」);
分工是 codex 逐檔機械改、opencode 跨檔綜合、agy 對抗式稽核。
我採 opencode 的順序(先停止說謊)、codex 的產物(生命週期契約,但以一支會紅的
harness 存在,不是一份 markdown)、agy 的測試網路圍籬。
不是缺一個對話框。批准只有一種實作:
// tool_gate.rs:176
fn prompt_tool_approval(name: &str, args: &serde_json::Value) -> Result<(), String> {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() { ... }
它從 stdin 讀、往 stderr 印。而全庫唯一安裝「會問人」那版閘的地方是bin/spectyn.rs:10581,註解自稱 "The REPL is interactive"。TUI 沒有自己安裝,
沿用 main.rs:24 的 install_with(false, …);app 的 in-process runtime
連閘都沒裝(grep tool_gate app/src-tauri/src/ 為空)。
TUI 擁有 raw mode 的 stdin 並用 ratatui 作畫 —— 一個從 stdin 讀、往 stderr 印的
提問無法與它共存。機制在那個介面上不可能運作,不是尚未實作。
操作者裁定 D1 = (c) 兩者都要(TUI 內建 modal + daemon 批准佇列,手機也能批)。
兩者其實是同一條 seam 的不同實作,所以先證明 seam 本身:
✓ Ask 抵達已安裝的通道,而非被直接拒絕
✓ 呼叫執行緒確實阻塞(等待 ≥250ms)—— 呼叫是被懸置,不是中止重試
✓ 由另一個執行緒送出的批准,放行了同一個 in-flight 呼叫
✓ 拒絕帶回拒絕者自己的理由,不被通用訊息取代
✓ 未安裝通道時仍 fail-closed,與 seam 之前一位元不差
最後一條是重點:否則這支釘子等於在每個 daemon 上悄悄開了一個洞。
48 個獨立行程同時對同一個 spectyn.db 做真實寫入,全部成功。
我原本的判斷是「store.rs 沒有設任何 pragma,所以 SQLite 預設 busy_timeout = 0,
第二個寫入者會立刻拿到 database is locked」。那是錯的,根因在驅動層:
rusqlite-0.31.0/src/inner_connection.rs:121
let r = ffi::sqlite3_busy_timeout(db, 5000);
rusqlite 每次開連線都給 5 秒 timeout,所以第二個寫入者會等。
我第一版的姿態測試斷言「必須有 busy_timeout」,理由就是那個錯的前提。
一條理由錯誤的測試比沒有測試更糟,因為下一個讀到它的人會相信那個理由 ——
改成只斷言真正還缺的 journal_mode=WAL(rollback journal 下讀者擋寫者;有 5s
timeout 時那是延遲而非錯誤,直到某次寫入超過 5 秒)。磁碟實測journal_mode = wal、busy_timeout = 5000,不是只有原始碼裡有那行。
$ spectyn priority show → ◆ show + 完整的 provider 呼叫鏈
$ spectyn tell me a joke → ◆ joke
$ spectyn --verison → spectyn v0.6.0 — AI agent mesh …(TUI)
第二條最值得講:它不是拒絕,是一個回答 —— 只是回答了另一個問題。argv 迴圈是one_shot_prompt = Some(arg),會被後面的 bareword 覆蓋,所以四個字裡三個被靜默
丟掉。也就是說「隱式提示必須加引號」這條新規則不損失任何原本可用的行為。
第三條在乾淨機器上不是外觀問題:裸 spectyn 會進 TUI,而 TUI 的首次執行路徑會鑄
裝置身分、經由精靈啟動一個在區網廣播的 daemon —— 由一個打錯的字觸發。
順帶一提,單一未知字本來就有很好的守衛(spectyn frobnicate 拒絕、rc=2、
還告訴你明確形式),只是那道 backstop 寫著 args.len() == 2,多一個 token 就繞過。
這不是「沒人想到」,是守衛的範圍寫窄了。
量到的現況:8 個測試檔會啟動 daemon,而 mdns_advertise 完全沒有拒絕的方式。
更糟的是那個 spawn 結果只被檢查錯誤:
if let Err(e) = result { tracing::debug!("mDNS advertise: {}", e); }
成功時 Child 直接被丟掉 —— advertiser 就此脫離,任何東西都停不掉它。
本機 ps 找到三天前(8/26、8/27)的 dns-sd -R spectyn-spectyn 仍在廣播。
而 cli_macos.rs 連一個 .env( 都沒有:它在操作者的真實 ~/.spectyn-mesh
上跑真 daemon。
新增 SPECTYN_NO_MDNS=1 開關(這也是給想要本機 daemon 但不想被公告的人的誠實選項,serve 原本沒有)、stop_mdns_advertising() 回收器,八個檔全部補上。
圍籬的紅測第一次做時沒有紅。我斷言的是「SPECTYN_NO_MDNS 這個字串出現在.spawn() 之前」,而區塊裡的 log 訊息也含有它,所以把條件換成 if false
測試照樣綠。改成斷言 env::var("SPECTYN_NO_MDNS") 這個讀取本身才真的會紅。
今天被母規則抓到的四次:sed 改失敗而測試照樣綠、重複的 panic hook、
只查「附近有 is_terminal」的斷言、以及這一次。四次都不是靠讀程式碼想出來的。
--config PATH 是指令,不是提示實測分佈很乾淨:
| 路徑不存在 | TOML 壞掉 | |
|---|---|---|
serve --config |
✗ 正確失敗、訊息精確 | ✗ 正確失敗 |
exec/裸 spectyn --config |
靜默忽略 → ◆ hi 送去模型 |
靜默忽略 |
serve 一直都是對的。另外兩處寫的是同一行:
Some(path) => std::fs::read_to_string(path).ok().or_else(find_config),
.ok() 丟掉讀取錯誤,.or_else 再靜默換成另一個檔。操作者指著自己剛寫好的設定,
拿到的卻是一個提到他從未設定過的環境變數的 provider 錯誤,而且沒有任何線索指向
「你指名的那個檔案根本沒被打開」。
能讀但不能解析的更糟:落到 load_config_toml,而它忽略看不懂的東西,
一個 typo 就讓明確指名的設定靜默變成內建預設。
測試的 fixture 有個刻意的設計:家目錄自己放一份完全正常的 agents.toml。
如果 --config 被忽略,那次執行會靠這個檔成功並看起來一切正常 —— 缺陷就是這樣
一直沒被看見。另有一條反向測試(正常的 --config 必須照用),否則「一律拒絕」
也能讓前兩條通過,同時把這個旗標整個廢掉。
五套設定探索。先讀完再動手,結果第一個結論是不該全部合併。
| 實作 | 走的層級 |
|---|---|
candidate_config_paths |
cwd/agents.toml · cwd/SPECTYN.toml · 資料根 · XDG |
load_home_only |
資料根 · XDG |
tui::find_config_simple |
"agents.toml"(相對路徑!) · 資料根 |
bin::find_config_path |
cwd/agents.toml · 資料根 |
bin::find_config |
cwd/agents.toml · 資料根 |
load_home_only 刻意只讀 HOME tier:安全策略(信任執行、權限上限)若從 cwd 讀,
一個惡意專案自己的 agents.toml 就能關掉用來管住它自己的保護。那是邊界,不是漂移,
所以不併,並新增一條測試守著它不被併進來。
真正的分歧在另外三套 —— 24 個呼叫點 —— 全都漏掉 cwd/SPECTYN.toml 與 XDG。
後果不是「少支援一種檔名」,是同一台機器上兩個答案:放了 SPECTYN.toml 的人,
doctor 說它是 live 的,而 exec/repl/TUI 拿內建預設在跑。你會看到 doctor 說
設定沒問題,而 provider 一個都沒載入。TUI 那套還多一個問題:它用的是相對路徑,
會隨 workspace pin 改變 cwd 之後解析到別的地方。
第六條路徑(agents_toml_path(),22 處)刻意不動:那些多是寫入端,金鑰不該被寫進
專案 checked-in 的設定。但 provider list 讀的也是它,而 runtime 是 cwd 優先。
正解不是讓 list 改讀 cwd(那會把分歧搬到寫入端),是讓它把分歧講出來:
Providers configured in <home>/agents.toml
⚠ the runtime reads <proj>/SPECTYN.toml first — the providers below are
from the home config and are NOT what a run in this directory will use.
沒有分歧時不出聲 —— 一個總是出現的警告等於沒有警告,測試也釘住這一點。
codex 要的是「一份強迫每一層停止自己發明真相來源的產物」,opencode 要的是
「把 install→daily-use 串起來的 harness」。一份 markdown 契約不會紅,這個會。
✓ stage1 裸機非互動執行不自作主張,而且會說出自己跳過了
✓ stage2 精靈先揭露(設定檔/身分/daemon)再問,答 n 不留痕
✓ stage3 --config 被遵守,或停下並說明
✓ stage4 doctor 指名的每個修法都是真指令 —— 逐一實跑驗證
✓ stage5 整趟不出沙箱,真實資料根不增加任何檔案
Stage 2 是今天才變得可測的。 同意閘之前,要走到精靈的第一個提問,就代表它已經
鑄了 ed25519 身分並 spawn 了一個會在區網廣播的 daemon —— 測試無法在不對測試機做
這兩件事的前提下驅動它。
過程中兩次是我的測試錯,不是產品錯:
nothing was created — no identity, no daemon, no config,然後 REPL 照樣起來走events.jsonl(每個行程啟動都會附加的安裝那一關拆成 selftest feature(從原始碼建置 2m20s,不該進每次都跑的套件),6/6 綠 ——
包括「PATH 那行有印出來」,因為宣稱接好了 PATH 卻沒接,是 Phase 0 那些謊的安裝時版本。
$ spectyn keys set groq XXXX
✗ unknown subcommand: spectyn keys set
這是專案自己的黃金路徑 runbook 教的指令。一個身分金鑰的 handler 佔著 keys
這個字,它認 init|show|path|reset|add|remove|list|test —— 就是沒有 set。而真正的
實作 providers_set_key 一直在同一個 binary 裡,只是掛在另一個名詞下面。
改成路由而不是重寫:一條金鑰匯入路徑,就只有一個地方拒絕 --key 上 argv、
一個地方用 toml_edit 編輯而不是重新產生設定、一套測試同時守著兩種拼法。
測試也直接斷言兩種拼法產出的設定位元相同 —— 兩條路徑正是其中一條會忘記 argv 規則的方式。
第二條:
claude_cli (claude_cli) key: ⚠ no key configured
local-ollama (ollama) key: ⚠ no key configured
兩個完全正常的 provider。訂閱制 CLI 帶著自己的 token,本地引擎根本不需要金鑰。
設定完全正確的操作者,看到的是每個 provider 一個警告,然後去找一個不存在的問題。doctor 早就分類正確了 —— 是這份列表有它自己那套較舊的想法。改用同一個is_local_keyless() 判定,而不是再抄一份清單:今天早上才有三份手抄清單同時漏掉llamacpp。
第三條最安靜:set-key 只寫 type 和金鑰參照,所以產出的 block 沒有default_model。runtime 會靜默跳過一個解析不出模型的 provider,而列表顯示
金鑰是 ✓。第一個徵兆是對話進行到一半的「all providers failed」。
修法刻意不猜模型 id —— 猜正是今天早上「把 Anthropic 模型寫給 localhost 伺服器」
的來源。改成把缺口和關閉它的指令講出來。
而我在寫那段訊息時,差點教使用者一個叫 spectyn provider set-model 的指令。
它不存在。 那正是 Phase 0 剛從 doctor 移除的那類缺陷,被我用來修另一個缺陷時
重新引入。改成只指向真的存在的路徑。
順帶抓到:provider 的 usage 字串停在 list | add | models,而 run_providers
早就也接 test/priority/set-key。照那行去打的人,找不到今天才加的provider test,也找不到 doctor 修法提示指名的 set-key。
計畫寫著這一階段禁止並行委派。理由不是保守:權限系統是那種「兩個 agent 各自
做對一半、合起來是個洞」的東西。所以這一段全部一條一條手做。
先量。設一個 confirm-first 的 profile —— ask = ["file_write", "shell"] ——
然後看同一個 Ask 在三個地方分別發生什麼:
| 介面 | 一個 Ask 的實際結果 |
|---|---|
spectyn repl |
在 stdin 上問 y / n / a / A |
spectyn(TUI,也就是預設) |
拒絕,並建議你「use an interactive spectyn repl」 |
| app 的 in-process runtime | 根本沒裝閘 —— 全部放行 |
三個介面,三種意思,而且錯的那兩個往相反方向錯。旗艦終端什麼都不准,
app 則完全不理會你設的 profile。
第二個更難看:那句拒絕訊息是說給一個正坐在互動式終端前面的人聽的。ask profile 的全部意義就是「agent 提議、你確認」,而在最多人用的那個介面上,
它把每一件事都拒絕了,然後叫你去找一個互動式終端。
程式碼裡的那行註解自己就把答案寫出來了:
// for every non-interactive surface — exec, the default TUI, serve, cluster
spectyn_mesh::tool_gate::install_with(false, pure_help);
「the default TUI」被歸類成 non-interactive,跟 serve 和 exec 並列。
障礙是真的:TUI 用 raw mode 佔著 stdin 並且自己畫整個畫面,一個讀 stdin、
寫 stderr 的閘會跟它搶兩樣東西。daemon 則兩樣都沒有。但兩者都有一條
通往人的路,只是那條路不是 file descriptor。
昨晚那支釘子(S1)已經在 tool_gate 上開了 Approver 這個縫。今天把縫接上:
1. 閘在工具執行緒上呼叫已安裝的 approver
2. approver 把一筆請求停在佇列上,然後 **block**
3. 某個介面 —— 今天是 TUI 的 render loop —— 取走、給人看、回答
4. 被擋住的那個呼叫帶著答案續行
block 是刻意的。 另一個選擇是回「暫時拒絕」讓模型重試 —— 那會把一個問題
變成一個操作者贏不了的迴圈。#[tokio::main] 是多執行緒的,回答的那個介面跑在
另一條執行緒上,所以停一下的代價是一條 worker thread。
三個地方都fail closed:
最後那條值得說一下。一個跑掉的迴圈可以在一秒內丟出幾百個 Ask。如果照單全收,
modal 就變成對操作者自己 session 的阻斷服務攻擊 —— 他得一個一個關掉才能繼續。
所以超過 32 筆就直接拒絕,並把理由講出來。
y / n / a / A —— 跟 REPL 的提示一模一樣。這不是美學問題:同一個按鍵在
兩個介面上意思不同,正是操作者「本來只想批准一次,結果永遠允許了」的方式。
Esc 和 Enter 都拒絕。在一個你沒預期的提示上反射性地敲 Enter,不可以就這樣
放行一個破壞性工具 —— 這也是這個 codebase 其他地方 [y/N] 的一貫慣例。
還有一條測試,是我覺得整個 Phase 3 最重要的一條:
an_answer_that_arrives_too_late_says_so
如果一個呼叫已經逾時被拒絕,而操作者這時才按下 y,畫面不可以顯示
「approved」。它得說「太遲了,這個呼叫已經被拒絕」。
在整個階段的主題(軟體說了不真的話)裡,這會是最有說服力的一次說謊 ——
使用者親手按了核准,畫面回他核准了,而實際上那個工具從來沒有跑。
SPECTYN_PERM=diff 這個模式的承諾是:核准之前先看到改動。它兌現這個承諾的
範圍是 —— 一個工具。render_write_diff 只認得 path + content,也就是file_write 的形狀。
而 agent 真正用來改既有檔案的工具是 file_edit(old_string/new_string),
批次的是 multi_file_edit(edits[])和 apply_patch(patch)。這三個
全部掉進「只有名字」的提示,參數還在第 200 個字元被截斷:
⚠ run file_edit({"path":"src/lib.rs","old_string":"pub fn install(inter…
操作者選的模式說他在批准一份 diff,他實際在批准一個工具名稱。
四種形狀現在都出圖。file_edit 顯示的是檔案套用後會長的樣子,不是原始的
替換字串對。而且它必須對一個套不上去的編輯誠實:如果 old_string 出現
零次,或在沒有 replace_all 的情況下出現多次,那個工具會失敗 —— 這時給一份
自信的 diff,跟完全不給 diff 是同一類缺陷,只是換個比較小的地方。
apply_patch 不套用就直接顯示它拿到的那份 unified diff:要套用才知道它做什麼,
就等於做了那件正在被批准的事。
/perm 在 REPL 認得 ask|diff|allow|deny|reset|list,在 TUI 認得ask|allow|deny。所以 /perm diff —— 剛剛才教會出四種 diff 的那個模式 ——
在最多人跑的介面上回答「unknown: diff」。而 /perm list(唯一能看到你已經
永遠允許了什麼的方法)在那裡根本不存在。
專案裡已經有一份 cli_slash.rs,開宗明義寫著「one table, many renderers」,
還有 ratchet 測試守著。/perm 在那張表裡標的是 Both。表統一了「有什麼、
在哪裡」,沒有統一「它做什麼」—— 這正是今天要補的那道縫。
/plan 同一個病、更嚴重一級:TUI 的 /plan 印出
plan mode ON — agents will preview their plan, then wait for 'go' before any tool call.
而 TUI 裡沒有任何東西實作那個 go。REPL 有一份 approval_words 寫在
函式裡;TUI 只有那個開關。所以在 TUI 裡打 go,它會被當成一句普通的話送給模型,
閘照樣拒絕所有工具,agent 再解釋一次它在等你批准。而且模型的 context 裡從來
沒有那段「先出計畫然後停下」的說明 —— 那段字串也只寫在 REPL 那一邊。
現在詞表和那段說明都只有一份,兩邊都讀它。測試守的是不准再抄第二份。
doctor 說的權限,要等於實際執行的權限doctor 報告規則解析了幾條、profile 是哪個,然後就停了。permissions show
也一樣。兩者都沒有回答那個真正的問題:
如果 agent 問了,有人聽得到嗎?
在 exec、serve、cron、subagent 裡,Ask 就是拒絕。一個設好 confirm-first
profile 的操作者,看到 doctor 說它 active,然後看著自動化在「需要批准」的訊息
上失敗 —— 而 doctor 從頭到尾沒說錯任何一句話,它只是沒說那一半。
現在會多一行,而且只在這份設定真的可能問的時候才印。永遠會印的那種行,
沒有人在讀。
app/src-tauri 裡 tool_gate 出現零次。而沒安裝閘的預設行為是 全部允許。
所以 [permissions] 和專案信任 —— CLI 每個介面都在執行的東西 —— 對 app 的
in-process runtime 完全不生效。設了 confirm-first、spectyn permissions show
告訴你它是 active 的人,他的 app 在無限制地跑。
這一條的修法只有一行,但它關的是今天所有缺口裡最實在的一個。
翻程式碼時撞到 core/src/pending_approvals.rs —— 一整套已經寫好的東西:~/.spectyn-mesh/pending 下的卡片、/rpc/approvals/list 列出、/rpc/inbox 提交決定、PhoneEscalator::await_decision 對應回來。
D1c 裁定的「daemon 批准佇列」有一半已經在那裡了。它現在服務的是 governed-run
那條路徑,跟 tool_gate 的 Ask 沒有接起來。所以那一半的正確做法是接上
既有的那套,不是再造一個佇列 —— 這正是 Phase 3 自己在講的那條道理。
#[cfg(test)] 裡的建構點加了一個欄位到 AppState,cargo build --lib 全綠。跑測試才炸:
error[E0063]: missing field `approval` in initializer of `tui::AppState`
測試模組裡還有第二個建構點。今天早上 LocalServer 那次是一模一樣的形狀 ——cargo build --bins 看不見 #[cfg(test)] 裡的 fixture。
multi_file_edit 的「疊加」測試:兩個編輯改同一個檔,第二個要對第一個的結果
出圖,而不是對原檔。我照直覺寫:
{"old_string": "one", "new_string": "1"},
{"old_string": "two", "new_string": "2"},
然後把疊加邏輯拿掉當紅證 —— 測試照樣綠。因為 one 和 two 在原檔裡都找
得到,不疊加也一樣出圖。那條測試證明的是「兩個編輯都會渲染」,不是「它們會組合」。
改成第二個編輯的 old_string 是第一個編輯才產生出來的文字:
{"old_string": "one", "new_string": "uno"},
{"old_string": "uno", "new_string": "1"},
現在不疊加就找不到 uno,會印「cannot preview」,測試紅。
順帶一個自找的麻煩:紅證腳本會用快照還原檔案,我在它跑的時候編輯同一個檔,
存的那份備援剛好抓到某個變異中的狀態。還原回去之後有一條測試紅 —— 不是新的缺陷,
是我自己把變異存進備援了。紅證腳本在跑的時候,不要碰它管的那些檔。
紅證腳本第一輪跑完,十一條裡前四條全部「綠」。看起來像四條空綠。
不是。是腳本裡那個包 python 的 shell function 少了 "$@":
py(){ python3 -c "...sys.argv[1]..."; } # 參數從來沒傳進去
所以變異根本沒套用,測試是對原始程式碼跑的,當然綠。今天早上那次是 sed 的
pattern 沒中,一樣的形狀:紅證本身失敗了,而它失敗的樣子跟「這條檢查沒用」
長得一模一樣。
修法不只是補 "$@",而是讓變異失敗變成致命:
py(){ python3 -c "...
print(' [mutated]', p)
" "$@" || { echo "MUTATION FAILED — this probe proves nothing"; exit 1; }; }
母規則的規則本身,也需要一條母規則。
紅證跑完十一條,十條精準命中,一條沒有 —— shutdown 那條。
第一版測試斷言的是「呼叫會帶著 Err 回來」。把明確的拒絕回覆拿掉,它照樣綠。
原因不是缺陷,是那件事不需要那段程式碼:PendingApproval 被 drop 的時候Sender 跟著沒了,呼叫端從 Disconnected 醒來,一樣拿到 Err、一樣很快。
那是正確行為,而且它是白送的。所以斷言它,證明不了被測的那段程式碼有做事。
明確回覆唯一多給的東西是理由那句話 —— 操作者和模型讀到的字。改成斷言那個。
然後第二版:斷言 why.contains("closed")。還是綠。
因為這個模組每一句拒絕都以 (fail-closed) 結尾 —— 包含被 drop 那條。
斷言匹配到了它自己的變異。今天早上才踩過一次同一個形狀:src.contains("verify_sha256()") 匹配到 gone_verify_sha256()。
第三版斷言 "the session that was asking" —— 只有 shutdown 那條路徑會說的話。紅。
一天之內,同一個形狀三次:變異靜默失敗、斷言吃到自己的變異、測試證明的是白送的行為。
三次都是紅證抓的,一次都不是綠燈抓的。