Skip to content

feat(compute): RunPod as an alternative compute provider to Modal - #145

Closed
lucastononro wants to merge 163 commits into
mainfrom
feat/runpod-provider
Closed

feat(compute): RunPod as an alternative compute provider to Modal#145
lucastononro wants to merge 163 commits into
mainfrom
feat/runpod-provider

Conversation

@lucastononro

@lucastononro lucastononro commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Closes #130

What

Adds RunPod as a full-parity alternative compute provider to Modal, selected globally via COMPUTE_PROVIDER=modal|runpod. Modal stays the default and its behavior is unchanged (the whole pre-existing suite passes untouched after the refactor commit, before any RunPod code exists).

Commit 1 — provider abstraction (pure refactor)

New backend/services/compute/ package with per-concern interfaces selected by settings.compute_provider:

Interface Modal impl Call-site facade
SandboxProvider (one-shot exec, streamed stdout/stderr) wraps modal.Sandbox.create services/sandbox.py:run_code (signature unchanged)
KernelTransport (newline-JSON cmd/event channel) sandbox stdin/stdout services/kernel_manager.py
StorageBackend (workspace volume, 1:1 with volume.py helpers) Modal Volume services/volume.py (all functions keep their names)
ServingBackend (codegen/deploy/stop/secret) wraps _serving_app_code / modal deploy CLI helpers services/deploy.py (orchestration + DB rows stay)

Also: billing rows now record the actual provider (record_sandbox_usage(provider=...) replaces the hardcoded "modal"), sandbox.yml gains a runpod: rate key, and deployments gains provider / provider_endpoint_id columns (ALTER-TABLE migration in db.py).

The Modal adapters deliberately resolve modal.Sandbox / get_app / get_image / get_volume through the legacy module namespaces so every existing test monkeypatch keeps working.

Commit 2 — RunPod implementation

  • Code execution → serverless "code-runner" endpoints, one per canonical GPU tier (≤8, lazily created, workersMin=0, 60 s idle keeps workers warm across consecutive agent calls, FlashBoot). The worker generator-handler subprocess-runs the code and yields stdout/stderr lines; the backend polls /stream/{job} and re-exposes them as the same async-iterator handle shape Modal has. Job timeout via policy.executionTimeout; TIMED_OUT maps to a provider-neutral SandboxTimeoutError that the execute-code skill handles like Modal's.
  • Notebook kernels → long-lived CPU pod running an HTTP kernel gateway (same AsyncKernelManager logic as the Modal stdin/stdout proxy): POST /cmd, cursor-based GET /events long-poll ≤25 s (the pod proxy kills connections at 100 s), per-pod token auth, SDK preamble via env. Ready timeout is provider-aware (600 s vs 120 s — first boot pulls the image). The existing idle reaper bounds pod cost.
  • Storage → RunPod network volume: live filesystem mount in workers/pods + S3-compatible API for backend reads/writes (the role modal.Volume's client API plays today). Multipart above 256 MB (500 MB single-PUT cap), Modal-shaped listdir entries (.path, .type.name), FileNotFoundError parity, auto-created volume with pinnable id.
  • Serving → one serverless endpoint per deployed model. create-serving-app writes a readable handler.py to the volume; a shared serving image loads it via HANDLER_PATH env (deploy = metadata, no image build — same property as Modal). Pure REST, no CLI subprocess. Per-model key enforced in-handler (input.api_key); rotation updates the template env (rolls on next cold start). endpoint_url = https://api.runpod.ai/v2/{id}/runsync.
  • GPU mapping: canonical labels stay unchanged everywhere; runpod_provider/gpu.py maps each to an ordered RunPod pool (T4→RTX A4000-class, A10G→RTX A5000/A40, A100-40GB schedules and bills as 80 GB — surfaced in /deploy/compute-options notes and docs).
  • Config/CLI/docs: RUNPOD_* settings with startup validation, trainable init compute-provider step (RunPod branch collects API key + S3 key pair + datacenter), .env.example block, docker/runpod-worker/ image + make runpod-image, docs/compute-providers.md.

✅ Verified automatically (no live account needed)

  • Modal regression: entire pre-existing suite (296 tests) green immediately after the refactor commit and at HEAD — proves COMPUTE_PROVIDER=modal behavior is byte-for-byte preserved, including every existing modal.Sandbox.create / get_volume monkeypatch.
  • 47 new unit tests (mocked RunPod client / in-memory S3 fake, no network):
    • factory: provider selection, singleton rebuild on switch, unknown-provider error, missing-credentials errors, provider-aware kernel ready timeout
    • sandbox: stdout/stderr stream routing, returncode extraction, TIMED_OUTSandboxTimeoutError (rc 124), FAILED→nonzero rc, cancel-on-terminate, policy.executionTimeout + workdir in the run payload, lazy endpoint creation, oversized-payload guard, and a full run_code() pass on the runpod provider asserting usage is recorded with provider="runpod" + the actual GPU
    • storage: key mapping, write/read round-trip (str + bytes), FileNotFoundError parity for files and dirs, recursive + delimiter listings with synthesized directory entries, prefix-recursive remove, session-workspace bootstrap, live-reload no-op
    • serving: handler codegen ast.parse + api-key gate presence/absence, no /data/data double-prefix, xgboost-native loader selection, first-deploy creates template+endpoint (env, volume, DC, GPU pool asserted), CPU deploys use computeType=CPU, redeploy PATCHes instead of re-creating, stop scales to 0 then deletes, missing-endpoint-id error
    • kernel: cursor advance across polls, boot-error retry (502 → retry → events), /cmd POST shape, pod-delete on terminate, pod-create payload (mount path, ports, env, dockerStartCmd), worker scripts parse + long-poll stays under the 100 s proxy cap
    • db: ALTER-TABLE migration on a legacy-shaped database (columns added, existing rows default provider='modal', idempotent)
    • billing: every canonical GPU label resolves a nonzero runpod rate
  • CLI wizard logic smoke-tested: _has_compute_creds truth table, .env write→read round-trip for the RunPod block, modal-only configs emit no RunPod keys.
  • entrypoint.sh shell syntax (sh -n); ruff check + ruff format clean; frontend production build green; full suite at HEAD: 343 passed, 8 skipped.

🧑‍💻 Requires a human with a live RunPod account

These touch real RunPod infrastructure and cannot be exercised by CI. In order:

  1. Publish the worker image (one-time prerequisite): make runpod-image RUNPOD_IMAGE=<your-registry>/trainable-runpod-worker:latest — needs docker buildx + a registry; verify the image is pullable by RunPod (public, or registry creds configured in the RunPod console). Nothing below works without this.
  2. Wizard + boot: trainable init → pick RunPod → enter API key, S3 key pair, datacenter → trainable up. Confirm backend starts with no COMPUTE_PROVIDER validation errors and the network volume is auto-created (check the log line, then pin RUNPOD_NETWORK_VOLUME_ID in .env).
  3. Code execution: start a session, ask the agent to run some Python. Verify stdout streams incrementally into the chat (not one blob at the end), created files appear in the file tree, and trainable.log(...) metrics render on the dashboard. Expect minutes of cold start on the very first call per GPU tier; a second call within ~60 s should be near-instant (warm worker).
  4. Timeout path: run something like time.sleep(9999) with a short project timeout — the agent should receive the sandbox-timeout tool error, not a crash.
  5. Notebook kernel: run a notebook cell; verify state transitions (starting→idle→busy), streamed output, interrupt works, and the pod is deleted after ~15 idle minutes (check RunPod console — this is a billing leak if it fails).
  6. Storage round-trips: upload a dataset (>256 MB if possible, to exercise multipart), browse the file tree, open a file preview, delete a file.
  7. Serving: create-serving-app → Deploy from /modelscurl -X POST <endpoint_url> -H "Authorization: Bearer $RUNPOD_API_KEY" -d '{"input": {"records": [...], "api_key": "<model key>"}}' → predictions returned; wrong api_key → 401-shaped error; Rotate key → old key rejected after next cold start; Stop → endpoint gone from the RunPod console.
  8. Billing: after 3–7, the Usage page shows sandbox rows priced with runpod rates and provider=runpod.
  9. GPU tier (cost ~cents): set the project training profile to T4, run a heavy=true call, confirm nvidia-smi/torch.cuda.is_available() sees a GPU and the endpoint created in the console targets the RTX A4000 pool.
  10. Modal regression smoke: flip .env back to COMPUTE_PROVIDER=modal, restart, rerun 3 + 7 briefly.

Known limitations / follow-ups

🤖 Generated with Claude Code

https://claude.ai/code/session_012GrGFBtdZv7qtgn8KQEQEB

Greptile Summary

This PR adds RunPod as a full-parity alternative compute provider to Modal, selected via COMPUTE_PROVIDER=modal|runpod. It introduces a clean four-interface abstraction (SandboxProvider, KernelTransport, StorageBackend, ServingBackend) with Modal and RunPod implementations, while keeping all existing Modal call-sites and test patch points unchanged.

  • Refactor (commit 1): New backend/services/compute/ package with provider factory; all services/volume.py, sandbox.py, kernel_manager.py, and deploy.py call sites route through the abstraction with no signature changes. DB migration adds provider/provider_endpoint_id to deployments; billing rows now record the actual provider.
  • RunPod implementation (commit 2): Serverless code-runner endpoints per GPU tier, long-lived CPU pods for notebook kernels via an HTTP gateway, S3-backed network volume storage, and one serverless endpoint per deployed model — all with 44 new unit tests (340 total passing).

Confidence Score: 3/5

RunPod code execution fails for scripts exceeding ~2 MB (Linux ARG_MAX) despite the backend accepting up to 5 MB, and large model artifacts written via write_to_volume bypass multipart and hit RunPod's 500 MB single-PUT cap.

Two concrete defects on the RunPod hot path: the worker subprocess rejects code via -c above ~2 MB while the backend permits 5 MB; and RunPodStorage.write() uses put_object without multipart, capping in-memory writes at RunPod's documented 500 MB S3 limit. The Modal path is untouched and its full suite passes. The abstraction is well-designed.

docker/runpod-worker/worker/runner_handler.py (subprocess ARG_MAX), backend/services/compute/runpod_provider/storage.py (write() missing multipart), backend/services/compute/runpod_provider/bootstrap.py (template/lock issues).

Important Files Changed

Filename Overview
docker/runpod-worker/worker/runner_handler.py Passes code as a -c argument to Python; fails for strings above Linux ARG_MAX (~2 MB) while the backend allows 5 MB.
backend/services/compute/runpod_provider/storage.py write() uses put_object directly, bypassing multipart and RunPod's 500 MB single-PUT cap.
backend/services/compute/runpod_provider/bootstrap.py Known reentrant-lock deadlock; image-match in template lookup causes duplicate templates on image updates.
backend/services/compute/runpod_provider/serving.py rotate_key may not propagate the new API key if RunPod caches template env at endpoint-creation time.
backend/services/compute/runpod_provider/kernel.py Well-structured pod + HTTP gateway transport; token auth and cursor-based polling are clean.
docker/runpod-worker/worker/kernel_gateway.py FastAPI HTTP gateway wrapping AsyncKernelManager with ring-buffer event store.
backend/services/compute/init.py Clean provider factory with startup validation and lazy singletons.
backend/services/compute/base.py Well-defined abstract interfaces with clear contracts and provider-neutral SandboxTimeoutError.
backend/services/deploy.py Provider-neutral orchestration cleanly routes through the backend interface; Modal paths preserved.
backend/services/volume.py Stable facade routing all async helpers through StorageBackend correctly.
backend/db.py Safe ALTER TABLE migration adding provider columns with proper existence checks.
backend/services/compute/runpod_provider/client.py Thin async httpx wrapper with consistent error surfacing via RunPodAPIError.
backend/services/kernel_manager.py Clean KernelTransport abstraction with provider-aware readiness timeout.

Fix All in Claude Code

Reviews (2): Last reviewed commit: "test(db): cover the deployments provider..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

lucastononro and others added 20 commits May 12, 2026 20:29
Adds `GET /api/sessions/{sid}/download` and `GET /api/projects/{pid}/download`
that stream a self-contained zip of the agent's workspace: every file under
`/sessions/{sid}/` plus a synthetic `trainable_local.py` shim, a filtered
`requirements.txt`, and a runbook README. The shim lets downloaded scripts
that import `from trainable import log, log_image, ...` run on a vanilla
Python install — calls land in `./trainable_out/` instead of the Modal
volume.

The zip is streamed via `StreamingResponse` over an in-memory buffer
drained per write, so multi-hundred-MB sessions don't materialize on
disk or in RAM. A 2 GB uncompressed cap with a trailing `__truncated.txt`
marker keeps a runaway walk bounded.

Frontend ships two entry points:
- Download icon in the WorkspaceSidebar header → session zip
- Download icon on every Project row in the sidebar → project zip

Closes #79.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
…erving

Introduce services/compute with per-concern interfaces (SandboxProvider,
KernelTransport, StorageBackend, ServingBackend) selected by the new
COMPUTE_PROVIDER setting. sandbox.py/volume.py/kernel_manager.py/deploy.py
become facades that keep their public signatures; Modal mechanics move to
services/compute/modal_provider/*. Billing rows now record the actual
provider, sandbox.yml gains runpod rates, and deployments get
provider/provider_endpoint_id columns.

Refs #130

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GrGFBtdZv7qtgn8KQEQEB
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
- Serverless code-runner endpoints per GPU tier (scale-to-zero, 60s idle,
  FlashBoot) streaming stdout/stderr via /stream; provider-neutral
  SandboxTimeoutError mapped from TIMED_OUT jobs
- Notebook kernels on long-lived pods behind an HTTP kernel gateway
  (cursor-based ≤25s long-poll under the proxy's 100s cap, token auth)
- Workspace storage on a RunPod network volume via its S3-compatible API
  (multipart above 256MB, Modal-shaped listdir entries, live reads)
- Model serving as one serverless endpoint per model: generated
  handler.py on the volume + shared serving image, pure REST deploys,
  per-model template env carries HANDLER_PATH/API_KEY
- Canonical GPU label → RunPod pool mapping with ordered fallbacks
  (no T4/A10G/A100-40GB SKUs; documented)
- docker/runpod-worker image (runner/kernel/serving roles), Makefile
  target, CLI wizard compute-provider step, .env.example + docs
- 44 new unit tests with a faked RunPod client / in-memory S3

Refs #130

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GrGFBtdZv7qtgn8KQEQEB
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
Comment thread backend/services/compute/runpod_provider/bootstrap.py
Comment on lines 65 to +70
return _volume


def _storage():
from services.compute import get_storage

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 read_volume_file is Modal-only but not visibly guarded at call sites

The function still calls get_volume().read_file(path) (a raw modal.Volume method) instead of routing through _storage(). Any call site that invokes this path when COMPUTE_PROVIDER=runpod will either fail if Modal is not configured or silently read from the wrong volume. The async twin read_volume_file_async was correctly migrated to _storage().read_file(), but the sync version was deliberately left behind. Consider raising NotImplementedError or logging a clear warning when the active provider is not Modal.

Fix in Claude Code

Comment thread backend/services/compute/runpod_provider/sandbox.py
Lucas Tonon and others added 9 commits July 18, 2026 23:22
HomePage.connectSSE and useNotebookSSE each opened their own EventSource
to the identical /api/sessions/{id}/stream endpoint, doubling server
fan-out and parsing every event twice. Add SSEStreamContext to expose
the single page-level stream via a publish/subscribe bus; the notebook
now subscribes instead of opening a second connection.

Closes #100

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
…wn on every token

renderChatItem was a plain function, so every agent_token/agent_message
setChatItems re-ran the whole list and re-parsed markdown for every prior
message. Extract a memo-wrapped ChatItemView keyed by item.id (only the
streaming bubble's item reference changes, so React bails out on the
rest) and hoist the assistant bubble's remark-plugins array to a module
constant so it doesn't defeat the memoization with a fresh array each
render.

Closes #98

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
…aming

bottomRef.scrollIntoView({behavior:'smooth'}) fired on every chatItems
change, including every agent_token during streaming, yanking the view
back to the bottom and making it impossible to read earlier output.
Track whether the user is pinned near the bottom via a scroll listener +
threshold and only auto-scroll while pinned; use behavior:'auto' while a
bubble is actively streaming, and re-pin when the user sends a message.

Closes #99

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
lucastononro and others added 23 commits July 29, 2026 15:38
…ptile P1 on #159)

FileViewer is memoized and keeps its component instance across file
switches, so a markdown render crash left the boundary stuck in its error
fallback for every subsequent file. key={filePath} remounts the boundary
per file.
Conflict in page.tsx FileViewer markdown block: kept #159's ErrorBoundary
wrapper (with key={filePath} P1 fix) AND staging's api.filesRawUrl helper
(#133). Also prettier --write on global-error.tsx/ErrorBoundary.tsx
(branch predates the CI format gate).
Reconciliation with the #148 SSE-bus chain:
- DROPPED useNotebookSSE.test.ts (stale — it tested the hook's own
  EventSource, which #148 replaced with the SSEStreamContext bus; #148's
  bus-based useNotebookSSE.test.tsx is the live suite).
- api.test.ts add/add: combined both suites — #158's fetch-mocked
  fetchJSON/FormData helper tests AND staging's (#133) relative-URL builder
  tests — 15 tests total.
- vitest.config.ts add/add: kept one config — staging's (#148) plus #158's
  globals + setupFiles (vitest.setup.ts / @testing-library/jest-dom).
- package.json: union of devDeps (@testing-library/dom + jest-dom), single
  test script; package-lock.json: took staging's, regenerated via npm install.
- ci.yml: #158's frontend-test job kept as-is (staging had none — #133 only
  wired the npm script); all 10 jobs intact, YAML valid.
# Conflicts:
#	frontend/src/app/page.tsx
- resume.py: prior-run wording no longer claims "stopped early" for
  done/*_done sessions (resumed as re-runs of finished work) — new
  _prior_run_outcome helper used by both build_resume_context and
  build_resume_prompt.
- resume.py: widen _clip annotation to str | None to match its runtime
  None guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	backend/services/agent/runner.py
#	frontend/src/components/chat/ChatPane.tsx
#	frontend/src/lib/useSessionStream.ts
- ApprovalCard: surface verdict-submission failures inline instead of
  only console.error — the buttons re-enable on failure but the user
  previously had no indication their Approve/Edit never reached the
  agent (expired approval, backend restart, ...).
- ApprovalCard: named ErrorBoundary import per components/ style guide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- EdaFindingsPanel: stable content-derived card key (was the sorted-array
  index, which shifts when new findings arrive — resetting each card's
  applied state and allowing duplicate "Apply in prep" appends).
- EdaFindingsPanel: named export per components/ style guide.
- report-eda-findings handler: report items truncated by the 50-finding
  cap separately from schema-invalid drops, so the agent doesn't mistake
  the cap for a formatting problem and retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	backend/routers/sessions.py
# Conflicts:
#	frontend/src/lib/useSessionStream.ts
… stream retry

- ensure_runner_endpoint no longer holds the shared non-reentrant lock
  across ensure_network_volume()/ensure_runner_template() (deadlock on
  the first execution of a fresh setup) + regression test
- worker runner stages job code in a temp file and execs it via a -c
  bootstrap instead of putting the whole script on argv (execve ARG_MAX
  ~2 MB killed Popen for multi-MB jobs the backend accepts)
- /stream poller tolerates a few consecutive transient failures before
  condemning a job to FAILED/returncode -9 + tests
# Conflicts:
#	backend/services/volume.py
#	docker-compose.yml
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown

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

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

@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.

Add RunPod as an alternative compute provider to Modal

2 participants