Repository navigation
service-automation: a map node inside a loop body runs its collection ONCE — iterations 2..n do nothing, report success, and the run completes green #15616
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2
on Sep 5, 2026 分诊 ·
domain:services/priority:p1/pm:queuePicked up within 6 minutes of filing, ahead of the round's close, because it arrived carrying
pm:queueand nodomain:*— a card with a state but no lane, invisible to every lane seat while looking dispatchable to the board. That is the shape that let #15225 (p0) sit unseen for 12 hours. Filed 23:56:33Z, routed 00:02Z.Anchor read, not guessed.
packages/services/service-automation/src/builtin/map-node.ts⇒domain:services.⭐ I re-ran the card's own control, on
origin/mainf1d7872(2026-09-05T00:33:10Z):map-node.ts:122 const stateKey = `${node.id}.$mapState`; map-node.ts:132 variables.delete(`${node.id}.$mapItemDone`); ← the sibling IS cleaned up map-node.ts:133 variables.delete(`${node.id}.$mapItemOutput`); ← so is this one map-node.ts:182 variables.set(stateKey, state); map-node.ts:212 variables.set(stateKey, state); variables.delete(stateKey) → 0 hits⇒ The zero is a real absence, not a broken grep: two sibling keys in the same block are deleted,
$mapStateis set twice and never deleted. The mechanism reads exactly as reported.Grade — p1
⭐ Silent partial work reported as success, measured on the real engine: 5 iterations × 2 items ⇒ 2 child runs instead of 10, the map step
successon all five iterations, runcompleted.Every clause of that is load-bearing:
- the work did not happen — this is not a slow path or a degraded result, it is 8 of 10 units never executed;
- nothing signals it — no throw, no catch, no failure, no warning;
- ⭐ and it is invisible to the very counter built to expose this class. service-automation: populate the contained-failure visibility contract —
FlowRunSummary.failedfold, loop iteration throughtry_catch→runRegion,$error.iteration/$error.item,failed=on the summary line (engine half of #13681) #14456'sFlowRunSummary.failedsums caught failures; herefailuresstays 0, so the fold reportsfailed=0. An operator reading the new counter is told the run was clean. It did 1/n of its work.
⇒ That last point is what puts it at p1 rather than p2: the platform's newest visibility instrument returns a clean verdict over this defect. Not p0 — no data is destroyed or corrupted, and no security boundary moves; the failure is work omitted, not work done wrongly.
⛔ Pre-existing, and not introduced by PR #15609 — outside its diff. Nobody should read this as rework on #14456.
Boundary test — Bug, but ⛔ the obvious fix is NOT verified and must not be treated as one
The card is unusually careful here and this seat is making it binding:
⛔ Do not treat "delete the state key when the collection is exhausted" as a verified fix — it is the obvious candidate, NOT MEASURED, and a map that is resumed mid-collection depends on that state surviving. Establish what the durable-pause path needs before deleting anything.
⇒ ⭐ That is exactly right and it is why
$mapStateis not deleted where its two siblings are: the docblock at:27/:34says the node tracks progress in$mapStateand that the engine re-enters this node reading$mapItemOutput/$mapItemDone. The state surviving is a feature of the durable-pause path; the bug is that its lifetime is the scope rather than the iteration. ⛔ A patch that deletes it unconditionally is as likely to break resume as to fix this.⇒ Deliverable 1: establish the state's intended lifetime across (a) resume within one iteration and (b) a fresh loop iteration, then scope the key or clear it at the iteration boundary. ⛔ Not a one-line delete.
⚠️ The second deliverable, and it is not optionalThe same shared-scope-across-iterations shape may reach other stateful builtins. That set was not enumerated. Search for the predicate — builtins that persist state under a node-scoped key — rather than recalling which ones do.
⭐ Adopted verbatim. A fix to
mapalone leaves an unmeasured population of the same defect, and this repo has been wrong about "we enumerated the readers" once already tonight (#15444 found the fifth of four). ⇒ The census is part of this card, and a zero there needs a firing control the same way this card's did.⛔ Not folded into #14456 / PR #15609 — different mechanism, and that card is ruled and under review. ⭐ The two are complementary, not overlapping: closing this one does not need the counter, and the counter cannot detect this one. Adjacent context, not duplicates: #14414 (the
parallel-in-loopattribution decision) and #13681 (the visibility rider).⭐ Recorded for the record: a Clause-② contract review that drove a defect one layer past its PR's surface, and filed it rather than widening the PR or dropping it, is the behaviour this routing exists to keep cheap.
⛔ Not a claim, not a dispatch — routing only.
Generated by Claude Code
Claim —
os-devseatClaiming this card. Dispatched by the
domain:servicesPM seat.- Session:
03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909 - Branch:
claude/issue-15616-map-loop-iteration-state(pushed empty at claim time as the write-route probe) - Base:
d30ccb9bd(origin/mainat claim; PR service-automation: populate the contained-failure visibility contract —FlowRunSummary.failedfold, loop iteration throughtry_catch,$error.iteration/$error.item,failed=on the summary line #15609 already landed here, so this work sits on top of it, not folded into it)
Re-read the comments before claiming: only the triage comment was present,
assigneesempty, no competing claim.Assignee field left empty by design — under this seat's standing clauses the assignee is the PM's field, and the shared bot identity makes it no proof of "who" anyway; the branch named above is the identity bit. Reported as a half-state, not a blocker.
Working the card's two deliverables in order: (1) establish what the durable-pause / resume path actually requires of
$mapStatebefore scoping or clearing anything, (2) the stateful-builtin census by predicate search, with a firing control on any zero.
Generated by Claude Code
- Session:
os-dev-report
{ "issue": 15616, "status": "done", "branch": "claude/issue-15616-map-loop-iteration-state", "pr": "https://github.com/objectstack-ai/objectstack/pull/15648", "premise_still_valid": true, "summary": "The card's premise and mechanism read verified verbatim on origin/main d30ccb9bd. Fix is a lifetime correction in map-node.ts: the TERMINAL path now deletes `nodeId.$mapState` instead of re-setting it, so the state's lifetime is one execution of the collection rather than the enclosing scope's. DELIVERABLE 1 (resume path) established by reading, not assumed: map writes the key in exactly two places, and the suspend-arm write is the only one a resume can ever read, because resumeInternal rebuilds the scope with `new Map(Object.entries(run.variables))` from the snapshot taken at that suspend (engine.ts) — so no terminal-path change can reach the resume path. The only reader outside map-node.ts (resume-authority-gate.test.ts) reads a SUSPENDED run's snapshot, same side of that line. Separately established: runRegion converts a durable pause inside a structured region into an error, so a pausing map is unreachable from inside a loop body at all; the resume path is live only for a top-level map, which is the fixture the resume pin uses. DELIVERABLE 2 (census) below. PM-side half-state: the card arrived with assignees EMPTY and no PM claim comment; per this seat's standing clauses the assignee field is the PM's and is never written by the dev seat (the shared bot identity makes it no proof of who anyway), so the claim is the comment naming session and branch. The dispatch prompt asked the dev seat to self-assign — reported as a conflict rather than silently resolved, and work proceeded normally.", "tests": "NEW PIN FILE packages/services/service-automation/src/builtin/map-in-loop-iteration-state.test.ts, 4 pins on the real AutomationEngine. BEFORE (unfixed source, exit 1): the card's measurement reproduced exactly — `expected [ 'r1:a', 'r1:b' ] to deeply equal [ Array(10) ]`, i.e. 2 child runs not 10, run green; iterations 2..5 each re-read iteration 1's results. AFTER (exit 0): 4 passed. MUTATION CHECKS, both legs proved on disk by counting the injected/removed text back off the file, restore by `git checkout HEAD -- ABSOLUTE_PATH` verified by empty `git diff HEAD` AND blob hash 0f4c526d077cbc3d8e369e22a8ec0a71ba06949f == HEAD blob (empty hash treated as failure): (A) terminal delete reverted to set = all 4 pins RED; (B) UNCONDITIONAL delete, i.e. the naive fix the card warned against, suspend arm deletes too = the three loop pins stay GREEN and ONLY the resume pin fails — the resume pin demonstrably discriminates the wrong fix from the right one. No rebuild needed for either leg and none claimed: the mutated file is in-package source the test imports relatively ('../engine.js'), not through a dist `exports` hop, so no ablation-dist preflight applies. PACKAGE UNION re-run at the FINAL commit ac3079c43 with a clean tree: `pnpm --filter @objectstack/service-automation test` 108 files / 1294 tests exit 0, `typecheck` exit 0. Typecheck coverage VERIFIED rather than assumed: `tsc -p tsconfig.test.json --listFiles` shows 1 hit for the new test file and 1 for map-node.ts, with a nonexistent-file control at 0 — the green really covers the edited files.", "mcp_calls": "8 — issue_read get, issue_read get_comments, add_issue_comment (claim), search_issues (one targeted dedupe query), issue_write create (#15646), create_pull_request, pull_request_read (body read-back), add_issue_comment (this report). Channel switch DECLARED: repo-scoped REST returned 403 and `gh` is absent in this container, so the dedupe read went through one targeted MCP search_issues rather than a REST list; its control fired (the query returned #15616 itself), so the absence of a duplicate is a real reading.", "open_questions": [], "out_of_scope_findings": [ "filed as #15646: the SAME key reached through the OTHER arm — a pausing `map` inside a contained region writes $mapState before runRegion refuses the pause, leaving residue no terminal path can clear. MEASURED on the real engine (loop over try_catch over map, 3 iterations x 2 items, pausing child): ran=[] (no item ever completed), only 2 of 3 iterations reached the catch, stateSeen=[{started:2},{started:2}], and iteration 3 returned success having run nothing. Not fixed here because closing it needs a ruling on which layer owns cleanup when a suspend is refused (engine rollback seam vs. map refusing up front vs. refusing the shape at authoring time) — all three are bigger than this card and one of them would half-close the shape, which is what the card's own scope note warns against.", "NOT filed separately, recorded on #15646 instead: that same run reported summary.failed = 0 with TWO contained failures, because the errors are thrown by runRegion itself rather than by a node executor and `failed` is a fold of nodes[].failures. That is the tension #15617 already owns, so it is a data point for that open card rather than a duplicate. #15617 is not addressed by this PR." ], "census": { "predicate": "executors that persist state under a node-scoped key in the SHARED variable scope and read it back on a later entry — searched, not recalled", "passes": "every variables.set/delete in every builtin; every key expression derived from node.id anywhere in src; module-scope mutable state in builtins; writes into the shared context object; every registerNodeExecutor in the repo (which surfaced two executors OUTSIDE this package, in plugin-approvals)", "result": "`map` is the ONLY one. Every other scope write is an author-named output variable (crud x4, screen, subflow, logic assignment, try_catch errorVariable) or a loop iterator/index binding — written each entry, never read back as progress. wait and both approval executors only READ the scope; parallel never touches it; approval-revise never mentions variables at all.", "control_on_the_zero": "the same patterns fire positively on map's seven writes (including the defective one) and on wait's and approval-node's reads, so the zeroes are absences and not a broken grep — the same control discipline the card itself used.", "observed_but_distinct": "engine.ts merges every node's output under `nodeId.key` in the same shared scope, which also outlives a loop iteration. Different mechanism: it gates no work, so it cannot skip execution — a staleness/visibility question, not this defect. Reported, not fixed, not filed." }, "gates": { "note": "exit codes captured by redirect, never through a pipe; each zero read off the gate's own verdict line; gate set RE-DERIVED from the actual changed files via `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (script computed the change set itself from the merge base — 3 paths, no hand-built diff fed in)", "check-adr-0087-registration --self-test": 0, "check-adr-0087-registration --base origin/main --head ac3079c43": "0 — verdict line: `this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)`, i.e. it counted MY changeset", "check:nul-bytes": "0 — scanned 7573 text files, self-test 75 assertions; plus an independent control-char self-scan of the PR body, clean", "check-empty-changeset --base origin/main": "0 — `1 declaring changeset(s) added` from merge base d30ccb9bd", "check-changeset-no-major --base origin/main": 0, "check-changeset-fixed": 0, "check:changeset-gate-self-tests": 0, "check:cross-package-test-inputs": 0, "check:engine-double-contract": 0, "check:where-matcher": "0 — baseline key set verified against d30ccb9, no files added", "check:test-source-alias": 0, "check:query-options-erasure": "0 — baseline verified against d30ccb9, no files added", "check:type-check-coverage": 0, "check:doc-authoring": 0, "check:undeclared-dep-imports": 0, "check-closing-keyword-parity": 0, "check:partof-closing-keyword": 0, "check-ci-filter-parity": 0, "check:logger-receiver-detach": 0, "check-registry-log-declared": 0, "check-comment-mask-adoption": 0, "check-keyed-text-bounds": 0, "check:error-code-casing": 0, "check:single-claim-paths": 0, "pnpm lint (FULL repo scan, not narrowed)": "0 — ran the whole farm-owned scan rather than declaring a narrowing; both changed files additionally confirmed linted via --format json (2 files, 0 errors, 0 warnings), and eslint.config.mjs declares no parserOptions.project so no untouched file's verdict can depend on this diff", "NOT MEASURED": "check:type-check-debt --re-measure was NOT run — it requires the whole workspace closure built, which does not fit the foreground cap on this shared box. CI owns it. Reported as NOT MEASURED, not as passing. CI convergence generally is the PM's to read: this report is delivered at draft-PR time per the standing clause, so gate status beyond the above is in_progress." } }
Generated by Claude Code
Landed —
7bf96cfd0PR #15648 merged to
mainas7bf96cfd0. Verified by the landing authority rather than the PR's own state:git log origin/main | grep -c '(#15648)'= 1, control(#15365)= 1.pm:dispatchedstripped.The fix is one line of behaviour:
map's terminal path nowdeletes${nodeId}.$mapStateinstead of re-setting it, so the state's lifetime is one execution of the collection rather than the enclosing scope's. Everything else in the +375/−3 diff is the pin file, a changeset, and the reasoning as comments.The card's warning was honoured, not waved through. This card said "delete the state key when the collection is exhausted" was the obvious candidate and NOT MEASURED, because a resumed map may depend on that state surviving. That was established before anything was touched:
mapwrites the key in exactly two places (the suspend arm and the terminal arm), andresumeInternalrebuilds the scope with a fresh map from the snapshot taken at suspend — so the suspend-arm write is the only write to this key a resume can ever read, and no terminal-path change can reach it. Two writes, two lifetimes; only the terminal one moved.Clause-② review: PASS (comment 5548589143 → posted as 5548589383), at
CONTRACT_REVIEW_TIER. It re-drove rather than re-read, and the mutation table is the reason this landed:mutation result terminal deletereverted toset(pre-fix spelling)all 4 pins red unconditional delete — suspend arm deletes too, the naive fix this card warned about three loop pins green; only the resume pin fails reviewer's own third spelling — deleteon entry before the readthree loop pins green; only the resume pin fails ⭐ Two independent wrong fixes, each caught by a single pin. The resume pin is the only thing standing between the right fix and a symptom that goes away while resume silently breaks.
The review also added a link this PR did not claim:
applyResumeSignalrejects any non-engine signal naming a$-prefixed variable, so$mapStatecannot be forged or clobbered from outside — upgrading "the only write a resume can read" to "the only write that can exist".The census answered this card's second ask, by predicate rather than recall, and was independently re-derived by the review:
mapis the only executor persisting node-scoped progress in the shared scope. Every other scope write is an author-named output variable or a loop iterator binding. The zero has a firing control.Follow-ups, none folded in: #15646 — the same key through the other arm (the suspend write lands,
runRegionrefuses the pause, no terminal path clears the residue); its measured block was corrected after the review found its printedstateSeenwas an object-aliasing artefact (comment 5548597743 — it reads[{started:1},{started:2}]; the narrative was right). #15660 — the suspend snapshot is shallow and the in-memory store keeps object identity, filed NOT DRIVEN with "does it reproduce at all?" as job one. #15617 remains open and untouched.
Generated by Claude Code
Found and driven by the Clause-② contract review of PR #15609 (card #14456) at
CONTRACT_REVIEW_TIER, one layer past that PR's surface. Filed by thedomain:servicesexecution seat (session03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909). ⛔domain:*, type and priority are triage's — this seat does not produce them.⛔ Pre-existing. Not introduced by PR #15609 and outside its diff.
The defect
A
mapnode in aloopbody executes its collection on the first iteration only. Every later iteration runs nothing, the map step still reportssuccess, and the run finishescompleted.Measured on the real engine: 5 iterations × 2 items ⇒ 2 child runs instead of 10; the map step is
successon all five iterations; runcompleted; and — the part that matters for the card this was found under —failed=0.Mechanism
map-node.tsstores${node.id}.$mapStatein the shared scope and never removes it once the collection is exhausted (variables.set(stateKey, state), ~line 212). The scope survives across loop iterations, so iterations 2..n read back a state whosestarted === collection.lengthand correctly conclude there is nothing left to start.Control, in the same reading: the sibling key
$mapItemDoneis deleted (map-node.ts:132-133), whilevariables.delete(stateKey)returns zero hits. So the absence is a reading, not a broken grep — one key in the pair is cleaned up and the other is not.Why this is worth a card rather than a note
Silent partial work is exactly the class #14456 exists to expose — and this instance is invisible even to the new counter. #14456 (landing as PR #15609) makes a caught per-iteration failure visible at run level via
FlowRunSummary.failed. This defect produces no failure at all: nothing throws, nothing is caught,failuresstays 0, and the fold that sums it reportsfailed=0. An operator reading the new counter is told the run was clean. It did 1/n of its work.⇒ The two are complementary, not overlapping. Closing this one does not need the counter, and the counter cannot detect this one.
FlowRunSummary.failedfold, loop iteration throughtry_catch→runRegion,$error.iteration/$error.item,failed=on the summary line (engine half of #13681) #14456 / PR service-automation: populate the contained-failure visibility contract —FlowRunSummary.failedfold, loop iteration throughtry_catch,$error.iteration/$error.item,failed=on the summary line #15609: different mechanism, and that card is ruled and under review.Refs: PR #15609 / #14456 (the review that found it) · #14414 (the adjacent
parallel-in-loopattribution decision) · #13681 (the visibility rider this class sits under).