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

This commit is contained in:
2026-06-25 00:22:59 +08:00
parent 2ec266201c
commit ed48674ab5
13 changed files with 473 additions and 162 deletions
@@ -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)