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,
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -98,4 +98,10 @@ export {
|
|||||||
saveAgentSessions,
|
saveAgentSessions,
|
||||||
updateAgentSession,
|
updateAgentSession,
|
||||||
clearAgentSessions,
|
clearAgentSessions,
|
||||||
|
// Worktree sessions
|
||||||
|
getWorktreeSessionsDir,
|
||||||
|
encodeWorktreePath,
|
||||||
|
getWorktreeSessionPath,
|
||||||
|
loadWorktreeSessions,
|
||||||
|
updateWorktreeSession,
|
||||||
} from './sessionStore.js';
|
} from './sessionStore.js';
|
||||||
|
|||||||
@ -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}/
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user