Astra v2.2.52
- 채팅 기록 목록 누락 수정: 후처리 예외로 _saveCurrentSession 이 건너뛰던 회귀를 try/finally 로 보장, _saveCurrentSession 자체도 throw 방지. 1인 기업 모드 업무 턴(_runCompanyTurn)도 요청/보고서 쌍으로 기록 (_saveCompanyTurnSession). - Self-Reflector 실행 검증 크로스플랫폼화: .py 는 python3 자동 탐지, .ts 는 로컬 node_modules/typescript/bin/tsc 직접 호출. - 버전 2.2.52 상향 + package-lock 동기화 + 재패키징. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
* 1. action-tag executor가 반환한 report를 받아 `✅ Created: <path>` /
|
||||
* `✅ Edited: <path>` 항목에서 경로를 추출
|
||||
* 2. 파일 확장자별 toolchain 선택:
|
||||
* .py → `python -m py_compile <path>`
|
||||
* .py → `python3 -m py_compile <path>` (`python` on Windows)
|
||||
* .js / .mjs / .cjs → `node --check <path>`
|
||||
* .ts / .tsx → 프로젝트 단위 `tsc --noEmit` (단일 파일 체크는 의존성 때문에 실패율 높음)
|
||||
* .json → `JSON.parse` (node)
|
||||
@@ -21,7 +21,7 @@
|
||||
* - 워크스페이스 외부 경로 무시
|
||||
* - 사용자가 `executionVerification=false`면 통째로 skip — 호출자가 가드
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
@@ -76,11 +76,40 @@ function _firstNonEmptyLine(s: string): string {
|
||||
return (s || '').split(/\r?\n/).map((x) => x.trim()).find((x) => x.length > 0) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Python executable name once and cache it.
|
||||
*
|
||||
* macOS 12.3+ removed the bare `/usr/bin/python`; it ships `python3` only.
|
||||
* Windows installers, conversely, expose `python` (and often no `python3`).
|
||||
* So probe the platform-preferred name first, fall back to the other.
|
||||
*
|
||||
* Returns the platform-preferred default when neither is found so the caller
|
||||
* still produces the usual "미설치" warning via the spawn-failure path.
|
||||
*/
|
||||
let _pythonCmdCache: string | undefined;
|
||||
function _resolvePythonCmd(): string {
|
||||
if (_pythonCmdCache !== undefined) return _pythonCmdCache;
|
||||
const candidates = process.platform === 'win32'
|
||||
? ['python', 'python3']
|
||||
: ['python3', 'python'];
|
||||
for (const cmd of candidates) {
|
||||
try {
|
||||
const r = spawnSync(cmd, ['--version'], { stdio: 'ignore', windowsHide: true });
|
||||
if (!r.error && (r.status === 0 || r.status === null)) {
|
||||
_pythonCmdCache = cmd;
|
||||
return cmd;
|
||||
}
|
||||
} catch { /* try next candidate */ }
|
||||
}
|
||||
_pythonCmdCache = candidates[0];
|
||||
return _pythonCmdCache;
|
||||
}
|
||||
|
||||
/** 확장자별 검사 명령 결정. 지원 안 하는 확장자면 null 반환 (skip). */
|
||||
function _pickTool(absPath: string, projectRoot: string): { cmd: string; args: string[]; cwd: string; label: string } | null {
|
||||
const ext = path.extname(absPath).toLowerCase();
|
||||
if (ext === '.py') {
|
||||
return { cmd: 'python', args: ['-m', 'py_compile', absPath], cwd: projectRoot, label: 'py_compile' };
|
||||
return { cmd: _resolvePythonCmd(), args: ['-m', 'py_compile', absPath], cwd: projectRoot, label: 'py_compile' };
|
||||
}
|
||||
if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
|
||||
return { cmd: 'node', args: ['--check', absPath], cwd: projectRoot, label: 'node --check' };
|
||||
@@ -99,8 +128,14 @@ function _pickTool(absPath: string, projectRoot: string): { cmd: string; args: s
|
||||
// 비용은 더 크지만 실제 사용자 환경에서 의미 있는 결과를 낸다.
|
||||
const tsconfig = path.join(projectRoot, 'tsconfig.json');
|
||||
if (!fs.existsSync(tsconfig)) return null;
|
||||
// `npx` is a `.cmd` shim on Windows and won't spawn under shell:false,
|
||||
// so invoke the TypeScript compiler's plain-JS bin script via `node`
|
||||
// directly — identical behavior on macOS, Linux and Windows. If the
|
||||
// project has no local typescript install there is nothing to run.
|
||||
const tscBin = path.join(projectRoot, 'node_modules', 'typescript', 'bin', 'tsc');
|
||||
if (!fs.existsSync(tscBin)) return null;
|
||||
return {
|
||||
cmd: 'npx', args: ['--no-install', 'tsc', '--noEmit', '-p', tsconfig],
|
||||
cmd: 'node', args: [tscBin, '--noEmit', '-p', tsconfig],
|
||||
cwd: projectRoot, label: 'tsc --noEmit',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,9 +191,17 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
||||
}
|
||||
return true;
|
||||
}
|
||||
await provider._handlePrompt(data);
|
||||
await provider._autoWriteChronicleAfterPrompt();
|
||||
await provider._saveCurrentSession();
|
||||
try {
|
||||
await provider._handlePrompt(data);
|
||||
await provider._autoWriteChronicleAfterPrompt();
|
||||
} finally {
|
||||
// Persist the session even if _handlePrompt or the chronicle
|
||||
// auto-write throws *after* the answer already streamed —
|
||||
// otherwise the reply shows in the UI but never lands in the
|
||||
// 기록(Chat History) list. This is the regression that made
|
||||
// recent conversations stop appearing.
|
||||
await provider._saveCurrentSession();
|
||||
}
|
||||
return true;
|
||||
case 'activity':
|
||||
provider._lmStudio?.activity.bump();
|
||||
|
||||
+73
-4
@@ -931,6 +931,17 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
}
|
||||
|
||||
async _saveCurrentSession() {
|
||||
try {
|
||||
await this._saveCurrentSessionInner();
|
||||
} catch (err: any) {
|
||||
// Never let a persistence failure escape — callers run this from a
|
||||
// `finally` block, so a throw here would mask the original error
|
||||
// and (worse) is itself the thing that drops chats from 기록.
|
||||
logError('Failed to save current chat session.', { error: err?.message ?? String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
private async _saveCurrentSessionInner() {
|
||||
const history = this._agent.getHistory();
|
||||
if (history.length === 0) return;
|
||||
|
||||
@@ -2358,9 +2369,14 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
type: 'companyIntentDecision',
|
||||
value: { intent, reason, label },
|
||||
});
|
||||
await this._handlePrompt(originalData);
|
||||
await this._autoWriteChronicleAfterPrompt();
|
||||
await this._saveCurrentSession();
|
||||
try {
|
||||
await this._handlePrompt(originalData);
|
||||
await this._autoWriteChronicleAfterPrompt();
|
||||
} finally {
|
||||
// Same guarantee as the normal chat path — a throw after the
|
||||
// answer streamed must not skip the 기록 save.
|
||||
await this._saveCurrentSession();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2561,12 +2577,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
// 잡아낸다 — turn이 중간 abort되면 plan만 남고 reportTail은 비어
|
||||
// 있게 되는데, 그 상태로도 followup 매칭에는 충분히 도움된다.
|
||||
let stagingBrief = '';
|
||||
// Full final report of this turn — captured so the company turn can be
|
||||
// recorded in the 기록(Chat History) list, same as a normal chat. The
|
||||
// dispatcher runs outside AgentExecutor, so `_agent` history stays
|
||||
// empty and `_saveCurrentSession()` would save nothing for it.
|
||||
let finalReport = '';
|
||||
const emit = (event: CompanyTurnEvent) => {
|
||||
this._view?.webview.postMessage({ type: 'companyTurnUpdate', value: event });
|
||||
if (event.phase === 'plan-ready') {
|
||||
stagingBrief = event.plan?.brief || '';
|
||||
} else if (event.phase === 'report-done') {
|
||||
const tail = (event.report || '').trim().slice(-600);
|
||||
finalReport = (event.report || '').trim();
|
||||
const tail = finalReport.slice(-600);
|
||||
this._lastCompanyTurnSummary = {
|
||||
brief: stagingBrief,
|
||||
reportTail: tail,
|
||||
@@ -2654,6 +2676,53 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
// turn이 끝났으면(완료든 abort든) resume 가능 세션 목록을 새로 푸시 —
|
||||
// 방금 abort된 세션이 곧장 목록에 떠야 하므로.
|
||||
void this._sendCompanyResumable();
|
||||
// 완료된 회사 turn을 채팅 기록(기록 목록)에도 한 줄로 남긴다 — 보고서가
|
||||
// 나온 경우(report-done)에만. abort돼서 보고서가 없으면 기록 목록은
|
||||
// 건드리지 않고 resume 목록에만 남는다.
|
||||
if (finalReport) {
|
||||
void this._saveCompanyTurnSession(userPrompt, finalReport);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a completed 1인 기업 turn as a standalone entry in the 기록(Chat
|
||||
* History) list. Company turns run through the dispatcher, not AgentExecutor,
|
||||
* so they never populate `_agent` history and `_saveCurrentSession()` can't
|
||||
* see them — without this they only ever appeared in the resume list.
|
||||
*
|
||||
* Each turn gets its own entry (it doesn't touch `_currentSessionId`): a
|
||||
* dispatch is a discrete unit of work, not a back-and-forth chat thread.
|
||||
*/
|
||||
async _saveCompanyTurnSession(userPrompt: string, report: string) {
|
||||
try {
|
||||
const prompt = (userPrompt || '').trim();
|
||||
const body = (report || '').trim();
|
||||
if (!prompt && !body) return;
|
||||
|
||||
const history: ChatMessage[] = [
|
||||
{ role: 'user', content: prompt || '(요청 내용 없음)' },
|
||||
{ role: 'assistant', content: body || '(보고서가 생성되지 않았습니다.)' },
|
||||
];
|
||||
const title = prompt
|
||||
? prompt.substring(0, 50).replace(/\n/g, ' ') + (prompt.length > 50 ? '...' : '')
|
||||
: '1인 기업 업무';
|
||||
const brainProfileId = this._currentSessionBrainId || getActiveBrainProfile().id;
|
||||
|
||||
let sessions = this._getSessions();
|
||||
sessions.unshift({
|
||||
id: Date.now().toString(),
|
||||
title,
|
||||
timestamp: Date.now(),
|
||||
history,
|
||||
brainProfileId,
|
||||
negativePrompt: this._currentNegativePrompt,
|
||||
});
|
||||
if (sessions.length > 50) sessions = sessions.slice(0, 50);
|
||||
await this._putSessions(sessions);
|
||||
await this._sendSessionList();
|
||||
} catch (err: any) {
|
||||
logError('Failed to record company turn into chat history.', { error: err?.message ?? String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user