Only retain a rollback obligation a retry could still discharge

Dismissal removes the pending creation entry before invoking the deferred
rollback, so a re-armed hook there could never run again and would strand the
attempt, and the workspace it references, for the session. Retry keeps the
entry, so it is the only caller that can re-attempt.

Gate the retained obligation on the entry still being present.
This commit is contained in:
Neil
2026-09-12 23:06:05 -07:00
parent 60f46ed52c
commit 6dbe04042b
2 changed files with 21 additions and 1 deletions
@@ -288,6 +288,19 @@ describe('worktree creation cancellation', () => {
expect(finalRetry).toHaveBeenCalledOnce()
})
it('does not arm an unreachable cleanup when a dismissal rollback fails', async () => {
await withWorktreeCreationCancellation('creation', async (attempt) => {
attempt.worktree = worktree
})
state.removeWorktree.mockResolvedValue({ ok: false, error: 'Host unavailable' })
// Dismissal removes the pending entry first, so nothing could ever invoke a
// re-armed hook again — holding the attempt would strand it for the session.
delete state.pendingWorktreeCreations.creation
expect(cancelActiveWorktreeCreation('creation')).toBe(true)
await vi.waitFor(() => expect(state.removeWorktree).toHaveBeenCalledOnce())
await vi.waitFor(() => expect(cancelActiveWorktreeCreation('creation')).toBe(false))
})
it('lets retry proceed once an unidentifiable workspace has been reported', async () => {
const unstamped = makeWorktree({ id: 'repo::/workspace', repoId: 'repo', hostId: 'ssh:owner' })
await withWorktreeCreationCancellation('creation', async (attempt) => {
@@ -45,7 +45,14 @@ export async function withWorktreeCreationCancellation(
// would let the next retry create a second workspace beside the first, so
// keep the obligation until a removal actually discharges it. An unidentified
// workspace is exempt: no call was made, so retrying can never discharge it.
if (deferred && !outcome.ok && outcome.retryable) {
// The entry check separates retry (kept, so it can re-attempt) from dismissal
// (already gone, so a re-armed hook could never run again).
if (
deferred &&
!outcome.ok &&
outcome.retryable &&
useAppStore.getState().pendingWorktreeCreations[creationId]
) {
attempt.cleanupAfterSettlement = () => cleanup(deferred)
} else {
releaseActiveWorktreeCreation(creationId, attempt)