콘텐츠로 이동

Persistence Layer 구현

에이전트가 재시작되거나 중단되었을 때 작업을 복구할 수 있어야 합니다. Persistence Layer는 세 가지 기능을 제공합니다.

기능목적구현
세션 저장대화 히스토리 보존, 재개JsonSessionStore
설정 해석환경별 설정 오버라이드ConfigResolver
운영 로그파일 변경 추적, 롤백 지원OperationLog + git

세션은 대화 히스토리와 메타데이터를 포함합니다. 자동저장 은 매 턴마다 파일에 기록해 프로세스 크래시에도 복구를 가능하게 합니다.

interface Session {
id: string;
createdAt: string;
updatedAt: string;
task: string;
status: 'running' | 'completed' | 'failed' | 'paused';
conversationHistory: Message[];
metadata: Record<string, unknown>;
}
class JsonSessionStore {
constructor(private baseDir: string) {}
private sessionPath(id: string): string {
return path.join(this.baseDir, `${id}.json`);
}
async create(task: string): Promise<Session> {
const session: Session = {
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
task,
status: 'running',
conversationHistory: [],
metadata: {},
};
await fs.mkdir(this.baseDir, { recursive: true });
await this.save(session);
return session;
}
async load(id: string): Promise<Session | null> {
try {
const raw = await fs.readFile(this.sessionPath(id), 'utf-8');
return JSON.parse(raw) as Session;
} catch {
return null;
}
}
async save(session: Session): Promise<void> {
const updated = { ...session, updatedAt: new Date().toISOString() };
// 원자적 쓰기: 임시 파일에 먼저 쓴 후 이름 변경
const tmpPath = `${this.sessionPath(session.id)}.tmp`;
await fs.writeFile(tmpPath, JSON.stringify(updated, null, 2), 'utf-8');
await fs.rename(tmpPath, this.sessionPath(session.id));
}
async list(): Promise<Pick<Session, 'id' | 'task' | 'status' | 'createdAt'>[]> {
const files = await fs.readdir(this.baseDir).catch(() => []);
const sessions = await Promise.all(
files
.filter(f => f.endsWith('.json') && !f.endsWith('.tmp.json'))
.map(async f => {
const raw = await fs.readFile(path.join(this.baseDir, f), 'utf-8');
const s = JSON.parse(raw) as Session;
return { id: s.id, task: s.task, status: s.status, createdAt: s.createdAt };
})
);
return sessions.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
}

ConfigResolver: 4단계 우선순위 해석

섹션 제목: “ConfigResolver: 4단계 우선순위 해석”
interface AgentConfig {
model: string;
maxTokens: number;
temperature: number;
requireApproval: string[];
sessionDir: string;
}
const CONFIG_DEFAULTS: AgentConfig = {
model: 'claude-sonnet-4-5',
maxTokens: 8192,
temperature: 0,
requireApproval: ['write_file', 'delete_file', 'run_command'],
sessionDir: '.agent/sessions',
};
class ConfigResolver {
private cache: Partial<AgentConfig> | null = null;
async resolve(): Promise<AgentConfig> {
if (this.cache) return this.cache as AgentConfig;
const layers = await Promise.all([
this.loadProjectConfig(), // 1순위: 프로젝트 로컬
this.loadUserConfig(), // 2순위: 사용자 글로벌
this.loadEnvConfig(), // 3순위: 환경 변수
]);
// 우선순위 순서로 머지 (앞 레이어가 우선)
this.cache = Object.assign({}, CONFIG_DEFAULTS, ...layers.reverse());
return this.cache as AgentConfig;
}
private async loadProjectConfig(): Promise<Partial<AgentConfig>> {
const paths = ['.agent/config.json', '.agentrc.json'];
for (const p of paths) {
try {
const raw = await fs.readFile(p, 'utf-8');
return JSON.parse(raw);
} catch { /* 없으면 건너뜀 */ }
}
return {};
}
private async loadUserConfig(): Promise<Partial<AgentConfig>> {
const configPath = path.join(os.homedir(), '.config', 'agent', 'config.json');
try {
const raw = await fs.readFile(configPath, 'utf-8');
return JSON.parse(raw);
} catch {
return {};
}
}
private loadEnvConfig(): Partial<AgentConfig> {
const config: Partial<AgentConfig> = {};
if (process.env.AGENT_MODEL) config.model = process.env.AGENT_MODEL;
if (process.env.AGENT_MAX_TOKENS) config.maxTokens = parseInt(process.env.AGENT_MAX_TOKENS, 10);
if (process.env.AGENT_SESSION_DIR) config.sessionDir = process.env.AGENT_SESSION_DIR;
return config;
}
}

에이전트가 파일을 수정할 때마다 로그를 기록하고, git commit을 생성합니다. 문제가 발생하면 특정 시점으로 롤백할 수 있습니다.

interface OperationEntry {
id: string;
timestamp: string;
sessionId: string;
type: 'file_write' | 'file_delete' | 'command_run';
details: Record<string, unknown>;
gitCommitHash?: string;
}
class OperationLog {
constructor(
private logPath: string,
private gitEnabled: boolean,
) {}
async record(entry: Omit<OperationEntry, 'id' | 'timestamp'>): Promise<OperationEntry> {
const full: OperationEntry = {
...entry,
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
};
if (this.gitEnabled && entry.type !== 'command_run') {
full.gitCommitHash = await this.gitCommit(entry);
}
await this.append(full);
return full;
}
async rollbackTo(operationId: string): Promise<void> {
const entries = await this.loadAll();
const target = entries.find(e => e.id === operationId);
if (!target?.gitCommitHash) {
throw new Error(`롤백 가능한 git 커밋이 없습니다: ${operationId}`);
}
// git reset으로 해당 커밋 이전 상태로 복구
await execAsync(`git checkout ${target.gitCommitHash}^ -- .`);
}
private async gitCommit(entry: Omit<OperationEntry, 'id' | 'timestamp'>): Promise<string> {
const message = `[agent] ${entry.type}: ${JSON.stringify(entry.details).slice(0, 80)}`;
await execAsync('git add -A');
await execAsync(`git commit -m "${message}" --no-verify`);
const { stdout } = await execAsync('git rev-parse HEAD');
return stdout.trim();
}
private async append(entry: OperationEntry): Promise<void> {
const line = JSON.stringify(entry) + '\n';
await fs.appendFile(this.logPath, line, 'utf-8');
}
private async loadAll(): Promise<OperationEntry[]> {
try {
const raw = await fs.readFile(this.logPath, 'utf-8');
return raw.trim().split('\n').map(line => JSON.parse(line));
} catch {
return [];
}
}
}
async function resumeOrCreate(sessionId: string | null, task: string): Promise<Session> {
const store = new JsonSessionStore(config.sessionDir);
if (sessionId) {
const existing = await store.load(sessionId);
if (existing && existing.status === 'paused') {
console.log(`세션 재개: ${existing.id} (${existing.conversationHistory.length}개 메시지)`);
return { ...existing, status: 'running' };
}
}
return store.create(task);
}

세션 재개 시 conversationHistory 를 그대로 MainAgent 에 주입하면 에이전트가 중단된 지점부터 작업을 이어갑니다.