diff --git a/media/sidebar.js b/media/sidebar.js
index c90e607..2c69e8d 100644
--- a/media/sidebar.js
+++ b/media/sidebar.js
@@ -994,6 +994,12 @@
}
break;
}
+ case 'companyResumable': {
+ if (typeof window.__renderCompanyResumable === 'function') {
+ window.__renderCompanyResumable(msg.value || {});
+ }
+ break;
+ }
case 'companyPipelineTemplateContent': {
const tpl = msg.value;
if (!tpl) { showToast('템플릿을 찾을 수 없습니다.', 'warn'); break; }
@@ -1034,6 +1040,7 @@
// Triggered by the Command Palette `Manage 1인 기업 Agents`.
document.getElementById('companyOverlay')?.classList.add('visible');
vscode.postMessage({ type: 'getCompanyAgents' });
+ vscode.postMessage({ type: 'getCompanyResumable' });
break;
}
case 'companyTurnUpdate': {
@@ -1717,6 +1724,7 @@
_companyStatusEl.textContent = '불러오는 중...';
vscode.postMessage({ type: 'getCompanyAgents' });
vscode.postMessage({ type: 'getCompanyPipelines' });
+ vscode.postMessage({ type: 'getCompanyResumable' });
};
}
for (const btn of _closeCompanyBtns) {
@@ -2368,6 +2376,114 @@
// expose for the message handler below
window.__renderCompanyPipelines = renderCompanyPipelines;
window.__closePipelineEditor = _closePipelineEditor;
+
+ // ──────────────────────────────────────────────────────────────────────
+ // 이어서 진행 가능 세션 렌더링.
+ //
+ // 백엔드가 보낸 items 배열을 카드 목록으로 그린다. 비어 있으면 섹션 자체를
+ // data-empty="true"로 숨겨 평소 시야에서 사라지게 만든다. 카드는 두 액션:
+ // - 이어서 진행 → resumeCompanyTurn 메시지
+ // - 버리기 → discardResumableSession (resume 파일을 'failed'로 마킹)
+ // ──────────────────────────────────────────────────────────────────────
+ const _formatRelative = (iso) => {
+ if (!iso) return '';
+ const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso;
+ const diff = Date.now() - d.getTime();
+ const m = Math.round(diff / 60000);
+ if (m < 1) return '방금 전';
+ if (m < 60) return `${m}분 전`;
+ const h = Math.round(m / 60);
+ if (h < 24) return `${h}시간 전`;
+ const days = Math.round(h / 24);
+ return `${days}일 전`;
+ };
+
+ const _formatAbortReason = (reason) => {
+ if (!reason) return '도중에 멈춤';
+ const map = {
+ 'signal-aborted': '시작 직전에 중단',
+ 'aborted-after-plan': '계획 직후 중단',
+ 'aborted-mid-dispatch': '실행 중에 중단',
+ 'aborted-mid-pipeline': '단계 진행 중 중단',
+ 'aborted-mid-approval': '승인 대기 중 중단',
+ 'aborted-by-user-at-approval': '승인 단계에서 중단',
+ 'aborted-before-report': '보고서 직전 중단',
+ };
+ return map[reason] || reason;
+ };
+
+ const renderCompanyResumable = (payload) => {
+ const section = document.getElementById('companyResumableSection');
+ const list = document.getElementById('companyResumableList');
+ if (!section || !list) return;
+ const items = (payload && Array.isArray(payload.items)) ? payload.items : [];
+ list.innerHTML = '';
+ if (items.length === 0) {
+ section.setAttribute('data-empty', 'true');
+ return;
+ }
+ section.setAttribute('data-empty', 'false');
+ for (const it of items) {
+ const li = document.createElement('li');
+ li.className = 'company-resumable-card';
+ li.dataset.timestamp = it.timestamp;
+
+ const head = document.createElement('div');
+ head.className = 'company-resumable-head';
+ const prompt = document.createElement('div');
+ prompt.className = 'company-resumable-prompt';
+ prompt.textContent = it.userPrompt || '(빈 요청)';
+ prompt.title = it.userPrompt || '';
+ const actions = document.createElement('div');
+ actions.className = 'company-resumable-actions';
+
+ const resumeBtn = document.createElement('button');
+ resumeBtn.className = 'primary company-resumable-resume';
+ resumeBtn.textContent = '이어서 진행';
+ resumeBtn.title = '이 작업을 멈췄던 다음 단계부터 같은 세션에 이어 기록합니다.';
+ resumeBtn.onclick = () => {
+ // 사용자에게 곧 시작될 거라는 시각 피드백.
+ resumeBtn.disabled = true;
+ resumeBtn.textContent = '재개 중…';
+ vscode.postMessage({ type: 'resumeCompanyTurn', timestamp: it.timestamp });
+ // overlay를 닫아 채팅 화면이 보이게 — 사용자가 진행 상황 즉시 확인.
+ document.getElementById('companyOverlay')?.classList.remove('visible');
+ };
+
+ const discardBtn = document.createElement('button');
+ discardBtn.className = 'company-resumable-discard';
+ discardBtn.textContent = '버리기';
+ discardBtn.title = '이 작업을 더 이상 이어가지 않습니다. 목록에서만 빠지고 기존 산출물 파일은 그대로 남습니다.';
+ discardBtn.onclick = () => {
+ if (!confirm('이 미완 작업을 목록에서 버릴까요? 이미 만들어진 산출물 파일은 사라지지 않습니다.')) return;
+ vscode.postMessage({ type: 'discardResumableSession', timestamp: it.timestamp });
+ };
+
+ actions.appendChild(resumeBtn);
+ actions.appendChild(discardBtn);
+ head.appendChild(prompt);
+ head.appendChild(actions);
+ li.appendChild(head);
+
+ const meta = document.createElement('div');
+ meta.className = 'company-resumable-meta';
+ const pipelineLabel = it.pipelineName
+ ? `📋 ${escAttr(it.pipelineName)}`
+ : '🧭 대표 분배 모드';
+ const progress = (it.totalCount > 0)
+ ? `${it.completedCount}/${it.totalCount} 단계 완료`
+ : '진행도 정보 없음';
+ const when = _formatRelative(it.lastUpdatedAt);
+ const why = it.status === 'aborted'
+ ? `· ${_formatAbortReason(it.abortReason)}`
+ : (it.status === 'in-progress' ? '· 프로세스 중단 추정' : '');
+ meta.innerHTML = `
${pipelineLabel}${escAttr(progress)}${escAttr(when)} ${escAttr(why)}`;
+ li.appendChild(meta);
+
+ list.appendChild(li);
+ }
+ };
+ window.__renderCompanyResumable = renderCompanyResumable;
// 템플릿 stamp 시 호출 — id/name 제안값 + stages를 카드 에디터에 미리 채움.
window.__openPipelineEditorWithTemplate = (tpl) => {
if (!tpl) return;
diff --git a/package.json b/package.json
index fcb26e7..dca5950 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "astra",
"displayName": "Astra",
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
- "version": "2.2.0",
+ "version": "2.2.1",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
diff --git a/src/extension.ts b/src/extension.ts
index 7fdf66e..c3bfe23 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -646,6 +646,7 @@ export async function activate(context: vscode.ExtensionContext) {
await vscode.commands.executeCommand('g1nation-v2-view.focus');
provider._view?.webview.postMessage({ type: 'openCompanyManageOverlay' });
await provider._sendCompanyAgents();
+ await provider._sendCompanyResumable();
}),
vscode.commands.registerCommand('g1nation.company.openSessions', async () => {
const { resolveCompanyBase } = await import('./features/company');
diff --git a/src/features/company/dispatcher.ts b/src/features/company/dispatcher.ts
index 2408fd8..538eccc 100644
--- a/src/features/company/dispatcher.ts
+++ b/src/features/company/dispatcher.ts
@@ -57,8 +57,14 @@ import {
writeReport,
writeSessionJson,
} from './sessionStore';
+import {
+ markResumeStatus,
+ readResumeState,
+ resolveSessionDir,
+ writeResumeState,
+} from './resumeStore';
import { buildTelegramReporter, formatCompanyTelegramReport } from './telegramReport';
-import { AgentTurnOutput, CompanyState, CompanyTaskPlan, PipelineDef, PipelineStage, SessionResult } from './types';
+import { AgentTurnOutput, CompanyResumeState, CompanyState, CompanyTaskPlan, PipelineDef, PipelineStage, SessionResult } from './types';
/** Trim length applied when an agent's output is fed into the next agent. */
const PEER_OUTPUT_BUDGET = 1500;
@@ -159,72 +165,190 @@ export interface DispatcherDeps {
/**
* Run a single company turn. Returns a fully-populated `SessionResult` even
* on partial failure (so callers can always render *something* in chat).
+ *
+ * When `seed` is supplied, this is a *resume* of a previously-aborted turn:
+ * the saved sessionDir/plan/outputs are reused, and dispatch picks up at the
+ * saved `nextIndex` instead of starting from scratch. The CEO planner is
+ * skipped on resume — we trust the original plan to keep behaviour
+ * deterministic. Pipeline mode also restores `latestByStage` / `iterations`
+ * / `revisionNotes` so loop-backs and template tokens resolve as if the
+ * turn never paused.
*/
export async function runCompanyTurn(
userPrompt: string,
deps: DispatcherDeps,
+ seed?: CompanyResumeState | null,
): Promise
{
const startedAt = Date.now();
const state = readCompanyState(deps.context);
- const timestamp = newSessionTimestamp();
- const sessionDir = createSessionDir(deps.context, timestamp);
+ const timestamp = seed?.timestamp ?? newSessionTimestamp();
+ const sessionDir = seed
+ ? resolveSessionDir(deps.context, seed.timestamp)
+ : createSessionDir(deps.context, timestamp);
+ const startedAtIso = seed?.startedAt ?? new Date().toISOString();
const emit: CompanyTurnEmitter = deps.onEvent ?? (() => { /* noop */ });
const isAborted = () => deps.signal?.aborted === true;
- const fail = (reason: string): SessionResult => {
+
+ // ── Resume state writer ──
+ // 모든 의미 있는 시점(plan 확정 / 각 stage 직후 / abort)에 같은 파일을 덮어쓴다.
+ // dispatch 중에 캡처해야 할 cursor·캐시들은 클로저로 넘기는 게 매번
+ // 인자를 6개씩 줄지어 보내는 것보다 깔끔하다.
+ const persistResume = (
+ status: CompanyResumeState['status'],
+ partial: {
+ plan: CompanyTaskPlan;
+ pipelineId: string | null;
+ outputs: AgentTurnOutput[];
+ nextIndex: number;
+ pipelineContext?: CompanyResumeState['pipelineContext'];
+ abortReason?: string;
+ },
+ ): void => {
+ writeResumeState(sessionDir, {
+ version: 1,
+ timestamp,
+ userPrompt,
+ pipelineId: partial.pipelineId,
+ plan: partial.plan,
+ agentOutputs: partial.outputs,
+ nextIndex: partial.nextIndex,
+ pipelineContext: partial.pipelineContext,
+ status,
+ abortReason: partial.abortReason,
+ lastUpdatedAt: new Date().toISOString(),
+ startedAt: startedAtIso,
+ });
+ };
+
+ const fail = (reason: string, ctx?: {
+ plan: CompanyTaskPlan;
+ pipelineId: string | null;
+ outputs: AgentTurnOutput[];
+ nextIndex: number;
+ pipelineContext?: CompanyResumeState['pipelineContext'];
+ }): SessionResult => {
emit({ phase: 'aborted', reason });
+ // abort 시점의 상태를 _resume.json에 영구화 — 이후 사용자가 "이어서 진행"
+ // 누르면 이 파일에서 plan + 진행도를 복원해 nextIndex부터 재개 가능.
+ if (ctx) {
+ persistResume('aborted', { ...ctx, abortReason: reason });
+ } else if (seed) {
+ // Resume 도중에 즉시 abort — 들어왔던 seed를 그대로 abort로 다시 마킹.
+ markResumeStatus(sessionDir, 'aborted', reason);
+ }
return {
timestamp, sessionDir,
userPrompt,
- plan: { brief: '', tasks: [] },
- agentOutputs: [],
+ plan: ctx?.plan ?? seed?.plan ?? { brief: '', tasks: [] },
+ agentOutputs: ctx?.outputs ?? seed?.agentOutputs ?? [],
report: '',
totalDurationMs: Date.now() - startedAt,
};
};
if (isAborted()) return fail('signal-aborted');
- // ── Phase 1: plan (pipeline or legacy planner) ──
- emit({ phase: 'plan-start' });
- const pipeline = resolveActivePipeline(state);
+ // ── Phase 1: plan (pipeline or legacy planner — skipped on resume) ──
+ let pipeline: PipelineDef | null;
let plan: CompanyTaskPlan;
let plannerRaw = '';
- let plannerParsed = false;
- if (pipeline) {
- // Pipeline mode: the user has authored a fixed sequence of stages.
- // We still surface a `plan` for the report writer and the session
- // summary — derived directly from the pipeline definition.
- plan = {
- brief: `[Pipeline: ${pipeline.name}] ${userPrompt.slice(0, 200)}`,
- tasks: pipeline.stages.map((s) => ({ agent: s.agentId, task: s.label })),
- };
- plannerParsed = true;
+ let plannerParsed = true;
+ if (seed) {
+ // Resume path: reuse the original plan + pipeline binding verbatim.
+ // If the user edited the pipeline definition in the meantime we still
+ // re-resolve by id from the current state — that way deleted stages
+ // wouldn't crash the dispatch (resolveActivePipeline returns null
+ // gracefully). Worst case the dispatch finishes the leftover plan.
+ pipeline = seed.pipelineId
+ ? (state.pipelines?.[seed.pipelineId] ?? null)
+ : null;
+ plan = seed.plan;
+ emit({ phase: 'plan-start' });
+ emit({ phase: 'plan-ready', plan, parsed: true, raw: '' });
} else {
- const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel);
- const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, { model: ceoModel });
- plan = plannerResult.plan;
- plannerRaw = plannerResult.raw;
- plannerParsed = plannerResult.parsed;
+ emit({ phase: 'plan-start' });
+ pipeline = resolveActivePipeline(state);
+ if (pipeline) {
+ // Pipeline mode: the user has authored a fixed sequence of stages.
+ // We still surface a `plan` for the report writer and the session
+ // summary — derived directly from the pipeline definition.
+ plan = {
+ brief: `[Pipeline: ${pipeline.name}] ${userPrompt.slice(0, 200)}`,
+ tasks: pipeline.stages.map((s) => ({ agent: s.agentId, task: s.label })),
+ };
+ } else {
+ const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel);
+ const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, { model: ceoModel });
+ plan = plannerResult.plan;
+ plannerRaw = plannerResult.raw;
+ plannerParsed = plannerResult.parsed;
+ }
+ if (isAborted()) {
+ return fail('aborted-after-plan', {
+ plan, pipelineId: pipeline?.id ?? null, outputs: [], nextIndex: 0,
+ });
+ }
+ emit({ phase: 'plan-ready', plan, parsed: plannerParsed, raw: plannerRaw });
+ writeBrief(sessionDir, userPrompt, plan);
}
- if (isAborted()) return fail('aborted-after-plan');
- emit({
- phase: 'plan-ready',
- plan,
- parsed: plannerParsed,
- raw: plannerRaw,
+ const pipelineId = pipeline?.id ?? null;
+
+ // 초기 resume 상태(또는 resume 진행 시작 상태) 영속화.
+ persistResume('in-progress', {
+ plan, pipelineId,
+ outputs: seed?.agentOutputs ?? [],
+ nextIndex: seed?.nextIndex ?? 0,
+ pipelineContext: seed?.pipelineContext,
});
- writeBrief(sessionDir, userPrompt, plan);
// ── Phase 2: sequential dispatch ──
- const outputs: AgentTurnOutput[] = [];
+ const outputs: AgentTurnOutput[] = seed?.agentOutputs ? [...seed.agentOutputs] : [];
if (pipeline) {
- const runResult = await _runPipeline(pipeline, userPrompt, plan.brief, sessionDir, timestamp, state, deps, isAborted, emit);
- if (runResult.aborted) return fail(runResult.aborted);
- outputs.push(...runResult.outputs);
+ const runResult = await _runPipeline(
+ pipeline, userPrompt, plan.brief, sessionDir, timestamp, state, deps, isAborted, emit,
+ seed && seed.pipelineId === pipeline.id
+ ? {
+ outputs,
+ latestByStage: seed.pipelineContext?.latestByStage ?? {},
+ iterations: seed.pipelineContext?.iterations ?? {},
+ revisionNotes: seed.pipelineContext?.revisionNotes ?? {},
+ startIndex: seed.nextIndex,
+ }
+ : undefined,
+ (commit) => {
+ // 각 stage 직후 호출됨 — _runPipeline의 내부 cursor·캐시를 통째로 받아
+ // resume 파일을 갱신. 다음 stage가 시작되기 전에 디스크에 한 번 떨어짐.
+ persistResume('in-progress', {
+ plan, pipelineId,
+ outputs: commit.outputs,
+ nextIndex: commit.nextIndex,
+ pipelineContext: {
+ latestByStage: commit.latestByStage,
+ iterations: commit.iterations,
+ revisionNotes: commit.revisionNotes,
+ },
+ });
+ },
+ );
+ if (runResult.aborted) {
+ return fail(runResult.aborted, {
+ plan, pipelineId,
+ outputs: runResult.outputs,
+ nextIndex: runResult.nextIndex ?? outputs.length,
+ pipelineContext: runResult.pipelineContext,
+ });
+ }
+ // outputs는 이미 _runPipeline 내부에서 누적 — 새 객체 push 불필요.
+ outputs.length = 0; outputs.push(...runResult.outputs);
} else {
const total = plan.tasks.length;
- for (let i = 0; i < total; i++) {
- if (isAborted()) return fail('aborted-mid-dispatch');
+ const startIdx = seed?.nextIndex ?? 0;
+ for (let i = startIdx; i < total; i++) {
+ if (isAborted()) {
+ return fail('aborted-mid-dispatch', {
+ plan, pipelineId: null, outputs, nextIndex: i,
+ });
+ }
const task = plan.tasks[i];
emit({ phase: 'agent-start', agentId: task.agent, task: task.task, index: i, total });
const turn = await _dispatchOne(task.agent, task.task, outputs, state, deps);
@@ -236,11 +360,25 @@ export async function runCompanyTurn(
`[${timestamp}] ${task.task} — ${turn.error ? `❌ ${turn.error}` : '✅'}`,
);
emit({ phase: 'agent-done', agentId: task.agent, output: turn, index: i, total });
+ // 각 task 직후 resume cursor 영속화 — 다음 task 전에 abort/crash 나도
+ // 같은 task 중복 실행 없이 그 다음부터 이어진다.
+ persistResume('in-progress', {
+ plan, pipelineId: null, outputs, nextIndex: i + 1,
+ });
}
}
// ── Phase 3: synthesis ──
- if (isAborted()) return fail('aborted-before-report');
+ // 여기까지 왔다는 건 모든 stage/task가 끝난 상태. abort 시에도 outputs는
+ // 이미 disk에 떨어져 있으므로 resume cursor를 plan.tasks.length로 옮겨
+ // 다음 재개 시점에는 report 단계부터 시작하도록 한다 (= 사실상 완료에 가깝다).
+ if (isAborted()) {
+ return fail('aborted-before-report', {
+ plan, pipelineId,
+ outputs,
+ nextIndex: plan.tasks.length,
+ });
+ }
emit({ phase: 'report-start' });
const reportModel = modelForAgent(state, 'ceo', deps.defaultModel);
const reportResult = await runCeoReporter(
@@ -293,6 +431,9 @@ export async function runCompanyTurn(
totalDurationMs: Date.now() - startedAt,
};
writeSessionJson(sessionDir, result);
+ // 자연 종료 — resume 파일은 'completed' 마킹으로 listResumable에서 자동 제외.
+ // 파일은 삭제하지 않고 남겨 추후 감사/디버깅 용도로 활용 (수 KB 수준).
+ markResumeStatus(sessionDir, 'completed');
// Heuristic: if the report mentions a 🚀 line, extract it as a decision.
const decisionLine = reportResult.report.split(/\n/).find((l) => /^\d+\.\s+/.test(l.trim()));
if (decisionLine) appendDecision(deps.context, decisionLine.trim());
@@ -305,6 +446,43 @@ export async function runCompanyTurn(
return result;
}
+/**
+ * Resume a previously-aborted company turn from disk.
+ *
+ * sessionDir의 `_resume.json`을 읽어 plan + 진행 cursor + 캐시를 복원한 다음
+ * `runCompanyTurn`을 seed 옵션으로 호출. 같은 sessionDir을 재사용하므로
+ * markdown 산출물(`_brief.md`, `.md` 등)이 누적됨.
+ *
+ * @returns 복구 가능한 상태가 있으면 SessionResult, 아니면 null
+ * (`_resume.json`이 없거나, 이미 'completed' 상태이거나, plan이 비어 있는 경우).
+ */
+export async function resumeCompanyTurn(
+ timestamp: string,
+ deps: DispatcherDeps,
+): Promise {
+ const sessionDir = resolveSessionDir(deps.context, timestamp);
+ const saved = readResumeState(sessionDir);
+ if (!saved) {
+ logInfo('company.dispatcher: resume requested but no state found.', { timestamp });
+ return null;
+ }
+ if (saved.status === 'completed') {
+ logInfo('company.dispatcher: resume requested for completed session — ignoring.', { timestamp });
+ return null;
+ }
+ if (!saved.plan || !Array.isArray(saved.plan.tasks) || saved.plan.tasks.length === 0) {
+ logInfo('company.dispatcher: resume requested but plan is empty.', { timestamp });
+ return null;
+ }
+ logInfo('company.dispatcher: resuming turn.', {
+ timestamp,
+ pipelineId: saved.pipelineId,
+ nextIndex: saved.nextIndex,
+ totalTasks: saved.plan.tasks.length,
+ });
+ return runCompanyTurn(saved.userPrompt, deps, saved);
+}
+
/**
* Dispatch one specialist. Wraps the AI call with try/catch so a single
* agent's failure never aborts the whole turn — we record the error and
@@ -472,6 +650,28 @@ async function _dispatchOne(
* Returns `{ outputs, aborted }`: `aborted` is set only when the abort
* signal flipped mid-run; the outer dispatcher then short-circuits.
*/
+interface PipelineSeed {
+ /** 이미 끝낸 stage들의 출력. 새로운 outputs 배열의 시드로 들어감. */
+ outputs: AgentTurnOutput[];
+ /** stage id → 최신 출력. `{{stage.}}` 치환에 사용. */
+ latestByStage: Record;
+ /** stage id → loop-back 누적 횟수. */
+ iterations: Record;
+ /** stage id → 사용자 수정요청 코멘트. */
+ revisionNotes: Record;
+ /** 재개를 시작할 0-based stage index. */
+ startIndex: number;
+}
+
+/** _runPipeline이 매 stage 직후 호출하는 commit 콜백의 payload. */
+export interface PipelineCommit {
+ outputs: AgentTurnOutput[];
+ latestByStage: Record;
+ iterations: Record;
+ revisionNotes: Record;
+ nextIndex: number;
+}
+
async function _runPipeline(
pipeline: PipelineDef,
userPrompt: string,
@@ -482,21 +682,58 @@ async function _runPipeline(
deps: DispatcherDeps,
isAborted: () => boolean,
emit: CompanyTurnEmitter,
-): Promise<{ outputs: AgentTurnOutput[]; aborted?: string }> {
- const outputs: AgentTurnOutput[] = [];
+ seed?: PipelineSeed,
+ onStageCommit?: (commit: PipelineCommit) => void,
+): Promise<{
+ outputs: AgentTurnOutput[];
+ aborted?: string;
+ /** abort 시점의 stage cursor — runCompanyTurn이 resume 파일 영속화에 사용. */
+ nextIndex?: number;
+ pipelineContext?: {
+ latestByStage: Record;
+ iterations: Record;
+ revisionNotes: Record;
+ };
+}> {
+ const outputs: AgentTurnOutput[] = seed?.outputs ? [...seed.outputs] : [];
// Keep the latest output per stage id so `{{stage.}}` template
// tokens always resolve to the most recent value across loop-backs.
- const latestByStage: Record = {};
- const iterations: Record = {};
+ const latestByStage: Record = seed?.latestByStage
+ ? { ...seed.latestByStage }
+ : {};
+ const iterations: Record = seed?.iterations
+ ? { ...seed.iterations }
+ : {};
const total = pipeline.stages.length;
// Per-stage extra instruction injected by user revision requests. Cleared
// after the stage re-runs successfully so it doesn't pollute the rest of
// the pipeline.
- const revisionNotes: Record = {};
- let i = 0;
- let stepIndex = 0;
+ const revisionNotes: Record = seed?.revisionNotes
+ ? { ...seed.revisionNotes }
+ : {};
+ let i = seed?.startIndex ?? 0;
+ // stepIndex는 emit의 index 인자(UI 진행률) — 재개 시 이미 완료된 stage 수만큼
+ // 미리 진행시켜둬야 "참여 중인 stage 4/7" 같은 표시가 정확해진다.
+ let stepIndex = seed?.outputs?.length ?? 0;
+ /** Snapshot helper — 현재 cursor·캐시 묶음을 PipelineCommit으로 캡슐. */
+ const snapshot = (nextIdx: number): PipelineCommit => ({
+ outputs: [...outputs],
+ latestByStage: { ...latestByStage },
+ iterations: { ...iterations },
+ revisionNotes: { ...revisionNotes },
+ nextIndex: nextIdx,
+ });
+ const abortReturn = (reason: string) => ({
+ outputs, aborted: reason,
+ nextIndex: i,
+ pipelineContext: {
+ latestByStage: { ...latestByStage },
+ iterations: { ...iterations },
+ revisionNotes: { ...revisionNotes },
+ },
+ });
while (i < pipeline.stages.length) {
- if (isAborted()) return { outputs, aborted: 'aborted-mid-pipeline' };
+ if (isAborted()) return abortReturn('aborted-mid-pipeline');
const stage = pipeline.stages[i];
const baseTask = _renderStageInstruction(stage, userPrompt, brief, latestByStage);
const note = revisionNotes[stage.id];
@@ -539,14 +776,16 @@ async function _runPipeline(
// 호스트가 에러를 던지면 안전하게 중단 — 무한 대기 방지.
decision = { kind: 'abort' };
}
- if (isAborted()) return { outputs, aborted: 'aborted-mid-approval' };
+ if (isAborted()) return abortReturn('aborted-mid-approval');
emit({ phase: 'approval-resolved', stageId: stage.id, decision: decision.kind });
if (decision.kind === 'abort') {
- return { outputs, aborted: 'aborted-by-user-at-approval' };
+ return abortReturn('aborted-by-user-at-approval');
}
if (decision.kind === 'revise') {
revisionNotes[stage.id] = decision.comment || '(추가 코멘트 없음)';
// 같은 stage 재실행 — i를 그대로 두고 continue.
+ // 수정요청 코멘트도 resume에 반영해야 재개 시 사용자 의도 보존.
+ onStageCommit?.(snapshot(i));
continue;
}
// 'approve' → 아래 loop-back/다음 stage 진행 로직으로 자연히 fall-through.
@@ -565,11 +804,15 @@ async function _runPipeline(
if (targetIdx !== -1 && targetIdx < i) {
emit({ phase: 'stage-loop', from: stage.id, to: stage.loopBackTo, iteration: count });
i = targetIdx;
+ onStageCommit?.(snapshot(i));
continue;
}
}
}
i++;
+ // 매 stage 자연 완료 직후 resume 파일을 갱신 — 다음 stage 시작 전에 디스크에
+ // 떨어지므로 그 사이에 abort/crash가 나도 정확히 그 다음 stage부터 재개된다.
+ onStageCommit?.(snapshot(i));
}
return { outputs };
}
diff --git a/src/features/company/index.ts b/src/features/company/index.ts
index 795bf6c..07e970a 100644
--- a/src/features/company/index.ts
+++ b/src/features/company/index.ts
@@ -64,12 +64,20 @@ export type {
AgentTurnOutput,
AgentPromptOverride,
SessionResult,
+ CompanyResumeState,
} from './types';
export {
runCompanyTurn,
+ resumeCompanyTurn,
} from './dispatcher';
+export {
+ listResumableSessions,
+ readResumeState,
+ resolveSessionDir,
+} from './resumeStore';
+
export type {
ApprovalDecision,
CompanyTurnEvent,
diff --git a/src/features/company/resumeStore.ts b/src/features/company/resumeStore.ts
new file mode 100644
index 0000000..dd9e808
--- /dev/null
+++ b/src/features/company/resumeStore.ts
@@ -0,0 +1,134 @@
+/**
+ * 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);
+}
diff --git a/src/features/company/types.ts b/src/features/company/types.ts
index 4913a7c..0006a72 100644
--- a/src/features/company/types.ts
+++ b/src/features/company/types.ts
@@ -321,6 +321,62 @@ export interface SessionResult {
totalDurationMs: number;
}
+/**
+ * Persistent resume state for a partially-completed company turn.
+ *
+ * 동기: 사용자가 Stop 버튼을 누르거나, 네트워크/모델 오류로 turn이 중간에 끝나면
+ * 지금까지 한 작업(planner 산출물 + 완료된 stage 출력)을 버리고 처음부터 다시
+ * 돌리는 수밖에 없었음. 각 stage 직후·중단 시점에 `_resume.json`으로 직렬화하면
+ * 사용자가 같은 세션을 이어서 진행할 수 있음.
+ *
+ * 1차 지원 범위: pipeline 모드(파이프라인 ID + stage 순서가 코드로 안정적).
+ * Ad-hoc planner 모드도 같은 구조로 직렬화하지만, 재실행 시 CEO planner를
+ * 재호출하지 않고 *원래 plan*을 그대로 사용 (재해석 일관성).
+ */
+export interface CompanyResumeState {
+ /** Schema 버전 — 향후 마이그레이션 대비. 호환 안 되는 변경 시 ++. */
+ version: 1;
+ /** 세션 디렉터리 이름 (= timestamp). 절대경로 X — 머신 간 이식성 확보. */
+ timestamp: string;
+ /** 사용자가 처음 보낸 prompt. 재개 시에도 동일하게 사용. */
+ userPrompt: string;
+ /** Pipeline 모드면 그 id; ad-hoc planner면 null. */
+ pipelineId: string | null;
+ /** CEO planner 또는 파이프라인에서 파생된 작업 계획. 재개 시 그대로 재사용. */
+ plan: CompanyTaskPlan;
+ /**
+ * 이미 끝낸 stage(파이프라인) 또는 task(ad-hoc) 출력. 재개 시 이 outputs는
+ * 그대로 유지되고, 그 뒤 인덱스부터 dispatch 재개.
+ */
+ agentOutputs: AgentTurnOutput[];
+ /** Pipeline 모드: 다음 실행할 stage의 0-based index. ad-hoc: 다음 task index. */
+ nextIndex: number;
+ /**
+ * 파이프라인 모드의 보존 상태. loop-back / 수정요청 / 단계 출력 재참조가
+ * 정상 재개되려면 같이 살아 있어야 함.
+ */
+ pipelineContext?: {
+ /** stage.id → 최신 출력 (템플릿의 `{{stage.}}` 치환용). */
+ latestByStage: Record;
+ /** stage.id → loop-back 누적 횟수. maxIterations 가드용. */
+ iterations: Record;
+ /** stage.id → 사용자가 수정요청 시 추가한 코멘트. */
+ revisionNotes: Record;
+ };
+ /**
+ * 마지막 상태. 'in-progress'는 도중에 프로세스가 죽었을 때(크래시) 남는 값.
+ * 'aborted'는 사용자가 명시적으로 Stop / 승인 게이트에서 abort.
+ * 'completed' / 'failed'는 자연 종료 — 재개 목록에서 자동으로 빠진다.
+ */
+ status: 'in-progress' | 'aborted' | 'completed' | 'failed';
+ /** abort 시 dispatcher가 emit한 reason ('aborted-mid-pipeline' 등). */
+ abortReason?: string;
+ /** 마지막 직렬화 시각 (ISO). 재개 목록 정렬 + UI 표시용. */
+ lastUpdatedAt: string;
+ /** 시작 시각 (ISO). UI에 "5분 전 시작" 형태로 노출. */
+ startedAt: string;
+}
+
/** Where on disk the company state lives, relative to the workspace root. */
export const COMPANY_DIR_REL = '.astra/company';
export const COMPANY_SHARED_REL = `${COMPANY_DIR_REL}/_shared`;
diff --git a/src/sidebar/chatHandlers.ts b/src/sidebar/chatHandlers.ts
index c5bcaa4..46286cd 100644
--- a/src/sidebar/chatHandlers.ts
+++ b/src/sidebar/chatHandlers.ts
@@ -174,6 +174,33 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
case 'getCompanyAgents':
await provider._sendCompanyAgents();
return true;
+ case 'getCompanyResumable':
+ await provider._sendCompanyResumable();
+ return true;
+ case 'resumeCompanyTurn': {
+ // 사용자가 "이어서 진행" 칩을 눌렀을 때. timestamp만 받아서 디스크의
+ // _resume.json을 읽고 그 다음 stage부터 dispatch가 이어진다.
+ const ts = typeof data.timestamp === 'string' ? data.timestamp : '';
+ if (!ts) return true;
+ // userPrompt 인자는 resume 경로에서 무시되지만(plan은 디스크에서 복원)
+ // 시그니처 일관성을 위해 dummy 값을 전달.
+ void provider._runCompanyTurn('', ts);
+ return true;
+ }
+ case 'discardResumableSession': {
+ // 사용자가 명시적으로 재개 항목을 버리고 싶을 때 — resume 파일을 'failed'로
+ // 마킹해서 listResumable에서 자동 제외. markResumeStatus가 안전한 idempotent
+ // 작업이라 별도 검증 불필요.
+ const ts = typeof data.timestamp === 'string' ? data.timestamp : '';
+ if (!ts) return true;
+ try {
+ const { resolveSessionDir } = await import('../features/company');
+ const { markResumeStatus } = await import('../features/company/resumeStore');
+ markResumeStatus(resolveSessionDir(provider._context, ts), 'failed', 'discarded-by-user');
+ } catch { /* 무시 — 다음 푸시에서 자연 복구 */ }
+ await provider._sendCompanyResumable();
+ return true;
+ }
case 'setCompanyEnabled': {
const { setCompanyEnabled } = await import('../features/company');
await setCompanyEnabled(provider._context, !!data.value);
diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts
index 6d40464..3fd146b 100644
--- a/src/sidebarProvider.ts
+++ b/src/sidebarProvider.ts
@@ -38,8 +38,12 @@ import { detectProjectIntent, KnownProject } from './features/projectArchitectur
import {
readCompanyState,
runCompanyTurn,
+ resumeCompanyTurn,
+ listResumableSessions,
summarizeForChip,
CompanyTurnEvent,
+ DispatcherDeps,
+ ApprovalDecision,
COMPANY_AGENTS,
COMPANY_AGENT_ORDER,
ROLE_CATEGORY_LABELS,
@@ -1680,7 +1684,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
* progress events back as `companyTurnUpdate` messages so the same bubble
* fills in as each agent finishes.
*/
- async _runCompanyTurn(userPrompt: string): Promise {
+ async _runCompanyTurn(userPrompt: string, resumeTimestamp?: string): Promise {
const cfg = getConfig();
const ai = new AIService();
const emit = (event: CompanyTurnEvent) => {
@@ -1692,7 +1696,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
const abort = new AbortController();
this._companyAbort = abort;
try {
- await runCompanyTurn(userPrompt, {
+ const deps: DispatcherDeps = {
context: this._context,
ai,
defaultModel: cfg.defaultModel || 'gemma4:e2b',
@@ -1706,21 +1710,36 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
// executor so specialist outputs like `` actually
// hit disk. Without this, agents would *claim* to create
// files while nothing happened — the exact bug we just fixed.
- executeActionTags: (text) => this._agent.executeActionTagsOnText(text),
+ executeActionTags: (text: string) => this._agent.executeActionTagsOnText(text),
signal: abort.signal,
onEvent: emit,
// 승인 게이트 bridge — dispatcher가 호출하면 Promise를 만들어
// resolver를 _pendingApprovals에 보관 후 await. 사용자가 카드 버튼을
// 누르면 chatHandlers가 resolveApprovalGate(stageId, decision)을 호출
// 하고 그 resolve가 이 await을 풀어준다.
- awaitApproval: ({ stageId }) => new Promise((resolve) => {
- if (abort.signal.aborted) {
- resolve({ kind: 'abort' });
- return;
- }
- this._pendingApprovals.set(stageId, resolve);
- }),
- });
+ awaitApproval: ({ stageId }: { stageId: string; stageLabel: string }) =>
+ new Promise((resolve) => {
+ if (abort.signal.aborted) {
+ resolve({ kind: 'abort' });
+ return;
+ }
+ this._pendingApprovals.set(stageId, resolve);
+ }),
+ };
+ // 일반 새 turn vs 재개 turn 분기. 재개 시 _resume.json에서 plan + cursor를
+ // 복원해 그 다음 stage부터 dispatch가 이어진다. resumeCompanyTurn이 null을
+ // 돌려주면(파일 없음·이미 완료 등) 사용자에게 알리고 종료.
+ if (resumeTimestamp) {
+ const result = await resumeCompanyTurn(resumeTimestamp, deps);
+ if (!result) {
+ this._view?.webview.postMessage({
+ type: 'error',
+ value: '재개 가능한 세션 정보를 찾지 못했습니다 (이미 완료되었거나 파일이 손상되었을 수 있습니다).',
+ });
+ }
+ } else {
+ await runCompanyTurn(userPrompt, deps);
+ }
} catch (e: any) {
logError('company.runTurn: unexpected failure.', { error: e?.message ?? String(e) });
this._view?.webview.postMessage({
@@ -1737,6 +1756,39 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
// with the red Stop button after the round completes.
this._view?.webview.postMessage({ type: 'streamEnd' });
void this._sendReadyStatus();
+ // turn이 끝났으면(완료든 abort든) resume 가능 세션 목록을 새로 푸시 —
+ // 방금 abort된 세션이 곧장 목록에 떠야 하므로.
+ void this._sendCompanyResumable();
+ }
+ }
+
+ /**
+ * Webview에 "이어서 진행할 수 있는 세션" 목록을 push. 관리 패널이 열릴 때와 turn이
+ * 끝날 때마다 호출됨. 빈 목록도 그대로 보내서 UI가 섹션을 자동으로 숨길 수 있게 함.
+ */
+ async _sendCompanyResumable(): Promise {
+ if (!this._view) return;
+ try {
+ const items = listResumableSessions(this._context).map((s) => ({
+ timestamp: s.timestamp,
+ userPrompt: s.userPrompt.slice(0, 200),
+ pipelineId: s.pipelineId,
+ pipelineName: s.pipelineId
+ ? (readCompanyState(this._context).pipelines?.[s.pipelineId]?.name ?? s.pipelineId)
+ : null,
+ completedCount: s.agentOutputs.length,
+ totalCount: s.plan.tasks.length,
+ status: s.status,
+ abortReason: s.abortReason ?? '',
+ lastUpdatedAt: s.lastUpdatedAt,
+ startedAt: s.startedAt,
+ }));
+ this._view.webview.postMessage({
+ type: 'companyResumable',
+ value: { items },
+ });
+ } catch (e: any) {
+ logError('company._sendCompanyResumable failed.', { error: e?.message ?? String(e) });
}
}