diff --git a/app/c/[ws]/routines/page.jsx b/app/c/[ws]/routines/page.jsx index 7b40cef9..257d5f7f 100644 --- a/app/c/[ws]/routines/page.jsx +++ b/app/c/[ws]/routines/page.jsx @@ -2,6 +2,7 @@ // 루틴 — 크루에게 반복 지시를 예약하고, 원클릭으로 즉시 실행한다. // 템플릿 원클릭 생성 → 폼 프리필. 실행 결과는 vault 기억으로 남는다. import { use, useEffect, useState } from 'react'; +import Link from 'next/link'; import { Icon, Avatar, Spinner, Skeleton, useScrollLock, ConfirmModal, DropUp, api, imeGuard, timeAgo } from '../../../ui'; import { useLang } from '../../../i18n'; @@ -24,7 +25,7 @@ function scheduleLabel(s, t, DOW) { export default function Routines({ params }) { const { ws } = use(params); - const { t, lang } = useLang(); + const { t, lang, fmtMoney } = useLang(); const DOW = [t('routines.dow.sun'), t('routines.dow.mon'), t('routines.dow.tue'), t('routines.dow.wed'), t('routines.dow.thu'), t('routines.dow.fri'), t('routines.dow.sat')]; const TEMPLATES = [ { title: t('routines.template1.title'), prompt: t('routines.template1.prompt'), schedule: { type: 'daily', time: '09:00' } }, @@ -85,6 +86,7 @@ export default function Routines({ params }) { times: tpl?.schedule?.times ?? [tpl?.schedule?.time ?? '09:00'], dows: tpl?.schedule?.dows ?? [tpl?.schedule?.dow ?? 1], everyMinutes: tpl?.schedule?.everyMinutes ?? 30, + maxRuns: 20, maxUsd: '', }); } @@ -99,6 +101,7 @@ export default function Routines({ params }) { times: r.schedule?.times ?? [r.schedule?.time ?? '09:00'], dows: r.schedule?.dows ?? [r.schedule?.dow ?? 1], everyMinutes: r.schedule?.everyMinutes ?? 30, + maxRuns: r.loop?.maxRuns ?? 20, maxUsd: r.loop?.maxUsd ?? '', }); } @@ -115,6 +118,8 @@ export default function Routines({ params }) { // 한국 시간으로"). 안 보내면 서버가 자기 시간대로 해석하는데, 웹·클라우드 경로에선 그게 // 사용자 시간대가 아니다(UTC 서버면 09:00이 KST 18:00에 터진다). : { type: form.type, times: form.times, dows: form.dows.map(Number), tz: Intl.DateTimeFormat().resolvedOptions().timeZone }, + // 루프 상한 — interval에만 보낸다(서버는 다른 타입의 loop를 무시하지만, 보내지 않는 편이 의도가 분명하다) + ...(form.type === 'interval' ? { loop: { maxRuns: Number(form.maxRuns) || 20, maxUsd: form.maxUsd === '' ? null : Number(form.maxUsd) } } : {}), }; if (form.id) { const res = await fetch(`/api/companies/${ws}/routines`, { @@ -250,11 +255,24 @@ export default function Routines({ params }) { )} {form.type === 'interval' && ( - + <> + + {/* 루프 상한 — 회차(1~200)·루프 예산(USD, 비우면 월 예산만). 한국어 모드는 원화 병기(fmtMoney 규칙) */} + + + )} {form.type !== 'interval' && (
@@ -335,7 +353,7 @@ export default function Routines({ params }) { ) : ( - + {routines.map((r) => ( @@ -349,7 +367,10 @@ export default function Routines({ params }) { {nameOf(r.agentSlug)} - +
{t('routines.colTitle')}{t('routines.colCrew')}{t('routines.colSchedule')}{t('routines.colLastRun')}{t('routines.colState')}
{t('routines.colTitle')}{t('routines.colCrew')}{t('routines.colSchedule')}{t('routines.colLastRun')}{t('routines.colState')}
{scheduleLabel(r.schedule, t, DOW)} + {scheduleLabel(r.schedule, t, DOW)} + {r.schedule?.type === 'interval' && r.loop && } + {r.lastRun ? ( @@ -358,8 +379,9 @@ export default function Routines({ params }) { ) : } - @@ -383,6 +405,27 @@ export default function Routines({ params }) { ); } +/** 루프 진행 — `3/20회 · $0.42` + 상태 칩. 막힘이면 사유와 결재함 안내(결재 카드는 데크에 있다). */ +function LoopStatus({ ws, loop, t, fmtMoney }) { + const reason = loop.stoppedReason; + const key = reason === 'done' ? 'done' : reason === 'blocked' ? 'blocked' : (reason === 'maxRuns' || reason === 'maxUsd') ? 'limit' : reason === 'manual' ? 'manual' : 'running'; + const color = key === 'running' ? 'var(--primary-strong)' : key === 'done' ? 'var(--ok, var(--fg-2))' : key === 'blocked' ? 'var(--danger)' : 'var(--fg-3)'; + return ( +
+ + {t('routines.loop.progress', { runs: loop.runs ?? 0, max: loop.maxRuns, cost: fmtMoney(loop.spentUsd ?? 0) })} + {t(`routines.loop.${key}`)} + + {key === 'blocked' && ( + + {t('routines.loop.blockedHint', { reason: loop.stoppedDetail ?? '' })} + {t('routines.loop.approveInbox')} + + )} +
+ ); +} + const selStyle = { height: 34, padding: '0 10px', background: 'var(--card-2)', border: '1px solid var(--border)', borderRadius: 8, outline: 'none', fontSize: 13, color: 'var(--fg)', diff --git a/app/i18n.jsx b/app/i18n.jsx index 03aa0060..003ea863 100644 --- a/app/i18n.jsx +++ b/app/i18n.jsx @@ -1117,6 +1117,19 @@ const DICT = { 'routines.runNow': ['지금 즉시 실행', 'Run now'], 'routines.running': ['실행 중 — 결과는 기억에도 남습니다', 'Running — the result is also saved to memory'], 'routines.resultTitle': ['결과 · 기억에 기록됨', 'Result · Recorded in Memory'], + // 루프(interval 루틴의 자율 반복) + 'routines.loop.maxRuns': ['최대 반복', 'Max runs'], + 'routines.loop.maxUsd': ['루프 예산(USD)', 'Loop budget (USD)'], + 'routines.loop.maxUsdHint': ['비우면 회사 월 예산만 적용', 'Leave empty to apply only the monthly budget'], + 'routines.loop.progress': ['{runs}/{max}회 · {cost}', '{runs}/{max} runs · {cost}'], + 'routines.loop.running': ['진행 중', 'Running'], + 'routines.loop.done': ['완료', 'Done'], + 'routines.loop.blocked': ['막힘', 'Blocked'], + 'routines.loop.limit': ['한도', 'Limit'], + 'routines.loop.manual': ['정지됨', 'Stopped'], + 'routines.loop.blockedHint': ['결정 필요: {reason}', 'Needs a decision: {reason}'], + 'routines.loop.approveInbox': ['결재함에서 승인', 'Approve in inbox'], + 'routines.loop.resume': ['지금 재개', 'Resume now'], // ── 마켓 'market.topLabel': ['추천 · {source}', 'Recommended · {source}'], diff --git a/src/approval-actions.mjs b/src/approval-actions.mjs index 36f3c119..e1a20819 100644 --- a/src/approval-actions.mjs +++ b/src/approval-actions.mjs @@ -11,6 +11,15 @@ import { emitNotify } from './notify.mjs'; // 후속 턴 결과를 원 채널( (capabilities.mjs). 옛 결재함에 남은 capability 항목은 여기서 특별 처리 없이 그냥 해소된다. */ export async function resolveWithFollowUp(wsId, id, approve) { const item = await resolveApproval(wsId, id, approve); + if (item.kind === 'loop') { + // 루프 막힘(LOOP: blocked) 결재 — 승인이면 루틴을 다시 켠다(다음 틱에 재개), 거절이면 정지 유지. + // 후속 턴을 돌리지 않는다: 재개된 루프의 다음 회차가 곧 후속이고, 여기서 턴을 더 쓰면 비용 이중. + if (approve && item.payload?.routineId) { + const { resumeLoop } = await import('./routines.mjs'); + await resumeLoop(wsId, item.payload.routineId).catch((e) => console.error('[argo] 루프 재개 실패:', e.message)); + } + return item; + } if (item.kind !== 'tool') { followUp(wsId, item, approve).catch((e) => console.error('[argo] 결재 후속 턴 실패:', e.message)); } diff --git a/src/chat.mjs b/src/chat.mjs index cde1a45e..f1aff4a3 100644 --- a/src/chat.mjs +++ b/src/chat.mjs @@ -669,26 +669,30 @@ export function makeCrewServer(wsId, fromSlug, fromName, colleagues, hop = 0, ch // 사장이 언제든 끄거나 고칠 수 있으므로(가시성) 결재 없이 실행한다 — hire_crew와 달리 되돌리기 쉽다. const scheduleTask = tool( 'schedule_task', - '나중에 할 일을 예약한다(예약 발송·리마인드·정기 보고·반복 루프). once=지정 날짜에 1회, daily=매일, weekly=지정 요일, interval=N분마다 반복(루프 작업 — 모니터링·주기 점검). 시각은 한국 시간 HH:MM. 때가 되면 지정 크루가 prompt를 새 턴으로 실행한다. 예약 후에는 "언제 무엇을 하도록 걸어두었다"고 한 줄로 알려라.', + '나중에 할 일을 예약한다(예약 발송·리마인드·정기 보고·반복 루프). once=지정 날짜에 1회, daily=매일, weekly=지정 요일, interval=N분마다 반복(루프 작업 — 모니터링·주기 점검). 시각은 한국 시간 HH:MM. 때가 되면 지정 크루가 prompt를 새 턴으로 실행한다. interval 루프는 매 회차 마지막 줄 `LOOP: continue|done|blocked`로 스스로 끝내며 maxRuns(기본 20)·maxUsd 상한에서 자동 정지한다. 예약 후에는 "언제 무엇을 하도록 걸어두었다"고 한 줄로 알려라.', { title: z.string().describe('예약 이름 — 루틴 목록에 보인다'), prompt: z.string().describe('실행할 지시 — 지금이 아니라 그때 읽힌다는 전제로 자세히 쓴다. 루프면 매 회차가 이 지시를 새로 읽는다'), type: z.enum(['once', 'daily', 'weekly', 'interval']).describe('once=1회, daily=매일, weekly=매주, interval=N분마다'), time: z.string().optional().describe('실행 시각 HH:MM (한국 시간, 24시간제) — interval이 아니면 필수'), everyMinutes: z.number().optional().describe('interval일 때 필수 — 반복 간격(분, 10~1440)'), + maxRuns: z.number().optional().describe('interval 루프의 최대 회차(1~200, 기본 20) — 도달하면 자동 정지'), + maxUsd: z.number().optional().describe('interval 루프의 누적 비용 상한(USD, 선택) — 없으면 회사 월 예산만 적용'), date: z.string().optional().describe('once일 때 필수 — 실행 날짜 YYYY-MM-DD'), dows: z.array(z.number()).optional().describe('weekly일 때 요일 배열(0=일 … 6=토), 예: 평일은 [1,2,3,4,5]'), agentSlug: z.string().optional().describe('실행할 크루 slug(기본 = 나 자신)'), }, - async ({ title, prompt, type, time, date, dows, everyMinutes, agentSlug }) => { + async ({ title, prompt, type, time, date, dows, everyMinutes, agentSlug, maxRuns, maxUsd }) => { try { const r = await addRoutine(wsId, { agentSlug: agentSlug || fromSlug, title, prompt, schedule: { type, ...(time ? { time } : {}), ...(date ? { date } : {}), ...(dows?.length ? { dows } : {}), ...(everyMinutes ? { everyMinutes } : {}) }, + // interval = 자율 루프 — 회차·예산 상한을 기본으로 건다(무한 반복 방지). 다른 타입엔 addRoutine이 무시 + ...(type === 'interval' ? { loop: { maxRuns: maxRuns ?? 20, ...(maxUsd != null ? { maxUsd } : {}) } } : {}), }); const when = type === 'once' ? `${r.schedule.date} ${r.schedule.time}` : type === 'weekly' ? `매주 ${(r.schedule.dows ?? []).join(',')} ${r.schedule.time}` - : type === 'interval' ? `${r.schedule.everyMinutes}분마다` + : type === 'interval' ? `${r.schedule.everyMinutes}분마다, 최대 ${r.loop?.maxRuns ?? 20}회` : `매일 ${r.schedule.time}`; return text(`예약 완료 — "${title}" (${when}, 담당 ${agentSlug || fromSlug}). 루틴 화면에서 사장이 끄거나 고칠 수 있다. 사장에게 언제 무엇을 하도록 걸어뒀는지 한 줄로 알려라.`); } catch (e) { @@ -1190,6 +1194,7 @@ ${lang === 'en' } let reply = ''; + let costUsd = null; // 이 턴의 청구 금액 — 루프 루틴의 예산 합산용. 구독(OAuth)·openrouter·CLI 턴은 null(=0으로 합산) let creditTurn = false; // OpenRouter 402 턴 표식 — 일지 기록 제외용(2R N3: 오류 원문이 기억으로 정제되지 않게) let sid = resumeId; // 새 세션이면 null에서 시작 — 외래 sessionId를 내 것으로 재스탬프하지 않는다 const toolCounts = {}; // 이 턴의 도구 사용 횟수 — 크루 프로필 "많이 쓴 도구"의 원천 @@ -1310,6 +1315,7 @@ ${lang === 'en' // 틀린 금액 표시·예산 차감은 이번에 죽인 신고 계열의 재발이다. 실비(P2)는 /generation API로. usage: msg.usage, costUsd: runner === 'openrouter' ? null : msg.total_cost_usd, ms: Date.now() - t0, tools: toolCounts, billed, }); + if (billed && runner !== 'openrouter' && Number.isFinite(msg.total_cost_usd)) costUsd = msg.total_cost_usd; } if (msg.subtype === 'success') reply = msg.result; else { @@ -1430,5 +1436,5 @@ ${lang === 'en' // diff와 합집합 — 도구 관측(즉시성)과 파일시스템 diff(Bash·MCP 포함 완전성)를 합친다. 필터는 // servableArtifact 하나로 통일(칩=서빙 일치 — 탐색 G8), 상한·정렬은 artDiff와 같은 규칙. for (const r of await artDiff()) artifacts.add(r); - return { reply, sessionId: sid, handover, artifacts: capLatest(artAfter, [...artifacts].filter(servableArtifact)) }; // 합집합도 최신 우선 12(알파벳 컷이 최신을 떨구던 것 — 검수 LOW-2) + return { reply, sessionId: sid, handover, costUsd, artifacts: capLatest(artAfter, [...artifacts].filter(servableArtifact)) }; // 합집합도 최신 우선 12(알파벳 컷이 최신을 떨구던 것 — 검수 LOW-2) } diff --git a/src/cli-directives.mjs b/src/cli-directives.mjs index f63eb62f..79b4cfbc 100644 --- a/src/cli-directives.mjs +++ b/src/cli-directives.mjs @@ -17,7 +17,7 @@ import { listAgents } from './hub.mjs'; import { callConnectorTool } from './connectors.mjs'; // 커넥터 단일 실행 경로 — SDK 표면과 같은 함수 /** 지시 블록 문법 — ```argo 펜스 안 JSON 1건. 여러 블록 허용. - 스케줄: {"action":"schedule","every":"30m"|"time":"09:00","days":[1,3],"title":"...","prompt":"..."} + 스케줄: {"action":"schedule","every":"30m"|"time":"09:00","days":[1,3],"title":"...","prompt":"...","maxRuns":20,"maxUsd":5} (maxRuns/maxUsd는 every 루프에만·선택) 쪽지: {"action":"mail","to":"슬러그","cc":["..."],"message":"..."} 커넥터: {"action":"tool","server":"gmail","tool":"search_threads","args":{…}} */ const BLOCK_RE = /```argo[ \t]*\r?\n([\s\S]*?)```/g; @@ -118,14 +118,17 @@ export async function runDirectives(wsId, fromSlug, directives, { lang = 'ko', b const prompt = String(d.prompt ?? '').trim(); if (!prompt) throw new Error(en ? 'prompt is required' : 'prompt가 필요합니다'); const target = d.crew ? find(d.crew) : null; + const schedule = normalizeSchedule(toSchedule(d)); const r = await addRoutine(wsId, { agentSlug: target?.slug ?? fromSlug, title: String(d.title ?? prompt).replace(/\s+/g, ' ').slice(0, 60), prompt, - schedule: normalizeSchedule(toSchedule(d)), + schedule, + // SDK의 schedule_task와 패리티 — interval은 자율 루프(기본 20회 상한, maxRuns/maxUsd 지시 필드 수용) + ...(schedule.type === 'interval' ? { loop: { maxRuns: d.maxRuns ?? 20, ...(d.maxUsd != null ? { maxUsd: d.maxUsd } : {}) } } : {}), }); const when = r.schedule.type === 'interval' - ? (en ? `every ${r.schedule.everyMinutes} min` : `${r.schedule.everyMinutes}분마다`) + ? (en ? `every ${r.schedule.everyMinutes} min, up to ${r.loop?.maxRuns ?? 20} runs` : `${r.schedule.everyMinutes}분마다, 최대 ${r.loop?.maxRuns ?? 20}회`) : (r.schedule.times ?? []).join('·'); notes.push(en ? `✓ Routine registered — ${r.title} (${when})` : `✓ 루틴 등록됨 — ${r.title} (${when})`); } else if (action === 'mail') { diff --git a/src/room.mjs b/src/room.mjs index def8bd24..379a2611 100644 --- a/src/room.mjs +++ b/src/room.mjs @@ -310,10 +310,11 @@ export async function runRoomTurn(wsId, text, attachments = []) { agentSlug: target.slug, title: (prompt.replace(/@\S+/g, '').trim() || (en ? 'Room loop' : '회의실 루프')).slice(0, 60), prompt, schedule: { type: 'interval', everyMinutes: dir.loop.everyMinutes }, + loop: { maxRuns: 20 }, // 자율 루프 기본 상한 — 회차마다 LOOP 판정으로 스스로 끝내고, 20회에서 자동 정지 }); await sys('loop', en - ? `Loop registered — @${target.slug} every ${r.schedule.everyMinutes} min. Manage it in Routines.` - : `루프 등록 — @${target.slug}, ${r.schedule.everyMinutes}분마다. 관리는 '루틴' 화면에서.`); + ? `Loop registered — @${target.slug} every ${r.schedule.everyMinutes} min, up to ${r.loop?.maxRuns ?? 20} runs. Manage it in Routines.` + : `루프 등록 — @${target.slug}, ${r.schedule.everyMinutes}분마다, 최대 ${r.loop?.maxRuns ?? 20}회. 관리는 '루틴' 화면에서.`); } catch (e) { // 간격 하한(10분) 등 검증 실패 — 사유를 방에 그대로 돌려준다 await sys('loop', en ? `Loop not registered: ${String(e.message || e)}` : `루프 등록 실패: ${String(e.message || e)}`); diff --git a/src/routines.mjs b/src/routines.mjs index d5f7bced..076f7477 100644 --- a/src/routines.mjs +++ b/src/routines.mjs @@ -17,7 +17,8 @@ async function patchRoutine(wsId, id, patch) { const routines = await loadRoutines(wsId); const r = routines.find((x) => x.id === id); if (!r) return null; // 실행 중 삭제됐으면 조용히 포기(부활 금지) - Object.assign(r, patch, { id: r.id }); + // 함수형 패치 — 현재 상태를 보고 결정해야 하는 변경(루프 수동 정지 사유 등)은 락 안에서 읽고 쓴다 + Object.assign(r, typeof patch === 'function' ? patch(r) : patch, { id: r.id }); await saveRoutines(wsId, routines); return { ...r }; }); @@ -81,21 +82,97 @@ export function normalizeSchedule(schedule = {}) { return withTz({ type, time: times[0], times, dow: dows[0], ...(type === 'weekly' ? { dows } : {}) }); } +/* ─── 루프(interval 루틴의 자율 반복) ─────────────────────────────────────── */ + +/** loop 필드 정규화 — interval 루틴에만 유효(호출부가 타입을 보고 붙인다). 설정값(maxRuns/maxUsd)은 + 클램프·기본값, 진행 카운터(runs/spentUsd/…)는 prev(디스크의 현재값)에서 이어받는다 — API 패치가 + 회차·지출을 되돌리지 못하게. (export: 단위 테스트용 — 순수 함수) */ +export const LOOP_MAX_RUNS_CAP = 200; +export function normalizeLoop(loop = {}, prev = null) { + const src = loop && typeof loop === 'object' ? loop : {}; + let maxRuns = Math.floor(Number(src.maxRuns ?? prev?.maxRuns ?? 20)); + if (!Number.isFinite(maxRuns)) maxRuns = 20; + maxRuns = Math.min(LOOP_MAX_RUNS_CAP, Math.max(1, maxRuns)); + const rawUsd = 'maxUsd' in src ? src.maxUsd : prev?.maxUsd ?? null; + const usdNum = Number(rawUsd); + const maxUsd = rawUsd == null || rawUsd === '' || !Number.isFinite(usdNum) || usdNum <= 0 ? null : Math.round(usdNum * 100) / 100; + return { + maxRuns, maxUsd, + runs: Math.max(0, Math.floor(Number(prev?.runs) || 0)), + spentUsd: Math.max(0, Number(prev?.spentUsd) || 0), + lastVerdict: ['continue', 'done', 'blocked'].includes(prev?.lastVerdict) ? prev.lastVerdict : null, + stoppedReason: ['done', 'blocked', 'maxRuns', 'maxUsd', 'manual'].includes(prev?.stoppedReason) ? prev.stoppedReason : null, + missingVerdicts: Math.max(0, Math.floor(Number(prev?.missingVerdicts) || 0)), + stoppedDetail: String(prev?.stoppedDetail ?? '').slice(0, 300), // 정지 상세(blocked의 필요한 결정·done의 이유) — 화면 표시용 + }; +} + +/** 회차 판정 마커 — 답변 **마지막 줄**. `LOOP: continue` / `LOOP: done <이유>` / `LOOP: blocked <필요한 결정>`. + (export: 테스트·프롬프트 문구 앵커) */ +export const LOOP_VERDICT_RE = /^\s*`?\s*LOOP\s*:\s*(continue|done|blocked)\b[\s.:\-—]*(.*?)\s*`?\s*[.。]?\s*$/i; +const LOOP_MISSING_LIMIT = 3; // 마커 연속 누락 허용 — CLI 러너가 형식을 못 지켜도 조용히 죽지 않되, 영영 헛돌지도 않게 + +/** 답변에서 판정 추출 — 마지막 비어있지 않은 줄만 본다. 마커가 없으면 { verdict:'continue', missing:true } — + 형식을 안 지킨 러너를 곧바로 정지시키지 않는다(연속 누락 상한은 runRoutine이 센다). + (export: 단위 테스트용 — 순수 함수) */ +export function parseLoopVerdict(reply) { + const lines = String(reply ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const last = lines[lines.length - 1] ?? ''; + const m = last.match(LOOP_VERDICT_RE); + if (!m) return { verdict: 'continue', reason: '', missing: true }; + return { verdict: m[1].toLowerCase(), reason: (m[2] ?? '').trim().slice(0, 300), missing: false }; +} + +const isLoopRoutine = (r) => r?.schedule?.type === 'interval' && !!r.loop; + +/** 루프 프로토콜 문단 — 회차·상한·지난 결과를 주고 마지막 줄 마커를 요구한다(러너 무관 — 텍스트 규약). */ +function loopProtocol(r, lang) { + const n = (r.loop.runs ?? 0) + 1; + const last = String(r.lastResult ?? '').trim(); + const budget = r.loop.maxUsd != null ? (lang === 'en' ? ` Loop budget: $${r.loop.spentUsd.toFixed(2)} of $${r.loop.maxUsd} used.` : ` 루프 예산: $${r.loop.maxUsd} 중 $${r.loop.spentUsd.toFixed(2)} 사용.`) : ''; + if (lang === 'en') { + return `\n\n---\n[Loop protocol] This is run ${n} of at most ${r.loop.maxRuns} in a repeating loop.${budget}\nPrevious run summary: ${last || '(none — first run)'}\nDo the next step of the work. The VERY LAST line of your answer must be exactly one of:\n\`LOOP: continue\` — more to do next run\n\`LOOP: done \` — the goal is reached, stop the loop\n\`LOOP: blocked \` — you cannot proceed without a human decision`; + } + return `\n\n---\n[루프 프로토콜] 이것은 반복 루프의 ${n}회차 / 최대 ${r.loop.maxRuns}회다.${budget}\n지난 회차 결과 요약: ${last || '(없음 — 첫 회차)'}\n이번 회차 몫의 일을 진행하라. 답변의 **마지막 줄**은 반드시 다음 셋 중 하나로만 끝내라:\n\`LOOP: continue\` — 다음 회차에 할 일이 남음\n\`LOOP: done <한 줄 이유>\` — 목표 달성, 루프 종료\n\`LOOP: blocked <사장에게 필요한 결정>\` — 사람 결정 없이는 진행 불가`; +} + +/** 정지 사유 문장 — 알림(emitNotify)에 그대로 실린다. */ +function loopStopMessage(reason, detail, lang, loop) { + const en = lang === 'en'; + switch (reason) { + case 'done': return en ? `Loop finished — ${detail || 'goal reached'}` : `루프 완료 — ${detail || '목표 달성'}`; + case 'blocked': return en ? `Loop paused — needs your decision: ${detail || '(no detail)'}. Approve in the inbox to resume.` : `루프 멈춤 — 결정이 필요합니다: ${detail || '(상세 없음)'}. 결재함에서 승인하면 재개됩니다.`; + case 'maxRuns': return en ? `Loop stopped — reached the run limit (${loop.maxRuns}).` : `루프 정지 — 최대 반복(${loop.maxRuns}회)에 도달했습니다.`; + case 'maxUsd': return en ? `Loop stopped — reached the loop budget ($${loop.maxUsd}).` : `루프 정지 — 루프 예산($${loop.maxUsd})에 도달했습니다.`; + default: return en ? 'Loop stopped.' : '루프 정지.'; + } +} + +/** 결재 승인 후 재개 — approval-actions(kind:'loop')가 부른다. 거절이면 부르지 않는다(정지 유지). */ +export async function resumeLoop(wsId, id) { + return patchRoutine(wsId, id, (r) => (isLoopRoutine(r) + ? { enabled: true, loop: { ...r.loop, stoppedReason: null, stoppedDetail: '', missingVerdicts: 0 } } + : { enabled: true })); +} + /** 이 기기의 시간대 — 로컬 우선 제품이라 서버는 사용자 컴퓨터에서 돈다. 즉 여기서 읽은 시간대가 곧 사용자의 시간대다(한국 사용자면 Asia/Seoul). 클라이언트가 tz를 보내면 그쪽이 우선. */ const hostTz = () => { try { return new Intl.DateTimeFormat().resolvedOptions().timeZone || null; } catch { return null; } }; -export async function addRoutine(wsId, { agentSlug, title, prompt, schedule, enabled = true }) { +export async function addRoutine(wsId, { agentSlug, title, prompt, schedule, enabled = true, loop = null }) { if (!agentSlug || !title?.trim() || !prompt?.trim()) throw new Error('크루·제목·지시가 필요합니다'); + const sched = normalizeSchedule({ tz: hostTz(), ...schedule }); const routine = { id: `r${Date.now().toString(36)}`, agentSlug, title: title.trim(), prompt: prompt.trim(), // 만들 때 시간대를 각인한다 — 이후 어느 기기(클라우드 워커 포함)가 돌려도 만든 사람의 시각으로 // 발화한다. 명시값이 있으면 그것을, 없으면 이 기기(=사용자 컴퓨터)의 시간대를 쓴다. - schedule: normalizeSchedule({ tz: hostTz(), ...schedule }), + schedule: sched, enabled, created: new Date().toISOString(), lastRun: null, lastOk: null, lastResult: '', + // 루프 — interval에만. 다른 타입에 loop가 오면 조용히 버린다(의미 없는 필드를 저장하지 않는다) + ...(sched.type === 'interval' && loop ? { loop: normalizeLoop(loop) } : {}), }; return withLock(lockKey(wsId), async () => { const routines = await loadRoutines(wsId); @@ -124,11 +201,31 @@ export function sanitizeRoutinePatch(patch = {}) { } if ('schedule' in patch) out.schedule = normalizeSchedule(patch.schedule); if ('enabled' in patch) out.enabled = !!patch.enabled; + // loop 설정(maxRuns/maxUsd)만 통과 — 카운터 병합·interval 여부 판정은 updateRoutine이 현재 루틴을 보고 한다 + if ('loop' in patch) out.loop = patch.loop && typeof patch.loop === 'object' ? { maxRuns: patch.loop.maxRuns, maxUsd: patch.loop.maxUsd } : null; return out; } export async function updateRoutine(wsId, id, patch) { - const r = await patchRoutine(wsId, id, sanitizeRoutinePatch(patch)); + const clean = sanitizeRoutinePatch(patch); + const r = await patchRoutine(wsId, id, (cur) => { + const out = { ...clean }; + const nextSched = out.schedule ?? cur.schedule; + if (nextSched?.type !== 'interval') { + // interval이 아닌 루틴엔 loop가 없다 — 패치의 loop는 무시하고, 타입을 바꿨으면 기존 루프 상태도 비운다 + if (cur.loop || 'loop' in out) out.loop = null; else delete out.loop; + return out; + } + if ('loop' in out) out.loop = out.loop ? normalizeLoop(out.loop, cur.loop) : null; + const base = out.loop ?? cur.loop; + if (base && 'enabled' in out) { + // 수동 정지 = stoppedReason 'manual'(이미 사유가 있으면 유지). 다시 켜면 사유·누락 카운터를 비운다(지금 재개) + out.loop = out.enabled + ? { ...base, stoppedReason: null, stoppedDetail: '', missingVerdicts: 0 } + : { ...base, stoppedReason: base.stoppedReason ?? 'manual' }; + } + return out; + }); if (!r) throw new Error('루틴을 찾을 수 없습니다'); return r; } @@ -143,24 +240,61 @@ export async function removeRoutine(wsId, id) { /** 루틴 실행 — 새 세션 1턴. 결과 요약을 루틴에 기록(전체는 vault 핸드오버에). chat()은 수 분 걸리므로 락 밖에서 돌리고, 결과 기록만 락 안에서 해당 루틴 필드에 반영한다 — 실행 도중 사용자가 다른 루틴을 지우거나 이 루틴을 꺼도 낡은 전체 스냅샷으로 되돌리지 않는다. */ -export async function runRoutine(wsId, id) { +export async function runRoutine(wsId, id, { chatFn = null } = {}) { const r0 = await patchRoutine(wsId, id, { lastRun: new Date().toISOString() }); if (!r0) throw new Error('루틴을 찾을 수 없습니다'); try { - const { chat } = await import('./chat.mjs'); // 순환 차단 — 파일 상단 주석 참조 - const t = await chat(wsId, r0.agentSlug, `[루틴: ${r0.title}] ${r0.prompt}`, null, { source: 'routine' }); + const chat = chatFn ?? (await import('./chat.mjs')).chat; // 순환 차단 — 파일 상단 주석 참조. chatFn=테스트 주입(실 러너 불필요) + const loop = isLoopRoutine(r0); + let lang = 'ko'; + if (loop) { + const { loadCompany } = await import('./workspace.mjs'); + lang = (await loadCompany(wsId).catch(() => ({}))).lang === 'en' ? 'en' : 'ko'; + } + const userMsg = `[루틴: ${r0.title}] ${r0.prompt}${loop ? loopProtocol(r0, lang) : ''}`; + const t = await chat(wsId, r0.agentSlug, userMsg, null, { source: 'routine' }); // 대화 스레드에 남긴다 — 루틴만 이게 빠져 있어서, 실행 중엔 채팅창에 보이다가 끝나면 사라졌다 // (신고 2026-07-28 "루틴 돌면서 채팅이 올라왔다가 실행되고 나니 유실"). 저장한 적이 없었던 것. // 사장 직접 대화·위임·쪽지 배달은 전부 appendTurn을 한다 — 루틴만 비대칭이었다. // 기록 실패는 무증상으로 삼키지 않는다(비용은 나갔는데 화면에 없다 — scheduler의 쪽지 경로와 동일 규칙). const { appendTurn } = await import('./thread.mjs'); - await appendTurn(wsId, r0.agentSlug, { userMsg: `[루틴: ${r0.title}] ${r0.prompt}`, reply: t.reply, handover: t.handover, sessionId: null, via: 'routine', artifacts: t.artifacts }) + await appendTurn(wsId, r0.agentSlug, { userMsg, reply: t.reply, handover: t.handover, sessionId: null, via: 'routine', artifacts: t.artifacts }) .catch((e) => console.error(`[argo] 루틴 스레드 기록 실패(${wsId}/${r0.agentSlug}):`, e.message)); const summary = t.reply.replace(/\s+/g, ' ').slice(0, 160); // 1회 예약은 성공 후 스스로 꺼진다 — 다음 날 같은 시각에 되살아나지 않게(실패 시엔 켜둬 당일 재시도 허용) - const r = await patchRoutine(wsId, id, { lastOk: true, lastResult: summary, ...(r0.schedule?.type === 'once' ? { enabled: false } : {}) }); + const patch = { lastOk: true, lastResult: summary, ...(r0.schedule?.type === 'once' ? { enabled: false } : {}) }; + let stop = null; // { reason, detail } + if (loop) { + const v = parseLoopVerdict(t.reply); + const L = { ...normalizeLoop(r0.loop, r0.loop) }; + L.runs += 1; + L.spentUsd = Math.round((L.spentUsd + (Number(t.costUsd) || 0)) * 10000) / 10000; // 구독(OAuth)·CLI 턴은 costUsd null → 0 + L.lastVerdict = v.verdict; + L.missingVerdicts = v.missing ? L.missingVerdicts + 1 : 0; + // 정지 조건 — 먼저 걸린 하나만 사유로 남긴다(판정 > 누락 상한 > 회차 > 예산) + if (v.verdict === 'done') stop = { reason: 'done', detail: v.reason }; + else if (v.verdict === 'blocked') stop = { reason: 'blocked', detail: v.reason }; + else if (L.missingVerdicts >= LOOP_MISSING_LIMIT) stop = { reason: 'blocked', detail: lang === 'en' ? `No LOOP verdict in ${LOOP_MISSING_LIMIT} consecutive runs — check the crew's runner/output format` : `${LOOP_MISSING_LIMIT}회 연속 LOOP 판정 누락 — 크루의 러너·출력 형식을 확인해 주세요` }; + else if (L.runs >= L.maxRuns) stop = { reason: 'maxRuns', detail: '' }; + else if (L.maxUsd != null && L.spentUsd >= L.maxUsd) stop = { reason: 'maxUsd', detail: '' }; + if (stop) { L.stoppedReason = stop.reason; L.stoppedDetail = String(stop.detail ?? '').slice(0, 300); patch.enabled = false; } + patch.loop = L; + } + const r = await patchRoutine(wsId, id, patch); + if (stop) { + if (stop.reason === 'blocked') { + // 막힘 = 사장 결재로 푼다. 승인 → approval-actions(kind:'loop')가 resumeLoop, 거절 → 정지 유지. + const { addApproval } = await import('./approvals.mjs'); + await addApproval(wsId, { + slug: r0.agentSlug, kind: 'loop', + action: lang === 'en' ? `Resume loop — ${r0.title}`.slice(0, 300) : `루프 재개 — ${r0.title}`.slice(0, 300), + reason: stop.detail, payload: { routineId: id }, + }).catch((e) => console.error(`[argo] 루프 결재 등록 실패(${wsId}/${id}):`, e.message)); + } + emitNotify({ type: 'routine', wsId, routine: r ?? r0, ok: true, reply: loopStopMessage(stop.reason, stop.detail, lang, r?.loop ?? r0.loop) }); + } emitNotify({ type: 'routine', wsId, routine: r ?? r0, ok: true, reply: t.reply }); // 메신저 브리핑 푸시 - return { ok: true, reply: t.reply, handover: t.handover }; + return { ok: true, reply: t.reply, handover: t.handover, ...(loop ? { loop: r?.loop ?? null, stopped: stop?.reason ?? null } : {}) }; } catch (e) { const msg = String(e.message || e).slice(0, 160); const r = await patchRoutine(wsId, id, { lastOk: false, lastResult: msg }); diff --git a/test/routine-loops.test.mjs b/test/routine-loops.test.mjs new file mode 100644 index 00000000..aa040907 --- /dev/null +++ b/test/routine-loops.test.mjs @@ -0,0 +1,151 @@ +// 자율 루프(interval 루틴의 loop 필드) — 정규화·판정 파싱·정지 조건·결재 재개를 임시 ARGO_ROOT에서 잠근다. +// chat()은 runRoutine의 chatFn 주입으로 대체 — 실 러너 없이 프로토콜 배선만 검증(라이브는 별도). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +process.env.ARGO_ROOT = await mkdtemp(join(tmpdir(), 'argo-loops-')); +const { normalizeLoop, parseLoopVerdict, LOOP_VERDICT_RE, addRoutine, updateRoutine, runRoutine, loadRoutines, resumeLoop } = await import('../src/routines.mjs'); +const { loadApprovals } = await import('../src/approvals.mjs'); +const { createCompany } = await import('../src/workspace.mjs'); +const { onNotify } = await import('../src/notify.mjs'); +const { resolveWithFollowUp } = await import('../src/approval-actions.mjs'); + +const WS = 'loopco'; +await createCompany(WS, '루프사', 'captain'); +await mkdir(join(process.env.ARGO_ROOT, WS, 'agents', 'alpha'), { recursive: true }); // 크루 카드 불필요 — chatFn 주입이라 러너·카드를 읽지 않는다 + +const byId = async (id) => (await loadRoutines(WS)).find((r) => r.id === id); +const fakeChat = (replies) => { + const calls = []; + const fn = async (_ws, _slug, userMsg) => { + calls.push(userMsg); + const r = replies.shift() ?? { reply: 'ok\nLOOP: continue' }; + return { reply: r.reply, handover: null, sessionId: null, costUsd: r.costUsd ?? null }; + }; + fn.calls = calls; + return fn; +}; +const mkLoop = (loop, every = 10) => addRoutine(WS, { agentSlug: 'alpha', title: '점검 루프', prompt: '서버 상태를 점검하라', schedule: { type: 'interval', everyMinutes: every }, loop }); + +test('normalizeLoop: 기본값·클램프·카운터 보존', () => { + assert.deepEqual(normalizeLoop({}), { maxRuns: 20, maxUsd: null, runs: 0, spentUsd: 0, lastVerdict: null, stoppedReason: null, missingVerdicts: 0, stoppedDetail: '' }); + assert.equal(normalizeLoop({ maxRuns: 999 }).maxRuns, 200); + assert.equal(normalizeLoop({ maxRuns: 0 }).maxRuns, 1); + assert.equal(normalizeLoop({ maxRuns: 'abc' }).maxRuns, 20); + assert.equal(normalizeLoop({ maxUsd: '2.5' }).maxUsd, 2.5); + assert.equal(normalizeLoop({ maxUsd: -1 }).maxUsd, null); + // API 패치는 설정만 바꾸고 진행 카운터는 디스크값을 잇는다 + const merged = normalizeLoop({ maxRuns: 5 }, { maxRuns: 20, maxUsd: 3, runs: 4, spentUsd: 1.2, lastVerdict: 'continue', stoppedReason: 'manual', missingVerdicts: 1 }); + assert.equal(merged.maxRuns, 5); assert.equal(merged.maxUsd, 3); assert.equal(merged.runs, 4); assert.equal(merged.spentUsd, 1.2); assert.equal(merged.stoppedReason, 'manual'); +}); + +test('parseLoopVerdict: 3종 + 누락 + 마커 뒤 공백/마침표/백틱', () => { + assert.deepEqual(parseLoopVerdict('작업함\nLOOP: continue'), { verdict: 'continue', reason: '', missing: false }); + assert.deepEqual(parseLoopVerdict('끝\nLOOP: done 모든 항목 점검 완료.'), { verdict: 'done', reason: '모든 항목 점검 완료', missing: false }); + assert.equal(parseLoopVerdict('...\n`LOOP: blocked 배포 승인이 필요함` \n\n').verdict, 'blocked'); + assert.equal(parseLoopVerdict('...\n`LOOP: blocked 배포 승인이 필요함`').reason, '배포 승인이 필요함'); + assert.equal(parseLoopVerdict('loop: DONE').verdict, 'done'); + assert.deepEqual(parseLoopVerdict('마커 없이 끝남'), { verdict: 'continue', reason: '', missing: true }); + assert.equal(parseLoopVerdict('LOOP: continue\n그 뒤에 더 말함').missing, true, '마지막 줄만 본다'); + assert.match('LOOP: done ok', LOOP_VERDICT_RE); +}); + +test('addRoutine: interval에만 loop가 붙고, daily에 loop가 오면 무시', async () => { + const r = await mkLoop({ maxRuns: 3 }); + assert.equal(r.loop.maxRuns, 3); assert.equal(r.loop.runs, 0); + const d = await addRoutine(WS, { agentSlug: 'alpha', title: 'd', prompt: 'p', schedule: { type: 'daily', time: '09:00' }, loop: { maxRuns: 3 } }); + assert.equal('loop' in d, false); +}); + +test('runRoutine: 프롬프트에 루프 프로토콜이 붙고, done이면 enabled:false + stoppedReason done + 비용 합산', async () => { + const r = await mkLoop({ maxRuns: 10 }); + const chatFn = fakeChat([{ reply: '1회차 진행\nLOOP: continue', costUsd: 0.1 }, { reply: '마무리\nLOOP: done 목표 달성', costUsd: 0.25 }]); + await runRoutine(WS, r.id, { chatFn }); + assert.match(chatFn.calls[0], /1회차 \/ 최대 10회/); + assert.match(chatFn.calls[0], /LOOP: continue/); + let cur = await byId(r.id); + assert.equal(cur.enabled, true); assert.equal(cur.loop.runs, 1); assert.equal(cur.loop.spentUsd, 0.1); assert.equal(cur.loop.lastVerdict, 'continue'); + const out = await runRoutine(WS, r.id, { chatFn }); + assert.match(chatFn.calls[1], /2회차/); assert.match(chatFn.calls[1], /지난 회차 결과 요약: 1회차 진행/); + cur = await byId(r.id); + assert.equal(cur.enabled, false); assert.equal(cur.loop.stoppedReason, 'done'); assert.equal(cur.loop.runs, 2); assert.equal(cur.loop.spentUsd, 0.35); + assert.equal(out.stopped, 'done'); +}); + +test('runRoutine: maxRuns 도달 정지 / maxUsd 도달 정지(costUsd null은 0)', async () => { + const r = await mkLoop({ maxRuns: 2 }); + const chatFn = fakeChat([]); + await runRoutine(WS, r.id, { chatFn }); + assert.equal((await byId(r.id)).enabled, true); + await runRoutine(WS, r.id, { chatFn }); + const cur = await byId(r.id); + assert.equal(cur.enabled, false); assert.equal(cur.loop.stoppedReason, 'maxRuns'); + + const u = await mkLoop({ maxRuns: 50, maxUsd: 0.5 }); + const uChat = fakeChat([{ reply: 'a\nLOOP: continue', costUsd: null }, { reply: 'b\nLOOP: continue', costUsd: 0.6 }]); + await runRoutine(WS, u.id, { chatFn: uChat }); + assert.equal((await byId(u.id)).loop.spentUsd, 0); + await runRoutine(WS, u.id, { chatFn: uChat }); + assert.equal((await byId(u.id)).loop.stoppedReason, 'maxUsd'); +}); + +test('runRoutine: blocked → 정지 + 결재함에 kind loop 1건 + 정지 알림; 승인 시 재개, 거절 시 정지 유지', async () => { + const r = await mkLoop({ maxRuns: 10 }); + const got = []; const off = onNotify((e) => got.push(e)); + await runRoutine(WS, r.id, { chatFn: fakeChat([{ reply: '진행 불가\nLOOP: blocked 예산 증액 결정 필요' }]) }); + await new Promise((res) => setTimeout(res, 20)); off(); + let cur = await byId(r.id); + assert.equal(cur.enabled, false); assert.equal(cur.loop.stoppedReason, 'blocked'); assert.equal(cur.loop.stoppedDetail, '예산 증액 결정 필요'); + const aps = (await loadApprovals(WS)).filter((a) => a.kind === 'loop' && a.payload?.routineId === r.id); + assert.equal(aps.length, 1); + assert.equal(aps[0].reason, '예산 증액 결정 필요'); + assert.ok(got.some((e) => e.type === 'routine' && /루프 멈춤/.test(e.reply)), '정지 사유 알림'); + // 거절 → 그대로 정지 + await resolveWithFollowUp(WS, aps[0].id, false); + cur = await byId(r.id); + assert.equal(cur.enabled, false); assert.equal(cur.loop.stoppedReason, 'blocked'); + // 두 번째 막힘 → 승인 → 재개 + await resumeLoop(WS, r.id); + await runRoutine(WS, r.id, { chatFn: fakeChat([{ reply: 'x\nLOOP: blocked 또 결정' }]) }); + const ap2 = (await loadApprovals(WS)).find((a) => a.kind === 'loop' && a.status === 'pending' && a.payload?.routineId === r.id); + assert.ok(ap2); + await resolveWithFollowUp(WS, ap2.id, true); + cur = await byId(r.id); + assert.equal(cur.enabled, true); assert.equal(cur.loop.stoppedReason, null); assert.equal(cur.loop.missingVerdicts, 0); + const raw = JSON.parse(await readFile(join(process.env.ARGO_ROOT, WS, 'approvals.json'), 'utf8')); + assert.equal(raw.filter((a) => a.kind === 'loop').length, 2); +}); + +test('runRoutine: 마커 3회 연속 누락 → blocked(missingVerdicts), 중간에 마커가 오면 리셋', async () => { + const r = await mkLoop({ maxRuns: 10 }); + const chatFn = fakeChat([{ reply: '마커 없음1' }, { reply: '마커 없음2' }, { reply: '있음\nLOOP: continue' }, { reply: '없음1' }, { reply: '없음2' }, { reply: '없음3' }]); + for (let i = 0; i < 3; i++) await runRoutine(WS, r.id, { chatFn }); + assert.equal((await byId(r.id)).loop.missingVerdicts, 0, '마커가 오면 리셋'); + for (let i = 0; i < 3; i++) await runRoutine(WS, r.id, { chatFn }); + const cur = await byId(r.id); + assert.equal(cur.enabled, false); assert.equal(cur.loop.stoppedReason, 'blocked'); assert.equal(cur.loop.missingVerdicts, 3); +}); + +test('updateRoutine: 수동 정지는 stoppedReason manual(기존 사유 유지), 다시 켜면 비움; 타입 전환 시 loop 제거', async () => { + const r = await mkLoop({ maxRuns: 10 }); + let cur = await updateRoutine(WS, r.id, { enabled: false }); + assert.equal(cur.loop.stoppedReason, 'manual'); + cur = await updateRoutine(WS, r.id, { enabled: true }); + assert.equal(cur.loop.stoppedReason, null); assert.equal(cur.enabled, true); + cur = await updateRoutine(WS, r.id, { loop: { maxRuns: 7, maxUsd: 1 } }); + assert.equal(cur.loop.maxRuns, 7); assert.equal(cur.loop.maxUsd, 1); + cur = await updateRoutine(WS, r.id, { schedule: { type: 'daily', time: '09:00' } }); + assert.equal(cur.loop, null); +}); + +test('비루프 interval 루틴(loop 없음)은 프로토콜 없이 그대로 돈다', async () => { + const r = await addRoutine(WS, { agentSlug: 'alpha', title: '구 루프', prompt: 'p', schedule: { type: 'interval', everyMinutes: 10 } }); + const chatFn = fakeChat([{ reply: '응답' }]); + const out = await runRoutine(WS, r.id, { chatFn }); + assert.doesNotMatch(chatFn.calls[0], /루프 프로토콜/); + assert.equal('loop' in out, false); + assert.equal((await byId(r.id)).enabled, true); +});