Skip to content

feat(drive): stop re-uploading permanently rejected files and ignore excluded watch events - #134

Merged
pocketsharkk merged 4 commits into
mainfrom
claude/ship-wspc-todo-e2f42e
Sep 18, 2026
Merged

pocketsharkk merged 4 commits into
mainfrom
claude/ship-wspc-todo-e2f42e

Conversation

@pocketsharkk

@pocketsharkk pocketsharkk commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

使用者遇到的問題

wspc-drive library 裡有一個 291 MB 的 heart-rate.jsonl,server 每次都以 HTTP 413 拒收。wspc drive watch 沒有記住「這份內容已被拒收」,每 4–6 秒就把整個檔案重新讀一次、重傳一次,三天累積 444 次,頻寬與 CPU 一直被吃掉。更糟的是,Finder 開著資料夾會不斷改寫 .DS_Store;即使 .wspc-drive/ignore 已經排除 **/.DS_Store*,watch 仍會因為這些 event 排程新一輪 sync,每一輪都再重傳一次大檔。

背景

問題出在哪裡

  1. 上傳失敗只進了該輪 summary,沒有進 state.json:

    const uploaded = await api.uploadFile(state.library_id, path, body, uploadDigest, action.expectedEntryVersion)

    uploadFile 丟出 DriveHttpError(413) 後,外層 catch 只呼叫 recordDrivePathError()。下一輪 decideDriveAction() 看到的 state 和上一輪一模一樣,仍判定 upload_update,於是重讀、重傳。平常檔案上傳會成功,或失敗是 5xx 暫時性錯誤、本來就該重試,所以「永久失敗也會一直重試」這件事不會被注意到;只有 server 對同一份內容永遠說不的時候才會變成迴圈。

  2. watch 對 excluded path 的 fs event 照樣排程:

    if (isInternalSyncArtifactName(basename(path))) return
    dirtyPaths.add(drivePath)
    scheduleSync(debounceMs, "local")

    Exclude rules 只在 sync 內部生效。當初認為這類 sync 只是 no-op 所以沒關係,但只要有一個永久失敗的檔,每次 no-op 都變成一次整檔上傳。

修法

判定(retry.ts):DriveHttpError、400 <= status < 500、不是 401/403/408/409/429、code !== "VERSION_CONFLICT" 才算 Permanent Upload Rejection。本機讀檔錯誤、local file changed after scan 不是 DriveHttpError,自然排除。

記錄(path-executor.ts):只包住 api.uploadFile() 那一行,讓讀檔錯誤不可能被誤判:

try {
  uploaded = await api.uploadFile(...)
} catch (error) {
  if (!isPermanentUploadRejection(error)) throw error
  await recordDrivePathError(summary, undefined, path, error, { debug, op: "process" })
  return { state: await rememberUploadRejection(root, state, path, uploadDigest, error, clock), stop: false }
}

rememberUploadRejection() 會確認 scan_cache[path].sha256 等於這次上傳的 digest 才記錄,確保 fingerprint 描述的就是剛被拒收的 bytes。欄位逐一挑出而非 spread,因為 isDriveUploadRejection() 採嚴格 key 白名單(比照 scan_errors),多一個 key 下次讀 state.json 就會被拒。先回報 path error 再寫 state:寫入失敗時外層 catch 的 recordDrivePathError() 會因同 path 去重,使用者看到的仍是 HTTP 413。

略過與清除(sync.ts):scan 與 applyRenamesAsMoves() 之後、計算 progress total 之前掃過每筆紀錄:

  • scan_cache 裡的 fingerprint 與紀錄不同、或 path 不在 scan_cache(刪除、改名、被排除)、或 cli_version !== VERSION → 刪紀錄,照一般流程處理。
  • 否則若決策是 upload → 放進 rejectedPaths,以 op: "upload_rejected" 回報同一筆 path_errors(retryable: false),不呼叫 executeDrivePathAction(),也不計入 total。
  • 決策不是 upload(例如 conflict)→ 紀錄不影響本輪。

pendingPaths 取代了原本迴圈裡的 movedPaths.has(path) 檢查,total 與迴圈共用同一份清單,確保 progress 與實際處理一致。

Watch(watch.ts):啟動時載入 exclude rules;fs event 符合規則就不加入 dirtyPaths、不排程。ignore 的 event 先重新載入再排程 full sync。重新載入以 promise chain 串起來,避免兩次快速編輯時較舊的讀取結果晚到、蓋掉新規則;ignore 無法解析時不過濾(sync 會照現行契約回報錯誤)。沿用 exclude-rules.ts 原本的 emptyDriveExcludeRules(改為 export),沒有另寫一份。

與 spec 的差異(已同步更新 spec)

spec 原本寫「上傳成功分支刪除紀錄」與「removeExcludedState() 一併移除紀錄」。實作上這兩處都走不到:會真的去上傳的 path,其紀錄必定已在上述清除步驟因 fingerprint/version 不同被刪;excluded path 不在 scan_cache,同樣由該步驟清除。因此不加這兩段,並在 b5514ec 更新 spec 的 Implementation Decisions 文字。AC 5(排除後清除)與 AC 7(上傳成功後無紀錄)的測試照樣覆蓋這些行為。

這個 PR 不修什麼

  • 大檔 chunked upload 與 413 對應 FILE_TOO_LARGE:tod_01M2S85BY0A38RM37TMNC4KTDY。
  • 永遠只回 5xx 時的 retry cap、重啟 watch 自動重試、conflict 流程中的拒收記憶、UI 重試按鈕:依 spec Out of Scope。

已知副作用

  • 被拒收的檔案在內容、mtime、CLI version 都不變時,會持續以 retryable: false 出現在每輪 path_errors,但不再上傳;wspc-drive 的「1 file needs attention」維持顯示。
  • state.json 新增 optional upload_rejections,schema_version 仍為 1;舊版 CLI 讀到會忽略,回退只會恢復舊的重傳行為。

測試

本機 npm run typecheck、npm test(66 files、786 tests)、npm run build 通過。

AC 測試 改動前的失敗
1、2 sync.test.ts uploads an unchanged rejected file only once across rounds and keeps reporting it AssertionError: expected 3 to be 1(三輪各上傳一次)
9 keeps syncing other files around a rejected file expected [ 'big.jsonl', 'notes.txt', …(2) ] to deeply equal [ 'big.jsonl', 'notes.txt', …(1) ]
3 state.test.ts round-trip 與 rejects malformed upload rejection records;舊 state 由既有 creates and reads empty state 覆蓋 形狀錯誤的紀錄被接受(promise resolved instead of rejecting)
8 retry.test.ts status 表格;sync.test.ts 502/429/408/401/403/VERSION_CONFLICT、本機讀檔錯誤(EACCES)、local file changed after scan isPermanentUploadRejection is not a function
10 watch.test.ts does not schedule syncs for fs events on excluded paths and follows ignore edits expected "vi.fn()" to be called 1 times, but got 2 times
4、5、6、7 retries once after the content changes/only the mtime changes、deleted/renamed/excluded、another CLI version、uploads successfully 與 AC 1 同一個 commit 實作,所以先寫的是 AC 1。改以 mutation 確認這些測試會擋住回歸:拿掉 mtime 比對 → mtime 測試紅;拿掉 version 比對 → version 測試紅;讓判定永遠為 true → 六個非永久失敗測試全紅。

sha256 比對無法經由真實 scanner 單獨觸發(mtime 與 size 相同時 scanner 會沿用 cache 的 hash),保留它是因為 spec 把 fingerprint 定義為這三個欄位。

Review

  • Repository standards:TDD、conventional commits、無 AI co-author trailer、未改 src/generated/、空值用 undefined、rejected_at 走 driveIsoTimestamp(clock),均符合。AC 4–7 屬「先實作後補測試+mutation 驗證」,如上表說明。
  • Governing contract:AC 1–10 均有對應實作與測試;event contract(drive_sync_once、drive_sync_progress、drive_watch_retry、path_errors)與 transient retry 行為未變。唯一差異是上述兩段走不到的清除邏輯被省略,已同步更新 spec。無額外未要求行為。

未完成驗收

  • AC 11:CI 的 typecheck/test/build(本機已通過,等待本 PR head 的 CI)。
  • AC 12(post-release、非 merge gate):CLI 發佈並由 wspc-drive 使用新版後,檢查 ~/.hermes/profiles/pocketshark/library/.wspc-drive/debug.log:heart-rate.jsonl 每個 fingerprint 最多一次 op: "process" 上傳錯誤,.DS_Store 的 fs_event 不再接 sync_start。

🤖 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.

@pocketsharkk
pocketsharkk merged commit ef095af into main Sep 18, 2026
3 checks passed
@pocketsharkk
pocketsharkk deleted the claude/ship-wspc-todo-e2f42e branch September 18, 2026 05:58
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