+ 部分觀測缺失({props.unknownDomains.join('、')}),請查看可靠性。 +
+ ); + } if (props.operations.status !== 'healthy' && props.top === 0) { return (diff --git a/apps/control-center/src/server/services/operations/operator/render-signals.ts b/apps/control-center/src/server/services/operations/operator/render-signals.ts index 3a74c4780..34b2b058a 100644 --- a/apps/control-center/src/server/services/operations/operator/render-signals.ts +++ b/apps/control-center/src/server/services/operations/operator/render-signals.ts @@ -21,6 +21,8 @@ function isRetryableRenderFailure(target: RenderTarget): boolean { ); } +export { isRetryableRenderFailure }; + export async function renderSignals( store: OperatorStore, now: Date, diff --git a/apps/control-center/src/server/services/operations/operator/runner.coverage.test.ts b/apps/control-center/src/server/services/operations/operator/runner.coverage.test.ts index 92112f4cf..9e197691e 100644 --- a/apps/control-center/src/server/services/operations/operator/runner.coverage.test.ts +++ b/apps/control-center/src/server/services/operations/operator/runner.coverage.test.ts @@ -233,4 +233,100 @@ describe('runOperatorCycle coverage gaps', () => { 'ops_retry_render', ]); }); + + it('skips an abandoned failed render and selects the retryable one', async () => { + const retryableId = '33333333-3333-4333-8333-333333333333'; + const abandoned = { + ...failedTarget(), + localizationId, + abandonedAt: '2026-09-05T11:34:35Z', + }; + const retryable = { + ...failedTarget(), + localizationId: retryableId, + abandonedAt: null, + }; + const persistence = store({ + renderTargets: vi.fn().mockResolvedValue([abandoned, retryable]), + }); + const ops = operations(); + + const result = await runOperatorCycle({ + operations: ops, + store: persistence as unknown as OperatorStore, + config, + actor: 'test', + mutationsEnabled: true, + }); + + expect(ops.investigate).toHaveBeenCalledWith( + `social-queue:render/${retryableId}`, + false, + ); + expect(result).toMatchObject({ + fingerprint: `social-queue:render/${retryableId}`, + }); + }); + + it('falls through to snapshot priorities when every failed render is terminal', async () => { + const fingerprint = 'sentry:issues/podcast-pipeline'; + const persistence = store({ + renderTargets: vi.fn().mockResolvedValue([ + { + ...failedTarget(), + abandonedAt: '2026-09-05T11:34:35Z', + }, + { + ...failedTarget(), + localizationId: '33333333-3333-4333-8333-333333333333', + visualVersion: 'v3', + }, + ]), + }); + const ops = operations({ + getOperations: vi.fn().mockResolvedValue({ + generatedAt: now.toISOString(), + priorities: [{ signal: { fingerprint } }], + }), + }); + + const result = await runOperatorCycle({ + operations: ops, + store: persistence as unknown as OperatorStore, + config, + actor: 'test', + mutationsEnabled: true, + }); + + expect(ops.investigate).toHaveBeenCalledWith(fingerprint, false); + expect(result).toEqual({ state: 'needs_human', fingerprint, action: null }); + }); + + it('goes idle when failed renders are terminal and there are no priorities', async () => { + const persistence = store({ + renderTargets: vi.fn().mockResolvedValue([ + { + ...failedTarget(), + abandonedAt: '2026-09-05T11:34:35Z', + }, + ]), + }); + const ops = operations({ + getOperations: vi.fn().mockResolvedValue({ + generatedAt: now.toISOString(), + priorities: [], + }), + }); + + const result = await runOperatorCycle({ + operations: ops, + store: persistence as unknown as OperatorStore, + config, + actor: 'test', + mutationsEnabled: true, + }); + + expect(result).toEqual({ state: 'idle' }); + expect(ops.investigate).not.toHaveBeenCalled(); + }); }); diff --git a/apps/control-center/src/server/services/operations/operator/runner.ts b/apps/control-center/src/server/services/operations/operator/runner.ts index 1a6c76487..52b283aca 100644 --- a/apps/control-center/src/server/services/operations/operator/runner.ts +++ b/apps/control-center/src/server/services/operations/operator/runner.ts @@ -3,6 +3,7 @@ import { fixSchema, observeRecovery } from './observe.js'; import { randomUUID } from 'node:crypto'; import type { OpsMcpOperations } from '../../../mcp/types.js'; import { renderAction } from './actions.js'; +import { isRetryableRenderFailure } from './render-signals.js'; import type { OperatorStore } from './store.js'; type OperatorCycleInput = { @@ -95,8 +96,8 @@ async function runOperatorCycleBody(input: OperatorCycleInput) { } const snapshot = await input.operations.getOperations(false); const targets = await input.store.renderTargets(); - const target = targets.find( - (candidate) => candidate.renderStatus === 'failed', + const target = targets.find((candidate) => + isRetryableRenderFailure(candidate), ); const fingerprint = target ? `social-queue:render/${target.localizationId}`