Contain a failing item to its own loop iteration: ten sweeps stop aborting mid-batch (42 flow-loop-body-uncontained to 0) - #1611
Conversation
A `loop` body has no error handling of its own — `loop-node.ts` iterates
with a bare `await` and carries no `try`/`catch` at all — so the first item
whose node fails ends the WHOLE run: every later item goes unprocessed and
the work already done is not reported. Measured by the platform on the real
engine: a 5-item sweep failing at item 3 touched 3 items, reported `acted: 0`.
`objectstack lint` names this 42 times, across 22 loops in 10 flows
(`flow-loop-body-uncontained`). Every one of them is a "process the batch"
sweep, so continuing past a failing record is the right reading in each; the
per-flow argument is in the PR body, `demo_bootstrap` included.
The containment is the one the rule prescribes: a `try_catch` inside the body
with the work in its `try` region and a single bare `assignment` as the
handler. Written out at each site it re-indents every body two levels, which
alone measured ~1,327 tokens over the `business semantics` ratchet ceiling —
a ceiling that moves only on a maintainer ruling. `guarded()` wraps the body
from outside instead: `body: {` becomes `body: guarded('<key>', {` and the
closing `},` becomes `}),`, so no body is re-indented and the whole change
costs 259 tokens, leaving ~162 of headroom.
Verified against the pinned `@objectstack/*` 17.3.0 this repo installs, not
the platform source: `try_catch` is registered and returns `success: true`
after running `catch`, so the loop continues; a bare `assignment` with no
`config` returns `success: true`; and the failed attempt's steps are kept in
the run log AHEAD of the handler's, so a skipped item stays visible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj
A dozen suites here hand-roll a walk over a flow's nodes, and every one knew about exactly one region: `loop`'s `config.body`. With the work now one region deeper — inside the `try_catch` guard each body opens with — those walks stop at the guard. Eight files said so by going red, 14 assertions in all. Two did not, and those are the reason this commit touches more than the eight: `demo-staffing`'s "ships no flow node that writes an identity table" (the #640 guard) and `flow-variable-conditions`' condition census both ask "nothing in this tree does X", and a walk that reaches an empty tree answers that vacuously. They stayed GREEN while inspecting nothing. The census is parameterised, so the loss is countable: it generated 14 fewer cases. Nothing is deleted, loosened, skipped or quarantined. Each walker gains the same descent the platform's own `collectFlowGraphs` takes, and takes it by reading the platform's slot map (`FLOW_REGION_SLOTS_BY_TYPE`) rather than a hand-written key list — so `loop.body`, `parallel.branches`, `try_catch.try` and `try_catch.catch` are all covered, and a slot added later is descended into without anybody remembering these suites exist. That is the same choice `test/flow-scheduled-org-partition.test.ts` already made, which is why it is the one flow walker in this repo that needed no change at all. `test/flow-run-summary.test.ts` is the one that RUNS rather than inspects: it builds a live-engine fixture by planting a dead predicate on a body edge, so it now executes the guarded shape instead of only reading it. Two type narrowings tightened rather than widened on the way: `node()` in `forecast-manual-override` throws on a missing id instead of dereferencing `undefined`, and the dead-gate fixture asserts it found the edge it plants on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj
A user-visible behaviour change, so it takes a changeset: ten flows' scheduled and bulk sweeps now skip a failing record instead of ending the run on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
| as the body states it | actual, on merge | |
|---|---|---|
| reading | 84,838 | 84,838 |
| ceiling | 85,000 | 100,000 |
| headroom | ~162 (0.19%) | ~15,162 (15.2%) |
⇒ ⛔ Nothing to change here. The measurement was conservative — the work passes under the stricter of the two ceilings, so it is safe under either, and Quality Checks (green, 23:48:32) ran the gate against a merge with the raised ceiling and agreed. This correction exists so the number is not carried forward as a constraint on the rest of epic #1579: I would otherwise have sequenced later cards around a 162-token budget that does not exist.
⭐ Not a mistake by this dev, and worth stating plainly: it re-measured exactly what it was asked to, honestly, on the right base. main took two commits in the ~40 minutes between measurement and PR open. That is the fourth time this session a well-formed reading has proved something other than what it appears to — and the first where the instruction to re-measure was itself already the fix and still was not enough.
The structural lesson, recorded on #1579: at this repo's merge rate, a dev-reported measurement is stale before it is reviewed. The authority is the gate running on the merge commit in CI, not the number in the report. A dev's figures are narrative — useful for showing the reasoning, ⛔ never the thing that decides a merge.
Related and deliberately untouched: #1607 is open on the consequence of that same raise — the ratchet's opportunistic-tightening advisory now tells every run to re-anchor back to ~89,000, i.e. to undo the ruling. It is already pm:dispatched to another seat. ⛔ Not this card's, and not this epic's.
Generated by Claude Code
Fixes #1604
Route 1 as ruled in comment 5555384744: the flows and the flow-graph walkers in
one revertible PR.
objectstack lint'sflow-loop-body-uncontainedcount goes42 to 0, and
pnpm verifyis green end to end.The per-flow product read — the ruling's required addition
The rule's own text says a sweep meant to stop at its first failure is a
legitimate reading and stays a warning. Ten flows, ten readings. All ten take
guarded(); nothing was left warning, and the reason is per flow ratherthan "they are all batch sweeps".
task_due_remindercase_sla_monitoropportunity_stagnationcontract_renewalrenewal_notice_days, so a lost pass is a renewal nobody is told about. Continue.contract_expirationactivatedpast theirend_date. Continue.quote_expirationcampaign_completionin_progresscampaigns pastend_date. The downstream metrics snapshot rides on each flip independently. Continue.forecast_snapshotcrm_forecastrow per owner, each computed from that owner's own opportunities. One rep whose write fails must not cost every other rep the night's snapshot — and the sweep is nightly, so a lost pass is a missing day in a time series. Continue.campaign_enrollmentdemo_bootstrapdemo_bootstrap, argued rather than swept inThe ruling names this as the one most likely to want the other answer: 12 loops
from one
claim()factory, and "a half-seeded demo that continues past afailure" is a defensible thing to not want. Four things decide it, and the
first is the one that settles it.
1. Aborting does not buy all-or-nothing — it produces the half-seeded org.
The flow is a straight line of twelve find/loop pairs. An abort inside pass 3
leaves passes 1–2 fully claimed and passes 4–12 untouched. There is no
transaction and nothing rolls back, so "stop at the first failure" is not
"leave the demo clean"; it is "stop at an arbitrary point and leave nine
objects entirely ownerless". The choice is not whole vs half. It is all but
the failing rows vs an arbitrary prefix.
2. A permanently un-claimable row is a poison pill today. The sweep runs
every 10 minutes and selects
{ owner_id: null }, so a row it cannot claim isselected again on the next tick — and aborts again, at the same place, forever.
Every object after it in the chain stays ownerless permanently. That is exactly
the #622 shape the flow exists to prevent: rows that are invisible under a
privateOWD, uneditable by everyone includingsystem_admin, absent fromevery "My …" view, with owner-addressed
notifyreaching nobody. Containmentturns "one bad row disables all twelve passes forever" into "one bad row stays
ownerless and is retried in ten minutes".
3. Idempotency means continuing costs nothing that stopping would have
saved. A claimed row stops matching the filter; a skipped row is retried on
the next pass. Stopping does not preserve a retry that continuing spends.
4. On visibility, the concession and the answer. The card's measurement is
that the failed attempt's steps stay in the run log ahead of the handler's
— re-confirmed here against the pinned 17.3.0 artifact, not the platform source
(
childSteps: [...failedAttemptSteps, ...catchSteps]intry-catch-node.ts,and
runRegionpushes the failing node's ownstatus: 'failure'step before itthrows). The ruling is right that "visible in the run log" is not "an operator
will see it", and I am not claiming otherwise. But the alternative on offer is
not an operator seeing it either: it is an aborted run reporting
acted: 0with no per-item attribution, which is less legible, not more. Containment is
strictly the more visible of the two — the failure step exists in both, and
continuing adds the successful items beside it, so the per-node fold in
sys_automation_runcan tell "3 of 40 skipped" apart from "the sweep died atitem 3". Nothing is lost by continuing; a discriminable signal is gained.
⇒
guarded()applied to all twelve.The three premises, re-measured on this branch
Every figure on the card came from #1596's branch at base
81a79ee. This branchis cut from
8223d0a(post-#1605). Re-derived here:One correction to the framing, not to the shape: the card and its title say
"10 scheduled sweeps", but nine are
type: 'schedule'andcampaign_enrollmentis
type: 'screen'. It is still a batch sweep and still takes the fix; it isjust user-invoked.
business semanticsheadroom — the PM's reasoning held, andit is now a measurement. Clear three platform lint rule families (31 of 90 warnings), and report the rest with named reasons #1605 did not move this ceiling. Pre-change reading
on this branch: 84,579 / 85,000, headroom 421. The helper costs 89
tokens (the card's figure, to the token) and the call sites plus their imports
170, for 259 total. Post-change: 84,838 / 85,000, headroom ~162.
No re-anchor, no maintainer ruling needed. It is tight — 0.19% — and The ratchet's opportunistic-tightening advisory now tells every run to re-anchor the ruled business-semantics ceiling back to ~89,000 — a standing invitation, in the gate's own output, to undo a maintainer ruling #1607 is
already open on that ceiling's advisory.
the eight the card names. It was not a closed set: see below.
The walkers — 8 red, and 2 that were worse than red
Nothing is deleted, loosened, skipped, disabled or quarantined. Each walker
gains the same region descent the platform's own
collectFlowGraphstakes.The eight red files are the card's eight. Two more were green and blind:
test/demo-staffing.test.ts's "ships no flow node that writes an identitytable" (the #640 guard) and
test/flow-variable-conditions.test.ts's conditioncensus both ask "nothing in this tree does X", and a walk that reaches an empty
tree answers that vacuously. The census is parameterised, so the loss is
countable — ablation on this branch, restoring only that file's pre-change
walker and running it both ways:
Green either way, 14 generated cases fewer. Fixing only the eight that went
red would have shipped that silently. Both are in the same defect class as the
eight, mechanical, in the same gate family, and add no new verification surface,
so they are fixed here rather than filed.
The descent reads the platform's slot map (
FLOW_REGION_SLOTS_BY_TYPE) ratherthan a hand-written key list, so
loop.body,parallel.branches,try_catch.tryandtry_catch.catchare all covered and a slot added later isdescended into without anybody remembering these suites exist. That is the same
choice
test/flow-scheduled-org-partition.test.tsalready made — which is whyit is the one flow walker in this repo that needed no change at all, and its own
header predicted today: "a walk that only knew about
loopwould silently stopcovering a sweep the day one is authored inside
parallelortry_catch".test/flow-run-summary.test.tsis the one that RUNS rather than inspects: itbuilds a live-engine fixture by planting a dead predicate on a body edge, so it
now executes the guarded shape rather than only reading it.
The shape, and why not the literal spelling
At each site
body: {becomesbody: guarded('KEY', {and the closing},becomes
}),— zero re-indentation, which is what makes it fit. The literal22x inline
try_catchstays refused per the ruling: its re-indentation alonemeasured ~1,327 tokens over the ceiling, and the ratchet strips comments so
prose-slimming cannot buy it back.
Three facts checked against the pinned
@objectstack/*17.3.0 this repoinstalls, not the platform source tree — all three re-confirmed:
try_catchis registered by the pinned engine, runs itstryregion, and ona caught failure runs
catchand returnssuccess: true, so the loopcontinues.
assignmentnode with noconfigreturnssuccess: true— itsexecutor folds an absent config to zero assignments, and
configis optionalon
FlowNodeSchema. It is also the exact handler the lint rule's own hintprescribes.
Verification
pnpm verifyexit 0 —validate,typecheck,lint,lint:i18n-gate,hygiene,hygiene:tokens,build,test, in that order.The 17 residual lint warnings are the
component-props-*family on pages andviews — a different family, untouched by this card. No lint rule was
suppressed, whitelisted or locally re-severitied, and no warning was found to
be wrong on this metadata, so nothing was filed upstream.
Changeset added:
.changeset/scheduled-sweeps-survive-a-failing-record.md,'hotcrm': patch. This is a live behaviour change.Generated by Claude Code