fix(service-automation): a flow switched off in the activation ledger stays unbound after a restart, and a trigger-fired refusal logs no run-history claim (#20677) - #20702
Conversation
…hydration leaves a disabled flow unbound (red on main) The pins drive every arming path from outside: a trigger type registered after hydrateFlowActivations(), a cold restart over one activation store, the enable toggle, and the trigger-fired failure line's run-history claim. Ten of eleven are red on the unfixed engine; the dispatched-and-failed control is green. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…so a trigger registered after ledger hydration never arms a disabled flow activateFlowTrigger now refuses to arm a flow isFlowEnabled answers false for, so registerFlow, registerTrigger, the enable toggle and any later arming path inherit it; registerFlow's caller-side check is folded into it. The trigger-fired callback says a failure is in the run history only for a run that dispatched and failed (status 'failed'), and a FLOW_DISABLED refusal that still reaches it is logged at info as a refusal, not at error as a failed run. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0c892126198c9266a4f839d1dc042f3dbc91bb13 && git checkout 0c892126198c9266a4f839d1dc042f3dbc91bb13
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e9ad74fb4e315be2fa6392a6cf478c81dd37ad1 758967616e1f8078c884bbea146c5980c19e7ad2 && git checkout -B drift-repro 0e9ad74fb4e315be2fa6392a6cf478c81dd37ad1 && git merge --no-ff 758967616e1f8078c884bbea146c5980c19e7ad2
node scripts/docs-audit/affected-docs.mjs --json 0e9ad74fb4e315be2fa6392a6cf478c81dd37ad1
|
Fixes #20677
Clause-②: no
What was wrong
A packaged flow switched off through the ADR-0126 activation ledger came back armed after a cold restart. At boot
AutomationServicePlugin.start()pulls the flows and runshydrateFlowActivations(), which leaves a switched-off flow unbound. The trigger plugins register later, atkernel:ready, andAutomationEngine.registerTriggerarmed every registered, unbound flow of the matching type throughactivateFlowTriggerwithout asking whether the flow may run. SoGET /api/v1/automation/_statusreportedenabled: false, bound: true. Every matching event then fired a run thatexecute()refused withFLOW_DISABLED, and the trigger-fired callback logged it at ERROR as a failed run whose failure "is recorded in the flow's run history". No row was written.The card's reproduction, reached on current
mainat the engine level (14f80e23, the pins in commit1a7ce83b7run against the unfixed engine): a boot replay over one activation store, inplugin.ts's order (pull, hydrate,kernel:readyprotocol sync, trigger registration, seal), reads on the first restart:That is the card's
/_statusshape. On the unfixed code 10 of the 11 new cases are red, and the one control (a run that dispatched and failed) is green.What changes (
packages/services/service-automation/src/engine.tsonly)activateFlowTriggeritself. It refuses to arm a flowisFlowEnabledanswersfalsefor, before the scheduled-work policy gate and before the trigger lookup.registerFlow,registerTrigger, the enable half oftoggleFlow, and any arming path added later inherit it.registerFlow's own caller-sideif (this.isFlowEnabled(name))is folded into the gate, so there is one check, not two.registerTrigger's loop is unchanged; only its comment now names the gate.FLOW_DISABLEDanswer that still reaches the callback (an event in flight at the switch-off, or a trigger whosestop()failed) is logged atinfo: the flow is disabled, the run was refused before it started, nothing ran, and no run-history row records it. It is no longer logged aterror.errorline. That line says "the terminal failure is recorded in the flow's run history" only when the result carriesstatus: 'failed', the verdictexecute()'s ran-and-failed exit andretryExecution's exit set after writing their failed row. A never-dispatched answer (nostatus) gets the same line with "it was refused before it dispatched" in place of the history claim.The dispatch's mechanism assumptions, measured
registerTrigger(was about :3368) calledactivateFlowTrigger(name)for every unbound flow of the matching type. Neither method consultedisFlowEnabled.registerFlow's only ledger check was the caller-side guard aroundactivateFlowTrigger(was :4271). The one at :4257 guards the node-type warning, not arming. The ordering is inplugin.ts: hydrate runs instart(), and the protocol sync and trigger plugins run atkernel:ready.activateFlowTrigger. There are three:registerTrigger,registerFlowandtoggleFlow's enable branch. The only refused-flow bookkeeping ispolicyDisabledFlows(the scheduled-work refusal record). A disabled flow now returns before the policy gate, so it records no policy refusal.describeUnboundReasonalready answeredundefinedfor a disabled flow, so neither the/_statusreasonnorgetTriggerBindingAudit()changes./_status'sboundreadsboundFlowTriggers, which is nowfalsefor the flow.toggleFlow(name, true)on a flow whosestatusisobsoleteorinvalidno longer arms it. Before, it was armed and every event it fired was refused withFLOW_DISABLED(the status dimension) and logged at ERROR, the same defect through the toggle. A status-disabled flow registered before its trigger was re-armed byregisterTriggertoo. Both are pinned.FLOW_DISABLEDreaches the log.execute()'s disabled exit returns before a run id is minted and before anyrecordLog, so no row is ever written for it. Before the fix, it reached the ERROR line on every matching event of a re-armed flow. With the gate, it is reached only by the residue named above.status: 'failed'. The fourstatus-less failure exits are flow not found,FLOW_DISABLED,FLOW_NO_START_NODEandFLOW_INPUT_SCHEMA_INVALID. The last one does write a failed row but carries nostatus, so the line makes no history claim for it: true, if less informative.PR #20551's arm-time
apisecret refusal is untouched. The gate sits beforetrigger.start.validateApiTriggerSecretat registration andApiTrigger.start()'s throw (into the existing Failed-to-bindwarn) are unchanged, andapi-trigger-secret-registration.test.tsis green.Tests (all read at
758967616, the last commit)src/flow-activation-late-trigger.test.ts, 11 cases:hydrateFlowActivations(), for each ofrecord_change,schedule,time_relativeandapi: the switched-off flow stays{ enabled: false, bound: false }, and its enabled sibling of the same kind arms (the control).active: true.obsoleteflow does not arm it.enabled: false, bound: falseon both boots, the sibling bound, and the binding audit empty.FLOW_DISABLEDis logged atinfowith no history claim andlistRunsis empty. A dispatched failure is logged aterrorwith the claim and onefailedrow. A never-dispatched failure is logged aterrorwith no claim and no row.pnpm --filter @objectstack/service-automation test:Test Files 155 passed (155),Tests 1924 passed (1924).pnpm --filter @objectstack/service-automation typecheck: exit 0 (tsc --noEmit, thencheck:test-typecheck: OK).tsc -p tsconfig.test.json --listFileslists the new test file (1 hit).scripts/ablation-replace.mjsin wrap mode (anchor must hit once, the restore is armed on EXIT/INT/TERM, and a shell trapgit checkout HEAD --sits behind it). The subject resolves fromsrc/through the relative./engine.jsimport, so nodist/leg applies.if (!this.isFlowEnabled(flowName)) return;, anchor x1 then x0, blob566ed4d67921thend09694f76678):Tests 8 failed | 3 passed (11). The 8 are every arming pin. For example,restart 1: expected { enabled: false, bound: true } to deeply equal { enabled: false, bound: false }. The 3 log-line cases do not depend on the gate and stay green.FLOW_DISABLEDbranch disabled:Tests 1 failed | 10 passed. The failing case is theinfopin:expected [ Array(1) ] to deeply equal [], where the array holds an error line.Tests 1 failed | 10 passed. The failing case is the never-dispatched pin, which now claims a run-history row.ok restored: blob == HEAD (566ed4d67921) and git diff HEAD is empty.git status --porcelainis empty afterwards.Gates
node scripts/pm/dispatch-gates.mjs --commandsre-derived against the real diff gives the same 62 commands as the dispatch list, byte-for-byte after sort.pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debtboth exit 3 (PREREQUISITE NOT MET: they read the whole workspace's builtdist/, and this worktree built only this package's dependency closure). CI builds that and runs both.--ran:62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).node scripts/check-changeset-fixed.mjs,pnpm check:authz-resolver,pnpm check:error-code-casingandpnpm check:filter-alias-parity. Added for the log-level and arming edit, also exit 0:pnpm check:durability-log-levelandpnpm check:startup-registry-verdict.pnpm lintover the whole repo is CI's):eslint --no-inline-config --format jsononengine.tsand the new test file..mdhas no matching config (--print-configanswersundefined, and the JSON says "File ignored because no matching configuration was supplied"), so it is outside eslint's population.eslint.config.mjsnever enables type-aware linting (noparserOptions.projectorprojectService), so this diff cannot move a verdict on any untouched file.pnpm check:nul-bytes: exit 0. A control-byte self-scan of the three changed files finds nothing.Changeset
.changeset/20677-ledger-disabled-stays-unbound.mdis apatchon@objectstack/service-automation. No export, option, route or response shape moves.Acceptance notes
plugin.ts's boot order over one sharedInMemoryFlowActivationStore. A LiteKernel boot of the real plugin was not built. The plugin attaches the durable ledger only through anobjectqldata engine with find/insert/update, so it would need a new data-engine double undercheck:engine-double-contract. The defect lives in the engine's arming path.api-trigger-secret-registration.test.tssays "anobsoleteflow is re-enabled by a toggle". Under ADR-0126 the toggle moves only the ledger bit, so it never re-enables a status-disabled flow. This is comment drift, outside this card's file surface. Carrier: none.recordLogitself throws onexecute()'s failure arm, the result still carriesstatus: 'failed'. The trigger-fired line then says the failure is recorded, while the separate ERROR line from that catch says the row never landed. The result has no channel for "the write failed", and the bookkeeping line is itself loud. Carrier: none.origin/mainhas moved 3 commits since the base (3b47a693c,0be898499,f379f57f4). None touchespackages/services/service-automationorpackages/spec/src/contracts, so the branch is not merged forward. CI and the merge queue validate the merge ref.Generated by Claude Code