콘텐츠로 이동

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-5-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;
}
}

에이전트 전용의 격리된 worktree에서 변경 파일을 명시해 스냅샷을 기록합니다. 공유 저장소 전체를 git add -A로 스테이징하거나 훅을 건너뛰지 않습니다. 아래 예제는 대상 작업의 파일을 이전 내용으로 되돌리기 전에 diff와 승인을 확인합니다.

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
interface OperationEntry {
id: string;
timestamp: string;
sessionId: string;
type: 'file_write' | 'file_delete' | 'command_run';
details: Record<string, unknown>;
paths: string[]; // 이 작업이 소유한 저장소 상대 경로
gitCommitHash?: string;
}
class OperationLog {
constructor(
private logPath: string,
private gitEnabled: boolean,
private approvalManager: ApprovalManager,
) {}
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}`);
}
const source = `${target.gitCommitHash}^`;
const { stdout: diff } = await execFileAsync('git', ['diff', source, '--', ...target.paths]);
const approved = await this.approvalManager.request({
toolName: 'restore_snapshot', input: { source, paths: target.paths, diff },
description: '검토한 파일 변경을 스냅샷 이전 내용으로 복구합니다.',
});
if (!approved) return;
await execFileAsync('git', ['restore', '--source', source, '--staged', '--worktree', '--', ...target.paths]);
}
private async gitCommit(entry: Omit<OperationEntry, 'id' | 'timestamp'>): Promise<string> {
const message = `[agent] ${entry.type}: ${JSON.stringify(entry.details).slice(0, 80)}`;
await execFileAsync('git', ['add', '--', ...entry.paths]);
await execFileAsync('git', ['commit', '--only', '-m', message, '--', ...entry.paths]);
const { stdout } = await execFileAsync('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 [];
}
}
}

각 작업의 paths는 비어 있지 않은 허용 경로 목록으로 검증합니다. 이 예제는 직렬 실행·전용 worktree를 전제합니다. 다른 작업이 같은 파일을 수정했으면 자동 복구하지 않습니다. 승인한 diff 이후 파일이 바뀌었는지도 다시 확인해야 합니다. 네트워크 전송·DB 수정·명령 실행 효과는 Git으로 되돌릴 수 없습니다.

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 에 주입하면 에이전트가 중단된 지점부터 작업을 이어갑니다.