worktree セッション引き継ぎ機能を追加

同じ worktree に再度指示を出す際、前回のセッションを引き継げるようにする。

- loadWorktreeSessions / updateWorktreeSession を追加
- worktree 別にエージェントセッションを保存(.takt/logs/worktree-sessions/)
- workflowExecution で worktree でもセッション管理を有効化
This commit is contained in:
nrslib 2026-01-29 01:10:05 +09:00
parent 42ae981b65
commit 75989522ca
4 changed files with 295 additions and 9 deletions

View File

@ -20,6 +20,12 @@ import {
addToInputHistory, addToInputHistory,
getInputHistoryPath, getInputHistoryPath,
MAX_INPUT_HISTORY, MAX_INPUT_HISTORY,
// Worktree session functions
getWorktreeSessionsDir,
encodeWorktreePath,
getWorktreeSessionPath,
loadWorktreeSessions,
updateWorktreeSession,
} from '../config/paths.js'; } from '../config/paths.js';
import { loadProjectConfig } from '../config/projectConfig.js'; import { loadProjectConfig } from '../config/projectConfig.js';
@ -417,3 +423,201 @@ describe('saveProjectConfig - gitignore copy', () => {
expect(content).toBe(customContent); 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');
});
});

View File

@ -5,7 +5,12 @@
import { WorkflowEngine } from '../workflow/engine.js'; import { WorkflowEngine } from '../workflow/engine.js';
import type { WorkflowConfig, Language } from '../models/types.js'; import type { WorkflowConfig, Language } from '../models/types.js';
import type { IterationLimitRequest } from '../workflow/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 { import {
header, header,
info, info,
@ -103,17 +108,18 @@ export async function executeWorkflow(
displayRef.current.createHandler()(event); displayRef.current.createHandler()(event);
}; };
// Load saved agent sessions for continuity (from project root) // Load saved agent sessions for continuity (from project root or worktree-specific storage)
// 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.
const isWorktree = cwd !== projectCwd; 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) // Session update handler - persist session IDs when they change
// Skip persisting worktree sessions since they can't be reused across different worktrees. // Worktree sessions are stored separately per worktree path
const sessionUpdateHandler = isWorktree const sessionUpdateHandler = isWorktree
? undefined ? (agentName: string, agentSessionId: string): void => {
updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId);
}
: (agentName: string, agentSessionId: string): void => { : (agentName: string, agentSessionId: string): void => {
updateAgentSession(projectCwd, agentName, agentSessionId); updateAgentSession(projectCwd, agentName, agentSessionId);
}; };

View File

@ -98,4 +98,10 @@ export {
saveAgentSessions, saveAgentSessions,
updateAgentSession, updateAgentSession,
clearAgentSessions, clearAgentSessions,
// Worktree sessions
getWorktreeSessionsDir,
encodeWorktreePath,
getWorktreeSessionPath,
loadWorktreeSessions,
updateWorktreeSession,
} from './sessionStore.js'; } from './sessionStore.js';

View File

@ -170,6 +170,76 @@ export function clearAgentSessions(projectDir: string): void {
clearClaudeProjectSessions(projectDir); 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<string, string> {
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<string, string> = {};
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. * Get the Claude CLI project session directory path.
* Claude CLI stores sessions in ~/.claude/projects/{encoded-project-path}/ * Claude CLI stores sessions in ~/.claude/projects/{encoded-project-path}/