fix: allow retry after definitive publish failures
Frontend CI / Frontend (push) Successful in 3m18s
Backend CI / Backend (push) Successful in 16m16s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 26s
Desktop Client Build / Resolve Build Metadata (push) Successful in 26s
Desktop Client Build / Build Desktop Client (push) Successful in 25m45s
Frontend CI / Frontend (push) Successful in 3m18s
Backend CI / Backend (push) Successful in 16m16s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 26s
Desktop Client Build / Resolve Build Metadata (push) Successful in 26s
Desktop Client Build / Build Desktop Client (push) Successful in 25m45s
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import type { JsonValue } from '@geo/shared-types'
|
||||
|
||||
import type { AdapterExecutionResult } from './adapters/base'
|
||||
|
||||
export function markAuthorizationFailureNonRetryable(
|
||||
error: Record<string, JsonValue>,
|
||||
category: 'ai_platform_authorization' | 'media_account_authorization',
|
||||
): Record<string, JsonValue> {
|
||||
return {
|
||||
...error,
|
||||
retryable: false,
|
||||
non_retryable: true,
|
||||
failure_category: category,
|
||||
}
|
||||
}
|
||||
|
||||
export function isAuthorizationFailureCode(code: string | null | undefined): boolean {
|
||||
if (!code) {
|
||||
return false
|
||||
}
|
||||
const normalized = code.trim().toLowerCase()
|
||||
return (
|
||||
normalized === 'desktop_account_auth_expired' ||
|
||||
normalized === 'desktop_account_challenge_required' ||
|
||||
normalized === 'desktop_account_risk_control' ||
|
||||
normalized.endsWith('_not_logged_in') ||
|
||||
normalized.endsWith('_login_required') ||
|
||||
normalized.endsWith('_login_expired') ||
|
||||
normalized.endsWith('_challenge_required')
|
||||
)
|
||||
}
|
||||
|
||||
export function isDefinitivePublishFailure(result: AdapterExecutionResult): boolean {
|
||||
if (result.status !== 'failed') {
|
||||
return false
|
||||
}
|
||||
|
||||
const code = typeof result.error?.code === 'string' ? result.error.code.toLowerCase() : ''
|
||||
const message =
|
||||
typeof result.error?.message === 'string' ? result.error.message.toLowerCase() : ''
|
||||
const summary = result.summary.toLowerCase()
|
||||
const combined = `${code} ${message} ${summary}`
|
||||
|
||||
if (
|
||||
result.error?.non_retryable === true ||
|
||||
result.error?.retryable === false ||
|
||||
isAuthorizationFailureCode(code)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return /(?:csrf|forbidden|\b403\b|not_logged_in|login_required|token_missing|cookie_missing|challenge_required|risk_control|captcha|验证码|滑块|人机验证|安全验证|风控|风险|参数错误|内容为空|image_fetch_failed|cover_fetch_failed|upload_failed|signature_failed)/i.test(
|
||||
combined,
|
||||
)
|
||||
}
|
||||
|
||||
export function asSubmitUncertainExecution(result: AdapterExecutionResult): AdapterExecutionResult {
|
||||
if (result.status !== 'failed' || isDefinitivePublishFailure(result)) {
|
||||
return result
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
status: 'unknown',
|
||||
summary: '发布可能已提交到平台,结果待人工确认,已避免自动重复发布。',
|
||||
error: {
|
||||
...(result.error ?? {}),
|
||||
publish_submit_uncertain: true,
|
||||
original_status: 'failed',
|
||||
original_summary: result.summary,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AdapterExecutionResult } from './adapters/base'
|
||||
import {
|
||||
asSubmitUncertainExecution,
|
||||
isDefinitivePublishFailure,
|
||||
} from './publish-result-classification'
|
||||
|
||||
describe('publish retry result classification', () => {
|
||||
it('keeps CSRF 403 publish failures definitive after submit started', () => {
|
||||
const result: AdapterExecutionResult = {
|
||||
status: 'failed',
|
||||
summary: '什么值得买发布失败。',
|
||||
error: {
|
||||
code: 'smzdm_publish_failed',
|
||||
message: '{"error_code":403,"error_msg":"CSRF token invalid"}',
|
||||
},
|
||||
}
|
||||
|
||||
expect(isDefinitivePublishFailure(result)).toBe(true)
|
||||
expect(asSubmitUncertainExecution(result).status).toBe('failed')
|
||||
})
|
||||
|
||||
it('marks ambiguous submit-phase publish failures as unknown', () => {
|
||||
const result: AdapterExecutionResult = {
|
||||
status: 'failed',
|
||||
summary: '发布失败。',
|
||||
error: {
|
||||
code: 'smzdm_publish_failed',
|
||||
message: 'network interrupted after submit',
|
||||
},
|
||||
}
|
||||
|
||||
const normalized = asSubmitUncertainExecution(result)
|
||||
expect(normalized.status).toBe('unknown')
|
||||
expect(normalized.error?.publish_submit_uncertain).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -80,6 +80,11 @@ import {
|
||||
startHiddenPlaywrightReaper,
|
||||
} from './playwright-cdp'
|
||||
import { getProcessMetricsSnapshot } from './process-metrics'
|
||||
import {
|
||||
asSubmitUncertainExecution,
|
||||
isAuthorizationFailureCode,
|
||||
markAuthorizationFailureNonRetryable,
|
||||
} from './publish-result-classification'
|
||||
import {
|
||||
enqueuePublishTask as enqueuePublishSchedulerTask,
|
||||
getPublishSchedulerSnapshot,
|
||||
@@ -500,34 +505,6 @@ function eligibleMonitorPlatformIds(): string[] {
|
||||
.filter((platform) => !blocked.has(platform))
|
||||
}
|
||||
|
||||
function markAuthorizationFailureNonRetryable(
|
||||
error: Record<string, JsonValue>,
|
||||
category: 'ai_platform_authorization' | 'media_account_authorization',
|
||||
): Record<string, JsonValue> {
|
||||
return {
|
||||
...error,
|
||||
retryable: false,
|
||||
non_retryable: true,
|
||||
failure_category: category,
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthorizationFailureCode(code: string | null | undefined): boolean {
|
||||
if (!code) {
|
||||
return false
|
||||
}
|
||||
const normalized = code.trim().toLowerCase()
|
||||
return (
|
||||
normalized === 'desktop_account_auth_expired' ||
|
||||
normalized === 'desktop_account_challenge_required' ||
|
||||
normalized === 'desktop_account_risk_control' ||
|
||||
normalized.endsWith('_not_logged_in') ||
|
||||
normalized.endsWith('_login_required') ||
|
||||
normalized.endsWith('_login_expired') ||
|
||||
normalized.endsWith('_challenge_required')
|
||||
)
|
||||
}
|
||||
|
||||
function isAuthorizationFailureMessage(message: string | null | undefined): boolean {
|
||||
if (!message) {
|
||||
return false
|
||||
@@ -782,7 +759,8 @@ function startRuntime(): void {
|
||||
return
|
||||
}
|
||||
if (playwrightCDPFatal) {
|
||||
const message = '浏览器自动化通道不可用,客户端已停止采集。请重启客户端或检查安全软件/端口占用。'
|
||||
const message =
|
||||
'浏览器自动化通道不可用,客户端已停止采集。请重启客户端或检查安全软件/端口占用。'
|
||||
state.lastError = message
|
||||
setSchedulerPhase('paused')
|
||||
setSchedulerError(message)
|
||||
@@ -1922,23 +1900,6 @@ async function markPublishSubmitStartedIfNeeded(taskId: string, stage: string):
|
||||
state.activeExecutions.set(taskId, active)
|
||||
}
|
||||
|
||||
function asSubmitUncertainExecution(result: AdapterExecutionResult): AdapterExecutionResult {
|
||||
if (result.status !== 'failed') {
|
||||
return result
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
status: 'unknown',
|
||||
summary: '发布可能已提交到平台,结果待人工确认,已避免自动重复发布。',
|
||||
error: {
|
||||
...(result.error ?? {}),
|
||||
publish_submit_uncertain: true,
|
||||
original_status: 'failed',
|
||||
original_summary: result.summary,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function retainPlaywrightPageOrThrow(
|
||||
task: RuntimeTaskRecord,
|
||||
payload: Record<string, JsonValue>,
|
||||
@@ -2183,7 +2144,12 @@ async function pullNextTask(kind: 'publish' | 'monitor'): Promise<void> {
|
||||
|
||||
async function pullMonitoringTasks(routing: RuntimeTaskRouting): Promise<void> {
|
||||
const leaseLimit = resolveLegacyMonitoringLeaseLimit()
|
||||
if (!canRunLive(state.session) || isLeaseInFlight('monitor') || leaseLimit <= 0 || playwrightCDPFatal) {
|
||||
if (
|
||||
!canRunLive(state.session) ||
|
||||
isLeaseInFlight('monitor') ||
|
||||
leaseLimit <= 0 ||
|
||||
playwrightCDPFatal
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2205,7 +2171,8 @@ async function pullMonitoringTasks(routing: RuntimeTaskRouting): Promise<void> {
|
||||
noteSchedulerPull()
|
||||
|
||||
enqueueLeasedMonitoringTasks(leased.tasks, routing)
|
||||
state.monitorFallbackBackoffUntil = leased.tasks.length > 0 ? 0 : Date.now() + monitorPullFallbackIntervalMs
|
||||
state.monitorFallbackBackoffUntil =
|
||||
leased.tasks.length > 0 ? 0 : Date.now() + monitorPullFallbackIntervalMs
|
||||
} catch (error) {
|
||||
state.lastPullAt = Date.now()
|
||||
state.lastPullStatus = 'failed'
|
||||
@@ -3720,7 +3687,9 @@ async function ensurePlaywrightCDPAdmissionForRequest(
|
||||
return ready
|
||||
}
|
||||
|
||||
async function ensurePlaywrightCDPAdmissionForFallback(kind: 'publish' | 'monitor'): Promise<boolean> {
|
||||
async function ensurePlaywrightCDPAdmissionForFallback(
|
||||
kind: 'publish' | 'monitor',
|
||||
): Promise<boolean> {
|
||||
if (kind === 'publish') {
|
||||
const queuedPlaywrightPublish = getPublishSchedulerSnapshot().tasks.some(
|
||||
(task) => task.state === 'queued' && adapterRequiresPlaywright('publish', task.platform),
|
||||
@@ -3759,7 +3728,9 @@ async function ensurePlaywrightCDPAdmission(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
function isPlaywrightCDPInfrastructureError(error: unknown): error is PlaywrightCDPInfrastructureError {
|
||||
function isPlaywrightCDPInfrastructureError(
|
||||
error: unknown,
|
||||
): error is PlaywrightCDPInfrastructureError {
|
||||
return error instanceof PlaywrightCDPInfrastructureError || isPlaywrightCDPUnavailableError(error)
|
||||
}
|
||||
|
||||
@@ -3779,7 +3750,8 @@ function deferPendingTaskRequest(
|
||||
}
|
||||
|
||||
function adapterRequiresPlaywright(kind: 'publish' | 'monitor', platform: string): boolean {
|
||||
const adapter = kind === 'publish' ? selectPublishAdapter(platform) : selectMonitorAdapter(platform)
|
||||
const adapter =
|
||||
kind === 'publish' ? selectPublishAdapter(platform) : selectMonitorAdapter(platform)
|
||||
return adapter?.executionMode === 'playwright'
|
||||
}
|
||||
|
||||
@@ -3820,8 +3792,7 @@ function markPlaywrightCDPFatal(message: string, taskId?: string): void {
|
||||
playwrightCDPFatal = true
|
||||
|
||||
const fatalMessage =
|
||||
message ||
|
||||
'浏览器自动化通道不可用,客户端已停止采集。请重启客户端或检查安全软件/端口占用。'
|
||||
message || '浏览器自动化通道不可用,客户端已停止采集。请重启客户端或检查安全软件/端口占用。'
|
||||
state.lastError = fatalMessage
|
||||
setSchedulerPhase('paused')
|
||||
setSchedulerError(fatalMessage)
|
||||
|
||||
@@ -201,7 +201,7 @@ function buildWangyihaoArticleUrl(
|
||||
}
|
||||
|
||||
function normalizePublishTaskStatus(status: DesktopTaskInfo['status']): DesktopTaskInfo['status'] {
|
||||
return status === 'unknown' ? 'failed' : status
|
||||
return status
|
||||
}
|
||||
|
||||
function normalizeTaskErrorMessage(
|
||||
@@ -810,7 +810,9 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
|
||||
extractString(taskError.code),
|
||||
task.platform,
|
||||
),
|
||||
submitUncertain: task.status === 'unknown' && taskError.publish_submit_uncertain === true,
|
||||
submitUncertain:
|
||||
task.status === 'unknown' &&
|
||||
(taskError.publish_submit_uncertain === true || Boolean(task.publish_submit_started_at)),
|
||||
complianceBlockedRecordId,
|
||||
complianceBlockedAt: task.compliance_blocked_at
|
||||
? parseTimestamp(task.compliance_blocked_at)
|
||||
|
||||
Reference in New Issue
Block a user