worktree セッション引き継ぎ機能を追加
同じ worktree に再度指示を出す際、前回のセッションを引き継げるようにする。 - loadWorktreeSessions / updateWorktreeSession を追加 - worktree 別にエージェントセッションを保存(.takt/logs/worktree-sessions/) - workflowExecution で worktree でもセッション管理を有効化
This commit is contained in:
parent
42ae981b65
commit
75989522ca
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -98,4 +98,10 @@ export {
|
||||
saveAgentSessions,
|
||||
updateAgentSession,
|
||||
clearAgentSessions,
|
||||
// Worktree sessions
|
||||
getWorktreeSessionsDir,
|
||||
encodeWorktreePath,
|
||||
getWorktreeSessionPath,
|
||||
loadWorktreeSessions,
|
||||
updateWorktreeSession,
|
||||
} from './sessionStore.js';
|
||||
|
||||
@ -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<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.
|
||||
* Claude CLI stores sessions in ~/.claude/projects/{encoded-project-path}/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user