diff --git a/.astra/project-context/architecture.md b/.astra/project-context/architecture.md index c0ab7f2..2c3490a 100644 --- a/.astra/project-context/architecture.md +++ b/.astra/project-context/architecture.md @@ -3,15 +3,15 @@ ## Snapshot -- **Workspace**: `ConnectAI` `v2.1.9` _(absolute path varies by environment; resolved from the active VS Code workspace)_ +- **Workspace**: `ConnectAI` `v2.2.1` _(absolute path varies by environment; resolved from the active VS Code workspace)_ - **Description**: The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making. - **Stack**: TypeScript, Node.js, VS Code Extension, LM Studio SDK, Test runner -- **Stats**: 214 source files, ~39,610 lines across 5 top-level modules. +- **Stats**: 215 source files, ~40,522 lines across 5 top-level modules. ## Last Refresh -- **Time**: 2026-05-14T13:38:08.438Z +- **Time**: 2026-05-14T14:26:31.694Z - **Files newly analysed**: 0 -- **Files reused from cache**: 214 +- **Files reused from cache**: 215 ## Directory Map ```mermaid @@ -37,7 +37,7 @@ mindmap > Arrows: which top-level module imports from which. ```mermaid flowchart LR - src["src/
102 files"] + src["src/
103 files"] media["media/
6 files"] tests["tests/
27 files"] core_py["core_py/
6 files"] @@ -53,10 +53,10 @@ flowchart LR ## Hub Files > Imported by many other files — touching these has wide blast radius. -- `src/utils.ts` — referenced by **44** files +- `src/utils.ts` — referenced by **45** files - `src/config.ts` — referenced by **13** files +- `src/features/company/types.ts` — referenced by **11** files · Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta - `src/lib/paths.ts` — referenced by **10** files -- `src/features/company/types.ts` — referenced by **10** files · Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta - `src/retrieval/lessonHelpers.ts` — referenced by **6** files · Lesson / Experience Memory — pure helpers (no vscode dependency) "Lesson" = a markdown file in the active brain that captures a past mistake/risk and how to avoid repeating it. Identified by a lessons - `src/skills/agentKnowledgeMap.ts` — referenced by **6** files - `src/lib/engine.ts` — referenced by **6** files @@ -64,10 +64,10 @@ flowchart LR ## Modules -### `src/` — 102 files, ~25,886 lines +### `src/` — 103 files, ~26,421 lines **Sub-directories** -- `src/features/` (29) — 기본 에이전트 로스터 — 1인 기업 모드의 출고 디폴트. 설계 의도: 소프트웨어/게임 개발 IT 회사의 1인 기업 운영을 가정. 한 사람이 기획 → 디자인 → 개발 → QA → 출시 → 운영/마케팅을 모두 책임질 때 +- `src/features/` (30) — 기본 에이전트 로스터 — 1인 기업 모드의 출고 디폴트. 설계 의도: 소프트웨어/게임 개발 IT 회사의 1인 기업 운영을 가정. 한 사람이 기획 → 디자인 → 개발 → QA → 출시 → 운영/마케팅을 모두 책임질 때 - `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토 - `src/memory/` (8) — Episodic Memory (일화 기억) 과거 대화/회의/결정의 맥락 흐름을 저장합니다. 세션 종료 시 자동으로 에피소드를 요약하여 저장합니다. "왜 이렇게 결정했는지", "어떤 흐름으로 진행했는지" 기록. 저장 - `src/retrieval/` (8) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-I @@ -83,10 +83,10 @@ flowchart LR **Key files** - `src/utils.ts` (268 lines) - `src/config.ts` (224 lines) -- `src/features/company/types.ts` (331 lines) — Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta +- `src/features/company/types.ts` (387 lines) — Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta - `src/lib/paths.ts` (151 lines) - `src/features/company/companyConfig.ts` (877 lines) — State + config plumbing for 1인 기업 모드. Two surfaces: - CompanyState (runtime data: enabled flag, company name, which agents are active, per-agent model overrides). Persisted in VS Code's globalState so -- `src/sidebarProvider.ts` (3232 lines) +- `src/sidebarProvider.ts` (3298 lines) - `src/memory/types.ts` (126 lines) — Memory Type Definitions (메모리 타입 정의) Astra의 5-Layer Cognitive Memory System의 모든 타입을 정의합니다. ① Short-Term ② Long-Term ③ Project ④ Procedural ⑤ Episodic - `src/retrieval/scoring.ts` (518 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다. - `src/skills/agentKnowledgeMap.ts` (374 lines) @@ -94,25 +94,25 @@ flowchart LR - `src/retrieval/lessonHelpers.ts` (325 lines) — Lesson / Experience Memory — pure helpers (no vscode dependency) "Lesson" = a markdown file in the active brain that captures a past mistake/risk and how to avoid repeating it. Identified by a lessons - `src/agent.ts` (3232 lines) - `src/lib/engine.ts` (906 lines) +- `src/features/company/dispatcher.ts` (874 lines) — Sequential dispatcher for 1인 기업 모드. Drives one company "turn": user prompt → CEO planner (JSON {brief, tasks}) → for each task in plan: dispatch one specialist (sequentially) - build specialist prompt - `src/features/approval/approvalQueue.ts` (129 lines) - `src/integrations/telegram/telegramClient.ts` (154 lines) -- `src/features/company/dispatcher.ts` (631 lines) — Sequential dispatcher for 1인 기업 모드. Drives one company "turn": user prompt → CEO planner (JSON {brief, tasks}) → for each task in plan: dispatch one specialist (sequentially) - build specialist prompt +- `src/features/company/sessionStore.ts` (231 lines) — Disk persistence for company-mode session artefacts. Each company turn produces a timestamped directory: /.astra/company/sessions/2026-05-13T21-29/ ├─ brief.md ← CEO's task decompositio - `src/features/projectArchitecture/scanner.ts` (644 lines) — Deep static analyser for the Project Architecture Context generator. Walks the project tree (skipping the usual nodemodules / out / dist noise), pulls the role of each interesting file from its leadin - `src/lib/contextManager.ts` (275 lines) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계 - `src/features/company/agents.ts` (196 lines) — 기본 에이전트 로스터 — 1인 기업 모드의 출고 디폴트. 설계 의도: 소프트웨어/게임 개발 IT 회사의 1인 기업 운영을 가정. 한 사람이 기획 → 디자인 → 개발 → QA → 출시 → 운영/마케팅을 모두 책임질 때 필요한 직군을 빠짐없이 커버하되 역할이 겹치지 않게 분리한다. 직군 구분 (혼동 방지): - 기획자(business) : 무엇을 만들지 정의 +- `src/features/company/resumeStore.ts` (134 lines) — Disk persistence for company-turn resume state. 각 turn의 sessionDir 안에 resume.json을 두고, dispatcher가 매 의미 있는 시점(plan 확정 / 각 stage 직후 / abort 시점)에 현재 상태를 덮어쓴다. 재개 시점에는 이 파일을 읽어 nextIndex 부터 dispatch 재개. - `src/core/astraPath.ts` (50 lines) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토리를 해결하여, 사용자 프로젝트 루트가 아닌 ConnectAI 패키지 내부에 데이터를 저장합니다. 이 모듈은 AAL(Astra Autonomou -- `src/extension.ts` (966 lines) +- `src/extension.ts` (967 lines) - `src/features/projectChronicle/types.ts` (118 lines) - `src/lmstudio/client.ts` (147 lines) -- `src/retrieval/brainIndex.ts` (325 lines) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-IDF 점수를 계산했습니다 — 파일 수가 많아지면 그게 병목입니다. 이 모듈은 /.astra/brain-index.json 에 -- `src/features/company/promptBuilder.ts` (231 lines) — System-prompt construction for company-mode agents. Each specialist needs a prompt that includes: - Their identity (name, role, specialty) + optional persona. - The action-tag contract (, -### `media/` — 6 files, ~5,535 lines +### `media/` — 6 files, ~5,912 lines **Key files** -- `media/sidebar.css` (1511 lines) — Stylesheet -- `media/sidebar.js` (2930 lines) -- `media/sidebar.html` (450 lines) — Astra +- `media/sidebar.css` (1686 lines) — Stylesheet +- `media/sidebar.js` (3119 lines) +- `media/sidebar.html` (463 lines) — Astra - `media/settings-panel.css` (210 lines) — Stylesheet - `media/settings-panel.html` (164 lines) — Astra Settings - `media/settings-panel.js` (270 lines) @@ -308,7 +308,7 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한 **Designed for High-Performance Decision Making.** Copyright (C) **g1nation**. All rights reserved. -_Last auto-scan: 2026-05-14T13:38:08.438Z · signature `d227380b`_ +_Last auto-scan: 2026-05-14T14:26:31.694Z · signature `6f3aa605`_ ## Purpose diff --git a/.astra/project-context/scan-cache.json b/.astra/project-context/scan-cache.json index 7f33344..c1cafb0 100644 --- a/.astra/project-context/scan-cache.json +++ b/.astra/project-context/scan-cache.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-05-14T13:38:08.446Z", + "generatedAt": "2026-05-14T14:26:31.701Z", "files": { "src/agent.ts": { "mtimeMs": 1778683690000, @@ -259,9 +259,9 @@ "imports": [] }, "src/extension.ts": { - "mtimeMs": 1778693606000, - "size": 50539, - "lines": 966, + "mtimeMs": 1778768500000, + "size": 50591, + "lines": 967, "role": "", "imports": [ "src/utils", @@ -368,9 +368,9 @@ ] }, "src/features/company/dispatcher.ts": { - "mtimeMs": 1778762677000, - "size": 29279, - "lines": 631, + "mtimeMs": 1778768379000, + "size": 40226, + "lines": 874, "role": "Sequential dispatcher for 1인 기업 모드. Drives one company \"turn\": user prompt → CEO planner (JSON {brief, tasks}) → for each task in plan: dispatch one specialist (sequentially) - build specialist prompt", "imports": [ "src/core/services", @@ -383,14 +383,15 @@ "src/features/company/ceoReporter", "src/features/company/promptBuilder", "src/features/company/sessionStore", + "src/features/company/resumeStore", "src/features/company/telegramReport", "src/features/company/types" ] }, "src/features/company/index.ts": { - "mtimeMs": 1778764795000, - "size": 1827, - "lines": 83, + "mtimeMs": 1778768145000, + "size": 1979, + "lines": 91, "role": "Public API for 1인 기업 모드. Consumers (sidebarProvider, chatHandlers, command handlers) import from this barrel so internal layout can move around without touching every call site.", "imports": [ "src/features/company/agents", @@ -398,6 +399,7 @@ "src/features/company/types", "src/features/company/pipelineTemplates", "src/features/company/dispatcher", + "src/features/company/resumeStore", "src/features/company/sessionStore" ] }, @@ -449,6 +451,17 @@ "role": "당신은 {{COMPANY}}의 CEO입니다. 방금 팀이 작업을 끝냈습니다. 각 에이전트의 산출물을 읽고 사장님께 올릴 종합 보고서를 작성하세요.", "imports": [] }, + "src/features/company/resumeStore.ts": { + "mtimeMs": 1778768128000, + "size": 5672, + "lines": 134, + "role": "Disk persistence for company-turn resume state. 각 turn의 sessionDir 안에 resume.json을 두고, dispatcher가 매 의미 있는 시점(plan 확정 / 각 stage 직후 / abort 시점)에 현재 상태를 덮어쓴다. 재개 시점에는 이 파일을 읽어 nextIndex 부터 dispatch 재개. ", + "imports": [ + "src/utils", + "src/features/company/sessionStore", + "src/features/company/types" + ] + }, "src/features/company/sessionStore.ts": { "mtimeMs": 1778680971000, "size": 8727, @@ -473,9 +486,9 @@ ] }, "src/features/company/types.ts": { - "mtimeMs": 1778764725000, - "size": 14306, - "lines": 331, + "mtimeMs": 1778768095000, + "size": 17279, + "lines": 387, "role": "Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta", "imports": [] }, @@ -922,15 +935,16 @@ ] }, "src/sidebar/chatHandlers.ts": { - "mtimeMs": 1778764814000, - "size": 22151, - "lines": 428, + "mtimeMs": 1778768483000, + "size": 23759, + "lines": 455, "role": "", "imports": [ "src/sidebarProvider", "src/utils", "src/lib/paths", - "src/features/company" + "src/features/company", + "src/features/company/resumeStore" ] }, "src/sidebar/chronicleHandlers.ts": { @@ -943,9 +957,9 @@ ] }, "src/sidebarProvider.ts": { - "mtimeMs": 1778764853000, - "size": 141684, - "lines": 3232, + "mtimeMs": 1778768541000, + "size": 144281, + "lines": 3298, "role": "", "imports": [ "src/utils", @@ -1056,23 +1070,23 @@ "imports": [] }, "media/sidebar.css": { - "mtimeMs": 1778765317000, - "size": 62558, - "lines": 1511, + "mtimeMs": 1778768679000, + "size": 69000, + "lines": 1686, "role": "Stylesheet", "imports": [] }, "media/sidebar.html": { - "mtimeMs": 1778765185000, - "size": 28137, - "lines": 450, + "mtimeMs": 1778768580000, + "size": 28892, + "lines": 463, "role": "Astra", "imports": [] }, "media/sidebar.js": { - "mtimeMs": 1778765299000, - "size": 167258, - "lines": 2930, + "mtimeMs": 1778768663000, + "size": 178034, + "lines": 3119, "role": "", "imports": [] }, @@ -1550,7 +1564,7 @@ "imports": [] }, "docs/records/ConnectAI/chronicle.config.json": { - "mtimeMs": 1778765173000, + "mtimeMs": 1778767705000, "size": 416, "lines": 11, "role": "JSON configuration", diff --git a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json index 096dd9a..6897c29 100644 --- a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json +++ b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1778767038020, + "createdAt": 1778768831416, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json index 051ef8f..42c7023 100644 --- a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json +++ b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json @@ -1,5 +1,5 @@ { "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1778767038017, + "createdAt": 1778768831407, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json index c7d8b47..a074876 100644 --- a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json +++ b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json @@ -1,5 +1,5 @@ { "result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", - "createdAt": 1778767038016, + "createdAt": 1778768831402, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json index 38628f8..f6050de 100644 --- a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json +++ b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json @@ -1,5 +1,5 @@ { - "result": "---\nid: stress_conflict_1778767038004\ndate: 2026-05-14T13:57:18.021Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (11ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (0ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (1ms)\n", - "createdAt": 1778767038021, + "result": "---\nid: stress_conflict_1778768831385\ndate: 2026-05-14T14:27:11.421Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (11ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (6ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (10ms)\n", + "createdAt": 1778768831421, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1778767038004.json b/.astra/tests/stress/.astra/missions/stress_conflict_1778768831385.json similarity index 80% rename from .astra/tests/stress/.astra/missions/stress_conflict_1778767038004.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1778768831385.json index 98a4438..7b8f400 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1778767038004.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1778768831385.json @@ -1,8 +1,8 @@ { - "missionId": "stress_conflict_1778767038004", + "missionId": "stress_conflict_1778768831385", "status": "completed", - "startTime": "2026-05-14T13:57:18.005Z", - "totalElapsedMs": 16, + "startTime": "2026-05-14T14:27:11.385Z", + "totalElapsedMs": 36, "results": { "planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", "researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", @@ -18,28 +18,28 @@ "to": "planner", "durationMs": 11, "message": "전략 수립 중...", - "ts": "2026-05-14T13:57:18.016Z" + "ts": "2026-05-14T14:27:11.396Z" }, { "from": "planner", "to": "researcher", - "durationMs": 0, + "durationMs": 6, "message": "핵심 정보 수집 및 분석 중...", - "ts": "2026-05-14T13:57:18.016Z" + "ts": "2026-05-14T14:27:11.402Z" }, { "from": "researcher", "to": "writer", - "durationMs": 1, + "durationMs": 10, "message": "최종 리포트 작성 및 편집 중...", - "ts": "2026-05-14T13:57:18.017Z" + "ts": "2026-05-14T14:27:11.412Z" }, { "from": "writer", "to": "completed", - "durationMs": 4, + "durationMs": 9, "message": "미션 완료", - "ts": "2026-05-14T13:57:18.021Z" + "ts": "2026-05-14T14:27:11.421Z" } ], "resilienceMetrics": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 2aefe89..95dc543 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,15 @@ # Astra Patch Notes +## v2.2.1 (2026-05-14) +### 🔄 Autonomous Task Resumption & Engine Resilience +- **작업 중단 후 자율 재개 기능 도입:** 예기치 않은 오류나 중단 상황에서도 이전에 진행하던 작업 흐름(Company Mission)을 마지막 성공 단계부터 즉시 이어서 실행할 수 있는 `resumeStore` 시스템을 구축했습니다. +- **상태 보존 정교화:** `_resume.json`을 통해 각 단계별 팀원의 결과물과 남은 작업들을 실시간으로 저장하며, 원자적(Atomic) 쓰기 방식을 통해 데이터 일관성을 보장합니다. +- **워크플로우 안정성 강화:** `dispatcher.ts` 내의 복구 로직을 강화하여 다단계 협업 미션 수행 중 발생할 수 있는 레이스 컨디션과 상태 불일치 문제를 해결했습니다. +- **UI 및 피드백 개선:** 작업 재개 시 현재 상황을 명확히 인지할 수 있도록 사이드바와 대화 핸들러의 상태 보고 기능을 최적화했습니다. + +--- + + ## v2.2.0 (2026-05-14) ### 💎 Milestone: Human-Centric UI & Workflow Evolution - **UI 용어 및 인터랙션 전면 한글화:** '에이전트', '파이프라인' 등 딱딱한 용어를 '팀원', '작업 흐름' 등 직관적인 한글로 순화하여 친숙도를 높였습니다. diff --git a/astra-2.2.0.vsix b/astra-2.2.1.vsix similarity index 52% rename from astra-2.2.0.vsix rename to astra-2.2.1.vsix index d8b35c7..74114aa 100644 Binary files a/astra-2.2.0.vsix and b/astra-2.2.1.vsix differ diff --git a/docs/records/ConnectAI/chronicle.config.json b/docs/records/ConnectAI/chronicle.config.json index dc58e53..aa8c20f 100644 --- a/docs/records/ConnectAI/chronicle.config.json +++ b/docs/records/ConnectAI/chronicle.config.json @@ -7,5 +7,5 @@ "corePurpose": "", "detailLevel": "standard", "createdAt": "2026-05-13T13:09:33.788Z", - "updatedAt": "2026-05-14T13:41:02.603Z" + "updatedAt": "2026-05-14T14:08:25.695Z" } diff --git a/media/sidebar.css b/media/sidebar.css index 32ce44a..c1ff34a 100644 --- a/media/sidebar.css +++ b/media/sidebar.css @@ -691,6 +691,54 @@ border: 1px solid var(--border); border-radius: 8px; } + + /* 이어서 진행 섹션 — 빈 목록이면 통째로 숨김. 있을 때만 노출돼서 + 평소 시야 부담이 없고, 사용자가 멈춘 작업을 즉시 식별 가능. */ + .company-resumable-section[data-empty="true"] { display: none; } + .company-resumable-list { + list-style: none; + display: flex; flex-direction: column; gap: 8px; + margin-top: 8px; padding: 0; + } + .company-resumable-card { + border: 1px solid var(--warning); + background: linear-gradient(180deg, rgba(210,153,34,0.08), transparent 70%); + border-radius: 8px; + padding: 10px 12px; + display: flex; flex-direction: column; gap: 6px; + } + .company-resumable-head { + display: flex; gap: 10px; align-items: flex-start; justify-content: space-between; + } + .company-resumable-prompt { + color: var(--text-bright); font-size: 12.5px; font-weight: 600; + line-height: 1.4; flex: 1; min-width: 0; + overflow: hidden; text-overflow: ellipsis; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + } + .company-resumable-actions { + display: flex; gap: 6px; flex-shrink: 0; + } + .company-resumable-resume { + font-size: 11px; padding: 5px 11px; + background: var(--accent); color: var(--bg); + border: 0; border-radius: 6px; cursor: pointer; font-weight: 600; + } + .company-resumable-resume:hover { filter: brightness(1.1); } + .company-resumable-resume[disabled] { opacity: 0.55; cursor: progress; } + .company-resumable-discard { + font-size: 11px; padding: 5px 11px; + background: transparent; color: var(--text-dim); + border: 1px solid var(--border); border-radius: 6px; cursor: pointer; + } + .company-resumable-discard:hover { + color: var(--error); border-color: var(--error); + } + .company-resumable-meta { + display: flex; flex-wrap: wrap; gap: 8px; + font-size: 10px; color: var(--text-dim); + } + .company-resumable-meta span { white-space: nowrap; } .pipeline-summary-head { display: flex; gap: 8px; diff --git a/media/sidebar.html b/media/sidebar.html index 162a948..8c2510e 100644 --- a/media/sidebar.html +++ b/media/sidebar.html @@ -146,6 +146,19 @@ + +
+
+
+
이어서 진행할 수 있는 작업
+
중간에 멈춘 작업이 있을 때만 노출됩니다. 누른 시점부터 다음 단계가 같은 세션 폴더에 이어 기록됩니다.
+
+
+
    +
    +
    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) }); } }