ebfce17b03
증상: 사용자가 기획서 + 폴더 주고 "여기 개발해줘" 요청 → ASTRA 가 파일 만들고
"개발 완료" 보고 → 실제 파일을 열면 class/함수 본문이 비어 있음
(def foo(): pass · 빈 class · imports only). 99% 확률 재발.
원인:
- 안전망 이미 존재 (selfReflectorHollow.ts 가 정규식으로 빈 깡통 감지)
- BUT 두 개 config 모두 OFF — selfReflectorEnabled (Phase A) +
selfReflectorExternalEnabled (Phase B)
- Phase A 켜면 [Self-Reflector Check] 블록이 답변에 노출 (UX 부작용),
Phase B 는 +1 LLM 호출 비용 — 부작용 때문에 기본 OFF
- 결과: 다수 사용자가 안전망 전혀 없는 상태로 코드 작성 → 빈 깡통 통과
Fix 3종:
1. Hollow check 를 selfReflector 와 분리 — 신규 설정 2개:
- g1nation.hollowCheck.enabled (boolean, 기본 ON) — action-tag 있는 모든
응답에 무조건 hollow 스캔. LLM 호출 0.
- g1nation.hollowCheck.autoRetry (boolean, 기본 ON) — 검출 시 1회 자동
재작업. Phase B 와 분리.
- dispatcher.ts 게이트 조건 교체
2. dev-impl 프롬프트 강화 (pipelineTemplates.ts) — [빈 깡통 금지] 5개 규칙:
- 파일은 하나씩 생성, 모든 함수 본문 완전 구현 후 다음 파일로
- 금지 패턴 명시: pass · ... · NotImplementedError · # TODO · 빈 class
- 인터페이스/추상 메서드만 빈 본문 OK
- 각 파일 생성 직후 자가 검증
- 최종 요약에 파일별 핵심 동작 한 줄씩
3. 기본값 변경 — 사용자 행동 없이 안전망 작동. 옛 selfReflector Phase A/B 는
그대로 OFF (UX 부작용 보존).
예상 효과: ~70-85% stub 감소. 남은 ~15% (작은 모델 attention 한계 / 큰 프로젝트)
는 per-file 순차 생성으로 v2.2.204+ 검토.
모델 한계 vs 로직 fix: 대부분 로직 fix 가능. 매우 작은 모델 (≤4B) 은 한계 더
빠름 — 더 큰 모델 (gemma-12b, qwen-32b) 권장.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1364 lines
68 KiB
TypeScript
1364 lines
68 KiB
TypeScript
/**
|
||
* 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 (incl. peer context from earlier agents)
|
||
* - call the AI service
|
||
* - persist its output to disk
|
||
* - append its output to the peer-context buffer for the next agent
|
||
* → CEO reporter (synthesis markdown)
|
||
* → persist `_report.md`, update agent memory + decisions
|
||
* → emit `companyTurnUpdate` events to the webview at each phase
|
||
*
|
||
* Why sequential? The user runs Astra on a single GPU/CPU with limited RAM,
|
||
* and parallel agents would force us to keep multiple models loaded
|
||
* simultaneously. Sequential dispatch keeps "exactly one model resident at
|
||
* a time" — the LM Studio lifecycle manager unloads the previous model and
|
||
* loads the next when an agent has its own override.
|
||
*
|
||
* Why not use `AgentExecutor.handlePrompt` here? Because `handlePrompt` is
|
||
* built for the *interactive* chat path: it owns the conversation history,
|
||
* streaming UI, agent-mode injection, and a dozen other things we don't
|
||
* want triggered by a company turn. The company dispatcher needs a clean
|
||
* "one system + one user → one string back" primitive — `AIService.chat()`
|
||
* fits that perfectly. Specialists can still emit action tags
|
||
* (`<create_file>`, `<run_command>`); we route their *raw* output through
|
||
* the existing action-tag executor afterwards so file/command tools work
|
||
* exactly as in chat.
|
||
*/
|
||
import * as vscode from 'vscode';
|
||
import { IAIService } from '../../core/services';
|
||
import { getActiveBrainProfile, logError, logInfo } from '../../utils';
|
||
import { retrieveScoped, buildContextBlock } from '../../skills/scopedBrainRetriever';
|
||
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
|
||
import {
|
||
mapWeightToBrainFileLimit,
|
||
buildKnowledgeMixPolicy,
|
||
} from '../../retrieval/knowledgeMix';
|
||
import {
|
||
listActiveAgentsByCategory,
|
||
modelForAgent, readCompanyState, resolveActivePipeline, resolveAgent, resolveCompanyKnowledgeMix,
|
||
} from './companyConfig';
|
||
import { runCeoPlanner } from './ceoPlanner';
|
||
import { runCeoReporter } from './ceoReporter';
|
||
import { buildSpecialistPrompt } from './promptBuilder';
|
||
import {
|
||
appendAgentMemory,
|
||
appendDecision,
|
||
createSessionDir,
|
||
newSessionTimestamp,
|
||
readAgentMemory,
|
||
readDecisions,
|
||
writeAgentOutput,
|
||
writeBrief,
|
||
writeReport,
|
||
writeSessionJson,
|
||
} from './sessionStore';
|
||
import {
|
||
markResumeStatus,
|
||
readResumeState,
|
||
resolveSessionDir,
|
||
writeResumeState,
|
||
} from './resumeStore';
|
||
import { buildTelegramReporter, formatCompanyTelegramReport } from './telegramReport';
|
||
// ── Self-reflector + intent alignment 모듈 정적 import (옛 dynamic require 8회 통합) ──
|
||
// 옛 코드는 매 stage 마다 `await import(...)` 로 모듈을 로드했음. 이유는 cyclic import
|
||
// 회피로 짐작됐지만 실제로 selfReflector / intentAlignment 모듈 어느 것도 dispatcher 를
|
||
// import 하지 않아 안전하게 정적 promote 가능. 코드 흐름 명확해지고, 매 dispatch 마다
|
||
// require 호출 8회 → 0회 (모듈 캐시 자동).
|
||
import { verifyResponse, formatIssuesForRetry } from '../selfReflector/selfReflectorVerifier';
|
||
import { verifyCreatedFiles } from '../selfReflector/selfReflectorExecution';
|
||
import { verifyHollow } from '../selfReflector/selfReflectorHollow';
|
||
import { formatContractForPrompt } from './intentAlignment';
|
||
import { getConfig as getDispatcherConfig } from '../../config';
|
||
import {
|
||
AgentRoleCategory, AgentTurnOutput, CompanyResumeState, CompanyState, CompanyTaskPlan,
|
||
PipelineDef, PipelineStage, RequirementContract, ROLE_CATEGORY_LABELS, SessionResult,
|
||
} from './types';
|
||
|
||
/** Trim length applied when an agent's output is fed into the next agent. */
|
||
const PEER_OUTPUT_BUDGET = 1500;
|
||
|
||
/**
|
||
* Events emitted during a turn. The sidebar webview subscribes to render
|
||
* progress (chips, headers, streamed agent replies). The shape is generic so
|
||
* the same channel can carry CEO/agent/report messages without per-type
|
||
* postMessage plumbing.
|
||
*/
|
||
/**
|
||
* User's decision after a stage with `requiresApproval=true` finishes.
|
||
* - 'approve' → proceed to next stage as-is
|
||
* - 'revise' → re-run the same stage; comment is prepended to its instruction
|
||
* - 'abort' → end the turn (same as hitting Stop)
|
||
*/
|
||
export type ApprovalDecision =
|
||
| { kind: 'approve' }
|
||
| { kind: 'revise'; comment: string }
|
||
| { kind: 'abort' };
|
||
|
||
export type CompanyTurnEvent =
|
||
| { phase: 'plan-start' }
|
||
| { phase: 'plan-ready'; plan: CompanyTaskPlan; parsed: boolean; raw: string }
|
||
| { phase: 'agent-start'; agentId: string; task: string; index: number; total: number }
|
||
| { phase: 'agent-done'; agentId: string; output: AgentTurnOutput; index: number; total: number }
|
||
/**
|
||
* Pipeline-mode only: emitted when a stage's output matches the
|
||
* configured `loopBackPattern` and the dispatcher jumps back to a
|
||
* previous stage. The webview uses this to render "🔁 stage X → Y
|
||
* (재시도 N차)" in the chat.
|
||
*/
|
||
| { phase: 'stage-loop'; from: string; to: string; iteration: number }
|
||
/**
|
||
* Manual approval gate. Emitted right before the dispatcher awaits the
|
||
* user's decision. Webview surfaces 승인/수정요청/중단 buttons.
|
||
*/
|
||
| { phase: 'awaiting-approval'; stageId: string; stageLabel: string; index: number; total: number }
|
||
/** Resolved approval — purely informational for the chat log. */
|
||
| { phase: 'approval-resolved'; stageId: string; decision: 'approve' | 'revise' | 'abort' }
|
||
/**
|
||
* 3-way 검수 사이클 시작 — 작업자 산출물 직후, 검수자/CEO 메타-판단을
|
||
* 돌리기 직전에 emit. webview는 stage 카드 안에 라운드 누적 영역을 연다.
|
||
*/
|
||
| { phase: 'review-start'; stageId: string; stageLabel: string; maxRounds: number; inspectorAgentId: string }
|
||
/**
|
||
* 한 검수 라운드 결과. inspectorVerdict + ceoVerdict + 각자 코멘트를
|
||
* 묶어 한 이벤트로. 라운드를 chat에서 한 줄씩 누적 표시 가능하다.
|
||
*/
|
||
| {
|
||
phase: 'review-round';
|
||
stageId: string;
|
||
round: number;
|
||
inspectorAgentId: string;
|
||
inspectorText: string;
|
||
inspectorVerdict: 'pass' | 'revise' | 'unclear';
|
||
ceoText: string;
|
||
ceoVerdict: 'pass' | 'revise' | 'abort' | 'unclear';
|
||
durationMs: number;
|
||
}
|
||
/** 검수 사이클 종료. final = 마지막 라운드 verdict. */
|
||
| { phase: 'review-end'; stageId: string; final: 'pass' | 'aborted' | 'maxed-out'; rounds: number }
|
||
| { phase: 'report-start' }
|
||
| { phase: 'report-done'; report: string; ok: boolean }
|
||
/**
|
||
* Emitted after the secretary attempts to mirror the CEO report to
|
||
* Telegram. `ok=true` ⇒ delivered. `ok=false` ⇒ delivery failed (network
|
||
* / API error). `ok=null` ⇒ no mirror was attempted because the user
|
||
* hasn't opted in (no token, no chat id, or `telegram.enabled=false`).
|
||
*/
|
||
| { phase: 'telegram-mirror'; ok: boolean | null; reason?: string }
|
||
| { phase: 'session-saved'; sessionDir: string }
|
||
| { phase: 'aborted'; reason: string }
|
||
// 일반 정보·경고·에러 메시지 — 진행 UI 와 별개로 사용자에게 전달할 텍스트.
|
||
// 예: resume state 저장 실패, optional feature 미설치 안내 등.
|
||
| { phase: 'log'; level: 'info' | 'warn' | 'error'; message: string };
|
||
|
||
export type CompanyTurnEmitter = (event: CompanyTurnEvent) => void;
|
||
|
||
export interface DispatcherDeps {
|
||
context: vscode.ExtensionContext;
|
||
ai: IAIService;
|
||
/** Default model to fall back to when an agent has no override. */
|
||
defaultModel: string;
|
||
/**
|
||
* Global Knowledge Mix weight (0–100) — fallback when an agent has no
|
||
* per-agent override. Mirrors `g1nation.knowledgeMix.secondBrainWeight`.
|
||
*/
|
||
globalKnowledgeMixWeight: number;
|
||
/**
|
||
* Baseline number of brain files to retrieve at weight=50 (balanced).
|
||
* The actual count is `mapWeightToBrainFileLimit(weight, baseline)`.
|
||
* Pass the same value the chat path uses (`config.memoryLongTermFiles`)
|
||
* so company-mode behaviour stays in sync.
|
||
*/
|
||
brainFileBaseline?: number;
|
||
/**
|
||
* Apply ConnectAI's action-tag executor to the specialist's raw response.
|
||
* Without this hook, agent outputs containing `<create_file>` etc. would
|
||
* be shown to the user as a *claim* of file creation but nothing would
|
||
* actually land on disk. We thread it through deps (rather than
|
||
* importing AgentExecutor directly) so the dispatcher stays free of
|
||
* sidebar / agent.ts dependencies for unit testability.
|
||
*/
|
||
executeActionTags?: (text: string) => Promise<string[]>;
|
||
/** Per-call cancellation. The sidebar's Stop button flips this. */
|
||
signal?: AbortSignal;
|
||
/** Optional event sink for the webview. Receives events synchronously. */
|
||
onEvent?: CompanyTurnEmitter;
|
||
/**
|
||
* Manual-approval bridge. When a pipeline stage has `requiresApproval`,
|
||
* the dispatcher emits `phase: 'awaiting-approval'` and then *awaits*
|
||
* this promise. The host (SidebarChatProvider) is responsible for:
|
||
* 1. Storing a resolver tied to the emitted stageId
|
||
* 2. Surfacing approval buttons in the webview chat
|
||
* 3. Resolving the promise when the user clicks one of them
|
||
* 4. Resolving with `{ kind: 'abort' }` if the turn is cancelled
|
||
* (so the dispatcher doesn't hang forever)
|
||
*/
|
||
awaitApproval?: (ctx: { stageId: string; stageLabel: string }) => Promise<ApprovalDecision>;
|
||
/**
|
||
* 이번 turn 한정으로 활성 파이프라인을 *override*. 비어 있으면 평소대로
|
||
* `state.activePipelineId` 따른다. 의도 분류기의 `suggestedPipelineId` 또는
|
||
* 사용자 키워드(`[파이프라인:id]`) 검출 시 chatHandlers가 채워서 넘긴다.
|
||
* 알 수 없는 id면 dispatcher가 silent fallback해서 legacy 동작
|
||
* (state.activePipelineId 또는 CEO planner)로 진행.
|
||
*/
|
||
pipelineIdOverride?: string;
|
||
/**
|
||
* Intent Alignment 단계에서 사용자와 합의된 Requirement Contract. 있으면
|
||
* CEO planner / specialist prompt / 검수자(inspector + CEO) prompt 전부에
|
||
* 같은 ground truth로 주입되어 에이전트들이 추측 대신 contract를 따른다.
|
||
* 없으면 legacy 동작 — alignment 단계를 거치지 않았거나 사용자 모드가
|
||
* 'off'였던 경우.
|
||
*/
|
||
requirementContract?: RequirementContract;
|
||
}
|
||
|
||
/**
|
||
* 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 = 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;
|
||
|
||
// ── 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 => {
|
||
const result = 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,
|
||
});
|
||
// 옛 코드는 write 실패해도 silent 로 logError 만 → 사용자는 *resume turn 손실*
|
||
// 사실을 모름. 실패 시 emit 으로 webview 에 통보해 사용자가 즉시 인지.
|
||
if (!result.ok) {
|
||
emit({
|
||
phase: 'log',
|
||
level: 'warn',
|
||
message: `Resume 상태 저장 실패 (${status}): ${result.reason}. 이 turn 은 이어서 진행 못 할 수 있습니다.`,
|
||
});
|
||
}
|
||
};
|
||
|
||
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: 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 — skipped on resume) ──
|
||
let pipeline: PipelineDef | null;
|
||
let plan: CompanyTaskPlan;
|
||
let plannerRaw = '';
|
||
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' });
|
||
// deps.pipelineIdOverride가 들어왔으면 *이번 turn만* 그 파이프라인을 쓴다.
|
||
// state.activePipelineId는 건드리지 않으므로 다음 라운드부턴 다시 사용자
|
||
// 설정 따른다. override id가 유효한 파이프라인을 못 가리키면 silent fallback.
|
||
const overrideId = deps.pipelineIdOverride;
|
||
pipeline = overrideId
|
||
? (state.pipelines?.[overrideId] ?? resolveActivePipeline(state))
|
||
: 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)}`,
|
||
// stage.agentId가 비어 있는 경우(CEO 동적 선택) 직군 라벨을 placeholder로
|
||
// 표시 — plan은 사전 요약용이므로 실제 dispatch는 _runPipeline에서 결정.
|
||
tasks: pipeline.stages.map((s) => ({
|
||
agent: s.agentId || (s.roleCategory ? `[직군:${s.roleCategory}]` : '[미정]'),
|
||
task: s.label,
|
||
})),
|
||
};
|
||
} else {
|
||
const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel);
|
||
const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, {
|
||
model: ceoModel,
|
||
contractBlock: deps.requirementContract
|
||
? formatContractForPrompt(deps.requirementContract)
|
||
: undefined,
|
||
signal: deps.signal,
|
||
});
|
||
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);
|
||
}
|
||
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[] = seed?.agentOutputs ? [...seed.agentOutputs] : [];
|
||
if (pipeline) {
|
||
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;
|
||
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);
|
||
outputs.push(turn);
|
||
writeAgentOutput(sessionDir, turn);
|
||
appendAgentMemory(
|
||
deps.context,
|
||
task.agent,
|
||
`[${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 ──
|
||
// 여기까지 왔다는 건 모든 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(
|
||
deps.ai,
|
||
plan,
|
||
outputs,
|
||
state,
|
||
{ model: reportModel, signal: deps.signal },
|
||
);
|
||
writeReport(sessionDir, reportResult.report);
|
||
emit({ phase: 'report-done', report: reportResult.report, ok: reportResult.ok });
|
||
|
||
// ── Phase 3.5: Secretary mirror to Telegram ──
|
||
// Origin parity (Connect_origin extension.ts ~line 20620): after the CEO
|
||
// synthesizes the round, the Secretary (영숙) ships a digest to the user's
|
||
// Telegram chat. Strictly best-effort — failures never break the turn.
|
||
let telegramOk: boolean | null = null;
|
||
let telegramReason: string | undefined;
|
||
try {
|
||
const reporter = await buildTelegramReporter(deps.context);
|
||
if (!reporter) {
|
||
telegramReason = 'no-config'; // opt-in off, no token, or no chat id
|
||
} else {
|
||
const tgText = formatCompanyTelegramReport({
|
||
state,
|
||
userPrompt,
|
||
plan,
|
||
outputs,
|
||
report: reportResult.report,
|
||
sessionTimestamp: timestamp,
|
||
sessionDir,
|
||
});
|
||
telegramOk = await reporter(tgText);
|
||
if (!telegramOk) telegramReason = 'delivery-failed';
|
||
}
|
||
} catch (e: any) {
|
||
telegramOk = false;
|
||
telegramReason = e?.message ?? String(e);
|
||
logError('company.dispatcher: telegram mirror threw.', { error: telegramReason });
|
||
}
|
||
emit({ phase: 'telegram-mirror', ok: telegramOk, reason: telegramReason });
|
||
|
||
// ── Phase 4: persist + side effects ──
|
||
const result: SessionResult = {
|
||
timestamp, sessionDir,
|
||
userPrompt,
|
||
plan,
|
||
agentOutputs: outputs,
|
||
report: reportResult.report,
|
||
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());
|
||
emit({ phase: 'session-saved', sessionDir });
|
||
|
||
logInfo('company.dispatcher: turn complete.', {
|
||
sessionDir, agents: outputs.length, ok: reportResult.ok,
|
||
durationMs: result.totalDurationMs,
|
||
});
|
||
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
|
||
* keep going so the user still gets the other agents' outputs.
|
||
*/
|
||
async function _dispatchOne(
|
||
agentId: string,
|
||
task: string,
|
||
earlierOutputs: AgentTurnOutput[],
|
||
state: ReturnType<typeof readCompanyState>,
|
||
deps: DispatcherDeps,
|
||
/** Pipeline stage override — wins over the agent's own model override. */
|
||
stageModelOverride?: string,
|
||
): Promise<AgentTurnOutput> {
|
||
const startedAt = Date.now();
|
||
const def = resolveAgent(state, agentId);
|
||
if (!def) {
|
||
return {
|
||
agentId, task, response: '', durationMs: 0,
|
||
error: `Unknown agent id: ${agentId}`,
|
||
};
|
||
}
|
||
const memory = readAgentMemory(deps.context, agentId);
|
||
const decisions = readDecisions(deps.context, 2000);
|
||
// Google Calendar iCal 캐시 (선택 사항). 셋업 안 된 사용자는 빈 문자열 → 무시.
|
||
// 매 dispatch 마다 디스크 read 1회 발생하지만 캐시는 KB 단위라 비용 무시 가능.
|
||
let calendarContext = '';
|
||
try {
|
||
const { readCalendarCache } = require('../calendar') as typeof import('../calendar');
|
||
calendarContext = readCalendarCache(deps.context) ?? '';
|
||
} catch { /* feature 미설치 / 캐시 없음 — silent skip */ }
|
||
|
||
// Task tracker — _shared/tasks.md 의 active 항목 요약. 모든 agent 가 진척 상황을
|
||
// 한 눈에 볼 수 있도록. 비어있으면 빈 문자열 → 프롬프트에서 섹션 자체 생략.
|
||
let tasksContext = '';
|
||
try {
|
||
const { readTaskStore, summarizeActiveTasks } = require('../tasks') as typeof import('../tasks');
|
||
tasksContext = summarizeActiveTasks(readTaskStore(deps.context));
|
||
} catch { /* silent */ }
|
||
const peerOutputs = earlierOutputs
|
||
.filter((o) => !o.error) // skip failed peers — they'd just confuse the next agent
|
||
.map((o) => {
|
||
const peerDef = resolveAgent(state, o.agentId);
|
||
const body = o.response.length > PEER_OUTPUT_BUDGET
|
||
? o.response.slice(0, PEER_OUTPUT_BUDGET) + '\n…(truncated)'
|
||
: o.response;
|
||
return {
|
||
agentId: o.agentId,
|
||
agentName: peerDef?.name ?? o.agentId,
|
||
emoji: peerDef?.emoji ?? '🤖',
|
||
content: body,
|
||
};
|
||
});
|
||
|
||
// ── Second Brain RAG for this specialist ────────────────────────────────
|
||
// The non-company chat path uses `AgentExecutor.buildMemoryContext` to
|
||
// pull RAG chunks before every LLM call. The dispatcher used to skip
|
||
// that entirely, leaving company agents *blind* to the user's stored
|
||
// knowledge — which made the Knowledge Mix slider effectively a no-op
|
||
// for company turns. We now run a lightweight scoped retrieval here so
|
||
// every dispatch sees the same brain the user expects, weighted by the
|
||
// agent's own Knowledge Mix.
|
||
const { weight: knowledgeWeight, source: knowledgeMixSource } =
|
||
resolveCompanyKnowledgeMix(state, agentId, deps.globalKnowledgeMixWeight);
|
||
const brainFileLimit = mapWeightToBrainFileLimit(knowledgeWeight, deps.brainFileBaseline ?? 6);
|
||
let brainContext = '';
|
||
if (brainFileLimit > 0) {
|
||
try {
|
||
const brain = getActiveBrainProfile();
|
||
const brainRoot = brain?.localBrainPath || '';
|
||
if (brainRoot) {
|
||
// Reuse the agent ↔ knowledge map: if the same agent name
|
||
// appears there (free-form .md path or canonical id), we
|
||
// honour its `knowledgeFolders` scope. Otherwise we search
|
||
// the whole brain so a missing mapping doesn't starve the
|
||
// dispatcher.
|
||
const scope = resolveScopeForAgent(agentId, brainRoot);
|
||
const retrieval = retrieveScoped(task, brainRoot, scope.folders, {
|
||
maxResults: brainFileLimit,
|
||
});
|
||
brainContext = buildContextBlock(retrieval);
|
||
}
|
||
} catch (e: any) {
|
||
logError('company.dispatcher: RAG retrieval failed; continuing without brain context.', {
|
||
agentId, error: e?.message ?? String(e),
|
||
});
|
||
}
|
||
}
|
||
const policyBlock = buildKnowledgeMixPolicy({
|
||
weight: knowledgeWeight,
|
||
source: knowledgeMixSource,
|
||
agent: def.name,
|
||
});
|
||
|
||
const system = buildSpecialistPrompt({
|
||
agentId, state,
|
||
agentMemory: memory, sharedDecisions: decisions,
|
||
calendarContext,
|
||
tasksContext,
|
||
peerOutputs,
|
||
brainContext, // injected as `[SECOND BRAIN CONTEXT]` block
|
||
knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block
|
||
// alignment 단계에서 도출된 contract가 deps에 있으면 모든 specialist의
|
||
// system 프롬프트에 같은 ground truth로 prepend된다. 추측 방지.
|
||
contractBlock: deps.requirementContract
|
||
? formatContractForPrompt(deps.requirementContract)
|
||
: undefined,
|
||
});
|
||
// 우선순위: stage > agent > global default.
|
||
const model = (stageModelOverride && stageModelOverride.trim())
|
||
? stageModelOverride.trim()
|
||
: modelForAgent(state, agentId, deps.defaultModel);
|
||
|
||
try {
|
||
const result = await deps.ai.chat({
|
||
system,
|
||
user: task,
|
||
model,
|
||
signal: deps.signal,
|
||
});
|
||
let rawResponse = (result.content || '').trim();
|
||
|
||
// ── Self-Reflector Phase B — 외부 검증 + 1회 retry ──
|
||
// 사용자가 selfReflector.externalVerification 켰을 때만 동작. 검증 LLM이
|
||
// 'fail' 내면 issue를 task에 prepend해서 같은 specialist 1회 더 호출.
|
||
// 검증 자체가 실패하면(verifierError) 원본 응답을 그대로 보존하고 진행 — 안전망.
|
||
let verifierIssues: string[] = [];
|
||
let verifierSummary = '';
|
||
try {
|
||
// 옛 dynamic import 8회 → 정적 import 로 promote (파일 상단). 모듈 자체 cyclic 없음.
|
||
const cfgRuntime = getDispatcherConfig();
|
||
if (cfgRuntime.selfReflectorExternalEnabled && rawResponse) {
|
||
const contractBlock = deps.requirementContract
|
||
? formatContractForPrompt(deps.requirementContract)
|
||
: undefined;
|
||
const verdict = await verifyResponse(deps.ai, {
|
||
task,
|
||
response: rawResponse,
|
||
agentName: def.name,
|
||
model,
|
||
contractBlock,
|
||
});
|
||
verifierIssues = verdict.issues;
|
||
verifierSummary = verdict.summary;
|
||
logInfo('selfReflector.B: verdict.', {
|
||
agentId, verdict: verdict.verdict, issuesCount: verdict.issues.length,
|
||
});
|
||
if (verdict.verdict === 'fail' && verdict.issues.length > 0) {
|
||
const retryTask = `${formatIssuesForRetry(verdict.issues)}\n\n[원래 지시]\n${task}`;
|
||
try {
|
||
const retryRes = await deps.ai.chat({
|
||
system, user: retryTask, model,
|
||
signal: deps.signal,
|
||
});
|
||
const retried = (retryRes.content || '').trim();
|
||
if (retried) {
|
||
rawResponse = retried;
|
||
verifierSummary = `검증 fail → 1회 retry 적용 (${verdict.issues.length}개 지적 반영)`;
|
||
}
|
||
} catch (e: any) {
|
||
logError('selfReflector.B: retry call failed; keeping original.', {
|
||
agentId, error: e?.message ?? String(e),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
// Phase B 전체가 실패해도 dispatch 자체는 계속.
|
||
logError('selfReflector.B: hook failed; continuing without verification.', {
|
||
agentId, error: e?.message ?? String(e),
|
||
});
|
||
}
|
||
|
||
// Apply ConnectAI's action-tag executor so `<create_file>`,
|
||
// `<run_command>`, `<edit_file>`, etc. emitted by the agent actually
|
||
// hit disk / shell. The report (e.g. "✅ Created: foo.py") is
|
||
// appended to the response so the user sees what really happened.
|
||
let finalResponse = rawResponse || '_(empty response)_';
|
||
let actionReport: string[] | undefined;
|
||
const hasTag = !!rawResponse && hasActionTag(rawResponse);
|
||
if (rawResponse && deps.executeActionTags && hasTag) {
|
||
try {
|
||
const report = await deps.executeActionTags(rawResponse);
|
||
actionReport = report;
|
||
|
||
// ── Self-Reflector Phase C — 생성/편집된 파일 syntax 체크 ──
|
||
// 사용자가 selfReflector.executionVerification 켰을 때만. 추가
|
||
// report 항목들을 actionReport에 append + finalResponse 첨부 본문에도 반영.
|
||
try {
|
||
const cfgRuntime = getDispatcherConfig();
|
||
if (cfgRuntime.selfReflectorExecutionEnabled && actionReport.length > 0) {
|
||
const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||
if (projectRoot) {
|
||
const extra = await verifyCreatedFiles(actionReport, projectRoot);
|
||
if (extra.length > 0) {
|
||
actionReport = [...actionReport, ...extra];
|
||
}
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
logError('selfReflector.C: hook failed; continuing without execution check.', {
|
||
agentId, error: e?.message ?? String(e),
|
||
});
|
||
}
|
||
|
||
// ── Hollow Code Check (휴리스틱, LLM 콜 0) ──
|
||
// Phase C(syntax)가 잡지 못하는 *빈 깡통* 패턴을 정규식으로 잡는다.
|
||
// v2.2.203: Phase A(selfReflectorEnabled) 와 분리 — 독립 toggle
|
||
// (hollowCheck.enabled, 기본 ON). action-tag 있는 모든 응답에 무조건 실행.
|
||
// Retry 도 Phase B 와 분리 (hollowCheck.autoRetry, 기본 ON) — 다수 사용자가
|
||
// 자동 안전망 받도록.
|
||
try {
|
||
const cfgRuntime = getDispatcherConfig();
|
||
if (cfgRuntime.hollowCheckEnabled && actionReport.length > 0) {
|
||
const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||
if (projectRoot) {
|
||
const hollowRes = verifyHollow(actionReport, projectRoot);
|
||
if (hollowRes.hasHollow) {
|
||
actionReport = [...actionReport, ...hollowRes.extraLines];
|
||
// hollowCheck.autoRetry 가 켜져 있고 아직 retry 안 했다면 hollow를
|
||
// 자동 재작업 트리거. (Phase B 와 무관 — 빈 깡통은 늘 잡아야)
|
||
if (cfgRuntime.hollowCheckAutoRetry && verifierIssues.length === 0) {
|
||
verifierIssues = hollowRes.hollowReasons.map((r) => `빈 깡통: ${r}`);
|
||
verifierSummary = `Hollow code 감지 — 자동 재시도 트리거`;
|
||
// 같은 specialist 1회 retry: 빈 깡통 지적을 task 앞에 prepend.
|
||
try {
|
||
const retryTask = `${formatIssuesForRetry(verifierIssues)}\n\n[원래 지시]\n${task}`;
|
||
const retryRes = await deps.ai.chat({ system, user: retryTask, model, signal: deps.signal });
|
||
const retried = (retryRes.content || '').trim();
|
||
if (retried) {
|
||
// 재작업 결과로 본문 갱신 + action-tag 다시 실행.
|
||
rawResponse = retried;
|
||
if (deps.executeActionTags && hasActionTag(retried)) {
|
||
const retryReport = await deps.executeActionTags(retried);
|
||
actionReport = retryReport;
|
||
// 재작업 결과도 hollow 한 번 더 검사.
|
||
const reCheck = verifyHollow(retryReport, projectRoot);
|
||
if (reCheck.hasHollow) {
|
||
actionReport = [...actionReport, ...reCheck.extraLines];
|
||
verifierSummary = `재작업 후에도 hollow 일부 잔존 — 사용자 확인 필요`;
|
||
} else {
|
||
verifierSummary = `Hollow 감지 → 재작업으로 해결`;
|
||
}
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
logError('selfReflector.hollow: retry call failed.', {
|
||
agentId, error: e?.message ?? String(e),
|
||
});
|
||
}
|
||
} else if (!cfgRuntime.hollowCheckAutoRetry) {
|
||
// auto-retry OFF — 사용자에게 경고만.
|
||
verifierSummary = `⚠️ Hollow code 감지 — hollowCheck.autoRetry 켜면 자동 재시도`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
logError('selfReflector.hollow: check failed; continuing.', {
|
||
agentId, error: e?.message ?? String(e),
|
||
});
|
||
}
|
||
|
||
if (actionReport.length > 0) {
|
||
finalResponse = `${rawResponse}\n\n---\n**Action 실행 결과:**\n${actionReport.map((r) => `- ${r}`).join('\n')}`;
|
||
}
|
||
} catch (e: any) {
|
||
// Surface the failure but keep the agent's text — partial
|
||
// success is more useful than dropping the whole response.
|
||
const err = e?.message ?? String(e);
|
||
logError('company.dispatcher: action-tag execution failed.', { agentId, err });
|
||
finalResponse = `${rawResponse}\n\n---\n⚠️ Action 실행 실패: ${err}`;
|
||
}
|
||
} else if (rawResponse && !hasTag && claimsFileCreation(rawResponse)) {
|
||
// Hallucination guard: small models love to *narrate* file
|
||
// creation ("foo.py를 생성했습니다 …") without emitting the
|
||
// <create_file> tag — so the user sees ✅ in chat but nothing
|
||
// on disk. Catch the mismatch here and flag it loudly so the
|
||
// CEO synthesis (which reads this response) and the user both
|
||
// know nothing was actually written.
|
||
const warning = '⚠️ **실제 파일이 생성되지 않았습니다.** Agent가 파일 생성을 텍스트로 설명했지만 ConnectAI 액션 태그(`<create_file>` 등)를 사용하지 않아 디스크에 아무것도 만들어지지 않았어요. 같은 요청을 다시 시도하거나, 사용자가 직접 만드세요.';
|
||
finalResponse = `${rawResponse}\n\n---\n${warning}`;
|
||
logInfo('company.dispatcher: agent claimed creation without action tag.', { agentId });
|
||
}
|
||
// `error: 'no-action-tag-but-claimed'` is *advisory* — we still let
|
||
// the turn complete because some agents (Writer, Researcher) are
|
||
// legitimately answer-only. But by flagging the agent output we
|
||
// mark it as not-fully-successful so the CEO synthesis can read
|
||
// the warning verbatim.
|
||
const claimedButDidnt = rawResponse && !hasTag && claimsFileCreation(rawResponse);
|
||
// 검증 요약을 response 끝에 한 줄로 첨부 — 사용자가 *어떻게 검증됐는지*
|
||
// 빠르게 보고 신뢰도 가늠. issues가 있으면 같이 노출.
|
||
if (verifierSummary) {
|
||
const issuesText = verifierIssues.length > 0
|
||
? '\n' + verifierIssues.map((i) => ` - ${i}`).join('\n')
|
||
: '';
|
||
finalResponse = `${finalResponse}\n\n---\n**🔬 외부 검증:** ${verifierSummary}${issuesText}`;
|
||
}
|
||
return {
|
||
agentId, task,
|
||
response: finalResponse,
|
||
durationMs: Date.now() - startedAt,
|
||
error: rawResponse
|
||
? (claimedButDidnt ? 'claimed-creation-no-tag' : undefined)
|
||
: 'empty-response',
|
||
actionReport,
|
||
};
|
||
} catch (e: any) {
|
||
const err = e?.message ?? String(e);
|
||
logError('company.dispatcher: agent dispatch failed.', { agentId, err });
|
||
return {
|
||
agentId, task,
|
||
response: `⚠️ 호출 실패: ${err}`,
|
||
durationMs: Date.now() - startedAt,
|
||
error: err,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Run an authored pipeline: each stage dispatches its agent with a templated
|
||
* instruction. Stages can declare a `loopBackPattern` regex — when it
|
||
* matches the stage's output, the dispatcher jumps back to `loopBackTo` (a
|
||
* stage that must precede the current one). Iteration count is bounded by
|
||
* `maxIterations` (default 3) to keep run-away loops from hanging the user.
|
||
*
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* Resolve which agent should run a given stage *right now*.
|
||
*
|
||
* Priority order:
|
||
* 1. `stage.agentId` is explicitly set → use that agent verbatim. The
|
||
* user pinned this stage to a specific person; honour it.
|
||
* 2. No agentId but `stage.roleCategory` → pull the active agents in
|
||
* that category. If exactly one is active, use them (saves an LLM
|
||
* call on the common case). If multiple, ask CEO via a single short
|
||
* JSON-shaped LLM call which is best fit for this *specific task*.
|
||
* 3. Neither — return null so the dispatcher can record an error and
|
||
* skip the stage cleanly. (normalize already rejects this case but
|
||
* we guard at runtime in case a stale state slipped through.)
|
||
*
|
||
* The LLM call is wrapped in try/catch with a `firstCandidate` fallback:
|
||
* a bad classifier response should never block the pipeline, just degrade
|
||
* to "first active agent in role". Caller decides whether to surface a
|
||
* note about who CEO chose; we just return `{ agentId, source, reason? }`.
|
||
*/
|
||
async function _resolveStageAgent(
|
||
stage: PipelineStage,
|
||
taskText: string,
|
||
state: CompanyState,
|
||
deps: DispatcherDeps,
|
||
): Promise<{ agentId: string; source: 'pinned' | 'sole-candidate' | 'ceo-selected' | 'fallback-first'; reason?: string } | null> {
|
||
if (stage.agentId && resolveAgent(state, stage.agentId)) {
|
||
return { agentId: stage.agentId, source: 'pinned' };
|
||
}
|
||
const cat = stage.roleCategory as AgentRoleCategory | undefined;
|
||
if (!cat) return null;
|
||
const candidates = listActiveAgentsByCategory(state)[cat] ?? [];
|
||
if (candidates.length === 0) return null;
|
||
if (candidates.length === 1) {
|
||
return { agentId: candidates[0].id, source: 'sole-candidate' };
|
||
}
|
||
// 다수 후보 → CEO에게 1회 LLM 콜로 결정. 시스템 프롬프트는 짧게, JSON만.
|
||
const catLabel = ROLE_CATEGORY_LABELS[cat] ?? cat;
|
||
const optionsBlock = candidates.map((c) =>
|
||
`- id: ${c.id} | 이름: ${c.name} ${c.emoji}`).join('\n');
|
||
const system = `당신은 1인 기업의 CEO입니다. 다음 task에 가장 적합한 *${catLabel}* 직군 구성원 한 명을 골라주세요.\n\n반드시 아래 JSON 한 줄만 출력. 다른 텍스트(설명, 펜스, 머리말) 일체 금지.\n{"agentId":"<선택한 id>","reason":"한 줄(40자 이내)"}`;
|
||
const user = `[현재 stage] ${stage.label || stage.id}\n[task]\n${taskText.slice(0, 600)}\n\n[후보]\n${optionsBlock}\n\n위 후보 중 task에 가장 적합한 한 명을 id로 골라 JSON 응답:`;
|
||
try {
|
||
const result = await deps.ai.chat({
|
||
system, user,
|
||
model: modelForAgent(state, 'ceo', deps.defaultModel),
|
||
signal: deps.signal,
|
||
});
|
||
const raw = (result.content || '').trim();
|
||
// 가벼운 파서 — 코드펜스 / 잡문 제거 후 첫 {…} 추출.
|
||
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||
const stage1 = (fenced ? fenced[1] : raw).trim();
|
||
let picked: { agentId?: unknown; reason?: unknown } | null = null;
|
||
try { picked = JSON.parse(stage1); } catch {
|
||
const m = stage1.match(/\{[\s\S]*\}/);
|
||
if (m) { try { picked = JSON.parse(m[0]); } catch { /* fall through */ } }
|
||
}
|
||
const aid = typeof picked?.agentId === 'string' ? picked.agentId.trim() : '';
|
||
if (aid && candidates.some((c) => c.id === aid)) {
|
||
const reason = typeof picked?.reason === 'string' ? picked.reason.trim() : '';
|
||
return { agentId: aid, source: 'ceo-selected', reason };
|
||
}
|
||
// 응답이 유효한 후보가 아님 → 첫 번째로 폴백.
|
||
logInfo('dispatcher: CEO selection invalid; falling back to first candidate.', {
|
||
stageId: stage.id, rawHead: raw.slice(0, 80),
|
||
});
|
||
} catch (e: any) {
|
||
logError('dispatcher: CEO selection call failed; falling back.', {
|
||
stageId: stage.id, error: e?.message ?? String(e),
|
||
});
|
||
}
|
||
return { agentId: candidates[0].id, source: 'fallback-first' };
|
||
}
|
||
// resolveInspector / parseInspectorVerdict / parseCeoVerdict / renderStageInstruction
|
||
// / hasActionTag / claimsFileCreation
|
||
// → `src/features/company/dispatcherHelpers.ts`
|
||
import {
|
||
resolveInspector,
|
||
parseInspectorVerdict,
|
||
parseCeoVerdict,
|
||
renderStageInstruction,
|
||
hasActionTag,
|
||
claimsFileCreation,
|
||
} from './dispatcherHelpers';
|
||
|
||
|
||
|
||
|
||
|
||
/**
|
||
* 3-way 합의 검수 사이클. 작업자 산출물(latestOutput)을 받고:
|
||
* 1. 검수자에게 보내 ✅/❌ 코멘트를 받음
|
||
* 2. CEO에게 (산출물 + 검수자 코멘트)를 보내 ✅/🔁/🛑 메타-판단을 받음
|
||
* 3. 검수자 ✅ + CEO ✅ → pass / 아니면 다음 라운드 / CEO 🛑 → 즉시 abort
|
||
* 4. 최대 라운드 도달 시 maxed-out (강제 통과로 처리하되 webview에 경고)
|
||
*
|
||
* Revise verdict 시 작업자에게 *어떤 부분을 고쳐야 하는지* 검수자 코멘트가
|
||
* 그대로 전달돼야 하므로 revisionNotes 맵에 검수 코멘트를 채워 caller가
|
||
* 사용자 코멘트와 동일한 메커니즘으로 stage 재실행하게 한다.
|
||
*/
|
||
async function _runReviewCycle(args: {
|
||
stage: PipelineStage;
|
||
stageTaskText: string;
|
||
latestOutput: AgentTurnOutput;
|
||
state: CompanyState;
|
||
deps: DispatcherDeps;
|
||
emit: CompanyTurnEmitter;
|
||
isAborted: () => boolean;
|
||
}): Promise<{
|
||
verdict: 'pass' | 'revise' | 'abort' | 'maxed-out' | 'aborted';
|
||
revisionNote?: string;
|
||
rounds: number;
|
||
}> {
|
||
const { stage, stageTaskText, latestOutput, state, deps, emit, isAborted } = args;
|
||
const reviewWith = stage.reviewWith || '';
|
||
if (!reviewWith) return { verdict: 'pass', rounds: 0 };
|
||
const inspector = resolveInspector(reviewWith, state);
|
||
if (!inspector) {
|
||
// 검수자 못 찾으면 사이클 생략하고 통과로 처리 — 사용자에게 보이지
|
||
// 않게 silent; 카드 에디터의 검수 dropdown에서 사용자가 직접 인지할
|
||
// 수 있다.
|
||
logInfo('reviewCycle: no inspector resolvable; skipping.', { stageId: stage.id, reviewWith });
|
||
return { verdict: 'pass', rounds: 0 };
|
||
}
|
||
const maxRounds = Math.max(1, Math.min(10, stage.reviewMaxRounds ?? 3));
|
||
emit({
|
||
phase: 'review-start',
|
||
stageId: stage.id,
|
||
stageLabel: stage.label || stage.id,
|
||
maxRounds,
|
||
inspectorAgentId: inspector.agentId,
|
||
});
|
||
let currentOutput = latestOutput;
|
||
let lastInspectorText = '';
|
||
let lastInspectorVerdict: 'pass' | 'revise' | 'unclear' = 'unclear';
|
||
let lastCeoText = '';
|
||
let lastCeoVerdict: 'pass' | 'revise' | 'abort' | 'unclear' = 'unclear';
|
||
for (let round = 1; round <= maxRounds; round++) {
|
||
if (isAborted()) {
|
||
emit({ phase: 'review-end', stageId: stage.id, final: 'aborted', rounds: round - 1 });
|
||
return { verdict: 'aborted', rounds: round - 1 };
|
||
}
|
||
const startedAt = Date.now();
|
||
// contract가 있으면 검수자/CEO 모두에게 같은 ground truth를 prepend —
|
||
// 검수 기준이 contract와 일치하는지를 정확히 평가할 수 있다.
|
||
const contractPrefix = deps.requirementContract
|
||
? formatContractForPrompt(deps.requirementContract) + '\n\n'
|
||
: '';
|
||
|
||
// ── 1) 검수자 LLM 콜 ──
|
||
const inspectorSystem = contractPrefix + '당신은 산출물 *감리*입니다. 작업자의 결과물을 객관적으로 검토하고 한국어 마크다운으로 응답하세요.\n\n반드시 첫 줄을 다음 둘 중 하나로 시작:\n - ✅ 통과 — 산출물이 task 요구 + 위 contract의 criteria를 모두 충족하면.\n - ❌ 보완 필요: <구체 항목 한 줄> — contract 기준 누락·오류·약점이 있으면.\n\n그 다음 줄들에 *구체적인* 피드백 또는 칭찬 1~3줄. 모호한 일반론 금지.';
|
||
const inspectorUser = `[현재 stage] ${stage.label || stage.id}\n[task]\n${stageTaskText.slice(0, 1500)}\n\n[작업자 산출물]\n${(currentOutput.response || '').slice(0, 3000)}`;
|
||
let inspectorText = '';
|
||
try {
|
||
const res = await deps.ai.chat({
|
||
system: inspectorSystem,
|
||
user: inspectorUser,
|
||
model: modelForAgent(state, inspector.agentId, deps.defaultModel),
|
||
signal: deps.signal,
|
||
});
|
||
inspectorText = (res.content || '').trim();
|
||
} catch (e: any) {
|
||
logError('reviewCycle: inspector call failed.', { stageId: stage.id, round, err: e?.message ?? String(e) });
|
||
inspectorText = `❌ 보완 필요: 검수자 호출 실패 (${e?.message ?? '알 수 없음'}) — 안전을 위해 한 번 더 시도`;
|
||
}
|
||
lastInspectorText = inspectorText;
|
||
lastInspectorVerdict = parseInspectorVerdict(inspectorText);
|
||
|
||
if (isAborted()) {
|
||
emit({ phase: 'review-end', stageId: stage.id, final: 'aborted', rounds: round });
|
||
return { verdict: 'aborted', rounds: round };
|
||
}
|
||
|
||
// ── 2) CEO 메타-판단 ──
|
||
const ceoSystem = contractPrefix + '당신은 회사 CEO입니다. 작업자 산출물 + 검수자 의견을 보고 *세 명이 모두 만족하는지* 메타-판단을 내립니다. 위 contract 기준에 부합하는지가 핵심.\n\n반드시 첫 줄을 다음 셋 중 하나로 시작:\n - ✅ 통과 — 산출물·검수가 contract criteria를 모두 충족.\n - 🔁 보완 — contract 기준 한 가지 이상 미흡. 작업자에게 줄 구체 지시 1~3줄.\n - 🛑 중단 — 라운드 더 돌아도 의미 없음. 사장님께 현 상태로 보고.';
|
||
const ceoUser = `[stage] ${stage.label || stage.id}\n[task]\n${stageTaskText.slice(0, 1000)}\n\n[작업자 산출물]\n${(currentOutput.response || '').slice(0, 2000)}\n\n[검수자 의견]\n${inspectorText.slice(0, 1500)}\n\n[지금 라운드: ${round}/${maxRounds}]`;
|
||
let ceoText = '';
|
||
try {
|
||
const res = await deps.ai.chat({
|
||
system: ceoSystem,
|
||
user: ceoUser,
|
||
model: modelForAgent(state, 'ceo', deps.defaultModel),
|
||
signal: deps.signal,
|
||
});
|
||
ceoText = (res.content || '').trim();
|
||
} catch (e: any) {
|
||
logError('reviewCycle: CEO meta call failed.', { stageId: stage.id, round, err: e?.message ?? String(e) });
|
||
ceoText = lastInspectorVerdict === 'pass' ? '✅ 통과' : '🔁 보완';
|
||
}
|
||
lastCeoText = ceoText;
|
||
lastCeoVerdict = parseCeoVerdict(ceoText);
|
||
|
||
emit({
|
||
phase: 'review-round',
|
||
stageId: stage.id,
|
||
round,
|
||
inspectorAgentId: inspector.agentId,
|
||
inspectorText,
|
||
inspectorVerdict: lastInspectorVerdict,
|
||
ceoText,
|
||
ceoVerdict: lastCeoVerdict,
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
|
||
// ── 3) 합의 판정 ──
|
||
// 검수자 ✅ + CEO ✅ → 통과. CEO 🛑 → 즉시 중단. 그 외 → 다음 라운드.
|
||
// unclear는 안전한 쪽(revise)으로 폴백.
|
||
if (lastInspectorVerdict === 'pass' && lastCeoVerdict === 'pass') {
|
||
emit({ phase: 'review-end', stageId: stage.id, final: 'pass', rounds: round });
|
||
return { verdict: 'pass', rounds: round };
|
||
}
|
||
if (lastCeoVerdict === 'abort') {
|
||
emit({ phase: 'review-end', stageId: stage.id, final: 'aborted', rounds: round });
|
||
return { verdict: 'abort', rounds: round };
|
||
}
|
||
// revise — 다음 라운드 진입 전 작업자에게 줄 코멘트 합성.
|
||
const note = [
|
||
`[검수자 ${inspector.agentId}] ${inspectorText.slice(0, 600)}`,
|
||
`[CEO 메타] ${ceoText.slice(0, 400)}`,
|
||
].join('\n\n');
|
||
// 마지막 라운드 직전이라면 더 이상 작업자를 부를 일 없음 — 그냥 maxed-out.
|
||
if (round >= maxRounds) {
|
||
emit({ phase: 'review-end', stageId: stage.id, final: 'maxed-out', rounds: round });
|
||
return { verdict: 'maxed-out', revisionNote: note, rounds: round };
|
||
}
|
||
// 작업자 재실행: caller가 stage를 다시 dispatch하도록 revisionNote 전달.
|
||
// 그런데 사이클은 한 단위(검수+CEO)를 caller 밖에서 끝나야 하므로 여기서
|
||
// 직접 작업자 재실행 → 새 currentOutput 갱신.
|
||
const reDispatchTask = `[검수 피드백 — ${round}라운드]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${stageTaskText}`;
|
||
emit({ phase: 'agent-start', agentId: currentOutput.agentId, task: reDispatchTask, index: -1, total: maxRounds });
|
||
const reTurn = await _dispatchOne(currentOutput.agentId, reDispatchTask, [], state, deps, stage.modelOverride);
|
||
emit({ phase: 'agent-done', agentId: currentOutput.agentId, output: reTurn, index: -1, total: maxRounds });
|
||
currentOutput = reTurn;
|
||
}
|
||
// 정상 흐름에선 위 break 조건 중 하나로 빠지지만 안전망으로:
|
||
emit({ phase: 'review-end', stageId: stage.id, final: 'maxed-out', rounds: maxRounds });
|
||
return {
|
||
verdict: 'maxed-out',
|
||
revisionNote: `[검수자 ${inspector.agentId}] ${lastInspectorText.slice(0, 600)}\n\n[CEO 메타] ${lastCeoText.slice(0, 400)}`,
|
||
rounds: maxRounds,
|
||
};
|
||
}
|
||
|
||
/** _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,
|
||
brief: string,
|
||
sessionDir: string,
|
||
timestamp: string,
|
||
state: CompanyState,
|
||
deps: DispatcherDeps,
|
||
isAborted: () => boolean,
|
||
emit: CompanyTurnEmitter,
|
||
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> = 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> = 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 abortReturn('aborted-mid-pipeline');
|
||
const stage = pipeline.stages[i];
|
||
const baseTask = renderStageInstruction(stage, userPrompt, brief, latestByStage);
|
||
const note = revisionNotes[stage.id];
|
||
const task = note
|
||
? `[사용자 수정 요청]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${baseTask}`
|
||
: baseTask;
|
||
// 동적 담당자 해결. stage.agentId가 박혀 있으면 그걸 쓰고, 비어 있으면
|
||
// CEO가 직군 후보 중에서 1회 LLM 콜로 적임자 선택. 모든 후보가 비활성/없음
|
||
// 이면 null — 그 경우 stage를 에러로 마킹하고 건너뛴다(파이프라인 hang 방지).
|
||
const picked = await _resolveStageAgent(stage, task, state, deps);
|
||
if (!picked) {
|
||
const errOutput: AgentTurnOutput = {
|
||
agentId: stage.agentId || `<${stage.roleCategory ?? 'unknown'}>`,
|
||
task,
|
||
response: `⚠️ 이 단계에 배정할 활성 에이전트가 없습니다 (직군: ${stage.roleCategory ?? '미지정'}). 관리 패널에서 해당 직군의 에이전트를 활성화하거나, stage에 직접 담당자를 지정하세요.`,
|
||
durationMs: 0,
|
||
error: 'no-active-agent-in-role',
|
||
};
|
||
outputs.push(errOutput);
|
||
latestByStage[stage.id] = errOutput;
|
||
writeAgentOutput(sessionDir, errOutput);
|
||
emit({ phase: 'agent-done', agentId: errOutput.agentId, output: errOutput, index: stepIndex, total });
|
||
stepIndex++;
|
||
i++;
|
||
continue;
|
||
}
|
||
const resolvedAgentId = picked.agentId;
|
||
// CEO 선택 시 사용자에게 *왜 이 사람*인지 한 줄로 보여주기 위해 task 앞에
|
||
// 짧은 메타 한 줄을 prepend — 에이전트 시스템 프롬프트엔 영향 없고 chat
|
||
// 카드 표시에만 쓰인다.
|
||
let taskForChat = task;
|
||
if (picked.source === 'ceo-selected' && picked.reason) {
|
||
taskForChat = `[🧭 CEO 선임: ${picked.reason}]\n\n${task}`;
|
||
}
|
||
emit({ phase: 'agent-start', agentId: resolvedAgentId, task: taskForChat, index: stepIndex, total });
|
||
const turn = await _dispatchOne(resolvedAgentId, task, outputs, state, deps, stage.modelOverride);
|
||
outputs.push(turn);
|
||
latestByStage[stage.id] = turn;
|
||
writeAgentOutput(sessionDir, turn);
|
||
appendAgentMemory(
|
||
deps.context, resolvedAgentId,
|
||
`[${timestamp}][${pipeline.id}/${stage.id}] ${task.slice(0, 120)} — ${turn.error ? `❌ ${turn.error}` : '✅'}`,
|
||
);
|
||
emit({ phase: 'agent-done', agentId: resolvedAgentId, output: turn, index: stepIndex, total });
|
||
stepIndex++;
|
||
// Successful run consumed the revision note (if any) — clear it.
|
||
if (!turn.error) delete revisionNotes[stage.id];
|
||
|
||
// ── 3-way 검수 사이클 ──
|
||
// 작업자가 에러 없이 응답을 냈고, stage에 reviewWith가 설정돼 있으면
|
||
// 검수자 + CEO 메타-판단 사이클로 합의를 도출. 합의 실패 시:
|
||
// - revise/maxed-out: 검수 코멘트를 revisionNote로 받아 stage 재실행
|
||
// (loop-back과 동일한 메커니즘 재활용)
|
||
// - abort: 사용자에게 알리고 라운드 종료
|
||
if (stage.reviewWith && !turn.error) {
|
||
const reviewResult = await _runReviewCycle({
|
||
stage,
|
||
stageTaskText: task,
|
||
latestOutput: turn,
|
||
state, deps, emit, isAborted,
|
||
});
|
||
if (reviewResult.verdict === 'aborted') {
|
||
return abortReturn('aborted-during-review');
|
||
}
|
||
if (reviewResult.verdict === 'abort') {
|
||
return abortReturn('aborted-by-ceo-review');
|
||
}
|
||
// revise / maxed-out — 모두 작업자에게 다시 보내 한 번 더 (loop-back).
|
||
// 단, maxed-out은 사용자에게 "한계 도달, 마지막 결과로 진행"을 알려야
|
||
// 더 자연스러우므로 다음 stage로 그대로 진행 (revisionNote 무시).
|
||
if (reviewResult.verdict === 'revise' && reviewResult.revisionNote) {
|
||
revisionNotes[stage.id] = reviewResult.revisionNote;
|
||
continue; // 같은 stage 재실행 — while(i)는 그대로
|
||
}
|
||
// pass / maxed-out → 다음 단계로 진행 (revisionNotes 클리어는 위에서 이미)
|
||
}
|
||
|
||
// ── Manual approval gate ──
|
||
// After agent-done emits, before loop-back / next stage advance,
|
||
// give the user a chance to inspect and approve. We only fire the
|
||
// gate when (a) the stage opted in, (b) we have an awaitApproval
|
||
// bridge from the host, and (c) the stage didn't error out
|
||
// (errored stages would loop forever waiting for "approval" of a
|
||
// failure — the user should just hit Stop).
|
||
if (stage.requiresApproval && deps.awaitApproval && !turn.error) {
|
||
emit({
|
||
phase: 'awaiting-approval',
|
||
stageId: stage.id,
|
||
stageLabel: stage.label || stage.id,
|
||
index: stepIndex - 1,
|
||
total,
|
||
});
|
||
let decision: ApprovalDecision;
|
||
try {
|
||
decision = await deps.awaitApproval({ stageId: stage.id, stageLabel: stage.label || stage.id });
|
||
} catch {
|
||
// 호스트가 에러를 던지면 안전하게 중단 — 무한 대기 방지.
|
||
decision = { kind: 'abort' };
|
||
}
|
||
if (isAborted()) return abortReturn('aborted-mid-approval');
|
||
emit({ phase: 'approval-resolved', stageId: stage.id, decision: decision.kind });
|
||
if (decision.kind === 'abort') {
|
||
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.
|
||
}
|
||
|
||
// Loop-back evaluation. We only loop on *successful* responses with
|
||
// non-empty body — an error or empty response would loop forever.
|
||
if (stage.loopBackTo && stage.loopBackPattern && !turn.error && turn.response.trim()) {
|
||
const limit = stage.maxIterations ?? 3;
|
||
const count = (iterations[stage.id] ?? 0) + 1;
|
||
iterations[stage.id] = count;
|
||
let re: RegExp | null = null;
|
||
try { re = new RegExp(stage.loopBackPattern, 'i'); } catch { re = null; }
|
||
if (re && re.test(turn.response) && count <= limit) {
|
||
const targetIdx = pipeline.stages.findIndex((s) => s.id === stage.loopBackTo);
|
||
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 };
|
||
}
|
||
|
||
|
||
|