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:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'エディターで開く',
|
||||
|
||||
@@ -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': '편집기에서 열기',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ç',
|
||||
|
||||
@@ -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': '在編輯器中開啟',
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from 'node:path';
|
||||
import type { State } from '@/sync/types';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import { markAmbiguousTransportFailure } from '@/lib/relay/transport-error';
|
||||
import type { SessionTreeMoveIntent, SessionTreeMoveMessages } from './sessionWorktreeMove';
|
||||
|
||||
const moveCalls: Array<{
|
||||
@@ -175,11 +176,28 @@ mock.module('@/stores/useGlobalSessionsStore', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mirrors session-actions: every child store is scanned, because a session's
|
||||
// live status can be reported by a directory other than its own, and "no store
|
||||
// covers this session" is 'unknown', never 'idle' — a populated store map says
|
||||
// nothing about a session none of its stores holds.
|
||||
const getSessionLiveActivity = (sessionId: string): 'unknown' | 'idle' | 'active' => {
|
||||
for (const state of directoryStates.values()) {
|
||||
const status = state.session_status[sessionId];
|
||||
if (status && status.type !== 'idle') return 'active';
|
||||
}
|
||||
for (const state of directoryStates.values()) {
|
||||
if (Object.hasOwn(state.session_status, sessionId)) return 'idle';
|
||||
}
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
mock.module('@/sync/session-actions', () => ({
|
||||
moveSessionToDirectory: (session: Session, sourceDirectory: string, destinationDirectory: string, moveChanges = true) => {
|
||||
moveCalls.push({ sessionId: session.id, sourceDirectory, destinationDirectory, moveChanges });
|
||||
return moveSessionImplementation(session, sourceDirectory, destinationDirectory, moveChanges);
|
||||
},
|
||||
getSessionLiveActivity,
|
||||
isSessionBusyNow: (sessionId: string) => getSessionLiveActivity(sessionId) === 'active',
|
||||
}));
|
||||
|
||||
mock.module('@/sync/session-ui-store', () => ({
|
||||
@@ -239,6 +257,7 @@ const makeMoveMessages = (): SessionTreeMoveMessages => ({
|
||||
failure: 'move failed',
|
||||
sourceVerificationFailed: 'source verification failed',
|
||||
applyChangesFailed: 'apply changes failed',
|
||||
changesMayBeInDestination: 'changes may be in destination',
|
||||
});
|
||||
|
||||
const makeQuickIntent = (): SessionTreeMoveIntent => ({
|
||||
@@ -1044,6 +1063,206 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
||||
expect(toastErrors).toEqual([{ title: 'move failed', description: 'Destination directory belongs to another project' }]);
|
||||
});
|
||||
|
||||
const requestDirtyQuickMove = (): void => {
|
||||
getGitStatusImplementation = async () => ({
|
||||
current: 'feature',
|
||||
isClean: false,
|
||||
files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }],
|
||||
});
|
||||
requestSessionTreeMove(makeQuickIntent());
|
||||
};
|
||||
|
||||
test('keeps a newly created worktree when an ambiguous failure may have transferred the changes', async () => {
|
||||
setStatuses('/source', { root: 'idle' });
|
||||
moveSessionImplementation = async () => {
|
||||
throw new Error('Request timed out');
|
||||
};
|
||||
|
||||
requestDirtyQuickMove();
|
||||
await waitFor(() => getSessionTreeMoveConfirmation() !== null);
|
||||
confirmSessionTreeMove(true);
|
||||
|
||||
await waitFor(() => toastErrors.length === 1);
|
||||
expect(toastErrors).toEqual([{ title: 'move failed', description: 'changes may be in destination' }]);
|
||||
expect(removeWorktreeCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('removes a newly created worktree when the change transfer is definitely rejected', async () => {
|
||||
setStatuses('/source', { root: 'idle' });
|
||||
moveSessionImplementation = async () => {
|
||||
throw Object.assign(new Error('Unable to apply your changes in the destination directory: conflict'), { status: 400 });
|
||||
};
|
||||
|
||||
requestDirtyQuickMove();
|
||||
await waitFor(() => getSessionTreeMoveConfirmation() !== null);
|
||||
confirmSessionTreeMove(true);
|
||||
|
||||
await waitFor(() => toastErrors.length === 1);
|
||||
expect(toastErrors).toEqual([{ title: 'move failed', description: 'apply changes failed' }]);
|
||||
expect(removeWorktreeCalls).toEqual([{
|
||||
projectDirectory: '/repo',
|
||||
directory: '/created-worktree',
|
||||
deleteLocalBranch: true,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('removes a newly created worktree when an ambiguous failure carried no changes', async () => {
|
||||
setStatuses('/source', { root: 'idle' });
|
||||
moveSessionImplementation = async () => {
|
||||
throw new Error('Request timed out');
|
||||
};
|
||||
|
||||
requestDirtyQuickMove();
|
||||
await waitFor(() => getSessionTreeMoveConfirmation() !== null);
|
||||
confirmSessionTreeMove(false);
|
||||
|
||||
await waitFor(() => toastErrors.length === 1);
|
||||
expect(toastErrors).toEqual([{ title: 'move failed', description: 'Request timed out' }]);
|
||||
expect(removeWorktreeCalls).toEqual([{
|
||||
projectDirectory: '/repo',
|
||||
directory: '/created-worktree',
|
||||
deleteLocalBranch: true,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('removes a newly created worktree when a descendant fails ambiguously before the root moved', async () => {
|
||||
setStatuses('/source', { root: 'idle', child: 'idle' });
|
||||
moveSessionImplementation = async (session) => {
|
||||
if (session.id === 'child') throw new Error('Request timed out');
|
||||
};
|
||||
|
||||
getGitStatusImplementation = async () => ({
|
||||
current: 'feature',
|
||||
isClean: false,
|
||||
files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }],
|
||||
});
|
||||
requestSessionTreeMove({
|
||||
kind: 'quick',
|
||||
root: makeSession('root'),
|
||||
descendants: [makeSession('child')],
|
||||
sourceDirectory: '/source',
|
||||
messages: makeMoveMessages(),
|
||||
});
|
||||
await waitFor(() => getSessionTreeMoveConfirmation() !== null);
|
||||
confirmSessionTreeMove(true);
|
||||
|
||||
await waitFor(() => toastErrors.length === 1);
|
||||
expect(toastErrors).toEqual([{ title: 'move failed', description: 'Request timed out' }]);
|
||||
expect(removeWorktreeCalls).toEqual([{
|
||||
projectDirectory: '/repo',
|
||||
directory: '/created-worktree',
|
||||
deleteLocalBranch: true,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('refuses to move a session whose live status is reported by another directory', async () => {
|
||||
setStatuses('/source', { root: 'idle' });
|
||||
setStatuses('/other-directory', { root: 'busy' });
|
||||
|
||||
await expect(moveSessionTreeToExistingWorktree({
|
||||
root: makeSession('root'),
|
||||
descendants: [],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
moveChanges: false,
|
||||
})).rejects.toThrow('Session is not idle');
|
||||
|
||||
expect(moveCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('refuses to move when no child store can report session status', async () => {
|
||||
directoryStates.clear();
|
||||
|
||||
await expect(moveSessionTreeToExistingWorktree({
|
||||
root: makeSession('root'),
|
||||
descendants: [],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
moveChanges: false,
|
||||
})).rejects.toThrow('Session status is unavailable');
|
||||
|
||||
expect(moveCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps a newly created worktree when the relay tags the failure as dispatched', async () => {
|
||||
setStatuses('/source', { root: 'idle' });
|
||||
moveSessionImplementation = async () => {
|
||||
// The relay tunnel's own tag, matched by no message heuristic.
|
||||
throw markAmbiguousTransportFailure(new Error('stream aborted by host'));
|
||||
};
|
||||
|
||||
requestDirtyQuickMove();
|
||||
await waitFor(() => getSessionTreeMoveConfirmation() !== null);
|
||||
confirmSessionTreeMove(true);
|
||||
|
||||
await waitFor(() => toastErrors.length === 1);
|
||||
expect(toastErrors).toEqual([{ title: 'move failed', description: 'changes may be in destination' }]);
|
||||
expect(removeWorktreeCalls).toEqual([]);
|
||||
expect(refreshCalls).toEqual([['/source', '/created-worktree']]);
|
||||
});
|
||||
|
||||
test('reports the destination guidance for an ambiguous existing-worktree move', async () => {
|
||||
setStatuses('/source', { root: 'idle' });
|
||||
getGitStatusImplementation = async () => ({
|
||||
current: 'feature',
|
||||
isClean: false,
|
||||
files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }],
|
||||
});
|
||||
moveSessionImplementation = async () => {
|
||||
throw markAmbiguousTransportFailure(new Error('stream aborted by host'));
|
||||
};
|
||||
|
||||
requestSessionTreeMove({
|
||||
kind: 'existing',
|
||||
root: makeSession('root'),
|
||||
descendants: [],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
messages: makeMoveMessages(),
|
||||
});
|
||||
await waitFor(() => getSessionTreeMoveConfirmation() !== null);
|
||||
confirmSessionTreeMove(true);
|
||||
|
||||
await waitFor(() => toastErrors.length === 1);
|
||||
expect(toastErrors).toEqual([{ title: 'move failed', description: 'changes may be in destination' }]);
|
||||
expect(removeWorktreeCalls).toEqual([]);
|
||||
expect(refreshCalls).toEqual([['/source', '/destination']]);
|
||||
});
|
||||
|
||||
test('keeps the destination guidance when rollback is also incomplete', async () => {
|
||||
setStatuses('/source', { root: 'idle', child: 'idle' });
|
||||
setStatuses('/destination', { child: 'idle' });
|
||||
getGitStatusImplementation = async () => ({
|
||||
current: 'feature',
|
||||
isClean: false,
|
||||
files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }],
|
||||
});
|
||||
moveSessionImplementation = async (session, sourceDirectory) => {
|
||||
if (session.id === 'root' && sourceDirectory === '/source') {
|
||||
throw markAmbiguousTransportFailure(new Error('stream aborted by host'));
|
||||
}
|
||||
if (session.id === 'child' && sourceDirectory === '/destination') {
|
||||
throw new Error('rollback failed');
|
||||
}
|
||||
};
|
||||
|
||||
requestSessionTreeMove({
|
||||
kind: 'existing',
|
||||
root: makeSession('root'),
|
||||
descendants: [makeSession('child')],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
messages: makeMoveMessages(),
|
||||
});
|
||||
await waitFor(() => getSessionTreeMoveConfirmation() !== null);
|
||||
confirmSessionTreeMove(true);
|
||||
|
||||
await waitFor(() => toastErrors.length === 1);
|
||||
expect(toastErrors[0]?.title).toBe('move failed');
|
||||
expect(toastErrors[0]?.description).toContain('could not be fully rolled back');
|
||||
expect(toastErrors[0]?.description).toContain('changes may be in destination');
|
||||
});
|
||||
|
||||
test('surfaces a pre-destination preparation failure without attempting removal', async () => {
|
||||
setStatuses('/source', { root: 'idle' });
|
||||
resolveProjectRefImplementation = () => null;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { I18nKey } from '@/lib/i18n';
|
||||
import { toast } from '@/components/ui';
|
||||
import { checkIsGitRepository, getGitStatus } from '@/lib/gitApi';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { createQuickWorktree, resolveProjectRef } from '@/lib/worktreeSessionCreator';
|
||||
import { getLatestWorktreeMetadata, removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import { refreshGlobalSessionsForDirectories } from '@/stores/useGlobalSessionsStore';
|
||||
import { moveSessionToDirectory } from '@/sync/session-actions';
|
||||
import { isAmbiguousSendFailure } from '@/sync/send-failure-classification';
|
||||
import { getSessionLiveActivity, isSessionBusyNow, moveSessionToDirectory } from '@/sync/session-actions';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { getDirectoryState } from '@/sync/sync-refs';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { waitForWorktreeGitReady } from '@/lib/worktrees/worktreeBootstrap';
|
||||
import { create } from 'zustand';
|
||||
@@ -17,8 +18,22 @@ export type SessionTreeMoveMessages = {
|
||||
failure: string;
|
||||
sourceVerificationFailed: string;
|
||||
applyChangesFailed: string;
|
||||
changesMayBeInDestination: string;
|
||||
};
|
||||
|
||||
/** Every move surface differs only in the success/failure pair, so the shared
|
||||
* failure copy is resolved once here instead of at each call site. */
|
||||
export const buildSessionTreeMoveMessages = (
|
||||
t: (key: I18nKey) => string,
|
||||
keys: { success: I18nKey; failure: I18nKey },
|
||||
): SessionTreeMoveMessages => ({
|
||||
success: t(keys.success),
|
||||
failure: t(keys.failure),
|
||||
sourceVerificationFailed: t('sessions.sidebar.session.moveToWorktree.sourceVerificationFailed'),
|
||||
applyChangesFailed: t('sessions.sidebar.session.moveToWorktree.applyChangesFailed'),
|
||||
changesMayBeInDestination: t('sessions.sidebar.session.moveToWorktree.changesMayBeInDestination'),
|
||||
});
|
||||
|
||||
export type SessionTreeMoveIntent =
|
||||
| {
|
||||
kind: 'existing';
|
||||
@@ -87,6 +102,13 @@ const setSessionMoveRequesting = (sessionId: string, requesting: boolean): void
|
||||
});
|
||||
};
|
||||
|
||||
// The control plane flattens every move failure into a single
|
||||
// `MoveSessionError` carrying only `data.message`, so there is no status or
|
||||
// error code to match on. This prefix is the exact text OpenCode's
|
||||
// `message(MoveSession.ApplyChangesError)` returns in
|
||||
// `packages/opencode/src/server/routes/instance/httpapi/handlers/control-plane.ts`.
|
||||
// If upstream reworks that wording the friendlier toast silently degrades to
|
||||
// the raw message, which is why the fallback stays readable.
|
||||
const APPLY_CHANGES_MESSAGE = 'Unable to apply your changes in the destination directory';
|
||||
|
||||
const isApplyChangesError = (error: Error): boolean => {
|
||||
@@ -116,16 +138,16 @@ const resolveSourceBranch = async (directory: string, projectDirectory: string):
|
||||
throw new Error('Unable to determine the current branch');
|
||||
};
|
||||
|
||||
const assertSessionsIdle = (sessions: Session[], sourceDirectory: string): void => {
|
||||
const directoryState = getDirectoryState(sourceDirectory);
|
||||
if (!directoryState) throw new Error('Session status is unavailable');
|
||||
|
||||
const statuses = directoryState.session_status;
|
||||
const hasActiveSession = sessions.some((session) => {
|
||||
const status = statuses[session.id]?.type;
|
||||
return status === 'busy' || status === 'retry';
|
||||
});
|
||||
if (hasActiveSession) throw new Error('Session is not idle');
|
||||
// Scans every child store instead of the source directory's: a session's live
|
||||
// status can be reported by a directory other than the one that wins the
|
||||
// directory dedup, and a directory-scoped read would then see no status at all
|
||||
// and move a running session.
|
||||
const assertSessionsIdle = (sessions: Session[]): void => {
|
||||
for (const session of sessions) {
|
||||
const activity = getSessionLiveActivity(session.id);
|
||||
if (activity === 'unknown') throw new Error('Session status is unavailable');
|
||||
if (activity === 'active') throw new Error('Session is not idle');
|
||||
}
|
||||
};
|
||||
|
||||
type RollbackFailure = {
|
||||
@@ -133,21 +155,34 @@ type RollbackFailure = {
|
||||
error: Error;
|
||||
};
|
||||
|
||||
const createIncompleteRollbackError = (moveError: Error, rollbackFailures: RollbackFailure[]): Error => {
|
||||
/** Rollback left sessions in the destination. `changesMayBeInDestination` says
|
||||
* the same failure also carried the working tree changes with an unknown
|
||||
* outcome, so the toast must keep that guidance instead of dropping it. */
|
||||
class IncompleteRollbackError extends Error {
|
||||
readonly changesMayBeInDestination: boolean;
|
||||
|
||||
constructor(message: string, cause: unknown, changesMayBeInDestination: boolean) {
|
||||
super(message, { cause });
|
||||
this.name = 'IncompleteRollbackError';
|
||||
this.changesMayBeInDestination = changesMayBeInDestination;
|
||||
}
|
||||
}
|
||||
|
||||
const createIncompleteRollbackError = (
|
||||
moveError: Error,
|
||||
rollbackFailures: RollbackFailure[],
|
||||
changesMayBeInDestination: boolean,
|
||||
): Error => {
|
||||
const rollbackSummary = rollbackFailures
|
||||
.map(({ sessionId, error }) => `${sessionId}: ${error.message}`)
|
||||
.join(', ');
|
||||
return new Error(
|
||||
return new IncompleteRollbackError(
|
||||
`Session move partially failed and could not be fully rolled back: ${moveError.message}. Rollback failures: ${rollbackSummary}`,
|
||||
{ cause: { moveError, rollbackFailures } },
|
||||
{ moveError, rollbackFailures },
|
||||
changesMayBeInDestination,
|
||||
);
|
||||
};
|
||||
|
||||
const isSessionBusyOrRetrying = (session: Session, directory: string): boolean => {
|
||||
const status = getDirectoryState(directory)?.session_status[session.id]?.type;
|
||||
return status === 'busy' || status === 'retry';
|
||||
};
|
||||
|
||||
const rollbackMovedSessions = async (
|
||||
sessions: Session[],
|
||||
sourceDirectory: string,
|
||||
@@ -156,7 +191,7 @@ const rollbackMovedSessions = async (
|
||||
): Promise<RollbackFailure[]> => {
|
||||
const failures: RollbackFailure[] = [];
|
||||
for (const session of [...sessions].reverse()) {
|
||||
if (isSessionBusyOrRetrying(session, worktreeDirectory)) {
|
||||
if (isSessionBusyNow(session.id)) {
|
||||
failures.push({ sessionId: session.id, error: new Error('Session is not idle') });
|
||||
continue;
|
||||
}
|
||||
@@ -178,6 +213,16 @@ const rollbackMovedSessions = async (
|
||||
return failures;
|
||||
};
|
||||
|
||||
/** The move failed after the change-carrying request was already dispatched, so
|
||||
* the user's changes may already be in the destination. A freshly created
|
||||
* worktree is kept rather than deleted, because it may hold the only copy. */
|
||||
class ChangesMayBeInDestinationError extends Error {
|
||||
constructor(moveError: Error) {
|
||||
super(moveError.message, { cause: moveError });
|
||||
this.name = 'ChangesMayBeInDestinationError';
|
||||
}
|
||||
}
|
||||
|
||||
const removeFailedWorktree = async (
|
||||
project: ProjectRef,
|
||||
worktree: WorktreeMetadata,
|
||||
@@ -191,6 +236,17 @@ const removeFailedWorktree = async (
|
||||
throw moveError;
|
||||
};
|
||||
|
||||
const refreshMovedDirectories = async (sourceDirectory: string, destinationDirectory: string | undefined): Promise<void> => {
|
||||
const directories = destinationDirectory ? [sourceDirectory, destinationDirectory] : [sourceDirectory];
|
||||
try {
|
||||
await refreshGlobalSessionsForDirectories(directories);
|
||||
} catch (error) {
|
||||
// Direct action updates already reconciled both stores. Keep the outcome
|
||||
// unchanged if this best-effort authoritative refresh is unavailable.
|
||||
console.warn('[session-worktree-move] Failed to refresh moved sessions', error);
|
||||
}
|
||||
};
|
||||
|
||||
const moveSessionTreeTransaction = async (
|
||||
input: {
|
||||
root: Session;
|
||||
@@ -217,10 +273,11 @@ const moveSessionTreeTransaction = async (
|
||||
useSessionUIStore.getState().getWorktreeMetadata(session.id),
|
||||
]),
|
||||
);
|
||||
assertSessionsIdle(sessions, input.sourceDirectory);
|
||||
assertSessionsIdle(sessions);
|
||||
|
||||
let destination: Awaited<ReturnType<typeof prepareDestination>> | null = null;
|
||||
const moved: Session[] = [];
|
||||
let changesMoveOutcomeUnknown = false;
|
||||
try {
|
||||
destination = await prepareDestination();
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
@@ -228,13 +285,18 @@ const moveSessionTreeTransaction = async (
|
||||
// session to start running, so re-check the remaining source tree
|
||||
// immediately before each move. The root moves last so no later
|
||||
// descendant failure can require replaying a transferred patch.
|
||||
assertSessionsIdle(sessions.slice(index), input.sourceDirectory);
|
||||
await moveSessionToDirectory(
|
||||
session,
|
||||
input.sourceDirectory,
|
||||
destination.directory,
|
||||
session.id === input.root.id && input.moveChanges,
|
||||
);
|
||||
assertSessionsIdle(sessions.slice(index));
|
||||
const movesChanges = session.id === input.root.id && input.moveChanges;
|
||||
try {
|
||||
await moveSessionToDirectory(session, input.sourceDirectory, destination.directory, movesChanges);
|
||||
} catch (error) {
|
||||
// A transport failure on the change-carrying request leaves the
|
||||
// destination unknown: the server may have applied the patch before
|
||||
// the response was lost. Definite rejections (the destination refused
|
||||
// the patch) keep this false.
|
||||
if (movesChanges && isAmbiguousSendFailure(error)) changesMoveOutcomeUnknown = true;
|
||||
throw error;
|
||||
}
|
||||
moved.push(session);
|
||||
if (session.id === input.root.id) continue;
|
||||
useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(destination.metadata));
|
||||
@@ -247,9 +309,19 @@ const moveSessionTreeTransaction = async (
|
||||
destination?.directory ?? input.sourceDirectory,
|
||||
previousMetadata,
|
||||
);
|
||||
if (rollbackFailures.length > 0) {
|
||||
throw createIncompleteRollbackError(moveError, rollbackFailures);
|
||||
if (changesMoveOutcomeUnknown) {
|
||||
// The move request may have completed server-side, so the session's
|
||||
// directory is unknown too. Reconcile both directories now instead of
|
||||
// letting the sidebar contradict the toast until the next poll.
|
||||
await refreshMovedDirectories(input.sourceDirectory, destination?.directory);
|
||||
}
|
||||
if (rollbackFailures.length > 0) {
|
||||
throw createIncompleteRollbackError(moveError, rollbackFailures, changesMoveOutcomeUnknown);
|
||||
}
|
||||
// Checked before `onMoveFailure` so the quick path's worktree removal
|
||||
// never runs while the user's changes may be sitting in it. Both intent
|
||||
// kinds share the messaging.
|
||||
if (changesMoveOutcomeUnknown) throw new ChangesMayBeInDestinationError(moveError);
|
||||
if (destination?.onMoveFailure) {
|
||||
return destination.onMoveFailure(moveError);
|
||||
}
|
||||
@@ -257,13 +329,7 @@ const moveSessionTreeTransaction = async (
|
||||
}
|
||||
useSessionUIStore.getState().setWorktreeMetadata(input.root.id, getLatestWorktreeMetadata(destination.metadata));
|
||||
|
||||
try {
|
||||
await refreshGlobalSessionsForDirectories([input.sourceDirectory, destination.directory]);
|
||||
} catch (error) {
|
||||
// Direct action updates already reconciled both stores. Keep the move
|
||||
// successful if this best-effort authoritative refresh is unavailable.
|
||||
console.warn('[session-worktree-move] Failed to refresh moved sessions', error);
|
||||
}
|
||||
await refreshMovedDirectories(input.sourceDirectory, destination.directory);
|
||||
return destination.directory;
|
||||
} finally {
|
||||
setSessionMovePending(input.root.id, false);
|
||||
@@ -315,11 +381,27 @@ const moveSessionTreeToQuickWorktree = async (input: {
|
||||
return {
|
||||
directory: worktree.path,
|
||||
metadata: worktree,
|
||||
onMoveFailure: (error) => removeFailedWorktree(project, worktree, error),
|
||||
// removeFailedWorktree force-deletes the worktree and its branch. The
|
||||
// transaction skips this callback when the change transfer's outcome is
|
||||
// unknown, so the worktree survives whenever it may hold the only copy.
|
||||
onMoveFailure: async (error) => removeFailedWorktree(project, worktree, error),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const describeMoveFailure = (
|
||||
messages: SessionTreeMoveMessages,
|
||||
failure: Error,
|
||||
moveChanges: boolean,
|
||||
): string => {
|
||||
if (failure instanceof ChangesMayBeInDestinationError) return messages.changesMayBeInDestination;
|
||||
if (failure instanceof IncompleteRollbackError && failure.changesMayBeInDestination) {
|
||||
return `${failure.message} ${messages.changesMayBeInDestination}`;
|
||||
}
|
||||
if (moveChanges && isApplyChangesError(failure)) return messages.applyChangesFailed;
|
||||
return failure.message;
|
||||
};
|
||||
|
||||
const executeSessionTreeMove = (intent: SessionTreeMoveIntent, moveChanges: boolean): void => {
|
||||
const movePromise = intent.kind === 'existing'
|
||||
? moveSessionTreeToExistingWorktree({
|
||||
@@ -341,9 +423,7 @@ const executeSessionTreeMove = (intent: SessionTreeMoveIntent, moveChanges: bool
|
||||
.catch((error) => {
|
||||
const failure = error instanceof Error ? error : new Error(String(error));
|
||||
toast.error(intent.messages.failure, {
|
||||
description: moveChanges && isApplyChangesError(failure)
|
||||
? intent.messages.applyChangesFailed
|
||||
: failure.message,
|
||||
description: describeMoveFailure(intent.messages, failure, moveChanges),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user