Release v2.2.1: Autonomous Task Resumption & Engine Resilience
This commit is contained in:
@@ -3,15 +3,15 @@
|
||||
<!-- ASTRA:AUTO-START -->
|
||||
|
||||
## 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/<br/>102 files"]
|
||||
src["src/<br/>103 files"]
|
||||
media["media/<br/>6 files"]
|
||||
tests["tests/<br/>27 files"]
|
||||
core_py["core_py/<br/>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: <workspaceRoot>/.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 점수를 계산했습니다 — 파일 수가 많아지면 그게 병목입니다. 이 모듈은 <brainPath>/.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 (<createfile>,
|
||||
|
||||
### `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`_
|
||||
<!-- ASTRA:AUTO-END -->
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1778767038020,
|
||||
"createdAt": 1778768831416,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
"createdAt": 1778767038017,
|
||||
"createdAt": 1778768831407,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
||||
"createdAt": 1778767038016,
|
||||
"createdAt": 1778768831402,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+10
-10
@@ -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": {
|
||||
@@ -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 용어 및 인터랙션 전면 한글화:** '에이전트', '파이프라인' 등 딱딱한 용어를 '팀원', '작업 흐름' 등 직관적인 한글로 순화하여 친숙도를 높였습니다.
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -146,6 +146,19 @@
|
||||
<button class="icon-btn" id="closeCompanyOverlayBtn">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- 이어서 진행할 수 있는 미완 작업. 빈 목록이면 JS가 섹션 자체를 숨겨서
|
||||
평소에는 시야에 들어오지 않는다. 각 카드는 [이어서 진행] / [버리기]
|
||||
두 버튼만 노출. -->
|
||||
<div id="companyResumableSection" class="map-section company-resumable-section" data-empty="true">
|
||||
<div class="map-section-head">
|
||||
<div>
|
||||
<div class="map-section-title">이어서 진행할 수 있는 작업</div>
|
||||
<div class="map-section-hint">중간에 멈춘 작업이 있을 때만 노출됩니다. 누른 시점부터 다음 단계가 같은 세션 폴더에 이어 기록됩니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul id="companyResumableList" class="map-list company-resumable-list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="map-section">
|
||||
<div class="map-section-head">
|
||||
<div>
|
||||
|
||||
@@ -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 = `<span>${pipelineLabel}</span><span>${escAttr(progress)}</span><span>${escAttr(when)} ${escAttr(why)}</span>`;
|
||||
li.appendChild(meta);
|
||||
|
||||
list.appendChild(li);
|
||||
}
|
||||
};
|
||||
window.__renderCompanyResumable = renderCompanyResumable;
|
||||
// 템플릿 stamp 시 호출 — id/name 제안값 + stages를 카드 에디터에 미리 채움.
|
||||
window.__openPipelineEditorWithTemplate = (tpl) => {
|
||||
if (!tpl) return;
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,37 +165,109 @@ 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<SessionResult> {
|
||||
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;
|
||||
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 {
|
||||
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
|
||||
@@ -198,7 +276,6 @@ export async function runCompanyTurn(
|
||||
brief: `[Pipeline: ${pipeline.name}] ${userPrompt.slice(0, 200)}`,
|
||||
tasks: pipeline.stages.map((s) => ({ agent: s.agentId, task: s.label })),
|
||||
};
|
||||
plannerParsed = true;
|
||||
} else {
|
||||
const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel);
|
||||
const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, { model: ceoModel });
|
||||
@@ -206,25 +283,72 @@ export async function runCompanyTurn(
|
||||
plannerRaw = plannerResult.raw;
|
||||
plannerParsed = plannerResult.parsed;
|
||||
}
|
||||
if (isAborted()) return fail('aborted-after-plan');
|
||||
emit({
|
||||
phase: 'plan-ready',
|
||||
plan,
|
||||
parsed: plannerParsed,
|
||||
raw: plannerRaw,
|
||||
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);
|
||||
}
|
||||
const pipelineId = pipeline?.id ?? null;
|
||||
|
||||
// 초기 resume 상태(또는 resume 진행 시작 상태) 영속화.
|
||||
persistResume('in-progress', {
|
||||
plan, pipelineId,
|
||||
outputs: seed?.agentOutputs ?? [],
|
||||
nextIndex: seed?.nextIndex ?? 0,
|
||||
pipelineContext: seed?.pipelineContext,
|
||||
});
|
||||
|
||||
// ── 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`, `<agentId>.md` 등)이 누적됨.
|
||||
*
|
||||
* @returns 복구 가능한 상태가 있으면 SessionResult, 아니면 null
|
||||
* (`_resume.json`이 없거나, 이미 'completed' 상태이거나, plan이 비어 있는 경우).
|
||||
*/
|
||||
export async function resumeCompanyTurn(
|
||||
timestamp: string,
|
||||
deps: DispatcherDeps,
|
||||
): Promise<SessionResult | null> {
|
||||
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.<id>}}` 치환에 사용. */
|
||||
latestByStage: Record<string, AgentTurnOutput>;
|
||||
/** stage id → loop-back 누적 횟수. */
|
||||
iterations: Record<string, number>;
|
||||
/** stage id → 사용자 수정요청 코멘트. */
|
||||
revisionNotes: Record<string, string>;
|
||||
/** 재개를 시작할 0-based stage index. */
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
/** _runPipeline이 매 stage 직후 호출하는 commit 콜백의 payload. */
|
||||
export interface PipelineCommit {
|
||||
outputs: AgentTurnOutput[];
|
||||
latestByStage: Record<string, AgentTurnOutput>;
|
||||
iterations: Record<string, number>;
|
||||
revisionNotes: Record<string, string>;
|
||||
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<string, AgentTurnOutput>;
|
||||
iterations: Record<string, number>;
|
||||
revisionNotes: Record<string, string>;
|
||||
};
|
||||
}> {
|
||||
const outputs: AgentTurnOutput[] = seed?.outputs ? [...seed.outputs] : [];
|
||||
// Keep the latest output per stage id so `{{stage.<id>}}` template
|
||||
// tokens always resolve to the most recent value across loop-backs.
|
||||
const latestByStage: Record<string, AgentTurnOutput> = {};
|
||||
const iterations: Record<string, number> = {};
|
||||
const latestByStage: Record<string, AgentTurnOutput> = seed?.latestByStage
|
||||
? { ...seed.latestByStage }
|
||||
: {};
|
||||
const iterations: Record<string, number> = 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<string, string> = {};
|
||||
let i = 0;
|
||||
let stepIndex = 0;
|
||||
const revisionNotes: Record<string, string> = 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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.<id>}}` 치환용). */
|
||||
latestByStage: Record<string, AgentTurnOutput>;
|
||||
/** stage.id → loop-back 누적 횟수. maxIterations 가드용. */
|
||||
iterations: Record<string, number>;
|
||||
/** stage.id → 사용자가 수정요청 시 추가한 코멘트. */
|
||||
revisionNotes: Record<string, string>;
|
||||
};
|
||||
/**
|
||||
* 마지막 상태. '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`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+56
-4
@@ -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<void> {
|
||||
async _runCompanyTurn(userPrompt: string, resumeTimestamp?: string): Promise<void> {
|
||||
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 `<create_file>` 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) => {
|
||||
awaitApproval: ({ stageId }: { stageId: string; stageLabel: string }) =>
|
||||
new Promise<ApprovalDecision>((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<void> {
|
||||
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) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user