runtime: split message agent executor lifecycle
This commit is contained in:
292
src/message-agent-executor-attempt-runner.ts
Normal file
292
src/message-agent-executor-attempt-runner.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import type { Logger } from 'pino';
|
||||||
|
|
||||||
|
import { createEvaluatedOutputHandler } from './agent-attempt.js';
|
||||||
|
import type { AttemptStreamedTrigger } from './agent-attempt-retry.js';
|
||||||
|
import { runAgentProcess, type AgentOutput } from './agent-runner.js';
|
||||||
|
import { getCodexAccountCount } from './codex-token-rotation.js';
|
||||||
|
import type { PreparedPairedExecutionContext } from './paired-execution-context.js';
|
||||||
|
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
||||||
|
import type { AgentType, RegisteredGroup, RoomRoleContext } from './types.js';
|
||||||
|
|
||||||
|
export interface MessageAgentAttempt {
|
||||||
|
output?: AgentOutput;
|
||||||
|
error?: unknown;
|
||||||
|
sawOutput: boolean;
|
||||||
|
sawVisibleOutput: boolean;
|
||||||
|
sawSuccessNullResultWithoutOutput: boolean;
|
||||||
|
retryableSessionFailureDetected: boolean;
|
||||||
|
resetSessionRequested: boolean;
|
||||||
|
streamedTriggerReason?: AttemptStreamedTrigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentInput {
|
||||||
|
prompt: string;
|
||||||
|
sessionId?: string;
|
||||||
|
memoryBriefing?: string;
|
||||||
|
groupFolder: string;
|
||||||
|
chatJid: string;
|
||||||
|
runId: string;
|
||||||
|
isMain: boolean;
|
||||||
|
assistantName: string;
|
||||||
|
roomRoleContext?: RoomRoleContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runMessageAgentAttempt(args: {
|
||||||
|
provider: 'claude' | 'codex';
|
||||||
|
currentSessionId: string | undefined;
|
||||||
|
isClaudeCodeAgent: boolean;
|
||||||
|
canRetryClaudeCredentials: boolean;
|
||||||
|
shouldPersistSession: boolean;
|
||||||
|
effectiveGroup: RegisteredGroup;
|
||||||
|
agentInput: AgentInput;
|
||||||
|
activeRole: string;
|
||||||
|
effectiveServiceId: string;
|
||||||
|
effectiveAgentType: AgentType;
|
||||||
|
sessionFolder: string;
|
||||||
|
roomRoleContext?: RoomRoleContext;
|
||||||
|
pairedExecutionContext?: PreparedPairedExecutionContext;
|
||||||
|
fallbackWorkspaceDir?: string | null;
|
||||||
|
onPersistSession: (sessionId: string) => void;
|
||||||
|
registerProcess: (
|
||||||
|
proc: Parameters<typeof runAgentProcess>[2] extends (
|
||||||
|
proc: infer TProc,
|
||||||
|
processName: infer TProcessName,
|
||||||
|
ipcDir: infer TIpcDir,
|
||||||
|
) => unknown
|
||||||
|
? TProc
|
||||||
|
: never,
|
||||||
|
processName: string,
|
||||||
|
ipcDir?: string,
|
||||||
|
) => void;
|
||||||
|
onOutput?: (output: AgentOutput) => Promise<void>;
|
||||||
|
pairedExecutionLifecycle: {
|
||||||
|
updateSummary(args: {
|
||||||
|
outputText?: string | null;
|
||||||
|
errorText?: string | null;
|
||||||
|
}): void;
|
||||||
|
recordFinalOutputBeforeDelivery(outputText: string): void;
|
||||||
|
};
|
||||||
|
log: Logger;
|
||||||
|
}): Promise<MessageAgentAttempt> {
|
||||||
|
const {
|
||||||
|
provider,
|
||||||
|
currentSessionId,
|
||||||
|
isClaudeCodeAgent,
|
||||||
|
canRetryClaudeCredentials,
|
||||||
|
shouldPersistSession,
|
||||||
|
effectiveGroup,
|
||||||
|
agentInput,
|
||||||
|
activeRole,
|
||||||
|
effectiveServiceId,
|
||||||
|
effectiveAgentType,
|
||||||
|
sessionFolder,
|
||||||
|
roomRoleContext,
|
||||||
|
pairedExecutionContext,
|
||||||
|
fallbackWorkspaceDir,
|
||||||
|
onPersistSession,
|
||||||
|
registerProcess,
|
||||||
|
onOutput,
|
||||||
|
pairedExecutionLifecycle,
|
||||||
|
log,
|
||||||
|
} = args;
|
||||||
|
const attemptSessionId = currentSessionId;
|
||||||
|
let resetSessionRequested = false;
|
||||||
|
const streamedOutputHandler = createEvaluatedOutputHandler({
|
||||||
|
agentType: isClaudeCodeAgent ? 'claude-code' : 'codex',
|
||||||
|
provider,
|
||||||
|
evaluationOptions: {
|
||||||
|
suppressClaudeAuthErrorOutput: provider === 'claude',
|
||||||
|
trackSuccessNullResult: true,
|
||||||
|
shortCircuitTriggeredErrors:
|
||||||
|
provider === 'claude'
|
||||||
|
? canRetryClaudeCredentials
|
||||||
|
: getCodexAccountCount() > 1,
|
||||||
|
},
|
||||||
|
onEvaluatedOutput: async ({
|
||||||
|
output,
|
||||||
|
outputText,
|
||||||
|
structuredOutput,
|
||||||
|
evaluation,
|
||||||
|
}) => {
|
||||||
|
const outputPhase = output.phase ?? 'final';
|
||||||
|
if (outputPhase !== 'final') {
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
provider,
|
||||||
|
outputPhase,
|
||||||
|
outputStatus: output.status,
|
||||||
|
visibility: structuredOutput?.visibility ?? null,
|
||||||
|
preview:
|
||||||
|
outputText && outputText.length > 0
|
||||||
|
? outputText.slice(0, 160)
|
||||||
|
: null,
|
||||||
|
errorPreview:
|
||||||
|
typeof output.error === 'string' && output.error.length > 0
|
||||||
|
? output.error.slice(0, 160)
|
||||||
|
: null,
|
||||||
|
activeRole,
|
||||||
|
effectiveServiceId,
|
||||||
|
effectiveAgentType,
|
||||||
|
sessionFolder,
|
||||||
|
resumedSession: attemptSessionId ?? null,
|
||||||
|
streamedSessionId: output.newSessionId ?? null,
|
||||||
|
roomRoleServiceId: roomRoleContext?.serviceId ?? null,
|
||||||
|
roomRole: roomRoleContext?.role ?? null,
|
||||||
|
pairedTaskId: pairedExecutionContext?.task.id ?? null,
|
||||||
|
workspaceDir:
|
||||||
|
pairedExecutionContext?.workspace?.workspace_dir ??
|
||||||
|
fallbackWorkspaceDir ??
|
||||||
|
null,
|
||||||
|
},
|
||||||
|
'Observed streamed agent activity',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isClaudeCodeAgent &&
|
||||||
|
provider === 'claude' &&
|
||||||
|
shouldResetSessionOnAgentFailure(output)
|
||||||
|
) {
|
||||||
|
resetSessionRequested = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
output.newSessionId &&
|
||||||
|
!resetSessionRequested &&
|
||||||
|
shouldPersistSession
|
||||||
|
) {
|
||||||
|
onPersistSession(output.newSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
pairedExecutionLifecycle.updateSummary({
|
||||||
|
outputText,
|
||||||
|
errorText: typeof output.error === 'string' ? output.error : null,
|
||||||
|
});
|
||||||
|
if (evaluation.newTrigger && outputText && output.status === 'success') {
|
||||||
|
log.warn(
|
||||||
|
{
|
||||||
|
reason: evaluation.newTrigger.reason,
|
||||||
|
resultPreview: outputText.slice(0, 120),
|
||||||
|
},
|
||||||
|
'Detected Claude rotation trigger in successful output',
|
||||||
|
);
|
||||||
|
} else if (evaluation.newTrigger && typeof output.error === 'string') {
|
||||||
|
log.warn(
|
||||||
|
{
|
||||||
|
reason: evaluation.newTrigger.reason,
|
||||||
|
errorPreview: output.error.slice(0, 120),
|
||||||
|
},
|
||||||
|
provider === 'claude'
|
||||||
|
? 'Detected Claude rotation trigger in streamed error output'
|
||||||
|
: 'Detected Codex rotation trigger in streamed error output',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evaluation.suppressedAuthError) {
|
||||||
|
log.warn(
|
||||||
|
{
|
||||||
|
resultPreview: outputText ? outputText.slice(0, 120) : undefined,
|
||||||
|
},
|
||||||
|
'Suppressed Claude 401 auth error from chat output',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evaluation.suppressedRetryableSessionFailure) {
|
||||||
|
log.warn(
|
||||||
|
{
|
||||||
|
resultPreview: outputText
|
||||||
|
? outputText.slice(0, 160)
|
||||||
|
: output.error?.slice(0, 160),
|
||||||
|
},
|
||||||
|
'Suppressed retryable Claude session failure from chat output',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!evaluation.shouldForwardOutput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (outputText && outputText.length > 0) {
|
||||||
|
streamedOutputHandler.markVisibleOutput();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
outputPhase === 'final' &&
|
||||||
|
output.status === 'success' &&
|
||||||
|
outputText &&
|
||||||
|
outputText.length > 0
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
pairedExecutionLifecycle.recordFinalOutputBeforeDelivery(outputText);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(
|
||||||
|
{ pairedTaskId: pairedExecutionContext?.task.id ?? null, err },
|
||||||
|
'Failed to persist paired turn output and status before delivery',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onOutput) {
|
||||||
|
await onOutput(output);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrappedOnOutput = async (output: AgentOutput) => {
|
||||||
|
await streamedOutputHandler.handleOutput(output);
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerLog = log.child({
|
||||||
|
provider,
|
||||||
|
agentType: effectiveAgentType,
|
||||||
|
});
|
||||||
|
providerLog.info('Using provider');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = await runAgentProcess(
|
||||||
|
effectiveGroup,
|
||||||
|
{
|
||||||
|
...agentInput,
|
||||||
|
sessionId: attemptSessionId,
|
||||||
|
},
|
||||||
|
registerProcess,
|
||||||
|
wrappedOnOutput,
|
||||||
|
pairedExecutionContext?.envOverrides,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (output.newSessionId && shouldPersistSession) {
|
||||||
|
onPersistSession(output.newSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
providerLog.info(
|
||||||
|
{
|
||||||
|
status: output.status,
|
||||||
|
sawOutput: streamedOutputHandler.getState().sawOutput,
|
||||||
|
},
|
||||||
|
`Provider response completed (provider: ${provider})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const streamedState = streamedOutputHandler.getState();
|
||||||
|
return {
|
||||||
|
output,
|
||||||
|
sawOutput: streamedState.sawOutput,
|
||||||
|
sawVisibleOutput: streamedState.sawVisibleOutput,
|
||||||
|
sawSuccessNullResultWithoutOutput:
|
||||||
|
streamedState.sawSuccessNullResultWithoutOutput,
|
||||||
|
retryableSessionFailureDetected:
|
||||||
|
streamedState.retryableSessionFailureDetected === true,
|
||||||
|
resetSessionRequested,
|
||||||
|
streamedTriggerReason: streamedState.streamedTriggerReason,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const streamedState = streamedOutputHandler.getState();
|
||||||
|
return {
|
||||||
|
error,
|
||||||
|
sawOutput: streamedState.sawOutput,
|
||||||
|
sawVisibleOutput: streamedState.sawVisibleOutput,
|
||||||
|
sawSuccessNullResultWithoutOutput:
|
||||||
|
streamedState.sawSuccessNullResultWithoutOutput,
|
||||||
|
retryableSessionFailureDetected:
|
||||||
|
streamedState.retryableSessionFailureDetected === true,
|
||||||
|
resetSessionRequested,
|
||||||
|
streamedTriggerReason: streamedState.streamedTriggerReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/message-agent-executor-handoff.ts
Normal file
71
src/message-agent-executor-handoff.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { getRoleModelConfig } from './config.js';
|
||||||
|
import { createServiceHandoff } from './db.js';
|
||||||
|
import type { AgentTriggerReason } from './agent-error-detection.js';
|
||||||
|
import { resolveCodexFallbackHandoff } from './paired-turn-fallback.js';
|
||||||
|
import { activateCodexFailover } from './service-routing.js';
|
||||||
|
import type { PairedTurnIdentity } from './paired-turn-identity.js';
|
||||||
|
import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
|
interface MessageAgentExecutorHandoffArgs {
|
||||||
|
activeRole: PairedRoomRole;
|
||||||
|
effectiveAgentType: AgentType;
|
||||||
|
hasReviewer: boolean;
|
||||||
|
reason: AgentTriggerReason;
|
||||||
|
sawVisibleOutput: boolean;
|
||||||
|
prompt: string;
|
||||||
|
startSeq?: number | null;
|
||||||
|
endSeq?: number | null;
|
||||||
|
chatJid: string;
|
||||||
|
group: RegisteredGroup;
|
||||||
|
pairedTurnIdentity?: PairedTurnIdentity;
|
||||||
|
markDelegated: () => void;
|
||||||
|
log: {
|
||||||
|
info: (obj: Record<string, unknown>, msg: string) => void;
|
||||||
|
warn: (obj: Record<string, unknown>, msg: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handoffMessageAgentExecutionToCodex(
|
||||||
|
args: MessageAgentExecutorHandoffArgs,
|
||||||
|
): boolean {
|
||||||
|
const handoffResolution = resolveCodexFallbackHandoff({
|
||||||
|
activeRole: args.activeRole,
|
||||||
|
effectiveAgentType: args.effectiveAgentType,
|
||||||
|
hasReviewer: args.hasReviewer,
|
||||||
|
fallbackEnabled: getRoleModelConfig(args.activeRole).fallbackEnabled,
|
||||||
|
reason: args.reason,
|
||||||
|
sawVisibleOutput: args.sawVisibleOutput,
|
||||||
|
prompt: args.prompt,
|
||||||
|
startSeq: args.startSeq,
|
||||||
|
endSeq: args.endSeq,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (handoffResolution.type === 'none') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handoffResolution.type === 'skip') {
|
||||||
|
args.log.info({ reason: args.reason }, handoffResolution.logMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handoffResolution.plan.activateOwnerFailoverReason) {
|
||||||
|
activateCodexFailover(
|
||||||
|
args.chatJid,
|
||||||
|
handoffResolution.plan.activateOwnerFailoverReason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
createServiceHandoff({
|
||||||
|
chat_jid: args.chatJid,
|
||||||
|
group_folder: args.group.folder,
|
||||||
|
paired_task_id: args.pairedTurnIdentity?.taskId,
|
||||||
|
paired_task_updated_at: args.pairedTurnIdentity?.taskUpdatedAt,
|
||||||
|
turn_id: args.pairedTurnIdentity?.turnId,
|
||||||
|
turn_intent_kind: args.pairedTurnIdentity?.intentKind,
|
||||||
|
turn_role: args.pairedTurnIdentity?.role,
|
||||||
|
...handoffResolution.plan.handoff,
|
||||||
|
});
|
||||||
|
args.markDelegated();
|
||||||
|
args.log.warn({ reason: args.reason }, handoffResolution.plan.logMessage);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
365
src/message-agent-executor-lifecycle.ts
Normal file
365
src/message-agent-executor-lifecycle.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import { getAgentOutputText } from './agent-output.js';
|
||||||
|
import {
|
||||||
|
executeAttemptRetryAction,
|
||||||
|
runClaudeAttemptWithRotation,
|
||||||
|
runCodexAttemptWithRotation,
|
||||||
|
} from './agent-attempt-orchestration.js';
|
||||||
|
import { isRetryableClaudeSessionFailureAttempt } from './agent-attempt-retry.js';
|
||||||
|
import { getCodexAccountCount } from './codex-token-rotation.js';
|
||||||
|
import type {
|
||||||
|
AgentTriggerReason,
|
||||||
|
CodexRotationReason,
|
||||||
|
} from './agent-error-detection.js';
|
||||||
|
import type { PairedExecutionLifecycle } from './message-agent-executor-paired.js';
|
||||||
|
import type { MessageAgentAttempt } from './message-agent-executor-attempt-runner.js';
|
||||||
|
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
||||||
|
import { getErrorMessage } from './utils.js';
|
||||||
|
|
||||||
|
type AttemptResult = 'success' | 'error';
|
||||||
|
|
||||||
|
export async function executeMessageAgentAttemptLifecycle(args: {
|
||||||
|
provider: 'claude' | 'codex';
|
||||||
|
runAttempt: (provider: 'claude' | 'codex') => Promise<MessageAgentAttempt>;
|
||||||
|
isClaudeCodeAgent: boolean;
|
||||||
|
canRetryClaudeCredentials: boolean;
|
||||||
|
clearStoredSession: () => void;
|
||||||
|
clearRoleSdkSessions: () => void;
|
||||||
|
sessionFolder: string;
|
||||||
|
maybeHandoffToCodex: (
|
||||||
|
reason: AgentTriggerReason,
|
||||||
|
sawVisibleOutput: boolean,
|
||||||
|
) => boolean;
|
||||||
|
hasDirectTerminalDelivery: () => boolean;
|
||||||
|
pairedExecutionLifecycle: Pick<
|
||||||
|
PairedExecutionLifecycle,
|
||||||
|
'markStatus' | 'markSawOutput' | 'updateSummary' | 'getSummary'
|
||||||
|
>;
|
||||||
|
shouldRetryFreshSessionOnAgentFailure: (args: {
|
||||||
|
result: null;
|
||||||
|
error: string;
|
||||||
|
}) => boolean;
|
||||||
|
rotationLogContext: {
|
||||||
|
chatJid: string;
|
||||||
|
group: string;
|
||||||
|
groupFolder: string;
|
||||||
|
runId: string;
|
||||||
|
};
|
||||||
|
log: {
|
||||||
|
warn: (obj: Record<string, unknown> | string, msg?: string) => void;
|
||||||
|
error: (obj: Record<string, unknown> | string, msg?: string) => void;
|
||||||
|
};
|
||||||
|
}): Promise<AttemptResult> {
|
||||||
|
const {
|
||||||
|
provider,
|
||||||
|
runAttempt,
|
||||||
|
isClaudeCodeAgent,
|
||||||
|
canRetryClaudeCredentials,
|
||||||
|
clearStoredSession,
|
||||||
|
clearRoleSdkSessions,
|
||||||
|
sessionFolder,
|
||||||
|
maybeHandoffToCodex,
|
||||||
|
hasDirectTerminalDelivery,
|
||||||
|
pairedExecutionLifecycle,
|
||||||
|
shouldRetryFreshSessionOnAgentFailure,
|
||||||
|
rotationLogContext,
|
||||||
|
log,
|
||||||
|
} = args;
|
||||||
|
|
||||||
|
let resetSessionRequested = false;
|
||||||
|
const rememberAttempt = (
|
||||||
|
attempt: MessageAgentAttempt,
|
||||||
|
): MessageAgentAttempt => {
|
||||||
|
if (attempt.resetSessionRequested) {
|
||||||
|
resetSessionRequested = true;
|
||||||
|
}
|
||||||
|
return attempt;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTrackedAttempt = async (
|
||||||
|
currentProvider: 'claude' | 'codex',
|
||||||
|
): Promise<MessageAgentAttempt> =>
|
||||||
|
rememberAttempt(await runAttempt(currentProvider));
|
||||||
|
|
||||||
|
const retryCodexWithRotation = async (
|
||||||
|
initialTrigger: { reason: CodexRotationReason },
|
||||||
|
rotationMessage?: string,
|
||||||
|
): Promise<AttemptResult> => {
|
||||||
|
return runCodexAttemptWithRotation({
|
||||||
|
initialTrigger,
|
||||||
|
runAttempt: () => runTrackedAttempt('codex'),
|
||||||
|
logContext: rotationLogContext,
|
||||||
|
rotationMessage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryClaudeWithRotation = async (
|
||||||
|
initialTrigger: {
|
||||||
|
reason: AgentTriggerReason;
|
||||||
|
retryAfterMs?: number;
|
||||||
|
},
|
||||||
|
rotationMessage?: string,
|
||||||
|
): Promise<AttemptResult> => {
|
||||||
|
return runClaudeAttemptWithRotation({
|
||||||
|
initialTrigger,
|
||||||
|
runAttempt: () => runTrackedAttempt('claude'),
|
||||||
|
logContext: rotationLogContext,
|
||||||
|
rotationMessage,
|
||||||
|
onSuccess: ({ sawOutput }) => {
|
||||||
|
pairedExecutionLifecycle.markSawOutput(sawOutput);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeHandoffAfterError = (
|
||||||
|
reason: AgentTriggerReason,
|
||||||
|
attempt: MessageAgentAttempt,
|
||||||
|
): AttemptResult => {
|
||||||
|
if (maybeHandoffToCodex(reason, attempt.sawVisibleOutput)) {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
return 'error';
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryClaudeAttemptIfNeeded = async (
|
||||||
|
attempt: MessageAgentAttempt,
|
||||||
|
rotationMessage?: string | null,
|
||||||
|
): Promise<AttemptResult | null> => {
|
||||||
|
const retryAction = await executeAttemptRetryAction({
|
||||||
|
provider,
|
||||||
|
canRetryClaudeCredentials,
|
||||||
|
canRetryCodex: false,
|
||||||
|
attempt,
|
||||||
|
rotationMessage,
|
||||||
|
runClaude: retryClaudeWithRotation,
|
||||||
|
runCodex: retryCodexWithRotation,
|
||||||
|
});
|
||||||
|
if (retryAction.kind !== 'claude') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retryAction.result === 'error') {
|
||||||
|
return maybeHandoffAfterError(retryAction.trigger.reason, attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
pairedExecutionLifecycle.markStatus('succeeded');
|
||||||
|
return retryAction.result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryCodexAttemptIfNeeded = async (
|
||||||
|
attempt: MessageAgentAttempt,
|
||||||
|
rotationMessage?: string | null,
|
||||||
|
): Promise<AttemptResult | null> => {
|
||||||
|
const retryAction = await executeAttemptRetryAction({
|
||||||
|
provider,
|
||||||
|
canRetryClaudeCredentials: false,
|
||||||
|
canRetryCodex: !isClaudeCodeAgent && getCodexAccountCount() > 1,
|
||||||
|
attempt,
|
||||||
|
rotationMessage,
|
||||||
|
runClaude: retryClaudeWithRotation,
|
||||||
|
runCodex: retryCodexWithRotation,
|
||||||
|
});
|
||||||
|
if (retryAction.kind !== 'codex') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retryAction.result === 'success') {
|
||||||
|
pairedExecutionLifecycle.markStatus('succeeded');
|
||||||
|
}
|
||||||
|
return retryAction.result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRetryableClaudeSessionFailure = (
|
||||||
|
attempt: MessageAgentAttempt,
|
||||||
|
): boolean =>
|
||||||
|
isRetryableClaudeSessionFailureAttempt({
|
||||||
|
attempt,
|
||||||
|
isClaudeCodeAgent,
|
||||||
|
provider,
|
||||||
|
shouldRetryFreshSessionOnAgentFailure,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recoverRetryableClaudeSessionFailure = async (
|
||||||
|
attempt: MessageAgentAttempt,
|
||||||
|
): Promise<{
|
||||||
|
attempt: MessageAgentAttempt;
|
||||||
|
resolved: AttemptResult | null;
|
||||||
|
}> => {
|
||||||
|
if (!isRetryableClaudeSessionFailure(attempt)) {
|
||||||
|
return { attempt, resolved: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
clearStoredSession();
|
||||||
|
clearRoleSdkSessions();
|
||||||
|
log.warn(
|
||||||
|
'Cleared poisoned Claude session before visible output, retrying fresh session',
|
||||||
|
);
|
||||||
|
|
||||||
|
const freshAttempt = await runTrackedAttempt('claude');
|
||||||
|
if (!isRetryableClaudeSessionFailure(freshAttempt)) {
|
||||||
|
return { attempt: freshAttempt, resolved: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
clearStoredSession();
|
||||||
|
log.warn('Fresh Claude retry also hit a retryable session failure');
|
||||||
|
log.error('Retryable Claude session failure persisted after fresh retry');
|
||||||
|
return {
|
||||||
|
attempt: freshAttempt,
|
||||||
|
resolved: maybeHandoffAfterError('session-failure', freshAttempt),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrimaryAttemptFailure = async (
|
||||||
|
attempt: MessageAgentAttempt,
|
||||||
|
rotationMessage: string,
|
||||||
|
): Promise<AttemptResult> => {
|
||||||
|
const claudeRetryResult = await retryClaudeAttemptIfNeeded(
|
||||||
|
attempt,
|
||||||
|
rotationMessage,
|
||||||
|
);
|
||||||
|
if (claudeRetryResult) {
|
||||||
|
return claudeRetryResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codexRetryResult = await retryCodexAttemptIfNeeded(
|
||||||
|
attempt,
|
||||||
|
rotationMessage,
|
||||||
|
);
|
||||||
|
if (codexRetryResult) {
|
||||||
|
return codexRetryResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.error) {
|
||||||
|
log.error(
|
||||||
|
{
|
||||||
|
provider,
|
||||||
|
err: attempt.error,
|
||||||
|
},
|
||||||
|
'Agent error',
|
||||||
|
);
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error(
|
||||||
|
{
|
||||||
|
provider,
|
||||||
|
error: attempt.output?.error,
|
||||||
|
},
|
||||||
|
'Agent process error',
|
||||||
|
);
|
||||||
|
return 'error';
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalizePrimaryAttempt = async (
|
||||||
|
attempt: MessageAgentAttempt,
|
||||||
|
): Promise<AttemptResult> => {
|
||||||
|
const output = attempt.output;
|
||||||
|
if (!output) {
|
||||||
|
log.error({ provider }, 'Agent produced no output object');
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pairedExecutionLifecycle.getSummary()) {
|
||||||
|
const finalOutputText = getAgentOutputText(output);
|
||||||
|
pairedExecutionLifecycle.updateSummary({
|
||||||
|
outputText:
|
||||||
|
typeof finalOutputText === 'string' && finalOutputText.length > 0
|
||||||
|
? finalOutputText
|
||||||
|
: null,
|
||||||
|
errorText:
|
||||||
|
typeof output.error === 'string' && output.error.length > 0
|
||||||
|
? output.error
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!attempt.sawOutput &&
|
||||||
|
!hasDirectTerminalDelivery() &&
|
||||||
|
output.status !== 'error'
|
||||||
|
) {
|
||||||
|
const claudeRetryResult = await retryClaudeAttemptIfNeeded(attempt);
|
||||||
|
if (claudeRetryResult) {
|
||||||
|
return claudeRetryResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isClaudeCodeAgent &&
|
||||||
|
(resetSessionRequested || shouldResetSessionOnAgentFailure(output))
|
||||||
|
) {
|
||||||
|
clearStoredSession();
|
||||||
|
log.warn(
|
||||||
|
{ sessionFolder },
|
||||||
|
'Cleared poisoned agent session after unrecoverable error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output.status === 'error') {
|
||||||
|
return handlePrimaryAttemptFailure(
|
||||||
|
attempt,
|
||||||
|
output.error ?? 'Agent process error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const codexRetryResult = await retryCodexAttemptIfNeeded(
|
||||||
|
attempt,
|
||||||
|
output.error ?? output.result,
|
||||||
|
);
|
||||||
|
if (codexRetryResult) {
|
||||||
|
return codexRetryResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.streamedTriggerReason) {
|
||||||
|
if (
|
||||||
|
isClaudeCodeAgent &&
|
||||||
|
maybeHandoffToCodex(
|
||||||
|
attempt.streamedTriggerReason.reason,
|
||||||
|
attempt.sawVisibleOutput,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
log.error(
|
||||||
|
{
|
||||||
|
reason: attempt.streamedTriggerReason.reason,
|
||||||
|
},
|
||||||
|
'Agent trigger detected but could not be resolved',
|
||||||
|
);
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
attempt.sawSuccessNullResultWithoutOutput &&
|
||||||
|
!attempt.sawOutput &&
|
||||||
|
!hasDirectTerminalDelivery()
|
||||||
|
) {
|
||||||
|
log.error(
|
||||||
|
'Agent returned success with null result and no visible output',
|
||||||
|
);
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
pairedExecutionLifecycle.markStatus('succeeded');
|
||||||
|
pairedExecutionLifecycle.markSawOutput(
|
||||||
|
attempt.sawOutput || hasDirectTerminalDelivery(),
|
||||||
|
);
|
||||||
|
return 'success';
|
||||||
|
};
|
||||||
|
|
||||||
|
let primaryAttempt = await runTrackedAttempt(provider);
|
||||||
|
const recoveredSessionAttempt =
|
||||||
|
await recoverRetryableClaudeSessionFailure(primaryAttempt);
|
||||||
|
if (recoveredSessionAttempt.resolved) {
|
||||||
|
return recoveredSessionAttempt.resolved;
|
||||||
|
}
|
||||||
|
primaryAttempt = recoveredSessionAttempt.attempt;
|
||||||
|
|
||||||
|
if (primaryAttempt.error) {
|
||||||
|
return handlePrimaryAttemptFailure(
|
||||||
|
primaryAttempt,
|
||||||
|
getErrorMessage(primaryAttempt.error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizePrimaryAttempt(primaryAttempt);
|
||||||
|
}
|
||||||
350
src/message-agent-executor-target.ts
Normal file
350
src/message-agent-executor-target.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { listAvailableGroups } from './available-groups.js';
|
||||||
|
import {
|
||||||
|
ARBITER_AGENT_TYPE,
|
||||||
|
REVIEWER_AGENT_TYPE,
|
||||||
|
getRoleModelConfig,
|
||||||
|
} from './config.js';
|
||||||
|
import {
|
||||||
|
getAllTasks,
|
||||||
|
getLatestOpenPairedTaskForChat,
|
||||||
|
markPairedTurnRunning,
|
||||||
|
} from './db.js';
|
||||||
|
import { createScopedLogger } from './logger.js';
|
||||||
|
import { buildRoomMemoryBriefing } from './sqlite-memory-store.js';
|
||||||
|
import type { PreparedPairedExecutionContext } from './paired-execution-context.js';
|
||||||
|
import { preparePairedExecutionContext } from './paired-execution-context.js';
|
||||||
|
import {
|
||||||
|
resolveRuntimePairedTurnIdentity,
|
||||||
|
type PairedTurnIdentity,
|
||||||
|
} from './paired-turn-identity.js';
|
||||||
|
import { resolveExecutionTarget } from './message-runtime-rules.js';
|
||||||
|
import { buildRoomRoleContext } from './room-role-context.js';
|
||||||
|
import { getEffectiveChannelLease } from './service-routing.js';
|
||||||
|
import { getTokenCount } from './token-rotation.js';
|
||||||
|
import { writeGroupsSnapshot, writeTasksSnapshot } from './agent-runner.js';
|
||||||
|
import type {
|
||||||
|
AgentType,
|
||||||
|
PairedRoomRole,
|
||||||
|
RegisteredGroup,
|
||||||
|
RoomRoleContext,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
interface ExecutionTargetDeps {
|
||||||
|
assistantName: string;
|
||||||
|
getRoomBindings: () => Record<string, RegisteredGroup>;
|
||||||
|
getSessions: () => Record<string, string>;
|
||||||
|
clearSession: (groupFolder: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecutionTargetArgs {
|
||||||
|
group: RegisteredGroup;
|
||||||
|
prompt: string;
|
||||||
|
chatJid: string;
|
||||||
|
runId: string;
|
||||||
|
startSeq?: number | null;
|
||||||
|
endSeq?: number | null;
|
||||||
|
hasHumanMessage?: boolean;
|
||||||
|
forcedRole?: PairedRoomRole;
|
||||||
|
forcedAgentType?: AgentType;
|
||||||
|
pairedTurnIdentity?: PairedTurnIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedMessageAgentExecution {
|
||||||
|
currentLease: ReturnType<typeof getEffectiveChannelLease>;
|
||||||
|
pairedTask: ReturnType<typeof getLatestOpenPairedTaskForChat> | null;
|
||||||
|
activeRole: PairedRoomRole;
|
||||||
|
effectiveServiceId: string;
|
||||||
|
effectiveAgentType: AgentType;
|
||||||
|
sessionFolder: string;
|
||||||
|
reviewerMode: boolean;
|
||||||
|
arbiterMode: boolean;
|
||||||
|
effectiveGroup: RegisteredGroup;
|
||||||
|
isClaudeCodeAgent: boolean;
|
||||||
|
forceFreshClaudeReviewerSession: boolean;
|
||||||
|
shouldPersistSession: boolean;
|
||||||
|
currentSessionId: string | undefined;
|
||||||
|
provider: 'claude' | 'codex';
|
||||||
|
memoryBriefing: string | undefined;
|
||||||
|
canRetryClaudeCredentials: boolean;
|
||||||
|
roomRoleContext: RoomRoleContext | undefined;
|
||||||
|
pairedExecutionContext: PreparedPairedExecutionContext | undefined;
|
||||||
|
runtimePairedTurnIdentity: PairedTurnIdentity | undefined;
|
||||||
|
log: ReturnType<typeof createScopedLogger>;
|
||||||
|
clearRoleSdkSessions: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareMessageAgentExecutionTarget(
|
||||||
|
deps: ExecutionTargetDeps,
|
||||||
|
args: ExecutionTargetArgs,
|
||||||
|
): Promise<PreparedMessageAgentExecution> {
|
||||||
|
const { group, chatJid, runId, startSeq, endSeq } = args;
|
||||||
|
const isMain = group.isMain === true;
|
||||||
|
const sessions = deps.getSessions();
|
||||||
|
|
||||||
|
const currentLease = getEffectiveChannelLease(chatJid);
|
||||||
|
const pairedTask = currentLease.reviewer_agent_type
|
||||||
|
? getLatestOpenPairedTaskForChat(chatJid)
|
||||||
|
: null;
|
||||||
|
const executionTarget = resolveExecutionTarget({
|
||||||
|
lease: currentLease,
|
||||||
|
pairedTaskStatus: pairedTask?.status,
|
||||||
|
groupFolder: group.folder,
|
||||||
|
groupAgentType: group.agentType,
|
||||||
|
forcedRole: args.forcedRole,
|
||||||
|
forcedAgentType: args.forcedAgentType,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
inferredRole,
|
||||||
|
canHonorForcedRole,
|
||||||
|
activeRole,
|
||||||
|
effectiveServiceId,
|
||||||
|
reviewerServiceId,
|
||||||
|
arbiterServiceId,
|
||||||
|
roleAgentPlan,
|
||||||
|
configuredAgentType,
|
||||||
|
effectiveAgentType,
|
||||||
|
sessionFolder,
|
||||||
|
} = executionTarget;
|
||||||
|
const reviewerMode = activeRole === 'reviewer';
|
||||||
|
const arbiterMode = activeRole === 'arbiter';
|
||||||
|
const effectiveGroup =
|
||||||
|
effectiveAgentType !== roleAgentPlan.ownerAgentType
|
||||||
|
? { ...group, agentType: effectiveAgentType }
|
||||||
|
: group;
|
||||||
|
const isClaudeCodeAgent = effectiveAgentType === 'claude-code';
|
||||||
|
const unsafeHostPairedMode =
|
||||||
|
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1';
|
||||||
|
const forceFreshClaudeReviewerSession =
|
||||||
|
reviewerMode && isClaudeCodeAgent && unsafeHostPairedMode;
|
||||||
|
const shouldPersistSession =
|
||||||
|
activeRole !== 'arbiter' &&
|
||||||
|
!args.forcedAgentType &&
|
||||||
|
!forceFreshClaudeReviewerSession;
|
||||||
|
const storedSessionId = sessions[sessionFolder];
|
||||||
|
if (forceFreshClaudeReviewerSession && storedSessionId) {
|
||||||
|
deps.clearSession(sessionFolder);
|
||||||
|
}
|
||||||
|
const currentSessionId =
|
||||||
|
activeRole === 'arbiter' ||
|
||||||
|
args.forcedAgentType ||
|
||||||
|
forceFreshClaudeReviewerSession
|
||||||
|
? undefined
|
||||||
|
: storedSessionId;
|
||||||
|
const provider = isClaudeCodeAgent ? 'claude' : 'codex';
|
||||||
|
const memoryBriefing = currentSessionId
|
||||||
|
? undefined
|
||||||
|
: await buildRoomMemoryBriefing({
|
||||||
|
groupFolder: group.folder,
|
||||||
|
groupName: group.name,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
|
const tasks = getAllTasks(roleAgentPlan.ownerAgentType);
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
writeGroupsSnapshot(
|
||||||
|
group.folder,
|
||||||
|
isMain,
|
||||||
|
listAvailableGroups(deps.getRoomBindings()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const claudeTokenCount = isClaudeCodeAgent ? getTokenCount() : 0;
|
||||||
|
const canRetryClaudeCredentials = isClaudeCodeAgent && claudeTokenCount > 0;
|
||||||
|
const roomRoleContext = buildRoomRoleContext(
|
||||||
|
currentLease,
|
||||||
|
effectiveServiceId,
|
||||||
|
activeRole,
|
||||||
|
);
|
||||||
|
const pairedExecutionContext = preparePairedExecutionContext({
|
||||||
|
group,
|
||||||
|
chatJid,
|
||||||
|
runId,
|
||||||
|
roomRoleContext,
|
||||||
|
hasHumanMessage: args.hasHumanMessage,
|
||||||
|
pairedTurnIdentity: args.pairedTurnIdentity,
|
||||||
|
});
|
||||||
|
const preparedTurnTaskUpdatedAt = pairedExecutionContext
|
||||||
|
? (pairedExecutionContext.claimedTaskUpdatedAt ??
|
||||||
|
pairedExecutionContext.task.updated_at)
|
||||||
|
: undefined;
|
||||||
|
const runtimePairedTurnIdentity =
|
||||||
|
args.pairedTurnIdentity ??
|
||||||
|
(pairedExecutionContext
|
||||||
|
? resolveRuntimePairedTurnIdentity({
|
||||||
|
taskId: pairedExecutionContext.task.id,
|
||||||
|
taskUpdatedAt:
|
||||||
|
preparedTurnTaskUpdatedAt ?? pairedExecutionContext.task.updated_at,
|
||||||
|
role: activeRole,
|
||||||
|
taskStatus: pairedExecutionContext.task.status,
|
||||||
|
hasHumanMessage: args.hasHumanMessage,
|
||||||
|
})
|
||||||
|
: pairedTask
|
||||||
|
? resolveRuntimePairedTurnIdentity({
|
||||||
|
taskId: pairedTask.id,
|
||||||
|
taskUpdatedAt: pairedTask.updated_at,
|
||||||
|
role: activeRole,
|
||||||
|
taskStatus: pairedTask.status,
|
||||||
|
hasHumanMessage: args.hasHumanMessage,
|
||||||
|
})
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
if (runtimePairedTurnIdentity) {
|
||||||
|
if (runtimePairedTurnIdentity.role !== activeRole) {
|
||||||
|
throw new Error(
|
||||||
|
`Paired turn ${runtimePairedTurnIdentity.turnId} cannot execute as ${activeRole}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
pairedExecutionContext &&
|
||||||
|
runtimePairedTurnIdentity.taskId !== pairedExecutionContext.task.id
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Paired turn ${runtimePairedTurnIdentity.turnId} task_id does not match the prepared execution context`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
pairedExecutionContext &&
|
||||||
|
runtimePairedTurnIdentity.taskUpdatedAt !== preparedTurnTaskUpdatedAt
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Paired turn ${runtimePairedTurnIdentity.turnId} task_updated_at does not match the prepared execution context`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!pairedExecutionContext &&
|
||||||
|
pairedTask &&
|
||||||
|
runtimePairedTurnIdentity.taskId !== pairedTask.id
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Paired turn ${runtimePairedTurnIdentity.turnId} task_id does not match the latest paired task`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!pairedExecutionContext &&
|
||||||
|
pairedTask &&
|
||||||
|
runtimePairedTurnIdentity.taskUpdatedAt !== pairedTask.updated_at
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Paired turn ${runtimePairedTurnIdentity.turnId} task_updated_at does not match the latest paired task`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimePairedTurnIdentity) {
|
||||||
|
markPairedTurnRunning({
|
||||||
|
turnIdentity: runtimePairedTurnIdentity,
|
||||||
|
executorServiceId: effectiveServiceId,
|
||||||
|
executorAgentType: effectiveAgentType,
|
||||||
|
runId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pairedExecutionContext && !args.forcedAgentType) {
|
||||||
|
const roleConfig = getRoleModelConfig(activeRole);
|
||||||
|
if (roleConfig.model) {
|
||||||
|
const modelKey = isClaudeCodeAgent ? 'CLAUDE_MODEL' : 'CODEX_MODEL';
|
||||||
|
pairedExecutionContext.envOverrides[modelKey] = roleConfig.model;
|
||||||
|
}
|
||||||
|
if (roleConfig.effort) {
|
||||||
|
const effortKey = isClaudeCodeAgent ? 'CLAUDE_EFFORT' : 'CODEX_EFFORT';
|
||||||
|
pairedExecutionContext.envOverrides[effortKey] = roleConfig.effort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pairedExecutionContext && runtimePairedTurnIdentity) {
|
||||||
|
pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TURN_ID =
|
||||||
|
runtimePairedTurnIdentity.turnId;
|
||||||
|
pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TURN_ROLE =
|
||||||
|
runtimePairedTurnIdentity.role;
|
||||||
|
pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TURN_INTENT =
|
||||||
|
runtimePairedTurnIdentity.intentKind;
|
||||||
|
pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TASK_UPDATED_AT =
|
||||||
|
runtimePairedTurnIdentity.taskUpdatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = createScopedLogger({
|
||||||
|
chatJid,
|
||||||
|
groupName: group.name,
|
||||||
|
groupFolder: group.folder,
|
||||||
|
runId,
|
||||||
|
messageSeqStart: startSeq ?? undefined,
|
||||||
|
messageSeqEnd: endSeq ?? undefined,
|
||||||
|
role: activeRole,
|
||||||
|
serviceId: effectiveServiceId,
|
||||||
|
turnId: runtimePairedTurnIdentity?.turnId,
|
||||||
|
});
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
forcedRole: args.forcedRole,
|
||||||
|
forcedAgentType: args.forcedAgentType ?? null,
|
||||||
|
inferredRole,
|
||||||
|
canHonorForcedRole,
|
||||||
|
pairedTaskId: pairedTask?.id,
|
||||||
|
pairedTaskStatus: pairedTask?.status,
|
||||||
|
configuredAgentType,
|
||||||
|
effectiveServiceId,
|
||||||
|
effectiveAgentType,
|
||||||
|
groupAgentType: group.agentType,
|
||||||
|
configuredReviewerAgentType: REVIEWER_AGENT_TYPE,
|
||||||
|
configuredArbiterAgentType: ARBITER_AGENT_TYPE,
|
||||||
|
reviewerServiceId,
|
||||||
|
arbiterServiceId,
|
||||||
|
reviewerAgentType: currentLease.reviewer_agent_type,
|
||||||
|
arbiterAgentType: currentLease.arbiter_agent_type,
|
||||||
|
reviewerMode,
|
||||||
|
arbiterMode,
|
||||||
|
sessionFolder,
|
||||||
|
resumedSession: currentSessionId ?? null,
|
||||||
|
},
|
||||||
|
'Resolved execution target for agent turn',
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearRoleSdkSessions = (): void => {
|
||||||
|
const configDir = pairedExecutionContext?.envOverrides?.CLAUDE_CONFIG_DIR;
|
||||||
|
if (!configDir) return;
|
||||||
|
for (const sdkDir of ['.claude', '.codex']) {
|
||||||
|
const sessionsDir = path.join(configDir, sdkDir, 'sessions');
|
||||||
|
if (fs.existsSync(sessionsDir)) {
|
||||||
|
fs.rmSync(sessionsDir, { recursive: true, force: true });
|
||||||
|
log.info({ sessionsDir }, 'Cleared SDK sessions for fresh Claude turn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentLease,
|
||||||
|
pairedTask,
|
||||||
|
activeRole,
|
||||||
|
effectiveServiceId,
|
||||||
|
effectiveAgentType,
|
||||||
|
sessionFolder,
|
||||||
|
reviewerMode,
|
||||||
|
arbiterMode,
|
||||||
|
effectiveGroup,
|
||||||
|
isClaudeCodeAgent,
|
||||||
|
forceFreshClaudeReviewerSession,
|
||||||
|
shouldPersistSession,
|
||||||
|
currentSessionId,
|
||||||
|
provider,
|
||||||
|
memoryBriefing,
|
||||||
|
canRetryClaudeCredentials,
|
||||||
|
roomRoleContext,
|
||||||
|
pairedExecutionContext,
|
||||||
|
runtimePairedTurnIdentity,
|
||||||
|
log,
|
||||||
|
clearRoleSdkSessions,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,67 +1,16 @@
|
|||||||
import fs from 'fs';
|
import { AgentOutput } from './agent-runner.js';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { getErrorMessage } from './utils.js';
|
|
||||||
|
|
||||||
import { getAgentOutputText } from './agent-output.js';
|
|
||||||
import { createEvaluatedOutputHandler } from './agent-attempt.js';
|
|
||||||
import {
|
|
||||||
executeAttemptRetryAction,
|
|
||||||
runClaudeAttemptWithRotation,
|
|
||||||
runCodexAttemptWithRotation,
|
|
||||||
} from './agent-attempt-orchestration.js';
|
|
||||||
import { isRetryableClaudeSessionFailureAttempt } from './agent-attempt-retry.js';
|
|
||||||
import {
|
|
||||||
AgentOutput,
|
|
||||||
runAgentProcess,
|
|
||||||
writeGroupsSnapshot,
|
|
||||||
writeTasksSnapshot,
|
|
||||||
} from './agent-runner.js';
|
|
||||||
import { listAvailableGroups } from './available-groups.js';
|
|
||||||
import {
|
|
||||||
createServiceHandoff,
|
|
||||||
getAllTasks,
|
|
||||||
getLatestOpenPairedTaskForChat,
|
|
||||||
markPairedTurnRunning,
|
|
||||||
} from './db.js';
|
|
||||||
import { GroupQueue } from './group-queue.js';
|
import { GroupQueue } from './group-queue.js';
|
||||||
import { createScopedLogger } from './logger.js';
|
import type { AgentTriggerReason } from './agent-error-detection.js';
|
||||||
import { buildRoomMemoryBriefing } from './sqlite-memory-store.js';
|
import { getMoaConfig } from './config.js';
|
||||||
import { preparePairedExecutionContext } from './paired-execution-context.js';
|
|
||||||
import { resolveCodexFallbackHandoff } from './paired-turn-fallback.js';
|
|
||||||
import { createPairedExecutionLifecycle } from './message-agent-executor-paired.js';
|
import { createPairedExecutionLifecycle } from './message-agent-executor-paired.js';
|
||||||
import {
|
import type { PairedTurnIdentity } from './paired-turn-identity.js';
|
||||||
resolveRuntimePairedTurnIdentity,
|
import { executeMessageAgentAttemptLifecycle } from './message-agent-executor-lifecycle.js';
|
||||||
type PairedTurnIdentity,
|
import { runMessageAgentAttempt } from './message-agent-executor-attempt-runner.js';
|
||||||
} from './paired-turn-identity.js';
|
import { handoffMessageAgentExecutionToCodex } from './message-agent-executor-handoff.js';
|
||||||
import { resolveExecutionTarget } from './message-runtime-rules.js';
|
import { prepareMessageAgentExecutionTarget } from './message-agent-executor-target.js';
|
||||||
import { buildRoomRoleContext } from './room-role-context.js';
|
|
||||||
import { type AgentTriggerReason } from './agent-error-detection.js';
|
|
||||||
import {
|
|
||||||
shouldResetSessionOnAgentFailure,
|
|
||||||
shouldRetryFreshSessionOnAgentFailure,
|
|
||||||
} from './session-recovery.js';
|
|
||||||
import {
|
|
||||||
ARBITER_AGENT_TYPE,
|
|
||||||
CODEX_REVIEW_SERVICE_ID,
|
|
||||||
REVIEWER_AGENT_TYPE,
|
|
||||||
TIMEZONE,
|
|
||||||
isClaudeService,
|
|
||||||
getRoleModelConfig,
|
|
||||||
getMoaConfig,
|
|
||||||
} from './config.js';
|
|
||||||
import { collectMoaReferences, formatMoaReferencesForPrompt } from './moa.js';
|
import { collectMoaReferences, formatMoaReferencesForPrompt } from './moa.js';
|
||||||
import { readArbiterPrompt } from './platform-prompts.js';
|
import { readArbiterPrompt } from './platform-prompts.js';
|
||||||
import {
|
import { shouldRetryFreshSessionOnAgentFailure } from './session-recovery.js';
|
||||||
activateCodexFailover,
|
|
||||||
getEffectiveChannelLease,
|
|
||||||
} from './service-routing.js';
|
|
||||||
import {
|
|
||||||
detectCodexRotationTrigger,
|
|
||||||
getCodexAccountCount,
|
|
||||||
} from './codex-token-rotation.js';
|
|
||||||
import type { CodexRotationReason } from './agent-error-detection.js';
|
|
||||||
import { getTokenCount } from './token-rotation.js';
|
|
||||||
import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js';
|
import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
// ── Main executor ─────────────────────────────────────────────────
|
// ── Main executor ─────────────────────────────────────────────────
|
||||||
@@ -94,255 +43,39 @@ export async function runAgentForGroup(
|
|||||||
): Promise<'success' | 'error'> {
|
): Promise<'success' | 'error'> {
|
||||||
const { group, prompt, chatJid, runId, startSeq, endSeq, onOutput } = args;
|
const { group, prompt, chatJid, runId, startSeq, endSeq, onOutput } = args;
|
||||||
const isMain = group.isMain === true;
|
const isMain = group.isMain === true;
|
||||||
const sessions = deps.getSessions();
|
const preparedExecution = await prepareMessageAgentExecutionTarget(deps, {
|
||||||
|
group,
|
||||||
const currentLease = getEffectiveChannelLease(chatJid);
|
prompt,
|
||||||
|
chatJid,
|
||||||
// In unified mode, determine role from the lease directly.
|
runId,
|
||||||
// Default to owner; the auto-review trigger in completePairedExecutionContext
|
startSeq,
|
||||||
// will switch to reviewer when the task is in review_ready state.
|
endSeq,
|
||||||
const pairedTask = currentLease.reviewer_agent_type
|
hasHumanMessage: args.hasHumanMessage,
|
||||||
? getLatestOpenPairedTaskForChat(chatJid)
|
|
||||||
: null;
|
|
||||||
const executionTarget = resolveExecutionTarget({
|
|
||||||
lease: currentLease,
|
|
||||||
pairedTaskStatus: pairedTask?.status,
|
|
||||||
groupFolder: group.folder,
|
|
||||||
groupAgentType: group.agentType,
|
|
||||||
forcedRole: args.forcedRole,
|
forcedRole: args.forcedRole,
|
||||||
forcedAgentType: args.forcedAgentType,
|
forcedAgentType: args.forcedAgentType,
|
||||||
});
|
|
||||||
const {
|
|
||||||
inferredRole,
|
|
||||||
canHonorForcedRole,
|
|
||||||
activeRole,
|
|
||||||
effectiveServiceId,
|
|
||||||
reviewerServiceId,
|
|
||||||
arbiterServiceId,
|
|
||||||
roleAgentPlan,
|
|
||||||
configuredAgentType,
|
|
||||||
effectiveAgentType,
|
|
||||||
sessionFolder,
|
|
||||||
} = executionTarget;
|
|
||||||
const reviewerMode = activeRole === 'reviewer';
|
|
||||||
const arbiterMode = activeRole === 'arbiter';
|
|
||||||
const effectiveGroup =
|
|
||||||
effectiveAgentType !== roleAgentPlan.ownerAgentType
|
|
||||||
? { ...group, agentType: effectiveAgentType }
|
|
||||||
: group;
|
|
||||||
const isClaudeCodeAgent = effectiveAgentType === 'claude-code';
|
|
||||||
const unsafeHostPairedMode =
|
|
||||||
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1';
|
|
||||||
const forceFreshClaudeReviewerSession =
|
|
||||||
reviewerMode && isClaudeCodeAgent && unsafeHostPairedMode;
|
|
||||||
const shouldPersistSession =
|
|
||||||
activeRole !== 'arbiter' &&
|
|
||||||
!args.forcedAgentType &&
|
|
||||||
!forceFreshClaudeReviewerSession;
|
|
||||||
const storedSessionId = sessions[sessionFolder];
|
|
||||||
if (forceFreshClaudeReviewerSession && storedSessionId) {
|
|
||||||
deps.clearSession(sessionFolder);
|
|
||||||
}
|
|
||||||
// Arbiter always starts fresh — never resume a previous session
|
|
||||||
let currentSessionId =
|
|
||||||
activeRole === 'arbiter' ||
|
|
||||||
args.forcedAgentType ||
|
|
||||||
forceFreshClaudeReviewerSession
|
|
||||||
? undefined
|
|
||||||
: storedSessionId;
|
|
||||||
const provider = isClaudeCodeAgent ? 'claude' : 'codex';
|
|
||||||
const memoryBriefing = currentSessionId
|
|
||||||
? undefined
|
|
||||||
: await buildRoomMemoryBriefing({
|
|
||||||
groupFolder: group.folder,
|
|
||||||
groupName: group.name,
|
|
||||||
}).catch(() => undefined);
|
|
||||||
|
|
||||||
const tasks = getAllTasks(roleAgentPlan.ownerAgentType);
|
|
||||||
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,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
writeGroupsSnapshot(
|
|
||||||
group.folder,
|
|
||||||
isMain,
|
|
||||||
listAvailableGroups(deps.getRoomBindings()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let resetSessionRequested = false;
|
|
||||||
|
|
||||||
const claudeTokenCount = isClaudeCodeAgent ? getTokenCount() : 0;
|
|
||||||
const canRetryClaudeCredentials = isClaudeCodeAgent && claudeTokenCount > 0;
|
|
||||||
const canRotateToken = isClaudeCodeAgent && claudeTokenCount > 1;
|
|
||||||
const roomRoleContext = buildRoomRoleContext(
|
|
||||||
currentLease,
|
|
||||||
effectiveServiceId,
|
|
||||||
activeRole,
|
|
||||||
);
|
|
||||||
const pairedExecutionContext = preparePairedExecutionContext({
|
|
||||||
group,
|
|
||||||
chatJid,
|
|
||||||
runId,
|
|
||||||
roomRoleContext,
|
|
||||||
hasHumanMessage: args.hasHumanMessage,
|
|
||||||
pairedTurnIdentity: args.pairedTurnIdentity,
|
pairedTurnIdentity: args.pairedTurnIdentity,
|
||||||
});
|
});
|
||||||
const preparedTurnTaskUpdatedAt = pairedExecutionContext
|
const {
|
||||||
? (pairedExecutionContext.claimedTaskUpdatedAt ??
|
currentLease,
|
||||||
pairedExecutionContext.task.updated_at)
|
activeRole,
|
||||||
: undefined;
|
effectiveServiceId,
|
||||||
const runtimePairedTurnIdentity =
|
effectiveAgentType,
|
||||||
args.pairedTurnIdentity ??
|
sessionFolder,
|
||||||
(pairedExecutionContext
|
arbiterMode,
|
||||||
? resolveRuntimePairedTurnIdentity({
|
effectiveGroup,
|
||||||
taskId: pairedExecutionContext.task.id,
|
isClaudeCodeAgent,
|
||||||
taskUpdatedAt:
|
forceFreshClaudeReviewerSession,
|
||||||
preparedTurnTaskUpdatedAt ?? pairedExecutionContext.task.updated_at,
|
shouldPersistSession,
|
||||||
role: activeRole,
|
provider,
|
||||||
taskStatus: pairedExecutionContext.task.status,
|
memoryBriefing,
|
||||||
hasHumanMessage: args.hasHumanMessage,
|
canRetryClaudeCredentials,
|
||||||
})
|
roomRoleContext,
|
||||||
: pairedTask
|
pairedExecutionContext,
|
||||||
? resolveRuntimePairedTurnIdentity({
|
runtimePairedTurnIdentity,
|
||||||
taskId: pairedTask.id,
|
log,
|
||||||
taskUpdatedAt: pairedTask.updated_at,
|
clearRoleSdkSessions,
|
||||||
role: activeRole,
|
} = preparedExecution;
|
||||||
taskStatus: pairedTask.status,
|
let currentSessionId = preparedExecution.currentSessionId;
|
||||||
hasHumanMessage: args.hasHumanMessage,
|
|
||||||
})
|
|
||||||
: undefined);
|
|
||||||
if (runtimePairedTurnIdentity) {
|
|
||||||
if (runtimePairedTurnIdentity.role !== activeRole) {
|
|
||||||
throw new Error(
|
|
||||||
`Paired turn ${runtimePairedTurnIdentity.turnId} cannot execute as ${activeRole}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
pairedExecutionContext &&
|
|
||||||
runtimePairedTurnIdentity.taskId !== pairedExecutionContext.task.id
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Paired turn ${runtimePairedTurnIdentity.turnId} task_id does not match the prepared execution context`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
pairedExecutionContext &&
|
|
||||||
runtimePairedTurnIdentity.taskUpdatedAt !== preparedTurnTaskUpdatedAt
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Paired turn ${runtimePairedTurnIdentity.turnId} task_updated_at does not match the prepared execution context`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!pairedExecutionContext &&
|
|
||||||
pairedTask &&
|
|
||||||
runtimePairedTurnIdentity.taskId !== pairedTask.id
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Paired turn ${runtimePairedTurnIdentity.turnId} task_id does not match the latest paired task`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!pairedExecutionContext &&
|
|
||||||
pairedTask &&
|
|
||||||
runtimePairedTurnIdentity.taskUpdatedAt !== pairedTask.updated_at
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Paired turn ${runtimePairedTurnIdentity.turnId} task_updated_at does not match the latest paired task`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (runtimePairedTurnIdentity) {
|
|
||||||
markPairedTurnRunning({
|
|
||||||
turnIdentity: runtimePairedTurnIdentity,
|
|
||||||
executorServiceId: effectiveServiceId,
|
|
||||||
executorAgentType: effectiveAgentType,
|
|
||||||
runId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Forced fallbacks run under a different agent runtime, so keep the
|
|
||||||
// fallback session on its default model/effort unless explicitly configured
|
|
||||||
// for that runtime elsewhere.
|
|
||||||
if (pairedExecutionContext && !args.forcedAgentType) {
|
|
||||||
const roleConfig = getRoleModelConfig(activeRole);
|
|
||||||
if (roleConfig.model) {
|
|
||||||
const modelKey = isClaudeCodeAgent ? 'CLAUDE_MODEL' : 'CODEX_MODEL';
|
|
||||||
pairedExecutionContext.envOverrides[modelKey] = roleConfig.model;
|
|
||||||
}
|
|
||||||
if (roleConfig.effort) {
|
|
||||||
const effortKey = isClaudeCodeAgent ? 'CLAUDE_EFFORT' : 'CODEX_EFFORT';
|
|
||||||
pairedExecutionContext.envOverrides[effortKey] = roleConfig.effort;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (pairedExecutionContext && runtimePairedTurnIdentity) {
|
|
||||||
pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TURN_ID =
|
|
||||||
runtimePairedTurnIdentity.turnId;
|
|
||||||
pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TURN_ROLE =
|
|
||||||
runtimePairedTurnIdentity.role;
|
|
||||||
pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TURN_INTENT =
|
|
||||||
runtimePairedTurnIdentity.intentKind;
|
|
||||||
pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TASK_UPDATED_AT =
|
|
||||||
runtimePairedTurnIdentity.taskUpdatedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
const log = createScopedLogger({
|
|
||||||
chatJid,
|
|
||||||
groupName: group.name,
|
|
||||||
groupFolder: group.folder,
|
|
||||||
runId,
|
|
||||||
messageSeqStart: startSeq ?? undefined,
|
|
||||||
messageSeqEnd: endSeq ?? undefined,
|
|
||||||
role: activeRole,
|
|
||||||
serviceId: effectiveServiceId,
|
|
||||||
turnId: runtimePairedTurnIdentity?.turnId,
|
|
||||||
});
|
|
||||||
log.info(
|
|
||||||
{
|
|
||||||
forcedRole: args.forcedRole,
|
|
||||||
forcedAgentType: args.forcedAgentType ?? null,
|
|
||||||
inferredRole,
|
|
||||||
canHonorForcedRole,
|
|
||||||
pairedTaskId: pairedTask?.id,
|
|
||||||
pairedTaskStatus: pairedTask?.status,
|
|
||||||
configuredAgentType,
|
|
||||||
effectiveServiceId,
|
|
||||||
effectiveAgentType,
|
|
||||||
groupAgentType: group.agentType,
|
|
||||||
configuredReviewerAgentType: REVIEWER_AGENT_TYPE,
|
|
||||||
configuredArbiterAgentType: ARBITER_AGENT_TYPE,
|
|
||||||
reviewerServiceId,
|
|
||||||
arbiterServiceId,
|
|
||||||
reviewerAgentType: currentLease.reviewer_agent_type,
|
|
||||||
arbiterAgentType: currentLease.arbiter_agent_type,
|
|
||||||
reviewerMode,
|
|
||||||
arbiterMode,
|
|
||||||
sessionFolder,
|
|
||||||
resumedSession: currentSessionId ?? null,
|
|
||||||
},
|
|
||||||
'Resolved execution target for agent turn',
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearRoleSdkSessions = (): void => {
|
|
||||||
const configDir = pairedExecutionContext?.envOverrides?.CLAUDE_CONFIG_DIR;
|
|
||||||
if (!configDir) return;
|
|
||||||
for (const sdkDir of ['.claude', '.codex']) {
|
|
||||||
const sessionsDir = path.join(configDir, sdkDir, 'sessions');
|
|
||||||
if (fs.existsSync(sessionsDir)) {
|
|
||||||
fs.rmSync(sessionsDir, { recursive: true, force: true });
|
|
||||||
log.info({ sessionsDir }, 'Cleared SDK sessions for fresh Claude turn');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (forceFreshClaudeReviewerSession) {
|
if (forceFreshClaudeReviewerSession) {
|
||||||
clearRoleSdkSessions();
|
clearRoleSdkSessions();
|
||||||
@@ -422,50 +155,24 @@ export async function runAgentForGroup(
|
|||||||
const maybeHandoffToCodex = (
|
const maybeHandoffToCodex = (
|
||||||
reason: AgentTriggerReason,
|
reason: AgentTriggerReason,
|
||||||
sawVisibleOutput: boolean,
|
sawVisibleOutput: boolean,
|
||||||
): boolean => {
|
): boolean =>
|
||||||
if (!isClaudeCodeAgent) return false;
|
isClaudeCodeAgent &&
|
||||||
const handoffResolution = resolveCodexFallbackHandoff({
|
handoffMessageAgentExecutionToCodex({
|
||||||
activeRole,
|
activeRole,
|
||||||
effectiveAgentType,
|
effectiveAgentType,
|
||||||
hasReviewer: currentLease.reviewer_agent_type !== null,
|
hasReviewer: currentLease.reviewer_agent_type !== null,
|
||||||
fallbackEnabled: getRoleModelConfig(activeRole).fallbackEnabled,
|
|
||||||
reason,
|
reason,
|
||||||
sawVisibleOutput,
|
sawVisibleOutput,
|
||||||
prompt,
|
prompt,
|
||||||
startSeq,
|
startSeq,
|
||||||
endSeq,
|
endSeq,
|
||||||
|
chatJid,
|
||||||
|
group,
|
||||||
|
pairedTurnIdentity: runtimePairedTurnIdentity,
|
||||||
|
markDelegated: () => pairedExecutionLifecycle.markDelegated(),
|
||||||
|
log,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (handoffResolution.type === 'none') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (handoffResolution.type === 'skip') {
|
|
||||||
log.info({ reason }, handoffResolution.logMessage);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (handoffResolution.plan.activateOwnerFailoverReason) {
|
|
||||||
activateCodexFailover(
|
|
||||||
chatJid,
|
|
||||||
handoffResolution.plan.activateOwnerFailoverReason,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
createServiceHandoff({
|
|
||||||
chat_jid: chatJid,
|
|
||||||
group_folder: group.folder,
|
|
||||||
paired_task_id: runtimePairedTurnIdentity?.taskId,
|
|
||||||
paired_task_updated_at: runtimePairedTurnIdentity?.taskUpdatedAt,
|
|
||||||
turn_id: runtimePairedTurnIdentity?.turnId,
|
|
||||||
turn_intent_kind: runtimePairedTurnIdentity?.intentKind,
|
|
||||||
turn_role: runtimePairedTurnIdentity?.role,
|
|
||||||
...handoffResolution.plan.handoff,
|
|
||||||
});
|
|
||||||
pairedExecutionLifecycle.markDelegated();
|
|
||||||
log.warn({ reason }, handoffResolution.plan.logMessage);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const agentInput = {
|
const agentInput = {
|
||||||
prompt: effectivePrompt,
|
prompt: effectivePrompt,
|
||||||
sessionId: currentSessionId,
|
sessionId: currentSessionId,
|
||||||
@@ -502,529 +209,57 @@ export async function runAgentForGroup(
|
|||||||
return 'success';
|
return 'success';
|
||||||
}
|
}
|
||||||
|
|
||||||
const runAttempt = async (
|
const runAttempt = (currentProvider: 'claude' | 'codex') =>
|
||||||
provider: string,
|
runMessageAgentAttempt({
|
||||||
): Promise<{
|
provider: currentProvider,
|
||||||
output?: AgentOutput;
|
currentSessionId,
|
||||||
error?: unknown;
|
isClaudeCodeAgent,
|
||||||
sawOutput: boolean;
|
canRetryClaudeCredentials,
|
||||||
sawVisibleOutput: boolean;
|
shouldPersistSession,
|
||||||
sawSuccessNullResultWithoutOutput: boolean;
|
effectiveGroup,
|
||||||
retryableSessionFailureDetected: boolean;
|
agentInput,
|
||||||
streamedTriggerReason?: {
|
activeRole,
|
||||||
reason: AgentTriggerReason;
|
effectiveServiceId,
|
||||||
retryAfterMs?: number;
|
effectiveAgentType,
|
||||||
};
|
sessionFolder,
|
||||||
}> => {
|
roomRoleContext,
|
||||||
const attemptSessionId = currentSessionId;
|
pairedExecutionContext,
|
||||||
const streamedOutputHandler = createEvaluatedOutputHandler({
|
fallbackWorkspaceDir: group.workDir ?? null,
|
||||||
agentType: isClaudeCodeAgent ? 'claude-code' : 'codex',
|
onPersistSession: (sessionId) => {
|
||||||
provider,
|
deps.persistSession(sessionFolder, sessionId);
|
||||||
evaluationOptions: {
|
currentSessionId = sessionId;
|
||||||
suppressClaudeAuthErrorOutput: provider === 'claude',
|
|
||||||
trackSuccessNullResult: true,
|
|
||||||
shortCircuitTriggeredErrors:
|
|
||||||
provider === 'claude'
|
|
||||||
? canRetryClaudeCredentials
|
|
||||||
: getCodexAccountCount() > 1,
|
|
||||||
},
|
|
||||||
onEvaluatedOutput: async ({
|
|
||||||
output,
|
|
||||||
outputText,
|
|
||||||
structuredOutput,
|
|
||||||
evaluation,
|
|
||||||
}) => {
|
|
||||||
const outputPhase = output.phase ?? 'final';
|
|
||||||
if (outputPhase !== 'final') {
|
|
||||||
log.info(
|
|
||||||
{
|
|
||||||
provider,
|
|
||||||
outputPhase,
|
|
||||||
outputStatus: output.status,
|
|
||||||
visibility: structuredOutput?.visibility ?? null,
|
|
||||||
preview:
|
|
||||||
outputText && outputText.length > 0
|
|
||||||
? outputText.slice(0, 160)
|
|
||||||
: null,
|
|
||||||
errorPreview:
|
|
||||||
typeof output.error === 'string' && output.error.length > 0
|
|
||||||
? output.error.slice(0, 160)
|
|
||||||
: null,
|
|
||||||
activeRole,
|
|
||||||
effectiveServiceId,
|
|
||||||
effectiveAgentType,
|
|
||||||
sessionFolder,
|
|
||||||
resumedSession: attemptSessionId ?? null,
|
|
||||||
streamedSessionId: output.newSessionId ?? null,
|
|
||||||
roomRoleServiceId: roomRoleContext?.serviceId ?? null,
|
|
||||||
roomRole: roomRoleContext?.role ?? null,
|
|
||||||
pairedTaskId: pairedExecutionContext?.task.id ?? null,
|
|
||||||
workspaceDir:
|
|
||||||
pairedExecutionContext?.workspace?.workspace_dir ??
|
|
||||||
group.workDir ??
|
|
||||||
null,
|
|
||||||
},
|
|
||||||
'Observed streamed agent activity',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
isClaudeCodeAgent &&
|
|
||||||
provider === 'claude' &&
|
|
||||||
shouldResetSessionOnAgentFailure(output)
|
|
||||||
) {
|
|
||||||
resetSessionRequested = true;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
output.newSessionId &&
|
|
||||||
!resetSessionRequested &&
|
|
||||||
shouldPersistSession
|
|
||||||
) {
|
|
||||||
deps.persistSession(sessionFolder, output.newSessionId);
|
|
||||||
currentSessionId = output.newSessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
pairedExecutionLifecycle.updateSummary({
|
|
||||||
outputText,
|
|
||||||
errorText: typeof output.error === 'string' ? output.error : null,
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
evaluation.newTrigger &&
|
|
||||||
outputText &&
|
|
||||||
output.status === 'success'
|
|
||||||
) {
|
|
||||||
log.warn(
|
|
||||||
{
|
|
||||||
reason: evaluation.newTrigger.reason,
|
|
||||||
resultPreview: outputText.slice(0, 120),
|
|
||||||
},
|
|
||||||
'Detected Claude rotation trigger in successful output',
|
|
||||||
);
|
|
||||||
} else if (evaluation.newTrigger && typeof output.error === 'string') {
|
|
||||||
log.warn(
|
|
||||||
{
|
|
||||||
reason: evaluation.newTrigger.reason,
|
|
||||||
errorPreview: output.error.slice(0, 120),
|
|
||||||
},
|
|
||||||
provider === 'claude'
|
|
||||||
? 'Detected Claude rotation trigger in streamed error output'
|
|
||||||
: 'Detected Codex rotation trigger in streamed error output',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (evaluation.suppressedAuthError) {
|
|
||||||
log.warn(
|
|
||||||
{
|
|
||||||
resultPreview: outputText ? outputText.slice(0, 120) : undefined,
|
|
||||||
},
|
|
||||||
'Suppressed Claude 401 auth error from chat output',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (evaluation.suppressedRetryableSessionFailure) {
|
|
||||||
log.warn(
|
|
||||||
{
|
|
||||||
resultPreview: outputText
|
|
||||||
? outputText.slice(0, 160)
|
|
||||||
: output.error?.slice(0, 160),
|
|
||||||
},
|
|
||||||
'Suppressed retryable Claude session failure from chat output',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!evaluation.shouldForwardOutput) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (outputText && outputText.length > 0) {
|
|
||||||
streamedOutputHandler.markVisibleOutput();
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
outputPhase === 'final' &&
|
|
||||||
output.status === 'success' &&
|
|
||||||
outputText &&
|
|
||||||
outputText.length > 0
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
pairedExecutionLifecycle.recordFinalOutputBeforeDelivery(
|
|
||||||
outputText,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(
|
|
||||||
{ pairedTaskId: pairedExecutionContext?.task.id ?? null, err },
|
|
||||||
'Failed to persist paired turn output and status before delivery',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (onOutput) {
|
|
||||||
await onOutput(output);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
registerProcess: (proc, processName, ipcDir) =>
|
||||||
|
deps.queue.registerProcess(chatJid, proc, processName, ipcDir),
|
||||||
|
onOutput,
|
||||||
|
pairedExecutionLifecycle,
|
||||||
|
log,
|
||||||
});
|
});
|
||||||
|
|
||||||
const wrappedOnOutput = async (output: AgentOutput) => {
|
try {
|
||||||
await streamedOutputHandler.handleOutput(output);
|
return await executeMessageAgentAttemptLifecycle({
|
||||||
};
|
|
||||||
|
|
||||||
const providerLog = log.child({
|
|
||||||
provider,
|
provider,
|
||||||
agentType: effectiveAgentType,
|
runAttempt,
|
||||||
});
|
isClaudeCodeAgent,
|
||||||
providerLog.info('Using provider');
|
canRetryClaudeCredentials,
|
||||||
|
clearStoredSession: () => {
|
||||||
try {
|
deps.clearSession(sessionFolder);
|
||||||
const output = await runAgentProcess(
|
currentSessionId = undefined;
|
||||||
effectiveGroup,
|
},
|
||||||
{
|
clearRoleSdkSessions,
|
||||||
...agentInput,
|
sessionFolder,
|
||||||
sessionId: attemptSessionId,
|
maybeHandoffToCodex,
|
||||||
},
|
hasDirectTerminalDelivery,
|
||||||
(proc, processName, ipcDir) =>
|
pairedExecutionLifecycle,
|
||||||
deps.queue.registerProcess(chatJid, proc, processName, ipcDir),
|
shouldRetryFreshSessionOnAgentFailure,
|
||||||
wrappedOnOutput,
|
rotationLogContext: {
|
||||||
pairedExecutionContext?.envOverrides,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (output.newSessionId && shouldPersistSession) {
|
|
||||||
deps.persistSession(sessionFolder, output.newSessionId);
|
|
||||||
currentSessionId = output.newSessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
providerLog.info(
|
|
||||||
{
|
|
||||||
status: output.status,
|
|
||||||
sawOutput: streamedOutputHandler.getState().sawOutput,
|
|
||||||
},
|
|
||||||
`Provider response completed (provider: ${provider})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const streamedState = streamedOutputHandler.getState();
|
|
||||||
return {
|
|
||||||
output,
|
|
||||||
sawOutput: streamedState.sawOutput,
|
|
||||||
sawVisibleOutput: streamedState.sawVisibleOutput,
|
|
||||||
sawSuccessNullResultWithoutOutput:
|
|
||||||
streamedState.sawSuccessNullResultWithoutOutput,
|
|
||||||
retryableSessionFailureDetected:
|
|
||||||
streamedState.retryableSessionFailureDetected === true,
|
|
||||||
streamedTriggerReason: streamedState.streamedTriggerReason,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const streamedState = streamedOutputHandler.getState();
|
|
||||||
return {
|
|
||||||
error,
|
|
||||||
sawOutput: streamedState.sawOutput,
|
|
||||||
sawVisibleOutput: streamedState.sawVisibleOutput,
|
|
||||||
sawSuccessNullResultWithoutOutput:
|
|
||||||
streamedState.sawSuccessNullResultWithoutOutput,
|
|
||||||
retryableSessionFailureDetected:
|
|
||||||
streamedState.retryableSessionFailureDetected === true,
|
|
||||||
streamedTriggerReason: streamedState.streamedTriggerReason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const retryCodexWithRotation = async (
|
|
||||||
initialTrigger: { reason: CodexRotationReason },
|
|
||||||
rotationMessage?: string,
|
|
||||||
): Promise<'success' | 'error'> => {
|
|
||||||
return runCodexAttemptWithRotation({
|
|
||||||
initialTrigger,
|
|
||||||
runAttempt: () => runAttempt('codex'),
|
|
||||||
logContext: {
|
|
||||||
chatJid,
|
chatJid,
|
||||||
group: group.name,
|
group: group.name,
|
||||||
groupFolder: group.folder,
|
groupFolder: group.folder,
|
||||||
runId,
|
runId,
|
||||||
},
|
},
|
||||||
rotationMessage,
|
log,
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
type AgentAttempt = Awaited<ReturnType<typeof runAttempt>>;
|
|
||||||
|
|
||||||
const retryClaudeWithRotation = async (
|
|
||||||
initialTrigger: {
|
|
||||||
reason: AgentTriggerReason;
|
|
||||||
retryAfterMs?: number;
|
|
||||||
},
|
|
||||||
rotationMessage?: string,
|
|
||||||
): Promise<'success' | 'error'> => {
|
|
||||||
const logCtx = {
|
|
||||||
chatJid,
|
|
||||||
group: group.name,
|
|
||||||
groupFolder: group.folder,
|
|
||||||
runId,
|
|
||||||
};
|
|
||||||
|
|
||||||
return runClaudeAttemptWithRotation({
|
|
||||||
initialTrigger,
|
|
||||||
runAttempt: () => runAttempt('claude'),
|
|
||||||
logContext: logCtx,
|
|
||||||
rotationMessage,
|
|
||||||
onSuccess: ({ sawOutput }) => {
|
|
||||||
pairedExecutionLifecycle.markSawOutput(sawOutput);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const retryClaudeAttemptIfNeeded = async (
|
|
||||||
attempt: AgentAttempt,
|
|
||||||
rotationMessage?: string | null,
|
|
||||||
): Promise<'success' | 'error' | null> => {
|
|
||||||
const retryAction = await executeAttemptRetryAction({
|
|
||||||
provider,
|
|
||||||
canRetryClaudeCredentials,
|
|
||||||
canRetryCodex: false,
|
|
||||||
attempt,
|
|
||||||
rotationMessage,
|
|
||||||
runClaude: retryClaudeWithRotation,
|
|
||||||
runCodex: retryCodexWithRotation,
|
|
||||||
});
|
|
||||||
if (retryAction.kind !== 'claude') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (retryAction.result === 'error') {
|
|
||||||
return maybeHandoffAfterError(retryAction.trigger.reason, attempt);
|
|
||||||
}
|
|
||||||
|
|
||||||
pairedExecutionLifecycle.markStatus('succeeded');
|
|
||||||
return retryAction.result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const retryCodexAttemptIfNeeded = async (
|
|
||||||
attempt: AgentAttempt,
|
|
||||||
rotationMessage?: string | null,
|
|
||||||
): Promise<'success' | 'error' | null> => {
|
|
||||||
const retryAction = await executeAttemptRetryAction({
|
|
||||||
provider,
|
|
||||||
canRetryClaudeCredentials: false,
|
|
||||||
canRetryCodex: !isClaudeCodeAgent && getCodexAccountCount() > 1,
|
|
||||||
attempt,
|
|
||||||
rotationMessage,
|
|
||||||
runClaude: retryClaudeWithRotation,
|
|
||||||
runCodex: retryCodexWithRotation,
|
|
||||||
});
|
|
||||||
if (retryAction.kind !== 'codex') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (retryAction.result === 'success') {
|
|
||||||
pairedExecutionLifecycle.markStatus('succeeded');
|
|
||||||
}
|
|
||||||
return retryAction.result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const maybeHandoffAfterError = (
|
|
||||||
reason: AgentTriggerReason,
|
|
||||||
attempt: AgentAttempt,
|
|
||||||
): 'success' | 'error' => {
|
|
||||||
if (maybeHandoffToCodex(reason, attempt.sawVisibleOutput)) {
|
|
||||||
return 'success';
|
|
||||||
}
|
|
||||||
return 'error';
|
|
||||||
};
|
|
||||||
|
|
||||||
const isRetryableClaudeSessionFailure = (attempt: AgentAttempt): boolean =>
|
|
||||||
isRetryableClaudeSessionFailureAttempt({
|
|
||||||
attempt,
|
|
||||||
isClaudeCodeAgent,
|
|
||||||
provider,
|
|
||||||
shouldRetryFreshSessionOnAgentFailure,
|
|
||||||
});
|
|
||||||
|
|
||||||
const recoverRetryableClaudeSessionFailure = async (
|
|
||||||
attempt: AgentAttempt,
|
|
||||||
): Promise<{
|
|
||||||
attempt: AgentAttempt;
|
|
||||||
resolved: 'success' | 'error' | null;
|
|
||||||
}> => {
|
|
||||||
if (!isRetryableClaudeSessionFailure(attempt)) {
|
|
||||||
return { attempt, resolved: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
currentSessionId = undefined;
|
|
||||||
deps.clearSession(sessionFolder);
|
|
||||||
clearRoleSdkSessions();
|
|
||||||
log.warn(
|
|
||||||
'Cleared poisoned Claude session before visible output, retrying fresh session',
|
|
||||||
);
|
|
||||||
|
|
||||||
const freshAttempt = await runAttempt('claude');
|
|
||||||
if (!isRetryableClaudeSessionFailure(freshAttempt)) {
|
|
||||||
return { attempt: freshAttempt, resolved: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
currentSessionId = undefined;
|
|
||||||
deps.clearSession(sessionFolder);
|
|
||||||
log.warn('Fresh Claude retry also hit a retryable session failure');
|
|
||||||
log.error('Retryable Claude session failure persisted after fresh retry');
|
|
||||||
return {
|
|
||||||
attempt: freshAttempt,
|
|
||||||
resolved: maybeHandoffAfterError('session-failure', freshAttempt),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrimaryAttemptFailure = async (
|
|
||||||
attempt: AgentAttempt,
|
|
||||||
rotationMessage: string,
|
|
||||||
): Promise<'success' | 'error'> => {
|
|
||||||
const claudeRetryResult = await retryClaudeAttemptIfNeeded(
|
|
||||||
attempt,
|
|
||||||
rotationMessage,
|
|
||||||
);
|
|
||||||
if (claudeRetryResult) {
|
|
||||||
return claudeRetryResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
const codexRetryResult = await retryCodexAttemptIfNeeded(
|
|
||||||
attempt,
|
|
||||||
rotationMessage,
|
|
||||||
);
|
|
||||||
if (codexRetryResult) {
|
|
||||||
return codexRetryResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt.error) {
|
|
||||||
log.error(
|
|
||||||
{
|
|
||||||
provider,
|
|
||||||
err: attempt.error,
|
|
||||||
},
|
|
||||||
'Agent error',
|
|
||||||
);
|
|
||||||
return 'error';
|
|
||||||
}
|
|
||||||
|
|
||||||
log.error(
|
|
||||||
{
|
|
||||||
provider,
|
|
||||||
error: attempt.output?.error,
|
|
||||||
},
|
|
||||||
'Agent process error',
|
|
||||||
);
|
|
||||||
return 'error';
|
|
||||||
};
|
|
||||||
|
|
||||||
const finalizePrimaryAttempt = async (
|
|
||||||
attempt: AgentAttempt,
|
|
||||||
): Promise<'success' | 'error'> => {
|
|
||||||
const output = attempt.output;
|
|
||||||
if (!output) {
|
|
||||||
log.error({ provider }, 'Agent produced no output object');
|
|
||||||
return 'error';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pairedExecutionLifecycle.getSummary()) {
|
|
||||||
const finalOutputText = getAgentOutputText(output);
|
|
||||||
pairedExecutionLifecycle.updateSummary({
|
|
||||||
outputText:
|
|
||||||
typeof finalOutputText === 'string' && finalOutputText.length > 0
|
|
||||||
? finalOutputText
|
|
||||||
: null,
|
|
||||||
errorText:
|
|
||||||
typeof output.error === 'string' && output.error.length > 0
|
|
||||||
? output.error
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!attempt.sawOutput &&
|
|
||||||
!hasDirectTerminalDelivery() &&
|
|
||||||
output.status !== 'error'
|
|
||||||
) {
|
|
||||||
const claudeRetryResult = await retryClaudeAttemptIfNeeded(attempt);
|
|
||||||
if (claudeRetryResult) {
|
|
||||||
return claudeRetryResult;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
isClaudeCodeAgent &&
|
|
||||||
(resetSessionRequested || shouldResetSessionOnAgentFailure(output))
|
|
||||||
) {
|
|
||||||
deps.clearSession(sessionFolder);
|
|
||||||
log.warn(
|
|
||||||
{ sessionFolder },
|
|
||||||
'Cleared poisoned agent session after unrecoverable error',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (output.status === 'error') {
|
|
||||||
return handlePrimaryAttemptFailure(
|
|
||||||
attempt,
|
|
||||||
output.error ?? 'Agent process error',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const codexRetryResult = await retryCodexAttemptIfNeeded(
|
|
||||||
attempt,
|
|
||||||
output.error ?? output.result,
|
|
||||||
);
|
|
||||||
if (codexRetryResult) {
|
|
||||||
return codexRetryResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unresolved streamed trigger — rotation was unavailable or output was
|
|
||||||
// already forwarded. Surfaces as an error since there is no alternative provider.
|
|
||||||
if (attempt.streamedTriggerReason) {
|
|
||||||
if (
|
|
||||||
isClaudeCodeAgent &&
|
|
||||||
maybeHandoffToCodex(
|
|
||||||
attempt.streamedTriggerReason.reason,
|
|
||||||
attempt.sawVisibleOutput,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return 'success';
|
|
||||||
}
|
|
||||||
log.error(
|
|
||||||
{
|
|
||||||
reason: attempt.streamedTriggerReason.reason,
|
|
||||||
},
|
|
||||||
'Agent trigger detected but could not be resolved',
|
|
||||||
);
|
|
||||||
return 'error';
|
|
||||||
}
|
|
||||||
|
|
||||||
// success-null-result with no visible output — agent returned nothing useful.
|
|
||||||
// But if output was already delivered to Discord (sawOutput), treat as success.
|
|
||||||
if (
|
|
||||||
attempt.sawSuccessNullResultWithoutOutput &&
|
|
||||||
!attempt.sawOutput &&
|
|
||||||
!hasDirectTerminalDelivery()
|
|
||||||
) {
|
|
||||||
log.error(
|
|
||||||
'Agent returned success with null result and no visible output',
|
|
||||||
);
|
|
||||||
return 'error';
|
|
||||||
}
|
|
||||||
|
|
||||||
pairedExecutionLifecycle.markStatus('succeeded');
|
|
||||||
pairedExecutionLifecycle.markSawOutput(
|
|
||||||
attempt.sawOutput || hasDirectTerminalDelivery(),
|
|
||||||
);
|
|
||||||
return 'success';
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
let primaryAttempt = await runAttempt(provider);
|
|
||||||
const recoveredSessionAttempt =
|
|
||||||
await recoverRetryableClaudeSessionFailure(primaryAttempt);
|
|
||||||
if (recoveredSessionAttempt.resolved) {
|
|
||||||
return recoveredSessionAttempt.resolved;
|
|
||||||
}
|
|
||||||
primaryAttempt = recoveredSessionAttempt.attempt;
|
|
||||||
|
|
||||||
if (primaryAttempt.error) {
|
|
||||||
return await handlePrimaryAttemptFailure(
|
|
||||||
primaryAttempt,
|
|
||||||
getErrorMessage(primaryAttempt.error),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await finalizePrimaryAttempt(primaryAttempt);
|
|
||||||
} finally {
|
} finally {
|
||||||
await pairedExecutionLifecycle.asyncFinalize();
|
await pairedExecutionLifecycle.asyncFinalize();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user