本日核心價值 (Core Focus): 用外層 Orchestrator 包住
codex exec:pytest 失敗就讀失敗訊息、做最小修正、再跑測試,最多三輪,並用git diff大小當保險絲,避免模型無限改檔。
概念說明與實戰情境 (Overview)
單元測試能指出「哪裡壞了」,但不會自己修好。人在迴圈裡反覆看 traceback、改程式、再跑 pytest,成本高且容易改過頭。Self-Correction Loop 把這件事變成可重現的工作流:測試當 oracle,Codex 當修補器,外層腳本當守門人。官方行為很明確:codex exec 預設是 read-only sandbox,要改檔必須加上 --sandbox workspace-write。CI 裡不要用 danger-full-access。迴圈不是讓模型自由發揮,而是「失敗 → 讀錯誤 → 最小修正 → 重跑 → 三輪後停」。
關鍵操作與範例 (Implementation & Example)
先確認 Codex CLI 已安裝,且工作目錄是 Git repo(codex exec 預設要求 Git,避免在非版本控制目錄做破壞性變更)。修補迴圈拆成兩層:
| 層級 | 職責 | 硬限制 |
|---|---|---|
| 內層 Prompt | 跑測試、讀失敗、最小修正、再跑 | 不重構無關檔、不加依賴 |
| 外層 Orchestrator | 呼叫 codex exec、檢查 exit code |
max_iters=3、diff 行數上限 |
| Sandbox | 控制能否寫檔 | 本機修補用 workspace-write;CI 禁止 danger-full-access |
官方要記的三件事:
codex exec 預設 read-only,只讀不寫。--sandbox workspace-write。舊的 codex exec --full-auto 仍能跑,但是相容路徑,會印警告。--ask-for-approval never,不要在 CI 開 --sandbox danger-full-access 或 --yolo。為什麼要外層腳本,而不是只對 Codex 說「修到 pytest 全綠為止」?因為 codex exec 是一次非互動行程:你給 Prompt、它在 sandbox 裡做完就結束。把「最多三輪」只交給模型,等於把停止條件建立在不可靠的指令遵從上。外層只認兩件事:pytest 的 exit code(成功條件),以及 git diff --numstat 的加總行數(變更預算)。MAX_DIFF_LINES=120 不是魔法數字——小型函式錯誤通常幾十行;一次改超過 120 行,幾乎一定夾帶格式化或無關重構。門檻可依倉庫調整,但必須是數字,而且超標要還原工作區,而不是帶著超大 diff 進入下一輪。
內層 Prompt 必須把「停止條件」寫死,否則模型會一直「再試一次」。建議固定如下:
Run pytest -q.
If tests fail: read the failure output, apply a minimal fix, re-run pytest -q.
Do not refactor unrelated files. Do not add new dependencies.
Stop after this iteration even if tests still fail; the outer loop will retry.
外層不要把「最多三輪」只寫在 Prompt 裡;模型可能忽略。用腳本強制截斷,並在每次 codex exec 後量 git diff。超過門檻就還原工作區,當作失敗退出。
Bash 版(Linux / WSL / Git Bash):
#!/usr/bin/env bash
set -euo pipefail
MAX_ITERS=3
MAX_DIFF_LINES=120
PROMPT='Run pytest -q. If tests fail, read the failure, apply a minimal fix, re-run pytest -q. Do not refactor unrelated files. Stop after this iteration.'
if pytest -q; then
echo "tests already pass"
exit 0
fi
for i in $(seq 1 "$MAX_ITERS"); do
echo "=== iteration ${i}/${MAX_ITERS} ==="
# Default exec is read-only; workspace-write is required to edit files.
codex exec --sandbox workspace-write --ask-for-approval never "$PROMPT"
changed=$(git diff --numstat | awk '{s+=$1+$2} END {print s+0}')
if [ "$changed" -gt "$MAX_DIFF_LINES" ]; then
echo "diff too large: ${changed} lines > ${MAX_DIFF_LINES}" >&2
git checkout -- .
exit 2
fi
if pytest -q; then
echo "tests pass after self-correction"
exit 0
fi
done
echo "still failing after max_iters=3" >&2
exit 1
等價 Python 版,方便加日誌與之後接到 CI:
#!/usr/bin/env python3
"""pytest -> Codex exec -> git diff size guard. max_iters=3."""
from __future__ import annotations
import subprocess
import sys
MAX_ITERS = 3
MAX_DIFF_LINES = 120
PROMPT = (
"Run pytest -q. If tests fail, read the failure, apply a minimal fix, "
"re-run pytest -q. Do not refactor unrelated files. Stop after this iteration."
)
def run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(cmd, text=True, capture_output=True)
def pytest_ok() -> bool:
result = run(["pytest", "-q"])
sys.stdout.write(result.stdout)
sys.stderr.write(result.stderr)
return result.returncode == 0
def diff_line_count() -> int:
result = run(["git", "diff", "--numstat"])
total = 0
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit():
total += int(parts[0]) + int(parts[1])
return total
def main() -> int:
if pytest_ok():
print("tests already pass")
return 0
for i in range(1, MAX_ITERS + 1):
print(f"=== iteration {i}/{MAX_ITERS} ===")
exec_result = subprocess.run(
[
"codex",
"exec",
"--sandbox",
"workspace-write",
"--ask-for-approval",
"never",
PROMPT,
]
)
if exec_result.returncode != 0:
print("codex exec failed", file=sys.stderr)
return exec_result.returncode
changed = diff_line_count()
if changed > MAX_DIFF_LINES:
print(
f"diff too large: {changed} lines > {MAX_DIFF_LINES}",
file=sys.stderr,
)
run(["git", "checkout", "--", "."])
return 2
if pytest_ok():
print("tests pass after self-correction")
return 0
print("still failing after max_iters=3", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
本機先用一個會失敗的測試驗證迴圈:例如把 assert add(1, 1) == 2 寫成 == 3,確認 Codex 只改計算函式、不順便重排整個套件。Windows 原生環境若 sandbox 行為與 Linux 不同,優先在 WSL 跑同一支腳本,避免「本機能改、CI 不能改」的落差。
三輪之後仍紅燈,不要自動 git commit。正確收尾是留下 pytest 輸出與(未超標的)diff,開 issue 或交給人看。常見不是模型不夠聰明,而是測試在描述錯誤行為、或缺環境變數;這時再改產品程式,只會把錯誤規格寫死。Self-Correction 應接在 Day 11 的測試產出與 Day 13 的 stack trace 對齊之後:先有穩定、決定性的測試,迴圈才有資格當修補器。若測試會打真實資料庫或外部 API,先改成 fixture,否則模型可能「修」成跳過測試或改 timeout。CODEX_API_KEY 只包住單次 codex exec,不要 export 成整個 shell 的環境變數,這點與明天的 GitHub Actions 相同。
若要把迴圈接進 Makefile 或 pre-push hook,記得它會花 API 額度與時間:本機對失敗測試跑最多三輪是合理的;對「每次存檔」觸發則不合理。先手動確認 pytest 真的紅、失敗訊息穩定,再啟動腳本。Orchestrator 本身用一般 Python / Bash 測試即可(mock codex exec 回傳碼與假 diff),不必為了測迴圈而每次打真實模型。
把迴圈當成「有預算的修補器」,不要當成無人值守的自動合併。第一輪失敗時先讀 pytest 最後數十行,分辨是 assertion 還是 import/環境問題;後者應停下來補 fixture,而不是讓模型改產品程式。第二輪若 diff 仍集中在同一個函式,通常才是真修補;若開始改測試名稱或刪掉 assertion,立刻 git checkout -- . 丟掉。第三輪仍紅就輸出「未修復」報告:失敗測試名稱、三輪各自的 numstat、最後一次 traceback。這份報告可接到明天的 CI artifact,而不是把半成品推進 main。Windows 上若 sandbox 支援不完整,改在 WSL 跑同一條 bash,不要為了「能改檔」改開 danger-full-access。
注意事項與常見失敗 (Pitfalls)
--sandbox workspace-write:codex exec 預設 read-only,看起來有跑、檔案卻沒變,pytest 會一直紅。修法:修補任務顯式開 workspace-write;純審查維持 read-only。max_iters 只寫在 Prompt:模型可能連跑十次或中途放棄。修法:外層 for 迴圈硬停在 3,Prompt 只負責「這一輪」。--numstat 加總超過門檻就 git checkout -- . 並以非零結束。danger-full-access:拿掉 sandbox 等於讓模型指令碰到 runner 上的 secrets 與網路。修法:CI 預設 read-only;需要寫檔時最多 workspace-write,而且金鑰不要設成 job 層環境變數。git status --porcelain 必須是空的,或先 stash。本日總結 (Takeaways)
codex exec 預設 read-only;要改檔用 --sandbox workspace-write,CI 不要用 danger-full-access。max_iters=3 與 git diff 行數上限必須由外層腳本執行,不能只寫在 Prompt。明日預告 (Next)
明天把同一套「測試當閘門、Codex 當檢視器」搬進 CI/CD Pipeline:用 GitHub Actions 在 Pull Request 上自動跑 pytest,並以 read-only 的 codex exec 產出程式碼檢視摘要。