系列:「從單一 agent 到多 agent 集群,再到接進我的生活」;不設天數,第 12 篇(上)
這篇是 Day 12 的上半:16 條紅怎麼從「五種」重分成「四種」、每種怎麼先寫一條會紅的測試、
看它紅在預期的那一行才動 lib、拿到綠燈紀錄;以及第 16 條(route ratchet 的下限)為什麼
今天決定不修、留紅等誰裁定。下半從全量收工開始——稅、閘、兩次撞用量上限、CI 接線、
勘誤與執行紀錄——是另一篇。紅證測試的 HEAD 是2d062f40,唯一一次 lib 改動的 commit
是f3fcb84f(10:24)。

Day 11 收工時的數字:修之前全量 45 紅;ConnectInfo fail-closed(
414db8c4)之後,
同一批 8 個 binary 剩 16 紅,五種[Day 11 已勘誤:四種]。修之後的全量 20:13 起跑,寫完文章時還沒收工。
修之後的全量收工了。早上 08:36,跑了 12 小時 23 分。數字在下一節。
今天不是把 16 條修掉。是一種一種來,每種先紅證:先寫一條會紅的測試,看它紅在
預期的那一行,才動 lib。做完拿到一行可以被別人核對的綠燈紀錄;然後讓那盞燈有人看。
Day 7 立的規矩是「先量再修」。Day 11 量出來 16 條、五種——但那五種是按測試的名字
分的,不是按 panic 的那一行分的。今天第一件事是把 16 條的 panic 行逐條再讀一次。讀完,
五種變四種,其中兩種在 Day 11 寫錯了原因。
還有一件事決定今天所有事的順序:這台 Mac 的 Gatekeeper。早上以為的規則是「lib 一改,
202 支 binary 全部重 link、全部重審」,所以會紅的測試先進 tree(lib 一個字不動)、lib 的改動
集中成一次 commit。順序是對的,理由是錯的:09:58 的 F0 紅證量到 core/build.rs 讓每一次
cargo 都重編 lib——跟有沒有改 lib 無關。這個 crate 的 lib 在這台機器上一次都沒 Fresh 過,
直到 10:16。那一段留在下一篇的「F0」那節。
到 14:32,16 條紅裡的 15 條都綠了,而且是同一個 lib commit 之後的一次 targeted 一起綠的;
第 16 條留著,等裁定——這篇寫到這裡結束。中間有六個小時什麼都沒推進——session 兩次撞上
用量上限,10 個 agent 死在半路。那段也留給下一篇,在「兩次撞上限」那節。
Day 11 估「Gatekeeper 80 分鐘」。實際:
START 2026-09-04 20:13:44 HEAD=414db8c4 core_clean=0
Finished `test` profile in 12m 47s
Starting 3593 tests across 202 binaries (70 tests skipped)
Summary [3410.455s] 3593 tests run: 3577 passed (5 slow, 2 leaky), 16 failed, 70 skipped
EXIT=100
END 2026-09-05 08:36:25
整輪 12 小時 23 分:編譯 12 分 47 秒、實跑 56 分 50 秒、中間 11 小時 13 分在--list——nextest 對 202 支剛 link 出來的 binary 逐支列舉,每支停在 S 狀態等syspolicyd。log 本身沒有 --list 的時間戳;11h13m 是 20:26:31(編完)到 07:39:34
(junit 的 timestamp,測試開跑)算出來的,W5 查核過。Day 11 同一步是 81 分鐘。
兩輪並排:
| 修之前(Day 11) | 修之後 | |
|---|---|---|
| HEAD | b7377c13 |
414db8c4 |
| 跑 / 過 / 紅 / 跳 | 3591 / 3546 / 45 / 70 | 3593 / 3577 / 16 / 70 |
| binary | 201 | 202(多一支放行側測試) |
| 編譯 | 18m35s | 12m47s |
--list(Gatekeeper) |
~81 min | 11 h 13 m |
| 實跑 | 176 s | 3410 s |
| 起 → 訖 | 18:17 → 20:02(1h45m) | 20:13 → 08:36(12h23m) |
45 → 16。ConnectInfo 那 31 條清掉了,這是 414db8c4 唯一宣稱的事。
而且 16 條的名字跟 Day 11 晚上 8 個 binary 的 targeted 跑出來的 16 條一模一樣:
12 條 skill_rpc_skills(含 latency 那條的 TRY 1/TRY 2)、2 條 libserve::squad_dispatch_tests、1 條 p9_the_shell_cannot_pin_itself、1 條the_app_only_calls_routes_that_exist。targeted 從起跑到收工不到 6 分鐘(20:04:47 → 20:10:31,
共 5m44s:57 秒編譯、3 分 24 秒 Gatekeeper(Day 11 原文寫「約 4 分鐘」,重算後偏低)、83 秒
實跑),預測了全量 12 小時的答案。
全量沒有告訴我任何 targeted 沒說的事。它買到的只有一行別人可以核對的紀錄——junit 在core/target/nextest/default/junit.xml,當時 mtime 08:36,已備份成junit-full-after-20260905-0836.xml(原始檔案後來被 10:45 targeted 與 14:32 Phase D 覆寫)。
一件沒解釋的:實跑從 176 秒變 3410 秒。修之前那輪 nextest 標 SLOW 的測試有 5 條(當時沒設slow-timeout,門檻是預設 60 秒);修之後那輪 junit 裡 time > 60 s 的有 348 條(這輪門檻改成
120 秒,nextest.toml:26;Summary 的「5 slow」是超過 120 秒的那 5 條);p9_the_shell_cannot_pin_itself
那條 0.062 秒變 98.5 秒。這段時間(07:40–08:36)session 正撞用量上限(07:15–09:30),tick #2 的
8 個 agent 有幾個還活著沒查。是 syspolicyd 對每次 exec 收稅、還是 agent 搶 CPU,沒查。
寫在這裡,不寫成知道了。
Day 11 的表用測試名字分。下面這張用 panic 行分:五列,四種。「今天」那欄是收工時的狀態。
| 種 | 數 | 紅在哪 | 為什麼紅(今天證實的) | 今天 |
|---|---|---|---|---|
| (a) | 12 | skill_rpc_skills:簽對的請求回「bad or missing X-Cluster-Auth」 |
證實是雙重驗章:三個 handler 自己驗章(serve_skillbank.rs:111/177/237),又被 gate_api_and_rpc 包住(serve.rs:242、:247-250),不在 SELF_GATED_ROUTES、UNGATED_API_ROUTES、GUARDED_EXACT_ROUTES、指令表任何一張清單。middleware 先驗、燒掉 nonce;handler 再驗,看到的是重放 |
紅證測試進 tree(2d062f40),09:52 紅在預期的三行;lib 三行進 f3fcb84f;10:45 targeted 15/15 綠 |
| (b) | 2 | lib squad_dispatch_tests 兩條 |
模擬本機呼叫者,沒帶 ConnectInfo;fail-closed 之後 500 變 401。20:10 junit:serve.rs:10219 left 401 right 200、serve.rs:9142 left 401 right 413 |
補 loopback 地址,兩處,同一個 lib commit;10:45 兩條 PASS |
| (c) | 1 | p9_the_shell_cannot_pin_itself |
不是 sw.js 的錯:sw.js:38 的 ASSETS 從 167f96c7 起就沒有 /console;紅的是測試對整個 body 做子字串比對,踩到 sw.js:8、:30 兩行註解。測試跟 sw.js 同一個 commit 誕生,從沒綠過 |
修測試:只讀程式碼行;加一條探測本身的紅證(2d062f40);09:52 3/3 綠,10:45 再一次 |
| (d) | 1 | the_app_only_calls_routes_that_exist 的 call-site 掃描 |
掃描器看不到 "{}/…",checked=0,撞自己的下限。紅得對 |
不修,留紅;修好會點名 8 條、3 個檔,只有 3 條等 memory 裁定(下面說) |
| (e) | 1 | search_fts5_p99_latency_under_200ms |
是 (a) 的第 12 條。 兩份 Day 11 log、20:10 junit、今早的全量、09:52 的紅證,八次 panic 全在 assert_eq!(status, 200) 那行(414db8c4 的 :449,2d062f40 之後是 :452)——第一個請求就 401。這台機器從沒量到 p99。Day 11 寫的「201 ms」是 GitHub CI(#371)的數字 |
(a) 修好之後 14:31 單獨跑:p99 = 180 ms(min 58、p50 79、max 180),沒 TRY,沒放寬 200 ms |
16 條,四種。(e) 那一列留著,因為 Day 11 把它當成一種,今天要把它併回去——而且要用一個數字併回去。
Day 11 寫「疑似」,因為我沒有去讀那個 handler。今天讓兩個 agent 各自獨立讀,再派駁斥者
預設推翻。結論一致,鏈條是這樣(行號是現在 HEAD 的;f3fcb84f 在 http.rs 的清單前面加了
17 行文件,清單往下移):
serve_skillbank.rs:73-75 掛三條路由:/api/skills、/api/skills/:id、/api/skill-timeline。require_cluster_auth::111、:177、:237。serve.rs:242 把它們掛進 router(attach_skill_routes_opt,featureexperimental-memory,預設開);:247-250 再把 gate_api_and_rpc 這層 middlewareSELF_GATED_ROUTES(http.rs:705),和指令表轉出來的路由。三條都不在。UNGATED_API_ROUTES(:621)、GUARDED_EXACT_ROUTES(:760)也沒有。http.rs:662 起的註解寫得很清楚:這不是政策清單,是碰撞清單。所以不是「疑似」了。這是 Day 9 建 SELF_GATED_ROUTES 要解的那個碰撞,只是 Day 9 的清單
沒看到這三條。
而且家族是 12 條,不是 11。第 12 條是 (e),下面第四種說。
SELF_GATED_ROUTES 不是手寫的,有一條測試從原始碼反推它——api_auth_inventory.rs::the_self_gated_list_matches_the_handlers。414db8c4 那版,它反推
的來源是 include_str!("../src/serve.rs"),一個檔案。而三條 skills 路由住在serve_skillbank.rs。
更難堪的是同一個測試檔裡,另一條檢查早就知道了。ROUTE_SOURCES(現在的 :53)
列了兩個檔:serve.rs 跟 serve_skillbank.rs;路由 parser 用它,還有一條「parser 要對得上
每一個 .route(」的下限測試守著它。deriver 就在幾百行下面,自己 include_str! 了serve.rs,沒讀那份清單。
「清點器只清點它名字裡那片」——Day 10 數到第五次,Day 11 寫「如果證實,這是第六次」。
證實了。第六次。
母規則:任何檢查先示範會紅才算數。今天 07:03 進 tree 的是 commit 2d062f40,6 個檔、
375 行,只有測試跟設定,lib 原始碼不動——targeted 只 link 這 5 支測試 binary,不是 202 支。
三條預期紅:
一、core/tests/skills_signed_caller_is_admitted.rs(新,213 行)。 一條路由、三個
請求,形狀跟 Day 11 的放行側測試一樣:
GET /api/skills?limit=1 → 401/403:149;這一列有走到閘。404/405 代表路由沒掛,200 代表門是開的)assert_eq!(200),而且 items 是陣列、只有一筆、id 等於種進去total 是 1、limit 是 1(:165-196;門開了,而且是對的 handler 在回答):201-211;那個 200 是簽章換來的,不是某個豁免)今天預期第 2 個請求紅,body 就是那行「bad or missing X-Cluster-Auth」。?limit=1 是
故意的:query string 在簽章範圍裡,拿到 200 同時證明 handler 驗的是呼叫者簽的那個
method/path/query。
二、p9_the_gate_closes_without_breaking_local.rs。 Day 9 那條「簽對的遠端呼叫者打
得到 self-gated 路由」的表,加一列 ("GET", "/api/skills", "")(:288)。旁邊加一條assert_ne!(404)(:311-313):路由沒掛的話這一列會拿到一個「不是 401」的 404,然後綠
給你看。這一列沒接 skill memory,簽章過了 handler 會回 503;503 也是「不是 401」。今天
預期紅(401)。
三、api_auth_inventory.rs::the_self_gated_list_matches_the_handlers。 deriver 改成
逐檔讀 ROUTE_SOURCES(迴圈在 :554)。而且加了每檔的下限(:562):一個檔的
production code 裡有 require_cluster_auth(,deriver 卻在它身上一條路由都沒找到,就 panic。
原本只有一個總下限「≥ 20 條」(:585)——serve.rs 自己的 35 條就跨過去了,它看不出少了
一整個檔。今天預期紅,訊息是:
In the source but not the const: ["/api/skill-timeline", "/api/skills", "/api/skills/:id"]
三條的紅,各證一件事:第一條證產品壞了,第二條證 Day 9 的閘測試看得到它,第三條證
清點器現在看得到那個檔。
09:31 build lock 空了,紅證 targeted 起跑;09:52 收工(redproof-tests.log):
START 2026-09-05 09:31:13 HEAD=1b7b005b core_clean=0 (第 1 行)
Finished `test` profile in 16m 46s (第 132 行)
Starting 30 tests across 5 binaries (1 test skipped) (第 135 行)
Summary [4.332s] 30 tests run: 15 passed, 15 failed, 1 skipped (第 511 行)
EXIT=100 (第 528 行)
END 2026-09-05 09:52:29 (第 529 行)
21 分鐘:編 16m46s(lib 又重編了——lib 原始碼自 414db8c4 起一個字沒動;為什麼,下一篇
「F0」那節)、--list 約 4 分(估:編完到第一條結果,log 沒有時間戳)、實跑 4.3 秒。五條
預測全中:
:165(log 第 504 行),就是第 2 個請求:「a correctly signed remote:591(第 151 行),source-not-const 正好三條:/api/skill-timeline、/api/skills、/api/skills/:id(第 153 行);反方向(const 有、source 沒有)是空的。:316(第 178 行):「/api/skills: a signature the middleware acceptedskill_rpc_skills 仍 12 紅、全部 401(第 514-525 行),latency 那條兩次 TRY 都死在:452:9(第 419、440 行)。PERF_RECORD 一行都沒印——12 條沒有一條走到綠的那行。一件新的:list_endpoint_meets_500ms_perf_gate_for_1000_skills 第一次有 TRY 1 / TRY 2
(第 235、256 行)——nextest filter 放寬生效了(第四種說)。它也死在 401(:404:5,第 250、
271 行),不是 500 ms。
三條紅、一條綠,都在預測的那一行。這一步花 21 分鐘,買到的是「修法動的是對的地方」。
http.rs 的 SELF_GATED_ROUTES 加三個 pattern(f3fcb84f,現在在 http.rs:718-721):
// serve_skillbank.rs — feature `experimental-memory`; see the doc above.
"/api/skill-timeline",
"/api/skills",
"/api/skills/:id",
就這樣。handler 自己的驗章不動,middleware 對這三條站開。不放寬任何斷言。理由跟http.rs:675 附近的註解講的一樣:碰撞的解法是 middleware 退開,不是刪 handler 的檢查——清單裡
有幾條的 handler 驗得比 middleware 嚴(/rpc/admin/shell 不給 loopback 豁免),讓
middleware 接手等於把 node shell 開給任何本機行程。feature 關掉時路由不掛,三個條目惰性,
所以無條件列。
而 deriver 已經在 2d062f40 改成讀 ROUTE_SOURCES。所以這三行進去之後,第三條測試會從
「source 有、const 沒有」翻成綠;三行少一行,它紅回來。清單跟原始碼從此綁在一起,而且
綁的是存在的那幾個檔,不是 deriver 名字裡那個。
補丁附了一段靜態重放:用 Python 逐字重做 deriver 的規則,serve.rs 35 條 +serve_skillbank.rs 3 條 = 38,const 也是 38。沒編譯、沒跑——那是 09:33 的事。
lib commit 是 f3fcb84f(10:24:22):build.rs、http.rs、serve.rs、embeddings/mod.rs、skill_wire.rs,加 24 行執行紀錄,共 +153/−15 行。四件事一次 commit——(a) 的三行、(b) 的兩處、
F0 的 build.rs、#371 的 clippy 移植——commit 訊息裡每件附紅證的 log 行,最後一段寫「未驗證:
這個 commit 之後還沒跑任何測試」。git show f3fcb84f -- core/tests 是空的:斷言一個字都沒放寬。
targeted 10:24:33 起跑、10:45:34 收工(targeted-fix.log,HEAD c2a59c5b,tree 乾淨):
Starting 101 tests across 8 binaries (2730 tests and 195 binaries skipped) (第 280 行)
Summary [15.610s] 101 tests run: 101 passed, 2730 skipped (第 383 行)
EXIT=0 (第 384 行)
21 分鐘:編 15m23s(第 277 行;為什麼一個 -E 過濾的 targeted 要編 15 分鐘,下一篇
「Gatekeeper」那節)、--list 約 5 分(估:10:24:33 → 10:45:34 共 21m01s,減編譯 15m23s、
實跑 15.6s ≈ 5m22s)、實跑 15.6 秒。修之前紅的每一條都綠了:
skill_rpc_skills 15/15——12 條 401 家族全部 PASS(第 360-379 行);the_self_gated_list_matches_the_handlers PASS(第 347 行)——const 38、source 38;a_signed_remote_caller_reaches_a_self_gated_route PASS(第 355 行)——/api/skills 那列 200 了;a_signed_caller_is_admitted_to_the_skill_list_with_the_page_it_asked_for PASS(第 380 行)——items 一筆、錯密鑰被拒。三條紅證翻綠,一條都沒少。第一種收工。
lib 裡 serve::squad_dispatch_tests 兩條:events_upload_rejects_too_many_parts_with_413
跟 durable_status_survives_restart。它們模擬的是本機呼叫者——同一台機器上的擷取
client、同一台機器上的輪詢器——不簽章,期待 handler 的回答。
Day 11 之前它們拿到 500(沒帶 ConnectInfo,extractor 在 middleware 之前就死)。414db8c4 之後拿到 401(fail-closed:不知道你從哪來,就不給本機豁免)。紅證的前半
從 20:10 的 junit 讀出來:
serve.rs:9142 left: 401 right: 413
serve.rs:10219 left: 401 right: 200
是 401,不是別的。這一步不能省——如果它們紅的是 500 或 403,「補一個 loopback 地址」
就不是對的修法。
修法是兩處 request builder 各加一個 extension:ConnectInfo(127.0.0.1:51000)。一處在post_event_n_parts 那個 helper(兩個請求都走它),一處在輪詢 /rpc/task/status/{id} 的
地方;f3fcb84f 之後在 serve.rs:9112 跟 :10229 那兩行 51000。不用加 Origin——worker 讀auth_gate.rs 的 origin_permits_local_exemption,None 直接回 true,駁斥者複核過;
我沒抽查。
這兩處改的是 serve.rs 裡的 #[cfg(test)] 模組。對 cargo 來說那是 lib 變了:202 支重
link。所以它跟 (a) 的三行併一個 commit。兩處各七、八行的修法,單獨跑一次全量要付一整輪的稅。
紅證的後半在 10:45:durable_status_survives_restart PASS 0.033 s(targeted-fix.log:319)、events_upload_rejects_too_many_parts_with_413 PASS 0.027 s(:322)。413 跟 200 各回到自己的
位置。第二種收工。
Day 11 的表寫:「/sw.js 又把 HTML shell 放進 precache——Day 9 的 p9 測試,今天沒動。」
錯。而且錯的方式很具體。
core/src/web/sw.js:38:
const ASSETS = ['/manifest.webmanifest', '/icon-180.png', '/icon-192.png', '/icon-512.png'];
沒有 /console。git log 對這個檔只有三個 commit:6e5637dd(07-02,原版,const SHELL = ['/console', …])、1518fad7(08-06 改名)、167f96c7(09-03,Day 10 重寫
成 network-first)。167f96c7 到 HEAD,byte-identical。/console 從 Day 10 離開 precache
之後,一次都沒回來。
那測試紅在哪?414db8c4 那版第 97-98 行的探測是:
!body.contains("'/console'") && !body.contains("\"/console\"")
對整個 body 找子字串。而 sw.js 開頭有一段「WHAT WENT WRONG HERE」——它用註解交代
自己以前怎麼錯。第 8 行:// The first version precached '/console' under a hand-written cache name;第 30 行:// 中文: 舊版把 '/console' 用手寫的 cache 名字 cache-first 快取住。
兩個命中,都是註解。
探測把自白讀成再犯。
再往回看一步:167f96c7 同一個 commit,既重寫了 sw.js(把 /console 從程式碼搬進註解),
也新增了這條測試。測試從出生那一刻就紅,從沒綠過。Day 11 說「Day 9 的測試,今天沒動」——
它沒動是真的,它從來沒對過也是真的。三份紀錄(Day 11 兩份 log、今早全量)都死在同一行:97:5。
Day 9 的意圖沒錯:cache 名字要綁 build、HTML 不能 cache-first、signer 不能被 cache。
現在的 sw.js 三件都做到了。錯的是探測,所以改探測(在 2d062f40):
code_only()(p9_the_shell_cannot_pin_itself.rs:78):剝掉整行是 // 的行,code(:148)。the_probe_reads_code_not_confessions(:89)。原版那行const SHELL = ['/console', …] 必須抓得到;只有註解提到 '/console' 的必須抓不到;而且const ASSETS 還要在——證明剝掉的是註解,不是程式碼。一條會抓錯東西的探測,跟一條什麼都抓不到的探測,是同一種東西。所以這條紅證不是可有可無:
它是這個測試第一次證明自己看得見。
09:52 跑過,3/3 綠(redproof-tests.log 第 184、186、187 行):the_probe_reads_code_not_confessions
0.015 s、the_html_shell_is_not_served_cache_first 0.025 s、the_cache_name_is_bound_to_this_build
0.026 s。10:45 lib commit 之後再跑一次,還是 3/3(targeted-fix.log:354/356/357)。sw.js 一個字沒改。
第三種收工。
Day 11 寫了兩句:
時間斷言,兩次嘗試都超過 200 ms——跟 main 第四層同一條測試,這次量到的是 syspolicyd
佔著 40% CPU 的這台 Mac。
search_fts5_p99_latency| 機器,不是程式
兩句都錯。今天派一個 agent 去讀兩份 Day 11 log 跟 20:10 的 junit,駁斥者再讀一次;
早上全量收工再多一份,09:52 紅證又多一份:
full21-before.log:4029 TRY 1 … panicked at tests/skill_rpc_skills.rs:449:9
full21-before.log:4050 TRY 2 … panicked at tests/skill_rpc_skills.rs:449:9
targeted-after.log:3327 TRY 1 → :449:9
targeted-after.log:3348 TRY 2 → :449:9
full22-after.log:3755 TRY 1 → :449:9
full22-after.log:3776 TRY 2 → :449:9
redproof-tests.log:419 TRY 1 → :452:9 (2d062f40 在上面加了三行)
redproof-tests.log:440 TRY 2 → :452:9
八次 panic,同一行。那一行是(現在的 :452):
assert_eq!(status, StatusCode::OK, "query {uri} failed: {body}");
它在那個跑 100 次查詢的迴圈裡面。第 0 次查詢(q=token42)就拿到 401,body 是同一行
「bad or missing X-Cluster-Auth」。算 p99 的那幾行(:460-464)一次都沒跑到。八次 panic
實測落在 0.846 到 1.046 秒之間——一條要種 1000 筆、打 100 次的測試,一秒就死了,它根本
沒開始量。
所以它不是「時間斷言量到機器」。它是 (a) 的第 12 條,同一個 handler、同一個雙重驗章。skill_rpc_skills 這個 binary 15 條測試,junit 說 12 條紅,12 條 body 全是 401——Day 11 的
表寫「11 條是另一件事」,少的那一條就是它。
原本 Phase D 要先確認 nextest.toml 的 serial-latency 群組對這條有生效。有:filtertest(/latency|_p99_|under_\d+ms/) 對這個名字命中三次;而 TRY 1 / TRY 2 這兩行只會出現
在 retries = 1 生效的測試上,retries 只寫在那個 override 裡(nextest.toml:49)——三份 log
只有它有 TRY 行。重試本身就是它在群組裡的證據。
順便查到另一條:list_endpoint_meets_500ms_perf_gate_for_1000_skills 也斷言時間(500 ms,skill_rpc_skills.rs:411),但名字裡沒有 latency、_p99_、under_\d+ms 任何一個,
filter 沒命中它,它一直排在平行池裡跑。filter 補了 meets_\d+ms|_perf_gate_
(nextest.toml:39);09:52 的紅證裡它第一次有 TRY 1/TRY 2,放寬生效。它今天也是 12 條裡
的一條——一樣死在 401(:404:5),不是 500 ms;所以它也從沒量到過。
還有一件:這兩條測試綠的時候什麼數字都不留,只有紅的時候才印。改成綠也 eprintln!
一行 PERF_RECORD …(skill_rpc_skills.rs:409、:460),綠燈紀錄要能寫「p99 = 幾 ms」,
不能只寫「在預算內」。
10:45 的 targeted 裡它們已經綠了——search_fts5_p99 PASS 12.131 s、list_endpoint PASS 0.938 s
(targeted-fix.log:373、:365),兩條都沒有 TRY。但 nextest 預設不印綠測試的 stderr,PERF_RECORD
那行沒進 log。所以 Phase D 照原計畫單獨再跑一次,加 --success-output immediate
(phaseD-latency.log,HEAD c2a59c5b,14:31:29 → 14:32:00):
Finished `test` profile in 0.70s (第 132 行;lib 沒重編——F0 生效)
Starting 2 tests across 1 binary (13 tests skipped) (第 135 行)
PASS [ 0.968s] list_endpoint_meets_500ms_perf_gate_for_1000_skills (第 136 行)
PERF_RECORD list_endpoint_1000_skills elapsed_ms=11 (第 145 行)
PASS [12.075s] search_fts5_p99_latency_under_200ms_with_1000_rows (第 147 行)
PERF_RECORD search_fts5_p99 p99_ms=180 min_ms=58 p50_ms=79 max_ms=180 (第 156 行)
Summary [13.044s] 2 tests run: 2 passed, 13 skipped (第 159 行)
EXIT=0
p99 = 180 ms。 200 ms 的預算沒動(f3fcb84f 沒碰 nextest.toml,也沒碰測試檔)。機器狀態:
執行紀錄 14:32 那列寫 load 1.69、syspolicyd 0%、六個 subagent 剛起跑;我沒另外量。list 那條 11 ms
對 500 ms。
所以「機器,不是程式」這句,在這台 Mac 上從來沒成立過——不是因為今天過了,是因為它以前根本
沒量到 p99。今天過了,而且是在有 agent 在跑的白天過的。這一句以後如果要寫,要有一個 PERF_RECORD
在後面。
那「201 ms」哪來的?W-F 把 PR #371(fix/clippy-1-98-as-chunks → main,head c1ae414c,
main 是 892518cf)最後一組 21 個 check 的 log 逐個讀了(tick4/pr371-checks.md)。三件事。
一、雙重驗章在 main 上不存在。 CI Fast 的 Cargo Tests 用 cargo test -- --test-threads=1
跑同一支 skill_rpc_skills:15 條,14 過 1 紅(pr371-checks.md:114)。紅的那條就是search_fts5_p99_latency,panic 在 :454:5——p99 那行,不是 :449:「got p99=201ms
(min=62ms max=205ms)」(:109-110)。:449 的 assert_eq!(status, 200) 在 CI 過了,
100 個查詢全部 200(:121)。為什麼?gate_api_and_rpc 跟 SELF_GATED_ROUTES 在origin/main 的 core/src 裡 0 處;它們是 dev 分支 Day 9 加的。main 沒有那層 middleware,
就沒有碰撞。所以 Day 11 那句「第四層量到機器」對 CI 是對的——共用的 ubuntu runner、序列跑、
201 對 200;錯的是我拿它解釋這台 Mac 的紅。兩台機器,兩行:這裡 :449,那裡 :454。
今天這台量到 180,CI 那台量到 201——同一條測試、同一個 200 ms 預算,一台過一台不過,這才是
「機器」該有的形狀。整組 21 個 check 裡,(a) 的 401、(b) 的 ConnectInfo 500 一次都沒出現(:250)。
二、Integration Tests 紅在腳本自己的矛盾。 Day 11 寫「Integration Tests 也紅著,今天沒看」。
看了:scripts/integration-test.sh 40 條,38 過 2 紅——/rpc/task/assign auth: expected 401, got 202、no-auth: expected 401, got 200(:40-41)。腳本第 64 行用SPECTYN_ALLOW_EMPTY_CLUSTER_SECRET=1 起 daemon,agents.toml 沒有 cluster_secret;auth_gate.rs:412-417 看到這個環境變數、又沒設密鑰,在 HMAC 檢查之前就回 Ok(403 訊息
在 :419-425)。而同一支腳本第 211-231 行斷言壞 token 跟沒 header 都要 401。兩段是不同
時候寫的:401 斷言是 cc789fb3(04-23),空密鑰豁免是 403915d4(06-12,closes #326)。
兩者從建構上互斥,所以這個 job 在每個 PR 上都紅——#371 第一個 commit 一樣,姊妹 PRdeps/h2-rustsec-2026-0258 一樣(:92);它主要在 PR 上跑(ci-medium.yml:on: pull_request: branches: [main],另外還掛了 workflow_dispatch 可以手動觸發跑三平台
矩陣),main 上沒有紀錄可比(除非有人手動觸發過)。這不是 (a),不是 401——它是一道被自己的
環境變數繞過的閘,跟一條堅持要 401 的斷言,住在同一個檔裡。修法歸 PR 那邊,今天不碰 #371。
三、其他八個紅,沒有一個是這個 PR 造成的。 h2 0.4.14 的 RUSTSEC-2026-0258(Ship Gate
跟 Dependency Audit,PR #369『deps/h2-rustsec-2026-0258』在修——這條查的是 gh pr view 369,
不在 pr371-checks.md 裡)、gitleaks 的 token 權限、GHAS 沒開、runner 磁碟滿、Androidpty_bridge 沒 cfg-gate(08-08 起 main 就紅)、scenario harness 寫 [cluster] secret = 而
struct 欄位叫 cluster_secret、skillbank 兩條測試的 env-var 競態(--test-threads=1
就過)。除了 #369 那條,其餘每一個在 pr371-checks.md 有 log 行號。Day 11 第八盞燈「剝掉一層紅,
下面還有一層」——剝到第四層停;今天知道第四層是 CI 真的,而旁邊還有一整排跟這個 PR 無關、
比它老的紅。
16 條紅裡有 1 條,今天決定不修。它是 core/tests/the_app_only_calls_routes_that_exist.rs 的every_daemon_path_the_app_builds_is_served(第 115 行)。這一節寫它為什麼紅、修好會發生
什麼、為什麼還是留著。
檔案裡有兩條測試。第一條(第 75 行)檢查 endpoint map:daemon_api.rs 的 daemon_route()
承諾的每條路徑,活著的 spectyn serve 都要有。那條今天是綠的。
第二條是 call-site 掃描。先建「活路由表」:serve.rs 與 serve_skillbank.rs 裡每一個.route("…"),加上 commands/http.rs 那張表轉出來的 /api/<a>/<b>。表要超過 80 條,不然
它先說「這個檢查沒有在看它以為在看的東西」(第 117-121 行)。重現出來是 118 條:serve.rs
97 + serve_skillbank.rs 3 + table_paths() 18。
然後讀 app/src-tauri/src/commands/ 底下每個 .rs。只看跟 hub_url 或 api_url( 寫在
同一行的字串(第 142 行;api_url( 在 app 裡 0 處,是死字);字串要以 / 開頭(第 146 行);
路徑段落裡有 {} 的換成 :id(第 153 行);拿去對活路由表。每對一次 checked 加一
(第 156 行)。最後兩條斷言:至少對過 1 條(第 171-172 行),而且沒有對不上的。
它自己的註解說它的工作是什麼(第 164-165 行):2026-09-03 之後 call site 都改走 endpoint
map,所以這條掃描不清點全部,它抓繞過那張表的人。
app 端組 URL 的寫法是這樣(memory.rs:25):
let url = format!("{}/memory/observations", config.hub_url);
這一行有 hub_url,過得了第 142 行。字串是 {}/memory/observations,第一個字是 {,
不是 /。第 146 行跳過它。
commands/ 底下跟 hub_url 同行的字串,每一個都以 { 開頭({}/… 或 {}{}),或者根本
不是路徑。以 / 開頭的:0。所以 checked 是 0,撞第 171 行:
一條跟 hub_url 同行的字面路徑都沒有 —— 這條掃描已經對不上程式碼的形狀了
它紅得對。第 153 行知道路徑中間會有 {},會換成 :id;但開頭那個 {}——放 hub_url 的
那個——在第 146 行就被丟掉,走不到第 153 行。Day 10 寫「留成 404,由 CI 每次指名它」;
Day 11 已經勘誤:它從來沒有指名過任何東西,因為它先撞自己的下限。一個專門抓「繞過那張表的
人」的掃描器,看不到任何一個繞過那張表的人。
修法是一行:在比對 / 之前,先把開頭的 {} 剝掉。不剝,第 153 行的規則也接不上——{}/memory/observations 會被正規化成 :id/memory/observations,少一個開頭的 /,一樣
對不上。
沒有跑。worker 跟駁斥者各自用 Python 逐行重做掃描器的規則(tick4/phaseE-offenders.md、tick4/we_scan_replica.out),數字一致:現況 checked 0;剝掉 {} 之後 checked 12、對不上 8。
對得上的 4 條:cluster.rs:25 的 /api/nodes、health.rs:61 的 /api/status、health.rs:93
的 /api/dashboard/status、provider.rs:204 的 /api/providers/health。對不上的 8 條:
| 檔案:行 | 字串 | 誰有這條路 | 等誰 |
|---|---|---|---|
memory.rs:25 |
"{}/memory/observations" |
只有 core/src/main.rs:244 |
D-memory |
memory.rs:54 |
"{}/memory/observations/stats" |
只有 core/src/main.rs:245 |
D-memory |
memory.rs:73 |
"{}/memory/observations" |
只有 core/src/main.rs:244 |
D-memory |
agent.rs:19 |
"{}/agent/{}/run" |
只有 core/src/main.rs:247;一個活呼叫者 AgentsPanel.tsx:123 |
沾 D-second-daemon |
agent.rs:45 |
"{}/hand/{}/run" |
沒有(main.rs:242 只有 /hands);零呼叫者 |
不等誰 |
networking.rs:13 |
"{}/networking/discovered" |
沒有;零呼叫者;檔案 04-10 之後沒人動 | 不等誰 |
networking.rs:32 |
"{}/networking/routes" |
同上 | 不等誰 |
networking.rs:51 |
"{}/networking/status" |
同上(daemon_api.rs:216-217,290 說它在 main.rs,過期) |
不等誰 |
再多一步——行閘也認 {hub}——會多第 9 條:cluster_peers.rs:415 的 /rpc/events,整個
core 沒這條路由,前端 useClusterPeers.ts:170 用 .catch 吞掉。
所以 Day 11 跟今天早上的目標檔寫的「修好會點名 memory.rs 三條」少算了。是 8 條、3 個
檔案,而且只有 3 條等 memory 的裁定。另外 5 條:1 條沾第二 daemon 那一案,4 條哪個
daemon 都沒有、誰都沒在叫——技術上今天就能刪。但「先清 5 條,讓這條測試紅得剛好只剩 memory.rs
三行」也是一個裁定,不代裁。
「只有 main.rs 有」也要說清楚。main.rs:341-346 那兩個 handler 是 stub:/memory/observations
回 {"observations": []},/stats 回 {"total_observations": 0}。就算那個 daemon 有人跑,
app 拿到的也是空的。
三條指令都在 lib.rs:1344-1346 註冊。誰在叫:
MobileMemory.tsx:98-99、115-116、138:三條都叫。檔頭第 6-7 行寫「no backend change」。MemoryPanel.tsx:307:叫 search_memory。它的清單那一半已經不走這三條——第 165-170 行/memory/… 這個 daemon 沒有、有的是 /rpc/recall,然後用fetchConcept("memory") 走 endpoint map(daemon_route("memory") → /rpc/recall,daemon_api.rs:251)。tauri-compat.ts:135-138、252-255:直接 GET /memory/observations,.catch(() => ({ observations: [] }))。404 變成空清單。這正是測試自己的錯誤訊息tauri-compat.mesh-gaps.test.ts:18-21:一條 vitest,名字叫「search_memory hits the realok: true。它綠,而它看不到後端。三條的驗章也不一樣:get_memory_observations、search_memory 用 bearer(memory.rs:38、77),get_memory_stats 用簽章(memory.rs:55-61)。Day 10 的 handoff 說「memory 還有
direct bearer」,就是這兩條。
(以上 app 端的行號是 agent 的靜態讀取;我只抽查了 memory.rs、agent.rs、networking.rs 那 8 行跟 main.rs 的路由與 stub,其餘未抽查。)
要讓這條測試今天就綠、不用裁定,有三種做法:在掃描器裡加一個「/memory/* 已知,跳過」的
清單;把下限從 1 降到 0;或把 /memory/observations 悄悄改指到 /rpc/recall。三種是同一
件事:告訴一條 ratchet 哪些紅不要報。
測試檔自己第 72-73 行寫了為什麼不行:「Its floor assertion caught that; without the floor
it would have reported clean while seeing almost nothing.」Day 10 也寫過:沒有那條下限,
它會報綠,一路綠下去,而它其實只看到一條。allowlist 是同一個東西的另一個形狀——一條 ratchet
的意義是「程式碼的形狀變了它會紅」;加了 allowlist,它的意義變成「除了我們決定不看的那些,
其他變了會紅」。那不是量測,是宣告。第三種做法 Day 10 已經拒絕過:把一個 404 換成一個形狀
不對的 200,比 404 更難發現。
所以留紅。綠燈紀錄那一行照實寫:1 紅,route ratchet,等裁定。
| 選項 | 動到什麼 | 代價 | 做完之後掃描器 |
|---|---|---|---|
| 刪 | memory.rs(85 行)、lib.rs:1344-1346、mod.rs:25;MobileMemory.tsx 三個呼叫點、MemoryPanel.tsx:307 的搜尋框、tauri-compat.ts 兩處 case、mesh-gaps.test.ts:18-21 那條測試 |
手機版 Memory 面板失去 stats、清單、搜尋三樣;桌面版只失去搜尋框(清單已走 /rpc/recall) |
memory 三條消失;剩 agent 2 + networking 3 |
接 /rpc/recall,誠實改名 |
search_memory 改名(例如 recall_memory),走 daemon_route("memory") + send_signed POST {query, limit};前端把 observations 改讀 hits |
只接得上 1 條。get_memory_observations(不帶 query 的清單)跟 get_memory_stats 在 serve.rs 沒有對應。這兩條回到「刪」或「蓋」。改名是讓讀的人知道拿到的是語意召回,不是觀察清單 |
memory 剩 0-2 條,看另外兩條怎麼裁;agent/networking 同上 |
| 蓋後端 | serve.rs 加 /memory/observations 與 /stats 兩條 .route(、handler、驗章、測試 |
沒有東西可以搬:main.rs:341-346 是 stub。這是定義一個新功能——「觀察」是什麼、存哪裡、要不要像 recall 一樣去識別化——不是修一個 bug。08-06「只有一個後端」的裁定說它要蓋在 serve 上,不是把 main.rs 拉回來 |
memory 三條變綠;agent/networking 同上 |
三個選項共用的第一步都一樣:先修掃描器,而且先紅證——修之前跑一次,拿到那句「一條跟 hub_url
同行的字面路徑都沒有」;修之後跑一次,拿到 8 條名字。第二次的紅才證明它看得到東西。然後
才按裁定處理三條。這一步今天不做:改它要重建 1 支 binary、再過一次 Gatekeeper;而沒有裁定,
修好也只是把一種紅換成另一種紅。
寫這篇的時候,D-memory 還在等操作者。下一篇從全量收工那一刻開始寫。