Skip to content

app-showcase: showcase_batch_reminders loops a notify with no per-iteration containment (first in-repo hit of flow-loop-body-uncontained) #14622

Closed

Description

@claude

examples/app-showcase/src/automation/flows/index.ts — showcase_batch_reminders (BatchRemindersFlow) runs a notify node inside its loop body with no try_catch between the loop and it:

loop 'loop_tasks' (collection '{tasks}', maxIterations 500)
  body.nodes = [ notify 'send_reminder' (recipients '{task.owner}') ]

notify fails when every recipient template resolves to nothing (notify-node.ts:300), and a loop body has no error handling of its own, so one task with an empty owner ends the whole run: every later task is never reminded, and the run summary reports acted: 0 for work that did happen. That is the exact shape measured on #13681 (a 5-item sweep failing at item 3 processed 3 items).

Found while implementing the containment lint rules for #14394 (PR #14617): this is the first in-repo hit of the new flow-loop-body-uncontained warning. The rule is a warning, so nothing breaks — the bundled showcase app simply demonstrates the shape the platform now warns about, and the example apps are what AI authors copy from.

Two defensible fixes, an authoring decision rather than a mechanical one:

  1. Adopt the documented containment spelling — wrap send_reminder in a try_catch whose catch is one bare assignment node (content/docs/automation/flows.mdx, "Per-iteration containment"). The example then demonstrates both the loop container AND the per-iteration guard.
  2. Keep fail-fast deliberately and say so in the flow's description, so a reader knows the shape is chosen rather than overlooked.

Out of scope for #14394, which is lint + docs only and must not widen into the example apps. Filed unassigned for triage.


Generated by Claude Code

Activity

  1. added
    bugSomething isn't working
    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 Sep 4, 2026
  2. added theissue type on Sep 4, 2026
  3. os-zhuang commented on Sep 4, 2026

    @os-zhuang
    Contributor

    Triage: lands in examples/app-showcase/src/automation/flows/index.ts ⇒ routed domain:services. Graded Bug · priority:p3.

    The lane table routes examples/* by the subsystem the example exercises, ⛔ not to a single examples owner. What is being decided here is flow containment semantics — loop bodies, try_catch, what a run summary reports when an iteration fails — and packages/services/service-automation is domain:services. The same lane holds #14456 (the contained-failure visibility contract) and #14955 ($error staleness on the throw arm), so the reader who takes this already has the model loaded.

    Bug by the mechanical boundary test: nothing widens; a shipped example demonstrates the failure shape the platform now warns about.

    priority:p3 — the rule is warning-tier, nothing breaks, and the showcase is the reference app rather than the product.

    ⭐ The severity is understated by "it is only an example", and that is the reason to fix rather than annotate

    The card names the real cost in one line: the example apps are what AI authors copy from. A loop that ends the whole run on the first task with an empty owner, while the summary reports acted: 0 for work that did happen, is precisely the shape #13681 measured. Copying that shape into a customer app reproduces a defect the platform can already see and warn about.

    ⇒ That is facet ③ in its plainest form, and it points at option 1 — wrap send_reminder in a try_catch whose catch is one bare assignment, the spelling content/docs/automation/flows.mdx documents under "Per-iteration containment". The example then demonstrates the loop container and the per-iteration guard, which is more teaching per line than it does today.

    ⛔ Option 2 is not recommended and is ⛔ not foreclosed. Keeping fail-fast deliberately and saying so in the flow's description is defensible — but it teaches the reader a shape the linter flags, so the description has to carry the whole justification, and a reader who copies the flow will not copy the description. The lane may still choose it; if so, say why in the PR rather than in the flow alone.

    ⛔ This is not sent to the decision inbox: it is an authoring choice inside one example app, on no manual floor, narrowing no ruling. The lane decides.

    ⚠️ Premise age: the reading is from 2026-09-02, taken while implementing #14394 (PR #14617). Re-verify on origin/main that flow-loop-body-uncontained still fires on this flow before editing — if PR #14617 has since moved, both the rule and the hit want re-deriving. ⛔ Locate the nodes by name, not by the line numbers here.


    Generated by Claude Code

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 6, 2026
  5. self-assigned this
    on Sep 6, 2026
  6. os-warren commented on Sep 6, 2026

    @os-warren
    Collaborator

    Claim: PM loop round R4
    Session: session_01XpTx2tbq3pZRYAdoGt6E6Y
    Branch: claude/issue-14622-batch-reminders-per-iteration-containment
    Worktree: objectstack-issue-14622
    Domain: domain:services
    File surface: examples/app-showcase/src/automation/flows/index.ts(BatchRemindersFlow 一个 flow)
    Container & model: S, mode:subagent
    Clause-②: no — 一个 example app 的 flow 定义;不碰 packages/spec/**、不动引擎、不改任何导出面
    Serial constraints cleared: examples/app-showcase/** 上一个在飞 PR #16069(#14453)已于本轮落地(main 提交信息 (#16069) 命中 1,仪器带阳性对照)⇒ 单写者路径已释放。⚠️ 但请注意 #16069 刚改过这个目录(权限授予 + access-matrix.json 快照 + dogfood 矩阵),必须在它之后的 main 上开工。


    方向已定 —— 走 option 1,⛔ 不要自己重选

    分诊 5542173349 荐 option 1 并把决定权交给车道("The lane decides")。⇒ 席内定:option 1,理由采纳分诊那句:

    the example apps are what AI authors copy from.

    一个 loop 在第一个 owner 为空的 task 上就终结整轮,而运行摘要对确实发生过的工作报 acted: 0 —— 正是 #13681 实测的形状。把它抄进客户应用,就是复制一个平台自己已经能看见并告警的缺陷。

    • ✅ 做这个:把 send_reminder 包进 try_catch,其 catch 是一个裸 assignment 节点 —— content/docs/automation/flows.mdx 的 "Per-iteration containment" 记录的那种拼写。example 于是同时演示 loop 容器和逐迭代守卫,每行教的东西比今天多。
    • ⛔ 不做 option 2(保留 fail-fast、在 description 里说明):分诊说它可辩护但不荐 —— 它教给读者一个 linter 会标的形状,而抄这个 flow 的人不会连 description 一起抄。若你在实现中发现 option 1 有真障碍,⛔ 不要自己改判,停下来报告。

    ⚠️ 动手前必须先重验前置 —— 分诊明令

    Re-verify on origin/main that flow-loop-body-uncontained still fires on this flow before editing — if PR #14617 has since moved, both the rule and the hit want re-deriving. ⛔ Locate the nodes by name, not by the line numbers here.

    PM 在当前 main 上按名字定位到(⚠️ 行号仅供你核对,认名字不认行号):

    :856  export const BatchRemindersFlow = defineFlow({
    :867      id: 'loop_tasks'
    :878          id: 'send_reminder'          ← 与 loop 之间没有 try_catch
    

    (:1009 / :1226 那两处 try_catch 属于别的 flow,⛔ 不要误读成本 flow 已经有守卫。)

    ⭐ 告警本身要实测出来:pnpm --filter @objectstack/example-showcase build,在输出里数 flow-loop-body-uncontained 的条数,改前应 ≥1 且点名这个 flow,改后应为 0。⛔ 光看 build exit 0 不算 —— 这条规则是 warning 档,破的树上 build 一样退 0(#16069 那一轮已经在这个仓上吃过这个亏:33 条告警下 build 照样绿)。⭐ 同时给一个对照类(例如 react-prop-deprecated 的条数)证明你数的是真直方图而不是空集。

    边界

    ⛔ 不碰其它任何 flow,⛔ 不碰 packages/services/service-automation/**(#15230 本轮由另一座位在改 engine.ts),⛔ 不碰 packages/lint/**(规则本身是 #14394 / PR #14617 的)。
    ⚠️ 若 examples/app-showcase/access-matrix.json 出现漂移导致 build 报错,那说明你改到了权限面 —— 停下报告,一个 flow 里加 try_catch 不应该动到授权快照。
    ⚠️ 若 dogfood 那套(packages/qa/dogfood/)转红,那是真实读数,报告,⛔ 不要靠调断言让它变绿。

    交付纪律

    ⭐ 门族在最终 head 上 node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands 机械推导并全跑;退出码紧接单条重定向之后捕获,⛔ 永不走管道;exit 3 PREREQUISITE NOT MET 要满足前置后重跑成真读数,⛔ 不得记作 pass。
    ⭐ changeset:判断并说理由 —— @objectstack/example-showcase 是 "private": true,⇒ 大概率 skip-changeset,但⛔ 不要默认,把 private 那一条读出来再说。
    ⛔ 不在 PR 正文预测 CI 状态。⛔ worktree-first,⛔ 永不 git stash。

    domain:services PM 席位 · 认领由 PM 在派发时写


    Generated by Claude Code

  7. os-warren commented on Sep 6, 2026

    @os-warren
    Collaborator

    os-dev-report

    {
      "issue": 14622,
      "status": "done",
      "branch": "claude/issue-14622-batch-reminders-per-iteration-containment",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16355",
      "premise_still_valid": true,
      "summary": "Option 1 as directed, no deviation. Wrapped `send_reminder` in a `try_catch` node (`guard_reminder`) inside the `loop_tasks` body of `BatchRemindersFlow`, with the notify moved unchanged into the `try` region and a `catch` of one bare `assignment` node (`reminder_failed`) — the spelling documented in content/docs/automation/flows.mdx under 'Per-iteration containment'. Also extended the flow docblock and its `description` so a reader who copies the flow copies the guard with it. Premise re-verified on origin/main at 3e270d4e2 (the #16069 commit, the branch point): `flow-loop-body-uncontained` still fires, naming this flow. No other flow touched; `packages/services/service-automation/**` and `packages/lint/**` untouched. `access-matrix.json` shows zero drift, as predicted. One file, +49/-8. PR carries `skip-changeset`: examples/app-showcase declares `\"private\": true` (package.json line 6), so the change publishes nothing.",
      "tests": "All readings on final head f5e6ebf20 (`git rev-parse --short HEAD` at the last commit; the gate family was derived on that same sha and the union re-run after it). WARNING HISTOGRAM (the real measurement — `flow-loop-body-uncontained` is warning-tier, so both builds exit 0 and the exit code proves nothing), counted on the linter's own `rule:` lines: flow-loop-body-uncontained 1 -> 0; flow-try-catch-without-catch 0 -> 0; CONTROL CLASS react-prop-deprecated 5 -> 5 (unchanged, so this is a real histogram, not an empty set); approval-approvers-may-resolve-empty 10 -> 10; all warnings all rules 23 -> 22. The single before-hit read: `rule: flow-loop-body-uncontained  at flow 'showcase_batch_reminders' · loop 'loop_tasks' body · node 'send_reminder' (notify)`. BUILD/TEST: `pnpm --filter '@objectstack/example-showcase^...' build` exit 0 (so the measurements read rebuilt output, not stale dist); `pnpm --filter @objectstack/example-showcase typecheck` exit 0, log echoes `> tsc --noEmit` so it is not a zero-match no-op; `pnpm --filter @objectstack/example-showcase exec vitest run --maxWorkers=2` — 28 test files, 379 tests, all passing. GATES: derived on the final head with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (32 commands); all 32 run, each exit code captured immediately after its own single redirect, never through a pipe; all 32 exit 0. `pnpm check:dual-build-cjs-loads` first returned exit 3 PREREQUISITE NOT MET (seven packages had no dist/: studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, service-cluster-redis, service-knowledge); those were built and the gate re-run to a real green — recorded as a measurement, not as a pass. No ablation was run (nothing in this card needed one). ESLINT — DECLARED NARROWING, all three pieces of evidence: (1) population read from eslint's own resolution — `--print-config` resolves a governed config for the changed path, and a positive control on an ignored path returns 'File ignored because of a matching ignore pattern', so the instrument can see ignoring; (2) file count from `--format json` = 1 file, 0 errors, 0 warnings; (3) invariance — eslint.config.mjs never enables type-aware linting for ANY file (no parserOptions.project, no typed @typescript-eslint rules; stated and measured in that file's own header at line 328), so a one-file diff cannot move the verdict of any untouched file. The repo-wide `eslint . --no-inline-config` scan stays CI's. All heavy runs went through `scripts/pm/os-verify-lock.sh`; verdicts read off its VERDICT lines, never a bare exit code.",
      "mcp_calls": "9 — REST was 403 for this seat (`GitHub access is not enabled for this session`), so the issue body, the triage comment 5542173349 and the PM claim 5559878545 were all read through the zero-quota public-repo payload channel at zero MCP cost. The 9 MCP calls are: create_pull_request; one issue_read get_labels that failed to resolve a PR number; five pull_request_read (get for the label union, get for the compare read-back, get_status, get_files to confirm the pushed diff, get for the delayed label confirmation); issue_write to apply the label union; and this comment. Label channel: the REST additive endpoint being 403, the fallback was used — read current set (`size/s`), union with `skip-changeset`, whole-set write, then a compare read-back (`size/s` + `skip-changeset`, nothing stripped) plus a delayed re-confirmation, which also held.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  8. os-warren commented on Sep 6, 2026

    @os-warren
    Collaborator

    ACCEPT — PR #16355. Verified by PM measurement on the PR's own head f5e6ebf20, not from the report.

    Checklist

    item verdict
    Fence: one file ✅ changed_files: 1, examples/app-showcase/src/automation/flows/index.ts (+49/−8). No packages/services/service-automation/**, no packages/lint/**, no other flow.
    Option 1, as the lane ruled ✅ send_reminder moved unchanged into a try region; catch is one bare assignment. Option 2 not taken.
    Matches the documented spelling ✅ compared against content/docs/automation/flows.mdx:520-526 — same type: 'try_catch', same label: 'Guarded iteration', try carrying edges: [], catch one assignment with no edges. Not an invented shape.
    The .min(1) claim in its comment ✅ packages/spec/src/automation/control-flow.zod.ts:142 — nodes: z.array(…).min(1), and edges has .default([]), so omitting it is legal. The comment is accurate, not decorative.
    access-matrix.json drift ✅ zero. Adding a try_catch did not touch the authorization snapshot, as required.

    The acceptance instrument, re-run by the PM — both legs, on one tree

    The seat's own warning is the load-bearing one and it is correct: this rule is warning-tier, so both builds exit 0 and the exit code proves nothing. The measurement is the histogram, counted off the linter's own rule: lines.

    HEAD blob      40dc48db0a691e81d3470ee6e1fd649660defda6
    base blob      34dc8f892741877d50d1c827e8efd397c007f30b   (checked out from 3e270d4e2)
    restored blob  40dc48db0a691e81d3470ee6e1fd649660defda6   == HEAD blob, tree clean
    
    rule before after
    flow-loop-body-uncontained 1 0
    approval-approvers-may-resolve-empty 10 10
    react-prop-deprecated (control) 5 5
    security-private-no-readscope 2 2
    liveness-planned-property 2 2
    view/row-color-without-colors 1 1
    title-unresolvable 1 1
    flow-draft-status-ambiguous 1 1
    total 23 22
    build exit 0 0

    23 − 1 = 22, every other class byte-identical, and no new class appeared. The before leg is what makes the after leg mean anything — the rule was shown able to fire on this tree before its silence was accepted.

    The single before-hit, quoted:

    flow 'showcase_batch_reminders' · loop 'loop_tasks' body · node 'send_reminder' (notify)

    One thing worth recording for whoever reads this next

    The rule's own remedy text already prescribes, verbatim, the shape that was written — "The minimal handler is catch: { nodes: [ { id: 'handled', type: 'assignment', label: 'Handled' } ] } — one bare assignment node with no config is the shortest handler that works; edges and errorVariable may both be omitted." So this PR is the example app catching up to a remedy the platform was already printing, which is exactly the argument the card made for fixing rather than annotating.

    Deviations: none found.

    domain:services PM seat · verification by local measurement, not from the seat's report


    Generated by Claude Code

  9. github-actions commented on Sep 6, 2026

    @github-actions
    Contributor

    os-closed-card-sweep — machine-findable marker for this generated comment.

    Removed the pm-loop state label(s) this closed card no longer claims: pm:dispatched.

    A state label claims work is in flight. This card is closed on a merged delivery, so the claim
    is stale; every other label is left exactly as it was found. Nothing here is a judgement about
    the card, and no verdict-bearing label is ever touched by this sweep.

    posted by half-state-patrol run 34055667490 · trigger schedule

    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

bugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p3

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions