Combining Strategic Cutover Docs with Tactical Headless Orchestrators
缺口:戰略協調有了,戰術執行還在手動驅動 上一篇講了一份 CUTOVER.md 怎麼當跨 session、跨 repo 的控制平面 — 用 8 個 pattern 解決「AI agent 有遺忘症、任務卻沒結束」的協調問題。那套機制解的是戰略層:哪些高階決策被做過、哪個方案被否決、誰改完了等誰配合。 但它有個明顯缺口。Cutover doc 追蹤的是「高階任務」這個粒度 — 例如「後端 dual-write regression 要修」是一個 row。可是這個 row 底下,真正的執行還是我一個 session、一個 session 手動驅動:開 session、貼 pickup prompt、看它跑、它卡住我再 nudge、跑完我再回來 push doc。 換句話說,戰略層自動化了(doc 自己會被各 session pickup),但每個高階任務內部的戰術 loop — 寫測試、跑、看結果、修、再跑 — 還是人在當 orchestrator。 Anthropic 自家在 Building effective agents 這篇 engineering 文章裡定義過一個叫 orchestrator-workers(orchestrator–工人 pattern — 一個中央 LLM 動態拆解任務、派發給 worker LLM、再彙總結果)的工作流:原文寫「a central LLM dynamically breaks down tasks, delegates them to worker LLMs, and synthesizes their results」。配上 Claude Code 的 subagents(子代理 — 由主 agent spawn 的獨立 agent,每個都有自己 isolated context window)跟 headless mode(非互動模式 — 用 claude -p 把 prompt 餵進去、跑完吐結果,不開互動對話),就能補上 cutover doc 的戰術缺口 — 也就是「單一高階任務底下,怎麼讓 AI 自己跑數小時的 autonomous loop(自主迴圈 — 不需要人在中間 nudge,loop 自己 plan / execute / evaluate 直到條件達成)而不撞牆」。 ...
Designing a Control-Plane Document for Async Multi-Session AI Agents
The Problem: AI Agents Forget, but the Task Doesn’t End 技術社群已經習慣「給人類看的 RFC」— Architecture Decision Records、incident postmortem、設計文件,模板都很成熟。但給 AI agent 之間用的非同步協作文件呢?少有公開討論。 最近處理一個跨三個 repo 的 schema migration:後端表結構改、前端 GraphQL 切換、行動端讀寫遷移,估計要 30+ 天,會跨越十多個獨立的 Claude session(不同時段、不同任務切片、不同筆電)。每次 session 結束、下一輪起來,前次的 context window 就消失了。問題不只是「上下文遺忘」,還包括: 上次 session 進度到哪?沒人記得。 之前否決過的方案?新 session 很容易「自信地重新提案」。 跨 repo 的「我改完了、等你那邊配合」訊息,怎麼跨越 session 邊界? 某次踩的坑,下次會不會重蹈? 我們最後長出一份 CUTOVER.md 放在獨立的 metadata repo,作為跨 session、跨 repo 的 out-of-band control plane。實戰一個月後,這份文件結晶出 8 個對 AI agent 特別有效的設計模式 — 它們幾乎都對應到分散式系統的經典概念。 The Architecture flowchart LR subgraph code[Code repositories] R1[backend repo] R2[frontend repo] R3[mobile repo] end subgraph plane[Control plane] DOC[CUTOVER.md<br/>append-only<br/>own repo] end subgraph time[AI sessions over time] S1[t1: backend session] S2[t2: frontend session] S3[t3: mobile session] S4[t4: backend session] end S1 -.modifies.-> R1 S2 -.modifies.-> R2 S3 -.modifies.-> R3 S4 -.modifies.-> R1 S1 -->|pull / push| DOC S2 -->|pull / push| DOC S3 -->|pull / push| DOC S4 -->|pull / push| DOC classDef p fill:#cce5ff,stroke:#007bff,color:#004085 classDef s fill:#d4edda,stroke:#28a745,color:#155724 classDef c fill:#f5f5f5,stroke:#333,color:#333 class DOC p class S1,S2,S3,S4 s class R1,R2,R3 c 每個 AI session 開場先 pull doc → 讀完上下文 → 完成任務 → push update。Doc 自己是獨立 repo,與三個 code repo 解耦,扮演純控制平面角色。 ...
EKS 維運的三個隱形陷阱:工具 Pod 消失、事件蒸發、審計空白
起因:工具 Pod 人間蒸發 某天要查資料庫,照慣例執行 kubectl exec -it psql-client,結果 Pod 不見了。 這個 psql-client 是用來連 RDS PostgreSQL 的工具容器,平常拿它跑 SQL 查詢、檢查資料表大小。問題是——沒有人記得刪過它,也沒有任何記錄顯示是誰或什麼原因讓它消失。 這件事本身影響不大,重建一個就好。但它暴露了三個更深層的問題:為什麼 Pod 會無聲消失?為什麼查不到是誰刪的?為什麼整個叢集沒有留下任何操作軌跡? 陷阱一:裸 Pod 沒有人管它的死活 問題本質 當初建立 psql-client 的指令大概是這樣: kubectl run psql-client --image=postgres:15-alpine --restart=Never -- sleep infinity 這行指令建立的是一個裸 Pod(Bare Pod)——直接建立 Pod 物件,不隸屬於任何 Deployment、ReplicaSet 或 StatefulSet。 裸 Pod 和 Deployment 管理的 Pod,差別在於有沒有 Controller 在背後看著它。Deployment 的 ReplicaSet Controller 會持續監控 Pod 數量,少一個就補一個。裸 Pod 沒有 Controller,它的生死完全取決於節點的命運。 flowchart TD subgraph 節點故障發生 A[Node 異常關機] end A --> B[裸 Pod] A --> C[Deployment Pod] B --> D[Pod 永久消失] D --> E[無人知曉] C --> F[ReplicaSet Controller 偵測] F --> G[在其他 Node 重建 Pod] G --> H[服務自動恢復] classDef default fill:#f5f5f5,stroke:#333,color:#333 classDef error fill:#f8d7da,stroke:#dc3545,color:#721c24 classDef success fill:#d4edda,stroke:#28a745,color:#155724 classDef info fill:#d1ecf1,stroke:#17a2b8,color:#0c5460 class A info class D,E error class G,H success 不處理會怎樣 裸 Pod 在以下情境會永久消失: ...
LLM 驅動的 E2E 驗證:為什麼 bot 比人更容易揭發 backend regression
前言:frontend 修補的驗證最常被推給人手點 修了一個 frontend modal 的提交守門 bug,把改動推上 STG(staging 環境),下一步「驗證」往往就是寄訊息給 QA 或產品經理:「請開瀏覽器點點看,看 UI 守門有沒有生效。」 這個步驟在 CI 跑完單元測試後通常還是被當成人類的責任,原因是 E2E(end-to-end,端到端)環境難搭、寫一次 Playwright 腳本要花的時間比人手點還久、而且 UI 本來就會變動。 但隨著 LLM agent 配上瀏覽器自動化能力,這個 trade-off 已經悄悄反轉了。Bot 不只能省掉人手點擊的時間,更會做一件人類常常忘了做的事:在 UI 看似成功之後,去資料庫驗證資料真的寫對了。 這篇文章記錄一次真實的 STG 驗收,原本要驗 frontend 修補,卻意外揭發 backend 的 dual-write regression(雙寫機制失效)。 Playwright MCP 是什麼,為什麼是它打破僵局 先把兩個關鍵字釐清: Playwright 是微軟釋出的瀏覽器自動化框架,原本是寫 JavaScript 或 Python 腳本控制 chromium。 MCP(Model Context Protocol) 是 Anthropic 推的開放協議,把外部工具用宣告式 schema 包成 LLM 可呼叫的 server,類似 LSP(Language Server Protocol)之於編輯器——讓不同 IDE 共用同一個語言分析後端。LLM 客戶端啟動時會跟 MCP server 握手取得可用 tool 清單,之後對話中就能像呼叫 function 一樣直接用。 ...
Schema 主表翻轉的 dual-write 過渡:一場不能 stop-the-world 的搬家
引言:當業務主表需要翻轉 某個 SaaS 系統長期以「客戶資料表」為核心:所有訂單、文件、操作紀錄都用客戶 ID 當外鍵。但這個客戶表的資料來源是外部 POS 系統匯入,每天同步幾百筆,schema 由廠商定義。 業務發展後問題浮現:自己 SaaS 的會員表(users)才是真正的「人」 — 有登入、有偏好、有應用內行為。新功能(個人化推薦、訂閱管理、社交綁定)都需要以 users 為主軸。 於是有了一個經典的 schema migration 需求:主表翻轉(primary table pivot)。把業務邏輯的中心從 customers(外部 POS 匯入)轉到 users(SaaS 自家會員),但歷史資料、新進資料、系統相依、回滾風險全部都要顧到。 名詞解釋 開始拆解之前,定義幾個會反覆出現的詞: 詞 定義 主表翻轉(Primary table pivot) 業務主要 entity 從表 A 改成表 B Dual-write 寫入時同時寫舊欄位 + 新欄位,回滾安全 Partial cut-over 分階段切換,read 跟 write 不同步切 Hard cut-over(stop-the-world) 一次切完,downtime 短但 risk 大 Backfill 歷史資料補齊新欄位的 batch update Idempotent migration 重跑無副作用,cron / retry 安全 DISTINCT ON PostgreSQL 專屬去重 syntax,搭 ORDER BY 取每組第一筆 Pseudo entity 為了統一查詢介面而建的「假」實體 Link table ORM 多對多 join 表(user_id + entity_id) 為什麼選 dual-write 而非 stop-the-world 最直接的搬家方式是「stop-the-world cut-over」:選一個維護窗口,停寫入、跑 batch script、改完所有 reference、開機。但這個 SaaS 的條件不允許: ...
可列印 A4 報表的 CSS 全攻略:頁碼、跨頁表頭、列印隔離
問題現場:想做一份「能印」的網頁報表 需求很常見:後台要一頁會計報表(明細列表 + 合計列),會計同事要能直接從瀏覽器按列印、出 A4。沒人想為這種小需求引入 PDF library 或產 PDF 的後端服務 —— 瀏覽器列印就好。 寫下去才發現坑滿地:頁碼從哪來?跨頁時表頭會不會消失?頁面外面那層 admin sidebar、左側選單會不會也跟著被印出來?螢幕上預覽是黑背景白字,印到紙上會變什麼樣? 這些問題的答案幾乎都不在「常見 CSS 教學」裡,而是藏在一份很少人讀的 W3C 規範:CSS Paged Media Module。本文用一份實際做出來的會計報表為例,把這些技巧串起來。 為什麼不選 PDF library? 開發者第一個反射動作往往是「裝個 jsPDF / pdfmake 直接產 PDF」。在跳進那條路之前,先看清各方案的代價: 方案 中文字型 排版精度 Bundle 適用情境 CSS @page(本文) ✅ 系統字型 高(向量字) 0 KB 後台報表、現場列印、人工流程 html2canvas + jsPDF ⚠️ 需嵌字型 中(光柵化) ~300 KB 簡單表單下載 pdfmake / React-PDF(向量 PDF) ⚠️ 需嵌字型 高 ~500 KB+ 給外部讀者的下載檔 後端 Puppeteer / WeasyPrint ✅ 系統字型 最高 0(前端) 批次寄信、加密簽章 前端 PDF library 最大的痛點是中文字型。系統列印免費借用作業系統字型;jsPDF 預設只內建 Helvetica,要中文不亂碼就得把整套思源黑體(壓縮後仍有 5-10 MB)打包進 bundle,否則出現方塊或缺字。 ...
ORM 在騙你:當 populate / include 悄悄失效
症狀:資料明明在,前端就是拿不到 一個 CMS 系統的文件列印預覽,每份文件 header 都固定顯示「上傳者:—」。DB 裡明明有資料,API 回 HTTP 200 OK,回應裡的其他欄位(日期、文件類型、附件)都對——唯獨 uploadedBy 是 null。 沒錯誤、沒警告,伺服器 log 也乾淨。前端碰到 uploadedBy?.realName || '-' 就乖乖畫 dash。使用者以為這份文件沒指派擁有者,作者本人以為畫面壞了。大家都錯,但系統看起來好好的。 這是最難纏的 bug——靜默失敗。 先看 ORM 層在整個架構哪裡 要理解為什麼這類 bug 發生、也為什麼解法是「繞過 ORM」,先把架構分層看清楚: flowchart TD A[前端 / Client<br/>Vue, React, App] --> B[Web / API 層<br/>Controller, Route, 權限檢查] B --> C[業務邏輯層<br/>Service / Use Case] C --> D[ORM 層 ⭐<br/>Strapi Entity Service, Prisma, TypeORM,<br/>Mongoose, SQLAlchemy ORM] D --> E[Query Builder 層 可選<br/>knex, SQLAlchemy Core] E --> F[DB Driver<br/>pg, mysql2, mongodb] F --> G[資料庫<br/>PostgreSQL, MongoDB, ...] classDef default fill:#f5f5f5,stroke:#333,color:#333 classDef orm fill:#d1ecf1,stroke:#17a2b8,color:#0c5460 classDef db fill:#d4edda,stroke:#28a745,color:#155724 class D orm class G db 為什麼 bug 只會發生在 ORM 層? ...
Strapi v5's Silent populate Failure with Relation Filters
Symptom: A Relation Everyone Believes Is Empty A document management print preview kept rendering “Uploader: —” for every report. The database had the data. The API returned HTTP 200 OK. Yet the uploadedBy relation came back as null, with no error or warning anywhere. Users assumed reports had no uploader assigned. Authors assumed the UI was broken. Nobody suspected the API, because it was perfectly silent. Where the Bug Lives in the Stack Before the curl session, it helps to fix where the failure happens: ...
Strapi 忘記密碼的安靜回應:Anti-Enumeration、Phishing-as-a-Service 與撞庫經濟學
問題現場:一個「成功」卻收不到信的忘記密碼 某個週一下午,QA 回報:在一個 Flutter + Strapi 架構的 App 上,用測試帳號 testuser@example.com 點「忘記密碼」,畫面跳出 Success: Reset password link sent successfully on your email account.,但信箱(包含垃圾郵件匣)一封信都沒有。 直覺先排除幾個明顯可能:SMTP 設定壞掉、用戶被 block、信件被 Gmail 擋。但真正的答案比這些都有趣 — 這不是 bug,是 Strapi 一個刻意的安全設計,只是 App 前端把它翻譯錯了。 kubectl 診斷:20ms 與 800ms 的兩種人生 進 Strapi pod 撈 log,找 forgot-password 請求: kubectl logs -n default strapi-prod-xxxxxxxxx-xxxxx --since=6h \ | grep -E 'forgot-password|Reset password URL' 抽出的關鍵幾行: 04:04:52 info: Reset password URL generated: https://example.com/... 04:04:53 http: POST /api/auth/forgot-password (948 ms) 200 05:15:44 http: POST /api/auth/forgot-password (20 ms) 200 05:16:53 info: Reset password URL generated: https://example.com/... 05:16:54 http: POST /api/auth/forgot-password (664 ms) 200 05:17:30 http: POST /api/auth/forgot-password (26 ms) 200 一眼看出兩種節奏:800ms 級別的請求伴隨「Reset password URL generated」log,而 20-30ms 的請求則完全沒有。同一支 API,為什麼耗時差 30 倍? ...
頭貼切不回去?一個 Bug 揪出 Admin Panel 權限漂移的跨後端通病
症狀:選完手機照片,畫面卻還是貼圖 使用者回報的步驟很簡單:點頭像 → 選「從手機圖庫」→ 選張照片 → 儲存。跳回會員中心,頭像還是原本的系統貼圖。 再點進去編輯頁,看到的又是手機照片。明明存成功了,為什麼 UI 顯示不一致? 這個 bug 表面上是前端畫面問題,但用 log 逼出來的真相,藏在三層之外——包括一個會在幾乎所有 admin-panel 型後端都出現的經典陷阱。 偵錯:跟著 log 走,別信錯誤訊息 第一步是打開前端 log 重現流程。關鍵幾行: StickerService -> Deactivating all stickers StickerService -> Error: status code of 403 StickerService -> Error Response: {status: 403, message: Forbidden} StickerService -> Deactivate all failed: You do not own this sticker 最後這句「You do not own this sticker」幾乎讓我停在錯誤方向——去查使用者到底擁有哪幾個貼圖。但翻前端程式碼才發現:那不是後端回的訊息,是前端 _handleDioError 把「403 Forbidden」硬翻譯成這句。 **這是第一個教訓:HTTP 狀態碼與文字訊息之間的語意對應是會說謊的。**真正的錯誤只有一個字——Forbidden。跟 ownership 無關,純粹是權限問題。 第一層根因:Admin Panel 的權限設定漂移 翻 Strapi admin → Settings → Users & Permissions → Roles → Authenticated → Sticker section,發現 deactivateAll 這支 route 的權限沒勾。 ...