diff --git a/AGENTS.md b/AGENTS.md index 2d5bd0ff..7c61a31d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,15 @@ Each package has its own agent context file — read it before modifying that pa - Commits: Conventional Commits (`feat(scope): summary`). Commit after every logical unit of work. - Deferred work: any work deferred during planning, implementation, verification, or testing must be filed as a GitHub issue immediately using the `.github/ISSUE_TEMPLATE/deferred_work.yml` template. Do not leave deferrals only in code comments, chat, or memory. +## Credentials and E2E provider UAT + +**This environment is credentialed by default.** Provider-requiring E2E tests run here — never assume "no credentials", never defer UAT to a later issue, and never wire a fake provider seam without asking the user first. + +- **Primary credential: codex OAuth.** The app is already authenticated against the codex harness (credentials in `dotfiles/pi/.pi/agent/auth.json`); the `chatgpt-plus` connection is reused without entering a key. +- **Fallback chain: root `.env`.** `KATA_E2E_AGENT_PROVIDER[_NN]` + `KATA_E2E_AGENT_MODEL[_NN]` select the provider; the matching `KATA_*_API_KEY` supplies the key (`openai-codex` → `KATA_OPENAI_API_KEY`, `opencode-go` → `KATA_OPENCODE_GO_API_KEY`, `openrouter` → `KATA_OPENROUTER_API_KEY`, `deepseek` → `KATA_DEEPSEEK_API_KEY`, `anthropic` → `KATA_ANTHROPIC_API_KEY` — avoid, expensive). +- The agent E2E specs (`@agent`, `@worktree-v2 fork`, `@worktree-v2 handoff`) walk the whole chain via `runWithAgentProviderFallback` (`e2e/src/flows/agentChat.ts`) and only fail after every option is exhausted, with each attempt logged and the aggregated failure naming every option. +- Before deferring any UAT tier or claiming credentials are unavailable: check the chain above and **ask the user**. Deterministic adapters (`@kata-sh/shared/agent/testing`) are test doubles only — never import them from production code, and never add `KATA_*_DETERMINISTIC_ADAPTER`-style env seams to production paths. + ## Active context - **Complete Kata brand transition** is complete. Canonical identity: `@kata-sh/*` packages, `KATA_*` env vars, `~/.kata-agents`, `kataagents://`, `sh.kata.agents`, and `agents.kata.sh`. See `./docs/specs/archive/2026-06-22-complete-kata-brand-transition-design.md` and the verify report `./docs/specs/archive/2026-06-23-complete-kata-brand-transition-verify-report.md`. Verify passed 2026-06-23 (all 12 ACs); fixes included a broken `kata-agent.svg` tool icon, Craft-named `kata-logos` assets, dead `CraftAppIcon` code, a `copy-assets.ts` stale-file hygiene fix, and GitHub org ref reconciliation. diff --git a/apps/electron/resources/release-notes/next.md b/apps/electron/resources/release-notes/next.md index 8477b353..2eeae636 100644 --- a/apps/electron/resources/release-notes/next.md +++ b/apps/electron/resources/release-notes/next.md @@ -8,6 +8,7 @@ This file accumulates release notes for the next unreleased version. PRs that ad - **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)). +- **Isolated conversation forks** — The Branch action now offers **New isolated worktree** next to the default **Shared worktree** for sessions whose provider advertises a strict cross-CWD native fork: the fork previews the source conversation head, branch, HEAD, owners, and destination identity, takes an editable name (`kata-agent/` branch), and commits a child session that copies the conversation through the current head into its own managed worktree and runtime while the source stays untouched. Provider identity stays **Pending** until the child's first message establishes the native fork; a failed establishment leaves one persisted message in a visible retryable state and never duplicates it ([#43](https://github.com/gannonh/kata-agents/issues/43)). ## Improvements ## Bug Fixes diff --git a/apps/electron/src/renderer/App.tsx b/apps/electron/src/renderer/App.tsx index 70a30eb7..28ae0fd3 100644 --- a/apps/electron/src/renderer/App.tsx +++ b/apps/electron/src/renderer/App.tsx @@ -22,6 +22,7 @@ import { ResetConfirmationDialog } from '@/components/ResetConfirmationDialog' import { DeleteSessionDialog } from '@/components/app-shell/DeleteSessionDialog' import { resolveDeleteConfirmation } from '@/components/app-shell/worktree-removal' import { FEATURE_FLAGS } from '@kata-sh/shared/feature-flags' +import { WORKTREE_FORK_ERROR_CODE } from '@kata-sh/shared/protocol' import { SplashScreen } from '@/components/SplashScreen' import { TooltipProvider } from '@kata-sh/ui' import { FocusProvider } from '@/context/FocusContext' @@ -54,6 +55,8 @@ import { loadedSessionsAtom, forceSessionMessagesReloadAtom, backgroundTasksAtomFamily, + forkRetryAtomFamily, + findPendingForkRetryMessage, extractSessionMeta, windowWorkspaceIdAtom, type SessionMeta, @@ -500,6 +503,61 @@ export default function App() { } }, [clearStreamingState, replaceLoadedSession, syncSessionOptionsFromSession, reconcilePermissionModeState, store]) + /** + * Poll a fork child's session DTO until its pending fork intent retires + * (first-Send establishment succeeded) or the attempts are exhausted. The + * send RPC resolves before the establish flow runs, so the renderer cannot + * learn establishment success from the RPC alone; a refresh also retires the + * PENDING provider identity badge and any retry banner (Phase 4). + */ + const refreshUntilForkEstablished = React.useCallback( + (sessionId: string) => { + void (async () => { + for (const delay of [1500, 3000, 6000]) { + await new Promise((resolve) => setTimeout(resolve, delay)) + const result = await refreshSessionFromServer(sessionId) + if (result === 'failed') continue + const session = store.get(sessionAtomFamily(sessionId)) + if (!session?.forkPending) { + store.set(forkRetryAtomFamily(sessionId), null) + return + } + } + })() + }, + [refreshSessionFromServer, store], + ) + + /** + * Retry a failed isolated-fork establishment: re-send the SAME persisted + * user message via existingMessageId (the server reuses the persisted fork + * idempotency key, so neither the provider artifact nor the message is ever + * duplicated). Clears the banner immediately; a re-failure re-surfaces it + * via the typed error event. + */ + const handleRetryForkSend = React.useCallback( + async (sessionId: string): Promise => { + const retry = store.get(forkRetryAtomFamily(sessionId)) + if (!retry) return false + store.set(forkRetryAtomFamily(sessionId), null) + try { + await window.electronAPI.sendMessage(sessionId, retry.text, undefined, undefined, { + existingMessageId: retry.messageId, + }) + } catch (error) { + store.set(forkRetryAtomFamily(sessionId), { + ...retry, + error: error instanceof Error ? error.message : String(error), + }) + return false + } + refreshUntilForkEstablished(sessionId) + return true + }, + [store, refreshUntilForkEstablished], + ) + + const loadSessionsFromServer = useCallback(async () => { setSessionLoadError(null) @@ -963,6 +1021,20 @@ export default function App() { return } + // Phase 4: a typed retryable isolated-fork establishment failure (the + // child stays pending with its single persisted user message). Surface + // the chat-input retry banner using the durable post-branch user + // message so the retry reuses the persisted message. + if (event.type === 'error' && event.code === WORKTREE_FORK_ERROR_CODE) { + const retryMessage = findPendingForkRetryMessage(store.get(sessionAtomFamily(sessionId))) + if (retryMessage) { + store.set(forkRetryAtomFamily(sessionId), { + ...retryMessage, + error: event.error, + }) + } + } + const agentEvent = event as unknown as AgentEvent // Track activity for stale session watchdog @@ -1473,6 +1545,12 @@ export default function App() { badges: badges.length > 0 ? badges : undefined, optimisticMessageId: userMessage.id, }) + // First send on a pending fork child: poll until the establish flow + // retires forkPending so the PENDING badge clears without a manual + // refresh. The RPC resolves before establishment runs. + if (store.get(sessionAtomFamily(sessionId))?.forkPending) { + refreshUntilForkEstablished(sessionId) + } // Resolved once the message is persisted/accepted (pre-persist failures // reject and land in the catch below). Signals successful submission so // callers like the Changes feedback flow can safely clear local state. @@ -1493,7 +1571,7 @@ export default function App() { })) return false } - }, [sessionOptions, updateSessionById, skills, sources, windowWorkspaceId]) + }, [sessionOptions, updateSessionById, skills, sources, windowWorkspaceId, refreshUntilForkEstablished, store]) /** * Unified handler for all session option changes. @@ -1930,6 +2008,7 @@ export default function App() { // Session callbacks onCreateSession: handleCreateSession, onSendMessage: handleSendMessage, + onRetryForkSend: handleRetryForkSend, onRenameSession: handleRenameSession, onFlagSession: handleFlagSession, onUnflagSession: handleUnflagSession, @@ -1977,6 +2056,7 @@ export default function App() { updateDefaultThinkingLevel, handleCreateSession, handleSendMessage, + handleRetryForkSend, handleRenameSession, handleFlagSession, handleUnflagSession, diff --git a/apps/electron/src/renderer/atoms/__tests__/sessions.test.ts b/apps/electron/src/renderer/atoms/__tests__/sessions.test.ts index 871ac887..0437b8b2 100644 --- a/apps/electron/src/renderer/atoms/__tests__/sessions.test.ts +++ b/apps/electron/src/renderer/atoms/__tests__/sessions.test.ts @@ -11,6 +11,7 @@ import { refreshSessionsMetadataAtom, initializeSessionsAtom, replaceLoadedSessionAtom, + findPendingForkRetryMessage, } from '../sessions' function msg(id: string, role: Message['role'] = 'user'): Message { @@ -33,6 +34,45 @@ function makeSession(overrides: Partial = {}): Session { } as Session } +describe('pending fork retry recovery', () => { + it('finds the durable user message after the branch point', () => { + const session = makeSession({ + forkPending: true, + branchFromMessageId: 'head', + messages: [msg('parent-user'), msg('head', 'assistant'), msg('retry-user')], + }) + + expect(findPendingForkRetryMessage(session)).toEqual({ + messageId: 'retry-user', + text: 'content:retry-user', + }) + }) + + it('does not treat copied parent history as a retry message', () => { + const session = makeSession({ + forkPending: true, + branchFromMessageId: 'head', + messages: [msg('parent-user'), msg('head', 'assistant')], + }) + + expect(findPendingForkRetryMessage(session)).toBeNull() + }) + + it('does not skip a structured first user message for a later text message', () => { + const structured = { + ...msg('structured-user'), + content: { kind: 'structured' } as unknown as string, + } + const session = makeSession({ + forkPending: true, + branchFromMessageId: 'head', + messages: [msg('head', 'assistant'), structured, msg('later-user')], + }) + + expect(findPendingForkRetryMessage(session)).toBeNull() + }) +}) + describe('session message loading atoms', () => { const originalWindow = globalThis.window diff --git a/apps/electron/src/renderer/atoms/sessions.ts b/apps/electron/src/renderer/atoms/sessions.ts index a01059c3..67451171 100644 --- a/apps/electron/src/renderer/atoms/sessions.ts +++ b/apps/electron/src/renderer/atoms/sessions.ts @@ -701,6 +701,47 @@ export const backgroundTasksAtomFamily = atomFamily( (a, b) => a === b ) +/** + * Retryable isolated-fork establishment failure surfaced above the chat input + * (Phase 4). Set when a fork child's first-Send establish fails with the typed + * WORKTREE_FORK_FAILED code; cleared on Retry, manual dismiss, or when the + * session DTO stops reporting forkPending. The server message ID lets the retry + * reuse the already-persisted user message instead of duplicating it. + */ +export interface ForkRetryState { + /** Server-persisted user message ID to reuse on retry. */ + messageId: string + /** The message text to re-send. */ + text: string + /** Sanitized server error detail. */ + error: string +} + +/** + * Recover the first-Send message from the durable child transcript. The + * branch marker separates copied parent history from the one user message + * whose establishment can be retried; no renderer-local send map is needed. + */ +export function findPendingForkRetryMessage( + session: Pick | null | undefined, +): Pick | null { + if (!session?.forkPending || !session.branchFromMessageId) return null + const branchIndex = session.messages.findIndex( + (message) => message.id === session.branchFromMessageId, + ) + if (branchIndex < 0) return null + const message = session.messages + .slice(branchIndex + 1) + .find((candidate) => candidate.role === 'user') + if (!message || typeof message.content !== 'string') return null + return { messageId: message.id, text: message.content } +} + +export const forkRetryAtomFamily = atomFamily( + (_sessionId: string) => atom(null), + (a, b) => a === b +) + /** * Window's current workspace ID — shared between Root (ThemeProvider) and App. * Written by App on workspace switch, read by Root to keep the theme in sync. diff --git a/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx b/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx index 91ac8521..ddc6d437 100644 --- a/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx +++ b/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx @@ -10,12 +10,17 @@ import { CircleAlert, ExternalLink, Info, + Loader2, + RotateCcw, + TriangleAlert, X, } from "lucide-react" import { motion, AnimatePresence } from "motion/react" import { toast } from "sonner" +import { useAtomValue, useSetAtom } from "jotai" import { ScrollArea } from "@/components/ui/scroll-area" +import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { coerceInputText, appendRestoredInput } from "@/lib/input-text" import { Markdown, CollapsibleMarkdownProvider, StreamingMarkdown, type RenderMode } from "@/components/markdown" @@ -76,6 +81,9 @@ import { CHAT_LAYOUT } from "@/config/layout" import { collectFileChangesFromActivities, getFirstFileChangeIdForActivity } from "@/lib/file-changes" import { resolveBranchNewPanelOption } from "./branching" import { handleErrorMessageAction } from "./error-message-actions" +import { ForkDialog } from "./fork/ForkDialog" +import { useForkCapability } from "./fork/ForkAction" +import { findPendingForkRetryMessage, forkRetryAtomFamily } from "@/atoms/sessions" // ============================================================================ // CSS Custom Highlight API helper @@ -540,6 +548,80 @@ export const ChatDisplay = React.forwardRef // Navigation for session branching const { navigate } = useNavigation() + // Worktree V2 effectiveness gates the fork dialog: V2 on → the Branch + // action opens the fork dialog; V2 off → immediate shared branch (byte- + // identical to the pre-Phase-4 behavior). + const { v2Effective: worktreeV2Effective, v2Pending: worktreeV2Pending } = useForkCapability() + const [forkDialog, setForkDialog] = React.useState<{ + messageId: string + newPanel?: boolean + } | null>(null) + + // Retryable isolated-fork establishment failure surfaced above the input + // (Phase 4). App sets this from the typed error event, and the initial + // loaded snapshot can reconstruct it from the durable child transcript. + const forkRetry = useAtomValue(forkRetryAtomFamily(session?.id ?? '__no_session__')) + const setForkRetry = useSetAtom(forkRetryAtomFamily(session?.id ?? '__no_session__')) + const clearForkRetry = useSetAtom(forkRetryAtomFamily(session?.id ?? '__no_session__')) + const forkRetryHydratedSessionIdsRef = React.useRef>(new Set()) + const [forkRetrying, setForkRetrying] = React.useState(false) + + // On a renderer reload, reconstruct a failed first-Send retry from the + // durable child transcript. Only inspect the fully loaded initial snapshot; + // a later first-send message is surfaced by App's typed error event instead + // of being mistaken for a failure before establishment has run. + React.useEffect(() => { + if (!session || forkRetryHydratedSessionIdsRef.current.has(session.id)) return + if ( + session.messageCount !== undefined && + session.messages.length < session.messageCount + ) { + return + } + forkRetryHydratedSessionIdsRef.current.add(session.id) + if (!session.forkPending || forkRetry) return + const retryMessage = findPendingForkRetryMessage(session) + if (retryMessage) { + setForkRetry({ + ...retryMessage, + error: t('git.fork.establishIncomplete'), + }) + } + }, [session, forkRetry, setForkRetry, t]) + const handleRetryFork = React.useCallback(async () => { + if (!session || forkRetrying) return + setForkRetrying(true) + try { + await appShellContext?.onRetryForkSend?.(session.id) + } finally { + setForkRetrying(false) + } + }, [session, forkRetrying, appShellContext]) + + // Shared-worktree branch creation — the pre-existing branch flow reused both + // by the immediate (V2-off) path and by the fork dialog's shared strategy. + const createSharedBranch = React.useCallback( + async (messageId: string, options?: { newPanel?: boolean }) => { + if (!session) return + const child = await appShellContext.onCreateSession( + session.workspaceId, + { + branchFromMessageId: messageId, + branchFromSessionId: session.id, + name: `Branch of ${session.name || 'Untitled'}`, + // Keep branch on the same backend/provider by inheriting parent session settings. + llmConnection: session.llmConnection, + model: session.model, + permissionMode: session.permissionMode, + workingDirectory: session.workingDirectory, + enabledSourceSlugs: session.enabledSourceSlugs, + }, + ) + navigate(routes.view.allSessions(child.id), { newPanel: resolveBranchNewPanelOption(options) }) + }, + [session, appShellContext, navigate], + ) + // Get isDark from useTheme hook for overlay theme // This accounts for scenic themes (like Haze) that force dark mode const { isDark } = useTheme() @@ -1733,22 +1815,15 @@ export const ChatDisplay = React.forwardRef openAnnotationRequest={openAnnotationRequest} onBranch={session?.supportsBranching ? async (messageId: string, options?: { newPanel?: boolean }) => { if (!session) return + // Worktree V2 effective → the fork dialog offers both + // strategies (shared default, isolated when eligible). + // V2 off → keep today's immediate shared branch. + if (worktreeV2Effective && !worktreeV2Pending) { + setForkDialog({ messageId, newPanel: options?.newPanel }) + return + } try { - const child = await appShellContext.onCreateSession( - session.workspaceId, - { - branchFromMessageId: messageId, - branchFromSessionId: session.id, - name: `Branch of ${session.name || 'Untitled'}`, - // Keep branch on the same backend/provider by inheriting parent session settings. - llmConnection: session.llmConnection, - model: session.model, - permissionMode: session.permissionMode, - workingDirectory: session.workingDirectory, - enabledSourceSlugs: session.enabledSourceSlugs, - } - ) - navigate(routes.view.allSessions(child.id), { newPanel: resolveBranchNewPanelOption(options) }) + await createSharedBranch(messageId, options) } catch (error) { const rawMessage = error instanceof Error ? error.message : 'Failed to create branch' const message = rawMessage.includes('source and target providers must match') @@ -1916,6 +1991,46 @@ export const ChatDisplay = React.forwardRef {/* === INPUT CONTAINER: FreeForm or Structured Input === */} + {forkRetry && ( +
+
+ +
+ + {t('git.fork.retryTitle')} + + {forkRetry.error} +
+ + +
+
+ )} ) : null} + {/* Fork dialog (Phase 4): Branch action with Worktree V2 effective. */} + {forkDialog && session && ( + + createSharedBranch(messageId, { newPanel: forkDialog.newPanel }) + } + onCommitted={(childSessionId) => { + const newPanel = forkDialog.newPanel + setForkDialog(null) + navigate(routes.view.allSessions(childSessionId), { + newPanel: resolveBranchNewPanelOption({ newPanel }), + }) + }} + onOpenChange={(open) => { + if (!open) setForkDialog(null) + }} + /> + )} + {/* ================================================================== */} {/* Preview Overlays - Rendered outside the main chat flow */} {/* ================================================================== */} diff --git a/apps/electron/src/renderer/components/app-shell/__tests__/fork-controls.test.ts b/apps/electron/src/renderer/components/app-shell/__tests__/fork-controls.test.ts new file mode 100644 index 00000000..52030622 --- /dev/null +++ b/apps/electron/src/renderer/components/app-shell/__tests__/fork-controls.test.ts @@ -0,0 +1,588 @@ +import { describe, expect, it } from 'bun:test' +import type { + ConversationForkPreview, + ConversationForkResult, + ConversationForkStatus, +} from '@kata-sh/shared/protocol' +import { + canConfirmFork, + canConfirmForkForName, + canRecoverFork, + finalizeForkName, + forkCommittedChildSessionId, + forkIsolatedDisabledReason, + forkIsolatedEligible, + forkStrategyDefault, + initialForkDialogState, + normalizeForkNameInput, + recoveryResultFromForkStatus, + reduceForkDialog, +} from '../input/fork-controls' + +function committedResult(): Extract { + return { + outcome: 'committed', + transactionId: 'txn-abc', + summary: { + sessionId: 'child-1', + strategy: 'isolated-worktree', + checkout: { + 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', + }, + executionCwd: '/srv/worktrees/repo/ab12cd34', + transcriptCwd: '/repo/.kata/sessions/s1', + childProviderIdPresent: false, + committedAt: 1, + }, + } +} + +function previewFor(overrides: Partial = {}): ConversationForkPreview { + return { + transactionId: 'txn-abc', + previewFingerprint: 'f'.repeat(64), + strategy: 'isolated-worktree', + providerCapability: { adapterId: 'pi', strictCrossCwdNativeFork: true }, + source: { + serverId: 'local', + sessionId: 's1', + conversationHeadMessageId: 'msg-9', + conversationHeadTurnId: 'turn-9', + checkout: { mode: 'current' }, + branch: 'main', + headSha: 'a'.repeat(40), + gitState: { + state: 'clean', + stagedFileCount: 0, + unstagedFileCount: 0, + untrackedFileCount: 0, + includedIgnoredFileCount: 0, + }, + leases: [], + }, + destination: { + serverId: 'local', + repositoryRoot: '/repo', + branch: 'kata-agent/ab12cd34', + checkoutPath: '/srv/worktrees/repo/ab12cd34', + exists: false, + leases: [], + }, + excludedIgnoredPolicy: { includeOnly: true, includeFileCount: 0 }, + currentHead: true, + ...overrides, + } +} + +describe('fork strategy default + eligibility', () => { + it('defaults to the shared-worktree strategy', () => { + expect(forkStrategyDefault()).toBe('shared-worktree') + }) + + it('offers isolated only when the provider is capable AND the branch point is the current head', () => { + expect(forkIsolatedEligible({ isolatedCapable: true, atConversationHead: true })).toBe(true) + expect(forkIsolatedEligible({ isolatedCapable: false, atConversationHead: true })).toBe(false) + expect(forkIsolatedEligible({ isolatedCapable: true, atConversationHead: false })).toBe(false) + expect(forkIsolatedEligible({ isolatedCapable: false, atConversationHead: false })).toBe(false) + }) + + it('normalizes names like the checkout controls', () => { + expect(normalizeForkNameInput('Auth Refresh')).toBe('auth-refresh') + expect(finalizeForkName('auth-refresh/')).toBe('auth-refresh') + }) +}) + +describe('fork preview helpers', () => { + it('confirms only a non-blocked preview', () => { + expect(canConfirmFork('preview', previewFor())).toBe(true) + expect(canConfirmFork('loading', previewFor())).toBe(false) + expect( + canConfirmFork('preview', previewFor({ blocked: { blocked: true, code: 'non-head-source', reason: 'older point' } })), + ).toBe(false) + expect(canConfirmFork('preview', null)).toBe(false) + }) + + it('recovers only from a recovery-required result', () => { + const recovery: ConversationForkResult = { + outcome: 'recovery-required', + transactionId: 'txn', + recovery: 'binding-committed', + retainedSnapshotId: 'abcd1234abcd1234', + reason: 'interrupted', + } + expect(canRecoverFork('recovery-required', recovery)).toBe(true) + expect(canRecoverFork('preview', recovery)).toBe(false) + expect(canRecoverFork('recovery-required', null)).toBe(false) + }) + + it('synthesizes a recovery-required result from an active status', () => { + const status: Extract = { + active: true, + transactionId: 'txn-status', + strategy: 'isolated-worktree', + state: 'binding-committed', + retainedSnapshotId: 'abcd1234abcd1234', + since: 123, + providerIdentity: { status: 'pending' }, + } + const result = recoveryResultFromForkStatus(status, 'The fork was interrupted.') + expect(result).toEqual({ + outcome: 'recovery-required', + transactionId: 'txn-status', + recovery: 'binding-committed', + retainedSnapshotId: 'abcd1234abcd1234', + reason: 'The fork was interrupted.', + }) + }) + + it('omits retainedSnapshotId when the active status does not carry one', () => { + const result = recoveryResultFromForkStatus( + { + active: true, + transactionId: 'txn-status', + strategy: 'shared-worktree', + state: 'pending', + since: 123, + providerIdentity: { status: 'pending' }, + }, + 'interrupted', + ) + expect(result.outcome).toBe('recovery-required') + expect('retainedSnapshotId' in result).toBe(false) + }) +}) + +describe('fork isolated disable reason', () => { + it('stays empty when the strategy may be selected', () => { + expect( + forkIsolatedDisabledReason({ + phase: 'preview', + strategy: 'isolated-worktree', + atConversationHead: true, + isolatedCapable: true, + blockedMessage: '', + }), + ).toBe('') + }) + + it('reports a typed reason for an unsupported provider', () => { + expect( + forkIsolatedDisabledReason({ + phase: 'preview', + strategy: 'shared-worktree', + atConversationHead: true, + isolatedCapable: false, + blockedMessage: '', + }), + ).toBe('git.fork.unsupportedProviderDisabled') + }) + + it('reports a typed reason for a non-head source turn', () => { + expect( + forkIsolatedDisabledReason({ + phase: 'preview', + strategy: 'shared-worktree', + atConversationHead: false, + isolatedCapable: true, + blockedMessage: '', + }), + ).toBe('git.fork.nonHeadDisabled') + }) + + it('prefers the current blocked preview reason for the isolated strategy', () => { + expect( + forkIsolatedDisabledReason({ + phase: 'preview-blocked', + strategy: 'isolated-worktree', + atConversationHead: true, + isolatedCapable: true, + blockedMessage: 'The requested worktree name is not a valid Git branch suffix.', + }), + ).toBe('The requested worktree name is not a valid Git branch suffix.') + }) + + it('does not leak a blocked shared preview reason into the isolated row', () => { + expect( + forkIsolatedDisabledReason({ + phase: 'preview-blocked', + strategy: 'shared-worktree', + atConversationHead: true, + isolatedCapable: true, + blockedMessage: 'source is missing', + }), + ).toBe('') + }) +}) + +describe('fork committed child session id', () => { + it('returns the child session id from a committed result (navigation target)', () => { + expect(forkCommittedChildSessionId(committedResult())).toBe('child-1') + }) + + it('returns null for blocked and recovery-required outcomes', () => { + expect( + forkCommittedChildSessionId({ + outcome: 'blocked', + transactionId: 'txn-abc', + code: 'identity-drift', + reason: 'drift', + }), + ).toBeNull() + expect( + forkCommittedChildSessionId({ + outcome: 'recovery-required', + transactionId: 'txn-abc', + recovery: 'binding-committed', + reason: 'interrupted', + }), + ).toBeNull() + expect(forkCommittedChildSessionId(null)).toBeNull() + }) +}) + +describe('fork dialog state machine', () => { + it('opens into a loading phase with the default strategy', () => { + const next = reduceForkDialog(initialForkDialogState(), { type: 'open' }) + expect(next.phase).toBe('loading') + expect(next.strategy).toBe('shared-worktree') + }) + + it('seeds a default isolated name when opened directly into isolated', () => { + const next = reduceForkDialog(initialForkDialogState(), { + type: 'open', + strategy: 'isolated-worktree', + }) + expect(next.phase).toBe('loading') + expect(next.strategy).toBe('isolated-worktree') + expect(next.nameInput).toMatch(/^[0-9a-f]{8}$/) + }) + + it('keeps name keystrokes while the initial preview is loading', () => { + let state = reduceForkDialog(initialForkDialogState(), { + type: 'open', + strategy: 'isolated-worktree', + initialName: 'initial', + }) + + state = reduceForkDialog(state, { type: 'name-changed', value: 'initial-a' }) + expect(state.phase).toBe('loading') + expect(state.nameInput).toBe('initial-a') + }) + + it('shows the preview and keeps the server fingerprint for confirm', () => { + let state = reduceForkDialog(initialForkDialogState(), { + type: 'open', + strategy: 'isolated-worktree', + }) + state = reduceForkDialog(state, { type: 'preview-ready', preview: previewFor() }) + expect(state.phase).toBe('preview') + expect(state.preview?.previewFingerprint).toBe('f'.repeat(64)) + + state = reduceForkDialog(state, { type: 'confirm' }) + expect(state.phase).toBe('confirming') + }) + + it('carries every preview fact the dialog renders (source/destination/capability/policy)', () => { + const preview = previewFor({ + source: { + serverId: 'local', + sessionId: 's1', + conversationHeadMessageId: 'msg-9', + conversationHeadTurnId: 'turn-9', + checkout: { mode: 'current' }, + branch: 'main', + headSha: 'a'.repeat(40), + gitState: { + state: 'dirty', + stagedFileCount: 1, + unstagedFileCount: 2, + untrackedFileCount: 3, + includedIgnoredFileCount: 1, + }, + leases: ['owner-a', 'owner-b'], + }, + destination: { + serverId: 'local', + repositoryRoot: '/repo', + branch: 'kata-agent/ab12cd34', + checkoutPath: '/srv/worktrees/repo/ab12cd34', + exists: false, + leases: [], + }, + excludedIgnoredPolicy: { includeOnly: true, includeFileCount: 2 }, + currentHead: true, + }) + let state = reduceForkDialog(initialForkDialogState(), { + type: 'open', + strategy: 'isolated-worktree', + }) + state = reduceForkDialog(state, { type: 'preview-ready', preview }) + + // Source block: conversation head, branch, HEAD, Git-state summary, owners. + expect(state.preview?.source.conversationHeadMessageId).toBe('msg-9') + expect(state.preview?.source.branch).toBe('main') + expect(state.preview?.source.headSha).toBe('a'.repeat(40)) + expect(state.preview?.source.gitState.state).toBe('dirty') + expect(state.preview?.source.gitState.stagedFileCount).toBe(1) + expect(state.preview?.source.gitState.unstagedFileCount).toBe(2) + expect(state.preview?.source.gitState.untrackedFileCount).toBe(3) + expect(state.preview?.source.gitState.includedIgnoredFileCount).toBe(1) + expect(state.preview?.source.leases).toEqual(['owner-a', 'owner-b']) + // Destination block: server, branch, checkout path (server-owned, display only). + expect(state.preview?.destination.serverId).toBe('local') + expect(state.preview?.destination.branch).toBe('kata-agent/ab12cd34') + expect(state.preview?.destination.checkoutPath).toBe('/srv/worktrees/repo/ab12cd34') + // Provider capability + ignored-file policy. + expect(state.preview?.providerCapability).toEqual({ adapterId: 'pi', strictCrossCwdNativeFork: true }) + expect(state.preview?.excludedIgnoredPolicy).toEqual({ includeOnly: true, includeFileCount: 2 }) + // The previewed branch suffix is what confirm revalidates against. + expect(state.previewedName).toBe('ab12cd34') + expect(state.phase).toBe('preview') + }) + + it('surfaces a typed blocker and disables confirm', () => { + let state = reduceForkDialog(initialForkDialogState(), { + type: 'open', + strategy: 'isolated-worktree', + }) + state = reduceForkDialog(state, { + type: 'preview-ready', + preview: previewFor({ + blocked: { blocked: true, code: 'non-head-source', reason: 'Isolated forks are only available at the current conversation head.' }, + }), + }) + expect(state.phase).toBe('preview-blocked') + expect(state.message).toBe('Isolated forks are only available at the current conversation head.') + expect(canConfirmFork(state.phase, state.preview)).toBe(false) + }) + + it('switching strategy re-previews and keeps confirm disabled for the stale preview', () => { + let state = reduceForkDialog(initialForkDialogState(), { type: 'open' }) + state = reduceForkDialog(state, { + type: 'preview-ready', + preview: previewFor({ strategy: 'shared-worktree' }), + }) + expect(state.phase).toBe('preview') + expect(canConfirmFork(state.phase, state.preview)).toBe(true) + + state = reduceForkDialog(state, { + type: 'strategy-changed', + strategy: 'isolated-worktree', + nameInput: 'ab12cd34', + }) + expect(state.phase).toBe('loading') + expect(state.strategy).toBe('isolated-worktree') + // The component supplies the exact name it previews so input and previewed + // branch suffix can never diverge. + expect(state.nameInput).toBe('ab12cd34') + // The stale shared preview must not be confirmable during the re-preview. + expect(canConfirmFork(state.phase, state.preview)).toBe(false) + }) + + it('keeps confirm disabled until an isolated preview matches the edited name', () => { + let state = reduceForkDialog(initialForkDialogState(), { + type: 'open', + strategy: 'isolated-worktree', + }) + state = reduceForkDialog(state, { type: 'preview-ready', preview: previewFor() }) + // Generated default name does not match the fixed preview branch. + expect(canConfirmForkForName(state)).toBe(false) + // Align the input with the previewed branch suffix → confirmable. + state = reduceForkDialog(state, { type: 'name-changed', value: 'ab12cd34' }) + state = reduceForkDialog(state, { type: 'preview-ready', preview: previewFor() }) + expect(canConfirmForkForName(state)).toBe(true) + // A name edit re-previews; a stale preview must not be confirmable. + state = reduceForkDialog(state, { type: 'name-changed', value: 'new-name' }) + state = reduceForkDialog(state, { type: 'preview-ready', preview: previewFor() }) + expect(canConfirmForkForName(state)).toBe(false) + // The re-preview for the edited name re-enables confirm. + state = reduceForkDialog(state, { + type: 'preview-ready', + preview: previewFor({ destination: { ...previewFor().destination, branch: 'kata-agent/new-name' } }), + }) + expect(canConfirmForkForName(state)).toBe(true) + }) + + it('treats shared as confirmable without a name', () => { + let state = reduceForkDialog(initialForkDialogState(), { type: 'open' }) + state = reduceForkDialog(state, { + type: 'preview-ready', + preview: previewFor({ strategy: 'shared-worktree' }), + }) + expect(canConfirmForkForName(state)).toBe(true) + }) + + it('renders the committed summary after a successful confirm', () => { + const committed: ConversationForkResult = { + outcome: 'committed', + transactionId: 'txn-abc', + summary: { + sessionId: 'child-1', + strategy: 'isolated-worktree', + checkout: { + 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', + }, + executionCwd: '/srv/worktrees/repo/ab12cd34', + transcriptCwd: '/repo/.kata/sessions/s1', + childProviderIdPresent: false, + committedAt: 1, + }, + } + let state = reduceForkDialog(initialForkDialogState(), { + type: 'open', + strategy: 'isolated-worktree', + }) + state = reduceForkDialog(state, { type: 'preview-ready', preview: previewFor() }) + state = reduceForkDialog(state, { type: 'confirm' }) + state = reduceForkDialog(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', () => { + const recovery: ConversationForkResult = { + outcome: 'recovery-required', + transactionId: 'txn-abc', + recovery: 'binding-committed', + retainedSnapshotId: 'abcd1234abcd1234', + reason: 'interrupted before publication', + } + let state = reduceForkDialog(initialForkDialogState(), { + type: 'open', + strategy: 'isolated-worktree', + }) + state = reduceForkDialog(state, { type: 'preview-ready', preview: previewFor() }) + state = reduceForkDialog(state, { type: 'confirm' }) + state = reduceForkDialog(state, { type: 'confirm-ready', result: recovery }) + expect(state.phase).toBe('recovery-required') + expect(state.message).toBe('interrupted before publication') + + state = reduceForkDialog(state, { type: 'recover' }) + expect(state.phase).toBe('recovering') + + const rolledBack: ConversationForkResult = { + outcome: 'blocked', + transactionId: 'txn-abc', + code: 'identity-drift', + reason: 'The fork preconditions changed; preview again.', + } + state = reduceForkDialog(state, { type: 'recover-ready', result: rolledBack }) + expect(state.phase).toBe('blocked') + expect(state.result?.outcome).toBe('blocked') + }) + + it('opens directly into recovery from a status without preview', () => { + let state = reduceForkDialog(initialForkDialogState(), { type: 'open' }) + state = reduceForkDialog(state, { + type: 'recovery-from-status', + result: { + outcome: 'recovery-required', + transactionId: 'txn-status', + recovery: 'binding-committed', + retainedSnapshotId: 'abcd1234abcd1234', + reason: 'interrupted after commit', + }, + }) + expect(state.phase).toBe('recovery-required') + expect(state.result?.outcome).toBe('recovery-required') + expect(canRecoverFork(state.phase, state.result)).toBe(true) + }) + + it('keeps the recovery surface mounted when recover itself fails', () => { + const recovery: ConversationForkResult = { + outcome: 'recovery-required', + transactionId: 'txn-abc', + recovery: 'target-materialized', + reason: 'interrupted', + } + let state = reduceForkDialog(initialForkDialogState(), { + type: 'open', + strategy: 'isolated-worktree', + }) + state = reduceForkDialog(state, { type: 'preview-ready', preview: previewFor() }) + state = reduceForkDialog(state, { type: 'confirm' }) + state = reduceForkDialog(state, { type: 'confirm-ready', result: recovery }) + state = reduceForkDialog(state, { type: 'recover' }) + + state = reduceForkDialog(state, { type: 'recover-error', message: 'IPC unreachable' }) + expect(state.phase).toBe('recovery-required') + expect(state.message).toBe('IPC unreachable') + expect(canRecoverFork(state.phase, state.result)).toBe(true) + }) + + it('surfaces preview errors and resets to idle', () => { + let state = reduceForkDialog(initialForkDialogState(), { type: 'open' }) + state = reduceForkDialog(state, { type: 'preview-error', message: 'server unreachable' }) + expect(state.phase).toBe('error') + expect(state.message).toBe('server unreachable') + + state = reduceForkDialog(state, { type: 'reset' }) + expect(state).toEqual(initialForkDialogState()) + }) + + it('ignores guard-violating actions', () => { + let state = reduceForkDialog(initialForkDialogState(), { type: 'open' }) + const loadingState = state + expect(reduceForkDialog(state, { type: 'confirm' })).toBe(loadingState) + // confirm with a blocked preview. + state = reduceForkDialog(state, { + type: 'preview-ready', + preview: previewFor({ + strategy: 'isolated-worktree', + blocked: { blocked: true, code: 'unsupported-provider', reason: 'adapter cannot fork' }, + }), + }) + const blockedState = state + expect(reduceForkDialog(state, { type: 'confirm' })).toBe(blockedState) + // name-changed from error. + state = reduceForkDialog(state, { type: 'preview-error', message: 'server unreachable' }) + expect(reduceForkDialog(state, { type: 'name-changed', value: 'x' })).toBe(state) + // recover outside recovery-required. + expect(reduceForkDialog(state, { type: 'recover' })).toBe(state) + // recovery-from-status with a non-recovery result. + const committed: ConversationForkResult = { + outcome: 'committed', + transactionId: 'txn-abc', + summary: { + sessionId: 'child-1', + strategy: 'isolated-worktree', + checkout: { + 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', + }, + executionCwd: '/srv/worktrees/repo/ab12cd34', + transcriptCwd: '/repo/.kata/sessions/s1', + childProviderIdPresent: false, + committedAt: 1, + }, + } + expect(reduceForkDialog(state, { type: 'recovery-from-status', result: committed })).toBe(state) + }) +}) diff --git a/apps/electron/src/renderer/components/app-shell/__tests__/worktree-removal.test.ts b/apps/electron/src/renderer/components/app-shell/__tests__/worktree-removal.test.ts index bfc6033e..5049d307 100644 --- a/apps/electron/src/renderer/components/app-shell/__tests__/worktree-removal.test.ts +++ b/apps/electron/src/renderer/components/app-shell/__tests__/worktree-removal.test.ts @@ -29,6 +29,20 @@ describe('summarizeWorktreeRemoval', () => { expect(s.branchWillBePruned).toBe(true) }) + it('an isolated fork child (sole owner) never yields the shared-worktree block copy', () => { + // Phase 4 provenance: an isolated fork child owns its own record as the + // SOLE owner. The inspection is resolved from the child's own record, so + // the summary must never show the shared-owner block language or name + // another owner — the dialog renders `git.delete.sharedBlocked` only when + // this summary is blocked. + const s = summarizeWorktreeRemoval( + risk({ ownerSessionIds: ['isolated-child'], otherOwnerCount: 0 }), + ) + expect(s.blocked).toBe(false) + expect(s.otherOwnerCount).toBe(0) + expect(s.blockedReason).toBeUndefined() + }) + it('blocks while another session owns the worktree', () => { const s = summarizeWorktreeRemoval( risk({ otherOwnerCount: 1, blocked: true, blockedReason: 'Another session still owns this worktree.' }), diff --git a/apps/electron/src/renderer/components/app-shell/fork/ForkAction.tsx b/apps/electron/src/renderer/components/app-shell/fork/ForkAction.tsx new file mode 100644 index 00000000..269f7fb8 --- /dev/null +++ b/apps/electron/src/renderer/components/app-shell/fork/ForkAction.tsx @@ -0,0 +1,115 @@ +/** + * ForkAction — fork recovery surface + Worktree V2 capability hook. + * + * `ForkRecoveryBadge` polls FORK_STATUS for the bound session and renders a + * recovery affordance while a fork transaction is active (pending or + * recovery-required), mirroring HandoffRecoveryBadge. Quiet when inactive. + * + * `useForkCapability` resolves whether the workspace-owning server has Worktree + * V2 effective; the Branch action only opens the fork dialog when V2 is on and + * otherwise keeps the immediate shared-branch behavior byte-identical. + */ + +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { Loader2, TriangleAlert } from 'lucide-react' + +import type { ConversationForkStatus } from '@kata-sh/shared/protocol' + +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@kata-sh/ui' + +/** + * Resolve Worktree V2 effectiveness from the workspace-owning server. V2 is + * governed by the server's feature flag, not by the local renderer + * environment; a V1-only server keeps the immediate shared-branch flow. + */ +export function useForkCapability(): { v2Effective: boolean; v2Pending: boolean } { + const [v2Effective, setV2Effective] = React.useState(false) + const [v2Pending, setV2Pending] = React.useState(true) + + React.useEffect(() => { + let cancelled = false + // Promise.resolve normalizes an unavailable optional IPC method to an + // asynchronous `undefined` result instead of calling `.then()` on it. + void Promise.resolve(window.electronAPI?.getGitCapabilities?.()) + .then((capability) => { + if (cancelled) return + setV2Effective(!!capability?.worktreeV2) + setV2Pending(false) + }) + .catch(() => { + if (!cancelled) { + setV2Effective(false) + setV2Pending(false) + } + }) + return () => { + cancelled = true + } + }, []) + + return { v2Effective, v2Pending } +} + +/** + * Polls FORK_STATUS for the bound session and renders a recovery affordance + * while a fork transaction is pending/recovery-required. Quiet when inactive. + */ +export function ForkRecoveryBadge({ + sessionId, + onRecover, +}: { + sessionId: string + /** Called with the active status so the caller can open recovery UI. */ + onRecover: (status: Extract) => void +}) { + const { t } = useTranslation() + const [status, setStatus] = React.useState({ active: false }) + const [checking, setChecking] = React.useState(false) + + React.useEffect(() => { + let cancelled = false + const poll = async () => { + setChecking(true) + try { + const next = await window.electronAPI.forkStatus({ sessionId }) + if (!cancelled) setStatus(next) + } catch { + // Transient server/unreachable states are quiet; the next poll retries. + } finally { + if (!cancelled) setChecking(false) + } + } + void poll() + const interval = window.setInterval(poll, 15_000) + return () => { + cancelled = true + window.clearInterval(interval) + } + }, [sessionId]) + + if (!status.active) return null + + return ( + + + + + {t('git.fork.recoveryHint')} + + ) +} diff --git a/apps/electron/src/renderer/components/app-shell/fork/ForkDialog.tsx b/apps/electron/src/renderer/components/app-shell/fork/ForkDialog.tsx new file mode 100644 index 00000000..b59b6ad6 --- /dev/null +++ b/apps/electron/src/renderer/components/app-shell/fork/ForkDialog.tsx @@ -0,0 +1,667 @@ +/** + * ForkDialog — preview / confirm / recovery for conversation forks. + * + * The Branch action opens this dialog when Worktree V2 is effective. It offers + * the two strategies — Shared worktree (default, pre-existing branch behavior) + * and New isolated worktree (only when eligible) — and drives the + * FORK_PREVIEW / FORK_CONFIRM / FORK_STATUS / FORK_RECOVER / FORK_CANCEL RPCs + * through the workspace-owning server. + * + * The client submits only a session ID, a strategy, and (for isolated) an + * editable worktree name suffix; the server binds every decision-relevant fact + * into the preview fingerprint and revalidates it under lock on confirm. The + * shared strategy confirms through the EXISTING branch flow (the server throws + * FORK_NOT_IMPLEMENTED for shared confirmation), so shared behavior stays + * byte-identical to today. + */ + +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { AlertTriangle, Check, CheckCircle2, ExternalLink, GitBranch, GitFork, Loader2, RotateCcw } from 'lucide-react' + +import type { + ConversationForkPreview, + ConversationForkResult, + ConversationForkStrategy, +} from '@kata-sh/shared/protocol' + +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useRegisterModal } from '@/context/ModalContext' +import { cn } from '@/lib/utils' + +import { + canConfirmForkForName, + canRecoverFork, + finalizeForkName, + forkCommittedChildSessionId, + forkIsolatedDisabledReason, + forkIsolatedEligible, + forkSourceStateKey, + forkStrategyDefault, + initialForkDialogState, + reduceForkDialog, + type ForkDialogState, +} from '../input/fork-controls' +import { generateDefaultWorktreeName } from '../input/checkout-controls' + +export interface ForkDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + sessionId: string + /** Source message the branch action was invoked on. */ + branchPointMessageId?: string + /** Current conversation head message id (client-side head gate for isolated). */ + conversationHeadMessageId?: string + /** Server-derived: provider advertises a strict cross-CWD native fork. */ + isolatedForkCapable?: boolean + /** True when the session's workspace is owned by a remote server. */ + isRemoteWorkspace?: boolean + /** + * Commit the shared-worktree strategy through the existing branch flow + * (server-side shared confirmation is FORK_NOT_IMPLEMENTED). Resolves once + * the child session exists; the caller navigates to it. Required for the + * creation flow; recovery mode skips confirm entirely. + */ + onCreateSharedBranch?: (messageId: string) => Promise + /** Navigate to the committed child session (isolated confirm). */ + onCommitted: (childSessionId: string) => void + /** + * Open directly into recovery for an already-known interrupted transaction + * (from FORK_STATUS), skipping preview. + */ + initialRecovery?: Extract +} + +function SummaryRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ) +} + +function RemoteLabel({ serverId }: { serverId: string }) { + const { t } = useTranslation() + return ( + + + {t('git.fork.remoteOwned', { serverId })} + + ) +} + +function BlockedNote({ message }: { message: string }) { + const { t } = useTranslation() + return ( +
+ + + {t('git.fork.blockedTitle')} · + {message} + +
+ ) +} + +function GitStateSummary({ preview }: { preview: ConversationForkPreview }) { + const { t } = useTranslation() + const git = preview.source.gitState + const items: Array<{ key: string; count: number }> = [] + if (git.stagedFileCount > 0) items.push({ key: 'git.fork.gitState.staged', count: git.stagedFileCount }) + if (git.unstagedFileCount > 0) items.push({ key: 'git.fork.gitState.unstaged', count: git.unstagedFileCount }) + if (git.untrackedFileCount > 0) items.push({ key: 'git.fork.gitState.untracked', count: git.untrackedFileCount }) + if (git.includedIgnoredFileCount > 0) { + items.push({ key: 'git.fork.gitState.includedIgnored', count: git.includedIgnoredFileCount }) + } + return ( + + {t(forkSourceStateKey(git.state))} + {items.length > 0 && ( + + {items.map(({ key, count }) => ( + {t(key, { count })} + ))} + + )} + + ) +} + +/** Render the sanitized preview body (blocked or ready). */ +function PreviewBody({ + state, + isRemoteWorkspace, + onNameChange, +}: { + state: ForkDialogState + isRemoteWorkspace: boolean + onNameChange: (value: string) => void +}) { + const { t } = useTranslation() + const preview = state.preview + if (!preview) return null + const remote = isRemoteWorkspace + + return ( +
+ {state.phase === 'preview-blocked' && } + + {(state.phase === 'preview' || state.phase === 'preview-blocked') && ( +
+ {/* Source block: conversation head, branch, HEAD, Git-state summary, owners/leases */} +
+ + {t('git.fork.sourceLabel')} + + + + {preview.source.conversationHeadMessageId.slice(0, 12)} + + + + + {preview.source.sessionId.slice(0, 12)} + · + + {preview.source.conversationHeadTurnId + ? preview.source.conversationHeadTurnId.slice(0, 12) + : '—'} + + + + + + {remote && } + {preview.source.branch ?? '—'} + · + {preview.source.headSha?.slice(0, 8) ?? '—'} + + + + + + {preview.source.leases.length > 0 && ( + + + {preview.source.leases.map((id) => id.slice(0, 8)).join(', ')} + + + )} +
+ + {/* Destination block: server, branch, checkout path */} +
+ + {t('git.fork.destinationLabel')} + + + + {remote && } + {preview.destination.serverId} + + + + + + {preview.destination.branch} + {preview.destination.exists ? ( + ({t('git.fork.occupied')}) + ) : null} + + + + {preview.destination.checkoutPath} + + + + {preview.destination.repositoryRoot} + + + {preview.destination.leases.length > 0 && ( + + + {preview.destination.leases.map((id) => id.slice(0, 8)).join(', ')} + + + )} +
+ + {/* Provider capability + ignored-file policy */} +
+ + {preview.providerCapability.adapterId} + + + + {preview.providerCapability.strictCrossCwdNativeFork ? ( + + ) : ( + + )} + {preview.providerCapability.strictCrossCwdNativeFork + ? t('git.fork.strictForkSupported') + : t('git.fork.strictForkUnsupported')} + + + + {t('git.fork.ignoredPolicyDetail', { count: preview.excludedIgnoredPolicy.includeFileCount })} + +
+
+ )} + + {state.strategy === 'isolated-worktree' && + (state.phase === 'preview' || state.phase === 'preview-blocked') && ( +
+ + onNameChange(event.target.value)} + placeholder={t('git.fork.namePlaceholder')} + /> +
+ )} +
+ ) +} + +function CommittedBody({ result }: { result: Extract }) { + const { t } = useTranslation() + const branch = result.summary.checkout.expectedBranch + return ( +
+ + + {t('git.fork.committedTitle')} + + {t('git.fork.committedDetail')} + {branch && ( + + {t('git.fork.committedBranch', { branch })} + + )} + {t('git.fork.providerPendingNote')} +
+ ) +} + +function RecoveryBody({ + state, + onRecover, +}: { + state: ForkDialogState + onRecover: () => void +}) { + const { t } = useTranslation() + const result = state.result + // Keep the button mounted while recovery is in flight so the spinner shows. + const canRecover = state.phase === 'recovering' || canRecoverFork(state.phase, result) + return ( +
+
+ + + {t('git.fork.recoveryTitle')} + + {t('git.fork.recoveryNote')} + {result?.outcome === 'recovery-required' && result.retainedSnapshotId && ( + + {t('git.fork.retainedSnapshot', { snapshotId: result.retainedSnapshotId.slice(0, 8) })} + + )} + {state.phase !== 'recovering' && state.message && ( + {state.message} + )} +
+ {canRecover && ( + + )} +
+ ) +} + +export function ForkDialog({ + open, + onOpenChange, + sessionId, + branchPointMessageId = '', + conversationHeadMessageId, + isolatedForkCapable = false, + isRemoteWorkspace = false, + onCreateSharedBranch, + onCommitted, + initialRecovery, +}: ForkDialogProps) { + const { t } = useTranslation() + const [state, dispatch] = React.useReducer(reduceForkDialog, undefined, initialForkDialogState) + const debounceRef = React.useRef | null>(null) + const previewSeqRef = React.useRef(0) + const strategyRef = React.useRef(state.strategy) + + // Client-side head gate: isolated requires the branch point to be the + // current conversation head (older points are shared-only). + const atConversationHead = + !conversationHeadMessageId || branchPointMessageId === conversationHeadMessageId + const isolatedEligible = forkIsolatedEligible({ + isolatedCapable: isolatedForkCapable, + atConversationHead, + }) + // When the current isolated preview is blocked, its blocker reason is the + // authoritative disable reason (e.g. the server rejected the name). + const isolatedDisabledReason = forkIsolatedDisabledReason({ + phase: state.phase, + strategy: state.strategy, + atConversationHead, + isolatedCapable: isolatedForkCapable, + blockedMessage: state.message, + }) + const isolatedDisabledReasonLabel = + isolatedDisabledReason && isolatedDisabledReason.startsWith('git.') + ? t(isolatedDisabledReason) + : isolatedDisabledReason + + // Dismissing the dialog without confirming must release the pending preview + // transaction, or the session stays fenced until recovery. + const releasePendingPreview = React.useCallback(() => { + const preview = state.preview + if (!preview || state.phase === 'recovery-required' || state.phase === 'recovering') return + void window.electronAPI.forkCancel({ sessionId, transactionId: preview.transactionId }).catch(() => { + /* best-effort; a confirm may have started, in which case cancel no-ops */ + }) + }, [state.preview, state.phase, sessionId]) + + const close = React.useCallback(() => { + releasePendingPreview() + onOpenChange(false) + }, [releasePendingPreview, onOpenChange]) + + useRegisterModal(open, close) + + const runPreview = React.useCallback( + async (strategy: ConversationForkStrategy, nameSuffix: string) => { + const seq = ++previewSeqRef.current + try { + const preview = await window.electronAPI.forkPreview({ + sessionId, + strategy, + ...(strategy === 'isolated-worktree' && nameSuffix + ? { worktreeNameSuffix: finalizeForkName(nameSuffix) } + : {}), + }) + if (seq !== previewSeqRef.current) return + dispatch({ type: 'preview-ready', preview }) + } catch (error) { + if (seq !== previewSeqRef.current) return + dispatch({ type: 'preview-error', message: error instanceof Error ? error.message : String(error) }) + } + }, + [sessionId], + ) + + // Open → reset the dialog and request the first preview for the default + // (shared) strategy — or enter recovery directly when the interrupted + // transaction is already known. + React.useEffect(() => { + if (!open || !sessionId) return + strategyRef.current = forkStrategyDefault() + dispatch({ type: 'open' }) + previewSeqRef.current += 1 // invalidate any in-flight preview from a previous open + if (initialRecovery) { + dispatch({ type: 'recovery-from-status', result: initialRecovery }) + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current) + previewSeqRef.current += 1 + } + } + void runPreview(forkStrategyDefault(), '') + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current) + previewSeqRef.current += 1 // invalidate any in-flight preview on close + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, sessionId, initialRecovery?.transactionId]) + + const handleStrategyChange = React.useCallback( + (strategy: ConversationForkStrategy) => { + if (strategy === strategyRef.current) return + strategyRef.current = strategy + // Compute the name ONCE so the input and the previewed branch suffix + // always agree (a fresh random default in the reducer would diverge + // from the name the preview was issued for). + const nameForIsolated = + strategy === 'isolated-worktree' ? state.nameInput || generateDefaultWorktreeName() : '' + dispatch({ type: 'strategy-changed', strategy, nameInput: nameForIsolated }) + void runPreview(strategy, nameForIsolated) + }, + [runPreview, state.nameInput], + ) + + const handleNameChange = React.useCallback( + (value: string) => { + dispatch({ type: 'name-changed', value }) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { + void runPreview(strategyRef.current, value) + }, 350) + }, + [runPreview], + ) + + // Navigate to the committed child session once the isolated confirm lands. + React.useEffect(() => { + const childSessionId = forkCommittedChildSessionId(state.result) + if (state.phase === 'committed' && childSessionId) { + onCommitted(childSessionId) + } + }, [state.phase, state.result, onCommitted]) + + const handleConfirm = React.useCallback(async () => { + const preview = state.preview + if (!canConfirmForkForName(state) || preview === null) return + dispatch({ type: 'confirm' }) + if (strategyRef.current === 'shared-worktree') { + // Shared confirmation is FORK_NOT_IMPLEMENTED server-side; reuse the + // existing branch flow so shared behavior stays byte-identical. + if (!onCreateSharedBranch) return + try { + await onCreateSharedBranch(branchPointMessageId) + dispatch({ type: 'reset' }) + onOpenChange(false) + } catch (error) { + dispatch({ type: 'preview-error', message: error instanceof Error ? error.message : String(error) }) + } + return + } + try { + const result = await window.electronAPI.forkConfirm({ + sessionId, + strategy: 'isolated-worktree', + transactionId: preview.transactionId, + previewFingerprint: preview.previewFingerprint, + worktreeNameSuffix: finalizeForkName(state.nameInput), + }) + dispatch({ type: 'confirm-ready', result }) + } catch (error) { + dispatch({ type: 'preview-error', message: error instanceof Error ? error.message : String(error) }) + } + }, [state, sessionId, branchPointMessageId, onCreateSharedBranch, onOpenChange]) + + const handleRecover = React.useCallback(async () => { + const result = state.result + if (!canRecoverFork(state.phase, result) || result === null || result.outcome !== 'recovery-required') return + dispatch({ type: 'recover' }) + try { + const recovered = await window.electronAPI.forkRecover({ + sessionId, + transactionId: result.transactionId, + }) + dispatch({ type: 'recover-ready', result: recovered }) + } catch (error) { + dispatch({ type: 'recover-error', message: error instanceof Error ? error.message : String(error) }) + } + }, [state.phase, state.result, sessionId]) + + const busy = state.phase === 'loading' || state.phase === 'confirming' || state.phase === 'recovering' + // Keep the confirm button mounted while the request is in flight so the + // spinner state is visible; the click handler guards on the phase itself. + const confirmable = + state.phase === 'confirming' || (canConfirmForkForName(state) && !busy) + + return ( + (next ? undefined : close())}> + event.preventDefault()} + > + + {t('git.fork.title')} + {t('git.fork.description')} + + +
+ {/* Strategy selection */} + {state.phase !== 'recovery-required' && state.phase !== 'recovering' && ( +
+ + + + {isolatedDisabledReasonLabel && ( + + {isolatedDisabledReasonLabel} + + )} +
+ )} + + {state.phase === 'loading' && ( +
+ + {t('git.fork.previewing')} +
+ )} + + {(state.phase === 'preview' || state.phase === 'preview-blocked') && ( + + )} + + {state.phase === 'committed' && state.result?.outcome === 'committed' && ( + + )} + + {(state.phase === 'recovery-required' || state.phase === 'recovering') && ( + + )} + + {(state.phase === 'blocked' || state.phase === 'error') && ( +
+ + {state.phase === 'blocked' && state.result?.outcome === 'blocked' && ( + {t('git.fork.rolledBackDetail')} + )} +
+ )} +
+ + + + {confirmable && ( + + )} + +
+
+ ) +} diff --git a/apps/electron/src/renderer/components/app-shell/input/WorkspaceCheckoutBadge.tsx b/apps/electron/src/renderer/components/app-shell/input/WorkspaceCheckoutBadge.tsx index 807454de..dcaefad6 100644 --- a/apps/electron/src/renderer/components/app-shell/input/WorkspaceCheckoutBadge.tsx +++ b/apps/electron/src/renderer/components/app-shell/input/WorkspaceCheckoutBadge.tsx @@ -455,10 +455,42 @@ function WorkspaceCheckoutBadgeInner( persistedCheckout, locallyPrepared: prepared?.checkout ?? null, sharedOwnerCount, + forkPending: session?.forkPending, }) if (identity.kind === 'none') return null + // Phase 4: a published-but-not-established isolated fork child carries a + // durable pending fork intent and NO child provider ID yet. The surface + // displays provider identity as PENDING and never claims a child provider + // ID until the first-Send establish flow retires the pending intent. + if (identity.kind === 'fork-pending') { + const branch = identity.branch ?? null + return ( + + } + label={t('git.fork.providerPending')} + isExpanded + hasSelection + showChevron={false} + tooltip={ + + {t('git.fork.providerPending')} + {t('git.fork.providerPendingNote')} + {branch && ( + + {t('chat.onBranch', { branch })} + + )} + + } + disabled + /> + + ) + } + // Locked managed-worktree identity (prepared, resumed, or conversation-branch // shared). Persists for the composer lifetime even if a later send fails. // Every session shows the same branch label; shared ownership is conveyed by diff --git a/apps/electron/src/renderer/components/app-shell/input/__tests__/checkout-controls.test.ts b/apps/electron/src/renderer/components/app-shell/input/__tests__/checkout-controls.test.ts index 699077c2..37e6b778 100644 --- a/apps/electron/src/renderer/components/app-shell/input/__tests__/checkout-controls.test.ts +++ b/apps/electron/src/renderer/components/app-shell/input/__tests__/checkout-controls.test.ts @@ -350,6 +350,47 @@ describe('resolveCheckoutIdentity', () => { expect(id.kind).toBe('menu') }) + test('shows PENDING provider identity for a published-but-not-established isolated fork child', () => { + const id = resolveCheckoutIdentity({ + isGitRepository: true, + isEmptySession: false, + hasSessionId: true, + persistedCheckout: worktreeCheckout, + locallyPrepared: null, + sharedOwnerCount: 1, + forkPending: true, + }) + // Never claims the worktree identity / a child provider ID before first Send. + expect(id.kind).toBe('fork-pending') + expect(id.branch).toBe('kata-agent/aabbccdd') + }) + + test('PENDING fork identity wins even when the worktree is shared', () => { + const id = resolveCheckoutIdentity({ + isGitRepository: true, + isEmptySession: false, + hasSessionId: true, + persistedCheckout: worktreeCheckout, + locallyPrepared: null, + sharedOwnerCount: 2, + forkPending: true, + }) + expect(id.kind).toBe('fork-pending') + }) + + test('does not show PENDING once the fork intent retired', () => { + const id = resolveCheckoutIdentity({ + isGitRepository: true, + isEmptySession: false, + hasSessionId: true, + persistedCheckout: worktreeCheckout, + locallyPrepared: null, + sharedOwnerCount: 1, + forkPending: false, + }) + expect(id.kind).toBe('worktree') + }) + test('generates an editable lowercase eight-hex default name', () => { expect(generateDefaultWorktreeName()).toMatch(/^[0-9a-f]{8}$/) }) diff --git a/apps/electron/src/renderer/components/app-shell/input/checkout-controls.ts b/apps/electron/src/renderer/components/app-shell/input/checkout-controls.ts index e76dfc1d..ac7dd522 100644 --- a/apps/electron/src/renderer/components/app-shell/input/checkout-controls.ts +++ b/apps/electron/src/renderer/components/app-shell/input/checkout-controls.ts @@ -192,6 +192,15 @@ export type CheckoutIdentityKind = | 'current' // locked Current checkout identity | 'worktree' // locked managed worktree identity | 'shared-worktree' // locked managed worktree shared by >1 owner + | 'fork-pending' // published-but-not-established isolated fork child (PENDING provider identity) + +export interface CheckoutIdentity { + kind: CheckoutIdentityKind + /** Exact branch identity, when applicable. */ + branch?: string | null + /** V2 display name, when the server supplied one. */ + displayName?: string | null +} export interface CheckoutIdentityState { isGitRepository: boolean @@ -203,14 +212,11 @@ export interface CheckoutIdentityState { locallyPrepared: SessionCheckout | null /** Shared-owner count derived on the server; > 1 means a shared worktree. */ sharedOwnerCount: number | undefined -} - -export interface CheckoutIdentity { - kind: CheckoutIdentityKind - /** Exact branch identity, when applicable. */ - branch?: string | null - /** V2 display name, when the server supplied one. */ - displayName?: string | null + /** + * True while this session is a published-but-not-established isolated fork + * child (durable pending fork intent, no child provider ID yet). + */ + forkPending?: boolean } /** @@ -218,13 +224,27 @@ export interface CheckoutIdentity { * * Preference order: * 1. Non-Git directory → nothing. - * 2. A prepared checkout (local result or persisted DTO) locks the identity — + * 2. A pending isolated fork child → PENDING provider identity (never a child + * provider ID before first Send; the branch is still named for context). + * 3. A prepared checkout (local result or persisted DTO) locks the identity — * resume/restart must NOT reset a managed worktree back to Current. - * 3. Managed worktree with owner count > 1 → Shared worktree (AC8). - * 4. An empty Git session with no checkout → interactive menu. - * 5. Otherwise (session already has messages, no checkout) → live Current checkout. + * 4. Managed worktree with owner count > 1 → Shared worktree (AC8). + * 5. An empty Git session with no checkout → interactive menu. + * 6. Otherwise (session already has messages, no checkout) → live Current checkout. */ export function resolveCheckoutIdentity(state: CheckoutIdentityState): CheckoutIdentity { + // A pending isolated fork child carries no child provider identity: the + // surface shows PENDING before any worktree identity. The branch (if the + // child already binds a managed worktree) is still surfaced for context. + if (state.forkPending) { + const checkout = state.locallyPrepared ?? state.persistedCheckout + const branch = + checkout?.mode === 'managed-worktree' + ? checkout.expectedBranch ?? checkout.branchAtPreparation ?? null + : null + return { kind: 'fork-pending', branch } + } + // A prepared checkout (local result or persisted DTO) locks the identity and // is authoritative even before repository context finishes loading on resume — // restart/resume must NOT reset a managed worktree back to Current. diff --git a/apps/electron/src/renderer/components/app-shell/input/fork-controls.ts b/apps/electron/src/renderer/components/app-shell/input/fork-controls.ts new file mode 100644 index 00000000..f700cb5b --- /dev/null +++ b/apps/electron/src/renderer/components/app-shell/input/fork-controls.ts @@ -0,0 +1,299 @@ +/** + * Pure helpers for the isolated conversation-fork dialog and action surfaces. + * + * Kept free of React so the strategy eligibility, dialog state machine, and + * preview formatting can be exercised in isolation and shared by the Branch + * action surface and the preview/confirm/recovery dialog (Phase 4 spec). + * + * The client never nominates paths: it submits a session ID, a strategy, and + * (for isolated) an editable worktree name suffix; the server owns every Git + * mutation and revalidates the exact preview fingerprint under lock before + * acting. The shared-worktree strategy keeps the pre-existing branch flow + * (the server throws FORK_NOT_IMPLEMENTED for shared confirmation, so the + * dialog falls back to the existing onCreateSession branch path). + */ + +import type { + ConversationForkPreview, + ConversationForkResult, + ConversationForkStatus, + ConversationForkStrategy, +} from '@kata-sh/shared/protocol' + +import { normalizeWorktreeName, normalizeWorktreeNameInput, generateDefaultWorktreeName } from './checkout-controls' + +// --------------------------------------------------------------------------- +// Strategy availability +// --------------------------------------------------------------------------- + +/** The pre-existing branch behavior remains the default choice. */ +export function forkStrategyDefault(): ConversationForkStrategy { + return 'shared-worktree' +} + +export interface ForkStrategyEligibility { + /** True when the session's provider advertises a strict cross-CWD native fork. */ + isolatedCapable: boolean + /** True when the branch point is the current conversation head. */ + atConversationHead: boolean +} + +/** + * Whether the isolated-worktree strategy may be offered. Both conditions are + * required: an unsupported provider and an older (non-head) source turn each + * disable isolated with a typed reason. The server's preview remains the + * authoritative backstop (typed blockers come back as normal preview results). + */ +export function forkIsolatedEligible({ isolatedCapable, atConversationHead }: ForkStrategyEligibility): boolean { + return isolatedCapable && atConversationHead +} + +// --------------------------------------------------------------------------- +// Name normalization +// --------------------------------------------------------------------------- + +/** Normalize an in-progress name edit for display (keep separators while typing). */ +export function normalizeForkNameInput(value: string): string { + return normalizeWorktreeNameInput(value) +} + +/** Normalize the finalized name for the wire (canonical branch suffix). */ +export function finalizeForkName(value: string): string { + return normalizeWorktreeName(value) +} + +/** Human-readable source git-state label key for a preview source state. */ +export function forkSourceStateKey(state: ConversationForkPreview['source']['gitState']['state']): string { + return state === 'clean' + ? 'git.fork.state.clean' + : state === 'dirty' + ? 'git.fork.state.dirty' + : 'git.fork.state.detached' +} + +// --------------------------------------------------------------------------- +// Preview helpers +// --------------------------------------------------------------------------- + +/** Whether a confirm is safe to dispatch for the current phase. */ +export function canConfirmFork(phase: ForkDialogPhase, preview: ConversationForkPreview | null): boolean { + return phase === 'preview' && preview !== null && preview.blocked === undefined +} + +/** + * Whether confirm is safe for the currently edited name: the preview must have + * been issued for exactly the suffix in the input, otherwise a stale preview + * (name edit's re-preview still in flight) could confirm the previous name. + * The shared strategy has no editable name and confirms on any preview. + */ +export function canConfirmForkForName(state: ForkDialogState): boolean { + if (!canConfirmFork(state.phase, state.preview)) return false + if (state.strategy !== 'isolated-worktree') return true + return finalizeForkName(state.nameInput) === state.previewedName +} + +/** Whether recovery can be attempted for the current phase. */ +export function canRecoverFork(phase: ForkDialogPhase, result: ConversationForkResult | null): boolean { + return phase === 'recovery-required' && result?.outcome === 'recovery-required' +} + +/** + * Client-side disable reason (i18n key) for the isolated strategy row. Empty + * when the strategy may be selected. A blocked isolated preview's server + * reason takes precedence; otherwise the two eligibility gates each carry a + * typed reason (unsupported provider / non-head source). + */ +export function forkIsolatedDisabledReason(input: { + phase: ForkDialogPhase + strategy: ConversationForkStrategy + atConversationHead: boolean + isolatedCapable: boolean + /** Current preview blocker message when the isolated preview is blocked. */ + blockedMessage: string +}): string { + if (input.phase === 'preview-blocked' && input.strategy === 'isolated-worktree') return input.blockedMessage + if (!input.atConversationHead) return 'git.fork.nonHeadDisabled' + if (!input.isolatedCapable) return 'git.fork.unsupportedProviderDisabled' + return '' +} + +/** + * Child session ID of a committed fork result; null for any other outcome. + * The dialog navigates to this child once the isolated confirm commits. + */ +export function forkCommittedChildSessionId(result: ConversationForkResult | null): string | null { + return result?.outcome === 'committed' ? result.summary.sessionId : null +} + +/** + * Synthesize a recovery-required result from an active fork status so the + * recovery surface can open directly from a pending/failed transaction + * (e.g. discovered by FORK_STATUS polling after a restart). + */ +export function recoveryResultFromForkStatus( + status: Extract, + reason: string, +): Extract { + return { + outcome: 'recovery-required', + transactionId: status.transactionId, + recovery: status.state, + ...(status.retainedSnapshotId ? { retainedSnapshotId: status.retainedSnapshotId } : {}), + reason, + } +} + +// --------------------------------------------------------------------------- +// Dialog state machine +// --------------------------------------------------------------------------- + +export type ForkDialogPhase = + | 'idle' + | 'loading' + | 'preview' + | 'preview-blocked' + | 'confirming' + | 'committed' + | 'blocked' + | 'recovery-required' + | 'recovering' + | 'error' + +export interface ForkDialogState { + phase: ForkDialogPhase + /** Strategy the current preview was issued for. */ + strategy: ConversationForkStrategy + /** Server-issued transaction + fingerprint; confirm reuses the exact preview. */ + preview: ConversationForkPreview | null + /** Branch suffix the current preview was issued for ('' for shared). */ + previewedName: string + /** Editable suffix for isolated; normalized for the wire. */ + nameInput: string + /** Sanitized server detail for blocked / error / recovery phases. */ + message: string + /** Most recent confirm/recover result (committed / blocked / recovery-required). */ + result: ConversationForkResult | null +} + +export function initialForkDialogState(): ForkDialogState { + return { phase: 'idle', strategy: forkStrategyDefault(), preview: null, previewedName: '', nameInput: '', message: '', result: null } +} + +export type ForkDialogAction = + | { type: 'open'; strategy?: ConversationForkStrategy; initialName?: string } + | { type: 'strategy-changed'; strategy: ConversationForkStrategy; nameInput?: string } + | { type: 'preview-ready'; preview: ConversationForkPreview } + | { type: 'preview-error'; message: string } + | { type: 'name-changed'; value: string } + | { type: 'confirm' } + | { type: 'confirm-ready'; result: ConversationForkResult } + | { type: 'recovery-from-status'; result: ConversationForkResult } + | { type: 'recover' } + | { type: 'recover-ready'; result: ConversationForkResult } + | { type: 'recover-error'; message: string } + | { type: 'reset' } + +function resultPhase(result: ConversationForkResult): ForkDialogPhase { + switch (result.outcome) { + case 'committed': + return 'committed' + case 'blocked': + return 'blocked' + case 'recovery-required': + return 'recovery-required' + } +} + +/** + * Pure dialog state machine. The React component wires RPC calls and feeds + * the results in; every branch is unit-tested here. + */ +export function reduceForkDialog(state: ForkDialogState, action: ForkDialogAction): ForkDialogState { + switch (action.type) { + case 'open': { + const strategy = action.strategy ?? forkStrategyDefault() + return { + ...initialForkDialogState(), + phase: 'loading', + strategy, + nameInput: + strategy === 'isolated-worktree' ? (action.initialName ?? generateDefaultWorktreeName()) : '', + } + } + case 'strategy-changed': { + if (action.strategy === state.strategy) return state + if (state.phase !== 'loading' && state.phase !== 'preview' && state.phase !== 'preview-blocked') return state + // Switching strategy invalidates the previous fingerprint; the component + // re-previews for the new strategy before confirm is re-enabled. The + // component supplies the exact name it previews so the input and the + // previewed branch suffix can never diverge. + return { + ...initialForkDialogState(), + phase: 'loading', + strategy: action.strategy, + nameInput: + action.strategy === 'isolated-worktree' + ? (action.nameInput ?? state.nameInput ?? generateDefaultWorktreeName()) + : '', + } + } + case 'preview-ready': { + const blocked = action.preview.blocked + // Track the branch suffix this preview was issued for so confirm stays + // disabled while a name edit's re-preview is still in flight. + const previewedName = + action.preview.strategy === 'isolated-worktree' + ? action.preview.destination.branch.replace(/^kata-agent\//, '') + : '' + if (blocked) { + return { ...state, phase: 'preview-blocked', preview: action.preview, previewedName, message: blocked.reason, result: null } + } + return { ...state, phase: 'preview', preview: action.preview, previewedName, message: '', result: null } + } + case 'preview-error': + return { ...state, phase: 'error', message: action.message, result: null } + case 'name-changed': { + // Editing the name invalidates the fingerprint; the component re-previews + // with the new suffix before confirm is re-enabled. Allowed while a + // blocker (e.g. invalid-name or a destination collision) keeps the + // preview unusable so the user can fix the name inline. + if (state.phase !== 'loading' && state.phase !== 'preview' && state.phase !== 'preview-blocked') return state + return { ...state, phase: 'loading', nameInput: action.value } + } + case 'confirm': + if (state.phase !== 'preview' || !state.preview || state.preview.blocked) return state + return { ...state, phase: 'confirming' } + case 'confirm-ready': + return { + ...state, + phase: resultPhase(action.result), + result: action.result, + message: action.result.outcome === 'committed' ? '' : action.result.reason, + } + case 'recovery-from-status': + if (action.result.outcome !== 'recovery-required') return state + return { + ...state, + phase: 'recovery-required', + result: action.result, + message: action.result.reason, + } + case 'recover': + if (state.phase !== 'recovery-required' || state.result?.outcome !== 'recovery-required') return state + return { ...state, phase: 'recovering' } + case 'recover-ready': + return { + ...state, + phase: resultPhase(action.result), + result: action.result, + message: action.result.outcome === 'committed' ? '' : action.result.reason, + } + case 'recover-error': + // Keep the recovery surface mounted so a transient IPC/network failure + // cannot strand the interrupted transaction unrecoverable from the + // open dialog; the Recover control stays available to retry. + return { ...state, phase: 'recovery-required', message: action.message } + case 'reset': + return initialForkDialogState() + } +} diff --git a/apps/electron/src/renderer/components/right-sidebar/git-changes/ChangesPanel.tsx b/apps/electron/src/renderer/components/right-sidebar/git-changes/ChangesPanel.tsx index 43dc829c..b974d994 100644 --- a/apps/electron/src/renderer/components/right-sidebar/git-changes/ChangesPanel.tsx +++ b/apps/electron/src/renderer/components/right-sidebar/git-changes/ChangesPanel.tsx @@ -30,7 +30,7 @@ import { changeIndicator, summarizePendingComments, } from '@kata-sh/shared/git' -import type { GitWorkingTreeEntry, WorktreeHandoffDirection, WorktreeHandoffResult } from '@kata-sh/shared/protocol' +import type { GitWorkingTreeEntry, WorktreeHandoffDirection, WorktreeHandoffResult, ConversationForkResult } from '@kata-sh/shared/protocol' import { cn } from '@/lib/utils' import { Tooltip, TooltipTrigger, TooltipContent } from '@kata-sh/ui' import { useAppShellContext, useSession } from '@/context/AppShellContext' @@ -45,6 +45,9 @@ import { submitPendingFeedback } from './feedback-send' import { HandoffButton, HandoffRecoveryBadge } from '@/components/app-shell/handoff/HandoffAction' import { HandoffDialog } from '@/components/app-shell/handoff/HandoffDialog' import { recoveryResultFromStatus } from '@/components/app-shell/input/handoff-controls' +import { ForkRecoveryBadge } from '@/components/app-shell/fork/ForkAction' +import { ForkDialog } from '@/components/app-shell/fork/ForkDialog' +import { recoveryResultFromForkStatus } from '@/components/app-shell/input/fork-controls' export interface ChangesPanelProps { sessionId: string | null @@ -113,6 +116,10 @@ export function ChangesPanel({ sessionId }: ChangesPanelProps) { direction: WorktreeHandoffDirection recovery: Extract } | null>(null) + // Phase 4: fork recovery dialog (active FORK_STATUS transaction). + const [forkRecovery, setForkRecovery] = React.useState<{ + recovery: Extract + } | null>(null) const { status, loading, error, lastUpdatedAt } = useGitStatusSubscription( sessionId ?? undefined, @@ -130,6 +137,7 @@ export function ChangesPanel({ sessionId }: ChangesPanelProps) { React.useEffect(() => { setSelectedPath(null) setRecoveryDialog(null) + setForkRecovery(null) }, [sessionId]) const entries = React.useMemo( @@ -269,6 +277,14 @@ export function ChangesPanel({ sessionId }: ChangesPanelProps) { }) } /> + + setForkRecovery({ + recovery: recoveryResultFromForkStatus(status, t('git.fork.recoveryNote')), + }) + } + /> )} @@ -396,6 +412,19 @@ export function ChangesPanel({ sessionId }: ChangesPanelProps) { }} /> )} + {forkRecovery && sessionId && ( + setForkRecovery(null)} + onOpenChange={(open) => { + if (!open) setForkRecovery(null) + }} + /> + )} ) } diff --git a/apps/electron/src/renderer/context/AppShellContext.tsx b/apps/electron/src/renderer/context/AppShellContext.tsx index 897de3af..3682dd99 100644 --- a/apps/electron/src/renderer/context/AppShellContext.tsx +++ b/apps/electron/src/renderer/context/AppShellContext.tsx @@ -84,6 +84,13 @@ export interface AppShellContextType { * (e.g. the Changes feedback flow) can clear local state only on success. */ onSendMessage: (sessionId: string, message: string, attachments?: FileAttachment[], skillSlugs?: string[], badges?: import('@kata-sh/core').ContentBadge[]) => Promise + /** + * Retry a failed isolated-fork establishment: re-sends the persisted user + * message via existingMessageId. Resolves true once the retry send is + * accepted (establishment completion is confirmed by the session DTO losing + * forkPending). + */ + onRetryForkSend: (sessionId: string) => Promise onRenameSession: (sessionId: string, name: string) => void onFlagSession: (sessionId: string) => void onUnflagSession: (sessionId: string) => void diff --git a/apps/electron/src/renderer/playground/PlaygroundAppShellProvider.tsx b/apps/electron/src/renderer/playground/PlaygroundAppShellProvider.tsx index 177f367e..ae4c325d 100644 --- a/apps/electron/src/renderer/playground/PlaygroundAppShellProvider.tsx +++ b/apps/electron/src/renderer/playground/PlaygroundAppShellProvider.tsx @@ -48,6 +48,10 @@ const playgroundValue: AppShellContextType = { logCall('onSendMessage')(...args) return true }, + onRetryForkSend: async () => { + logCall('onRetryForkSend')() + return true + }, onRenameSession: logCall('onRenameSession'), onFlagSession: logCall('onFlagSession'), onUnflagSession: logCall('onUnflagSession'), diff --git a/apps/electron/src/shared/__tests__/ipc-channels.test.ts b/apps/electron/src/shared/__tests__/ipc-channels.test.ts index 425232e6..f84d04ab 100644 --- a/apps/electron/src/shared/__tests__/ipc-channels.test.ts +++ b/apps/electron/src/shared/__tests__/ipc-channels.test.ts @@ -99,6 +99,11 @@ const EXPECTED_CHANNELS: string[] = [ 'git:commit', 'git:createPullRequest', 'git:findPullRequest', + 'git:forkCancel', + 'git:forkConfirm', + 'git:forkPreview', + 'git:forkRecover', + 'git:forkStatus', 'git:getBranch', 'git:getCapabilities', 'git:getContext', diff --git a/apps/electron/src/shared/types.ts b/apps/electron/src/shared/types.ts index cf3aa4e2..09871afb 100644 --- a/apps/electron/src/shared/types.ts +++ b/apps/electron/src/shared/types.ts @@ -235,6 +235,17 @@ import type { WorktreeHandoffStatus, WorktreeHandoffRecoverInput, WorktreeHandoffCancelInput, + ConversationForkPreviewInput, + ConversationForkPreview, + ConversationForkConfirmInput, + ConversationForkResult, + ConversationForkStatusInput, + ConversationForkStatus, + ConversationForkRecoverInput, + ConversationForkRecoverResult, + ConversationForkCancelInput, + ConversationForkCancelResult, + ConversationForkStrategy, SessionDeleteOptions, SessionDeleteResult, GitCommitInput, @@ -261,7 +272,13 @@ export interface ElectronAPI { sessionId: string, options?: SessionDeleteOptions, ): Promise - sendMessage(sessionId: string, message: string, attachments?: FileAttachment[], storedAttachments?: StoredAttachmentType[], options?: SendMessageOptions): Promise + sendMessage( + sessionId: string, + message: string, + attachments?: FileAttachment[], + storedAttachments?: StoredAttachmentType[], + options?: SendMessageOptions, + ): Promise<{ accepted: true; messageId: string }> cancelProcessing(sessionId: string, silent?: boolean): Promise killShell(sessionId: string, shellId: string): Promise<{ success: boolean; error?: string }> getTaskOutput(taskId: string): Promise @@ -666,6 +683,22 @@ export interface ElectronAPI { handoffRecover(input: WorktreeHandoffRecoverInput): Promise handoffCancel(input: WorktreeHandoffCancelInput): Promise + // Git / GitHub V1 — isolated conversation forks (Phase 4). Mirrors the + // handoff contract: clients submit a session ID, a strategy, and an editable + // name suffix — never paths; the workspace-owning server binds every + // decision-relevant fact into the preview fingerprint and revalidates it + // under lock on confirm. Typed blockers come back as normal preview results. + /** Preview a conversation fork; the server returns typed blockers as normal results. */ + forkPreview(input: ConversationForkPreviewInput): Promise + /** Confirm a previewed fork by transaction ID + exact preview fingerprint. */ + forkConfirm(input: ConversationForkConfirmInput): Promise + /** Report whether a session has an active (pending/recovery) fork transaction. */ + forkStatus(input: ConversationForkStatusInput): Promise + /** Continue/recover an interrupted fork transaction (idempotent, snapshot-backed). */ + forkRecover(input: ConversationForkRecoverInput): Promise + /** Cancel a pending fork preview transaction (dialog dismissed without confirming). */ + forkCancel(input: ConversationForkCancelInput): Promise + // Git / GitHub V1 — commit / pull / push + GitHub pull requests (Phase 3). // Identity is resolved server-side from the session's persisted checkout; // callers pass only the session ID and typed operation input. diff --git a/apps/electron/src/transport/channel-map.ts b/apps/electron/src/transport/channel-map.ts index 29db79c5..e8b5c221 100644 --- a/apps/electron/src/transport/channel-map.ts +++ b/apps/electron/src/transport/channel-map.ts @@ -349,6 +349,12 @@ export const CHANNEL_MAP = { handoffStatus: invoke(RPC_CHANNELS.git.HANDOFF_STATUS), handoffRecover: invoke(RPC_CHANNELS.git.HANDOFF_RECOVER), handoffCancel: invoke(RPC_CHANNELS.git.HANDOFF_CANCEL), + // Git / GitHub V1 — isolated conversation forks (Phase 4) + forkPreview: invoke(RPC_CHANNELS.git.FORK_PREVIEW), + forkConfirm: invoke(RPC_CHANNELS.git.FORK_CONFIRM), + forkStatus: invoke(RPC_CHANNELS.git.FORK_STATUS), + forkRecover: invoke(RPC_CHANNELS.git.FORK_RECOVER), + forkCancel: invoke(RPC_CHANNELS.git.FORK_CANCEL), // Git / GitHub V1 — commit / pull / push + pull requests (Phase 3) commitGit: invoke(RPC_CHANNELS.git.COMMIT), diff --git a/apps/online-docs/core-concepts/git-worktrees.mdx b/apps/online-docs/core-concepts/git-worktrees.mdx index cb8e1bb8..d83a6df8 100644 --- a/apps/online-docs/core-concepts/git-worktrees.mdx +++ b/apps/online-docs/core-concepts/git-worktrees.mdx @@ -205,6 +205,60 @@ reports `handoff-rolled-back` and you can preview again. guessing. +## Forking a conversation + +The Branch action on an assistant message offers two strategies when Worktree +V2 is enabled: + + + + The default. The new session branches the conversation into the source's + managed worktree (existing behavior): both sessions share one working + tree, one branch, and one checkout, and each can keep its own + conversation path. + + + A separately named managed worktree, Git branch, Kata session, and + execution runtime at the source conversation's **current head**. The + source conversation and checkout stay completely unchanged. + + + +Isolated forking is offered only when the source session is idle at its +current conversation head, the Git state is supported, and the provider can +establish a **strict cross-CWD native fork**. If the provider does not support +it yet, or the message you branched from is not the latest one, the isolated +option is disabled with a typed reason and only the shared strategy remains. + +Before confirming, the fork dialog previews the source conversation head, +session, checkout, branch, HEAD, Git-state summary, path owners, destination +server/root/branch, provider capability, and the ignored-file policy (only +`.worktreeinclude`-listed ignored files copy). Confirm takes an editable name +for the isolated target — Kata uses it as the suffix of a `kata-agent/` +branch — and commits the child session through a durable journaled +transaction, so an interrupted fork either resumes to a committed child or +surfaces an explicit recovery state. + +The child session copies the conversation through the fork point, binds the +new worktree, and keeps its **provider identity pending** until its first +message: no child provider ID is claimed before the provider creates one. On +that first Send, Kata establishes the native fork at the recorded source head +with a persisted idempotency key and proves every file, shell, MCP, and +provider tool executes in the child's checkout. If establishment fails, the +child keeps exactly one persisted message in a visible retryable state and +never duplicates it, never executes in the source, and never falls back to a +full-history or shared fork. + +Deleting an isolated fork child removes only that child's own worktree and +branch; the source session, checkout, and conversation are never touched. + + + Isolated forks are available only at the current conversation head — older + messages keep the shared strategy. Forking from a snapshotted or missing + checkout requires restoring it first, and cross-repository forks are out of + scope. + + ## Archiving and deleting sessions - **Archiving** a session never removes its worktree, but with Worktree V2 the diff --git a/docs/adrs/2026-08-08-isolated-conversation-forks.md b/docs/adrs/2026-08-08-isolated-conversation-forks.md new file mode 100644 index 00000000..eae18688 --- /dev/null +++ b/docs/adrs/2026-08-08-isolated-conversation-forks.md @@ -0,0 +1,77 @@ +--- +type: ADR +title: Isolated conversation forks +description: The workspace-owning server forks an idle source conversation's current head into a separately named managed worktree, Git branch, Kata session, and execution runtime with a durable pending provider-fork intent, strict cross-CWD native-fork establishment on first Send, and journaled fork transactions with compensation and an orphan ledger +tags: [git, worktrees, forks, sessions, snapshots, provider, architecture] +timestamp: 2026-08-08T00:00:00Z +--- + +# ADR: Isolated conversation forks + +## Status + +Accepted + +## Context + +Worktree V2 Phases 1–3 ([#40](https://github.com/gannonh/kata-agents/issues/40), [server-owned managed worktrees ADR](2026-07-29-server-owned-managed-worktrees.md); [#41](https://github.com/gannonh/kata-agents/issues/41), [snapshot-backed worktree lifecycle ADR](2026-08-05-snapshot-backed-worktree-lifecycle.md); [#42](https://github.com/gannonh/kata-agents/issues/42), [conflict-safe checkout handoff ADR](2026-08-07-conflict-safe-checkout-handoff.md)) give a session an isolated managed checkout with verified snapshots, lifecycle ownership, path leases, and provider CWD capability contracts. V1 conversation branching preserves provider-native context by adding another session owner to the **same** managed worktree ([#33](https://github.com/gannonh/kata-agents/issues/33)); that shared checkout is useful for alternate conversation paths that edit one working tree, but it cannot provide filesystem or branch isolation. + +Phase 4 ([#43](https://github.com/gannonh/kata-agents/issues/43)) must let a user fork the **current head** of a provider-native conversation into a separately named managed worktree, Git branch, Kata session, and execution runtime while leaving the source conversation and checkout unchanged. Two properties make a naive fork unsafe: + +- **The provider creates native forks on first Send.** Current Claude branching establishes the native fork on the child's first Send from the parent's SDK session, so a fork cannot claim a child provider ID at session creation. The design must persist a pending fork intent and prove destination execution instead. +- **A provider child ID must be created exactly once.** A retried first Send must reuse the persisted idempotency key and never duplicate the provider child or the user message, and a provider artifact created without a persisted link must never be silently attached. + +The server owns all Git mutations and all checkout paths, so forking must stay server-side: clients submit a session ID, a strategy, and (for isolated) an editable worktree name suffix — never paths or patches. + +## Decision + +**`IsolatedConversationForkService` (server-core) is the single owner of fork eligibility previews, seed capture, confirmation, recovery, and cancellation, and every enabled provider adapter must establish a strict cross-CWD native fork with an idempotency key before the child's first Send unlocks.** + +### Shared default, isolated when eligible + +- The Branch action offers two strategies. **Shared worktree** remains the default and preserves #33 behavior byte-for-byte (its confirmation reuses the existing branch flow; the server throws a typed `FORK_NOT_IMPLEMENTED` for shared confirmation). +- **New isolated worktree** is offered only when Worktree V2 is effective, the branch point is the current conversation head, the source session is idle at a supported Git state, and the provider adapter advertises **and** structurally implements the strict cross-CWD native fork (`resolveIsolatedForkCapability` gate). Unsupported providers and non-head source turns receive a typed blocker (`unsupported-provider`, `non-head-source`) with **no fallback** to shared/full-history/fresh behavior. + +### Fingerprint-bound previews and typed blockers + +- A preview is side-effect free beyond registering an in-memory transaction and a durable PENDING journal entry. It binds every decision-relevant fact — source conversation head and turn, session/checkout/branch/HEAD, Git-state summary, all path owners and leases, destination server/root/branch, provider capability, and ignored-file policy — into a `previewFingerprint`. +- Confirmation revalidates the fingerprint under the common-directory mutation lock + registry lock; any drift returns a typed `identity-drift` blocker and claims no mutation. Typed blockers cover `flags-disabled`, `unsupported-provider`, `fork-in-progress`, `cleanup-in-progress`, `missing-source`, `non-head-source`, `source-active`, `path-unleased`, `git-operation-in-progress`, `unsupported-snapshot`, `oversized-capture`, `invalid-name`, `name-collision`, and `identity-drift`. + +### Journaled fork transaction with compensation and an orphan ledger + +- A durable append-only journal records the transaction before any mutation and every idempotent step (locks-acquired, source-quiesced, target-reserved, seed-captured, destination-leased, target-materialized, target-restored, target-verified, child-created, binding-committed). Metadata carries the source HEAD OID, seed snapshot id, reserved name/path/branch, managed worktree id, and child session id. A pure PENDING preview can be cancelled (recovered `preview-cancelled`); any journaled confirm step makes the transaction non-resumable-failed on error and recovery-required on child-created-without-commit, so a restarted server classifies interrupted forks from journal evidence — never from a missing-path heuristic — and reconciles a lost `established` marker from the durable child session record. +- Pre-publication failures compensate **only transaction-owned artifacts** with CAS proof (a removed target/branch only while it still points at the captured OID, the captured seed, and an unpublished child session); the source is never edited. Failed transactions are never resumable on any path. +- After publication, a provider-fork failure does not delete the child or target: the child stays pending with exactly one persisted user message and a retryable typed error. A provider artifact created without a persisted link is recorded in a durable **fork orphan ledger** and never silently attached. + +### Pending provider-fork intent (no child provider ID claim before first Send) + +- The child session stores a durable `pendingFork` intent containing the strict parent conversation/turn identity, the immutable transcript lookup CWD, the destination execution CWD, the reserved idempotency key, and the target checkout binding. Before first Send the session DTO reports `forkPending` and the UI displays provider identity as **Pending** — no child provider ID is claimed. +- First Send runs `SessionManager.establishPendingFork`: it creates the child agent, resolves the strict fork adapter, and calls `establishNativeFork` with the **persisted** idempotency key and parent identity. Success persists the child provider ID exactly once, retires the pending metadata (checkout `checkoutStrategy` stays `'isolated'` as provenance), and records the establishment in the fork journal (metadata-only on the committed entry; the child session record is authoritative if the marker is lost). Missing/malformed anchors, absent adapters, throwing establishes, and malformed results are typed retryable errors — no fallback, no duplicate message, no duplicate provider child, and no execution in the source. Concurrent first-sends on the same pending child are serialized before message persistence. +- Review hardening keeps isolated confirmation fail-closed: a missing parent session/turn anchor is a typed `missing-parent-anchor` blocker before child creation, and a first-Send proof must identify the selected adapter, resolve to the exact destination execution CWD, carry a fresh bounded verification timestamp, and cover file, shell, MCP, and provider execution before the provider ID is persisted. Unresolved in-progress or recovery-required journal entries are rehydrated into session/path fences after restart, and fork children inherit the source session's locked LLM connection/model identity. + +### Checkout-strategy provenance for cleanup + +- Session branch cleanup uses the durable `checkoutStrategy` provenance: shared-child deletion drops one owner of the shared record; isolated-child deletion uses only the child's own lifecycle and never mutates the source record. The source session, checkout, owners, registry, and runtime remain untouched by the fork. + +### Client contract and credential-free coverage + +- Local Electron and headless/remote clients share the same capability, preview, confirm, status, recover, and cancel RPC channels (`git:forkPreview`, `git:forkConfirm`, `git:forkStatus`, `git:forkRecover`, `git:forkCancel`), all remote-eligible. Clients submit only a session ID, strategy, transaction ID, preview fingerprint, and name suffix; server-derived previews label remote servers and expose no local reveal. +- Isolated actions render only when the server-derived session `isolatedForkCapable` flag is true; the server's typed blockers remain the authoritative backstop. +- Credential-free state-machine and UI-UAT coverage uses a deterministic strict fork adapter factory (`createDeterministicStrictForkAdapter`) with explicit failure injection and a stable child SDK session ID per adapter, wired through the `KATA_FORK_DETERMINISTIC_ADAPTER=1` seam (non-production only, mirroring the handoff seam). It exercises preview, confirm, pending identity, first-Send establishment, failure points, and the destination-execution proof gate without a live provider. + +## Consequences + +- A user can fork the current head of a provider-native conversation into a dedicated managed worktree, `kata-agent/` branch, Kata session, and execution runtime while the source conversation, checkout, owners, and runtime stay byte-identical. +- The child is durable and independently lifecycle-managed from the moment it is visible; a crash between target materialization and the commit marker resolves to either a committed child or explicit recovery-required — never a silent source edit or a fabricated child. +- The pending-fork intent makes first-Send establishment idempotent: retries reuse the persisted key, so the provider child and the user message are each created exactly once, and unlinkable provider artifacts land in the orphan ledger. +- Historical conversation points, cross-repository forks, merge/PR from a fork, arbitrary ignored copying, provider emulation, and non-Git checkout isolation remain out of scope by decision; older turns keep the shared strategy only. +- Providers that cannot establish a strict cross-CWD native fork simply do not offer the isolated strategy (shared preserved); enabling a production adapter is a future, credentialed, per-provider decision gated by live dev and packaged UAT — not part of this Build phase. + +## Links + +- Spec: [#43](https://github.com/gannonh/kata-agents/issues/43) (Worktree V2 Phase 4) +- Parent epic: [#17](https://github.com/gannonh/kata-agents/issues/17) +- Phase 1: [#40](https://github.com/gannonh/kata-agents/issues/40), [server-owned managed worktrees ADR](2026-07-29-server-owned-managed-worktrees.md) +- Phase 2: [#41](https://github.com/gannonh/kata-agents/issues/41), [snapshot-backed worktree lifecycle ADR](2026-08-05-snapshot-backed-worktree-lifecycle.md) +- Phase 3: [#42](https://github.com/gannonh/kata-agents/issues/42), [conflict-safe checkout handoff ADR](2026-08-07-conflict-safe-checkout-handoff.md) +- Deferred credentialed UAT: [#47](https://github.com/gannonh/kata-agents/issues/47) model (handoff) diff --git a/docs/adrs/index.md b/docs/adrs/index.md index 01e0e03b..43b03cec 100644 --- a/docs/adrs/index.md +++ b/docs/adrs/index.md @@ -12,6 +12,7 @@ Durable architecture decisions for this fork are recorded here. See the Accepted * [2026-07-29-server-owned-managed-worktrees.md](2026-07-29-server-owned-managed-worktrees.md) — The workspace-owning server owns all managed-worktree lifecycle and Git mutation; worktrees live under Kata config data, mutations serialize by Git common directory, and checkout preparation is an atomic empty-session gate. * [2026-08-05-snapshot-backed-worktree-lifecycle.md](2026-08-05-snapshot-backed-worktree-lifecycle.md) — Every destructive V2 path routes through one lifecycle service with verified snapshots, path leases, a durable journal, and event-driven retention cleanup. * [2026-08-07-conflict-safe-checkout-handoff.md](2026-08-07-conflict-safe-checkout-handoff.md) — The server moves a single-owner idle session between current and managed checkouts with fingerprint-bound previews, journaled idempotent steps, snapshot-backed rollback, and provider-proven execution-CWD rebinding. +* [2026-08-08-isolated-conversation-forks.md](2026-08-08-isolated-conversation-forks.md) — The server forks an idle source conversation's current head into a separate managed worktree, branch, session, and runtime with a durable pending provider-fork intent, strict cross-CWD native-fork establishment on first Send, journaled fork transactions with compensation, and an orphan ledger. ## Superseded diff --git a/docs/adrs/log.md b/docs/adrs/log.md index 3b121b0c..657ab52b 100644 --- a/docs/adrs/log.md +++ b/docs/adrs/log.md @@ -1,5 +1,11 @@ # ADR Update Log +## 2026-08-08 + +* **Isolated conversation forks ADR hardened**: [2026-08-08-isolated-conversation-forks.md](2026-08-08-isolated-conversation-forks.md) records PR #50 review invariants for restart fence rehydration, authoritative journal-attempt lookup, source backend identity inheritance, missing-anchor blocking, complete execution proofs, and pre-persist first-Send serialization. + +* **Isolated conversation forks ADR accepted**: [2026-08-08-isolated-conversation-forks.md](2026-08-08-isolated-conversation-forks.md) records the server-owned fork engine for Worktree V2 Phase 4 ([#43](https://github.com/gannonh/kata-agents/issues/43)): shared stays the default while isolated is offered only for current-head idle sources with a strict cross-CWD native fork adapter, fingerprint-bound previews with typed blockers and no fallback, the journaled fork transaction (PENDING preview cancel, compensation with CAS proof, recovery-required classification, startup reconciliation), the durable pending provider-fork intent with no child provider ID claim before first Send, idempotency-keyed first-Send establishment with exactly-once provider/message creation and the orphan ledger, checkout-strategy provenance for cleanup, and the `KATA_FORK_DETERMINISTIC_ADAPTER=1` seam for credential-free coverage while production adapters stay disabled until credentialed UAT. + ## 2026-08-07 * **Conflict-safe handoff ADR accepted**: [2026-08-07-conflict-safe-checkout-handoff.md](2026-08-07-conflict-safe-checkout-handoff.md) records the server-owned handoff engine for Worktree V2 Phase 3 ([#42](https://github.com/gannonh/kata-agents/issues/42)): fingerprint-bound previews with typed blockers, journal-first durability with idempotent steps, direction-specific snapshot-backed rollback, managed-to-current release retaining the `snapshotted` record as the hand-back target, immutable transcript vs. execution-CWD separation, the provider capability gate with production adapters disabled until credentialed UAT, the session runtime reconstruction proof gate, and the deterministic adapter for credential-free state-machine coverage. diff --git a/docs/architecture/log.md b/docs/architecture/log.md index 7aac7a8b..ce76cb46 100644 --- a/docs/architecture/log.md +++ b/docs/architecture/log.md @@ -1,5 +1,11 @@ # Architecture Update Log +## 2026-08-08 + +* **Update**: [2026-08-08-isolated-conversation-forks.md](../adrs/2026-08-08-isolated-conversation-forks.md) — PR #50 review hardening records restart fence rehydration, strict execution-proof validation, source backend identity inheritance, and pre-persist first-Send concurrency fencing. + +* **Update**: [system-overview.md](system-overview.md) — added the Phase 4 isolated conversation-fork bullet to the "Git & GitHub worktrees (preview)" section: server-owned eligibility previews with typed blockers, the journaled fork transaction with compensation, the durable pending provider-fork intent (no child provider ID claim before first Send), idempotency-keyed first-Send establishment, and checkout-strategy provenance for cleanup. + ## 2026-07-29 * **Update**: [system-overview.md](system-overview.md) — added the "Git & GitHub worktrees (preview)" section documenting server-owned Git behavior, managed-worktree storage/lifecycle, remote-eligible RPCs and identity revalidation, and the `KATA_FEATURE_GIT_WORKSPACE_V1` flag (Phase 4 of the git-github-worktrees-v1 spec). diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md index 0b0f1047..d9cc7965 100644 --- a/docs/architecture/system-overview.md +++ b/docs/architecture/system-overview.md @@ -78,6 +78,7 @@ workspaces at parity. - **Managed worktrees** live beneath the owning server's configured root (not inside the repository). V1 uses generated `kata-agent/<8-hex>` branches; opt-in V2 accepts an exact validated suffix such as `kata-agent/auth-refresh` and adds a random internal ID to the filesystem leaf. The fixed authoritative registry remains at `/worktrees/registry.json`, separate from the configurable materialization root. Host-specific worktree IDs/paths are not portable; session import and remote transfer clear managed-worktree ownership. - **V2 settings** are server-owned and effective only when both `KATA_FEATURE_GIT_WORKSPACE_V1` and `KATA_FEATURE_WORKTREE_V2` are enabled. Roots are canonicalized, writable, and overlap-checked against protected storage, repositories, and registered checkouts. Root changes affect only new worktrees; records retain their own materialization roots. - **Lifecycle** — archiving preserves worktrees; deleting a session drops the owner reference but never removes the checkout on its own. Managed-worktree removal is a separate, explicitly-confirmed choice, blocked while another owner remains, and destructive removal requires force and names uncommitted/unpushed/unique work. +- **Conversation forks (Phase 4)** — `IsolatedConversationForkService` + `ForkOrphanLedger` own eligibility previews, seed capture, confirmation, recovery, and cancellation for the **New isolated worktree** strategy (shared stays the default and reuses the existing branch flow). Isolated is offered only at the current conversation head for an idle source whose provider advertises a strict cross-CWD native fork; confirmation journals every idempotent step and compensates only transaction-owned artifacts with CAS proof. The published child stores a durable **pending provider-fork intent** (transcript CWD + destination execution CWD + persisted idempotency key) and claims **no child provider ID until first Send**: `SessionManager.establishPendingFork` creates the child agent and establishes the native fork with the persisted key, persisting the child provider ID exactly once and retiring the pending metadata. Durable `checkoutStrategy: 'isolated'` provenance keeps child deletion on the child's own lifecycle, never the source record. Remote-server requirement: the workspace-owning machine needs a working `git` client, and pull-request actions additionally need `gh` installed and diff --git a/docs/index.md b/docs/index.md index bfe1e009..81c873a7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,13 +16,15 @@ Kata Agents is an open-source Electron desktop app, headless server, and CLI cli ## Roadmap -* **Worktree V2 in progress**: phase 1 [#40](https://github.com/gannonh/kata-agents/issues/40) is implemented on this branch, covering custom identity, server-owned roots, registry authority, and local/headless parity. Sequential phases [#41](https://github.com/gannonh/kata-agents/issues/41), [#42](https://github.com/gannonh/kata-agents/issues/42), and [#43](https://github.com/gannonh/kata-agents/issues/43) cover snapshot-backed lifecycle, checkout handoff, and isolated conversation forks. Phase 2 [#41](https://github.com/gannonh/kata-agents/issues/41) is implemented on this branch: snapshot-backed management with automatic cleanup (see [ADR](adrs/2026-08-05-snapshot-backed-worktree-lifecycle.md)). Phase 3 [#42](https://github.com/gannonh/kata-agents/issues/42) is implemented on this branch: conflict-safe checkout handoff between current and managed checkouts (see [ADR](adrs/2026-08-07-conflict-safe-checkout-handoff.md)). +* **Worktree V2 in progress**: phase 1 [#40](https://github.com/gannonh/kata-agents/issues/40) is implemented on this branch, covering custom identity, server-owned roots, registry authority, and local/headless parity. Sequential phases [#41](https://github.com/gannonh/kata-agents/issues/41), [#42](https://github.com/gannonh/kata-agents/issues/42), and [#43](https://github.com/gannonh/kata-agents/issues/43) cover snapshot-backed lifecycle, checkout handoff, and isolated conversation forks. Phase 2 [#41](https://github.com/gannonh/kata-agents/issues/41) is implemented on this branch: snapshot-backed management with automatic cleanup (see [ADR](adrs/2026-08-05-snapshot-backed-worktree-lifecycle.md)). Phase 3 [#42](https://github.com/gannonh/kata-agents/issues/42) is implemented on this branch: conflict-safe checkout handoff between current and managed checkouts (see [ADR](adrs/2026-08-07-conflict-safe-checkout-handoff.md)). Phase 4 [#43](https://github.com/gannonh/kata-agents/issues/43) is implemented on this branch: isolated conversation forks with a pending provider-fork intent and first-Send establishment (see [ADR](adrs/2026-08-08-isolated-conversation-forks.md)). * **Next planned initiative: integrated browser** — parent issue [#28](https://github.com/gannonh/kata-agents/issues/28) tracks a panel-by-default browser, secure Chrome cookie import, persistent page annotations, and agent handoff. The issue's implementation breakdown starts with [#29](https://github.com/gannonh/kata-agents/issues/29) for the embedded panel and detachable surface, allows [#30](https://github.com/gannonh/kata-agents/issues/30) to proceed after the profile/session contract is established, and places [#31](https://github.com/gannonh/kata-agents/issues/31) after the panel and annotation overlay model. * **Supporting test work** — [#25](https://github.com/gannonh/kata-agents/issues/25) is implemented and awaiting Verify; [#34](https://github.com/gannonh/kata-agents/issues/34) covers running development and production builds together. * **Deferred product backlog** — Git V2 ([#16](https://github.com/gannonh/kata-agents/issues/16)), Forge V2 ([#18](https://github.com/gannonh/kata-agents/issues/18)), Git V1 WebUI/CLI parity ([#19](https://github.com/gannonh/kata-agents/issues/19)), and the standalone server/remote client/TUI story ([#6](https://github.com/gannonh/kata-agents/issues/6)) remain tracked for later planning. ## Recently implemented +* **Worktree V2 Phase 4** — [#43](https://github.com/gannonh/kata-agents/issues/43) — isolated conversation forks: the Branch action offers **New isolated worktree** next to the default **Shared worktree** for capable providers, previewing the source conversation head/branch/HEAD/owners and destination identity with typed blockers; confirm commits a durable child session bound to a `kata-agent/` managed worktree at the source HEAD through a journaled fork transaction; the child carries a durable pending provider-fork intent (provider identity shown as **Pending**) and first Send establishes the native fork with a persisted idempotency key, exactly-once provider/message creation, and an orphan ledger for unlinkable provider artifacts (see [ADR](adrs/2026-08-08-isolated-conversation-forks.md)). Production provider adapters remain disabled until credentialed UAT. + * **Share managed worktrees across sessions** — [specs/archive/2026-08-03-allow-new-sessions-to-use-existing-managed-worktrees-design.md](specs/archive/2026-08-03-allow-new-sessions-to-use-existing-managed-worktrees-design.md) — **Implemented.** A new empty session can pick an existing managed worktree from the composer Workspace control and bind to it as a shared owner; discovery is scoped to the workspace + repository and cleanup guards keep shared checkouts intact. Tracks [#33](https://github.com/gannonh/kata-agents/issues/33). * **Pi SDK 0.83 migration** — [specs/archive/2026-08-01-pi-sdk-0.83-migration-design.md](specs/archive/2026-08-01-pi-sdk-0.83-migration-design.md) — **Implemented.** Migrated the embedded Pi runtime to the current Pi CLI-aligned `@earendil-works` packages and adopted native model reasoning metadata. [Build report](specs/archive/2026-08-01-pi-sdk-0.83-migration-build-report.md). * **Provider-aware reasoning levels** — [specs/archive/2026-08-01-provider-aware-reasoning-levels-design.md](specs/archive/2026-08-01-provider-aware-reasoning-levels-design.md) — **Implemented.** OpenAI API, ChatGPT/Codex, Copilot, and Pi-managed model controls expose reported reasoning capabilities, including `minimal`. [Build report](specs/archive/2026-08-01-provider-aware-reasoning-levels-build-report.md). diff --git a/docs/log.md b/docs/log.md index 59e23590..f003bfed 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,11 @@ # Documentation Bundle Update Log +## 2026-08-08 + +* **PR #50 review hardening**: tightened isolated-fork safety with restart fence rehydration, newest journal-attempt resolution, source LLM identity inheritance, missing-anchor blockers, complete destination execution-proof validation, pre-persist concurrent-send rejection, durable renderer retry reconstruction, optional startup reconciliation guards, and E2E credential/onboarding protections. Updated [adrs/2026-08-08-isolated-conversation-forks.md](adrs/2026-08-08-isolated-conversation-forks.md). + +* **Worktree V2 Phase 4 implemented**: [#43](https://github.com/gannonh/kata-agents/issues/43) adds isolated conversation forks — the Branch action offers **New isolated worktree** next to the default **Shared worktree** for capable providers; server-owned eligibility previews with typed blockers (current-head only, strict cross-CWD native fork required, no fallback); journaled fork transactions with PENDING-preview cancel, snapshot-backed seed capture, CAS-compensated pre-publication failure, and startup classification/reconciliation; a durable pending provider-fork intent on the published child (provider identity shown as **Pending**, no child provider ID claim); idempotency-keyed first-Send native-fork establishment with exactly-once provider/message creation and a durable orphan ledger; and checkout-strategy provenance so isolated-child deletion never mutates the source record. ADR: [adrs/2026-08-08-isolated-conversation-forks.md](adrs/2026-08-08-isolated-conversation-forks.md). Updated the roadmap, online Git/worktree documentation, ADR index/log, release notes, and this log; the E2E fork spec is authored and listed (run tier deferred to credentialed UAT exactly like handoff #47). + ## 2026-08-07 * **Worktree V2 Phase 3 implemented**: [#42](https://github.com/gannonh/kata-agents/issues/42) adds conflict-safe checkout handoff — server-owned previews/confirmation/recovery for current → managed, managed → current, and hand-back; fingerprint-bound previews with typed blockers; durable journaling of idempotent steps; snapshot-backed rollback; path/transaction fencing; provider execution-CWD rebinding with proof and immutable transcript identity; session runtime reconstruction before Send; and the Electron handoff preview/confirm/recovery UI. ADR: [adrs/2026-08-07-conflict-safe-checkout-handoff.md](adrs/2026-08-07-conflict-safe-checkout-handoff.md). Updated the roadmap, online Git/worktree documentation, ADR index/log, and this log. diff --git a/docs/specs/log.md b/docs/specs/log.md index a3972417..59a5111e 100644 --- a/docs/specs/log.md +++ b/docs/specs/log.md @@ -1,5 +1,11 @@ # Specs Update Log +## 2026-08-08 + +* **PR #50 review hardening**: acceptance evidence now covers restart fencing, exact provider execution proofs, missing parent anchors, source connection inheritance, pre-persist concurrency rejection, durable renderer retry recovery, and protected E2E onboarding credentials for [#43](https://github.com/gannonh/kata-agents/issues/43). + +* **Worktree V2 Phase 4 built**: [#43](https://github.com/gannonh/kata-agents/issues/43) — isolated conversation forks are implemented on branch `feat/worktree-v2-phase-4-isolated-conversation-forks` (shared stays the default; isolated is offered only for current-head idle sources with a strict cross-CWD native fork adapter; pending provider-fork intent with first-Send idempotency-keyed establishment; journaled fork transactions with compensation and an orphan ledger; checkout-strategy provenance for cleanup). The E2E fork spec is authored and lists cleanly; the credentialed UI UAT run tier is deferred (mirrors #47): the run needs a provider credential plus the desktop build artifacts, and production adapters stay disabled until credentialed UAT proves native ancestry, a distinct provider ID after first Send, and destination-only tool CWD. + ## 2026-08-05 * **Worktree V2 Phase 2 feedback applied**: [#41](https://github.com/gannonh/kata-agents/issues/41) now specifies automatic deletion off by default and a simplified delete-only active-worktree management surface; server-side snapshot safety remains internal. diff --git a/e2e/src/flows/agentChat.ts b/e2e/src/flows/agentChat.ts index 25e730c1..d26885ac 100644 --- a/e2e/src/flows/agentChat.ts +++ b/e2e/src/flows/agentChat.ts @@ -1,7 +1,108 @@ import { type Page } from "@playwright/test"; import { E2E_TIMEOUTS } from "../config/timeouts.ts"; -import { readAgentProviderConfig } from "../harness/env.ts"; +import { + readAgentProviderChain, + readAgentProviderConfig, + type AgentProviderCandidate, +} from "../harness/env.ts"; + +/** + * Serial-suite timeout that budgets one {@link E2E_TIMEOUTS.agentTestMs} per + * ready fallback candidate so a chain-wide walk cannot hit the describe + * timeout before every option is exhausted. + */ +export function agentSuiteTimeoutMs(): number { + const chain = readAgentProviderChain(); + const readyCount = Math.max(1, chain.filter((candidate) => candidate.ready).length); + return readyCount * E2E_TIMEOUTS.agentTestMs; +} + +/** One recorded fallback attempt (skipped, failed, or ok). */ +export interface ProviderAttemptRecord { + readonly candidate: AgentProviderCandidate; + readonly status: "skipped" | "failed" | "ok"; + readonly error?: string; + readonly durationMs?: number; +} + +/** + * Raised only after every provider option in the fallback chain has been + * attempted. The message names each candidate, its credential source, and its + * failure reason so the Playwright report/UI shows exactly what was tried. + */ +export class AgentProviderChainExhaustedError extends Error { + readonly attempts: readonly ProviderAttemptRecord[]; + + constructor(label: string, attempts: readonly ProviderAttemptRecord[]) { + const lines = attempts.map((attempt, index) => { + const { candidate } = attempt; + const detail = + attempt.status === "skipped" + ? `skipped: ${candidate.readyReason}` + : attempt.status === "ok" + ? "ok" + : `failed after ${attempt.durationMs}ms: ${attempt.error ?? "unknown error"}`; + return ` ${index + 1}. ${candidate.provider} (${candidate.model || "no model"}, ${candidate.keySource}) — ${detail}`; + }); + super( + `${label}: all ${attempts.length} provider option(s) exhausted.\n${lines.join("\n")}\n\n` + + `Check the credentials in root .env (KATA_*_API_KEY) and the codex OAuth harness ` + + `(dotfiles/pi/.pi/agent/auth.json), then re-run. See e2e/README.md.`, + ); + this.name = "AgentProviderChainExhaustedError"; + this.attempts = attempts; + } +} + +/** + * Walk the provider fallback chain for one agent-requiring section. Each ready + * candidate is attempted in order (configure + run); failures are logged with + * the candidate and reason and the next option is tried. When every option is + * exhausted, throws {@link AgentProviderChainExhaustedError} so the failure is + * loud in both the logs and the Playwright report. + */ +export async function runWithAgentProviderFallback( + page: Page, + label: string, + run: (candidate: AgentProviderCandidate) => Promise, +): Promise { + const chain = readAgentProviderChain(); + const attempts: ProviderAttemptRecord[] = []; + + for (const candidate of chain) { + if (!candidate.ready) { + console.warn( + `[e2e][provider] ${label}: candidate ${candidate.index}/${chain.length} (${candidate.provider}) skipped — ${candidate.readyReason}`, + ); + attempts.push({ candidate, status: "skipped" }); + continue; + } + + const started = Date.now(); + console.log( + `[e2e][provider] ${label}: attempting candidate ${candidate.index}/${chain.length} — ${candidate.provider} (${candidate.model}, ${candidate.keySource})`, + ); + try { + const result = await run(candidate); + const durationMs = Date.now() - started; + console.log( + `[e2e][provider] ${label}: candidate ${candidate.index}/${chain.length} OK after ${durationMs}ms — ${candidate.provider}`, + ); + attempts.push({ candidate, status: "ok", durationMs }); + return result; + } catch (error) { + const durationMs = Date.now() - started; + const reason = error instanceof Error ? error.message : String(error); + console.error( + `[e2e][provider] ${label}: candidate ${candidate.index}/${chain.length} FAILED after ${durationMs}ms — ${candidate.provider}: ${reason.slice(0, 500)}`, + ); + attempts.push({ candidate, status: "failed", error: reason, durationMs }); + } + } + + throw new AgentProviderChainExhaustedError(label, attempts); +} export interface DeterministicAgentTurn { readonly prompt: string; diff --git a/e2e/src/flows/onboarding.ts b/e2e/src/flows/onboarding.ts index 81fb32e2..fa33764f 100644 --- a/e2e/src/flows/onboarding.ts +++ b/e2e/src/flows/onboarding.ts @@ -1,8 +1,9 @@ import { expect, type Page } from "@playwright/test"; import { E2E_TIMEOUTS } from "../config/timeouts.ts"; -import { readAgentProviderConfig } from "../harness/env.ts"; +import type { AgentProviderCandidate } from "../harness/env.ts"; import { + waitForOnboardingOrReady, waitForOnboardingWizard, waitForReadyOrWorkspacePicker, } from "./shell.ts"; @@ -52,58 +53,6 @@ export async function completeDeferredSetup(page: Page): Promise { await handleWorkspacePickerIfPresent(page); } -/** - * Real API-key path for @agent: configure a real provider connection using the - * key from root .env, then reach the ready shell. - * - * Flow (validated on a headed harness run, adoption guide learning #7): - * provider select -> "I use other provider" lands on the API Configuration - * step with an API key field (Anthropic endpoint preselected) and a Continue - * button. - * - * NOTE: this function is the Anthropic API-key path. The configured - * ChatGPT/Codex OAuth path is handled by completeConfiguredChatGptOnboarding - * and does not enter an API key. - */ -export async function completeApiKeyOnboarding(page: Page): Promise { - const { apiKey } = readAgentProviderConfig(); - if (!apiKey) { - throw new Error( - "API-key onboarding requires an API-key provider. Set KATA_E2E_AGENT_PROVIDER=anthropic or use the configured openai-codex OAuth flow; see e2e/README.md.", - ); - } - const wizard = page.locator("#onboarding-wizard"); - await waitForOnboardingWizard(page); - - // Provider select -> "I use other provider" -> API Configuration step. - await wizard.locator('[data-testid="onboarding-provider-api_key"]').click(); - - // Enter the key and continue. - const keyInput = wizard.locator("#api-key"); - await keyInput.waitFor({ - state: "visible", - timeout: E2E_TIMEOUTS.electronWindowMs, - }); - await keyInput.fill(apiKey); - - await wizard.locator('[data-testid="onboarding-api-key-continue"]').click(); - - // Completion step -> finish onboarding. - const finishButton = wizard.locator('[data-testid="onboarding-finish"]'); - await finishButton.waitFor({ - state: "visible", - timeout: E2E_TIMEOUTS.electronWindowMs, - }); - await finishButton.click(); - - // Onboarding completes and the app reaches ready (handling workspace picker). - await handleWorkspacePickerIfPresent(page); - await setAgentWorkingDirectory(page); - await expect(page.locator("#app-ready")).toBeVisible({ - timeout: E2E_TIMEOUTS.electronWindowMs, - }); -} - async function setAgentWorkingDirectory(page: Page): Promise { await page.evaluate(async (workingDirectory) => { const api = ( @@ -133,6 +82,160 @@ async function setAgentWorkingDirectory(page: Page): Promise { } const CHATGPT_CONNECTION_SLUG = "chatgpt-plus"; +const PI_API_KEY_SLUG = "pi-api-key"; +const ANTHROPIC_API_KEY_SLUG = "anthropic-api"; + +/** Connection slug used for an API-key candidate (Anthropic has its own). */ +function apiKeySlugFor(provider: string): string { + return provider === "anthropic" ? ANTHROPIC_API_KEY_SLUG : PI_API_KEY_SLUG; +} + +/** Make a connection the global default (retries must repoint the default). */ +async function setDefaultConnection(page: Page, slug: string): Promise { + const result = await page.evaluate(async (targetSlug) => { + const api = ( + window as unknown as { + electronAPI: { + setDefaultLlmConnection(slug: string): Promise<{ + success: boolean; + error?: string; + }>; + }; + } + ).electronAPI; + return api.setDefaultLlmConnection(targetSlug); + }, slug); + if (!result.success) { + throw new Error( + `Failed to set default LLM connection to ${slug}: ${result.error ?? "unknown error"}`, + ); + } +} + +/** + * Programmatic API-key connection setup (re-runnable on fallback retries, + * unlike the one-shot onboarding wizard): create/update the connection with + * the candidate's key + provider + model, make it the default, then reload + * into the ready shell. Mirrors what the wizard's pi_api_key / anthropic_api_key + * paths ultimately call (setupLlmConnection). + */ +async function configureApiKeyConnection( + page: Page, + candidate: AgentProviderCandidate, +): Promise { + if (!candidate.apiKey) { + throw new Error( + `API-key provider ${candidate.provider} has no key (${candidate.keySource}).`, + ); + } + const slug = apiKeySlugFor(candidate.provider); + // Keep the credential out of page.evaluate arguments: Playwright records + // those arguments in failure traces. The page-local binding returns it only + // when the authenticated renderer invokes the setup IPC. + const credentialBinding = `__kataE2eCredential_${candidate.index}_${Date.now()}`; + await page.exposeFunction(credentialBinding, () => candidate.apiKey!); + const setup = await page.evaluate( + async ({ targetSlug, defaultModel, piAuthProvider, credentialBinding }) => { + const api = ( + window as unknown as { + electronAPI: { + setupLlmConnection(setup: { + slug: string; + credential: string; + defaultModel: string; + models: string[]; + piAuthProvider: string; + modelSelectionMode: "userDefined3Tier"; + }): Promise<{ success: boolean; error?: string }>; + }; + } + ).electronAPI; + const credentialProvider = ( + window as unknown as Record Promise> + )[credentialBinding]; + if (!credentialProvider) { + throw new Error("Agent E2E setup: credential binding is unavailable."); + } + return api.setupLlmConnection({ + slug: targetSlug, + credential: await credentialProvider(), + defaultModel, + models: [defaultModel], + piAuthProvider, + modelSelectionMode: "userDefined3Tier", + }); + }, + { + targetSlug: slug, + defaultModel: candidate.model, + piAuthProvider: candidate.provider, + credentialBinding, + }, + ); + if (!setup.success) { + throw new Error( + `Provider connection setup failed for ${candidate.provider} (${candidate.keySource}): ${setup.error ?? "unknown error"}. See e2e/README.md.`, + ); + } + await setDefaultConnection(page, slug); + await page.reload({ waitUntil: "domcontentloaded" }); + await handleWorkspacePickerIfPresent(page); + await setAgentWorkingDirectory(page); + await expect(page.locator("#app-ready")).toBeVisible({ + timeout: E2E_TIMEOUTS.electronWindowMs, + }); +} + +/** + * Configure the app for one fallback-chain candidate, re-runnable for retries. + * OAuth candidates (openai-codex) prefer the existing codex OAuth credential + * and fall back to the provider API key when it is unavailable; api-key + * candidates configure programmatically. + */ +export async function configureAgentConnection( + page: Page, + candidate: AgentProviderCandidate, +): Promise { + if (candidate.auth !== "oauth") { + await configureApiKeyConnection(page, candidate); + return; + } + + const authStatus = await page.evaluate(async (connectionSlug) => { + const api = ( + window as unknown as { + electronAPI: { + getChatGptAuthStatus(slug: string): Promise<{ + authenticated: boolean; + expiresAt?: number; + hasRefreshToken?: boolean; + }>; + }; + } + ).electronAPI; + return api.getChatGptAuthStatus(connectionSlug); + }, CHATGPT_CONNECTION_SLUG); + + if (authStatus.authenticated) { + await completeConfiguredChatGptOnboarding(page, candidate.model); + // A retry may have pointed the default at an earlier api-key candidate; + // repoint it at the OAuth connection so new sessions use it. + await setDefaultConnection(page, CHATGPT_CONNECTION_SLUG); + return; + } + + if (candidate.apiKey) { + console.warn( + `[e2e][provider] chatgpt-plus OAuth credential is not available; falling back to ${candidate.keySource}`, + ); + await configureApiKeyConnection(page, candidate); + return; + } + + throw new Error( + `openai-codex OAuth is not authenticated (chatgpt-plus) and no ${candidate.keySource} fallback is set.`, + ); +} /** * Reuse the existing ChatGPT/Codex OAuth credential without opening a browser @@ -143,7 +246,10 @@ export async function completeConfiguredChatGptOnboarding( page: Page, model: string, ): Promise { - await waitForOnboardingWizard(page); + const shell = await waitForOnboardingOrReady(page); + if (shell === "workspace-picker") { + await handleWorkspacePickerIfPresent(page); + } const authStatus = await page.evaluate(async (connectionSlug) => { const api = ( diff --git a/e2e/src/flows/shell.ts b/e2e/src/flows/shell.ts index 2b896e5d..022d35a0 100644 --- a/e2e/src/flows/shell.ts +++ b/e2e/src/flows/shell.ts @@ -36,6 +36,29 @@ export async function waitForOnboardingWizard( await page.locator(ONBOARDING_SELECTOR).waitFor({ state: "visible", timeout: timeoutMs }); } +/** Wait for the onboarding wizard or a shell that has already completed setup. */ +export async function waitForOnboardingOrReady( + page: Page, + timeoutMs = E2E_TIMEOUTS.electronWindowMs, +): Promise<"onboarding" | "ready" | "workspace-picker"> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await page.locator(ONBOARDING_SELECTOR).isVisible().catch(() => false)) { + return "onboarding"; + } + if (await page.locator(APP_READY_SELECTOR).isVisible().catch(() => false)) { + return "ready"; + } + if (await isWorkspacePickerVisible(page)) { + return "workspace-picker"; + } + await delay(100); + } + throw new Error( + `E2E shell: onboarding or a ready shell did not become visible within ${timeoutMs}ms.`, + ); +} + export async function waitForAppReady( page: Page, timeoutMs = E2E_TIMEOUTS.electronWindowMs, diff --git a/e2e/src/harness/env.ts b/e2e/src/harness/env.ts index 96ef8703..aac81cd6 100644 --- a/e2e/src/harness/env.ts +++ b/e2e/src/harness/env.ts @@ -26,90 +26,180 @@ export function formatMissingPrerequisiteError( // Agent provider configuration (@agent tier) // ---------------------------------------------------------------------------- -export type AgentProvider = "anthropic" | "openai-codex"; +/** + * How a candidate authenticates. OAuth candidates reuse the existing codex + * OAuth credential (chatgpt-plus connection); api-key candidates enter a key. + */ +export type AgentAuthMode = "oauth" | "api-key"; -export interface AgentProviderConfig { - readonly provider: AgentProvider; +/** + * One entry in the agent provider fallback chain. The chain is read from + * KATA_E2E_AGENT_PROVIDER (+ KATA_E2E_AGENT_MODEL) plus numbered fallbacks + * KATA_E2E_AGENT_PROVIDER_02/MODEL_02, _03, ... so new fallback levels can be + * added to root .env without any code change. Tests walk the chain via + * runWithAgentProviderFallback and only fail after every option is exhausted. + */ +export interface AgentProviderCandidate { + /** 1-based position in the chain (1 = primary). */ + readonly index: number; + /** Provider id, e.g. openai-codex, opencode-go, openrouter, deepseek, anthropic. */ + readonly provider: string; readonly model: string; - /** API-key providers populate this value; OAuth providers leave it undefined. */ + readonly auth: AgentAuthMode; + /** API key for api-key candidates (and the openai-codex OAuth fallback). */ readonly apiKey?: string; + /** Human-readable credential source for logs and errors. */ + readonly keySource: string; + /** False when the candidate cannot run (missing key/model env). */ + readonly ready: boolean; + /** Why the candidate is not ready (empty when ready). */ + readonly readyReason: string; } -// This repo's root .env uses KATA_-prefixed key names; unprefixed names are -// also accepted for parity with the adoption guide and external setups. -function anthropicApiKey(): string | undefined { - return firstNonEmpty( - process.env.KATA_ANTHROPIC_API_KEY, - process.env.ANTHROPIC_API_KEY, - ); -} +/** Root .env key that carries the API key for each provider. */ +const PROVIDER_KEY_ENV: Record = { + "openai-codex": "KATA_OPENAI_API_KEY", + "opencode-go": "KATA_OPENCODE_GO_API_KEY", + "openrouter": "KATA_OPENROUTER_API_KEY", + "deepseek": "KATA_DEEPSEEK_API_KEY", + "anthropic": "KATA_ANTHROPIC_API_KEY", +}; -const ANTHROPIC_KEY_NAMES = "KATA_ANTHROPIC_API_KEY (or ANTHROPIC_API_KEY)"; -const DEFAULT_MODELS: Record = { +/** Providers that authenticate through the existing codex OAuth credential. */ +const OAUTH_PROVIDERS: ReadonlySet = new Set(["openai-codex"]); + +const DEFAULT_MODELS: Record = { anthropic: "claude-haiku-4-5-20251001", "openai-codex": "gpt-5.6-luna", }; -function resolveProvider(): AgentProvider { - const raw = firstNonEmpty(process.env.KATA_E2E_AGENT_PROVIDER)?.toLowerCase(); - if (raw === undefined) { - // The local E2E harness should use the existing subscription by default. - // Anthropic remains available as an explicit API-key override. - return "openai-codex"; +function keyNameForProvider(provider: string): string { + return PROVIDER_KEY_ENV[provider] ?? `KATA_*_API_KEY (unknown provider ${provider})`; +} + +/** Read a numbered fallback env var (e.g. KATA_E2E_AGENT_MODEL_03). */ +function numberedEnv(prefix: string, index: number): string | undefined { + return firstNonEmpty(process.env[`${prefix}_${String(index).padStart(2, "0")}`]); +} + +function buildCandidate(index: number, providerRaw: string, modelRaw: string | undefined): AgentProviderCandidate { + const provider = providerRaw.trim().toLowerCase(); + const keyEnv = PROVIDER_KEY_ENV[provider]; + if (!keyEnv) { + return { + index, + provider, + model: modelRaw?.trim() ?? "", + auth: "api-key", + keySource: keyNameForProvider(provider), + ready: false, + readyReason: `unknown provider; supported: ${Object.keys(PROVIDER_KEY_ENV).join(", ")}`, + }; + } + + const oauth = OAUTH_PROVIDERS.has(provider); + const apiKey = firstNonEmpty(process.env[keyEnv]); + const model = firstNonEmpty(modelRaw) ?? DEFAULT_MODELS[provider]; + + if (!model) { + return { + index, + provider, + model: "", + auth: oauth ? "oauth" : "api-key", + apiKey, + keySource: keyEnv, + ready: false, + readyReason: `KATA_E2E_AGENT_MODEL${index === 1 ? "" : `_${String(index).padStart(2, "0")}`} is not set`, + }; } - if (raw === "openai-codex") { - return raw; + + // OAuth providers prefer the existing credential; the API key is the + // fallback when the OAuth credential is unavailable at runtime. + if (oauth) { + return { + index, + provider, + model, + auth: "oauth", + apiKey, + keySource: "chatgpt-plus OAuth (codex harness)", + ready: true, + readyReason: "", + }; } - if (raw === "anthropic") { - return raw; + + if (!apiKey) { + return { + index, + provider, + model, + auth: "api-key", + keySource: keyEnv, + ready: false, + readyReason: `${keyEnv} is not set`, + }; } - throw new Error( - `Unknown KATA_E2E_AGENT_PROVIDER="${raw}". Supported values: openai-codex or anthropic (or omit for the ChatGPT OAuth default).`, - ); -} -function keyNameFor(provider: AgentProvider): string { - return provider === "openai-codex" - ? "existing ChatGPT OAuth credentials for the chatgpt-plus connection" - : ANTHROPIC_KEY_NAMES; + return { + index, + provider, + model, + auth: "api-key", + apiKey, + keySource: keyEnv, + ready: true, + readyReason: "", + }; } -function apiKeyFor(provider: AgentProvider): string | undefined { - return provider === "openai-codex" ? undefined : anthropicApiKey(); +/** + * The ordered agent provider fallback chain: the primary candidate from + * KATA_E2E_AGENT_PROVIDER / KATA_E2E_AGENT_MODEL (openai-codex default), then + * every numbered KATA_E2E_AGENT_PROVIDER_0N / KATA_E2E_AGENT_MODEL_0N pair up + * to the first gap. Unready candidates stay in the chain (skipped with a + * logged reason) so the exhausted error lists every option. + */ +export function readAgentProviderChain(): AgentProviderCandidate[] { + const chain: AgentProviderCandidate[] = []; + chain.push( + buildCandidate(1, process.env.KATA_E2E_AGENT_PROVIDER ?? "openai-codex", process.env.KATA_E2E_AGENT_MODEL), + ); + for (let index = 2; index <= 99; index++) { + const provider = numberedEnv("KATA_E2E_AGENT_PROVIDER", index); + if (!provider) break; + chain.push(buildCandidate(index, provider, numberedEnv("KATA_E2E_AGENT_MODEL", index))); + } + return chain; } -/** The `@agent` tier requires a real key or existing OAuth credential. */ +/** + * The @agent tier requires at least one ready credential in the fallback + * chain (codex OAuth and/or any KATA_*_API_KEY). Missing entries are listed + * so the failure names every option that is not configured. + */ export function readAgentProviderPrerequisite(): PrerequisiteResult { - const provider = resolveProvider(); - if (provider === "openai-codex") { + const chain = readAgentProviderChain(); + const ready = chain.find((candidate) => candidate.ready); + if (ready) { return { ok: true }; } - if (!apiKeyFor(provider)) { - return { ok: false, missing: [keyNameFor(provider)] }; - } - return { ok: true }; + return { + ok: false, + missing: chain.map((candidate) => + candidate.readyReason ? `${candidate.keySource} (${candidate.readyReason})` : candidate.keySource, + ), + }; } /** - * Resolve the agent provider, model, and credential source for the @agent tier. - * Provider defaults to openai-codex; model defaults per provider; both are - * overridable via KATA_E2E_AGENT_PROVIDER / KATA_E2E_AGENT_MODEL. The - * openai-codex path reuses the existing ChatGPT OAuth credential and never - * reads or asks for an API key. + * The primary (or first ready) candidate. Kept for callers that only need a + * single config; agent-requiring tests should use the full chain via + * {@link readAgentProviderChain} and {@link runWithAgentProviderFallback}. */ -export function readAgentProviderConfig(): AgentProviderConfig { - const provider = resolveProvider(); - const apiKey = apiKeyFor(provider); - if (provider !== "openai-codex" && !apiKey) { - throw new Error( - formatMissingPrerequisiteError("Agent provider config", [ - keyNameFor(provider), - ]), - ); - } - const model = - firstNonEmpty(process.env.KATA_E2E_AGENT_MODEL) ?? DEFAULT_MODELS[provider]; - return { provider, model, apiKey }; +export function readAgentProviderConfig(): AgentProviderCandidate { + const chain = readAgentProviderChain(); + return chain.find((candidate) => candidate.ready) ?? chain[0]; } export function readWorkerCount(): number { diff --git a/e2e/tests/agent/reply.spec.ts b/e2e/tests/agent/reply.spec.ts index 48f1c5c5..2ba9e564 100644 --- a/e2e/tests/agent/reply.spec.ts +++ b/e2e/tests/agent/reply.spec.ts @@ -1,27 +1,26 @@ import { E2E_TAGS } from "../../src/config/tags.ts"; import { E2E_TIMEOUTS } from "../../src/config/timeouts.ts"; import { + agentSuiteTimeoutMs, buildDeterministicAgentTurn, expectAssistantReply, + runWithAgentProviderFallback, selectModel, sendAgentPrompt, startNewSession, } from "../../src/flows/agentChat.ts"; -import { - completeApiKeyOnboarding, - completeConfiguredChatGptOnboarding, -} from "../../src/flows/onboarding.ts"; +import { configureAgentConnection } from "../../src/flows/onboarding.ts"; import { formatMissingPrerequisiteError, - readAgentProviderConfig, readAgentProviderPrerequisite, } from "../../src/harness/env.ts"; import { waitForAppReady } from "../../src/flows/shell.ts"; import { test } from "../../src/fixtures/testFixtures.ts"; -// Real provider + shared state: keep a single worker. Allow a longer per-test -// budget for onboarding + real provider round-trip. -test.describe.configure({ mode: "serial", timeout: E2E_TIMEOUTS.agentTestMs }); +// Real provider + shared state: keep a single worker. The suite timeout +// budgets one agent-test window per fallback candidate so a chain-wide walk +// can exhaust every option without hitting the describe timeout. +test.describe.configure({ mode: "serial", timeout: agentSuiteTimeoutMs() }); test.describe(`Agent reply ${E2E_TAGS.agent}`, () => { test("real provider connection returns a deterministic reply", async ({ @@ -36,23 +35,20 @@ test.describe(`Agent reply ${E2E_TAGS.agent}`, () => { ), ); } - const { model, provider } = readAgentProviderConfig(); - console.log( - `[e2e] Agent provider=${provider} model=${model} credential=${provider === "openai-codex" ? "chatgpt-plus OAuth" : "Anthropic API key"}`, - ); const page = appWindow; - if (provider === "openai-codex") { - await completeConfiguredChatGptOnboarding(page, model); - } else { - await completeApiKeyOnboarding(page); - } - await waitForAppReady(page); + // Walk the provider fallback chain: codex OAuth first, then every + // numbered .env fallback, until one completes a real turn. Only when all + // options are exhausted does this throw the aggregated loud error. + await runWithAgentProviderFallback(page, "Agent reply", async (candidate) => { + await configureAgentConnection(page, candidate); + await waitForAppReady(page); - const turn = buildDeterministicAgentTurn(); - await startNewSession(page); - await selectModel(page, model); - await sendAgentPrompt(page, turn.prompt); - await expectAssistantReply(page, turn, E2E_TIMEOUTS.agentReplyMs); + const turn = buildDeterministicAgentTurn(); + await startNewSession(page); + await selectModel(page, candidate.model); + await sendAgentPrompt(page, turn.prompt); + await expectAssistantReply(page, turn, E2E_TIMEOUTS.agentReplyMs); + }); }); }); diff --git a/e2e/tests/git/worktree-v2-fork.spec.ts b/e2e/tests/git/worktree-v2-fork.spec.ts new file mode 100644 index 00000000..fbc0b781 --- /dev/null +++ b/e2e/tests/git/worktree-v2-fork.spec.ts @@ -0,0 +1,215 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { E2E_TAGS } from "../../src/config/tags.ts"; +import { E2E_TIMEOUTS } from "../../src/config/timeouts.ts"; +import { + agentSuiteTimeoutMs, + buildDeterministicAgentTurn, + expectAssistantReply, + runWithAgentProviderFallback, + selectModel, + sendAgentPrompt, + startNewSession, +} from "../../src/flows/agentChat.ts"; +import { configureAgentConnection } from "../../src/flows/onboarding.ts"; +import { waitForAppReady } from "../../src/flows/shell.ts"; +import { + formatMissingPrerequisiteError, + readAgentProviderPrerequisite, +} from "../../src/harness/env.ts"; +import { + readManagedWorktreeSessions, + useRepositoryAsWorkspaceDefault, +} from "../../src/flows/gitWorkspace.ts"; +import { expect, test } from "../../src/fixtures/testFixtures.ts"; + +// Real provider + shared state. The source session agent is created on the +// first Send; the fork dialog then exercises the REAL surface: shared stays +// the default and works through the existing branch flow, and the isolated +// strategy is offered but typed-blocked (unsupported-provider) because no +// production provider adapter implements the strict cross-CWD native fork yet. +// The provider itself walks the full credential fallback chain. +process.env.KATA_FEATURE_GIT_WORKSPACE_V1 = "1"; +process.env.KATA_FEATURE_WORKTREE_V2 = "1"; + +async function git(cwd: string, ...args: string[]): Promise { + const { execFile } = await import("node:child_process"); + return await new Promise((resolve, reject) => { + execFile("git", args, { cwd, encoding: "utf8" }, (error, stdout) => { + if (error) reject(error); + else resolve(stdout.trim()); + }); + }); +} + +async function createRepository(): Promise { + const repository = await mkdtemp(join(tmpdir(), "kata-agents-fork-e2e-")); + await git(repository, "init", "-b", "main"); + await git(repository, "config", "user.name", "Kata E2E"); + await git(repository, "config", "user.email", "kata-e2e@example.com"); + await writeFile(join(repository, "README.md"), "# Fork fixture\n"); + await git(repository, "add", "README.md"); + await git(repository, "commit", "-m", "fixture: initial commit"); + return repository; +} + +interface ForkChildSession { + id: string; + name?: string; + workingDirectory?: string; + checkout?: { checkoutPath?: string } | null; +} + +async function readSessions(page: import("@playwright/test").Page): Promise< + Array<{ + id: string; + name?: string; + workingDirectory?: string; + checkout?: { checkoutPath?: string } | null; + }> +> { + return page.evaluate(async () => { + const api = (window as unknown as { + electronAPI: { + getSessions(): Promise< + Array<{ + id: string; + name?: string; + workingDirectory?: string; + checkout?: { checkoutPath?: string } | null; + }> + >; + }; + }).electronAPI; + return api.getSessions(); + }); +} + +async function readSourceSessionId(page: import("@playwright/test").Page): Promise { + const sessions = await readSessions(page); + if (sessions.length !== 1) { + throw new Error(`Expected exactly one session before branching, got ${sessions.length}.`); + } + return sessions[0]!.id; +} + +test.describe.configure({ mode: "serial", timeout: agentSuiteTimeoutMs() }); + +test.describe(`Worktree V2 conversation fork ${E2E_TAGS.worktreeV2}`, () => { + test("branches via the fork dialog with real credentials: shared default works, isolated is typed-blocked without a strict provider adapter @worktree-v2 fork", async ({ + appWindow, + }) => { + const prerequisite = readAgentProviderPrerequisite(); + if (!prerequisite.ok) { + throw new Error( + formatMissingPrerequisiteError( + "Worktree V2 conversation fork", + prerequisite.missing, + ), + ); + } + + const repository = await createRepository(); + try { + const page = appWindow; + // Real provider prologue through the fallback chain: codex OAuth first, + // then every numbered .env fallback. The turn creates the source agent. + await runWithAgentProviderFallback(page, "Worktree V2 conversation fork", async (candidate) => { + await configureAgentConnection(page, candidate); + await waitForAppReady(page); + await useRepositoryAsWorkspaceDefault(page, repository); + await startNewSession(page); + await selectModel(page, candidate.model); + + const turn = buildDeterministicAgentTurn(); + await sendAgentPrompt(page, turn.prompt); + await expectAssistantReply(page, turn, E2E_TIMEOUTS.agentReplyMs, { match: "contains" }); + }); + + // The reply can land before title generation finishes; branching requires + // a fully idle session (runtime-active blocker otherwise). + await expect + .poll( + async () => + page.evaluate(async () => { + const api = (window as unknown as { + electronAPI: { getSessions(): Promise> }; + }).electronAPI; + const sessions = await api.getSessions(); + return sessions.every((session) => !session.isProcessing); + }), + { timeout: 60_000 }, + ) + .toBe(true); + + const sourceSessionId = await readSourceSessionId(page); + const sourceCheckoutPath = ( + await readSessions(page) + )[0]?.workingDirectory; + expect(sourceCheckoutPath).toBeTruthy(); + + // Open the Branch action on the final assistant turn — Worktree V2 + // effective routes it through the fork dialog instead of the immediate + // shared branch. + await page.getByRole("button", { name: "Branch options" }).last().click(); + await page.getByRole("menuitem", { name: "Branch From This Message" }).click(); + const dialog = page.getByTestId("fork-dialog"); + await expect(dialog).toBeVisible(); + + // Shared is the default strategy and the preview renders the real source + // identity. Isolated is offered but disabled: no production provider + // adapter advertises the strict cross-CWD native fork, so the server + // returns the typed unsupported-provider blocker. + await expect(page.getByTestId("fork-strategy-shared")).toBeVisible(); + await expect(page.getByTestId("fork-strategy-isolated")).toBeVisible(); + await expect(page.getByTestId("fork-strategy-isolated")).toBeDisabled(); + await expect(dialog).toContainText("This provider can't establish an isolated fork yet."); + await expect(page.getByTestId("fork-loading")).toBeHidden({ timeout: 30_000 }); + await expect(dialog).toContainText("main"); + + // Confirm the shared strategy: the existing branch flow creates the + // shared child and navigates to it; no managed worktree is created. + await page.getByTestId("fork-confirm-button").click(); + await expect(page.getByTestId("fork-dialog")).toHaveCount(0, { timeout: 30_000 }); + await expect + .poll(async () => (await readSessions(page)).length, { timeout: 60_000 }) + .toBe(2); + expect(await readManagedWorktreeSessions(page)).toHaveLength(0); + + // The shared child mirrors the source checkout (same working directory) + // and the source stays untouched on main with a clean index. + const sessions = await readSessions(page); + const child = sessions.find((session) => session.id !== sourceSessionId); + expect(child).toBeDefined(); + expect(child?.workingDirectory).toBe(sourceCheckoutPath); + expect(await git(repository, "branch", "--show-current")).toBe("main"); + expect((await git(repository, "status", "--porcelain")).trim()).toBe(""); + + // Cleanup: deleting the shared child drops its owner without touching + // the source (no managed worktree exists to remove). + if (child) { + await page.evaluate(async (id) => { + const api = (window as unknown as { + electronAPI: { + deleteSession(id: string, options: { removeManagedWorktree: boolean }): Promise; + }; + }).electronAPI; + await api.deleteSession(id, { removeManagedWorktree: true }); + }, child.id); + await expect + .poll(async () => (await readSessions(page)).length, { timeout: 60_000 }) + .toBe(1); + } + expect(await git(repository, "branch", "--show-current")).toBe("main"); + } finally { + await rm(repository, { recursive: true, force: true }); + } + }); + + // The isolated strategy's full UI flow (preview facts, name edit, confirm, + // pending identity, first-Send establishment) is covered by unit tests with + // test-only doubles and becomes E2E-exercisable once a production provider + // adapter implements the strict cross-CWD native fork (credentialed UAT). +}); diff --git a/e2e/tests/git/worktree-v2-handoff.spec.ts b/e2e/tests/git/worktree-v2-handoff.spec.ts index e1c2c61e..fee366d8 100644 --- a/e2e/tests/git/worktree-v2-handoff.spec.ts +++ b/e2e/tests/git/worktree-v2-handoff.spec.ts @@ -5,34 +5,36 @@ import { join } from "node:path"; import { E2E_TAGS } from "../../src/config/tags.ts"; import { E2E_TIMEOUTS } from "../../src/config/timeouts.ts"; import { + agentSuiteTimeoutMs, buildDeterministicAgentTurn, expectAssistantReply, + runWithAgentProviderFallback, selectModel, sendAgentPrompt, startNewSession, } from "../../src/flows/agentChat.ts"; -import { - completeApiKeyOnboarding, - completeConfiguredChatGptOnboarding, -} from "../../src/flows/onboarding.ts"; +import { configureAgentConnection } from "../../src/flows/onboarding.ts"; import { waitForAppReady } from "../../src/flows/shell.ts"; import { formatMissingPrerequisiteError, - readAgentProviderConfig, readAgentProviderPrerequisite, } from "../../src/harness/env.ts"; import { - readManagedWorktreeSessions, useRepositoryAsWorkspaceDefault, } from "../../src/flows/gitWorkspace.ts"; import { expect, test } from "../../src/fixtures/testFixtures.ts"; -// The credential-free UI UAT seam (spec AC-15): the deterministic adapter lets -// the real Electron app exercise preview/confirm/recovery without claiming -// live provider continuity. Production adapters stay disabled without this. +// Real provider + shared state: the session agent is created on the first +// Send, and only then does the server resolve provider capabilities. The +// handoff control is offered only for adapters that prove safe execution-CWD +// rebinding — no production provider adapter implements that capability yet, +// so this spec asserts the REAL surface: a working provider turn with real +// credentials (walking the fallback chain), and the handoff control absent +// for the unsupported provider. The full handoff UI flow is unit-tested with +// test-only doubles and becomes E2E-exercisable once a production adapter +// proves the capability. process.env.KATA_FEATURE_GIT_WORKSPACE_V1 = "1"; process.env.KATA_FEATURE_WORKTREE_V2 = "1"; -process.env.KATA_HANDOFF_DETERMINISTIC_ADAPTER = "1"; async function git(cwd: string, ...args: string[]): Promise { const { execFile } = await import("node:child_process"); @@ -55,15 +57,10 @@ async function createRepository(): Promise { return repository; } -// Real provider + shared state: the session agent is created on the first -// Send, and only then does the server advertise handoff capability. This is -// the credential-backed UI UAT tier (spec AC-15) — it drives preview/confirm/ -// recovery with the real app; it does not claim live provider continuity for -// the handoff proof itself (the deterministic adapter covers that seam). -test.describe.configure({ mode: "serial", timeout: E2E_TIMEOUTS.agentTestMs }); +test.describe.configure({ mode: "serial", timeout: agentSuiteTimeoutMs() }); test.describe(`Worktree V2 handoff ${E2E_TAGS.worktreeV2}`, () => { - test("previews and confirms Hand off to new worktree, then commits the session binding @worktree-v2 handoff", async ({ + test("completes a real provider turn and keeps the handoff surface blocked for unsupported providers @worktree-v2 handoff", async ({ appWindow, }) => { const prerequisite = readAgentProviderPrerequisite(); @@ -75,31 +72,27 @@ test.describe(`Worktree V2 handoff ${E2E_TAGS.worktreeV2}`, () => { ), ); } - const { model, provider } = readAgentProviderConfig(); + const repository = await createRepository(); try { const page = appWindow; - if (provider === "openai-codex") { - await completeConfiguredChatGptOnboarding(page, model); - } else { - await completeApiKeyOnboarding(page); - } - await waitForAppReady(page); - await useRepositoryAsWorkspaceDefault(page, repository); - await startNewSession(page); - await selectModel(page, model); + // Real provider prologue through the fallback chain: codex OAuth first, + // then every numbered .env fallback. The turn creates the session agent. + await runWithAgentProviderFallback(page, "Worktree V2 handoff", async (candidate) => { + await configureAgentConnection(page, candidate); + await waitForAppReady(page); + await useRepositoryAsWorkspaceDefault(page, repository); + await startNewSession(page); + await selectModel(page, candidate.model); - // First Send creates the agent; the deterministic adapter seam arms the - // session's handoff capability. Wait for the reply so the runtime is - // idle (handoff requires quiescence). - const turn = buildDeterministicAgentTurn(); - await sendAgentPrompt(page, turn.prompt); - // The handoff flow only needs the turn to complete (quiescence), not an - // exact token echo, so match by containment within an assistant reply. - await expectAssistantReply(page, turn, E2E_TIMEOUTS.agentReplyMs, { match: "contains" }); + // The handoff flow only needs the turn to complete, not an exact + // token echo, so match by containment within an assistant reply. + const turn = buildDeterministicAgentTurn(); + await sendAgentPrompt(page, turn.prompt); + await expectAssistantReply(page, turn, E2E_TIMEOUTS.agentReplyMs, { match: "contains" }); + }); - // The reply can land before title generation finishes; handoff requires - // a fully idle session (runtime-active blocker otherwise). + // The reply can land before title generation finishes. await expect .poll( async () => @@ -114,36 +107,14 @@ test.describe(`Worktree V2 handoff ${E2E_TAGS.worktreeV2}`, () => { ) .toBe(true); - // Open the Changes panel — the handoff action lives there. + // The handoff control is NOT offered: no production provider adapter + // advertises safe execution-CWD rebinding, so the real surface is the + // typed-blocked state (unit-tested contract; live UI asserts absence). await page.getByTestId("git-changes-affordance").click(); - await page.getByTestId("handoff-open-button").waitFor({ timeout: 30_000 }); - await page.getByTestId("handoff-open-button").click(); - await page.getByTestId("handoff-direction-current-to-managed").click(); + await page.getByTestId("git-changes-panel").waitFor({ timeout: 30_000 }); + await expect(page.getByTestId("handoff-open-button")).toHaveCount(0); - // Preview surfaces the exact source identity and lets the name be edited. - const dialog = page.getByTestId("handoff-dialog"); - await expect(dialog).toBeVisible(); - await expect(page.getByTestId("handoff-loading")).toBeHidden({ timeout: 30_000 }); - await expect(dialog).toContainText("main"); - await expect(dialog).toContainText("Recovery behavior"); - const nameInput = page.getByTestId("handoff-name-input"); - await nameInput.fill("e2e-handoff"); - await expect(page.getByTestId("handoff-confirm-button")).toBeEnabled({ timeout: 30_000 }); - await page.getByTestId("handoff-confirm-button").click(); - - // UI UAT outcome: the durable binding commits — the session owns a - // managed worktree on the named branch, the composer rebinds to it, the - // Changes panel shows the new checkout, and the current checkout is back - // on the original branch with nothing transferred left behind. - await expect - .poll(async () => (await readManagedWorktreeSessions(page)).length, { timeout: 60_000 }) - .toBe(1); - const managed = (await readManagedWorktreeSessions(page))[0]; - expect(managed?.checkout?.expectedBranch).toBe("kata-agent/e2e-handoff"); - await expect(page.getByTestId("git-changes-panel")).toContainText( - "kata-agent/e2e-handoff", - { timeout: 30_000 }, - ); + // The provider turn itself is untouched: repo on main, clean index. expect(await git(repository, "branch", "--show-current")).toBe("main"); expect((await git(repository, "status", "--porcelain")).trim()).toBe(""); } finally { @@ -151,8 +122,8 @@ test.describe(`Worktree V2 handoff ${E2E_TAGS.worktreeV2}`, () => { } }); - // AC-1 (controls appear only for capable providers) is covered by unit - // tests (handoff-capability gate, HandoffButton gating, handoff-controls - // state machine); the unsupported-provider path stays a unit-tested - // contract rather than a live UI assertion. + // The handoff preview/confirm UI flow (direction choice, preview, name + // edit, committed binding, recovery) is covered by unit tests with + // test-only doubles and becomes E2E-exercisable once a production provider + // adapter proves safe execution-CWD rebinding (credentialed UAT). }); diff --git a/packages/server-core/src/git/__tests__/fork-orphan-ledger.test.ts b/packages/server-core/src/git/__tests__/fork-orphan-ledger.test.ts new file mode 100644 index 00000000..92bfc42d --- /dev/null +++ b/packages/server-core/src/git/__tests__/fork-orphan-ledger.test.ts @@ -0,0 +1,177 @@ +import { describe, test, expect, afterEach } from 'bun:test' +import { appendFileSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { makeTmpDir, cleanup } from './test-helpers' +import { ForkOrphanLedger } from '../fork-orphan-ledger' + +const cleanups: string[] = [] +function tmp(): string { + const dir = makeTmpDir('kata-orphan-ledger-') + cleanups.push(dir) + return dir +} +afterEach(() => { + while (cleanups.length) cleanup(cleanups.pop()!) +}) + +function makeLedger(): ForkOrphanLedger { + return new ForkOrphanLedger(join(tmp(), 'fork-orphan-ledger.jsonl')) +} + +describe('ForkOrphanLedger reconcile', () => { + test('retires a ledger entry with an append-only resolution marker when its transaction later establishes', () => { + const ledger = makeLedger() + const entry = ledger.recordAttempt({ + transactionId: 'a'.repeat(16), + idempotencyKey: 'key-1', + parentSdkSessionId: 'parent', + parentSdkTurnId: 'turn-1', + executionCwd: '/wt/child', + result: 'failed', + }) + expect(ledger.entries()).toHaveLength(1) + + const report = ledger.reconcile({ isEstablished: (txId) => txId === 'a'.repeat(16) }) + + expect(report).toEqual({ resolved: 1, retained: 0, expiredUnresolved: 0, expiredAttemptIds: [] }) + // Default entries() skips the resolved attempt (never rewritten/deleted). + expect(ledger.entries()).toHaveLength(0) + // The raw file gained exactly one resolution marker line after the attempt. + const raw = readFileSync(ledger.getLedgerPath(), 'utf8') + const lines = raw.trim().split('\n') + expect(lines).toHaveLength(2) + expect(JSON.parse(lines[0]!)).toMatchObject({ attemptId: entry.attemptId, transactionId: 'a'.repeat(16) }) + expect(JSON.parse(lines[1]!)).toMatchObject({ + type: 'resolution', + attemptId: entry.attemptId, + result: 'resolved', + resolvedAt: expect.any(Number), + }) + // includeResolved exposes the original attempt alongside the marker. + expect(ledger.entries({ includeResolved: true })).toHaveLength(1) + }) + + test('resolves only entries whose transaction established; unrelated entries stay', () => { + const ledger = makeLedger() + ledger.recordAttempt({ + transactionId: 'txn-established', + idempotencyKey: 'k1', + parentSdkSessionId: 'p', + parentSdkTurnId: 't', + executionCwd: '/a', + result: 'failed', + }) + ledger.recordAttempt({ + transactionId: 'txn-unrelated', + idempotencyKey: 'k2', + parentSdkSessionId: 'p', + parentSdkTurnId: 't', + executionCwd: '/b', + result: 'failed', + }) + + const report = ledger.reconcile({ isEstablished: (txId) => txId === 'txn-established' }) + + expect(report).toEqual({ resolved: 1, retained: 1, expiredUnresolved: 0, expiredAttemptIds: [] }) + const remaining = ledger.entries() + expect(remaining).toHaveLength(1) + expect(remaining[0]!.transactionId).toBe('txn-unrelated') + expect(remaining[0]!.idempotencyKey).toBe('k2') + }) + + test('respects the retention window: stale unresolved entries are surfaced, never auto-deleted', () => { + const ledger = makeLedger() + const now = Date.now() + // A stale attempt appended directly (the ledger treats it as a normal line). + appendFileSync( + ledger.getLedgerPath(), + `${JSON.stringify({ + attemptId: 'old-attempt', + transactionId: 'old', + idempotencyKey: 'k-old', + parentSdkSessionId: 'p', + parentSdkTurnId: 't', + executionCwd: '/old', + attemptedAt: now - 2 * 24 * 60 * 60 * 1000, + result: 'failed', + })}\n`, + 'utf8', + ) + const fresh = ledger.recordAttempt({ + transactionId: 'fresh', + idempotencyKey: 'k-fresh', + parentSdkSessionId: 'p', + parentSdkTurnId: 't', + executionCwd: '/fresh', + result: 'unverified', + }) + + const report = ledger.reconcile({ isEstablished: () => false, now, retentionMs: 24 * 60 * 60 * 1000 }) + + // The stale attempt is surfaced (not resolved, not deleted); the fresh one + // is retained within the window. The file gained no lines at all. + expect(report).toEqual({ + resolved: 0, + retained: 1, + expiredUnresolved: 1, + expiredAttemptIds: ['old-attempt'], + }) + const raw = readFileSync(ledger.getLedgerPath(), 'utf8') + expect(raw.trim().split('\n')).toHaveLength(2) + // The stale entry is still listed (never auto-deleted). + expect(ledger.entries().map((e) => e.attemptId).sort()).toEqual([fresh.attemptId, 'old-attempt'].sort()) + expect(ledger.entries().find((e) => e.attemptId === 'old-attempt')?.attemptedAt).toBe(now - 2 * 24 * 60 * 60 * 1000) + }) + + test('reconcile only annotates; it never attaches an orphan to a session binding', () => { + const ledger = makeLedger() + ledger.recordAttempt({ + transactionId: 'txn', + idempotencyKey: 'k', + parentSdkSessionId: 'p', + parentSdkTurnId: 't', + executionCwd: '/wt', + result: 'failed', + }) + const before = readFileSync(ledger.getLedgerPath(), 'utf8') + + const report = ledger.reconcile({ isEstablished: (txId) => txId === 'txn' }) + + const after = readFileSync(ledger.getLedgerPath(), 'utf8') + // The original attempt line is byte-for-byte untouched; only a marker was + // appended. No session id / binding was written into the entry. + expect(after.startsWith(before)).toBe(true) + expect(after.trim().split('\n')).toHaveLength(2) + expect(report.resolved).toBe(1) + const original = ledger.entries({ includeResolved: true })[0]! + expect(original).toMatchObject({ + transactionId: 'txn', + idempotencyKey: 'k', + parentSdkSessionId: 'p', + parentSdkTurnId: 't', + executionCwd: '/wt', + result: 'failed', + }) + expect(original).not.toHaveProperty('childSdkSessionId') + expect(original).not.toHaveProperty('boundSessionId') + }) + + test('reconcile is idempotent: a second run does not re-resolve or re-append', () => { + const ledger = makeLedger() + ledger.recordAttempt({ + transactionId: 'idem', + idempotencyKey: 'k', + parentSdkSessionId: 'p', + parentSdkTurnId: 't', + executionCwd: '/idem', + result: 'failed', + }) + + const first = ledger.reconcile({ isEstablished: (txId) => txId === 'idem' }) + const second = ledger.reconcile({ isEstablished: (txId) => txId === 'idem' }) + + expect(first.resolved).toBe(1) + expect(second).toEqual({ resolved: 0, retained: 0, expiredUnresolved: 0, expiredAttemptIds: [] }) + expect(readFileSync(ledger.getLedgerPath(), 'utf8').trim().split('\n')).toHaveLength(2) + }) +}) diff --git a/packages/server-core/src/git/__tests__/handoff-runtime-gate.test.ts b/packages/server-core/src/git/__tests__/handoff-runtime-gate.test.ts index e031859f..b35bb1e8 100644 --- a/packages/server-core/src/git/__tests__/handoff-runtime-gate.test.ts +++ b/packages/server-core/src/git/__tests__/handoff-runtime-gate.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, afterEach, beforeEach } from 'bun:test' import { mkdirSync } from 'node:fs' import { join } from 'node:path' -import { createDeterministicHandoffAdapter } from '@kata-sh/shared/agent/backend' +import { createDeterministicHandoffAdapter } from '@kata-sh/shared/agent/testing' import { loadSession as loadStoredSession } from '@kata-sh/shared/sessions' import { setupI18n } from '@kata-sh/shared/i18n/setupI18n' import { SessionManager, createManagedSession } from '../../sessions/SessionManager' diff --git a/packages/server-core/src/git/__tests__/isolated-conversation-fork.test.ts b/packages/server-core/src/git/__tests__/isolated-conversation-fork.test.ts new file mode 100644 index 00000000..c8dd12e8 --- /dev/null +++ b/packages/server-core/src/git/__tests__/isolated-conversation-fork.test.ts @@ -0,0 +1,2181 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { existsSync, mkdirSync, realpathSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { createGitServices } from '../index' +import type { GitServices } from '../index' +import type { + ConversationForkPreview, + ConversationForkPreviewInput, + ConversationForkStrategy, + SessionCheckout, +} from '@kata-sh/shared/protocol' +import type { StrictConversationForkCapability } from '@kata-sh/shared/agent/backend' +import { createDeterministicStrictForkAdapter } from '@kata-sh/shared/agent/testing' +import { resolveIsolatedForkCapability } from '@kata-sh/shared/agent/backend' +import { initRepo, makeTmpDir, cleanup, git, writeFile, runGit } from './test-helpers' +import { + WORKTREE_SNAPSHOT_REF_PREFIX, + computeWorktreeFingerprint, +} from '../worktree-snapshot-service' +import { ConversationForkError, type ConversationForkChildSessionInput } from '../isolated-conversation-fork-service' + +const cleanups: string[] = [] +function tmp(): string { + const dir = makeTmpDir('kata-fork-test-') + cleanups.push(dir) + return dir +} +afterEach(() => { + while (cleanups.length) cleanup(cleanups.pop()!) +}) + +interface SessionFixture { + checkoutPath: string + workspaceId: string + checkout: SessionCheckout | null + transcriptCwd: string + conversationHead: { messageId: string; turnId: string } + sdkSessionId?: string + forkPointMessageId?: string + forkPointTurnId?: string +} + +interface Harness { + root: string + repo: string + svc: GitServices + sessions: Map + adapters: Map + activeSessions: Set + childCalls: ConversationForkChildSessionInput[] + deletedChildren: string[] + failChildCreation: boolean +} + +let previousV1: string | undefined +let previousV2: string | undefined +let harness: Harness + +function makeHarness(): Harness { + const root = tmp() + const repo = join(root, 'repo') + const sessions = new Map() + const adapters = new Map() + const activeSessions = new Set() + const childCalls: ConversationForkChildSessionInput[] = [] + const deletedChildren: string[] = [] + const state = { failChildCreation: false } + const svc = createGitServices({ + worktreeRoot: join(root, 'worktrees'), + registryPath: join(root, 'worktrees', 'registry.json'), + snapshotsRoot: join(root, 'snapshots'), + lockDirectory: join(root, 'locks'), + forkHooks: { + resolveSession: (sessionId) => sessions.get(sessionId) ?? null, + // Mirrors production wiring (SessionManager): the advertised capability + // is the strict-fork gate's output, never the raw adapter. + resolveCapability: (sessionId) => { + const adapter = adapters.get(sessionId) ?? null + if (!adapter) return null + const resolution = resolveIsolatedForkCapability({ conversationFork: adapter }) + return resolution.supported ? resolution.capability : null + }, + resolveCapabilityAdapter: (sessionId) => adapters.get(sessionId) ?? null, + isSessionActive: (sessionId) => activeSessions.has(sessionId), + quiesceRuntimes: async (ids) => { + for (const id of ids) activeSessions.delete(id) + return true + }, + // SessionManager implements durable child creation in a later phase; + // this stub records the call and returns a fake child session id. + createForkChildSession: async (input) => { + childCalls.push(input) + if (state.failChildCreation) throw new Error('simulated child-session creation failure') + return `child-${childCalls.length}` + }, + deleteForkChildSession: async (childSessionId) => { + deletedChildren.push(childSessionId) + }, + }, + }) + svc.worktreeSettings.update({ + materializationRoot: join(root, 'worktrees'), + autoDeleteEnabled: false, + retentionLimit: 15, + }) + return { + root, + repo, + svc, + sessions, + adapters, + activeSessions, + childCalls, + deletedChildren, + get failChildCreation() { + return state.failChildCreation + }, + set failChildCreation(v: boolean) { + state.failChildCreation = v + }, + } +} + +beforeEach(async () => { + previousV1 = process.env.KATA_FEATURE_GIT_WORKSPACE_V1 + previousV2 = process.env.KATA_FEATURE_WORKTREE_V2 + process.env.KATA_FEATURE_GIT_WORKSPACE_V1 = '1' + process.env.KATA_FEATURE_WORKTREE_V2 = '1' + harness = makeHarness() + await initRepo(harness.repo) + harness.svc.lifecycle.markReady() + harness.adapters.set('session-1', createDeterministicStrictForkAdapter({ adapterId: 'pi-test' })) +}) + +afterEach(() => { + if (previousV1 === undefined) delete process.env.KATA_FEATURE_GIT_WORKSPACE_V1 + else process.env.KATA_FEATURE_GIT_WORKSPACE_V1 = previousV1 + if (previousV2 === undefined) delete process.env.KATA_FEATURE_WORKTREE_V2 + else process.env.KATA_FEATURE_WORKTREE_V2 = previousV2 +}) + +function currentSession(overrides: Partial = {}): SessionFixture { + const fixture: SessionFixture = { + checkoutPath: harness.repo, + workspaceId: 'ws1', + checkout: null, + transcriptCwd: join(harness.repo, '.kata-transcript'), + conversationHead: { messageId: 'msg-1', turnId: 'turn-1' }, + sdkSessionId: 'sdk-parent-1', + ...overrides, + } + harness.sessions.set('session-1', fixture) + // Mirror startup reconciliation: every live session leases its canonical + // (git-resolved) checkout root so lifecycle decisions see the full fence set. + harness.svc.pathLeases.lease('session-1', realpathSync(fixture.checkoutPath)) + return fixture +} + +async function preview( + strategy: ConversationForkStrategy, + nameSuffix?: string, +): Promise { + // Pass the suffix even when empty: the renderer clearing the field sends an + // explicit empty string, which the server must reject as invalid-name. + const input: ConversationForkPreviewInput = { sessionId: 'session-1', strategy } + if (nameSuffix !== undefined) input.worktreeNameSuffix = nameSuffix + return harness.svc.fork.preview(input) +} + +describe('IsolatedConversationForkService preview', () => { + test('returns a typed blocked preview (never throws) when no strict fork capability is advertised', async () => { + currentSession() + harness.adapters.set('session-1', null) + + const p = await preview('isolated-worktree', 'demo') + expect(p.blocked?.blocked).toBe(true) + expect(p.blocked?.code).toBe('unsupported-provider') + expect(p.strategy).toBe('isolated-worktree') + expect(p.currentHead).toBe(true) + }) + + test('returns a typed blocked preview for an adapter that advertises but lacks the establish surface', async () => { + currentSession() + // Capability DTO advertises true, but the adapter is structurally + // incomplete (no establishNativeFork) — the gate must still block. + harness.adapters.set('session-1', { + adapterId: 'pi-test', + forkCapability: () => ({ adapterId: 'pi-test', strictCrossCwdNativeFork: true }), + } as StrictConversationForkCapability) + + const p = await preview('isolated-worktree', 'demo') + expect(p.blocked?.blocked).toBe(true) + expect(p.blocked?.code).toBe('unsupported-provider') + }) + + test('returns a typed blocked preview for an adapter advertising strictCrossCwdNativeFork false', async () => { + currentSession() + harness.adapters.set('session-1', { + adapterId: 'pi-test', + forkCapability: () => ({ adapterId: 'pi-test', strictCrossCwdNativeFork: false }), + establishNativeFork: async () => ({ + childSdkSessionId: 'sdk-child', + proof: { adapterId: 'pi-test', destinationPath: '', verifiedAt: 0, checks: [] }, + }), + } as StrictConversationForkCapability) + + const p = await preview('isolated-worktree', 'demo') + expect(p.blocked?.blocked).toBe(true) + expect(p.blocked?.code).toBe('unsupported-provider') + }) + + test('returns a valid isolated preview with capability, currentHead, and bound fingerprint for an eligible current-checkout source', async () => { + currentSession() + + const p = await preview('isolated-worktree', 'demo') + expect(p.blocked).toBeUndefined() + expect(p.strategy).toBe('isolated-worktree') + expect(p.currentHead).toBe(true) + expect(p.providerCapability).toEqual({ adapterId: 'pi-test', strictCrossCwdNativeFork: true }) + expect(p.source.sessionId).toBe('session-1') + expect(p.source.conversationHeadMessageId).toBe('msg-1') + expect(p.source.conversationHeadTurnId).toBe('turn-1') + expect(p.source.checkout.mode).toBe('current') + expect(p.source.branch).toBe('main') + expect(p.source.headSha).toMatch(/^[0-9a-f]{40}$/) + expect(p.source.leases).toContain('session-1') + expect(p.destination.branch).toBe('kata-agent/demo') + expect(p.destination.serverId).toBe('local') + expect(p.destination.exists).toBe(false) + expect(p.excludedIgnoredPolicy.includeOnly).toBe(true) + expect(p.previewFingerprint).toMatch(/^[0-9a-f]{64}$/) + expect(p.transactionId).toMatch(/^[0-9a-f]{16}$/) + }) + + test('shared-worktree preview is unblocked without a provider capability and reports the shared checkout as destination', async () => { + currentSession() + harness.adapters.set('session-1', null) + + const p = await preview('shared-worktree') + expect(p.blocked).toBeUndefined() + expect(p.strategy).toBe('shared-worktree') + expect(p.destination.branch).toBe('main') + expect(p.destination.exists).toBe(true) + expect(p.destination.checkoutPath).toBe(realpathSync(harness.repo)) + }) + + test('registers a preview transaction only for the isolated strategy; a re-preview supersedes it', async () => { + currentSession() + + const isolated = await preview('isolated-worktree', 'txn-demo') + expect(isolated.blocked).toBeUndefined() + expect(harness.svc.fork.isSessionFenced('session-1')).toBe(true) + + // A fresh preview of either strategy supersedes the stale pending + // preview-only transaction (it has never mutated anything). + const shared = await preview('shared-worktree') + expect(shared.blocked).toBeUndefined() + expect(harness.svc.fork.isSessionFenced('session-1')).toBe(false) + + const again = await preview('isolated-worktree', 'txn-demo') + expect(again.blocked).toBeUndefined() + expect(harness.svc.fork.isSessionFenced('session-1')).toBe(true) + }) + + test('blocks isolated for an older fork point (non-head-source); shared stays available', async () => { + currentSession() + harness.sessions.set('session-1', { + ...harness.sessions.get('session-1')!, + forkPointMessageId: 'msg-0', + forkPointTurnId: 'turn-0', + }) + + const p = await preview('isolated-worktree', 'demo') + expect(p.blocked?.blocked).toBe(true) + expect(p.blocked?.code).toBe('non-head-source') + expect(p.currentHead).toBe(false) + + // Shared branching remains the historical-conversation-point path. + const shared = await preview('shared-worktree') + expect(shared.blocked).toBeUndefined() + expect(shared.currentHead).toBe(false) + }) + + test('blocks isolated when the parent provider session or turn anchor is missing', async () => { + currentSession({ + sdkSessionId: '', + conversationHead: { messageId: 'msg-1', turnId: '' }, + }) + + const p = await preview('isolated-worktree', 'missing-anchor') + expect(p.blocked?.blocked).toBe(true) + expect(p.blocked?.code).toBe('missing-parent-anchor') + expect(p.blocked?.reason).toBe('missing-parent-anchor') + expect(harness.childCalls).toHaveLength(0) + }) + + test('blocks when any source owner has an active turn (source-active)', async () => { + currentSession() + harness.activeSessions.add('session-1') + + const p = await preview('isolated-worktree', 'demo') + expect(p.blocked?.code).toBe('source-active') + }) + + test('blocks when a foreign session leases the source path (path-unleased)', async () => { + currentSession() + harness.svc.pathLeases.lease('foreign-session', realpathSync(harness.repo)) + + const p = await preview('isolated-worktree', 'demo') + expect(p.blocked?.code).toBe('path-unleased') + }) + + test('blocks an invalid name suffix (invalid-name)', async () => { + currentSession() + + const p = await preview('isolated-worktree', 'bad name!') + expect(p.blocked?.code).toBe('invalid-name') + + const empty = await preview('isolated-worktree', '') + expect(empty.blocked?.code).toBe('invalid-name') + }) + + test('blocks a colliding branch or occupied destination (name-collision)', async () => { + currentSession() + await git(harness.repo, ['branch', 'kata-agent/taken']) + + const p = await preview('isolated-worktree', 'taken') + expect(p.blocked?.code).toBe('name-collision') + }) + + test('blocks when feature flags are disabled (flags-disabled)', async () => { + currentSession() + const previousV2 = process.env.KATA_FEATURE_WORKTREE_V2 + process.env.KATA_FEATURE_WORKTREE_V2 = '0' + try { + const p = await preview('isolated-worktree', 'demo') + expect(p.blocked?.code).toBe('flags-disabled') + } finally { + if (previousV2 === undefined) delete process.env.KATA_FEATURE_WORKTREE_V2 + else process.env.KATA_FEATURE_WORKTREE_V2 = previousV2 + } + }) + + test('blocks while lifecycle cleanup is in progress (cleanup-in-progress)', async () => { + currentSession() + ;(harness.svc.lifecycle as unknown as { sweepRunning: object }).sweepRunning = {} + + const p = await preview('isolated-worktree', 'demo') + expect(p.blocked?.code).toBe('cleanup-in-progress') + }) + + test('blocks while a pending fork journal transaction exists (fork-in-progress)', async () => { + currentSession() + + const first = await preview('isolated-worktree', 'demo') + expect(first.blocked).toBeUndefined() + // A restarted server instance reads the SAME journal file: the in-memory + // transaction is gone but the durable 'fork' entry is still pending. + const fresh = createGitServices({ + worktreeRoot: join(harness.root, 'worktrees'), + registryPath: join(harness.root, 'worktrees', 'registry.json'), + snapshotsRoot: join(harness.root, 'snapshots'), + lockDirectory: join(harness.root, 'locks'), + forkHooks: { + resolveSession: (sessionId) => harness.sessions.get(sessionId) ?? null, + resolveCapability: () => ({ adapterId: 'pi-test', strictCrossCwdNativeFork: true }), + resolveCapabilityAdapter: () => createDeterministicStrictForkAdapter({ adapterId: 'pi-test' }), + isSessionActive: () => false, + }, + }) + fresh.lifecycle.markReady() + + const p = await fresh.fork.preview({ sessionId: 'session-1', strategy: 'isolated-worktree', worktreeNameSuffix: 'demo' }) + expect(p.blocked?.code).toBe('fork-in-progress') + }) + + test('returns a typed blocked preview for an unknown session (missing-source)', async () => { + const p = await harness.svc.fork.preview({ sessionId: 'ghost', strategy: 'isolated-worktree', worktreeNameSuffix: 'demo' }) + expect(p.blocked?.blocked).toBe(true) + expect(p.blocked?.code).toBe('missing-source') + }) + + test('blocks a snapshotted/missing managed source (missing-source)', async () => { + const record = await managedSession('snap-src') + // Snapshotted: the record leaves `ready` and its checkout is released. + harness.svc.registry.setState(record.managedWorktreeId, 'snapshotted') + rmSync(record.checkoutPath, { recursive: true, force: true }) + + const p = await preview('isolated-worktree', 'demo') + expect(p.blocked?.code).toBe('missing-source') + }) + + test('blocks an unmerged index (git-operation-in-progress)', async () => { + currentSession() + // A real merge conflict leaves an unmerged index and operation metadata. + writeFile(harness.repo, 'conflict.txt', 'base\n') + await git(harness.repo, ['add', 'conflict.txt']) + await git(harness.repo, ['commit', '-m', 'base']) + await git(harness.repo, ['switch', '-c', 'side']) + writeFile(harness.repo, 'conflict.txt', 'side\n') + await git(harness.repo, ['add', 'conflict.txt']) + await git(harness.repo, ['commit', '-m', 'side']) + await git(harness.repo, ['switch', 'main']) + writeFile(harness.repo, 'conflict.txt', 'main\n') + await git(harness.repo, ['add', 'conflict.txt']) + await git(harness.repo, ['commit', '-m', 'main']) + await runGit(['merge', 'side'], { cwd: harness.repo, okExitCodes: [1] }) + + const p = await preview('isolated-worktree', 'demo') + expect(p.blocked?.code).toBe('git-operation-in-progress') + }) + + test('blocks a detached HEAD source (unsupported-snapshot)', async () => { + currentSession() + await git(harness.repo, ['checkout', '--detach', 'HEAD']) + + const p = await preview('isolated-worktree', 'demo') + expect(p.blocked?.code).toBe('unsupported-snapshot') + }) + + test('shared strategy stays available for a detached HEAD source (no seed is captured)', async () => { + currentSession() + await git(harness.repo, ['checkout', '--detach', 'HEAD']) + + const p = await preview('shared-worktree') + expect(p.blocked).toBeUndefined() + expect(p.strategy).toBe('shared-worktree') + }) + + test('blocks an oversized source state (oversized-capture)', async () => { + currentSession() + const big = 'x'.repeat(1024 * 1024) + for (let i = 0; i < 150; i++) writeFile(harness.repo, `bulk/file-${i}.bin`, `${i}:${big}`) + + const p = await preview('isolated-worktree', 'demo') + expect(p.blocked?.code).toBe('oversized-capture') + }) +}) + +/** Bind session-1 to a newly created managed worktree (V2 named record). */ +async function managedSession(name = 'demo'): Promise>['record']> { + const gitCommonDir = (await git(harness.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim() + const { record } = await harness.svc.worktrees.createWorktree({ + workspaceId: 'ws1', + sessionId: 'session-1', + repositoryRoot: harness.repo, + gitCommonDir, + baseRef: 'main', + worktreeNameSuffix: name, + }) + if (record.schemaVersion !== 2) throw new Error('expected a V2 named record') + const checkout: SessionCheckout = { + schemaVersion: 2, + mode: 'managed-worktree', + repositoryRoot: record.repositoryRoot, + checkoutPath: record.checkoutPath, + branchAtPreparation: record.expectedBranch, + baseRef: record.baseRef, + managedWorktreeId: record.managedWorktreeId, + displayName: record.displayName, + expectedBranch: record.expectedBranch, + materializationRoot: record.materializationRoot, + } + currentSession({ checkoutPath: record.checkoutPath, checkout }) + harness.svc.pathLeases.lease('session-1', record.checkoutPath) + return record +} + +describe('IsolatedConversationForkService managed/shared sources', () => { + test('previews an eligible single-owner managed source with the record owners leased', async () => { + await managedSession('src') + + const p = await preview('isolated-worktree', 'child') + expect(p.blocked).toBeUndefined() + expect(p.source.checkout.mode).toBe('managed-worktree') + expect(p.source.checkout.managedWorktreeId).toBeDefined() + expect(p.source.branch).toBe('kata-agent/src') + expect(p.source.leases).toContain('session-1') + expect(p.destination.branch).toBe('kata-agent/child') + }) + + test('requires every shared-source owner to be idle (source-active on a second owner)', async () => { + const record = await managedSession('shared-src') + harness.svc.worktrees.addOwner(record.managedWorktreeId, 'session-2') + harness.svc.pathLeases.lease('session-2', record.checkoutPath) + + // Both owners idle → eligible. + const ok = await preview('isolated-worktree', 'child') + expect(ok.blocked).toBeUndefined() + expect(ok.source.leases).toEqual(expect.arrayContaining(['session-1', 'session-2'])) + + // Second owner active → blocked. + harness.activeSessions.add('session-2') + const p = await preview('isolated-worktree', 'child') + expect(p.blocked?.code).toBe('source-active') + }) + + test('blocks a shared managed source with a foreign lease on the checkout (path-unleased)', async () => { + const record = await managedSession('shared-src-2') + harness.svc.worktrees.addOwner(record.managedWorktreeId, 'session-2') + harness.svc.pathLeases.lease('session-2', record.checkoutPath) + // A session outside the owner set occupies the checkout path. + harness.svc.pathLeases.lease('session-99', record.checkoutPath) + + const p = await preview('isolated-worktree', 'child') + expect(p.blocked?.code).toBe('path-unleased') + }) + + test('canonicalizes a current-checkout source with a nested working directory to the repository root', async () => { + const nested = join(harness.repo, 'nested', 'workdir') + const { mkdirSync } = await import('node:fs') + mkdirSync(nested, { recursive: true }) + currentSession({ checkoutPath: nested }) + harness.svc.pathLeases.lease('session-1', realpathSync(harness.repo)) + + const p = await preview('isolated-worktree', 'child') + expect(p.blocked).toBeUndefined() + expect(p.source.branch).toBe('main') + expect(p.destination.repositoryRoot).toBe(realpathSync(harness.repo)) + // The source lease set is evaluated at the canonical repository root. + expect(p.source.leases).toContain('session-1') + }) + + test('exposes currentHead false for an older fork point and blocks isolated', async () => { + await managedSession('head-src') + harness.sessions.set('session-1', { + ...harness.sessions.get('session-1')!, + forkPointMessageId: 'msg-0', + }) + + const p = await preview('isolated-worktree', 'child') + expect(p.blocked?.code).toBe('non-head-source') + expect(p.currentHead).toBe(false) + }) +}) + +async function dirtySource(): Promise<{ policyVersion: number; branch: string; headOid: string }> { + writeFile(harness.repo, 'staged.txt', 'staged\n') + await git(harness.repo, ['add', 'staged.txt']) + writeFile(harness.repo, 'unstaged.txt', 'unstaged\n') + writeFile(harness.repo, 'untracked.txt', 'untracked\n') + writeFile(harness.repo, '.gitignore', 'secret.txt\n') + writeFile(harness.repo, '.worktreeinclude', 'secret.txt\n') + writeFile(harness.repo, 'secret.txt', 'included-secret\n') + const branch = (await git(harness.repo, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() + const headOid = (await git(harness.repo, ['rev-parse', 'HEAD'])).trim() + return { policyVersion: harness.svc.worktreeSettings.getSnapshot('local').version, branch, headOid } +} + +describe('IsolatedConversationForkService seed capture', () => { + + test('captures a fingerprinted seed at the source head without changing the source', async () => { + currentSession() + const gitCommonDir = (await git(harness.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim() + const { policyVersion, branch, headOid } = await dirtySource() + const before = await computeWorktreeFingerprint({ + managedWorktreeId: 'seed-test', + checkoutPath: realpathSync(harness.repo), + gitCommonDir, + expectedBranch: branch, + baseRef: null, + ownerSessionIds: ['session-1'], + policyVersion, + archivedOwnerSessionIds: [], + }) + + const { snapshotId, fingerprint } = await harness.svc.fork.captureForkSeed({ + checkoutPath: realpathSync(harness.repo), + repositoryRoot: realpathSync(harness.repo), + gitCommonDir, + expectedBranch: branch, + baseRef: null, + ownerSessionIds: ['session-1'], + policyVersion, + previewFingerprint: 'fp-preview', + }) + + expect(snapshotId).toMatch(/^[0-9a-f]{16}$/) + expect(fingerprint).toMatch(/^[0-9a-f]{64}$/) + + // HEAD is pinned by the CAS-created hidden ref. + const pinned = (await git(harness.repo, ['rev-parse', '--verify', '--quiet', `${WORKTREE_SNAPSHOT_REF_PREFIX}${snapshotId}`])).trim() + expect(pinned).toBe(headOid) + + // The payload holds staged + unstaged + untracked + include content. + const manifest = JSON.parse(readFileSync(join(harness.root, 'snapshots', snapshotId, 'manifest.json'), 'utf8')) as { + headOid: string + files: Array<{ path: string }> + } + expect(manifest.headOid).toBe(headOid) + const paths = manifest.files.map((f) => f.path) + expect(paths).toEqual(expect.arrayContaining(['untracked.txt', 'secret.txt'])) + + // Source is byte-for-byte unchanged (staged + unstaged + untracked + + // included state identical after capture). + const after = await computeWorktreeFingerprint({ + managedWorktreeId: 'seed-test', + checkoutPath: realpathSync(harness.repo), + gitCommonDir, + expectedBranch: branch, + baseRef: null, + ownerSessionIds: ['session-1'], + policyVersion, + archivedOwnerSessionIds: [], + }) + expect(after).toBe(before) + }) + + test('removes a seed by deleting only its payload and owned hidden ref', async () => { + currentSession() + const gitCommonDir = (await git(harness.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim() + const { policyVersion, branch } = await dirtySource() + + const { snapshotId } = await harness.svc.fork.captureForkSeed({ + checkoutPath: realpathSync(harness.repo), + repositoryRoot: realpathSync(harness.repo), + gitCommonDir, + expectedBranch: branch, + baseRef: null, + ownerSessionIds: ['session-1'], + policyVersion, + previewFingerprint: 'fp-preview', + }) + expect(existsSync(join(harness.root, 'snapshots', snapshotId))).toBe(true) + + await harness.svc.fork.removeSeed(snapshotId, realpathSync(harness.repo)) + + expect(existsSync(join(harness.root, 'snapshots', snapshotId))).toBe(false) + const refStillThere = await runGit( + ['rev-parse', '--verify', '--quiet', `${WORKTREE_SNAPSHOT_REF_PREFIX}${snapshotId}`], + { cwd: harness.repo, okExitCodes: [1, 128] }, + ) + expect(refStillThere.exitCode).not.toBe(0) + // Source checkout survives seed removal unchanged. + expect((await git(harness.repo, ['status', '--porcelain'])).trim().length).toBeGreaterThan(0) + }) + + test('maps capture limit failures to a typed fork seed error', async () => { + currentSession() + const gitCommonDir = (await git(harness.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim() + const { policyVersion, branch } = await dirtySource() + const big = 'y'.repeat(1024 * 1024) + for (let i = 0; i < 150; i++) writeFile(harness.repo, `bulk/file-${i}.bin`, `${i}:${big}`) + + let error: unknown + try { + await harness.svc.fork.captureForkSeed({ + checkoutPath: realpathSync(harness.repo), + repositoryRoot: realpathSync(harness.repo), + gitCommonDir, + expectedBranch: branch, + baseRef: null, + ownerSessionIds: ['session-1'], + policyVersion, + previewFingerprint: 'fp-preview', + }) + } catch (caught) { + error = caught + } + expect(error).toBeInstanceOf(ConversationForkError) + expect((error as ConversationForkError).code).toBe('FORK_SEED_LIMIT') + }) +}) + +// --------------------------------------------------------------------------- +// Confirm — durable target/child transaction core +// --------------------------------------------------------------------------- + +/** Source-identity fingerprint mirroring the fork service's binding. */ +async function sourceFingerprint(owners: string[] = ['session-1']): Promise { + const gitCommonDir = (await git(harness.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim() + const branch = (await git(harness.repo, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() + return computeWorktreeFingerprint({ + managedWorktreeId: `fork:${realpathSync(harness.repo)}`, + checkoutPath: realpathSync(harness.repo), + gitCommonDir, + expectedBranch: branch, + baseRef: null, + ownerSessionIds: owners, + policyVersion: harness.svc.worktreeSettings.getSnapshot('local').version, + archivedOwnerSessionIds: [], + }) +} + +async function confirmIsolated(previewResult: ConversationForkPreview, nameSuffix: string) { + return harness.svc.fork.confirm({ + sessionId: 'session-1', + strategy: 'isolated-worktree', + transactionId: previewResult.transactionId, + previewFingerprint: previewResult.previewFingerprint, + worktreeNameSuffix: nameSuffix, + }) +} + +function forkJournalEntries() { + return harness.svc.journal.entries().filter((entry) => entry.op === 'fork') +} + +describe('IsolatedConversationForkService confirm', () => { + test('commits one child owner + one target worktree at source HEAD with the seed restored', async () => { + currentSession() + writeFile(harness.repo, 'staged.txt', 'staged\n') + await git(harness.repo, ['add', 'staged.txt']) + writeFile(harness.repo, 'unstaged.txt', 'unstaged\n') + writeFile(harness.repo, 'untracked.txt', 'untracked\n') + writeFile(harness.repo, '.gitignore', 'secret.txt\n') + writeFile(harness.repo, '.worktreeinclude', 'secret.txt\n') + writeFile(harness.repo, 'secret.txt', 'included-secret\n') + const headOid = (await git(harness.repo, ['rev-parse', 'HEAD'])).trim() + const sourceBefore = await sourceFingerprint() + + const p = await preview('isolated-worktree', 'child-one') + expect(p.blocked).toBeUndefined() + const result = await confirmIsolated(p, 'child-one') + + expect(result.outcome).toBe('committed') + if (result.outcome !== 'committed') return + expect(result.summary.strategy).toBe('isolated-worktree') + expect(result.summary.childProviderIdPresent).toBe(false) + expect(result.summary.transcriptCwd).toBe(join(harness.repo, '.kata-transcript')) + expect(result.summary.checkout.mode).toBe('managed-worktree') + expect(result.summary.checkout.schemaVersion).toBe(2) + expect(result.summary.checkout.expectedBranch).toBe('kata-agent/child-one') + expect(result.summary.executionCwd).toBe(result.summary.checkout.checkoutPath) + + // Exactly one registry owner + one child session + one target worktree. + const records = harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/child-one') + expect(records).toHaveLength(1) + expect(records[0]?.ownerSessionIds).toEqual([result.summary.sessionId]) + expect(result.summary.sessionId).toMatch(/^child-\d+$/) + expect((await git(records[0]!.checkoutPath, ['branch', '--show-current'])).trim()).toBe('kata-agent/child-one') + expect((await git(records[0]!.checkoutPath, ['rev-parse', 'HEAD'])).trim()).toBe(headOid) + expect(existsSync(records[0]!.checkoutPath)).toBe(true) + + // Seed restored: staged/unstaged/untracked/.worktreeinclude all present. + const target = records[0]!.checkoutPath + expect(readFileSync(join(target, 'staged.txt'), 'utf8')).toBe('staged\n') + expect(readFileSync(join(target, 'unstaged.txt'), 'utf8')).toBe('unstaged\n') + expect(readFileSync(join(target, 'untracked.txt'), 'utf8')).toBe('untracked\n') + expect(readFileSync(join(target, 'secret.txt'), 'utf8')).toBe('included-secret\n') + expect((await git(target, ['diff', '--cached', '--name-only'])).trim()).toContain('staged.txt') + expect((await git(target, ['status', '--porcelain'])).trim()).toContain('unstaged.txt') + + // Durable journal committed; seed removed; child hook called with the + // exact target identity; pending-fork intent is NOT persisted here. + const committed = forkJournalEntries().filter((entry) => entry.status === 'committed') + expect(committed).toHaveLength(1) + const seedSnapshotId = committed[0]?.metadata?.seedSnapshotId + expect(typeof seedSnapshotId).toBe('string') + expect(existsSync(join(harness.root, 'snapshots', seedSnapshotId as string))).toBe(false) + expect(harness.childCalls).toHaveLength(1) + const call = harness.childCalls[0]! + expect(call.transactionId).toBe(p.transactionId) + expect(call.parentSessionId).toBe('session-1') + expect(call.parentSdkSessionId).toBe('sdk-parent-1') + expect(call.parentSdkTurnId).toBe('turn-1') + expect(call.workspaceId).toBe('ws1') + expect(call.nameSuffix).toBe('child-one') + expect(call.sourceMessageId).toBe('msg-1') + expect(call.forkPointMessageId).toBe('msg-1') + expect(call.transcriptCwd).toBe(join(harness.repo, '.kata-transcript')) + expect(call.executionCwd).toBe(target) + expect(call.checkout).toMatchObject({ + mode: 'managed-worktree', + managedWorktreeId: records[0]!.managedWorktreeId, + expectedBranch: 'kata-agent/child-one', + }) + expect(harness.svc.journal.entries().some((entry) => entry.metadata?.pendingIntent)).toBe(false) + + // Source untouched: same HEAD/branch, same index/worktree bytes, same + // fingerprint, no leftover leases or fences. + expect((await git(harness.repo, ['branch', '--show-current'])).trim()).toBe('main') + expect((await git(harness.repo, ['rev-parse', 'HEAD'])).trim()).toBe(headOid) + expect(await sourceFingerprint()).toBe(sourceBefore) + expect(harness.svc.fork.isSessionFenced('session-1')).toBe(false) + expect(harness.svc.pathLeases.leasedBy(realpathSync(harness.repo))).toEqual(['session-1']) + }) + + test('confirms from a shared managed source with every owner leased and leaves source owners unchanged', async () => { + const record = await managedSession('shared-src-confirm') + harness.svc.worktrees.addOwner(record.managedWorktreeId, 'session-2') + harness.svc.pathLeases.lease('session-2', record.checkoutPath) + writeFile(record.checkoutPath, 'shared-state.txt', 'from source\n') + + const p = await preview('isolated-worktree', 'shared-child') + expect(p.blocked).toBeUndefined() + const result = await confirmIsolated(p, 'shared-child') + + expect(result.outcome).toBe('committed') + if (result.outcome !== 'committed') return + // Source owners are unchanged; the target is owned solely by the child. + expect(harness.svc.registry.get(record.managedWorktreeId)?.ownerSessionIds).toEqual(['session-1', 'session-2']) + const targetRecord = harness.svc.registry.list().find((r) => r.expectedBranch === 'kata-agent/shared-child') + expect(targetRecord?.ownerSessionIds).toEqual([result.summary.sessionId]) + expect(readFileSync(join(targetRecord!.checkoutPath, 'shared-state.txt'), 'utf8')).toBe('from source\n') + expect((await git(record.checkoutPath, ['rev-parse', 'HEAD'])).trim()).toBe( + (await git(targetRecord!.checkoutPath, ['rev-parse', 'HEAD'])).trim(), + ) + }) + + test('blocks confirm when a shared source owner holds no stable lease (path-unleased)', async () => { + const record = await managedSession('unleased-src') + harness.svc.worktrees.addOwner(record.managedWorktreeId, 'session-2') + // session-2 is deliberately NOT leased. + + const p = await preview('isolated-worktree', 'unleased-child') + expect(p.blocked).toBeUndefined() + const result = await confirmIsolated(p, 'unleased-child') + + expect(result.outcome).toBe('blocked') + if (result.outcome === 'blocked') expect(result.code).toBe('path-unleased') + expect(harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/unleased-child')).toHaveLength(0) + expect(existsSync(p.destination.checkoutPath)).toBe(false) + expect(harness.childCalls).toHaveLength(0) + }) + + test('blocks confirm when a shared source owner becomes active (source-active)', async () => { + const record = await managedSession('active-src') + harness.svc.worktrees.addOwner(record.managedWorktreeId, 'session-2') + harness.svc.pathLeases.lease('session-2', record.checkoutPath) + + const p = await preview('isolated-worktree', 'active-child') + expect(p.blocked).toBeUndefined() + harness.activeSessions.add('session-2') + const result = await confirmIsolated(p, 'active-child') + + expect(result.outcome).toBe('blocked') + if (result.outcome === 'blocked') expect(result.code).toBe('source-active') + expect(harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/active-child')).toHaveLength(0) + expect(harness.childCalls).toHaveLength(0) + }) + + test('blocks confirm on a name/branch collision appearing after the preview', async () => { + currentSession() + const p = await preview('isolated-worktree', 'collide') + expect(p.blocked).toBeUndefined() + await git(harness.repo, ['branch', 'kata-agent/collide']) + + const result = await confirmIsolated(p, 'collide') + + expect(result.outcome).toBe('blocked') + if (result.outcome === 'blocked') expect(result.code).toBe('name-collision') + expect(harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/collide')).toHaveLength(0) + expect(harness.childCalls).toHaveLength(0) + expect(existsSync(p.destination.checkoutPath)).toBe(false) + }) + + test('blocks confirm on fingerprint drift (file change or nameSuffix change)', async () => { + currentSession() + writeFile(harness.repo, 'tracked.txt', 'base\n') + await git(harness.repo, ['add', 'tracked.txt']) + await git(harness.repo, ['commit', '-m', 'base']) + + // A source file changed between preview and confirm. + const p1 = await preview('isolated-worktree', 'drift-one') + writeFile(harness.repo, 'tracked.txt', 'changed\n') + const r1 = await confirmIsolated(p1, 'drift-one') + expect(r1.outcome).toBe('blocked') + if (r1.outcome === 'blocked') expect(r1.code).toBe('identity-drift') + + // The editable nameSuffix changed between preview and confirm. + const p2 = await preview('isolated-worktree', 'drift-two') + const r2 = await confirmIsolated(p2, 'different-name') + expect(r2.outcome).toBe('blocked') + if (r2.outcome === 'blocked') expect(r2.code).toBe('identity-drift') + + // No mutation happened for either stale confirmation. + expect(harness.childCalls).toHaveLength(0) + expect(harness.svc.registry.list().filter((r) => r.expectedBranch.startsWith('kata-agent/drift'))).toHaveLength(0) + expect(harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/different-name')).toHaveLength(0) + }) + + test('fails with a typed hook-not-wired error when child-session creation is not wired', async () => { + currentSession() + const bare = createGitServices({ + worktreeRoot: join(harness.root, 'worktrees-bare'), + registryPath: join(harness.root, 'worktrees-bare', 'registry.json'), + snapshotsRoot: join(harness.root, 'snapshots-bare'), + lockDirectory: join(harness.root, 'locks-bare'), + forkHooks: { + resolveSession: (sessionId) => harness.sessions.get(sessionId) ?? null, + resolveCapability: () => ({ adapterId: 'pi-test', strictCrossCwdNativeFork: true }), + resolveCapabilityAdapter: () => createDeterministicStrictForkAdapter({ adapterId: 'pi-test' }), + isSessionActive: () => false, + }, + }) + bare.lifecycle.markReady() + bare.worktreeSettings.update({ + materializationRoot: join(harness.root, 'worktrees-bare'), + autoDeleteEnabled: false, + retentionLimit: 15, + }) + const p = await bare.fork.preview({ sessionId: 'session-1', strategy: 'isolated-worktree', worktreeNameSuffix: 'bare' }) + expect(p.blocked).toBeUndefined() + + let error: unknown + try { + await bare.fork.confirm({ + sessionId: 'session-1', + strategy: 'isolated-worktree', + transactionId: p.transactionId, + previewFingerprint: p.previewFingerprint, + worktreeNameSuffix: 'bare', + }) + } catch (caught) { + error = caught + } + expect(error).toBeInstanceOf(ConversationForkError) + expect((error as ConversationForkError).code).toBe('FORK_HOOK_NOT_WIRED') + // The session lease is still required for a current source; nothing mutated. + expect(existsSync(join(harness.root, 'worktrees-bare'))).toBe(true) + }) + + test('compensates only transaction-owned artifacts on a mid-transaction failure (CAS proof)', async () => { + currentSession() + writeFile(harness.repo, 'tracked.txt', 'base\n') + await git(harness.repo, ['add', 'tracked.txt']) + await git(harness.repo, ['commit', '-m', 'base']) + const headOid = (await git(harness.repo, ['rev-parse', 'HEAD'])).trim() + const externalOid = (await git(harness.repo, ['rev-parse', 'HEAD~1'])).trim() + const sourceBefore = await sourceFingerprint() + + const p = await preview('isolated-worktree', 'cas-demo') + expect(p.blocked).toBeUndefined() + // The child hook fires after target materialization. It advances the + // target branch to an OID this transaction never created, then fails: the + // branch is external work and compensation must NOT delete it. + harness.svc.fork.setHooks({ + createForkChildSession: async () => { + await runGit(['update-ref', 'refs/heads/kata-agent/cas-demo', externalOid], { cwd: harness.repo }) + throw new Error('simulated mid-transaction failure') + }, + }) + + let error: unknown + try { + await confirmIsolated(p, 'cas-demo') + } catch (caught) { + error = caught + } + expect(error).toBeInstanceOf(ConversationForkError) + expect((error as ConversationForkError).code).toBe('FORK_TARGET_FAILED') + + // The pre-existing/advanced branch survives (CAS proof: it no longer + // points at the journaled head OID this transaction created). + const branchOid = (await git(harness.repo, ['rev-parse', '--verify', '--quiet', 'refs/heads/kata-agent/cas-demo'])).trim() + expect(branchOid).toBe(externalOid) + // The target checkout + record are gone. + expect(harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/cas-demo')).toHaveLength(0) + expect(existsSync(p.destination.checkoutPath)).toBe(false) + // The seed is removed and the journal records the rollback. + const rolledBack = forkJournalEntries().filter((entry) => entry.status === 'recovered') + expect(rolledBack).toHaveLength(1) + const seedSnapshotId = rolledBack[0]?.metadata?.seedSnapshotId + expect(typeof seedSnapshotId).toBe('string') + expect(existsSync(join(harness.root, 'snapshots', seedSnapshotId as string))).toBe(false) + // Source untouched. + expect((await git(harness.repo, ['rev-parse', 'HEAD'])).trim()).toBe(headOid) + expect(await sourceFingerprint()).toBe(sourceBefore) + expect(harness.svc.fork.isSessionFenced('session-1')).toBe(false) + }) + + test('compensates a created child session when a post-creation step fails', async () => { + currentSession() + writeFile(harness.repo, 'tracked.txt', 'base\n') + await git(harness.repo, ['add', 'tracked.txt']) + await git(harness.repo, ['commit', '-m', 'base']) + const p = await preview('isolated-worktree', 'child-cas') + expect(p.blocked).toBeUndefined() + // The child is created, then the target record leaves `ready` before the + // owner commit — the transaction must compensate the created child too. + harness.svc.fork.setHooks({ + createForkChildSession: async (input) => { + harness.svc.registry.setState(input.checkout.managedWorktreeId, 'snapshotted') + return 'child-created-then-failed' + }, + }) + + let error: unknown + try { + await confirmIsolated(p, 'child-cas') + } catch (caught) { + error = caught + } + expect(error).toBeInstanceOf(ConversationForkError) + expect((error as ConversationForkError).code).toBe('FORK_TARGET_FAILED') + expect(harness.deletedChildren).toEqual(['child-created-then-failed']) + expect(harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/child-cas')).toHaveLength(0) + expect(existsSync(p.destination.checkoutPath)).toBe(false) + const branch = await runGit(['rev-parse', '--verify', '--quiet', 'refs/heads/kata-agent/child-cas'], { + cwd: harness.repo, + okExitCodes: [1, 128], + }) + expect(branch.exitCode).not.toBe(0) + expect(forkJournalEntries().filter((entry) => entry.status === 'recovered')).toHaveLength(1) + }) + + test('replays an interrupted confirm with the same transactionId and commits exactly once', async () => { + currentSession() + writeFile(harness.repo, 'tracked.txt', 'base\n') + await git(harness.repo, ['add', 'tracked.txt']) + await git(harness.repo, ['commit', '-m', 'base']) + const p = await preview('isolated-worktree', 'replay') + expect(p.blocked).toBeUndefined() + let childCalls = 0 + harness.svc.fork.setHooks({ + createForkChildSession: async () => { + childCalls++ + if (childCalls === 1) throw new Error('simulated interrupt after target creation') + return `child-replay-${childCalls}` + }, + }) + + let error: unknown + try { + await confirmIsolated(p, 'replay') + } catch (caught) { + error = caught + } + expect(error).toBeInstanceOf(ConversationForkError) + // The interrupted attempt was fully compensated and journaled as rolled back. + expect(forkJournalEntries().find((entry) => entry.recordId === p.transactionId)?.status).toBe('recovered') + expect(harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/replay')).toHaveLength(0) + expect(existsSync(p.destination.checkoutPath)).toBe(false) + + // A repeated confirm with the same transactionId resumes from the journal + // and commits exactly once: one child, one owner, one worktree. + const result = await confirmIsolated(p, 'replay') + expect(result.outcome).toBe('committed') + if (result.outcome !== 'committed') return + expect(childCalls).toBe(2) + const records = harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/replay') + expect(records).toHaveLength(1) + expect(records[0]?.ownerSessionIds).toEqual([result.summary.sessionId]) + expect(await git(harness.repo, ['worktree', 'list'])).toContain(records[0]!.checkoutPath) + expect(forkJournalEntries().filter((entry) => entry.recordId === p.transactionId && entry.status === 'committed')).toHaveLength(1) + expect(forkJournalEntries().filter((entry) => entry.recordId === p.transactionId)).toHaveLength(2) + }) + + test('a repeat confirm after a durable commit returns the committed summary without double-creating', async () => { + currentSession() + const p = await preview('isolated-worktree', 'double-submit') + expect(p.blocked).toBeUndefined() + const first = await confirmIsolated(p, 'double-submit') + expect(first.outcome).toBe('committed') + if (first.outcome !== 'committed') return + + const second = await confirmIsolated(p, 'double-submit') + expect(second.outcome).toBe('committed') + if (second.outcome !== 'committed') return + expect(second.summary.sessionId).toBe(first.summary.sessionId) + expect(second.summary.executionCwd).toBe(first.summary.executionCwd) + expect(harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/double-submit')).toHaveLength(1) + expect(harness.childCalls).toHaveLength(1) + }) + + test('continues an in-progress fork journal from the recorded steps (forward replay after restart)', async () => { + currentSession() + writeFile(harness.repo, 'tracked.txt', 'base\n') + await git(harness.repo, ['add', 'tracked.txt']) + await git(harness.repo, ['commit', '-m', 'base']) + const p = await preview('isolated-worktree', 'forward-replay') + expect(p.blocked).toBeUndefined() + // Simulate a crash after the first journal step: the durable entry is still + // in-progress with the preview metadata, and the in-memory transaction is + // gone. A fresh server instance over the same roots must rehydrate the + // transaction from the journal and complete it exactly once. + const journalId = harness.svc.journal.inProgress().find((entry) => entry.recordId === p.transactionId)!.journalId + harness.svc.journal.step(journalId, 'locks-acquired') + + const freshChildCalls: ConversationForkChildSessionInput[] = [] + const fresh = createGitServices({ + worktreeRoot: join(harness.root, 'worktrees'), + registryPath: join(harness.root, 'worktrees', 'registry.json'), + snapshotsRoot: join(harness.root, 'snapshots'), + lockDirectory: join(harness.root, 'locks'), + forkHooks: { + resolveSession: (sessionId) => harness.sessions.get(sessionId) ?? null, + resolveCapability: () => ({ adapterId: 'pi-test', strictCrossCwdNativeFork: true }), + resolveCapabilityAdapter: () => createDeterministicStrictForkAdapter({ adapterId: 'pi-test' }), + isSessionActive: () => false, + createForkChildSession: async (input) => { + freshChildCalls.push(input) + return 'child-forward-replay' + }, + deleteForkChildSession: async () => undefined, + }, + }) + fresh.lifecycle.markReady() + + const result = await fresh.fork.confirm({ + sessionId: 'session-1', + strategy: 'isolated-worktree', + transactionId: p.transactionId, + previewFingerprint: p.previewFingerprint, + worktreeNameSuffix: 'forward-replay', + }) + + expect(result.outcome).toBe('committed') + if (result.outcome !== 'committed') return + expect(freshChildCalls).toHaveLength(1) + const records = fresh.registry.list().filter((r) => r.expectedBranch === 'kata-agent/forward-replay') + expect(records).toHaveLength(1) + expect(records[0]?.ownerSessionIds).toEqual(['child-forward-replay']) + expect(harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/forward-replay')).toHaveLength(1) + expect(forkJournalEntries().filter((entry) => entry.recordId === p.transactionId && entry.status === 'committed')).toHaveLength(1) + }) + + test('resumes a fork journal that crashed after target materialization (own destination is not a name-collision)', async () => { + currentSession() + writeFile(harness.repo, 'tracked.txt', 'base\n') + await git(harness.repo, ['add', 'tracked.txt']) + await git(harness.repo, ['commit', '-m', 'base']) + const p = await preview('isolated-worktree', 'crash-materialized') + expect(p.blocked).toBeUndefined() + if (p.blocked) return + + const gitCommonDir = (await git(harness.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim() + const headOid = (await git(harness.repo, ['rev-parse', 'HEAD'])).trim() + const branch = (await git(harness.repo, ['branch', '--show-current'])).trim() + const entry = forkJournalEntries().find((e) => e.recordId === p.transactionId)! + const journalId = entry.journalId + const pathToken = entry.metadata?.pathToken as string + const journal = harness.svc.journal + + // Drive the durable journal to the exact post-materialization state a crash + // would leave: steps recorded through target-verified, a real target + // worktree materialized, a real seed captured and restored into it. The + // in-memory transaction is gone (server restarted). + journal.step(journalId, 'locks-acquired') + journal.step(journalId, 'source-quiesced') + const seed = await harness.svc.fork.captureForkSeed({ + checkoutPath: realpathSync(harness.repo), + repositoryRoot: realpathSync(harness.repo), + gitCommonDir, + expectedBranch: branch, + baseRef: null, + ownerSessionIds: ['session-1'], + policyVersion: harness.svc.worktreeSettings.getSnapshot().version, + previewFingerprint: p.previewFingerprint, + }) + journal.updateMetadata(journalId, { + state: 'seed-captured', + seedSnapshotId: seed.snapshotId, + seedFingerprint: seed.fingerprint, + headOid, + }) + journal.step(journalId, 'seed-captured') + + const created = await harness.svc.worktrees.createWorktree({ + workspaceId: 'ws1', + sessionId: 'session-1', + repositoryRoot: realpathSync(harness.repo), + gitCommonDir, + baseRef: headOid, + worktreeNameSuffix: 'crash-materialized', + pathToken, + lockAlreadyHeld: true, + }) + journal.updateMetadata(journalId, { + state: 'target-materialized', + managedWorktreeId: created.record.managedWorktreeId, + }) + journal.step(journalId, 'target-materialized') + + const seedMeta = harness.svc.snapshots.loadSnapshotMeta(seed.snapshotId) + expect(seedMeta).toBeTruthy() + if (!seedMeta) return + await harness.svc.snapshots.applySnapshotToCheckout({ + meta: seedMeta, + checkoutPath: created.record.checkoutPath, + }) + journal.step(journalId, 'target-restored') + journal.step(journalId, 'target-verified') + + // A fresh server instance over the same roots must rehydrate the + // transaction and resume WITHOUT treating the transaction's own + // materialized destination as a name-collision, and without creating a + // second target/child/owner. + const freshChildCalls: ConversationForkChildSessionInput[] = [] + const fresh = createGitServices({ + worktreeRoot: join(harness.root, 'worktrees'), + registryPath: join(harness.root, 'worktrees', 'registry.json'), + snapshotsRoot: join(harness.root, 'snapshots'), + lockDirectory: join(harness.root, 'locks'), + forkHooks: { + resolveSession: (sessionId) => harness.sessions.get(sessionId) ?? null, + resolveCapability: () => ({ adapterId: 'pi-test', strictCrossCwdNativeFork: true }), + resolveCapabilityAdapter: () => createDeterministicStrictForkAdapter({ adapterId: 'pi-test' }), + isSessionActive: () => false, + createForkChildSession: async (input) => { + freshChildCalls.push(input) + return 'child-crash-resume' + }, + deleteForkChildSession: async () => undefined, + }, + }) + fresh.lifecycle.markReady() + + const result = await fresh.fork.confirm({ + sessionId: 'session-1', + strategy: 'isolated-worktree', + transactionId: p.transactionId, + previewFingerprint: p.previewFingerprint, + worktreeNameSuffix: 'crash-materialized', + }) + + expect(result.outcome).toBe('committed') + if (result.outcome !== 'committed') return + expect(freshChildCalls).toHaveLength(1) + const records = fresh.registry.list().filter((r) => r.expectedBranch === 'kata-agent/crash-materialized') + expect(records).toHaveLength(1) + expect(records[0]?.managedWorktreeId).toBe(created.record.managedWorktreeId) + expect(records[0]?.ownerSessionIds).toEqual(['child-crash-resume']) + expect( + harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/crash-materialized'), + ).toHaveLength(1) + expect(forkJournalEntries().find((e) => e.recordId === p.transactionId)?.status).toBe('committed') + // The seed is released only after the durable commit. + expect(existsSync(join(harness.root, 'snapshots', seed.snapshotId))).toBe(false) + }) +}) + +describe('IsolatedConversationForkService fork-journal GC retention', () => { + test('retains an in-progress fork journal seed through orphan GC and releases it after resolution', async () => { + currentSession() + const gitCommonDir = (await git(harness.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim() + const { policyVersion, branch } = await dirtySource() + const { snapshotId } = await harness.svc.fork.captureForkSeed({ + checkoutPath: realpathSync(harness.repo), + repositoryRoot: realpathSync(harness.repo), + gitCommonDir, + expectedBranch: branch, + baseRef: null, + ownerSessionIds: ['session-1'], + policyVersion, + previewFingerprint: 'fp-gc', + }) + expect(existsSync(join(harness.root, 'snapshots', snapshotId))).toBe(true) + const entry = harness.svc.journal.begin({ + op: 'fork', + recordId: 'a'.repeat(16), + sessionIds: ['session-1'], + policyVersion, + metadata: { seedSnapshotId: snapshotId, state: 'seed-captured' }, + }) + // An unrelated orphan payload must still be swept. + const orphan = join(harness.root, 'snapshots', 'b'.repeat(16)) + mkdirSync(orphan, { recursive: true }) + writeFileSync(join(orphan, 'manifest.json'), '{}') + + await harness.svc.lifecycle.reconcileJournal() + + // The in-progress fork journal entry's seed survives GC. + expect(existsSync(join(harness.root, 'snapshots', snapshotId))).toBe(true) + expect(existsSync(orphan)).toBe(false) + + // After the journal commits nothing retains the seed: GC removes it. + harness.svc.journal.commit(entry.journalId, 'gc-test-commit') + await harness.svc.lifecycle.reconcileJournal() + expect(existsSync(join(harness.root, 'snapshots', snapshotId))).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Status / cancel / recover — the recovery and cancellation surface +// --------------------------------------------------------------------------- + +function freshServicesWithChildRecording(childCalls: ConversationForkChildSessionInput[], childId: string) { + const fresh = createGitServices({ + worktreeRoot: join(harness.root, 'worktrees'), + registryPath: join(harness.root, 'worktrees', 'registry.json'), + snapshotsRoot: join(harness.root, 'snapshots'), + lockDirectory: join(harness.root, 'locks'), + forkHooks: { + resolveSession: (sessionId) => harness.sessions.get(sessionId) ?? null, + resolveCapability: () => ({ adapterId: 'pi-test', strictCrossCwdNativeFork: true }), + resolveCapabilityAdapter: () => createDeterministicStrictForkAdapter({ adapterId: 'pi-test' }), + isSessionActive: () => false, + createForkChildSession: async (input) => { + childCalls.push(input) + return childId + }, + deleteForkChildSession: async () => undefined, + }, + }) + fresh.lifecycle.markReady() + return fresh +} + +/** + * Drive a confirm into a durable FAILED (recovery-required) state: the child + * is created, then the target record leaves `ready` before the owner commit, + * and compensation then fails (the child-removal hook throws). Mirrors the + * existing compensation-failure test pattern with compensation itself failing, + * which is exactly the state a real FORK_COMPENSATION_FAILED leaves behind. + */ +async function compensationFailedFork(nameSuffix: string): Promise { + writeFile(harness.repo, 'tracked.txt', 'base\n') + await git(harness.repo, ['add', 'tracked.txt']) + await git(harness.repo, ['commit', '-m', 'base']) + const p = await preview('isolated-worktree', nameSuffix) + expect(p.blocked).toBeUndefined() + if (p.blocked) return p + harness.svc.fork.setHooks({ + createForkChildSession: async (input) => { + harness.svc.registry.setState(input.checkout.managedWorktreeId, 'snapshotted') + return `child-${nameSuffix}` + }, + deleteForkChildSession: async () => { + throw new Error('simulated compensation failure') + }, + }) + let error: unknown + try { + await confirmIsolated(p, nameSuffix) + } catch (caught) { + error = caught + } + expect(error).toBeInstanceOf(ConversationForkError) + expect((error as ConversationForkError).code).toBe('FORK_COMPENSATION_FAILED') + const entry = forkJournalEntries().find((e) => e.recordId === p.transactionId) + expect(entry?.status).toBe('failed') + expect(entry?.metadata?.state).toBe('recovery-required') + return p +} + +describe('IsolatedConversationForkService status', () => { + test('reports active:false for a session with no fork transaction', async () => { + currentSession() + + expect(await harness.svc.fork.status({ sessionId: 'session-1' })).toEqual({ active: false }) + }) + + test('reports an active pending transaction after preview (in-memory and durable)', async () => { + currentSession() + + const p = await preview('isolated-worktree', 'status-pending') + expect(p.blocked).toBeUndefined() + + const status = await harness.svc.fork.status({ sessionId: 'session-1' }) + expect(status).toMatchObject({ + active: true, + transactionId: p.transactionId, + strategy: 'isolated-worktree', + state: 'pending', + providerIdentity: { status: 'pending' }, + }) + if (status.active) expect(status.since).toBeGreaterThan(0) + + // The preview transaction is durably journaled as in-progress. + expect(forkJournalEntries().find((e) => e.recordId === p.transactionId)?.status).toBe('in-progress') + }) + + test('rehydrates an active transaction from the journal after a restart', async () => { + currentSession() + + const p = await preview('isolated-worktree', 'status-rehydrate') + expect(p.blocked).toBeUndefined() + + const fresh = freshServicesWithChildRecording([], 'child-unused') + await fresh.fork.reconcileForkJournal() + expect(fresh.fork.isSessionFenced('session-1')).toBe(true) + expect(fresh.fork.isPathFenced(realpathSync(harness.repo))).toBe(true) + const status = await fresh.fork.status({ sessionId: 'session-1' }) + + expect(status).toMatchObject({ + active: true, + transactionId: p.transactionId, + strategy: 'isolated-worktree', + state: 'pending', + }) + if (status.active) expect(status.since).toBeGreaterThan(0) + // Status rehydration also restores the runtime session/path fences so + // Send and Git mutations remain blocked before explicit recovery. + expect(fresh.fork.isSessionFenced('session-1')).toBe(true) + expect(fresh.fork.isPathFenced(realpathSync(harness.repo))).toBe(true) + }) + + test('status reports recovery-required for a compensation-failed transaction', async () => { + currentSession() + + const p = await compensationFailedFork('recovery-required-status') + if (p.blocked) return + + // The durable entry is failed with recovery-required metadata and the + // in-memory transaction is still present: status must surface the durable + // recovery-required state, not the stale in-memory step state, and the + // fence stays held until the recovery-required state resolves. + const status = await harness.svc.fork.status({ sessionId: 'session-1' }) + expect(status).toMatchObject({ + active: true, + transactionId: p.transactionId, + strategy: 'isolated-worktree', + state: 'recovery-required', + }) + if (status.active) expect(status.since).toBeGreaterThan(0) + expect(harness.svc.fork.isSessionFenced('session-1')).toBe(true) + }) +}) + +describe('IsolatedConversationForkService cancel', () => { + test('cancel makes status inactive and a re-preview works (stale-journal gap regression)', async () => { + currentSession() + + const p = await preview('isolated-worktree', 'cancel-repreview') + expect(p.blocked).toBeUndefined() + expect(forkJournalEntries().filter((e) => e.status === 'in-progress')).toHaveLength(1) + + const cancelled = await harness.svc.fork.cancel({ sessionId: 'session-1', transactionId: p.transactionId }) + expect(cancelled).toEqual({ active: false }) + expect(await harness.svc.fork.status({ sessionId: 'session-1' })).toEqual({ active: false }) + + // The durable entry is recovered with the preview-cancelled marker: a + // restarted server must NOT block a new preview on this session (the Task 2 + // review gap: a dismissed preview previously left a durable in-progress + // fork entry that blocked re-preview forever after restart). + const cancelledEntry = forkJournalEntries().find((e) => e.recordId === p.transactionId) + expect(cancelledEntry?.status).toBe('recovered') + expect(cancelledEntry?.commitMarker).toBe('preview-cancelled') + expect(cancelledEntry?.metadata?.state).toBe('preview-cancelled') + + const fresh = freshServicesWithChildRecording([], 'child-unused') + expect(await fresh.fork.status({ sessionId: 'session-1' })).toEqual({ active: false }) + + const again = await preview('isolated-worktree', 'cancel-repreview') + expect(again.blocked).toBeUndefined() + expect(again.transactionId).not.toBe(p.transactionId) + expect(forkJournalEntries().filter((e) => e.recordId === again.transactionId && e.status === 'in-progress')).toHaveLength(1) + }) + + test('cancel releases the session and path fences held by a pending preview', async () => { + currentSession() + + const p = await preview('isolated-worktree', 'cancel-fence') + expect(p.blocked).toBeUndefined() + expect(harness.svc.fork.isSessionFenced('session-1')).toBe(true) + expect(harness.svc.fork.isPathFenced(realpathSync(harness.repo))).toBe(true) + expect(harness.svc.fork.isPathFenced(p.destination.checkoutPath)).toBe(true) + + await harness.svc.fork.cancel({ sessionId: 'session-1', transactionId: p.transactionId }) + + expect(harness.svc.fork.isSessionFenced('session-1')).toBe(false) + expect(harness.svc.fork.isPathFenced(realpathSync(harness.repo))).toBe(false) + expect(harness.svc.fork.isPathFenced(p.destination.checkoutPath)).toBe(false) + }) + + test('cancel on an unknown transaction id is a no-op returning the current status', async () => { + currentSession() + + expect(await harness.svc.fork.cancel({ sessionId: 'session-1', transactionId: 'c'.repeat(16) })).toEqual({ + active: false, + }) + }) + + test('cancel refuses a failed (recovery-required) entry on both in-memory and restarted paths', async () => { + currentSession() + + const p = await compensationFailedFork('cancel-refused-failed') + if (p.blocked) return + + // In-process: the failed entry is still in the map and fenced; cancel must + // refuse (nothing to cancel) and report the recovery-required status. + const cancelled = await harness.svc.fork.cancel({ sessionId: 'session-1', transactionId: p.transactionId }) + expect(cancelled).toMatchObject({ active: true, transactionId: p.transactionId, state: 'recovery-required' }) + expect(forkJournalEntries().find((e) => e.recordId === p.transactionId)?.status).toBe('failed') + expect(harness.svc.fork.isSessionFenced('session-1')).toBe(true) + + // After restart: the durable failed entry is still not cancellable and the + // entry is left untouched (a new preview is still gated by the leftover + // recovery state rather than a bogus cancel). + const fresh = freshServicesWithChildRecording([], 'child-unused') + const cancelledFresh = await fresh.fork.cancel({ sessionId: 'session-1', transactionId: p.transactionId }) + expect(cancelledFresh).toMatchObject({ active: true, transactionId: p.transactionId, state: 'recovery-required' }) + expect(fresh.fork.isSessionFenced('session-1')).toBe(true) + expect(forkJournalEntries().find((e) => e.recordId === p.transactionId)?.status).toBe('failed') + }) + + test('cancel that wins the mutation lock first aborts the queued confirm (no child on a cancelled entry)', async () => { + currentSession() + writeFile(harness.repo, 'tracked.txt', 'base\n') + await git(harness.repo, ['add', 'tracked.txt']) + await git(harness.repo, ['commit', '-m', 'base']) + const p = await preview('isolated-worktree', 'cancel-wins-race') + expect(p.blocked).toBeUndefined() + if (p.blocked) return + const gitCommonDir = (await git(harness.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim() + + // Hold the mutation lock so both the cancel and the confirm queue behind + // it in FIFO order (cancel first). When released, cancel must cancel the + // pure pending preview, and the queued confirm must abort with a typed + // error instead of durably committing a child the journal no longer + // records. + let releaseGate!: () => void + const gate = new Promise((resolve) => { + releaseGate = resolve + }) + const gateHeld = harness.svc.mutationLock.withLock(gitCommonDir, async () => { + await gate + }) + const cancelP = harness.svc.fork.cancel({ sessionId: 'session-1', transactionId: p.transactionId }) + const confirmP = harness.svc.fork + .confirm({ + sessionId: 'session-1', + strategy: 'isolated-worktree', + transactionId: p.transactionId, + previewFingerprint: p.previewFingerprint, + worktreeNameSuffix: 'cancel-wins-race', + }) + .then( + (result) => ({ result }), + (error) => ({ error }), + ) + releaseGate() + await gateHeld + const [cancelResult, confirmOutcome] = await Promise.all([cancelP, confirmP]) + + expect(cancelResult).toEqual({ active: false }) + expect('error' in confirmOutcome).toBe(true) + if ('error' in confirmOutcome) { + expect(confirmOutcome.error).toBeInstanceOf(ConversationForkError) + expect((confirmOutcome.error as ConversationForkError).code).toBe('FORK_TRANSACTION_UNKNOWN') + } + const entry = forkJournalEntries().find((e) => e.recordId === p.transactionId) + expect(entry?.status).toBe('recovered') + expect(entry?.commitMarker).toBe('preview-cancelled') + expect(harness.childCalls).toHaveLength(0) + }) + + test('confirm that wins the mutation lock first makes the queued cancel refuse (entry stays committed)', async () => { + currentSession() + writeFile(harness.repo, 'tracked.txt', 'base\n') + await git(harness.repo, ['add', 'tracked.txt']) + await git(harness.repo, ['commit', '-m', 'base']) + const p = await preview('isolated-worktree', 'confirm-wins-race') + expect(p.blocked).toBeUndefined() + if (p.blocked) return + const gitCommonDir = (await git(harness.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim() + + // FIFO order: confirm queues first, cancel second. Confirm commits + // durably; the queued cancel then sees the first journal step and refuses + // instead of cancelling a committed transaction. + let releaseGate!: () => void + const gate = new Promise((resolve) => { + releaseGate = resolve + }) + const gateHeld = harness.svc.mutationLock.withLock(gitCommonDir, async () => { + await gate + }) + const confirmP = harness.svc.fork.confirm({ + sessionId: 'session-1', + strategy: 'isolated-worktree', + transactionId: p.transactionId, + previewFingerprint: p.previewFingerprint, + worktreeNameSuffix: 'confirm-wins-race', + }) + const cancelP = harness.svc.fork.cancel({ sessionId: 'session-1', transactionId: p.transactionId }) + releaseGate() + await gateHeld + const [confirmResult, cancelResult] = await Promise.all([confirmP, cancelP]) + + expect(confirmResult.outcome).toBe('committed') + expect(cancelResult).toEqual({ active: false }) + const entry = forkJournalEntries().find((e) => e.recordId === p.transactionId) + expect(entry?.status).toBe('committed') + expect(entry?.commitMarker).toBe(p.transactionId) + expect(harness.childCalls).toHaveLength(1) + }) +}) + +describe('IsolatedConversationForkService recover', () => { + test('recover with an unknown transactionId throws a typed fork error', async () => { + currentSession() + + let error: unknown + try { + await harness.svc.fork.recover({ sessionId: 'session-1', transactionId: 'f'.repeat(16) }) + } catch (caught) { + error = caught + } + expect(error).toBeInstanceOf(ConversationForkError) + expect((error as ConversationForkError).code).toBe('FORK_TRANSACTION_UNKNOWN') + }) + + test('recover and confirm on a failed (recovery-required) entry throw the same typed fork error in-process', async () => { + currentSession() + + const p = await compensationFailedFork('recover-failed-inprocess') + if (p.blocked) return + + // The failed entry must not be silently reset and re-run over possibly + // uncompensated artifacts (the old in-memory beginFreshAttempt bug): both + // recover and confirm surface the typed transaction-unknown error, exactly + // like the durable failed entry does after a restart. + let recoverError: unknown + try { + await harness.svc.fork.recover({ sessionId: 'session-1', transactionId: p.transactionId }) + } catch (caught) { + recoverError = caught + } + expect(recoverError).toBeInstanceOf(ConversationForkError) + expect((recoverError as ConversationForkError).code).toBe('FORK_TRANSACTION_UNKNOWN') + + let confirmError: unknown + try { + await confirmIsolated(p, 'recover-failed-inprocess') + } catch (caught) { + confirmError = caught + } + expect(confirmError).toBeInstanceOf(ConversationForkError) + expect((confirmError as ConversationForkError).code).toBe('FORK_TRANSACTION_UNKNOWN') + + // Nothing re-ran: still one failed entry, no fresh journal entry, no new + // child creation attempt beyond the original failure. + expect(forkJournalEntries().filter((e) => e.recordId === p.transactionId)).toHaveLength(1) + }) + + test('recover on a failed (recovery-required) entry after restart throws the same typed fork error', async () => { + currentSession() + + const p = await compensationFailedFork('recover-failed-restart') + if (p.blocked) return + + const fresh = freshServicesWithChildRecording([], 'child-unused') + let error: unknown + try { + await fresh.fork.recover({ sessionId: 'session-1', transactionId: p.transactionId }) + } catch (caught) { + error = caught + } + expect(error).toBeInstanceOf(ConversationForkError) + expect((error as ConversationForkError).code).toBe('FORK_TRANSACTION_UNKNOWN') + // The durable failed entry is untouched. + expect(forkJournalEntries().find((e) => e.recordId === p.transactionId)?.status).toBe('failed') + }) + + test('recover on a committed entry returns the committed summary without re-creating anything', async () => { + currentSession() + + const p = await preview('isolated-worktree', 'recover-committed') + expect(p.blocked).toBeUndefined() + const first = await confirmIsolated(p, 'recover-committed') + expect(first.outcome).toBe('committed') + if (first.outcome !== 'committed') return + + const rec = await harness.svc.fork.recover({ sessionId: 'session-1', transactionId: p.transactionId }) + + expect(rec.outcome).toBe('committed') + if (rec.outcome !== 'committed') return + expect(rec.summary.sessionId).toBe(first.summary.sessionId) + expect(rec.summary.executionCwd).toBe(first.summary.executionCwd) + expect(rec.summary.committedAt).toBe(first.summary.committedAt) + // Nothing re-created: still exactly one target, one child call, one owner. + expect(harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/recover-committed')).toHaveLength(1) + expect(harness.childCalls).toHaveLength(1) + }) + + test('recover after a rolled-back entry starts a fresh attempt and commits exactly once', async () => { + currentSession() + writeFile(harness.repo, 'tracked.txt', 'base\n') + await git(harness.repo, ['add', 'tracked.txt']) + await git(harness.repo, ['commit', '-m', 'base']) + + const p = await preview('isolated-worktree', 'recover-rolled-back') + expect(p.blocked).toBeUndefined() + let childCalls = 0 + harness.svc.fork.setHooks({ + createForkChildSession: async () => { + childCalls++ + if (childCalls === 1) throw new Error('simulated interrupt') + return `child-rolled-back-${childCalls}` + }, + }) + let error: unknown + try { + await confirmIsolated(p, 'recover-rolled-back') + } catch (caught) { + error = caught + } + expect(error).toBeInstanceOf(ConversationForkError) + // The interrupted attempt was fully compensated and journaled rolled-back. + const rolledBack = forkJournalEntries().find((e) => e.recordId === p.transactionId) + expect(rolledBack?.status).toBe('recovered') + expect(rolledBack?.commitMarker).toBe('rolled-back') + expect(harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/recover-rolled-back')).toHaveLength(0) + expect(existsSync(p.destination.checkoutPath)).toBe(false) + + // Recover re-enters the confirm machinery as a fresh attempt (the + // rolled-back entry's artifacts are fully compensated) and commits once. + const rec = await harness.svc.fork.recover({ sessionId: 'session-1', transactionId: p.transactionId }) + + expect(rec.outcome).toBe('committed') + if (rec.outcome !== 'committed') return + expect(childCalls).toBe(2) + const records = harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/recover-rolled-back') + expect(records).toHaveLength(1) + expect(records[0]?.ownerSessionIds).toEqual([rec.summary.sessionId]) + expect(await git(harness.repo, ['worktree', 'list'])).toContain(records[0]!.checkoutPath) + expect(forkJournalEntries().filter((e) => e.recordId === p.transactionId && e.status === 'committed')).toHaveLength(1) + expect(forkJournalEntries().filter((e) => e.recordId === p.transactionId)).toHaveLength(2) + expect(harness.svc.fork.isSessionFenced('session-1')).toBe(false) + }) + + test('cancel does NOT cancel an in-progress confirm and recover completes it exactly once', async () => { + currentSession() + writeFile(harness.repo, 'tracked.txt', 'base\n') + await git(harness.repo, ['add', 'tracked.txt']) + await git(harness.repo, ['commit', '-m', 'base']) + + const p = await preview('isolated-worktree', 'in-progress-cancel') + expect(p.blocked).toBeUndefined() + if (p.blocked) return + const entry = forkJournalEntries().find((e) => e.recordId === p.transactionId)! + const journalId = entry.journalId + const journal = harness.svc.journal + const gitCommonDir = (await git(harness.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim() + const headOid = (await git(harness.repo, ['rev-parse', 'HEAD'])).trim() + const branch = (await git(harness.repo, ['branch', '--show-current'])).trim() + + // Drive the durable journal past the preview (seed-captured), as a crash + // after seed capture would leave it. + journal.step(journalId, 'locks-acquired') + journal.step(journalId, 'source-quiesced') + const seed = await harness.svc.fork.captureForkSeed({ + checkoutPath: realpathSync(harness.repo), + repositoryRoot: realpathSync(harness.repo), + gitCommonDir, + expectedBranch: branch, + baseRef: null, + ownerSessionIds: ['session-1'], + policyVersion: harness.svc.worktreeSettings.getSnapshot().version, + previewFingerprint: p.previewFingerprint, + }) + journal.updateMetadata(journalId, { + state: 'seed-captured', + seedSnapshotId: seed.snapshotId, + seedFingerprint: seed.fingerprint, + headOid, + }) + journal.step(journalId, 'seed-captured') + + // In-process cancel must refuse: the transaction is past pending. + const cancelResult = await harness.svc.fork.cancel({ sessionId: 'session-1', transactionId: p.transactionId }) + expect(cancelResult.active).toBe(true) + if (cancelResult.active) expect(cancelResult.transactionId).toBe(p.transactionId) + // The transaction stays in the map (still fenced, still recoverable). + expect(harness.svc.fork.isSessionFenced('session-1')).toBe(true) + const durable = forkJournalEntries().find((e) => e.recordId === p.transactionId) + expect(durable?.status).toBe('in-progress') + expect(durable?.metadata?.state).toBe('seed-captured') + + // A restarted server sees the same durable state: the fork is recoverable. + const freshChildCalls: ConversationForkChildSessionInput[] = [] + const fresh = freshServicesWithChildRecording(freshChildCalls, 'child-seed-captured-recover') + const status = await fresh.fork.status({ sessionId: 'session-1' }) + expect(status).toMatchObject({ active: true, transactionId: p.transactionId, state: 'seed-captured' }) + + const rec = await fresh.fork.recover({ sessionId: 'session-1', transactionId: p.transactionId }) + + expect(rec.outcome).toBe('committed') + if (rec.outcome !== 'committed') return + expect(freshChildCalls).toHaveLength(1) + const records = fresh.registry.list().filter((r) => r.expectedBranch === 'kata-agent/in-progress-cancel') + expect(records).toHaveLength(1) + expect(records[0]?.ownerSessionIds).toEqual(['child-seed-captured-recover']) + expect( + harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/in-progress-cancel'), + ).toHaveLength(1) + expect(forkJournalEntries().find((e) => e.recordId === p.transactionId)?.status).toBe('committed') + expect(existsSync(join(harness.root, 'snapshots', seed.snapshotId))).toBe(false) + }) + + test('recover resumes a fork journal that crashed after target materialization exactly once', async () => { + currentSession() + writeFile(harness.repo, 'tracked.txt', 'base\n') + await git(harness.repo, ['add', 'tracked.txt']) + await git(harness.repo, ['commit', '-m', 'base']) + + const p = await preview('isolated-worktree', 'crash-materialized-recover') + expect(p.blocked).toBeUndefined() + if (p.blocked) return + const gitCommonDir = (await git(harness.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim() + const headOid = (await git(harness.repo, ['rev-parse', 'HEAD'])).trim() + const branch = (await git(harness.repo, ['branch', '--show-current'])).trim() + const entry = forkJournalEntries().find((e) => e.recordId === p.transactionId)! + const journalId = entry.journalId + const pathToken = entry.metadata?.pathToken as string + const journal = harness.svc.journal + + // Drive the durable journal to the exact post-materialization state a crash + // would leave: steps recorded through target-verified, a real target + // worktree materialized, a real seed captured and restored into it. + journal.step(journalId, 'locks-acquired') + journal.step(journalId, 'source-quiesced') + const seed = await harness.svc.fork.captureForkSeed({ + checkoutPath: realpathSync(harness.repo), + repositoryRoot: realpathSync(harness.repo), + gitCommonDir, + expectedBranch: branch, + baseRef: null, + ownerSessionIds: ['session-1'], + policyVersion: harness.svc.worktreeSettings.getSnapshot().version, + previewFingerprint: p.previewFingerprint, + }) + journal.updateMetadata(journalId, { + state: 'seed-captured', + seedSnapshotId: seed.snapshotId, + seedFingerprint: seed.fingerprint, + headOid, + }) + journal.step(journalId, 'seed-captured') + + const created = await harness.svc.worktrees.createWorktree({ + workspaceId: 'ws1', + sessionId: 'session-1', + repositoryRoot: realpathSync(harness.repo), + gitCommonDir, + baseRef: headOid, + worktreeNameSuffix: 'crash-materialized-recover', + pathToken, + lockAlreadyHeld: true, + }) + journal.updateMetadata(journalId, { + state: 'target-materialized', + managedWorktreeId: created.record.managedWorktreeId, + }) + journal.step(journalId, 'target-materialized') + + const seedMeta = harness.svc.snapshots.loadSnapshotMeta(seed.snapshotId) + expect(seedMeta).toBeTruthy() + if (!seedMeta) return + await harness.svc.snapshots.applySnapshotToCheckout({ + meta: seedMeta, + checkoutPath: created.record.checkoutPath, + }) + journal.step(journalId, 'target-restored') + journal.step(journalId, 'target-verified') + + // A fresh server instance must rehydrate and resume WITHOUT treating the + // transaction's own materialized destination as a name-collision, and + // without creating a second target/child/owner. + const freshChildCalls: ConversationForkChildSessionInput[] = [] + const fresh = freshServicesWithChildRecording(freshChildCalls, 'child-crash-materialized-recover') + + const result = await fresh.fork.recover({ sessionId: 'session-1', transactionId: p.transactionId }) + + expect(result.outcome).toBe('committed') + if (result.outcome !== 'committed') return + expect(freshChildCalls).toHaveLength(1) + const records = fresh.registry.list().filter((r) => r.expectedBranch === 'kata-agent/crash-materialized-recover') + expect(records).toHaveLength(1) + expect(records[0]?.managedWorktreeId).toBe(created.record.managedWorktreeId) + expect(records[0]?.ownerSessionIds).toEqual(['child-crash-materialized-recover']) + expect( + harness.svc.registry.list().filter((r) => r.expectedBranch === 'kata-agent/crash-materialized-recover'), + ).toHaveLength(1) + expect(forkJournalEntries().find((e) => e.recordId === p.transactionId)?.status).toBe('committed') + expect(existsSync(join(harness.root, 'snapshots', seed.snapshotId))).toBe(false) + }) + + test('markEstablished records the child provider identity on the committed journal entry (metadata-only)', async () => { + currentSession() + const p = await preview('isolated-worktree', 'established-child') + expect(p.blocked).toBeUndefined() + const result = await confirmIsolated(p, 'established-child') + expect(result.outcome).toBe('committed') + if (result.outcome !== 'committed') return + + const entry = forkJournalEntries().find((e) => e.recordId === p.transactionId)! + expect(entry.status).toBe('committed') + expect(entry.metadata?.state).not.toBe('established') + + // First-Send establishment records the provider child ID on the same + // committed entry without changing its status. + harness.svc.fork.markEstablished(p.transactionId, 'sdk-child-xyz') + + const updated = forkJournalEntries().find((e) => e.recordId === p.transactionId)! + expect(updated.status).toBe('committed') + expect(updated.metadata?.state).toBe('established') + expect(updated.metadata?.childSdkSessionId).toBe('sdk-child-xyz') + expect(typeof updated.metadata?.establishedAt).toBe('number') + }) + + test('markEstablished for an unknown/missing transaction is a no-op (child session record is authoritative)', async () => { + currentSession() + expect(() => harness.svc.fork.markEstablished('no-such-transaction', 'sdk-child-ghost')).not.toThrow() + expect(forkJournalEntries().some((e) => e.metadata?.state === 'established')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Startup reconciliation — fork journal classification + establish backfill +// --------------------------------------------------------------------------- + +/** Begin a durable fork journal entry in an arbitrary crash-left state. */ +function journalFork(opts: { + recordId: string + childSessionId?: string + steps?: string[] + status?: 'in-progress' | 'committed' | 'failed' + state?: string +}): void { + const entry = harness.svc.journal.begin({ + op: 'fork', + recordId: opts.recordId, + sessionIds: ['session-1'], + policyVersion: harness.svc.worktreeSettings.getSnapshot('local').version, + metadata: { + transactionId: opts.recordId, + strategy: 'isolated-worktree', + state: opts.state ?? 'pending', + ...(opts.childSessionId ? { childSessionId: opts.childSessionId } : {}), + }, + }) + for (const step of opts.steps ?? []) harness.svc.journal.step(entry.journalId, step) + if (opts.status === 'committed') harness.svc.journal.commit(entry.journalId, opts.recordId) + if (opts.status === 'failed') harness.svc.journal.fail(entry.journalId, 'test failure') +} + +const CHILD_CREATED_STEPS = [ + 'locks-acquired', + 'source-quiesced', + 'seed-captured', + 'destination-leased', + 'target-materialized', + 'target-restored', + 'target-verified', + 'child-created', +] + +function forkStateResolver( + states: Map, +) { + return (sessionId: string) => states.get(sessionId) ?? null +} + +describe('IsolatedConversationForkService startup reconciliation', () => { + test('classifies committed entries as committed and never touches them', async () => { + currentSession() + journalFork({ + recordId: 'a'.repeat(16), + steps: ['child-created', 'owner-committed'], + status: 'committed', + state: 'binding-committed', + childSessionId: 'child-a', + }) + + const report = await harness.svc.fork.reconcileForkJournal() + + const entry = forkJournalEntries().find((e) => e.recordId === 'a'.repeat(16)) + expect(entry?.status).toBe('committed') + expect(entry?.metadata?.state).toBe('binding-committed') + expect(report).toEqual({ resumed: 0, flagged: 0, recoveryRequired: 0 }) + }) + + test('classifies a child-created in-progress entry as recovery-required when no live pending child owns it', async () => { + currentSession() + journalFork({ + recordId: 'b'.repeat(16), + steps: CHILD_CREATED_STEPS, + state: 'target-materialized', + childSessionId: 'child-b', + }) + + const report = await harness.svc.fork.reconcileForkJournal() + + const entry = forkJournalEntries().find((e) => e.recordId === 'b'.repeat(16)) + expect(entry?.status).toBe('in-progress') + expect(entry?.metadata?.state).toBe('recovery-required') + expect(typeof entry?.metadata?.recoveryReason).toBe('string') + expect(report).toEqual({ resumed: 0, flagged: 1, recoveryRequired: 1 }) + }) + + test('leaves a pre-child in-progress entry resumable (steps through target-verified, no child)', async () => { + currentSession() + journalFork({ + recordId: 'c'.repeat(16), + steps: CHILD_CREATED_STEPS.slice(0, -1), + state: 'target-verified', + }) + + const report = await harness.svc.fork.reconcileForkJournal() + + const entry = forkJournalEntries().find((e) => e.recordId === 'c'.repeat(16)) + expect(entry?.status).toBe('in-progress') + expect(entry?.metadata?.state).toBe('target-verified') + expect(report).toEqual({ resumed: 0, flagged: 0, recoveryRequired: 0 }) + }) + + test('leaves failed entries failed (not resumable) and reports pre-existing recovery-required state', async () => { + currentSession() + journalFork({ + recordId: 'd'.repeat(16), + steps: ['child-created'], + status: 'failed', + state: 'recovery-required', + childSessionId: 'child-d', + }) + + const report = await harness.svc.fork.reconcileForkJournal() + + const entry = forkJournalEntries().find((e) => e.recordId === 'd'.repeat(16)) + expect(entry?.status).toBe('failed') + expect(entry?.metadata?.state).toBe('recovery-required') + expect(report).toEqual({ resumed: 0, flagged: 0, recoveryRequired: 1 }) + }) + + test('leaves a child-created in-progress entry untouched when the child is live and pending (establish flow owns it)', async () => { + currentSession() + journalFork({ + recordId: 'e'.repeat(16), + steps: CHILD_CREATED_STEPS, + state: 'target-materialized', + childSessionId: 'child-e', + }) + const states = new Map([ + ['child-e', { pendingFork: { transactionId: 'e'.repeat(16) }, checkoutStrategy: 'isolated' }], + ]) + + const report = await harness.svc.fork.reconcileForkJournal({ + resolveSessionForkState: forkStateResolver(states), + }) + + const entry = forkJournalEntries().find((e) => e.recordId === 'e'.repeat(16)) + expect(entry?.status).toBe('in-progress') + expect(entry?.metadata?.state).toBe('target-materialized') + expect(report).toEqual({ resumed: 0, flagged: 0, recoveryRequired: 0 }) + }) + + test('backfills the established marker on a committed entry whose child is durably established', async () => { + currentSession() + journalFork({ + recordId: 'f'.repeat(16), + steps: ['child-created', 'owner-committed'], + status: 'committed', + state: 'binding-committed', + childSessionId: 'child-f', + }) + const states = new Map([ + ['child-f', { sdkSessionId: 'sdk-child-f', pendingFork: null, checkoutStrategy: 'isolated' }], + ]) + + const report = await harness.svc.fork.reconcileForkJournal({ + resolveSessionForkState: forkStateResolver(states), + }) + + const entry = forkJournalEntries().find((e) => e.recordId === 'f'.repeat(16)) + expect(entry?.status).toBe('committed') + expect(entry?.metadata?.state).toBe('established') + expect(entry?.metadata?.childSdkSessionId).toBe('sdk-child-f') + expect(typeof entry?.metadata?.establishedAt).toBe('number') + expect(report).toEqual({ resumed: 1, flagged: 0, recoveryRequired: 0 }) + }) + + test('does not backfill when the committed child session is still pending', async () => { + currentSession() + journalFork({ + recordId: 'g'.repeat(16), + steps: ['child-created', 'owner-committed'], + status: 'committed', + state: 'binding-committed', + childSessionId: 'child-g', + }) + const states = new Map([ + ['child-g', { pendingFork: { transactionId: 'g'.repeat(16) }, checkoutStrategy: 'isolated' }], + ]) + + const report = await harness.svc.fork.reconcileForkJournal({ + resolveSessionForkState: forkStateResolver(states), + }) + + const entry = forkJournalEntries().find((e) => e.recordId === 'g'.repeat(16)) + expect(entry?.status).toBe('committed') + expect(entry?.metadata?.state).toBe('binding-committed') + expect(report).toEqual({ resumed: 0, flagged: 0, recoveryRequired: 0 }) + }) + + test('leaves an already-established committed entry untouched', async () => { + currentSession() + journalFork({ + recordId: 'h'.repeat(16), + steps: ['child-created', 'owner-committed'], + status: 'committed', + state: 'established', + childSessionId: 'child-h', + }) + const states = new Map([ + ['child-h', { sdkSessionId: 'sdk-child-h', pendingFork: null, checkoutStrategy: 'isolated' }], + ]) + + const report = await harness.svc.fork.reconcileForkJournal({ + resolveSessionForkState: forkStateResolver(states), + }) + + const entry = forkJournalEntries().find((e) => e.recordId === 'h'.repeat(16)) + expect(entry?.status).toBe('committed') + expect(entry?.metadata?.state).toBe('established') + expect(entry?.metadata?.childSdkSessionId).toBeUndefined() + expect(report).toEqual({ resumed: 0, flagged: 0, recoveryRequired: 0 }) + }) +}) + +// --------------------------------------------------------------------------- +// Orphan ledger reconciliation — the journal-backed startup wiring +// --------------------------------------------------------------------------- + +describe('IsolatedConversationForkService orphan reconciliation', () => { + /** The startup `isEstablished` wiring: the fork journal records childSessionId + * + established state under the same transaction id as the orphan attempt. */ + function isEstablished(transactionId: string): boolean { + return harness.svc.journal + .entries() + .some( + (entry) => + entry.op === 'fork' && + entry.recordId === transactionId && + entry.status === 'committed' && + entry.metadata?.state === 'established', + ) + } + + test('resolves a failed orphan attempt once the same transaction establishes', async () => { + currentSession() + const transactionId = 'i'.repeat(16) + // The failed establishment attempt is on the ledger; the journal entry is + // still committed-but-unestablished (the establish window crash state). + const orphan = harness.svc.forkOrphans.recordAttempt({ + transactionId, + idempotencyKey: 'idem-key-1', + parentSdkSessionId: 'parent', + parentSdkTurnId: 'turn-1', + executionCwd: '/wt/child', + result: 'failed', + }) + journalFork({ + recordId: transactionId, + steps: ['child-created', 'owner-committed'], + status: 'committed', + state: 'binding-committed', + childSessionId: 'child-i', + }) + + // Before establishment: the orphan is retained. + const before = harness.svc.forkOrphans.reconcile({ isEstablished }) + expect(before).toEqual({ resolved: 0, retained: 1, expiredUnresolved: 0, expiredAttemptIds: [] }) + + // The transaction later establishes (first Send, journal marker durable). + harness.svc.fork.markEstablished(transactionId, 'sdk-child-i') + + // Startup reconciliation resolves the orphan and never touches the entry. + const report = harness.svc.forkOrphans.reconcile({ isEstablished }) + expect(report).toEqual({ resolved: 1, retained: 0, expiredUnresolved: 0, expiredAttemptIds: [] }) + expect(harness.svc.forkOrphans.entries()).toHaveLength(0) + expect(harness.svc.forkOrphans.entries({ includeResolved: true })).toHaveLength(1) + expect(harness.svc.forkOrphans.entries({ includeResolved: true })[0]).toMatchObject({ + attemptId: orphan.attemptId, + transactionId, + idempotencyKey: 'idem-key-1', + }) + }) + + test('keeps unrelated orphans when their transaction never established', async () => { + currentSession() + harness.svc.forkOrphans.recordAttempt({ + transactionId: 'unrelated-txn', + idempotencyKey: 'idem-key-2', + parentSdkSessionId: 'parent', + parentSdkTurnId: 'turn-1', + executionCwd: '/wt/other', + result: 'unverified', + }) + + const report = harness.svc.forkOrphans.reconcile({ isEstablished }) + + expect(report).toEqual({ resolved: 0, retained: 1, expiredUnresolved: 0, expiredAttemptIds: [] }) + expect(harness.svc.forkOrphans.entries()).toHaveLength(1) + expect(harness.svc.forkOrphans.entries()[0]?.transactionId).toBe('unrelated-txn') + // No session was created or bound by the reconcile. + expect(harness.childCalls).toHaveLength(0) + }) +}) diff --git a/packages/server-core/src/git/__tests__/worktree-handoff.test.ts b/packages/server-core/src/git/__tests__/worktree-handoff.test.ts index 9f558382..f3050c94 100644 --- a/packages/server-core/src/git/__tests__/worktree-handoff.test.ts +++ b/packages/server-core/src/git/__tests__/worktree-handoff.test.ts @@ -9,7 +9,7 @@ import type { WorktreeHandoffProviderCapability, } from '@kata-sh/shared/protocol' import type { ExecutionCwdRebindCapability } from '@kata-sh/shared/agent/backend' -import { createDeterministicHandoffAdapter } from '@kata-sh/shared/agent/backend' +import { createDeterministicHandoffAdapter } from '@kata-sh/shared/agent/testing' import { initRepo, makeTmpDir, cleanup, git, writeFile } from './test-helpers' const cleanups: string[] = [] diff --git a/packages/server-core/src/git/__tests__/worktree-journal.test.ts b/packages/server-core/src/git/__tests__/worktree-journal.test.ts index d35b56bd..a5f12940 100644 --- a/packages/server-core/src/git/__tests__/worktree-journal.test.ts +++ b/packages/server-core/src/git/__tests__/worktree-journal.test.ts @@ -68,6 +68,28 @@ describe('WorktreeJournal', () => { expect(journal.inProgress()).toEqual([]) }) + test('compaction keeps committed fork entries so establishment metadata and orphan resolution survive restarts', () => { + const root = tmp() + const journal = new WorktreeJournal(join(root, 'journal.jsonl')) + + // A committed fork entry receives its establishment marker AFTER the + // commit (first-Send establish flow) and cross-restart orphan resolution + // matches ledger attempts against committed+established fork entries — + // compacting it away would make the orphan ledger permanently unresolvable. + const fork = journal.begin({ op: 'fork', recordId: '0123456789abcdef', sessionIds: ['s1'], policyVersion: 1 }) + journal.commit(fork.journalId, '0123456789abcdef') + const handoff = journal.begin({ op: 'handoff', recordId: 'fedcba9876543210', sessionIds: ['s2'], policyVersion: 1 }) + journal.commit(handoff.journalId, 'm2') + + journal.compact() + const after = journal.entries() + expect(after.map((e) => e.op)).toEqual(['fork']) + expect(after[0]?.status).toBe('committed') + // Establishment metadata can still be written to the retained entry. + journal.updateMetadata(after[0]!.journalId, { state: 'established', childSdkSessionId: 'sdk-child' }) + expect(new WorktreeJournal(join(root, 'journal.jsonl')).entries()[0]?.metadata?.state).toBe('established') + }) + test('appends entries from separate instances without losing records', () => { const root = tmp() const path = join(root, 'journal.jsonl') diff --git a/packages/server-core/src/git/fork-orphan-ledger.ts b/packages/server-core/src/git/fork-orphan-ledger.ts new file mode 100644 index 00000000..b6062ff6 --- /dev/null +++ b/packages/server-core/src/git/fork-orphan-ledger.ts @@ -0,0 +1,204 @@ +/** + * Durable orphan ledger for isolated-fork establishment attempts. + * + * When first-Send provider establishment THROWS, the server cannot know + * whether the provider created a native child SDK session before throwing. + * Every failed/malformed attempt is appended here so an unlinked provider + * artifact is never silently attached: reconciliation (Task 5) reads this + * ledger to find provider children that may exist without a persisted + * session link. + * + * The ledger is an append-only JSONL file next to the registry (mirroring + * the worktree journal). Entries are never rewritten; concurrent writers + * serialize on a small file lock. + */ + +import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { randomBytes } from 'node:crypto' +import { CrossProcessFileLock } from './mutation-lock' + +export type ForkOrphanResult = 'failed' | 'unverified' + +export interface ForkOrphanEntry { + /** Opaque ledger entry id (dedupe/reconciliation handle). */ + attemptId: string + /** Fork transaction id whose child establishment was attempted. */ + transactionId: string + /** Persisted idempotency key used for the attempt (never regenerated). */ + idempotencyKey: string + parentSdkSessionId: string + parentSdkTurnId: string + executionCwd: string + attemptedAt: number + /** 'failed': establish threw (provider may have created an artifact). */ + result: ForkOrphanResult + error?: string +} + +/** Append-only resolution marker line written by reconcile (Task 5). */ +export interface ForkOrphanResolutionMarker { + type: 'resolution' + attemptId: string + result: 'resolved' + resolvedAt: number +} + +export interface ForkOrphanReconcileInput { + /** + * True when the fork transaction is now durably established (the fork + * journal records the child session + established state under the same + * transaction id). The caller wires this to the journal. + */ + isEstablished: (transactionId: string) => boolean + /** Injectable clock for deterministic retention tests (defaults to now). */ + now?: number + /** Retention window for unresolved entries (defaults to 30 days). */ + retentionMs?: number +} + +export interface ForkOrphanReconcileReport { + /** Ledger entries retired because their transaction later established. */ + resolved: number + /** Unresolved entries still within the retention window. */ + retained: number + /** Unresolved entries older than the retention window (operator/UI decides). */ + expiredUnresolved: number + /** Attempt ids of the expired unresolved entries (surfaced, never deleted). */ + expiredAttemptIds: string[] +} + +export class ForkOrphanLedger { + private readonly path: string + private readonly lock: CrossProcessFileLock + + constructor(path: string) { + this.path = path + this.lock = new CrossProcessFileLock(`${path}.lock`) + mkdirSync(dirname(path), { recursive: true }) + } + + getLedgerPath(): string { + return this.path + } + + /** Append a failed/unverified establishment attempt (best-effort durable). */ + recordAttempt( + input: Omit, + ): ForkOrphanEntry { + const entry: ForkOrphanEntry = { + attemptId: randomBytes(8).toString('hex'), + attemptedAt: Date.now(), + ...input, + } + this.lock.runSync(() => { + appendFileSync(this.path, `${JSON.stringify(entry)}\n`, { + encoding: 'utf8', + mode: 0o600, + }) + }) + return entry + } + + /** + * Every ledger entry, oldest first, excluding retired (resolved) entries. + * Pass `includeResolved: true` to also return entries that received a + * resolution marker. Torn tail lines are crash artifacts. + */ + entries(options?: { includeResolved?: boolean }): ForkOrphanEntry[] { + if (!existsSync(this.path)) return [] + const raw = readFileSync(this.path, 'utf8') + const resolvedAttemptIds = new Set() + const all: ForkOrphanEntry[] = [] + for (const line of raw.split('\n')) { + if (!line.trim()) continue + try { + const parsed = JSON.parse(line) as Partial & Partial + if (parsed.type === 'resolution') { + if (typeof parsed.attemptId === 'string' && parsed.result === 'resolved') { + resolvedAttemptIds.add(parsed.attemptId) + } + continue + } + if ( + typeof parsed.attemptId !== 'string' || + typeof parsed.transactionId !== 'string' || + typeof parsed.idempotencyKey !== 'string' + ) { + continue + } + all.push({ + attemptId: parsed.attemptId, + transactionId: parsed.transactionId, + idempotencyKey: parsed.idempotencyKey, + parentSdkSessionId: typeof parsed.parentSdkSessionId === 'string' ? parsed.parentSdkSessionId : '', + parentSdkTurnId: typeof parsed.parentSdkTurnId === 'string' ? parsed.parentSdkTurnId : '', + executionCwd: typeof parsed.executionCwd === 'string' ? parsed.executionCwd : '', + attemptedAt: Number.isFinite(parsed.attemptedAt) ? parsed.attemptedAt! : 0, + result: parsed.result === 'unverified' ? 'unverified' : 'failed', + ...(typeof parsed.error === 'string' ? { error: parsed.error } : {}), + }) + } catch { + // A torn tail line is a crash artifact, never a fatal read error. + } + } + // Resolution markers always follow their attempt line, so resolved + // attempts are excluded only after the full scan. + return options?.includeResolved ? all : all.filter((entry) => !resolvedAttemptIds.has(entry.attemptId)) + } + + /** + * Reconcile the ledger (Task 5). An entry is retired when its fork + * transaction is now durably established — the establish succeeded after + * the failed attempt, and the journal records the child session + established + * state under the same transaction id. Retirement is an append-only + * resolution marker line; entries are never rewritten or deleted, so the + * ledger stays a complete durable audit trail. Unresolved entries older than + * the retention window are surfaced in the report (never auto-deleted — the + * operator/UI decides; Task 8/UAT owns the surface). Reconciliation NEVER + * attaches an orphan to a session binding: the ledger has no session access + * and only annotates its own file. + */ + reconcile(input: ForkOrphanReconcileInput): ForkOrphanReconcileReport { + const now = input.now ?? Date.now() + const retentionMs = input.retentionMs ?? 30 * 24 * 60 * 60 * 1000 + const report: ForkOrphanReconcileReport = { + resolved: 0, + retained: 0, + expiredUnresolved: 0, + expiredAttemptIds: [], + } + const markers: string[] = [] + for (const entry of this.entries()) { + if (input.isEstablished(entry.transactionId)) { + markers.push( + JSON.stringify({ + type: 'resolution', + attemptId: entry.attemptId, + result: 'resolved', + resolvedAt: now, + } satisfies ForkOrphanResolutionMarker), + ) + report.resolved += 1 + continue + } + if (now - entry.attemptedAt > retentionMs) { + report.expiredUnresolved += 1 + report.expiredAttemptIds.push(entry.attemptId) + continue + } + report.retained += 1 + } + if (markers.length > 0) { + this.lock.runSync(() => { + appendFileSync(this.path, `${markers.join('\n')}\n`, { encoding: 'utf8', mode: 0o600 }) + }) + } + return report + } +} + +/** Ledger path next to a registry file (mirrors journalPathFor). */ +export function forkOrphanLedgerPathFor(registryPath: string): string { + return join(dirname(registryPath), 'fork-orphan-ledger.jsonl') +} diff --git a/packages/server-core/src/git/index.ts b/packages/server-core/src/git/index.ts index 75cda08b..57335130 100644 --- a/packages/server-core/src/git/index.ts +++ b/packages/server-core/src/git/index.ts @@ -22,6 +22,8 @@ import { WorktreeLifecycleService, type WorktreeLifecycleDeps } from './worktree import { PathLeaseManager } from './path-leases' import { WorktreeJournal, journalPathFor } from './worktree-journal' import { WorktreeHandoffService, type WorktreeHandoffHooks } from './worktree-handoff-service' +import { IsolatedConversationForkService, type ConversationForkHooks } from './isolated-conversation-fork-service' +import { ForkOrphanLedger, forkOrphanLedgerPathFor } from './fork-orphan-ledger' export * from './command-runner' export * from './repository-service' @@ -39,6 +41,8 @@ export * from './worktree-lifecycle-service' export * from './path-leases' export * from './worktree-journal' export * from './worktree-handoff-service' +export * from './isolated-conversation-fork-service' +export * from './fork-orphan-ledger' export interface GitServices { repository: RepositoryService @@ -62,6 +66,10 @@ export interface GitServices { journal: WorktreeJournal /** Phase 3: conflict-safe checkout handoff. */ handoff: WorktreeHandoffService + /** Phase 4: isolated conversation forks (eligibility preview + seed capture). */ + fork: IsolatedConversationForkService + /** Phase 4: durable orphan ledger for failed/unverified fork establishments. */ + forkOrphans: ForkOrphanLedger } export interface GitServicesConfig { @@ -88,6 +96,8 @@ export interface GitServicesConfig { > /** Optional handoff hooks (session/capability resolution), wired by the host. */ handoffHooks?: WorktreeHandoffHooks + /** Optional fork hooks (session/capability resolution), wired by the host. */ + forkHooks?: ConversationForkHooks } export function createGitServices(config: GitServicesConfig): GitServices { @@ -119,6 +129,7 @@ export function createGitServices(config: GitServicesConfig): GitServices { const snapshots = new WorktreeSnapshotService(config.snapshotsRoot ?? join(CONFIG_DIR, 'snapshots')) const pathLeases = new PathLeaseManager(join(lockBase, 'path-leases')) const journal = new WorktreeJournal(journalPathFor(config.registryPath)) + const forkOrphans = new ForkOrphanLedger(forkOrphanLedgerPathFor(config.registryPath)) const lifecycle = new WorktreeLifecycleService({ registry, snapshots, @@ -144,7 +155,20 @@ export function createGitServices(config: GitServicesConfig): GitServices { serverId: config.serverId ?? 'local', hooks: config.handoffHooks, }) - lifecycle.setHooks({ isPathFenced: (path) => handoff.isPathFenced(path) }) + const fork = new IsolatedConversationForkService({ + registry, + snapshots, + worktrees, + mutationLock, + leases: pathLeases, + journal, + lifecycle, + repository, + settings: worktreeSettings, + serverId: config.serverId ?? 'local', + hooks: config.forkHooks, + }) + lifecycle.setHooks({ isPathFenced: (path) => handoff.isPathFenced(path) || fork.isPathFenced(path) }) return { repository, worktrees, @@ -158,6 +182,8 @@ export function createGitServices(config: GitServicesConfig): GitServices { pathLeases, journal, handoff, + fork, + forkOrphans, get worktreeRoot() { return worktrees.getWorktreeRoot() }, diff --git a/packages/server-core/src/git/isolated-conversation-fork-service.ts b/packages/server-core/src/git/isolated-conversation-fork-service.ts new file mode 100644 index 00000000..acd97f4a --- /dev/null +++ b/packages/server-core/src/git/isolated-conversation-fork-service.ts @@ -0,0 +1,2136 @@ +/** + * IsolatedConversationForkService — Worktree V2 Phase 4 eligibility preview + * and seed capture for isolated conversation forks. + * + * Conversation branching keeps the existing **Shared worktree** behavior and + * adds an explicit **New isolated worktree** alternative: a separately named + * managed worktree, Git branch, Kata session, and execution runtime at the + * source conversation's current head, leaving the source conversation and + * checkout unchanged. + * + * This service owns the eligibility/preview surface (typed blockers, no + * mutation, no seed written during preview) and the fingerprinted seed + * capture that pins the source checkout's exact captured HEAD. Git services + * own target and seed lifecycle; session/provider code owns conversation + * ancestry and the pending native-fork intent via the host hooks. + * + * Isolated is offered only when Worktree V2 is effective, the source session + * is idle at its current conversation head, Git state is supported, and the + * provider adapter advertises strict safe cross-CWD native fork. Unsupported + * providers receive a typed blocker with no fallback. Historical conversation + * points remain available only through the existing shared branching; this + * phase does not reconstruct historical Git state. + */ + +import { createHash, randomBytes } from 'node:crypto' +import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync, statSync } from 'node:fs' +import { isAbsolute, join, relative, resolve as resolvePath } from 'node:path' +import type { + ConversationForkBlockerCode, + ConversationForkCancelInput, + ConversationForkCommitSummary, + ConversationForkConfirmInput, + ConversationForkPreview, + ConversationForkPreviewInput, + ConversationForkProviderCapability, + ConversationForkRecoverInput, + ConversationForkRecoveryState, + ConversationForkResult, + ConversationForkStatus, + ConversationForkStatusInput, + ConversationForkStrategy, + ManagedWorktreeRecordV2, + ManagedWorktreeSnapshotMeta, + SessionCheckout, + SessionCheckoutV2, +} from '@kata-sh/shared/protocol' +import { CONVERSATION_FORK_RECOVERY_STATES } from '@kata-sh/shared/protocol' +import { isGitWorkspaceV1Enabled, isWorktreeV2Enabled } from '@kata-sh/shared/feature-flags' +import type { StrictConversationForkCapability } from '@kata-sh/shared/agent/backend' +import { runGit, runGitBuffer, splitNul } from './command-runner' +import { removeCheckoutFiles } from './managed-worktree-service' +import { listWorktreeIncludeFiles } from './worktree-include' +import { + WORKTREE_SNAPSHOT_MAX_BYTES, + WORKTREE_SNAPSHOT_MAX_FILES, + WorktreeSnapshotError, + computeWorktreeFingerprint, + type WorktreeSnapshotService, +} from './worktree-snapshot-service' +import type { ManagedWorktreeService } from './managed-worktree-service' +import type { MutationLock } from './mutation-lock' +import type { PathLeaseManager } from './path-leases' +import type { WorktreeJournal, WorktreeJournalEntry } from './worktree-journal' +import type { WorktreeLifecycleService } from './worktree-lifecycle-service' +import type { RepositoryService } from './repository-service' +import type { WorktreeRegistry } from './worktree-registry' +import type { WorktreeSettingsService } from './worktree-settings-service' + +export type ConversationForkErrorCode = + | 'FORK_SESSION_UNKNOWN' + | 'FORK_TRANSACTION_UNKNOWN' + | 'FORK_STRATEGY_MISMATCH' + | 'FORK_NOT_IMPLEMENTED' + | 'FORK_HOOK_NOT_WIRED' + | 'FORK_TARGET_FAILED' + | 'FORK_COMPENSATION_FAILED' + | 'FORK_SEED_LIMIT' + | 'FORK_SEED_CAPTURE_FAILED' + | 'FORK_SEED_REMOVE_FAILED' + +export class ConversationForkError extends Error { + readonly code: ConversationForkErrorCode + constructor(code: ConversationForkErrorCode, message: string) { + super(message) + this.name = 'ConversationForkError' + this.code = code + } +} + +function sanitizeError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + // Strip POSIX and Windows absolute path tokens; server layout never crosses + // the wire. Keep a bounded diagnostic rather than returning raw exceptions. + return message + .replace(/(?:[A-Za-z]:[\\/]|\\\\|\/)[^\s'"`]+/g, '…') + .slice(0, 500) +} + +function newTransactionId(): string { + return randomBytes(8).toString('hex') +} + +function newPathToken(): string { + return randomBytes(4).toString('hex') +} + +function sha256(value: string | Buffer): string { + return createHash('sha256').update(value).digest('hex') +} + +function lstatSyncSafe(path: string): 'symlink' | 'other' | null { + try { + const stat = lstatSync(path) + if (stat.isSymbolicLink()) return 'symlink' + return 'other' + } catch { + return null + } +} + +/** True when `child` is contained within `parent` (both resolved, non-empty). */ +function isContainedPath(parent: string, child: string): boolean { + const rel = relative(parent, child) + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel) +} + +/** Resolve a path, following symlinks when possible (tolerant of absence). */ +function realpathSafe(path: string): string { + try { + return resolvePath(realpathSync(path)) + } catch { + return resolvePath(path) + } +} + +/** Session facts the host supplies for one fork evaluation. */ +export interface ForkSessionInfo { + /** The session's active checkout path (never client-nominated). */ + checkoutPath: string + workspaceId: string + /** Persisted checkout metadata (null for legacy/current sessions). */ + checkout: SessionCheckout | null + /** Immutable transcript CWD (session.sdkCwd) — never rewritten by a fork. */ + transcriptCwd: string + /** Current conversation head of the source session. */ + conversationHead: { + messageId: string + turnId: string + } + /** Provider SDK session identity of the source (anchor lineage). */ + sdkSessionId?: string + /** + * Requested fork point message ID; defaults to the current conversation + * head. Older points cannot select isolated (non-head-source). + */ + forkPointMessageId?: string + /** Provider turn anchor at the requested fork point. */ + forkPointTurnId?: string +} + +/** Host input for durable child-session creation of an isolated fork. */ +export interface ConversationForkChildSessionInput { + transactionId: string + /** Source Kata session the fork is created from. */ + parentSessionId: string + /** Parent provider SDK session identity (anchor lineage). */ + parentSdkSessionId: string | undefined + /** Parent provider turn anchor at the branch point. */ + parentSdkTurnId: string | undefined + /** Immutable transcript lookup identity — never rewritten by the fork. */ + transcriptCwd: string + /** Destination execution CWD every runtime must resolve to. */ + executionCwd: string + /** Durable checkout binding for the isolated target (always a V2 managed worktree). */ + checkout: SessionCheckoutV2 + /** Generated/edited name suffix of the target worktree. */ + nameSuffix: string + /** Source message ID at the current conversation head (the branch point). */ + sourceMessageId: string + workspaceId: string + /** Requested fork point message ID (the current conversation head for isolated). */ + forkPointMessageId: string +} + +export interface ConversationForkHooks { + /** Resolve persisted session facts; null for an unknown session. */ + resolveSession?: (sessionId: string) => ForkSessionInfo | null + /** Resolve the provider adapter's advertised strict fork capability. */ + resolveCapability?: (sessionId: string) => ConversationForkProviderCapability | null + /** Resolve the live adapter used for first-Send native-fork establishment. */ + resolveCapabilityAdapter?: (sessionId: string) => StrictConversationForkCapability | null + /** Whether a session is running an agent turn. */ + isSessionActive?: (sessionId: string) => boolean + /** Quiesce the session's runtime; false when it cannot quiesce. */ + quiesceRuntimes?: (sessionIds: string[]) => Promise + /** + * Durable fork-child session state for startup reconciliation (Task 5); + * null for an unknown session. The pendingFork transaction id ties a + * journal entry to a live published-but-unestablished child; sdkSessionId + + * checkoutStrategy 'isolated' + no pendingFork identify an established child + * whose journal marker a crash may have lost. SessionManager implements + * this; the git.ts startup reconciliation may also pass it explicitly. + */ + resolveSessionForkState?: (sessionId: string) => SessionForkState | null + /** + * Create the durable child Kata session for an isolated fork target. The + * service journals the returned child session id after the hook returns; + * the child must not be visible to the client until the commit marker is + * durable. Absent hook → typed hook-not-wired error, never a fabricated + * child. SessionManager implements the durable pending child + * creation (pendingFork intent) and first-Send establishment. + */ + createForkChildSession?: (input: ConversationForkChildSessionInput) => Promise + /** + * Remove a child Kata session created by a rolled-back fork transaction + * (compensation). Absent hook → the compensation fails closed and the + * transaction stays recovery-required. + */ + deleteForkChildSession?: (childSessionId: string) => Promise +} + +/** + * Durable fork-child session facts startup reconciliation classifies against + * (Task 5). Matches what SessionManager can read from the managed session + * without the provider adapter. + */ +export interface SessionForkState { + /** Durable child provider identity; present after first-Send establishment. */ + sdkSessionId?: string + /** Durable pending-fork intent; present until establishment retires it. */ + pendingFork?: { transactionId: string } | null + /** Checkout provenance recorded at fork creation ('isolated' for forks). */ + checkoutStrategy?: string +} + +/** Startup reconciliation report for interrupted fork journal entries. */ +export interface ForkJournalReconcileReport { + /** Committed entries whose lost established marker was backfilled. */ + resumed: number + /** In-progress child-created entries newly classified recovery-required. */ + flagged: number + /** Total entries surfacing recovery-required after reconciliation. */ + recoveryRequired: number +} + +export interface ConversationForkDeps { + registry: WorktreeRegistry + snapshots: WorktreeSnapshotService + worktrees: ManagedWorktreeService + mutationLock: MutationLock + leases: PathLeaseManager + journal: WorktreeJournal + lifecycle: WorktreeLifecycleService + repository: RepositoryService + settings: WorktreeSettingsService + /** Stable server identity stamped into previews. */ + serverId: string + hooks?: ConversationForkHooks +} + +/** One in-flight fork transaction (preview → confirm → recover/cancel). */ +interface ForkTransaction { + transactionId: string + sessionId: string + strategy: ConversationForkStrategy + state: ConversationForkRecoveryState + fingerprint: string + /** Generated/edited name for the new managed worktree (isolated only). */ + nameSuffix?: string + /** Pre-issued path token pinning the destination path from the preview. */ + pathToken?: string + sourcePath: string + destinationPath: string + repositoryRoot: string + gitCommonDir: string + /** Branch the isolated target carries, or '' for shared. */ + expectedBranch: string + /** Idempotent steps completed so far, in order. */ + steps: string[] + /** Durable journal identity. */ + journalId: string + providerCapability?: ConversationForkProviderCapability + transcriptCwd?: string + sourceLeases: string[] + startedAt: number + /** Source HEAD OID pinned by the captured seed (journaled before capture). */ + headOid?: string + /** Seed snapshot id captured by this transaction (GC-retained until commit). */ + seedSnapshotId?: string + /** Managed worktree record id of the materialized target. */ + managedWorktreeId?: string + /** Child session id returned by the host hook (journaled after creation). */ + childSessionId?: string + /** Child checkout binding built from the materialized target. */ + childCheckout?: SessionCheckoutV2 + /** Commit timestamp, set when the binding commits. */ + committedAt?: number +} + +/** Facts collected during one fork preview evaluation. */ +interface ForkFacts { + blocker: ConversationForkBlockerCode | null + blockerReason?: string + source: ConversationForkPreview['source'] + destination: ConversationForkPreview['destination'] + excludedIgnoredPolicy: { includeOnly: true; includeFileCount: number } + currentHead: boolean + /** Internal canonical source checkout path (not part of the wire preview). */ + sourceCheckoutPath: string + repositoryRoot: string + gitCommonDir: string + expectedBranch: string + nameSuffix?: string + pathToken?: string + ownerSessionIds: string[] +} + +export class IsolatedConversationForkService { + private readonly deps: ConversationForkDeps + private readonly transactions = new Map() + private previewSerial: Promise = Promise.resolve() + /** Seed snapshotId → source repository root, for Task 3 seed cleanup. */ + private readonly seedRepositoryRoots = new Map() + + constructor(deps: ConversationForkDeps) { + this.deps = deps + } + + /** Install runtime/session hooks late (the host wires them after construction). */ + setHooks(hooks: ConversationForkHooks): void { + this.deps.hooks = { ...this.deps.hooks, ...hooks } + } + + private get hooks(): ConversationForkHooks { + return this.deps.hooks ?? {} + } + + // ------------------------------------------------------------------------- + // Fences + // ------------------------------------------------------------------------- + + /** True while a pending/recovery fork owns a session fence. */ + isSessionFenced(sessionId: string): boolean { + return this.transactions.has(sessionId) + } + + /** True while a pending/recovery fork owns a canonical path fence. */ + isPathFenced(path: string): boolean { + const canonical = resolvePath(path) + for (const txn of this.transactions.values()) { + if (resolvePath(txn.sourcePath) === canonical || resolvePath(txn.destinationPath) === canonical) return true + } + return false + } + + /** Return the authoritative durable attempt for a transaction identity. */ + private findLatestForkEntry(recordId: string, sessionId?: string): WorktreeJournalEntry | undefined { + const entries = this.deps.journal.entries() + let newest: WorktreeJournalEntry | undefined + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index] + if ( + entry.op !== 'fork' || + entry.recordId !== recordId || + (sessionId && !entry.sessionIds.includes(sessionId)) + ) { + continue + } + newest ??= entry + // A committed entry is the publication authority. Prefer the newest + // committed attempt when stale/replayed records share the same ID. + if (entry.status === 'committed') return entry + } + return newest + } + + /** Rehydrate unresolved durable entries into the runtime fence map. */ + private restoreJournalFence(entry: WorktreeJournalEntry): void { + const unresolved = + entry.status === 'in-progress' || + (entry.status === 'failed' && entry.metadata?.state === 'recovery-required') + if (!unresolved) return + for (const sessionId of entry.sessionIds) { + const transaction = this.rehydrateTransaction(entry, sessionId) + if (transaction) this.transactions.set(sessionId, transaction) + } + } + + // ------------------------------------------------------------------------- + // Status + // ------------------------------------------------------------------------- + + /** + * Status of the session's fork transaction: the live in-memory transaction + * when one exists, otherwise a durable in-progress journal transaction + * (restart). The reported state prefers the durable journal metadata state + * (the authoritative "how far did we get" signal) over the in-memory one. + */ + async status(input: ConversationForkStatusInput): Promise { + const sessionId = input.sessionId + const txn = this.transactions.get(sessionId) + if (txn) { + const entry = this.deps.journal.entries().find((candidate) => candidate.journalId === txn.journalId) + return this.statusFor(txn, entry) + } + const entry = this.deps.journal.entries().find( + (candidate) => + candidate.op === 'fork' && + candidate.sessionIds.includes(sessionId) && + (candidate.status === 'in-progress' || + (candidate.status === 'failed' && candidate.metadata?.state === 'recovery-required')), + ) + if (!entry) return { active: false } + const rehydrated = this.rehydrateTransaction(entry, sessionId) + if (!rehydrated) return { active: false } + this.transactions.set(sessionId, rehydrated) + return this.statusFor(rehydrated, entry) + } + + /** Build the active status payload for a transaction (optionally with its journal entry). */ + private statusFor(txn: ForkTransaction, entry?: WorktreeJournalEntry): ConversationForkStatus { + // Prefer the durable journal metadata state over the in-memory one for any + // unresolved entry (in-progress AND failed/recovery-required): the journal + // is the authoritative "how far did we get" signal, and a compensation + // failure must surface as recovery-required, not as the stale in-memory + // step state the transaction had when the confirm threw. Journal-internal + // markers outside the protocol union (preview-cancelled, rolled-back) are + // never surfaced: a corrupted journal cannot leak a non-union wire value. + const durableState = entry?.metadata?.state + const state: ConversationForkRecoveryState = + entry && entry.status !== 'committed' && typeof durableState === 'string' + ? CONVERSATION_FORK_RECOVERY_STATES.includes(durableState as ConversationForkRecoveryState) + ? (durableState as ConversationForkRecoveryState) + : txn.state + : txn.state + return { + active: true, + transactionId: txn.transactionId, + strategy: txn.strategy, + state, + // The fork seed is the retained snapshot authority backing rollback/recovery. + ...(txn.seedSnapshotId ? { retainedSnapshotId: txn.seedSnapshotId } : {}), + since: txn.startedAt, + // Before the first Send the child provider identity is always pending. + providerIdentity: { status: 'pending' }, + } + } + + // ------------------------------------------------------------------------- + // Establishment + // ------------------------------------------------------------------------- + + /** + * Record the child's first-Send provider establishment on the committed + * fork journal entry (metadata-only; the entry status stays 'committed'). + * The child session record is authoritative: returns false when the + * committed entry is missing (the caller logs-and-continues, never fails + * Send for a bookkeeping miss). + */ + markEstablished(transactionId: string, childSdkSessionId: string): boolean { + const entry = this.findLatestForkEntry(transactionId) + if (!entry || entry.status !== 'committed') { + // Missing/not-yet-committed journal entry: the durable child session + // record is authoritative; skip without failing the establishment. + return false + } + this.deps.journal.updateMetadata(entry.journalId, { + state: 'established', + childSdkSessionId, + establishedAt: Date.now(), + }) + return true + } + + /** + * Startup reconciliation of interrupted fork transactions. Classifies every + * durable 'fork' journal entry exactly once from journal evidence (never + * from a missing-path heuristic): + * + * - Committed entries stay committed. When a committed entry records a child + * session that is durably established (sdkSessionId + 'isolated' strategy + * + no pendingFork) but the journal lacks the established marker — a crash + * between the child-session flush and markEstablished — the marker is + * backfilled from the session record. + * - In-progress entries WITHOUT the child-created step stay in-progress: + * recover() resumes them from the journal (never auto-compensated; the + * child may have been created and only the durable steps decide). + * - In-progress entries WITH child-created are classified recovery-required: + * the durable commit point may be ambiguous (the child session may or may + * not exist; SessionManager restore from disk decides). The exception is + * a live pending child whose pendingFork.transactionId matches the entry's + * recordId: the first-Send establish flow owns it and the entry is left + * untouched. + * - Failed entries stay failed (recover() throws the typed fork error). + * + * Reconciliation never edits the source and never fabricates a child. + */ + async reconcileForkJournal(options?: { + resolveSessionForkState?: (sessionId: string) => SessionForkState | null + }): Promise { + const report: ForkJournalReconcileReport = { resumed: 0, flagged: 0, recoveryRequired: 0 } + const resolveSessionForkState = options?.resolveSessionForkState ?? this.hooks.resolveSessionForkState + for (const entry of this.deps.journal.entries()) { + if (entry.op !== 'fork') continue + this.restoreJournalFence(entry) + const metadata = entry.metadata + const childSessionId = typeof metadata?.childSessionId === 'string' ? metadata.childSessionId : undefined + + if (entry.status === 'committed') { + // Committed entries stay committed; only the lost established marker + // is backfilled from the durable child session record. + if (metadata?.state === 'recovery-required') { + report.recoveryRequired += 1 + continue + } + if (metadata?.state !== 'established' && childSessionId && resolveSessionForkState) { + const sessionState = resolveSessionForkState(childSessionId) + if ( + sessionState && + typeof sessionState.sdkSessionId === 'string' && + sessionState.sdkSessionId.trim() !== '' && + !sessionState.pendingFork && + sessionState.checkoutStrategy === 'isolated' + ) { + this.deps.journal.updateMetadata(entry.journalId, { + state: 'established', + childSdkSessionId: sessionState.sdkSessionId, + establishedAt: Date.now(), + }) + report.resumed += 1 + } + } + continue + } + + if (entry.status === 'failed') { + // Failed entries (blocked confirms, compensation failures) are not + // resumable; recover() surfaces the typed error. Report pre-existing + // recovery-required state so the operator sees the full inventory. + if (metadata?.state === 'recovery-required') report.recoveryRequired += 1 + continue + } + + if (entry.status !== 'in-progress') continue + + if (!entry.steps.includes('child-created')) { + // Steps through target-verified only: recover() resumes the confirm + // from the journal. Do NOT auto-compensate. + continue + } + // Child-created is durable but the commit point is not. A live pending + // child (recordId === pendingFork.transactionId) is owned by the + // first-Send establish flow; anything else may be an orphaned child and + // surfaces as recovery-required for explicit recovery. + if (childSessionId && resolveSessionForkState) { + const sessionState = resolveSessionForkState(childSessionId) + if (sessionState?.pendingFork?.transactionId === entry.recordId) { + continue + } + } + this.deps.journal.updateMetadata(entry.journalId, { + state: 'recovery-required', + recoveryReason: + 'The fork journal recorded a child session without a durable commit point; the child may or may not exist.', + reconciledAt: Date.now(), + }) + for (const sessionId of entry.sessionIds) { + const transaction = this.transactions.get(sessionId) + if (transaction?.journalId === entry.journalId) transaction.state = 'recovery-required' + } + report.flagged += 1 + report.recoveryRequired += 1 + } + return report + } + + // ------------------------------------------------------------------------- + // Cancel + // ------------------------------------------------------------------------- + + /** + * Cancel a pending preview transaction (dialog dismissed without confirming). + * Only a transaction whose durable journal entry is still a pure PENDING + * preview may be cancelled: any journaled confirm step (quiescence, seed + * capture, materialization, …) means the confirm is in flight and must not + * be discarded — recovery continues through recover(). The durable entry is + * recovered with a `preview-cancelled` marker so a restarted server never + * treats the dismissed preview as an in-progress fork (re-preview stays + * possible). Returns the post-cancel status. + */ + async cancel(input: ConversationForkCancelInput): Promise { + const sessionId = input.sessionId + const inMemory = this.transactions.get(sessionId) + if (inMemory && inMemory.transactionId !== input.transactionId) return this.status(input) + const entry = this.findLatestForkEntry(input.transactionId, sessionId) + if (!entry) return this.status(input) + const gitCommonDir = + typeof entry.metadata?.gitCommonDir === 'string' ? entry.metadata.gitCommonDir : undefined + // Serialize with confirm: a confirm may be revalidating before its first + // journal step, during which the durable entry still looks like a pure + // pending preview. Without the lock, a cancel in that window would mark + // the entry preview-cancelled while the in-flight confirm durably commits + // a child the journal no longer records. Taking the same common-directory + // mutation lock makes the guard below effective: once confirm has started + // (or finished), its first journal step (or commit marker) is durable and + // the cancel is refused. + if (!gitCommonDir) return this.status(input) + return this.deps.mutationLock.withLock(gitCommonDir, async () => { + const latest = this.findLatestForkEntry(input.transactionId, sessionId) + if ( + !latest || + latest.status !== 'in-progress' || + latest.steps.length > 0 || + latest.metadata?.state !== 'pending' + ) { + return this.status(input) + } + this.deps.journal.updateMetadata(latest.journalId, { state: 'preview-cancelled', cancelledAt: Date.now() }) + this.deps.journal.recover(latest.journalId, 'preview-cancelled') + this.transactions.delete(sessionId) + return { active: false } + }) + } + + // ------------------------------------------------------------------------- + // Preview + // ------------------------------------------------------------------------- + + async preview(input: ConversationForkPreviewInput): Promise { + let release!: () => void + const previous = this.previewSerial + this.previewSerial = new Promise((resolve) => { release = resolve }) + await previous + try { + return await this.previewInternal(input) + } finally { + release() + } + } + + private async previewInternal(input: ConversationForkPreviewInput): Promise { + const session = this.hooks.resolveSession?.(input.sessionId) + if (!session) { + // Preview never throws for eligibility failures: a missing source is a + // typed blocker, not an error (confirm/recover own the error surface). + return this.blockedPreview(input, 'missing-source', 'The source session could not be resolved.', this.sessionFactsFallback(input.sessionId, this.deps.serverId)) + } + const capability = this.hooks.resolveCapability?.(input.sessionId) ?? null + const facts = await this.gatherFacts(input, session, capability) + + const blocked = facts.blocker + ? { blocked: true as const, code: facts.blocker, reason: facts.blockerReason ?? '' } + : undefined + + let preview: ConversationForkPreview + if (blocked) { + preview = { + transactionId: newTransactionId(), + previewFingerprint: sha256(JSON.stringify({ blocked: facts.blocker, at: facts.sourceCheckoutPath })), + strategy: input.strategy, + providerCapability: capability ?? { adapterId: 'unknown', strictCrossCwdNativeFork: false }, + source: facts.source, + destination: facts.destination, + excludedIgnoredPolicy: facts.excludedIgnoredPolicy, + currentHead: facts.currentHead, + blocked, + } + return preview + } + + const transactionId = newTransactionId() + const fingerprint = await this.computeFingerprint(input, session, facts, capability, transactionId) + preview = { + transactionId, + previewFingerprint: fingerprint, + strategy: input.strategy, + providerCapability: capability ?? { adapterId: 'unknown', strictCrossCwdNativeFork: false }, + source: facts.source, + destination: facts.destination, + excludedIgnoredPolicy: facts.excludedIgnoredPolicy, + currentHead: facts.currentHead, + } + + if (input.strategy !== 'isolated-worktree') { + // Shared-worktree forks reuse the existing branch/shared-checkout path + // and own no transaction; only the isolated target does. + return preview + } + + return this.deps.registry.runExclusive(async () => { + const existingJournal = this.deps.journal.inProgress().find( + (entry) => entry.op === 'fork' && entry.sessionIds.includes(input.sessionId), + ) + if (existingJournal) { + return { + ...preview, + previewFingerprint: sha256(JSON.stringify({ blocked: 'fork-in-progress', sessionId: input.sessionId })), + blocked: { + blocked: true, + code: 'fork-in-progress' as const, + reason: 'A fork transaction is already in progress for this session.', + }, + } + } + const transaction: ForkTransaction = { + transactionId, + sessionId: input.sessionId, + strategy: input.strategy, + state: 'pending', + fingerprint, + nameSuffix: facts.nameSuffix, + pathToken: facts.pathToken, + sourcePath: facts.sourceCheckoutPath, + destinationPath: facts.destination.checkoutPath, + repositoryRoot: facts.repositoryRoot, + gitCommonDir: facts.gitCommonDir, + expectedBranch: facts.expectedBranch, + steps: [], + journalId: '', + providerCapability: capability ?? undefined, + transcriptCwd: session.transcriptCwd, + sourceLeases: [...facts.source.leases], + startedAt: Date.now(), + } + const journal = this.deps.journal.begin({ + op: 'fork', + recordId: transactionId, + sessionIds: [input.sessionId], + policyVersion: this.deps.settings.getSnapshot(this.deps.serverId).version, + metadata: this.transactionMetadata(transaction), + }) + transaction.journalId = journal.journalId + this.transactions.set(input.sessionId, transaction) + return preview + }) + } + + // ------------------------------------------------------------------------- + // Seed capture (used by the confirm transaction) + // ------------------------------------------------------------------------- + + /** + * Capture a fingerprinted Phase 2 fork seed at the source checkout's exact + * captured HEAD. The snapshot service is read-only on the checkout: it pins + * the HEAD with a CAS-created hidden ref and copies supported staged, + * unstaged, eligible untracked, and `.worktreeinclude` state without + * cleaning or changing the source. Ignored files outside `.worktreeinclude` + * do not copy. Capture failures map to typed fork errors; the caller decides + * compensation. + */ + async captureForkSeed(input: { + checkoutPath: string + repositoryRoot: string + gitCommonDir: string + expectedBranch: string + baseRef: string | null + ownerSessionIds: string[] + policyVersion: number + previewFingerprint: string + }): Promise<{ snapshotId: string; fingerprint: string }> { + const record: ManagedWorktreeRecordV2 = { + schemaVersion: 2, + managedWorktreeId: `fork-seed:${randomBytes(4).toString('hex')}`, + workspaceId: 'fork', + repositoryRoot: input.repositoryRoot, + gitCommonDir: input.gitCommonDir, + checkoutPath: input.checkoutPath, + baseRef: input.baseRef, + expectedBranch: input.expectedBranch, + displayName: 'fork-seed', + materializationRoot: '', + createdAt: Date.now(), + lastUsedAt: Date.now(), + policyVersion: input.policyVersion, + ownerSessionIds: input.ownerSessionIds, + state: 'ready', + } + const finalFingerprint = await computeWorktreeFingerprint({ + managedWorktreeId: record.managedWorktreeId, + checkoutPath: input.checkoutPath, + gitCommonDir: input.gitCommonDir, + expectedBranch: input.expectedBranch, + baseRef: input.baseRef, + ownerSessionIds: input.ownerSessionIds, + policyVersion: input.policyVersion, + archivedOwnerSessionIds: [], + }) + try { + const { meta } = await this.deps.snapshots.capture({ + record, + finalFingerprint, + previewFingerprint: input.previewFingerprint, + policyVersion: input.policyVersion, + }) + this.seedRepositoryRoots.set(meta.snapshotId, input.repositoryRoot) + return { snapshotId: meta.snapshotId, fingerprint: meta.fingerprint } + } catch (error) { + if (error instanceof WorktreeSnapshotError) { + if (error.code === 'SNAPSHOT_LIMIT') { + throw new ConversationForkError('FORK_SEED_LIMIT', sanitizeError(error)) + } + throw new ConversationForkError('FORK_SEED_CAPTURE_FAILED', sanitizeError(error)) + } + throw error + } + } + + /** Remove a fork seed: verify, CAS-delete its owned hidden ref, drop the payload. */ + async removeSeed(snapshotId: string, repositoryRoot: string): Promise { + const meta = this.deps.snapshots.loadSnapshotMeta(snapshotId) + if (!meta) { + this.seedRepositoryRoots.delete(snapshotId) + return + } + try { + await this.deps.snapshots.permanentDelete(repositoryRoot, meta) + } catch (error) { + if (error instanceof WorktreeSnapshotError) { + throw new ConversationForkError('FORK_SEED_REMOVE_FAILED', sanitizeError(error)) + } + throw error + } + this.seedRepositoryRoots.delete(snapshotId) + } + + /** Repository root recorded for an in-process seed (Task 3 cleanup helper). */ + seedRepositoryRoot(snapshotId: string): string | undefined { + return this.seedRepositoryRoots.get(snapshotId) + } + + // ------------------------------------------------------------------------- + // Facts / blockers + // ------------------------------------------------------------------------- + + private async gatherFacts( + input: ConversationForkPreviewInput, + session: ForkSessionInfo, + capability: ConversationForkProviderCapability | null, + transactionIdToAllow?: string, + recordLookup?: (id: string) => ManagedWorktreeRecordV2 | undefined, + options?: { allowOwnedDestination?: boolean; ownedLeaseId?: string }, + ): Promise { + const fail = (code: ConversationForkBlockerCode, reason: string, overrides: Partial = {}): ForkFacts => + ({ ...this.fallbackFacts(input.sessionId, session, this.deps.serverId), blocker: code, blockerReason: reason, currentHead, ...overrides }) + const isIsolated = input.strategy === 'isolated-worktree' + // Current conversation head enforcement (isolated only). Computed up front + // so blocked previews report the true head state. + const forkPointMessageId = session.forkPointMessageId ?? session.conversationHead.messageId + const currentHead = forkPointMessageId === session.conversationHead.messageId + + let sourcePath = resolvePath(session.checkoutPath) + let repositoryRoot = sourcePath + let gitCommonDir = '' + let expectedBranch = '' + let nameSuffix: string | undefined + let pathToken: string | undefined + + // Early blockers (no Git inspection needed). + if (!isGitWorkspaceV1Enabled() || !isWorktreeV2Enabled()) { + return fail('flags-disabled', 'Required feature flags are disabled.') + } + if (isIsolated && (!capability || capability.strictCrossCwdNativeFork !== true)) { + return fail('unsupported-provider', 'The provider adapter cannot establish a strict cross-CWD native fork.') + } + if ( + isIsolated && + (!session.sdkSessionId?.trim() || !session.conversationHead.turnId?.trim()) + ) { + return fail('missing-parent-anchor', 'missing-parent-anchor') + } + const existingTransaction = this.transactions.get(input.sessionId) + if (existingTransaction && existingTransaction.transactionId !== transactionIdToAllow) { + if (existingTransaction.state === 'pending') { + // A fresh preview supersedes a stale pending preview. A pending + // transaction has never mutated anything (quiescence and capture + // happen at confirm), so cancelling it is safe and keeps dialog + // re-opens from stranding the session with a fenced transaction. + if (existingTransaction.journalId) { + this.deps.journal.recover(existingTransaction.journalId, 'preview-superseded') + } + this.transactions.delete(input.sessionId) + } else { + return fail('fork-in-progress', 'A fork transaction is already in progress for this session.') + } + } + if (isIsolated && this.deps.journal.inProgress().some( + (entry) => entry.op === 'fork' && entry.sessionIds.includes(input.sessionId) && entry.recordId !== transactionIdToAllow, + )) { + return fail('fork-in-progress', 'A fork transaction is already in progress for this session.') + } + if (this.deps.lifecycle.isCleanupInProgress()) { + return fail('cleanup-in-progress', 'Worktree lifecycle cleanup is running; try again shortly.') + } + + if (!existsSync(sourcePath)) { + return fail('missing-source', 'The source checkout path does not exist.') + } + + const sourceCtx = await this.deps.repository.getContext(sourcePath) + if (!sourceCtx.isGitRepository || !sourceCtx.gitCommonDir) { + return fail('missing-source', 'The source checkout is not a readable Git worktree.') + } + gitCommonDir = sourceCtx.gitCommonDir + repositoryRoot = sourceCtx.repositoryRoot ?? sourcePath + // Legacy/current sessions may retain a nested working directory. The + // canonical current checkout is the repository root; snapshots, leases, + // and fingerprints must all use that root (spec: canonicalized, leased, + // and fingerprinted through the owning server). + if (sourceCtx.repositoryRoot) { + sourcePath = resolvePath(sourceCtx.repositoryRoot) + repositoryRoot = sourcePath + } + + // Owner set: a managed source may have multiple owners; every owner must + // be idle, quiesceable, and covered by a stable path lease during capture. + let ownerSessionIds = [input.sessionId] + if (session.checkout?.mode === 'managed-worktree' && session.checkout.managedWorktreeId) { + const record = (recordLookup ?? ((id: string) => this.deps.registry.get(id)))(session.checkout.managedWorktreeId) + if (!record || record.state !== 'ready' || !existsSync(record.checkoutPath)) { + return fail('missing-source', 'The managed worktree is snapshotted or missing; restore it before forking.') + } + if (resolvePath(record.gitCommonDir) !== resolvePath(gitCommonDir)) { + return fail('missing-source', 'The managed worktree no longer belongs to the recorded repository.') + } + sourcePath = resolvePath(record.checkoutPath) + repositoryRoot = record.repositoryRoot + ownerSessionIds = [...record.ownerSessionIds] + } + + // Current conversation head enforcement (isolated only). + if (isIsolated && !currentHead) { + return fail('non-head-source', 'Isolated forks are only available at the current conversation head.') + } + + // Every source owner must be idle. + for (const owner of ownerSessionIds) { + if (this.hooks.isSessionActive?.(owner)) { + return fail('source-active', `Source owner ${owner} has an active turn; forking requires idle runtimes.`) + } + } + + // No foreign lease may occupy the source path; every owner must be + // leaseable. Confirm takes the stable leases under lock. + const foreignLeases = this.deps.leases.leasedBy(sourcePath).filter((id) => !ownerSessionIds.includes(id)) + if (foreignLeases.length > 0) { + return fail('path-unleased', 'Another session or runtime leases the source path.') + } + + // Supported Git state: no in-progress operation, no unmerged index. + const status = await this.deps.repository.getStatus(sourcePath) + if (status.operationInProgress || status.entries.some((entry) => entry.conflicted)) { + return fail('git-operation-in-progress', 'A Git operation is in progress or the index is unmerged.') + } + + if (isIsolated && sourceCtx.detached) { + return fail('unsupported-snapshot', 'The source checkout is on a detached HEAD; a fork seed cannot be captured.') + } + + const counts = await this.transferableStateCounts(sourcePath) + const included = await listWorktreeIncludeFiles(sourcePath) + const includedIgnored = included.filter((path) => !counts.untracked.includes(path)) + + // Destination identity (isolated only). + let destination: ConversationForkPreview['destination'] + if (isIsolated) { + nameSuffix = input.worktreeNameSuffix ?? existingTransaction?.nameSuffix ?? newPathToken() + pathToken = existingTransaction?.pathToken ?? newPathToken() + expectedBranch = `kata-agent/${nameSuffix}` + const destinationPath = this.deps.worktrees.resolveWorktreePath({ + workspaceId: session.workspaceId, + gitCommonDir, + worktreeNameSuffix: nameSuffix, + pathToken, + }) + // An interrupted-transaction replay treats the transaction's own + // materialized destination as the as-of-preview value: the collision + // checks and the fingerprint then revalidate the ORIGINAL preview facts + // instead of the transaction's own effects, so a crash after target + // materialization stays resumable (name-collision would otherwise block + // the transaction's own destination forever). + const ownedDestination = options?.allowOwnedDestination === true + destination = { + serverId: this.deps.serverId, + repositoryRoot: sourceCtx.repositoryRoot ?? sourcePath, + branch: expectedBranch, + checkoutPath: destinationPath, + exists: ownedDestination ? false : existsSync(destinationPath), + leases: this.deps.leases + .leasedBy(destinationPath) + .filter((owner) => owner !== options?.ownedLeaseId), + } + const nameValid = nameSuffix.trim() === nameSuffix && nameSuffix.length > 0 && !nameSuffix.includes('\0') + const refCheck = nameValid + ? await runGit(['check-ref-format', '--branch', expectedBranch], { cwd: sourcePath, okExitCodes: [1, 128] }) + : null + if (!nameValid || !refCheck || refCheck.exitCode !== 0) { + return { ...fail('invalid-name', 'The requested worktree name is not a valid Git branch suffix.'), destination, expectedBranch, nameSuffix, pathToken } + } + if (!ownedDestination && (destination.exists || lstatSyncSafe(destinationPath) === 'symlink')) { + return { ...fail('name-collision', 'The requested worktree name resolves to an occupied destination.'), destination, expectedBranch, nameSuffix, pathToken } + } + if (!ownedDestination && (await this.branchOccupied(repositoryRoot, expectedBranch))) { + return { ...fail('name-collision', `The branch ${expectedBranch} is already in use.`), destination, expectedBranch, nameSuffix, pathToken } + } + } else { + // Shared-worktree destination IS the source checkout (the child shares it). + destination = { + serverId: this.deps.serverId, + repositoryRoot, + branch: sourceCtx.currentBranch ?? '', + checkoutPath: sourcePath, + exists: true, + leases: this.deps.leases.leasedBy(sourcePath), + } + } + + // Seed-capture feasibility (isolated only — shared forks capture no seed): + // unsupported state or oversize are typed blockers at preview time + // (authoritative enforcement stays at capture). + if (isIsolated) { + try { + await this.deps.snapshots.assertSupportedState(sourcePath) + } catch (error) { + if (error instanceof WorktreeSnapshotError) { + return { ...fail('unsupported-snapshot', sanitizeError(error)), destination, expectedBranch, nameSuffix, pathToken, currentHead } + } + throw error + } + } + if (isIsolated && await this.estimateSeedOversize(sourcePath, counts, includedIgnored)) { + return { ...fail('oversized-capture', `The source state exceeds the snapshot limit (${WORKTREE_SNAPSHOT_MAX_FILES} files / ${WORKTREE_SNAPSHOT_MAX_BYTES} bytes).`), destination, expectedBranch, nameSuffix, pathToken, currentHead } + } + + return { + blocker: null, + source: { + serverId: this.deps.serverId, + sessionId: input.sessionId, + conversationHeadMessageId: session.conversationHead.messageId, + conversationHeadTurnId: session.conversationHead.turnId, + checkout: { + mode: session.checkout?.mode ?? 'current', + ...(session.checkout?.managedWorktreeId ? { managedWorktreeId: session.checkout.managedWorktreeId } : {}), + }, + branch: sourceCtx.currentBranch ?? null, + headSha: sourceCtx.headSha, + gitState: { + state: sourceCtx.detached + ? 'detached' + : counts.staged + counts.unstaged + counts.untracked.length > 0 + ? 'dirty' + : 'clean', + stagedFileCount: counts.staged, + unstagedFileCount: counts.unstaged, + untrackedFileCount: counts.untracked.length, + includedIgnoredFileCount: includedIgnored.length, + }, + leases: this.deps.leases.leasedBy(sourcePath), + }, + destination, + excludedIgnoredPolicy: { includeOnly: true, includeFileCount: includedIgnored.length }, + currentHead, + sourceCheckoutPath: sourcePath, + repositoryRoot, + gitCommonDir, + expectedBranch, + nameSuffix, + pathToken, + ownerSessionIds, + } + } + + /** Minimal source/destination facts used by blocked previews. */ + private fallbackFacts(sessionId: string, session: ForkSessionInfo, serverId: string): ForkFacts { + return { + blocker: null, + source: { + serverId, + sessionId, + conversationHeadMessageId: session.conversationHead?.messageId ?? '', + conversationHeadTurnId: session.conversationHead?.turnId ?? '', + checkout: { mode: session.checkout?.mode ?? 'current' }, + branch: null, + headSha: null, + gitState: { state: 'clean', stagedFileCount: 0, unstagedFileCount: 0, untrackedFileCount: 0, includedIgnoredFileCount: 0 }, + leases: [], + }, + destination: { + serverId, + repositoryRoot: resolvePath(session.checkoutPath), + branch: '', + checkoutPath: '', + exists: false, + leases: [], + }, + excludedIgnoredPolicy: { includeOnly: true, includeFileCount: 0 }, + currentHead: true, + sourceCheckoutPath: resolvePath(session.checkoutPath), + repositoryRoot: resolvePath(session.checkoutPath), + gitCommonDir: '', + expectedBranch: '', + ownerSessionIds: [], + } + } + + /** Blocked preview for a session that could not be resolved at all. */ + private blockedPreview( + input: ConversationForkPreviewInput, + code: ConversationForkBlockerCode, + reason: string, + facts: ForkFacts, + ): ConversationForkPreview { + return { + transactionId: newTransactionId(), + previewFingerprint: sha256(JSON.stringify({ blocked: code })), + strategy: input.strategy, + providerCapability: { adapterId: 'unknown', strictCrossCwdNativeFork: false }, + source: facts.source, + destination: facts.destination, + excludedIgnoredPolicy: facts.excludedIgnoredPolicy, + currentHead: facts.currentHead, + blocked: { blocked: true, code, reason }, + } + } + + // ------------------------------------------------------------------------- + // Confirm (durable target/child transaction core) + // ------------------------------------------------------------------------- + + /** + * Commit the isolated target + child session through the durable fork + * journal. Revalidates every preview-bound fact under the common-directory + * mutation lock + registry lock, captures the fingerprinted seed, materializes + * and restores the target, creates the child session through the host hook, + * and commits the registry owner + journal marker before the child is + * visible. Pre-publication failures compensate only transaction-owned + * artifacts with CAS proof. A repeated confirm with the same transactionId + * after an interrupt continues from the journal without double-creating + * target/child/owner. + */ + async confirm(input: ConversationForkConfirmInput): Promise { + if (input.strategy !== 'isolated-worktree') { + // Shared-worktree forks own no transaction and reuse the existing + // branch/shared-checkout path; the renderer routes shared confirmation + // through sessions:create (the branch flow), never this RPC. + throw new ConversationForkError('FORK_NOT_IMPLEMENTED', 'Shared-worktree fork confirmation is not implemented by the isolated transaction core.') + } + const resolved = this.resolveConfirmTransaction(input) + if (!resolved) { + throw new ConversationForkError('FORK_TRANSACTION_UNKNOWN', 'Unknown fork transaction.') + } + const txn = resolved.txn + if (txn.strategy !== input.strategy) { + throw new ConversationForkError('FORK_STRATEGY_MISMATCH', 'Fork strategy does not match the transaction.') + } + if (!input.worktreeNameSuffix) { + throw new ConversationForkError('FORK_STRATEGY_MISMATCH', 'An isolated fork confirmation requires the worktree name suffix from the preview.') + } + return this.enterLockedResume(input, txn, resolved.committedSummary) + } + + /** + * Recover an interrupted fork transaction by re-entering the locked + * confirm/resume machinery. The recover input carries only sessionId + + * transactionId, so the resume validates against the transaction's OWN + * journaled fingerprint and name (recover never accepts a client-supplied + * fingerprint). A committed journal entry returns its summary idempotently; + * a rolled-back entry starts a fresh attempt through the same resolution + * confirm uses. + */ + async recover(input: ConversationForkRecoverInput): Promise { + const resolved = this.resolveConfirmTransaction(input) + if (!resolved) { + throw new ConversationForkError('FORK_TRANSACTION_UNKNOWN', 'Unknown fork transaction.') + } + return this.enterLockedResume( + this.confirmInputFromTxn(resolved.txn, input.sessionId), + resolved.txn, + resolved.committedSummary, + ) + } + + /** Rebuild the confirm input a resume uses from the transaction's journaled facts. */ + private confirmInputFromTxn(txn: ForkTransaction, sessionId: string): ConversationForkConfirmInput { + return { + sessionId, + strategy: txn.strategy, + transactionId: txn.transactionId, + previewFingerprint: txn.fingerprint, + worktreeNameSuffix: txn.nameSuffix, + } + } + + /** + * Shared preamble of confirm and recover: resolve hooks, validate capability + * wiring, then re-enter the locked confirm/resume core. A committed + * transaction returns its durable summary instead of re-running. Recovery + * re-enters with the transaction's journaled fingerprint; confirm with the + * client's preview fingerprint (both revalidated inside confirmLocked). + */ + private async enterLockedResume( + input: ConversationForkConfirmInput, + txn: ForkTransaction, + committedSummary?: ConversationForkCommitSummary, + ): Promise { + if (committedSummary) { + // A repeated confirm/recover after the durable commit returns the + // committed summary instead of double-creating a target/child/owner. + return { outcome: 'committed', transactionId: txn.transactionId, summary: committedSummary } + } + const session = this.hooks.resolveSession?.(input.sessionId) + if (!session) throw new ConversationForkError('FORK_SESSION_UNKNOWN', 'Unknown session for fork confirmation.') + const capability = this.hooks.resolveCapability?.(input.sessionId) ?? null + if (!capability || capability.strictCrossCwdNativeFork !== true) { + this.deps.journal.fail(txn.journalId, 'The provider adapter cannot establish a strict cross-CWD native fork.') + this.transactions.delete(input.sessionId) + return this.blockedResult(txn, 'unsupported-provider', 'The provider adapter cannot establish a strict cross-CWD native fork.') + } + if (!this.hooks.createForkChildSession || !this.hooks.deleteForkChildSession) { + this.deps.journal.fail(txn.journalId, 'Fork child-session hooks are not wired.') + this.transactions.delete(input.sessionId) + throw new ConversationForkError( + 'FORK_HOOK_NOT_WIRED', + 'Isolated fork confirmation requires a wired child-session creation hook.', + ) + } + + return this.deps.mutationLock.withLock(txn.gitCommonDir, async () => { + return this.confirmLocked(txn, input, session, capability) + }) + } + + /** + * Revalidation + mutation core of confirm, running under the git lock. + * Re-entrant for an interrupted transaction: completed journal steps are + * skipped, so a repeated confirm never double-creates target/child/owner. + */ + private async confirmLocked( + txn: ForkTransaction, + input: ConversationForkConfirmInput, + session: ForkSessionInfo, + capability: ConversationForkProviderCapability, + ): Promise { + // Durability guard under the mutation lock: cancel() serializes on this + // same lock, so by the time we hold it either a queued cancel already + // recovered the entry as `preview-cancelled` (or a new preview superseded + // it) — the journal is authoritative and a cancelled entry must never + // receive a child commit. If the entry is no longer in-progress, abort + // with the typed transaction-unknown error instead of mutating. + const durableEntry = this.deps.journal.entries().find( + (candidate) => + candidate.op === 'fork' && + candidate.journalId === txn.journalId && + candidate.sessionIds.includes(txn.sessionId), + ) + if (!durableEntry || durableEntry.status !== 'in-progress') { + throw new ConversationForkError( + 'FORK_TRANSACTION_UNKNOWN', + 'The fork transaction is no longer in progress (cancelled or superseded).', + ) + } + // Re-gather facts + revalidate every preview-bound fact under the registry + // lock so a concurrent owner bind / lifecycle decision cannot interleave + // between the revalidation and the capture. + const revalidation = await this.deps.registry.runExclusive(async (tx) => { + const gathered = await this.gatherFacts( + { + sessionId: input.sessionId, + strategy: txn.strategy, + worktreeNameSuffix: input.worktreeNameSuffix, + }, + session, + capability, + txn.transactionId, + (id: string) => tx.get(id), + { + // A transaction that already materialized its target must not be + // blocked by its own destination when resuming after a crash. + allowOwnedDestination: txn.steps.includes('target-materialized'), + ownedLeaseId: `fork:${txn.transactionId}`, + }, + ) + if (gathered.blocker) { + this.deps.journal.fail(txn.journalId, gathered.blockerReason ?? 'Fork precondition failed.') + this.transactions.delete(input.sessionId) + return { blocked: this.blockedResult(txn, gathered.blocker, gathered.blockerReason ?? 'Fork precondition failed.') } + } + // Path-unleased at confirm: EVERY source owner must now hold a stable + // lease on the canonical source path (the preview only blocked foreign + // leases). + const sourceLeases = this.deps.leases.leasedBy(gathered.sourceCheckoutPath) + const unleasedOwners = gathered.ownerSessionIds.filter((owner) => !sourceLeases.includes(owner)) + if (unleasedOwners.length > 0 || sourceLeases.some((owner) => !gathered.ownerSessionIds.includes(owner))) { + this.deps.journal.fail(txn.journalId, 'A source path owner or lease is missing at confirm.') + this.transactions.delete(input.sessionId) + return { + blocked: this.blockedResult( + txn, + 'path-unleased', + unleasedOwners.length > 0 + ? `Source owner ${unleasedOwners[0]} does not hold a stable lease on the source path.` + : 'A foreign session or runtime leases the source path.', + ), + } + } + // Fork-in-progress re-check: no other pending fork may own the source or + // the target paths. + if (this.isForkInProgressFor(txn)) { + this.deps.journal.fail(txn.journalId, 'Another fork transaction is in progress for the source or target.') + this.transactions.delete(input.sessionId) + return { blocked: this.blockedResult(txn, 'fork-in-progress', 'Another fork transaction is in progress for the source or target.') } + } + const freshFingerprint = await this.computeFingerprint( + { sessionId: input.sessionId, strategy: txn.strategy, worktreeNameSuffix: input.worktreeNameSuffix }, + session, + gathered, + capability, + txn.transactionId, + // Exclude this transaction's own destination lease (if held) so the + // revalidation fingerprint matches the preview, not the txn's effects. + `fork:${txn.transactionId}`, + ) + if (freshFingerprint !== txn.fingerprint || input.previewFingerprint !== txn.fingerprint) { + this.deps.journal.fail(txn.journalId, 'The fork facts changed after the preview.') + this.transactions.delete(input.sessionId) + return { blocked: this.blockedResult(txn, 'identity-drift', 'The fork facts changed after the preview; inspect it again.') } + } + return { facts: gathered } + }) + if (revalidation.blocked) return revalidation.blocked + const facts = revalidation.facts + if (!facts) throw new ConversationForkError('FORK_TARGET_FAILED', 'Fork facts could not be gathered.') + + this.journalStep(txn, 'locks-acquired') + + // Source quiescence: every owner must be idle, then quiesced through the + // host hook (the harness quiesce removes processing runtimes). + const activeOwner = facts.ownerSessionIds.find((owner) => this.hooks.isSessionActive?.(owner)) + if (activeOwner) { + this.deps.journal.fail(txn.journalId, `Source owner ${activeOwner} has an active turn.`) + this.transactions.delete(input.sessionId) + return this.blockedResult(txn, 'source-active', `Source owner ${activeOwner} has an active turn; forking requires idle runtimes.`) + } + const quiesced = this.hooks.quiesceRuntimes ? await this.hooks.quiesceRuntimes(facts.ownerSessionIds) : true + if (!quiesced) { + this.deps.journal.fail(txn.journalId, 'A source runtime could not be quiesced.') + this.transactions.delete(input.sessionId) + return this.blockedResult(txn, 'source-active', 'A source runtime could not be quiesced; forking requires idle runtimes.') + } + this.journalStep(txn, 'source-quiesced') + + let transactionLeaseId: string | null = null + try { + // Target reservation, journaled BEFORE the seed capture: nameSuffix, + // pathToken, expectedBranch, and the source HEAD OID the target must pin. + txn.headOid = facts.source.headSha ?? '' + if (!txn.headOid) throw new Error('The source HEAD could not be resolved for the fork target.') + this.deps.journal.updateMetadata(txn.journalId, { + state: 'target-reserved', + nameSuffix: txn.nameSuffix ?? null, + pathToken: txn.pathToken ?? null, + expectedBranch: txn.expectedBranch, + headOid: txn.headOid, + }) + + // Stable-lease guard: the source fingerprint must not change under our + // own capture (the seed is read-only on the checkout). + const settings = this.deps.settings.getSnapshot(this.deps.serverId) + const sourceFingerprintBeforeCapture = await computeWorktreeFingerprint({ + managedWorktreeId: `fork:${facts.sourceCheckoutPath}`, + checkoutPath: facts.sourceCheckoutPath, + gitCommonDir: txn.gitCommonDir, + expectedBranch: facts.source.branch ?? '', + baseRef: null, + ownerSessionIds: facts.ownerSessionIds, + policyVersion: settings.version, + archivedOwnerSessionIds: [], + }) + + // Seed capture (skipped on replay: the seed id is journaled). The seed + // pins the SOURCE checkout, so it records the source branch — the target + // branch is applied by the restore projection. + if (!txn.steps.includes('seed-captured')) { + const captured = await this.captureForkSeed({ + checkoutPath: facts.sourceCheckoutPath, + repositoryRoot: txn.repositoryRoot, + gitCommonDir: txn.gitCommonDir, + expectedBranch: facts.source.branch ?? '', + baseRef: txn.headOid, + ownerSessionIds: facts.ownerSessionIds, + policyVersion: settings.version, + previewFingerprint: txn.fingerprint, + }) + txn.seedSnapshotId = captured.snapshotId + // The seed is journaled immediately after capture so an in-progress + // fork entry's seed is GC-retained until the commit marker. + this.deps.journal.updateMetadata(txn.journalId, { + state: 'seed-captured', + seedSnapshotId: captured.snapshotId, + seedFingerprint: captured.fingerprint, + }) + this.journalStep(txn, 'seed-captured') + txn.state = 'seed-captured' + } else if (!txn.seedSnapshotId) { + throw new Error('The interrupted fork journal has no recorded seed.') + } + + const afterCaptureFingerprint = await computeWorktreeFingerprint({ + managedWorktreeId: `fork:${facts.sourceCheckoutPath}`, + checkoutPath: facts.sourceCheckoutPath, + gitCommonDir: txn.gitCommonDir, + expectedBranch: facts.source.branch ?? '', + baseRef: null, + ownerSessionIds: facts.ownerSessionIds, + policyVersion: settings.version, + archivedOwnerSessionIds: [], + }) + if (afterCaptureFingerprint !== sourceFingerprintBeforeCapture) { + throw new Error('The source checkout changed during seed capture.') + } + + // Destination lease fences the target path against other runtimes. + transactionLeaseId = `fork:${txn.transactionId}` + this.deps.leases.lease(transactionLeaseId, txn.destinationPath) + this.journalStep(txn, 'destination-leased') + + // Materialize the target (skipped on replay when the journal records it). + if (!txn.steps.includes('target-materialized')) { + const created = await this.deps.worktrees.createWorktree({ + workspaceId: session.workspaceId, + sessionId: input.sessionId, + repositoryRoot: txn.repositoryRoot, + gitCommonDir: txn.gitCommonDir, + baseRef: txn.headOid, + worktreeNameSuffix: txn.nameSuffix, + pathToken: txn.pathToken, + lockAlreadyHeld: true, + }) + if (created.record.schemaVersion !== 2) { + throw new Error('Named fork creation did not produce a V2 worktree record.') + } + txn.managedWorktreeId = created.record.managedWorktreeId + this.deps.journal.updateMetadata(txn.journalId, { + state: 'target-materialized', + managedWorktreeId: created.record.managedWorktreeId, + }) + this.journalStep(txn, 'target-materialized') + txn.state = 'target-materialized' + } + const targetRecord = txn.managedWorktreeId ? this.deps.registry.get(txn.managedWorktreeId) : undefined + if (!targetRecord || targetRecord.schemaVersion !== 2 || targetRecord.state !== 'ready') { + throw new Error('The materialized fork target record is missing or not ready.') + } + if (realpathSafe(targetRecord.checkoutPath) !== realpathSafe(txn.destinationPath)) { + throw new Error('The materialized fork target is not at the reserved destination.') + } + + const seedMeta = this.deps.snapshots.loadSnapshotMeta(txn.seedSnapshotId) + if (!seedMeta) throw new Error('The fork seed is missing; it cannot restore the target.') + + // Restore the seed into the target (skipped on replay when recorded). + if (!txn.steps.includes('target-restored')) { + await this.deps.snapshots.applySnapshotToCheckout({ + meta: seedMeta, + checkoutPath: targetRecord.checkoutPath, + }) + this.journalStep(txn, 'target-restored') + } + + // Verify: the restored target must reproduce the seed content exactly + // (staged/unstaged/untracked/.worktreeinclude byte-for-byte) and sit at + // the captured HEAD on the reserved branch. + if (!txn.steps.includes('target-verified')) { + const targetContext = await this.deps.repository.getContext(targetRecord.checkoutPath) + if ( + !targetContext.isGitRepository || + !targetContext.gitCommonDir || + resolvePath(targetContext.gitCommonDir) !== resolvePath(txn.gitCommonDir) || + targetContext.currentBranch !== targetRecord.expectedBranch || + targetContext.headSha !== seedMeta.headOid + ) { + throw new Error('The fork target failed identity verification after restore.') + } + await this.assertTargetMatchesSeed(txn, seedMeta, targetRecord.checkoutPath) + this.journalStep(txn, 'target-verified') + txn.state = 'target-verified' + } + + // Child session through the host hook (skipped on replay: the child id + // is journaled). The child is invisible until the journal commit marker. + if (!txn.steps.includes('child-created')) { + const childSessionId = await this.hooks.createForkChildSession!({ + transactionId: txn.transactionId, + parentSessionId: input.sessionId, + parentSdkSessionId: session.sdkSessionId, + parentSdkTurnId: session.forkPointTurnId ?? session.conversationHead.turnId, + transcriptCwd: session.transcriptCwd, + executionCwd: targetRecord.checkoutPath, + checkout: this.childCheckoutFor(targetRecord), + nameSuffix: txn.nameSuffix!, + sourceMessageId: session.conversationHead.messageId, + workspaceId: session.workspaceId, + forkPointMessageId: session.forkPointMessageId ?? session.conversationHead.messageId, + }) + if (!childSessionId || typeof childSessionId !== 'string' || !childSessionId.trim()) { + throw new Error('The child-session hook did not return a durable child session id.') + } + txn.childSessionId = childSessionId + this.deps.journal.updateMetadata(txn.journalId, { state: 'target-materialized', childSessionId }) + this.journalStep(txn, 'child-created') + } + if (!txn.childSessionId) { + throw new Error('The interrupted fork journal has no recorded child session.') + } + txn.childCheckout = this.childCheckoutFor(targetRecord) + + // Registry: the child becomes the SOLE owner of the new record; the + // source record is never touched. + if (!txn.steps.includes('owner-committed')) { + await this.deps.registry.runExclusive(async (tx) => { + const record = tx.get(txn.managedWorktreeId!) + if (!record || record.state !== 'ready') { + throw new Error('The fork target record is missing or not ready before the owner commit.') + } + if (record.ownerSessionIds.length !== 1 || record.ownerSessionIds[0] !== input.sessionId) { + throw new Error('The fork target gained unexpected owners before the commit.') + } + record.ownerSessionIds = [txn.childSessionId!] + record.lastUsedAt = Date.now() + tx.commit() + }) + this.journalStep(txn, 'owner-committed') + } + + // Durable commit marker, then the child is visible through the result. + txn.state = 'binding-committed' + txn.committedAt = Date.now() + this.deps.journal.updateMetadata(txn.journalId, { + state: 'binding-committed', + childSessionId: txn.childSessionId, + childCheckout: txn.childCheckout, + executionCwd: targetRecord.checkoutPath, + transcriptCwd: session.transcriptCwd, + committedAt: txn.committedAt, + }) + this.deps.journal.commit(txn.journalId, txn.transactionId) + const committedAt = txn.committedAt + const childCheckout = txn.childCheckout + + // Post-commit cleanup: remove the seed (best-effort; GC covers stragglers). + if (txn.seedSnapshotId) { + try { + await this.removeSeed(txn.seedSnapshotId, txn.repositoryRoot) + } catch { + // The journal is committed; an unreferenced seed is GC-removed. + } + } + if (transactionLeaseId) this.deps.leases.release(transactionLeaseId, txn.destinationPath) + this.transactions.delete(input.sessionId) + return { + outcome: 'committed', + transactionId: txn.transactionId, + summary: { + sessionId: txn.childSessionId!, + strategy: 'isolated-worktree', + checkout: childCheckout!, + executionCwd: targetRecord.checkoutPath, + transcriptCwd: session.transcriptCwd, + childProviderIdPresent: false, + committedAt, + }, + } + } catch (error) { + if (transactionLeaseId) this.deps.leases.release(transactionLeaseId, txn.destinationPath) + try { + await this.compensate(txn) + this.deps.journal.updateMetadata(txn.journalId, { state: 'rolled-back', rolledBackAt: Date.now() }) + this.deps.journal.recover(txn.journalId, 'rolled-back') + this.transactions.delete(input.sessionId) + } catch (compensationError) { + this.deps.journal.updateMetadata(txn.journalId, { state: 'recovery-required', lastError: sanitizeError(error) }) + this.deps.journal.fail(txn.journalId, sanitizeError(error)) + throw new ConversationForkError( + 'FORK_COMPENSATION_FAILED', + `The fork transaction could not be fully compensated: ${sanitizeError(compensationError)}.`, + ) + } + throw new ConversationForkError('FORK_TARGET_FAILED', sanitizeError(error)) + } + } + + /** Build the V2 child checkout binding from the materialized target record. */ + private childCheckoutFor(record: ManagedWorktreeRecordV2): SessionCheckoutV2 { + return { + schemaVersion: 2, + mode: 'managed-worktree', + repositoryRoot: record.repositoryRoot, + checkoutPath: record.checkoutPath, + branchAtPreparation: record.expectedBranch, + baseRef: record.baseRef, + managedWorktreeId: record.managedWorktreeId, + displayName: record.displayName, + expectedBranch: record.expectedBranch, + materializationRoot: record.materializationRoot, + } + } + + /** Idempotent journal step: append to the in-memory steps only once. */ + private journalStep(txn: ForkTransaction, step: string): void { + if (txn.steps.includes(step)) return + txn.steps.push(step) + this.deps.journal.step(txn.journalId, step) + } + + private blockedResult( + txn: ForkTransaction, + code: ConversationForkBlockerCode, + reason: string, + ): ConversationForkResult { + return { outcome: 'blocked', transactionId: txn.transactionId, code, reason: sanitizeError(reason) } + } + + /** True when another in-memory/journal fork owns the source or target path. */ + private isForkInProgressFor(txn: ForkTransaction): boolean { + const source = resolvePath(txn.sourcePath) + const destination = resolvePath(txn.destinationPath) + const journalCollision = this.deps.journal.inProgress().some((entry) => { + if (entry.op !== 'fork' || entry.recordId === txn.transactionId) return false + const entrySource = entry.metadata?.sourcePath + const entryDestination = entry.metadata?.destinationPath + return ( + entry.sessionIds.includes(txn.sessionId) || + (typeof entrySource === 'string' && + (resolvePath(entrySource) === source || resolvePath(entrySource) === destination)) || + (typeof entryDestination === 'string' && + (resolvePath(entryDestination) === source || resolvePath(entryDestination) === destination)) + ) + }) + if (journalCollision) return true + for (const [owner, other] of this.transactions) { + if (owner === txn.sessionId || other.transactionId === txn.transactionId) continue + const otherSource = resolvePath(other.sourcePath) + const otherDestination = resolvePath(other.destinationPath) + if ( + otherSource === source || + otherDestination === source || + otherSource === destination || + otherDestination === destination + ) { + return true + } + } + return false + } + + /** + * Compensate ONLY transaction-owned artifacts, each with CAS/containment + * proof: the branch only while it still points at the journaled OID, the + * target only beneath the server root with the exact created owner set, the + * seed only when owned by this transaction, and the child session only when + * this transaction created it. The source checkout is never touched. + */ + private async compensate(txn: ForkTransaction): Promise { + // 1. Child session created by this transaction. + if (txn.childSessionId && txn.steps.includes('child-created')) { + const removeChild = this.hooks.deleteForkChildSession + if (!removeChild) { + throw new ConversationForkError('FORK_COMPENSATION_FAILED', 'No child-session removal hook is wired for compensation.') + } + await removeChild(txn.childSessionId) + } + // 2. Target worktree + registry record (only the record this transaction created). + if (txn.steps.includes('target-materialized')) { + const record = txn.managedWorktreeId ? this.deps.registry.get(txn.managedWorktreeId) : undefined + if (record && record.schemaVersion === 2) { + const ownersAreOurs = + record.ownerSessionIds.length === 1 && record.ownerSessionIds[0] === txn.sessionId + const branchIsOurs = record.expectedBranch === txn.expectedBranch + const rootIsServer = this.deps.worktrees.isUnderWorktreeRoot(record.checkoutPath, record.materializationRoot) + if (!ownersAreOurs || !branchIsOurs || !rootIsServer) { + throw new ConversationForkError( + 'FORK_COMPENSATION_FAILED', + 'The interrupted fork target is not provably owned by this transaction.', + ) + } + const released = await removeCheckoutFiles(record.repositoryRoot, record.checkoutPath) + if (!released) { + throw new ConversationForkError('FORK_COMPENSATION_FAILED', 'The interrupted fork target checkout could not be removed.') + } + this.deps.registry.remove(record.managedWorktreeId) + } else if (existsSync(txn.destinationPath) || lstatSyncSafe(txn.destinationPath) === 'symlink') { + // Crash between the provisional record and the ready record: remove + // the reserved path only when it is beneath the server root. + if (!this.deps.worktrees.isUnderWorktreeRoot(txn.destinationPath)) { + throw new ConversationForkError('FORK_COMPENSATION_FAILED', 'The interrupted fork target path escapes the server root.') + } + const released = await removeCheckoutFiles(txn.repositoryRoot, txn.destinationPath) + if (!released) { + throw new ConversationForkError('FORK_COMPENSATION_FAILED', 'The interrupted fork target path could not be removed.') + } + } + // 3. Branch CAS: remove it only while it still points at the OID this + // transaction created (the journaled head OID). A branch advanced or + // replaced by external work is never ours to delete. + if (txn.headOid) { + const branchOid = await runGit(['rev-parse', '--verify', '--quiet', `refs/heads/${txn.expectedBranch}`], { + cwd: txn.repositoryRoot, + okExitCodes: [1, 128], + }) + if (branchOid.exitCode === 0 && branchOid.stdout.trim() === txn.headOid) { + await runGit(['branch', '-D', txn.expectedBranch], { cwd: txn.repositoryRoot, okExitCodes: [1, 128] }) + } + } + } + // 4. Seed owned by this transaction (CAS-deletes only the owned hidden ref). + if (txn.seedSnapshotId) { + await this.removeSeed(txn.seedSnapshotId, txn.repositoryRoot) + } + } + + /** + * Content verification: the restored target must reproduce the seed's + * captured staged/unstaged projections and every untracked/included file + * byte-for-byte and mode-for-mode. + */ + private async assertTargetMatchesSeed( + txn: ForkTransaction, + meta: ManagedWorktreeSnapshotMeta, + checkoutPath: string, + ): Promise { + const manifest = this.deps.snapshots.verifyPayload(meta) + const maxBufferBytes = this.deps.snapshots.getMaxBytes() + 16 * 1024 + const staged = ( + await runGitBuffer(['diff', '--cached', '--binary', '--no-color', '--no-ext-diff'], { + cwd: checkoutPath, + maxBufferBytes, + }) + ).stdout + if (sha256(staged) !== manifest.stagedPatch.sha256) { + throw new Error('The fork target staged state differs from the captured seed.') + } + const unstaged = ( + await runGitBuffer(['diff', '--binary', '--no-color', '--no-ext-diff'], { + cwd: checkoutPath, + maxBufferBytes, + }) + ).stdout + if (sha256(unstaged) !== manifest.unstagedPatch.sha256) { + throw new Error('The fork target unstaged state differs from the captured seed.') + } + for (const entry of manifest.files) { + const dest = join(checkoutPath, entry.path) + const kind = lstatSyncSafe(dest) + if (kind === null) { + throw new Error(`The fork target is missing a captured file: ${entry.path}`) + } + if (entry.mode === '120000') { + if (kind !== 'symlink' || readlinkSync(dest) !== entry.linkText) { + throw new Error(`The fork target symlink differs from the captured seed: ${entry.path}`) + } + continue + } + if (kind !== 'other' || !statSync(dest).isFile()) { + throw new Error(`The fork target path is not a regular file: ${entry.path}`) + } + const actual = statSync(dest) + if ( + (actual.mode & 0o777) !== parseInt(entry.mode.slice(-3), 8) || + sha256(readFileSync(dest)) !== entry.sha256 + ) { + throw new Error(`The fork target file differs from the captured seed: ${entry.path}`) + } + } + void txn + } + + /** + * Resolve the transaction a confirm/recover refers to: the in-memory preview + * transaction, a durable in-progress journal transaction (crash replay), a + * rolled-back journal transaction (fresh re-run after full compensation), or + * a committed journal transaction (repeat confirm/recover returns the + * summary). Only `sessionId` + `transactionId` are consulted, so recover + * resolves through the same path as confirm. In-memory and journal-only + * resolution decide identically so confirm/recover behave the same before + * and after a restart: a failed entry (blocked confirm or recovery-required + * compensation failure) is never resumable. + */ + private resolveConfirmTransaction(input: { sessionId: string; transactionId: string }): { + txn: ForkTransaction + committedSummary?: ConversationForkCommitSummary + } | null { + const existing = this.transactions.get(input.sessionId) + if (existing) { + if (existing.transactionId !== input.transactionId) return null + const entry = this.deps.journal.entries().find((candidate) => candidate.journalId === existing.journalId) + if (entry && entry.status === 'in-progress') { + existing.steps = [...entry.steps] + return { txn: existing } + } + if (entry) { + // Mirror the journal-only resolution below: a committed entry returns + // its summary, a fully compensated (rolled-back) entry starts a fresh + // attempt, and every other durable terminal state — a failed entry + // (blocked confirm or recovery-required compensation failure) — is not + // resumable and yields the typed transaction-unknown error. + if (entry.status === 'committed') { + const summary = this.committedSummaryFromMetadata(entry, existing) + if (summary) return { txn: existing, committedSummary: summary } + return null + } + if (entry.status === 'recovered' && entry.commitMarker === 'rolled-back') { + return this.beginFreshAttempt(existing) + } + return null + } + // No durable entry (compacted/lost journal): restart with a fresh journal + // entry so this confirm/recover can still complete durably. + return this.beginFreshAttempt(existing) + } + const entry = this.findLatestForkEntry(input.transactionId, input.sessionId) + if (!entry) return null + const rehydrated = this.rehydrateTransaction(entry, input.sessionId) + if (!rehydrated) return null + if (entry.status === 'committed') { + const summary = this.committedSummaryFromMetadata(entry, rehydrated) + return { txn: rehydrated, committedSummary: summary } + } + if (entry.status === 'recovered') { + if (entry.commitMarker !== 'rolled-back') return null + // The previous attempt was fully compensated; start a fresh journal entry + // for the re-run so the transaction commits exactly once. + return this.beginFreshAttempt(rehydrated) + } + if (entry.status === 'in-progress') { + this.transactions.set(rehydrated.sessionId, rehydrated) + return { txn: rehydrated } + } + // Failed entries (blocked confirms) are not resumable. + return null + } + + /** Reset a transaction for a fresh confirm attempt with a new journal entry. */ + private beginFreshAttempt(txn: ForkTransaction): { txn: ForkTransaction } | null { + txn.steps = [] + txn.state = 'pending' + txn.headOid = undefined + txn.seedSnapshotId = undefined + txn.managedWorktreeId = undefined + txn.childSessionId = undefined + txn.childCheckout = undefined + txn.committedAt = undefined + const journal = this.deps.journal.begin({ + op: 'fork', + recordId: txn.transactionId, + sessionIds: [txn.sessionId], + policyVersion: this.deps.settings.getSnapshot(this.deps.serverId).version, + metadata: this.transactionMetadata(txn), + }) + txn.journalId = journal.journalId + this.transactions.set(txn.sessionId, txn) + return { txn } + } + + /** Rebuild a transaction from a journal entry's recorded metadata + steps. */ + private rehydrateTransaction(entry: WorktreeJournalEntry, sessionId: string): ForkTransaction | null { + const metadata = entry.metadata + if (!metadata) return null + const stringValue = (key: string): string | undefined => + typeof metadata[key] === 'string' ? (metadata[key] as string) : undefined + const transactionId = stringValue('transactionId') + const strategy = stringValue('strategy') + const fingerprint = stringValue('fingerprint') + const sourcePath = stringValue('sourcePath') + const destinationPath = stringValue('destinationPath') + const repositoryRoot = stringValue('repositoryRoot') + const gitCommonDir = stringValue('gitCommonDir') + const expectedBranch = stringValue('expectedBranch') + const nameSuffix = stringValue('nameSuffix') + const pathToken = stringValue('pathToken') + const transcriptCwd = stringValue('transcriptCwd') + if ( + entry.recordId !== transactionId || + strategy !== 'isolated-worktree' || + !transactionId || + !/^[a-f0-9]{16}$/.test(transactionId) || + !sessionId || + !fingerprint || + !/^[a-f0-9]{64}$/.test(fingerprint) || + !sourcePath || + !destinationPath || + !repositoryRoot || + !gitCommonDir || + !expectedBranch || + !transcriptCwd || + !nameSuffix || + nameSuffix.includes('\0') || + !pathToken || + !/^[a-f0-9]{8}$/.test(pathToken) + ) { + return null + } + const absolutePaths = [sourcePath, destinationPath, repositoryRoot, gitCommonDir, transcriptCwd] + if (absolutePaths.some((path) => !isAbsolute(path) || path.includes('\0'))) return null + const root = resolvePath(this.deps.settings.getSnapshot(this.deps.serverId).materializationRoot) + if (!isContainedPath(root, destinationPath)) return null + const sourceLeases = metadata.sourceLeases + if (!Array.isArray(sourceLeases) || !sourceLeases.every((value) => typeof value === 'string')) return null + const providerAdapterId = stringValue('providerAdapterId') + const capability: ConversationForkProviderCapability | undefined = providerAdapterId + ? { adapterId: providerAdapterId, strictCrossCwdNativeFork: true } + : undefined + return { + transactionId, + sessionId, + strategy: 'isolated-worktree', + state: (stringValue('state') as ConversationForkRecoveryState) ?? 'pending', + fingerprint, + nameSuffix, + pathToken, + sourcePath, + destinationPath, + repositoryRoot, + gitCommonDir, + expectedBranch, + steps: [...entry.steps], + journalId: entry.journalId, + providerCapability: capability, + transcriptCwd, + sourceLeases: sourceLeases as string[], + startedAt: typeof metadata.startedAt === 'number' ? metadata.startedAt : entry.startedAt, + headOid: stringValue('headOid'), + seedSnapshotId: stringValue('seedSnapshotId'), + managedWorktreeId: stringValue('managedWorktreeId'), + childSessionId: stringValue('childSessionId'), + committedAt: typeof metadata.committedAt === 'number' ? metadata.committedAt : undefined, + } + } + + /** Rebuild the committed summary from a committed journal entry. */ + private committedSummaryFromMetadata(entry: WorktreeJournalEntry, txn: ForkTransaction): ConversationForkCommitSummary | undefined { + const metadata = entry.metadata + if (!metadata) return undefined + const childSessionId = txn.childSessionId + const checkout = metadata.childCheckout + const executionCwd = metadata.executionCwd + const transcriptCwd = typeof metadata.transcriptCwd === 'string' ? metadata.transcriptCwd : txn.transcriptCwd + const committedAt = txn.committedAt + if ( + !childSessionId || + !checkout || + typeof checkout !== 'object' || + typeof (checkout as { schemaVersion?: unknown }).schemaVersion !== 'number' || + typeof executionCwd !== 'string' || + !transcriptCwd || + !committedAt + ) { + return undefined + } + return { + sessionId: childSessionId, + strategy: 'isolated-worktree', + checkout: checkout as SessionCheckoutV2, + executionCwd, + transcriptCwd, + childProviderIdPresent: false, + committedAt, + } + } + + /** Session facts helper for the unknown-session fallback. */ + private sessionFactsFallback(sessionId: string, serverId: string): ForkFacts { + return { + blocker: null, + source: { + serverId, + sessionId, + conversationHeadMessageId: '', + conversationHeadTurnId: '', + checkout: { mode: 'current' }, + branch: null, + headSha: null, + gitState: { state: 'clean', stagedFileCount: 0, unstagedFileCount: 0, untrackedFileCount: 0, includedIgnoredFileCount: 0 }, + leases: [], + }, + destination: { + serverId, + repositoryRoot: '', + branch: '', + checkoutPath: '', + exists: false, + leases: [], + }, + excludedIgnoredPolicy: { includeOnly: true, includeFileCount: 0 }, + currentHead: true, + sourceCheckoutPath: '', + repositoryRoot: '', + gitCommonDir: '', + expectedBranch: '', + ownerSessionIds: [], + } + } + + /** Counts of the exact supported state a fork seed would capture. */ + private async transferableStateCounts(checkoutPath: string): Promise<{ + staged: number + unstaged: number + untracked: string[] + }> { + const staged = splitNul((await runGit(['diff', '--cached', '--name-only', '-z'], { cwd: checkoutPath })).stdout) + const unstaged = splitNul((await runGit(['diff', '--name-only', '-z'], { cwd: checkoutPath })).stdout) + const untracked = splitNul( + (await runGit(['ls-files', '--others', '--exclude-standard', '-z'], { cwd: checkoutPath })).stdout, + ) + return { staged: staged.length, unstaged: unstaged.length, untracked } + } + + /** + * Dry oversize estimate for the preview (name-only lists + stat sizes). + * Authoritative enforcement happens inside the snapshot capture; this is a + * conservative preview-time blocker so the dialog can disable isolated with + * a typed reason without writing a seed. + */ + private async estimateSeedOversize( + checkoutPath: string, + counts: { staged: number; unstaged: number; untracked: string[] }, + includedIgnored: string[], + ): Promise { + const fileCount = counts.staged + counts.unstaged + counts.untracked.length + includedIgnored.length + if (fileCount > WORKTREE_SNAPSHOT_MAX_FILES) return true + let estimatedBytes = 0 + for (const rel of [...counts.untracked, ...includedIgnored]) { + if (!rel || rel.includes('\0') || rel.startsWith('/') || /(^|\/)\.\.(\/|$)/.test(rel)) continue + try { + const stat = lstatSync(join(checkoutPath, rel)) + if (stat.isSymbolicLink()) continue + if (!stat.isFile()) continue + estimatedBytes += stat.size + if (estimatedBytes > WORKTREE_SNAPSHOT_MAX_BYTES) return true + } catch { + // disappeared between listing and estimate — not counted + } + } + return false + } + + private async branchOccupied(repositoryRoot: string, branch: string): Promise { + const ref = await runGit(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], { + cwd: repositoryRoot, + okExitCodes: [1, 128], + }) + if (ref.exitCode === 0) return true + const worktrees = await runGit(['worktree', 'list', '--porcelain'], { cwd: repositoryRoot }) + const expected = `branch refs/heads/${branch}` + return worktrees.stdout.split('\n').some((line) => line.trim() === expected) + } + + private transactionMetadata(txn: ForkTransaction): Record { + return { + transactionId: txn.transactionId, + strategy: txn.strategy, + state: txn.state, + fingerprint: txn.fingerprint, + nameSuffix: txn.nameSuffix ?? null, + pathToken: txn.pathToken ?? null, + sourcePath: txn.sourcePath, + destinationPath: txn.destinationPath, + repositoryRoot: txn.repositoryRoot, + gitCommonDir: txn.gitCommonDir, + expectedBranch: txn.expectedBranch, + providerAdapterId: txn.providerCapability?.adapterId ?? null, + sourceLeases: txn.sourceLeases, + transcriptCwd: txn.transcriptCwd ?? null, + startedAt: txn.startedAt, + } + } + + private async computeFingerprint( + input: ConversationForkPreviewInput, + session: ForkSessionInfo, + facts: ForkFacts, + capability: ConversationForkProviderCapability | null, + transactionId: string, + excludeLeaseId?: string, + ): Promise { + const hash = createHash('sha256') + hash.update('kata-isolated-conversation-fork-v1\0') + // Source side: reuse the lifecycle fingerprint — it binds repository + // identity, HEAD, branch, index, working tree, untracked/included state, + // owner set, and policy version. + hash.update( + await computeWorktreeFingerprint({ + managedWorktreeId: `fork:${facts.sourceCheckoutPath}`, + checkoutPath: facts.sourceCheckoutPath, + gitCommonDir: facts.gitCommonDir, + expectedBranch: facts.source.branch ?? '', + baseRef: null, + ownerSessionIds: facts.ownerSessionIds, + policyVersion: this.deps.settings.getSnapshot(this.deps.serverId).version, + archivedOwnerSessionIds: [], + }), + ) + hash.update('\0') + hash.update( + JSON.stringify({ + strategy: input.strategy, + currentHead: facts.currentHead, + conversationHead: { + messageId: session.conversationHead.messageId, + turnId: session.conversationHead.turnId, + }, + forkPoint: { + messageId: session.forkPointMessageId ?? session.conversationHead.messageId, + turnId: session.forkPointTurnId ?? session.conversationHead.turnId, + }, + destination: { + serverId: facts.destination.serverId, + repositoryRoot: facts.destination.repositoryRoot, + branch: facts.destination.branch, + checkoutPath: facts.destination.checkoutPath, + exists: facts.destination.exists, + leases: [...facts.destination.leases].sort(), + }, + nameSuffix: facts.nameSuffix ?? null, + excludedIgnoredPolicy: facts.excludedIgnoredPolicy, + capability: capability ?? { adapterId: 'unknown', strictCrossCwdNativeFork: false }, + transcriptCwd: session.transcriptCwd, + ownerSessionIds: [...facts.ownerSessionIds].sort(), + allPathLeases: [...this.deps.leases.allLeases().entries()] + .map(([owner, paths]) => [owner, [...paths].sort()]) + .filter(([owner]) => owner !== excludeLeaseId) + .sort(([a], [b]) => String(a).localeCompare(String(b))), + }), + ) + return hash.digest('hex') + } +} diff --git a/packages/server-core/src/git/worktree-journal.ts b/packages/server-core/src/git/worktree-journal.ts index a169396a..53c92118 100644 --- a/packages/server-core/src/git/worktree-journal.ts +++ b/packages/server-core/src/git/worktree-journal.ts @@ -31,6 +31,7 @@ export type WorktreeJournalOp = | 'session-delete' | 'cleanup' | 'handoff' + | 'fork' export type WorktreeJournalStatus = 'in-progress' | 'committed' | 'failed' | 'recovered' @@ -167,12 +168,17 @@ export class WorktreeJournal { }) } - /** Update restart-relevant transaction facts without changing its state. */ + /** + * Update restart-relevant transaction facts without changing its state. + * In-progress and failed entries always accept metadata; committed entries + * accept metadata-only updates too (e.g. the fork journal records the + * child provider identity on first-Send establishment after commit). + */ updateMetadata(journalId: string, metadata: Record): void { this.lock.runSync(() => { const entries = this.readAll() const entry = entries.find((candidate) => candidate.journalId === journalId) - if (!entry || (entry.status !== 'in-progress' && entry.status !== 'failed')) return + if (!entry || (entry.status !== 'in-progress' && entry.status !== 'failed' && entry.status !== 'committed')) return entry.metadata = { ...(entry.metadata ?? {}), ...metadata } this.writeAll(entries) }) @@ -224,10 +230,19 @@ export class WorktreeJournal { return this.readAll() } - /** Drop committed/recovered entries; keep failures as recovery evidence. */ + /** + * Drop committed/recovered entries; keep failures as recovery evidence. + * Committed fork entries are EXEMPT: their establishment metadata (and the + * orphan-ledger resolution that depends on it) must survive restarts until + * the fork is durably established — the first-Send establish flow writes + * `markEstablished` after the commit marker, and startup reconciliation + * resolves ledger entries against committed+established fork entries. + */ compact(): void { this.lock.runSync(() => { - const entries = this.readAll().filter((entry) => entry.status === 'failed') + const entries = this.readAll().filter( + (entry) => entry.status === 'failed' || (entry.status === 'committed' && entry.op === 'fork'), + ) if (entries.length === this.readAll().length) return this.writeAll(entries) }) diff --git a/packages/server-core/src/git/worktree-lifecycle-service.ts b/packages/server-core/src/git/worktree-lifecycle-service.ts index d5e832cb..138d799b 100644 --- a/packages/server-core/src/git/worktree-lifecycle-service.ts +++ b/packages/server-core/src/git/worktree-lifecycle-service.ts @@ -1482,10 +1482,18 @@ export class WorktreeLifecycleService { // A pending/failed handoff retains its snapshot as the recovery // authority even after the managed source record was removed // (managed-to-current release). Never GC a payload recovery needs. + // A pending/failed fork retains its seed the same way: the confirm + // journal records the seed id immediately after capture, and an + // interrupted confirm must still be able to restore the target from it. for (const entry of this.deps.journal.entries()) { - if (entry.op !== 'handoff' || (entry.status !== 'in-progress' && entry.status !== 'failed')) continue - const retained = entry.metadata?.retainedSnapshotId - if (typeof retained === 'string' && retained) referenced.add(retained) + if (entry.status !== 'in-progress' && entry.status !== 'failed') continue + if (entry.op === 'handoff') { + const retained = entry.metadata?.retainedSnapshotId + if (typeof retained === 'string' && retained) referenced.add(retained) + } else if (entry.op === 'fork') { + const seed = entry.metadata?.seedSnapshotId + if (typeof seed === 'string' && seed) referenced.add(seed) + } } for (const name of entries) { if (name.startsWith('.tmp-')) { diff --git a/packages/server-core/src/handlers/rpc/git.test.ts b/packages/server-core/src/handlers/rpc/git.test.ts index 5db49e12..468b589f 100644 --- a/packages/server-core/src/handlers/rpc/git.test.ts +++ b/packages/server-core/src/handlers/rpc/git.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { RPC_CHANNELS, WORKTREE_BRANCH_COLLISION_CODE, + WORKTREE_FORK_ERROR_CODE, WORKTREE_HANDOFF_ERROR_CODE, WORKTREE_LIFECYCLE_ERROR_CODE, WORKTREE_OWNERS_PRESENT_CODE, @@ -19,7 +20,7 @@ import type { SessionCheckoutV1, } from '@kata-sh/shared/protocol' import type { HandlerFn, RequestContext, RpcServer } from '@kata-sh/server-core/transport' -import { WorktreeCreationError, WorktreeHandoffError, WorktreeSettingsError } from '../../git' +import { WorktreeCreationError, WorktreeHandoffError, WorktreeSettingsError, ConversationForkError } from '../../git' import type { GitServices } from '../../git' import type { HandlerDeps } from '../handler-deps' import { registerGitHandlers, checkManagedCheckoutIdentity } from './git' @@ -46,23 +47,36 @@ interface MockOverrides { createdPr?: PullRequestSummary /** Make every handoff mock method throw (typed or plain) to exercise the error mapping. */ handoffError?: 'typed' | 'plain' + /** Make every fork mock method throw (typed or plain) to exercise the error mapping. */ + forkError?: 'typed' | 'plain' + /** Gate the startup reconciliation on a controllable promise (wiring tests). */ + waitForInit?: () => Promise } interface MockGit { git: GitServices calls: string[] createPrArgs: Array<{ baseRef: string }> + /** Order of startup reconciliation steps, recorded by the mock. */ + startupCalls: string[] } function makeGitServices(overrides?: MockOverrides): MockGit { const calls: string[] = [] const createPrArgs: Array<{ baseRef: string }> = [] + const startupCalls: string[] = [] const maybeThrow = () => { if (overrides?.handoffError === 'typed') { throw new WorktreeHandoffError('HANDOFF_TRANSACTION_UNKNOWN', 'Unknown handoff transaction.') } if (overrides?.handoffError === 'plain') throw new Error('boom') } + const maybeThrowFork = () => { + if (overrides?.forkError === 'typed') { + throw new ConversationForkError('FORK_TRANSACTION_UNKNOWN', 'Unknown fork transaction.') + } + if (overrides?.forkError === 'plain') throw new Error('boom') + } const defaultContext: RepositoryContext = { isGitRepository: false, repositoryRoot: null, @@ -153,7 +167,10 @@ function makeGitServices(overrides?: MockOverrides): MockGit { }, }, worktrees: { - reconcile: async () => ({ repaired: 0, removed: 0 }), + reconcile: async () => { + startupCalls.push('worktrees.reconcile') + return { repaired: 0, removed: 0 } + }, }, pathLeases: { lease: () => undefined, @@ -161,7 +178,9 @@ function makeGitServices(overrides?: MockOverrides): MockGit { }, lifecycle: { assertReady: () => undefined, - markReady: () => undefined, + markReady: () => { + startupCalls.push('lifecycle.markReady') + }, isReady: () => true, recordStateForSession: (sessionId: string) => ({ managedWorktreeId: null, state: 'ready' }), isSessionRecordReady: () => true, @@ -190,10 +209,15 @@ function makeGitServices(overrides?: MockOverrides): MockGit { permanentDelete: async () => ({ deleted: true }), setArchived: async () => ({ archived: true, state: 'ready', cleanupEnqueued: false }), enqueueCleanup: async () => ({ at: 1, outcome: 'skipped', policyVersion: 0 }), - reconcileJournal: async () => ({ resumed: 0, recovered: 0 }), + reconcileJournal: async () => { + startupCalls.push('lifecycle.reconcileJournal') + return { resumed: 0, recovered: 0 } + }, }, journal: { - compact: () => undefined, + compact: () => { + startupCalls.push('journal.compact') + }, }, handoff: { preview: async (input: { sessionId: string; direction: string; worktreeNameSuffix?: string }) => { @@ -222,6 +246,48 @@ function makeGitServices(overrides?: MockOverrides): MockGit { return { active: false } }, }, + fork: { + preview: async (input: { sessionId: string; strategy: string; worktreeNameSuffix?: string }) => { + calls.push(`fork.preview:${input.sessionId}:${input.strategy}`) + maybeThrowFork() + return { + transactionId: 'fork-txn-1', + previewFingerprint: 'fp-fork', + strategy: input.strategy, + currentHead: true, + } + }, + confirm: async (input: { sessionId: string; transactionId: string; strategy: string }) => { + calls.push(`fork.confirm:${input.sessionId}:${input.transactionId}`) + maybeThrowFork() + return { outcome: 'committed', transactionId: input.transactionId, summary: { sessionId: 'child-1' } } + }, + status: async (input: { sessionId: string }) => { + calls.push(`fork.status:${input.sessionId}`) + maybeThrowFork() + return { active: false } + }, + recover: async (input: { sessionId: string; transactionId: string }) => { + calls.push(`fork.recover:${input.sessionId}:${input.transactionId}`) + maybeThrowFork() + return { outcome: 'blocked', transactionId: input.transactionId, code: 'identity-drift', reason: 'stale' } + }, + cancel: async (input: { sessionId: string; transactionId: string }) => { + calls.push(`fork.cancel:${input.sessionId}:${input.transactionId}`) + maybeThrowFork() + return { active: false } + }, + reconcileForkJournal: async () => { + startupCalls.push('fork.reconcileForkJournal') + return { resumed: 0, recovered: 0, recoveryRequired: 0 } + }, + }, + forkOrphans: { + reconcile: async () => { + startupCalls.push('forkOrphans.reconcile') + return { resolved: 0, retained: 0, expiredUnresolved: 0, expiredAttemptIds: [] } + }, + }, worktreeSettings: { getCapability: (serverId = 'mock-server') => ({ serverId, worktreeV2: true }), getSnapshot: (serverId = 'mock-server') => ({ @@ -244,7 +310,7 @@ function makeGitServices(overrides?: MockOverrides): MockGit { }), }, } as unknown as GitServices - return { git, calls, createPrArgs } + return { git, calls, createPrArgs, startupCalls } } interface SessionShape { @@ -259,6 +325,7 @@ function makeHarness( sessions: SessionShape[] = [{ id: 's1', workspaceId: 'ws1', workingDirectory: '/repo' }], overrides?: { prepareCheckout?: (sessionId: string, intent: unknown) => Promise + waitForInit?: () => Promise }, ) { const handlers = new Map() @@ -287,6 +354,7 @@ function makeHarness( const deps: HandlerDeps = { sessionManager: { + waitForInit: overrides?.waitForInit ?? (async () => {}), getSessions() { return sessions }, @@ -589,6 +657,157 @@ describe('registerGitHandlers', () => { } }) + it('routes fork preview, confirm, status, recover, and cancel through shared contracts', async () => { + process.env[FLAG] = '1' + process.env[V2_FLAG] = '1' + const { git, calls } = makeGitServices() + const harness = makeHarness(git) + const ctx = harness.ctx + + await expect( + harness.handlers.get(RPC_CHANNELS.git.FORK_PREVIEW)!(ctx, { + sessionId: 's1', + strategy: 'isolated-worktree', + worktreeNameSuffix: 'demo', + }), + ).resolves.toMatchObject({ transactionId: 'fork-txn-1', previewFingerprint: 'fp-fork', strategy: 'isolated-worktree' }) + await expect( + harness.handlers.get(RPC_CHANNELS.git.FORK_CONFIRM)!(ctx, { + sessionId: 's1', + strategy: 'isolated-worktree', + transactionId: 'fork-txn-1', + previewFingerprint: 'fp-fork', + worktreeNameSuffix: 'demo', + }), + ).resolves.toMatchObject({ outcome: 'committed', transactionId: 'fork-txn-1' }) + await expect(harness.handlers.get(RPC_CHANNELS.git.FORK_STATUS)!(ctx, { sessionId: 's1' })).resolves.toEqual({ + active: false, + }) + await expect( + harness.handlers.get(RPC_CHANNELS.git.FORK_RECOVER)!(ctx, { + sessionId: 's1', + transactionId: 'fork-txn-1', + }), + ).resolves.toMatchObject({ outcome: 'blocked', code: 'identity-drift' }) + await expect( + harness.handlers.get(RPC_CHANNELS.git.FORK_CANCEL)!(ctx, { + sessionId: 's1', + transactionId: 'fork-txn-1', + }), + ).resolves.toEqual({ active: false }) + expect(calls.filter((call) => call.startsWith('fork.'))).toEqual([ + 'fork.preview:s1:isolated-worktree', + 'fork.confirm:s1:fork-txn-1', + 'fork.status:s1', + 'fork.recover:s1:fork-txn-1', + 'fork.cancel:s1:fork-txn-1', + ]) + }) + + it('rejects every fork RPC when the V2 flag is disabled', async () => { + process.env[FLAG] = '1' + delete process.env[V2_FLAG] + const { git } = makeGitServices() + const harness = makeHarness(git) + const ctx = harness.ctx + + const forkInputs = { + [RPC_CHANNELS.git.FORK_PREVIEW]: { sessionId: 's1', strategy: 'isolated-worktree' }, + [RPC_CHANNELS.git.FORK_CONFIRM]: { sessionId: 's1', strategy: 'isolated-worktree', transactionId: 'fork-txn-1', previewFingerprint: 'fp' }, + [RPC_CHANNELS.git.FORK_STATUS]: { sessionId: 's1' }, + [RPC_CHANNELS.git.FORK_RECOVER]: { sessionId: 's1', transactionId: 'fork-txn-1' }, + [RPC_CHANNELS.git.FORK_CANCEL]: { sessionId: 's1', transactionId: 'fork-txn-1' }, + } as const + + for (const [channel, input] of Object.entries(forkInputs)) { + await expect(harness.handlers.get(channel)!(ctx, input as never)).rejects.toMatchObject({ + code: WORKTREE_V2_CAPABILITY_ERROR_CODE, + }) + } + }) + + it('maps ConversationForkError to the typed wire code and rethrows unrelated errors unchanged', async () => { + process.env[FLAG] = '1' + process.env[V2_FLAG] = '1' + + const forkInputs = { + [RPC_CHANNELS.git.FORK_PREVIEW]: { sessionId: 's1', strategy: 'isolated-worktree' }, + [RPC_CHANNELS.git.FORK_CONFIRM]: { sessionId: 's1', strategy: 'isolated-worktree', transactionId: 'fork-txn-1', previewFingerprint: 'fp' }, + [RPC_CHANNELS.git.FORK_STATUS]: { sessionId: 's1' }, + [RPC_CHANNELS.git.FORK_RECOVER]: { sessionId: 's1', transactionId: 'fork-txn-1' }, + [RPC_CHANNELS.git.FORK_CANCEL]: { sessionId: 's1', transactionId: 'fork-txn-1' }, + } as const + + const typed = makeHarness(makeGitServices({ forkError: 'typed' }).git) + for (const [channel, input] of Object.entries(forkInputs)) { + await expect(typed.handlers.get(channel)!(typed.ctx, input as never)).rejects.toMatchObject({ + code: WORKTREE_FORK_ERROR_CODE, + }) + } + + const plain = makeHarness(makeGitServices({ forkError: 'plain' }).git) + for (const [channel, input] of Object.entries(forkInputs)) { + await expect(plain.handlers.get(channel)!(plain.ctx, input as never)).rejects.toMatchObject({ message: 'boom' }) + } + }) + + it('startup reconciliation runs fork and orphan reconcile before journal compact and markReady', async () => { + process.env[FLAG] = '1' + const { git, startupCalls } = makeGitServices() + // Gate the best-effort startup reconciliation so the wiring test can await + // its exact ordering instead of racing the fire-and-forget IIFE. + let releaseInit!: () => void + const initGate = new Promise((resolve) => { + releaseInit = resolve + }) + const harness = makeHarness(git, [{ id: 's1', workspaceId: 'ws1', workingDirectory: '/repo' }], { + waitForInit: async () => initGate, + }) + expect(startupCalls).toEqual([]) + releaseInit() + // Drain the async startup reconciliation (bounded wait for markReady). + for (let i = 0; i < 25 && !startupCalls.includes('lifecycle.markReady'); i++) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + // Every step runs, in the lifecycle-ready gate order: session leases → + // worktrees.reconcile → lifecycle.reconcileJournal → fork reconcile → + // orphan reconcile → journal.compact → markReady. + expect(startupCalls).toEqual([ + 'worktrees.reconcile', + 'lifecycle.reconcileJournal', + 'fork.reconcileForkJournal', + 'forkOrphans.reconcile', + 'journal.compact', + 'lifecycle.markReady', + ]) + }) + + it('marks lifecycle ready when optional fork reconciliation services are absent', async () => { + process.env[FLAG] = '1' + const { git, startupCalls } = makeGitServices() + ;(git as any).fork = {} + ;(git as any).forkOrphans = {} + let releaseInit!: () => void + const initGate = new Promise((resolve) => { + releaseInit = resolve + }) + makeHarness(git, [{ id: 's1', workspaceId: 'ws1', workingDirectory: '/repo' }], { + waitForInit: async () => initGate, + }) + releaseInit() + for (let i = 0; i < 25 && !startupCalls.includes('lifecycle.markReady'); i++) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + expect(startupCalls).toEqual([ + 'worktrees.reconcile', + 'lifecycle.reconcileJournal', + 'journal.compact', + 'lifecycle.markReady', + ]) + }) + it('serves inventory, preview, delete, restore, retry, permanent-delete, archive, and unarchive RPCs', async () => { process.env[FLAG] = '1' process.env.KATA_FEATURE_WORKTREE_V2 = '1' diff --git a/packages/server-core/src/handlers/rpc/git.ts b/packages/server-core/src/handlers/rpc/git.ts index d5502597..37dc892a 100644 --- a/packages/server-core/src/handlers/rpc/git.ts +++ b/packages/server-core/src/handlers/rpc/git.ts @@ -22,6 +22,8 @@ import { WORKTREE_PREVIEW_STALE_CODE, WORKTREE_SETTINGS_ERROR_CODE, WORKTREE_STATE_UNMANAGEABLE_CODE, + WORKTREE_FORK_ERROR_CODE, + WORKTREE_FORK_PENDING_CODE, WorktreeV2CapabilityError, } from '@kata-sh/shared/protocol' import type { @@ -45,6 +47,11 @@ import type { WorktreeHandoffRecoverInput, WorktreeHandoffCancelInput, WorktreeHandoffStatusInput, + ConversationForkPreviewInput, + ConversationForkConfirmInput, + ConversationForkStatusInput, + ConversationForkRecoverInput, + ConversationForkCancelInput, } from '@kata-sh/shared/protocol' import { isGitWorkspaceV1Enabled, isWorktreeV2Enabled } from '@kata-sh/shared/feature-flags' import { i18n } from '@kata-sh/shared/i18n' @@ -57,8 +64,9 @@ import { WorktreeLifecycleError, WorktreeSettingsError, WorktreeHandoffError, + ConversationForkError, } from '../../git' -import type { GitServices } from '../../git' +import type { GitServices, SessionForkState } from '../../git' import type { HandlerDeps } from '../handler-deps' export const GIT_HANDLED_CHANNELS = [ @@ -94,6 +102,11 @@ export const GIT_HANDLED_CHANNELS = [ RPC_CHANNELS.git.HANDOFF_STATUS, RPC_CHANNELS.git.HANDOFF_RECOVER, RPC_CHANNELS.git.HANDOFF_CANCEL, + RPC_CHANNELS.git.FORK_PREVIEW, + RPC_CHANNELS.git.FORK_CONFIRM, + RPC_CHANNELS.git.FORK_STATUS, + RPC_CHANNELS.git.FORK_RECOVER, + RPC_CHANNELS.git.FORK_CANCEL, ] as const function assertFeatureEnabled(): void { @@ -151,6 +164,13 @@ function throwTypedWorktreeHandoffError(error: unknown): never { throw error } +function throwTypedConversationForkError(error: unknown): never { + if (error instanceof ConversationForkError) { + throw new CodedError(WORKTREE_FORK_ERROR_CODE, error.message) + } + throw error +} + /** * Fence Git work on a session whose managed-worktree record is not `ready`: * Send, agent creation, Git actions, and further lifecycle actions stay fenced @@ -162,6 +182,9 @@ function assertSessionWorktreeUsable(git: GitServices, sessionId: string): void if (git.handoff?.isSessionFenced?.(sessionId)) { throw new CodedError(WORKTREE_HANDOFF_PENDING_CODE, i18n.t('git.handoff.pendingFence')) } + if (git.fork?.isSessionFenced?.(sessionId)) { + throw new CodedError(WORKTREE_FORK_PENDING_CODE, i18n.t('git.fork.pendingFence')) + } git.lifecycle.assertReady() const { state } = git.lifecycle.recordStateForSession(sessionId) if (state !== 'ready') { @@ -362,6 +385,31 @@ export function registerGitHandlers( } await git.worktrees.reconcile({ knownSessionIds, sessionCheckouts }) const journalReport = await git.lifecycle.reconcileJournal() + // Fork reconciliation: classify interrupted fork journal entries + // (committed stay; pre-child in-progress stays resumable; child-created + // without a live pending child becomes recovery-required) and backfill + // the establish marker a crash between the child-session flush and + // markEstablished may have lost. The session lookup comes from the + // SessionManager (wired through the fork hooks by setGitServices above; + // passed explicitly here so reconciliation never depends on hook-wiring + // order). + const forkReport = (await git.fork?.reconcileForkJournal?.({ + resolveSessionForkState: (sessionId: string) => + deps.sessionManager.resolveSessionForkState?.(sessionId) ?? null, + })) ?? { resumed: 0, flagged: 0, recoveryRequired: 0 } + // Orphan reconcile: retire ledger entries whose fork transaction later + // established; surface stale unresolved entries (never auto-deleted — + // the operator/UI decides). Never attaches an orphan to a session. + const orphanReport = (await git.forkOrphans?.reconcile?.({ + isEstablished: (transactionId) => + git.journal.entries().some( + (entry) => + entry.op === 'fork' && + entry.recordId === transactionId && + entry.status === 'committed' && + entry.metadata?.state === 'established', + ), + })) ?? { resolved: 0, retained: 0, expiredUnresolved: 0, expiredAttemptIds: [] } git.journal.compact() git.lifecycle.markReady() if (journalReport.resumed > 0 || journalReport.recovered > 0) { @@ -369,6 +417,16 @@ export function registerGitHandlers( `[worktree] startup reconciliation resumed ${journalReport.resumed} and recovered ${journalReport.recovered} interrupted lifecycle transaction(s).`, ) } + if (forkReport.resumed > 0 || forkReport.flagged > 0 || forkReport.recoveryRequired > 0) { + console.info( + `[worktree] startup fork reconciliation backfilled ${forkReport.resumed}, flagged ${forkReport.flagged} new, and surfaced ${forkReport.recoveryRequired} recovery-required fork transaction(s).`, + ) + } + if (orphanReport.resolved > 0 || orphanReport.expiredUnresolved > 0) { + console.info( + `[worktree] startup orphan reconciliation resolved ${orphanReport.resolved} and surfaced ${orphanReport.expiredUnresolved} expired unresolved fork establishment attempt(s).`, + ) + } } catch (error) { console.error('[worktree] startup reconciliation failed; lifecycle work stays fenced.', error) } @@ -556,6 +614,73 @@ export function registerGitHandlers( }, ) + // --- Isolated conversation forks (Phase 4) --- + + // The fork surface is server-authoritative: previews return typed blockers + // as normal results (never throw), confirms/status/recover/cancel map typed + // fork errors to the WORKTREE_FORK_FAILED code, and every handler requires + // Worktree V2 effective. + + server.handle( + RPC_CHANNELS.git.FORK_PREVIEW, + async (_ctx, input: ConversationForkPreviewInput) => { + assertWorktreeV2Enabled() + try { + return await git.fork.preview(input) + } catch (error) { + throwTypedConversationForkError(error) + } + }, + ) + + server.handle( + RPC_CHANNELS.git.FORK_CONFIRM, + async (_ctx, input: ConversationForkConfirmInput) => { + assertWorktreeV2Enabled() + try { + return await git.fork.confirm(input) + } catch (error) { + throwTypedConversationForkError(error) + } + }, + ) + + server.handle( + RPC_CHANNELS.git.FORK_STATUS, + async (_ctx, input: ConversationForkStatusInput) => { + assertWorktreeV2Enabled() + try { + return await git.fork.status(input) + } catch (error) { + throwTypedConversationForkError(error) + } + }, + ) + + server.handle( + RPC_CHANNELS.git.FORK_RECOVER, + async (_ctx, input: ConversationForkRecoverInput) => { + assertWorktreeV2Enabled() + try { + return await git.fork.recover(input) + } catch (error) { + throwTypedConversationForkError(error) + } + }, + ) + + server.handle( + RPC_CHANNELS.git.FORK_CANCEL, + async (_ctx, input: ConversationForkCancelInput) => { + assertWorktreeV2Enabled() + try { + return await git.fork.cancel(input) + } catch (error) { + throwTypedConversationForkError(error) + } + }, + ) + // --- Repository context and ref listing (Phase 1, read-only) --- server.handle(RPC_CHANNELS.git.GET_CONTEXT, async (_ctx, dir: string) => { diff --git a/packages/server-core/src/handlers/rpc/headless-server-flow.test.ts b/packages/server-core/src/handlers/rpc/headless-server-flow.test.ts index 0dc8fa41..941c3198 100644 --- a/packages/server-core/src/handlers/rpc/headless-server-flow.test.ts +++ b/packages/server-core/src/handlers/rpc/headless-server-flow.test.ts @@ -340,4 +340,57 @@ describe('headless-server Git flow (remote ownership) — AC17/AC21', () => { expect(restored.checkout?.recoveryState).toBeUndefined() expect(restored.checkout?.checkoutPath).not.toBe(checkoutPath) }) + + test('serves the conversation-fork surface headlessly: typed blocked previews, typed confirm errors, pending fences', async () => { + process.env[V2_FLAG] = '1' + const repo = tmp() + await initRepo(repo) + const { sm, handlers, ctx } = makeServer() + const sessionRoot = tmp() + injectSession(sm, 'remote-fork', sessionRoot) + // Point the session's working directory at the real repository so the + // fork source resolves; the session has no live agent, so the strict fork + // capability is absent and preview returns the typed unsupported-provider + // blocker. + const managed = (sm as unknown as { sessions: Map }).sessions.get('remote-fork') as { + workingDirectory?: string + } + managed.workingDirectory = repo + + // A session without a live agent advertises no strict fork capability: + // preview must return a typed blocked preview (never throw) — the same + // contract a remote client sees. + const preview = (await handlers.get(RPC_CHANNELS.git.FORK_PREVIEW)!(ctx, { + sessionId: 'remote-fork', + strategy: 'isolated-worktree', + worktreeNameSuffix: 'headless-isolated', + })) as { blocked?: { code: string }; strategy: string } + expect(preview.strategy).toBe('isolated-worktree') + expect(preview.blocked?.code).toBe('unsupported-provider') + + // Shared-worktree strategy stays available headlessly without a capability. + const shared = (await handlers.get(RPC_CHANNELS.git.FORK_PREVIEW)!(ctx, { + sessionId: 'remote-fork', + strategy: 'shared-worktree', + })) as { blocked?: { code: string }; strategy: string } + expect(shared.strategy).toBe('shared-worktree') + expect(shared.blocked).toBeUndefined() + + // Unknown transaction on the wire maps to the typed fork error code. + await expect( + handlers.get(RPC_CHANNELS.git.FORK_CONFIRM)!(ctx, { + sessionId: 'remote-fork', + strategy: 'isolated-worktree', + transactionId: 'deadbeefdeadbeef', + previewFingerprint: 'fp', + worktreeNameSuffix: 'headless-isolated', + }), + ).rejects.toMatchObject({ code: 'WORKTREE_FORK_FAILED' }) + + // Fork RPCs are V2-gated like every other V2 surface. + delete process.env[V2_FLAG] + await expect( + handlers.get(RPC_CHANNELS.git.FORK_STATUS)!(ctx, { sessionId: 'remote-fork' }), + ).rejects.toMatchObject({ code: 'GIT_WORKTREE_V2_UNAVAILABLE' }) + }) }) diff --git a/packages/server-core/src/handlers/rpc/sessions.ts b/packages/server-core/src/handlers/rpc/sessions.ts index 5703c071..cb2ae434 100644 --- a/packages/server-core/src/handlers/rpc/sessions.ts +++ b/packages/server-core/src/handlers/rpc/sessions.ts @@ -232,7 +232,7 @@ export function registerSessionsHandlers(server: RpcServer, deps: HandlerDeps): } sessionManager - .sendMessage(sessionId, message, attachments, storedAttachments, options, undefined, undefined, onAck, { callerClientId }) + .sendMessage(sessionId, message, attachments, storedAttachments, options, options?.existingMessageId, undefined, onAck, { callerClientId }) .then(() => { // sendMessage finished without firing onAck — should not happen in // practice (every code path that creates a user message acks). @@ -250,11 +250,15 @@ export function registerSessionsHandlers(server: RpcServer, deps: HandlerDeps): reject(err) return } - // Post-persist error — route via the event stream as today. + // Post-persist error — route via the event stream as today. The + // typed code travels with the event so the renderer can distinguish + // retryable isolated-fork establishment failures (Phase 4) from + // generic send errors. pushTyped(server, RPC_CHANNELS.sessions.EVENT, { to: 'client', clientId: callerClientId }, { type: 'error', sessionId, - error: err instanceof Error ? err.message : 'Unknown error' + error: err instanceof Error ? err.message : 'Unknown error', + ...((err as { code?: string } | null)?.code ? { code: (err as { code: string }).code } : {}), } as SessionEvent) pushTyped(server, RPC_CHANNELS.sessions.EVENT, { to: 'client', clientId: callerClientId }, { type: 'complete', diff --git a/packages/server-core/src/handlers/session-manager-interface.ts b/packages/server-core/src/handlers/session-manager-interface.ts index d3ce04b9..e1d72183 100644 --- a/packages/server-core/src/handlers/session-manager-interface.ts +++ b/packages/server-core/src/handlers/session-manager-interface.ts @@ -104,6 +104,13 @@ export interface ISessionManager { * to the lazily-constructed default services when never called. */ setGitServices?(services: import('../git').GitServices): void + /** + * Durable fork-state facts for one session (Phase 4 reconciliation): the + * child SDK session id, the pending fork intent, and the checkout-strategy + * provenance. Used by fork-journal reconciliation to backfill the establish + * marker a crash between the child-session flush and markEstablished lost. + */ + resolveSessionForkState?(sessionId: string): import('../git').SessionForkState | null /** * Install a callback that requests an immediate Git status refresh for a * session. The git RPC handlers wire this so agent turn completion refreshes diff --git a/packages/server-core/src/sessions/SessionManager.ts b/packages/server-core/src/sessions/SessionManager.ts index 3a51a1c2..1d1ce148 100644 --- a/packages/server-core/src/sessions/SessionManager.ts +++ b/packages/server-core/src/sessions/SessionManager.ts @@ -4,7 +4,7 @@ import type { ISessionManager, IBrowserPaneManager, ExecutePromptAutomationInput import { RemoteBrowserPaneManager } from './RemoteBrowserPaneManager' import { validateFilePath, getWorkspaceAllowedDirs } from '@kata-sh/server-core/handlers' import { createScopedLogger, CONSOLE_LOGGER, type PlatformServices, type Logger } from '@kata-sh/server-core/runtime' -import { basename, dirname, join, resolve } from 'path' +import { basename, dirname, isAbsolute, join, resolve } from 'path' import { existsSync, mkdirSync, @@ -23,16 +23,18 @@ import { resolveBackendContext, createBackendFromResolvedContext, resolveHandoffCapability, + resolveIsolatedForkCapability, cleanupSourceRuntimeArtifacts, providerTypeToAgentProvider, type AgentBackend, type BackendHostRuntimeContext, + type ConversationForkEstablishResult, type PostInitResult, } from '@kata-sh/shared/agent/backend' import { getLlmConnection, getLlmConnections, getDefaultLlmConnection, getDefaultThinkingLevel, resetManagedAnthropicAuthEnvVars, resolveMidStreamBehavior } from '@kata-sh/shared/config' import { PrivilegedExecutionBroker } from '@kata-sh/server-core/services' import { isValidWorkingDirectory } from '../utils/path-validation' -import { getDefaultGitServices, type GitServices, safeRealpath } from '../git' +import { getDefaultGitServices, type GitServices, safeRealpath, type ConversationForkChildSessionInput, type ForkOrphanResult } from '../git' import { isGitWorkspaceV1Enabled, isWorktreeV2Enabled } from '@kata-sh/shared/feature-flags' import { InitGate } from '@kata-sh/server-core/domain' import { i18n, LOCALE_REGISTRY, type LanguageCode } from '@kata-sh/shared/i18n' @@ -92,7 +94,7 @@ import { isParentTaskTool } from '@kata-sh/shared/utils/toolNames' import { restoreFiles } from '@kata-sh/shared/utils/bundle-files' import { getCredentialManager } from '@kata-sh/shared/credentials' import { CraftMcpClient, McpClientPool, McpPoolServer } from '@kata-sh/shared/mcp' -import { type Session, type SessionCheckout, type SessionEvent, type FileAttachment, type SendMessageOptions, type UnreadSummary, type RemoteSessionTransferPayload, type ImportRemoteSessionTransferResult, type WorktreeRemovalConfirmation, RPC_CHANNELS, WorktreeV2CapabilityError, generateMessageId } from '@kata-sh/shared/protocol' +import { type Session, type SessionCheckout, type SessionEvent, type FileAttachment, type SendMessageOptions, type UnreadSummary, type RemoteSessionTransferPayload, type ImportRemoteSessionTransferResult, type WorktreeRemovalConfirmation, RPC_CHANNELS, WorktreeV2CapabilityError, CodedError, WORKTREE_FORK_PENDING_CODE, WORKTREE_FORK_ERROR_CODE, generateMessageId } from '@kata-sh/shared/protocol' import { messageToStored, storedToMessage, type Message, type StoredAttachment, type ToolDisplayMeta } from '@kata-sh/core/types' import { formatPathsToRelative, formatToolInputPaths, perf, encodeIconToDataUrlAsync, getEmojiIcon, resetSummarizationClient, resolveToolIcon, readFileAttachment, selectSpreadMessages, normalizePath } from '@kata-sh/shared/utils' import { loadAllSkills, loadSkillBySlug, invalidateSkillsCache, type LoadedSkill } from '@kata-sh/shared/skills' @@ -845,6 +847,28 @@ interface ManagedSession { checkout?: import('@kata-sh/shared/protocol').SessionCheckout // Handoff runtime reconstruction state ('unverified' arms the Send proof gate). handoffRuntimeState?: 'unverified' | 'verified' | 'recovery-required' + /** + * Durable pending isolated-fork intent (Phase 4). Present on an isolated + * fork child from publication until first-Send provider establishment + * (Task 4 replaces the Send gate with the establish flow). Mirrors the + * persisted StoredSession.pendingFork. + */ + pendingFork?: { + transactionId: string + parentSessionId: string + parentSdkSessionId: string + parentSdkTurnId: string + transcriptCwd: string + executionCwd: string + idempotencyKey: string + createdAt: number + } + /** + * Durable checkout-strategy provenance recorded at branch/fork creation + * (Phase 4): 'shared' for branches sharing the parent managed worktree, + * 'isolated' for isolated fork children owning a dedicated target. + */ + checkoutStrategy?: 'shared' | 'isolated' // Shared viewer URL (if shared via viewer) sharedUrl?: string // Shared session ID in viewer (for revoke) @@ -974,6 +998,7 @@ interface ManagedSession { } const PI_SDK_MESSAGE_ID_CACHE_LIMIT = 256 +const FORK_PROOF_MAX_AGE_MS = 5 * 60 * 1000 export interface AutoRetryPendingHost { autoRetryPending?: { @@ -1114,10 +1139,26 @@ function managedToSession(m: ManagedSession, overrides?: Partial): Sess /* adapter capability unavailable — omit; the server blocker remains authoritative */ } } + // Client-visible isolated-fork capability (Phase 4): the fork dialog offers + // the isolated strategy only when the provider adapter advertises a strict + // cross-CWD native fork. Absent until the runtime exists, like handoffCapable. + let isolatedForkCapable: boolean | undefined + if (m.agent) { + try { + isolatedForkCapable = resolveIsolatedForkCapability(m.agent).supported + } catch { + /* adapter capability unavailable — omit; the server blocker remains authoritative */ + } + } return { ...pickSessionFields(m), sharedOwnerCount, handoffCapable, + isolatedForkCapable, + // Phase 4: a published-but-not-established isolated fork child shows + // provider identity as PENDING (never a child provider ID) until the + // first-Send establish flow retires the pending intent. + forkPending: !!m.pendingFork, // Pre-computed fields from header (not in SESSION_PERSISTENT_FIELDS) preview: m.preview, lastMessageRole: m.lastMessageRole, @@ -1143,12 +1184,51 @@ interface PendingDelta { turnId?: string } +/** + * Internal (SessionManager-only) option that turns {@link SessionManager.createSession} + * into durable pending isolated-fork child creation (Phase 4 Task 3c). The fork + * service invokes it through the wired `createForkChildSession` hook: the child + * copies messages through the fork point, binds the TARGET checkout (never the + * source's), persists the pendingFork intent, and skips the branch backend + * preflight entirely — provider establishment happens on first Send (Task 4), + * not at creation. + */ +interface ForkChildCreateOptions { + pendingFork?: { + transactionId: string + parentSessionId: string + parentSdkSessionId: string | undefined + parentSdkTurnId: string | undefined + /** Immutable transcript lookup identity of the parent. */ + transcriptCwd: string + /** Destination execution CWD every runtime must resolve to. */ + executionCwd: string + idempotencyKey: string + /** Durable checkout binding for the isolated target (V2 managed worktree). */ + checkout: import('@kata-sh/shared/protocol').SessionCheckoutV2 + } +} + export class SessionManager implements ISessionManager { private sessions: Map = new Map() /** Sends that have started but have not yet entered agent.chat(). */ private pendingPreChatBarriers: Map>> = new Map() /** Counts concurrent delete operations so a fence is cleared only after all settle. */ private sessionTeardownFences: Map = new Map() + /** + * Sessions whose first-Send fork establishment is currently in flight. + * Serializes concurrent first-sends on a pending isolated fork child so a + * second send cannot double-dispatch while establishment runs (the + * persisted idempotency key dedupes the provider artifact itself). + */ + private forkEstablishing: Set = new Set() + /** + * Pending-child sends that have claimed the pre-persist first-Send slot. + * This is separate from forkEstablishing because the slot must be claimed + * before the user message is flushed, while provider establishment starts + * afterward. + */ + private forkSendInFlight: Set = new Set() /** Keeps a blocked destructive deletion fenced until a later retry succeeds. */ private sessionTeardownFenceHolds: Set = new Set() /** Reused by retries while a timed-out backend teardown is still running. */ @@ -2661,7 +2741,10 @@ export class SessionManager implements ISessionManager { return getSessionStoragePath(managed.workspace.rootPath, sessionId) } - async createSession(workspaceId: string, options?: import('@kata-sh/shared/protocol').CreateSessionOptions): Promise { + async createSession( + workspaceId: string, + options?: import('@kata-sh/shared/protocol').CreateSessionOptions & ForkChildCreateOptions, + ): Promise { const workspace = getWorkspaceByNameOrId(workspaceId) if (!workspace) { throw new Error(`Workspace ${workspaceId} not found`) @@ -2932,6 +3015,24 @@ export class SessionManager implements ISessionManager { isFlagged: options?.isFlagged, }) + // Phase 4: durable pending isolated-fork intent, computed once so the + // persisted record and the in-memory managed session agree exactly. Only + // set for pending fork children (Task 3c); ordinary branches leave this + // undefined. + const forkPending = options?.pendingFork + const forkPendingIntent = forkPending + ? { + transactionId: forkPending.transactionId, + parentSessionId: forkPending.parentSessionId, + parentSdkSessionId: forkPending.parentSdkSessionId ?? '', + parentSdkTurnId: forkPending.parentSdkTurnId ?? '', + transcriptCwd: forkPending.transcriptCwd, + executionCwd: forkPending.executionCwd, + idempotencyKey: forkPending.idempotencyKey, + createdAt: Date.now(), + } + : undefined + // Branch: copy messages from source session up to and including the branch point if (validatedBranch) { const branchedStored = loadStoredSession(workspaceRootPath, storedSession.id) @@ -2974,11 +3075,25 @@ export class SessionManager implements ISessionManager { // session shares the same managed worktree (V1 does not claim filesystem // isolation between provider-native conversation branches). Inherit the // parent's checkout metadata and worktree working directory / sdk cwd. - if (validatedBranch.sourceSession.checkout) { + // + // Pending isolated-fork children (Phase 4) are the exception: they bind + // the TARGET checkout the fork transaction materialized (isolated = new + // record), persist the durable pendingFork intent, and record the + // 'isolated' checkout-strategy provenance for session-branch cleanup. + if (forkPendingIntent && forkPending) { + const targetCheckout = forkPending.checkout + branchedStored.checkout = targetCheckout + branchedStored.workingDirectory = targetCheckout.checkoutPath + branchedStored.sdkCwd = targetCheckout.checkoutPath + branchedStored.pendingFork = forkPendingIntent + branchedStored.checkoutStrategy = 'isolated' + } else if (validatedBranch.sourceSession.checkout) { const parentCheckout = validatedBranch.sourceSession.checkout branchedStored.checkout = parentCheckout branchedStored.workingDirectory = parentCheckout.checkoutPath branchedStored.sdkCwd = parentCheckout.checkoutPath + // Conversation-branch shared ownership: durable provenance for cleanup. + branchedStored.checkoutStrategy = 'shared' } await saveStoredSession(branchedStored) @@ -3042,7 +3157,13 @@ export class SessionManager implements ISessionManager { if (isBranch) { await this.ensureMessagesLoaded(managed) - const requiresBranchPreflight = managed.branchContextStrategy === 'sdk-fork' + const requiresBranchPreflight = + managed.branchContextStrategy === 'sdk-fork' && !options?.pendingFork + // Phase 4 guard: pendingFork children (isolated fork children) NEVER + // enter this preflight, so rollbackFailedBranchCreation is unreachable + // for them — provider establishment happens on first Send, and failed + // fork creation is compensated by the fork service (which owns the + // target worktree), never by this branch rollback. if (requiresBranchPreflight) { // Enforce branch correctness at creation time. // A branch is only valid if backend context can be established now, @@ -3086,7 +3207,21 @@ export class SessionManager implements ISessionManager { // Conversation-branch shared ownership: mirror the parent's checkout onto // the in-memory child and register it as an additional owner of the shared // managed worktree so removal is blocked while this owner remains. - if (validatedBranch?.sourceSession.checkout) { + // Pending isolated-fork children instead bind the TARGET checkout, mirror + // the durable pendingFork intent onto the runtime session (Task 4 consumes + // it at first Send), and fence the target path with a session lease. + // Guarded on validatedBranch like the stored block: the internal + // pendingFork option must never apply to a payload that was not a real + // branch/fork (the sessions:create RPC boundary is untyped). + if (validatedBranch && forkPendingIntent && forkPending) { + const targetCheckout = forkPending.checkout + managed.checkout = targetCheckout + managed.workingDirectory = targetCheckout.checkoutPath + managed.sdkCwd = targetCheckout.checkoutPath + managed.pendingFork = forkPendingIntent + managed.checkoutStrategy = 'isolated' + this.getGitServices().pathLeases.lease(storedSession.id, targetCheckout.checkoutPath) + } else if (validatedBranch?.sourceSession.checkout) { const parentCheckout = validatedBranch.sourceSession.checkout managed.checkout = parentCheckout managed.workingDirectory = parentCheckout.checkoutPath @@ -3343,9 +3478,24 @@ export class SessionManager implements ISessionManager { * 2. workspace.defaults.defaultLlmConnection * 3. global defaultLlmConnection * 4. fallback: no connection configured + * + * The pending-child fence is bypassed ONLY when the establish flow calls + * in with `allowPendingForkEstablish` (first-Send provider establishment); + * every other caller keeps the typed pending gate. */ - private async getOrCreateAgent(managed: ManagedSession): Promise { + private async getOrCreateAgent( + managed: ManagedSession, + opts?: { allowPendingForkEstablish?: boolean }, + ): Promise { this.assertSessionHandoffNotFenced(managed.id) + // Phase 4: a pending fork transaction owns this session's checkout, and a + // published-but-unestablished isolated fork child must not create a plain + // agent — provider establishment happens on first Send (Task 4). The + // establish path alone bypasses the pending-child gate. + this.assertSessionForkNotFenced(managed.id) + if (!opts?.allowPendingForkEstablish) { + this.assertSessionNotPendingForkChild(managed) + } // Refresh runtime config in-place when the connection has drifted since // the agent was created. May null out `managed.agent` if the in-place // refresh fails, in which case the create branch below rebuilds it. @@ -3618,22 +3768,6 @@ export class SessionManager implements ISessionManager { }, }) as AgentInstance - // Credential-free UI UAT seam (AC-15): the deterministic adapter lets - // the real Electron app exercise preview/confirm/recovery without a - // live provider. Off by default; production adapters remain disabled - // until credentialed UAT proves context continuity. - if ( - process.env.KATA_HANDOFF_DETERMINISTIC_ADAPTER === '1' && - process.env.NODE_ENV !== 'production' && - managed.agent - ) { - sessionLog.warn( - `Session ${managed.id}: deterministic handoff adapter is active. Execution-CWD proofs are synthetic and prove nothing about the live runtime.`, - ) - const { createDeterministicHandoffAdapter } = await import('@kata-sh/shared/agent/backend') - managed.agent.executionCwdRebind = createDeterministicHandoffAdapter({ adapterId: 'deterministic-e2e' }) - } - sessionLog.info(`Created ${provider} agent for session ${managed.id} (model: ${backendContext.resolvedModel})${managed.sdkSessionId ? ' (resuming)' : ''}`) // The renderer's session DTO now carries capabilities that only exist @@ -5479,6 +5613,378 @@ export class SessionManager implements ISessionManager { } } + /** + * Fence an action while a pending/recovery conversation-fork transaction + * owns the session: the checkout identity is not safe to act on until the + * transaction commits, rolls back, or the session is deleted. Composes with + * the handoff fence (a session may only carry one active binding transition). + */ + private assertSessionForkNotFenced(sessionId: string): void { + if (this.getGitServices().fork?.isSessionFenced?.(sessionId)) { + throw new CodedError( + WORKTREE_FORK_PENDING_CODE, + i18n.t('git.fork.pendingFence'), + ) + } + } + + /** + * Fence Send/agent-creation on a published-but-not-established isolated fork + * child (Phase 4). The child carries a durable pendingFork intent until the + * provider-native fork is established on first Send; before Task 4 the gate + * blocks with the typed pending code instead of proceeding. + */ + private assertSessionNotPendingForkChild(managed: ManagedSession): void { + if (managed.pendingFork) { + throw new CodedError( + WORKTREE_FORK_PENDING_CODE, + i18n.t('git.fork.pendingChild'), + ) + } + } + + /** + * First-Send provider establishment for a pending isolated fork child + * (Phase 4 Task 4). Runs after the user message is persisted: resolves the + * strict fork adapter on the child's agent, establishes the native fork at + * the recorded source head with the PERSISTED idempotency key, persists the + * child provider ID exactly once, retires the pending metadata, and records + * the establishment in the fork journal. The message dispatch continues on + * the SAME send afterwards. + * + * No fallback for isolated forks: a missing/malformed anchor, an adapter + * without the strict capability, a throwing establish, or a malformed + * result are all typed retryable errors. The child stays pending with its + * single persisted user message; a retry reuses the SAME persisted + * idempotency key and never duplicates the provider child or the message. + * + * Returns false (no-op) when the session is not a pending fork child. + */ + private async establishPendingFork(managed: ManagedSession): Promise { + const pending = managed.pendingFork + if (!pending) return false + + // Serialize concurrent first-sends on the same pending child: a second + // send arriving while establishment is in flight must not double-dispatch. + // The persisted idempotency key already dedupes the provider artifact, so + // this guard closes the double-dispatch window (two model turns). + if (this.forkEstablishing.has(managed.id)) { + throw new CodedError( + WORKTREE_FORK_PENDING_CODE, + i18n.t('git.fork.establishing'), + ) + } + this.forkEstablishing.add(managed.id) + try { + return await this.establishPendingForkLocked(managed) + } finally { + this.forkEstablishing.delete(managed.id) + } + } + + private async establishPendingForkLocked(managed: ManagedSession): Promise { + const pending = managed.pendingFork + if (!pending) return false + + // Strict anchor errors: the establish input comes from the persisted + // pendingFork. A missing/malformed anchor (e.g. corrupted record) is a + // typed retryable error — no provider call, no fallback. + if (!pending.parentSdkSessionId || !pending.parentSdkTurnId || !pending.idempotencyKey) { + throw new CodedError( + WORKTREE_FORK_ERROR_CODE, + i18n.t('git.fork.anchorMissing'), + ) + } + if (!pending.executionCwd || !pending.transcriptCwd) { + throw new CodedError( + WORKTREE_FORK_ERROR_CODE, + i18n.t('git.fork.cwdInvalid'), + ) + } + + // The child has no agent yet: create it through the normal machinery with + // the pending-child fence bypassed ONLY for this establish path. + const agent = await this.getOrCreateAgent(managed, { allowPendingForkEstablish: true }) + // Strict capability gate: absent OR structurally incomplete adapters are + // a typed failure — no fallback, no provider call, no orphan risk. + const resolution = resolveIsolatedForkCapability(agent) + const adapter = agent.conversationFork + if (!resolution.supported || !adapter) { + throw new CodedError( + WORKTREE_FORK_ERROR_CODE, + i18n.t('git.fork.strictAdapterUnavailable'), + ) + } + + let result: ConversationForkEstablishResult + try { + result = await adapter.establishNativeFork({ + parentSdkSessionId: pending.parentSdkSessionId, + parentSdkTurnId: pending.parentSdkTurnId, + idempotencyKey: pending.idempotencyKey, + executionCwd: pending.executionCwd, + transcriptCwd: pending.transcriptCwd, + }) + } catch (error) { + // We cannot know whether the provider created a native child before + // throwing: record the attempt in the durable orphan ledger so an + // unlinked provider artifact is never silently attached (Task 5 + // reconciles). The child stays pending, retryable with the same key. + this.recordForkOrphanAttempt(pending, 'failed', error) + throw new CodedError( + WORKTREE_FORK_ERROR_CODE, + i18n.t('git.fork.establishFailed', { + error: error instanceof Error ? error.message : String(error), + }), + ) + } + + // Malformed or mismatched results are never attached silently. The proof + // must come from the selected adapter, name the exact destination, and + // cover every execution surface that an isolated child can use. + const proof = result?.proof + const proofNow = Date.now() + const requiredProofCategories = ['file:', 'shell:', 'mcp:', 'provider:'] as const + const proofIsValid = + !!result && + typeof result.childSdkSessionId === 'string' && + result.childSdkSessionId.trim() !== '' && + !!proof && + typeof proof === 'object' && + typeof proof.adapterId === 'string' && + proof.adapterId === resolution.capability.adapterId && + proof.adapterId === adapter.adapterId && + typeof proof.destinationPath === 'string' && + isAbsolute(proof.destinationPath) && + resolve(proof.destinationPath) === resolve(pending.executionCwd) && + Number.isFinite(proof.verifiedAt) && + proof.verifiedAt > proofNow - FORK_PROOF_MAX_AGE_MS && + proof.verifiedAt <= proofNow && + Array.isArray(proof.checks) && + proof.checks.every((check) => typeof check === 'string') && + requiredProofCategories.every((category) => proof.checks.some((check) => check.startsWith(category))) + if (!proofIsValid) { + this.recordForkOrphanAttempt(pending, 'unverified') + throw new CodedError( + WORKTREE_FORK_ERROR_CODE, + i18n.t('git.fork.establishIncomplete'), + ) + } + + // Persist the child provider ID exactly once and retire the pending + // metadata (checkoutStrategy stays 'isolated' as provenance). Mirrors the + // onSdkSessionIdUpdate persistence pattern: mutate managed, persist, flush. + managed.sdkSessionId = result.childSdkSessionId + managed.pendingFork = undefined + this.persistSession(managed) + await this.flushSession(managed.id) + + // Record the establishment in the fork journal (metadata-only on the + // committed entry; a missing entry is logged-and-continued because the + // child session record is authoritative). + try { + const recorded = this.getGitServices().fork.markEstablished( + pending.transactionId, + result.childSdkSessionId, + ) + if (!recorded) { + sessionLog.warn('Fork journal entry not found for establishment; the child session record is authoritative', { + transactionId: pending.transactionId, + childSdkSessionId: result.childSdkSessionId, + }) + } + } catch (error) { + sessionLog.warn('Failed to record fork establishment in the journal', { + transactionId: pending.transactionId, + error: error instanceof Error ? error.message : String(error), + }) + } + + sessionLog.info(`Isolated fork child ${managed.id} established: childSdkSessionId=${result.childSdkSessionId}`) + return true + } + + /** + * Append a failed/unverified establishment attempt to the durable orphan + * ledger (best-effort: a ledger write failure never masks the typed error). + */ + private recordForkOrphanAttempt( + pending: NonNullable, + result: ForkOrphanResult, + error?: unknown, + ): void { + try { + this.getGitServices().forkOrphans.recordAttempt({ + transactionId: pending.transactionId, + idempotencyKey: pending.idempotencyKey, + parentSdkSessionId: pending.parentSdkSessionId, + parentSdkTurnId: pending.parentSdkTurnId, + executionCwd: pending.executionCwd, + result, + ...(error ? { error: error instanceof Error ? error.message : String(error) } : {}), + }) + } catch (ledgerError) { + sessionLog.warn('Failed to record fork orphan attempt', { + error: ledgerError instanceof Error ? ledgerError.message : String(ledgerError), + }) + } + } + + /** + * Shared quiescence loop for the lifecycle, handoff, and fork hooks: await + * teardown of every processing runtime in the set, bounded per session. + * Returns false when any runtime cannot quiesce. + */ + private async quiesceSessionRuntimes(sessionIds: string[]): Promise { + for (const sessionId of sessionIds) { + const managed = this.sessions.get(sessionId) + if (!managed || !managed.isProcessing) continue + const quiesced = await this.awaitAgentTeardown(sessionId, managed, 60_000, undefined) + if (!quiesced) return false + } + return true + } + + /** + * Fork hook: resolve the persisted source-session facts a fork evaluation + * needs. The conversation head is the LAST user/assistant message of the + * source conversation (managed view when loaded, else the persisted record); + * forkPointMessageId/forkPointTurnId default to that head. + */ + private resolveForkSessionInfo(sessionId: string): import('../git').ForkSessionInfo | null { + const managed = this.sessions.get(sessionId) + if (!managed) return null + const checkoutPath = managed.checkout?.checkoutPath ?? managed.workingDirectory + if (!checkoutPath) return null + const conversationHead = this.resolveForkConversationHead(managed) + return { + checkoutPath, + workspaceId: managed.workspace.id, + checkout: managed.checkout ?? null, + transcriptCwd: managed.sdkCwd ?? checkoutPath, + conversationHead, + sdkSessionId: managed.sdkSessionId, + } + } + + /** + * Durable fork-child session state for startup reconciliation (Task 5): the + * managed session's provider identity, pending-fork intent, and checkout + * provenance. The pendingFork transaction id ties a journal entry to a live + * published-but-unestablished child; sdkSessionId + 'isolated' strategy + no + * pendingFork identify an established child whose journal marker a crash may + * have lost. Null for an unknown session. Wired into the fork service's + * `resolveSessionForkState` hook; the git.ts startup reconciliation also + * passes it explicitly. + */ + resolveSessionForkState(sessionId: string): import('../git').SessionForkState | null { + const managed = this.sessions.get(sessionId) + if (!managed) return null + return { + sdkSessionId: managed.sdkSessionId, + pendingFork: managed.pendingFork ? { transactionId: managed.pendingFork.transactionId } : null, + checkoutStrategy: managed.checkoutStrategy, + } + } + + /** Last user/assistant message id + turn id of a session's conversation. */ + private resolveForkConversationHead(managed: ManagedSession): { messageId: string; turnId: string } { + const findHead = ( + messages: ReadonlyArray<{ id: string; type?: string; role?: string; turnId?: string }>, + ): { messageId: string; turnId: string } => { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + // StoredMessage spells the role `type`; runtime Message spells it `role`. + const role = m.type ?? m.role + if (role === 'user' || role === 'assistant') { + return { messageId: m.id, turnId: m.turnId ?? '' } + } + } + return { messageId: '', turnId: '' } + } + if (managed.messagesLoaded && managed.messages.length > 0) return findHead(managed.messages) + const stored = loadStoredSession(managed.workspace.rootPath, managed.id) + if (stored && stored.messages.length > 0) return findHead(stored.messages) + return findHead(managed.messages) + } + + /** + * Fork hook: durably create the pending isolated-fork child Kata session. + * Reuses the createSession branch path (message copy through the fork point + + * branchFrom* identity) with the internal pendingFork option: the child binds + * the TARGET checkout, persists the pendingFork intent, and skips the branch + * backend preflight (provider establishment happens on first Send, Task 4). + * Returns the durable child session id. + */ + private async createForkChildSession(input: ConversationForkChildSessionInput): Promise { + const parent = this.sessions.get(input.parentSessionId) + const child = await this.createSession(input.workspaceId, { + // An isolated child must use the source session's locked backend identity, + // not the workspace default. Otherwise branch validation and first-Send + // establishment can select a different provider or account. + llmConnection: parent?.llmConnection, + model: parent?.model, + thinkingLevel: parent?.thinkingLevel, + permissionMode: parent?.permissionMode, + enabledSourceSlugs: parent?.enabledSourceSlugs, + branchFromSessionId: input.parentSessionId, + branchFromMessageId: input.forkPointMessageId, + pendingFork: { + transactionId: input.transactionId, + parentSessionId: input.parentSessionId, + parentSdkSessionId: input.parentSdkSessionId, + parentSdkTurnId: input.parentSdkTurnId, + transcriptCwd: input.transcriptCwd, + executionCwd: input.executionCwd, + idempotencyKey: randomUUID(), + checkout: input.checkout, + }, + }) + // createSession initially persists the branch before applying its runtime + // options. Flush the managed child again so a restart before first Send + // cannot revert to the workspace default connection or model. + const childManaged = this.sessions.get(child.id) + if (!childManaged) throw new Error(`Fork child ${child.id} was not registered.`) + this.persistSession(childManaged) + await this.flushSession(child.id) + return child.id + } + + /** + * Fork hook: best-effort removal of a child session created by a fork + * transaction that failed before publication (compensation). Removes the + * runtime session + persisted record; the fork service compensates the + * target worktree/registry/seed itself. Only ever called for un-published + * children, so no registry owner is touched here. + */ + private async deleteForkChildSession(childSessionId: string): Promise { + const managed = this.sessions.get(childSessionId) + const workspaceRootPath = managed?.workspace.rootPath + try { + this.getGitServices().pathLeases.releaseSession(childSessionId) + } catch { + // Best-effort: stale leases are pruned by the lifecycle sweep. + } + if (managed) { + if (managed.agent) { + try { + managed.agent.destroy?.() + } catch { + // Best-effort compensation cleanup. + } + managed.agent = null + } + this.sessions.delete(childSessionId) + } + if (workspaceRootPath) { + try { + await deleteStoredSession(workspaceRootPath, childSessionId) + } catch { + // Best-effort rollback: runtime removal is the critical path. + } + } + } + /** * Override the Git domain services. Bootstrap wires the same instance used by * the RPC handlers so checkout preparation and read-only Git RPCs share one @@ -5579,22 +6085,31 @@ export class SessionManager implements ISessionManager { return managed?.agent?.executionCwdRebind ?? null }, isSessionActive: (sessionId) => this.sessions.get(sessionId)?.isProcessing ?? false, - quiesceRuntimes: async (sessionIds) => { - for (const sessionId of sessionIds) { - const managed = this.sessions.get(sessionId) - if (!managed || !managed.isProcessing) continue - const quiesced = await this.awaitAgentTeardown( - sessionId, - managed, - 60_000, - undefined, - ) - if (!quiesced) return false - } - return true - }, + quiesceRuntimes: (sessionIds) => this.quiesceSessionRuntimes(sessionIds), commitSessionBinding: (input) => this.commitHandoffBinding(input), }) + + // Phase 4: fork resolves all session identity and provider capability + // server-side (mirroring handoff). The child-session hooks implement the + // durable pending isolated-fork child lifecycle. + services.fork.setHooks({ + resolveSession: (sessionId) => this.resolveForkSessionInfo(sessionId), + resolveCapability: (sessionId) => { + const managed = this.sessions.get(sessionId) + if (!managed?.agent) return null + const resolution = resolveIsolatedForkCapability(managed.agent) + return resolution.supported ? resolution.capability : null + }, + resolveCapabilityAdapter: (sessionId) => { + const managed = this.sessions.get(sessionId) + return managed?.agent?.conversationFork ?? null + }, + resolveSessionForkState: (sessionId) => this.resolveSessionForkState(sessionId), + isSessionActive: (sessionId) => this.sessions.get(sessionId)?.isProcessing ?? false, + quiesceRuntimes: (sessionIds) => this.quiesceSessionRuntimes(sessionIds), + createForkChildSession: (input) => this.createForkChildSession(input), + deleteForkChildSession: (childSessionId) => this.deleteForkChildSession(childSessionId), + }) } /** @@ -6635,6 +7150,45 @@ export class SessionManager implements ISessionManager { } } } + // Phase 4: a pending conversation-fork transaction fences deletion the + // same way. A pure pending preview is cancelled; an in-progress confirm is + // blocked (the child would otherwise be published onto a deleted source); + // recovery-required stays deletable as the escape hatch and the journal + // keeps the recovery authority. + if (this.getGitServices().fork?.isSessionFenced?.(sessionId)) { + const forkStatus = await this.getGitServices().fork?.status({ sessionId }) + if (forkStatus?.active && forkStatus.state !== 'recovery-required') { + if (forkStatus.state === 'pending') { + // A pure preview has never mutated anything; cancel it so the + // session can be deleted (cancel serializes with confirm under the + // mutation lock and refuses once a confirm is in flight). If the + // cancel refuses — durable steps already recorded — the confirm is + // genuinely in flight and deletion must block. + try { + const cancelled = await this.getGitServices().fork?.cancel({ + sessionId, + transactionId: forkStatus.transactionId, + }) + if (cancelled?.active) { + throw new CodedError( + WORKTREE_FORK_PENDING_CODE, + i18n.t('git.fork.pendingFence'), + ) + } + } catch (err) { + if (err instanceof CodedError) throw err + sessionLog.warn(`Failed to release pending conversation fork for deleted session ${sessionId}:`, err) + } + } else { + // An in-progress confirm must not be discarded: the child would + // otherwise be published onto a deleted source session. + throw new CodedError( + WORKTREE_FORK_PENDING_CODE, + i18n.t('git.fork.pendingFence'), + ) + } + } + } return this.withSessionTeardownFence(sessionId, async (retainFence, preChatSettled, teardownDeadline) => { // Get workspace slug before deleting @@ -6660,6 +7214,14 @@ export class SessionManager implements ISessionManager { // authoritative confirmation check and removal complete while the session // still exists. If anything changed after the dialog inspection, the // operation stops before ownership or session state is touched. + // + // Phase 4 provenance: an isolated fork child owns its worktree record as + // the SOLE owner, so this path removes only that child's lifecycle — the + // standard snapshot-first transaction on the child's own record + // (resolveOwnedWorktreeId resolves the CHILD's checkout metadata, never the + // source's) — and the SOURCE session/record/branch are never referenced + // here. A shared child (or a legacy session with no checkoutStrategy) stays + // on the shared record and drops exactly one owner below. let completedWorktreeRemoval: | import('@kata-sh/shared/protocol').WorktreeRemovalResult | undefined @@ -6742,7 +7304,10 @@ export class SessionManager implements ISessionManager { // shared-owner counts stay correct. When explicit removal was requested, // the registry record is already gone and this is a harmless no-op. Phase 2 // routes through the lifecycle service (final-owner detach leaves an - // unowned record and enqueues policy cleanup). + // unowned record and enqueues policy cleanup). Phase 4: for an isolated + // child the detached record is the child's OWN record (its sole owner), so + // this never touches the source's record; for a shared child it drops + // exactly this session's owner reference. if (managed.checkout?.mode === 'managed-worktree' && managed.checkout.managedWorktreeId) { try { if (isWorktreeV2Enabled()) { @@ -6899,6 +7464,11 @@ export class SessionManager implements ISessionManager { throw new Error('Session is being torn down') } this.assertSessionHandoffNotFenced(sessionId) + // Phase 4: a pending fork transaction fences Send; a published isolated + // fork child is pending until its first-Send provider establishment + // (the establish flow runs in the prelude below, after the user message + // is persisted). + this.assertSessionForkNotFenced(sessionId) // Phase 2: Send stays fenced while the session's worktree record is not // ready (recovery required). Sessions without a managed checkout pass. this.assertSessionCheckoutReady(sessionId) @@ -6907,7 +7477,21 @@ export class SessionManager implements ISessionManager { throw new Error('Session is being torn down') } - return this.withPreChatBarrier(sessionId, async (releasePreChat) => { + // Claim the pending-child first-Send slot before any message mutation or + // flush. A concurrent send must be rejected rather than leaving an + // acknowledged orphan message in the transcript. + const pendingForkSend = !!managed.pendingFork + if (pendingForkSend) { + if (this.forkSendInFlight.has(sessionId)) { + throw new CodedError( + WORKTREE_FORK_PENDING_CODE, + i18n.t('git.fork.establishing'), + ) + } + this.forkSendInFlight.add(sessionId) + } + + const sendPromise = this.withPreChatBarrier(sessionId, async (releasePreChat) => { this.setLastMessageClientId(sessionId, rpcContext?.callerClientId) // Source-activation auto-retry dedup (kata-agents-oss#804). When the server @@ -7015,6 +7599,11 @@ export class SessionManager implements ISessionManager { if (!userMessage) { throw new Error(`Existing message ${existingMessageId} not found`) } + // The message is already durable from the original send; acknowledge it + // so the RPC promise resolves exactly like the fresh path (the renderer + // retry depends on this ack — a retried fork send must resolve + // `{ accepted, messageId }` at persistence time, not after the turn). + onAck?.(existingMessageId) } else { // Create new message userMessage = { @@ -7086,6 +7675,16 @@ export class SessionManager implements ISessionManager { } } + // Phase 4 Task 4: first-Send provider establishment for a pending + // isolated fork child. The strict adapter creates the native fork at + // the recorded source head with the PERSISTED idempotency key, the + // child provider ID is persisted exactly once, and the pending + // metadata retires before the message is dispatched (the user message + // is already on disk — persisted, flushed and acked above, or reused + // via existingMessageId on a retry). Failure is a typed retryable + // error with no fallback; the message is never duplicated on retry. + await this.establishPendingFork(managed) + // Evaluate auto-label rules against the user message (common path for both // fresh and queued messages). Scans regex patterns configured on labels, // then merges any new matches into the session's label array. @@ -7505,6 +8104,10 @@ export class SessionManager implements ISessionManager { } } }) + if (!pendingForkSend) return sendPromise + return sendPromise.finally(() => { + this.forkSendInFlight.delete(sessionId) + }) } async cancelProcessing(sessionId: string, silent = false): Promise { diff --git a/packages/server-core/src/sessions/isolated-fork-child.test.ts b/packages/server-core/src/sessions/isolated-fork-child.test.ts new file mode 100644 index 00000000..063b8263 --- /dev/null +++ b/packages/server-core/src/sessions/isolated-fork-child.test.ts @@ -0,0 +1,1159 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Import the config module FIRST so the bound CONFIG_DIR is whatever this +// worker process actually uses (bun test may share a process across files). +// The test temporarily registers its workspace in that config file and +// restores the original bytes (or absence) afterwards — never leaving a trace. +const { SessionManager, createManagedSession } = await import('./SessionManager') +const { CONFIG_DIR } = await import('@kata-sh/shared/config') +const { createGitServices } = await import('../git') +const { initRepo, makeTmpDir, cleanup, git } = await import('../git/__tests__/test-helpers') +const { createDeterministicStrictForkAdapter } = await import('@kata-sh/shared/agent/testing') +import type { ConversationForkEstablishInput, StrictConversationForkCapability } from '@kata-sh/shared/agent/backend' +const { + saveSession: saveStoredSession, + loadSession: loadStoredSession, + getSessionFilePath, +} = await import('@kata-sh/shared/sessions/storage') +const { WORKTREE_FORK_PENDING_CODE, WORKTREE_FORK_ERROR_CODE } = await import('@kata-sh/shared/protocol') + +/** + * SessionManager-level coverage for Phase 4 Task 3c: the wired fork hooks + * create a durable pending isolated-fork child through the real createSession + * branch path — messages copied through the fork point, TARGET checkout bound, + * pendingFork intent persisted, no agent created, source untouched — plus the + * Send fence on published-but-unestablished pending children. + */ +describe('SessionManager isolated fork child creation', () => { + let root: string + let repo: string + let sm: InstanceType + let services: ReturnType + /** Original config-file bytes (or null when absent) restored in afterEach. */ + let originalConfig: string | null + + const configFile = join(CONFIG_DIR, 'config.json') + const previousV1 = process.env.KATA_FEATURE_GIT_WORKSPACE_V1 + const previousV2 = process.env.KATA_FEATURE_WORKTREE_V2 + + beforeEach(async () => { + process.env.KATA_FEATURE_GIT_WORKSPACE_V1 = '1' + process.env.KATA_FEATURE_WORKTREE_V2 = '1' + root = makeTmpDir('kata-fork-child-') + repo = join(root, 'repo') + await initRepo(repo) + // Register the test workspace in the config file this process actually + // reads, preserving everything else byte-for-byte for the afterEach. + originalConfig = existsSync(configFile) ? readFileSync(configFile, 'utf8') : null + const config = originalConfig + ? (JSON.parse(originalConfig) as { workspaces: unknown[] }) + : { workspaces: [], activeWorkspaceId: null, activeSessionId: null } + config.workspaces = (config.workspaces ?? []).filter( + (w) => (w as { id?: string }).id !== 'ws_test', + ) + config.workspaces.push({ + id: 'ws_test', + name: 'WS', + slug: 'ws-test', + rootPath: root, + createdAt: Date.now(), + }) + writeFileSync(configFile, JSON.stringify(config, null, 2)) + sm = new SessionManager() + services = createGitServices({ + worktreeRoot: join(root, 'worktrees'), + registryPath: join(root, 'worktrees', 'registry.json'), + }) + sm.setGitServices(services) + services.lifecycle.markReady() + }) + + afterEach(() => { + if (previousV1 === undefined) delete process.env.KATA_FEATURE_GIT_WORKSPACE_V1 + else process.env.KATA_FEATURE_GIT_WORKSPACE_V1 = previousV1 + if (previousV2 === undefined) delete process.env.KATA_FEATURE_WORKTREE_V2 + else process.env.KATA_FEATURE_WORKTREE_V2 = previousV2 + // Restore the config file exactly (or remove it when it did not exist). + try { + if (originalConfig === null) rmSync(configFile, { force: true }) + else writeFileSync(configFile, originalConfig) + } catch { + // Best-effort: the test workspace entry is additive and harmless. + } + cleanup(root) + }) + + function injectSession(id: string): ReturnType { + const workspace = { id: 'ws_test', name: 'WS', rootPath: root, createdAt: Date.now() } + const managed = createManagedSession( + { + id, + name: `Session ${id}`, + sdkCwd: join(root, 'sessions', id), + sdkSessionId: 'sdk-parent-1', + workingDirectory: repo, + } as never, + workspace as never, + { messagesLoaded: true }, + ) + ;(sm as unknown as { sessions: Map }).sessions.set(id, managed) + return managed + } + + /** Inject a session that still satisfies the empty-session checkout gate. */ + function injectEmptySession(id: string): ReturnType { + const managed = injectSession(id) + delete (managed as unknown as { sdkSessionId?: string }).sdkSessionId + return managed + } + + /** Persist a source session with a user + assistant message (head = msg-2). */ + async function persistSourceWithMessages(id: string): Promise { + const stored = { + id, + workspaceRootPath: root, + name: 'source', + createdAt: Date.now(), + lastUsedAt: Date.now(), + sdkCwd: join(root, 'sessions', id), + sdkSessionId: 'sdk-parent-1', + workingDirectory: repo, + messages: [ + { id: 'msg-1', type: 'user' as const, content: 'first', timestamp: Date.now() - 1000, turnId: 'turn-1' }, + { id: 'msg-2', type: 'assistant' as const, content: 'second', timestamp: Date.now(), turnId: 'turn-2' }, + ], + tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, contextTokens: 0, costUsd: 0 }, + } as never + await saveStoredSession(stored) + // Mirror the same messages on the runtime session: createSession flushes + // the managed session to disk before branch validation, so the in-memory + // list is the authoritative source. + const managed = (sm as unknown as { sessions: Map }).sessions.get(id) as { + messages?: unknown[] + } + managed.messages = [ + { id: 'msg-1', role: 'user', content: 'first', timestamp: Date.now() - 1000, turnId: 'turn-1' }, + { id: 'msg-2', role: 'assistant', content: 'second', timestamp: Date.now(), turnId: 'turn-2' }, + ] + } + + /** Advertise a strict fork capability for a session (deterministic adapter). */ + function armStrictAdapter(sessionId: string): void { + const managed = (sm as unknown as { sessions: Map }).sessions.get(sessionId) as { + agent?: unknown + } + managed.agent = { conversationFork: createDeterministicStrictForkAdapter({ adapterId: 'pi-test' }) } + } + + /** + * Arm a pending child's agent with the deterministic strict fork adapter + * plus a minimal chat() that records dispatched messages. Mirrors the real + * flow where the establish path creates the agent through getOrCreateAgent + * (the harness cannot build a live backend without a configured platform). + */ + function armChildAgent( + sessionId: string, + adapter: StrictConversationForkCapability, + chatCalls: string[], + ): void { + const managed = (sm as unknown as { sessions: Map }).sessions.get(sessionId) as { + agent?: unknown + } + managed.agent = { + conversationFork: adapter, + chat: async function* (message: string) { + chatCalls.push(message) + }, + setAllSources: () => undefined, + getModel: () => 'test-model', + generateTitle: async () => undefined, + isProcessing: () => false, + } as never + } + + /** Persisted user messages whose content contains the given text. */ + function persistedMessageCount(sessionId: string, content: string): number { + const file = getSessionFilePath(root, sessionId) + if (!existsSync(file)) return 0 + const lines = readFileSync(file, 'utf-8').trim().split('\n').slice(1) + return lines.filter((line) => line.includes(content)).length + } + + /** + * Record EVERY establishNativeFork call (including throwing ones) so tests + * can assert call counts and the persisted idempotency key on both attempts. + */ + function countingAdapter( + adapter: StrictConversationForkCapability, + callLog: Array<{ input: ConversationForkEstablishInput }>, + ): StrictConversationForkCapability { + const establishNativeFork = adapter.establishNativeFork.bind(adapter) + return { + ...adapter, + establishNativeFork: async (input: ConversationForkEstablishInput) => { + callLog.push({ input }) + return establishNativeFork(input) + }, + } + } + + /** Confirm an isolated fork for the last injected source and return the child id. */ + async function confirmChild(sourceId: string, suffix: string): Promise { + services.pathLeases.lease(sourceId, realpathSync(repo)) + const preview = await services.fork.preview({ + sessionId: sourceId, + strategy: 'isolated-worktree', + worktreeNameSuffix: suffix, + }) + expect(preview.blocked).toBeUndefined() + if (preview.blocked) return '' + const result = await services.fork.confirm({ + sessionId: sourceId, + strategy: 'isolated-worktree', + transactionId: preview.transactionId, + previewFingerprint: preview.previewFingerprint, + worktreeNameSuffix: suffix, + }) + expect(result.outcome).toBe('committed') + if (result.outcome !== 'committed') return '' + return result.summary.sessionId + } + + it('confirm through the wired hooks creates a durable pending child: target-bound, pendingFork persisted, no agent', async () => { + injectSession('source-1') + await persistSourceWithMessages('source-1') + armStrictAdapter('source-1') + services.pathLeases.lease('source-1', realpathSync(repo)) + + const preview = await services.fork.preview({ + sessionId: 'source-1', + strategy: 'isolated-worktree', + worktreeNameSuffix: 'fork-demo', + }) + expect(preview.blocked).toBeUndefined() + if (preview.blocked) return + + const result = await services.fork.confirm({ + sessionId: 'source-1', + strategy: 'isolated-worktree', + transactionId: preview.transactionId, + previewFingerprint: preview.previewFingerprint, + worktreeNameSuffix: 'fork-demo', + }) + expect(result.outcome).toBe('committed') + if (result.outcome !== 'committed') return + + // The child is a real runtime session with the TARGET checkout bound. + const sessions = sm.getSessions() + const child = sessions.find((s) => s.id === result.summary.sessionId) + expect(child).toBeDefined() + expect(child?.checkout).toMatchObject({ + mode: 'managed-worktree', + expectedBranch: 'kata-agent/fork-demo', + }) + expect(child?.workingDirectory).toBe(child?.checkout?.checkoutPath) + const childManaged = (sm as unknown as { sessions: Map }).sessions.get( + result.summary.sessionId, + ) as { sdkCwd?: string } + expect(childManaged?.sdkCwd).toBe(child?.checkout?.checkoutPath) + // No agent until first Send (Task 4). + const managed = (sm as unknown as { sessions: Map }).sessions.get( + result.summary.sessionId, + ) as { + agent?: unknown + pendingFork?: unknown + checkoutStrategy?: string + } + // No agent until first Send (Task 4): the field is null-initialized. + expect(managed.agent == null).toBe(true) + expect(managed.checkoutStrategy).toBe('isolated') + + // The durable record carries the pending provider-fork intent with strict + // parent identity and NO child provider ID, plus checkout provenance. + const stored = loadStoredSession(root, result.summary.sessionId) + expect(stored).toBeDefined() + expect(stored?.pendingFork).toMatchObject({ + transactionId: preview.transactionId, + parentSessionId: 'source-1', + transcriptCwd: join(root, 'sessions', 'source-1'), + executionCwd: child?.checkout?.checkoutPath, + idempotencyKey: expect.any(String), + }) + expect('childSdkSessionId' in (stored?.pendingFork ?? {})).toBe(false) + expect(stored?.checkoutStrategy).toBe('isolated') + expect(stored?.messages.map((m) => m.id)).toEqual(['msg-1', 'msg-2']) + + // The source session is untouched: same message ids, no checkout/pendingFork. + const source = loadStoredSession(root, 'source-1') + expect(source?.messages.map((m) => m.id)).toEqual(['msg-1', 'msg-2']) + // The injected source carries checkout: null; pendingFork is child-only. + expect(source?.checkout == null).toBe(true) + expect(source?.pendingFork).toBeUndefined() + }) + + it('persists the source backend identity on an isolated child before first Send', async () => { + const source = injectSession('source-identity') + source.llmConnection = 'source-connection' + await persistSourceWithMessages('source-identity') + armStrictAdapter('source-identity') + + const childId = await confirmChild('source-identity', 'identity-child') + expect(childId).toBeDefined() + if (!childId) throw new Error('Expected isolated child confirmation to succeed') + + const stored = loadStoredSession(root, childId)! + expect(stored.llmConnection).toBe('source-connection') + const child = (sm as unknown as { sessions: Map }).sessions.get(childId) as { + llmConnection?: string + } + expect(child.llmConnection).toBe('source-connection') + }) + + it('rejects Send on a published-but-unestablished pending child with no establishable adapter: typed fork error, no fallback, message persisted once', async () => { + injectSession('source-2') + await persistSourceWithMessages('source-2') + armStrictAdapter('source-2') + services.pathLeases.lease('source-2', realpathSync(repo)) + + const preview = await services.fork.preview({ + sessionId: 'source-2', + strategy: 'isolated-worktree', + worktreeNameSuffix: 'fenced-child', + }) + expect(preview.blocked).toBeUndefined() + if (preview.blocked) return + const result = await services.fork.confirm({ + sessionId: 'source-2', + strategy: 'isolated-worktree', + transactionId: preview.transactionId, + previewFingerprint: preview.previewFingerprint, + worktreeNameSuffix: 'fenced-child', + }) + expect(result.outcome).toBe('committed') + if (result.outcome !== 'committed') return + + // A child whose created agent carries NO strict fork adapter: the + // establish flow is a typed failure with no fallback to shared/ + // full-history/fresh behavior. The user message stays persisted exactly + // once and the pendingFork intent survives for a visible retry. + const chatCalls: string[] = [] + armChildAgent(result.summary.sessionId, {} as StrictConversationForkCapability, chatCalls) + + await expect( + sm.sendMessage(result.summary.sessionId, 'hello from the pending child'), + ).rejects.toMatchObject({ code: WORKTREE_FORK_ERROR_CODE }) + + // The user message was persisted exactly once; no chat was dispatched. + expect(persistedMessageCount(result.summary.sessionId, 'hello from the pending child')).toBe(1) + expect(chatCalls).toHaveLength(0) + const stored = loadStoredSession(root, result.summary.sessionId) + expect(stored?.pendingFork).toBeDefined() + expect(stored?.sdkSessionId).toBeUndefined() + // No provider call happened, so no orphan is recorded. + expect(services.forkOrphans.entries()).toHaveLength(0) + }) + + it('deleteSession cancels a pending preview and blocks an in-progress confirm (recovery-required stays deletable by the state guard)', async () => { + injectSession('source-3') + await persistSourceWithMessages('source-3') + armStrictAdapter('source-3') + services.pathLeases.lease('source-3', realpathSync(repo)) + + // Pending preview: deletion cancels it and the session goes away. + const preview = await services.fork.preview({ + sessionId: 'source-3', + strategy: 'isolated-worktree', + worktreeNameSuffix: 'deletable-preview', + }) + expect(preview.blocked).toBeUndefined() + if (preview.blocked) return + const deleted = await sm.deleteSession('source-3') + expect(deleted.deleted).toBe(true) + expect( + services.journal + .entries() + .find((e) => e.recordId === preview.transactionId)?.commitMarker, + ).toBe('preview-cancelled') + // Release the deleted session's path lease so the next source preview is + // not blocked as a foreign lease holder. + services.pathLeases.releaseSession('source-3') + + // In-progress confirm: deletion is blocked with the typed pending code. + injectSession('source-4') + await persistSourceWithMessages('source-4') + armStrictAdapter('source-4') + services.pathLeases.lease('source-4', realpathSync(repo)) + const preview4 = await services.fork.preview({ + sessionId: 'source-4', + strategy: 'isolated-worktree', + worktreeNameSuffix: 'inprogress-delete', + }) + expect(preview4.blocked).toBeUndefined() + if (preview4.blocked) return + // Drive the durable entry into a genuinely in-flight state: the metadata + // still reads pending, but the durable steps make cancel refuse and the + // deletion must block rather than delete the source mid-confirm. + const entry = services.journal + .entries() + .find((e) => e.recordId === preview4.transactionId)! + services.journal.step(entry.journalId, 'locks-acquired') + await expect(sm.deleteSession('source-4')).rejects.toMatchObject({ + code: WORKTREE_FORK_PENDING_CODE, + }) + // The source survives. + expect(sm.getSessions().some((s) => s.id === 'source-4')).toBe(true) + }) + + it('first Send of a pending child establishes the native fork with the persisted idempotency key, persists the child provider ID, and retires pendingFork', async () => { + injectSession('source-5') + await persistSourceWithMessages('source-5') + armStrictAdapter('source-5') + const childId = await confirmChild('source-5', 'established-child') + if (!childId) return + + // The child is published pending with a durable anchor and NO child provider ID. + const pendingBefore = loadStoredSession(root, childId)!.pendingFork! + expect(pendingBefore.parentSdkSessionId).toBe('sdk-parent-1') + expect(pendingBefore.parentSdkTurnId).toBe('turn-2') + expect(pendingBefore.idempotencyKey).toBeTruthy() + + const establishLog: Array<{ input: ConversationForkEstablishInput; childSdkSessionId: string }> = [] + const chatCalls: string[] = [] + armChildAgent( + childId, + createDeterministicStrictForkAdapter({ + adapterId: 'pi-test', + childSdkSessionId: 'sdk-child-1', + establishLog, + }), + chatCalls, + ) + + await sm.sendMessage(childId, 'hello from the child') + + // Establish called exactly once with the PERSISTED key + parent identity + cwds. + expect(establishLog).toHaveLength(1) + expect(establishLog[0]!.input).toMatchObject({ + parentSdkSessionId: 'sdk-parent-1', + parentSdkTurnId: 'turn-2', + idempotencyKey: pendingBefore.idempotencyKey, + executionCwd: pendingBefore.executionCwd, + transcriptCwd: pendingBefore.transcriptCwd, + }) + + // Child provider ID persisted on stored + managed; pendingFork retired; strategy stays 'isolated'. + const storedAfter = loadStoredSession(root, childId)! + expect(storedAfter.sdkSessionId).toBe('sdk-child-1') + expect(storedAfter.pendingFork).toBeUndefined() + expect(storedAfter.checkoutStrategy).toBe('isolated') + const managedAfter = (sm as unknown as { sessions: Map }).sessions.get(childId) as { + sdkSessionId?: string + pendingFork?: unknown + } + expect(managedAfter.sdkSessionId).toBe('sdk-child-1') + expect(managedAfter.pendingFork).toBeUndefined() + + // The user message was dispatched to the agent chat and persisted exactly once. + expect(chatCalls).toEqual(['hello from the child']) + expect(persistedMessageCount(childId, 'hello from the child')).toBe(1) + + // The fork journal records the establishment (metadata-only on the committed entry). + const entry = services.journal + .entries() + .find((e) => e.op === 'fork' && e.recordId === pendingBefore.transactionId) + expect(entry?.status).toBe('committed') + expect(entry?.metadata?.state).toBe('established') + expect(entry?.metadata?.childSdkSessionId).toBe('sdk-child-1') + }) + + it('retry idempotency: failed establish persists the message once + records an orphan; a same-key retry never duplicates', async () => { + injectSession('source-6') + await persistSourceWithMessages('source-6') + armStrictAdapter('source-6') + const childId = await confirmChild('source-6', 'retry-child') + if (!childId) return + + const pendingBefore = loadStoredSession(root, childId)!.pendingFork! + const establishCalls: Array<{ input: ConversationForkEstablishInput }> = [] + const chatCalls: string[] = [] + armChildAgent( + childId, + countingAdapter( + createDeterministicStrictForkAdapter({ + adapterId: 'pi-test', + failEstablish: true, + }), + establishCalls, + ), + chatCalls, + ) + + // First Send: establish throws → typed retryable error, message persisted + // once, child stays pending, orphan ledger records the attempt. + await expect(sm.sendMessage(childId, 'retry me')).rejects.toMatchObject({ + code: WORKTREE_FORK_ERROR_CODE, + }) + const afterFail = loadStoredSession(root, childId)! + expect(afterFail.pendingFork).toBeDefined() + expect(afterFail.sdkSessionId).toBeUndefined() + expect(persistedMessageCount(childId, 'retry me')).toBe(1) + const orphans = services.forkOrphans.entries() + expect(orphans).toHaveLength(1) + expect(orphans[0]!.result).toBe('failed') + expect(orphans[0]!.transactionId).toBe(pendingBefore.transactionId) + expect(orphans[0]!.idempotencyKey).toBe(pendingBefore.idempotencyKey) + expect(orphans[0]!.executionCwd).toBe(pendingBefore.executionCwd) + + // Retry with the SAME persisted idempotency key, reusing the persisted + // message id (the ack contract gives the caller the message id). + const managed = (sm as unknown as { sessions: Map }).sessions.get(childId) as { + agent?: { conversationFork?: StrictConversationForkCapability } + messages?: Array<{ id: string; content: string }> + } + managed.agent!.conversationFork = countingAdapter( + createDeterministicStrictForkAdapter({ + adapterId: 'pi-test', + childSdkSessionId: 'sdk-child-2', + }), + establishCalls, + ) + const retryMessageId = managed.messages!.find((m) => m.content === 'retry me')!.id + + // The retry reuses the persisted message id; the ack contract must fire + // exactly like the fresh path so the RPC resolves { accepted, messageId } + // at persistence time (the renderer retry depends on it). + let retryAck: string | null = null + await sm.sendMessage( + childId, + 'retry me', + undefined, + undefined, + undefined, + retryMessageId, + undefined, + (messageId) => { + retryAck = messageId + }, + ) + expect(retryAck === retryMessageId).toBe(true) + + // Establish called twice total, SAME persisted key both times; the + // provider child ID is persisted exactly once and pendingFork retires. + expect(establishCalls).toHaveLength(2) + expect(establishCalls[0]!.input.idempotencyKey).toBe(pendingBefore.idempotencyKey) + expect(establishCalls[1]!.input.idempotencyKey).toBe(pendingBefore.idempotencyKey) + expect(establishCalls[0]!.input.parentSdkSessionId).toBe('sdk-parent-1') + expect(establishCalls[0]!.input.executionCwd).toBe(pendingBefore.executionCwd) + const afterRetry = loadStoredSession(root, childId)! + expect(afterRetry.sdkSessionId).toBe('sdk-child-2') + expect(afterRetry.pendingFork).toBeUndefined() + // The user message is STILL on disk exactly once (no duplicate on retry). + expect(persistedMessageCount(childId, 'retry me')).toBe(1) + // The retry's establish succeeded and the message was dispatched once. + expect(chatCalls).toEqual(['retry me']) + // The orphan ledger keeps the failed attempt (append-only). + expect(services.forkOrphans.entries()).toHaveLength(1) + }) + + it('a pending child with a missing/malformed provider anchor rejects with a typed error, no establish call, no fallback', async () => { + injectSession('source-7') + await persistSourceWithMessages('source-7') + armStrictAdapter('source-7') + const childId = await confirmChild('source-7', 'anchor-child') + if (!childId) return + + // Corrupt the persisted + managed pendingFork anchor (empty parent SDK + // session id — e.g. a corrupted record). + const stored = loadStoredSession(root, childId)! + stored.pendingFork = { ...stored.pendingFork!, parentSdkSessionId: '' } + await saveStoredSession(stored) + const managed = (sm as unknown as { sessions: Map }).sessions.get(childId) as { + pendingFork?: { parentSdkSessionId: string } + } + managed.pendingFork = { ...managed.pendingFork!, parentSdkSessionId: '' } + + const establishCalls: Array<{ input: ConversationForkEstablishInput }> = [] + const chatCalls: string[] = [] + armChildAgent( + childId, + countingAdapter( + createDeterministicStrictForkAdapter({ adapterId: 'pi-test' }), + establishCalls, + ), + chatCalls, + ) + + await expect(sm.sendMessage(childId, 'anchor test')).rejects.toMatchObject({ + code: WORKTREE_FORK_ERROR_CODE, + }) + + // No provider call, no fallback, no orphan (nothing was attempted), and + // the child stays pending with its persisted message exactly once. + expect(establishCalls).toHaveLength(0) + expect(chatCalls).toHaveLength(0) + expect(services.forkOrphans.entries()).toHaveLength(0) + const after = loadStoredSession(root, childId)! + expect(after.pendingFork).toBeDefined() + expect(after.sdkSessionId).toBeUndefined() + expect(persistedMessageCount(childId, 'anchor test')).toBe(1) + }) + + it('a malformed establish result (missing child provider id) is a typed error recorded as an unverified orphan, no attach, no fallback', async () => { + injectSession('source-8') + await persistSourceWithMessages('source-8') + armStrictAdapter('source-8') + const childId = await confirmChild('source-8', 'malformed-child') + if (!childId) return + + const pendingBefore = loadStoredSession(root, childId)!.pendingFork! + const chatCalls: string[] = [] + const malformedAdapter: StrictConversationForkCapability = { + adapterId: 'pi-test', + forkCapability: () => ({ adapterId: 'pi-test', strictCrossCwdNativeFork: true }), + // Returns no childSdkSessionId: an unverifiable provider artifact. + establishNativeFork: async () => ({}) as never, + } + armChildAgent(childId, malformedAdapter, chatCalls) + + await expect(sm.sendMessage(childId, 'malformed result')).rejects.toMatchObject({ + code: WORKTREE_FORK_ERROR_CODE, + }) + + // The provider was called but its result could not be verified: the + // attempt is journaled as 'unverified' and never silently attached. + const orphans = services.forkOrphans.entries() + expect(orphans).toHaveLength(1) + expect(orphans[0]!.result).toBe('unverified') + expect(orphans[0]!.transactionId).toBe(pendingBefore.transactionId) + expect(orphans[0]!.idempotencyKey).toBe(pendingBefore.idempotencyKey) + const after = loadStoredSession(root, childId)! + expect(after.pendingFork).toBeDefined() + expect(after.sdkSessionId).toBeUndefined() + expect(persistedMessageCount(childId, 'malformed result')).toBe(1) + expect(chatCalls).toHaveLength(0) + }) + + it('rejects an incomplete or mismatched execution proof before attaching the provider child', async () => { + injectSession('source-proof') + await persistSourceWithMessages('source-proof') + armStrictAdapter('source-proof') + const childId = await confirmChild('source-proof', 'proof-child') + expect(childId).toBeDefined() + if (!childId) throw new Error('Expected isolated child confirmation to succeed') + + const chatCalls: string[] = [] + const malformedProofAdapter: StrictConversationForkCapability = { + adapterId: 'pi-test', + forkCapability: () => ({ adapterId: 'pi-test', strictCrossCwdNativeFork: true }), + establishNativeFork: async () => ({ + childSdkSessionId: 'sdk-unverified-child', + proof: { + adapterId: 'other-adapter', + destinationPath: '/wrong-destination', + verifiedAt: Date.now(), + checks: ['file:read'], + }, + }), + } + armChildAgent(childId, malformedProofAdapter, chatCalls) + + await expect(sm.sendMessage(childId, 'proof test')).rejects.toMatchObject({ + code: WORKTREE_FORK_ERROR_CODE, + }) + + const stored = loadStoredSession(root, childId)! + expect(stored.sdkSessionId).toBeUndefined() + expect(stored.pendingFork).toBeDefined() + expect(chatCalls).toHaveLength(0) + expect(services.forkOrphans.entries()).toHaveLength(1) + expect(services.forkOrphans.entries()[0]!.result).toBe('unverified') + }) + + it('rejects a stale execution proof before attaching the provider child', async () => { + injectSession('source-stale-proof') + await persistSourceWithMessages('source-stale-proof') + armStrictAdapter('source-stale-proof') + const childId = await confirmChild('source-stale-proof', 'stale-proof-child') + expect(childId).toBeDefined() + if (!childId) throw new Error('Expected isolated child confirmation to succeed') + + const chatCalls: string[] = [] + armChildAgent(childId, { + adapterId: 'pi-test', + forkCapability: () => ({ adapterId: 'pi-test', strictCrossCwdNativeFork: true }), + establishNativeFork: async (input) => ({ + childSdkSessionId: 'sdk-stale-child', + proof: { + adapterId: 'pi-test', + destinationPath: input.executionCwd, + verifiedAt: 0, + checks: ['file:read', 'shell:cwd', 'mcp:list', 'provider:cwd'], + }, + }), + }, chatCalls) + + await expect(sm.sendMessage(childId, 'stale proof test')).rejects.toMatchObject({ + code: WORKTREE_FORK_ERROR_CODE, + }) + expect(loadStoredSession(root, childId)!.sdkSessionId).toBeUndefined() + expect(chatCalls).toHaveLength(0) + }) + + it('ordinary session sends are unaffected by the pending-fork establish flow', async () => { + injectSession('plain-1') + const chatCalls: string[] = [] + armChildAgent( + 'plain-1', + createDeterministicStrictForkAdapter({ adapterId: 'pi-test' }), + chatCalls, + ) + + await sm.sendMessage('plain-1', 'plain hello') + + // No establishment happened (no pendingFork) and the message dispatched. + expect(chatCalls).toEqual(['plain hello']) + expect(services.forkOrphans.entries()).toHaveLength(0) + const stored = loadStoredSession(root, 'plain-1') + expect(stored?.pendingFork).toBeUndefined() + }) + + it('serializes concurrent first-sends: a second send during establishment is refused with the pending code', async () => { + injectSession('source-concurrent') + await persistSourceWithMessages('source-concurrent') + armStrictAdapter('source-concurrent') + services.pathLeases.lease('source-concurrent', realpathSync(repo)) + + const preview = await services.fork.preview({ + sessionId: 'source-concurrent', + strategy: 'isolated-worktree', + worktreeNameSuffix: 'concurrent-child', + }) + expect(preview.blocked).toBeUndefined() + if (preview.blocked) return + const result = await services.fork.confirm({ + sessionId: 'source-concurrent', + strategy: 'isolated-worktree', + transactionId: preview.transactionId, + previewFingerprint: preview.previewFingerprint, + worktreeNameSuffix: 'concurrent-child', + }) + expect(result.outcome).toBe('committed') + if (result.outcome !== 'committed') return + + // Block the establish call so the second send lands while establishment + // is in flight. + let releaseEstablish!: () => void + const gate = new Promise((resolve) => { + releaseEstablish = resolve + }) + const establishLog: Array<{ input: unknown }> = [] + const gatedAdapter = createDeterministicStrictForkAdapter({ adapterId: 'pi-test' }) + const blockingAdapter: StrictConversationForkCapability = { + ...gatedAdapter, + establishNativeFork: async (input) => { + establishLog.push({ input }) + await gate + return gatedAdapter.establishNativeFork(input) + }, + } + const childId = result.summary.sessionId + const chatCalls: string[] = [] + armChildAgent(childId, blockingAdapter, chatCalls) + + const firstSend = sm.sendMessage(childId, 'first concurrent send') + // Wait until the establish call is in flight, then fire the second send. + while (establishLog.length === 0) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + await expect(sm.sendMessage(childId, 'second concurrent send')).rejects.toMatchObject({ + code: WORKTREE_FORK_PENDING_CODE, + }) + // The pre-persist fence rejects the concurrent send without leaving an + // acknowledged orphan user message on disk. + expect(persistedMessageCount(childId, 'second concurrent send')).toBe(0) + releaseEstablish() + await firstSend + + // Exactly one establishment, one dispatch; the refused second message is + // never dispatched. + expect(establishLog).toHaveLength(1) + expect(chatCalls).toEqual(['first concurrent send']) + const managed = (sm as unknown as { sessions: Map }).sessions.get(childId) as { + pendingFork?: unknown + sdkSessionId?: string + } + expect(managed.pendingFork).toBeUndefined() + expect(managed.sdkSessionId).toBeTruthy() + }) + + it('exposes the durable fork-child session state for startup reconciliation and backfills the lost established marker', async () => { + injectSession('source-hook') + await persistSourceWithMessages('source-hook') + armStrictAdapter('source-hook') + const childId = await confirmChild('source-hook', 'hook-child') + if (!childId) return + const pendingBefore = loadStoredSession(root, childId)!.pendingFork! + + const smAny = sm as unknown as { + resolveSessionForkState?: (sessionId: string) => { + sdkSessionId?: string + pendingFork?: { transactionId: string } | null + checkoutStrategy?: string + } | null + } + + // Published-but-unestablished child: no provider id, pending intent with + // the fork transaction id, 'isolated' checkout provenance. + const pending = smAny.resolveSessionForkState!(childId) + expect(pending).toMatchObject({ + sdkSessionId: undefined, + checkoutStrategy: 'isolated', + }) + expect(pending?.pendingFork?.transactionId).toBe(pendingBefore.transactionId) + + // First Send establishes the child: the hook now reports the provider id + // and no pending intent. + const chatCalls: string[] = [] + armChildAgent( + childId, + createDeterministicStrictForkAdapter({ adapterId: 'pi-test', childSdkSessionId: 'sdk-hook-child' }), + chatCalls, + ) + await sm.sendMessage(childId, 'hello hook') + const established = smAny.resolveSessionForkState!(childId) + expect(established?.sdkSessionId).toBe('sdk-hook-child') + expect(established?.pendingFork).toBeNull() + expect(established?.checkoutStrategy).toBe('isolated') + + // Simulate the establish-window crash: the session flushed and retired + // pendingFork, but markEstablished never ran on the committed journal + // entry. Startup reconciliation through the wired hook must backfill it. + const entry = services.journal + .entries() + .find((e) => e.op === 'fork' && e.recordId === pendingBefore.transactionId)! + expect(entry?.metadata?.state).toBe('established') + services.journal.updateMetadata(entry.journalId, { state: 'binding-committed' }) + + const report = await services.fork.reconcileForkJournal() + + expect(report).toEqual({ resumed: 1, flagged: 0, recoveryRequired: 0 }) + const after = services.journal + .entries() + .find((e) => e.op === 'fork' && e.recordId === pendingBefore.transactionId)! + expect(after.metadata?.state).toBe('established') + expect(after.metadata?.childSdkSessionId).toBe('sdk-hook-child') + }) + + /** + * Phase 4 Task 7: provenance-aware cleanup. An isolated fork child owns its + * worktree record as the SOLE owner, so deletion uses only that child's + * lifecycle — the standard snapshot-first removal transaction on the child's + * own record — and never mutates the source session, the source's record, or + * the source branch/HEAD/index. Shared children keep dropping exactly one + * owner from the shared record (legacy behavior). + */ + describe('SessionManager isolated fork child deletion (Task 7 cleanup provenance)', () => { + /** The fork journal entry that published the given child session. */ + function forkEntryForChild(childId: string) { + return services.journal + .entries() + .find((e) => e.op === 'fork' && e.metadata?.childSessionId === childId) + } + + it('delete-with-worktree removes only the child lifecycle and leaves the source untouched', async () => { + injectSession('source-del') + await persistSourceWithMessages('source-del') + armStrictAdapter('source-del') + const childId = await confirmChild('source-del', 'del-child') + if (!childId) return + + // The child owns its own record as the SOLE owner. + const childRecord = services.registry.list().find((r) => r.ownerSessionIds.includes(childId)) + expect(childRecord).toBeDefined() + expect(childRecord!.ownerSessionIds).toEqual([childId]) + expect(childRecord!.state).toBe('ready') + const childCheckoutPath = childRecord!.checkoutPath + + // Source repo state (branch/HEAD/index) and fork journal entry before deletion. + const headBefore = (await git(repo, ['rev-parse', 'HEAD'])).trim() + const branchBefore = (await git(repo, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() + const indexBefore = await git(repo, ['status', '--porcelain']) + expect(forkEntryForChild(childId)?.status).toBe('committed') + + const result = await sm.deleteSession(childId, { removeManagedWorktree: true }) + + expect(result.deleted).toBe(true) + expect(result.worktreeRemoval?.removed).toBe(true) + + // Child session gone: runtime and persisted storage. + expect(sm.getSessions().some((s) => s.id === childId)).toBe(false) + expect(existsSync(getSessionFilePath(root, childId))).toBe(false) + + // Child record removed from the ready/owned set snapshot-first: the + // checkout is gone, the record is snapshotted with no owners and a + // verified snapshot, and the removal is journaled. + const after = services.registry.get(childRecord!.managedWorktreeId) + expect(after?.ownerSessionIds).toEqual([]) + expect(after?.state).toBe('snapshotted') + expect((after as import('@kata-sh/shared/protocol').ManagedWorktreeRecordV2 | undefined)?.snapshot).toBeDefined() + expect(existsSync(childCheckoutPath)).toBe(false) + expect( + services.journal + .entries() + .some((e) => e.op === 'session-delete' && e.recordId === childRecord!.managedWorktreeId && e.status === 'committed'), + ).toBe(true) + + // The fork journal entry is retained (kept by compaction) — never cleaned + // up by child deletion. + expect(forkEntryForChild(childId)?.status).toBe('committed') + + // Source session + persisted record untouched. + expect(sm.getSessions().some((s) => s.id === 'source-del')).toBe(true) + const sourceStored = loadStoredSession(root, 'source-del') + expect(sourceStored?.messages.map((m) => m.id)).toEqual(['msg-1', 'msg-2']) + expect(sourceStored?.checkout).toBeUndefined() + + // Source branch/HEAD/index unchanged. + expect((await git(repo, ['rev-parse', 'HEAD'])).trim()).toBe(headBefore) + expect((await git(repo, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()).toBe(branchBefore) + expect(await git(repo, ['status', '--porcelain'])).toBe(indexBefore) + }) + + it('deleting an isolated child without the removal choice leaves its record unowned and manageable (auto-delete applies)', async () => { + injectSession('source-del2') + await persistSourceWithMessages('source-del2') + armStrictAdapter('source-del2') + const childId = await confirmChild('source-del2', 'keep-child') + if (!childId) return + + const childRecord = services.registry.list().find((r) => r.ownerSessionIds.includes(childId))! + const childCheckoutPath = childRecord.checkoutPath + // A second materialized record so the retention sweep has a candidate to + // select beyond the limit (retention candidates require count > limit). + injectEmptySession('extra-owner') + const prep = await sm.prepareCheckout('extra-owner', { + mode: 'managed-worktree', + workingDirectory: repo, + baseRef: 'main', + }) + const extraRecordId = prep.checkout.managedWorktreeId! + + const result = await sm.deleteSession(childId) + expect(result.deleted).toBe(true) + + // The child's record stays manageable: unowned, checkout intact, nothing + // removed, no snapshot. The source record is untouched. + const after = services.registry.get(childRecord.managedWorktreeId)! + expect(after.state).toBe('unowned') + expect(after.ownerSessionIds).toEqual([]) + expect((after as import('@kata-sh/shared/protocol').ManagedWorktreeRecordV2).snapshot).toBeUndefined() + expect(existsSync(childCheckoutPath)).toBe(true) + expect(services.registry.get(extraRecordId)!.ownerSessionIds).toEqual(['extra-owner']) + expect(sm.getSessions().some((s) => s.id === 'source-del2')).toBe(true) + + // Auto-delete policy then removes the unowned record snapshot-first, + // exactly like any other record (the registry is provenance-neutral). + services.worktreeSettings.update({ + materializationRoot: join(root, 'worktrees'), + autoDeleteEnabled: true, + retentionLimit: 1, + }) + await services.lifecycle.runCleanupSweep() + const removed = services.registry.get(childRecord.managedWorktreeId)! + expect(removed.state).toBe('snapshotted') + expect((removed as import('@kata-sh/shared/protocol').ManagedWorktreeRecordV2).snapshot).toBeDefined() + expect(existsSync(childCheckoutPath)).toBe(false) + // The owned record survives the sweep. + expect(existsSync(prep.checkout.checkoutPath)).toBe(true) + }) + + it('deleting a shared-branch child drops exactly one owner from the shared record (regression guard)', async () => { + injectEmptySession('shared-parent') + const prep = await sm.prepareCheckout('shared-parent', { + mode: 'managed-worktree', + workingDirectory: repo, + baseRef: 'main', + }) + const recordId = prep.checkout.managedWorktreeId! + // Mirror the createSession shared-branch end state: the child mirrors the + // parent checkout, records 'shared' provenance, and joins the record as a + // second owner with a path lease. + const childId = 'shared-child' + const childManaged = injectSession(childId) + childManaged.checkout = prep.checkout as never + childManaged.workingDirectory = prep.checkout.checkoutPath + childManaged.sdkCwd = prep.checkout.checkoutPath + ;(childManaged as unknown as { checkoutStrategy?: string }).checkoutStrategy = 'shared' + services.worktrees.addOwner(recordId, childId) + services.pathLeases.lease(childId, prep.checkout.checkoutPath) + await saveStoredSession({ + id: childId, + workspaceRootPath: root, + name: 'shared-child', + createdAt: Date.now(), + lastUsedAt: Date.now(), + sdkCwd: prep.checkout.checkoutPath, + sdkSessionId: 'sdk-parent-1', + workingDirectory: prep.checkout.checkoutPath, + messages: [], + tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, contextTokens: 0, costUsd: 0 }, + } as never) + + expect(services.registry.get(recordId)!.ownerSessionIds).toEqual(['shared-parent', childId]) + + const result = await sm.deleteSession(childId) + expect(result.deleted).toBe(true) + + // Exactly one owner dropped; the shared record, checkout, and the other + // owner are untouched. + const record = services.registry.get(recordId)! + expect(record.ownerSessionIds).toEqual(['shared-parent']) + expect(record.state).toBe('ready') + expect(existsSync(prep.checkout.checkoutPath)).toBe(true) + expect(sm.getSessions().some((s) => s.id === childId)).toBe(false) + expect(sm.getSessions().some((s) => s.id === 'shared-parent')).toBe(true) + }) + + it('inspectManagedWorktreeRemoval reports the child record with no other owners (provenance-correct inspection)', async () => { + injectSession('source-inspect') + await persistSourceWithMessages('source-inspect') + armStrictAdapter('source-inspect') + const childId = await confirmChild('source-inspect', 'inspect-child') + if (!childId) return + const childRecord = services.registry.list().find((r) => r.ownerSessionIds.includes(childId))! + + // The inspection resolves the CHILD's own record — never the source's — + // and reports a sole owner, so the delete dialog shows no shared-worktree + // language and an accurate removal label. + const risk = await sm.inspectManagedWorktreeRemoval(childId) + expect(risk.managedWorktreeId).toBe(childRecord.managedWorktreeId) + expect(risk.ownerSessionIds).toEqual([childId]) + expect(risk.otherOwnerCount).toBe(0) + expect(risk.blocked).toBe(false) + }) + + it('a recovery-required fork journal entry survives child deletion and later reconcile', async () => { + injectSession('source-rec') + await persistSourceWithMessages('source-rec') + armStrictAdapter('source-rec') + const childId = await confirmChild('source-rec', 'recovery-child') + if (!childId) return + const forkEntry = forkEntryForChild(childId)! + // Drive the committed entry into the recovery-required state that an + // interrupted fork leaves behind; the deletion escape hatch must still + // apply and the entry must stay for later reconcile. + services.journal.updateMetadata(forkEntry.journalId, { + state: 'recovery-required', + recoveryReason: 'simulated interrupted fork', + }) + + const result = await sm.deleteSession(childId, { removeManagedWorktree: true }) + expect(result.deleted).toBe(true) + + const after = services.journal + .entries() + .find((e) => e.journalId === forkEntry.journalId)! + expect(after.status).toBe('committed') + expect(after.metadata?.state).toBe('recovery-required') + + // Reconcile reports the retained entry without blocking or editing it. + const report = await services.fork.reconcileForkJournal() + expect(report.recoveryRequired).toBeGreaterThanOrEqual(1) + const still = services.journal + .entries() + .find((e) => e.journalId === forkEntry.journalId)! + expect(still.status).toBe('committed') + }) + + it('pendingFork children skip the branch-preflight rollback (structural guard)', async () => { + // rollbackFailedBranchCreation is only reachable from the branch backend + // preflight gate, which requires `branchContextStrategy === 'sdk-fork' && + // !options.pendingFork`. A pendingFork child therefore never enters it: + // the child is created durably with NO backend preflight (no agent) and + // its worktree record survives, even though this harness cannot complete + // the shared-branch preflight (no real backend). The fork service owns + // compensation for failed fork creation instead. + injectSession('source-gate') + await persistSourceWithMessages('source-gate') + armStrictAdapter('source-gate') + const childId = await confirmChild('source-gate', 'gate-child') + if (!childId) return + + const managed = (sm as unknown as { sessions: Map }).sessions.get( + childId, + ) as { agent?: unknown; pendingFork?: unknown; checkoutStrategy?: string } + expect(managed.agent == null).toBe(true) + expect(managed.pendingFork).toBeDefined() + expect(managed.checkoutStrategy).toBe('isolated') + // The child's worktree record exists (nothing was rolled back). + expect(services.registry.list().some((r) => r.ownerSessionIds.includes(childId))).toBe(true) + // The durable fork creation committed exactly once. + expect(forkEntryForChild(childId)?.status).toBe('committed') + }) + + it('delete-with-worktree on an isolated child never mutates a managed source record', async () => { + // A managed source: the fork source owns its OWN worktree record. The + // child's deletion must leave that record, its checkout, and its HEAD + // exactly as they were. + const sourceManaged = injectEmptySession('managed-source') + const prep = await sm.prepareCheckout('managed-source', { + mode: 'managed-worktree', + workingDirectory: repo, + baseRef: 'main', + }) + const sourceRecordId = prep.checkout.managedWorktreeId! + // Restore the SDK identity the fork child anchors on (the stored record + // below also carries it). + ;(sourceManaged as unknown as { sdkSessionId?: string }).sdkSessionId = 'sdk-parent-1' + await persistSourceWithMessages('managed-source') + armStrictAdapter('managed-source') + services.pathLeases.lease('managed-source', prep.checkout.checkoutPath) + + const preview = await services.fork.preview({ + sessionId: 'managed-source', + strategy: 'isolated-worktree', + worktreeNameSuffix: 'src-isolated', + }) + expect(preview.blocked).toBeUndefined() + if (preview.blocked) return + const result = await services.fork.confirm({ + sessionId: 'managed-source', + strategy: 'isolated-worktree', + transactionId: preview.transactionId, + previewFingerprint: preview.previewFingerprint, + worktreeNameSuffix: 'src-isolated', + }) + expect(result.outcome).toBe('committed') + if (result.outcome !== 'committed') return + const childId = result.summary.sessionId + + const childRecord = services.registry.list().find((r) => r.ownerSessionIds.includes(childId))! + expect(childRecord.managedWorktreeId).not.toBe(sourceRecordId) + const sourceCheckoutPath = prep.checkout.checkoutPath + const sourceHeadBefore = (await git(sourceCheckoutPath, ['rev-parse', 'HEAD'])).trim() + const sourceBranchBefore = (await git(sourceCheckoutPath, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() + + const del = await sm.deleteSession(childId, { removeManagedWorktree: true }) + expect(del.deleted).toBe(true) + + // The source record is untouched: same sole owner, still ready, checkout + // on disk, branch/HEAD unchanged. + const sourceRecordAfter = services.registry.get(sourceRecordId)! + expect(sourceRecordAfter.ownerSessionIds).toEqual(['managed-source']) + expect(sourceRecordAfter.state).toBe('ready') + expect(existsSync(sourceCheckoutPath)).toBe(true) + expect((await git(sourceCheckoutPath, ['rev-parse', 'HEAD'])).trim()).toBe(sourceHeadBefore) + expect((await git(sourceCheckoutPath, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()).toBe(sourceBranchBefore) + expect(sm.getSessions().some((s) => s.id === 'managed-source')).toBe(true) + + // The child record is the only thing removed from the ready/owned set. + const childAfter = services.registry.get(childRecord.managedWorktreeId) + expect(childAfter?.ownerSessionIds).toEqual([]) + expect(childAfter?.state).toBe('snapshotted') + expect(existsSync(childRecord.checkoutPath)).toBe(false) + }) + }) +}) diff --git a/packages/shared/package.json b/packages/shared/package.json index bfbf2d64..1a8f5fc5 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -15,57 +15,58 @@ "exports": { ".": "./src/index.ts", "./agent": "./src/agent/index.ts", - "./agent/modes": "./src/agent/mode-types.ts", "./agent/mode-types": "./src/agent/mode-types.ts", + "./agent/modes": "./src/agent/mode-types.ts", + "./agent/testing": "./src/agent/backend/testing.ts", "./agent/thinking-levels": "./src/agent/thinking-levels.ts", "./auth": "./src/auth/index.ts", "./auth/callback-page": "./src/auth/callback-page.ts", "./auth/types": "./src/auth/types.ts", + "./automations": "./src/automations/index.ts", + "./automations/resolve-config-path": "./src/automations/resolve-config-path.ts", + "./branding": "./src/branding.ts", + "./colors": "./src/colors/index.ts", "./config": "./src/config/index.ts", "./config/agents-cli-invoke": "./src/config/agents-cli-invoke.ts", - "./docs": "./src/docs/index.ts", - "./docs/doc-links": "./src/docs/doc-links.ts", "./config/types": "./src/config/types.ts", "./credentials": "./src/credentials/index.ts", + "./docs": "./src/docs/index.ts", + "./docs/doc-links": "./src/docs/doc-links.ts", + "./feature-flags": "./src/feature-flags.ts", + "./git": "./src/git/index.ts", + "./i18n": "./src/i18n/index.ts", + "./icons": "./src/icons/index.ts", + "./interceptor": "./src/interceptor-common.ts", + "./labels": "./src/labels/index.ts", + "./labels/auto": "./src/labels/auto/index.ts", + "./labels/crud": "./src/labels/crud.ts", + "./labels/storage": "./src/labels/storage.ts", "./mcp": "./src/mcp/index.ts", + "./mentions": "./src/mentions/index.ts", "./prompts": "./src/prompts/index.ts", + "./protocol": "./src/protocol/index.ts", + "./resources": "./src/resources/index.ts", + "./search": "./src/search/index.ts", "./sessions": "./src/sessions/index.ts", + "./skills": "./src/skills/index.ts", + "./skills/types": "./src/skills/types.ts", "./sources": "./src/sources/index.ts", "./sources/types": "./src/sources/types.ts", - "./skills": "./src/skills/index.ts", - "./workspaces": "./src/workspaces/index.ts", + "./tools": "./src/tools/index.ts", "./utils": "./src/utils/index.ts", - "./utils/toolNames": "./src/utils/toolNames.ts", - "./utils/logo": "./src/utils/logo.ts", - "./utils/service-url": "./src/utils/service-url.ts", "./utils/icon": "./src/utils/icon.ts", - "./icons": "./src/icons/index.ts", - "./colors": "./src/colors/index.ts", "./utils/icon-constants": "./src/utils/icon-constants.ts", + "./utils/logo": "./src/utils/logo.ts", + "./utils/service-url": "./src/utils/service-url.ts", + "./utils/toolNames": "./src/utils/toolNames.ts", "./utils/url-safety": "./src/utils/url-safety.ts", "./utils/workspace": "./src/utils/workspace.ts", "./utils/workspace-slug": "./src/utils/workspace-slug.ts", - "./labels": "./src/labels/index.ts", - "./labels/auto": "./src/labels/auto/index.ts", - "./labels/storage": "./src/labels/storage.ts", - "./labels/crud": "./src/labels/crud.ts", - "./views": "./src/views/index.ts", - "./views/storage": "./src/views/storage.ts", "./validation": "./src/validation/index.ts", "./version": "./src/version/index.ts", - "./branding": "./src/branding.ts", - "./feature-flags": "./src/feature-flags.ts", - "./git": "./src/git/index.ts", - "./interceptor": "./src/interceptor-common.ts", - "./search": "./src/search/index.ts", - "./tools": "./src/tools/index.ts", - "./mentions": "./src/mentions/index.ts", - "./skills/types": "./src/skills/types.ts", - "./automations": "./src/automations/index.ts", - "./automations/resolve-config-path": "./src/automations/resolve-config-path.ts", - "./protocol": "./src/protocol/index.ts", - "./i18n": "./src/i18n/index.ts", - "./resources": "./src/resources/index.ts" + "./views": "./src/views/index.ts", + "./views/storage": "./src/views/storage.ts", + "./workspaces": "./src/workspaces/index.ts" }, "dependencies": { "@kata-sh/core": "workspace:*", diff --git a/packages/shared/src/agent/backend/__tests__/conversation-fork-capability.test.ts b/packages/shared/src/agent/backend/__tests__/conversation-fork-capability.test.ts new file mode 100644 index 00000000..94df2d39 --- /dev/null +++ b/packages/shared/src/agent/backend/__tests__/conversation-fork-capability.test.ts @@ -0,0 +1,132 @@ +/** + * Contract tests for the isolated-conversation-fork provider capability gate. + * + * Isolated forks are exposed only when the session's provider adapter + * advertises AND structurally can prove a strict cross-CWD native fork: the + * adapter establishes a provider-native fork at the recorded source + * conversation head while guaranteeing every file, shell, MCP, and provider + * tool executes in the destination and the immutable transcript identity is + * preserved. These fixtures are deterministic adapters, not mocks: they + * exercise the real contract surface (advertise → establish) that the fork + * service will consume. + */ + +import { describe, expect, it } from 'bun:test' +import { + resolveIsolatedForkCapability, + type IsolatedForkCapabilityResolution, +} from '../conversation-fork-capability' +import type { + AgentBackend, + ConversationForkEstablishInput, + ConversationForkEstablishResult, + ExecutionCwdProof, + StrictConversationForkCapability, +} from '../types' +import type { ConversationForkProviderCapability } from '../../../protocol' + +/** Deterministic adapter that advertises and establishes native forks. */ +class RecordingForkAdapter implements StrictConversationForkCapability { + readonly adapterId = 'test-pi' + establishCalls: ConversationForkEstablishInput[] = [] + constructor(private readonly strict: boolean = true) {} + + forkCapability(): ConversationForkProviderCapability { + return { adapterId: this.adapterId, strictCrossCwdNativeFork: this.strict } + } + + async establishNativeFork(input: ConversationForkEstablishInput): Promise { + this.establishCalls.push(input) + const proof: ExecutionCwdProof = { + adapterId: this.adapterId, + destinationPath: input.executionCwd, + verifiedAt: 1, + checks: ['file:read', 'shell:cwd', 'mcp:list', 'provider:cwd'], + } + return { childSdkSessionId: 'sdk-child-test-pi', proof } + } +} + +/** Deterministic fixture backend; the rest of AgentBackend is irrelevant here. */ +function makeBackend(conversationFork?: StrictConversationForkCapability): AgentBackend { + return { conversationFork } as unknown as AgentBackend +} + +describe('isolated conversation fork provider capability gate', () => { + it('returns a typed unsupported-provider blocker when the backend has no capability', () => { + const backend = makeBackend() + const resolution = resolveIsolatedForkCapability(backend) + + expect(resolution.supported).toBe(false) + if (!resolution.supported) { + expect(resolution.blocker).toBe('unsupported-provider') + } + }) + + it('resolves a supported adapter to its advertised capability', () => { + const adapter = new RecordingForkAdapter() + const resolution: IsolatedForkCapabilityResolution = resolveIsolatedForkCapability(makeBackend(adapter)) + + expect(resolution.supported).toBe(true) + if (resolution.supported) { + expect(resolution.capability.adapterId).toBe('test-pi') + expect(resolution.capability.strictCrossCwdNativeFork).toBe(true) + } + }) + + it('blocks an adapter that advertises strictCrossCwdNativeFork: false', () => { + // E.g. an adapter that cannot separate transcript storage from execution. + const adapter = new RecordingForkAdapter(false) + const resolution = resolveIsolatedForkCapability(makeBackend(adapter)) + + expect(resolution.supported).toBe(false) + }) + + it('blocks a degraded adapter missing the establish surface', () => { + const incomplete = { + adapterId: 'broken', + forkCapability: () => ({ adapterId: 'broken', strictCrossCwdNativeFork: true }), + } + const resolution = resolveIsolatedForkCapability( + makeBackend(incomplete as unknown as StrictConversationForkCapability), + ) + + expect(resolution.supported).toBe(false) + }) + + it('blocks an adapter whose capability callback throws', () => { + const throwing = { + adapterId: 'degraded', + forkCapability: () => { + throw new Error('adapter degraded') + }, + establishNativeFork: async () => ({ childSdkSessionId: 'sdk-child', proof: {} as ExecutionCwdProof }), + } + const resolution = resolveIsolatedForkCapability( + makeBackend(throwing as unknown as StrictConversationForkCapability), + ) + + expect(resolution.supported).toBe(false) + if (!resolution.supported) { + expect(resolution.blocker).toBe('unsupported-provider') + } + }) + + it('passes idempotency-keyed parent identity through and proves the destination CWD', async () => { + const adapter = new RecordingForkAdapter() + const input: ConversationForkEstablishInput = { + parentSdkSessionId: 'sdk-parent-1', + parentSdkTurnId: 'turn-42', + idempotencyKey: 'fork-txn-abc-step-4', + executionCwd: '/srv/kata/worktrees/repo/feature-x', + transcriptCwd: '/repo/.kata/sessions/session-child', + } + + const result = await adapter.establishNativeFork(input) + + expect(adapter.establishCalls).toEqual([input]) + expect(result.childSdkSessionId).toBe('sdk-child-test-pi') + expect(result.proof.destinationPath).toBe(input.executionCwd) + expect(result.proof.destinationPath).not.toBe(input.transcriptCwd) + }) +}) diff --git a/packages/shared/src/agent/backend/__tests__/deterministic-fork-adapter.test.ts b/packages/shared/src/agent/backend/__tests__/deterministic-fork-adapter.test.ts new file mode 100644 index 00000000..5dacc9f3 --- /dev/null +++ b/packages/shared/src/agent/backend/__tests__/deterministic-fork-adapter.test.ts @@ -0,0 +1,79 @@ +import { describe, test, expect } from 'bun:test' +import { createDeterministicStrictForkAdapter } from '../testing' +import { resolveIsolatedForkCapability } from '../conversation-fork-capability' +import type { ConversationForkEstablishInput } from '../types' + +const INPUT: ConversationForkEstablishInput = { + parentSdkSessionId: 'sdk-parent-1', + parentSdkTurnId: 'turn-42', + idempotencyKey: 'fork-txn-abc-step-4', + executionCwd: '/srv/kata/worktrees/repo/feature-x', + transcriptCwd: '/repo/.kata/sessions/session-child', +} + +describe('deterministic strict fork adapter factory', () => { + test('produces a complete strict capability with all four proof categories', async () => { + const adapter = createDeterministicStrictForkAdapter({ adapterId: 'det-fork' }) + + expect(adapter.adapterId).toBe('det-fork') + expect(adapter.forkCapability()).toEqual({ + adapterId: 'det-fork', + strictCrossCwdNativeFork: true, + }) + const result = await adapter.establishNativeFork(INPUT) + expect(result.childSdkSessionId).toBe('sdk-child-det-fork') + expect(result.proof.adapterId).toBe('det-fork') + expect(result.proof.destinationPath).toBe(INPUT.executionCwd) + expect(result.proof.checks).toEqual(['file:read', 'shell:cwd', 'mcp:list', 'provider:cwd']) + }) + + test('records establish calls with the idempotency-keyed input when a log is supplied', async () => { + const establishLog: Array<{ input: ConversationForkEstablishInput; childSdkSessionId: string }> = [] + const adapter = createDeterministicStrictForkAdapter({ establishLog }) + await adapter.establishNativeFork(INPUT) + expect(establishLog).toHaveLength(1) + expect(establishLog[0]!.input).toEqual(INPUT) + expect(establishLog[0]!.childSdkSessionId).toBe('sdk-child-deterministic-strict-fork') + }) + + test('failEstablish makes native-fork establishment throw without touching the capability', async () => { + const adapter = createDeterministicStrictForkAdapter({ failEstablish: true }) + await expect(adapter.establishNativeFork(INPUT)).rejects.toThrow('establish refused') + // The capability gate is about advertisement, not the live establishment. + expect(adapter.forkCapability().strictCrossCwdNativeFork).toBe(true) + }) + + test('missingChecks drops exactly the requested categories from the proof', async () => { + const adapter = createDeterministicStrictForkAdapter({ missingChecks: ['mcp', 'provider'] }) + const result = await adapter.establishNativeFork(INPUT) + expect(result.proof.checks).toEqual(['file:read', 'shell:cwd']) + }) + + test('honors a childSdkSessionId override for deterministic assertions', async () => { + const adapter = createDeterministicStrictForkAdapter({ childSdkSessionId: 'sdk-child-e2e' }) + const result = await adapter.establishNativeFork(INPUT) + expect(result.childSdkSessionId).toBe('sdk-child-e2e') + }) + + test('an adapter with missing categories still passes the capability gate but yields an incomplete proof', async () => { + const adapter = createDeterministicStrictForkAdapter({ missingChecks: ['shell'] }) + expect(adapter.forkCapability().strictCrossCwdNativeFork).toBe(true) + const resolution = resolveIsolatedForkCapability({ conversationFork: adapter }) + expect(resolution.supported).toBe(true) + const result = await adapter.establishNativeFork(INPUT) + expect(result.proof.checks.some((check) => check.startsWith('shell:'))).toBe(false) + }) + + // Retrying the same idempotency key never produces a different child ID; + // persist-exactly-once dedupe itself is the fork service's responsibility + // (the fixture records every call and does not dedupe). + test('retrying establish with the same key returns the same child provider ID', async () => { + const establishLog: Array<{ input: ConversationForkEstablishInput; childSdkSessionId: string }> = [] + const adapter = createDeterministicStrictForkAdapter({ establishLog }) + await adapter.establishNativeFork(INPUT) + await adapter.establishNativeFork(INPUT) + expect(establishLog).toHaveLength(2) + expect(establishLog[0]!.childSdkSessionId).toBe(establishLog[1]!.childSdkSessionId) + expect(establishLog[1]!.input.idempotencyKey).toBe(INPUT.idempotencyKey) + }) +}) diff --git a/packages/shared/src/agent/backend/__tests__/deterministic-handoff-adapter.test.ts b/packages/shared/src/agent/backend/__tests__/deterministic-handoff-adapter.test.ts index adca68f8..1f1aed3d 100644 --- a/packages/shared/src/agent/backend/__tests__/deterministic-handoff-adapter.test.ts +++ b/packages/shared/src/agent/backend/__tests__/deterministic-handoff-adapter.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test' -import { createDeterministicHandoffAdapter } from '../deterministic-handoff-adapter' +import { createDeterministicHandoffAdapter } from '../testing' import { resolveHandoffCapability } from '../handoff-capability' describe('deterministic handoff adapter factory', () => { diff --git a/packages/shared/src/agent/backend/conversation-fork-capability.ts b/packages/shared/src/agent/backend/conversation-fork-capability.ts new file mode 100644 index 00000000..8fcc8de6 --- /dev/null +++ b/packages/shared/src/agent/backend/conversation-fork-capability.ts @@ -0,0 +1,54 @@ +/** + * Isolated conversation fork provider capability gate. + * + * Isolated forks are exposed only when the session's provider adapter + * advertises AND is structurally able to prove a strict cross-CWD native + * fork: establishing the provider-native child at the recorded source + * conversation head while every file, shell, MCP, and provider tool executes + * in the destination and the immutable transcript identity is preserved. + * Unsupported adapters resolve to a typed `unsupported-provider` blocker; + * the existing missing-anchor/full-history fallback is never used for the + * isolated strategy. + */ + +import type { ConversationForkProviderCapability } from '../../protocol' +import type { AgentBackend, StrictConversationForkCapability } from './types' + +export type IsolatedForkCapabilityResolution = + | { supported: true; capability: ConversationForkProviderCapability } + | { supported: false; blocker: 'unsupported-provider' } + +function isCompleteCapability(value: StrictConversationForkCapability): boolean { + return ( + typeof value.forkCapability === 'function' && + typeof value.establishNativeFork === 'function' + ) +} + +/** + * Resolve whether a backend may expose isolated conversation forks. Requires + * the adapter to advertise `strictCrossCwdNativeFork: true` AND implement the + * full advertise + establish surface. Live proof is demanded at first Send + * (establishNativeFork's proof) before the child provider ID is persisted; + * this gate decides whether isolated is offered at all. + */ +export function resolveIsolatedForkCapability( + backend: Pick, +): IsolatedForkCapabilityResolution { + const adapter = backend.conversationFork + if (!adapter || !isCompleteCapability(adapter)) { + return { supported: false, blocker: 'unsupported-provider' } + } + let capability: ConversationForkProviderCapability | undefined + try { + capability = adapter.forkCapability() + } catch { + // A degraded adapter may throw inside the capability callback; the + // documented typed blocker beats a generic error escaping the gate. + return { supported: false, blocker: 'unsupported-provider' } + } + if (!capability || capability.strictCrossCwdNativeFork !== true) { + return { supported: false, blocker: 'unsupported-provider' } + } + return { supported: true, capability } +} diff --git a/packages/shared/src/agent/backend/deterministic-fork-adapter.ts b/packages/shared/src/agent/backend/deterministic-fork-adapter.ts new file mode 100644 index 00000000..f4761d8c --- /dev/null +++ b/packages/shared/src/agent/backend/deterministic-fork-adapter.ts @@ -0,0 +1,80 @@ +/** + * Deterministic strict conversation-fork adapter factory (credential-free). + * + * The spec mandates deterministic provider adapters for state-machine coverage: + * production adapters stay disabled until credentialed UAT, so tests and the + * headless/E2E flows use this factory to exercise the first-Send native-fork + * establishment, failure points, and the destination-execution proof gate + * without a live provider. Failure injection is explicit per adapter instance; + * nothing here touches a real SDK runtime. + */ + +import type { + ConversationForkEstablishInput, + ConversationForkEstablishResult, + ExecutionCwdProof, + StrictConversationForkCapability, +} from './types' + +const PROOF_CATEGORIES = ['file', 'shell', 'mcp', 'provider'] as const + +export interface DeterministicStrictForkAdapterOptions { + /** Adapter identity stamped into capabilities and proofs. */ + adapterId?: string + /** establishNativeFork throws when true (native anchor missing/malformed). */ + failEstablish?: boolean + /** Proof categories to omit; the strict fork requires all four. */ + missingChecks?: (typeof PROOF_CATEGORIES)[number][] + /** Establish calls recorded for later assertions (shared array). */ + establishLog?: Array<{ input: ConversationForkEstablishInput; childSdkSessionId: string }> + /** Deterministic child SDK session ID override. */ + childSdkSessionId?: string +} + +/** + * Build a deterministic strict fork adapter whose establish/verify succeed + * unless failure injection is requested. `establishNativeFork` always returns + * a stable child provider ID for the same adapter and proves the exact + * destination execution CWD with all four tool categories unless + * `missingChecks` removes one — the first-Send gate then rejects the proof + * exactly like a broken production adapter would. Retrying the same + * idempotency key never produces a different child ID. + */ +export function createDeterministicStrictForkAdapter( + options: DeterministicStrictForkAdapterOptions = {}, +): StrictConversationForkCapability { + const adapterId = options.adapterId ?? 'deterministic-strict-fork' + const childSdkSessionId = options.childSdkSessionId ?? `sdk-child-${adapterId}` + const missing = new Set(options.missingChecks ?? []) + return { + adapterId, + forkCapability: () => ({ adapterId, strictCrossCwdNativeFork: true }), + establishNativeFork: async ( + input: ConversationForkEstablishInput, + ): Promise => { + if (options.failEstablish) { + throw new Error(`deterministic adapter ${adapterId}: establish refused`) + } + options.establishLog?.push({ input, childSdkSessionId }) + const checks = PROOF_CATEGORIES.filter((category) => !missing.has(category)).map((category) => { + switch (category) { + case 'file': + return 'file:read' + case 'shell': + return 'shell:cwd' + case 'mcp': + return 'mcp:list' + case 'provider': + return 'provider:cwd' + } + }) + const proof: ExecutionCwdProof = { + adapterId, + destinationPath: input.executionCwd, + verifiedAt: Date.now(), + checks, + } + return { childSdkSessionId, proof } + }, + } +} diff --git a/packages/shared/src/agent/backend/index.ts b/packages/shared/src/agent/backend/index.ts index e4b1acf3..1cd93c17 100644 --- a/packages/shared/src/agent/backend/index.ts +++ b/packages/shared/src/agent/backend/index.ts @@ -46,6 +46,9 @@ export type { PostInitResult, ExecutionCwdProof, ExecutionCwdRebindCapability, + ConversationForkEstablishInput, + ConversationForkEstablishResult, + StrictConversationForkCapability, } from './types.ts'; // Handoff capability gate @@ -54,11 +57,11 @@ export { type HandoffCapabilityResolution, } from './handoff-capability.ts'; -// Credential-free deterministic handoff adapter (state-machine coverage) +// Isolated conversation fork capability gate (Worktree V2 Phase 4) export { - createDeterministicHandoffAdapter, - type DeterministicHandoffAdapterOptions, -} from './deterministic-handoff-adapter.ts'; + resolveIsolatedForkCapability, + type IsolatedForkCapabilityResolution, +} from './conversation-fork-capability.ts'; // Enums need to be exported as values, not just types export { AbortReason } from './types.ts'; diff --git a/packages/shared/src/agent/backend/testing.ts b/packages/shared/src/agent/backend/testing.ts new file mode 100644 index 00000000..c0a83949 --- /dev/null +++ b/packages/shared/src/agent/backend/testing.ts @@ -0,0 +1,21 @@ +/** + * Test-only deterministic provider adapters. + * + * These factories simulate provider capabilities so unit and state-machine + * tests can run credential-free. They are NOT provider implementations and + * MUST NEVER be wired into production code paths — no env-var seam, no + * SessionManager wiring, no renderer path. Production provider backends + * implement the real capabilities (ExecutionCwdRebindCapability / + * StrictConversationForkCapability) and stay disabled until credentialed UAT + * proves them. Import only from test files via `@kata-sh/shared/agent/testing`. + */ + +export { + createDeterministicHandoffAdapter, + type DeterministicHandoffAdapterOptions, +} from './deterministic-handoff-adapter.ts'; + +export { + createDeterministicStrictForkAdapter, + type DeterministicStrictForkAdapterOptions, +} from './deterministic-fork-adapter.ts'; diff --git a/packages/shared/src/agent/backend/types.ts b/packages/shared/src/agent/backend/types.ts index c11288db..752931d0 100644 --- a/packages/shared/src/agent/backend/types.ts +++ b/packages/shared/src/agent/backend/types.ts @@ -30,7 +30,7 @@ export { AbortReason, type RecoveryMessage }; import type { ModelProvider } from '../../config/models.ts'; // Import the sanitized provider capability DTO used by the handoff gate. -import type { WorktreeHandoffProviderCapability } from '../../protocol/index.ts'; +import type { ConversationForkProviderCapability, WorktreeHandoffProviderCapability } from '../../protocol/index.ts'; // Import LLM connection types for auth import type { LlmAuthType, LlmProviderType } from '../../config/llm-connections.ts'; @@ -377,6 +377,69 @@ export interface ExecutionCwdRebindCapability { verifyExecutionCwd(destinationPath: string): Promise; } +// --------------------------------------------------------------------------- +// Strict cross-CWD native conversation fork (Worktree V2 Phase 4) +// --------------------------------------------------------------------------- + +/** + * Input consumed by a strict conversation-fork adapter on the child's first + * Send. Carries the idempotency-keyed pending parent/message identity, the + * immutable transcript lookup identity, and the destination execution CWD. + */ +export interface ConversationForkEstablishInput { + /** Parent provider SDK session identity (anchor lineage). */ + parentSdkSessionId: string; + /** Parent provider turn anchor at the branch point. */ + parentSdkTurnId: string; + /** Idempotency key: retries must never duplicate the native child. */ + idempotencyKey: string; + /** Destination execution CWD every file/shell/MCP/provider tool must use. */ + executionCwd: string; + /** Immutable transcript lookup identity — never rewritten by the fork. */ + transcriptCwd: string; +} + +/** + * Result of a strict native-fork establishment: the provider-created child + * ID plus concrete proof that tool CWD resolution is exclusively the + * destination. Persisted exactly once (idempotency-keyed). + */ +export interface ConversationForkEstablishResult { + /** Provider-created child SDK session ID (claimed only now, not before). */ + childSdkSessionId: string; + /** Proof that every file/shell/MCP/provider tool resolves the destination. */ + proof: ExecutionCwdProof; +} + +/** + * Optional backend capability for Worktree V2 Phase 4 isolated conversation + * forks. + * + * A backend implements this ONLY when it can establish a provider-native fork + * at the recorded source conversation head with every file, shell, MCP, and + * provider tool executing in the destination execution CWD while preserving + * the immutable transcript lookup identity (the transcript CWD). Adapters + * that cannot separate transcript storage from execution (Claude's current + * use of `sdkCwd` for both) or cannot prove destination-only tool CWD must + * NOT implement this; their sessions stay typed-blocked with no fallback. + */ +export interface StrictConversationForkCapability { + readonly adapterId: string; + + /** Sanitized capability DTO shown in previews and used for the gate. */ + forkCapability(): ConversationForkProviderCapability; + + /** + * Establish the native child fork at the recorded source head on first + * Send. Consumes the idempotency-keyed pending parent/message identity, + * guarantees destination execution, and persists the child provider ID + * exactly once. A missing or malformed native anchor is a typed error — + * the existing full-history/fresh fallback is never used for isolated + * forks. + */ + establishNativeFork(input: ConversationForkEstablishInput): Promise; +} + /** * Core backend interface - all AI providers must implement this. * @@ -631,6 +694,14 @@ export interface AgentBackend { */ executionCwdRebind?: ExecutionCwdRebindCapability; + /** + * Optional Worktree V2 Phase 4 capability: strict cross-CWD native + * conversation fork. Present only for adapters that can establish a + * provider-native child fork at the source head while proving every tool + * executes in the destination and transcript identity is preserved. + */ + conversationFork?: StrictConversationForkCapability; + // ============================================================ // Session & Workspace State // ============================================================ diff --git a/packages/shared/src/i18n/locales/de.json b/packages/shared/src/i18n/locales/de.json index 0341567e..0b13fc1f 100644 --- a/packages/shared/src/i18n/locales/de.json +++ b/packages/shared/src/i18n/locales/de.json @@ -534,6 +534,72 @@ "git.delete.unpushedWarning_one": "{{count}} nicht gepushter Commit wird verworfen", "git.delete.unpushedWarning_other": "{{count}} nicht gepushte Commits werden verworfen", "git.delete.worktreeKeptNote": "Standardmäßig bleibt der Worktree-Checkout erhalten.", + "git.fork.anchorMissing": "Das isolierte Fork-Kind hat keinen gültigen Provider-Anker (fehlende Eltern-SDK-Sitzung, Turn-ID oder Idempotenzschlüssel). Der Fork muss neu erstellt werden.", + "git.fork.blockedTitle": "Fork blockiert", + "git.fork.cancel": "Abbrechen", + "git.fork.checkoutPath": "Checkout-Pfad", + "git.fork.committedBranch": "Branch {{branch}}", + "git.fork.committedDetail": "Die Kind-Sitzung arbeitet nun in ihrem isolierten Checkout.", + "git.fork.committedTitle": "Fork erstellt", + "git.fork.confirm": "Fork erstellen", + "git.fork.confirming": "Fork wird erstellt…", + "git.fork.conversationHead": "Gesprächsstand", + "git.fork.cwdInvalid": "Das isolierte Fork-Kind hat kein gültiges Ausführungs- oder Transkript-Arbeitsverzeichnis. Der Fork muss neu erstellt werden.", + "git.fork.description": "Diese Konversation als neue Sitzung abzweigen.", + "git.fork.destinationBranch": "Branch", + "git.fork.destinationLabel": "Ziel", + "git.fork.destinationLeases": "Ziel-Leases", + "git.fork.establishFailed": "Der native Fork konnte nicht eingerichtet werden: {{error}}. Versuche es erneut, um den Fork abzuschließen.", + "git.fork.establishIncomplete": "Die native Fork-Einrichtung hat ein unvollständiges Ergebnis zurückgegeben. Versuche es erneut, um den Fork abzuschließen.", + "git.fork.establishing": "Diese Sitzung richtet gerade ihren isolierten Konversations-Fork ein; versuche es gleich noch einmal.", + "git.fork.gitState.includedIgnored": "{{count}} enthaltene ignorierte Datei(en)", + "git.fork.gitState.staged": "{{count}} gestaged", + "git.fork.gitState.unstaged": "{{count}} nicht gestaged", + "git.fork.gitState.untracked": "{{count}} ungetrackt", + "git.fork.gitStateLabel": "Git-Status", + "git.fork.ignoredPolicy": "Ignorierte Dateien", + "git.fork.ignoredPolicyDetail": "Nur {{count}} in .worktreeinclude gelistete ignorierte Datei(en) werden in den Fork kopiert", + "git.fork.nameLabel": "Worktree-Name", + "git.fork.namePlaceholder": "auth-refresh", + "git.fork.nonHeadDisabled": "Isolierte Forks sind nur am aktuellen Gesprächsstand verfügbar.", + "git.fork.occupied": "belegt", + "git.fork.ownersLabel": "Pfad-Eigentümer", + "git.fork.pendingChild": "Diese Sitzung ist ein ausstehender isolierter Konversations-Fork, der noch nicht eingerichtet wurde.", + "git.fork.pendingFence": "Ein Konversations-Fork ist ausstehend oder erfordert eine Wiederherstellung.", + "git.fork.previewing": "Vorschau wird vorbereitet…", + "git.fork.provider": "Anbieter", + "git.fork.providerPending": "Ausstehend", + "git.fork.providerPendingNote": "Die Anbieter-Identität ist ausstehend, bis die erste Nachricht den Fork einrichtet.", + "git.fork.recover": "Wiederherstellen", + "git.fork.recovering": "Wiederherstellung läuft…", + "git.fork.recoveryHint": "Eine Fork-Transaktion ist ausstehend oder benötigt eine Wiederherstellung für diese Sitzung.", + "git.fork.recoveryNote": "Der Fork wurde unterbrochen. Ein verifizierter Snapshot wurde beibehalten – stelle ihn wieder her, um zurückzusetzen und es erneut zu versuchen.", + "git.fork.recoveryTitle": "Wiederherstellung erforderlich", + "git.fork.remoteOwned": "Gehört zum Server {{serverId}}", + "git.fork.repositoryRoot": "Repository-Stamm", + "git.fork.retainedSnapshot": "Snapshot {{snapshotId}} beibehalten", + "git.fork.retry": "Erneut versuchen", + "git.fork.retryTitle": "Fork-Einrichtung fehlgeschlagen", + "git.fork.retrying": "Erneuter Versuch…", + "git.fork.rolledBackDetail": "Der unterbrochene Fork wurde zurückgesetzt. Du kannst ihn erneut in der Vorschau ansehen.", + "git.fork.server": "Server", + "git.fork.sourceCheckout": "Quell-Checkout", + "git.fork.sourceLabel": "Quelle", + "git.fork.sourceSession": "Quellsitzung", + "git.fork.state.clean": "Sauber", + "git.fork.state.detached": "Losgelöst", + "git.fork.state.dirty": "Schmutzig", + "git.fork.statusActive": "Fork ausstehend", + "git.fork.strategy.isolated": "Neuer isolierter Worktree", + "git.fork.strategy.isolatedNote": "Einen eigenen verwalteten Worktree, Git-Branch und eine Laufzeit am aktuellen Stand erstellen.", + "git.fork.strategy.shared": "Gemeinsamer Worktree", + "git.fork.strategy.sharedNote": "In den verwalteten Worktree der Quelle abzweigen (bisheriges Verhalten).", + "git.fork.strictAdapterUnavailable": "Der Provider-Adapter kann für diese Sitzung keinen strikten Cross-CWD-Native-Fork einrichten.", + "git.fork.strictFork": "Strikter Fork", + "git.fork.strictForkSupported": "Unterstützt", + "git.fork.strictForkUnsupported": "Nicht unterstützt", + "git.fork.title": "Konversation forken", + "git.fork.unsupportedProviderDisabled": "Dieser Anbieter kann noch keinen isolierten Fork einrichten.", "git.github.authRequired": "Bei GitHub anmelden", "git.github.installRequired": "GitHub CLI installieren", "git.handoff.action": "Übergeben", diff --git a/packages/shared/src/i18n/locales/en.json b/packages/shared/src/i18n/locales/en.json index 1690ce17..c131774f 100644 --- a/packages/shared/src/i18n/locales/en.json +++ b/packages/shared/src/i18n/locales/en.json @@ -534,6 +534,72 @@ "git.delete.unpushedWarning_one": "{{count}} unpushed commit will be discarded", "git.delete.unpushedWarning_other": "{{count}} unpushed commits will be discarded", "git.delete.worktreeKeptNote": "By default the worktree checkout is preserved.", + "git.fork.anchorMissing": "The isolated fork child has no valid provider anchor (missing parent SDK session, turn id, or idempotency key). The fork must be re-created.", + "git.fork.blockedTitle": "Fork blocked", + "git.fork.cancel": "Cancel", + "git.fork.checkoutPath": "Checkout path", + "git.fork.committedBranch": "Branch {{branch}}", + "git.fork.committedDetail": "The child session now works in its isolated checkout.", + "git.fork.committedTitle": "Fork created", + "git.fork.confirm": "Create fork", + "git.fork.confirming": "Creating fork…", + "git.fork.conversationHead": "Conversation head", + "git.fork.cwdInvalid": "The isolated fork child has no valid execution or transcript CWD. The fork must be re-created.", + "git.fork.description": "Branch this conversation as a new session.", + "git.fork.destinationBranch": "Branch", + "git.fork.destinationLabel": "Destination", + "git.fork.destinationLeases": "Destination leases", + "git.fork.establishFailed": "The native fork could not be established: {{error}}. Retry to complete the fork.", + "git.fork.establishIncomplete": "The native fork establishment returned an incomplete result. Retry to complete the fork.", + "git.fork.establishing": "This session is establishing its isolated conversation fork; try again shortly.", + "git.fork.gitState.includedIgnored": "{{count}} included ignored file(s)", + "git.fork.gitState.staged": "{{count}} staged", + "git.fork.gitState.unstaged": "{{count}} unstaged", + "git.fork.gitState.untracked": "{{count}} untracked", + "git.fork.gitStateLabel": "Git state", + "git.fork.ignoredPolicy": "Ignored files", + "git.fork.ignoredPolicyDetail": "Only {{count}} .worktreeinclude-listed ignored file(s) copy to the fork", + "git.fork.nameLabel": "Worktree name", + "git.fork.namePlaceholder": "auth-refresh", + "git.fork.nonHeadDisabled": "Isolated forks are only available at the current conversation head.", + "git.fork.occupied": "occupied", + "git.fork.ownersLabel": "Path owners", + "git.fork.pendingChild": "This session is a pending isolated conversation fork that has not been established yet.", + "git.fork.pendingFence": "A conversation fork is pending or requires recovery.", + "git.fork.previewing": "Preparing preview…", + "git.fork.provider": "Provider", + "git.fork.providerPending": "Pending", + "git.fork.providerPendingNote": "Provider identity is pending until the first message establishes the fork.", + "git.fork.recover": "Recover", + "git.fork.recovering": "Recovering…", + "git.fork.recoveryHint": "A fork transaction is pending or needs recovery for this session.", + "git.fork.recoveryNote": "The fork was interrupted. A verified snapshot is retained — recover to roll it back and try again.", + "git.fork.recoveryTitle": "Recovery required", + "git.fork.remoteOwned": "Owned by server {{serverId}}", + "git.fork.repositoryRoot": "Repository root", + "git.fork.retainedSnapshot": "Snapshot {{snapshotId}} retained", + "git.fork.retry": "Retry", + "git.fork.retryTitle": "Fork establishment failed", + "git.fork.retrying": "Retrying…", + "git.fork.rolledBackDetail": "The interrupted fork was rolled back. You can preview it again.", + "git.fork.server": "Server", + "git.fork.sourceCheckout": "Source checkout", + "git.fork.sourceLabel": "Source", + "git.fork.sourceSession": "Source session", + "git.fork.state.clean": "Clean", + "git.fork.state.detached": "Detached", + "git.fork.state.dirty": "Dirty", + "git.fork.statusActive": "Fork pending", + "git.fork.strategy.isolated": "New isolated worktree", + "git.fork.strategy.isolatedNote": "Create a dedicated managed worktree, Git branch, and runtime at the current head.", + "git.fork.strategy.shared": "Shared worktree", + "git.fork.strategy.sharedNote": "Branch into the source's managed worktree (existing behavior).", + "git.fork.strictAdapterUnavailable": "The provider adapter cannot establish a strict cross-CWD native fork for this session.", + "git.fork.strictFork": "Strict fork", + "git.fork.strictForkSupported": "Supported", + "git.fork.strictForkUnsupported": "Unsupported", + "git.fork.title": "Fork conversation", + "git.fork.unsupportedProviderDisabled": "This provider can't establish an isolated fork yet.", "git.github.authRequired": "Sign in to GitHub", "git.github.installRequired": "Install GitHub CLI", "git.handoff.action": "Hand off", diff --git a/packages/shared/src/i18n/locales/es.json b/packages/shared/src/i18n/locales/es.json index f089a495..74814fa9 100644 --- a/packages/shared/src/i18n/locales/es.json +++ b/packages/shared/src/i18n/locales/es.json @@ -534,6 +534,72 @@ "git.delete.unpushedWarning_one": "Se descartará {{count}} commit sin enviar", "git.delete.unpushedWarning_other": "Se descartarán {{count}} commits sin enviar", "git.delete.worktreeKeptNote": "De forma predeterminada, el worktree se conserva.", + "git.fork.anchorMissing": "El fork secundario aislado no tiene un ancla de proveedor válida (falta la sesión SDK principal, el ID de turno o la clave de idempotencia). Debes volver a crear el fork.", + "git.fork.blockedTitle": "Fork bloqueado", + "git.fork.cancel": "Cancelar", + "git.fork.checkoutPath": "Ruta de checkout", + "git.fork.committedBranch": "Rama {{branch}}", + "git.fork.committedDetail": "La sesión hija ahora trabaja en su checkout aislado.", + "git.fork.committedTitle": "Fork creado", + "git.fork.confirm": "Crear fork", + "git.fork.confirming": "Creando fork…", + "git.fork.conversationHead": "Final de la conversación", + "git.fork.cwdInvalid": "El fork secundario aislado no tiene un CWD de ejecución o transcripción válido. Debes volver a crear el fork.", + "git.fork.description": "Deriva esta conversación como una sesión nueva.", + "git.fork.destinationBranch": "Rama", + "git.fork.destinationLabel": "Destino", + "git.fork.destinationLeases": "Bloqueos del destino", + "git.fork.establishFailed": "No se pudo establecer el fork nativo: {{error}}. Vuelve a intentarlo para completar el fork.", + "git.fork.establishIncomplete": "El establecimiento del fork nativo devolvió un resultado incompleto. Vuelve a intentarlo para completar el fork.", + "git.fork.establishing": "Esta sesión está estableciendo su fork de conversación aislado; inténtalo de nuevo en breve.", + "git.fork.gitState.includedIgnored": "{{count}} archivo(s) ignorado(s) incluido(s)", + "git.fork.gitState.staged": "{{count}} en staging", + "git.fork.gitState.unstaged": "{{count}} fuera de staging", + "git.fork.gitState.untracked": "{{count}} sin seguimiento", + "git.fork.gitStateLabel": "Estado de Git", + "git.fork.ignoredPolicy": "Archivos ignorados", + "git.fork.ignoredPolicyDetail": "Solo {{count}} archivo(s) ignorado(s) listado(s) en .worktreeinclude se copian al fork", + "git.fork.nameLabel": "Nombre del worktree", + "git.fork.namePlaceholder": "auth-refresh", + "git.fork.nonHeadDisabled": "Los forks aislados solo están disponibles al final de la conversación.", + "git.fork.occupied": "ocupado", + "git.fork.ownersLabel": "Propietarios de la ruta", + "git.fork.pendingChild": "Esta sesión es un fork de conversación aislado pendiente que aún no se ha establecido.", + "git.fork.pendingFence": "Un fork de conversación está pendiente o requiere recuperación.", + "git.fork.previewing": "Preparando vista previa…", + "git.fork.provider": "Proveedor", + "git.fork.providerPending": "Pendiente", + "git.fork.providerPendingNote": "La identidad del proveedor está pendiente hasta que el primer mensaje establezca el fork.", + "git.fork.recover": "Recuperar", + "git.fork.recovering": "Recuperando…", + "git.fork.recoveryHint": "Una transacción de fork está pendiente o necesita recuperación para esta sesión.", + "git.fork.recoveryNote": "El fork se interrumpió. Se conserva una instantánea verificada: recupérala para revertirla e inténtalo de nuevo.", + "git.fork.recoveryTitle": "Recuperación requerida", + "git.fork.remoteOwned": "Propiedad del servidor {{serverId}}", + "git.fork.repositoryRoot": "Raíz del repositorio", + "git.fork.retainedSnapshot": "Instantánea {{snapshotId}} conservada", + "git.fork.retry": "Reintentar", + "git.fork.retryTitle": "Falló el establecimiento del fork", + "git.fork.retrying": "Reintentando…", + "git.fork.rolledBackDetail": "El fork interrumpido se revirtió. Puedes previsualizarlo de nuevo.", + "git.fork.server": "Servidor", + "git.fork.sourceCheckout": "Checkout de origen", + "git.fork.sourceLabel": "Origen", + "git.fork.sourceSession": "Sesión de origen", + "git.fork.state.clean": "Limpio", + "git.fork.state.detached": "Desprendido", + "git.fork.state.dirty": "Sucio", + "git.fork.statusActive": "Fork pendiente", + "git.fork.strategy.isolated": "Nuevo worktree aislado", + "git.fork.strategy.isolatedNote": "Crea un worktree administrado, una rama de Git y un runtime dedicados en el final actual.", + "git.fork.strategy.shared": "Worktree compartido", + "git.fork.strategy.sharedNote": "Deriva al worktree administrado de la fuente (comportamiento existente).", + "git.fork.strictAdapterUnavailable": "El adaptador del proveedor no puede establecer un fork nativo estricto entre CWD para esta sesión.", + "git.fork.strictFork": "Fork estricto", + "git.fork.strictForkSupported": "Compatible", + "git.fork.strictForkUnsupported": "No compatible", + "git.fork.title": "Fork de conversación", + "git.fork.unsupportedProviderDisabled": "Este proveedor aún no puede establecer un fork aislado.", "git.github.authRequired": "Inicia sesión en GitHub", "git.github.installRequired": "Instalar GitHub CLI", "git.handoff.action": "Entregar", diff --git a/packages/shared/src/i18n/locales/hu.json b/packages/shared/src/i18n/locales/hu.json index c51c201f..94155040 100644 --- a/packages/shared/src/i18n/locales/hu.json +++ b/packages/shared/src/i18n/locales/hu.json @@ -534,6 +534,72 @@ "git.delete.unpushedWarning_one": "{{count}} nem beküldött commit elveszik", "git.delete.unpushedWarning_other": "{{count}} nem beküldött commit elveszik", "git.delete.worktreeKeptNote": "Alapértelmezés szerint a worktree checkout megmarad.", + "git.fork.anchorMissing": "Az izolált fork-gyermek nem rendelkezik érvényes szolgáltatói horgonnyal (hiányzó szülő SDK-munkamenet, fordulóazonosító vagy idempotencia-kulcs). A forkot újra létre kell hozni.", + "git.fork.blockedTitle": "Fork blokkolva", + "git.fork.cancel": "Mégse", + "git.fork.checkoutPath": "Checkout elérési út", + "git.fork.committedBranch": "Branch: {{branch}}", + "git.fork.committedDetail": "A gyermek-munkamenet mostantól az elkülönített checkoutjában dolgozik.", + "git.fork.committedTitle": "Fork létrehozva", + "git.fork.confirm": "Fork létrehozása", + "git.fork.confirming": "Fork létrehozása…", + "git.fork.conversationHead": "Beszélgetés feje", + "git.fork.cwdInvalid": "Az izolált fork-gyermek nem rendelkezik érvényes végrehajtási vagy átirat-CWD-vel. A forkot újra létre kell hozni.", + "git.fork.description": "A beszélgetés elágaztatása új munkamenetként.", + "git.fork.destinationBranch": "Branch", + "git.fork.destinationLabel": "Cél", + "git.fork.destinationLeases": "Cél-leases", + "git.fork.establishFailed": "A natív fork nem hozható létre: {{error}}. Próbáld újra a fork befejezéséhez.", + "git.fork.establishIncomplete": "A natív fork létrehozása hiányos eredményt adott vissza. Próbáld újra a fork befejezéséhez.", + "git.fork.establishing": "Ez a munkamenet éppen létrehozza az izolált beszélgetés-forkot; próbáld újra rövidesen.", + "git.fork.gitState.includedIgnored": "{{count}} bevont figyelmen kívül hagyott fájl", + "git.fork.gitState.staged": "{{count}} staged", + "git.fork.gitState.unstaged": "{{count}} nem staged", + "git.fork.gitState.untracked": "{{count}} nem követett", + "git.fork.gitStateLabel": "Git-állapot", + "git.fork.ignoredPolicy": "Figyelmen kívül hagyott fájlok", + "git.fork.ignoredPolicyDetail": "Csak {{count}} .worktreeinclude-listában szereplő figyelmen kívül hagyott fájl másolódik a forkba", + "git.fork.nameLabel": "Worktree neve", + "git.fork.namePlaceholder": "auth-refresh", + "git.fork.nonHeadDisabled": "Izolált fork csak a beszélgetés jelenlegi fejénél érhető el.", + "git.fork.occupied": "foglalt", + "git.fork.ownersLabel": "Útvonaltulajdonosok", + "git.fork.pendingChild": "Ez a munkamenet egy függőben lévő izolált beszélgetés-fork, amely még nem lett létrehozva.", + "git.fork.pendingFence": "Egy beszélgetés-fork függőben van, vagy helyreállítást igényel.", + "git.fork.previewing": "Előnézet készítése…", + "git.fork.provider": "Szolgáltató", + "git.fork.providerPending": "Függőben", + "git.fork.providerPendingNote": "A szolgáltatói azonosság függőben van, amíg az első üzenet létre nem hozza a forkot.", + "git.fork.recover": "Helyreállítás", + "git.fork.recovering": "Helyreállítás…", + "git.fork.recoveryHint": "Egy fork-tranzakció függőben van, vagy helyreállítást igényel ehhez a munkamenethez.", + "git.fork.recoveryNote": "A fork megszakadt. Ellenőrzött pillanatkép megőrizve – állítsd vissza a visszagörgetéshez, majd próbáld újra.", + "git.fork.recoveryTitle": "Helyreállítás szükséges", + "git.fork.remoteOwned": "A(z) {{serverId}} szerver tulajdona", + "git.fork.repositoryRoot": "Adattár gyökér", + "git.fork.retainedSnapshot": "{{snapshotId}} pillanatkép megőrizve", + "git.fork.retry": "Újra", + "git.fork.retryTitle": "A fork létrehozása nem sikerült", + "git.fork.retrying": "Újrapróbálkozás…", + "git.fork.rolledBackDetail": "A megszakított fork vissza lett görgetve. Újra megtekintheted az előnézetét.", + "git.fork.server": "Szerver", + "git.fork.sourceCheckout": "Forrás-checkout", + "git.fork.sourceLabel": "Forrás", + "git.fork.sourceSession": "Forrás-munkamenet", + "git.fork.state.clean": "Tiszta", + "git.fork.state.detached": "Leválasztva", + "git.fork.state.dirty": "Módosított", + "git.fork.statusActive": "Fork függőben", + "git.fork.strategy.isolated": "Új izolált worktree", + "git.fork.strategy.isolatedNote": "Dedikált kezelt worktree, Git-branch és futási környezet létrehozása a jelenlegi fejnél.", + "git.fork.strategy.shared": "Megosztott worktree", + "git.fork.strategy.sharedNote": "Elágazás a forrás kezelt worktree-jébe (meglévő viselkedés).", + "git.fork.strictAdapterUnavailable": "A szolgáltatói adapter nem tud szigorú cross-CWD natív forkot létrehozni ehhez a munkamenethez.", + "git.fork.strictFork": "Szigorú fork", + "git.fork.strictForkSupported": "Támogatott", + "git.fork.strictForkUnsupported": "Nem támogatott", + "git.fork.title": "Beszélgetés forkja", + "git.fork.unsupportedProviderDisabled": "Ez a szolgáltató még nem tud izolált forkot létrehozni.", "git.github.authRequired": "Bejelentkezés a GitHubra", "git.github.installRequired": "GitHub CLI telepítése", "git.handoff.action": "Átadás", diff --git a/packages/shared/src/i18n/locales/ja.json b/packages/shared/src/i18n/locales/ja.json index c14201bc..3d1814ad 100644 --- a/packages/shared/src/i18n/locales/ja.json +++ b/packages/shared/src/i18n/locales/ja.json @@ -534,6 +534,72 @@ "git.delete.unpushedWarning_one": "未プッシュのコミット {{count}} 件が破棄されます", "git.delete.unpushedWarning_other": "未プッシュのコミット {{count}} 件が破棄されます", "git.delete.worktreeKeptNote": "既定ではワークツリーのチェックアウトは保持されます。", + "git.fork.anchorMissing": "隔離フォークの子には有効なプロバイダーアンカーがありません(親SDKセッション、ターンID、または冪等キーがありません)。フォークを再作成する必要があります。", + "git.fork.blockedTitle": "フォークはブロックされました", + "git.fork.cancel": "キャンセル", + "git.fork.checkoutPath": "チェックアウトパス", + "git.fork.committedBranch": "ブランチ {{branch}}", + "git.fork.committedDetail": "子セッションは独自の隔離チェックアウトで作業します。", + "git.fork.committedTitle": "フォークを作成しました", + "git.fork.confirm": "フォークを作成", + "git.fork.confirming": "フォークを作成中…", + "git.fork.conversationHead": "会話の先頭", + "git.fork.cwdInvalid": "隔離フォークの子には有効な実行CWDまたはトランスクリプトCWDがありません。フォークを再作成する必要があります。", + "git.fork.description": "この会話を新しいセッションとしてフォークします。", + "git.fork.destinationBranch": "ブランチ", + "git.fork.destinationLabel": "宛先", + "git.fork.destinationLeases": "宛先のリース", + "git.fork.establishFailed": "ネイティブフォークを確立できませんでした: {{error}}。フォークを完了するには再試行してください。", + "git.fork.establishIncomplete": "ネイティブフォークの確立が不完全な結果を返しました。フォークを完了するには再試行してください。", + "git.fork.establishing": "このセッションは隔離された会話フォークを確立中です。しばらくしてから再試行してください。", + "git.fork.gitState.includedIgnored": "{{count}} 件の含まれる無視ファイル", + "git.fork.gitState.staged": "{{count}} 件ステージ済み", + "git.fork.gitState.unstaged": "{{count}} 件未ステージ", + "git.fork.gitState.untracked": "{{count}} 件未追跡", + "git.fork.gitStateLabel": "Git状態", + "git.fork.ignoredPolicy": "無視ファイル", + "git.fork.ignoredPolicyDetail": ".worktreeinclude に列挙された {{count}} 件の無視ファイルだけがフォークにコピーされます", + "git.fork.nameLabel": "ワークツリー名", + "git.fork.namePlaceholder": "auth-refresh", + "git.fork.nonHeadDisabled": "隔離フォークは現在の会話の先頭でのみ利用できます。", + "git.fork.occupied": "使用中", + "git.fork.ownersLabel": "パス所有者", + "git.fork.pendingChild": "このセッションはまだ確立されていない保留中の隔離された会話フォークです。", + "git.fork.pendingFence": "会話フォークが保留中またはリカバリが必要です。", + "git.fork.previewing": "プレビューを準備中…", + "git.fork.provider": "プロバイダー", + "git.fork.providerPending": "保留中", + "git.fork.providerPendingNote": "最初のメッセージでフォークが確立されるまで、プロバイダーIDは保留中です。", + "git.fork.recover": "リカバリ", + "git.fork.recovering": "リカバリ中…", + "git.fork.recoveryHint": "このセッションではフォークトランザクションが保留中またはリカバリが必要です。", + "git.fork.recoveryNote": "フォークが中断されました。検証済みスナップショットが保持されています。ロールバックして再試行するにはリカバリしてください。", + "git.fork.recoveryTitle": "リカバリが必要です", + "git.fork.remoteOwned": "サーバー {{serverId}} が所有", + "git.fork.repositoryRoot": "リポジトリルート", + "git.fork.retainedSnapshot": "スナップショット {{snapshotId}} を保持", + "git.fork.retry": "再試行", + "git.fork.retryTitle": "フォークの確立に失敗しました", + "git.fork.retrying": "再試行中…", + "git.fork.rolledBackDetail": "中断されたフォークはロールバックされました。再度プレビューできます。", + "git.fork.server": "サーバー", + "git.fork.sourceCheckout": "ソースチェックアウト", + "git.fork.sourceLabel": "ソース", + "git.fork.sourceSession": "元セッション", + "git.fork.state.clean": "クリーン", + "git.fork.state.detached": "デタッチ", + "git.fork.state.dirty": "ダーティ", + "git.fork.statusActive": "フォーク保留中", + "git.fork.strategy.isolated": "新しい隔離ワークツリー", + "git.fork.strategy.isolatedNote": "現在の先頭に専用の管理ワークツリー、Gitブランチ、ランタイムを作成します。", + "git.fork.strategy.shared": "共有ワークツリー", + "git.fork.strategy.sharedNote": "ソースの管理ワークツリーにフォークします(既存の動作)。", + "git.fork.strictAdapterUnavailable": "プロバイダーアダプターはこのセッションで厳密なクロスCWDネイティブフォークを確立できません。", + "git.fork.strictFork": "厳格フォーク", + "git.fork.strictForkSupported": "対応", + "git.fork.strictForkUnsupported": "非対応", + "git.fork.title": "会話をフォーク", + "git.fork.unsupportedProviderDisabled": "このプロバイダーはまだ隔離フォークを確立できません。", "git.github.authRequired": "GitHub にサインイン", "git.github.installRequired": "GitHub CLI をインストール", "git.handoff.action": "引き継ぐ", diff --git a/packages/shared/src/i18n/locales/pl.json b/packages/shared/src/i18n/locales/pl.json index d002f86e..c57caf46 100644 --- a/packages/shared/src/i18n/locales/pl.json +++ b/packages/shared/src/i18n/locales/pl.json @@ -540,6 +540,72 @@ "git.delete.unpushedWarning_one": "{{count}} niewysłany commit zostanie odrzucony", "git.delete.unpushedWarning_other": "{{count}} niewysłanych commitów zostanie odrzuconych", "git.delete.worktreeKeptNote": "Domyślnie checkout worktree jest zachowywany.", + "git.fork.anchorMissing": "Izolowany fork podrzędny nie ma prawidłowego zakotwiczenia dostawcy (brak nadrzędnej sesji SDK, identyfikatora tury lub klucza idempotencji). Fork musi zostać utworzony ponownie.", + "git.fork.blockedTitle": "Fork zablokowany", + "git.fork.cancel": "Anuluj", + "git.fork.checkoutPath": "Ścieżka checkout", + "git.fork.committedBranch": "Gałąź {{branch}}", + "git.fork.committedDetail": "Sesja podrzędna działa teraz we własnym izolowanym checkout.", + "git.fork.committedTitle": "Utworzono fork", + "git.fork.confirm": "Utwórz fork", + "git.fork.confirming": "Tworzenie forka…", + "git.fork.conversationHead": "Głowa rozmowy", + "git.fork.cwdInvalid": "Izolowany fork podrzędny nie ma prawidłowego katalogu roboczego wykonania lub transkrypcji. Fork musi zostać utworzony ponownie.", + "git.fork.description": "Rozgałęź tę rozmowę jako nową sesję.", + "git.fork.destinationBranch": "Gałąź", + "git.fork.destinationLabel": "Cel", + "git.fork.destinationLeases": "Dzierżawy celu", + "git.fork.establishFailed": "Nie udało się ustanowić natywnego forka: {{error}}. Ponów próbę, aby ukończyć forka.", + "git.fork.establishIncomplete": "Ustanawianie natywnego forka zwróciło niekompletny wynik. Ponów próbę, aby ukończyć forka.", + "git.fork.establishing": "Ta sesja ustanawia swój izolowany fork rozmowy; spróbuj ponownie za chwilę.", + "git.fork.gitState.includedIgnored": "{{count}} uwzględnionych ignorowanych plików", + "git.fork.gitState.staged": "{{count}} w indeksie", + "git.fork.gitState.unstaged": "{{count}} poza indeksem", + "git.fork.gitState.untracked": "{{count}} nieśledzonych", + "git.fork.gitStateLabel": "Stan Git", + "git.fork.ignoredPolicy": "Ignorowane pliki", + "git.fork.ignoredPolicyDetail": "Tylko {{count}} ignorowanych plików z listy .worktreeinclude kopiuje się do forka", + "git.fork.nameLabel": "Nazwa worktree", + "git.fork.namePlaceholder": "auth-refresh", + "git.fork.nonHeadDisabled": "Izolowane forki są dostępne tylko w bieżącej głowie rozmowy.", + "git.fork.occupied": "zajęte", + "git.fork.ownersLabel": "Właściciele ścieżki", + "git.fork.pendingChild": "Ta sesja to oczekujący izolowany fork rozmowy, który nie został jeszcze ustanowiony.", + "git.fork.pendingFence": "Fork rozmowy jest w toku lub wymaga odzyskania.", + "git.fork.previewing": "Przygotowywanie podglądu…", + "git.fork.provider": "Dostawca", + "git.fork.providerPending": "Oczekujące", + "git.fork.providerPendingNote": "Tożsamość dostawcy oczekuje, aż pierwsza wiadomość utworzy forka.", + "git.fork.recover": "Odzyskaj", + "git.fork.recovering": "Odzyskiwanie…", + "git.fork.recoveryHint": "Transakcja forka jest w toku lub wymaga odzyskania dla tej sesji.", + "git.fork.recoveryNote": "Fork został przerwany. Zachowano zweryfikowany snapshot — odzyskaj go, aby cofnąć i spróbować ponownie.", + "git.fork.recoveryTitle": "Wymagane odzyskanie", + "git.fork.remoteOwned": "Należący do serwera {{serverId}}", + "git.fork.repositoryRoot": "Katalog główny repozytorium", + "git.fork.retainedSnapshot": "Zachowano snapshot {{snapshotId}}", + "git.fork.retry": "Ponów", + "git.fork.retryTitle": "Nie udało się utworzyć forka", + "git.fork.retrying": "Ponawianie…", + "git.fork.rolledBackDetail": "Przerwany fork został cofnięty. Możesz ponownie wyświetlić podgląd.", + "git.fork.server": "Serwer", + "git.fork.sourceCheckout": "Checkout źródłowy", + "git.fork.sourceLabel": "Źródło", + "git.fork.sourceSession": "Sesja źródłowa", + "git.fork.state.clean": "Czysty", + "git.fork.state.detached": "Odłączony", + "git.fork.state.dirty": "Brudny", + "git.fork.statusActive": "Fork oczekujący", + "git.fork.strategy.isolated": "Nowy izolowany worktree", + "git.fork.strategy.isolatedNote": "Utwórz dedykowany zarządzany worktree, gałąź Git i środowisko uruchomieniowe w bieżącej głowie.", + "git.fork.strategy.shared": "Współdzielony worktree", + "git.fork.strategy.sharedNote": "Rozgałęź do zarządzanego worktree źródła (istniejące zachowanie).", + "git.fork.strictAdapterUnavailable": "Adapter dostawcy nie może ustanowić ścisłego natywnego forka między katalogami roboczymi dla tej sesji.", + "git.fork.strictFork": "Ścisły fork", + "git.fork.strictForkSupported": "Obsługiwany", + "git.fork.strictForkUnsupported": "Nieobsługiwany", + "git.fork.title": "Fork rozmowy", + "git.fork.unsupportedProviderDisabled": "Ten dostawca nie może jeszcze utworzyć izolowanego forka.", "git.github.authRequired": "Zaloguj się do GitHub", "git.github.installRequired": "Zainstaluj GitHub CLI", "git.handoff.action": "Przekaż", diff --git a/packages/shared/src/i18n/locales/zh-Hans.json b/packages/shared/src/i18n/locales/zh-Hans.json index a6b27267..88152d99 100644 --- a/packages/shared/src/i18n/locales/zh-Hans.json +++ b/packages/shared/src/i18n/locales/zh-Hans.json @@ -534,6 +534,72 @@ "git.delete.unpushedWarning_one": "将丢弃 {{count}} 个未推送的提交", "git.delete.unpushedWarning_other": "将丢弃 {{count}} 个未推送的提交", "git.delete.worktreeKeptNote": "默认情况下会保留工作区检出。", + "git.fork.anchorMissing": "隔离派生的子会话没有有效的提供商锚点(缺少父SDK会话、轮次ID或幂等键)。必须重新创建派生。", + "git.fork.blockedTitle": "派生创建被阻止", + "git.fork.cancel": "取消", + "git.fork.checkoutPath": "检出路径", + "git.fork.committedBranch": "分支 {{branch}}", + "git.fork.committedDetail": "子会话现在在其隔离检出中工作。", + "git.fork.committedTitle": "已创建派生", + "git.fork.confirm": "创建派生", + "git.fork.confirming": "正在创建派生…", + "git.fork.conversationHead": "对话头部", + "git.fork.cwdInvalid": "隔离派生的子会话没有有效的执行或记录工作目录。必须重新创建派生。", + "git.fork.description": "将此对话派生为新会话。", + "git.fork.destinationBranch": "分支", + "git.fork.destinationLabel": "目标", + "git.fork.destinationLeases": "目标租约", + "git.fork.establishFailed": "无法建立原生派生:{{error}}。请重试以完成派生。", + "git.fork.establishIncomplete": "原生派生的建立返回了不完整的结果。请重试以完成派生。", + "git.fork.establishing": "此会话正在建立其隔离的对话派生;请稍后重试。", + "git.fork.gitState.includedIgnored": "{{count}} 个包含的忽略文件", + "git.fork.gitState.staged": "{{count}} 个已暂存", + "git.fork.gitState.unstaged": "{{count}} 个未暂存", + "git.fork.gitState.untracked": "{{count}} 个未跟踪", + "git.fork.gitStateLabel": "Git 状态", + "git.fork.ignoredPolicy": "忽略的文件", + "git.fork.ignoredPolicyDetail": "仅 {{count}} 个 .worktreeinclude 列出的忽略文件会复制到派生", + "git.fork.nameLabel": "工作树名称", + "git.fork.namePlaceholder": "auth-refresh", + "git.fork.nonHeadDisabled": "隔离派生仅可在当前对话头部使用。", + "git.fork.occupied": "已占用", + "git.fork.ownersLabel": "路径所有者", + "git.fork.pendingChild": "此会话是尚未建立的待定隔离对话派生。", + "git.fork.pendingFence": "对话派生正在等待或需要恢复。", + "git.fork.previewing": "正在准备预览…", + "git.fork.provider": "提供商", + "git.fork.providerPending": "待定", + "git.fork.providerPendingNote": "在第一条消息建立派生前,提供商身份处于待定状态。", + "git.fork.recover": "恢复", + "git.fork.recovering": "正在恢复…", + "git.fork.recoveryHint": "此会话的派生事务正在等待或需要恢复。", + "git.fork.recoveryNote": "派生被中断。已保留经过验证的快照 — 恢复以回滚并重试。", + "git.fork.recoveryTitle": "需要恢复", + "git.fork.remoteOwned": "由服务器 {{serverId}} 拥有", + "git.fork.repositoryRoot": "仓库根目录", + "git.fork.retainedSnapshot": "已保留快照 {{snapshotId}}", + "git.fork.retry": "重试", + "git.fork.retryTitle": "派生建立失败", + "git.fork.retrying": "正在重试…", + "git.fork.rolledBackDetail": "已回滚被中断的派生。您可以再次预览。", + "git.fork.server": "服务器", + "git.fork.sourceCheckout": "源检出", + "git.fork.sourceLabel": "源", + "git.fork.sourceSession": "源会话", + "git.fork.state.clean": "干净", + "git.fork.state.detached": "分离", + "git.fork.state.dirty": "脏", + "git.fork.statusActive": "派生待定", + "git.fork.strategy.isolated": "新建隔离工作树", + "git.fork.strategy.isolatedNote": "在当前头部创建专用的管理工作树、Git 分支和运行时。", + "git.fork.strategy.shared": "共享工作树", + "git.fork.strategy.sharedNote": "派生到源的管理工作树(现有行为)。", + "git.fork.strictAdapterUnavailable": "提供商适配器无法为此会话建立严格的跨工作目录原生派生。", + "git.fork.strictFork": "严格派生", + "git.fork.strictForkSupported": "支持", + "git.fork.strictForkUnsupported": "不支持", + "git.fork.title": "派生对话", + "git.fork.unsupportedProviderDisabled": "此提供商尚无法建立隔离派生。", "git.github.authRequired": "登录 GitHub", "git.github.installRequired": "安装 GitHub CLI", "git.handoff.action": "交接", diff --git a/packages/shared/src/protocol/__tests__/conversation-fork-contracts.test.ts b/packages/shared/src/protocol/__tests__/conversation-fork-contracts.test.ts new file mode 100644 index 00000000..b006f7a8 --- /dev/null +++ b/packages/shared/src/protocol/__tests__/conversation-fork-contracts.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from 'bun:test' +import { + CONVERSATION_FORK_BLOCKER_CODES, + CONVERSATION_FORK_RECOVERY_STATES, + CONVERSATION_FORK_STRATEGIES, + WORKTREE_FORK_BLOCKED_CODE, + WORKTREE_FORK_ERROR_CODE, + WORKTREE_FORK_PENDING_CODE, + WORKTREE_FORK_PREVIEW_STALE_CODE, + RPC_CHANNELS, + type ConversationForkBlockerCode, + type ConversationForkConfirmInput, + type ConversationForkPendingIntent, + type ConversationForkPreview, + type ConversationForkPreviewInput, + type ConversationForkProviderCapability, + type ConversationForkRecoveryState, + type ConversationForkResult, + type ConversationForkStatus, +} from '../index' +import { isErrorCode } from '../types' + +describe('Conversation fork protocol contracts', () => { + it('defines exactly the two conversation fork strategies with shared-worktree as the default', () => { + expect(CONVERSATION_FORK_STRATEGIES).toHaveLength(2) + expect(new Set(CONVERSATION_FORK_STRATEGIES).size).toBe(2) + // Wire values are part of the RPC contract with the renderer and server; + // pin them so a rename breaks the contract test. + expect([...CONVERSATION_FORK_STRATEGIES].sort()).toEqual([ + 'isolated-worktree', + 'shared-worktree', + ]) + // Shared remains the default; isolated is the new explicit alternative. + expect(CONVERSATION_FORK_STRATEGIES[0]).toBe('shared-worktree') + }) + + it('keeps the provider capability DTO free of paths, payloads, and transcript identity', () => { + const capability: ConversationForkProviderCapability = { + adapterId: 'pi', + strictCrossCwdNativeFork: true, + } + expect(Object.keys(capability).sort()).toEqual([ + 'adapterId', + 'strictCrossCwdNativeFork', + ]) + // An adapter that cannot separate transcript storage from execution or + // cannot prove destination-only tool CWD must not advertise the capability. + const incapable: ConversationForkProviderCapability = { + adapterId: 'anthropic', + strictCrossCwdNativeFork: false, + } + expect(incapable.strictCrossCwdNativeFork).toBe(false) + }) + + it('models the pending provider-fork intent without any child provider ID claim', () => { + // Before first Send, the child stores a pending intent. It carries strict + // parent identity + immutable transcript lookup identity + destination + // execution CWD + an idempotency key — and structurally CANNOT carry a + // child provider ID, because the provider has not created one yet. + const pending: ConversationForkPendingIntent = { + parentSessionId: 'session-1', + parentSdkSessionId: 'sdk-parent-1', + parentSdkTurnId: 'turn-42', + parentMessageId: 'msg-42', + transcriptCwd: '/repo/.kata/sessions/session-1', + executionCwd: '/srv/kata/worktrees/repo/ab12cd34', + idempotencyKey: 'fork-txn-abc-step-4', + } + expect('childProviderId' in pending).toBe(false) + expect('childSdkSessionId' in pending).toBe(false) + // Immutable transcript identity and mutable execution CWD stay distinct. + expect(pending.transcriptCwd).not.toBe(pending.executionCwd) + }) + + it('accepts fork preview/confirm inputs by session, strategy, and name only — never paths', () => { + const previewInput: ConversationForkPreviewInput = { + sessionId: 'session-1', + strategy: 'isolated-worktree', + worktreeNameSuffix: 'feature-x', + } + expect('path' in previewInput).toBe(false) + expect('checkoutPath' in previewInput).toBe(false) + expect('repositoryRoot' in previewInput).toBe(false) + + const confirmInput: ConversationForkConfirmInput = { + sessionId: 'session-1', + strategy: 'isolated-worktree', + transactionId: 'txn-abc', + previewFingerprint: 'fp-123', + worktreeNameSuffix: 'feature-x', + } + // Structural guarantee: no client-nominated path component may be added. + const allowedKeys = new Set([ + 'sessionId', + 'strategy', + 'transactionId', + 'previewFingerprint', + 'worktreeNameSuffix', + ]) + for (const key of Object.keys(confirmInput)) { + expect(allowedKeys.has(key)).toBe(true) + } + }) + + it('sanitizes previews: source/destination summaries never carry snapshot payload bytes', () => { + const preview: ConversationForkPreview = { + transactionId: 'txn-abc', + previewFingerprint: 'fp-123', + strategy: 'isolated-worktree', + providerCapability: { adapterId: 'pi', strictCrossCwdNativeFork: true }, + source: { + serverId: 'server-a', + sessionId: 'session-1', + conversationHeadMessageId: 'msg-42', + conversationHeadTurnId: 'turn-42', + checkout: { mode: 'managed-worktree', managedWorktreeId: 'repo-ab12cd34' }, + branch: 'kata-agent/ab12cd34', + headSha: 'deadbeef', + gitState: { + state: 'clean', + stagedFileCount: 0, + unstagedFileCount: 0, + untrackedFileCount: 0, + includedIgnoredFileCount: 0, + }, + leases: [], + }, + destination: { + serverId: 'server-a', + repositoryRoot: '/repo', + branch: 'kata-agent/feature-x', + checkoutPath: '/srv/kata/worktrees/repo/feature-x', + exists: false, + leases: [], + }, + excludedIgnoredPolicy: { includeOnly: true, includeFileCount: 1 }, + currentHead: true, + } + // No snapshot payload, manifest, or file bytes cross into the renderer. + expect('payloadPath' in preview).toBe(false) + expect('manifestHash' in preview).toBe(false) + expect('snapshotBytes' in preview).toBe(false) + expect('totalBytes' in preview).toBe(false) + expect(preview.currentHead).toBe(true) + expect(preview.destination.branch).toBe('kata-agent/feature-x') + }) + + it('models every documented blocker code and typed wire errors', () => { + // Each tuple is the single source of truth for its union: a code added to + // the type must be listed here, and removing one breaks the length gate. + const allCodes: readonly ConversationForkBlockerCode[] = CONVERSATION_FORK_BLOCKER_CODES + expect(allCodes).toHaveLength(15) + expect(new Set(allCodes).size).toBe(allCodes.length) + const required: ConversationForkBlockerCode[] = [ + 'unsupported-provider', + 'non-head-source', + 'source-active', + 'path-unleased', + 'name-collision', + 'identity-drift', + 'missing-source', + 'unsupported-snapshot', + 'oversized-capture', + 'git-operation-in-progress', + 'cleanup-in-progress', + 'flags-disabled', + 'invalid-name', + 'fork-in-progress', + 'missing-parent-anchor', + ] + for (const code of required) { + expect(allCodes).toContain(code) + } + expect(isErrorCode(WORKTREE_FORK_ERROR_CODE)).toBe(true) + expect(isErrorCode(WORKTREE_FORK_BLOCKED_CODE)).toBe(true) + expect(isErrorCode(WORKTREE_FORK_PREVIEW_STALE_CODE)).toBe(true) + expect(isErrorCode(WORKTREE_FORK_PENDING_CODE)).toBe(true) + }) + + it('discriminates committed, blocked, and recovery-required results', () => { + const committed: ConversationForkResult = { + outcome: 'committed', + transactionId: 'txn-abc', + summary: { + sessionId: 'session-child', + strategy: 'isolated-worktree', + checkout: { + schemaVersion: 2, + mode: 'managed-worktree', + repositoryRoot: '/repo', + checkoutPath: '/srv/kata/worktrees/repo/feature-x', + branchAtPreparation: 'kata-agent/feature-x', + baseRef: 'kata-agent/ab12cd34', + displayName: 'feature-x', + managedWorktreeId: 'repo-feature-x', + expectedBranch: 'kata-agent/feature-x', + materializationRoot: '/srv/kata/worktrees', + }, + executionCwd: '/srv/kata/worktrees/repo/feature-x', + transcriptCwd: '/repo/.kata/sessions/session-child', + childProviderIdPresent: false, + committedAt: 1, + }, + } + const blocked: ConversationForkResult = { + outcome: 'blocked', + transactionId: 'txn-abc', + code: 'unsupported-provider', + reason: 'The provider cannot establish a strict cross-CWD native fork.', + } + const recovery: ConversationForkResult = { + outcome: 'recovery-required', + transactionId: 'txn-abc', + recovery: 'target-materialized', + reason: 'Seed verification failed after target materialization.', + } + + expect(committed.outcome).toBe('committed') + if (committed.outcome === 'committed') { + // At the durable commit point (child visible), the child provider ID is + // still pending — never claimed before the provider creates it. + expect(committed.summary.childProviderIdPresent).toBe(false) + expect(committed.summary.executionCwd).not.toBe(committed.summary.transcriptCwd) + } + expect(blocked.outcome).toBe('blocked') + if (blocked.outcome === 'blocked') { + expect(blocked.code).toBe('unsupported-provider') + } + expect(recovery.outcome).toBe('recovery-required') + if (recovery.outcome === 'recovery-required') { + expect(recovery.recovery).toBe('target-materialized') + } + }) + + it('reports fork status with the child provider identity pending before first Send', () => { + const idle: ConversationForkStatus = { active: false } + const active: ConversationForkStatus = { + active: true, + transactionId: 'txn-abc', + strategy: 'isolated-worktree', + state: 'published', + since: 1, + providerIdentity: { status: 'pending' }, + } + expect(idle.active).toBe(false) + if (active.active) { + expect(active.strategy).toBe('isolated-worktree') + expect(active.state).toBe('published') + // Before first Send the surface displays provider identity as pending + // rather than claiming a child provider ID. + expect(active.providerIdentity).toEqual({ status: 'pending' }) + expect('childSdkSessionId' in active.providerIdentity).toBe(false) + } + }) + + it('exposes the persisted child provider ID only after first-Send establishment', () => { + const established: ConversationForkStatus = { + active: true, + transactionId: 'txn-abc', + strategy: 'isolated-worktree', + state: 'established', + since: 2, + providerIdentity: { status: 'established', childSdkSessionId: 'sdk-child-1' }, + } + if (established.active) { + expect(established.state).toBe('established') + expect(established.providerIdentity.status).toBe('established') + if (established.providerIdentity.status === 'established') { + expect(established.providerIdentity.childSdkSessionId).toBe('sdk-child-1') + } + } + }) + + it('covers the durable transaction steps in the recovery state union', () => { + // The tuple is the single source of truth for the union. + const states: readonly ConversationForkRecoveryState[] = CONVERSATION_FORK_RECOVERY_STATES + // Pin the length so adding a state is a deliberate contract change. + expect(states).toHaveLength(13) + for (const state of states) { + expect(state).toBeTruthy() + } + }) + + it('registers fork RPC channels under the git namespace', () => { + expect(RPC_CHANNELS.git.FORK_PREVIEW).toBe('git:forkPreview') + expect(RPC_CHANNELS.git.FORK_CONFIRM).toBe('git:forkConfirm') + expect(RPC_CHANNELS.git.FORK_STATUS).toBe('git:forkStatus') + expect(RPC_CHANNELS.git.FORK_RECOVER).toBe('git:forkRecover') + expect(RPC_CHANNELS.git.FORK_CANCEL).toBe('git:forkCancel') + }) +}) diff --git a/packages/shared/src/protocol/channels.ts b/packages/shared/src/protocol/channels.ts index 58e715c4..a7588485 100644 --- a/packages/shared/src/protocol/channels.ts +++ b/packages/shared/src/protocol/channels.ts @@ -382,6 +382,12 @@ export const RPC_CHANNELS = { HANDOFF_STATUS: 'git:handoffStatus', HANDOFF_RECOVER: 'git:handoffRecover', HANDOFF_CANCEL: 'git:handoffCancel', + // Phase 4: isolated conversation forks + FORK_PREVIEW: 'git:forkPreview', + FORK_CONFIRM: 'git:forkConfirm', + FORK_STATUS: 'git:forkStatus', + FORK_RECOVER: 'git:forkRecover', + FORK_CANCEL: 'git:forkCancel', }, gitbash: { CHECK: 'gitbash:check', diff --git a/packages/shared/src/protocol/conversation-fork.ts b/packages/shared/src/protocol/conversation-fork.ts new file mode 100644 index 00000000..a46070ad --- /dev/null +++ b/packages/shared/src/protocol/conversation-fork.ts @@ -0,0 +1,370 @@ +/** + * Worktree V2 Phase 4: isolated conversation forks protocol DTOs. + * + * Conversation branching keeps the existing **Shared worktree** behavior + * (a child session shares the parent's managed worktree, preserving #33) and + * adds an explicit **New isolated worktree** alternative. Isolated forks + * create a separately named managed worktree, Git branch, Kata session, and + * execution runtime at the source conversation's current head while leaving + * the source conversation and checkout unchanged. + * + * Isolated is offered only when Worktree V2 is effective, the source session + * is idle at its current conversation head, Git state is supported, and the + * provider adapter advertises strict safe cross-CWD native fork. The target + * branch/worktree/session is prepared and durably committed BEFORE the child + * is visible; the child stores a *pending* provider-fork intent and does not + * claim a child provider ID until the provider creates one on the first Send. + * + * Clients submit a server-issued opaque `transactionId` plus the exact + * `previewFingerprint` they were shown — never paths. The server owns every + * mutation. Unsupported providers receive a typed blocker with no fallback; + * the existing missing-anchor/full-history fallback is bypassed for the + * isolated strategy. + */ + +import type { SessionCheckout } from './git' + +// Typed wire errors for fork RPCs (canonical definitions in ./types). +export { + WORKTREE_FORK_ERROR_CODE, + WORKTREE_FORK_BLOCKED_CODE, + WORKTREE_FORK_PREVIEW_STALE_CODE, + WORKTREE_FORK_PENDING_CODE, +} from './types' + +// --------------------------------------------------------------------------- +// Strategy and provider capability +// --------------------------------------------------------------------------- + +/** + * The two conversation-fork strategies. `shared-worktree` remains the default + * (the pre-existing branch behavior sharing the source managed worktree); + * `isolated-worktree` is the new explicit alternative added by Worktree V2 + * Phase 4. The order pins `shared-worktree` as the default choice. + */ +export const CONVERSATION_FORK_STRATEGIES = ['shared-worktree', 'isolated-worktree'] as const + +/** A supported conversation-fork strategy. */ +export type ConversationForkStrategy = (typeof CONVERSATION_FORK_STRATEGIES)[number] + +/** + * Sanitized provider capability DTO. Never carries secrets, paths beyond the + * server-owned ones in the preview, or transcript/session identity details. + */ +export interface ConversationForkProviderCapability { + /** Stable adapter identity, e.g. `pi`. */ + adapterId: string + /** + * True only when the adapter can establish a provider-native fork at the + * recorded source conversation head while guaranteeing every file, shell, + * MCP, and provider tool executes in the destination execution CWD and the + * immutable transcript lookup identity (transcript CWD) is preserved. An + * adapter that cannot separate transcript storage from execution (e.g. + * Claude's current use of `sdkCwd` for both) or cannot prove destination + * tool CWD must advertise `false` and remain typed-blocked. + */ + strictCrossCwdNativeFork: boolean +} + +// --------------------------------------------------------------------------- +// Blockers +// --------------------------------------------------------------------------- + +/** + * Typed conversation-fork blockers: every entry corresponds to a precondition + * the server checks before any mutation, and a blocked fork claims no + * mutation. Unlike handoff there is no separate outcome-code set: post-/ + * mid-recovery outcomes surface through `recovery-required` results and the + * `ConversationForkRecoveryState` union, never through this blocker tuple. + * Each tuple is the single source of truth: its union is derived from it so + * a code can never be added to the type without being listed here. + */ +export const CONVERSATION_FORK_BLOCKER_CODES = [ + /** Provider adapter cannot establish a strict cross-CWD native fork. */ + 'unsupported-provider', + /** Forking from an older conversation point; isolated requires current head. */ + 'non-head-source', + /** Source has an active turn or an unquiesceable runtime. */ + 'source-active', + /** A source path owner/lease cannot be established (stable lease + fingerprint). */ + 'path-unleased', + /** The requested `kata-agent/` branch or display name already exists. */ + 'name-collision', + /** Conversation head or Git fingerprint changed between preview and confirm. */ + 'identity-drift', + /** Source session or checkout is missing/snapshotted; restore is required. */ + 'missing-source', + /** The snapshot service cannot capture the supported source state. */ + 'unsupported-snapshot', + /** Captured seed exceeds Phase 2 snapshot limits (10,000 files / 100 MiB). */ + 'oversized-capture', + /** A Git operation is in progress or the index is unmerged. */ + 'git-operation-in-progress', + /** Lifecycle cleanup is in progress for either path. */ + 'cleanup-in-progress', + /** Required feature flags are disabled (Worktree V2 not effective). */ + 'flags-disabled', + /** The requested generated/display name is not a valid branch suffix. */ + 'invalid-name', + /** A pending/recovery fork transaction exists for the source or target. */ + 'fork-in-progress', + /** The source provider session or conversation-turn anchor is missing. */ + 'missing-parent-anchor', +] as const + +/** A typed conversation-fork blocker code (precondition checks only). */ +export type ConversationForkBlockerCode = (typeof CONVERSATION_FORK_BLOCKER_CODES)[number] + +/** Typed blocker payload carried by previews and confirm results. */ +export interface ConversationForkBlocked { + blocked: true + code: ConversationForkBlockerCode + /** Sanitized, non-localized server detail retained for diagnostics. */ + reason: string +} + +// --------------------------------------------------------------------------- +// Pending provider-fork intent +// --------------------------------------------------------------------------- + +/** + * Durable pending provider-fork intent stored by an isolated child before its + * first Send. Carries strict parent conversation/turn identity, the immutable + * transcript lookup identity, the destination execution CWD, and an + * idempotency key — and STRUCTURALLY CANNOT carry a child provider ID: the + * provider has not created one yet. The child provider ID is persisted only + * after the strict adapter establishes the native fork on first Send. + */ +export interface ConversationForkPendingIntent { + /** Source Kata session the fork is created from. */ + parentSessionId: string + /** Parent provider SDK session identity (anchor lineage). */ + parentSdkSessionId: string + /** Parent provider turn anchor at the branch point. */ + parentSdkTurnId: string + /** Source Kata message ID at the branch point (current conversation head). */ + parentMessageId: string + /** Immutable transcript lookup identity — never rewritten by the fork. */ + transcriptCwd: string + /** Destination execution CWD every tool must resolve to. */ + executionCwd: string + /** Idempotency key for the first-Send provider establishment. */ + idempotencyKey: string +} + +// --------------------------------------------------------------------------- +// Preview +// --------------------------------------------------------------------------- + +/** + * Renderer-safe fork preview. The server binds every decision-relevant fact + * (source conversation head, Git state, owners/leases, destination identity, + * provider capability, ignored-file policy) into `previewFingerprint`; + * confirmation revalidates it under lock. Snapshot payload bytes, manifest + * hashes, and file contents never cross into clients. + */ +export interface ConversationForkPreview { + /** Opaque server-issued transaction identity for the confirmation. */ + transactionId: string + /** Exact server-issued fingerprint the confirmation is checked against. */ + previewFingerprint: string + strategy: ConversationForkStrategy + providerCapability: ConversationForkProviderCapability + source: { + /** Server identity that owns the source checkout. */ + serverId: string + /** Source Kata session ID. */ + sessionId: string + /** Source conversation head message ID (current head enforcement). */ + conversationHeadMessageId: string + /** Source conversation head provider turn ID. */ + conversationHeadTurnId: string + /** Source session checkout metadata (current or managed). */ + checkout: Pick & { managedWorktreeId?: string | null } + /** Branch name, or null when detached. */ + branch: string | null + /** HEAD SHA at preview time. */ + headSha: string | null + /** Git-state summary at preview time. */ + gitState: { + /** `clean`, `dirty`, or `detached` at preview time. */ + state: 'clean' | 'dirty' | 'detached' + stagedFileCount: number + unstagedFileCount: number + untrackedFileCount: number + includedIgnoredFileCount: number + } + /** Every path owner / turn blocker the source lease must cover. */ + leases: string[] + } + destination: { + serverId: string + repositoryRoot: string + /** `kata-agent/` branch for the isolated target. */ + branch: string + checkoutPath: string + /** Whether the destination checkout is currently materialized. */ + exists: boolean + /** Live foreign lease owners on the destination path. */ + leases: string[] + } + /** Ignored-file policy: only `.worktreeinclude`-listed files copy. */ + excludedIgnoredPolicy: { + includeOnly: true + includeFileCount: number + } + /** + * Eligibility flag: true only when the source conversation is at its + * current head. Older conversation points cannot select isolated. + */ + currentHead: boolean + /** Present when the preview is blocked; confirmation must not proceed. */ + blocked?: ConversationForkBlocked +} + +// --------------------------------------------------------------------------- +// Confirmation +// --------------------------------------------------------------------------- + +/** Server-resolved preview request; clients nominate a name, never a path. */ +export interface ConversationForkPreviewInput { + sessionId: string + strategy: ConversationForkStrategy + /** Editable suffix for a new managed worktree (isolated only). */ + worktreeNameSuffix?: string +} + +/** Confirmation by transaction ID + preview fingerprint only — never paths. */ +export interface ConversationForkConfirmInput { + sessionId: string + strategy: ConversationForkStrategy + /** Opaque transaction ID issued by the preview. */ + transactionId: string + /** Exact preview fingerprint the user was shown. */ + previewFingerprint: string + /** + * Editable suffix for the new managed worktree. Required when `strategy` is + * `isolated-worktree`; absent for `shared-worktree` (no new worktree name + * exists). The server enforces required-for-isolated. + */ + worktreeNameSuffix?: string +} + +// --------------------------------------------------------------------------- +// Result and recovery +// --------------------------------------------------------------------------- + +/** + * Isolated-fork transaction state. Pending states fence Send, agent creation, + * Git mutations, session deletion, auto-cleanup, and another fork for both + * paths; `recovery-required` states expose the retained snapshot authority. + * `binding-committed` is the durable commit point BEFORE child visibility; + * `published` makes the child visible with pending provider identity; + * `establishing`/`established` cover the first-Send native-fork lifecycle. + */ +export const CONVERSATION_FORK_RECOVERY_STATES = [ + 'pending', + 'source-leased', + 'seed-captured', + 'target-reserved', + 'target-materialized', + 'target-verified', + 'binding-committed', + 'published', + 'establishing', + 'established', + 'restore-failed', + 'cleanup-failed', + 'recovery-required', +] as const + +/** Every durable state an isolated-fork transaction can report. */ +export type ConversationForkRecoveryState = (typeof CONVERSATION_FORK_RECOVERY_STATES)[number] + +/** Durable binding summary recorded at the fork commit point. */ +export interface ConversationForkCommitSummary { + sessionId: string + strategy: ConversationForkStrategy + /** New durable session checkout binding (always a managed worktree). */ + checkout: SessionCheckout + /** Execution CWD the runtime must resolve before Send unlocks. */ + executionCwd: string + /** Immutable transcript CWD — unchanged by the fork. */ + transcriptCwd: string + /** + * Always `false` at the commit point: the child provider ID is pending + * until the first-Send native fork succeeds. Kept as an explicit field so + * a commit can never be mistaken for an established provider identity. + */ + childProviderIdPresent: false + committedAt: number +} + +export type ConversationForkResult = + | { + outcome: 'committed' + transactionId: string + summary: ConversationForkCommitSummary + } + | { + outcome: 'blocked' + transactionId: string + code: ConversationForkBlockerCode + reason: string + } + | { + outcome: 'recovery-required' + transactionId: string + recovery: ConversationForkRecoveryState + /** Retained snapshot that backs rollback/recovery. */ + retainedSnapshotId?: string + reason: string + } + +// --------------------------------------------------------------------------- +// Status and recovery +// --------------------------------------------------------------------------- + +/** Status query for one session's fork transaction. */ +export interface ConversationForkStatusInput { + sessionId: string +} + +export type ConversationForkStatus = + | { active: false } + | { + active: true + transactionId: string + strategy: ConversationForkStrategy + state: ConversationForkRecoveryState + /** Retained snapshot authority when the fork is snapshot-backed. */ + retainedSnapshotId?: string + /** Server timestamp of the last state transition. */ + since: number + /** + * Child provider identity display state. Before first Send this is + * `pending` — the surface displays provider identity as PENDING rather + * than claiming a child provider ID. After the strict adapter + * establishes the native fork, the persisted child provider ID is + * exposed as `established` and pending metadata is retired. + */ + providerIdentity: + | { status: 'pending' } + | { status: 'established'; childSdkSessionId: string } + } + +/** Continue an interrupted fork transaction (idempotent steps). */ +export interface ConversationForkRecoverInput { + sessionId: string + transactionId: string +} + +export type ConversationForkRecoverResult = ConversationForkResult + +/** Cancel a pending preview transaction (dialog dismissed without confirming). */ +export interface ConversationForkCancelInput { + sessionId: string + transactionId: string +} + +export type ConversationForkCancelResult = ConversationForkStatus diff --git a/packages/shared/src/protocol/dto.ts b/packages/shared/src/protocol/dto.ts index 7fdeb359..14eb9e8d 100644 --- a/packages/shared/src/protocol/dto.ts +++ b/packages/shared/src/protocol/dto.ts @@ -98,6 +98,8 @@ export interface Session { } /** When true, session is hidden from session list (e.g., mini edit sessions) */ hidden?: boolean + /** Durable source-message boundary separating copied parent history from new messages. */ + branchFromMessageId?: string isArchived?: boolean archivedAt?: number supportsBranching?: boolean @@ -118,6 +120,20 @@ export interface Session { * the agent runtime has not been created yet. */ handoffCapable?: boolean + /** + * Server-derived: true when this session's provider adapter advertises a + * strict cross-CWD native fork (the isolated conversation-fork strategy may + * be offered). Absent while the agent runtime has not been created yet. + */ + isolatedForkCapable?: boolean + /** + * Server-derived: true while this session is a published-but-not-established + * isolated conversation-fork child (a durable pending fork intent exists and + * the child provider identity has not been claimed yet). Before first Send + * the surface must display provider identity as PENDING and never claim a + * child provider ID. Absent (false) for every other session. + */ + forkPending?: boolean } export interface CreateSessionOptions { @@ -186,7 +202,7 @@ export type SessionEvent = | { type: 'text_complete'; sessionId: string; text: string; isIntermediate?: boolean; turnId?: string; parentToolUseId?: string; timestamp?: number; messageId?: string } | { type: 'tool_start'; sessionId: string; toolName: string; toolUseId: string; toolInput: Record; toolIntent?: string; toolDisplayName?: string; toolDisplayMeta?: ToolDisplayMeta; turnId?: string; parentToolUseId?: string; timestamp?: number } | { type: 'tool_result'; sessionId: string; toolUseId: string; toolName: string; result: string; turnId?: string; parentToolUseId?: string; isError?: boolean; timestamp?: number } - | { type: 'error'; sessionId: string; error: string; timestamp?: number } + | { type: 'error'; sessionId: string; error: string; timestamp?: number; code?: string } | { type: 'typed_error'; sessionId: string; error: TypedError; timestamp?: number } | { type: 'complete'; sessionId: string; tokenUsage?: Session['tokenUsage']; hasUnread?: boolean } | { type: 'interrupted'; sessionId: string; message?: Message; queuedMessages?: string[] } @@ -235,6 +251,14 @@ export interface SendMessageOptions { skillSlugs?: string[] badges?: ContentBadge[] optimisticMessageId?: string + /** + * Retry a previously persisted user message by its server message ID instead + * of creating a new one. Used by the isolated-fork establish retry: the + * failed first Send already persisted the user message, so a retry must + * reuse it (the server also reuses the persisted fork idempotency key, so + * the provider artifact is never duplicated). + */ + existingMessageId?: string } // --------------------------------------------------------------------------- diff --git a/packages/shared/src/protocol/index.ts b/packages/shared/src/protocol/index.ts index 9905e4d0..1daf39b0 100644 --- a/packages/shared/src/protocol/index.ts +++ b/packages/shared/src/protocol/index.ts @@ -3,5 +3,6 @@ export * from './channels' export * from './dto' export * from './git' export * from './worktree-handoff' +export * from './conversation-fork' export * from './events' export * from './routing' diff --git a/packages/shared/src/protocol/routing.ts b/packages/shared/src/protocol/routing.ts index f06f33d5..88e87db0 100644 --- a/packages/shared/src/protocol/routing.ts +++ b/packages/shared/src/protocol/routing.ts @@ -442,6 +442,11 @@ export const REMOTE_ELIGIBLE_CHANNELS = new Set([ RPC_CHANNELS.git.HANDOFF_STATUS, RPC_CHANNELS.git.HANDOFF_RECOVER, RPC_CHANNELS.git.HANDOFF_CANCEL, + RPC_CHANNELS.git.FORK_PREVIEW, + RPC_CHANNELS.git.FORK_CONFIRM, + RPC_CHANNELS.git.FORK_STATUS, + RPC_CHANNELS.git.FORK_RECOVER, + RPC_CHANNELS.git.FORK_CANCEL, // resources — workspace resource export/import RPC_CHANNELS.resources.EXPORT, diff --git a/packages/shared/src/protocol/types.ts b/packages/shared/src/protocol/types.ts index 4ebd584e..9e71cac6 100644 --- a/packages/shared/src/protocol/types.ts +++ b/packages/shared/src/protocol/types.ts @@ -100,6 +100,14 @@ export const WORKTREE_HANDOFF_BLOCKED_CODE = 'WORKTREE_HANDOFF_BLOCKED' as const export const WORKTREE_HANDOFF_PREVIEW_STALE_CODE = 'WORKTREE_HANDOFF_PREVIEW_STALE' as const /** Wire error returned when a pending/recovery handoff fences an action. */ export const WORKTREE_HANDOFF_PENDING_CODE = 'WORKTREE_HANDOFF_PENDING' as const +/** Wire error returned when a fork RPC fails during execution. */ +export const WORKTREE_FORK_ERROR_CODE = 'WORKTREE_FORK_FAILED' as const +/** Wire error returned when a fork RPC is rejected by a typed blocker. */ +export const WORKTREE_FORK_BLOCKED_CODE = 'WORKTREE_FORK_BLOCKED' as const +/** Wire error returned when a fork preview fingerprint is stale. */ +export const WORKTREE_FORK_PREVIEW_STALE_CODE = 'WORKTREE_FORK_PREVIEW_STALE' as const +/** Wire error returned when a pending/recovery fork fences an action. */ +export const WORKTREE_FORK_PENDING_CODE = 'WORKTREE_FORK_PENDING' as const export type ErrorCode = | 'HANDLER_ERROR' @@ -128,6 +136,10 @@ export type ErrorCode = | typeof WORKTREE_HANDOFF_BLOCKED_CODE | typeof WORKTREE_HANDOFF_PREVIEW_STALE_CODE | typeof WORKTREE_HANDOFF_PENDING_CODE + | typeof WORKTREE_FORK_ERROR_CODE + | typeof WORKTREE_FORK_BLOCKED_CODE + | typeof WORKTREE_FORK_PREVIEW_STALE_CODE + | typeof WORKTREE_FORK_PENDING_CODE | 'CLIENT_DISCONNECTED' | 'CLIENT_REQUEST_TIMEOUT' | 'BROWSER_NO_CAPABLE_CLIENT' @@ -162,6 +174,10 @@ const KNOWN_ERROR_CODES: ReadonlySet = new Set([ WORKTREE_HANDOFF_BLOCKED_CODE, WORKTREE_HANDOFF_PREVIEW_STALE_CODE, WORKTREE_HANDOFF_PENDING_CODE, + WORKTREE_FORK_ERROR_CODE, + WORKTREE_FORK_BLOCKED_CODE, + WORKTREE_FORK_PREVIEW_STALE_CODE, + WORKTREE_FORK_PENDING_CODE, 'CLIENT_DISCONNECTED', 'CLIENT_REQUEST_TIMEOUT', 'BROWSER_NO_CAPABLE_CLIENT', diff --git a/packages/shared/src/sessions/types.ts b/packages/shared/src/sessions/types.ts index c28e2262..6907b6af 100644 --- a/packages/shared/src/sessions/types.ts +++ b/packages/shared/src/sessions/types.ts @@ -58,6 +58,10 @@ export const SESSION_PERSISTENT_FIELDS = [ 'checkout', // Handoff runtime reconstruction state (unverified arms the Send proof gate) 'handoffRuntimeState', + // Durable pending isolated-fork intent (Phase 4: isolated conversation forks) + 'pendingFork', + // Durable checkout-strategy provenance recorded at branch/fork creation + 'checkoutStrategy', ] as const; export type SessionPersistentField = typeof SESSION_PERSISTENT_FIELDS[number]; @@ -214,6 +218,39 @@ export interface SessionConfig { * this process; `recovery-required` blocks Send until the runtime is fixed. */ handoffRuntimeState?: 'unverified' | 'verified' | 'recovery-required'; + /** + * Durable pending isolated-fork intent (Phase 4). Set on an isolated fork + * child between fork publication and first-Send provider establishment. + * Carries strict parent conversation/turn identity, the immutable transcript + * lookup identity, and the destination execution CWD. Blocks Send with the + * typed pending code until Task 4 replaces the gate with the establish flow. + */ + pendingFork?: { + /** Opaque fork transaction id that created this child. */ + transactionId: string; + /** Source Kata session the fork is created from. */ + parentSessionId: string; + /** Parent provider SDK session identity (anchor lineage). */ + parentSdkSessionId: string; + /** Parent provider turn anchor at the branch point. */ + parentSdkTurnId: string; + /** Immutable transcript lookup identity — never rewritten by the fork. */ + transcriptCwd: string; + /** Destination execution CWD every runtime must resolve to. */ + executionCwd: string; + /** Idempotency key for the first-Send provider establishment. */ + idempotencyKey: string; + /** Server timestamp of child creation. */ + createdAt: number; + }; + /** + * Durable checkout-strategy provenance recorded at branch/fork creation + * (Phase 4): `shared` for conversation branches sharing the parent managed + * worktree, `isolated` for isolated fork children owning a dedicated target. + * Session branch cleanup consumes this to decide shared-owner removal vs + * isolated-child-only lifecycle. + */ + checkoutStrategy?: 'shared' | 'isolated'; } /** @@ -307,6 +344,10 @@ export interface SessionHeader { triggeredBy?: { automationName?: string; event?: string; timestamp?: number }; /** Git checkout metadata (schema-versioned). See SessionConfig.checkout. */ checkout?: SessionCheckout; + /** Durable pending isolated-fork intent. See SessionConfig.pendingFork. */ + pendingFork?: SessionConfig['pendingFork']; + /** Durable checkout-strategy provenance. See SessionConfig.checkoutStrategy. */ + checkoutStrategy?: SessionConfig['checkoutStrategy']; // Pre-computed fields for fast list loading /** Number of messages in session */ messageCount: number;