refactor: consolidate runtime helpers

This commit is contained in:
Eyejoker
2026-03-23 06:39:26 +09:00
parent c733217074
commit 898c77fcfc
15 changed files with 808 additions and 608 deletions

View File

@@ -1,13 +1,5 @@
import { AgentOutput } from './agent-runner.js';
import {
AgentOutput,
AvailableGroup,
runAgentProcess,
writeGroupsSnapshot,
writeTasksSnapshot,
} from './agent-runner.js';
import {
getAllChats,
getAllTasks,
getMessagesSinceSeq,
getNewMessagesBySeq,
getOpenWorkItem,
@@ -16,17 +8,8 @@ import {
markWorkItemDeliveryRetry,
type WorkItem,
} from './db.js';
import { DATA_DIR, isSessionCommandSenderAllowed } from './config.js';
import { isSessionCommandSenderAllowed } from './config.js';
import { GroupQueue, GroupRunContext } from './group-queue.js';
import {
detectFallbackTrigger,
getActiveProvider,
getFallbackEnvOverrides,
getFallbackProviderName,
hasGroupProviderOverride,
isFallbackEnabled,
markPrimaryCooldown,
} from './provider-fallback.js';
import { findChannel, formatMessages } from './router.js';
import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
import {
@@ -37,6 +20,7 @@ import {
hasAllowedTrigger,
shouldSkipBotOnlyCollaboration,
} from './message-runtime-rules.js';
import { runAgentForGroup } from './message-agent-executor.js';
import { MessageTurnController } from './message-turn-controller.js';
import {
extractSessionCommand,
@@ -44,10 +28,8 @@ import {
isSessionCommandAllowed,
isSessionCommandControlMessage,
} from './session-commands.js';
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
import path from 'path';
export interface MessageRuntimeDeps {
assistantName: string;
@@ -67,22 +49,6 @@ export interface MessageRuntimeDeps {
clearSession: (groupFolder: string) => void;
}
export function getAvailableGroups(
registeredGroups: Record<string, RegisteredGroup>,
): AvailableGroup[] {
const chats = getAllChats();
const registeredJids = new Set(Object.keys(registeredGroups));
return chats
.filter((chat) => chat.jid !== '__group_sync__' && chat.is_group)
.map((chat) => ({
jid: chat.jid,
name: chat.name,
lastActivity: chat.last_message_time,
isRegistered: registeredJids.has(chat.jid),
}));
}
export function createMessageRuntime(deps: MessageRuntimeDeps): {
processGroupMessages: (
chatJid: string,
@@ -97,9 +63,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
deps.idleTimeout,
);
const getCurrentAvailableGroups = (): AvailableGroup[] =>
getAvailableGroups(deps.getRegisteredGroups());
const deliverOpenWorkItem = async (
channel: Channel,
item: WorkItem,
@@ -176,345 +139,25 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
chatJid: string,
runId: string,
onOutput?: (output: AgentOutput) => Promise<void>,
): Promise<'success' | 'error'> => {
const isMain = group.isMain === true;
const isClaudeCodeAgent =
(group.agentType || 'claude-code') === 'claude-code';
const sessions = deps.getSessions();
const sessionId = sessions[group.folder];
const tasks = getAllTasks(group.agentType || 'claude-code');
writeTasksSnapshot(
group.folder,
isMain,
tasks.map((task) => ({
id: task.id,
groupFolder: task.group_folder,
prompt: task.prompt,
schedule_type: task.schedule_type,
schedule_value: task.schedule_value,
status: task.status,
next_run: task.next_run,
})),
): Promise<'success' | 'error'> =>
runAgentForGroup(
{
assistantName: deps.assistantName,
queue: deps.queue,
getRegisteredGroups: deps.getRegisteredGroups,
getSessions: deps.getSessions,
persistSession: deps.persistSession,
clearSession: deps.clearSession,
},
{
group,
prompt,
chatJid,
runId,
onOutput,
},
);
writeGroupsSnapshot(group.folder, isMain, getCurrentAvailableGroups());
let resetSessionRequested = false;
const settingsPath = path.join(
DATA_DIR,
'sessions',
group.folder,
'.claude',
'settings.json',
);
const groupHasOverride = hasGroupProviderOverride(settingsPath);
const canFallback =
isClaudeCodeAgent && isFallbackEnabled() && !groupHasOverride;
const agentInput = {
prompt,
sessionId,
groupFolder: group.folder,
chatJid,
runId,
isMain,
assistantName: deps.assistantName,
};
const runAttempt = async (
provider: string,
): Promise<{
output?: AgentOutput;
error?: unknown;
sawOutput: boolean;
sawSuccessNullResultWithoutOutput: boolean;
streamedTriggerReason?: {
reason: string;
retryAfterMs?: number;
};
}> => {
const persistSessionIds = provider === 'claude';
let sawOutput = false;
let sawSuccessNullResultWithoutOutput = false;
let streamedTriggerReason:
| {
reason: string;
retryAfterMs?: number;
}
| undefined;
const wrappedOnOutput = onOutput
? async (output: AgentOutput) => {
if (persistSessionIds && output.newSessionId) {
deps.persistSession(group.folder, output.newSessionId);
}
if (
persistSessionIds &&
isClaudeCodeAgent &&
shouldResetSessionOnAgentFailure(output)
) {
resetSessionRequested = true;
}
if (output.result !== null && output.result !== undefined) {
sawOutput = true;
} else if (
provider === 'claude' &&
output.status === 'success' &&
!sawOutput
) {
sawSuccessNullResultWithoutOutput = true;
}
if (
provider === 'claude' &&
output.status === 'error' &&
!sawOutput &&
!streamedTriggerReason
) {
const trigger = detectFallbackTrigger(output.error);
if (trigger.shouldFallback) {
streamedTriggerReason = {
reason: trigger.reason,
retryAfterMs: trigger.retryAfterMs,
};
}
}
await onOutput(output);
}
: undefined;
if (provider !== 'claude') {
logger.info(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
provider,
},
`Claude provider in cooldown, routing request to ${provider}`,
);
}
logger.info(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
provider,
canFallback,
groupHasOverride,
},
`Using provider: ${provider}`,
);
try {
const output = await runAgentProcess(
group,
{
...agentInput,
sessionId: persistSessionIds ? sessionId : undefined,
},
(proc, processName) =>
deps.queue.registerProcess(
chatJid,
proc,
processName,
group.folder,
),
wrappedOnOutput,
provider === 'claude' ? undefined : getFallbackEnvOverrides(),
);
if (persistSessionIds && output.newSessionId) {
deps.persistSession(group.folder, output.newSessionId);
}
logger.info(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
provider,
status: output.status,
sawOutput,
},
`Provider response completed (provider: ${provider})`,
);
return {
output,
sawOutput,
sawSuccessNullResultWithoutOutput,
streamedTriggerReason,
};
} catch (error) {
return {
error,
sawOutput,
sawSuccessNullResultWithoutOutput,
streamedTriggerReason,
};
}
};
const runFallbackAttempt = async (
reason: string,
retryAfterMs?: number,
): Promise<'success' | 'error'> => {
const fallbackName = getFallbackProviderName();
markPrimaryCooldown(reason, retryAfterMs);
logger.info(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
reason,
retryAfterMs,
fallbackProvider: fallbackName,
},
`Falling back to provider: ${fallbackName} (reason: ${reason})`,
);
const fallbackAttempt = await runAttempt(fallbackName);
if (fallbackAttempt.error) {
logger.error(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
provider: fallbackName,
err: fallbackAttempt.error,
},
'Fallback provider also threw',
);
return 'error';
}
if (fallbackAttempt.output?.status === 'error') {
logger.error(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
provider: fallbackName,
error: fallbackAttempt.output.error,
},
`Fallback provider (${fallbackName}) also failed`,
);
return 'error';
}
return 'success';
};
const provider = canFallback ? getActiveProvider() : 'claude';
const primaryAttempt = await runAttempt(provider);
if (primaryAttempt.error) {
if (canFallback && provider === 'claude' && !primaryAttempt.sawOutput) {
const errMsg =
primaryAttempt.error instanceof Error
? primaryAttempt.error.message
: String(primaryAttempt.error);
const trigger = primaryAttempt.streamedTriggerReason
? {
shouldFallback: true,
reason: primaryAttempt.streamedTriggerReason.reason,
retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs,
}
: detectFallbackTrigger(errMsg);
if (trigger.shouldFallback) {
return runFallbackAttempt(trigger.reason, trigger.retryAfterMs);
}
}
logger.error(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
provider,
err: primaryAttempt.error,
},
'Agent error',
);
return 'error';
}
const output = primaryAttempt.output;
if (!output) {
logger.error(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
provider,
},
'Agent produced no output object',
);
return 'error';
}
if (
canFallback &&
provider === 'claude' &&
!primaryAttempt.sawOutput &&
primaryAttempt.sawSuccessNullResultWithoutOutput
) {
return runFallbackAttempt('success-null-result');
}
if (
isClaudeCodeAgent &&
(resetSessionRequested || shouldResetSessionOnAgentFailure(output))
) {
deps.clearSession(group.folder);
logger.warn(
{ group: group.name, chatJid, runId },
'Cleared poisoned agent session after unrecoverable error',
);
}
if (output.status === 'error') {
if (canFallback && provider === 'claude' && !primaryAttempt.sawOutput) {
const trigger = primaryAttempt.streamedTriggerReason
? {
shouldFallback: true,
reason: primaryAttempt.streamedTriggerReason.reason,
retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs,
}
: detectFallbackTrigger(output.error);
if (trigger.shouldFallback) {
return runFallbackAttempt(trigger.reason, trigger.retryAfterMs);
}
}
logger.error(
{
group: group.name,
chatJid,
runId,
provider,
error: output.error,
},
'Agent process error',
);
return 'error';
}
return 'success';
};
const processGroupMessages = async (
chatJid: string,
context: GroupRunContext,