iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
AI Engineering

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

Day 10:門關上了,呼叫端還連得到嗎?

  • 分享至 

  • xImage
  •  

系列:「從單一 agent 到多 agent 集群,再到接進我的生活」;不設天數,第 10 篇

前言

路由受到保護與呼叫端獲准是兩個不同證明

Day 9 花一整天證明 spectyn serve 上每一條路由要嘛驗證、要嘛無害。
那個證明對「呼叫端還進不進得來」一句話都沒說。

這跟 Day 8 的主題是同一條線:東西修好了,不等於它送得到。
Day 8 是「Day 6 的每一個修法都不在操作者實際會跑的那支 binary 裡」;
今天是「門關對了,而敲門的人手上那把鑰匙我還沒逐個看過」。

架構圖

Day 10 呼叫端、簽章閘門與活路由總覽

開場:昨天收工前,已經長出三件

Day 9 留下的三個開口:第二 daemon、未簽章 caller、錯誤的 401 解釋

Day 9 收工前跑的第三輪對抗式稽核,除了當天修掉的,留下三件成立而沒動的。
它們不是新發現的 bug,是昨天那個證明的邊界:

一、app 有四個指令在打「沒人跑的那個 daemon」才有的路由

/costs     serve.rs=0  main.rs=1     ← 只有死 daemon 有
/tools     serve.rs=0  main.rs=1
/hands     serve.rs=0  main.rs=1
/revenue   serve.rs=0  main.rs=0     ← 兩邊都沒有

provider.rsformat!("{}/costs", config.hub_url) 這種寫法直接組路徑。
daemon_api.rs 裡有一整張 endpoint map,它的模組註解寫著這張表存在的
理由:app 當初照著錯的那個 daemon 寫,付出「後端連不上」好幾週的代價,
2026-08-06 由操作者裁定「只有一個後端」。

這四個 call site 繞過了那張表。 而繞過的方式很平凡 —— 它們不在
daemon_api.rs 裡,所以那張表管不到它們。

二、六個模組打 daemon 不簽章

cluster_peers(26 處提及)、healthonboardingprovidersecurity
skillbank_wire

Day 9 給 daemon_api.rs 做的「一扇門」只管一個檔案。而
every_request_goes_through_one_door 這條守衛掃的也只有那一個檔案 ——
又是一次「檢查它名字裡的那片」,只是這次我當天就知道,只是沒做。

而「反正都是 loopback」不成立:hub_url 是操作者可設定的,
cluster_peers.rs 的 allowlist 還明確允許 *.tail.ts.net

三、401 被顯示成「沒東西」

Err(e) if e.contains("401") => Ok(json!({ "tasks": [], "note": "unauthenticated" })),

Day 9 的文章寫了這一段,但程式還沒改。CLI 那批已經改成會簽章,
可是把「連不上」和「被拒絕」印成同一句話的顯示層一行都沒動 ——
簽章修好了,而下一次認證出問題時,它一樣不會出聲。

Phase A:逐條量送出端

一條 caller 的五欄稽核框架

規則跟 Day 9 一樣:先量再修,而且量的是「誰在呼叫、打到哪、會不會簽、
出錯時使用者看到什麼」,不是「這行程式碼看起來對不對」。

插曲:把今天學到的那條規則,變成一道會擋的閘

拒絕與放行測試必須成對

Day 9 最貴的一條發現,一句話講得完:

一個只驗證「該拒的有被拒」的套件,對「該放的有沒有被放」是全盲的
—— 因為閘壞掉的時候,兩者的答案都是 401。

這條規則的價值不在它多深刻,在它便宜到可以複製。所以把它寫進工廠的
WORKFLOW.md,並且——因為今天整天都在證明「寫進文件的規則會失效」——
一併寫成 scripts/paired_gate_check.sh:改動碰到閘的 PR,測試裡必須找得到
放行側的斷言。

順帶加第二道 test_count_ratchet.sh:測試總數不准變少。理由是昨天那條
被編輯吃掉 #[test] 的測試——它沒有變紅,它是不存在了,而覆蓋率也不會叫,
因為沒有人寫的測試不會降低覆蓋率。

然後我對這道閘做紅證,它沒中

變異是「加一行閘程式碼、只寫拒絕側測試」,而閘說「這次改動沒有碰到閘,跳過」。

追下去,先是變異本身失敗(我加的是一行註解,而註解會被過濾掉)。
但修正變異之後,發現了真的洞:

這道閘只看 diff 的 + 行。 而拆掉一道閘最直接的做法是刪掉那一行
if let Err(..) = require_cluster_auth(..) —— 那種 PR 對它完全隱形。

一個「動了閘」的偵測器,看不到「把閘拿掉」。

改成新增與刪除都看,兩個變異(加閘只寫拒絕側 / 刪掉整段驗證)都紅了。

它第一版還有兩個毛病,都是它自己要擋的那一類

  • 它把一張 SVG 圖檔當成產品程式碼掃到了 —— 因為那張圖的文字剛好在講
    這些常數的名字。掃描要限定在它真的能推理的檔案型別上。
  • 放行側的「證據」裡,有一條是 //! 文件註解。一句註解就能讓它變綠 ——
    一個被無關文字滿足的斷言,正是這整套規則存在的理由。
    我在寫這道閘的時候,犯了它要擋的那個錯。

最後留了逃生口(PAIRED_GATE_ACK=1,但 PR 說明要寫理由)。
沒有逃生口的閘會被整個關掉,那比誤報更糟。

Phase A 的結果:38 條,只有 8 條打得到活著的路由

38 條 caller 的路由與認證分布

七個 agent 平行量,每條 high/medium 再交給獨立駁斥者。38 條撐過駁斥。

路徑狀態 條數
哪裡都不存在 20
只在 main.rs(沒人跑的那個 daemon) 4
打得到活著的路由 8
外部服務(broker / supabase / ollama) 4
判定不了(由前端傳進來的 URL) 2

簽章:27 條不簽、7 條 canonical v2、4 條走其他服務的認證。
38 條裡有 37 條的目標可以是非 loopback。

一、一整個功能打在不存在的路徑上

app/src-tauri/src/commands/goals.rs18 個 Tauri 指令,全部打
{hub_url}/goals/... —— 列表、建立、更新、刪除、進度、今日、摘要、里程碑、
週期任務、check-in。

"/goals" 在 serve.rs        : 0
"/goals" 在 serve_skillbank : 0
"/goals" 在 main.rs         : 0

三個地方都沒有。 不是打錯 daemon,是打在一個從來沒被實作的後端上。
每一條的錯誤處理都是 .error_for_status().map_err(|e| e.to_string())?,
所以使用者會拿到 HTTP status client error (404 Not Found) for url (...)

這不是「Day 9 把門關上造成的」——它一直是壞的。Day 9 只是讓我第一次去數

二、安全稽核畫面把「被拒絕」顯示成「離線」

這是今天最尖的一條,因為它出現在一個安全產品的安全畫面上

security.rs/api/governance/log(治理飛行紀錄器),用的是:

.bearer_auth(&config.auth_key)

那是第三套認證方案。 daemon 的 require_cluster_auth 只收 canonical v2 ——
bearer token 從頭到尾不會被看,結果是 401。

而 401 到畫面上變成:

(離線模式)
無法取得審計日誌(離線)— 目前無審計資料可顯示
離線中 — 無審計資料可顯示

daemon 是活的,而且它手上就握著那份稽核日誌。 畫面說它離線。

Day 9 把 /api/governance/log 關進閘裡之後,只要 hub_url 指向非 loopback,
這個畫面就永久顯示離線,而且顯示的理由是錯的。

跟昨天 daemon_tasks 那條是同一個形狀,但更糟:
那條是把 401 顯示成「沒有資料」,這條是把 401 顯示成「連不上」 ——
一個會讓人去檢查網路、重開 daemon、看 Tailscale,而問題在憑證。

三、真正被 Day 9 弄壞的只有兩條

38 條裡,「路徑活著 + 不簽章 + 目標可遠端」的只有兩條:
cluster_peers/rpc/peerssecurity/api/governance/log

這是好消息,也是壞消息。 好消息是 Day 9 的破壞面很小;壞消息是其餘 36 條
的問題跟 Day 9 無關,它們本來就是壞的,只是從來沒有人把送出端逐條數過。

「補後端」是錯的決定,而我差點就開始蓋

補不存在的 API 與刪除殘骸的決策岔路

操作者看完 Phase A,說:補後端。

那 19 個指令是一整套目標管理系統的設計 —— 目標 CRUD、里程碑、週期任務、
每日打卡、心情趨勢、週報。要蓋,大概三天。

開工之前我先讀了那個檔案為什麼長這樣。(WORKFLOW.md §8:替換一段程式碼
之前,先讀它為什麼長這樣。)

前端 Goals.tsx 的檔頭註解是這樣寫的:

這一頁取代的是 684 行、建在八個 spectyn serve 從來沒有過的端點上的舊頁。
每一個都 404,而每一個呼叫點都用 .catch(() => ({ goals: [] })) 把錯誤吞掉 ——
所以一個完全沒有後端的頁面,畫出來的是「還沒有目標。從對話中說出你的
目標」,讀的人會以為是自己還沒設。它還畫了心情趨勢、里程碑數、每日打卡,
而那些資料沒有來源

上一輪已經有人拆過這件事了。 而且拆得比我準:他不只發現後端不存在,
還發現這個 bug 的真正代價不是 404 —— 是它會讓使用者以為問題在自己身上

而真正存在的東西,那段註解也寫了:~/.spectyn-mesh/goals.jsonl,一個 CLI
寫了好幾個月的 append-only ledger,四個欄位:

{tag, target, unit, window}

「每天寫 1 篇、每週運動 3 次」—— 然後 goal_deviations() 拿今天實際捕捉到的
事件去對它打分。前端早就搬過去了(goals_local_list)。

所以那 19 個指令不是壞掉的功能,是上一輪清理沒清乾淨的殘骸
補它們等於為一個沒有使用者的介面蓋後端,然後還要處理「它跟那個真的 ledger
誰是真相」—— 兩份真相來源,一個問題,正是這兩天一直在拆的東西。

我把這個判斷交回去,操作者改成:直接刪掉。

20 條測試,對「後端不存在」完全無感

刪之前發現它們有一套 regression 測試,20 條:

vi.mocked(invoke).mockImplementation(mockInvoke({
  goals_create: goalFixture,
  goals_list:  [goalFixture],
  ...
}));

it('returns an object with an id field', async () => {
  const result = await invoke('goals_create', { data: { title: 'Test goal' } });
  expect(result).toHaveProperty('id');
});

它餵進去一個固定值,然後斷言那個固定值有 id

19 個指令 404 了好幾個月,這 20 條一路全綠 —— 因為它們 mock 掉的正是壞掉的
那一層。這是「空綠」最乾淨的一個標本:測試覆蓋了一個功能的形狀,而那個功能
沒有後端。

刪完之後加一條讓它不會再長出來

app 端建出來的每一個 daemon 路徑,都必須在實際掛上去的路由表裡。

core/tests/the_app_only_calls_routes_that_exist.rs。它讀
serve.rs + serve_skillbank.rs + 指令表推出來的路徑(Day 9 學到的:
路由表是好幾個檔案合起來的),然後掃 app 的 commands/ 裡每一個用 hub_url
組出來的路徑,對不上就紅。

:id 這種參數化路徑會做樣式比對,query 會先剝掉。刻意不管的:外部服務
(broker / supabase / ollama)、前端傳進來的 URL、以及 main.rs ——
那是沒有人跑的第二個 daemon,有另一條檢查管它。

下限斷言兩條:讀不到 80 條路由就紅、檢查不到 10 條 app 端路徑就紅 ——
一個掃描器要先證明自己看得到東西。

我在昨天的文章裡寫錯一句,而它今天被抓到

未執行的測試不能稱為全綠

昨天那篇的核心句子是:

一層新 middleware 讓 53 條路由對簽章正確的遠端呼叫者回 401,
而三千六百條測試全綠。

後半句不成立。

今天對整份計畫跑前提查核時,一個 agent 去數了:apex4_phone_roundtrip.rs:77
test_rpc_forwarding.rs:103 本來就會auth_gate::signed_request
簽章正確的遠端請求到 /rpc/approvals/list/rpc/inbox/rpc/task/assign ——
而那三條都在 SELF_GATED_ROUTES

那些測試會紅。

我沒看到它們紅,是因為我從來沒跑完那次全量。而我自己的 commit 訊息結尾
就寫著:「未驗證:全量測試被存檔打斷,沒有綠燈紀錄」。

所以那六比零也是錯的

我寫過一張表,說原本六道 CI 閘對那天六個缺陷「六比零」。真正的成績是
1 抓到 / 1 有條件 / 4 漏掉:

缺陷 實際
53 條路由對簽對的人回 401 nextest 會抓到 —— 我沒跑完
一條測試的 #[test] 被吃掉 只有 clippy 加 --all-targets 才會(當時沒加)
/scan/*、三批死鑰匙、路徑洩漏、service worker 真的四道都擋不住

後面那四條漏掉的原因是結構性的,值得記住:--in-diff 的 mutation 測試與
diff-cover 只看這次改動到的行,所以「這次沒改到的舊程式碼裡的缺陷」
按定義就在範圍外 —— /scan/* 那條路由是 2026-05-30 掛上去的,
CLI 那批死鑰匙放了 24 天。

順手修掉那個一個字的洞:ci-fast.ymlcargo clippy -- -D warnings 加上
--all-targets。沒有它,clippy 不編譯 cfg(test),所以一個失去 #[test]
屬性的測試函式(會變成 dead_code 警告)完全看不到。

教訓因此變成兩層,而第二層更難堪

  1. 絕大多數 auth 測試確實是單邊的 —— 送沒有憑證的請求、斷言 401。
    閘壞掉時答案也是 401,它們分不出「擋住壞人」和「擋住所有人」。
  2. 少數幾條不是單邊的,而它們正是會抓到這個 bug 的那幾條。
    我把它們浪費掉了,因為沒等測試跑完就提交。

第二層更便宜也更難堪。第一層是一個真的認知盲點,值得寫成規則;
第二層只是規則再好,不跑也沒有用

而這件事今天又差點重演:為了讓紅證拿到 build lock,我殺掉了跑到一半的全量。
現在的狀態仍然是沒有綠燈紀錄,而今天動了 CI、刪了一個模組、加了新測試。

38 條裡,「路徑活著 + 不簽章 + 目標可遠端」的只有兩條:
cluster_peers/rpc/peerssecurity/api/governance/log

這是好消息,也是壞消息。 好消息是 Day 9 的破壞面很小;壞消息是其餘 36 條
的問題跟 Day 9 無關,它們本來就是壞的,只是從來沒有人把送出端逐條數過。

Phase B:把「被拒絕」跟「連不上」分開

HTTP 回應的拒絕、無路由、離線與成功診斷樹

Phase A 量出來的 38 條裡,真正需要修的那幾條有一個共同點:
它們不是壞在請求上,是壞在請求失敗之後那句話上。

一、安全稽核畫面:上一輪修的是路徑,不是認證

security.rs 的檔頭有一段註解,寫的是上一輪修過這個 bug:

/audit 在這個 daemon 上從來不存在。簽章的 pre-action gate log 在
/api/governance/log,而且現在就有真實的條目 —— 所以「安全與審計」畫面
在對一個活著、而且手上正握著被要求的那份資料的 daemon 說「(離線模式)
無法取得審計日誌」。那個面板拒絕捏造條目是對的;它只是被指向了空氣。

修得很好。路徑改對了。

.bearer_auth(&config.auth_key) 原封不動留著。

daemon 只收一種簽章(canonical v2),bearer token 從頭到尾不會被看
2026-09-01 之前這件事不會顯現,因為 /api/governance/log 正是那二十條
沒有閘的路由之一 —— 沒有閘的時候,錯的憑證跟對的憑證一樣好用。

Day 9 把它關進閘的那一天,「離線」就回來了。同樣的字,換了一個錯的理由。

這是今天最值得記的一種形狀:

上一輪的修法把 bug 從「指向不存在的東西」變成「指向存在但進不去的東西」,
而這兩件事在畫面上長得完全一樣。

二、那句話會把人送到錯的地方

原本的畫面是這樣:

(離線模式)
無法取得審計日誌(離線)— 目前無審計資料可顯示
離線中 — 無審計資料可顯示

三個地方都說「離線」。而實際情況是 daemon 活著、日誌就在它手上、憑證被拒

「離線」會把人送去:檢查網路 → 重開 daemon → 看 Tailscale → 看防火牆。
全部都是對的地方以外的地方。 而正確的動作是:去 setting 分頁把
cluster 密鑰貼上去。

所以錯誤要能分辨,而不是只有「有錯」跟「沒錯」:

pub(crate) const E_REFUSED: &str = "E_REFUSED";

if status.as_u16() == 401 || status.as_u16() == 403 {
    return Err(format!(
        "{E_REFUSED}: {what} 被 daemon 拒絕({}) —— daemon 是通的,是憑證的問題。\
         檢查這台裝置的 cluster secret,不是網路。", status.as_u16()));
}

前端據此分岔:

(憑證被拒)
審計日誌被 daemon 拒絕 — daemon 是通的,是這台裝置的憑證問題

一個錯的解釋比沒有解釋更貴。 沒有解釋的時候人會去找;有一個聽起來合理的
錯解釋時,人會照著它走完一整條死路,然後放棄。

三、daemon_tasks 的註解在替 401 說謊

同一個形狀的第二例,而且更乾淨:

/// `/rpc/tasks/list` is HMAC-gated; when this node has no cluster_secret
/// the call comes back 401 and we surface an empty list rather than an error,
/// because "no secret configured" is not a broken daemon.
Err(e) if e.contains("401") => Ok(json!({ "tasks": [], "note": "unauthenticated" })),

那個節點有 secret。 401 的原因從來不是「沒設定密鑰」,是簽章方案在
2026-08-08 就作廢了。所以 app 的任務清單空了 24 天,而任何一個去看的人,
都會讀到那句合理的解釋,然後停在那裡。

一個錯誤處理如果對錯誤有一套解釋,而解釋是錯的,
比單純把錯誤吞掉更難發現。

單純吞掉至少什麼都沒說,人還會繼續找。有解釋的那個會主動把人擋在真相外面

拆掉那個 match,讓錯誤原樣往上走。

四、/rpc/peers 也是 bearer,而它的 allowlist 允許 tailnet

cluster_peers.rs/rpc/peers 帶的也是 bearer token。而這個模組自己的
host allowlist 明確允許 *.tail.ts.net —— 所以「反正都是 loopback、
反正有豁免」對它從來就不成立。

三處一起改成走 daemon_api::send_signed 那扇門。Day 9 在 daemon_api.rs
裡開的那扇門,今天變成 pub(crate),給另外兩個模組用 —— 一扇門,而不是
三個各自記得要簽的地方。

Phase D:CLI 那邊,量出來只有兩處在靜音

CLI sessions 與 dispatch polling 的前後差異

Day 9 的結論是「CLI 六個指令帶著作廢的鑰匙,而且把 401 印成離線」。
簽章那半當天修了,顯示那半今天才逐條看。而逐條看的結果跟我的印象不一樣。

spectyn dispatch(派工)、spectyn msg(廣播)、spectyn git sync
(admin_shell)這三條,本來就把狀態碼和 body 一起印出來:

anyhow::bail!("RPC assign failed: HTTP {} — {}", status.as_u16(), body);

而 daemon 的 401 body 是
"unauthorized — bad or missing X-Cluster-Auth / X-Cluster-Timestamp / X-Cluster-Nonce"
—— 那句話已經足夠精確。這三條沒有問題,不需要修。

真正在靜音的只有兩處,而它們一個比一個安靜。

一、sessions:一個回答了 401 的 peer,不是「連不上」

Ok(r) => NodeRow { node: name, reachable: false, error: Some(format!("HTTP {}", ...)) }
Err(e) => NodeRow { node: name, reachable: false, error: Some("unreachable".into()) }

兩個分支都寫 reachable: false,而畫面上兩者都是

一台回了 401 的機器是連得上的。 它開著、網路通、daemon 在跑、它讀了我們的
請求並且做了一個決定。把它跟一台關機的機器印成同一個符號,就是把
「憑證錯了」藏進「機器掛了」裡面。

改成:回答了的就 reachable: true,符號分成 (沒有人回答)與
(有人回答了,但答案不是我們要的),而 401 的訊息直接講清楚:

z13   ⚠ HTTP 401 — 這台拒絕了我們的憑證(它是活的)。
                   對一下兩邊 agents.toml 的 [cluster] cluster_secret。

二、dispatch 的輪詢:每一次認證失敗都被印成「逾時」

這一處是今天最安靜的一個 bug,而且它連錯誤處理都沒有:

let r = signed_peer_request(...).send().await?;
let body = r.text().await.unwrap_or_default();
let v = serde_json::from_str(&body).unwrap_or(Value::Null);
last_status = v.get("status").and_then(|s| s.as_str()).unwrap_or("?").to_string();

它從來沒有看過狀態碼。

401 的 body 沒有 status 欄位,所以 last_status 變成 "?",迴圈繼續輪詢,
一分鐘之後印出:

⚠ timeout after 60s. last status: ?. Continue polling: spectyn dispatch-status <id>

每一次認證失敗,都被顯示成逾時。

而「逾時」會把人送去看:那台機器是不是很忙、網路是不是慢、任務是不是卡住、
要不要把 timeout 調長。全部都是對的地方以外的地方。

改成先看狀態碼再讀 body —— 兩行的事,而它安靜了不知道多久。

這一整類的形狀

三天下來,同一個形狀出現了六次:

在哪 真相 使用者看到
daemon_tasks 憑證被拒 一張空的任務清單
安全稽核畫面 憑證被拒 「(離線模式)」
cluster_peers 憑證被拒 「無法取得對等節點」
sessions 憑證被拒 ,跟關機的機器同一個符號
dispatch 輪詢 憑證被拒 「逾時 60 秒」
provider.rs 四條 路徑不存在 404 的原始字串

沒有一個是「錯誤沒有被處理」。每一個都被處理了 —— 處理成錯的東西。

一個把認證失敗顯示成別的東西的介面,等於把自己的認證錯誤靜音。
而如果它還附上一個聽起來合理的理由,它就不只是靜音,是主動把人擋在真相外面

Phase B 收尾:那張表早就有答案

Endpoint map 作為所有 caller 的單一契約

provider.rs 那四條打「沒人跑的 daemon」的路徑,追下去發現一件更難堪的事:

daemon_api 裡那張 endpoint map 對它們每一個早就有答案。

"cost" | "costs"  => Some("/api/cost"),
"tools"           => Some("/api/tools/history"),
"revenue" | "estop" | "hands" | "scores" => None,   // 這個 daemon 沒有這個東西

那張表存在的理由寫在它自己的模組註解裡:app 當初照著錯的那個 daemon 寫,
付出「後端連不上」好幾週的代價,2026-08-06 由操作者裁定「只有一個後端」。

而那四個 call site 繞過了它。 繞過的方式很平凡 —— 它們不在
daemon_api.rs 裡,所以那張表管不到它們。

一張表如果只約束跟它住在同一個檔案裡的人,它就不是一張表,是一個習慣。

處理:

指令 前端呼叫者 表怎麼說 做法
get_costs 2 /api/cost 走表 + 簽章
get_tools 1 /api/tools/history 走表 + 簽章
get_hands 2 None 走表,而表誠實地說「這個 daemon 沒有這個面」
get_revenue 0 None
get_estop_status 0 None

get_hands 那條值得單獨講:它有兩個前端呼叫者,所以不能直接刪。但那張表說
None,所以正確的行為不是「試著打某個看起來像的路由」,是回一句實話:

這個 daemon 沒有 hands 這個面 —— 不是連線問題,是這個功能在
spectyn serve 上不存在。

memory.rs/memory/observations 同理,而且我刻意沒有把它改指到
endpoint map 的 "memory" => /rpc/recall:那是不同的東西(語意召回,
不是觀察清單)。把一個 404 換成一個形狀不對的 200,比 404 更難發現 ——
那正是今天一整天在拆的形狀。留成 404,由 CI 每次指名它,並列成裁定點。

我的修法把自己的檢查弄瞎了

Lexical gate 失明與雙感測器修法

寫完那條 ratchet(「app 建出來的每個 daemon 路徑,必須在實際掛上去的路由表裡」)
之後跑紅證,它紅了 —— 但紅在下限斷言上:

只檢查到 1 條 app 端路徑 —— 掃描沒有看到它以為在看的東西

原因:那條掃描找的是「跟 hub_url 寫在同一行的字面路徑」。
而我剛剛把所有 call site 都改成走 daemon_route("costs") —— 路徑不再跟
hub_url 出現在同一行了。

我的修法,把用來守這件事的檢查弄瞎了。 而且如果沒有那條下限斷言,
它會報綠,一路綠下去,而它其實只看到一條。

這是這十天裡「下限斷言」第二次救場。第一次是 Day 9 的一扇門守衛
(「至少要看到三個呼叫點」抓到它只掃了三分之一的檔案);這次是它自己。

一個掃描器要先證明自己看得到東西 —— 而它「看得到多少」會隨著程式碼的形狀
改變,所以那個下限本身也是會過期的。

修法不是把掃描器改複雜,是換一個更小也更真的問題:

既然現在所有 call site 都走那張表,那就檢查那張表。

the_endpoint_map_only_promises_paths_that_exist:endpoint map 承諾的每一條
路徑,都必須在活著的路由表裡。一份清單,一個地方。原本那條 call-site 掃描
留著,但它的工作改成抓繞過那張表的人,下限也跟著改成「至少一條」——

一個掃描器的下限應該描述「它現在該看到多少」,而不是「它以前看到多少」。


十天,我實際上在做什麼

鐵人賽到今天第十天。這十天沒有一天是「規劃某個功能然後把它做出來」——
回頭看,做的事只有一件,重複了十次:去量一件從來沒有被量過的東西,
然後修掉量出來的結果。

執行紀錄

Day 1 到 Day 10 的量測時間軸

Day 量了什麼 量出來的東西
1–3 為什麼要自己養一支 AI 艦隊、四支商用 CLI 在一台 Mac 上 架構與定位
4 CLI 不准用猜的 --config 被靜默忽略、doctor 謊報身分
5 兩支 AI 交叉複查一份 diff review gate;夜間終端 agent
6 把 CLI 真的塞進 app 的全生命週期 權限批准往返、同根寫入競爭、五個階段
7 先量再修 五個題目裡有三個題目本身寫錯了
8 交付 Day 6 的每一個修法,都不在操作者實際會跑的那支 binary 裡
9 22 條無憑證路由 關上;然後對自己的成果稽核,17 條成立
10 送出端 38 條裡只有 8 條打得到活著的路由

Day 8 之後,主題就固定下來了。

Day 8 問的是「做好的東西送到了嗎」,答案是沒有。
Day 9 問的是「門關上了嗎」,答案是關上了、但關的時候我自己弄壞了一個。
Day 10 問的是「門後面的東西還連得上嗎」,答案是三十八分之八。

這三天連起來是同一句話:證明一件事情做完了,跟證明它在使用者手上還是那樣,
是兩件完全不同的事。
而我這三天做的每一次量測,都是在第二件事上。

這十天真正學到的三件

假綠、重複 bug 與錯誤解釋的三角原則

一、綠燈不是證據,而「全綠」這句話我自己也說錯過

母規則(任何檢查先示範會紅才算數)是這個專案的老規矩。但 Day 9 我寫了
「53 條路由壞掉,而三千六百條測試全綠」,今天被一輪事實查核推翻 ——
那些測試裡有幾條本來就會紅,是我沒跑完全量就提交。

規則沒有錯。是我沒有跑。 而這件事今天又差點重演。

二、同一個錯誤會穿不同的衣服回來

「清點器只清點它名字裡的那片」在 Day 9 出現了四次:只量 /api
只量兩個前綴、只認整行開頭的 .route(、只讀一個檔案。
每一次修完我都以為這次是全部了。

而今天寫「成對規則」那道閘的時候,我在它裡面犯了它要擋的那個錯:
放行側的證據裡混進一條文件註解,而「被無關文字滿足的斷言」正是這整套規則
存在的理由。

三、錯誤處理的解釋比錯誤本身更會騙人

daemon_tasks 的註解說 401 是「這台沒有設 cluster_secret」。那台有 secret。
dispatch 的輪詢把 401 印成逾時。sessions 把 401 印成關機。

沒有一個是「錯誤沒被處理」。每一個都被處理了,處理成錯的東西。
而錯的解釋比沒有解釋更貴 —— 沒有解釋的時候人會繼續找,有解釋的時候人會
照著它走完一整條死路。

接下來大致的規劃

近期、中期與長期三條技術車道

近的(這週)

  1. 把全量跑到綠。 這十天最誠實的一句話是:現在沒有綠燈紀錄。
    Day 9、Day 10 都動了很多東西,而我兩次為了讓別的東西拿到 build lock
    殺掉跑到一半的全量。這件事排在所有功能前面。
  2. Day 10 剩下的 Phase:先替一條已遷移的活路由補 signed-admit positive test;
    memory 三條 caller 的契約、全 commands lexical gate 與全 UI 401 ratchet
    仍未收口。/hands/estop 與 provider bypass 已處理,不再列為待辦。
  3. 四個裁定點:第二 daemon 留或刪;手機主控台採 per-device secret 或
    trust_tailnet_peers;memory 三條 caller 攬哪個活契約;禁網路的本地模型
    是否可以處理私人資料。

中的(這個月)

主題不變:繼續把「沒有人量過的東西」量掉。 排隊中的:

  • 一次完整的 e2e:從安裝到跨機器派工,走一次沒有捷徑的路。
    Day 8 證明了「打勾」跟「走過」是兩件事,而那條路到今天還沒有人走完。
  • 三平台的 keystore:macOS、Windows、Android 到今天仍然是 stub,
    而 client-side crypto 在文件上被標成 solid。
  • 錯誤顯示的全面清點:今天量了 CLI 和 app 的認證失敗,還沒量的是
    其他每一種失敗 —— 磁碟滿了、模型逾時、額度用完,分別顯示成什麼。

遠的(這一季)

這十天讓我對「接下來要做什麼」的看法變了一次,值得寫下來。

原本的路線圖是往功能長:治理層 → Skill SDK → 五平台。
而這三天量到的東西說,已經寫出來的東西正在以肉眼看不見的速度爛掉 ——
20 條打不存在路徑的請求、19 個沒有後端也沒有呼叫者的指令、
3 批用作廢簽章方案的送出端(24 天)、一個沒人跑的第二 daemon。

一個只會產出新功能的流程,會用今天的速度製造明天的這批。

所以接下來要加的不是功能,是一條拆除車道:每週固定一次「量一件從來
沒有量過的事」。這三天最有價值的三個發現全部來自清點 —— 數路由、數送出端、
數測試裡的斷言方向。不是靈感,是數數。

而那件事本身可能就是這個專案最值錢的東西。它三天內在一份「已經做完」的
程式碼裡找出五十幾條成立的問題,其中包括作者當天寫的,以及它自己
寫錯的那一句
。一個只會找別人 bug 的方法不稀奇;會在同一天內拆掉作者當天
成果的方法,才是可以交給別人用的。


2026-09-03 開發續記:拒絕不是離線

SecurityPanel refusal state 與 render condition 的前後對照

上一輪已經讓後端錯誤保留 E_REFUSED,但畫面還有一個很小、也很實際的 correctness bug:

  • state 正確設成 isRefused=trueisOffline=false
  • 標題 badge 會顯示「憑證被拒」。
  • 詳細 banner 的外層卻只檢查 isOffline,所以拒絕說明永遠不可達。
  • 空表文案也先檢查 isOffline,拒絕時只剩一般空資料訊息。

這次把 banner 條件改成 isOffline || isRefused,空表則先判斷 isRefused。新增的 regression test 讓 get_audit_log 拒絕 E_REFUSED: HTTP 401,並固定四件事:拒絕 banner 可見、空表明說憑證被拒、不出現離線空表、不出現「離線模式」。

精準前端驗證:PASS(SecurityPanel.test.tsx:4 passed,exit 0)

Rust caller/route baseline 這次沒有完成。同一個 core/target 已經有另一個先前啟動、超過一小時仍未結束的 cargo nextest;本輪 targeted Cargo test 編譯超過六分鐘後中止,exit 130,後面的 paired gate 與 count ratchet 沒有執行。這一段不能寫成綠燈。

Day 10 現在真正做到哪裡

Phase 狀態 還缺什麼
A caller inventory 部分完成 38 條 inventory 已分類;memory 三條 caller 尚未決定活契約或移除,原 Phase A DoD 尚未達成
B dead path removal 部分完成 provider/hands/estop/revenue 已處理;memory 三條刻意 404 仍待產品裁定,route ratchet 因此尚未收口
C one request door 部分完成 多數 caller 已走 endpoint map + signed request;memory 還有 direct bearer,全 commands 掃描與 signed positive tests 尚未完成
D rejection semantics 部分完成 CLI 與多個 command 已分開 refused/offline;本輪修正 SecurityPanel 的不可達拒絕正文,仍需全 UI 401 ratchet
E Day 9 tail 未完成 mobile credential、第二 daemon 與 medium audit 保留原 Phase E 定義,均待 owner/gate
發布證據(非 Phase) 進行中 不能在 Rust gate 與 owner decisions 未結束前宣稱 Day 10 全綠

還有四個必須由 owner 明文裁定的邊界:

  1. main.rs 的第二 daemon 是保留、合併,還是刪除。
  2. mobile credential 是 per-device secret,還是明確採用 tailnet trust。
  3. memory 三條 caller 應改接哪個活契約,或應從 UI 移除;不能用形狀不對的 200 掩蓋 404。
  4. 禁網路的本地模型是否可以處理私人資料;未裁定前只使用公開或合成資料。

讓人腦、設備與 AI 訂閱產出最大化

以人類決策小時為核心的模型與設備路由

**PROPOSAL / AWAITING OWNER:**以下是根據目前 repo、設備與訂閱限制提出的執行建議,不是已裁決的產品 SSOT。

最大化的單位不是 token、同時跑幾個 agent,也不是六台機器的使用率。應該量:

每一小時人類決策時間,產生多少通過驗收、隔天仍可使用的成果。

如果 owner 接受這個方向,建議分工如下:

資源 固定車道 不做
人腦 每天唯一優先序、驗收條件、風險裁決、dogfood、客戶訪談 不同時救三條產品線,不替 agent 猜模糊需求
Codex 主實作、整合、長任務、CI 修復;每張卡只有一個 DoD 不與另一 worker 同改核心檔;沒有 timeout/budget 不過夜
Claude Code Tier A 架構、威脅模型、安全 review、難 Rust 不當 24/7 daemon,不握 release secret
Antigravity / Jules 獨立測試、文件、低耦合 GitHub 背景任務 不與 Codex 同檔競寫,不進簽章與 production
Z13 本地模型 離線 eval、公開或合成資料的批次分類、低成本 janitor 私人資料需 owner 明文允許;不直接 merge、deploy 或寫正式 memory
免費雲端模型 公開程式碼的 Tier C/D:測試候選、文件、分類 不放 secret、私人 memory、客戶資料或 critical path

一張 task 只設一個 primary worker 與一個 reviewer。只有金鑰、資料刪除、安全邊界、release 才升級 Claude + Codex 雙檢。所有模型用實際一次通過率、修復次數、人類審核分鐘數動態路由,不用品牌印象排優先級。

設備建議採固定車道:

  • M5 #2:hub 與 macOS runner,但簽章必須是隔離 OS account/keychain、人工短時解鎖,不能讓無人值守 runner 讀私鑰。
  • M5 #1:互動、Xcode、Tier A 操作。
  • Z13:本地推論與 Windows worker。
  • A514:Linux CI 與 Android build,不先當產品 mesh 節點。
  • Oracle ARM:觀測、加密備份、metadata 與備援,不放 prompt corpus。
  • RB102:乾淨安裝與 rollback,保持 disposable。
  • 手機:只做 status、approve、deny、stop、通知;長跑永遠在自有 backend。

官方工具可當 operator control plane,而不是產品 dependency:Codex 官方 use cases 涵蓋 goal-following、project teammate 與文件維護;深度安全掃描需要另行安裝 Codex Security plugin。Claude Remote Control 連線期間的完整 transcript、訊息、回應與 tool activity 會儲存在 Anthropic 伺服器;Antigravity 的 retention 邊界本輪尚未確認,兩者都不承載未經允許的私人內容。Jules 適合 bounded GitHub background task。這些工具的額度與 preview 行為會變,也不能寫進產品 SLA。

後續 120 天提案:先驗證一條主線

0 到 120 天的三段 stage-gate 提案

0–14 天:每天可靠地真用

  • 關掉匿名 privileged route,所有遠端高權限動作 fail closed。
  • 修完 Day 10 caller/route、401 語意與現有高風險 gate;不新增功能面積。
  • 先只跑 M5 #2 hub + Z13 worker;A514 只跑 CI。
  • 手機只交 internal build 的 status/stop/approve。
  • 每天留一筆 evidence:真實任務、是否完成、節省時間、失敗原因、是否人工救援。
  • 完成 secret map,分開 identity、broker token、provider key、cluster secret、signing key。

出口:14 天至少 10 天真用;連續 7 天有非空真實事件;RB102 三次 clean install + rollback;沒有匿名 privileged route;runner 讀不到簽章私鑰。

15–45 天:兩機閉環與第一筆人工收入

  • Claude、Codex、Google 共用一份 task schema:timeout、取消、結果、artifact、ledger。
  • 以連續 14 天、至少 50 個有固定 timeout 的任務為樣本,M5 #2 coordinator + Z13 worker 的「在 deadline 內完成且結果被接受/總派工數」目標至少 90%;掉線算失敗,不可 hang。
  • governor v0 只做五件事:時間上限、token/金額上限、取消、危險動作 approval、append-only ledger。
  • consensus 暫緩;先證明單一 policy + human approval 的責任邊界。
  • 啟動 8 週 dogfood,每週只修真實失敗前三名。
  • 找 10 位目標使用者,提出 2 份付費 pilot;首客賣「主權 agent 工作流部署 + 驗證報告」,先人工報價與 entitlement,不先做 billing。

出口:每週使用至少 5 天;每個過夜任務都有 budget/timeout/kill/ledger;手機 console 在 14 天內至少 10 天完成 status/stop/approve 且零次繞過 auth;至少一筆非零 pilot 訂金實際入帳。

46–120 天:窄版 v1.0

建議將 v1.0 範圍縮到:單機完整可用、兩機派工、OS keystore、最小 governor、signed installer、單一手機 console、可驗證復原。

  • Skill SDK / MCP 降為 beta,最多三個自己真用過至少三次的 skill。
  • 一次只推一個公開商店;第一平台連續 14 天無 P0/P1 crash、auth 或 recovery incident,才排第二平台。
  • L4 只做選擇性設備能力,不追求每台能力對等。
  • L5 只准 shadow proposal:從 failure trace 提候選、跑 eval、由人走正常 PR/ship gate/人工簽章。
  • 工廠套件、安全套件、agent bench 與內容都先是 dogfood 的副產物;在首客交付重複使用後才獨立產品化。
  • 年底若要一位客戶覆蓋月成本,必須是有導入費的 pilot,不會是單一低價 App Store 訂閱。

若 owner 決定替「L0–L5 自主度」改名,建議採 AUT0–AUT5,避免同時撞上 repo lifecycle L0/L1 與本文 Phase A–E。

只有 owner 接受本提案後,46–120 天才採用以下 GO/NO-GO:

  1. 8 週 dogfood 中至少 6 週達到每週 5 天真實使用,證據取自 usage.jsonl
  2. RB102 連續 3 次完成 signed installer 的 clean install、upgrade、rollback。
  3. M5 #2 與 Z13 至少 50 個固定 timeout 任務,deadline 內完成且結果被接受的比例至少 90%。
  4. budget、timeout、cancel、危險動作 approval、append-only ledger 各有一個會先紅再轉綠的 regression test。
  5. 主要手機 console 連續 14 天無 P0/P1 crash、auth 或 recovery incident,且零次繞過 auth。
  6. 依文件從備份恢復 hub 兩次,兩次都在 60 分鐘內重新完成 status、dispatch 與 approval。
  7. 沒有未關閉的 P0/P1 finding,且至少一筆非零 pilot 款項已實際入帳。

以上證據集中寫入 docs/releases/v1.0.0.md,引用 CI/nightly URL、flight-recorder run id、usage.jsonl 摘要與 store submission 紀錄;缺一項就是 NO-GO。

本輪採用的官方限制

使用者自有資料面與供應商控制面的責任邊界

若 owner 接受此方向,商店架構建議為:可執行邏輯在使用者自有 backend,手機只收送資料、顯示狀態與代為執行明確授權的裝置能力。不要把下載 executable skill、手機 24/7 常駐 agent 或訂閱 OAuth 轉接放進 critical path。


上一篇
Day 9:把門關上之後,才發現鑰匙早就作廢了
下一篇
Day 11:從來沒紅過的燈,不是綠燈
系列文
從單一agent 到多agent 集群的開發流水帳以及應用18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言