Auto-continue after a phantom-notification dead-end instead of just warning

Extends the phantom-notification guard: when a non-paired turn dead-ends on an
"I'll wait … I'll be notified" final with no watcher registered, re-invoke the
agent once (inject a corrective nudge as an inbound instruction + re-enqueue) so
it actually finishes the work in the foreground, rather than only appending a
"send a follow-up" notice. A hard per-chat cap (max 1 re-run per phantom streak,
reset on any clean final) makes it loop-safe — a model that keeps producing the
dead-end can never spin the bot. Paired rooms are excluded because they already
continue on their own (owner → reviewer → …). Pure cap/decision logic is unit-
tested; reuses the proven ipc_injected_human + enqueueMessageCheck path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-08-25 23:05:29 +09:00
parent f5d1ec302d
commit 984922884c
3 changed files with 199 additions and 14 deletions

View File

@@ -1,6 +1,13 @@
import { type AgentOutput } from './agent-runner.js'; import { type AgentOutput } from './agent-runner.js';
import { getLastBotFinalMessage, hasActiveCiWatcherForChat } from './db.js'; import {
import { applyPhantomNotificationGuard } from './phantom-notification-guard.js'; getLastBotFinalMessage,
hasActiveCiWatcherForChat,
storeMessage,
} from './db.js';
import {
PHANTOM_CONTINUE_NUDGE,
resolvePhantomFinalOutcome,
} from './phantom-notification-guard.js';
import { runAgentForGroup } from './message-agent-executor.js'; import { runAgentForGroup } from './message-agent-executor.js';
import { MessageTurnController } from './message-turn-controller.js'; import { MessageTurnController } from './message-turn-controller.js';
import { import {
@@ -208,15 +215,25 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn {
getCloseReason: () => getCloseReason: () =>
deps.queue.getCloseReasonForRun?.(chatJid, runId) ?? null, deps.queue.getCloseReasonForRun?.(chatJid, runId) ?? null,
deliverFinalText: async (text, options) => { deliverFinalText: async (text, options) => {
// Never let a turn close on a phantom "I'll wait … I'll be notified" // Guard against phantom "I'll wait … I'll be notified" dead-end finals
// dead-end when no watcher is actually registered — annotate it so the // with no registered watcher. Paired flows already continue on their own
// user gets an actionable follow-up prompt instead of silence. // (owner → reviewer → …), so this only applies to non-paired turns:
const guardedText = applyPhantomNotificationGuard(text, { // re-invoke the agent once to actually finish (capped, no loop), and if
hasActiveWatcher: hasActiveCiWatcherForChat(chatJid), // 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 { try {
return await deps.deliverFinalText({ delivered = await deps.deliverFinalText({
text: guardedText, text: outcome.text,
...(options?.attachments?.length ...(options?.attachments?.length
? { attachments: options.attachments } ? { attachments: options.attachments }
: {}), : {}),
@@ -238,6 +255,32 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn {
); );
return false; 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;
}, },
}); });

View File

@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { import {
applyPhantomNotificationGuard, applyPhantomNotificationGuard,
isPhantomNotificationDeadEnd, isPhantomNotificationDeadEnd,
PHANTOM_NOTIFICATION_NOTICE, PHANTOM_NOTIFICATION_NOTICE,
resolvePhantomFinalOutcome,
_resetPhantomAutoContinueForTests,
} from './phantom-notification-guard.js'; } from './phantom-notification-guard.js';
describe('isPhantomNotificationDeadEnd', () => { describe('isPhantomNotificationDeadEnd', () => {
@@ -16,9 +18,9 @@ describe('isPhantomNotificationDeadEnd', () => {
}); });
it('detects close variants of the notification promise', () => { it('detects close variants of the notification promise', () => {
expect(isPhantomNotificationDeadEnd('I will be notified when it finishes.')).toBe( expect(
true, isPhantomNotificationDeadEnd('I will be notified when it finishes.'),
); ).toBe(true);
expect( expect(
isPhantomNotificationDeadEnd('Waiting for the deploy to finish now.'), isPhantomNotificationDeadEnd('Waiting for the deploy to finish now.'),
).toBe(true); ).toBe(true);
@@ -74,3 +76,85 @@ describe('applyPhantomNotificationGuard', () => {
expect(twice).toBe(once); 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');
});
});

View File

@@ -51,3 +51,61 @@ export function applyPhantomNotificationGuard(
if (text.includes(NOTICE_MARKER)) return text; if (text.includes(NOTICE_MARKER)) return text;
return `${text}${PHANTOM_NOTIFICATION_NOTICE}`; 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<string, number>();
/** 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();
}