diff --git a/app/api/companies/[ws]/chat/route.js b/app/api/companies/[ws]/chat/route.js index 25df2c56..a8c10211 100644 --- a/app/api/companies/[ws]/chat/route.js +++ b/app/api/companies/[ws]/chat/route.js @@ -1,7 +1,7 @@ import { relative } from 'node:path'; import { chat } from '../../../../../src/chat.mjs'; import { paths } from '../../../../../src/workspace.mjs'; -import { loadThread, appendTurn, beginTurn, resetThread } from '../../../../../src/thread.mjs'; +import { loadThread, appendTurn, appendUserMsg, completePendingTurn, failPendingTurn, resetThread } from '../../../../../src/thread.mjs'; import { getTurnStatus } from '../../../../../src/turn-status.mjs'; import { nudgeSync } from '../../../../../src/sync.mjs'; import { guardCompany } from '../../../../auth.mjs'; @@ -12,10 +12,35 @@ export const maxDuration = 300; // 에이전트 턴은 vault 탐색 포함 수 export async function GET(req, { params }) { const { ws } = await params; const denied = await guardCompany(ws); if (denied) return denied; - const slug = new URL(req.url).searchParams.get('slug'); + const search = new URL(req.url).searchParams; + const slug = search.get('slug'); if (!slug) return Response.json({ error: 'slug가 필요합니다' }, { status: 400 }); const [thread, status] = await Promise.all([loadThread(ws, slug), getTurnStatus(ws, slug)]); - return Response.json({ ...thread, status }); + // 긴 대화는 최근 구간부터 보내고, 사용자가 위로 요청할 때만 이전 구간을 내려준다. + // limit/before/after가 없는 기존 호출자는 전체 스레드를 받는 하위호환 계약을 유지한다. + const windowed = search.has('limit') || search.has('before') || search.has('after'); + if (!windowed) return Response.json({ ...thread, status }); + const all = Array.isArray(thread.messages) ? thread.messages : []; + const totalMessages = all.length; + const limit = Math.max(1, Math.min(100, Number(search.get('limit')) || 50)); + const afterRaw = search.get('after'); + let start; + let end; + if (afterRaw !== null) { + start = Math.max(0, Math.min(totalMessages, Number(afterRaw) || 0)); + end = totalMessages; + } else { + end = Math.max(0, Math.min(totalMessages, Number(search.get('before')) || totalMessages)); + start = Math.max(0, end - limit); + } + return Response.json({ + ...thread, + messages: all.slice(start, end), + totalMessages, + start, + hasMore: start > 0, + status, + }); } export async function POST(req, { params }) { @@ -31,36 +56,36 @@ export async function POST(req, { params }) { .filter((a) => typeof a?.rel === 'string' && a.rel.startsWith('files/') && !a.rel.includes('..')) .map((a) => ({ rel: a.rel, name: String(a.name ?? ''), mime: String(a.mime ?? ''), isImage: !!a.isImage })) .slice(0, 8); - // 지시를 **먼저** 저장한다 — 답변을 기다리는 동안 새로고침하거나 페이지를 벗어나도 내가 쓴 글이 - // 그대로 남아 있어야 한다(신고 2026-08-02). 저장 실패는 턴을 막지 않는다(대화가 우선). - const turnId = await beginTurn(ws, slug, { userMsg: message.trim(), attachments }) - .catch((err) => { console.error(`[argo] 지시 선저장 실패(${ws}/${slug}):`, err?.message ?? err); return null; }); - if (turnId) nudgeSync(); // 다른 기기에도 곧바로 보이게 + // 1단계: 사용자 메시지 사전 저장 + // 지시를 **먼저** 저장한다 — 답변을 기다리는 동안 새로고침하거나 페이지를 벗어나도 내가 쓴 글이 그대로 남아 있어야 한다(신고 2026-08-02). + const mid = `t${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + await appendUserMsg(ws, slug, { userMsg: message.trim(), attachments, mid }) + .catch((err) => { console.error(`[argo] 지시 선저장 실패(${ws}/${slug}):`, err?.message ?? err); }); + nudgeSync(); // 다른 기기에도 곧바로 보이게 + let t; try { + // 2단계: 답변 생성 t = await chat(ws, slug, message.trim(), sessionId || null, { attachments }); } catch (e) { // 실패·중단 턴도 스레드에 남긴다 — 성공 뒤에만 저장하면 지시문이 새로고침에 증발하고 비용만 // 남는다(전수리뷰 2026-07-30 #1). UI는 m.failed로 사유+재전송을 그린다(기존 낙관 사본 패턴). - // 중단 판정은 **별도 필드(aborted)** — 사유 문자열과 같은 필드에 'aborted' 센티널을 두면 - // 상류 원문이 우연히 그 단어일 때(node:http ECONNRESET의 message='aborted' 실측) "지시대로 - // 중단했습니다"로 원인을 오도한다(재검수 MEDIUM). 사유는 원문 그대로, 표시 문구는 UI가 t()로. const failed = String(e?.message || e); const aborted = !!e?.aborted; - // 저장 성공 여부(saved)를 응답에 싣는다 — 클라 낙관 사본은 saved=false일 때만 폴링 병합에서 - // 캐리오버한다. 안 실으면 서버 보존분과 사본이 라운드마다 복제 누적된다(분리 검수 HIGH 시뮬레이션). - // 기록 실패는 무증상으로 삼키지 않는다(scheduler·routines와 같은 규칙 — 검수 LOW). - const saved = await appendTurn(ws, slug, { turnId, userMsg: message.trim(), failed, aborted, attachments }) + // 저장 성공 여부(saved)를 응답에 싣는다 + const saved = await failPendingTurn(ws, slug, { mid, error: failed, aborted }) .then(() => true) .catch((err) => { console.error(`[argo] 실패 턴 기록 실패(${ws}/${slug}):`, err?.message ?? err); return false; }); if (saved) nudgeSync(); return Response.json({ error: failed, aborted, saved }, { status: 500 }); } + + // 3단계: 답변 저장 // handover 없는 턴(예: 예산 초과 안내)도 안전하게 — null 접근 크래시 방지 const handover = t.handover ? { rel: relative(paths(ws).vault, t.handover.file), linked: t.handover.linked } : null; - await appendTurn(ws, slug, { turnId, userMsg: message.trim(), reply: t.reply, handover, sessionId: t.sessionId, attachments, artifacts: t.artifacts }); + const saved = await completePendingTurn(ws, slug, { mid, reply: t.reply, handover, sessionId: t.sessionId, artifacts: t.artifacts }); nudgeSync(); // 로컬 변경 즉시 다른 기기로 전파(준실시간 — 다음 대기 건너뜀) - return Response.json({ reply: t.reply, sessionId: t.sessionId, handover, artifacts: t.artifacts }); + return Response.json({ reply: t.reply, sessionId: t.sessionId, handover, artifacts: t.artifacts, totalMessages: saved?.messages?.length ?? 0 }); } catch (e) { return Response.json({ error: String(e.message || e) }, { status: 500 }); } diff --git a/app/c/[ws]/crew/[slug]/page.jsx b/app/c/[ws]/crew/[slug]/page.jsx index 855cf5ce..57433b13 100644 --- a/app/c/[ws]/crew/[slug]/page.jsx +++ b/app/c/[ws]/crew/[slug]/page.jsx @@ -947,7 +947,7 @@ export default function CrewChat({ params }) { ) : m.who === 'user' ? (
{ if (!m.mid) return; if (el) msgRefs.current.set(m.mid, el); else msgRefs.current.delete(m.mid); }}> -
+
{m.attachments?.length > 0 && ( {m.attachments.map((a, j) => a.isImage ? ( @@ -960,6 +960,12 @@ export default function CrewChat({ params }) { )} {m.text} + {m.pending && !viewing && ( +
+ + {t('chat.pending')} +
+ )}
{/* 실패한 턴 — 글은 스레드에 그대로 두고 사유와 재시도만 붙인다(호버로 숨지 않게 항상 표시) */} {m.failed && !viewing && ( diff --git a/app/i18n.jsx b/app/i18n.jsx index f4f71782..9d83da95 100644 --- a/app/i18n.jsx +++ b/app/i18n.jsx @@ -32,6 +32,7 @@ const DICT = { 'common.count': ['건', ''], 'common.people': ['명', ''], 'common.clear': ['지우기', 'Clear'], + 'common.loading': ['불러오는 중', 'Loading'], // ── 위험 모달 (깃헙식) 'danger.typeName': ['확인을 위해 이름을 똑같이 입력하세요:', 'To confirm, type the name exactly:'], @@ -748,14 +749,68 @@ const DICT = { 'chat.cmd.aliasText': ['넣을 지시 내용', 'Prompt to insert'], 'chat.cmd.aliasTextPh': ['예: 오늘 진행 상황을 표로 정리해서 보고해줘', 'e.g. Summarize today’s progress as a table'], 'chat.newChat': ['새 대화', 'New chat'], - 'crew.panel.title': ['작업 패널', 'Workspace'], + 'chat.older': ['이전 대화 {n}개 불러오기', 'Load {n} earlier messages'], + 'chat.olderLoading': ['이전 대화 불러오는 중', 'Loading earlier messages'], + 'crew.panel.title': ['도구 패널', 'Tool panel'], 'crew.panel.open': ['패널', 'Panel'], + 'crew.panel.toggle': ['사이드 패널 표시/숨기기', 'Toggle side panel'], + 'crew.panel.shortcut': ['Ctrl+Alt+B', 'Ctrl+Alt+B'], 'crew.panel.close': ['닫기', 'Close'], 'crew.panel.tab.tasks': ['작업', 'Tasks'], 'crew.panel.tab.files': ['파일', 'Files'], 'crew.panel.tasks.recent': ['최근', 'Recent'], 'crew.panel.tasks.empty': ['이 크루의 진행 중·최근 작업이 없습니다', 'No active or recent work for this crew'], 'crew.panel.files.empty': ['주고받은 파일이 없습니다', 'No files exchanged yet'], + 'crew.tools.title': ['도구 작업영역', 'Tool workspace'], + 'crew.tools.open': ['도구 열기', 'Open tool'], + 'crew.tools.closeTab': ['{name} 탭 닫기', 'Close {name} tab'], + 'crew.tools.files': ['파일', 'Files'], + 'crew.tools.terminal': ['터미널', 'Terminal'], + 'crew.tools.browser': ['브라우저', 'Browser'], + 'crew.tools.files.desc': ['프로젝트 트리에서 문서 열기', 'Open documents from the project tree'], + 'crew.tools.terminal.desc': ['현재 프로젝트에서 셸 실행', 'Run a shell in the current project'], + 'crew.tools.browser.desc': ['웹 또는 로컬 앱 열기', 'Open the web or a local app'], + 'crew.tools.empty': ['열 도구를 선택하세요', 'Choose a tool to open'], + 'crew.tools.refresh': ['새로고침', 'Refresh'], + 'crew.tools.resizePanel': ['사이드 패널 폭 조절', 'Resize side panel'], + 'crew.tools.settings': ['설정 열기', 'Open settings'], + 'crew.tools.files.root': ['작업 폴더', 'Workspace root'], + 'crew.tools.files.filter': ['파일 필터링…', 'Filter files…'], + 'crew.tools.files.searching': ['찾는 중', 'Searching'], + 'crew.tools.files.noMatch': ['일치하는 파일이 없습니다', 'No matching files'], + 'crew.tools.files.connect': ['외부 작업 폴더 연결', 'Connect an external work folder'], + 'crew.tools.files.pick': ['파일을 선택해서 여세요', 'Select a file to open it'], + 'crew.tools.files.openFiles': ['열린 파일', 'Open files'], + 'crew.tools.files.closeFile': ['{name} 파일 닫기', 'Close {name}'], + 'crew.tools.files.location': ['파일 위치', 'File location'], + 'crew.tools.files.reload': ['파일 다시 열기', 'Reload file'], + 'crew.tools.files.download': ['파일 다운로드', 'Download file'], + 'crew.tools.files.editor': ['Markdown 편집기', 'Markdown editor'], + 'crew.tools.files.preview': ['미리보기', 'Preview'], + 'crew.tools.files.saving': ['저장 중…', 'Saving…'], + 'crew.tools.files.unsaved': ['저장하지 않은 변경 사항', 'Unsaved changes'], + 'crew.tools.files.discardConfirm': ['저장하지 않은 변경 사항을 버릴까요?', 'Discard unsaved changes?'], + 'crew.tools.files.changed': ['파일이 외부에서 변경되었습니다. 다시 연 뒤 수정해 주세요.', 'The file changed outside Argo. Reload it before editing again.'], + 'crew.tools.files.saveFailed': ['파일을 저장하지 못했습니다', 'Could not save the file'], + 'crew.tools.files.tooLarge': ['이 파일은 앱에서 열 수 있는 크기를 넘습니다', 'This file is too large to open in the app'], + 'crew.tools.files.binary': ['이 파일 형식은 앱에서 열 수 없습니다', 'This file type cannot be opened in the app'], + 'crew.tools.files.resize': ['파일 트리 폭 조절', 'Resize file tree'], + 'crew.tools.terminal.cwd': ['터미널 작업 폴더', 'Terminal working directory'], + 'crew.tools.terminal.starting': ['터미널을 시작하는 중…', 'Starting terminal…'], + 'crew.tools.terminal.restart': ['터미널 다시 시작', 'Restart terminal'], + 'crew.tools.terminal.output': ['터미널 출력', 'Terminal output'], + 'crew.tools.terminal.disabled': ['셸 권한이 꺼져 있습니다', 'Shell capability is disabled'], + 'crew.tools.terminal.exited': ['터미널이 종료되었습니다. 다시 시작을 누르세요.', 'Terminal exited. Press restart.'], + 'crew.tools.terminal.placeholder': ['명령을 입력하세요', 'Enter a command'], + 'crew.tools.terminal.run': ['실행', 'Run'], + 'crew.tools.terminal.note': ['일반 셸 명령과 장기 실행 출력에 적합합니다. 전체화면 TUI는 지원하지 않습니다.', 'Supports normal shell commands and long-running output. Full-screen TUIs are not supported.'], + 'crew.tools.browser.back': ['뒤로', 'Back'], + 'crew.tools.browser.forward': ['앞으로', 'Forward'], + 'crew.tools.browser.address': ['주소 또는 검색어', 'Address or search'], + 'crew.tools.browser.placeholder': ['주소 또는 검색어 입력', 'Enter an address or search'], + 'crew.tools.browser.external': ['외부 브라우저에서 열기', 'Open in external browser'], + 'crew.tools.browser.frame': ['내장 브라우저', 'Embedded browser'], + 'crew.tools.browser.note': ['사이트가 내장 표시를 막는 경우 오른쪽 위 외부 열기 버튼을 사용하세요.', 'If a site blocks embedded viewing, use the external-open button above.'], 'chat.firstPrompt': ['첫 지시를 내려보세요. 매 턴의 결과는 회사 기억에 남고, 비슷한 기억끼리 이어집니다.', 'Give your first instruction. Every turn\'s result stays in company memory, and similar memories link up.'], 'chat.recordedInMemory': ['기억에 기록됨', 'Recorded in memory'], 'chat.linkedMemories': ['· 관련 기억 {n}건과 연결', '· linked with {n} related memories'], @@ -796,6 +851,7 @@ const DICT = { 'chat.annotate.msgHead': ['부분 코멘트 — 아래 표시한 부분만 고쳐 주세요. 나머지는 그대로 유지하세요.', 'Inline comments — revise only the marked parts. Keep everything else as is.'], 'chat.copied': ['복사됨', 'Copied'], 'chat.resend': ['다시 보내기', 'Resend'], + 'chat.pending': ['답변 대기 중…', 'Waiting for reply…'], // 배달 지시 출처표식 — 사장 말풍선과 구분(신고 2026-07-28 "내가 쓴 게 아니거든") 'chat.via.crewmail': ['동료 쪽지', 'Crew mail'], 'chat.via.delegate': ['동료 위임', 'Delegated'], diff --git "a/docs/debugging/006_\355\201\254\353\243\250-\354\240\204\355\231\230-\354\213\234-\354\247\210\353\254\270-\354\202\254\353\235\274\354\247\220-\354\235\264\354\212\210-\354\233\220\354\235\270\353\266\204\354\204\235.md" "b/docs/debugging/006_\355\201\254\353\243\250-\354\240\204\355\231\230-\354\213\234-\354\247\210\353\254\270-\354\202\254\353\235\274\354\247\220-\354\235\264\354\212\210-\354\233\220\354\235\270\353\266\204\354\204\235.md" new file mode 100644 index 00000000..df7c2e07 --- /dev/null +++ "b/docs/debugging/006_\355\201\254\353\243\250-\354\240\204\355\231\230-\354\213\234-\354\247\210\353\254\270-\354\202\254\353\235\274\354\247\220-\354\235\264\354\212\210-\354\233\220\354\235\270\353\266\204\354\204\235.md" @@ -0,0 +1,196 @@ +# 크루 전환 시 질문 사라짐 이슈 — 원인 분석 + +> **분석일**: 2026-08-12 21:50 KST + +--- + +## 결론 (TL;DR) + +**사용자 메시지는 답변 생성이 완료된 후에만 디스크에 저장됩니다.** +답변 생성 중 크루를 전환하면 React 컴포넌트가 언마운트되면서 메모리상의 낙관적 업데이트(메시지)가 유실되고, 서버에도 아직 저장되지 않았으므로 메시지가 완전히 사라집니다. + +--- + +## 문제의 전체 흐름 + +```mermaid +sequenceDiagram + participant User as 사용자 + participant UI as React State
(page.jsx) + participant API as POST /chat + participant LLM as AI 러너 + participant Disk as chats/slug.json + + User->>UI: 메시지 입력 + 전송 + Note over UI: setThread([...thread, userMsg])
낙관적 업데이트 (메모리만) + UI->>API: fetch POST /chat {message} + API->>LLM: await chat(ws, slug, message) + Note over LLM: 답변 생성 중...
(수초~수분 소요) + + rect rgb(255, 200, 200) + Note over User,UI: ⚠️ 이 시점에 크루 전환! + User->>UI: 다른 크루 클릭 + Note over UI: slug 변경 → useEffect 발동
setThread(null) → React 상태 초기화
컴포넌트 언마운트 + Note over UI: 낙관적 업데이트된
사용자 메시지 유실! + end + + LLM-->>API: reply 반환 + API->>Disk: appendTurn(userMsg + reply) + Note over Disk: ✅ 이제야 디스크에 저장 + API-->>UI: Response (but UI가 이미 없음) + Note over UI: fetch의 응답을
받을 컴포넌트가 없음 +``` + +--- + +## 원인 분석 (코드 근거) + +### 원인 1: `appendTurn`이 답변 완료 후에만 실행 + +[`app/api/companies/[ws]/chat/route.js`](file:///home/bhlee/services/argo_agent/argo/app/api/companies/%5Bws%5D/chat/route.js) L59-62: + +```javascript +// 1. 답변 생성 대기 (수초~수분) +const t = await chat(ws, slug, message.trim(), sessionId || null, { attachments }); +// 2. 답변 완료 후 비로소 디스크에 저장 +const saved = await appendTurn(ws, slug, { + userMsg: message.trim(), reply: t.reply, ... +}); +``` + +[`src/thread.mjs`](file:///home/bhlee/services/argo_agent/argo/src/thread.mjs) L26-43에서 `appendTurn`은 **사용자 메시지와 크루 답변을 한 쌍으로** 동시에 저장합니다: + +```javascript +t.messages.push( + { who: 'user', text: userMsg, ts }, // 사용자 메시지 + { who: 'crew', text: reply, ts }, // 크루 답변 +); +await writeJsonAtomic(file(wsId, slug), t); +``` + +→ **답변이 없으면 사용자 메시지도 저장되지 않습니다.** + +### 원인 2: 크루 전환 시 React 상태 초기화 + +[`app/c/[ws]/crew/[slug]/page.jsx`](file:///home/bhlee/services/argo_agent/argo/app/c/%5Bws%5D/crew/%5Bslug%5D/page.jsx) L279-298: + +```javascript +useEffect(() => { + let alive = true; + // ⚠️ slug가 바뀌면 즉시 thread를 null로 초기화 + setThread(null); setError(''); sessionRef.current = null; + // 새 크루의 대화를 서버에서 로드 + api(`/api/companies/${ws}/chat?slug=${slug}&limit=...`) + .then((data) => { if (!alive) return; setThread(data.messages); }) + ... + return () => { alive = false; }; +}, [ws, slug]); // slug 변경에 반응 +``` + +크루 전환 = **slug 변경** → `useEffect` cleanup + 재실행 → `setThread(null)` → **이전 크루의 낙관적 메시지 유실** + +### 원인 3: 프론트엔드에서 요청 취소를 하지 않음 + +`sendMessage`의 `api()` 호출에 `AbortController`가 연결되어 있지 않습니다. 따라서: + +- **백엔드 작업은 계속 진행됨** (크루 전환해도 답변 생성은 중단되지 않음) +- **그러나 fetch 응답을 받을 컴포넌트가 이미 없음** (언마운트됨) +- **결과**: 백엔드가 답변을 완료하면 `appendTurn`이 실행되어 디스크에 저장은 됨 + +--- + +## 시나리오별 결과 + +| 시나리오 | 질문 표시 | 디스크 저장 | 돌아왔을 때 | +|----------|----------|------------|------------| +| 같은 크루에 머물러 있음 | ✅ 화면에 보임 | ✅ 답변 후 저장 | — | +| 전환 후 답변 완료 전 돌아옴 | ❌ 사라짐 (서버에서 GET, 아직 미저장) | ❌ 아직 저장 안 됨 | 빈 상태 | +| 전환 후 답변 완료 후 돌아옴 | ✅ 복원됨 (서버 GET으로 로드) | ✅ appendTurn 완료 | 정상 | +| 전환 후 답변 실패(에러) | ❌ 영구 소실 | ❌ appendTurn 미도달 | 빈 상태 | + +> [!IMPORTANT] +> **핵심 모순**: 코드 주석에 "도중엔 이 사본이 사장 글의 유일한 원본이다"라고 명시하면서도, 크루 전환 시 이 "유일한 원본"이 유실되는 것을 방어하지 않습니다. + +--- + +## 개선안 + +### 방안 A: 사용자 메시지 사전 저장 (권장) + +`appendTurn`을 분리하여, 사용자 메시지를 **답변 생성 전에** 먼저 디스크에 기록합니다. + +```javascript +// app/api/companies/[ws]/chat/route.js — 변경안 +export async function POST(req, { params }) { + const { ws } = await params; + const { slug, message, sessionId, attachments } = await req.json(); + + // 1단계: 사용자 메시지를 즉시 디스크에 저장 (pending 상태) + await appendUserMessage(ws, slug, { userMsg: message.trim(), attachments }); + + // 2단계: 답변 생성 + const t = await chat(ws, slug, message.trim(), sessionId || null, { attachments }); + + // 3단계: 크루 답변 추가 (pending 해제) + const saved = await completeUserMessage(ws, slug, { + reply: t.reply, handover, sessionId: t.sessionId, artifacts: t.artifacts + }); + return Response.json({ ... }); +} +``` + +**장점**: 답변 완료 여부와 관계없이 사용자 메시지가 항상 보존됨 +**단점**: `thread.mjs`의 구조 변경 필요, 실패 시 pending 메시지 정리 로직 필요 + +### 방안 B: 프론트엔드 임시 저장 (최소 변경) + +크루 전환 시 진행 중인 메시지를 `localStorage`에 임시 보관합니다. + +```javascript +// page.jsx — useEffect cleanup에 추가 +useEffect(() => { + return () => { + // 언마운트 시 진행 중인 낙관적 메시지를 localStorage에 보존 + if (busyRef.current && pendingMidRef.current) { + const pending = threadRef.current?.find(m => m.mid === pendingMidRef.current); + if (pending) { + localStorage.setItem(`argo-pending:${ws}:${slug}`, JSON.stringify(pending)); + } + } + }; +}, [ws, slug]); +``` + +**장점**: 변경 최소 (프론트엔드만) +**단점**: 백엔드 실패 시 orphan 메시지 처리 필요, localStorage 의존 + +### 방안 C: 백엔드 선저장 + 낙관적 조회 + +API 라우트에서 `chat()` 호출 전에 사용자 메시지만 먼저 `appendUserMsg`로 저장하고, GET 조회 시 pending 포함 반환합니다. + +```javascript +// thread.mjs에 추가 +export async function appendUserMsg(wsId, slug, { userMsg, attachments }) { + return withLock(lockKey(wsId, slug), async () => { + const t = await loadThread(wsId, slug); + t.messages.push({ who: 'user', text: userMsg, ts: Date.now(), + pending: true, ...(attachments?.length ? { attachments } : {}) }); + await writeJsonAtomic(file(wsId, slug), t); + return t; + }); +} +``` + +**장점**: 어떤 경우에도 사용자 메시지 보존, 서버 중심 단일 진실 소스 +**단점**: 실패/중단 시 pending 메시지 정리 로직 필요 + +--- + +## 관련 파일 + +| 파일 | 역할 | +|------|------| +| [`chat/route.js`](file:///home/bhlee/services/argo_agent/argo/app/api/companies/%5Bws%5D/chat/route.js) | POST 핸들러 — `chat()` → `appendTurn()` 순서 | +| [`thread.mjs`](file:///home/bhlee/services/argo_agent/argo/src/thread.mjs) | `appendTurn` — user+crew 메시지 쌍으로 저장 | +| [`page.jsx`](file:///home/bhlee/services/argo_agent/argo/app/c/%5Bws%5D/crew/%5Bslug%5D/page.jsx) | `sendMessage` 낙관적 업데이트, slug 변경 시 `setThread(null)` | +| [`chat.mjs`](file:///home/bhlee/services/argo_agent/argo/src/chat.mjs) | 러너 실행 후 `saveHandover` (일지 저장) | diff --git "a/docs/debugging/007_\355\201\254\353\243\250-\354\240\204\355\231\230-\354\247\210\353\254\270\354\234\240\354\213\244-\353\260\251\354\247\200-A\354\225\210-\352\265\254\355\230\204\352\262\260\352\263\274.md" "b/docs/debugging/007_\355\201\254\353\243\250-\354\240\204\355\231\230-\354\247\210\353\254\270\354\234\240\354\213\244-\353\260\251\354\247\200-A\354\225\210-\352\265\254\355\230\204\352\262\260\352\263\274.md" new file mode 100644 index 00000000..10b22b91 --- /dev/null +++ "b/docs/debugging/007_\355\201\254\353\243\250-\354\240\204\355\231\230-\354\247\210\353\254\270\354\234\240\354\213\244-\353\260\251\354\247\200-A\354\225\210-\352\265\254\355\230\204\352\262\260\352\263\274.md" @@ -0,0 +1,112 @@ +# 크루 전환 시 질문 유실 방지 — A안(사전 저장 분리) 구현 결과 + +> **구현일**: 2026-08-12 22:10 KST + +--- + +## 변경 요약 + +| 파일 | 변경 | 설명 | +|------|------|------| +| [`src/thread.mjs`](file:///home/bhlee/services/argo_agent/argo/src/thread.mjs) | +48줄 | `appendUserMsg`, `completePendingTurn`, `failPendingTurn` 3개 함수 추가 | +| [`app/api/.../chat/route.js`](file:///home/bhlee/services/argo_agent/argo/app/api/companies/%5Bws%5D/chat/route.js) | +14/-8줄 | POST를 3단계 구조로 변경 | +| [`app/c/.../page.jsx`](file:///home/bhlee/services/argo_agent/argo/app/c/%5Bws%5D/crew/%5Bslug%5D/page.jsx) | +7/-1줄 | pending 메시지 렌더링 추가 | +| [`app/i18n.jsx`](file:///home/bhlee/services/argo_agent/argo/app/i18n.jsx) | +1줄 | `chat.pending` 키 추가 | +| [`src/chat.mjs`](file:///home/bhlee/services/argo_agent/argo/src/chat.mjs) | +3/-3줄 | 3개 러너의 컨텍스트 필터에 `!m.pending` 추가 (중복 방지) | + +전체: 7개 파일, +83줄 / -14줄 (이전 턴의 read_file/로깅 변경 포함) + +--- + +## 핵심 설계 + +### 기존 흐름 (문제) +``` +POST → chat() (수분 소요) → appendTurn(user + crew) → 응답 + ↑ 이 구간에서 크루 전환 시 메시지 유실 +``` + +### 변경 후 흐름 (해결) +``` +POST → appendUserMsg(user, pending) → chat() → completePendingTurn → 응답 + ↑ 즉시 디스크 저장 ↑ 실패 시 failPendingTurn +``` + +--- + +## 변경 상세 + +### 1. `src/thread.mjs` — 3개 새 함수 + +```javascript +// 1단계: 사용자 메시지 즉시 저장 (pending: true) +export async function appendUserMsg(wsId, slug, { userMsg, attachments, mid }) + +// 2단계-성공: pending 해제 + 크루 답변 추가 +export async function completePendingTurn(wsId, slug, { mid, reply, handover, sessionId, artifacts }) + +// 2단계-실패: pending → failed 전환 +export async function failPendingTurn(wsId, slug, { mid, error }) +``` + +기존 `appendTurn`은 변경하지 않음 (gateway, scheduler 등 다른 호출자가 사용 중). + +### 2. `app/api/.../chat/route.js` — POST 3단계 구조 + +```diff ++ // 1단계: 사용자 메시지를 답변 전에 즉시 디스크에 저장 ++ const mid = `t${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; ++ await appendUserMsg(ws, slug, { userMsg: message.trim(), attachments, mid }); ++ try { ++ // 2단계: 답변 생성 + const t = await chat(ws, slug, message.trim(), ...); +- const saved = await appendTurn(ws, slug, { userMsg, reply, ... }); ++ // 3단계: 답변 저장 — pending 해제 + 크루 답변 추가 ++ const saved = await completePendingTurn(ws, slug, { mid, reply, ... }); ++ } catch (e) { ++ await failPendingTurn(ws, slug, { mid, error: ... }).catch(() => {}); ++ } +``` + +### 3. `app/c/.../page.jsx` — pending UI + +- `m.pending` 시 메시지 opacity 감소 + ArgoSpinner + "답변 대기 중…" 텍스트 표시 +- `m.failed` (서버에서 로드된 실패 메시지)도 기존 패턴 그대로 렌더링 (에러 라벨 + 재전송 버튼) + +--- + +## 시나리오별 동작 + +| 시나리오 | 변경 전 | 변경 후 | +|----------|---------|---------| +| 전환 후 답변 완료 전 돌아옴 | ❌ 빈 화면 | ✅ "답변 대기 중…" 표시 | +| 전환 후 답변 완료 후 돌아옴 | ✅ 복원 | ✅ 복원 (동일) | +| 전환 후 답변 실패 | ❌ 영구 소실 | ✅ 에러 라벨 + 재전송 버튼 | +| 같은 크루에서 정상 전송 | ✅ 정상 | ✅ 정상 (동일) | +| 같은 크루에서 중단 버튼 | ✅ 에러 라벨 | ✅ 에러 라벨 (동일) | + +--- + +## 데이터 충돌 방지 + +| 우려 | 대응 | +|------|------| +| 낙관적 업데이트 + 서버 저장 중복 | polling이 `busy` 중 skip되므로 중복 없음 | +| 크루 전환 후 돌아왔을 때 중복 | 컴포넌트 재마운트 시 서버에서 전체 로드(fresh state) | +| 동시 전송 시 mid 충돌 | `t${Date.now()}-${random}` 형식으로 고유 | + +--- + +## 검증 + +``` +✅ node --check src/thread.mjs +✅ node --check src/openai-compat-tools.mjs +✅ node --check src/runners/deepseek-local.mjs +``` + +> [!WARNING] +> **미검증 사항 (정직한 기록)** +> - dev 서버 기동 및 실제 크루 대화를 통한 라이브 동작 확인은 수행하지 않았습니다 (추론 — 코드 동일성 검증만 완료). +> - `appendUserMsg` → `completePendingTurn` 사이에 서버가 크래시하면 pending 메시지가 영구 잔존합니다. 별도 정리 로직은 추가하지 않았습니다 (빈도 극히 낮음, 사용자가 재시도로 해결 가능). +> - gateway.mjs 등 다른 경로의 `appendTurn` 호출자는 변경하지 않았습니다 (메신저 경로는 UI 전환 이슈 없음). diff --git a/src/thread.mjs b/src/thread.mjs index c8991b4a..030a443a 100644 --- a/src/thread.mjs +++ b/src/thread.mjs @@ -87,7 +87,53 @@ export async function appendTurn(wsId, slug, { turnId, userMsg, reply, handover, }); } -/** 참조(cc) 공유 — 대상 크루 스레드에 노트를 남긴다. pending 표시는 "아직 그 크루가 못 본 맥락"이라는 뜻. */ +/** 웹 UI 전송 — 답변 전에 사용자 메시지를 먼저 디스크에 저장한다. + 크루 전환으로 React 상태가 유실되어도 디스크 기록이 남아 돌아왔을 때 복원된다. */ +export async function appendUserMsg(wsId, slug, { userMsg, attachments, mid }) { + return withLock(lockKey(wsId, slug), async () => { + const t = await loadThread(wsId, slug); + t.messages.push({ + who: 'user', text: userMsg, ts: Date.now(), mid, pending: true, + ...(attachments?.length ? { attachments } : {}), + }); + await writeJsonAtomic(file(wsId, slug), t); + return t; + }); +} + +/** 답변 완료 — pending 사용자 메시지를 확정하고 크루 답변을 추가한다. */ +export async function completePendingTurn(wsId, slug, { mid, reply, handover, sessionId, artifacts }) { + return withLock(lockKey(wsId, slug), async () => { + const t = await loadThread(wsId, slug); + const userMsg = t.messages.find((m) => m.mid === mid); + if (userMsg) { delete userMsg.pending; delete userMsg.mid; } + t.messages.push({ + who: 'crew', text: reply, handover, ts: Date.now(), + ...(artifacts?.length ? { artifacts } : {}), + }); + if (sessionId) { + t.sessionId = sessionId; + t.sessionDevice = await getDeviceId().catch(() => t.sessionDevice ?? null); + } + await writeJsonAtomic(file(wsId, slug), t); + return t; + }); +} + +/** 답변 실패 — pending 메시지에 실패 사유를 기록한다. 돌아왔을 때 사용자가 재시도할 수 있다. */ +export async function failPendingTurn(wsId, slug, { mid, error }) { + return withLock(lockKey(wsId, slug), async () => { + const t = await loadThread(wsId, slug); + const userMsg = t.messages.find((m) => m.mid === mid); + if (userMsg) { + delete userMsg.pending; + userMsg.failed = String(error || '').slice(0, 400); + } + await writeJsonAtomic(file(wsId, slug), t); + return t; + }); +} + export async function appendSharedNote(wsId, slug, text) { return withLock(lockKey(wsId, slug), async () => { const t = await loadThread(wsId, slug);