Skip to content

fix(rules): don't consume cooldown when a fire dispatched nothing#178

Merged
xunholy merged 1 commit into
mainfrom
fix/rules-cooldown-accounting
Jul 1, 2026
Merged

fix(rules): don't consume cooldown when a fire dispatched nothing#178
xunholy merged 1 commit into
mainfrom
fix/rules-cooldown-accounting

Conversation

@xunholy

@xunholy xunholy commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Engine.Handle bumped lastFire+fires BEFORE dispatch. When a rule's only action was an ActionTool dropped due to the in-flight cap (maxToolActions=8) being saturated, nothing ran yet the cooldown clock started — so the NEXT genuinely-matching event was wrongly suppressed (a saturation blip silently swallows a real trigger). Handle now records optimistically under the lock (concurrent same-rule fires still cooldown-suppressed) and rolls back (fires--, restore lastFire guarded by Equal(bump) so a concurrent successful fire isn't clobbered) when fire() reports nothing dispatched. Also clamps a negative Cooldown to 0 at Register. Found via the automation-engine sweep (clears the 2 banked rules items). -race tests; the core test is proven to fail pre-fix. task ci green; eval 17/17.

Engine.Handle bumped lastFire and fires for a matched rule BEFORE dispatching
its actions. When the rule's only action was an ActionTool dropped because the
global in-flight cap (maxToolActions=8) was saturated, nothing actually ran —
yet the rule was recorded as fired and its cooldown clock started. The real
damage is the cooldown: the NEXT genuinely-matching event inside the window is
then wrongly suppressed, so a saturation blip silently swallows a real trigger.
/rules list also over-reported Fires/LastFire.

Handle now records the fire optimistically under the lock (so a concurrent
Handle for the same rule is still cooldown-suppressed) and rolls it back when
fire() reports that nothing dispatched: fires-- and lastFire restored to its
prior value (or deleted). The restore is guarded by an Equal(bump) check so a
concurrent successful fire that legitimately advanced lastFire — possible only
for Cooldown==0, where lastFire is display-only; a real cooldown prevents two
same-rule fires from both passing the check — is never clobbered. fire() now
returns whether at least one action dispatched (a webhook/log ran, or a tool
slot was reserved); a missing dispatcher, unknown kind, or saturation-drop
counts as not-dispatched.

Also clamps a negative Rule.Cooldown to 0 at Register (with a warn) so an
operator sign typo can't slip past the `Cooldown > 0` suppression guard and
silently disable rate-limiting.

Found via the internal audit sweep of the automation engines (v0.795.0 fixed the
campaign fail-open from the same sweep; this clears the two banked rules items).

Tests (-race):
- TestEngine_SaturationDropDoesNotConsumeCooldown: saturate the cap, fire a
  tool-only rule, assert Fires==0 and LastFire unset; free the cap and assert
  the same event now fires (proving the cooldown wasn't consumed). Proven to
  fail pre-fix — the drop consumed the cooldown and suppressed the next event.
- TestEngine_NegativeCooldownClampedToZero.

Verified: task ci green (lint 0 / vet / build / test:full -race 0 fail /
govulncheck clean); task eval 17/17.
@xunholy xunholy merged commit 5cd6ba9 into main Jul 1, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant