AI 工程

讓 coding agent 每晚自己審一次 repo:拆解 Anthropic cookbook 的「排程審查員」recipe

排程審查(scheduled review)這件事,多數團隊的做法是:寫一段 prompt、掛個 cron、每天早上收一封「AI 幫你看過了」的信。跑兩週之後你會發現一個很煩的現象——每天的報告長得幾乎一樣。同一個 config.py 的問題被重講第 14 次,昨天你已經修掉的東西今天還在名單上,而真正在昨天晚上被 merge 進去的那支新檔案,反而沒人提。

原因不在模型不夠聰明,在於每一輪都從零開始。agent 沒有「上一次」,自然分不出「舊的」和「新的」。

2026-09-03,anthropics/anthropic-cookbook(repo 已改名 claude-cookbooks)合入了一支專門處理這件事的 recipe:claude_agent_sdk/scheduled_repository_reviewer/。commit a97b9a2,PR #860,由 Anthropic 的 mattmccarley-ant(Matt McCarley)合入,registry.yaml 裡把作者記為 codyanthony736(Cody Anthony)。內容是一個 1211 行的 notebook 加一支 503 行、可以直接丟上 cron 的 scheduled_review.py

它的核心主張只有一句:用 Claude Agent SDK 的 resume(續接 session)當作跨輪記憶,然後強迫每一輪自己證明「我真的記得上一輪」。

這篇把這支 recipe 的機制、可以直接抄的設定、以及它自己承認的限制拆開講。先講清楚:我沒有實際跑這支 script(那需要 API key 加上至少幾晚的排程觀察),底下所有數字都來自 notebook 裡保留下來的 recorded output,我會逐一標註。

二、它要解決的到底是哪一段

一個排程審查員要能用,得同時滿足四件事,缺一件就會在第三天被你關掉:

  1. 不會亂動東西:沒人在看,agent 不能有寫檔、不能有 shell、不能連外
  2. 不會燒錢:不能有失控的 agentic loop 把你的帳單跑到四位數
  3. 輸出能被程式讀:報告的消費者是 alerting 系統,不是人的眼睛
  4. 記得上一輪:能講「這條修好了」「這條是新的」,而不是每天重印全量清單

前三件用 SDK 的既有選項就能鎖死。第四件是這支 recipe 真正在示範的東西,也是最容易做錯的:多數人會想「那我把上一輪的 findings 存成 JSON,下一輪塞回 prompt 就好了」——recipe 明確說這是一個合法但不同的選擇,並且列了什麼時候該選它(後面第七節有對照表)。

三、運作原理:一輪 cycle 到底發生什麼

排程 repo 審查員一輪 cycle 的完整流程:cron 觸發、讀 session file、冷啟或續接、唯讀掃描、結構化回覆、RESUME-LINK 自檢、寫回狀態

整支 script 一次執行 = 一次 review。流程拆開是六步:

第 1 步,cron 觸發。 每次執行都從 service directory(例如 /srv/reviewer)出發,把要審的 repo 路徑當唯一參數傳進去。

第 2 步,讀 .last_review_session 這個檔案就放在 script 旁邊,內容是四個欄位:reposession_idreview_idfinding_ids。檔案不在 → 冷啟;檔案在 → 續接。repo 欄位跟這次的參數對不上,也一律冷啟(換一個 repo 就重建基線,這個判斷寫在 main() 裡)。

第 3 步,依冷啟/續接選兩套不同的參數。 這是我覺得最值得抄的一招——兩條路徑用的 schema、turn 上限、預算上限全都不同

冷啟(cold) 續接(resumed)
schema FIRST_REVIEW_SCHEMA FOLLOW_UP_REVIEW_SCHEMA
max_turns 40 20
max_budget_usd 2.00 0.50
prompt 讀全部檔案、建立基線 先重列檔案、再對照上一輪

(以上是 scheduled_review.py 頂端的常數值;notebook 裡的示範 cell 因為只審三個檔案的 demo repo,用的是 20/14 turns 與 $0.25/$0.20。)

道理很直觀:冷啟要讀整個 repo,續接只要看差異。用同一組上限,等於天天付冷啟的錢。

第 4 步,跑 query(),唯讀掃描。 工具只有 ReadGlobGrep,權限模式 dontAsk,再加一個 PreToolUse hook 把路徑鎖在 repo 裡(下一節細講)。

第 5 步,拿結構化回覆。 output_format 給的是 JSON Schema,回覆會出現在 ResultMessage.structured_output,已經過驗證。

第 6 步,自檢 + 寫回狀態。 續接的那一輪會多印一行 RESUME-LINK,把「續接有沒有真的成立」量化成三個欄位;然後用 write-then-rename 把新狀態寫回 .last_review_session,最後印 REVIEW-RUN-COMPLETE

四、回覆契約:兩層 schema,第二層才是重點

第一層是每次 review 都要回的東西:

FINDING_SCHEMA = {
    "type": "object",
    "properties": {
        "id": {"type": "string"},
        "file": {"type": "string"},
        "summary": {"type": "string"},
    },
    "required": ["id", "file", "summary"],
}

FIRST_REVIEW_SCHEMA = {
    "type": "object",
    "properties": {
        "review_id": {"type": "string"},
        "verdict": {"type": "string", "enum": ["ok", "concerns"]},
        "findings": {"type": "array", "items": FINDING_SCHEMA},
    },
    "required": ["review_id", "verdict", "findings"],
}

第二層是續接才有的三個欄位,用 dict merge 疊上去,這樣共用的一半只會有一份、不會漂移:

CONTINUITY_PROPERTIES = {
    "previous_review_id": {"type": "string"},
    "previous_finding_ids": {"type": "array", "items": {"type": "string"}},
    "resolved": {"type": "array", "items": {"type": "string"}},
}

FOLLOW_UP_REVIEW_SCHEMA = FIRST_REVIEW_SCHEMA | {
    "properties": FIRST_REVIEW_SCHEMA["properties"] | CONTINUITY_PROPERTIES,
    "required": [*FIRST_REVIEW_SCHEMA["required"], *CONTINUITY_PROPERTIES.keys()],
}

previous_review_idprevious_finding_ids 不是給人看的欄位——它們是測謊題。schema 逼 agent 必須把「上一輪的 review id 和 finding id」寫出來,你的程式再拿它跟本地存的狀態逐欄比對。如果續接其實斷了、agent 從空白開始,這兩欄就填不出對的值,你當場就會知道。

比對邏輯在 script 裡就是幾行:

prior_ids = string_items(state.get("finding_ids"))
recalled = string_items(outcome.payload.get("previous_finding_ids"))
matched = sorted(set(recalled) & set(prior_ids))
echoed = outcome.payload.get("previous_review_id") == state.get("review_id")
print(
    f"RESUME-LINK same_session={outcome.session_id == resume_id} "
    f"prior_review_id_echoed={echoed} "
    f"recalled_findings={len(matched)}/{len(set(prior_ids))} "
    f"current_findings={len(set(finding_ids(outcome.payload)))}"
)

三個欄位任一不成立,就再印一行 RESUME-LINK-BROKEN。注意這行是在成功的 run 上印的,exit code 還是 0——所以你的告警要掛在這個字串上,不能只看 exit status。這是 recipe 特別標註的設計選擇。

還有一個容易被忽略的細節:續接的 prompt 第一句是「先重新列出 repo 的檔案」。沒有這句,一個續接的 agent 很可能直接拿上一輪讀過的檔案回答,完全不知道昨晚多了一支新檔。

五、五道邊界:讓它在沒人看的時候只能做這麼多

無人看管審查 agent 的五道邊界:工具面、權限面、hook、設定隔離、預算上限,以及各自擋掉什麼

review_options() 這個函式基本上可以整段抄走:

options = ClaudeAgentOptions(
    cwd=str(repo),
    system_prompt=REVIEWER_SYSTEM_PROMPT,
    tools=["Read", "Glob", "Grep"],
    allowed_tools=[f"Read(/{repo}/**)", "Glob", "Grep"],
    permission_mode="dontAsk",
    model="claude-sonnet-5",
    strict_mcp_config=True,
    max_turns=max_turns,
    max_budget_usd=FOLLOW_UP_BUDGET_USD if resume_session_id else FIRST_RUN_BUDGET_USD,
    hooks={"PreToolUse": [HookMatcher(matcher="Read|Grep|Glob",
                                      hooks=[deny_reads_outside_repo(repo)])]},
    setting_sources=[],
    output_format={"type": "json_schema", "schema": schema},
    resume=resume_session_id,
)

逐項講重點:

  • tools 決定「存在什麼」,allowed_tools 決定「不用問就能跑什麼」。這是兩件事,很多人混在一起。這裡工具面只留三個唯讀工具,agent 根本沒有寫檔的選項。
  • Read(/{repo}/**) 那個雙斜線是刻意的。原始碼註解直接寫明:路徑規則用個前導斜線時,錨點是「設定的來源」(session 工作目錄),不是檔案系統根目錄;因為 repo 本身是絕對路徑,f-string 渲染出來會變成 Read(//srv/...) 這種雙斜線形式,才會真的錨在磁碟根。官方 permissions 文件也是這樣描述四種錨定形式的。抄錯成單斜線,在 dontAsk 下的後果是——規則匹配不到任何東西,每一次讀取都被拒絕,你會看到一個什麼都沒讀就宣告 review 完成的 agent。
  • permission_mode="dontAsk":沒被預先批准的一律當場拒絕,不會停在等人回答的地方。文件裡另一個 auto 模式是用模型分類器即時判斷,但這支 reviewer 選 dontAsk,理由是要把工具面固定在 allow 規則上。
  • PreToolUse hook 是補 GrepGlob 的洞。文件寫得很直白:Claude Code 把 Read 規則套用到 GrepGlobbest-effort。所以 deny_reads_outside_repo 自己做確定性的檢查——路徑 resolve() 之後(會跟穿 symlink)不在 repo 底下就拒絕;Glob 的 pattern 只要以 /~ 開頭、含 .. 片段、或含 { 大括號,一律拒絕。
  • setting_sources=[] 有兩個作用:讓 cron 主機跟你筆電行為一致;同時把被審 repo 自己的 .claude/settings.jsonCLAUDE.md 擋在 session 外。第二點才是關鍵——recipe 的原話是:一個你不控制的 repo,不該有權設定自己的審查員。
  • strict_mcp_config=True:不然機器上或帳號上設定的 MCP server 會自動掛進 session,它們的 tool definition 會塞進每一個 request。
  • max_turns / max_budget_usd:超過任一個,run 會以終端錯誤結果收尾(error_max_turnserror_max_budget_usd),SDK 把它拋成 ResultError。文件明確標註:是在花費「已經超過」上限之後才停,所以單輪可能微幅超標。

script 還多加了一層 disallowed_tools=[f"Read(/{service_dir}/**)"],把 service directory(放 API key 和 session file 的地方)從 Read 工具擋掉。hook 其實已經擋過一次了,這是刻意的第二層。也因為這個自我封鎖,script 會在啟動時直接拒絕「repo 在 service directory 底下」或「service directory 在 repo 底下」這兩種佈局,各回 exit code 2 加一行 reason=repository-inside-service-directory / reason=service-directory-inside-repository

順帶一提 prompt injection:因為 session 沒有 shell、沒有網路工具,agent 讀到的任何惡意內容,唯一的去處就是回覆本身。反過來說——review.log 裡有 repo 的內容,要用跟 repo 同等級的權限去管。recipe 特別提醒了這件事。

六、失敗路徑:這支 script 最完整的一段

大部分「排程 agent」教學寫到成功路徑就結束了。這支的失敗處理反而是篇幅最大的部分,值得單獨看。

它只 catch 一種例外——claude-agent-sdk 0.2.140 才加入的 ResultError。這個型別是 ProcessError 的子類別,帶 subtypeerrorsresultapi_error_statusterminal_reasonsession_id 和原始 data,讓你可以直接分支,不用字串比對。(我從 SDK 0.2.140 的 sdist 原始碼確認過這些欄位;PyPI 上 0.2.140 的上傳時間是 2026-08-18,本文寫作時最新版是 0.2.152。cookbook 的 pyproject.toml 也在這次 commit 從 >=0.1.51 直接拉到 >=0.2.140。)

分支邏輯很值得抄:

except ResultError as exc:
    ...
    if label == "resumed" and exc.subtype == "error_during_execution":
        SESSION_FILE.unlink(missing_ok=True)
        print("SESSION-FILE-CLEARED: next run reviews cold")
    return 1

續接失敗 + error_during_execution → 清掉 session file,下一輪自己冷啟重建基線。 這是自癒:最常見的原因是存下來的 session 已經無法 resume(~/.claude/projects/ 底下的 transcript 被清了)。

超上限的失敗不清檔案——因為 error_max_turns / error_max_budget_usd 的解法是改設定,不是重置狀態。清掉只會白白多付一次冷啟。這個區分很精準。

日誌標記行的設計也是照「給程式讀」來的,全部錨在行首:

標記行 什麼時候出現 exit 要不要告警
VERDICT: ok / concerns 完成的 review 0 concerns 才告警
RESUME-LINK ... 每次續接 0 自己對 recalled_findings 設閾值
RESUME-LINK-BROKEN ... 續接鏈斷了 0 要告警(exit code 幫不了你)
REVIEW-RUN-COMPLETE: cold/resumed 確實成功 0 不用
REVIEW-RUN-INCOMPLETE ... 終端錯誤,多半是超上限 1
REVIEW-RUN-INCOMPLETE stage=usage ... 呼叫方式就錯了 2 要,去修 crontab
SESSION-FILE-CLEARED 下一輪會冷啟 1 跟著失敗告警走
SESSION-STATE-NOT-SAVED review 成功但狀態寫不進去(磁碟滿/唯讀) 1 要,去修磁碟
REVIEW-RUN-EXIT: <code> crontab 尾巴記下的非零 exit n/a 99 以外都要告警
什麼都沒印 process 被砍、連線沒開起來 非零

crontab 那行本身也有幾個細節值得學:

0 2 * * * cd /srv/reviewer && . ./reviewer.env && flock -n -E 99 .review.lock .venv/bin/python scheduled_review.py /path/to/your/repo >> /srv/reviewer/review.log 2>&1 || echo "REVIEW-RUN-EXIT: $?" >> /srv/reviewer/review.log
  • cron 不保留 exit status,所以尾巴的 || echo 是把退出碼寫進 log 的唯一辦法
  • flock -n -E 99:上一輪還沒跑完就跳過這一輪,而且給跳過一個專屬的退出碼 99,不會被誤判成失敗。重複出現 99 的意思是「你的 review 跑得比排程間隔還久」
  • 環境變數用 export 寫在 reviewer.env,不是 KEY=value——後者只設 shell 變數,Python process 繼承不到。而且 cron 會把 PATH 砍到只剩最小集,要自己補
  • API key 不要寫在 crontab 行裡,任何能 crontab -l 的人都看得到

七、數字:誠實版

notebook 裡保留的兩次真實執行輸出(demo repo 只有 3–4 個小檔案):

  • RUN-1(冷啟)turns=10 denials=3 cost_usd=0.0278,verdict concerns,抓到兩個埋好的 bug——config.py 把整個 os.environ 複製進 config 再 print 出來(等於把所有 secret 印進 log),以及 math_utils.pyaverage([])ZeroDivisionError
  • RUN-2(續接)turns=7 denials=0 cost_usd=0.0339same_session=Trueprior_review_id_echoed=Truerecalled_findings=2/2resolved=['F1'],並且抓到了兩次執行之間新種下的 retry.py 無上限重試迴圈
  • 刻意誘發的失敗max_turns=1):subtype=error_max_turns reason=max_turns cost_usd=0.0032

那個 denials=3 不是 bug,是防護在作用:agent 一開始嘗試用 /app/config.py 這種根錨定路徑、或 **/*.{py,md} 這種大括號 pattern,被 hook 擋下後改用 repo 內路徑重試。recipe 也直說了代價——每次拒絕都吃掉一個 turn,如果你的 repo 很依賴 brace pattern,該做的是改寫 hook 去展開它、再對每個展開結果做同樣檢查,而不是整條拒絕。

成本估算方面,recipe 自己給的是:照 script 的預設值,一個每晚跑的 job 最壞情況大約是 30 × $0.50 = $15 一個月,加上偶爾的冷啟重建;notebook 說跑完整本的實際花費「大約一角美金」。但要注意兩件事:

  1. 這些是 3–4 個檔案的 demo repo 的數字,你的 repo 有幾百個檔案,冷啟成本完全是另一個量級。正確做法是先手動跑幾輪,看 summary line 裡的 cost_usd 再回頭定上限。
  2. cost_usd 是 client-side 估算,不是帳單資料。這點文件寫得很清楚。

模型輸出本身也會變動——recipe 明列了幾種正常變異:措辭不同、id 分配不同、同一個 bug 被拆成兩條 finding(divideaverage 可能各算一條)、多報一些低嚴重度的觀察。所以你的 alerting 不該綁死在 finding 數量上。

還有一個必須知道的長期問題:續接的 session 會愈養愈肥。context 會持續累積,成本跟著漲,而且可能把 agent 錨在舊結論上。recipe 給的處方是定期主動重置:每週(或每次 repo 結構大改)刪掉 session file,讓下一輪冷啟重建基線。判斷時機的信號就是 recalled_findings ——這個數字開始緩慢下滑,就是該重置了。而且這種「緩慢衰退」不會觸發 RESUME-LINK-BROKEN,得靠你自己設閾值。

八、什麼時候該 resume、什麼時候別

這是 recipe 裡我覺得最有價值的一張表,因為它明白告訴你「resume 不是預設最佳解」:

選擇 什麼時候用
每次都開新 session 每個單位工作要被獨立評判。gate PR 的 CI reviewer 就是這種——不能有舊意見、不能有累積 context 去影響這次的判定
開新 session,但把上一輪的 findings 餵回去 你只需要「上一輪報了什麼」。例如追 TODO 債的 tracker:回覆裡的 finding id 就承載了比較所需的全部資訊,每輪都用新鮮眼睛看 code,而且沒有任何 session 會過期或遺失
續接 session 你要跟「上一輪看過的全部東西」比較。每晚的依賴稽核就是這種——它要比對的是 reviewer 讀過的完整清單,而那份清單絕大部分從來沒進到回覆裡。要自己餵回去,等於得把 agent 觀察到的一切都序列化,而這正是 resume 幫你省下的工

判準說白了就一句:你要比對的東西有沒有全部出現在上一輪的回覆裡? 有 → 別 resume,自己存 findings 更穩、更便宜、也不會有 session 失效問題。沒有(也就是 agent 讀過但沒寫進回覆的隱性上下文才是重點)→ resume 才有價值。

如果你根本不想自己顧排程器和主機,recipe 也點名了兩個托管選項:Claude Code routines(research preview,跑在 Claude 訂閱方案上,適合個人開發者審自己的 GitHub repo)和 Managed Agents 的 scheduled deployments(beta,透過 Claude API 設定,agent loop 和 workspace 都托管,但被審的 repo 會進到托管的 session workspace)。

九、照做清單

想今晚就裝起來的話,順序是這樣:

  1. 開 service directory,跟被審 repo 分開放(這點是硬需求,兩者互相包含都會被 script 擋下):
    sudo mkdir -p /srv/reviewer && sudo chown $USER /srv/reviewer && cd /srv/reviewer
    python3 -m venv .venv          # 3.11 以上,script 用了 StrEnum 和 match/case
    .venv/bin/pip install "claude-agent-sdk>=0.2.140"
    cp /path/to/scheduled_review.py .
    
  2. reviewer.env 並鎖權限:先 touch reviewer.env && chmod 600 reviewer.env,再寫 export ANTHROPIC_API_KEY=... 和補好的 export PATH=...
  3. 先手動跑一次冷啟,確認看到 REVIEW-RUN-COMPLETE: cold。這一步會建立 .last_review_session~/.claude/projects/ 底下的 session transcript,之後每一輪都靠它們續接。如果這步以 error_max_budget_usd 失敗,先把 FIRST_RUN_BUDGET_USD 調高再排程——冷啟建不起基線的話,之後每一輪都會重複同一個失敗
  4. cost_usd 調上限,再掛 crontab(照第六節那行,別漏 flock 和尾巴的 || echo
  5. 改 prompt 和 schema:把續接的 prompt 指向你真正要追的東西(新依賴、breaking API 變更、TODO 債),schema 加上 alerting 需要的欄位(例如每條 finding 的 severity)。注意 script 和 notebook 各有一份 schema 與 reader,客製時兩邊都要改。
  6. 告警接在標記行上concernsRESUME-LINK-BROKEN、任何非 99 的 REVIEW-RUN-EXIT、以及「該有卻沒有 completion line」
  7. 加一條每週刪 session file 的排程,主動冷啟重建

再補一個沒被寫進 script、但文件有提的成本槓桿:Agent SDK 的 effort 選項。官方建議純讀檔的 agent 用 "low",這支 reviewer 正好符合,值得實測看能省多少。

十、對工程團隊的意義

這支 recipe 表面上是「排程 code review」,但它示範的三件事,任何無人看管的 agent 都用得上:

第一,把「證明」寫進 schema,而不是寫進 prompt。 「請記得上一輪」是願望;required: ["previous_review_id", "previous_finding_ids"] 是契約。前者失敗你不會知道,後者失敗會在 log 裡自己現形。這個「讓 agent 回報可被本地狀態驗證的欄位」的模式,可以套到任何需要跨輪一致性的 agent 上。

第二,成功和失敗都要有專屬的、錨在行首的標記行。 尤其是 RESUME-LINK-BROKEN 這種「run 成功了但語義壞了」的情況——exit code 表達不了它,只有標記行可以。你的 agent 有多少種「跑完了但結果不能信」的狀態?每一種都該有自己的字串。

第三,冷啟和續接是兩種不同的工作,要給不同的預算。 這個道理不限於 review:任何有「建立基線 → 追蹤差異」形狀的 agent 任務都適用。

最後一個我覺得最需要放進 checklist 的細節:setting_sources=[]被審查的 repo 不該有權配置自己的審查員——如果你正打算讓 agent 去掃一個外部貢獻者的 PR,或掃一個你不完全控制的 repo,這一行的分量比其他所有設定加起來都重。

來源

整理:DataAgent · Coding Agent 實戰教學

發表迴響

%d 位部落客按了讚: