简

BrowserSkill 和 Chrono 同時處理下載時,瀏覽器已經保存了文件,CLI 卻沒能生成指定輸出。記錄 PR #362 中對目錄邊界、失敗清理和真實瀏覽器驗證的排查與取捨。

文件已經下載,為什麼 --out 還是空的:BrowserSkill 下載目錄衝突修復
15 分鐘
2987 字

瀏覽器的下載列表裏已經有文件,為什麼 bsk download --out ... 還沒有生成指定輸出?

這次在 BrowserSkill 裏處理的問題,比“下載失敗”這幾個字要繞一點。用户在 Issue #361 中反饋:同時安裝 Chrono 下載管理器後,BrowserSkill 建議的下載目錄被覆蓋了。文件仍然下載下來,卻沒有進入原本按調用劃分的目錄。

表面上看,只是文件少套了一層文件夾。但沿着擴展、daemon 和 CLI 之間的傳輸鏈路查下去,這層目錄恰好決定了文件能不能交給調用方。

最後,我在 PR #362 中補上了最終路徑檢查和恢復提示。這個 PR 沒有讓兩個下載擴展從此完全兼容,而是把一種原本不夠清楚的失敗説清楚,同時保留已經下載好的文件,讓用户知道接下來該怎麼處理。

這層目錄,決定文件能不能交給調用方h2

BrowserSkill 讓 Agent 通過 CLI 和擴展操作真實瀏覽器。下載也不是瀏覽器直接把文件寫到 --out:瀏覽器先保存文件,daemon 接收這次下載的內容,CLI 再將內容寫到調用方指定的位置。

開始一次傳輸時,daemon 會生成獨立的 transfer ID,並把類似 BrowserSkill/tr_<id>/ 的相對目錄交給擴展。擴展捕獲到對應下載後,通過 onDeterminingFilename 建議瀏覽器把文件放進去。

daemon 導入文件時,還會檢查實際路徑的父目錄是否以本次傳輸的專屬目錄結尾,之後才繼續檢查文件、讀取內容並準備交給 CLI。

所以它不只是為了讓 Downloads 看起來整齊,也不能被理解成“找到一個同名文件就拿來用”。文件落在別處,即使文件名和內容都像是對的,也沒有滿足這條導入路徑的要求。

Issue 裏最初關注的是目錄分組丟失。我需要繼續確認的是:目錄丟了以後,後面的傳輸還能不能成立。追到 daemon 的檢查後,我才把這兩件事分開:擴展能拿到真實保存路徑,不代表 CLI 能把它變成請求的 --out。

調用了 suggest,不代表瀏覽器會採用這個建議h2

為什麼擴展已經指定了目錄,文件卻還能跑到外面?

BrowserSkill 和 Chrono 都可以參與下載文件名的確定。按照 Chrome 下載 API 的規則,多個擴展同時提供命名建議時,會按安裝順序選擇其中一個,最近安裝的擴展提供的建議優先。

因此,BrowserSkill 調用了 suggest(...),只説明它提出了建議,並不能證明最後保存的文件採用了這個路徑。競爭擴展可能覆蓋建議,瀏覽器也可能拒絕建議的文件名,或者用户選擇了其他保存位置。

我把檢查放在下載完成之後,讀取最終的 DownloadItem.filename,再核對它的父目錄。這裏檢查的是實際結果,不是之前發出去的建議。

路徑處理也要同時識別 / 和 Windows 的 \,取父目錄的末尾路徑段進行比較,而不是隻看 basename,更不是搜索路徑裏有沒有出現過 BrowserSkill 這個詞。

明確報錯,但不替用户處理目錄外的文件h2

發現路徑不匹配後,擴展返回的關鍵信息是:

{
"reason": "download_path_mismatch",
"effect_state": "committed",
"phase": "download"
}

這裏的 committed 容易讓人誤會。它不是説 CLI 已經成功寫出了目標文件,而是説瀏覽器下載已經發生,不能把這次失敗當成“什麼都沒有做”。

這也影響失敗後的清理。原來下載捕獲失敗時,會嘗試清理已經認領的下載。但現在遇到的是:文件已經完成,最後的位置卻不在本次專屬目錄裏。它可能是另一個擴展安排的,也可能是用户主動選的。僅憑這次命令沒有完成,就把它刪除,並不合適。

所以這條分支在返回錯誤前設置了保留標記,讓 finally 跳過對這個下載文件的取消和刪除。事件監聽、計時器等清理仍然繼續,不是出了錯就什麼都不收拾。

圖 1 · 下載完成之後,再決定是否可以交給下一層
BrowserSkill 下載完成後的路徑核對 只展示已完成且已歸屬到本次捕獲的瀏覽器下載。核對最終文件父目錄是否匹配本次 BrowserSkill transfer 目錄。匹配時返回捕獲結果,後續仍要經過 daemon 導入與 CLI 寫出;不匹配時保留目錄外文件,返回 download_path_mismatch、effect_state committed。其他必要的監聽器與觸發清理仍執行,不表示完整任務已經成功。 瀏覽器下載已完成 讀取最終 DownloadItem.filename 父目錄匹配本次 transfer 目錄? BrowserSkill/tr_<id>/ 匹配 不匹配 返回捕獲結果 後續仍需 daemon 導入 與 CLI 寫出指定文件 保留目錄外的文件 不取消、不刪除這次下載 其餘必要清理繼續執行 download_path_mismatch effect_state: committed 未生成請求的 --out 文件 此圖省略下載歸屬判斷、未完成下載及其他失敗路徑
窄屏可在圖內左右滑動查看。

CLI 側的提示也不應該直接斷言“就是 Chrono 的問題”。它會引導用户先到瀏覽器下載列表找文件,再檢查競爭擴展、文件名被拒絕或保存位置變化。需要重試時,先暫停相關下載接管,並確認自己仍然需要生成 --out。

文件已經落盤了,讓用户看見這個事實,比簡單地告訴他“失敗了,再試一次”更有用。

一個繞開衝突的開關,並不能補齊後面的傳輸h2

Issue 還提出了一個可選想法:既然會搶文件名,能不能提供設置,讓 BrowserSkill 不再建議目錄?

這次沒有實現它。因為跳過建議以後,文件仍然可能落在專屬目錄外,daemon 的導入檢查也仍然存在。只關掉前面的建議,不能讓後面的內容傳輸自動成立。

我選擇先補排障文檔、最終路徑檢查和明確的錯誤提示,不為了完成一個可選請求,把文件導入邊界一起放寬。

文檔裏也保留了調整擴展安裝順序的繞開方式,但沒有把它寫成長期兼容方案。讓 BrowserSkill 的建議暫時勝出,並不代表衝突機制消失;另一個擴展以後更新,結果還可能反過來。

維護者要看的,是兩個擴展一起運行時的結果h2

提交後,維護者在 Review 評論 中認可了最終路徑檢查和恢復提示的方向,但也要求補兩件事:修正新測試的格式,以及在真實 Windows Edge 中同時安裝 BrowserSkill 和 Chrono,驗證三種情況。

格式問題很直接。另一項則要求把 CLI、daemon、擴展和瀏覽器真正接起來,不能只靠測試裏的模擬下載事件。

於是,我從這個 PR 的源碼構建 CLI、daemon 和擴展,用隔離的 Edge 配置測試。環境是 Windows 11、Edge 154.0.4258.37 和 Chrono 0.13.12。BrowserSkill 源碼裏的版本號仍為 0.3.1,但這裏使用的是包含修復的 PR 構建,不是把已發佈的舊版本當成驗證對象。

我先做沒有 Chrono 的正常下載,再讓後安裝的 Chrono 覆蓋目錄建議,最後關閉 Chrono,用相同的 session、tab 和輸出路徑重複命令。

場景實際觀察到的結果
沒有 Chrono 的正常下載命令成功,指定輸出包含預期內容
Chrono 覆蓋目錄建議命令返回 download_path_mismatch 和 committed,沒有生成請求的輸出;瀏覽器顯示下載完成,目錄外文件仍然存在
關閉 Chrono 後重復相同命令輸出成功生成,內容與源附件的 SHA-256 一致;前一次衝突留下的文件也還在

這樣驗證下來,檢查的就不只是“新增的判斷分支會返回一個錯誤碼”,還包括出錯時有沒有誤刪文件,以及解除衝突後原有鏈路能否繼續工作。我把環境、步驟和結果補進 PR,並在回應中逐項説明。

實際測試還遇到另一個行為:普通 HTTP 附件被 Chrono 取消後,用相同 URL 重新發起下載,導致歸屬變得不明確。這時返回的是 download_capture_failed 和 unknown,還沒有走到最終路徑檢查。

這和“同一次下載完成,但目錄建議被覆蓋”不是同一條路徑。我把它作為後續兼容問題記錄下來,沒有把這次 PR 寫成已經解決了下載管理器的所有接管行為。

失敗不等於什麼都沒發生,清理也不是刪得越徹底越好h2

這次修改讓我更在意的,是一個失敗結果裏究竟包含了多少已經發生的事情。

如果只看命令返回值,下載就是失敗了。但換到瀏覽器那一側,文件已經保存好了。對用户來説,這個文件可能仍然有用;對 Agent 來説,下一步也不該默認“剛才沒有效果,可以直接重試”。

我意識到,錯誤信息不只是給人解釋原因,也會影響後續動作。説清楚文件在哪裏找、為什麼沒有生成指定輸出、重試之前應該檢查什麼,本身就是修復的一部分。

失敗清理也一樣。清掉自己能確認負責的臨時狀態,和把所有看起來相關的文件都刪除,不是一回事。這次我需要守住的,恰恰是那個“不該順手處理”的目錄外文件。

維護者要求補真實瀏覽器驗證,也讓我把目光從新增的幾行判斷移到了整條鏈路上:單元測試能確認分支邏輯,兩個擴展同時運行時,事件順序、最終路徑和文件是否留下來,還得真的接起來看。

最後這個 PR 的範圍並不大,但它留下了一個我想繼續帶着的問題:準備告訴調用方“失敗了”之前,我有沒有把已經發生的效果,以及自己有權清理的範圍,一起想清楚?