Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md).

## [Unreleased]

### Fixed
- **Loop/time-budget halts now persist the run status.** A run halted on its loop
or wall-clock budget is enforced in `BeginStep`/`CanProceed`, which returned
before writing through — so `runs list`, `audit`, and `GET /v1/runs/{id}` still
showed it as `running`. The halt (and its reason) is now persisted on that path,
matching the token/dollar halt behavior. ([#34](https://github.com/prashar32/riskkernel/issues/34))

## [0.1.1] - 2026-05-31

A fast follow-up to v0.1.0: makes the Python SDK installable from a build, and
Expand Down
20 changes: 19 additions & 1 deletion internal/runs/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ type View struct {
// start another step.
func (r *Run) BeginStep() (int32, error) {
if err := r.gov.PreStep(); err != nil {
// PreStep flipped the governor to halted (loop or time budget). Persist that
// terminal state — the gateway's 402 path returns here without recording a
// call, so without this the run would still read "running" in
// runs list / audit / GET. (#34)
r.persistHalt()
return 0, err
}
now := time.Now()
Expand All @@ -83,7 +88,20 @@ func (r *Run) BeginStep() (int32, error) {
}

// CanProceed enforces the hard ceiling immediately before a model/tool call.
func (r *Run) CanProceed() error { return r.gov.CanProceed() }
func (r *Run) CanProceed() error {
if err := r.gov.CanProceed(); err != nil {
r.persistHalt() // token/dollar/time ceiling tripped pre-call → persist it (#34)
return err
}
return nil
}

// persistHalt writes the run's now-halted state through to the store. Best-effort
// and idempotent; a no-op for the in-memory-only manager (nil store).
func (r *Run) persistHalt() {
r.touch()
r.mgr.persistRun(r)
}

// RecordCall meters a completed model call in the governor and writes through to
// the cost ledger + step + run rows. Returns a *governor.HaltError if this call
Expand Down
27 changes: 27 additions & 0 deletions internal/runs/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,30 @@ func TestManager_HaltPersistsStatus(t *testing.T) {
t.Fatalf("halted step not persisted: %+v", steps)
}
}

// A loop-budget halt happens in BeginStep/PreStep, which returns before recording
// a call — so the run's persisted status must be updated there too, not only on the
// RecordCall (token/dollar) path. (#34)
func TestManager_LoopHaltPersistsStatus(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "loophalt.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()

m := NewManager(governor.Budget{Loops: 1}).
WithStore(store, slog.New(slog.NewTextHandler(noopWriter{}, nil)))
r := m.Create(CreateOptions{ID: "run-l"})

if _, err := r.BeginStep(); err != nil { // step 1: allowed (loops 0→1)
t.Fatalf("first BeginStep: %v", err)
}
if _, err := r.BeginStep(); err == nil { // step 2: 1+1 > 1 → loop-budget halt
t.Fatal("expected loop-budget halt on second BeginStep")
}

got, _ := store.GetRun(context.Background(), "run-l")
if got.Status != "halted" || got.HaltReason != string(governor.HaltLoopBudget) {
t.Fatalf("loop halt not persisted: status=%q reason=%q", got.Status, got.HaltReason)
}
}
Loading