2706c07381
Add monitor-account-pool to select authorized, non-blocked candidates for an AI-platform monitor task, classify failover reasons (risk control, challenge, auth failure, empty/unknown result) and cool the account down before rotating to the next. Wire runtime-controller to run monitor tasks through the pool, retrying across accounts and annotating the result with per-account attempt diagnostics and a failover count. Expose the pool state via the runtime snapshot. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
163 lines
4.9 KiB
TypeScript
163 lines
4.9 KiB
TypeScript
import type { AdapterExecutionResult } from './adapters/base'
|
|
|
|
export type MonitorAccountFailoverReason =
|
|
| 'risk_control'
|
|
| 'challenge_required'
|
|
| 'authorization_failed'
|
|
| 'empty_result'
|
|
| 'missing_citations'
|
|
| 'unknown_result'
|
|
| 'adapter_failure'
|
|
|
|
export type MonitorAccountPoolEntry = {
|
|
id: string
|
|
authState: string
|
|
hasLocalAuthorization: boolean
|
|
}
|
|
|
|
type MonitorAccountCooldown = {
|
|
reason: MonitorAccountFailoverReason
|
|
until: number
|
|
}
|
|
|
|
const lastUsedAt = new Map<string, number>()
|
|
const cooldowns = new Map<string, MonitorAccountCooldown>()
|
|
|
|
const blockedAuthStates = new Set(['challenge_required', 'expired', 'revoked'])
|
|
const deterministicFailurePattern =
|
|
/adapter_aborted|stale_business_date|desktop_monitor_task_stale|desktop_task_adapter_missing|question_text_missing|playwright_required|playwright_cdp_(?:unavailable|disabled)/i
|
|
const riskControlPattern = /risk[_ -]?control|风控|频繁|访问受限|操作受限/i
|
|
const challengePattern = /challenge|required|captcha|验证码|人机验证|人工验证|安全验证/i
|
|
const authorizationPattern =
|
|
/authorization|unauthorized|login[_ -]?(?:required|expired)|not logged in|登录态|未登录|请先登录|授权过期/i
|
|
|
|
export function resetMonitorAccountPoolState(): void {
|
|
lastUsedAt.clear()
|
|
cooldowns.clear()
|
|
}
|
|
|
|
export function isMonitorAccountAuthBlocked(authState: string): boolean {
|
|
return blockedAuthStates.has(authState)
|
|
}
|
|
|
|
export function selectMonitorAccountCandidates(
|
|
entries: readonly MonitorAccountPoolEntry[],
|
|
preferredAccountId: string | null,
|
|
now = Date.now(),
|
|
): MonitorAccountPoolEntry[] {
|
|
for (const [accountId, cooldown] of cooldowns.entries()) {
|
|
if (cooldown.until <= now) {
|
|
cooldowns.delete(accountId)
|
|
}
|
|
}
|
|
|
|
return entries
|
|
.filter(
|
|
(entry) =>
|
|
entry.hasLocalAuthorization &&
|
|
!isMonitorAccountAuthBlocked(entry.authState) &&
|
|
(cooldowns.get(entry.id)?.until ?? 0) <= now,
|
|
)
|
|
.sort((left, right) => {
|
|
if (left.id === preferredAccountId && right.id !== preferredAccountId) {
|
|
return -1
|
|
}
|
|
if (right.id === preferredAccountId && left.id !== preferredAccountId) {
|
|
return 1
|
|
}
|
|
const usedDelta = (lastUsedAt.get(left.id) ?? 0) - (lastUsedAt.get(right.id) ?? 0)
|
|
return usedDelta || left.id.localeCompare(right.id)
|
|
})
|
|
}
|
|
|
|
export function noteMonitorAccountAttempt(accountId: string, now = Date.now()): void {
|
|
lastUsedAt.set(accountId, now)
|
|
}
|
|
|
|
export function clearMonitorAccountCooldown(accountId: string): void {
|
|
cooldowns.delete(accountId)
|
|
}
|
|
|
|
export function monitorAccountCooldownMs(reason: MonitorAccountFailoverReason): number {
|
|
switch (reason) {
|
|
case 'risk_control':
|
|
case 'challenge_required':
|
|
case 'authorization_failed':
|
|
return 30 * 60_000
|
|
case 'empty_result':
|
|
case 'unknown_result':
|
|
return 3 * 60_000
|
|
case 'missing_citations':
|
|
return 0
|
|
case 'adapter_failure':
|
|
return 60_000
|
|
}
|
|
}
|
|
|
|
export function coolDownMonitorAccount(
|
|
accountId: string,
|
|
reason: MonitorAccountFailoverReason,
|
|
now = Date.now(),
|
|
): void {
|
|
cooldowns.set(accountId, {
|
|
reason,
|
|
until: now + monitorAccountCooldownMs(reason),
|
|
})
|
|
}
|
|
|
|
export function classifyMonitorAccountFailover(
|
|
result: AdapterExecutionResult,
|
|
): MonitorAccountFailoverReason | null {
|
|
const answerCandidates = [
|
|
result.payload?.answer,
|
|
result.payload?.answer_text,
|
|
result.payload?.content,
|
|
result.payload?.response_text,
|
|
]
|
|
const hasAnswer = answerCandidates.some(
|
|
(value) => typeof value === 'string' && value.trim().length > 0,
|
|
)
|
|
if (result.status === 'succeeded' && hasAnswer) {
|
|
const hasCitationSources = [result.payload?.citations, result.payload?.search_results].some(
|
|
(value) => Array.isArray(value) && value.length > 0,
|
|
)
|
|
return hasCitationSources ? null : 'missing_citations'
|
|
}
|
|
if (result.status === 'succeeded') {
|
|
return 'empty_result'
|
|
}
|
|
if (result.status === 'unknown') {
|
|
return 'unknown_result'
|
|
}
|
|
|
|
const errorCode = typeof result.error?.code === 'string' ? result.error.code : ''
|
|
const errorMessage = typeof result.error?.message === 'string' ? result.error.message : ''
|
|
const failureText = `${errorCode} ${errorMessage} ${result.summary}`
|
|
if (deterministicFailurePattern.test(failureText)) {
|
|
return null
|
|
}
|
|
if (riskControlPattern.test(failureText)) {
|
|
return 'risk_control'
|
|
}
|
|
if (challengePattern.test(failureText)) {
|
|
return 'challenge_required'
|
|
}
|
|
if (authorizationPattern.test(failureText)) {
|
|
return 'authorization_failed'
|
|
}
|
|
return 'adapter_failure'
|
|
}
|
|
|
|
export function getMonitorAccountPoolSnapshot(now = Date.now()) {
|
|
return {
|
|
trackedAccountCount: lastUsedAt.size,
|
|
cooldowns: [...cooldowns.entries()]
|
|
.filter(([, cooldown]) => cooldown.until > now)
|
|
.map(([accountId, cooldown]) => ({
|
|
accountId,
|
|
reason: cooldown.reason,
|
|
until: cooldown.until,
|
|
})),
|
|
}
|
|
}
|