fix(worktrees): protect user changes across ambiguous move failures

Post-merge hardening of the session-to-worktree move (#2998), driven by
review findings on the follow-up pass:

- an ambiguous transport failure (relay abort, timeout) on the
  change-carrying move no longer force-deletes the fresh worktree that
  may hold the user's only copy of their changes; both intent kinds
  surface honest guidance and refresh both directories
- assertSdkSuccess re-tags ambiguous transport errors when wrapping SDK
  failures, so ambiguity classification survives the wrapper on every
  path, matching the prompt-send precedent
- session liveness checks scan all child stores plus the global status
  index, and report unknown (not idle) when no store covers the session
  — an evicted background directory can no longer make a busy session
  look movable
- incomplete-rollback errors carry the changes-may-be-in-destination
  guidance instead of swallowing it
- move-message assembly shared across the three call sites; tests now
  exercise the real ambiguity classifier (extracted to
  send-failure-classification.ts) instead of a hand-mirrored mock
- i18n fallout from the merge train: Turkish gains the 21 worktree-move
  keys, all 12 locales get the hedged ambiguous-failure toast; owning
  DOCUMENTATION.md files record the new contracts
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 12:03:55 +03:00
parent 9d279137ce
commit 4f53db17e6
21 changed files with 493 additions and 107 deletions
+1
View File
@@ -3015,6 +3015,7 @@ export const dict = {
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Abbrechen',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Die Änderungen in der Quelle konnten nicht geprüft werden. Es wurde kein Worktree und keine Sitzung geändert.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'Das Ziel konnte die Änderungen der Quelle nicht übernehmen. Sitzung und Änderungen wurden nicht verschoben. Versuche es erneut und wähle Nur Sitzung verschieben.',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': 'Die Verbindung brach ab, bevor das Ziel den Wechsel bestätigt hat. Die Sitzung wurde möglicherweise nicht verschoben, und deine nicht committeten Änderungen liegen eventuell schon im Ziel-Worktree. Sieh dort nach, bevor du es erneut versuchst.',
'sessions.sidebar.session.export.failedLoadHistory': 'Die vollständige Sitzungshistorie konnte nicht geladen werden',
'sessions.sidebar.session.status.movingToWorktree': 'Sitzung wird in einen neuen Worktree verschoben',
'gitView.header.updateBranch': 'Branch aktualisieren',
+1
View File
@@ -563,6 +563,7 @@ export const dict = {
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Cancel',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Source changes could not be verified. No worktree or session was changed.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'The destination could not accept the source changes. The session and source changes were not moved. Retry and choose Move session only.',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': 'The connection dropped before the destination confirmed the move. The session may not have moved, and your uncommitted changes may already be in the destination worktree. Check there before retrying.',
'sessions.sidebar.session.menu.runFusion': 'Run fusion',
'sessions.sidebar.session.menu.openInSidePanel': 'Open in Side Panel',
'sessions.sidebar.session.actions.openInEditor': 'Open in Editor',
+1
View File
@@ -564,6 +564,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.moveToWorktree.confirm.cancel": "Cancelar",
"sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "No se pudieron verificar los cambios de la fuente. No se modificó ningún worktree ni sesión.",
"sessions.sidebar.session.moveToWorktree.applyChangesFailed": "El destino no pudo aceptar los cambios de la fuente. No se movieron la sesión ni los cambios. Reintenta y elige Mover solo la sesión.",
"sessions.sidebar.session.moveToWorktree.changesMayBeInDestination": "La conexión se cortó antes de que el destino confirmara el movimiento. Puede que la sesión no se haya movido y que tus cambios sin confirmar ya estén en el worktree de destino. Compruébalo antes de volver a intentarlo.",
"sessions.sidebar.session.menu.runFusion": "Ejecutar fusion",
"sessions.sidebar.session.menu.openInSidePanel": "Abrir en panel lateral",
"sessions.sidebar.session.actions.openInEditor": "Abrir en el editor",
+1
View File
@@ -394,6 +394,7 @@ export const dict = {
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Annuler',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Les modifications de la source n’ont pas pu être vérifiées. Aucun worktree ni session n’a été modifié.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'La destination n’a pas pu accepter les modifications de la source. La session et les modifications n’ont pas été déplacées. Réessayez et choisissez Déplacer la session uniquement.',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': 'La connexion a été perdue avant que la destination ne confirme le déplacement. La session n’a peut-être pas été déplacée, et vos modifications non validées se trouvent peut-être déjà dans le worktree de destination. Vérifiez-le avant de réessayer.',
'sessions.sidebar.session.menu.runFusion': 'Exécuter la fusion',
'sessions.sidebar.session.menu.openInSidePanel': 'Ouvrir dans le panneau latéral',
'sessions.sidebar.session.actions.openInEditor': 'Ouvrir dans l\'éditeur',
+1
View File
@@ -564,6 +564,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'キャンセル',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'ソースの変更を検証できませんでした。worktreeもセッションも変更されませんでした。',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '宛先がソースの変更を受け付けられませんでした。セッションもソースの変更も移動されていません。再試行して「セッションのみ移動」を選んでください。',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': '移動が宛先で確定する前に接続が切れました。セッションは移動していない可能性があり、コミットしていない変更はすでに移動先のワークツリーにあるかもしれません。再試行する前に確認してください。',
'sessions.sidebar.session.menu.runFusion': 'フュージョンを実行',
'sessions.sidebar.session.menu.openInSidePanel': 'サイドパネルで開く',
'sessions.sidebar.session.actions.openInEditor': 'エディターで開く',
+1
View File
@@ -564,6 +564,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.moveToWorktree.confirm.cancel': '취소',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '원본 변경 사항을 확인하지 못했습니다. worktree와 세션 모두 변경되지 않았습니다.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '목적지가 원본 변경 사항을 받아들이지 못했습니다. 세션과 원본 변경 사항이 이동되지 않았습니다. 다시 시도해 세션만 이동을 선택하세요.',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': '목적지가 이동을 확인하기 전에 연결이 끊겼습니다. 세션이 이동하지 않았을 수 있고, 커밋하지 않은 변경 사항이 이미 대상 워크트리에 있을 수 있습니다. 다시 시도하기 전에 확인하세요.',
'sessions.sidebar.session.menu.runFusion': 'fusion 실행',
'sessions.sidebar.session.menu.openInSidePanel': '사이드 패널에서 열기',
'sessions.sidebar.session.actions.openInEditor': '편집기에서 열기',
+1
View File
@@ -357,6 +357,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Anuluj',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Nie udało się zweryfikować zmian w źródle. Żaden worktree ani sesja nie został zmieniony.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'Cel nie mógł przyjąć zmian ze źródła. Sesja i zmiany w źródle nie zostały przeniesione. Spróbuj ponownie i wybierz Przenieś tylko sesję.',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': 'Połączenie zostało zerwane, zanim cel potwierdził przeniesienie. Sesja mogła nie zostać przeniesiona, a niezatwierdzone zmiany mogą już być w docelowym worktree. Sprawdź go przed ponowną próbą.',
'sessions.sidebar.session.menu.runFusion': 'Uruchom fusion',
'sessions.sidebar.session.menu.openInSidePanel': 'Otwórz w panelu bocznym',
'sessions.sidebar.session.actions.openInEditor': 'Otwórz w edytorze',
@@ -564,6 +564,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.moveToWorktree.confirm.cancel": "Cancelar",
"sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "As alterações da fonte não puderam ser verificadas. Nenhum worktree ou sessão foi alterado.",
"sessions.sidebar.session.moveToWorktree.applyChangesFailed": "O destino não pôde aceitar as alterações da fonte. A sessão e as alterações da fonte não foram movidas. Tente novamente e escolha Mover apenas a sessão.",
"sessions.sidebar.session.moveToWorktree.changesMayBeInDestination": "A conexão caiu antes de o destino confirmar a movimentação. A sessão pode não ter sido movida, e suas alterações não commitadas podem já estar no worktree de destino. Confira lá antes de tentar de novo.",
"sessions.sidebar.session.menu.runFusion": "Executar fusion",
"sessions.sidebar.session.menu.openInSidePanel": "Abrir no painel lateral",
"sessions.sidebar.session.actions.openInEditor": "Abrir no editor",
+22
View File
@@ -524,6 +524,28 @@ export const dict = {
'sessions.sidebar.session.moveToWorktree.tooltip': 'Geçerli branch\'ten yeni bir worktree oluşturur, commit edilmemiş değişiklikleri aktarır ve bu session\'ı alt session\'larıyla birlikte oraya taşır.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Session boşta olduğunda kullanılabilir. Geçerli etkinliği durdur veya bitmesini bekle.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Bu session zaten yeni bir worktree\'ye taşınıyor.',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Worktree\'ye taşı',
'sessions.sidebar.session.menu.newWorktree': 'Yeni worktree...',
'sessions.sidebar.session.moveToWorktree.main': 'Ana worktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'Worktree\'ler yenileniyor...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Worktree\'ler yüklenemedi',
'sessions.sidebar.session.moveToWorktree.current': 'Geçerli worktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session worktree\'ye taşındı',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Session worktree\'ye taşınamadı',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Mevcut worktree\'leri ve bu session için yeni bir tane oluşturma seçeneğini gösterir.',
'sessions.sidebar.session.moveToWorktree.confirm.title': 'Kaynakta commit edilmemiş değişiklikler var',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Bu worktree\'de değişen dosya sayısı: {count}.',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode bu değişiklikleri session\'a göre değil, dizine göre izler.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Bu session\'ı ve alt session\'larını taşır, kaynaktaki dosyalara hiç dokunmaz.',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Session dizinindeki değişiklikleri aktarır. Stage\'lenmemiş ve takip edilmeyen dosyalar başarıdan sonra kaynaktan çıkar.',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Stage\'lenmiş değişiklikler kaynakta kalır ve hedefe kopyalanır.',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'Hedef farklı bir Git tabanı kullanıyorsa aktarım başarısız olabilir.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Yalnızca session\'ı taşı',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Kaynaktaki tüm değişiklikleri taşı',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'İptal',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Kaynaktaki değişiklikler doğrulanamadı. Hiçbir worktree veya session değiştirilmedi.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'Hedef, kaynaktaki değişiklikleri kabul edemedi. Session da kaynaktaki değişiklikler de taşınmadı. Tekrar deneyip Yalnızca session\'ı taşı seçeneğini kullan.',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': 'Hedef taşımayı onaylamadan önce bağlantı koptu. Session taşınmamış olabilir ve commit edilmemiş değişikliklerin hedef worktree içinde olabilir. Tekrar denemeden önce oraya bak.',
'sessions.sidebar.session.menu.runFusion': 'Fusion\'ı çalıştır',
'sessions.sidebar.session.menu.openInSidePanel': 'Yan panelde aç',
'sessions.sidebar.session.actions.openInEditor': 'Editörde aç',
+1
View File
@@ -564,6 +564,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.moveToWorktree.confirm.cancel": "Скасувати",
"sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "Не вдалося перевірити зміни в джерелі. Жоден worktree чи сесію не змінено.",
"sessions.sidebar.session.moveToWorktree.applyChangesFailed": "Призначення не змогло прийняти зміни з джерела. Сесію та зміни в джерелі не перенесено. Спробуйте знову й оберіть Перенести лише сесію.",
"sessions.sidebar.session.moveToWorktree.changesMayBeInDestination": "З’єднання обірвалося, перш ніж призначення підтвердило перенесення. Сесія могла не переїхати, а незакомічені зміни можуть уже бути в цільовому worktree. Перевірте його, перш ніж повторювати.",
"sessions.sidebar.session.menu.runFusion": "Запустити fusion",
"sessions.sidebar.session.menu.openInSidePanel": "Відкрити на бічній панелі",
"sessions.sidebar.session.actions.openInEditor": "Відкрити в редакторі",
@@ -564,6 +564,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.moveToWorktree.confirm.cancel': '取消',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '无法验证源的更改。未更改任何工作树或会话。',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '目的地无法接受源的更改。会话和源更改均未移动。请重试并选择“仅移动会话”。',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': '在目的地确认移动之前连接中断。会话可能没有移动,未提交的更改可能已经在目标工作树中。重试前请先检查。',
'sessions.sidebar.session.menu.runFusion': '运行融合',
'sessions.sidebar.session.menu.openInSidePanel': '在侧边面板中打开',
'sessions.sidebar.session.actions.openInEditor': '在编辑器中打开',
@@ -577,6 +577,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.moveToWorktree.confirm.cancel': '取消',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '無法驗證來源的變更。未變更任何工作樹或工作階段。',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '目的地無法接受來源的變更。工作階段與來源變更均未移動。請重試並選擇「僅移動工作階段」。',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': '在目的地確認移動之前連線中斷。工作階段可能沒有移動,未提交的變更可能已經在目標工作樹中。重試前請先檢查。',
'sessions.sidebar.session.menu.runFusion': '執行 fusion',
'sessions.sidebar.session.menu.openInSidePanel': '在側邊面板中開啟',
'sessions.sidebar.session.actions.openInEditor': '在編輯器中開啟',