refactor: move retry and recovery paths to structured output helpers

This commit is contained in:
Eyejoker
2026-03-28 06:17:52 +09:00
parent 47dda19ded
commit 38d96578a4
8 changed files with 144 additions and 18 deletions

View File

@@ -26,6 +26,16 @@ export function getAgentOutputText(output: {
return stringifyLegacyAgentResult(output.result); return stringifyLegacyAgentResult(output.result);
} }
export function hasAgentOutputPayload(output: {
output?: StructuredAgentOutput;
result?: string | object | null;
}): boolean {
if (output.output) {
return true;
}
return output.result !== null && output.result !== undefined;
}
export function isSilentAgentOutput(output: { export function isSilentAgentOutput(output: {
output?: StructuredAgentOutput; output?: StructuredAgentOutput;
}): boolean { }): boolean {

View File

@@ -54,7 +54,10 @@ describe('classifySuppressTokenOutput', () => {
it('treats a truncated structured silent envelope as mixed', () => { it('treats a truncated structured silent envelope as mixed', () => {
expect( expect(
classifySuppressTokenOutput('{"ejclaw":{"visibility":"silent"', undefined), classifySuppressTokenOutput(
'{"ejclaw":{"visibility":"silent"',
undefined,
),
).toBe('mixed'); ).toBe('mixed');
}); });

View File

@@ -83,4 +83,42 @@ describe('runClaudeRotationLoop', () => {
trigger: { reason: 'success-null-result' }, trigger: { reason: 'success-null-result' },
}); });
}); });
it('uses structured public output text as the next rotation message', async () => {
vi.mocked(getTokenCount).mockReturnValue(2);
vi.mocked(rotateToken)
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
let attempts = 0;
const outcome = await runClaudeRotationLoop(
{ reason: '429' },
async () => {
attempts += 1;
if (attempts === 1) {
return {
output: {
status: 'success',
result: null,
output: { visibility: 'public', text: 'retry with next token' },
},
sawOutput: false,
streamedTriggerReason: { reason: 'usage-exhausted' },
};
}
return {
output: { status: 'success', result: 'ok' },
sawOutput: true,
};
},
{ runId: 'structured-rotation-message' },
);
expect(outcome).toEqual({
type: 'error',
trigger: { reason: 'usage-exhausted' },
});
expect(rotateToken).toHaveBeenNthCalledWith(2, 'retry with next token');
});
}); });

View File

@@ -5,6 +5,8 @@
* to eliminate the ~255-line structural duplication. * to eliminate the ~255-line structural duplication.
*/ */
import type { AgentOutput } from './agent-runner.js';
import { getAgentOutputText } from './agent-output.js';
import { import {
classifyRotationTrigger, classifyRotationTrigger,
shouldRotateClaudeToken, shouldRotateClaudeToken,
@@ -26,7 +28,7 @@ export interface TriggerInfo {
} }
export interface RotationAttemptResult { export interface RotationAttemptResult {
output?: { status: string; result?: string | null; error?: string | null }; output?: Pick<AgentOutput, 'status' | 'result' | 'output' | 'error'>;
thrownError?: unknown; thrownError?: unknown;
sawOutput: boolean; sawOutput: boolean;
sawSuccessNullResult?: boolean; sawSuccessNullResult?: boolean;
@@ -115,8 +117,7 @@ export async function runClaudeRotationLoop(
reason: attempt.streamedTriggerReason.reason, reason: attempt.streamedTriggerReason.reason,
retryAfterMs: attempt.streamedTriggerReason.retryAfterMs, retryAfterMs: attempt.streamedTriggerReason.retryAfterMs,
}; };
lastRotationMessage = lastRotationMessage = getAgentOutputText(output) ?? undefined;
typeof output.result === 'string' ? output.result : undefined;
continue; continue;
} }

View File

@@ -44,6 +44,19 @@ describe('shouldResetSessionOnAgentFailure', () => {
}), }),
).toBe(true); ).toBe(true);
}); });
it('matches structured public output text too', () => {
expect(
shouldResetSessionOnAgentFailure({
result: null,
output: {
visibility: 'public',
text: 'API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.11.content.0: Invalid `signature` in `thinking` block"}}',
},
error: undefined,
}),
).toBe(true);
});
}); });
describe('shouldRetryFreshSessionOnAgentFailure', () => { describe('shouldRetryFreshSessionOnAgentFailure', () => {
@@ -75,4 +88,14 @@ describe('shouldRetryFreshSessionOnAgentFailure', () => {
}), }),
).toBe(false); ).toBe(false);
}); });
it('does not treat structured silent output as a retryable session failure', () => {
expect(
shouldRetryFreshSessionOnAgentFailure({
result: null,
output: { visibility: 'silent' },
error: undefined,
}),
).toBe(false);
});
}); });

View File

@@ -1,3 +1,4 @@
import { getAgentOutputText } from './agent-output.js';
import type { AgentOutput } from './agent-runner.js'; import type { AgentOutput } from './agent-runner.js';
const SESSION_RESET_PATTERNS = [ const SESSION_RESET_PATTERNS = [
@@ -28,18 +29,24 @@ function toText(value: string | object | null | undefined): string[] {
} }
export function shouldResetSessionOnAgentFailure( export function shouldResetSessionOnAgentFailure(
output: Pick<AgentOutput, 'result' | 'error'>, output: Pick<AgentOutput, 'result' | 'output' | 'error'>,
): boolean { ): boolean {
const texts = [...toText(output.result), ...toText(output.error)]; const texts = [
...toText(getAgentOutputText(output)),
...toText(output.error),
];
return texts.some((text) => return texts.some((text) =>
SESSION_RESET_PATTERNS.some((pattern) => pattern.test(text)), SESSION_RESET_PATTERNS.some((pattern) => pattern.test(text)),
); );
} }
export function shouldRetryFreshSessionOnAgentFailure( export function shouldRetryFreshSessionOnAgentFailure(
output: Pick<AgentOutput, 'result' | 'error'>, output: Pick<AgentOutput, 'result' | 'output' | 'error'>,
): boolean { ): boolean {
const texts = [...toText(output.result), ...toText(output.error)]; const texts = [
...toText(getAgentOutputText(output)),
...toText(output.error),
];
return texts.some((text) => return texts.some((text) =>
SESSION_RETRY_PATTERNS.some((pattern) => pattern.test(text)), SESSION_RETRY_PATTERNS.some((pattern) => pattern.test(text)),
); );

View File

@@ -138,6 +138,21 @@ describe('evaluateStreamedOutput', () => {
expect(result.shouldForwardOutput).toBe(true); expect(result.shouldForwardOutput).toBe(true);
expect(result.state.sawOutput).toBe(false); expect(result.state.sawOutput).toBe(false);
}); });
it('treats structured silent final output as output, not success-null-result', () => {
const result = evaluateStreamedOutput(
{
status: 'success',
result: null,
output: { visibility: 'silent' },
},
freshState(),
{ ...claudeOpts, trackSuccessNullResult: true },
);
expect(result.shouldForwardOutput).toBe(true);
expect(result.state.sawOutput).toBe(true);
expect(result.state.sawSuccessNullResultWithoutOutput).toBe(false);
});
}); });
describe('Claude usage-exhausted banner', () => { describe('Claude usage-exhausted banner', () => {
@@ -180,6 +195,28 @@ describe('evaluateStreamedOutput', () => {
expect(result.shouldForwardOutput).toBe(true); expect(result.shouldForwardOutput).toBe(true);
expect(result.newTrigger).toBeUndefined(); expect(result.newTrigger).toBeUndefined();
}); });
it('detects a structured public usage-exhausted banner too', () => {
vi.mocked(isClaudeUsageExhaustedMessage).mockReturnValue(true);
const result = evaluateStreamedOutput(
{
status: 'success',
result: null,
output: {
visibility: 'public',
text: "You're out of extra usage. Resets at 5pm.",
},
},
freshState(),
claudeOpts,
);
expect(result.shouldForwardOutput).toBe(false);
expect(result.newTrigger).toEqual({ reason: 'usage-exhausted' });
expect(result.state.streamedTriggerReason).toEqual({
reason: 'usage-exhausted',
});
});
}); });
describe('Claude retryable session failure suppression', () => { describe('Claude retryable session failure suppression', () => {

View File

@@ -1,3 +1,8 @@
import {
getAgentOutputText,
hasAgentOutputPayload,
isSilentAgentOutput,
} from './agent-output.js';
import { import {
classifyClaudeAuthError, classifyClaudeAuthError,
classifyRotationTrigger, classifyRotationTrigger,
@@ -53,6 +58,8 @@ export function evaluateStreamedOutput(
options.agentType === 'codex' && options.provider === 'codex'; options.agentType === 'codex' && options.provider === 'codex';
const countsAsFinalOutput = const countsAsFinalOutput =
output.phase === undefined || output.phase === 'final'; output.phase === undefined || output.phase === 'final';
const outputText = getAgentOutputText(output);
const silentOutput = isSilentAgentOutput(output);
if ( if (
isPrimaryClaude && isPrimaryClaude &&
@@ -71,19 +78,19 @@ export function evaluateStreamedOutput(
isPrimaryClaude && isPrimaryClaude &&
output.status === 'success' && output.status === 'success' &&
!state.sawOutput && !state.sawOutput &&
typeof output.result === 'string' typeof outputText === 'string'
) { ) {
const authClassification = classifyClaudeAuthError(output.result); const authClassification = classifyClaudeAuthError(outputText);
const triggerReason: AgentTriggerReason | undefined = const triggerReason: AgentTriggerReason | undefined =
isClaudeUsageExhaustedMessage(output.result) isClaudeUsageExhaustedMessage(outputText)
? 'usage-exhausted' ? 'usage-exhausted'
: isClaudeOrgAccessDeniedMessage(output.result) || : isClaudeOrgAccessDeniedMessage(outputText) ||
authClassification.category === 'org-access-denied' authClassification.category === 'org-access-denied'
? 'org-access-denied' ? 'org-access-denied'
: isClaudeAuthExpiredMessage(output.result) || : isClaudeAuthExpiredMessage(outputText) ||
authClassification.category === 'auth-expired' authClassification.category === 'auth-expired'
? 'auth-expired' ? 'auth-expired'
: detectClaudeProviderFailureMessage(output.result) || undefined; : detectClaudeProviderFailureMessage(outputText) || undefined;
if (triggerReason) { if (triggerReason) {
const newTrigger = nextState.streamedTriggerReason const newTrigger = nextState.streamedTriggerReason
@@ -100,7 +107,7 @@ export function evaluateStreamedOutput(
if ( if (
options.suppressClaudeAuthErrorOutput && options.suppressClaudeAuthErrorOutput &&
isClaudeAuthError(output.result) isClaudeAuthError(outputText)
) { ) {
return { return {
state: nextState, state: nextState,
@@ -112,15 +119,15 @@ export function evaluateStreamedOutput(
if ( if (
countsAsFinalOutput && countsAsFinalOutput &&
output.result !== null && hasAgentOutputPayload(output)
output.result !== undefined
) { ) {
nextState.sawOutput = true; nextState.sawOutput = true;
} else if ( } else if (
options.trackSuccessNullResult && options.trackSuccessNullResult &&
isPrimaryClaude && isPrimaryClaude &&
output.status === 'success' && output.status === 'success' &&
!state.sawOutput !state.sawOutput &&
!silentOutput
) { ) {
nextState.sawSuccessNullResultWithoutOutput = true; nextState.sawSuccessNullResultWithoutOutput = true;
} }