~/blog/claude-code-codex-handoff-protocol

AI Workflow · part 9

[Dev Workflow] 額度斷在半路:讓 Claude Code 跟 Codex 交接任務的 handoff 協議

cat --toc

TL;DR

額度斷在任務半路,蒸發的不是知識庫(codebase 形狀、部署步驟、教訓都還在),是這個任務的 live state:做到哪、排除了哪些死路、背景在跑什麼、下一步跑哪個命令——只活在 context window 裡。修法是把 state 外部化成一個 handoff 檔:一個任務一個,七節,存檔只卡在乾淨可續跑的那一刻(一步做完、剛發射長跑命令、換方向),不定時 dump。鐵律兩條——Done 每項附證據(file:line / commit / output),接手先重跑最後一項證據才往上疊。⚠️ 用 hook 上鎖後踩到一個 bug:「2 小時沒動=可接手」對長跑訓練是錯的,一個好好跑著的 19 小時 job 被標為可接手;staleness 該看 phase 不看時鐘。

白話版:交接班留一張便條,不是把整本病歷重抄一遍

醫院護士換班,會留一張交班便條:幾號床剛打了針、哪個病人在等檢查結果、哪張單還沒回。她不會把整本病歷從頭抄一遍——病歷本來就在那,查得到。要交接的只有「此刻正在發生、下一班要接著做」的那幾件事。

用 AI 寫 code 也一樣。你叫 Claude Code 幫你做一件跨好幾步的活,做到一半,額度突然斷了。你重開一個 session,想接著做——結果它一問三不知。奇怪的是,你的 codebase 一行沒少,筆記還在,git 紀錄好好的。那到底掉了什麼?

掉的就是那張交班便條。做到第幾步、剛剛排除了哪條走不通的路、有個東西在背景跑你在等它、下一步該敲哪個命令——這些不是知識,是「這個任務此刻」的易逝細節。它們只活在對話的 context window 裡,window 一蒸發,新 session 從白紙開始。

這篇講我怎麼替這件事寫了一套協議:把那張便條變成一個檔,讓 Claude Code 跟 Codex 兩個工具讀寫同一份、互相接手彼此做一半的活。然後——好笑的是——協議跑的第一張單就是它自己;跑順了之後,我又用 hook 幫它上鎖,結果早上八點,一個跑得好好的訓練被 hook 準時標成「可接手」。那個 bug 比協議本身還值得講。


額度斷在半路,蒸發的不是知識庫,是任務的 live state

你正做到一半,額度斷了。新開一個 session,一切從白紙開始——即使 codebase 還在、記憶還在、git history 一行沒少。這口氣嚥不下去,但先別急著怪工具,先分清楚到底掉了什麼。

有兩種東西容易被混為一談。一種是 durable knowledge:查得到的、活過一個 session 的東西——codebase 的形狀、某個服務怎麼部署、上次踩過什麼坑學到什麼。另一種是 live state:五個子步驟做到哪、排除了哪兩條死路和為什麼、有個 build 在背景跑你正在等它、下一步是重跑哪一個特定命令。前者是知識,後者不是——後者是「這個任務、這一刻」的易逝細節,只活在 session 的 context window 裡。額度一斷、window 一蒸發,後者就沒了。

想通這一點,一件事也跟著清楚:把知識庫養大,永遠修不好交接。你可以多存十倍的 durable knowledge,還是每個做一半的任務都掉——因為蒸發掉的那個東西從來不在知識庫的守備範圍,它是 state。一旦這樣命名,修法就明顯了:把 state 外部化成檔案,就像你把任何「要活過一個 process」的東西寫進檔案那樣。

好笑的是,起源一點都不高科技。前一天我已經手動做過一份了:一個做一半的 blog 任務,那時是 Codex 在維護,連「done」那行都附了 commit hash。協議沒發明任何東西,只是把我本來就會手動做、卻總有一天會忘記做的那件事,制度化。

存檔要卡在「乾淨可續跑」那一刻,不是每五分鐘 dump 一次

直覺會想 autosave:每幾分鐘 dump 一次 state,穩。這個直覺是錯的,而且錯得會回頭咬你。

定時器什麼時候響,是它自己決定的——通常正好在你想到一半、計畫還沒成形的時候。它寫下的那個檔,是一張困惑的快照:半成形的做法、一個你正要跑但還沒跑的命令、一個你再 30 秒就要推翻的假設。接棒的人——不管是新 session 還是另一個 CLI——會把你當下的不確定,當成你已經下的決定,原封不動繼承下去。

state-transition 存檔剛好相反:只在該存的時刻才存,存下來的都是整理好、下一個人能直接續跑的狀態。一個步驟做完後(附上證據:file:line、commit、命令輸出)、剛發射一個長跑命令後(檔案記一句「這個在飛,這樣收」)、換方向時(檔案永遠不會宣稱一條死路還活著)。定時器不知道現在是不是這種時刻——這就是全部的差別。

定時器在你想到一半時開火,存下一張困惑的快照(半成形計畫、還沒跑的命令、快被推翻的假設);state-transition 存檔卡在乾淨的時刻(步驟完成、發射長跑命令、換方向),存下的是可續跑的狀態。

一個任務一個檔,七節,Done 沒證據就不算數

格式很土:一個任務一個檔,~/.claude/handoffs/<slug>.md,固定七節。

  • frontmatter:task / status / owner / updated / project / ticket
  • Goal:「done」的定義,一兩行講完
  • Now:此刻在飛什麼、怎麼收
  • Done:做完的每一項,附證據——file:line、commit、或命令 output
  • Next:接下來每一項都自包含,不看對話也能照著跑
  • Gotchas:下一個 owner 會重新踩到的雷
  • Resume:literally 第一個要敲的命令

Codex 經一個 symlink(~/.codex/handoffs 指向 ~/.claude/handoffs)讀寫同一個目錄,兩個 CLI 共用一份 source of truth。任務結束就把檔移進 archive/

真正做掉大半工作的,是兩條規則。

第一,Done 沒證據就沒價值。「修好 parser」是一句宣稱;「修好 parser,parser.py:88,commit a1b2c3d,fixtures 11/11」才是一份你敢信的 state。差別不在文筆,在你能不能不重讀對話就相信它。

**第二,接手的人往上疊之前,先重跑最後一項 Done 的證據。**你不繼承前朝「它會動」的一面之詞;你親手敲它附的證據命令、看它 pass,才往上加東西。

所以接手時固定做四步:讀全文 → 把 owner 設成自己、順手 bump updated(這一步就是防兩個 agent 同時動的鎖)→ 重跑最後一項 Done 的證據 → 從 Next 第一項開始。

背景 AI 不會告訴你它卡死了:三個我踩過的雷

把活丟給背景跑的 codex exec,舒服,但有三個雷會讓「看起來在跑」跟「真的在跑」對不上。

一、detached 的 codex exec 要把 stdin 導向 /dev/null 如果 stdin 還開著、又不是接在終端機上就啟動它——背景啟動器常留下這種狀態——Codex 會堵在 Reading additional input from stdin...,等一個永遠不會來的 EOF,永遠不開工。process 不會死,所以從外面看它像在跑。指紋很好認:log 卡在大約 39 bytes,而且那張 handoff 的 owner 一直沒人認領。把 stdin 導向 /dev/null 等於直接餵它一個 EOF;我頭兩張單沒中,純粹是運氣。

二、卡死不會叫醒你。 崩掉的 process 會退出,退出會叫醒你;卡死的只是安安靜靜坐在那。所以每一張丟到背景跑的 codex exec 單,我都加一個啟動確認:大約 45 秒後看它的 log 有沒有繼續更新。「它沒失敗」跟「它有在進展」是兩回事,而背景任務只會主動告訴你前者。

三、「報告說 done」不等於 done。 委派出去的活回報成功時,orchestrator 親手重跑一次驗收命令——fixture、round-trip、或檔案對帳。這次 Codex 回來的數字全對,但我是驗了才知道全對的。報告是一句宣稱;驗收是一個你親手敲下去的命令。

協議跑的第一張單,就是它自己

協議寫好,第一張單我就丟給它自己:「寫那些讓協議誠實的 staleness hook」,交給 Codex。Codex 讀 handoff → 把 owner 設成 codex → 每一步附證據 → 收 done,全程沒有人工插手。我獨立重驗它的 fixture:5/5、8/8、11 pass。同一天晚上五張過夜訓練單也全程 handoff 驅動,每一張我都先驗前一個 owner 的證據才動手。

這批 hook 補的,就是大家最容易忘記的三個時間點:

  • 開工忘記開檔——open-nudge:你摸了三個不同的檔卻還沒有一張 active handoff 時,提醒你一次。
  • 中途忘記更新——stale-check:20 分鐘沒動就推你一下。
  • 收攤忘記存——stop-guard:handoff 還 stale 就擋一次 session 結束。

最後那個是我唯一允許 exit 非零的腳本。「拒絕讓你停下來」是一把大槌,所以它節流得很兇、也尊重 stop-loop guard——絕不會把你困在原地出不去。

早上八點,一個跑得好好的 19 小時 job 被標為「可接手」

有天早上,session 開場摘要把一個過夜的 GPU 訓練標成「⚠️ 可接手」。那個 run 好好的,還有大約 19 小時要跑。我第一反應是去翻 hook,以為它壞了。hook 也好好的。

兇手是我自己。是我在協議裡用白話寫的一條規則:「owner 不是你、而且 updated 落後超過 2 小時 → abandoned,可接手」。

這條規則對互動式的活是對的:一個兩小時沒碰 handoff 的 session,幾乎一定死了。但長跑命令把這個假設整個顛倒過來——它的重點就是沒人會去碰那個檔,而且一碰就是好幾個小時。規則把「安靜兩小時」讀成「死了」;可是對訓練來說,「安靜兩小時」等於「正照計畫在跑」。

同一個訊號「兩小時沒更新」,意思隨 phase 相反:互動式 session 代表死了、可接手;還剩 19 小時的長跑訓練代表正照計畫在跑。只看時鐘的 stale 規則把長跑誤標成可接手,修法是讓 staleness 看 phase、不看時鐘。

hook 沒有 misfire。它忠實地執行了一條壞規則。這就是把 doctrine 自動化最不舒服的地方:它不會替你把錯的地方圓過去,只會放大你的錯,還準時幫它排程。一條你在對話裡會隨口自己修正掉的規則,一旦進了 hook,就變成早上八點對著一個跑得好好的 job 準時開火的東西。

還有第二個徵兆,我一直忽略它:上面那個 stdin 雷,在我的 log 裡躺了整整一個月。每個 session 都「知道」這件事——直到有一個 session 不知道。說穿了,一個只活在 log 裡的教訓,離「被重新忘掉」只差一個不走運的 session。

先修規則再修 hook:staleness 從看時鐘變成看 phase

改 hook 是修症狀。bug 是那條規則,所以要動的是規則。「stale」不再是一個純時鐘函數,它變成 phase 的函數。

frontmatter 因此長出一個 phase:running_external | waiting_user | blocked | working,再配一個 expected_until timestamp——ISO 8601、帶明確的 UTC offset;少了它,「逾期」就沒有一個明確的比較基準。終態也從光禿禿一個 done,擴成 done | failed | cancelled | superseded

session 開場摘要也改成按 phase 顯示:長跑 job 還沒到 ETA,就顯示 🏃 長跑中,不標可接手;只有過了 expected_until 才標,而且措辭是「需要看一下」而不是「拿去」;waiting_userblocked 的 job 也從隱形變成可見。

有沒有修對,看 fixture 輸出就知道:裡面再也 grep 不到「可接手」三個字。誰接手還是由人決定,owner 欄位只拿來當 routing hint。昨天被標可接手的那同一個 19 小時 run,現在讀起來是:gx-fit-ladder-launch … 🏃 長跑中(ETA 2026-07-11T17:55:00+08:00)

順帶一提,現在判斷 staleness 一律只看 frontmatter 的 updated。之前發現兩個 hook 在打架:一個看檔案 mtime、一個看 updated,某個實際的 handoff 這兩個值差了兩小時。Codex 做這張單時跑了對抗式自審,自己抓到兩個 medium bug(切目錄時 counter 重複計數、fractional timestamp 的契約不一致),先寫紅燈 fixture、再修。

教訓不是「學到」就結束:它有四階,大多數卡在第一階

這兩天貫穿始終的,其實是同一句話:一個教訓,不是「學到」就結束了。它有四階,而大多數教訓一輩子卡在第一階。

  1. Logged——寫在一個你找得到的地方(那個 stdin 雷就在這一階躺了一個月)。
  2. Documented——進了手冊,一個你會刻意去翻的地方。
  3. Templated——正確的做法變成預設,焊進工單模板,讓你想忘也忘不掉(< /dev/null、sandbox 的 --add-dir、45 秒確認器,現在綁成固定的一組)。
  4. Enforced——hook 讓錯的做法變得不可能,或至少變得很吵。

一條每個 session 都會重讀的 log,跟一個每個 session 都會用的模板,不是同一回事。stdin bug 明明「logged」了一個月,還是咬了我——因為 logged 跟 enforced 之間,差了整整三階。而 false-takeover bug 也是同一類問題,只是又多一層:規則就算寫下來,還是可能是錯的;把錯的規則自動化,只會讓系統更快照著錯的方式跑。

收工前照著跑一遍:六條

  1. 外部化 live state,不只是知識。 知識庫活過 session,任務的 live state 不會。寫進檔案。
  2. state-transition 存檔,絕不定時。 一步做完、發射長跑命令後、換方向時——絕不在想到一半的時候。
  3. 有證據才算數。 每項 Done 帶 file:line / commit / output;下一個 owner 疊上去之前,先重跑它。
  4. 卡死的背景 AI 不會告訴你。 每個 detached run 啟動後先看 log 有沒有繼續更新,stdin 從 /dev/null 讀。
  5. 先修規則,再修 hook。 自動化做了蠢事,先看它是不是忠實執行了一條壞規則——通常就是。
  6. 把教訓推到 enforcement。 Logged 只是四階的第一階;真的重要,就焊進模板或 hook,否則你離重學它一次,只差一個不走運的 session。

常見問題

AI CLI 的額度斷在任務做一半,會丟掉什麼?
不是知識庫。codebase 形狀、部署步驟、學過的教訓這些查得到的東西都還在。丟掉的是任務的 live state——五個子步驟做到哪、排除了哪兩條死路、背景有個命令在跑你在等它、下一步該跑哪個命令。這些只活在 session 的 context window 裡,window 一蒸發就沒了。修法是把 state 寫進一個 handoff 檔。
handoff 檔什麼時候該存?
卡在 state-transition,不是定時。一步做完後、剛發射一個長跑命令後、換方向時——這些時刻存下來的檔,本來就是整理好、可以直接續跑的。絕不要 autosave:定時器通常在你想到一半、計畫還沒成形時開火,寫下的是一張困惑的快照,接手的人會把你的不確定當成決定繼承下去。
Claude Code 跟 Codex 怎麼共用同一份 handoff?
一個任務一個 markdown 檔放在 ~/.claude/handoffs/,Codex 經 symlink(~/.codex/handoffs 指向同一目錄)讀寫同一份,兩個 CLI 一份 source of truth。接手四步:讀全文、把 owner 設成自己並 bump updated(這就是防兩個 agent 同時動的鎖)、重跑最後一項 Done 的證據、從 Next 第一項開始。
為什麼一個跑得好好的訓練 job 會被 hook 標成「可接手」?
因為那條規則是「owner 不是你、updated 落後超過 2 小時就算 abandoned」。這對互動式工作對,但長跑命令的重點就是沒人會去碰那個檔好幾個小時。hook 沒有 misfire,它忠實執行了一條壞規則。修法是先改規則不是改 hook:讓 staleness 看 phase(running_external 沒到 expected_until 就不算 stale),不看時鐘。

接著讀

不想錯過新文章?

訂閱我確保不漏接!

隨時一鍵退訂。