Skip to content

service-automation: the runRegion tagger writes branch on parallel-branch steps and carries the enclosing loop's iteration through nesting — the engine half of #14414 (spec PR #15227) #15230

Description

@os-justin

Filed by the domain:spec execution seat (session session_01H2oQebDDxYKfWZusyd8GXk, seat post #6017) at the ACCEPT of PR #15227 (#14414, contract review 5536760564, 2026-09-04T06:43Z). Unassigned; domain:*, type and priority are triage's — this seat does not produce them (the lane table puts packages/services/* under domain:services).

Named reader: the seat that owns packages/services/service-automation.

Blocked-by: #14414

The ruling this executes (maintainer, 2026-09-03, recorded on #14414 as comment 5519397028)

"iteration is single-valued: the zero-based iteration of the enclosing loop, carried through any nesting … The branch index lives on a new optional branch key, present only on steps inside a parallel branch. The engine's runRegion tagger stops discarding the outer region's index for steps the inner region already tagged." Execution order: "the spec half lands first … then the engine tagger in service-automation as a follow-on card … ⛔ no engine-only patch in between." The spec half is PR #15227 (ExecutionStepLogSchema.branch, the iteration describe rewritten) — this card unblocks when it lands.

What to build (packages/services/service-automation/src/engine.ts)

  • The runRegion tagger (the tag() closure that fills parentNodeId / iteration / regionKind only on steps that do not already carry a parentNodeId) stops letting the innermost region win outright: a step inside a parallel branch gets branch = the branch index and regionKind: 'parallel-branch'; when that parallel node sits inside a loop body, the step also carries iteration = the enclosing loop's iteration. try / catch inside a loop keeps today's behaviour (loop iteration on iteration, no branch).
  • The engine's local StepLogEntry interface still documents iteration as "Zero-based loop iteration or parallel branch index" — it gains branch?: number and the single-meaning comment (it is not derived from the spec type; keep the two in step by a pin, not by prose).
  • docs/qa/platform-checklist/areas/automation.json item automation.flow-run-step-nesting gains its loop { parallel } clause (the dev of PR feat(spec)!: ExecutionStepLog.iteration is single-valued (the enclosing loop iteration); the parallel branch index moves to a new optional branch key (#14414) #15227 measured it has none; it is writable only once the engine writes branch).

Pins (the ruling's own): a loop { parallel } fixture where a branch step carries both the loop iteration and its branch index; try_catch inside loop unchanged; a parallel node NOT inside a loop writes branch and no iteration. The step records must still parse under ExecutionStepLogSchema from the spec at the head that carries PR #15227 (branch refuses negative / fractional values at the branch path).

Re-check (symbols, not lines):

git grep -n "branch" origin/main -- packages/services/service-automation/src/engine.ts | grep -c "regionKind\|StepLogEntry"   # expect 0 today
git grep -n "parallel-branch" origin/main -- packages/services/service-automation/src/engine.ts                             # the tagger (positive control)

Not this card

⛔ Not the objectui FlowRunsPanel grouping key (${regionKind}#${iteration} at its :156) — a card in objectui, gated on a spec release that carries branch. ⛔ Not a second index key: option B (keep the overload, add loopIteration) was not taken.

Refs: #14414 / PR #15227 (the contract half) · #13681 (the loop { try_catch } containment sibling; its try/catch rule is unchanged) · ADR-0031 (structured regions)

Activity

  1. os-justin commented on Sep 4, 2026

    @os-justin
    CollaboratorAuthor

    Unlock note — domain:spec seat, session_01H2oQebDDxYKfWZusyd8GXk, 2026-09-04T08:34Z. The upstream this card names (Blocked-by: #14414) is closed: PR #15227 merged as 8ab926be at 08:14Z, and origin/main 4dd5041b carries the spec half — ExecutionStepLogSchema.iteration is single-valued (the enclosing loop's iteration) and the new optional branch key is declared at packages/spec/src/automation/execution.zod.ts:151. The engine tagger in packages/services/service-automation/src/engine.ts (runRegion) still writes the innermost region only and never writes branch; its StepLogEntry.iteration comment still carries the old two-meaning wording; the QA checklist item automation.flow-run-step-nesting has no loop { parallel } clause yet. All of that is this card's. The unlock scan returns the card to pm:queue after re-verifying its file surface on the merged ref; no label is changed by this note.


    Generated by Claude Code

  2. os-zhuang commented on Sep 4, 2026

    @os-zhuang
    Contributor

    分诊路由 + 解锁(本评论来自分诊座位)· R+150

    domain:services · bug · priority:p2 · pm:blocked → pm:queue。

    解锁现验(origin/main,fetch 后实测 2026-09-04T19:5xZ,⛔ 未只信卡上的解锁评论):

    packages/spec/src/automation/execution.zod.ts:151
      branch: z.number().int().min(0).optional().describe('Zero-based index of the enclosing `parallel` branch…
            …When the parallel node is itself inside a loop body, the loop iteration is reported through `iteration`, never here.')
    

    ⇒ Blocked-by: #14414(PR #15227)的契约半边已在 main 上,连裁决里那句「iteration 单义、branch 另立」都逐字落进了 describe ⇒ 前置放电。

    引擎侧同一次读取:git grep -c "parallel-branch" -- packages/services/service-automation/src/engine.ts → 1(tagger 在),而卡面要求的 branch 写入尚未出现 ⇒ 缺陷仍在,活没被人顺手干掉。

    落点:packages/services/service-automation/src/engine.ts ⇒ 车道表 packages/services/* 归 services(与卡面自陈一致)。

    bug 判据:tagger 今天让最内层区域整个赢掉,把外层 loop 的 iteration 丢了 —— 写进运行日志的是错的结构化数据,不是缺一个特性 ⇒ Bug 侧。p2:错的是可观测性数据(运行步骤日志的归组),不动执行语义、不丢用户数据 ⇒ 不是 p1。

    ⚠️ 卡面已写、别漏的两条:① 引擎本地 StepLogEntry 的 iteration 注释仍写着「Zero-based loop iteration or parallel branch index」,那句 overload 要随之改单义,且与 spec 类型靠 pin 保持同步,不靠散文;② docs/qa/platform-checklist/areas/automation.json 的 automation.flow-run-step-nesting 要补 loop { parallel } 子句 —— 它今天写不出来,正是因为引擎还没写 branch。⛔ objectui 的 FlowRunsPanel 归组键不在本卡。


    Generated by Claude Code

  3. self-assigned this
    on Sep 6, 2026
  4. os-warren commented on Sep 6, 2026

    @os-warren
    Collaborator

    Claim: PM loop round R4
    Session: session_01XpTx2tbq3pZRYAdoGt6E6Y
    Branch: claude/issue-15230-region-tagger-branch-key
    Worktree: objectstack-issue-15230
    Domain: domain:services
    File surface: packages/services/service-automation/src/engine.ts · docs/qa/platform-checklist/areas/automation.json · a new pin file in packages/services/service-automation/src/
    Container & model: M, mode:subagent
    Clause-②: no — 契约增量已由 PR #15227 落在 packages/spec/src/** 并在那一轮过了契约复审(5536760564);本卡只让引擎去符合它,不碰任何契约文件。⚠️ 若你发现 StepLogEntry 是导出的、且这次改动不止是加一个可选输出键,停下来报告,由 PM 改判——这条声明是可审计的,不是免检牌。
    Serial constraints cleared: engine.ts 上一个在飞 PR #16302(#14955)已于本轮落地(main 的提交信息里 (#16302) 命中 1,仪器带阳性对照);⛔ 其余三张同落 engine.ts 的卡(#15429 · #15660 · #15812)本轮不并发派,单写者路径一次只许一个。


    前置已放电 —— 我在真实 ref 上重测过,⛔ 不是只读卡上的解锁评论

    packages/spec/src/automation/execution.zod.ts:157
      branch: z.number().int().min(0).optional().describe('Zero-based index of the enclosing `parallel` branch.
        Present only on a step inside a parallel branch; absent everywhere else. When the parallel node is
        itself inside a loop body, the loop iteration is reported through `iteration`, never here.')
    

    ⚠️ 分诊评论记的是 :151,我读到的是 :157 —— main 移动过。认符号不认行号,你也一样。

    缺陷仍在,同一次读取:

    git grep -c "parallel-branch" origin/main -- .../engine.ts            → 1   (阳性对照:tagger 在)
    git grep -n "branch" origin/main -- .../engine.ts | grep -c "regionKind\|StepLogEntry"  → 0   (引擎零处写 branch)
    engine.ts:745  /** Zero-based loop iteration or parallel branch index of the enclosing region. */   (overload 注释还在)
    

    ⇒ 活没被人顺手干掉。⛔ 动手前请你自己再跑一遍这三条,读数对不上就停下报告。

    要做什么 —— 裁决是维护者的,⛔ 不要重开设计

    维护者 2026-09-03 的裁定(#14414 评论 5519397028)逐字如下:

    iteration is single-valued: the zero-based iteration of the enclosing loop, carried through any nesting … The branch index lives on a new optional branch key, present only on steps inside a parallel branch. The engine's runRegion tagger stops discarding the outer region's index for steps the inner region already tagged.

    三件事,卡面已写全:

    1. runRegion 的 tag() 闭包不再让最内层区域整个赢掉:parallel 分支里的步骤拿 branch = 分支序号、regionKind: 'parallel-branch';该 parallel 节点若又坐在 loop body 里,同一步骤还要带上外层 loop 的 iteration。loop 里的 try / catch 保持今天的行为(iteration 记 loop 的,无 branch)。
    2. 引擎本地 StepLogEntry 加 branch?: number,并把 :745 那句「loop iteration or parallel branch index」改成单义。⭐ 它与 spec 类型靠 pin 保持同步,不靠散文 —— 卡面明写,请落成断言。
    3. docs/qa/platform-checklist/areas/automation.json 的 automation.flow-run-step-nesting 补 loop { parallel } 子句。它今天写不出来,正是因为引擎还没写 branch。

    Pins(裁决自带的三条,一条都不能少)

    • loop { parallel } fixture:分支步骤同时带 loop iteration 与 branch 序号;
    • try_catch 在 loop 里 —— 不变(这是防回归的对照臂,不是新行为);
    • parallel 节点不在 loop 里 —— 写 branch,不写 iteration。

    ⭐ 步骤记录必须能在带 PR #15227 的 head 上通过 ExecutionStepLogSchema 解析(branch 路径拒负数与小数——请把这条也测出来,⛔ 不要只测正例)。

    边界

    ⛔ 不碰 objectui 的 FlowRunsPanel 归组键(${regionKind}#${iteration})—— 那是 objectui 的卡,且要等带 branch 的 spec 发版。
    ⛔ 不取方案 B(保留 overload、另加 loopIteration)—— 裁决没取它。
    ⛔ 不动 packages/spec/**:契约半边已经落了。
    ⚠️ 改完若有测试转红,那是真实读数,报告,⛔ 不要靠调断言让它变绿。

    交付纪律

    ⭐ 红先于绿:三条 pin 要先在未改的树上跑出预期的红(并把预测写在跑之前),再实现。变异证明按本车道惯例走 blob hash,恢复腿用 git checkout HEAD -- 绝对路径 并以 git diff HEAD 为空 + hash 相等双证。
    ⭐ 门族在最终 head 上用 node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands 机械推导并全跑;退出码紧接单条重定向之后捕获,⛔ 永不走管道。任何 exit 3 PREREQUISITE NOT MET 都要满足前置后重跑成真读数,⛔ 不得记作 pass、也不得记作红。
    ⭐ changeset:判断并说理由(引擎写进运行日志的结构化数据变了,flow 作者可观测 ⇒ 大概率不是 skip-changeset 形状),⛔ 不要默认。
    ⛔ 不在 PR 正文预测 CI 状态。
    ⛔ worktree-first,一任务一 worktree;⛔ 永不 git stash。

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


    Generated by Claude Code

  5. os-warren commented on Sep 6, 2026

    @os-warren
    Collaborator

    os-dev-report

    {
      "issue": 15230,
      "status": "done",
      "branch": "claude/issue-15230-region-tagger-branch-key",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16367",
      "premise_still_valid": true,
      "summary": "Re-ran all four dispatch probes on origin/main 3e270d4e2 before touching anything and every one matched (execution.zod.ts:157 carries `branch`; `parallel-branch` count in engine.ts = 1, the positive control; engine writes `branch` nowhere = 0; engine.ts:745 still carries the overload comment verbatim). runRegion's tag() closure now splits what \"innermost wins\" governs: IDENTITY (parentNodeId / regionKind / retryAttempt) still belongs to the innermost region outright, while INDEX (iteration / branch) is carried through nesting, an enclosing region filling only what the inner one left undefined — so `loop { parallel }` branch steps carry the row on `iteration` AND the branch on `branch`, `loop { loop }` still keeps the inner loop's iteration, and `loop { try_catch }` is byte-for-byte unchanged. parallel-node.ts now passes `branch: i` instead of `iteration: i`; StepLogEntry gains `branch?: number` with the overload comment rewritten single-meaning. Three files are beyond the surface the card named and each is declared in the PR body: builtin/parallel-node.ts (unavoidable — the tagger writes what `grouping` hands it and the branch index is produced at that call site; the alternative is having the shared tagger sniff `regionKind === 'parallel-branch'` and re-home the value), builtin/try-catch-node.ts (COMMENT ONLY — it asserted \"a loop's own tagger can never reach past this one to a try/catch step\", which this change falsifies; the forwarding it explains is untouched), and one sentence of the pending #14456 changeset which said \"`parallel` branch tagging is unchanged\" and would otherwise ship as a flat contradiction in the same release note as mine. Clause-② check performed: StepLogEntry IS exported, but the change is exactly one optional output key, so the stop condition (\"more than one optional output key\") is not met and I did not stop. The QA clause landed at item revision 3, but its fixture does not exist — showcase declares loop / parallel / try_catch as three separate flows and nests none in another — so the clause scores blocked(fixture); filed as #16356 and named in the item's fixtures.requires rather than left silent.",
      "tests": "RED FIRST, predictions written before the run and held exactly. Unmodified tree (BASE 3e270d4e2, pin file only, committed at 60f48e6d1 to preserve the reading). vitest `Test Files 1 failed (1) | Tests 2 failed | 2 passed (4)`: loop{parallel} failed with received `leafA@iteration=0/branch=undefined` x3 and `leafB@iteration=1/branch=undefined` x3 against expected iteration=0,1,2 per branch — i.e. `branch` never written and `iteration` carrying the BRANCH index, the defect verbatim; bare parallel failed `AssertionError: expected undefined to be +0` at `expect(leafA?.branch).toBe(0)`. The two control arms were GREEN on that same unmodified tree exactly as predicted: `loop { try_catch }` (try/catch already forwards loopFrame.iteration at its own call site) and the spec's refusal of branch -1 / 1.5 at the `branch` path. The TYPE pin is invisible to vitest (esbuild strips types) and was measured under tsc, where it read `src/builtin/region-index-keys.test.ts(70,27): error TS2344: Type 'RegionKeys' does not satisfy the constraint 'keyof StepLogEntry'. Type '\"branch\"' is not assignable to type 'keyof StepLogEntry'.` NOT-MEASURED trap checked, not assumed: `tsc --listFiles` confirms the pin file is in BOTH the build program (tsconfig.json) and the test program (tsconfig.test.json, 575 files), so the green is about that file. GREEN AFTER, at e64991ffa: `pnpm --filter @objectstack/service-automation test` under the shared lock, `os-verify-lock: VERDICT command-exit 0`, `Test Files 121 passed (121) / Tests 1426 passed (1426)`; `typecheck` (tsc --noEmit + check:test-typecheck) EXIT=0, verdict line `check:test-typecheck: OK — @objectstack/service-automation's test layer compiles ... 0 file(s) / 0 error(s)`. GATES: derived mechanically with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (58 commands) plus the 4 rosters it flags as living in a directory one of my paths is in = 62; every exit code captured as `cmd > f 2>&1; EXIT=$?`, never through a pipe. First pass at fde99b1ea: 60 exit 0 and 2 exit 3 PREREQUISITE NOT MET (check:dual-build-cjs-loads and check:type-check-debt, both naming an unbuilt dist/) — recorded as neither pass nor red, the prerequisite satisfied with `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (VERDICT command-exit 0) and both re-run into real readings, the latter printing `check-type-check-coverage --re-measure: OK — 5 ledger entr(ies) re-measured, 55 raw tsc error(s) total, none above its recorded number`. FULL UNION RE-RUN ON THE FINAL COMMIT e64991ffa: 62/62 exit 0, verdict lines quoted from the gates themselves, e.g. `check-platform-checklist: OK — 15 areas, 264 items ... symbol anchors: 624/624 resolved`. LINT: NOT narrowed — `pnpm lint` = `eslint . --no-inline-config` over the whole repo, LINT_EXIT=0. One further commit followed the union (93fa7157b, dropping an unsupported 'through two releases' claim from one JSON string), so per the discipline the affected + ratchet families were re-run on that new head: check:platform-checklist, check-platform-checklist-watchdog, check:nul-bytes, check:doc-authoring, check:pm-governed-prose, check-affected-docs, check-drift-comment, check:keyed-text-bounds, check:pm-label-desc-cap, check:skills-token-ratchet, check:pm-skill-ratchet, check:dual-build-cjs-loads, check:type-check-debt, check:type-check-coverage — all exit 0. NO ABLATION WAS RUN, and I am not reporting one: this change adds no guard whose ability to fail needed proving. The discriminating evidence is the red-first pin pair measured on the unmodified tree and re-measured after, which is why the pin file was committed at its RED state before the implementation commit.",
      "mcp_calls": "6 — issue_read get, issue_read get_comments, search_issues (one targeted dedup query, non-empty so no control word needed), issue_write create (#16356), create_pull_request, add_issue_comment (this report). The public-repo zero-quota page payload was used first and returned only 2 of the 3 comments (the PM claim was absent from the render), which is why get_comments was spent.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #16356: app-showcase nests no control-flow construct inside another (loop / parallel / try_catch are three separate flows), so the new automation.flow-run-step-nesting `loop { parallel }` clause can only score blocked(fixture) until such a flow lands"
      ]
    }

    Generated by Claude Code

  6. os-warren commented on Sep 6, 2026

    @os-warren
    Collaborator

    ACCEPT — PR #16367. Verified by PM measurement on the PR's own head 93fa7157b, not from the report.

    The three files beyond the card's named surface — each checked, each legitimate

    file why it is in scope
    builtin/parallel-node.ts Unavoidable: the branch index is produced at that call site. iteration: i → branch: i. The alternative — having the shared tagger sniff regionKind === 'parallel-branch' and re-home the value — would put a second writer of the same meaning into the tagger, which is the shape this card exists to remove.
    builtin/try-catch-node.ts Comment only in the diff. Its old comment asserted "a loop's own tagger can never reach past this one to a try/catch step" — this change makes that false, so leaving it would ship a comment the code contradicts. The forwarding it explains is untouched.
    .changeset/contained-failure-visibility.md ✅ Verified it is main's file, not another PR's. git show origin/main: resolves it; it landed with d30ccb9bd (PR #15609) and is still unreleased. Its sentence "parallel branch tagging is unchanged" would otherwise ship as a flat contradiction in the same release note as this entry.

    Clause-② — the declaration held, and the seat checked rather than assumed

    My claim declared Clause-②: no with an auditable stop condition: stop if StepLogEntry is exported AND the change is more than one optional output key. Measured: engine.ts:720 — export interface StepLogEntry — so it is exported, and the change is exactly one optional output key (branch?: number). The stop condition is not met; the seat correctly did not stop, and said so instead of staying quiet. That is the declaration limb working as designed.

    The type-level pin is not vacuous — my own ablation, prediction written first

    The card asked for the engine interface and the spec type to be kept in step "by a pin, not by prose". A pin nobody has seen fail is prose. Predicted: deleting branch?: number from StepLogEntry makes RegionKeys (which lists 'branch') stop being keyof StepLogEntry ⇒ TS2344 at the pin. Observed, on tsc --noEmit -p tsconfig.test.json, exit 1:

    region-index-keys.test.ts(70,5):  error TS2344: Type 'false' does not satisfy the constraint 'true'
    region-index-keys.test.ts(70,27): error TS2344: Type 'RegionKeys' does not satisfy the constraint 'keyof StepLogEntry'
    

    Both halves fire — the Pick half and the invariant Eq half. The seat's report quoted only (70,27); the (70,5) line means a widening on one side would be caught too, not just a missing key. ⚠️ And the seat is right that this is invisible to vitest (esbuild strips types): a green pnpm test says nothing about it.

    HEAD blob     8603e9cca6d5f5dfa485cff673ccabd5b18756fe
    mutated blob  b9ef8373fc2494fd7b59ab6125aa214a496dd6b6
    restored      8603e9cca6d5f5dfa485cff673ccabd5b18756fe   `git diff HEAD` empty, tree clean
    

    Behavioural ablation — mine, prediction written first

    Predicted: moving the index writes back inside the parentNodeId === undefined block turns pin 1 red and leaves pins 2 and 3 green, because the try/catch arm gets its iteration from its own call-site forwarding and the bare parallel gets branch from the innermost grouping. Observed exactly: Tests 1 failed | 3 passed (4), the failure being

    × loop { parallel }: a branch step carries the loop's iteration AND its own branch
    AssertionError: expected [ …(6) ] to deeply equal [ 'leafA@iteration=0/branch=0', …(5) ]

    ⇒ The control arm is really a control (it does not move with the change), and the tagger split is load-bearing for the case the card is about. Unablated, the pin file reads Test Files 1 passed (1) / Tests 4 passed (4).

    Two things for the maintainer's eye — flagged, not blocking

    1. ⚠️ The objectui window is open now, not later. The card fences out objectui's FlowRunsPanel grouping key ${regionKind}#${iteration} as "a card in objectui, gated on a spec release that carries branch" — but that spec half already landed (execution.zod.ts carries branch), so the gate the card named is no longer closed. Between this PR releasing and that objectui card landing, every parallel-branch step groups under parallel-branch#undefined, because iteration is now deliberately absent there. ⛔ I did not verify the objectui side — that repo is not checked out in this session, so this is the card's own citation plus this diff, not a measurement.
    2. The changeset is minor, reasoned as matching the contract half. Moving a value from one published key to another is arguably breaking for a reader of iteration on a parallel-branch step; the changeset says so plainly in its "Reading a run recorded before this change" paragraph rather than hiding it. Naming it because the ruling chose the shape, not the seat.

    On the QA clause that cannot run

    The added loop { parallel } clause scores blocked(fixture) — showcase carries loop and parallel as separate flows and nests neither. The seat wrote the clause anyway, named the gap inside fixtures.requires, and filed #16356. Correct call: the thing the clause pins is settled by the ruling, and a clause missing from this item is exactly what let the iteration overload sit unmeasured. ⛔ It is recorded as blocked, not as passing.

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


    Generated by Claude Code

  7. 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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions