diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 2189772..558c9a3 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -20,6 +20,12 @@ import { addToInputHistory, getInputHistoryPath, MAX_INPUT_HISTORY, + // Worktree session functions + getWorktreeSessionsDir, + encodeWorktreePath, + getWorktreeSessionPath, + loadWorktreeSessions, + updateWorktreeSession, } from '../config/paths.js'; import { loadProjectConfig } from '../config/projectConfig.js'; @@ -417,3 +423,201 @@ describe('saveProjectConfig - gitignore copy', () => { expect(content).toBe(customContent); }); }); + +// ============ Worktree Sessions ============ + +describe('encodeWorktreePath', () => { + it('should replace slashes with dashes', () => { + const encoded = encodeWorktreePath('/project/.takt/worktrees/my-task'); + + expect(encoded).not.toContain('/'); + expect(encoded).toContain('-'); + }); + + it('should handle Windows-style paths', () => { + const encoded = encodeWorktreePath('C:\\project\\worktrees\\task'); + + expect(encoded).not.toContain('\\'); + expect(encoded).not.toContain(':'); + }); + + it('should produce consistent output for same input', () => { + const path = '/project/.takt/worktrees/feature-x'; + const encoded1 = encodeWorktreePath(path); + const encoded2 = encodeWorktreePath(path); + + expect(encoded1).toBe(encoded2); + }); +}); + +describe('getWorktreeSessionsDir', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `takt-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should return path inside .takt directory', () => { + const sessionsDir = getWorktreeSessionsDir(testDir); + + expect(sessionsDir).toContain('.takt'); + expect(sessionsDir).toContain('worktree-sessions'); + }); +}); + +describe('getWorktreeSessionPath', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `takt-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should return .json file path', () => { + const sessionPath = getWorktreeSessionPath(testDir, '/worktree/path'); + + expect(sessionPath).toMatch(/\.json$/); + }); + + it('should include encoded worktree path in filename', () => { + const worktreePath = '/project/.takt/worktrees/my-feature'; + const sessionPath = getWorktreeSessionPath(testDir, worktreePath); + + expect(sessionPath).toContain('worktree-sessions'); + }); +}); + +describe('loadWorktreeSessions', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `takt-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should return empty object when no session file exists', () => { + const sessions = loadWorktreeSessions(testDir, '/some/worktree'); + + expect(sessions).toEqual({}); + }); + + it('should load saved sessions from file', () => { + const worktreePath = '/project/worktree'; + const sessionsDir = getWorktreeSessionsDir(testDir); + mkdirSync(sessionsDir, { recursive: true }); + + const sessionPath = getWorktreeSessionPath(testDir, worktreePath); + const data = { + agentSessions: { coder: 'session-123', reviewer: 'session-456' }, + updatedAt: new Date().toISOString(), + }; + writeFileSync(sessionPath, JSON.stringify(data)); + + const sessions = loadWorktreeSessions(testDir, worktreePath); + + expect(sessions).toEqual({ coder: 'session-123', reviewer: 'session-456' }); + }); + + it('should return empty object for corrupted JSON', () => { + const worktreePath = '/project/worktree'; + const sessionsDir = getWorktreeSessionsDir(testDir); + mkdirSync(sessionsDir, { recursive: true }); + + const sessionPath = getWorktreeSessionPath(testDir, worktreePath); + writeFileSync(sessionPath, 'not valid json'); + + const sessions = loadWorktreeSessions(testDir, worktreePath); + + expect(sessions).toEqual({}); + }); +}); + +describe('updateWorktreeSession', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `takt-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should create session file if not exists', () => { + const worktreePath = '/project/worktree'; + + updateWorktreeSession(testDir, worktreePath, 'coder', 'session-abc'); + + const sessions = loadWorktreeSessions(testDir, worktreePath); + expect(sessions).toEqual({ coder: 'session-abc' }); + }); + + it('should update existing session', () => { + const worktreePath = '/project/worktree'; + + updateWorktreeSession(testDir, worktreePath, 'coder', 'session-1'); + updateWorktreeSession(testDir, worktreePath, 'coder', 'session-2'); + + const sessions = loadWorktreeSessions(testDir, worktreePath); + expect(sessions.coder).toBe('session-2'); + }); + + it('should preserve other agent sessions when updating one', () => { + const worktreePath = '/project/worktree'; + + updateWorktreeSession(testDir, worktreePath, 'coder', 'coder-session'); + updateWorktreeSession(testDir, worktreePath, 'reviewer', 'reviewer-session'); + + const sessions = loadWorktreeSessions(testDir, worktreePath); + expect(sessions).toEqual({ + coder: 'coder-session', + reviewer: 'reviewer-session', + }); + }); + + it('should create worktree-sessions directory if not exists', () => { + const worktreePath = '/project/worktree'; + const sessionsDir = getWorktreeSessionsDir(testDir); + expect(existsSync(sessionsDir)).toBe(false); + + updateWorktreeSession(testDir, worktreePath, 'coder', 'session-xyz'); + + expect(existsSync(sessionsDir)).toBe(true); + }); + + it('should keep sessions isolated between different worktrees', () => { + const worktree1 = '/project/worktree-1'; + const worktree2 = '/project/worktree-2'; + + updateWorktreeSession(testDir, worktree1, 'coder', 'wt1-session'); + updateWorktreeSession(testDir, worktree2, 'coder', 'wt2-session'); + + const sessions1 = loadWorktreeSessions(testDir, worktree1); + const sessions2 = loadWorktreeSessions(testDir, worktree2); + + expect(sessions1.coder).toBe('wt1-session'); + expect(sessions2.coder).toBe('wt2-session'); + }); +}); diff --git a/src/commands/workflowExecution.ts b/src/commands/workflowExecution.ts index 2fa9ee2..c01c0da 100644 --- a/src/commands/workflowExecution.ts +++ b/src/commands/workflowExecution.ts @@ -5,7 +5,12 @@ import { WorkflowEngine } from '../workflow/engine.js'; import type { WorkflowConfig, Language } from '../models/types.js'; import type { IterationLimitRequest } from '../workflow/types.js'; -import { loadAgentSessions, updateAgentSession } from '../config/paths.js'; +import { + loadAgentSessions, + updateAgentSession, + loadWorktreeSessions, + updateWorktreeSession, +} from '../config/paths.js'; import { header, info, @@ -103,17 +108,18 @@ export async function executeWorkflow( displayRef.current.createHandler()(event); }; - // Load saved agent sessions for continuity (from project root) - // When running in a worktree (cwd !== projectCwd), skip session resume because - // Claude Code sessions are stored per-cwd in ~/.claude/projects/{encoded-path}/ - // and sessions from the main project dir can't be resumed in a worktree dir. + // Load saved agent sessions for continuity (from project root or worktree-specific storage) const isWorktree = cwd !== projectCwd; - const savedSessions = isWorktree ? {} : loadAgentSessions(projectCwd); + const savedSessions = isWorktree + ? loadWorktreeSessions(projectCwd, cwd) + : loadAgentSessions(projectCwd); - // Session update handler - persist session IDs when they change (to project root) - // Skip persisting worktree sessions since they can't be reused across different worktrees. + // Session update handler - persist session IDs when they change + // Worktree sessions are stored separately per worktree path const sessionUpdateHandler = isWorktree - ? undefined + ? (agentName: string, agentSessionId: string): void => { + updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId); + } : (agentName: string, agentSessionId: string): void => { updateAgentSession(projectCwd, agentName, agentSessionId); }; diff --git a/src/config/paths.ts b/src/config/paths.ts index 389a42d..f2830dc 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -98,4 +98,10 @@ export { saveAgentSessions, updateAgentSession, clearAgentSessions, + // Worktree sessions + getWorktreeSessionsDir, + encodeWorktreePath, + getWorktreeSessionPath, + loadWorktreeSessions, + updateWorktreeSession, } from './sessionStore.js'; diff --git a/src/config/sessionStore.ts b/src/config/sessionStore.ts index 9981ffd..22f32c5 100644 --- a/src/config/sessionStore.ts +++ b/src/config/sessionStore.ts @@ -170,6 +170,76 @@ export function clearAgentSessions(projectDir: string): void { clearClaudeProjectSessions(projectDir); } +// ============ Worktree Sessions ============ + +/** Get the worktree sessions directory */ +export function getWorktreeSessionsDir(projectDir: string): string { + return join(getProjectConfigDir(projectDir), 'worktree-sessions'); +} + +/** Encode a worktree path to a safe filename */ +export function encodeWorktreePath(worktreePath: string): string { + const resolved = resolve(worktreePath); + return resolved.replace(/[/\\:]/g, '-'); +} + +/** Get path for a worktree's session file */ +export function getWorktreeSessionPath(projectDir: string, worktreePath: string): string { + const dir = getWorktreeSessionsDir(projectDir); + const encoded = encodeWorktreePath(worktreePath); + return join(dir, `${encoded}.json`); +} + +/** Load saved agent sessions for a worktree */ +export function loadWorktreeSessions( + projectDir: string, + worktreePath: string +): Record { + const sessionPath = getWorktreeSessionPath(projectDir, worktreePath); + if (existsSync(sessionPath)) { + try { + const content = readFileSync(sessionPath, 'utf-8'); + const data = JSON.parse(content) as AgentSessionData; + return data.agentSessions || {}; + } catch { + return {}; + } + } + return {}; +} + +/** Update a single agent session for a worktree (atomic) */ +export function updateWorktreeSession( + projectDir: string, + worktreePath: string, + agentName: string, + sessionId: string +): void { + const dir = getWorktreeSessionsDir(projectDir); + ensureDir(dir); + + const sessionPath = getWorktreeSessionPath(projectDir, worktreePath); + let sessions: Record = {}; + + if (existsSync(sessionPath)) { + try { + const content = readFileSync(sessionPath, 'utf-8'); + const data = JSON.parse(content) as AgentSessionData; + sessions = data.agentSessions || {}; + } catch { + sessions = {}; + } + } + + sessions[agentName] = sessionId; + + const data: AgentSessionData = { + agentSessions: sessions, + updatedAt: new Date().toISOString(), + }; + writeFileAtomic(sessionPath, JSON.stringify(data, null, 2)); +} + /** * Get the Claude CLI project session directory path. * Claude CLI stores sessions in ~/.claude/projects/{encoded-project-path}/