From 8e6ad4a5ccd4abe3d760ae514696e613b6917bc0 Mon Sep 17 00:00:00 2001 From: wangzhenjia Date: Sun, 20 Sep 2026 11:45:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(automation):=20=E7=A0=94=E7=A9=B6=E5=88=86?= =?UTF-8?q?=E6=9E=90=E4=B8=8E=E9=80=9A=E7=9F=A5=E5=9B=9E=E8=B0=83=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=97=B6=E9=99=8D=E7=BA=A7=E4=B8=BA=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=EF=BC=8C=E4=B8=8D=E5=86=8D=E4=B8=AD=E6=AD=A2?= =?UTF-8?q?=E6=95=B4=E8=BD=AE=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runner 对行情获取与日历探测都做了降级(失败即跳过),但 researchStart 与 notify 是裸 await:任一 symbol 的分析或通知回调抛错,runAutomation 整体 reject,调用方拿不到返回值,于是 AutomationRun 不落盘、后续 symbol 不再评估、也没有任何失败记录,规则静默失败且每分钟重试。 现在把两个副作用回调按同一策略降级:失败写入 failures(research analysis failed / notification failed),继续处理剩余 symbol。analyzed 只统计真正完成的分析,notified 只在通知真正送达后置位。 新增回归测试:3 个 material symbol,第一个研究失败、第二个通知失败, 仍评估全部 3 个、产出失败记录并保留其余分析与通知;修复前该测试因整轮 reject 失败。 --- packages/shared/src/automation/runner.test.ts | 31 +++++++++++++++++++ packages/shared/src/automation/runner.ts | 20 +++++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/automation/runner.test.ts b/packages/shared/src/automation/runner.test.ts index 20c92f6e..16a4dc74 100644 --- a/packages/shared/src/automation/runner.test.ts +++ b/packages/shared/src/automation/runner.test.ts @@ -307,6 +307,37 @@ describe('runAutomation material filter', () => { await runAutomation(rule({ strategyId: 'event-driven' }), context) expect(researchCalls).toEqual(['AAPL.US']) }) + + it('degrades a failing research or notify callback into a recorded failure', async () => { + const { context, researchCalls, notifications } = makeContext({ + quotes: { + 'AAPL.US': quote(106, 100), + 'MSFT.US': quote(106, 100), + 'NVDA.US': quote(106, 100), + }, + }) + context.watchlistSymbols = async () => ['AAPL.US', 'MSFT.US', 'NVDA.US'] + context.researchStart = async (symbol: string) => { + researchCalls.push(symbol) + if (symbol === 'AAPL.US') throw new Error('research backend offline') + } + context.notify = async (event: NotificationEvent) => { + notifications.push(event) + if (event.symbol === 'MSFT.US') throw new Error('notification bridge down') + } + const run = await runAutomation(rule({ notify: 'all' }), context) + + // A failing side effect must not abort the rule or lose the run record. + expect(run.evaluated).toBe(3) + expect(run.materialChanges).toBe(3) + expect(run.analyzed).toBe(2) + expect(researchCalls).toEqual(['AAPL.US', 'MSFT.US', 'NVDA.US']) + expect(notifications.map((event) => event.symbol)).toEqual(['AAPL.US', 'MSFT.US', 'NVDA.US']) + expect(run.failures).toEqual([ + 'AAPL.US: research analysis failed', + 'MSFT.US: notification failed', + ]) + }) }) describe('runAutomation notify semantics', () => { diff --git a/packages/shared/src/automation/runner.ts b/packages/shared/src/automation/runner.ts index 571f3825..98d5c43c 100644 --- a/packages/shared/src/automation/runner.ts +++ b/packages/shared/src/automation/runner.ts @@ -100,12 +100,24 @@ export async function runAutomation( const material = signalsAreMaterial(outcome.signals) if (material) { materialChanges += 1 - analyzed += 1 - await ctx.researchStart(symbol, rule.strategyId) + // A failing analysis or notification is recorded and degraded, exactly + // like the quote/calendar probes: it must never abort the whole rule + // (which would also drop the AutomationRun record) or skip the + // remaining symbols. + try { + await ctx.researchStart(symbol, rule.strategyId) + analyzed += 1 + } catch { + failures.push(`${symbol}: research analysis failed`) + } } if (rule.notify === 'all' || material) { - await ctx.notify?.(notificationFor(rule, symbol, material, outcome.signals, ranAt, ctx.locale)) - notified = true + try { + await ctx.notify?.(notificationFor(rule, symbol, material, outcome.signals, ranAt, ctx.locale)) + notified = true + } catch { + failures.push(`${symbol}: notification failed`) + } } }