diff --git a/src/message-runtime-turns.ts b/src/message-runtime-turns.ts index a231f63..335efe6 100644 --- a/src/message-runtime-turns.ts +++ b/src/message-runtime-turns.ts @@ -1,6 +1,13 @@ import { type AgentOutput } from './agent-runner.js'; -import { getLastBotFinalMessage, hasActiveCiWatcherForChat } from './db.js'; -import { applyPhantomNotificationGuard } from './phantom-notification-guard.js'; +import { + getLastBotFinalMessage, + hasActiveCiWatcherForChat, + storeMessage, +} from './db.js'; +import { + PHANTOM_CONTINUE_NUDGE, + resolvePhantomFinalOutcome, +} from './phantom-notification-guard.js'; import { runAgentForGroup } from './message-agent-executor.js'; import { MessageTurnController } from './message-turn-controller.js'; import { @@ -208,15 +215,25 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn { getCloseReason: () => deps.queue.getCloseReasonForRun?.(chatJid, runId) ?? null, deliverFinalText: async (text, options) => { - // Never let a turn close on a phantom "I'll wait … I'll be notified" - // dead-end when no watcher is actually registered — annotate it so the - // user gets an actionable follow-up prompt instead of silence. - const guardedText = applyPhantomNotificationGuard(text, { - hasActiveWatcher: hasActiveCiWatcherForChat(chatJid), - }); + // Guard against phantom "I'll wait … I'll be notified" dead-end finals + // with no registered watcher. Paired flows already continue on their own + // (owner → reviewer → …), so this only applies to non-paired turns: + // re-invoke the agent once to actually finish (capped, no loop), and if + // it dead-ends again, fall back to an explicit follow-up notice so the + // user is never left staring at silence. + const outcome = + resolvedDeliveryRole == null + ? resolvePhantomFinalOutcome({ + chatJid, + text, + hasActiveWatcher: hasActiveCiWatcherForChat(chatJid), + canAutoContinue: true, + }) + : { kind: 'clean' as const, text }; + let delivered = false; try { - return await deps.deliverFinalText({ - text: guardedText, + delivered = await deps.deliverFinalText({ + text: outcome.text, ...(options?.attachments?.length ? { attachments: options.attachments } : {}), @@ -238,6 +255,32 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn { ); return false; } + if (outcome.kind === 'auto-continue' && delivered) { + try { + storeMessage({ + id: `phantom-continue-${runId}-${Date.now().toString(36)}`, + chat_jid: chatJid, + sender: 'ejclaw-system', + sender_name: 'EJClaw', + content: PHANTOM_CONTINUE_NUDGE, + timestamp: new Date().toISOString(), + is_from_me: false, + is_bot_message: false, + message_source_kind: 'ipc_injected_human', + }); + deps.queue.enqueueMessageCheck(chatJid, group.folder); + logger.info( + { group: group.name, chatJid, runId }, + 'Auto-continuing after phantom-notification dead-end final (no watcher registered)', + ); + } catch (err) { + logger.warn( + { group: group.name, chatJid, runId, err }, + 'Failed to schedule phantom-notification auto-continue', + ); + } + } + return delivered; }, }); diff --git a/src/phantom-notification-guard.test.ts b/src/phantom-notification-guard.test.ts index 227476f..f473e3d 100644 --- a/src/phantom-notification-guard.test.ts +++ b/src/phantom-notification-guard.test.ts @@ -1,9 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { applyPhantomNotificationGuard, isPhantomNotificationDeadEnd, PHANTOM_NOTIFICATION_NOTICE, + resolvePhantomFinalOutcome, + _resetPhantomAutoContinueForTests, } from './phantom-notification-guard.js'; describe('isPhantomNotificationDeadEnd', () => { @@ -16,9 +18,9 @@ describe('isPhantomNotificationDeadEnd', () => { }); it('detects close variants of the notification promise', () => { - expect(isPhantomNotificationDeadEnd('I will be notified when it finishes.')).toBe( - true, - ); + expect( + isPhantomNotificationDeadEnd('I will be notified when it finishes.'), + ).toBe(true); expect( isPhantomNotificationDeadEnd('Waiting for the deploy to finish now.'), ).toBe(true); @@ -74,3 +76,85 @@ describe('applyPhantomNotificationGuard', () => { expect(twice).toBe(once); }); }); + +describe('resolvePhantomFinalOutcome', () => { + const deadEnd = "I'll wait for the build to complete — I'll be notified."; + const chatJid = 'dc:test-room'; + + beforeEach(() => { + _resetPhantomAutoContinueForTests(); + }); + + it('auto-continues once, then falls back to the notice on a repeated dead-end', () => { + const first = resolvePhantomFinalOutcome({ + chatJid, + text: deadEnd, + hasActiveWatcher: false, + canAutoContinue: true, + }); + expect(first.kind).toBe('auto-continue'); + + // Second consecutive dead-end for the same chat hits the cap → notice, no + // further auto-continue (this is the loop guard). + const second = resolvePhantomFinalOutcome({ + chatJid, + text: deadEnd, + hasActiveWatcher: false, + canAutoContinue: true, + }); + expect(second.kind).toBe('notice'); + expect(second.text).toContain(PHANTOM_NOTIFICATION_NOTICE.trim()); + }); + + it('resets the streak after a clean final so a later dead-end can auto-continue again', () => { + expect( + resolvePhantomFinalOutcome({ + chatJid, + text: deadEnd, + hasActiveWatcher: false, + canAutoContinue: true, + }).kind, + ).toBe('auto-continue'); + + // A clean final resets the streak. + expect( + resolvePhantomFinalOutcome({ + chatJid, + text: 'TASK_DONE\n작업을 마쳤습니다.', + hasActiveWatcher: false, + canAutoContinue: true, + }).kind, + ).toBe('clean'); + + // Streak reset → next dead-end auto-continues again. + expect( + resolvePhantomFinalOutcome({ + chatJid, + text: deadEnd, + hasActiveWatcher: false, + canAutoContinue: true, + }).kind, + ).toBe('auto-continue'); + }); + + it('never auto-continues when a watcher is active (legitimate wait)', () => { + expect( + resolvePhantomFinalOutcome({ + chatJid, + text: deadEnd, + hasActiveWatcher: true, + canAutoContinue: true, + }).kind, + ).toBe('clean'); + }); + + it('emits the notice (never auto-continue) when auto-continue is disabled', () => { + const outcome = resolvePhantomFinalOutcome({ + chatJid, + text: deadEnd, + hasActiveWatcher: false, + canAutoContinue: false, + }); + expect(outcome.kind).toBe('notice'); + }); +}); diff --git a/src/phantom-notification-guard.ts b/src/phantom-notification-guard.ts index 0140193..afc77f4 100644 --- a/src/phantom-notification-guard.ts +++ b/src/phantom-notification-guard.ts @@ -51,3 +51,61 @@ export function applyPhantomNotificationGuard( if (text.includes(NOTICE_MARKER)) return text; return `${text}${PHANTOM_NOTIFICATION_NOTICE}`; } + +// ── Auto-continue after a phantom dead-end ──────────────────────────────── +// +// When a non-paired turn dead-ends on a phantom "I'll be notified" final with no +// watcher, instead of just leaving a notice we re-invoke the agent once so it +// actually finishes the work in the foreground. A hard per-chat cap makes this +// safe: at most MAX_PHANTOM_AUTO_CONTINUES re-runs per phantom streak, so a model +// that keeps producing the dead-end can never spin the bot in a loop. The streak +// resets as soon as any clean (non-phantom) final is produced. + +const MAX_PHANTOM_AUTO_CONTINUES = 1; +const phantomAutoContinueStreak = new Map(); + +/** Corrective nudge injected (as an inbound instruction) before the re-run. */ +export const PHANTOM_CONTINUE_NUDGE = + '[시스템 자동 안내] 방금 실제 감시 작업(watch_ci 등)을 등록하지 않은 채 "기다렸다가 알림을 받겠다"는 식으로 응답을 끝냈습니다. 이 환경에는 백그라운드 완료 알림이 없어, 그대로 두면 아무것도 다시 시작되지 않습니다. 지금 바로 포그라운드에서 작업을 끝까지 진행해 실제 결과를 보고하세요. 정말 오래 걸리는 작업이면 watch_ci/schedule_task로 감시를 등록한 뒤 그 사실을 알려주세요. 다시는 "알림을 받겠다"며 끝내지 마세요.'; + +export type PhantomFinalOutcome = + | { kind: 'clean'; text: string } + | { kind: 'auto-continue'; text: string } + | { kind: 'notice'; text: string }; + +/** + * Decide what to do with a final that may be a phantom dead-end. Pure except for + * the per-chat streak counter, which is the loop guard. + * + * - `clean`: not a dead-end (or a watcher is active) — deliver as-is; resets streak. + * - `auto-continue`: dead-end, under the cap — deliver as-is, and the caller must + * re-invoke the agent once with PHANTOM_CONTINUE_NUDGE. + * - `notice`: dead-end but the cap is exhausted (or auto-continue disabled) — + * deliver with the follow-up notice appended so the user isn't left hanging. + */ +export function resolvePhantomFinalOutcome(args: { + chatJid: string; + text: string; + hasActiveWatcher: boolean; + canAutoContinue: boolean; +}): PhantomFinalOutcome { + const { chatJid, text, hasActiveWatcher, canAutoContinue } = args; + if (hasActiveWatcher || !isPhantomNotificationDeadEnd(text)) { + phantomAutoContinueStreak.delete(chatJid); + return { kind: 'clean', text }; + } + const prior = phantomAutoContinueStreak.get(chatJid) ?? 0; + if (canAutoContinue && prior < MAX_PHANTOM_AUTO_CONTINUES) { + phantomAutoContinueStreak.set(chatJid, prior + 1); + return { kind: 'auto-continue', text }; + } + return { + kind: 'notice', + text: applyPhantomNotificationGuard(text, { hasActiveWatcher: false }), + }; +} + +/** Test-only: clear the per-chat auto-continue streak counters. */ +export function _resetPhantomAutoContinueForTests(): void { + phantomAutoContinueStreak.clear(); +}