Skip to content

Enforce per-project cost budgets with a hard-stop guardrail - #165

Closed
lucastononro wants to merge 98 commits into
mainfrom
fix/107-cost-budget
Closed

Enforce per-project cost budgets with a hard-stop guardrail#165
lucastononro wants to merge 98 commits into
mainfrom
fix/107-cost-budget

Conversation

@lucastononro

@lucastononro lucastononro commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

Cost was fully observable after the fact (services/usage.py, live CostBadge) but nothing capped it — an orchestrator fanning out to specialists could silently rack up spend. This adds a per-project USD budget that the runner checks against accumulated UsageEvent rows and hard-stops the agent when exceeded, landing the session in a clean budget_exceeded terminal state (not failed) with a clear message telling the user how to raise or clear the cap.

The budget is project-scoped on purpose: sub-agents spawned via delegate-task share the session/project, so the whole fan-out is capped as one unit (spend includes both LLM tokens and sandbox compute).

Changes

Backend

  • models.py / db.py: new nullable projects.budget_usd column (NULL = uncapped) + idempotent dev-DB migration; surfaced in Project.to_dict().
  • schemas.py / routers/projects.py: budget_usd on ProjectCreate/ProjectUpdate (0 ≤ x ≤ 1M). PATCH distinguishes "omitted" from "explicit null clears the cap" via model_fields_set.
  • services/budget.py (new): BudgetStatus (budget vs. project-wide spend summed over usage_events.cost_usd), get_budget_status() (by project or session), and check_budget() which raises BudgetExceededError.
  • services/agent/runner.py:
    • Pre-run check — a project already over its cap never starts an agent (no provider call at all).
    • Per-usage-event check — after every recorded LLM usage event, the runner re-checks; crossing the cap mid-run unwinds the provider loop immediately (both the Claude/MCP path and the runner-managed tool loop go through the same _record_usage).
    • run_agent catches BudgetExceededError and publishes a budget_exceeded event (message + spent/cap amounts) plus a state_change to the clean budget_exceeded terminal state.
  • routers/usage.py: /sessions/{id}/usage and /projects/{id}/usage now return a budget block (budget_usd, spent_usd, remaining_usd, exceeded) for the UI.

Frontend (minimal)

  • Project Settings modal: new "Budget — spend cap (USD)" field (empty = no limit); saved through the existing PATCH /projects/{id} path in Sidebar.
  • CostBadge: shows project spend vs. cap with a progress bar; flips to a red "over budget" state when exceeded.
  • page.tsx (kept tiny — 5 small edits): budgetInfo state hydrated from session usage, a budget_exceeded SSE case (error message + stop running indicator), budget_exceeded added to the terminal-state list, prop pass-through to CostBadge. ⚠️ Overlaps with Break up the 4155-line page.tsx monolith #97 (page.tsx split) — these edits are small and localized (SSE switch + usage hydration block) but will need a trivial port if Break up the 4155-line page.tsx monolith #97 lands first.

Testsbackend/tests/test_budget.py (8 new):

  • Budget math (exceeded / remaining / uncapped / under-budget / orphan session).
  • API: PATCH set → untouched-by-other-PATCH → explicit-null clear → negative rejected; usage endpoints expose the budget block.
  • Hard-stop: a session whose project has fake UsageEvents over the cap never drives the provider and persists budget_exceeded (and is not marked failed); a run that crosses the cap mid-flight is halted at the next usage event (events after the tripping usage event are never consumed).

Test plan

  • Existing behavior: no budget set (budget_usd NULL) → check_budget is a no-op, runs behave exactly as today, cost still displays in CostBadge. Full backend suite: 304 passed, 8 skipped (was 296 before; +8 new). ruff check + ruff format --check clean.
  • New behavior: set a low budget in Project Settings → agent halts with "Budget limit reached: this project has spent $X of its $Y cap…" as soon as spend crosses it (or refuses to start if already over); badge shows the red "over budget" state. Covered by the two runner hard-stop tests; frontend npm ci + next lint + tsc --noEmit + prettier --check + next build all green.

Follow-ups (out of scope)

  • Token-count ceiling in addition to USD (the check point in services/budget.py makes this a small extension).
  • Per-session budgets layered under the project cap.
  • Pause-and-ask via the clarifications mechanism (resumable prompt instead of terminal state) — terminal state chosen here because a paused agent still holds a provider subprocess open indefinitely.
  • Sandbox usage events trigger the halt only at the next LLM call (the check hooks LLM usage recording); a very long single sandbox run isn't interrupted mid-execution.

Closes #107

🤖 Generated with Claude Code

https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4

Greptile Summary

This PR introduces a per-project USD budget guardrail that hard-stops agents when accumulated spend (usage_events.cost_usd, LLM + sandbox compute) crosses a configurable cap. The backend adds a nullable budget_usd column on projects, a new services/budget.py module with BudgetStatus / check_budget, and hook points in the agent runner (pre-run and post-usage-event). The frontend adds a spend-cap field in Project Settings and updates CostBadge with a progress bar and an "over budget" state.

  • services/budget.py resolves a session → project via the legacy experiment back-pointer and sums all UsageEvent.cost_usd rows; check_budget raises BudgetExceededError when spend meets or exceeds the cap.
  • Runner integration: _check_budget_failopen (new wrapper) is called twice — once before the provider loop starts and once after every recorded usage event. BudgetExceededError is caught in run_agent and lands the session in a clean budget_exceeded terminal state with a descriptive user-facing message.
  • Eight new tests cover budget math, API surface (PATCH set/clear/negative rejected, usage endpoint budget block), and two runner hard-stop scenarios (before-start and mid-run).

Confidence Score: 4/5

Safe to merge with one fix: a $0 budget silently blocks all agents immediately, before any spend occurs.

The only new defect introduced is the >= comparison in BudgetStatus.exceeded combined with the schema allowing budget_usd=0. A user who enters 0 in the settings field gets all agents hard-stopped with a "spent $0.00 of $0.00" message before any work happens. Everything else — the fail-open wrapper, the pre-run and mid-run check hooks, the clean terminal state, the frontend SSE handling — is implemented correctly and covered by tests.

backend/services/budget.py (the exceeded property) and backend/schemas.py (the ge=0.0 constraint) should be reviewed together for the $0-budget edge case.

Important Files Changed

Filename Overview
backend/services/budget.py New budget service: two separate SELECT queries, no explicit DB transaction (already flagged in previous thread); exceeded uses >= which means a budget_usd=0 project is immediately exceeded before any spend.
backend/services/agent/runner.py Budget check correctly integrated via _check_budget_failopen wrapper; pre-run and per-usage-event hooks both propagate only BudgetExceededError, swallowing other exceptions (fail-open). BudgetExceededError handled cleanly before the generic Exception catch.
backend/schemas.py Schema allows budget_usd=0.0 via ge=0.0; combined with the >= comparison in BudgetStatus.exceeded, a $0 budget is immediately exceeded even with $0 spend.
backend/tests/test_budget.py Eight tests cover math, API surface, and runner hard-stop (pre-start and mid-run). Fail-open behavior is also verified with a dedicated test.
frontend/src/components/ProjectSettingsModal.tsx Budget field added; empty field = null (no cap), numeric value parsed via parseFloat. Negative input silently sends null (already flagged in previous thread).

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as Frontend
    participant API as projects / usage routers
    participant Runner as agent runner
    participant Budget as services/budget.py
    participant DB as SQLite (usage_events, projects)

    UI->>API: "PATCH /projects/{id} {budget_usd: 5.0}"
    API->>DB: "UPDATE projects SET budget_usd=5.0"
    API-->>UI: "200 {budget_usd: 5.0}"

    UI->>Runner: run_agent(session_id)
    Runner->>Budget: _check_budget_failopen(session_id) [pre-run]
    Budget->>DB: "SELECT budget_usd FROM projects WHERE id=pid"
    Budget->>DB: "SELECT SUM(cost_usd) FROM usage_events WHERE project_id=pid"
    DB-->>Budget: "budget=5.0, spent=2.0"
    Budget-->>Runner: OK (not exceeded)

    Runner->>Runner: _drive_provider() — LLM call
    Runner->>DB: record_llm_usage (new UsageEvent)
    Runner->>Budget: _check_budget_failopen(session_id) [post-usage]
    Budget->>DB: SELECT budget_usd / SUM(cost_usd)
    DB-->>Budget: "budget=5.0, spent=6.1"
    Budget-->>Runner: raises BudgetExceededError

    Runner->>UI: "SSE budget_exceeded {error, spent_usd, budget_usd}"
    Runner->>UI: "SSE state_change {state: budget_exceeded}"

    UI->>API: "GET /sessions/{id}/usage"
    API->>Budget: get_budget_status(session_id)
    Budget->>DB: SELECT budget_usd / SUM(cost_usd)
    API-->>UI: "{summary, budget: {exceeded: true, spent_usd: 6.1, budget_usd: 5.0}}"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as Frontend
    participant API as projects / usage routers
    participant Runner as agent runner
    participant Budget as services/budget.py
    participant DB as SQLite (usage_events, projects)

    UI->>API: "PATCH /projects/{id} {budget_usd: 5.0}"
    API->>DB: "UPDATE projects SET budget_usd=5.0"
    API-->>UI: "200 {budget_usd: 5.0}"

    UI->>Runner: run_agent(session_id)
    Runner->>Budget: _check_budget_failopen(session_id) [pre-run]
    Budget->>DB: "SELECT budget_usd FROM projects WHERE id=pid"
    Budget->>DB: "SELECT SUM(cost_usd) FROM usage_events WHERE project_id=pid"
    DB-->>Budget: "budget=5.0, spent=2.0"
    Budget-->>Runner: OK (not exceeded)

    Runner->>Runner: _drive_provider() — LLM call
    Runner->>DB: record_llm_usage (new UsageEvent)
    Runner->>Budget: _check_budget_failopen(session_id) [post-usage]
    Budget->>DB: SELECT budget_usd / SUM(cost_usd)
    DB-->>Budget: "budget=5.0, spent=6.1"
    Budget-->>Runner: raises BudgetExceededError

    Runner->>UI: "SSE budget_exceeded {error, spent_usd, budget_usd}"
    Runner->>UI: "SSE state_change {state: budget_exceeded}"

    UI->>API: "GET /sessions/{id}/usage"
    API->>Budget: get_budget_status(session_id)
    Budget->>DB: SELECT budget_usd / SUM(cost_usd)
    API-->>UI: "{summary, budget: {exceeded: true, spent_usd: 6.1, budget_usd: 5.0}}"
Loading

Comments Outside Diff (1)

  1. frontend/src/components/ProjectSettingsModal.tsx, line 881-888 (link)

    P2 Negative budget input silently clears the cap

    Number.isFinite(parsed) && parsed >= 0 correctly rejects negative numbers, but the fallback is null (no cap), not an error. A user who accidentally types -5 would see no validation message — the cap would simply be cleared. The backend validator (ge=0.0) will also reject negatives, but only via a 422 response that is swallowed by the catch block in handleSaveSandboxConfig. Consider either showing an inline validation message when the parsed value is negative, or clamping with Math.max(0, parsed) and a visible hint.

    Fix in Claude Code

Reviews (2): Last reviewed commit: "fix(review): address Greptile findings o..." | Re-trigger Greptile

Dodothereal and others added 26 commits July 18, 2026 01:15
The cli/README.md was stale against v0.0.4: it described a single-provider
(Anthropic + Modal) setup, claimed the wizard downloaded the compose file
from raw.githubusercontent.com, and omitted `trainable reconfigure`. Update
it to match the current implementation:

- multi-provider selection (Claude API key / subscription OAuth, OpenAI,
  Gemini, LiteLLM-routed backends) as a flat picker, with a Providers table
- compose file bundled inside the wheel (offline installs)
- version-pinned images on `trainable up`
- `trainable reconfigure` command and the existing-config merge flow

Assisted-by: Claude Code
…t:8000 (#96)

Replace getSSEBase()/getBackendUrl() (which returned http://host:8000)
with relative /api paths at all EventSource and raw file/image/PDF fetch
sites, routing through the existing Next rewrite proxy so HTTPS deploys
no longer hit mixed-content blocking or an unexposed port. Both helpers
are deleted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
…tores (#90)

docker-compose.prod.yml shipped hardcoded default credentials
(trainable/trainable, minioadmin/minioadmin) and published Postgres
(5432), MinIO (9000/9001) and the auth-less Jaeger UI (16686) on
0.0.0.0 — direct data-store compromise with well-known creds on any
networked host, and Jaeger traces leak prompts/paths/token counts.

- Replace every hardcoded secret with required env-var interpolation
  that fails loudly if unset: POSTGRES_USER/PASSWORD/DB and
  MINIO_ROOT_USER/PASSWORD via `${VAR:?set VAR in .env}`. The backend's
  DATABASE_URL and AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY are now
  derived from those same vars, so app creds always match the
  datastores (var names match what backend/config.py reads).
- Bind Postgres, MinIO API+console and the Jaeger UI to 127.0.0.1 so
  they are reachable only from the host / internal compose network,
  never from another machine. Drop the Jaeger OTLP host publishes
  (4317/4318) — the backend reaches them by service name internally.
  frontend (3000) and backend (8000) stay published as before.
- Document the newly-required vars in .env.example with placeholders
  and a note that prod has no defaults.

Validated: `docker compose -f docker-compose.prod.yml config` parses
and interpolates with a filled .env, and fails fast with a clear
message when the secrets are unset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Add an opt-in shared-secret gate for the API. When API_AUTH_TOKEN is
unset (the default) nothing changes — every endpoint stays open, so
local dev and the frontend are unaffected. When set, all /api/*
requests must send `Authorization: Bearer <token>`.

- backend/auth.py: pure-ASGI BearerTokenAuthMiddleware (no
  BaseHTTPMiddleware wrapping, so SSE streaming is untouched);
  constant-time token comparison; 401 + WWW-Authenticate on failure.
- Exempt: /api/health, /api/readyz, CORS preflight (OPTIONS).
- SSE: EventSource cannot send headers, so the stream endpoint
  (/api/sessions/{id}/stream) additionally accepts ?token=<token>;
  the query param is stream-only and does not unlock other routes.
- config.py: api_auth_token setting (env API_AUTH_TOKEN, default None).
- Middleware added before CORSMiddleware so CORS stays outermost and
  answers preflights before auth runs.
- .env.example: new "Optional — security" section documenting it.
- tests/test_api_auth.py: middleware unit tests (open-by-default,
  401/200 matrix, exemptions, stream query token, preflight).

Closes #88

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
…edentials

Replace the `cors_origins=["*"]` + `allow_credentials=True` default
(any web page could script credentialed cross-origin requests against
localhost:8000) with a safe, configurable policy:

- config.py: default origins are the local frontend
  (http://localhost:3000, http://127.0.0.1:3000); overridable via
  CORS_ORIGINS as a comma-separated env var (NoDecode + validator so
  plain CSV works, not just JSON).
- main.py: if `*` is configured it is honored but credentials are
  disabled (allow_credentials=False) with a startup warning — the
  wildcard+credentials combination is now impossible.
- .env.example: document CORS_ORIGINS.

No impact on the standard setup: the frontend proxies /api through
Next.js rewrites (same-origin), so CORS is not involved there.

Closes #89

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
- Stream uploads to S3 in bounded 8 MB chunks (single put_object for small
  bodies, multipart upload for larger ones) instead of reading the whole
  body into RAM; abort the multipart upload on any failure.
- Enforce the configured max_upload_size_bytes cap incrementally while
  reading (413 without buffering the oversize body).
- Validate bucket against the app-provisioned allowlist (new
  settings.s3_allowed_buckets, also used by startup bucket init) and
  reject traversal/absolute/backslash keys; write endpoints (upload +
  put-presign) are scoped to the datasets/projects/ prefix.
- Authentication is intentionally NOT added here — it lands globally via
  issue #88.

Closes #91

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
A stalled provider HTTP call had no wall-clock limit: the runner's outer
asyncio.timeout was deliberately removed (the Modal sandbox timeout only
bounds tool execution), and every provider accepted timeout_seconds but
discarded it. A hung network call left the session's background task
alive forever — spinner never cleared, task-registry entry never freed.

Thread the existing timeout_seconds into each provider around the HTTP
call only, so tool-execution time is never counted:

- base.py: new enforce_wall_clock() helper — asyncio.wait_for around a
  single provider call, raising builtin TimeoutError on expiry.
- openai: per-request SDK timeout via client.with_options(timeout=...)
  plus the hard wall-clock cap (SDK retries can't stretch past it).
- gemini: hard cap around generate_content (google-genai has no default
  request timeout at all).
- litellm: keep the existing per-attempt timeout= knob, add the hard cap
  so backend retries/fallbacks can't exceed the budget.
- claude: query() runs the whole tool loop internally, so it must not be
  wrapped wholesale; bound each underlying API HTTP request via Claude
  Code's API_TIMEOUT_MS env instead (caller-supplied value wins).

On expiry the providers let TimeoutError propagate: run_agent's existing
TimeoutError handler publishes agent_timeout, moves the session to
timed_out, and the finally block frees the task registry — the run ends
cleanly instead of hanging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Every boto3 call ran synchronously inside async def handlers/services,
stalling the event loop for each S3 round-trip. Wrap them all in
asyncio.to_thread, behavior-identical:

- services/s3_sync.py: put_object in the per-file sync loop
- routers/s3_browser.py: list_buckets, list_objects_v2, presign
  generation, and the whole upload path (put_object + multipart
  create/upload_part/complete/abort)
- routers/experiments.py: put_object in create_experiment/attach_data;
  the four get_object(...).read() sites now stream to a temp file in
  bounded 1 MB chunks inside a worker thread (_download_to_tempfile)
  instead of reading whole bodies into memory on the loop

Test mock: conftest get_object now returns a fresh BytesIO body per call
so chunked reads hit EOF like a real StreamingBody.

Closes #92

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
/api/health stays a cheap static liveness check. New /api/readyz pings
the DB (SELECT 1 on the async engine) and S3 (list_buckets via
asyncio.to_thread so sync boto3 doesn't block the event loop) and
returns 503 with per-check detail when either is down, so an
orchestrator no longer routes traffic to a backend whose Postgres pool
or object store is dead.

Compose wiring (both docker-compose.yml and docker-compose.prod.yml):
- backend healthcheck: curl -fsS /api/health (curl is already in the
  backend image), 5s interval, 15s start_period
- frontend depends_on backend with condition: service_healthy, so the
  frontend waits for a responsive backend instead of racing it

tests/test_readyz.py covers ready, S3-down, DB-down, and health-stays-
static; verified live in-process (real app, TestClient): 200 when both
up, 503 EndpointConnectionError when S3 unreachable, 503 when DB
connect fails. Both compose files pass `docker compose config -q`.

Closes #119

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
create_experiment accumulated every file fully in RAM (content += chunk)
and then retained all bytes again in the staged tuples, so a folder
upload of many large files (500 MB per-file cap) could OOM the worker.

- Stream each multipart body straight to its temp file in bounded 1 MB
  chunks; the size cap is enforced on a running counter and the content
  hash for dataset versioning is computed incrementally (sha256) while
  streaming — the full bytes are never materialized.
- Staged tuples are now (tmp_path, remote_path) only; the temp path is
  registered before streaming so the finally block also cleans up
  partially-written files on mid-stream failure.
- The S3 push now uses s3.upload_file from the temp file (streams from
  disk, off-loop) instead of put_object(Body=<whole bytes>).
- record_upload accepts precomputed content_hash/size_bytes so streaming
  callers don't need the full bytes; content-based callers unchanged.

Closes #94

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Adds pytest-cov to backend/requirements.txt and runs the suite with
--cov=. --cov-report=xml --cov-report=term-missing, uploading the
coverage.xml as a build artifact. Sets --cov-fail-under=60 as a floor
(measured baseline is ~66%), catching future regressions in untested
modules while leaving headroom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
)

generic_exception_handler only logged unhandled request exceptions and
never reported them to Sentry. Background agent work spawned via
asyncio.create_task in routers/sessions.py (send_message's
_run_followup) runs outside the request lifecycle, so FastAPI's
exception handler never saw those errors either — they were silently
swallowed into a "failed" session state with no logging or tracking.

Add errors.capture_exception(), a thin no-op-safe wrapper around
sentry_sdk.capture_exception (no-ops with no DSN and never lets a
Sentry-side failure mask the original error), call it from the generic
handler, and call it (plus add the missing logger.exception) from the
background-task except block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
…grations) (#121)

Scaffolds backend/alembic/ wired to the app's async engine (settings.database_url)
and Base.metadata, with a single autogenerated initial revision reflecting today's
full schema. init_db() now runs `alembic upgrade head` on boot (in a worker thread,
since env.py drives its own asyncio.run()); a DB that already has the full schema
but no alembic_version table gets `stamp head` instead, so existing deployments
don't get destructive re-creation attempts. _run_migrations is kept in place but
unused, documented as dead code for rollback reference.

Verified schema-equivalence of `alembic upgrade head` (fresh DB) vs
`create_all + _run_migrations` (legacy path) on both SQLite and Postgres: only
difference found is two redundant duplicate indexes the legacy code created
under typo'd names, no logical schema divergence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
backend-test now spins up a postgres:16-alpine service container and
points TEST_DATABASE_URL at it; conftest.py respects TEST_DATABASE_URL
(falling back to DATABASE_URL, then the sqlite default for local dev)
instead of unconditionally forcing sqlite+aiosqlite. This exercises the
Column(JSON) fields, FK ON DELETE CASCADE behavior, and the hand-rolled
db._run_migrations against the engine we actually ship.

Also pins asyncio_default_fixture_loop_scope/asyncio_default_test_loop_scope
to "session" in a new backend/pytest.ini. Without it, pytest-asyncio 1.x's
per-test event loop (fine for aiosqlite) breaks the module-level asyncpg
engine with "Future attached to a different loop" once a second test needs
the DB. Pinning both to session fixes this for both SQLite and Postgres.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Adds focused unit tests for the two most critical, near-untested modules
in the agent runtime, with the Modal boundary faked throughout:

- test_sandbox.py: run_code's success/timeout/exception paths against a
  faked modal.Sandbox (create/stdout/stderr/wait) — asserts span
  attributes, metric/log-event dispatch routing, and usage recording per
  path.
- test_runner.py: run_agent's reachable orchestration state machine
  (spawn -> run -> complete/error/cancel) and its interplay with the real
  task registry (register_task/abort_agent/is_agent_running/
  cleanup_session), using real asyncio Tasks for the registry tests so
  cancellation semantics are genuine. _drive_provider is faked — its
  internals are already covered by test_drive_provider.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
CI's only security step was bandit (Python SAST) — no dependency
scanning and no image scanning, despite requirements.txt already
carrying a manual note pinning litellm to dodge a CVE that a scanner
would enforce automatically.

Adds three new jobs, all advisory (continue-on-error: true on the
scan step) so pre-existing advisories don't break CI:
  - backend-vuln-scan: pip-audit against backend/requirements.txt
  - frontend-vuln-scan: npm audit against the frontend lockfile
  - image-scan: builds trainable-backend/trainable-frontend locally
    (not pushed) and scans them with Trivy (CRITICAL/HIGH)

Each can be flipped to blocking later by dropping continue-on-error
once its current advisories are triaged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
The backend /compare endpoint already aggregates metrics, feature
overlap, and cost totals across up to 8 sessions, but nothing in the
frontend consumed it. This adds the leaderboard UI:

- api.ts: typed api.compare(sessionIds) client fn; types.ts: Compare*
  types mirroring routers/compare.py (incl. the feature_overlap
  list-when-empty quirk)
- /compare page: sortable leaderboard table (per-metric latest values
  with best-value highlight, total cost, created-at), overlaid
  per-metric charts reusing the MetricsTab chart stack (palette,
  tooltip, formatters now exported), session legend toggles, and a
  feature-overlap panel (common + per-session extras)
- experiments page: checkbox column + "Compare selected (n/8)" header
  action that navigates to /compare with the selected session ids
  (project-scoped when the selection stays within one project)

Closes #105

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
#112)

Snapshots were a passive record (dataset/code hashes + manifest). This adds
an active reproduce path:

- services/reproduce.py: load the snapshot manifest, verify current
  workspace files against the captured hashes (input drift), replay the
  captured .py scripts in a Modal sandbox (stage=None so the replay never
  writes into the original run's metric history), parse the metrics the
  replay emits, and diff final values vs the recorded run with a
  configurable tolerance — flagging drift/missing/new metrics. Report is
  also written to the volume as reproduce_report.json (best-effort).
- POST /api/sessions/{id}/snapshot/reproduce with Pydantic request/response
  schemas (404 no snapshot, 409 manifest unreadable / no scripts).
- Frontend: SnapshotReproduce component (button + status pill + metric diff
  table + input-drift warning) mounted in the experiment detail snapshot
  section; api.reproduceSnapshot + ReproduceReport types.
- Tests: pure diff/verify/select logic + route-level tests with the sandbox
  call faked (drift, match, failed replay, 404).

Closes #112

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Adds a "Test" panel to live model cards on /models so the
train -> deploy -> validate loop closes without leaving the app:

- Backend proxy POST /api/models/{id}/predict forwards the batch to the
  live Modal endpoint with the stored X-API-Key, so the browser never
  holds the key and never fights Modal CORS. Upstream 4xx (e.g. key
  drift 401) passes through; network errors and upstream 5xx surface as
  502. Batches capped at 200 records.
- GET /api/models/{id}/predict-schema resolves the trained
  feature_columns/target_column from the training dataset's metadata
  (same lookup generate_serving_app uses, now extracted into
  _resolve_training_metadata).
- Frontend Test panel renders a per-feature input form from the schema,
  or accepts a small CSV upload (client-side parser, header row +
  quoted-field support) when metadata is missing or batch testing is
  wanted; predictions render inline in a table.
- 10 new backend tests covering proxy success/auth-forwarding, 404/409/
  400 paths, upstream 401/500 mapping, schema resolution, and a
  regression guard that generate_serving_app still embeds the resolved
  feature columns.

Closes #109

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
…, trial budget, caps

Give users say over the trainer agent before it runs (closes #104).

Backend:
- New TrainingConfig schema (optimization_metric, model_families,
  max_trials, max_wallclock_minutes, max_cost_usd) stored as
  project.training_config (JSON column + startup migration), editable
  via POST/PATCH /api/projects.
- Agent runner injects a "## User training constraints" prompt block
  into any agent that can call start-training (orchestrator, trainer,
  chat), and clamps the training sandbox profile's per-call timeout to
  the wall-clock cap before sandbox_config flows into execute-code.
- start-training skill now accepts optional optimization_metric and
  max_trials, and enforces the project's constraints at the skill
  boundary: disallowed framework, conflicting metric, or over-budget
  trial plan are rejected before the training window opens. Compliant
  calls echo the active constraints back to the agent.
- trainer.yaml: user constraints override the default 30-50-trial
  strategy.

Frontend:
- ProjectSettingsModal grows a "Training Controls" section (metric
  input with suggestions, model-family chips, trial/wall-clock/cost
  caps); Sidebar saves sandbox_config + training_config together.
  No page.tsx changes.

Projects with no training_config behave exactly as before (empty
config renders no prompt block and skips all skill-boundary checks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
…rail

Cost was fully observable (usage_events, CostBadge) but nothing capped it.
This adds a per-project USD budget that hard-stops agents when exceeded:

- projects.budget_usd column (nullable = uncapped) + dev-DB migration,
  exposed via ProjectCreate/ProjectUpdate (explicit null clears the cap)
- services/budget.py: BudgetStatus (project-wide spend summed over
  usage_events) + check_budget() raising BudgetExceededError
- runner: pre-run check (an over-budget project never starts an agent)
  and a check after every recorded usage event (a running agent halts at
  its next LLM call once the cap is crossed); run_agent lands the session
  in a clean `budget_exceeded` terminal state (not `failed`) with a
  budget_exceeded event carrying spent/cap amounts
- usage endpoints (/sessions/{id}/usage, /projects/{id}/usage) now return
  a `budget` block for the UI
- frontend: budget field in Project Settings, CostBadge shows spend vs.
  cap with an "over budget" state, chat surfaces the halt message and
  stops the running indicator

Closes #107

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Comment thread backend/services/agent/runner.py Outdated
Comment on lines +104 to +119
async with async_session() as db:
pid = project_id
if pid is None and session_id:
pid = await _project_id_for_session(db, session_id)
if not pid:
return None

row = await db.execute(select(Project.budget_usd).where(Project.id == pid))
budget = row.scalar_one_or_none()

row = await db.execute(
select(func.coalesce(func.sum(UsageEvent.cost_usd), 0.0)).where(
UsageEvent.project_id == pid
)
)
spent = float(row.scalar_one() or 0.0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Two separate queries, no atomicity guarantee

get_budget_status runs two independent SELECTs — one for projects.budget_usd and one for SUM(usage_events.cost_usd) — within the same async with async_session() block but not inside a single database transaction. A concurrent agent that records a usage event between these two queries could make the spend sum appear slightly lower than reality, potentially allowing one more LLM round before the cap fires.

Suggested change
async with async_session() as db:
pid = project_id
if pid is None and session_id:
pid = await _project_id_for_session(db, session_id)
if not pid:
return None
row = await db.execute(select(Project.budget_usd).where(Project.id == pid))
budget = row.scalar_one_or_none()
row = await db.execute(
select(func.coalesce(func.sum(UsageEvent.cost_usd), 0.0)).where(
UsageEvent.project_id == pid
)
)
spent = float(row.scalar_one() or 0.0)
async with async_session() as db:
pid = project_id
if pid is None and session_id:
pid = await _project_id_for_session(db, session_id)
if not pid:
return None
# Read both values inside an explicit transaction to minimise the
# window where a concurrent usage event could be recorded between
# the two queries and make the spend appear lower than reality.
await db.execute(text("BEGIN"))
row = await db.execute(select(Project.budget_usd).where(Project.id == pid))
budget = row.scalar_one_or_none()
row = await db.execute(
select(func.coalesce(func.sum(UsageEvent.cost_usd), 0.0)).where(
UsageEvent.project_id == pid
)
)
spent = float(row.scalar_one() or 0.0)

Fix in Claude Code

lucastononro and others added 3 commits July 19, 2026 08:30
After uploading a CSV/Parquet the user talked to the agent blind — the
only structured preview (/sessions/{id}/prep/preview) required processed
data that doesn't exist until after prep. Practitioners want to eyeball
the raw data before spending tokens.

Backend: GET /api/projects/{project_id}/datasets/preview runs a fast
DuckDB scan (read_csv_auto / read_parquet + SUMMARIZE) on the raw file
in the volume, returning head rows, dtypes, row/col counts, and
per-column missing %/cardinality. The blocking scan is offloaded via
asyncio.to_thread (issue #93 convention), external access is disabled
before any query runs, and the path is confined to the project's
datasets root.

Frontend: CSV/TSV/Parquet rows in ProjectDataModal are now clickable
and open a DatasetPreviewPanel (head-rows table + per-column profile)
with a back button; files that never reached the sandbox stay
non-clickable since there's nothing on the volume to scan.

Closes #103

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
New users landed on an empty experiments gallery with no obvious first
move, even though the repo ships seven demo datasets in sample-data/.

Backend: samples.yml is the one-source-of-truth catalog (tile copy,
files, suggested prompt per sample). GET /api/samples lists the tiles;
POST /api/projects/from-sample creates a project + experiment + session
and copies the sample files through the normal dataset-upload path
(S3 + Modal Volume batch + dataset versioning), so the result is
indistinguishable from a hand-uploaded project. Sample data location is
probed (repo checkout / /app/sample-data mount) and overridable via
SAMPLE_DATA_DIR; deployments without it degrade gracefully (tiles
report available=false, create returns 503). Dev compose mounts
./sample-data read-only since it sits outside the backend build context.

Frontend: the empty gallery now renders one-click "Try a sample
dataset" tiles (task badge, description, size) plus a three-step inline
tour of the chat/canvas split. Clicking a tile creates the pre-loaded
project, activates it, and drops the user into the studio with the
sample's suggested prompt pre-filled in the chat input (sessionStorage
hand-off keyed to the new session, consumed once).

Closes #110

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
lucastononro and others added 24 commits July 29, 2026 13:47
Resolve s3_browser.py conflict keeping both intents: #141's bounded
chunked upload, bucket allowlist, key-traversal validation and typed
UploadResponse, plus #143's asyncio.to_thread offloading and shielded
multipart-abort-on-cancellation.

Also restore the loop's original default executor in the
cancellation-race regression test (Greptile P1 on #143): the test
replaced the session-scoped loop's default executor with a single-worker
pool and shut it down without restoring it, breaking any later
asyncio.to_thread on the same loop.
The files branch of attach_data still buffered each upload body in
memory (content = await f.read()); apply the same bounded 1 MB chunked
temp-file streaming as create_experiment (issue #94), with the S3 put
switched to upload_file from the temp path.
 #116)

Stack wave 5, PR 1/4. Greptile findings already addressed on the PR
branch by author fixup cd58511 (.coveragerc omits tests/, gate 50).
Stack wave 5, PR 2/4. Greptile P1 (ambient DATABASE_URL drop risk)
already fixed on the PR branch by author fixup de197b8.
…ity scanning (closes #118)

Stack wave 5, PR 3/4. Greptile findings already handled on the PR
branch by author fixup 4a0ca9b (pip-audit on py3.13; trivy-action
pinned by SHA; SARIF upload dismissed as out of advisory scope).
- render_as_batch=True in both offline and online alembic contexts so
  future ALTER-style migrations work on SQLite (Greptile 4/5 note);
  no-op on Postgres.
- ruff format (0.15.22) the generated initial-schema revision file,
  unblocking Backend Lint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
)

Stack wave 5, PR 4/4. Greptile P2 stamp-vs-upgrade atomicity addressed
on the PR branch by author fixup 7dfaf43 (documented single-instance
assumption + regression tests + fileConfig logger fix). Outstanding
4/5 render_as_batch note fixed in 7fe06d7, plus ruff format of the
initial-schema revision unblocking Backend Lint.
Also includes the post-merge ruff UP007 autofix on #153's initial
alembic revision (staging's pinned ruff config from #137 enables UP
rules the PR branch predates).
…f metrics (closes #112)

# Conflicts:
#	backend/schemas.py
- Move raw-file profiling into services/dataset_preview.py (thin routers)
- Map duckdb.Error to 400; let server-side failures surface as 500
- Build preview query string with URLSearchParams (api.ts)
- Add type="button" to the preview back button
- prettier format frontend/src/components/ProjectDataModal.tsx
…pload (closes #103)

# Conflicts:
#	backend/schemas.py
#	frontend/src/lib/api.ts
- Typed response models: SampleDatasetEntry, SampleProjectSummary,
  SampleExperimentSummary, ProjectFromSampleResponse in schemas.py
- Move catalog scan + project-from-sample flow into services/samples.py
  (thin routers)
- Move shared dataset path helpers into services/datasets.py as public
  API (no more cross-router underscore imports)
- Offload sync file I/O (read_bytes, stat, is_file) via asyncio.to_thread
- ruff format backend/routers/samples.py (+ reformatted service files)
- prettier format frontend/src/app/experiments/page.tsx
…oses #110)

# Conflicts:
#	backend/routers/experiments.py
#	frontend/src/app/experiments/page.tsx
#	frontend/src/lib/api.ts
…c, model families, trial budget, caps (closes #104)

# Conflicts:
#	backend/schemas.py
# Conflicts:
#	backend/models.py
#	backend/routers/projects.py
#	backend/schemas.py
#	backend/services/agent/runner.py
#	frontend/src/components/ProjectSettingsModal.tsx
#	frontend/src/components/Sidebar.tsx
#	frontend/src/lib/api.ts
#	frontend/src/lib/types.ts
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown

Too many files changed for review. (101 files found, 100 file limit)

Bypass the limit by tagging @greptile-apps to review.

lucastononro added a commit that referenced this pull request Jul 29, 2026
lucastononro added a commit that referenced this pull request Jul 29, 2026
Conflicts/semantic reconciliation:
- MetricsTab.tsx: #157 typed ChartTooltip props (dropped `any`); staging's
  #161 compare page imports it — kept the typed props AND the export.
- SSE union extended with events other staging PRs added/consume:
  `budget_exceeded` (#165, new BudgetExceededSSEData) and the
  `notebook.kernel.state`/`notebook.structure.changed`/`notebook.cell.*`
  events dispatched by useNotebookSSE (#148's typed bus requires them).
  StructureChangedEvent moved to lib/notebook/types.ts (re-exported from
  useNotebookSSE) so lib/types.ts can reference it.
- page.tsx budget_exceeded case adapted to #157's per-case
  `const data = event.data` block pattern.
- useNotebookSSE.test.tsx: cell.completed payload gained exec_count: null
  to satisfy the now-typed CellCompletedEvent.
@lucastononro

Copy link
Copy Markdown
Owner Author

Merged into integration branch staging-v0.0.5 (see the STAGING-v0.0.5.md ledger on that branch for merge order, Greptile follow-ups and test results). These changes will land on main via the staging merge — closing to clear the queue.

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.

Enforce cost budgets with a hard-stop guardrail

2 participants