Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e7529c6
feat(worktree): add handoff protocol contracts, channels, and error c…
Aug 6, 2026
c9bfb93
feat(agent): add handoff execution-CWD rebind capability gate
Aug 6, 2026
332d307
refactor(worktree): derive handoff direction/blocker unions from cons…
Aug 6, 2026
5e8ce6b
feat(worktree): add conflict-safe handoff execution
Aug 7, 2026
60d8c8c
feat(worktree): implement managed-to-current handoff confirm
Aug 7, 2026
f9c9f48
feat(worktree): implement hand-back handoff confirm
Aug 7, 2026
461bfd6
feat(worktree): reconcile interrupted handoffs with snapshot-backed r…
Aug 7, 2026
4a3e838
feat(agent): reconstruct and verify handoff runtimes before Send
Aug 7, 2026
1648f8a
feat(electron): add worktree handoff preview/confirm/recovery UI
Aug 7, 2026
ce0fc10
fix(electron): gate handoff actions on provider capability and polish…
Aug 7, 2026
642a174
docs(worktree): add conflict-safe handoff ADR and documentation
Aug 7, 2026
0d01586
test(e2e): add worktree v2 handoff UI UAT specs
Aug 7, 2026
07f2491
test(worktree): close AC-15 handoff blocker and state-transfer covera…
Aug 7, 2026
ad187ea
fix(worktree): make handoff UI UAT pass end-to-end in the real app
Aug 7, 2026
486adfa
fix(worktree): address review findings on conflict-safe handoff
Aug 7, 2026
54846ea
fix(worktree): journal source-cleaned intent before the destructive r…
Aug 7, 2026
d451b13
fix(e2e): require an exact assistant reply in the agent reply test
Aug 7, 2026
4e61fc3
fix(worktree): address CodeRabbit findings on conflict-safe handoff
Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/electron/resources/release-notes/next.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This file accumulates release notes for the next unreleased version. PRs that ad
- **Share managed worktrees across sessions** — The workspace checkout control now offers **Existing worktree** for a new empty session: any ready managed worktree of the current workspace + repository can be selected, and the session binds to it as a shared owner without recreating or mutating the checkout. Every session bound to a worktree shows the same branch label; shared ownership shows as a Users icon and tooltip, and the existing deletion guards keep the checkout while any other session owns it ([#33](https://github.com/gannonh/kata-agents/issues/33), commit [ac0dd3b4](https://github.com/gannonh/kata-agents/commit/ac0dd3b4a41d81bef3c8c20353bc8b4cc2b3a4b1)).
- **Simplified managed worktree settings** — Worktrees settings now uses a compact delete-only list for active checkouts, labels the location **Worktree root**, and defaults **Automatically delete old worktrees** to off. The configurable **Auto-delete limit** still uses snapshot-first pruning so older worktrees remain recoverable on the owning server ([#41](https://github.com/gannonh/kata-agents/issues/41)).
- **Named managed worktrees and server-owned roots** — With Worktree V2 enabled, new worktrees accept a human-readable name that is normalized to lowercase kebab-case for the exact branch suffix and display name, while the Worktrees settings page configures a server-local materialization root without moving existing checkouts ([#40](https://github.com/gannonh/kata-agents/issues/40)).
- **Conflict-safe checkout handoff** — Sessions whose provider supports safe execution-CWD rebinding can move between the current checkout and a managed worktree: **Hand off to new worktree**, **Hand off to current checkout**, and **Hand back to worktree** preview the exact source/destination state, typed blockers, and cleanup before confirming; interrupted handoffs surface a snapshot-backed recovery state with a **Recover** action that rolls back exactly the journaled steps. The transcript and provider identity never move ([#42](https://github.com/gannonh/kata-agents/issues/42)).
## Improvements

## Bug Fixes
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,358 @@
import { describe, expect, it } from 'bun:test'
import type {
SessionCheckout,
WorktreeHandoffDirection,
WorktreeHandoffPreview,
WorktreeHandoffResult,
} from '@kata-sh/shared/protocol'
import {
canOfferHandoff,
canConfirmHandoff,
canConfirmHandoffForName,
canRecoverHandoff,
defaultNameForDirection,
finalizeHandoffName,
handoffDirectionsForCheckout,
initialHandoffDialogState,
isRemoteOwnedPreview,
normalizeHandoffNameInput,
recoveryResultFromStatus,
reduceHandoffDialog,
sourceStateKey,
} from '../input/handoff-controls'

function currentCheckout(): SessionCheckout {
return {
schemaVersion: 1,
mode: 'current',
repositoryRoot: '/repo',
checkoutPath: '/repo',
branchAtPreparation: null,
baseRef: null,
managedWorktreeId: null,
expectedBranch: null,
}
}

function managedCheckout(): SessionCheckout {
return {
schemaVersion: 2,
mode: 'managed-worktree',
repositoryRoot: '/repo',
checkoutPath: '/srv/worktrees/repo/ab12cd34',
branchAtPreparation: 'kata-agent/ab12cd34',
baseRef: 'main',
managedWorktreeId: 'repo-ab12cd34',
displayName: 'ab12cd34',
expectedBranch: 'kata-agent/ab12cd34',
materializationRoot: '/srv/worktrees',
}
}

function previewFor(overrides: Partial<WorktreeHandoffPreview> = {}): WorktreeHandoffPreview {
return {
transactionId: 'txn-abc',
previewFingerprint: 'f'.repeat(64),
direction: 'current-to-managed',
providerCapability: { adapterId: 'pi', executionCwdRebindable: true },
source: {
serverId: 'local',
branch: 'main',
headSha: 'a'.repeat(40),
state: 'clean',
checkoutPath: '/repo',
leases: [],
},
destination: {
serverId: 'local',
repositoryRoot: '/repo',
branch: 'kata-agent/ab12cd34',
checkoutPath: '/srv/worktrees/repo/ab12cd34',
exists: false,
leases: [],
},
includeCopyConflicts: [],
excludedIgnoredPolicy: { includeOnly: true, includeFileCount: 0 },
cleanup: { trackedFileCount: 0, stagedFileCount: 0, eligibleUntrackedFileCount: 0, includedIgnoredFileCount: 0 },
recoveryBehavior: 'destination-authoritative',
...overrides,
}
}

describe('handoff direction availability', () => {
it('offers current-to-managed for a legacy session without checkout', () => {
expect(handoffDirectionsForCheckout(undefined)).toEqual(['current-to-managed'])
})

it('offers current-to-managed for a bound current checkout', () => {
expect(handoffDirectionsForCheckout(currentCheckout())).toEqual(['current-to-managed'])
})

it('adds hand-back once a current session completed a prior handoff', () => {
expect(handoffDirectionsForCheckout(currentCheckout(), 'unverified')).toEqual(['current-to-managed', 'hand-back'])
expect(handoffDirectionsForCheckout(currentCheckout(), 'recovery-required')).toEqual(['current-to-managed', 'hand-back'])
})

it('offers managed-to-current for a managed worktree', () => {
expect(handoffDirectionsForCheckout(managedCheckout())).toEqual(['managed-to-current'])
})

it('canOfferHandoff is false only when no direction applies', () => {
expect(canOfferHandoff(undefined)).toBe(true)
expect(canOfferHandoff(currentCheckout())).toBe(true)
expect(canOfferHandoff(managedCheckout())).toBe(true)
})

it('defaults the name only for current-to-managed', () => {
expect(defaultNameForDirection('current-to-managed')).toMatch(/^[0-9a-f]{8}$/)
expect(defaultNameForDirection('managed-to-current')).toBe('')
expect(defaultNameForDirection('hand-back')).toBe('')
})

it('normalizes names like the checkout controls', () => {
expect(normalizeHandoffNameInput('Auth Refresh')).toBe('auth-refresh')
expect(finalizeHandoffName('auth-refresh/')).toBe('auth-refresh')
})
})

describe('handoff preview helpers', () => {
it('labels previews as remote only when the workspace is remote', () => {
expect(isRemoteOwnedPreview(previewFor(), false)).toBe(false)
expect(isRemoteOwnedPreview(previewFor(), true)).toBe(true)
})

it('maps source states to i18n keys', () => {
expect(sourceStateKey('clean')).toBe('git.handoff.state.clean')
expect(sourceStateKey('dirty')).toBe('git.handoff.state.dirty')
expect(sourceStateKey('detached')).toBe('git.handoff.state.detached')
})

it('confirms only a non-blocked preview', () => {
expect(canConfirmHandoff('preview', previewFor())).toBe(true)
expect(canConfirmHandoff('loading', previewFor())).toBe(false)
expect(canConfirmHandoff('preview', previewFor({ blocked: { blocked: true, code: 'destination-dirty', reason: 'occupied' } }))).toBe(false)
expect(canConfirmHandoff('preview', null)).toBe(false)
})

it('recovers only from a recovery-required result', () => {
const recovery: WorktreeHandoffResult = {
outcome: 'recovery-required',
transactionId: 'txn',
recovery: 'source-released',
retainedSnapshotId: 'abcd1234abcd1234',
reason: 'interrupted',
}
expect(canRecoverHandoff('recovery-required', recovery)).toBe(true)
expect(canRecoverHandoff('preview', recovery)).toBe(false)
expect(canRecoverHandoff('recovery-required', null)).toBe(false)
})

it('synthesizes a recovery-required result from an active status', () => {
const result = recoveryResultFromStatus(
{
active: true,
transactionId: 'txn-status',
direction: 'managed-to-current',
state: 'source-released',
retainedSnapshotId: 'abcd1234abcd1234',
since: 123,
},
'The handoff was interrupted.',
)
expect(result).toEqual({
outcome: 'recovery-required',
transactionId: 'txn-status',
recovery: 'source-released',
retainedSnapshotId: 'abcd1234abcd1234',
reason: 'The handoff was interrupted.',
})
})

it('opens directly into recovery from a status without preview', () => {
let state = reduceHandoffDialog(initialHandoffDialogState(), { type: 'open', direction: 'managed-to-current' })
state = reduceHandoffDialog(state, {
type: 'recovery-from-status',
result: {
outcome: 'recovery-required',
transactionId: 'txn-status',
recovery: 'runtime-rebuilding',
retainedSnapshotId: 'abcd1234abcd1234',
reason: 'interrupted after release',
},
})
expect(state.phase).toBe('recovery-required')
expect(state.result?.outcome).toBe('recovery-required')
expect(state.message).toBe('interrupted after release')
expect(canRecoverHandoff(state.phase, state.result)).toBe(true)
})
})

describe('handoff dialog state machine', () => {
it('opens into a loading phase with a default name for current-to-managed', () => {
const next = reduceHandoffDialog(initialHandoffDialogState(), { type: 'open', direction: 'current-to-managed' })
expect(next.phase).toBe('loading')
expect(next.nameInput).toMatch(/^[0-9a-f]{8}$/)
})

it('opens into a loading phase without a name for other directions', () => {
const next = reduceHandoffDialog(initialHandoffDialogState(), { type: 'open', direction: 'managed-to-current' })
expect(next.phase).toBe('loading')
expect(next.nameInput).toBe('')
})

it('shows the preview and keeps the server fingerprint for confirm', () => {
let state = reduceHandoffDialog(initialHandoffDialogState(), { type: 'open', direction: 'current-to-managed' })
state = reduceHandoffDialog(state, { type: 'preview-ready', preview: previewFor() })
expect(state.phase).toBe('preview')
expect(state.preview?.previewFingerprint).toBe('f'.repeat(64))

state = reduceHandoffDialog(state, { type: 'confirm' })
expect(state.phase).toBe('confirming')
})

it('surfaces a typed blocker and disables confirm', () => {
let state = reduceHandoffDialog(initialHandoffDialogState(), { type: 'open', direction: 'managed-to-current' })
state = reduceHandoffDialog(state, {
type: 'preview-ready',
preview: previewFor({ blocked: { blocked: true, code: 'destination-dirty', reason: 'The current checkout has tracked state.' } }),
})
expect(state.phase).toBe('preview-blocked')
expect(state.message).toBe('The current checkout has tracked state.')
expect(canConfirmHandoff(state.phase, state.preview)).toBe(false)
})

it('flags an unsupported provider without a confirm path', () => {
let state = reduceHandoffDialog(initialHandoffDialogState(), { type: 'open', direction: 'current-to-managed' })
state = reduceHandoffDialog(state, {
type: 'preview-ready',
preview: previewFor({ blocked: { blocked: true, code: 'unsupported-provider', reason: 'adapter cannot rebind' } }),
})
expect(state.phase).toBe('unsupported')
expect(canConfirmHandoff(state.phase, state.preview)).toBe(false)
})

it('re-previews after a name edit so confirm never uses a stale fingerprint', () => {
let state = reduceHandoffDialog(initialHandoffDialogState(), { type: 'open', direction: 'current-to-managed' })
state = reduceHandoffDialog(state, { type: 'preview-ready', preview: previewFor() })
state = reduceHandoffDialog(state, { type: 'name-changed', value: 'auth-refresh' })
expect(state.phase).toBe('loading')
expect(state.nameInput).toBe('auth-refresh')
expect(canConfirmHandoff(state.phase, state.preview)).toBe(false)
})

it('keeps confirm disabled until the preview matches the edited name', () => {
let state = reduceHandoffDialog(initialHandoffDialogState(), { type: 'open', direction: 'current-to-managed' })
// The generated default name does not match the fixed preview branch → the
// mismatch guard protects confirm (default-name transaction after edit).
state = reduceHandoffDialog(state, { type: 'preview-ready', preview: previewFor() })
expect(canConfirmHandoffForName(state)).toBe(false)
// Align the input with the previewed branch suffix → confirmable.
state = reduceHandoffDialog(state, { type: 'name-changed', value: 'ab12cd34' })
state = reduceHandoffDialog(state, { type: 'preview-ready', preview: previewFor() })
expect(canConfirmHandoffForName(state)).toBe(true)
// An edit re-previews; until the new preview lands, a stale preview with
// the OLD name must not be confirmable even if a stale render re-enables
// the button (E2E flake: default-name transaction confirmed after edit).
state = reduceHandoffDialog(state, { type: 'name-changed', value: 'new-name' })
state = reduceHandoffDialog(state, { type: 'preview-ready', preview: previewFor() })
expect(canConfirmHandoffForName(state)).toBe(false)
// The re-preview for the edited name re-enables confirm.
state = reduceHandoffDialog(state, {
type: 'preview-ready',
preview: previewFor({
destination: { ...previewFor().destination, branch: 'kata-agent/new-name' },
}),
})
expect(canConfirmHandoffForName(state)).toBe(true)
})

it('lets the user fix the name inline while a blocker keeps the preview unusable', () => {
let state = reduceHandoffDialog(initialHandoffDialogState(), { type: 'open', direction: 'current-to-managed' })
state = reduceHandoffDialog(state, {
type: 'preview-ready',
preview: previewFor({ blocked: { blocked: true, code: 'invalid-name', reason: 'not a valid branch suffix' } }),
})
expect(state.phase).toBe('preview-blocked')
state = reduceHandoffDialog(state, { type: 'name-changed', value: 'valid-name' })
expect(state.phase).toBe('loading')
expect(state.nameInput).toBe('valid-name')
expect(canConfirmHandoff(state.phase, state.preview)).toBe(false)
})

it('renders the committed summary after a successful confirm', () => {
const committed: WorktreeHandoffResult = {
outcome: 'committed',
transactionId: 'txn-abc',
summary: {
sessionId: 's1',
direction: 'current-to-managed',
checkout: managedCheckout(),
executionCwd: '/srv/worktrees/repo/ab12cd34',
transcriptCwd: '/repo/.kata/sessions/s1',
committedAt: 1,
},
}
let state = reduceHandoffDialog(initialHandoffDialogState(), { type: 'open', direction: 'current-to-managed' })
state = reduceHandoffDialog(state, { type: 'preview-ready', preview: previewFor() })
state = reduceHandoffDialog(state, { type: 'confirm' })
state = reduceHandoffDialog(state, { type: 'confirm-ready', result: committed })
expect(state.phase).toBe('committed')
expect(state.result?.outcome).toBe('committed')
expect(state.message).toBe('')
})

it('enters recovery-required on a failed confirm and recovers via a rolled-back blocker', () => {
const recovery: WorktreeHandoffResult = {
outcome: 'recovery-required',
transactionId: 'txn-abc',
recovery: 'runtime-rebuilding',
retainedSnapshotId: 'abcd1234abcd1234',
reason: 'runtime rebind failed',
}
let state = reduceHandoffDialog(initialHandoffDialogState(), { type: 'open', direction: 'current-to-managed' })
state = reduceHandoffDialog(state, { type: 'preview-ready', preview: previewFor() })
state = reduceHandoffDialog(state, { type: 'confirm' })
state = reduceHandoffDialog(state, { type: 'confirm-ready', result: recovery })
expect(state.phase).toBe('recovery-required')
expect(state.message).toBe('runtime rebind failed')

state = reduceHandoffDialog(state, { type: 'recover' })
expect(state.phase).toBe('recovering')

const rolledBack: WorktreeHandoffResult = {
outcome: 'blocked',
transactionId: 'txn-abc',
code: 'handoff-rolled-back',
reason: 'The interrupted handoff was rolled back; preview again to retry.',
}
state = reduceHandoffDialog(state, { type: 'recover-ready', result: rolledBack })
expect(state.phase).toBe('blocked')
expect(state.result?.outcome).toBe('blocked')
})

it('keeps recovery-required when recover cannot finish', () => {
const recovery: WorktreeHandoffResult = {
outcome: 'recovery-required',
transactionId: 'txn-abc',
recovery: 'source-released',
reason: 'snapshot authority missing',
}
let state = reduceHandoffDialog(initialHandoffDialogState(), { type: 'open', direction: 'hand-back' })
state = reduceHandoffDialog(state, { type: 'preview-ready', preview: previewFor() })
state = reduceHandoffDialog(state, { type: 'confirm' })
state = reduceHandoffDialog(state, { type: 'confirm-ready', result: recovery })
state = reduceHandoffDialog(state, { type: 'recover' })
state = reduceHandoffDialog(state, { type: 'recover-ready', result: recovery })
expect(state.phase).toBe('recovery-required')
})

it('surfaces preview errors and resets to idle', () => {
let state = reduceHandoffDialog(initialHandoffDialogState(), { type: 'open', direction: 'current-to-managed' })
state = reduceHandoffDialog(state, { type: 'preview-error', message: 'server unreachable' })
expect(state.phase).toBe('error')
expect(state.message).toBe('server unreachable')

state = reduceHandoffDialog(state, { type: 'reset' })
expect(state).toEqual(initialHandoffDialogState())
})
})
Loading
Loading