Skip to content

finding: the HTTP node reports acted: 1 for a merely-enqueued outbox delivery — same overstatement #7747 fixed for notify, against sys_http_delivery #7882

Closed

Description

@huangyiirene

Observation

packages/services/service-automation/src/builtin/http-nodes.ts:139 returns { output: { deliveryId, enqueued: true }, metrics: { acted: 1 } } for a delivery that has only been enqueued to the HTTP outbox. sys_http_delivery carries its own terminal outcomes (including dead), decided by the dispatcher afterwards — so a flow-run summary can read acted: 1 for an HTTP delivery that later dead-letters, and the operator-facing summary disagrees with the durable record.

This is the same defect class #7747 (PR #7875) just fixed for the notify node, at the sibling node. Found by that card's dev while measuring the notify path; deliberately left out of scope there rather than ridden along.

Why it is likely the same fix shape

#7875 established the pattern in this exact codebase: acted counts only what a channel accepted terminally; a merely-enqueued effect is reported as unmeasuredEffect, which is the platform's existing qualifier for "an effect happened but the outcome is not yet knowable". That keeps healthy outbox-backed runs out of the documented broken-sweep alert (selected > 0 AND acted = 0 AND unmeasured = 0) without asserting success, and respects connector.zod.ts:613's rule against a blanket acted: 0.

Worth confirming before assuming it transfers: whether the HTTP node has an inline (non-outbox) path whose outcome IS terminal at return time, as MessagingService.emit() did — if so, the same two-path narrowing applies; if every HTTP delivery goes through the outbox, the fix is simpler.

Where

Refs

Activity

  1. added
    pm:queueReady for the PM dispatch loop
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Aug 12, 2026
  2. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: lands in packages/services/service-automation/src/builtin/http-nodes.ts (~:139) → domain:services; promoted straight to pm:queue rather than held as a finding: the defect is concrete (operator-facing summary disagrees with the durable sys_http_delivery record), the fix pattern is already established and merged at the sibling node (#7747 / PR #7875, unmeasuredEffect for merely-enqueued effects), and the scope is closed.

    One verification the card itself names as required before assuming transfer: whether the HTTP node has an inline (non-outbox) path whose outcome IS terminal at return time — if yes, the same two-path narrowing as #7875 applies; if all deliveries go through the outbox, the fix is simpler. Respect connector.zod.ts:613's rule against a blanket acted: 0 either way.

    Size/model suggestion: S–M (pattern transfer with one measurement), mode:subagent acceptable if the verification is mechanical, model: sonnet.


    Generated by Claude Code

  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 12, 2026
  4. self-assigned this
    on Aug 12, 2026
  5. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    Claim: PM loop round 6
    Session: session_01JVfWSQN9RDDGwdVSmMc29x
    Branch: claude/issue-7882-http-node-enqueued-acted
    Worktree: cloud session's own clone (mode:cloud)
    Domain: domain:services
    File surface: packages/services/service-automation/src/builtin/http-nodes.ts (~:139) + its tests. The sys_http_delivery object and the outbox/dispatcher path are read surfaces for measurement.
    Container & model: M, mode:cloud, model: opus

    Precedent card, and the reason this one should be cheap: #7747 → PR #7875 (MERGED) fixed the identical overstatement class for the notify node. That PR's reasoning is the starting point, not a suggestion to copy blindly:

    • EmitResult split enqueued from delivered — delivered means a channel accepted the delivery (terminal, observed); merely-durable rows are enqueued;
    • the node then reports unmeasuredEffect for enqueued-but-unsent, ⛔ not a bare acted: 0 — connector.zod.ts:613 explicitly forbids the latter, and it would trip the broken-sweep alert on every healthy outbox-backed run;
    • the alert is selected > 0 AND acted = 0 AND unmeasured = 0, so a pending delivery suppresses the alert without asserting success.

    ⚠️ This is a precedent, not a ruling. #7875's shape was derived from MessagingService's two-path structure; http-nodes.ts may not have the same seam. Measure before porting — if the HTTP path's structure makes a different (or simpler) shape correct, take it and say so. Falsifying the precedent's applicability is a success outcome in this lane.

    Serial constraints cleared: same-package predecessors #7742 (PR #7847), #7747 (PR #7875), #7639 (PR #7896) are all MERGED; #7639 touched engine.ts's suspend paths, #7747 touched builtin/notify-node.ts — neither is this file. No in-flight PR touches service-automation. Same-round siblings #7925 (service-settings) and #7902 (read-only) are file-disjoint.


    Generated by Claude Code

  6. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    PR: #7962 (draft) · Branch: claude/issue-7882-http-node-enqueued-acted

    Premise: confirmed

    http-nodes.ts:139 returned metrics: { acted: 1 } for a merely-enqueued delivery. enqueueHttp() returns the id of a pending sys_http_delivery row and HttpDispatcher decides the outcome afterwards — a 404 dead-letters on the first attempt (classifyAttempt → non-retriable → dead). Reproduced end-to-end below.

    Did #7875's shape apply? The ruling did; the structure did not

    unmeasuredEffect (not a bare acted: 0) transfers verbatim and is what I shipped. The EmitResult narrowing does not, and porting it would have been inventing a seam that isn't there:

    • MessagingService.emit() hides two outcomes behind one call — inline P0 fan-out, which really knows the result, and the P1 outbox, which doesn't. The notify node literally could not tell them apart without splitting delivered from enqueued inside service-messaging. That is why fix(services): notify's run summary stops claiming a delivery that dead-lettered (#7747) #7875 had to touch the service.
    • enqueueHttp() has no ambiguity to split: it returns a row id, and the row is unconditionally pending. There is no "sometimes this call already delivered" case to narrow away.
    • The two-path structure the notify fix had to manufacture already exists in the node: durable: true enqueues; everything else calls fetch() inline. And the inline half was already honest (acted: 0 for a read, acted: 1 for an accepted mutation, unmeasuredEffect for a rejected/timed-out one — with the reasoning already in the comments).

    So the correct fix is one branch of one node. service-messaging is untouched. The card's own caveat ("if every HTTP delivery goes through the outbox, the fix is simpler") was the right instinct — with the refinement that the inline path does exist, it just doesn't need changing, so the "two-path narrowing" and "simpler" options are both slightly off: the seam is real but already in the right place.

    Status stays success, output stays { deliveryId, enqueued: true }, no synchronous wait added.

    Reverse-verification on origin/main

    New test file run against unmodified origin/main node code — red 2 / green 2:

    Tests  2 failed | 2 passed (4)
      × durable delivery that dead-letters    expected { acted: 0, unmeasured: 1 }, got { acted: 1, unmeasured: 0 }
      × durable delivery that later succeeds  expected { acted: 0, unmeasured: 1 }, got { acted: 1, unmeasured: 0 }
      ✓ inline mutating call → acted: 1
      ✓ inline GET → acted: 0
    

    Every durable-record assertion passed on main (sys_http_delivery row dead with responseCode: 404; row pending/attempts: 0 at settle, success after the tick) — the defect is real and lives only in the summary. After the fix: 4/4 green. Full service-automation suite: 940 passed, 79 files.

    Per AC-1/AC-2 the test wires the real MessagingService + MemoryHttpOutbox + HttpDispatcher with a manual tick() — same reason as #7875: the outcome is decided after the run settles, which a one-shot fake cannot express. Assertions are on the two durable records only, never on call counts. Both directions covered (enqueued→dead, enqueued→succeeded, plus two inline cases that keep measured counts), so the fix can't degenerate into "dead-lettered deliveries are special".

    Consumer grep (§4.7)

    • { deliveryId, enqueued: true } — read by nothing outside the node and its own tests. The plugin-webhooks deliveryId hits are an unrelated request-body field on the redeliver endpoint. Output shape unchanged anyway.
    • metrics — folded generically by run-summary.ts; no consumer special-cases http.
    • One real find: content/docs/automation/flows.mdx:737 documented | `http`, `durable: true` | `acted: 1` — the outbox row is a real, durable effect |. That is the one place the old behavior was promised to readers, so it is corrected in this PR. No pin test asserts the old string.

    Gates

    • No packages/spec/src/** file touched → no gen:schema / gen:docs (AC-6 checked, not assumed: io-node-config.zod.ts's doc comment describes the { deliveryId } output, which is unchanged, and makes no acted claim).
    • AC-4/AC-5 not applicable — no engine fake and no refusal in the diff.
    • Local: typecheck, check:doc-authoring, check:doc-anchors, check:docs-audit-scope green. Changeset .changeset/http-node-enqueued-not-acted.md added.
    • CI: running on fix(service-automation): report a durable http callout as unmeasured, not acted (#7882) #7962 at the time of writing; I'm watching it and will drive it to green.

    Deliberately not done


    Generated by Claude Code

  7. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    PM 复核:ACCEPT —— PR #7962 转 ready + 已挂 auto-merge

    26 个 check 全部 success/skipped,亲核 ESLint ✅(09:03:30Z)与 TypeScript Type Check ✅(09:07:25Z)两 job 的 conclusion;Check Changeset ✅、ADR maintainer approval ✅ 且 diff 不触 docs/adr/**、skills/**。

    证伪背书第十六笔 —— 本轮最精确的一次「先例不适用」

    我在派发令里把 #7875 写成「先例而非裁决」,并要求先测量结构再决定是否照搬。dev 给的答案比二选一更准:裁决部分适用,结构部分不适用,而且说清了为什么这不是同一件事。

    • 可迁移的是裁定:入队未投递报 unmeasuredEffect 而非裸 acted: 0 —— 逐字照搬,connector.zod.ts:613 的禁令在这里同样成立。
    • 不可迁移的是 EmitResult 拆分,理由是那个缝在这里根本不存在:MessagingService.emit() 把两种结局(inline P0 真知道结果 / P1 outbox 不知道)藏在一次调用背后,notify 节点不进 service-messaging 拆 delivered/enqueued 就分不开它们 —— 那正是 fix(services): notify's run summary stops claiming a delivery that dead-lettered (#7747) #7875 不得不动服务层的原因。而 enqueueHttp() 没有可拆的歧义:它返回一个行 id,行无条件是 pending,不存在「有时候这次调用其实已经投递了」的分支。
    • 更进一步:fix(services): notify's run summary stops claiming a delivery that dead-lettered (#7747) #7875 需要制造的双路结构,在 HTTP 节点里本来就有 —— durable: true 走入队,其余走 inline fetch(),而inline 那一半原本就是诚实的(读操作 acted: 0、被接受的写 acted: 1、被拒/超时 unmeasuredEffect,理由早写在注释里)。

    ⇒ 正确的修法是一个节点的一个分支,service-messaging 一字未动。他还顺手纠正了卡片自己的措辞:卡上写「若所有 HTTP 投递都走 outbox,修法更简单」—— 方向对,但 inline 路径确实存在,只是不需要改;所以「双路拆分」与「更简单」两个选项都略偏,真相是缝是真的、而且已经在正确的位置上。这比我要求的「适用/不适用」二分多给了一层。

    反向验证与断言面

    origin/main 未改节点代码上跑新测试:2 红 2 绿,两条红恰是 durable 分支(expected { acted: 0, unmeasured: 1 }, got { acted: 1, unmeasured: 0 }),两条 inline 绿。⚠️ 关键读数:所有 durable-record 断言在 main 上就通过(sys_http_delivery 行 dead + responseCode: 404;settle 时 pending/attempts: 0,tick 后 success)—— 也就是说缺陷只活在 summary 里,durable 记录一直是对的。修后 4/4 绿,service-automation 全套 940 passed / 79 files。

    测试按 AC-1/AC-2 接真 MessagingService + MemoryHttpOutbox + HttpDispatcher 并手动 tick(),断言只落在两条 durable 记录、⛔ 不碰调用计数 —— 理由与 #7875 同源:结局在 run settle 之后才定,一次性假体表达不出这个不一致。双向覆盖(enqueued→dead / enqueued→succeeded)外加两条 inline 的已测量计数,使修法无法退化成「dead-letter 是特例」。

    §4.7 消费方 grep —— 抓到一处文档在承诺旧行为

    content/docs/automation/flows.mdx:737 写着 | `http`, `durable: true` | `acted: 1` — the outbox row is a real, durable effect |。那是旧行为唯一被公开承诺的地方,本 PR 一并改正,且无 pin 测试断言旧字串。这正是消费方 grep 该抓的东西 —— 代码改对了而文档继续教旧语义,是同一个过报换个载体。({deliveryId, enqueued:true} 输出形状未变;metrics 由 run-summary.ts 通用折叠,无消费方特判 http;plugin-webhooks 的 deliveryId 命中是 redeliver 端点的请求体字段,无关。)

    判读正确的三条

    • 不触 packages/spec/src/** ⇒ gen:schema/gen:docs 不适用 —— 而且他是核过才说的(io-node-config.zod.ts 的 doc comment 描述 {deliveryId} 输出,未变、也未对 acted 作任何声明),不是假设。
    • AC-4/AC-5 不适用:diff 里无 engine fake、无拒收路径 —— 照实说「不适用」优于硬凑。
    • 不做事后回填:acted 在 run settle 铸造、run summary 从不修订,与 fix(services): notify's run summary stops claiming a delivery that dead-lettered (#7747) #7875 接受的边界一致;deliveryId 就是追进 sys_http_delivery 的文档化句柄。⛔ 未引入对异步投递的同步等待。

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions