fix: address verified gadfly P5/#5 findings (contrib/store concurrency)
executus CI / test (pull_request) Successful in 1m34s

All 3 cloud models converged on real concurrency bugs in the SQLite stores:

- AppendVersion (HIGH): the seq key was `SELECT MAX(seq)+1` then INSERT in two
  un-transacted statements with a NON-unique index, AND the Scan error was
  swallowed (seq stayed 0 on failure). Concurrent appends could both land the
  same seq, silently breaking newest-first ordering. Now: one transaction, the
  Scan error is propagated, the (skill_id, seq) index is UNIQUE (the loser of a
  race fails loudly), and an empty SkillID is rejected.
- MarkScheduledRun / MarkAgentScheduledRun (all 3): replaced the Get→mutate→Save
  read-modify-write (lost-update window) with a single atomic UPDATE using
  json_set, so a concurrent Mark/edit can't clobber it. json_set keeps the JSON
  blob's NextRunAt/LastScheduledRunAt consistent with the indexed column;
  RFC3339Nano matches Go's time encoding so the blob still round-trips (tested).
- Open: actually applies PRAGMA busy_timeout=5000 (the doc advertised it but it
  was never set) — a contended writer waits instead of erroring SQLITE_BUSY.
- budgetStore.Add: rejects NaN/Inf secondsUsed (would irrecoverably poison the
  column).

Triaged-but-kept: plaintext webhook secret (documented design, high-entropy URL
key, pre-existing); SQL()/free-form `where` helpers (no untrusted input reaches
them — defense-in-depth notes only).

Core go.sum still free of host/DB deps; contrib/store green (incl. a json_set
blob-round-trip test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 23:37:37 -04:00
parent b194a9621d
commit c8a87f1733
5 changed files with 100 additions and 16 deletions
+6
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"math"
"time"
"gitea.stevedudenhoeffer.com/steve/executus/budget"
@@ -57,6 +58,11 @@ func (s *budgetStore) Get(ctx context.Context, userID string) (*budget.SkillBudg
// Add increments usage atomically, rolling the 7-day window over inside one
// transaction so concurrent Adds can't race the read-modify-write.
func (s *budgetStore) Add(ctx context.Context, userID string, secondsUsed float64, now time.Time) error {
// A NaN/Inf would poison the seconds_used column irrecoverably (NaN
// propagates through every later add), so reject it at the boundary.
if math.IsNaN(secondsUsed) || math.IsInf(secondsUsed, 0) {
return fmt.Errorf("budgetStore.Add: invalid secondsUsed %v", secondsUsed)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("budgetStore.Add: begin: %w", err)