Skip to content

fix(drive): stop the watch from wedging on HTTP 409 after an interrupted bulk rename - #137

Merged
yurenju merged 9 commits into
mainfrom
fix/drive-move-409-wedge
Sep 25, 2026
Merged

yurenju merged 9 commits into
mainfrom
fix/drive-move-409-wedge

Conversation

@yurenju

@yurenju yurenju commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

在本機一次改名或搬移大量檔案後,wspc-drive 的同步會永久停住:每次啟動 watch 都在 1~3 秒內結束,events.log 只留下 cli error: error: HTTP 409,重開 app、隔幾個小時都一樣,那個資料夾之後的修改全部不會同步。2026-09-24 的 personal library(約 5,300 個檔案)從 08:58 UTC 卡到 23:39 都沒有恢復。

背景

wspc drive watch 每一輪 sync 會先向 server 拿「遠端現在長什麼樣」(manifest),跟本機檔案和 .wspc-drive/state.json(上次同步時每個檔案在 server 上的版本)比對,再決定每個檔案要上傳、下載或刪除。為了不每次都抓整份清單,CLI 會記一個 cursor(server 變動紀錄的書籤),下一輪只拿「書籤之後的變動」(delta),沒變的部分用 state 裡的紀錄補齊。

本機改名時,CLI 看到「舊路徑不見了、新路徑多一個內容一樣的檔案」,會呼叫 server 的 move API,並送出 state 裡保存的 entry ID 和 Entry Version 當作確認。server 發現確認對不上就回 409 VERSION_CONFLICT。依照 backend confirmation spec,CLI 不能改拿 server 上的最新值重送,也不能改用「上傳新檔+刪舊檔」。

當天發生了什麼(Workers Logs)

時間(UTC) events.log server 上的 files/move
08:46~08:52:25 sync started,之後沒有進度,接著 watch stopped 成功約 186 次
08:52:29~08:57:39 同上,又被停掉 成功 138 次
08:58 之後 每次都 HTTP 409 0 次成功、43 次 409

每次 move 在 server 端平均要 1,980 ms,而 move 階段 CLI 不回報進度,UI 一直停在 sync started,看起來就像卡住,所以被停掉了兩次。

問題出在哪裡

runDriveSyncOnce() 拿到 manifest 之後,還沒處理任何檔案就先把新 cursor 寫進 state:

manifest = await fetchRemoteManifest(root, state, syncApi, summary, blockedPaths, debug)
...
if (excludeRules.size === 0 && manifest.manifestCursor !== undefined && manifest.manifestCursor !== state.manifest_cursor) {
  state = { ...state, manifest_cursor: manifest.manifestCursor }
  await writeDriveState(root, state, clock)
}
// 接著才做 move,以及逐檔處理

原本的意思是「這批變動我已經拿到了」,實際上代表的卻是「這批變動我已經處理完了」。平常一輪 sync 都會跑完,這兩件事是同一件事,所以看不出差別。但只要一輪在中途被停掉,下一輪就從新 cursor 開始,被跳過的變動再也不會出現,CLI 會把 state 裡的舊紀錄當成遠端現況。

當天的順序:

  1. 第 1 輪最後一個 move 在 server 已經做完,但 CLI 還沒寫 state 就被停掉。
  2. 第 2 輪的 delta 帶回了那個 move,本來應該在逐檔處理時補上。可是它先把 cursor 往前推,然後花 5 分鐘做剩下的 138 個 move,還沒輪到逐檔處理就又被停掉。
  3. 第 3 輪從新 cursor 開始,看不到那個 move。state 仍說檔案在舊路徑,本機只有新路徑,CLI 就再送一次 move。server 上那個檔案早就搬走了,回 409 VERSION_CONFLICT。

接下來輪到第二個問題:applyRenamesAsMoves() 不接住 move 的錯誤,整輪 sync 直接丟出錯誤,watch 結束。state 完全沒變,所以下次啟動配出同一組 move、又 409,永遠重複。

同一個原因還有一個比較安靜的後果:只要 sync 在中途中斷(fetch failed 也算),別台裝置改過的檔案可能永遠不會下載下來。

修法

1. 整輪跑完才寫 cursor。

// The cursor is persisted only after every change it covers is applied; an
// interrupted round replays the same delta next time instead of skipping it.
if (!roundCompleted) {
  debug.log("manifest_cursor", { action: "keep", reason: "round_interrupted" })
} else if (excludeRules.size === 0 && view.manifestCursor !== undefined && view.manifestCursor !== state.manifest_cursor) {
  state = { ...state, manifest_cursor: view.manifestCursor }
  await writeDriveState(root, state, clock)
}

被中斷的那一輪,下一輪會重播同一段 delta。重播是安全的:已經寫進 state 的檔案比對結果是 unchanged;server 做完、但 state 沒寫到的 move,會以 remove_state(舊路徑)和 state_only(新路徑)補上。當天的卡死光靠這一點就不會發生(下面測試裡的 moveErrors 全部是 undefined)。

2. move 被拒時不再讓整輪停下來。 retryable(429、5xx、網路)和 auth(401/403)的錯誤維持原本行為往外丟。其他錯誤:

  • 這輪用的是 delta:先停下剩下的 move,重抓一次完整清單再重新配對。如果清單顯示舊路徑已經不在,這組就不再算改名。如果還是配成同一組,送出的仍是 state 裡的原確認(有測試鎖住)。
  • 這輪用的已經是完整清單:跳過這一組(不上傳、不刪除),在新路徑上記一筆 path error,例如 VERSION_CONFLICT: move from a.md rejected (HTTP 409),其他檔案照常同步。

為什麼要先重抓完整清單,不直接跳過:被拒最常見的原因就是 delta 的遠端現況過期,完整清單可以讓它在同一輪修好。如果只是跳過,watch 在一輪正常結束後不會自己排下一輪,要等下一次檔案變動才會再試。

為什麼不記成 conflict:這個 repo 的 conflict 指內容衝突,會產生 conflict copy 並寫進 state.conflicts。這裡內容沒有衝突,只是改名的確認失敗。

3. move 算進進度。 onProgress 在 move 開始前就回報 (0, total),total 包含 move 的數量,大量改名時不會再長時間停在 sync started。

4. 診斷。

  • CLI 最外層的錯誤會帶上 server code:error: HTTP 409 (VERSION_CONFLICT)。
  • debug.log 新增 manifest、move_rejected(含路徑、entry ID、版本、status、code)和 manifest_cursor 這幾個 event。

這個 PR 不修什麼

  • server move 要 2 秒。 瓶頸在 drive-worker 的 files/move,大量改名還是很慢,要在 wspc repo 另外處理(例如批次 move API)。
  • 單一檔案的永久錯誤。 如果某個檔案在一輪裡記成 path error,但整輪有跑完,cursor 仍會前進。所以萬一剛好是一個遠端變動下載失敗、而且是非 retryable 的錯誤,那個變動不會在下一輪重現。這次只處理「整輪中斷」。
  • server 通知遺失。 server 寄出變動通知時用 .catch(() => undefined) 吞掉錯誤,漏掉的變動不會出現在 delta 裡。這次沒有證據顯示發生過,另外追蹤。

已知副作用

  • 如果 server 對同一份確認一直拒絕(非 retryable 的 4xx),每一輪都會多兩次 move 請求和一次完整清單分頁。代價是真的,但換來的是不會永久卡住。
  • b921fcc 這個 commit 單獨跑 npm test 會紅:兩個既有測試還在斷言舊行為,要到 e12c79f 才更新。依照 repo 規則,放寬既有斷言要獨立成 commit,所以不管怎麼排,都會有一個 commit 是紅的。

測試

test/handwritten/drive/sync-interruption.test.ts 用一個有狀態的假 server(entry ID、路徑、版本、變動紀錄與 cursor,確認規則照 server files-repo.ts)重現當天的時序:四個檔案在本機改名,連續兩輪在 move 做到一半時中斷。改動前,之後每一輪都是:

AssertionError: expected [ 'HTTP 409', 'HTTP 409', 'HTTP 409' ] to deeply equal [ 'ok', 'ok', 'ok' ]

同一個檔案也涵蓋「中斷後遺失遠端修改」。改動前:

AssertionError: expected 'v1\n' to be 'v2 from another device\n'

sync.test.ts 新增的測試涵蓋以下行為:

  • 中斷或 state 寫入失敗時,cursor 維持原值(改動前 expected '000000000000000007' to be '000000000000000005')
  • delta 下被拒會重抓完整清單並收斂
  • 完整清單下被拒,只跳過那一組
  • 重抓之後送出的是同一份原確認
  • 重抓之後被拆散的配對,仍會跳過曾被永久拒收的上傳
  • move 階段遇到 429 中止時,重試事件仍帶有「檔案過大」的 path error
  • 進度包含 move(改動前 expected [ [ +0, 1 ], [ 1, 1 ] ] to deeply equal [ [ +0, 3 ], … ])
  • move_rejected 與 manifest_cursor 兩個 debug event

test/cli-error-message.test.ts 經由 dispatch() 對本機假 HTTP server 執行 drive sync once,檢查輸出是 error: HTTP 409 (VERSION_CONFLICT)。

修改的既有斷言放在獨立的 e12c79f,commit message 寫了舊斷言、為什麼錯、新斷言是什麼。

npm run typecheck、npm test(816 passed)、npm run build 都通過。

設計見 docs/superpowers/specs/2026-09-25-drive-move-rejection-wedge-design.md。

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…cted move

The manifest cursor was saved before the round applied the changes it
covers. When a round stopped part way (watch stopped, lost response), the
next delta skipped those changes and the state kept stale entries. A local
rename of such a path then became a move that the server rejected with
409 VERSION_CONFLICT, and every later round stopped on the same move.

- Save the manifest cursor only after the round completes.
- On a rejected move over a delta view, fetch a full manifest once and plan
  the moves again. On a full view, skip the pair and report a path error.
  Never fall back to upload + delete and never replace the confirmation.
- Report progress before the moves run and count them in the total.
- Log manifest, move_rejected, and manifest_cursor debug events.
…ing the round

Two tests asserted that runDriveSyncOnce() rejects when moveFile() fails
with a non-retryable error:
- "stops without upload or delete when move has a permanent failure"
  expected rejects.toThrow("move unsupported").
- "uses the saved identity for sync move ..." expected
  rejects.toMatchObject({ code: "VERSION_CONFLICT" }).

That expectation made the watch stop on every start while the state stayed
the same, so one rejected pair blocked the whole library (see
docs/superpowers/specs/2026-09-25-drive-move-rejection-wedge-design.md).

The new expectation: the round completes and reports one path error on the
destination path with the server code (or DRIVE_PATH_ERROR) and the message
"move from <old path> rejected (...)". The protections the tests exist for
do not change: one move call with the saved identity, no upload, no delete.
The Oversized File and Permanent Upload Rejection checks ran before the
moves and excluded the first plan's pairs. After a full manifest refetch a
pair can drop out of the plan, and its new path then reached the upload
step without the checks, so rejected content was uploaded again.

Run both checks after the moves against the final view and the pairs that
were moved or skipped. Plan no moves when the API has no move call, report
remaining work for retryable move failures, and cover the resent move
confirmation and the manifest_cursor debug events.
A retryable failure during the moves ended the round before the
Oversized File and Permanent Upload Rejection errors were recorded, so the
watch retry event lost them. Record skips for paths outside the planned
pairs before the moves; the check after the moves still covers pairs that
the refetch dropped.
@yurenju
yurenju merged commit 1f37912 into main Sep 25, 2026
6 checks passed
@yurenju
yurenju deleted the fix/drive-move-409-wedge branch September 25, 2026 01:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant