feat: Stabilize Company Suite & Self-Reflection logic, integrate new ADRs and bug records

This commit is contained in:
2026-05-14 16:05:28 +09:00
parent f521c3f557
commit 618b8d5b34
33 changed files with 2203 additions and 655 deletions
+99 -19
View File
@@ -42,6 +42,9 @@ import {
CompanyTurnEvent,
COMPANY_AGENTS,
COMPANY_AGENT_ORDER,
ROLE_CATEGORY_LABELS,
ROLE_CATEGORY_ORDER,
resolveAgent,
} from './features/company';
import { AIService } from './core/services';
@@ -91,6 +94,14 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
_modelsCache: { url: string; models: string[]; online: boolean; fetchedAt: number } | null = null;
static readonly MODELS_CACHE_TTL_MS = 30000;
/**
* AbortController for the currently-running 1인 기업 turn. Cleared when
* the turn ends (success or fail). The webview's Stop button routes
* through `stopGeneration`, which calls `abortCompanyTurn()` to flip this
* — the dispatcher's `signal` then short-circuits between phases.
*/
private _companyAbort?: AbortController;
/** Active file-system watcher for the project-architecture auto-update. Disposed on switch/detach. */
private _archWatcher?: vscode.FileSystemWatcher;
/** Debounce timer for the architecture watcher. */
@@ -1453,6 +1464,49 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
return readCompanyState(this._context).enabled;
}
/**
* Abort the currently-running 1인 기업 turn if any. Returns true when an
* abort was actually fired (so the chat handler can skip `agent.stop()`
* — the company path never touches AgentExecutor). The dispatcher will
* see `signal.aborted` at its next phase boundary and emit
* `phase: 'aborted'`; `_runCompanyTurn`'s finally clause then posts
* `streamEnd` so the UI unlocks.
*/
abortCompanyTurn(): boolean {
if (!this._companyAbort) return false;
this._companyAbort.abort();
return true;
}
/**
* Push the full pipeline catalogue + active id to the webview so the
* editor overlay can render the cards. Pipelines are user-defined
* (no built-ins) so an empty list is the default for new users.
*/
async _sendCompanyPipelines(): Promise<void> {
if (!this._view) return;
const state = readCompanyState(this._context);
// 직군별 활성 에이전트도 같이 — 파이프라인 에디터가 "직군 → 담당자"
// cascading dropdown을 채울 때 이 페이로드만 보고 그릴 수 있게.
const { listActiveAgentsByCategory } = await import('./features/company');
const byCategory = listActiveAgentsByCategory(state);
// CompanyAgentDef를 통째로 보내는 대신 UI에 필요한 필드만 추려서.
const slimByCategory: Record<string, Array<{ id: string; name: string; emoji: string }>> = {};
for (const [cat, defs] of Object.entries(byCategory)) {
slimByCategory[cat] = defs.map((d) => ({ id: d.id, name: d.name, emoji: d.emoji }));
}
this._view.webview.postMessage({
type: 'companyPipelines',
value: {
pipelines: state.pipelines ?? {},
activePipelineId: state.activePipelineId ?? null,
roleCategoryLabels: ROLE_CATEGORY_LABELS,
roleCategoryOrder: ROLE_CATEGORY_ORDER,
activeAgentsByCategory: slimByCategory,
},
});
}
/** Send the chip state (active flag + agent count + name) to the webview. */
async _sendCompanyStatus(): Promise<void> {
if (!this._view) return;
@@ -1480,44 +1534,63 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
const state = readCompanyState(this._context);
const cfg = getConfig();
const globalWeight = cfg.knowledgeMixSecondBrainWeight ?? 50;
const agents = COMPANY_AGENT_ORDER.map((id) => {
const def = COMPANY_AGENTS[id];
// Built-ins first (insertion order from agents.ts), then user-added
// customs in their own order. `custom: true` lets the UI render a
// delete button only for user-added entries.
const builtinIds = COMPANY_AGENT_ORDER.filter((id) => !!COMPANY_AGENTS[id]);
const customIds = state.customAgents ? Object.keys(state.customAgents) : [];
const orderedIds = [...builtinIds, ...customIds];
const renderEntry = (id: string) => {
const builtin = COMPANY_AGENTS[id];
const custom = state.customAgents?.[id];
const baseDef = builtin ?? custom;
if (!baseDef) return null;
// 직군 override 적용된 effective def. 카드의 드롭다운이 옳은 선택값을
// 보이려면 override 결과를 보내야 한다.
const effective = resolveAgent(state, id) ?? baseDef;
const isCustom = !builtin;
const override = state.promptOverrides[id] || {};
const kmOverride = state.knowledgeMixOverrides[id];
const hasKmOverride = typeof kmOverride === 'number';
const roleOverride = state.roleCategoryOverrides?.[id];
return {
id,
name: def.name,
role: def.role,
emoji: def.emoji,
color: def.color,
alwaysOn: !!def.alwaysOn,
name: effective.name,
role: effective.role,
emoji: effective.emoji,
color: effective.color,
alwaysOn: !!effective.alwaysOn,
custom: isCustom,
active: id === 'ceo' || state.activeAgentIds.includes(id),
modelOverride: state.modelOverrides[id] || '',
// Defaults — never change at runtime.
defaultTagline: def.tagline,
defaultSpecialty: def.specialty,
defaultPersona: def.persona || '',
// Current effective values (default + override merged).
tagline: override.tagline || def.tagline,
specialty: override.specialty || def.specialty,
persona: override.persona || def.persona || '',
// Per-field override flags for the UI.
defaultTagline: baseDef.tagline,
defaultSpecialty: baseDef.specialty,
defaultPersona: baseDef.persona || '',
tagline: override.tagline || baseDef.tagline,
specialty: override.specialty || baseDef.specialty,
persona: override.persona || baseDef.persona || '',
personaOverridden: !!override.persona,
specialtyOverridden: !!override.specialty,
taglineOverridden: !!override.tagline,
// Knowledge Mix — null when using global default, number otherwise.
// 직군: effective(override 반영) + def 기본값 + override 플래그
roleCategory: effective.roleCategory,
defaultRoleCategory: baseDef.roleCategory,
roleCategoryOverridden: !!roleOverride && roleOverride !== baseDef.roleCategory,
knowledgeMixOverride: hasKmOverride ? kmOverride : null,
// What the dispatcher *will actually use* this turn (for hint UI).
effectiveKnowledgeMixWeight: hasKmOverride ? kmOverride : globalWeight,
};
});
};
const agents = orderedIds.map(renderEntry).filter((x): x is NonNullable<ReturnType<typeof renderEntry>> => !!x);
this._view.webview.postMessage({
type: 'companyAgents',
value: {
companyName: state.companyName,
globalKnowledgeMixWeight: globalWeight,
agents,
// 직군 라벨 사전 + 표시 순서. 웹뷰는 enum 값을 모르므로
// 백엔드가 정한 라벨/순서를 같이 보내 UI 일관성을 유지.
roleCategoryLabels: ROLE_CATEGORY_LABELS,
roleCategoryOrder: ROLE_CATEGORY_ORDER,
},
});
}
@@ -1534,6 +1607,11 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
const emit = (event: CompanyTurnEvent) => {
this._view?.webview.postMessage({ type: 'companyTurnUpdate', value: event });
};
// Fresh AbortController per turn — the Stop button routes through
// `abortCompanyTurn()` to fire `.abort()`. The dispatcher checks
// `signal.aborted` between phases and short-circuits cleanly.
const abort = new AbortController();
this._companyAbort = abort;
try {
await runCompanyTurn(userPrompt, {
context: this._context,
@@ -1550,6 +1628,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
// 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),
signal: abort.signal,
onEvent: emit,
});
} catch (e: any) {
@@ -1559,6 +1638,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
value: `1인 기업 모드 실행 실패: ${e?.message ?? e}`,
});
} finally {
if (this._companyAbort === abort) this._companyAbort = undefined;
// The webview's send button is locked into the "generating" state
// when the user submits; it only unlocks on `streamEnd`. The
// normal chat path posts that from inside AgentExecutor, but