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:
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user