发现于 #4001 批 12(automation/etl.zod.ts 的 strict 收紧),不在该 PR 范围内 ,按 Prime Directive #10 单独记录。
事实 #4661 把重试策略收敛成一份声明 shared/retry-policy.zod.ts,因为 automation/control-flow.zod.ts 与 system/job.zod.ts 用同一个导出名 RetryPolicy 发布了两个不同的形状(#4411 陷阱)。收敛后的词表是:
maxRetries // 初次尝试之后的重试次数,default 0(#4661 起 opt-in)
backoffMs // 首次重试前的基础延迟
backoffMultiplier // 指数退避倍数
maxRetryDelayMs // 单次退避延迟上限
jitter // 抖动
retryDelayMs // 墓碑(retiredKey)→ backoffMs
ETLPipelineSchema.retry(packages/spec/src/automation/etl.zod.ts)是同一个概念的第三份编码 ,而 #4661 没有碰它:
retry: z . object ( { maxAttempts : z . number ( ) . int ( ) . min ( 0 ) . default ( 3 ) . describe ( 'Max retry attempts' ) , backoffMs : z . number ( ) . int ( ) . min ( 0 ) . default ( 60000 ) . describe ( 'Backoff in milliseconds' ) , } ) . optional ( ) 三处分歧,都是真实的:
次数键拼写不同 —— maxAttempts vs 收敛后的 maxRetries。语义相同(初次之后的重试数),只有拼写不同。默认值方向相反 —— 这里 default(3),收敛后的策略 default(0)。spec 双源清账 C8:RetryPolicy / RetryPolicySchema(./automation ≠ ./system)—— 2 条 #4661 的理由写得很明确:重试会重放上一次尝试已经产生的副作用,所以"不写 = 不重试"必须是默认,隐式重试是最难在测试里抓到、在生产里最贵的失败模式,而 LLM 写的 metadata 恰恰是"没写出来的键"藏身的地方 。ETL pipeline 的 retry 目前正好是被 spec 双源清账 C8:RetryPolicy / RetryPolicySchema(./automation ≠ ./system)—— 2 条 #4661 判为错误的那个方向。少三个键 —— 没有 backoffMultiplier / maxRetryDelayMs / jitter,所以 ETL 的退避是平的、无上限的、无抖动的。一条每天凌晨 2 点跑、失败后固定 60s 重试 3 次的仓库管道,正是 thundering-herd 的典型形状。之所以 #4661 漏掉它:那次收敛是按导出名冲突 (#4411 / #4535 C8)驱动的,而 ETL 的 retry 是一个匿名内联 block,没有导出名,所以不在那次的雷达上。这是同一类债务的另一种外观 —— 一个概念三种词表 —— 只是不表现为 dual-source。
为什么现在只是记录,不是顺手改 etl.zod.ts 在本仓库/objectui/cloud 三仓零 importer、零 parse 点 (与 #4738 删掉 L1 sync.zod.ts 时测到的形状相同,区别在于 ETL 不是任何东西的第二份声明,是 SYNC_ARCHITECTURE.md 的 L2 唯一一层,sync-retirement.test.ts 还显式 pin 了 ETLPipelineSchema 必须存活)。所以这里没有"运行时行为"可谈,改动纯粹是可授权契约 的形状问题 —— 而契约形状是 needs-decision 级别的取舍,不该塞进一个 strictness 批次里顺手做掉。
批 12 做的是把这个分歧变吵 而不是变对:retry block 收紧成 strict 之后,maxRetries 走 alias 提示改写成 maxAttempts,retryDelayMs / backoffMultiplier / maxRetryDelayMs / jitter 各自带一条 guidance,明说"这是 shared/retry-policy.zod.ts 上有、ETL 上故意没有 的键,不是笔误"。在本 issue 有结论之前,那四条 guidance 就是这份分歧的可见形式。
三个选项 A. 让 ETLPipeline.retry 直接引用 shared/RetryPolicySchema。
B. 只统一拼写,不引用共享声明 (maxAttempts → maxRetries,默认值改 0,保持内联两键)。
长期正确性:差。这是"看起来收敛了"的补丁 —— 词表对上了,声明还是三份,下一次给重试策略加键时 ETL 依然会被漏掉,和这次被漏掉的机制一模一样。付了 breaking 改名的代价却没买到"一份声明"这个唯一值钱的东西。 让 AI 写的 metadata 不易出错:中等。拼写一致确实有帮助,但 backoffMultiplier / jitter 在别处能写、在这里不能写的坑原样保留。 C. 什么都不做,保留批 12 的 guidance。
长期正确性:差,但是诚实的差 。分歧被写进了拒绝信息里,不再是静默的;债务可见、可检索、可排期。 让 AI 写的 metadata 不易出错:中等偏下。作者会拿到明确的改写指示,但只在他已经写错之后 ;结构上并没有阻止他写错。 成本:零。 建议 A ,理由在两个轴上一致:它是 #4661 已经论证过的那个方向(一个概念一份声明),而且此处零 parse 点意味着 A 的迁移成本在整个仓库里此刻最低 —— 越晚做越贵,因为一旦真有 ETL 引擎落地,默认值 3 → 0 就从"改一份 schema"变成"改所有已部署管道的行为"。B 花掉了 A 的全部 breaking 预算却留下了 A 要解决的问题,不推荐。
需要维护者裁决的是 A 里的默认值:maxRetries 跟随收敛后的 0(与 #4661 的 opt-in 论证一致),还是为 ETL 保留 3(与现状一致但重新引入"隐式重试")。我的读法是跟随 0 —— #4661 的论证不依赖于是哪个 domain。
/cc #4001 #4661 #4535
发现于 #4001 批 12(
automation/etl.zod.ts的 strict 收紧),不在该 PR 范围内,按 Prime Directive #10 单独记录。事实
#4661 把重试策略收敛成一份声明
shared/retry-policy.zod.ts,因为automation/control-flow.zod.ts与system/job.zod.ts用同一个导出名RetryPolicy发布了两个不同的形状(#4411 陷阱)。收敛后的词表是:ETLPipelineSchema.retry(packages/spec/src/automation/etl.zod.ts)是同一个概念的第三份编码,而 #4661 没有碰它:三处分歧,都是真实的:
maxAttemptsvs 收敛后的maxRetries。语义相同(初次之后的重试数),只有拼写不同。default(3),收敛后的策略default(0)。spec 双源清账 C8:RetryPolicy / RetryPolicySchema(./automation ≠ ./system)—— 2 条 #4661 的理由写得很明确:重试会重放上一次尝试已经产生的副作用,所以"不写 = 不重试"必须是默认,隐式重试是最难在测试里抓到、在生产里最贵的失败模式,而 LLM 写的 metadata 恰恰是"没写出来的键"藏身的地方。ETL pipeline 的 retry 目前正好是被 spec 双源清账 C8:RetryPolicy / RetryPolicySchema(./automation ≠ ./system)—— 2 条 #4661 判为错误的那个方向。backoffMultiplier/maxRetryDelayMs/jitter,所以 ETL 的退避是平的、无上限的、无抖动的。一条每天凌晨 2 点跑、失败后固定 60s 重试 3 次的仓库管道,正是 thundering-herd 的典型形状。之所以 #4661 漏掉它:那次收敛是按导出名冲突(#4411 / #4535 C8)驱动的,而 ETL 的 retry 是一个匿名内联 block,没有导出名,所以不在那次的雷达上。这是同一类债务的另一种外观 —— 一个概念三种词表 —— 只是不表现为 dual-source。
为什么现在只是记录,不是顺手改
etl.zod.ts在本仓库/objectui/cloud三仓零 importer、零 parse 点(与 #4738 删掉 L1sync.zod.ts时测到的形状相同,区别在于 ETL 不是任何东西的第二份声明,是 SYNC_ARCHITECTURE.md 的 L2 唯一一层,sync-retirement.test.ts还显式 pin 了ETLPipelineSchema必须存活)。所以这里没有"运行时行为"可谈,改动纯粹是可授权契约的形状问题 —— 而契约形状是 needs-decision 级别的取舍,不该塞进一个 strictness 批次里顺手做掉。批 12 做的是把这个分歧变吵而不是变对:
retryblock 收紧成 strict 之后,maxRetries走 alias 提示改写成maxAttempts,retryDelayMs/backoffMultiplier/maxRetryDelayMs/jitter各自带一条 guidance,明说"这是shared/retry-policy.zod.ts上有、ETL 上故意没有的键,不是笔误"。在本 issue 有结论之前,那四条 guidance 就是这份分歧的可见形式。三个选项
A. 让
ETLPipeline.retry直接引用shared/RetryPolicySchema。shared/retry-policy.zod.ts的 module JSDoc 里"不进shared/index.ts、由各 domain barrel 再导出以保住 def key"的做法现成可用。maxAttempts→maxRetries是 breaking 的可授权键改名,需要 ADR-0087 D2 conversion + 墓碑 + changeset 迁移文案;默认值 3 → 0 是行为改变,但此处零 parse 点,所以没有已部署的 stack 会变(这一点和 spec 双源清账 C8:RetryPolicy / RetryPolicySchema(./automation ≠ ./system)—— 2 条 #4661 当时必须写retry-policy-convergedconversion 的处境不同,便宜得多)。automation/ETLPipeline:retry在authorable-surface.json的展开会变宽(2 键 → 5 键)。B. 只统一拼写,不引用共享声明(
maxAttempts→maxRetries,默认值改 0,保持内联两键)。backoffMultiplier/jitter在别处能写、在这里不能写的坑原样保留。C. 什么都不做,保留批 12 的 guidance。
建议
A,理由在两个轴上一致:它是 #4661 已经论证过的那个方向(一个概念一份声明),而且此处零 parse 点意味着 A 的迁移成本在整个仓库里此刻最低 —— 越晚做越贵,因为一旦真有 ETL 引擎落地,默认值 3 → 0 就从"改一份 schema"变成"改所有已部署管道的行为"。B 花掉了 A 的全部 breaking 预算却留下了 A 要解决的问题,不推荐。
需要维护者裁决的是 A 里的默认值:
maxRetries跟随收敛后的0(与 #4661 的 opt-in 论证一致),还是为 ETL 保留3(与现状一致但重新引入"隐式重试")。我的读法是跟随0—— #4661 的论证不依赖于是哪个 domain。/cc #4001#4661#4535