Skip to content

Commit e845fc5

Browse files
committed
feat(aiplugin): add TransitionBudget for runaway prevention
Directly addresses the qmuntal/stateless #77 pattern: workflow execution that needs to halt after N transitions to prevent runaway loops. The author there had to fight the library — statekit users can drop in TransitionBudget instead. Behavior: - AfterTransition counts only state-changing transitions (XState semantics — self-transitions don't tick the counter). - OnEvent rewrites the event type to a configurable halt-event once the budget is exhausted. Payload is preserved so the halt state can read why it fired. - atomic counter — safe under concurrent OnEvent + AfterTransition. Tests cover budget tracking, self-transition skipping, event rewriting under exhaustion, Reset, concurrent safety, plugin-interface satisfaction, and an end-to-end runaway-machine scenario that confirms the budget halts a real loop within the configured budget. 91.8% coverage. Source: ICP signal sweep — verbatim quote from qmuntal/stateless #77.
1 parent 47bee81 commit e845fc5

2 files changed

Lines changed: 250 additions & 0 deletions

File tree

aiplugin/budget.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package aiplugin
2+
3+
import (
4+
"sync/atomic"
5+
6+
"github.com/felixgeelhaar/statekit/plugin"
7+
)
8+
9+
// TransitionBudget caps the number of transitions an interpreter is
10+
// allowed to perform. When the budget is exhausted, subsequent events
11+
// are rewritten to a configurable halt-event type, signalling
12+
// runaway prevention without forcing the machine into a bespoke
13+
// error state.
14+
//
15+
// Why this exists: a recurring pain in incumbent Go FSM libraries
16+
// (e.g. https://github.com/qmuntal/stateless/issues/77) is the
17+
// inability to halt execution after N transitions. Wire a
18+
// TransitionBudget into the plugin slot and your machine gets a
19+
// bounded-blast-radius guarantee out of the box.
20+
//
21+
// Usage:
22+
//
23+
// budget := aiplugin.NewTransitionBudget[Ctx](100, "BUDGET_EXHAUSTED")
24+
// interp.Use(budget)
25+
// // Add a transition for "BUDGET_EXHAUSTED" that routes to a
26+
// // halted/failed state from anywhere it can fire.
27+
//
28+
// TransitionBudget tracks transitions via the AfterTransition hook,
29+
// not via OnEvent — this counts only events that actually changed
30+
// state, matching the qmuntal-issue's "transition count" semantics.
31+
// OnEvent rewrites the event type once the budget is exhausted so
32+
// the machine can route to its halt state on the same Send call.
33+
type TransitionBudget[C any] struct {
34+
limit int64
35+
haltEvent plugin.EventType
36+
transitions atomic.Int64
37+
}
38+
39+
// NewTransitionBudget constructs a TransitionBudget[C] capped at
40+
// `limit` transitions. Once the budget is reached, OnEvent rewrites
41+
// every subsequent event to `haltEvent`.
42+
func NewTransitionBudget[C any](limit int64, haltEvent plugin.EventType) *TransitionBudget[C] {
43+
return &TransitionBudget[C]{
44+
limit: limit,
45+
haltEvent: haltEvent,
46+
}
47+
}
48+
49+
// Name implements plugin.Plugin.
50+
func (*TransitionBudget[C]) Name() string { return "ai-transition-budget" }
51+
52+
// AfterTransition implements plugin.OnTransitionHook. Every observed
53+
// transition increments the counter.
54+
func (b *TransitionBudget[C]) AfterTransition(_ plugin.Context[C], from, to plugin.StateID, _ plugin.Event) {
55+
if from == to {
56+
// Internal/no-op transitions don't count against the budget;
57+
// XState semantics: only state-changing transitions tick.
58+
return
59+
}
60+
b.transitions.Add(1)
61+
}
62+
63+
// BeforeTransition is required to satisfy plugin.OnTransitionHook
64+
// (paired with AfterTransition). No-op.
65+
func (*TransitionBudget[C]) BeforeTransition(_ plugin.Context[C], _, _ plugin.StateID, _ plugin.Event) {
66+
}
67+
68+
// OnEvent implements plugin.OnEventHook. Once the budget is
69+
// exhausted, the event type is rewritten to the halt event.
70+
func (b *TransitionBudget[C]) OnEvent(_ plugin.Context[C], event plugin.Event) plugin.Event {
71+
if b.Exhausted() {
72+
// Preserve payload — caller can read why the halt fired.
73+
return plugin.Event{Type: b.haltEvent, Payload: event.Payload}
74+
}
75+
return event
76+
}
77+
78+
// Used returns the running transition count.
79+
func (b *TransitionBudget[C]) Used() int64 { return b.transitions.Load() }
80+
81+
// Remaining returns how many transitions are still allowed before
82+
// the halt event is forced. A negative value means the budget has
83+
// already been overshot (race conditions on counter increments are
84+
// possible but bounded by the number of in-flight events).
85+
func (b *TransitionBudget[C]) Remaining() int64 {
86+
return b.limit - b.transitions.Load()
87+
}
88+
89+
// Exhausted reports whether the budget has been reached.
90+
func (b *TransitionBudget[C]) Exhausted() bool {
91+
return b.transitions.Load() >= b.limit
92+
}
93+
94+
// Reset zeroes the transition counter. Useful between independent
95+
// runs of the same interpreter, or after a manual recovery flow.
96+
func (b *TransitionBudget[C]) Reset() {
97+
b.transitions.Store(0)
98+
}

aiplugin/budget_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package aiplugin_test
2+
3+
import (
4+
"sync"
5+
"testing"
6+
7+
"github.com/felixgeelhaar/statekit"
8+
"github.com/felixgeelhaar/statekit/aiplugin"
9+
"github.com/felixgeelhaar/statekit/plugin"
10+
)
11+
12+
type budgetCtx struct{}
13+
14+
func TestTransitionBudget_CountsAndExhausts(t *testing.T) {
15+
t.Parallel()
16+
b := aiplugin.NewTransitionBudget[budgetCtx](2, "HALT")
17+
18+
// AfterTransition counts state-changing transitions.
19+
b.AfterTransition(plugin.Context[budgetCtx]{}, "a", "b", plugin.Event{Type: "X"})
20+
if b.Used() != 1 {
21+
t.Errorf("Used = %d, want 1", b.Used())
22+
}
23+
b.AfterTransition(plugin.Context[budgetCtx]{}, "b", "c", plugin.Event{Type: "Y"})
24+
if b.Used() != 2 {
25+
t.Errorf("Used = %d, want 2", b.Used())
26+
}
27+
if !b.Exhausted() {
28+
t.Error("expected Exhausted")
29+
}
30+
if b.Remaining() != 0 {
31+
t.Errorf("Remaining = %d, want 0", b.Remaining())
32+
}
33+
}
34+
35+
func TestTransitionBudget_SkipsSelfTransition(t *testing.T) {
36+
t.Parallel()
37+
b := aiplugin.NewTransitionBudget[budgetCtx](100, "HALT")
38+
39+
// Self-transitions don't count.
40+
b.AfterTransition(plugin.Context[budgetCtx]{}, "x", "x", plugin.Event{Type: "X"})
41+
if b.Used() != 0 {
42+
t.Errorf("self-transition counted: Used = %d", b.Used())
43+
}
44+
}
45+
46+
func TestTransitionBudget_RewritesEventWhenExhausted(t *testing.T) {
47+
t.Parallel()
48+
b := aiplugin.NewTransitionBudget[budgetCtx](1, "HALT")
49+
50+
// Below budget — pass-through.
51+
out := b.OnEvent(plugin.Context[budgetCtx]{}, plugin.Event{Type: "GO", Payload: 42})
52+
if out.Type != "GO" {
53+
t.Errorf("below budget, event rewritten to %q", out.Type)
54+
}
55+
56+
// Tick the budget.
57+
b.AfterTransition(plugin.Context[budgetCtx]{}, "a", "b", plugin.Event{Type: "GO"})
58+
59+
// Above budget — rewritten.
60+
out = b.OnEvent(plugin.Context[budgetCtx]{}, plugin.Event{Type: "STILL_GO", Payload: 99})
61+
if out.Type != "HALT" {
62+
t.Errorf("above budget, event = %q, want HALT", out.Type)
63+
}
64+
if out.Payload != 99 {
65+
t.Errorf("payload not preserved: %v", out.Payload)
66+
}
67+
}
68+
69+
func TestTransitionBudget_Reset(t *testing.T) {
70+
t.Parallel()
71+
b := aiplugin.NewTransitionBudget[budgetCtx](2, "HALT")
72+
b.AfterTransition(plugin.Context[budgetCtx]{}, "a", "b", plugin.Event{})
73+
b.AfterTransition(plugin.Context[budgetCtx]{}, "b", "c", plugin.Event{})
74+
if !b.Exhausted() {
75+
t.Fatal("expected exhausted before Reset")
76+
}
77+
b.Reset()
78+
if b.Exhausted() {
79+
t.Error("expected not exhausted after Reset")
80+
}
81+
if b.Used() != 0 {
82+
t.Errorf("Used after Reset = %d, want 0", b.Used())
83+
}
84+
}
85+
86+
func TestTransitionBudget_Concurrent(t *testing.T) {
87+
t.Parallel()
88+
b := aiplugin.NewTransitionBudget[budgetCtx](100_000, "HALT")
89+
var wg sync.WaitGroup
90+
const goroutines = 16
91+
const perG = 1000
92+
wg.Add(goroutines)
93+
for i := 0; i < goroutines; i++ {
94+
go func() {
95+
defer wg.Done()
96+
for j := 0; j < perG; j++ {
97+
b.AfterTransition(plugin.Context[budgetCtx]{}, "a", "b", plugin.Event{})
98+
}
99+
}()
100+
}
101+
wg.Wait()
102+
if got := b.Used(); got != goroutines*perG {
103+
t.Errorf("Used = %d, want %d", got, goroutines*perG)
104+
}
105+
}
106+
107+
// TestTransitionBudget_PluginInterface verifies the type satisfies
108+
// the relevant plugin hook interfaces at compile time.
109+
func TestTransitionBudget_PluginInterface(t *testing.T) {
110+
t.Parallel()
111+
var _ plugin.OnTransitionHook[budgetCtx] = aiplugin.NewTransitionBudget[budgetCtx](1, "X")
112+
var _ plugin.OnEventHook[budgetCtx] = aiplugin.NewTransitionBudget[budgetCtx](1, "X")
113+
if aiplugin.NewTransitionBudget[budgetCtx](1, "X").Name() != "ai-transition-budget" {
114+
t.Error("name mismatch")
115+
}
116+
}
117+
118+
// TestTransitionBudget_EndToEnd_HaltsRunawayMachine drops a budget
119+
// into a real interpreter and confirms it halts a runaway loop —
120+
// the pattern qmuntal/stateless #77 author wanted.
121+
func TestTransitionBudget_EndToEnd_HaltsRunawayMachine(t *testing.T) {
122+
t.Parallel()
123+
124+
machine, err := statekit.NewMachine[budgetCtx]("loop").
125+
WithInitial("a").
126+
State("a").On("TICK").Target("b").On("HALT").Target("done").Done().
127+
State("b").On("TICK").Target("a").On("HALT").Target("done").Done().
128+
State("done").Final().Done().
129+
Build()
130+
if err != nil {
131+
t.Fatalf("build: %v", err)
132+
}
133+
134+
interp := statekit.NewInterpreter(machine)
135+
defer func() { _ = interp.Close() }()
136+
137+
budget := aiplugin.NewTransitionBudget[budgetCtx](3, "HALT")
138+
interp.Use(budget)
139+
interp.Start()
140+
141+
// Send 10 TICKs — should halt by the 4th transition.
142+
for i := 0; i < 10; i++ {
143+
interp.Send(statekit.Event{Type: "TICK"})
144+
}
145+
146+
if !interp.Done() {
147+
t.Errorf("expected interpreter Done (in halt final state); state = %q", interp.State().Value)
148+
}
149+
if got := budget.Used(); got > 4 {
150+
t.Errorf("budget overshot: Used = %d, expected <= 4", got)
151+
}
152+
}

0 commit comments

Comments
 (0)