Repository navigation
fix(drive): stop the watch from wedging on HTTP 409 after an interrupted bulk rename - #137
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
在本機一次改名或搬移大量檔案後,wspc-drive 的同步會永久停住:每次啟動 watch 都在 1~3 秒內結束,
events.log只留下cli error: error: HTTP 409,重開 app、隔幾個小時都一樣,那個資料夾之後的修改全部不會同步。2026-09-24 的personallibrary(約 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)
events.logfiles/movesync started,之後沒有進度,接著watch stoppedHTTP 409每次 move 在 server 端平均要 1,980 ms,而 move 階段 CLI 不回報進度,UI 一直停在
sync started,看起來就像卡住,所以被停掉了兩次。問題出在哪裡
runDriveSyncOnce()拿到 manifest 之後,還沒處理任何檔案就先把新 cursor 寫進 state:原本的意思是「這批變動我已經拿到了」,實際上代表的卻是「這批變動我已經處理完了」。平常一輪 sync 都會跑完,這兩件事是同一件事,所以看不出差別。但只要一輪在中途被停掉,下一輪就從新 cursor 開始,被跳過的變動再也不會出現,CLI 會把 state 裡的舊紀錄當成遠端現況。
當天的順序:
409 VERSION_CONFLICT。接下來輪到第二個問題:
applyRenamesAsMoves()不接住 move 的錯誤,整輪 sync 直接丟出錯誤,watch 結束。state 完全沒變,所以下次啟動配出同一組 move、又 409,永遠重複。同一個原因還有一個比較安靜的後果:只要 sync 在中途中斷(
fetch failed也算),別台裝置改過的檔案可能永遠不會下載下來。修法
1. 整輪跑完才寫 cursor。
被中斷的那一輪,下一輪會重播同一段 delta。重播是安全的:已經寫進 state 的檔案比對結果是
unchanged;server 做完、但 state 沒寫到的 move,會以remove_state(舊路徑)和state_only(新路徑)補上。當天的卡死光靠這一點就不會發生(下面測試裡的moveErrors全部是undefined)。2. move 被拒時不再讓整輪停下來。 retryable(429、5xx、網路)和 auth(401/403)的錯誤維持原本行為往外丟。其他錯誤:
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. 診斷。
error: HTTP 409 (VERSION_CONFLICT)。debug.log新增manifest、move_rejected(含路徑、entry ID、版本、status、code)和manifest_cursor這幾個 event。這個 PR 不修什麼
drive-worker的files/move,大量改名還是很慢,要在 wspc repo 另外處理(例如批次 move API)。.catch(() => undefined)吞掉錯誤,漏掉的變動不會出現在 delta 裡。這次沒有證據顯示發生過,另外追蹤。已知副作用
b921fcc這個 commit 單獨跑npm test會紅:兩個既有測試還在斷言舊行為,要到e12c79f才更新。依照 repo 規則,放寬既有斷言要獨立成 commit,所以不管怎麼排,都會有一個 commit 是紅的。測試
test/handwritten/drive/sync-interruption.test.ts用一個有狀態的假 server(entry ID、路徑、版本、變動紀錄與 cursor,確認規則照 serverfiles-repo.ts)重現當天的時序:四個檔案在本機改名,連續兩輪在 move 做到一半時中斷。改動前,之後每一輪都是:同一個檔案也涵蓋「中斷後遺失遠端修改」。改動前:
sync.test.ts新增的測試涵蓋以下行為:expected '000000000000000007' to be '000000000000000005')expected [ [ +0, 1 ], [ 1, 1 ] ] to deeply equal [ [ +0, 3 ], … ])move_rejected與manifest_cursor兩個 debug eventtest/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
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.