[finding] A schedule (cron) flow has no dispatch-claim ledger while a time_relative flow does — a re-run of a digest re-notifies #14501

Description

@os-project-manager

Filed by the director seat (session session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02) out of #14149's fourth point, which the triage seat asked to be carried as its own finding after the ruling. Observation only; unassigned; grading is triage's.

The asymmetry, as recorded on #14149 (measured on 17.2.0)

TimeRelativeTrigger.claimDispatch() takes a persisted claim through the automation service (sys_flow_dispatch, #10220) keyed per (flow, window, record) before launching, so per-record once-only delivery is free for time_relative flows. A schedule (cron) flow runs once per tick with no record, so it has no claim surface at all: "a re-run must not re-notify" falls back to application state that the platform already tracks for the other trigger type.

A digest necessarily runs on the cron path (#14149 ruled A lands the rendering half of digests), so this asymmetry becomes user-visible the first time a scheduled digest tick is retried.

Re-check: git grep -n "claimDispatch" origin/main -- packages/services/service-automation/src packages/triggers (positive control: sys_flow_dispatch in the same tree).

What is not claimed

Not measured: whether the scheduler retries a tick at all today, and under what failure. If it never retries, the exposure is a restart-in-window, not a retry. Whoever grades this should measure that first.

Refs: #14149 (the digest card, ruled A) · #10220 (the dispatch-claim ledger for time_relative).


Triage — the card's re-check reproduces, and its open question is now answered

Ran the card's own command at origin/mained44512, with its control. claimDispatch appears at exactly two lines, both in packages/triggers/trigger-schedule/src/time-relative-trigger.ts (:379 call, :427 definition), and nowhere else in either tree. Control (sys_flow_dispatch) returns a non-empty file set. The asymmetry is real and the instrument fires.

The card asked the grader to measure the retry question first. I did, and the answer reshapes the card in three ways.

1. There is no automatic retry — and the reason is worse than the absence.packages/triggers/trigger-schedule/src/schedule-trigger.ts:197-215 wraps the whole callback(ctx) in a try/catch whose comment says so outright:

Error isolation: a scheduled flow failure must not crash the job runner / ticker. Log and swallow.

The handler then returns normally. So a scheduled flow that throws is recorded by the job service as a successful run, and the job-level retryPolicy (packages/spec/src/system/job.zod.ts:142 and its readers) can never fire for it. ⇒ The card's headline — "a re-run of a digest re-notifies" — does not happen via retry today. That limb should not be carried forward as written.

2. The live re-run door is replay(), and it is operator-facing.packages/spec/src/contracts/job-service.ts:200-206 declares replay?(name, data?) — "Equivalent to trigger(name) but records that this run is a replay" — implemented at packages/services/service-job/src/db-job-adapter.ts:211. An operator replaying a scheduled digest job re-runs it with nothing to stop the second delivery. That is the card's risk, reached through a different door than the one it names, and it is a door someone actually opens on purpose.

3. Across-replica duplication is NOT part of this. I went looking for it and it is already handled: packages/services/service-job/src/interval-job-adapter.ts:95-107 records that DbJobAdapter delegates registration while "the scheduled fire is owned by whichever adapter can leader-elect it", explicitly against "the across-replicas duplication the delegation exists to fix" (#13686). content/docs/deployment/self-hosting.mdx:393-401 matches: in-process coordination is single-node, and replicas > 1 requires OS_CLUSTER_DRIVER. I am not claiming a multi-instance duplicate-tick exposure, and nobody should chase one from this card.

Routing

domain:servicespackages/triggers/* and packages/services/* are one lane, and every landing site (the trigger, the automation service's claim ledger, the job adapter) is inside it.

priority:p2: user-visible duplicate delivery, but it needs a deliberate operator replay or a restart inside the window — not something that happens on its own. It is not p3 because the feature that will meet it is being built right now (#14149 ruled A, and a digest can only run on the cron path).

needs-user-decision — see the facet block. Note for whoever implements after the ruling: if the chosen direction changes what IJobService.replay() does for a scheduled flow, that implementing card carries needs:contract-review and dispatches at the contract-review tier. I am not applying that label now, because one of the live options changes no contract at all and pre-imposing the gate would presuppose the ruling.

<!-- os-decision-facets -->

  • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 「这一批已经发过了」这张台账平台已经有了,只是只接在按记录触发的那类流程上。把同一张台账接到定时路径上 = 复用已有机制,把两类触发之间的行为差抹平;另一条路是让每个定时流程自己在应用层记「我发过了」= 每个客户、每个流程各写一遍同样的东西,永久增生。①明确指向:台账接到定时路径。
  • ② 实际业务拉动 —— 有,而且正在长出来:A digest — one message per recipient listing their N records — is not authorable in a flow #14149 已裁 A,摘要的渲染半边要落地,而摘要只能跑定时路径。第一个会撞上这件事的功能已经在建了。非零且可预见的拉动 ⇒ 按分歧推荐序,荐①的长远终态。
  • ③ 防 AI 犯错 —— 出错时谁看到什么:客户收到两封一模一样的摘要邮件,平台这边一切正常、无报错无告警。今天连「失败」本身都是静默的 —— 定时流程抛出的错误被 schedule-trigger.ts:208-214 捕获吞掉(注释就写着 Log and swallow),运维看到的是一个成功的任务。有台账时,重放会明确告诉运维「这个窗口已经派发过了」,静默重复换成响亮拒绝。
  • ④ 创业阶段不扩散 —— 不引入新概念:复用 sys_flow_dispatch 这张已存在的台账,只多一种键形(按「流程 + 时间窗」,而不是「流程 + 窗口 + 记录」)。比起要求每个作者永远记得给定时流程自带幂等键,平台侧一次做掉是更少的永久义务。

推荐:A —— 把派发认领台账接到定时路径上,键形 (flow, tick-window) 四棱同向。
⚠️A 带一个必须由维护者一并裁的副作用:replay() 是明确面向运维的动词(spec 里写着「等同于 trigger,但记录这次是重放」)。台账一旦生效,对定时流程的重放就变成空操作 —— 运维按下重放、什么都没发生,这本身是坏体验。所以裁 A 的同时要裁 replay 的语义:响亮拒绝、需要一个显式的「强制重发」参数、还是照旧发送并接受重复。本席不代裁,三种都能自圆其说。
回退:B —— 不动机制,只把这条不对称写进文档,并要求摘要类流程在应用层自带幂等键。代价是每个作者都得记得,忘记时是静默重复 —— ③④都反对,列在这里是因为它零风险且今天就能做。
置信缺口(本分析看不见什么): 未测量两件事 —— 多副本部署下领导权在窗口中间交接(#13686 的 leader 选举)会不会重发一次,以及进程在窗口内重启的行为。这两个是除运维手动重放之外仅有的重复入口,本轮没量,它们只会让 A 更有必要、不会翻掉推荐。另需记入:卡面「重试会重发」这一前提今天不成立(见上文第 1 点),所以 A 的收益要按「运维重放 + 重启」这两个入口来算,而不是按自动重试。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
       blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      
      Skip to content

      [finding] A schedule (cron) flow has no dispatch-claim ledger while a time_relative flow does — a re-run of a digest re-notifies #14501

      Description

      @os-project-manager

      Filed by the director seat (session session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02) out of #14149's fourth point, which the triage seat asked to be carried as its own finding after the ruling. Observation only; unassigned; grading is triage's.

      The asymmetry, as recorded on #14149 (measured on 17.2.0)

      TimeRelativeTrigger.claimDispatch() takes a persisted claim through the automation service (sys_flow_dispatch, #10220) keyed per (flow, window, record) before launching, so per-record once-only delivery is free for time_relative flows. A schedule (cron) flow runs once per tick with no record, so it has no claim surface at all: "a re-run must not re-notify" falls back to application state that the platform already tracks for the other trigger type.

      A digest necessarily runs on the cron path (#14149 ruled A lands the rendering half of digests), so this asymmetry becomes user-visible the first time a scheduled digest tick is retried.

      Re-check: git grep -n "claimDispatch" origin/main -- packages/services/service-automation/src packages/triggers (positive control: sys_flow_dispatch in the same tree).

      What is not claimed

      Not measured: whether the scheduler retries a tick at all today, and under what failure. If it never retries, the exposure is a restart-in-window, not a retry. Whoever grades this should measure that first.

      Refs: #14149 (the digest card, ruled A) · #10220 (the dispatch-claim ledger for time_relative).


      Triage — the card's re-check reproduces, and its open question is now answered

      Ran the card's own command at origin/mained44512, with its control. claimDispatch appears at exactly two lines, both in packages/triggers/trigger-schedule/src/time-relative-trigger.ts (:379 call, :427 definition), and nowhere else in either tree. Control (sys_flow_dispatch) returns a non-empty file set. The asymmetry is real and the instrument fires.

      The card asked the grader to measure the retry question first. I did, and the answer reshapes the card in three ways.

      1. There is no automatic retry — and the reason is worse than the absence.packages/triggers/trigger-schedule/src/schedule-trigger.ts:197-215 wraps the whole callback(ctx) in a try/catch whose comment says so outright:

      Error isolation: a scheduled flow failure must not crash the job runner / ticker. Log and swallow.

      The handler then returns normally. So a scheduled flow that throws is recorded by the job service as a successful run, and the job-level retryPolicy (packages/spec/src/system/job.zod.ts:142 and its readers) can never fire for it. ⇒ The card's headline — "a re-run of a digest re-notifies" — does not happen via retry today. That limb should not be carried forward as written.

      2. The live re-run door is replay(), and it is operator-facing.packages/spec/src/contracts/job-service.ts:200-206 declares replay?(name, data?) — "Equivalent to trigger(name) but records that this run is a replay" — implemented at packages/services/service-job/src/db-job-adapter.ts:211. An operator replaying a scheduled digest job re-runs it with nothing to stop the second delivery. That is the card's risk, reached through a different door than the one it names, and it is a door someone actually opens on purpose.

      3. Across-replica duplication is NOT part of this. I went looking for it and it is already handled: packages/services/service-job/src/interval-job-adapter.ts:95-107 records that DbJobAdapter delegates registration while "the scheduled fire is owned by whichever adapter can leader-elect it", explicitly against "the across-replicas duplication the delegation exists to fix" (#13686). content/docs/deployment/self-hosting.mdx:393-401 matches: in-process coordination is single-node, and replicas > 1 requires OS_CLUSTER_DRIVER. I am not claiming a multi-instance duplicate-tick exposure, and nobody should chase one from this card.

      Routing

      domain:servicespackages/triggers/* and packages/services/* are one lane, and every landing site (the trigger, the automation service's claim ledger, the job adapter) is inside it.

      priority:p2: user-visible duplicate delivery, but it needs a deliberate operator replay or a restart inside the window — not something that happens on its own. It is not p3 because the feature that will meet it is being built right now (#14149 ruled A, and a digest can only run on the cron path).

      needs-user-decision — see the facet block. Note for whoever implements after the ruling: if the chosen direction changes what IJobService.replay() does for a scheduled flow, that implementing card carries needs:contract-review and dispatches at the contract-review tier. I am not applying that label now, because one of the live options changes no contract at all and pre-imposing the gate would presuppose the ruling.

      <!-- os-decision-facets -->

      • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 「这一批已经发过了」这张台账平台已经有了,只是只接在按记录触发的那类流程上。把同一张台账接到定时路径上 = 复用已有机制,把两类触发之间的行为差抹平;另一条路是让每个定时流程自己在应用层记「我发过了」= 每个客户、每个流程各写一遍同样的东西,永久增生。①明确指向:台账接到定时路径。
      • ② 实际业务拉动 —— 有,而且正在长出来:A digest — one message per recipient listing their N records — is not authorable in a flow #14149 已裁 A,摘要的渲染半边要落地,而摘要只能跑定时路径。第一个会撞上这件事的功能已经在建了。非零且可预见的拉动 ⇒ 按分歧推荐序,荐①的长远终态。
      • ③ 防 AI 犯错 —— 出错时谁看到什么:客户收到两封一模一样的摘要邮件,平台这边一切正常、无报错无告警。今天连「失败」本身都是静默的 —— 定时流程抛出的错误被 schedule-trigger.ts:208-214 捕获吞掉(注释就写着 Log and swallow),运维看到的是一个成功的任务。有台账时,重放会明确告诉运维「这个窗口已经派发过了」,静默重复换成响亮拒绝。
      • ④ 创业阶段不扩散 —— 不引入新概念:复用 sys_flow_dispatch 这张已存在的台账,只多一种键形(按「流程 + 时间窗」,而不是「流程 + 窗口 + 记录」)。比起要求每个作者永远记得给定时流程自带幂等键,平台侧一次做掉是更少的永久义务。

      推荐:A —— 把派发认领台账接到定时路径上,键形 (flow, tick-window) 四棱同向。
      ⚠️A 带一个必须由维护者一并裁的副作用:replay() 是明确面向运维的动词(spec 里写着「等同于 trigger,但记录这次是重放」)。台账一旦生效,对定时流程的重放就变成空操作 —— 运维按下重放、什么都没发生,这本身是坏体验。所以裁 A 的同时要裁 replay 的语义:响亮拒绝、需要一个显式的「强制重发」参数、还是照旧发送并接受重复。本席不代裁,三种都能自圆其说。
      回退:B —— 不动机制,只把这条不对称写进文档,并要求摘要类流程在应用层自带幂等键。代价是每个作者都得记得,忘记时是静默重复 —— ③④都反对,列在这里是因为它零风险且今天就能做。
      置信缺口(本分析看不见什么): 未测量两件事 —— 多副本部署下领导权在窗口中间交接(#13686 的 leader 选举)会不会重发一次,以及进程在窗口内重启的行为。这两个是除运维手动重放之外仅有的重复入口,本轮没量,它们只会让 A 更有必要、不会翻掉推荐。另需记入:卡面「重试会重发」这一前提今天不成立(见上文第 1 点),所以 A 的收益要按「运维重放 + 重启」这两个入口来算,而不是按自动重试。

      Activity

      Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

      Metadata

      Metadata

      Assignees

      No one assigned

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          Skip to content

          [finding] A schedule (cron) flow has no dispatch-claim ledger while a time_relative flow does — a re-run of a digest re-notifies #14501

          Description

          @os-project-manager

          Filed by the director seat (session session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02) out of #14149's fourth point, which the triage seat asked to be carried as its own finding after the ruling. Observation only; unassigned; grading is triage's.

          The asymmetry, as recorded on #14149 (measured on 17.2.0)

          TimeRelativeTrigger.claimDispatch() takes a persisted claim through the automation service (sys_flow_dispatch, #10220) keyed per (flow, window, record) before launching, so per-record once-only delivery is free for time_relative flows. A schedule (cron) flow runs once per tick with no record, so it has no claim surface at all: "a re-run must not re-notify" falls back to application state that the platform already tracks for the other trigger type.

          A digest necessarily runs on the cron path (#14149 ruled A lands the rendering half of digests), so this asymmetry becomes user-visible the first time a scheduled digest tick is retried.

          Re-check: git grep -n "claimDispatch" origin/main -- packages/services/service-automation/src packages/triggers (positive control: sys_flow_dispatch in the same tree).

          What is not claimed

          Not measured: whether the scheduler retries a tick at all today, and under what failure. If it never retries, the exposure is a restart-in-window, not a retry. Whoever grades this should measure that first.

          Refs: #14149 (the digest card, ruled A) · #10220 (the dispatch-claim ledger for time_relative).


          Triage — the card's re-check reproduces, and its open question is now answered

          Ran the card's own command at origin/mained44512, with its control. claimDispatch appears at exactly two lines, both in packages/triggers/trigger-schedule/src/time-relative-trigger.ts (:379 call, :427 definition), and nowhere else in either tree. Control (sys_flow_dispatch) returns a non-empty file set. The asymmetry is real and the instrument fires.

          The card asked the grader to measure the retry question first. I did, and the answer reshapes the card in three ways.

          1. There is no automatic retry — and the reason is worse than the absence.packages/triggers/trigger-schedule/src/schedule-trigger.ts:197-215 wraps the whole callback(ctx) in a try/catch whose comment says so outright:

          Error isolation: a scheduled flow failure must not crash the job runner / ticker. Log and swallow.

          The handler then returns normally. So a scheduled flow that throws is recorded by the job service as a successful run, and the job-level retryPolicy (packages/spec/src/system/job.zod.ts:142 and its readers) can never fire for it. ⇒ The card's headline — "a re-run of a digest re-notifies" — does not happen via retry today. That limb should not be carried forward as written.

          2. The live re-run door is replay(), and it is operator-facing.packages/spec/src/contracts/job-service.ts:200-206 declares replay?(name, data?) — "Equivalent to trigger(name) but records that this run is a replay" — implemented at packages/services/service-job/src/db-job-adapter.ts:211. An operator replaying a scheduled digest job re-runs it with nothing to stop the second delivery. That is the card's risk, reached through a different door than the one it names, and it is a door someone actually opens on purpose.

          3. Across-replica duplication is NOT part of this. I went looking for it and it is already handled: packages/services/service-job/src/interval-job-adapter.ts:95-107 records that DbJobAdapter delegates registration while "the scheduled fire is owned by whichever adapter can leader-elect it", explicitly against "the across-replicas duplication the delegation exists to fix" (#13686). content/docs/deployment/self-hosting.mdx:393-401 matches: in-process coordination is single-node, and replicas > 1 requires OS_CLUSTER_DRIVER. I am not claiming a multi-instance duplicate-tick exposure, and nobody should chase one from this card.

          Routing

          domain:servicespackages/triggers/* and packages/services/* are one lane, and every landing site (the trigger, the automation service's claim ledger, the job adapter) is inside it.

          priority:p2: user-visible duplicate delivery, but it needs a deliberate operator replay or a restart inside the window — not something that happens on its own. It is not p3 because the feature that will meet it is being built right now (#14149 ruled A, and a digest can only run on the cron path).

          needs-user-decision — see the facet block. Note for whoever implements after the ruling: if the chosen direction changes what IJobService.replay() does for a scheduled flow, that implementing card carries needs:contract-review and dispatches at the contract-review tier. I am not applying that label now, because one of the live options changes no contract at all and pre-imposing the gate would presuppose the ruling.

          <!-- os-decision-facets -->

          • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 「这一批已经发过了」这张台账平台已经有了,只是只接在按记录触发的那类流程上。把同一张台账接到定时路径上 = 复用已有机制,把两类触发之间的行为差抹平;另一条路是让每个定时流程自己在应用层记「我发过了」= 每个客户、每个流程各写一遍同样的东西,永久增生。①明确指向:台账接到定时路径。
          • ② 实际业务拉动 —— 有,而且正在长出来:A digest — one message per recipient listing their N records — is not authorable in a flow #14149 已裁 A,摘要的渲染半边要落地,而摘要只能跑定时路径。第一个会撞上这件事的功能已经在建了。非零且可预见的拉动 ⇒ 按分歧推荐序,荐①的长远终态。
          • ③ 防 AI 犯错 —— 出错时谁看到什么:客户收到两封一模一样的摘要邮件,平台这边一切正常、无报错无告警。今天连「失败」本身都是静默的 —— 定时流程抛出的错误被 schedule-trigger.ts:208-214 捕获吞掉(注释就写着 Log and swallow),运维看到的是一个成功的任务。有台账时,重放会明确告诉运维「这个窗口已经派发过了」,静默重复换成响亮拒绝。
          • ④ 创业阶段不扩散 —— 不引入新概念:复用 sys_flow_dispatch 这张已存在的台账,只多一种键形(按「流程 + 时间窗」,而不是「流程 + 窗口 + 记录」)。比起要求每个作者永远记得给定时流程自带幂等键,平台侧一次做掉是更少的永久义务。

          推荐:A —— 把派发认领台账接到定时路径上,键形 (flow, tick-window) 四棱同向。
          ⚠️A 带一个必须由维护者一并裁的副作用:replay() 是明确面向运维的动词(spec 里写着「等同于 trigger,但记录这次是重放」)。台账一旦生效,对定时流程的重放就变成空操作 —— 运维按下重放、什么都没发生,这本身是坏体验。所以裁 A 的同时要裁 replay 的语义:响亮拒绝、需要一个显式的「强制重发」参数、还是照旧发送并接受重复。本席不代裁,三种都能自圆其说。
          回退:B —— 不动机制,只把这条不对称写进文档,并要求摘要类流程在应用层自带幂等键。代价是每个作者都得记得,忘记时是静默重复 —— ③④都反对,列在这里是因为它零风险且今天就能做。
          置信缺口(本分析看不见什么): 未测量两件事 —— 多副本部署下领导权在窗口中间交接(#13686 的 leader 选举)会不会重发一次,以及进程在窗口内重启的行为。这两个是除运维手动重放之外仅有的重复入口,本轮没量,它们只会让 A 更有必要、不会翻掉推荐。另需记入:卡面「重试会重发」这一前提今天不成立(见上文第 1 点),所以 A 的收益要按「运维重放 + 重启」这两个入口来算,而不是按自动重试。

          Activity

          Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

          Metadata

          Metadata

          Assignees

          No one assigned

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              [finding] A schedule (cron) flow has no dispatch-claim ledger while a time_relative flow does — a re-run of a digest re-notifies #14501

              Description

              @os-project-manager

              Filed by the director seat (session session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02) out of #14149's fourth point, which the triage seat asked to be carried as its own finding after the ruling. Observation only; unassigned; grading is triage's.

              The asymmetry, as recorded on #14149 (measured on 17.2.0)

              TimeRelativeTrigger.claimDispatch() takes a persisted claim through the automation service (sys_flow_dispatch, #10220) keyed per (flow, window, record) before launching, so per-record once-only delivery is free for time_relative flows. A schedule (cron) flow runs once per tick with no record, so it has no claim surface at all: "a re-run must not re-notify" falls back to application state that the platform already tracks for the other trigger type.

              A digest necessarily runs on the cron path (#14149 ruled A lands the rendering half of digests), so this asymmetry becomes user-visible the first time a scheduled digest tick is retried.

              Re-check: git grep -n "claimDispatch" origin/main -- packages/services/service-automation/src packages/triggers (positive control: sys_flow_dispatch in the same tree).

              What is not claimed

              Not measured: whether the scheduler retries a tick at all today, and under what failure. If it never retries, the exposure is a restart-in-window, not a retry. Whoever grades this should measure that first.

              Refs: #14149 (the digest card, ruled A) · #10220 (the dispatch-claim ledger for time_relative).


              Triage — the card's re-check reproduces, and its open question is now answered

              Ran the card's own command at origin/mained44512, with its control. claimDispatch appears at exactly two lines, both in packages/triggers/trigger-schedule/src/time-relative-trigger.ts (:379 call, :427 definition), and nowhere else in either tree. Control (sys_flow_dispatch) returns a non-empty file set. The asymmetry is real and the instrument fires.

              The card asked the grader to measure the retry question first. I did, and the answer reshapes the card in three ways.

              1. There is no automatic retry — and the reason is worse than the absence.packages/triggers/trigger-schedule/src/schedule-trigger.ts:197-215 wraps the whole callback(ctx) in a try/catch whose comment says so outright:

              Error isolation: a scheduled flow failure must not crash the job runner / ticker. Log and swallow.

              The handler then returns normally. So a scheduled flow that throws is recorded by the job service as a successful run, and the job-level retryPolicy (packages/spec/src/system/job.zod.ts:142 and its readers) can never fire for it. ⇒ The card's headline — "a re-run of a digest re-notifies" — does not happen via retry today. That limb should not be carried forward as written.

              2. The live re-run door is replay(), and it is operator-facing.packages/spec/src/contracts/job-service.ts:200-206 declares replay?(name, data?) — "Equivalent to trigger(name) but records that this run is a replay" — implemented at packages/services/service-job/src/db-job-adapter.ts:211. An operator replaying a scheduled digest job re-runs it with nothing to stop the second delivery. That is the card's risk, reached through a different door than the one it names, and it is a door someone actually opens on purpose.

              3. Across-replica duplication is NOT part of this. I went looking for it and it is already handled: packages/services/service-job/src/interval-job-adapter.ts:95-107 records that DbJobAdapter delegates registration while "the scheduled fire is owned by whichever adapter can leader-elect it", explicitly against "the across-replicas duplication the delegation exists to fix" (#13686). content/docs/deployment/self-hosting.mdx:393-401 matches: in-process coordination is single-node, and replicas > 1 requires OS_CLUSTER_DRIVER. I am not claiming a multi-instance duplicate-tick exposure, and nobody should chase one from this card.

              Routing

              domain:servicespackages/triggers/* and packages/services/* are one lane, and every landing site (the trigger, the automation service's claim ledger, the job adapter) is inside it.

              priority:p2: user-visible duplicate delivery, but it needs a deliberate operator replay or a restart inside the window — not something that happens on its own. It is not p3 because the feature that will meet it is being built right now (#14149 ruled A, and a digest can only run on the cron path).

              needs-user-decision — see the facet block. Note for whoever implements after the ruling: if the chosen direction changes what IJobService.replay() does for a scheduled flow, that implementing card carries needs:contract-review and dispatches at the contract-review tier. I am not applying that label now, because one of the live options changes no contract at all and pre-imposing the gate would presuppose the ruling.

              <!-- os-decision-facets -->

              • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 「这一批已经发过了」这张台账平台已经有了,只是只接在按记录触发的那类流程上。把同一张台账接到定时路径上 = 复用已有机制,把两类触发之间的行为差抹平;另一条路是让每个定时流程自己在应用层记「我发过了」= 每个客户、每个流程各写一遍同样的东西,永久增生。①明确指向:台账接到定时路径。
              • ② 实际业务拉动 —— 有,而且正在长出来:A digest — one message per recipient listing their N records — is not authorable in a flow #14149 已裁 A,摘要的渲染半边要落地,而摘要只能跑定时路径。第一个会撞上这件事的功能已经在建了。非零且可预见的拉动 ⇒ 按分歧推荐序,荐①的长远终态。
              • ③ 防 AI 犯错 —— 出错时谁看到什么:客户收到两封一模一样的摘要邮件,平台这边一切正常、无报错无告警。今天连「失败」本身都是静默的 —— 定时流程抛出的错误被 schedule-trigger.ts:208-214 捕获吞掉(注释就写着 Log and swallow),运维看到的是一个成功的任务。有台账时,重放会明确告诉运维「这个窗口已经派发过了」,静默重复换成响亮拒绝。
              • ④ 创业阶段不扩散 —— 不引入新概念:复用 sys_flow_dispatch 这张已存在的台账,只多一种键形(按「流程 + 时间窗」,而不是「流程 + 窗口 + 记录」)。比起要求每个作者永远记得给定时流程自带幂等键,平台侧一次做掉是更少的永久义务。

              推荐:A —— 把派发认领台账接到定时路径上,键形 (flow, tick-window) 四棱同向。
              ⚠️A 带一个必须由维护者一并裁的副作用:replay() 是明确面向运维的动词(spec 里写着「等同于 trigger,但记录这次是重放」)。台账一旦生效,对定时流程的重放就变成空操作 —— 运维按下重放、什么都没发生,这本身是坏体验。所以裁 A 的同时要裁 replay 的语义:响亮拒绝、需要一个显式的「强制重发」参数、还是照旧发送并接受重复。本席不代裁,三种都能自圆其说。
              回退:B —— 不动机制,只把这条不对称写进文档,并要求摘要类流程在应用层自带幂等键。代价是每个作者都得记得,忘记时是静默重复 —— ③④都反对,列在这里是因为它零风险且今天就能做。
              置信缺口(本分析看不见什么): 未测量两件事 —— 多副本部署下领导权在窗口中间交接(#13686 的 leader 选举)会不会重发一次,以及进程在窗口内重启的行为。这两个是除运维手动重放之外仅有的重复入口,本轮没量,它们只会让 A 更有必要、不会翻掉推荐。另需记入:卡面「重试会重发」这一前提今天不成立(见上文第 1 点),所以 A 的收益要按「运维重放 + 重启」这两个入口来算,而不是按自动重试。

              Activity

              Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

              Metadata

              Metadata

              Assignees

              No one assigned

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  Skip to content

                  [finding] A schedule (cron) flow has no dispatch-claim ledger while a time_relative flow does — a re-run of a digest re-notifies #14501

                  Description

                  @os-project-manager

                  Filed by the director seat (session session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02) out of #14149's fourth point, which the triage seat asked to be carried as its own finding after the ruling. Observation only; unassigned; grading is triage's.

                  The asymmetry, as recorded on #14149 (measured on 17.2.0)

                  TimeRelativeTrigger.claimDispatch() takes a persisted claim through the automation service (sys_flow_dispatch, #10220) keyed per (flow, window, record) before launching, so per-record once-only delivery is free for time_relative flows. A schedule (cron) flow runs once per tick with no record, so it has no claim surface at all: "a re-run must not re-notify" falls back to application state that the platform already tracks for the other trigger type.

                  A digest necessarily runs on the cron path (#14149 ruled A lands the rendering half of digests), so this asymmetry becomes user-visible the first time a scheduled digest tick is retried.

                  Re-check: git grep -n "claimDispatch" origin/main -- packages/services/service-automation/src packages/triggers (positive control: sys_flow_dispatch in the same tree).

                  What is not claimed

                  Not measured: whether the scheduler retries a tick at all today, and under what failure. If it never retries, the exposure is a restart-in-window, not a retry. Whoever grades this should measure that first.

                  Refs: #14149 (the digest card, ruled A) · #10220 (the dispatch-claim ledger for time_relative).


                  Triage — the card's re-check reproduces, and its open question is now answered

                  Ran the card's own command at origin/mained44512, with its control. claimDispatch appears at exactly two lines, both in packages/triggers/trigger-schedule/src/time-relative-trigger.ts (:379 call, :427 definition), and nowhere else in either tree. Control (sys_flow_dispatch) returns a non-empty file set. The asymmetry is real and the instrument fires.

                  The card asked the grader to measure the retry question first. I did, and the answer reshapes the card in three ways.

                  1. There is no automatic retry — and the reason is worse than the absence.packages/triggers/trigger-schedule/src/schedule-trigger.ts:197-215 wraps the whole callback(ctx) in a try/catch whose comment says so outright:

                  Error isolation: a scheduled flow failure must not crash the job runner / ticker. Log and swallow.

                  The handler then returns normally. So a scheduled flow that throws is recorded by the job service as a successful run, and the job-level retryPolicy (packages/spec/src/system/job.zod.ts:142 and its readers) can never fire for it. ⇒ The card's headline — "a re-run of a digest re-notifies" — does not happen via retry today. That limb should not be carried forward as written.

                  2. The live re-run door is replay(), and it is operator-facing.packages/spec/src/contracts/job-service.ts:200-206 declares replay?(name, data?) — "Equivalent to trigger(name) but records that this run is a replay" — implemented at packages/services/service-job/src/db-job-adapter.ts:211. An operator replaying a scheduled digest job re-runs it with nothing to stop the second delivery. That is the card's risk, reached through a different door than the one it names, and it is a door someone actually opens on purpose.

                  3. Across-replica duplication is NOT part of this. I went looking for it and it is already handled: packages/services/service-job/src/interval-job-adapter.ts:95-107 records that DbJobAdapter delegates registration while "the scheduled fire is owned by whichever adapter can leader-elect it", explicitly against "the across-replicas duplication the delegation exists to fix" (#13686). content/docs/deployment/self-hosting.mdx:393-401 matches: in-process coordination is single-node, and replicas > 1 requires OS_CLUSTER_DRIVER. I am not claiming a multi-instance duplicate-tick exposure, and nobody should chase one from this card.

                  Routing

                  domain:servicespackages/triggers/* and packages/services/* are one lane, and every landing site (the trigger, the automation service's claim ledger, the job adapter) is inside it.

                  priority:p2: user-visible duplicate delivery, but it needs a deliberate operator replay or a restart inside the window — not something that happens on its own. It is not p3 because the feature that will meet it is being built right now (#14149 ruled A, and a digest can only run on the cron path).

                  needs-user-decision — see the facet block. Note for whoever implements after the ruling: if the chosen direction changes what IJobService.replay() does for a scheduled flow, that implementing card carries needs:contract-review and dispatches at the contract-review tier. I am not applying that label now, because one of the live options changes no contract at all and pre-imposing the gate would presuppose the ruling.

                  <!-- os-decision-facets -->

                  • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 「这一批已经发过了」这张台账平台已经有了,只是只接在按记录触发的那类流程上。把同一张台账接到定时路径上 = 复用已有机制,把两类触发之间的行为差抹平;另一条路是让每个定时流程自己在应用层记「我发过了」= 每个客户、每个流程各写一遍同样的东西,永久增生。①明确指向:台账接到定时路径。
                  • ② 实际业务拉动 —— 有,而且正在长出来:A digest — one message per recipient listing their N records — is not authorable in a flow #14149 已裁 A,摘要的渲染半边要落地,而摘要只能跑定时路径。第一个会撞上这件事的功能已经在建了。非零且可预见的拉动 ⇒ 按分歧推荐序,荐①的长远终态。
                  • ③ 防 AI 犯错 —— 出错时谁看到什么:客户收到两封一模一样的摘要邮件,平台这边一切正常、无报错无告警。今天连「失败」本身都是静默的 —— 定时流程抛出的错误被 schedule-trigger.ts:208-214 捕获吞掉(注释就写着 Log and swallow),运维看到的是一个成功的任务。有台账时,重放会明确告诉运维「这个窗口已经派发过了」,静默重复换成响亮拒绝。
                  • ④ 创业阶段不扩散 —— 不引入新概念:复用 sys_flow_dispatch 这张已存在的台账,只多一种键形(按「流程 + 时间窗」,而不是「流程 + 窗口 + 记录」)。比起要求每个作者永远记得给定时流程自带幂等键,平台侧一次做掉是更少的永久义务。

                  推荐:A —— 把派发认领台账接到定时路径上,键形 (flow, tick-window) 四棱同向。
                  ⚠️A 带一个必须由维护者一并裁的副作用:replay() 是明确面向运维的动词(spec 里写着「等同于 trigger,但记录这次是重放」)。台账一旦生效,对定时流程的重放就变成空操作 —— 运维按下重放、什么都没发生,这本身是坏体验。所以裁 A 的同时要裁 replay 的语义:响亮拒绝、需要一个显式的「强制重发」参数、还是照旧发送并接受重复。本席不代裁,三种都能自圆其说。
                  回退:B —— 不动机制,只把这条不对称写进文档,并要求摘要类流程在应用层自带幂等键。代价是每个作者都得记得,忘记时是静默重复 —— ③④都反对,列在这里是因为它零风险且今天就能做。
                  置信缺口(本分析看不见什么): 未测量两件事 —— 多副本部署下领导权在窗口中间交接(#13686 的 leader 选举)会不会重发一次,以及进程在窗口内重启的行为。这两个是除运维手动重放之外仅有的重复入口,本轮没量,它们只会让 A 更有必要、不会翻掉推荐。另需记入:卡面「重试会重发」这一前提今天不成立(见上文第 1 点),所以 A 的收益要按「运维重放 + 重启」这两个入口来算,而不是按自动重试。

                  Activity

                  Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Type

                    No type

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      Skip to content

                      [finding] A schedule (cron) flow has no dispatch-claim ledger while a time_relative flow does — a re-run of a digest re-notifies #14501

                      Description

                      @os-project-manager

                      Filed by the director seat (session session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02) out of #14149's fourth point, which the triage seat asked to be carried as its own finding after the ruling. Observation only; unassigned; grading is triage's.

                      The asymmetry, as recorded on #14149 (measured on 17.2.0)

                      TimeRelativeTrigger.claimDispatch() takes a persisted claim through the automation service (sys_flow_dispatch, #10220) keyed per (flow, window, record) before launching, so per-record once-only delivery is free for time_relative flows. A schedule (cron) flow runs once per tick with no record, so it has no claim surface at all: "a re-run must not re-notify" falls back to application state that the platform already tracks for the other trigger type.

                      A digest necessarily runs on the cron path (#14149 ruled A lands the rendering half of digests), so this asymmetry becomes user-visible the first time a scheduled digest tick is retried.

                      Re-check: git grep -n "claimDispatch" origin/main -- packages/services/service-automation/src packages/triggers (positive control: sys_flow_dispatch in the same tree).

                      What is not claimed

                      Not measured: whether the scheduler retries a tick at all today, and under what failure. If it never retries, the exposure is a restart-in-window, not a retry. Whoever grades this should measure that first.

                      Refs: #14149 (the digest card, ruled A) · #10220 (the dispatch-claim ledger for time_relative).


                      Triage — the card's re-check reproduces, and its open question is now answered

                      Ran the card's own command at origin/mained44512, with its control. claimDispatch appears at exactly two lines, both in packages/triggers/trigger-schedule/src/time-relative-trigger.ts (:379 call, :427 definition), and nowhere else in either tree. Control (sys_flow_dispatch) returns a non-empty file set. The asymmetry is real and the instrument fires.

                      The card asked the grader to measure the retry question first. I did, and the answer reshapes the card in three ways.

                      1. There is no automatic retry — and the reason is worse than the absence.packages/triggers/trigger-schedule/src/schedule-trigger.ts:197-215 wraps the whole callback(ctx) in a try/catch whose comment says so outright:

                      Error isolation: a scheduled flow failure must not crash the job runner / ticker. Log and swallow.

                      The handler then returns normally. So a scheduled flow that throws is recorded by the job service as a successful run, and the job-level retryPolicy (packages/spec/src/system/job.zod.ts:142 and its readers) can never fire for it. ⇒ The card's headline — "a re-run of a digest re-notifies" — does not happen via retry today. That limb should not be carried forward as written.

                      2. The live re-run door is replay(), and it is operator-facing.packages/spec/src/contracts/job-service.ts:200-206 declares replay?(name, data?) — "Equivalent to trigger(name) but records that this run is a replay" — implemented at packages/services/service-job/src/db-job-adapter.ts:211. An operator replaying a scheduled digest job re-runs it with nothing to stop the second delivery. That is the card's risk, reached through a different door than the one it names, and it is a door someone actually opens on purpose.

                      3. Across-replica duplication is NOT part of this. I went looking for it and it is already handled: packages/services/service-job/src/interval-job-adapter.ts:95-107 records that DbJobAdapter delegates registration while "the scheduled fire is owned by whichever adapter can leader-elect it", explicitly against "the across-replicas duplication the delegation exists to fix" (#13686). content/docs/deployment/self-hosting.mdx:393-401 matches: in-process coordination is single-node, and replicas > 1 requires OS_CLUSTER_DRIVER. I am not claiming a multi-instance duplicate-tick exposure, and nobody should chase one from this card.

                      Routing

                      domain:servicespackages/triggers/* and packages/services/* are one lane, and every landing site (the trigger, the automation service's claim ledger, the job adapter) is inside it.

                      priority:p2: user-visible duplicate delivery, but it needs a deliberate operator replay or a restart inside the window — not something that happens on its own. It is not p3 because the feature that will meet it is being built right now (#14149 ruled A, and a digest can only run on the cron path).

                      needs-user-decision — see the facet block. Note for whoever implements after the ruling: if the chosen direction changes what IJobService.replay() does for a scheduled flow, that implementing card carries needs:contract-review and dispatches at the contract-review tier. I am not applying that label now, because one of the live options changes no contract at all and pre-imposing the gate would presuppose the ruling.

                      <!-- os-decision-facets -->

                      • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 「这一批已经发过了」这张台账平台已经有了,只是只接在按记录触发的那类流程上。把同一张台账接到定时路径上 = 复用已有机制,把两类触发之间的行为差抹平;另一条路是让每个定时流程自己在应用层记「我发过了」= 每个客户、每个流程各写一遍同样的东西,永久增生。①明确指向:台账接到定时路径。
                      • ② 实际业务拉动 —— 有,而且正在长出来:A digest — one message per recipient listing their N records — is not authorable in a flow #14149 已裁 A,摘要的渲染半边要落地,而摘要只能跑定时路径。第一个会撞上这件事的功能已经在建了。非零且可预见的拉动 ⇒ 按分歧推荐序,荐①的长远终态。
                      • ③ 防 AI 犯错 —— 出错时谁看到什么:客户收到两封一模一样的摘要邮件,平台这边一切正常、无报错无告警。今天连「失败」本身都是静默的 —— 定时流程抛出的错误被 schedule-trigger.ts:208-214 捕获吞掉(注释就写着 Log and swallow),运维看到的是一个成功的任务。有台账时,重放会明确告诉运维「这个窗口已经派发过了」,静默重复换成响亮拒绝。
                      • ④ 创业阶段不扩散 —— 不引入新概念:复用 sys_flow_dispatch 这张已存在的台账,只多一种键形(按「流程 + 时间窗」,而不是「流程 + 窗口 + 记录」)。比起要求每个作者永远记得给定时流程自带幂等键,平台侧一次做掉是更少的永久义务。

                      推荐:A —— 把派发认领台账接到定时路径上,键形 (flow, tick-window) 四棱同向。
                      ⚠️A 带一个必须由维护者一并裁的副作用:replay() 是明确面向运维的动词(spec 里写着「等同于 trigger,但记录这次是重放」)。台账一旦生效,对定时流程的重放就变成空操作 —— 运维按下重放、什么都没发生,这本身是坏体验。所以裁 A 的同时要裁 replay 的语义:响亮拒绝、需要一个显式的「强制重发」参数、还是照旧发送并接受重复。本席不代裁,三种都能自圆其说。
                      回退:B —— 不动机制,只把这条不对称写进文档,并要求摘要类流程在应用层自带幂等键。代价是每个作者都得记得,忘记时是静默重复 —— ③④都反对,列在这里是因为它零风险且今天就能做。
                      置信缺口(本分析看不见什么): 未测量两件事 —— 多副本部署下领导权在窗口中间交接(#13686 的 leader 选举)会不会重发一次,以及进程在窗口内重启的行为。这两个是除运维手动重放之外仅有的重复入口,本轮没量,它们只会让 A 更有必要、不会翻掉推荐。另需记入:卡面「重试会重发」这一前提今天不成立(见上文第 1 点),所以 A 的收益要按「运维重放 + 重启」这两个入口来算,而不是按自动重试。

                      Activity

                      Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Type

                        No type

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          Skip to content

                          [finding] A schedule (cron) flow has no dispatch-claim ledger while a time_relative flow does — a re-run of a digest re-notifies #14501

                          Description

                          @os-project-manager

                          Filed by the director seat (session session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02) out of #14149's fourth point, which the triage seat asked to be carried as its own finding after the ruling. Observation only; unassigned; grading is triage's.

                          The asymmetry, as recorded on #14149 (measured on 17.2.0)

                          TimeRelativeTrigger.claimDispatch() takes a persisted claim through the automation service (sys_flow_dispatch, #10220) keyed per (flow, window, record) before launching, so per-record once-only delivery is free for time_relative flows. A schedule (cron) flow runs once per tick with no record, so it has no claim surface at all: "a re-run must not re-notify" falls back to application state that the platform already tracks for the other trigger type.

                          A digest necessarily runs on the cron path (#14149 ruled A lands the rendering half of digests), so this asymmetry becomes user-visible the first time a scheduled digest tick is retried.

                          Re-check: git grep -n "claimDispatch" origin/main -- packages/services/service-automation/src packages/triggers (positive control: sys_flow_dispatch in the same tree).

                          What is not claimed

                          Not measured: whether the scheduler retries a tick at all today, and under what failure. If it never retries, the exposure is a restart-in-window, not a retry. Whoever grades this should measure that first.

                          Refs: #14149 (the digest card, ruled A) · #10220 (the dispatch-claim ledger for time_relative).


                          Triage — the card's re-check reproduces, and its open question is now answered

                          Ran the card's own command at origin/mained44512, with its control. claimDispatch appears at exactly two lines, both in packages/triggers/trigger-schedule/src/time-relative-trigger.ts (:379 call, :427 definition), and nowhere else in either tree. Control (sys_flow_dispatch) returns a non-empty file set. The asymmetry is real and the instrument fires.

                          The card asked the grader to measure the retry question first. I did, and the answer reshapes the card in three ways.

                          1. There is no automatic retry — and the reason is worse than the absence.packages/triggers/trigger-schedule/src/schedule-trigger.ts:197-215 wraps the whole callback(ctx) in a try/catch whose comment says so outright:

                          Error isolation: a scheduled flow failure must not crash the job runner / ticker. Log and swallow.

                          The handler then returns normally. So a scheduled flow that throws is recorded by the job service as a successful run, and the job-level retryPolicy (packages/spec/src/system/job.zod.ts:142 and its readers) can never fire for it. ⇒ The card's headline — "a re-run of a digest re-notifies" — does not happen via retry today. That limb should not be carried forward as written.

                          2. The live re-run door is replay(), and it is operator-facing.packages/spec/src/contracts/job-service.ts:200-206 declares replay?(name, data?) — "Equivalent to trigger(name) but records that this run is a replay" — implemented at packages/services/service-job/src/db-job-adapter.ts:211. An operator replaying a scheduled digest job re-runs it with nothing to stop the second delivery. That is the card's risk, reached through a different door than the one it names, and it is a door someone actually opens on purpose.

                          3. Across-replica duplication is NOT part of this. I went looking for it and it is already handled: packages/services/service-job/src/interval-job-adapter.ts:95-107 records that DbJobAdapter delegates registration while "the scheduled fire is owned by whichever adapter can leader-elect it", explicitly against "the across-replicas duplication the delegation exists to fix" (#13686). content/docs/deployment/self-hosting.mdx:393-401 matches: in-process coordination is single-node, and replicas > 1 requires OS_CLUSTER_DRIVER. I am not claiming a multi-instance duplicate-tick exposure, and nobody should chase one from this card.

                          Routing

                          domain:servicespackages/triggers/* and packages/services/* are one lane, and every landing site (the trigger, the automation service's claim ledger, the job adapter) is inside it.

                          priority:p2: user-visible duplicate delivery, but it needs a deliberate operator replay or a restart inside the window — not something that happens on its own. It is not p3 because the feature that will meet it is being built right now (#14149 ruled A, and a digest can only run on the cron path).

                          needs-user-decision — see the facet block. Note for whoever implements after the ruling: if the chosen direction changes what IJobService.replay() does for a scheduled flow, that implementing card carries needs:contract-review and dispatches at the contract-review tier. I am not applying that label now, because one of the live options changes no contract at all and pre-imposing the gate would presuppose the ruling.

                          <!-- os-decision-facets -->

                          • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 「这一批已经发过了」这张台账平台已经有了,只是只接在按记录触发的那类流程上。把同一张台账接到定时路径上 = 复用已有机制,把两类触发之间的行为差抹平;另一条路是让每个定时流程自己在应用层记「我发过了」= 每个客户、每个流程各写一遍同样的东西,永久增生。①明确指向:台账接到定时路径。
                          • ② 实际业务拉动 —— 有,而且正在长出来:A digest — one message per recipient listing their N records — is not authorable in a flow #14149 已裁 A,摘要的渲染半边要落地,而摘要只能跑定时路径。第一个会撞上这件事的功能已经在建了。非零且可预见的拉动 ⇒ 按分歧推荐序,荐①的长远终态。
                          • ③ 防 AI 犯错 —— 出错时谁看到什么:客户收到两封一模一样的摘要邮件,平台这边一切正常、无报错无告警。今天连「失败」本身都是静默的 —— 定时流程抛出的错误被 schedule-trigger.ts:208-214 捕获吞掉(注释就写着 Log and swallow),运维看到的是一个成功的任务。有台账时,重放会明确告诉运维「这个窗口已经派发过了」,静默重复换成响亮拒绝。
                          • ④ 创业阶段不扩散 —— 不引入新概念:复用 sys_flow_dispatch 这张已存在的台账,只多一种键形(按「流程 + 时间窗」,而不是「流程 + 窗口 + 记录」)。比起要求每个作者永远记得给定时流程自带幂等键,平台侧一次做掉是更少的永久义务。

                          推荐:A —— 把派发认领台账接到定时路径上,键形 (flow, tick-window) 四棱同向。
                          ⚠️A 带一个必须由维护者一并裁的副作用:replay() 是明确面向运维的动词(spec 里写着「等同于 trigger,但记录这次是重放」)。台账一旦生效,对定时流程的重放就变成空操作 —— 运维按下重放、什么都没发生,这本身是坏体验。所以裁 A 的同时要裁 replay 的语义:响亮拒绝、需要一个显式的「强制重发」参数、还是照旧发送并接受重复。本席不代裁,三种都能自圆其说。
                          回退:B —— 不动机制,只把这条不对称写进文档,并要求摘要类流程在应用层自带幂等键。代价是每个作者都得记得,忘记时是静默重复 —— ③④都反对,列在这里是因为它零风险且今天就能做。
                          置信缺口(本分析看不见什么): 未测量两件事 —— 多副本部署下领导权在窗口中间交接(#13686 的 leader 选举)会不会重发一次,以及进程在窗口内重启的行为。这两个是除运维手动重放之外仅有的重复入口,本轮没量,它们只会让 A 更有必要、不会翻掉推荐。另需记入:卡面「重试会重发」这一前提今天不成立(见上文第 1 点),所以 A 的收益要按「运维重放 + 重启」这两个入口来算,而不是按自动重试。

                          Activity

                          Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Type

                            No type

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              Skip to content

                              [finding] A schedule (cron) flow has no dispatch-claim ledger while a time_relative flow does — a re-run of a digest re-notifies #14501

                              Description

                              @os-project-manager

                              Filed by the director seat (session session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02) out of #14149's fourth point, which the triage seat asked to be carried as its own finding after the ruling. Observation only; unassigned; grading is triage's.

                              The asymmetry, as recorded on #14149 (measured on 17.2.0)

                              TimeRelativeTrigger.claimDispatch() takes a persisted claim through the automation service (sys_flow_dispatch, #10220) keyed per (flow, window, record) before launching, so per-record once-only delivery is free for time_relative flows. A schedule (cron) flow runs once per tick with no record, so it has no claim surface at all: "a re-run must not re-notify" falls back to application state that the platform already tracks for the other trigger type.

                              A digest necessarily runs on the cron path (#14149 ruled A lands the rendering half of digests), so this asymmetry becomes user-visible the first time a scheduled digest tick is retried.

                              Re-check: git grep -n "claimDispatch" origin/main -- packages/services/service-automation/src packages/triggers (positive control: sys_flow_dispatch in the same tree).

                              What is not claimed

                              Not measured: whether the scheduler retries a tick at all today, and under what failure. If it never retries, the exposure is a restart-in-window, not a retry. Whoever grades this should measure that first.

                              Refs: #14149 (the digest card, ruled A) · #10220 (the dispatch-claim ledger for time_relative).


                              Triage — the card's re-check reproduces, and its open question is now answered

                              Ran the card's own command at origin/mained44512, with its control. claimDispatch appears at exactly two lines, both in packages/triggers/trigger-schedule/src/time-relative-trigger.ts (:379 call, :427 definition), and nowhere else in either tree. Control (sys_flow_dispatch) returns a non-empty file set. The asymmetry is real and the instrument fires.

                              The card asked the grader to measure the retry question first. I did, and the answer reshapes the card in three ways.

                              1. There is no automatic retry — and the reason is worse than the absence.packages/triggers/trigger-schedule/src/schedule-trigger.ts:197-215 wraps the whole callback(ctx) in a try/catch whose comment says so outright:

                              Error isolation: a scheduled flow failure must not crash the job runner / ticker. Log and swallow.

                              The handler then returns normally. So a scheduled flow that throws is recorded by the job service as a successful run, and the job-level retryPolicy (packages/spec/src/system/job.zod.ts:142 and its readers) can never fire for it. ⇒ The card's headline — "a re-run of a digest re-notifies" — does not happen via retry today. That limb should not be carried forward as written.

                              2. The live re-run door is replay(), and it is operator-facing.packages/spec/src/contracts/job-service.ts:200-206 declares replay?(name, data?) — "Equivalent to trigger(name) but records that this run is a replay" — implemented at packages/services/service-job/src/db-job-adapter.ts:211. An operator replaying a scheduled digest job re-runs it with nothing to stop the second delivery. That is the card's risk, reached through a different door than the one it names, and it is a door someone actually opens on purpose.

                              3. Across-replica duplication is NOT part of this. I went looking for it and it is already handled: packages/services/service-job/src/interval-job-adapter.ts:95-107 records that DbJobAdapter delegates registration while "the scheduled fire is owned by whichever adapter can leader-elect it", explicitly against "the across-replicas duplication the delegation exists to fix" (#13686). content/docs/deployment/self-hosting.mdx:393-401 matches: in-process coordination is single-node, and replicas > 1 requires OS_CLUSTER_DRIVER. I am not claiming a multi-instance duplicate-tick exposure, and nobody should chase one from this card.

                              Routing

                              domain:servicespackages/triggers/* and packages/services/* are one lane, and every landing site (the trigger, the automation service's claim ledger, the job adapter) is inside it.

                              priority:p2: user-visible duplicate delivery, but it needs a deliberate operator replay or a restart inside the window — not something that happens on its own. It is not p3 because the feature that will meet it is being built right now (#14149 ruled A, and a digest can only run on the cron path).

                              needs-user-decision — see the facet block. Note for whoever implements after the ruling: if the chosen direction changes what IJobService.replay() does for a scheduled flow, that implementing card carries needs:contract-review and dispatches at the contract-review tier. I am not applying that label now, because one of the live options changes no contract at all and pre-imposing the gate would presuppose the ruling.

                              <!-- os-decision-facets -->

                              • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 「这一批已经发过了」这张台账平台已经有了,只是只接在按记录触发的那类流程上。把同一张台账接到定时路径上 = 复用已有机制,把两类触发之间的行为差抹平;另一条路是让每个定时流程自己在应用层记「我发过了」= 每个客户、每个流程各写一遍同样的东西,永久增生。①明确指向:台账接到定时路径。
                              • ② 实际业务拉动 —— 有,而且正在长出来:A digest — one message per recipient listing their N records — is not authorable in a flow #14149 已裁 A,摘要的渲染半边要落地,而摘要只能跑定时路径。第一个会撞上这件事的功能已经在建了。非零且可预见的拉动 ⇒ 按分歧推荐序,荐①的长远终态。
                              • ③ 防 AI 犯错 —— 出错时谁看到什么:客户收到两封一模一样的摘要邮件,平台这边一切正常、无报错无告警。今天连「失败」本身都是静默的 —— 定时流程抛出的错误被 schedule-trigger.ts:208-214 捕获吞掉(注释就写着 Log and swallow),运维看到的是一个成功的任务。有台账时,重放会明确告诉运维「这个窗口已经派发过了」,静默重复换成响亮拒绝。
                              • ④ 创业阶段不扩散 —— 不引入新概念:复用 sys_flow_dispatch 这张已存在的台账,只多一种键形(按「流程 + 时间窗」,而不是「流程 + 窗口 + 记录」)。比起要求每个作者永远记得给定时流程自带幂等键,平台侧一次做掉是更少的永久义务。

                              推荐:A —— 把派发认领台账接到定时路径上,键形 (flow, tick-window) 四棱同向。
                              ⚠️A 带一个必须由维护者一并裁的副作用:replay() 是明确面向运维的动词(spec 里写着「等同于 trigger,但记录这次是重放」)。台账一旦生效,对定时流程的重放就变成空操作 —— 运维按下重放、什么都没发生,这本身是坏体验。所以裁 A 的同时要裁 replay 的语义:响亮拒绝、需要一个显式的「强制重发」参数、还是照旧发送并接受重复。本席不代裁,三种都能自圆其说。
                              回退:B —— 不动机制,只把这条不对称写进文档,并要求摘要类流程在应用层自带幂等键。代价是每个作者都得记得,忘记时是静默重复 —— ③④都反对,列在这里是因为它零风险且今天就能做。
                              置信缺口(本分析看不见什么): 未测量两件事 —— 多副本部署下领导权在窗口中间交接(#13686 的 leader 选举)会不会重发一次,以及进程在窗口内重启的行为。这两个是除运维手动重放之外仅有的重复入口,本轮没量,它们只会让 A 更有必要、不会翻掉推荐。另需记入:卡面「重试会重发」这一前提今天不成立(见上文第 1 点),所以 A 的收益要按「运维重放 + 重启」这两个入口来算,而不是按自动重试。

                              Activity

                              Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Type

                                No type

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions