135 lines
5.5 KiB
TypeScript
135 lines
5.5 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|