diff --git a/app/api/companies/[ws]/vault/consolidate/route.js b/app/api/companies/[ws]/vault/consolidate/route.js index 3e32a7bb..6bf5f62a 100644 --- a/app/api/companies/[ws]/vault/consolidate/route.js +++ b/app/api/companies/[ws]/vault/consolidate/route.js @@ -1,7 +1,7 @@ import { consolidateMemory, rollupJournals } from '../../../../../../src/consolidate.mjs'; import { guardCompany } from '../../../../../auth.mjs'; -export const maxDuration = 120; // 하이쿠 1턴 — 수십 초 +export const maxDuration = 800; // 호스티드(Vercel Pro) 함수 상한 800 안 — // sonnet 5 청크 1회 실측 264초 + JSON 복구 최대 3분 — 옛 120(하이쿠 1턴)은 상시 타임아웃(검수 MEDIUM-2) /** 기억 정리 수동 실행 — 새 일지를 주제 노트로 정제 + 오래된 일지 주간 롤업. */ export async function POST(_req, { params }) { diff --git a/src/consolidate.mjs b/src/consolidate.mjs index 19facd65..8b2e333c 100644 --- a/src/consolidate.mjs +++ b/src/consolidate.mjs @@ -13,12 +13,33 @@ import { appendEvent } from './events.mjs'; import { writeJsonAtomic, readJsonLenient } from './jsonstore.mjs'; const WATERMARK = (wsId) => join(paths(wsId).vault, '.consolidate.json'); -const CAP = 14_000; // 정리 1회당 읽는 일지 총량 — 넘치면 다음 실행이 이어서 정리 +// 정리 모델 — A/B 실측(2026-09-04, 같은 14KB 청크·같은 프롬프트): sonnet 5가 주제 포착 3/3·기존 제목 재사용·JSON 정상에 비용도 +// 4.6(0.19$)·4.5(0.38$)보다 낮은 0.14$. haiku는 주제 1/3만 건지고 기존 노트 옆에 사본을 만들었다(유건 승인으로 교체). +// 실운영 청크(가져온 200KB) 1회 실측: 264초·0.74$(출력 22.6K 토큰) — 밤당 7MB ≈ 36청크 ≈ 27$(가격표 기준, 구독은 청구 0). +export const CONSOLIDATE_MODEL = 'claude-sonnet-5'; +// 청크 크기(바이트) — 회당 고정 오버헤드(시스템 프롬프트·노트 발췌 ≈ 20K 토큰)가 크므로 청크를 키워 회수를 줄인다. +// 가져온(imported) 일지는 이미 세션 요약본이라 200KB, 크루 일지는 정제 품질을 위해 60KB. 한 파일이 청크보다 크면 +// 줄 경계에서 잘라 다음 청크가 이어 받는다(옛 구현은 통째로 읽고 잘라 버려 큰 파일 뒷부분이 영영 정리에서 빠졌다). +const CHUNK_CREW = 60_000; +const CHUNK_IMPORTED = 200_000; +const IMPORTED_RE = /-imported\.md$/; +export const chunkCapFor = (name) => (IMPORTED_RE.test(name) ? CHUNK_IMPORTED : CHUNK_CREW); +// 야간 루프 상한(consolidateBacklog) — 21MB 백로그를 3밤에(유건 지시) → 밤당 7MB. 청구 러너(BYOK)는 비용 상한으로 5청크. +export const NIGHTLY_BYTES = 7 * 1024 * 1024; +export const BILLED_MAX_CHUNKS = 5; +const MIN_ROOM = 4096; // 청크 남은 자리가 이보다 작으면 다음 파일을 시작하지 않는다(자투리 조각 방지). 줄 경계 절단도 이만큼은 전진해야 채택 +const NOTE_CAP = 40; // 청크당 저장 노트 상한 — 200KB 청크는 주제가 십수 개일 수 있다(옛 8은 조용한 유실 창 — 검수 HIGH-1). 초과는 이벤트로 남긴다 +const SOURCE_LINK_CAP = 20; // 노트 하나에 붙이는 근거 일지 링크 상한 — 청크 하나가 30여 파일이라 링크 섹션이 본문을 삼킨다(검수 LOW) +const REPAIR_MAX = 60_000; // 이보다 긴 출력은 복구를 시도하지 않는다 — 잘린 JSON을 "고치면" 노트가 빠진 채 문법만 맞아 워터마크가 전진한다 +const MIN_TEXT = 400; // 소량이면 스킵(워터마크도 안 움직임) — 정제할 만큼 쌓일 때까지 기다린다 +let oneShot = runOneShot; +export const _setOneShotForTest = (fn) => { oneShot = fn ?? runOneShot; }; const PROMPT = (journals, noteTitles, lang = 'ko', noteCtx = []) => lang === 'en' ? `You are the librarian of the company's memory. Read the journals (raw conversations) below and distill only knowledge worth reusing into topic notes. Do not call any tools — the text provided below is all the material you have. Rules: +- First list every topic that appears in the journals, then write exactly one note per topic (a missed topic = lost memory). Do not merge different topics into one note, and do not split one topic across two notes. - Each topic note is the single source of truth for its topic. If a topic matches an existing note title, reuse that exact title to update it (don't spawn new titles). - Updating a note REPLACES its body entirely — output a complete body that keeps and integrates the still-valid conclusions from the "existing note excerpts" below (omission = memory loss). When new journals contradict an old decision, prefer the new one and keep a one-line trace like "(was: …)". - Note content should center on conclusions, decisions, numbers, and rules that "the next crew handling this topic can use right away." No conversation quotes or process narration. @@ -36,6 +57,7 @@ ${journals}` : `당신은 회사 기억의 사서다. 아래 일지(대화 원 도구를 호출하지 마라 — 아래 제공된 텍스트가 자료의 전부다. 규칙: +- 먼저 일지에 등장한 주제를 전부 나열한 뒤, 주제마다 노트를 정확히 1개씩 써라(주제 누락 = 기억 유실). 서로 다른 주제를 한 노트에 합치지 말고, 같은 주제를 두 노트로 쪼개지 마라. - 주제 노트는 주제당 1개가 단일 진실이다. 기존 노트 제목과 같은 주제면 그 제목을 그대로 써서 갱신하라(새 제목 남발 금지). - 노트 갱신은 본문 전체 교체다 — 아래 "기존 노트 발췌"의 여전히 유효한 결론을 유지·통합한 완전한 본문을 출력하라(누락 = 기억 유실). 새 일지가 이전 결정과 모순되면 새 결정을 우선하고 "(변경 전: …)" 한 줄로 흔적을 남겨라. - 노트 내용은 "다음에 이 주제를 다룰 크루가 바로 쓸 수 있는" 결론·결정·수치·규칙 중심으로. 대화 인용·과정 서술 금지. @@ -60,37 +82,64 @@ async function readWatermark(wsId) { return mark.v >= 2 ? mark : { v: 2, offsets: {} }; } -/** 워터마크 이후의 새 일지 내용만 모은다. sources = 이번 정리에 기여한 일지(근거 링크용). */ -async function gatherNewJournal(wsId, mark) { +/** 워터마크 이후의 새 일지 내용을 **청크 상한까지** 모은다(export: 순수 청킹 테스트용). + sources = 이번 정리에 기여한 일지(근거 링크용). 청크 상한은 첫 미정리 파일의 종류(크루/가져온)가 정한다. + 파일이 상한보다 크면 상한 안의 마지막 줄바꿈에서 잘라 워터마크를 그만큼만 전진 — 다음 청크가 이어 받는다. */ +export async function gatherNewJournal(wsId, mark) { const dir = paths(wsId).journal; let names = []; // 일별(YYYY-MM-DD-*)만 — 주간 롤업(YYYY-Wnn.md)은 정리의 산출물이라 다시 섭취하면 자기 요약을 재정리하는 루프가 된다 - try { names = (await readdir(dir)).filter((n) => /^\d{4}-\d{2}-\d{2}-.+\.md$/.test(n)).sort(); } catch { return { text: '', next: mark, sources: [] }; } + try { names = (await readdir(dir)).filter((n) => /^\d{4}-\d{2}-\d{2}-.+\.md$/.test(n)).sort(); } catch { return { text: '', next: mark, sources: [], consumed: 0, remaining: 0 }; } let text = ''; + let used = 0; // 이번 청크에 담은 바이트 + let cap = 0; // 첫 미정리 파일이 정한다 + let consumed = 0; let remaining = 0; const sources = []; const next = { v: 2, offsets: { ...mark.offsets } }; for (const n of names) { const file = join(dir, n); - // Buffer로 읽어 바이트 기준으로 자른다 — 워터마크 오프셋 단위는 바이트(append 경계 = 이전 파일 크기라 - // 멀티바이트 절단 없음). stat 대신 buf.length를 쓰면 읽기·크기가 같은 스냅샷이라 레이스도 없다. - const buf = await readFile(file); + // Buffer로 읽어 바이트 기준으로 자른다 — 워터마크 오프셋 단위는 바이트. buf.length를 쓰면 읽기·크기가 같은 스냅샷이라 레이스도 없다. + let buf; + try { buf = await readFile(file); } catch { continue; } // readdir 뒤 사라진 파일(수동 롤업·동기화 삭제) — 그 밤 루프를 통째로 던지지 않는다 const size = buf.length; - const done = mark.offsets[n] ?? 0; - if (size <= done || text.length > CAP) { next.offsets[n] = Math.min(done, size); continue; } - const body = buf.subarray(done).toString('utf8'); - text += `\n[${n}]\n${body}`; + const done = Math.min(mark.offsets[n] ?? 0, size); + if (size <= done) { next.offsets[n] = done; continue; } + if (!cap) cap = chunkCapFor(n); + const room = cap - used; + // 청크가 찼거나 남은 자리가 자투리(4KB 미만)면 손대지 않는다 — 다음 파일 앞 몇 줄만 떼어 오면 문맥이 끊긴 조각이 된다 + if (room < MIN_ROOM) { remaining += size - done; next.offsets[n] = done; continue; } + let end = Math.min(size, done + room); + if (end < size) { // 파일 중간 절단 — 상한 안의 마지막 줄바꿈까지, 단 그래도 MIN_ROOM 이상 전진할 때만(검수 HIGH-2: 창 안 유일한 + // 줄바꿈이 done 바로 뒤면 몇 바이트짜리 청크가 되어 MIN_TEXT 미만 스킵 → 워터마크가 영영 안 움직였다). 아니면 하드 컷 — + // UTF-8 연속 바이트(10xxxxxx) 앞으로 물러나 글자를 쪼개지 않는다(검수 LOW: 하드 컷이 U+FFFD를 만들었다). + const nl = buf.lastIndexOf(0x0a, end - 1); + if (nl > done && nl + 1 - done >= MIN_ROOM) end = nl + 1; + else while (end > done + 1 && (buf[end] & 0xc0) === 0x80) end -= 1; + } + text += `\n[${n}]\n${buf.subarray(done, end).toString('utf8')}`; sources.push(`journal/${n.replace(/\.md$/, '')}`); - next.offsets[n] = size; + next.offsets[n] = end; + used += end - done; consumed += end - done; remaining += size - end; } - return { text: text.slice(0, CAP + 4000), next, sources }; + return { text, next, sources, consumed, remaining }; +} + +/** LLM 출력 → {notes} 파싱(export: 테스트용). 코드펜스 관용. 실패는 null. */ +export function parseNotes(out) { + try { + const parsed = JSON.parse(String(out ?? '').trim().replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '')); + return parsed && typeof parsed === 'object' && Array.isArray(parsed.notes) ? parsed : null; + } catch { return null; } } +const REPAIR_PROMPT = (bad, lang) => (lang === 'en' + ? `The text below was meant to be JSON of the form {"notes":[{"title":"...","content":"..."}]} but does not parse. Return ONLY the corrected JSON (no code fences, no explanation) — escape quotes and newlines inside strings, keep every note and its full content.\n\n${bad}` + : `아래 텍스트는 {"notes":[{"title":"...","content":"..."}]} 형태의 JSON이어야 하는데 파싱되지 않는다. 문자열 안 따옴표·줄바꿈을 이스케이프해 **수정된 JSON만** 출력하라(코드펜스·설명 금지). 노트와 본문은 하나도 빼지 마라.\n\n${bad}`); /** 정리 1회 실행 — 반환: 갱신/생성된 노트 목록. 새 내용 없으면 빈 배열. */ export async function consolidateMemory(wsId) { const mark = await readWatermark(wsId); - const { text, next, sources } = await gatherNewJournal(wsId, mark); - // 소량이면 스킵(워터마크도 안 움직임) — 정제할 만큼 쌓일 때까지 기다린다 - if (text.trim().length < 400) return { notes: [] }; + const { text, next, sources, consumed, remaining } = await gatherNewJournal(wsId, mark); + if (text.trim().length < MIN_TEXT) return { notes: [], consumed: 0, remaining, billed: false }; const p = paths(wsId); const { lang = 'ko' } = await loadCompany(wsId).catch(() => ({})); // 시스템 언어 — 주제 노트 정제 언어(기존 회사=ko 폴백) @@ -123,32 +172,68 @@ export async function consolidateMemory(wsId) { // 러너 독립(runOneShot) — Claude 없이 Codex/Gemini/GLM만 연결한 회사도 기억 정리가 돈다. // (이전: SDK 직호출 + env 미주입 — 호스트 Claude 로그인에만 의존해 BYOK 웹 사용자·타 러너 사용자는 조용히 실패) // model은 claude 러너일 때만 haiku 적용(정리는 잔일 — 저비용), maxTurns 4 = 도구 거부돼도 최종 답까지. - const { runner, text: out, usage, costUsd } = await runOneShot(wsId, PROMPT(text, noteTitles, lang, noteCtx), - // 배치 작업(새벽 자동 실행, 사용자 대기 없음)이라 공통 기본(120s)보다 넉넉히 — 상한의 목적은 - // 지연 SLO가 아니라 "영원히 안 끝나는 것"을 끊어 스케줄러 in-flight 표시를 반드시 풀어주는 것이다. - { lang, model: 'claude-haiku-4-5-20251001', maxTurns: 4, timeoutMs: 10 * 60_000 }); + // 2턴 상한 — 프롬프트가 도구 금지를 말해도 턴이 열려 있으면 파일을 읽으러 다녀 비용이 5배로 뛰었다(A/B 실측). readOnly는 CLI 경로 + // (codex/agy 샌드박스·caps)를 막고, SDK 경로는 이미 allowedTools []라 무효 — 비용 절감은 maxTurns가 담당한다(검수 확인). + // 배치 작업(새벽 자동 실행, 사용자 대기 없음)이라 공통 기본(120s)보다 넉넉히 — 상한의 목적은 + // 지연 SLO가 아니라 "영원히 안 끝나는 것"을 끊어 스케줄러 in-flight 표시를 반드시 풀어주는 것이다. + const { runner, text: out, usage, costUsd } = await oneShot(wsId, PROMPT(text, noteTitles, lang, noteCtx), + { lang, model: CONSOLIDATE_MODEL, maxTurns: 2, readOnly: true, timeoutMs: 10 * 60_000 }); // billed 각인 — 구독 러너의 기억 정리 턴 금액이 청구로 새지 않게(검수 2026-07-27 부수 발견) - await appendUsage(wsId, { kind: 'consolidate', slug: '', runner, usage, costUsd, ms: Date.now() - t0, billed: await isBilledRunner(wsId, runner).catch(() => undefined) }); + // 판정 실패는 청구로 본다(fail-closed) — 자격 파일 손상 시 1회 throw가 명세라, undefined로 두면 비용 상한이 조용히 사라진다(검수 MEDIUM-1) + const billed = await isBilledRunner(wsId, runner).catch(() => true); + await appendUsage(wsId, { kind: 'consolidate', slug: '', runner, usage, costUsd, ms: Date.now() - t0, billed }); - let parsed; - try { - parsed = JSON.parse(out.trim().replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '')); - } catch { + let parsed = parseNotes(out); + if (!parsed && out.length <= REPAIR_MAX) { // JSON 복구 1회(출력이 상한 안일 때만 — 잘린 JSON을 고치면 노트가 빠진 채 통과한다) — 본문 안 따옴표 미이스케이프 같은 형식 오류로 하루치 정리가 통째로 날아가지 않게(A/B: sonnet 4.6이 실증) + const fix = await oneShot(wsId, REPAIR_PROMPT(out.slice(0, 60_000), lang), { lang, model: CONSOLIDATE_MODEL, maxTurns: 1, readOnly: true, timeoutMs: 3 * 60_000 }); + await appendUsage(wsId, { kind: 'consolidate', slug: '', runner: fix.runner, usage: fix.usage, costUsd: fix.costUsd, ms: 0, billed }); + parsed = parseNotes(fix.text); + } + if (!parsed) { await appendEvent(wsId, { type: 'memory', ok: false, error: `정리 결과 파싱 실패: ${out.slice(0, 80)}` }); throw new Error(`정리 결과 파싱 실패: ${out.slice(0, 120)}`); } const written = []; - for (const n of (parsed.notes ?? []).slice(0, 8)) { + const all = parsed.notes ?? []; + if (all.length > NOTE_CAP) { // 조용히 버리지 않는다 — 초과 주제는 사용자가 알아야 다시 정리시킬 수 있다 + console.warn(`[argo] 기억 정리: 노트 ${all.length}개 중 ${NOTE_CAP}개만 저장(${wsId}) — 청크가 너무 크다`); + await appendEvent(wsId, { type: 'memory', ok: false, error: `노트 ${all.length}개 중 ${NOTE_CAP}개만 저장 — 초과: ${all.slice(NOTE_CAP).map((n) => n.title).join(', ').slice(0, 200)}` }); + } + for (const n of all.slice(0, NOTE_CAP)) { if (!n.title?.trim() || !n.content?.trim()) continue; const { file } = await saveNote(wsId, n.title, n.content, { merge: true }); - await appendSourceLinks(file, sources); // 이 결론의 근거 일지 — 드릴다운 경로(섹션 파서 경유) + await appendSourceLinks(file, sources.slice(0, SOURCE_LINK_CAP)); // 이 결론의 근거 일지 — 드릴다운 경로(섹션 파서 경유) written.push(n.title.trim()); } await writeJsonAtomic(WATERMARK(wsId), next); // 정리 성공 후에만 전진 await updateIndex(wsId); if (written.length) await appendEvent(wsId, { type: 'memory', ok: true, notes: written }); - return { notes: written }; + return { notes: written, consumed, remaining, billed: !!billed }; +} + +/** 야간 루프 — 청크를 연속으로 정리한다. 멈춤 조건(먼저 닿는 것): 잔량 소진 · 밤당 바이트 상한 · 마감 시각 · 청구 러너 청크 상한. + 실패는 던진다(워터마크는 성공한 청크까지 전진해 있으므로 스케줄러 재시도가 이어 받는다). 반환은 로그·테스트용 요약. */ +export async function consolidateBacklog(wsId, { deadlineMs = Infinity, nightlyBytes = NIGHTLY_BYTES, billedMaxChunks = BILLED_MAX_CHUNKS, maxChunks = 500, now = () => Date.now(), onChunk = null } = {}) { + let chunks = 0; let bytes = 0; const notes = []; let stoppedBy = 'drained'; + if (onChunk) await Promise.resolve().then(() => onChunk({ chunks, bytes })).catch(() => {}); // 진입 직후 1회 — 첫 청크가 5분을 넘어도 선점 창이 열리지 않게(검수 권고) + for (;;) { + if (now() >= deadlineMs) { stoppedBy = 'deadline'; break; } + if (bytes >= nightlyBytes) { stoppedBy = 'nightly-bytes'; break; } + if (chunks >= maxChunks) { stoppedBy = 'max-chunks'; break; } + const r = await consolidateMemory(wsId); + if (!r.consumed) { // 소량 스킵 또는 잔량 0 + stoppedBy = r.remaining ? 'too-small' : 'drained'; + if (r.remaining) console.warn(`[argo] 기억 정리: ${wsId} 잔량 ${Math.round(r.remaining / 1024)}KB인데 청크가 소량이라 스킵 — 일지 분할·워터마크 확인`); + break; + } + chunks += 1; bytes += r.consumed; notes.push(...r.notes); + if (onChunk) await Promise.resolve().then(() => onChunk({ chunks, bytes })).catch(() => {}); // 스케줄러 선점 스탬프 연장(4시간 루프 동안 다른 기기·재기동이 두 번째 루프를 열지 않게) + console.log(`[argo] 기억 정리 청크 ${chunks}: ${wsId} ${Math.round(r.consumed / 1024)}KB → 노트 ${r.notes.length}, 잔량 ${Math.round(r.remaining / 1024)}KB`); + if (r.billed && chunks >= billedMaxChunks) { stoppedBy = 'billed-cap'; break; } + if (!r.remaining) { stoppedBy = 'drained'; break; } + } + return { chunks, bytes, notes, stoppedBy }; } /** ISO 주차 라벨 — 주간 파일명(2026-W28)용. */ diff --git a/src/scheduler.mjs b/src/scheduler.mjs index 844f2254..0072d4d3 100644 --- a/src/scheduler.mjs +++ b/src/scheduler.mjs @@ -9,7 +9,7 @@ import { chat } from './chat.mjs'; import { readAgentCard } from './persona.mjs'; import { resolveRunner, isCliRunner } from './runners.mjs'; import { appendTurn } from './thread.mjs'; -import { consolidateMemory, rollupJournals } from './consolidate.mjs'; +import { consolidateBacklog, rollupJournals } from './consolidate.mjs'; import { runHealthChecks } from './runner-health.mjs'; import { daemonLease } from './lock.mjs'; import { isCloudLeader } from './sync.mjs'; @@ -19,6 +19,7 @@ import { paths } from './workspace.mjs'; import { join } from 'node:path'; const CONSOLIDATE_AT = '04:00'; // 새벽 정리 — 사람 뇌의 수면 정리처럼 +const CONSOLIDATE_UNTIL_HOUR = 8; // 야간 루프 마감(로컬 08:00) — 그 뒤엔 낮 크루 턴과 러너를 나눠 쓰지 않는다 // 하루 1회 실행 스탬프 — 정각(hhmm===) 일치는 그 1분에 기기가 수면·앱 종료면 그날 정리가 영영 스킵된다. // "04:00 이후 첫 틱에 아직 오늘 안 돌았으면 실행"으로 캐치업한다(랩탑 현실 대응). const RUN_STAMP = (wsId) => join(paths(wsId).vault, '.consolidate-run.json'); @@ -60,6 +61,17 @@ export function planConsolidate(st, nowMs, today) { return attempt(n + 1); } +/** 루프 진행 중 스탬프 연장 — 야간 루프는 청크(≤10분+복구 3분)마다 nextRetryAt을 지금+15분으로 밀어, 다른 기기가 리더가 되거나 앱이 + 재기동돼도 "재시도 가능"으로 읽지 않는다(검수 MEDIUM-3: 선점+5분 스탬프가 4시간 루프 중 만료돼 두 번째 루프 → 워터마크 되감기·중복 과금). */ +export async function bumpConsolidateClaim(wsId, nowMs, today) { + return withLock(`consolidate:${wsId}`, async () => { + let cur = {}; + try { cur = await readJson(RUN_STAMP(wsId), {}); } catch { return; } + if ((cur.day ?? '') !== today || cur.done) return; + await writeJsonAtomic(RUN_STAMP(wsId), { ...cur, nextRetryAt: new Date(nowMs + 15 * 60_000).toISOString() }); + }); +} + /** 실행 직전 선점 — 락 안에서 읽고 판정하고 쓴다(claimRoutine과 같은 원칙). 반환 = 쓴 스탬프 또는 null. */ export async function claimConsolidate(wsId, nowMs, today) { // export: 회귀 테스트용(스탬프 실제 기록 여부) return withLock(`consolidate:${wsId}`, async () => { @@ -229,7 +241,10 @@ export function ensureScheduler() { console.log(`[argo] 기억 정리: ${cid} (${nth})`); // 재시도는 consolidate부터 다시 탄다 — 워터마크가 성공 후에만 전진하므로 이미 정제된 // 구간은 조기 반환(LLM 호출 0)이고, rollup은 주간 블록 중복 append를 자체 차단한다. - consolidateMemory(cid) + // 야간 루프 — 잔량 소진·밤당 7MB·08:00·청구 러너 5청크 중 먼저 닿는 것까지 청크를 연속 정리 + const deadline = new Date(now.getFullYear(), now.getMonth(), now.getDate(), CONSOLIDATE_UNTIL_HOUR, 0, 0).getTime(); + consolidateBacklog(cid, { deadlineMs: Math.max(deadline, now.getTime() + 60_000), onChunk: () => bumpConsolidateClaim(cid, Date.now(), today) }) // 08:00 뒤 캐치업이면 최소 1청크 + .then((r) => console.log(`[argo] 기억 정리 끝: ${cid} 청크 ${r.chunks}·${Math.round(r.bytes / 1024)}KB·노트 ${r.notes.length} (${r.stoppedBy})`)) .then(() => rollupJournals(cid)) // 정제가 소화한 일지만 주간으로 접힌다 .then(() => markConsolidateDone(cid, today)) .catch((e) => console.error( diff --git a/test/consolidate-backlog.test.mjs b/test/consolidate-backlog.test.mjs new file mode 100644 index 00000000..9f491ca2 --- /dev/null +++ b/test/consolidate-backlog.test.mjs @@ -0,0 +1,194 @@ +// 기억 정리 야간 루프·청킹·JSON 복구 — 임시 ARGO_ROOT + 가짜 원샷(_setOneShotForTest). 실 러너 호출 0. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +process.env.ARGO_ROOT = await mkdtemp(join(tmpdir(), 'argo-consol-')); +process.env.ARGO_SYNC = '0'; +const cons = await import('../src/consolidate.mjs'); +const { gatherNewJournal, parseNotes, chunkCapFor, consolidateMemory, consolidateBacklog, _setOneShotForTest, CONSOLIDATE_MODEL, NIGHTLY_BYTES, BILLED_MAX_CHUNKS } = cons; +const { paths } = await import('../src/workspace.mjs'); + +let WS = 'consol-ws'; +async function seed({ journals = {}, notes = {} } = {}, ws = `consol-${Math.random().toString(36).slice(2, 8)}`) { + WS = ws; const p = paths(WS); + await mkdir(p.journal, { recursive: true }); await mkdir(p.notes, { recursive: true }); + await writeFile(join(p.root, 'company.json'), JSON.stringify({ id: WS, name: 'Consol', lang: 'ko' })); + for (const [n, body] of Object.entries(journals)) await writeFile(join(p.journal, n), body); + for (const [n, body] of Object.entries(notes)) await writeFile(join(p.notes, n), body); + return p; +} +const line = (i, w = 80) => `${String(i).padStart(6, '0')} ${'가'.repeat(Math.floor((w - 8) / 3))}\n`; // 한글(3바이트) 섞인 줄 +const lines = (n, w) => Array.from({ length: n }, (_, i) => line(i, w)).join(''); + +test('chunkCapFor — 가져온(imported) 일지 200KB, 크루 일지 60KB', () => { + assert.equal(chunkCapFor('2026-02-20-x-imported.md'), 200_000); + assert.equal(chunkCapFor('2026-09-03-pepper.md'), 60_000); + assert.equal(CONSOLIDATE_MODEL, 'claude-sonnet-5'); +}); + +test('gatherNewJournal — 큰 파일은 줄 경계에서 잘라 워터마크가 그만큼만 전진하고, 다음 청크가 이어 받는다(잔량 정확)', async () => { + const big = lines(2000, 100); // ≈ 200KB 크루 일지 + const p = await seed({ journals: { '2026-09-01-pepper.md': big, '2026-09-02-pepper.md': 'short\n'.repeat(50) } }); + const bigBytes = Buffer.byteLength(big); + let mark = { v: 2, offsets: {} }; + const c1 = await gatherNewJournal(WS, mark); + assert.ok(c1.consumed <= 60_000 && c1.consumed > 55_000, `첫 청크 ≈ 60KB(줄 경계): ${c1.consumed}`); + assert.equal(c1.next.offsets['2026-09-01-pepper.md'], c1.consumed, '오프셋 = 소비 바이트'); + assert.ok(big.slice(0, 1).length && Buffer.from(big).subarray(c1.consumed - 1, c1.consumed).toString() === '\n', '줄바꿈 직후에서 잘린다'); + assert.equal(c1.remaining, bigBytes - c1.consumed + Buffer.byteLength('short\n'.repeat(50)), '잔량 = 큰 파일 나머지 + 다음 파일 전체(자투리 4KB 미만이면 다음 파일을 시작하지 않는다)'); + assert.deepEqual(c1.sources, ['journal/2026-09-01-pepper']); + assert.doesNotMatch(c1.text, /�/, '멀티바이트 절단 없음'); + // 이어 받기 — 두 번째 청크는 첫 청크 끝 줄 번호 다음부터 + const firstLineOfNext = (await gatherNewJournal(WS, c1.next)).text.split('\n').find((l) => /^\d{6} /.test(l)); + const lastLineOfPrev = c1.text.trimEnd().split('\n').pop(); + assert.equal(Number(firstLineOfNext.slice(0, 6)), Number(lastLineOfPrev.slice(0, 6)) + 1, '중복·누락 없이 이어진다'); + // 4청크 안에 전부 소화되고 잔량 0 + mark = c1.next; let total = c1.consumed; + for (let i = 0; i < 6 && total < bigBytes + 300; i++) { const c = await gatherNewJournal(WS, mark); mark = c.next; total += c.consumed; if (!c.remaining) break; } + assert.equal((await gatherNewJournal(WS, mark)).remaining, 0); + void p; +}); + +test('gatherNewJournal — 청크 상한은 첫 미정리 파일의 종류가 정한다(가져온 일지 200KB), 옛 v1 워터마크는 리셋', async () => { + const imp = lines(3000, 100); // ≈ 300KB + await seed({ journals: { '2026-01-05-sess-imported.md': imp } }); + const c = await gatherNewJournal(WS, { v: 2, offsets: {} }); + assert.ok(c.consumed > 190_000 && c.consumed <= 200_000, `가져온 일지 청크 ≈ 200KB: ${c.consumed}`); + assert.ok(c.remaining > 0); +}); + +test('parseNotes — 코드펜스 관용, 형식 이탈은 null', () => { + assert.equal(parseNotes('```json\n{"notes":[{"title":"a","content":"b"}]}\n```').notes.length, 1); + assert.equal(parseNotes('{"notes":[]}').notes.length, 0); + assert.equal(parseNotes('{"notes":[{"title":"a","content":"say "hi""}]}'), null, '따옴표 미이스케이프'); + assert.equal(parseNotes('{"foo":1}'), null); assert.equal(parseNotes(''), null); +}); + +test('consolidateMemory — sonnet 5·읽기 전용·2턴으로 호출, JSON 깨지면 복구 1회, 그래도 실패면 throw + memory 이벤트', async () => { + const p = await seed({ journals: { '2026-09-03-pepper.md': lines(20, 120) }, notes: { 'old.md': '# 기존 주제\n\n본문\n' } }); + const calls = []; + _setOneShotForTest(async (ws, prompt, opts) => { + calls.push({ prompt, opts }); + if (calls.length === 1) return { runner: 'claude', text: '{"notes":[{"title":"기존 주제","content":"say "x""}]}', usage: {}, costUsd: 0.1 }; // 깨진 JSON + return { runner: 'claude', text: '{"notes":[{"title":"기존 주제","content":"수정된 본문"}]}', usage: {}, costUsd: 0.05 }; + }); + try { + const r = await consolidateMemory(WS); + assert.deepEqual(r.notes, ['기존 주제']); + assert.ok(r.consumed > 0 && r.remaining === 0); + assert.equal(calls.length, 2, '본 호출 + 복구 1회'); + assert.deepEqual({ model: calls[0].opts.model, readOnly: calls[0].opts.readOnly, maxTurns: calls[0].opts.maxTurns }, { model: 'claude-sonnet-5', readOnly: true, maxTurns: 2 }); + assert.match(calls[0].prompt, /먼저 일지에 등장한 주제를 전부 나열/, '주제 나열 단계'); + assert.match(calls[1].prompt, /수정된 JSON만/, '복구 프롬프트'); + assert.equal(calls[1].opts.maxTurns, 1); + const mark = JSON.parse(await readFile(join(p.vault, '.consolidate.json'), 'utf8')); + assert.equal(mark.offsets['2026-09-03-pepper.md'], Buffer.byteLength(lines(20, 120)), '성공 후 워터마크 전진'); + assert.match(await readFile(join(p.notes, 'old.md'), 'utf8').catch(() => ''), /수정된 본문|기존 주제/, '기존 노트 갱신'); + // 복구도 실패 → throw + 이벤트 + calls.length = 0; + await writeFile(join(p.journal, '2026-09-04-pepper.md'), lines(20, 120)); + _setOneShotForTest(async () => ({ runner: 'claude', text: 'not json', usage: {}, costUsd: 0 })); + await assert.rejects(() => consolidateMemory(WS), /파싱 실패/); + const ev = (await readFile(join(p.root, 'events.jsonl'), 'utf8')).trim().split('\n').map((l) => JSON.parse(l)); + assert.ok(ev.some((e) => e.type === 'memory' && e.ok === false), '실패 이벤트'); + const mark2 = JSON.parse(await readFile(join(p.vault, '.consolidate.json'), 'utf8')); + assert.equal(mark2.offsets['2026-09-04-pepper.md'], undefined, '실패 청크는 워터마크 미전진'); + } finally { _setOneShotForTest(null); } +}); + +test('consolidateBacklog — 잔량 소진까지 연속, 밤당 바이트·마감·청구 러너 청크 상한에서 멈춘다', async () => { + const journals = {}; for (let d = 1; d <= 6; d++) journals[`2026-08-0${d}-pepper.md`] = lines(700, 100); // 각 ≈ 70KB → 청크 60KB 기준 7~8청크 + const p = await seed({ journals }); + let n = 0; + _setOneShotForTest(async () => ({ runner: 'claude', text: `{"notes":[{"title":"주제 ${++n}","content":"본문 ${n}"}]}`, usage: {}, costUsd: 0.01 })); + try { + const r = await consolidateBacklog(WS); + assert.equal(r.stoppedBy, 'drained'); assert.ok(r.chunks >= 7, `청크 ${r.chunks}`); assert.ok(r.bytes > 400_000); + assert.equal((await gatherNewJournal(WS, JSON.parse(await readFile(join(p.vault, '.consolidate.json'), 'utf8')))).remaining, 0, '전부 소화'); + // 밤당 바이트 상한 + for (let d = 1; d <= 6; d++) await writeFile(join(p.journal, `2026-08-1${d}-pepper.md`), lines(700, 100)); + const r2 = await consolidateBacklog(WS, { nightlyBytes: 100_000 }); + assert.equal(r2.stoppedBy, 'nightly-bytes'); assert.equal(r2.chunks, 2, '60KB 두 청크에서 100KB 상한 초과 → 멈춤'); + // 마감 — 시계를 주입: 첫 청크 뒤 마감 경과 + let t = 0; const r3 = await consolidateBacklog(WS, { deadlineMs: 5, now: () => (t += 3) }); + assert.equal(r3.stoppedBy, 'deadline'); assert.equal(r3.chunks, 1); + // 청구 러너 상한 + _setOneShotForTest(async () => ({ runner: 'openrouter', text: `{"notes":[{"title":"주제 ${++n}","content":"b"}]}`, usage: {}, costUsd: 0.01 })); + const r4 = await consolidateBacklog(WS, { billedMaxChunks: 2 }); + // openrouter는 청구 러너(isBilledRunner) — 자격 파일이 없으면 판정이 undefined일 수 있어 두 갈래 모두 허용하되, 상한 2를 넘지 않는다 + assert.ok(r4.stoppedBy === 'billed-cap' || r4.stoppedBy === 'drained', r4.stoppedBy); + if (r4.stoppedBy === 'billed-cap') assert.equal(r4.chunks, 2); + assert.equal(NIGHTLY_BYTES, 7 * 1024 * 1024); assert.equal(BILLED_MAX_CHUNKS, 5); + } finally { _setOneShotForTest(null); } +}); + +test('배선 — 스케줄러가 야간 루프(consolidateBacklog)를 08:00 마감으로 부른다, 정리 호출은 읽기 전용', async () => { + const sched = await readFile(new URL('../src/scheduler.mjs', import.meta.url), 'utf8'); + assert.match(sched, /import \{ consolidateBacklog, rollupJournals \} from '\.\/consolidate\.mjs';/); + assert.match(sched, /const CONSOLIDATE_UNTIL_HOUR = 8;/); + assert.match(sched, /consolidateBacklog\(cid, \{ deadlineMs: Math\.max\(deadline, now\.getTime\(\) \+ 60_000\), onChunk: \(\) => bumpConsolidateClaim\(cid, Date\.now\(\), today\) \}\)[\s\S]{0,300}?\.then\(\(\) => rollupJournals\(cid\)\)/, '루프 뒤 롤업 + 청크마다 선점 스탬프 연장'); + const route = await readFile(new URL('../app/api/companies/[ws]/vault/consolidate/route.js', import.meta.url), 'utf8'); + assert.match(route, /export const maxDuration = 800;/, '수동 정리 라우트 상한 = 청크 264초 + 복구 3분 여유, 호스티드 함수 상한 800 안(검수 MEDIUM-2)'); + + assert.doesNotMatch(sched, /consolidateMemory\(cid\)/, '단발 호출 잔재 없음'); + const src = await readFile(new URL('../src/consolidate.mjs', import.meta.url), 'utf8'); + assert.match(src, /\{ lang, model: CONSOLIDATE_MODEL, maxTurns: 2, readOnly: true, timeoutMs: 10 \* 60_000 \}/, '본 호출: sonnet 5·읽기 전용·2턴'); + assert.doesNotMatch(src, /claude-haiku/, 'haiku 잔재 없음'); + assert.match(src, /if \(onChunk\) await Promise\.resolve\(\)\.then\(\(\) => onChunk\(\{ chunks, bytes \}\)\)\.catch\(\(\) => \{\}\); \/\/ 진입 직후 1회/, '루프 진입 직후 선점 스탬프 연장'); + assert.match(src, /isBilledRunner\(wsId, runner\)\.catch\(\(\) => true\)/, '청구 판정 실패는 청구로(fail-closed — 비용 상한 소멸 방지)'); + assert.match(src, /if \(!parsed && out\.length <= REPAIR_MAX\)/, '잘린 출력은 복구하지 않는다'); + assert.match(src, /appendSourceLinks\(file, sources\.slice\(0, SOURCE_LINK_CAP\)\)/, '근거 링크 상한'); + assert.match(src, /const NOTE_CAP = 40;/); assert.doesNotMatch(src, /\.slice\(0, 8\)/, '옛 노트 상한 8 잔재 없음'); +}); + +test('절단 진행 보장 — 창 안 유일한 줄바꿈이 시작 직후면 줄 경계를 버리고 하드 컷(MIN_ROOM 이상 전진), UTF-8 글자를 쪼개지 않는다(검수 HIGH-2·LOW)', async () => { + const oneLine = 'x짧은 첫 줄\n' + '가'.repeat(40_000); // 120KB 한 줄. 접두 16바이트(≡1 mod 3) — 60,000 하드 컷이 한글 글자 중간에 떨어지게 잡는다(15바이트면 우연히 정렬돼 검사가 헛돈다) + const p = await seed({ journals: { '2026-09-05-pepper.md': oneLine } }); + const c = await gatherNewJournal(WS, { v: 2, offsets: {} }); + assert.ok(c.consumed >= 4096, `자투리 청크 금지: ${c.consumed}`); + assert.ok(c.consumed > 55_000 && c.consumed <= 60_000, `하드 컷 ≈ 60KB: ${c.consumed}`); + assert.doesNotMatch(c.text, /�/, 'UTF-8 연속 바이트 경계에서 물러나 절단'); + assert.equal(c.consumed % 3, Buffer.byteLength('x짧은 첫 줄\n') % 3, '한글 3바이트 단위로 끊긴다(하드 컷 60,000에서 2바이트 후퇴)'); + assert.equal(c.consumed, 59_998); + const c2 = await gatherNewJournal(WS, c.next); + assert.doesNotMatch(c2.text, /�/); assert.ok(c2.consumed > 0); + // 루프도 전진한다(옛 결함: 매일 15바이트만 집어 스킵 → 영구 스톨) + _setOneShotForTest(async () => ({ runner: 'claude', text: '{"notes":[]}', usage: {}, costUsd: 0 })); + try { const r = await consolidateBacklog(WS); assert.equal(r.stoppedBy, 'drained'); assert.ok(r.chunks >= 2, `청크 ${r.chunks}`); } finally { _setOneShotForTest(null); } + void p; +}); + +test('노트 상한 40 — 초과분은 조용히 버리지 않고 memory 실패 이벤트로 남긴다(검수 HIGH-1), 워터마크는 전진', async () => { + const p = await seed({ journals: { '2026-09-06-pepper.md': lines(30, 120) } }); + const many = Array.from({ length: 45 }, (_, i) => ({ title: `주제 ${i + 1}`, content: `본문 ${i + 1}` })); + _setOneShotForTest(async () => ({ runner: 'claude', text: JSON.stringify({ notes: many }), usage: {}, costUsd: 0 })); + try { + const r = await consolidateMemory(WS); + assert.equal(r.notes.length, 40); + const ev = (await readFile(join(p.root, 'events.jsonl'), 'utf8')).trim().split('\n').map((l) => JSON.parse(l)); + const over = ev.find((e) => e.type === 'memory' && e.ok === false && /45개 중 40개만/.test(e.error)); + assert.ok(over, '초과 이벤트'); assert.match(over.error, /주제 41/, '버려진 제목 명시'); + assert.ok(ev.some((e) => e.type === 'memory' && e.ok === true && e.notes.length === 40)); + } finally { _setOneShotForTest(null); } +}); + +test('bumpConsolidateClaim — 오늘 미완료 스탬프의 nextRetryAt을 지금+15분으로 연장, done·다른 날 스탬프는 손대지 않는다(검수 MEDIUM-3)', async () => { + const { claimConsolidate, bumpConsolidateClaim } = await import('../src/scheduler.mjs'); + const p = await seed({}); + const today = '2026-09-04'; const t0 = Date.parse('2026-09-04T04:00:00Z'); + const st = await claimConsolidate(WS, t0, today); assert.ok(st && st.attempts === 1); + await bumpConsolidateClaim(WS, t0 + 3 * 60_000, today); + const cur = JSON.parse(await readFile(join(p.vault, '.consolidate-run.json'), 'utf8')); + assert.equal(Date.parse(cur.nextRetryAt), t0 + 18 * 60_000, '3분 뒤 청크 완료 → 재시도 창 = 그 시점 + 15분'); + assert.equal(cur.attempts, 1); assert.equal(cur.done, false); + await bumpConsolidateClaim(WS, t0, '2026-09-05'); // 다른 날 → 무시 + assert.equal(JSON.parse(await readFile(join(p.vault, '.consolidate-run.json'), 'utf8')).nextRetryAt, cur.nextRetryAt); + const { markConsolidateDone } = await import('../src/scheduler.mjs'); + await markConsolidateDone(WS, today); // 완료 뒤 늦게 도착한 하트비트는 done 스탬프를 되살리지 않는다 + await bumpConsolidateClaim(WS, t0 + 60 * 60_000, today); + const fin = JSON.parse(await readFile(join(p.vault, '.consolidate-run.json'), 'utf8')); + assert.equal(fin.done, true); assert.equal(fin.nextRetryAt, null, 'done 스탬프는 손대지 않는다'); +}); diff --git a/test/consolidate-retry.test.mjs b/test/consolidate-retry.test.mjs index 8a65ba96..db344beb 100644 --- a/test/consolidate-retry.test.mjs +++ b/test/consolidate-retry.test.mjs @@ -219,7 +219,7 @@ test('상한은 호출부가 용도에 맞게 명시한다 — 통일된 knob .map((f) => readFile(new URL(f, import.meta.url), 'utf8'))); // 기본값 자체를 잠근다 — I-3: '호출부에 문자열이 있나'만 보면 기본값을 30분으로 바꿔도 안 잡힌다 assert.match(oneshot, /timeoutMs = 120_000/, '명시 안 한 호출의 기본 상한'); - assert.match(cons, /runOneShot\([\s\S]{0,400}?timeoutMs: 10 \* 60_000/, '기억 정리 = 새벽 배치, 대기자 없음'); + assert.match(cons, /oneShot\(wsId, PROMPT\([\s\S]{0,400}?timeoutMs: 10 \* 60_000/, '기억 정리 = 새벽 배치, 대기자 없음(oneShot = runOneShot 테스트 오버라이드 가능)'); // I-1: SDK 경로엔 이번에 처음 상한이 생긴다(이전 무제한). 첫 영입은 무료 모델(큐 지연) + 단일 // 러너(자가치유 대체 없음) 조합이라 기본 120s면 온보딩이 통째로 실패할 수 있다. assert.match(persona, /CARD_PROMPT\([\s\S]{0,120}?timeoutMs: 5 \* 60_000/, '첫 영입은 기본보다 넉넉히');