/** * Disk persistence for company-turn resume state. * * 각 turn의 sessionDir 안에 `_resume.json`을 두고, dispatcher가 매 의미 있는 * 시점(plan 확정 / 각 stage 직후 / abort 시점)에 현재 상태를 덮어쓴다. * 재개 시점에는 이 파일을 읽어 `nextIndex` 부터 dispatch 재개. * * 쓰기 정책: * - 같은 파일을 매번 덮어쓰지만, 부분쓰기로 깨지면 다음 재개가 실패하므로 * tmp → rename으로 원자성 보장 (POSIX rename은 atomic, Windows도 NTFS면 OK). * - 실패는 로그만 남기고 turn 흐름은 절대 막지 않는다 (resume은 nice-to-have). * - 자연 종료(completed/failed) 후에는 같은 파일에 status='completed'로 마킹. * 물리적으로 지우진 않음 — 향후 분석/감사 용도로 유지. */ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { logError, logInfo } from '../../utils'; import { resolveCompanyBase } from './sessionStore'; import { CompanyResumeState } from './types'; const RESUME_FILE = '_resume.json'; /** * Write the resume state atomically. tmp 파일에 쓰고 rename으로 덮어써서 부분 * 쓰기 도중 크래시가 나도 기존 _resume.json은 일관된 상태로 남도록 한다. */ export function writeResumeState(sessionDir: string, state: CompanyResumeState): void { const target = path.join(sessionDir, RESUME_FILE); const tmp = target + '.tmp'; try { fs.mkdirSync(sessionDir, { recursive: true }); fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8'); fs.renameSync(tmp, target); } catch (e: any) { logError('company.resumeStore: write failed.', { sessionDir: path.basename(sessionDir), error: e?.message ?? String(e), }); } } /** * 해당 세션의 resume 상태를 읽어온다. 파일이 없거나 파싱 실패 시 null. * 호환성 검사: version이 일치하지 않으면 안전하게 거부 (재개 불가로 취급). */ export function readResumeState(sessionDir: string): CompanyResumeState | null { const p = path.join(sessionDir, RESUME_FILE); if (!fs.existsSync(p)) return null; try { const raw = fs.readFileSync(p, 'utf8'); const parsed = JSON.parse(raw) as CompanyResumeState; if (!parsed || parsed.version !== 1) return null; if (!parsed.timestamp || !parsed.userPrompt || !parsed.plan) return null; if (!Array.isArray(parsed.agentOutputs)) return null; if (typeof parsed.nextIndex !== 'number' || parsed.nextIndex < 0) return null; if (!['in-progress', 'aborted', 'completed', 'failed'].includes(parsed.status)) return null; return parsed; } catch (e: any) { logError('company.resumeStore: read failed.', { sessionDir: path.basename(sessionDir), error: e?.message ?? String(e), }); return null; } } /** * 모든 세션 디렉터리를 스캔해서 "이어서 진행 가능한" 것만 골라낸다. * 기준: * - `_resume.json`이 존재 * - status === 'in-progress' || 'aborted' (자연 종료된 것 제외) * - agentOutputs / nextIndex가 plan보다 짧음 (정말 미완) * 결과는 lastUpdatedAt 내림차순 (최근에 멈춘 것이 위로). */ export function listResumableSessions(context: vscode.ExtensionContext): CompanyResumeState[] { const base = path.join(resolveCompanyBase(context), 'sessions'); if (!fs.existsSync(base)) return []; let entries: string[]; try { entries = fs.readdirSync(base); } catch (e: any) { logError('company.resumeStore: list failed.', { error: e?.message ?? String(e) }); return []; } const out: CompanyResumeState[] = []; for (const name of entries) { const dir = path.join(base, name); try { if (!fs.statSync(dir).isDirectory()) continue; } catch { continue; } const state = readResumeState(dir); if (!state) continue; if (state.status !== 'in-progress' && state.status !== 'aborted') continue; // sanity: nextIndex가 plan.tasks 길이 이상이면 사실상 완료 — skip. const totalTasks = state.plan?.tasks?.length ?? 0; if (totalTasks > 0 && state.nextIndex >= totalTasks) continue; out.push(state); } out.sort((a, b) => (b.lastUpdatedAt || '').localeCompare(a.lastUpdatedAt || '')); return out; } /** * 세션의 resume 상태를 마킹. 자연 종료 시 status='completed' (또는 'failed')로 * 덮어써서 listResumable에서 자동으로 빠지게 한다. * * 파일을 물리적으로 지우지 않는 이유: 사용자의 _resume.json이 디버깅/감사 * 경로에서 유용할 수 있고, 디스크 용량도 미미함 (~수 KB). */ export function markResumeStatus( sessionDir: string, status: CompanyResumeState['status'], abortReason?: string, ): void { const cur = readResumeState(sessionDir); if (!cur) return; const next: CompanyResumeState = { ...cur, status, abortReason: abortReason ?? cur.abortReason, lastUpdatedAt: new Date().toISOString(), }; writeResumeState(sessionDir, next); logInfo('company.resumeStore: status updated.', { sessionDir: path.basename(sessionDir), status, }); } /** 절대 세션 디렉터리 경로 헬퍼 — 재개 진입점이 timestamp만 받았을 때 사용. */ export function resolveSessionDir(context: vscode.ExtensionContext, timestamp: string): string { return path.join(resolveCompanyBase(context), 'sessions', timestamp); }