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
+5 -7
View File
@@ -71,7 +71,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { requestSessionTreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
import { buildSessionTreeMoveMessages, requestSessionTreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors';
@@ -1064,12 +1064,10 @@ export const Header: React.FC = () => {
root,
descendants,
sourceDirectory: sessionDirectory,
messages: {
success: t('sessions.sidebar.session.moveToWorktree.success'),
failure: t('sessions.sidebar.session.moveToWorktree.failed'),
sourceVerificationFailed: t('sessions.sidebar.session.moveToWorktree.sourceVerificationFailed'),
applyChangesFailed: t('sessions.sidebar.session.moveToWorktree.applyChangesFailed'),
},
messages: buildSessionTreeMoveMessages(t, {
success: 'sessions.sidebar.session.moveToWorktree.success',
failure: 'sessions.sidebar.session.moveToWorktree.failed',
}),
});
}, [currentSessionId, isCurrentSessionActive, isCurrentSessionMovingToWorktree, sessionDirectory, t]);
@@ -20,6 +20,12 @@ kept at this root in `types.ts` and `utils.tsx`.
changes and roll back session-only if a later descendant fails. The root moves
last and carries source changes once, which prevents rollback from replaying the
transferred patch into the source.
- Failure cleanup: a worktree created for the move is removed only after a
definite failure. When the change-carrying request fails without confirming
its outcome, that worktree is KEPT (it may hold the only copy of the user's
changes), both directories are refreshed authoritatively because the session
may have moved server-side, and the toast points the user at the destination.
Existing destinations are never removed; they get the same guidance.
`MainLayout` and `VSCodeLayout` call `useSessionListSync({ isVSCode })`
unconditionally. The hook publishes complete directory bootstrap demand,
@@ -33,6 +33,7 @@ const makeMoveMessages = (): SessionTreeMoveMessages => ({
failure: 'move failed',
sourceVerificationFailed: 'source verification failed',
applyChangesFailed: 'apply changes failed',
changesMayBeInDestination: 'changes may be in destination',
});
const makeExistingIntent = (): SessionTreeMoveIntent => ({
@@ -48,6 +48,7 @@ import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog
import { FusionIcon } from '@/components/icons/FusionIcon';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import {
buildSessionTreeMoveMessages,
requestSessionTreeMove,
useIsSessionWorktreeMovePending,
} from '@/lib/worktrees/sessionWorktreeMove';
@@ -1047,12 +1048,10 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
descendants: collectNodeDescendantSessions(node),
sourceDirectory: sessionDirectory,
destination: target.metadata,
messages: {
success: t('sessions.sidebar.session.moveToWorktree.existingSuccess'),
failure: t('sessions.sidebar.session.moveToWorktree.existingFailed'),
sourceVerificationFailed: t('sessions.sidebar.session.moveToWorktree.sourceVerificationFailed'),
applyChangesFailed: t('sessions.sidebar.session.moveToWorktree.applyChangesFailed'),
},
messages: buildSessionTreeMoveMessages(t, {
success: 'sessions.sidebar.session.moveToWorktree.existingSuccess',
failure: 'sessions.sidebar.session.moveToWorktree.existingFailed',
}),
});
}}
>
@@ -1086,12 +1085,10 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
root: resolvedSession,
descendants: collectNodeDescendantSessions(node),
sourceDirectory: sessionDirectory,
messages: {
success: t('sessions.sidebar.session.moveToWorktree.success'),
failure: t('sessions.sidebar.session.moveToWorktree.failed'),
sourceVerificationFailed: t('sessions.sidebar.session.moveToWorktree.sourceVerificationFailed'),
applyChangesFailed: t('sessions.sidebar.session.moveToWorktree.applyChangesFailed'),
},
messages: buildSessionTreeMoveMessages(t, {
success: 'sessions.sidebar.session.moveToWorktree.success',
failure: 'sessions.sidebar.session.moveToWorktree.failed',
}),
});
}}
className="[&>svg]:mr-1"