Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d3159b9
feat(worktree): add conversation-fork protocol contract
Aug 7, 2026
165843e
fix(worktree): model established child provider identity in fork status
Aug 7, 2026
21519d3
test(worktree): pin conversation-fork blocker tuple length
Aug 7, 2026
ac4d128
chore(worktree): apply fork-contract review polish
Aug 7, 2026
e5b2e16
feat(worktree): add isolated conversation fork preview and seed capture
Aug 8, 2026
0094799
fix(worktree): gate seed-capture blockers on isolated strategy
Aug 8, 2026
58c9833
feat(worktree): commit isolated fork target and child through the dur…
Aug 8, 2026
d727ead
fix(worktree): resume fork transactions that crashed after target mat…
Aug 8, 2026
9a0e21d
feat(worktree): add fork status/recover/cancel
Aug 8, 2026
a7c5e1a
fix(worktree): failed fork transactions are never resumable, on any path
Aug 8, 2026
3a1fef9
fix(worktree): serialize cancel with confirm under the mutation lock
Aug 8, 2026
5c7b8a1
feat(worktree): wire fork hooks, pending child creation, fencing, and…
Aug 8, 2026
61d8ad2
fix(worktree): deleteSession cancels pending fork previews; ship chil…
Aug 8, 2026
6258084
fix(worktree): guard pendingFork application on validatedBranch
Aug 8, 2026
d30a4af
feat(worktree): add fork orphan ledger and journal establishment marker
Aug 8, 2026
8256566
feat(worktree): establish pending fork child on first Send via strict…
Aug 8, 2026
ea4fa9e
fix(worktree): serialize concurrent first-sends on a pending fork child
Aug 8, 2026
9c6525c
feat(worktree): reconcile fork journal and orphan ledger at startup
Aug 8, 2026
82b7d4d
fix(worktree): keep committed fork entries through journal compaction
Aug 8, 2026
c4bcde5
feat(worktree): renderer fork dialog, pending identity, and recovery …
Aug 8, 2026
1e35cc0
fix(worktree): ack retried sends; render remaining fork preview facts
Aug 8, 2026
69b7340
test(worktree): provenance-aware isolated child cleanup coverage
Aug 8, 2026
745f332
feat(worktree): add deterministic fork seam and i18n fork establishme…
Aug 8, 2026
7f210db
test(worktree): author credential-backed conversation fork E2E spec
Aug 8, 2026
fac69a8
docs(worktree): add isolated conversation forks ADR, docs, release no…
Aug 8, 2026
3d2bf84
docs(worktree): correct stale 'later phase' comments in the fork service
Aug 8, 2026
32c5b96
fix(e2e): remove fake provider seams; walk the real credential fallba…
Aug 8, 2026
ab57671
fix(worktree): harden isolated fork review invariants
Aug 8, 2026
f3e6fc0
fix(worktree): preserve exact fork retry target
Aug 8, 2026
cf71d18
fix(worktree): reject stale fork execution proofs
Aug 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions apps/electron/resources/release-notes/next.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` 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
Expand Down
82 changes: 81 additions & 1 deletion apps/electron/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -54,6 +55,8 @@ import {
loadedSessionsAtom,
forceSessionMessagesReloadAtom,
backgroundTasksAtomFamily,
forkRetryAtomFamily,
findPendingForkRetryMessage,
extractSessionMeta,
windowWorkspaceIdAtom,
type SessionMeta,
Expand Down Expand Up @@ -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<boolean> => {
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)

Expand Down Expand Up @@ -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), {
Comment thread
gannonh marked this conversation as resolved.
...retryMessage,
error: event.error,
})
}
}

const agentEvent = event as unknown as AgentEvent

// Track activity for stale session watchdog
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -1930,6 +2008,7 @@ export default function App() {
// Session callbacks
onCreateSession: handleCreateSession,
onSendMessage: handleSendMessage,
onRetryForkSend: handleRetryForkSend,
onRenameSession: handleRenameSession,
onFlagSession: handleFlagSession,
onUnflagSession: handleUnflagSession,
Expand Down Expand Up @@ -1977,6 +2056,7 @@ export default function App() {
updateDefaultThinkingLevel,
handleCreateSession,
handleSendMessage,
handleRetryForkSend,
handleRenameSession,
handleFlagSession,
handleUnflagSession,
Expand Down
40 changes: 40 additions & 0 deletions apps/electron/src/renderer/atoms/__tests__/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
refreshSessionsMetadataAtom,
initializeSessionsAtom,
replaceLoadedSessionAtom,
findPendingForkRetryMessage,
} from '../sessions'

function msg(id: string, role: Message['role'] = 'user'): Message {
Expand All @@ -33,6 +34,45 @@ function makeSession(overrides: Partial<Session> = {}): 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

Expand Down
41 changes: 41 additions & 0 deletions apps/electron/src/renderer/atoms/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Session, 'forkPending' | 'branchFromMessageId' | 'messages'> | null | undefined,
): Pick<ForkRetryState, 'messageId' | 'text'> | 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return { messageId: message.id, text: message.content }
}

export const forkRetryAtomFamily = atomFamily(
(_sessionId: string) => atom<ForkRetryState | null>(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.
Expand Down
Loading
Loading