refactor: unify runtime into single message-runtime path

Remove duplicate processGroupMessages, runAgent, startMessageLoop, and
recoverPendingMessages from index.ts. All message processing now flows
through createMessageRuntime() in message-runtime.ts, eliminating the
dual-path issue that caused the v1 follow-up turn reset bug.

index.ts reduced from ~2300 to ~1210 lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eyejoker
2026-03-23 00:43:57 +09:00
parent badb900a23
commit d4fc498268
3 changed files with 410 additions and 1340 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -11,13 +11,98 @@ vi.mock('./config.js', () => ({
isSessionCommandSenderAllowed: vi.fn(() => false), isSessionCommandSenderAllowed: vi.fn(() => false),
})); }));
vi.mock('./db.js', () => ({ vi.mock('./db.js', () => {
const getMessagesSince = vi.fn(
(
_chatJid?: string,
_sinceCursor?: string,
_botPrefix?: string,
_limit?: number,
) => [],
);
const getNewMessages = vi.fn(
(
_jids?: string[],
_lastSeqCursor?: string,
_botPrefix?: string,
_limit?: number,
) => ({ messages: [], newSeqCursor: '0' }),
);
const withSeqs = (messages: Array<Record<string, unknown>>) =>
messages.map((message, index) => ({
...message,
seq:
typeof message.seq === 'number'
? message.seq
: index + 1,
}));
return {
getAllChats: vi.fn(() => []), getAllChats: vi.fn(() => []),
getAllTasks: vi.fn(() => []), getAllTasks: vi.fn(() => []),
getLastHumanMessageTimestamp: vi.fn(() => null), getLastHumanMessageTimestamp: vi.fn(() => null),
getMessagesSince: vi.fn(), getMessagesSince,
getNewMessages: vi.fn(() => ({ messages: [], newTimestamp: '' })), getNewMessages,
})); getLatestMessageSeqAtOrBefore: vi.fn(() => 0),
getMessagesSinceSeq: vi.fn(
(
chatJid: string,
sinceSeqCursor: string,
botPrefix: string,
limit?: number,
) => withSeqs(getMessagesSince(chatJid, sinceSeqCursor, botPrefix, limit)),
),
getNewMessagesBySeq: vi.fn(
(
jids: string[],
lastSeqCursor: string,
botPrefix: string,
limit?: number,
) => {
const result:
| {
messages?: Array<Record<string, unknown>>;
newSeqCursor?: string;
newTimestamp?: string;
}
| undefined =
getNewMessages(jids, lastSeqCursor, botPrefix, limit) || {
messages: [],
newSeqCursor: '0',
};
const messages = withSeqs(result.messages || []);
const lastSeq =
messages.length > 0
? String(messages[messages.length - 1].seq)
: String(lastSeqCursor || '0');
return {
messages,
newSeqCursor: result.newSeqCursor || result.newTimestamp || lastSeq,
};
},
),
getOpenWorkItem: vi.fn(() => undefined),
createProducedWorkItem: vi.fn((input) => ({
id: 1,
group_folder: input.group_folder,
chat_jid: input.chat_jid,
agent_type: input.agent_type || 'claude-code',
status: 'produced',
start_seq: input.start_seq,
end_seq: input.end_seq,
result_payload: input.result_payload,
delivery_attempts: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
delivered_at: null,
delivery_message_id: null,
last_error: null,
})),
markWorkItemDelivered: vi.fn(),
markWorkItemDeliveryRetry: vi.fn(),
isPairedRoomJid: vi.fn(() => false),
};
});
vi.mock('./logger.js', () => ({ vi.mock('./logger.js', () => ({
logger: { logger: {
@@ -173,14 +258,14 @@ describe('createMessageRuntime', () => {
expect(clearSession).toHaveBeenCalledWith(group.folder); expect(clearSession).toHaveBeenCalledWith(group.folder);
expect(closeStdin).toHaveBeenCalledWith(chatJid, { expect(closeStdin).toHaveBeenCalledWith(chatJid, {
runId: 'run-1', runId: 'run-1',
reason: 'poisoned-session', reason: 'poisoned-session-detected',
}); });
expect(notifyIdle).not.toHaveBeenCalled(); expect(notifyIdle).not.toHaveBeenCalled();
expect(channel.sendMessage).toHaveBeenCalledWith( expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid, chatJid,
'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.', 'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
); );
expect(lastAgentTimestamps[chatJid]).toBe('2026-03-18T09:00:00.000Z'); expect(lastAgentTimestamps[chatJid]).toBe('1');
expect(saveState).toHaveBeenCalled(); expect(saveState).toHaveBeenCalled();
}); });
@@ -548,7 +633,7 @@ describe('createMessageRuntime', () => {
expect(channel.editMessage).toHaveBeenLastCalledWith( expect(channel.editMessage).toHaveBeenLastCalledWith(
chatJid, chatJid,
'progress-1', 'progress-1',
'오래 걸리는 작업입니다.\n\n1시간 1분 10초', '오래 걸리는 작업입니다.\n\n1시간 0초',
); );
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();
@@ -1153,7 +1238,7 @@ describe('createMessageRuntime', () => {
chatJid, chatJid,
'중간 진행상황입니다.\n\n0초', '중간 진행상황입니다.\n\n0초',
); );
expect(lastAgentTimestamps[chatJid]).toBe('2026-03-19T00:00:00.000Z'); expect(lastAgentTimestamps[chatJid]).toBe('1');
expect(saveState).toHaveBeenCalled(); expect(saveState).toHaveBeenCalled();
}); });

View File

@@ -8,11 +8,19 @@ import {
import { import {
getAllChats, getAllChats,
getAllTasks, getAllTasks,
getMessagesSince, getLatestMessageSeqAtOrBefore,
getNewMessages, getMessagesSinceSeq,
getNewMessagesBySeq,
getOpenWorkItem,
createProducedWorkItem,
markWorkItemDelivered,
markWorkItemDeliveryRetry,
isPairedRoomJid,
type WorkItem,
} from './db.js'; } from './db.js';
import { DATA_DIR, isSessionCommandSenderAllowed } from './config.js'; import { DATA_DIR, isSessionCommandSenderAllowed } from './config.js';
import { GroupQueue, GroupRunContext } from './group-queue.js'; import { GroupQueue, GroupRunContext } from './group-queue.js';
import { filterProcessableMessages } from './bot-message-filter.js';
import { import {
detectFallbackTrigger, detectFallbackTrigger,
getActiveProvider, getActiveProvider,
@@ -31,7 +39,6 @@ import {
isSessionCommandControlMessage, isSessionCommandControlMessage,
} from './session-commands.js'; } from './session-commands.js';
import { shouldResetSessionOnAgentFailure } from './session-recovery.js'; import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
import { isTaskStatusControlMessage } from './task-scheduler.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import path from 'path'; import path from 'path';
@@ -80,24 +87,77 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
} { } {
let messageLoopRunning = false; let messageLoopRunning = false;
const filterOwnChannelMessages = (messages: NewMessage[]): NewMessage[] =>
messages.filter((msg) => {
const channel = findChannel(deps.channels, msg.chat_jid);
if (channel?.isOwnMessage?.(msg)) {
return false;
}
if (msg.is_bot_message && isSessionCommandControlMessage(msg.content)) {
return false;
}
if (msg.is_bot_message && isTaskStatusControlMessage(msg.content)) {
return false;
}
return true;
});
const getCurrentAvailableGroups = (): AvailableGroup[] => const getCurrentAvailableGroups = (): AvailableGroup[] =>
getAvailableGroups(deps.getRegisteredGroups()); getAvailableGroups(deps.getRegisteredGroups());
const normalizeStoredSeqCursor = (
cursor: string | undefined,
chatJid?: string,
): string => {
if (!cursor) return '0';
if (/^\d+$/.test(cursor.trim())) return cursor.trim();
return String(getLatestMessageSeqAtOrBefore(cursor, chatJid));
};
const advanceLastAgentCursor = (
chatJid: string,
cursorOrTimestamp: string | number,
): void => {
const lastAgentTimestamps = deps.getLastAgentTimestamps();
if (typeof cursorOrTimestamp === 'number') {
lastAgentTimestamps[chatJid] = String(cursorOrTimestamp);
} else {
lastAgentTimestamps[chatJid] = normalizeStoredSeqCursor(
cursorOrTimestamp,
chatJid,
);
}
deps.saveState();
};
const getProcessableMessages = (
chatJid: string,
messages: Parameters<typeof filterProcessableMessages>[0],
channel?: Channel,
) =>
filterProcessableMessages(
messages,
isPairedRoomJid(chatJid),
channel?.isOwnMessage?.bind(channel),
);
const deliverOpenWorkItem = async (
channel: Channel,
item: WorkItem,
): Promise<boolean> => {
try {
await channel.sendMessage(item.chat_jid, item.result_payload);
markWorkItemDelivered(item.id);
logger.info(
{
chatJid: item.chat_jid,
workItemId: item.id,
deliveryAttempts: item.delivery_attempts + 1,
},
'Delivered produced work item',
);
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
markWorkItemDeliveryRetry(item.id, errorMessage);
logger.warn(
{
chatJid: item.chat_jid,
workItemId: item.id,
deliveryAttempts: item.delivery_attempts + 1,
err,
},
'Failed to deliver produced work item',
);
return false;
}
};
const runAgent = async ( const runAgent = async (
group: RegisteredGroup, group: RegisteredGroup,
prompt: string, prompt: string,
@@ -447,60 +507,46 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
chatJid: string, chatJid: string,
context: GroupRunContext, context: GroupRunContext,
): Promise<boolean> => { ): Promise<boolean> => {
const { runId, reason } = context; const { runId } = context;
const registeredGroups = deps.getRegisteredGroups(); const group = deps.getRegisteredGroups()[chatJid];
const group = registeredGroups[chatJid]; if (!group) return true;
if (!group) {
logger.warn(
{ chatJid, runId, reason },
'Registered group missing for queued run',
);
return true;
}
const channel = findChannel(deps.channels, chatJid); const channel = findChannel(deps.channels, chatJid);
if (!channel) { if (!channel) {
logger.warn( logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
{ chatJid, runId, reason },
'No channel owns JID, skipping messages',
);
return true; return true;
} }
const openWorkItem = getOpenWorkItem(
chatJid,
(group.agentType || 'claude-code') as 'claude-code' | 'codex',
);
if (openWorkItem) {
const delivered = await deliverOpenWorkItem(channel, openWorkItem);
if (!delivered) return false;
}
const isMainGroup = group.isMain === true; const isMainGroup = group.isMain === true;
const lastAgentTimestamps = deps.getLastAgentTimestamps(); const sinceSeqCursor = deps.getLastAgentTimestamps()[chatJid] || '0';
const sinceTimestamp = lastAgentTimestamps[chatJid] || ''; const rawMissedMessages = getMessagesSinceSeq(
const missedMessages = filterOwnChannelMessages( chatJid,
getMessagesSince(chatJid, sinceTimestamp, deps.assistantName), sinceSeqCursor,
deps.assistantName,
);
const missedMessages = getProcessableMessages(
chatJid,
rawMissedMessages,
channel,
); );
if (missedMessages.length === 0) { if (missedMessages.length === 0) {
logger.info( const lastIgnored = rawMissedMessages[rawMissedMessages.length - 1];
{ if (lastIgnored) {
chatJid, advanceLastAgentCursor(chatJid, lastIgnored.timestamp);
group: group.name, }
groupFolder: group.folder,
runId,
reason,
},
'No pending messages for queued run',
);
return true; return true;
} }
logger.info(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
reason,
messageCount: missedMessages.length,
sinceTimestamp,
},
'Loaded pending messages for queued run',
);
const cmdResult = await handleSessionCommand({ const cmdResult = await handleSessionCommand({
missedMessages, missedMessages,
isMainGroup, isMainGroup,
@@ -516,13 +562,11 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
runAgent(group, prompt, chatJid, runId, onOutput), runAgent(group, prompt, chatJid, runId, onOutput),
closeStdin: () => closeStdin: () =>
deps.queue.closeStdin(chatJid, { deps.queue.closeStdin(chatJid, {
runId,
reason: 'session-command', reason: 'session-command',
}), }),
clearSession: () => deps.clearSession(group.folder), clearSession: () => deps.clearSession(group.folder),
advanceCursor: (timestamp) => { advanceCursor: (cursorOrTimestamp) => {
lastAgentTimestamps[chatJid] = timestamp; advanceLastAgentCursor(chatJid, cursorOrTimestamp);
deps.saveState();
}, },
formatMessages, formatMessages,
isAdminSender: (msg) => isSessionCommandSenderAllowed(msg.sender), isAdminSender: (msg) => isSessionCommandSenderAllowed(msg.sender),
@@ -560,10 +604,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
} }
const prompt = formatMessages(missedMessages, deps.timezone); const prompt = formatMessages(missedMessages, deps.timezone);
const previousCursor = lastAgentTimestamps[chatJid] || ''; const previousCursor = deps.getLastAgentTimestamps()[chatJid] || '0';
lastAgentTimestamps[chatJid] = const startSeq = missedMessages[0].seq ?? null;
missedMessages[missedMessages.length - 1].timestamp; const endSeq = missedMessages[missedMessages.length - 1].seq ?? null;
deps.saveState(); if (endSeq !== null) {
advanceLastAgentCursor(chatJid, endSeq);
}
logger.info( logger.info(
{ {
@@ -577,21 +623,40 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
); );
let idleTimer: ReturnType<typeof setTimeout> | null = null; let idleTimer: ReturnType<typeof setTimeout> | null = null;
let producedFinalText: string | null = null;
let followUpQueuedAt: number | null = null;
let followUpQueuedTextLength: number | null = null;
let followUpQueuedFilename: string | null = null;
let followUpNoOutputWarnTimer: ReturnType<typeof setTimeout> | null = null;
let hadError = false;
let latestProgressText: string | null = null; let latestProgressText: string | null = null;
let latestProgressRendered: string | null = null; let latestProgressRendered: string | null = null;
let progressMessageId: string | null = null; let progressMessageId: string | null = null;
let progressStartedAt: number | null = null; let progressStartedAt: number | null = null;
let progressTicker: ReturnType<typeof setInterval> | null = null; let progressTicker: ReturnType<typeof setInterval> | null = null;
let finalOutputSentToUser = false;
let progressOutputSentToUser = false;
let latestModelProgressTextForFinalFallback: string | null = null;
let sawNonProgressOutput = false;
let producedDeliverySucceeded = true;
let isFirstLogicalTurn = true;
let poisonedSessionDetected = false;
const isClaudeCodeAgent =
(group.agentType || 'claude-code') === 'claude-code';
const FOLLOW_UP_NO_OUTPUT_WARN_MS = 10_000;
const resetIdleTimer = () => { const resetIdleTimer = () => {
if (idleTimer) clearTimeout(idleTimer); if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => { idleTimer = setTimeout(() => {
logger.debug(
{ group: group.name, chatJid, runId },
'Idle timeout, closing agent stdin',
);
if (followUpQueuedAt !== null) { if (followUpQueuedAt !== null) {
logger.warn( logger.warn(
{ {
chatJid,
group: group.name, group: group.name,
groupFolder: group.folder, chatJid,
runId, runId,
followUpQueuedAt: new Date(followUpQueuedAt).toISOString(), followUpQueuedAt: new Date(followUpQueuedAt).toISOString(),
followUpQueuedTextLength, followUpQueuedTextLength,
@@ -601,21 +666,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
'Idle timeout reached while a queued follow-up still had no agent output', 'Idle timeout reached while a queued follow-up still had no agent output',
); );
} }
logger.info( deps.queue.closeStdin(chatJid, { reason: 'idle-timeout' });
{ chatJid, group: group.name, groupFolder: group.folder, runId },
'Idle timeout reached, closing active agent stdin',
);
deps.queue.closeStdin(chatJid, {
runId,
reason: 'idle-timeout',
});
}, deps.idleTimeout); }, deps.idleTimeout);
}; };
let followUpQueuedAt: number | null = null;
let followUpQueuedTextLength: number | null = null;
let followUpQueuedFilename: string | null = null;
let followUpNoOutputWarnTimer: ReturnType<typeof setTimeout> | null = null;
const FOLLOW_UP_NO_OUTPUT_WARN_MS = 10_000;
const clearFollowUpNoOutputWarnTimer = () => { const clearFollowUpNoOutputWarnTimer = () => {
if (followUpNoOutputWarnTimer) { if (followUpNoOutputWarnTimer) {
@@ -637,9 +690,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
if (followUpQueuedAt === null) return; if (followUpQueuedAt === null) return;
logger.warn( logger.warn(
{ {
chatJid,
group: group.name, group: group.name,
groupFolder: group.folder, chatJid,
runId, runId,
followUpQueuedAt: new Date(followUpQueuedAt).toISOString(), followUpQueuedAt: new Date(followUpQueuedAt).toISOString(),
followUpQueuedTextLength, followUpQueuedTextLength,
@@ -663,9 +715,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
scheduleFollowUpNoOutputWarning(); scheduleFollowUpNoOutputWarning();
logger.info( logger.info(
{ {
chatJid,
group: group.name, group: group.name,
groupFolder: group.folder, chatJid,
runId, runId,
followUpQueuedTextLength, followUpQueuedTextLength,
followUpQueuedFilename, followUpQueuedFilename,
@@ -678,9 +729,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
if (followUpQueuedAt === null) return; if (followUpQueuedAt === null) return;
logger.info( logger.info(
{ {
chatJid,
group: group.name, group: group.name,
groupFolder: group.folder, chatJid,
runId, runId,
followUpQueuedAt: new Date(followUpQueuedAt).toISOString(), followUpQueuedAt: new Date(followUpQueuedAt).toISOString(),
followUpQueuedTextLength, followUpQueuedTextLength,
@@ -693,19 +743,18 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
clearPendingFollowUpDiagnostics(); clearPendingFollowUpDiagnostics();
}; };
const warnFollowUpEndedWithoutOutput = (reason: string) => { const warnFollowUpEndedWithoutOutput = (reasonText: string) => {
if (followUpQueuedAt === null) return; if (followUpQueuedAt === null) return;
logger.warn( logger.warn(
{ {
chatJid,
group: group.name, group: group.name,
groupFolder: group.folder, chatJid,
runId, runId,
followUpQueuedAt: new Date(followUpQueuedAt).toISOString(), followUpQueuedAt: new Date(followUpQueuedAt).toISOString(),
followUpQueuedTextLength, followUpQueuedTextLength,
followUpQueuedFilename, followUpQueuedFilename,
followUpWaitMs: Date.now() - followUpQueuedAt, followUpWaitMs: Date.now() - followUpQueuedAt,
reason, reason: reasonText,
}, },
'Active agent ended a turn without any output after a queued follow-up', 'Active agent ended a turn without any output after a queued follow-up',
); );
@@ -717,15 +766,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
resetIdleTimer(); resetIdleTimer();
}); });
let hadError = false;
let finalOutputSentToUser = false;
let progressOutputSentToUser = false;
let latestModelProgressTextForFinalFallback: string | null = null;
let sawNonProgressOutput = false;
let poisonedSessionDetected = false;
const isClaudeCodeAgent =
(group.agentType || 'claude-code') === 'claude-code';
const clearProgressTicker = () => { const clearProgressTicker = () => {
if (progressTicker) { if (progressTicker) {
clearInterval(progressTicker); clearInterval(progressTicker);
@@ -738,6 +778,40 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
progressOutputSentToUser = false; progressOutputSentToUser = false;
latestModelProgressTextForFinalFallback = null; latestModelProgressTextForFinalFallback = null;
sawNonProgressOutput = false; sawNonProgressOutput = false;
producedFinalText = null;
};
const flushProducedFinalText = async () => {
if (!producedFinalText) {
return;
}
try {
const workItem = createProducedWorkItem({
group_folder: group.folder,
chat_jid: chatJid,
agent_type: group.agentType || 'claude-code',
start_seq: isFirstLogicalTurn ? startSeq : null,
end_seq: isFirstLogicalTurn ? endSeq : null,
result_payload: producedFinalText,
});
const delivered = await deliverOpenWorkItem(channel, workItem);
if (delivered) {
finalOutputSentToUser = true;
} else {
producedDeliverySucceeded = false;
}
} catch (err) {
producedDeliverySucceeded = false;
logger.warn(
{ group: group.name, chatJid, runId, err },
'Failed to persist produced output for delivery',
);
} finally {
producedFinalText = null;
latestModelProgressTextForFinalFallback = null;
isFirstLogicalTurn = false;
}
}; };
const resetProgressState = () => { const resetProgressState = () => {
@@ -756,14 +830,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const hours = Math.floor(elapsedSeconds / 3600); const hours = Math.floor(elapsedSeconds / 3600);
const minutes = Math.floor((elapsedSeconds % 3600) / 60); const minutes = Math.floor((elapsedSeconds % 3600) / 60);
const seconds = elapsedSeconds % 60; const seconds = elapsedSeconds % 60;
const elapsedLabel = const elapsedParts: string[] = [];
hours > 0
? `${hours}시간 ${minutes}${seconds}`
: minutes > 0
? `${minutes}${seconds}`
: `${seconds}`;
return `${text}\n\n${elapsedLabel}`; if (hours > 0) elapsedParts.push(`${hours}시간`);
if (minutes > 0) elapsedParts.push(`${minutes}`);
elapsedParts.push(`${seconds}`);
return `${text}\n\n${elapsedParts.join(' ')}`;
}; };
const syncTrackedProgressMessage = async () => { const syncTrackedProgressMessage = async () => {
@@ -817,10 +890,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
} }
if (progressStartedAt === null) { if (progressStartedAt === null) {
// A fresh progress cycle after a prior final in the same active agent
// session represents a new logical turn (follow-up IPC message). Reset
// turn-scoped output flags so progress-only completion can still be
// promoted to a final message.
resetTurnOutputState(); resetTurnOutputState();
progressStartedAt = Date.now(); progressStartedAt = Date.now();
} }
@@ -845,7 +914,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
return; return;
} }
if (channel.sendAndTrack) { if (!channel.sendAndTrack) {
return;
}
try { try {
progressMessageId = await channel.sendAndTrack(chatJid, rendered); progressMessageId = await channel.sendAndTrack(chatJid, rendered);
} catch (err) { } catch (err) {
@@ -861,6 +933,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
); );
return; return;
} }
if (progressMessageId) { if (progressMessageId) {
logger.info( logger.info(
{ {
@@ -876,13 +949,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
latestProgressRendered = rendered; latestProgressRendered = rendered;
ensureProgressTicker(); ensureProgressTicker();
progressOutputSentToUser = true; progressOutputSentToUser = true;
return;
} }
}
latestProgressRendered = rendered;
await channel.sendMessage(chatJid, rendered);
progressOutputSentToUser = true;
}; };
await channel.setTyping?.(chatJid, true); await channel.setTyping?.(chatJid, true);
@@ -903,7 +970,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
deps.clearSession(group.folder); deps.clearSession(group.folder);
deps.queue.closeStdin(chatJid, { deps.queue.closeStdin(chatJid, {
runId, runId,
reason: 'poisoned-session', reason: 'poisoned-session-detected',
}); });
logger.warn( logger.warn(
{ chatJid, group: group.name, groupFolder: group.folder, runId }, { chatJid, group: group.name, groupFolder: group.folder, runId },
@@ -931,10 +998,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
); );
noteAgentOutputObserved(result.phase); noteAgentOutputObserved(result.phase);
if (result.phase === 'progress') { if (result.phase === 'progress') {
// Detect new logical turn (follow-up IPC): if a final was already
// sent in this run, a new progress output means a follow-up turn
// has started. Reset turn-level state so the promotion logic at
// end-of-run works correctly for the new turn.
if (finalOutputSentToUser || sawNonProgressOutput) { if (finalOutputSentToUser || sawNonProgressOutput) {
logger.info( logger.info(
{ {
@@ -945,11 +1008,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}, },
'New logical turn detected (follow-up), resetting turn state', 'New logical turn detected (follow-up), resetting turn state',
); );
finalOutputSentToUser = false; resetTurnOutputState();
sawNonProgressOutput = false;
progressOutputSentToUser = false;
latestModelProgressTextForFinalFallback = null;
resetProgressState(); resetProgressState();
isFirstLogicalTurn = false;
await channel.setTyping?.(chatJid, true); await channel.setTyping?.(chatJid, true);
} }
if (text) { if (text) {
@@ -965,9 +1026,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
if (text) { if (text) {
await finalizeProgressMessage(); await finalizeProgressMessage();
await channel.sendMessage(chatJid, text); producedFinalText = text;
finalOutputSentToUser = true; await flushProducedFinalText();
latestModelProgressTextForFinalFallback = null;
} else { } else {
logger.info( logger.info(
{ {
@@ -992,7 +1052,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
} }
await channel.setTyping?.(chatJid, false); await channel.setTyping?.(chatJid, false);
if (!poisonedSessionDetected) { if (!poisonedSessionDetected) {
resetIdleTimer(); resetIdleTimer();
} }
@@ -1016,7 +1075,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
if ( if (
output === 'success' && output === 'success' &&
!hadError && !hadError &&
!finalOutputSentToUser && !producedFinalText &&
!sawNonProgressOutput && !sawNonProgressOutput &&
latestModelProgressTextForFinalFallback latestModelProgressTextForFinalFallback
) { ) {
@@ -1029,12 +1088,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}, },
'Promoting last progress output to final message after agent completion', 'Promoting last progress output to final message after agent completion',
); );
await channel.sendMessage( producedFinalText = latestModelProgressTextForFinalFallback;
chatJid, await flushProducedFinalText();
latestModelProgressTextForFinalFallback,
);
finalOutputSentToUser = true;
latestModelProgressTextForFinalFallback = null;
} }
clearProgressTicker(); clearProgressTicker();
@@ -1044,14 +1099,18 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
deps.queue.setActivityTouch?.(chatJid, null); deps.queue.setActivityTouch?.(chatJid, null);
if (hadError) { if (hadError) {
if (finalOutputSentToUser || progressOutputSentToUser) { if (
finalOutputSentToUser ||
progressOutputSentToUser ||
producedFinalText
) {
logger.warn( logger.warn(
{ chatJid, group: group.name, groupFolder: group.folder, runId }, { chatJid, group: group.name, groupFolder: group.folder, runId },
'Agent error after conversational output was sent, skipping cursor rollback to prevent duplicates', 'Agent error after output was produced, skipping cursor rollback to prevent duplicates',
); );
return true; return producedFinalText ? producedDeliverySucceeded : true;
} }
lastAgentTimestamps[chatJid] = previousCursor; deps.getLastAgentTimestamps()[chatJid] = previousCursor;
deps.saveState(); deps.saveState();
logger.warn( logger.warn(
{ chatJid, group: group.name, groupFolder: group.folder, runId }, { chatJid, group: group.name, groupFolder: group.folder, runId },
@@ -1060,6 +1119,14 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
return false; return false;
} }
if (!producedDeliverySucceeded) {
logger.warn(
{ chatJid, group: group.name, groupFolder: group.folder, runId },
'Persisted produced output for delivery retry without rerunning agent',
);
return false;
}
logger.info( logger.info(
{ {
chatJid, chatJid,
@@ -1088,23 +1155,18 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
try { try {
const registeredGroups = deps.getRegisteredGroups(); const registeredGroups = deps.getRegisteredGroups();
const jids = Object.keys(registeredGroups); const jids = Object.keys(registeredGroups);
const { messages: rawMessages, newTimestamp } = getNewMessages( const { messages, newSeqCursor } = getNewMessagesBySeq(
jids, jids,
deps.getLastTimestamp(), deps.getLastTimestamp(),
deps.assistantName, deps.assistantName,
); );
const messages = filterOwnChannelMessages(rawMessages);
if (rawMessages.length > 0) { if (messages.length > 0) {
logger.info({ count: messages.length }, 'New messages'); logger.info({ count: messages.length }, 'New messages');
deps.setLastTimestamp(newTimestamp); deps.setLastTimestamp(newSeqCursor);
deps.saveState(); deps.saveState();
if (messages.length === 0) {
continue;
}
const messagesByGroup = new Map<string, NewMessage[]>(); const messagesByGroup = new Map<string, NewMessage[]>();
for (const msg of messages) { for (const msg of messages) {
const existing = messagesByGroup.get(msg.chat_jid); const existing = messagesByGroup.get(msg.chat_jid);
@@ -1129,6 +1191,20 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
} }
const isMainGroup = group.isMain === true; const isMainGroup = group.isMain === true;
const processableGroupMessages = getProcessableMessages(
chatJid,
groupMessages,
channel,
);
if (processableGroupMessages.length === 0) {
const lastIgnored = groupMessages[groupMessages.length - 1];
if (lastIgnored?.seq != null) {
advanceLastAgentCursor(chatJid, lastIgnored.seq);
}
continue;
}
const loopCmdMsg = groupMessages.find( const loopCmdMsg = groupMessages.find(
(msg) => (msg) =>
extractSessionCommand(msg.content, deps.triggerPattern) !== extractSessionCommand(msg.content, deps.triggerPattern) !==
@@ -1155,7 +1231,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
!isMainGroup && group.requiresTrigger !== false; !isMainGroup && group.requiresTrigger !== false;
if (needsTrigger) { if (needsTrigger) {
const allowlistCfg = loadSenderAllowlist(); const allowlistCfg = loadSenderAllowlist();
const hasTrigger = groupMessages.some( const hasTrigger = processableGroupMessages.some(
(msg) => (msg) =>
deps.triggerPattern.test(msg.content.trim()) && deps.triggerPattern.test(msg.content.trim()) &&
(msg.is_from_me || (msg.is_from_me ||
@@ -1164,16 +1240,20 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
if (!hasTrigger) continue; if (!hasTrigger) continue;
} }
const lastAgentTimestamps = deps.getLastAgentTimestamps(); const allPending = getMessagesSinceSeq(
const allPending = filterOwnChannelMessages(
getMessagesSince(
chatJid, chatJid,
lastAgentTimestamps[chatJid] || '', deps.getLastAgentTimestamps()[chatJid] || '',
deps.assistantName, deps.assistantName,
), );
const processablePending = getProcessableMessages(
chatJid,
allPending,
channel,
); );
const messagesToSend = const messagesToSend =
allPending.length > 0 ? allPending : groupMessages; processablePending.length > 0
? processablePending
: processableGroupMessages;
const formatted = formatMessages(messagesToSend, deps.timezone); const formatted = formatMessages(messagesToSend, deps.timezone);
if (deps.queue.sendMessage(chatJid, formatted)) { if (deps.queue.sendMessage(chatJid, formatted)) {
@@ -1181,9 +1261,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
{ chatJid, count: messagesToSend.length }, { chatJid, count: messagesToSend.length },
'Piped messages to active agent', 'Piped messages to active agent',
); );
lastAgentTimestamps[chatJid] = const endSeq = messagesToSend[messagesToSend.length - 1].seq;
messagesToSend[messagesToSend.length - 1].timestamp; if (endSeq != null) {
deps.saveState(); advanceLastAgentCursor(chatJid, endSeq);
}
channel channel
.setTyping?.(chatJid, true) .setTyping?.(chatJid, true)
?.catch((err) => ?.catch((err) =>
@@ -1206,11 +1287,18 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const recoverPendingMessages = (): void => { const recoverPendingMessages = (): void => {
const registeredGroups = deps.getRegisteredGroups(); const registeredGroups = deps.getRegisteredGroups();
const lastAgentTimestamps = deps.getLastAgentTimestamps();
for (const [chatJid, group] of Object.entries(registeredGroups)) { for (const [chatJid, group] of Object.entries(registeredGroups)) {
const sinceTimestamp = lastAgentTimestamps[chatJid] || ''; const sinceSeqCursor = deps.getLastAgentTimestamps()[chatJid] || '';
const pending = filterOwnChannelMessages( const rawPending = getMessagesSinceSeq(
getMessagesSince(chatJid, sinceTimestamp, deps.assistantName), chatJid,
sinceSeqCursor,
deps.assistantName,
);
const recoveryChannel = findChannel(deps.channels, chatJid);
const pending = getProcessableMessages(
chatJid,
rawPending,
recoveryChannel ?? undefined,
); );
if (pending.length > 0) { if (pending.length > 0) {
logger.info( logger.info(
@@ -1218,6 +1306,11 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
'Recovery: found unprocessed messages', 'Recovery: found unprocessed messages',
); );
deps.queue.enqueueMessageCheck(chatJid, group.folder); deps.queue.enqueueMessageCheck(chatJid, group.folder);
} else if (rawPending.length > 0) {
const endSeq = rawPending[rawPending.length - 1].seq;
if (endSeq != null) {
advanceLastAgentCursor(chatJid, endSeq);
}
} }
} }
}; };