Concise always-on rules for this repo. Prefer Modal SDK shapes, idiomatic Python, and library-native APIs. Do not treat this as a product README. Code is liability: keep it small; complexity only when warranted.
- Prefer short, concrete nouns and verbs. Say what the thing is or does in one pass.
- No underscore-prefix for “privacy.” Types, module constants, methods, and test helpers are normal names; omit from
__all__if internal. Do not invent_Foo,_helper,_post. - Do not shadow Modal / stdlib names. Never call our FastAPI control plane
App(that ismodal.App). PreferWebhookApp,DeliveryStore, etc. - Avoid redundant / encoding noise in names: no
…Helper,…Manager,…Utils,…Sync(ambiguous),…Data,FooBarBazResponsewhenFooResponse/JitResponseis enough. Spell units in constants (DELIVERY_TTL_SECONDS, notDELIVERY_TTL_S). - Tag / env keys: name the key
…_TAG(e.g.KIND_TAG,POOL_TAG) and the value plainly (JOB_KIND). Do not useTAG_KIND+TAG_KIND_VALUE. - Async boundary:
asyncroute reads I/O; sync worker has a clear verb (process_webhook), not…_sync. - Temporary diagnosis / repro hooks stay out of product PRs; use a throwaway local branch or one-off workflow for testing, not names that look permanent.
- Match Modal twin vocabulary for public entities (
create/from_name/objects/ephemeral/hydrate); do not invent parallel jargon for the same idea.
- Public surface is entity-based:
Runner+ nestedRunner.Job. Prefer Modal SDK vocabulary (create/from_name/objects/ephemeral; Job ≈ Sandbox). - Export only via
__all__. Do not underscore-prefix types for “privacy”; omit them from__all__instead. - No free public helpers (
spawn,parse_labels, pools, ASGI attach helpers). If a concern is a lead responsibility, put it on an entity (or a method on the owning entity). - One
Runner.createper App. Control plane is a Runner-registered Function@modal.asgi_appnamedwebhookwith@modal.concurrent. Runner identity isRUNNER_MODAL_NAME. Inputs queue on cold start — do not use Modal Server for GitHub webhooks (503 on zero→one). - Soft capacity (
has_capacity/max_concurrent) is soft (list-then-create TOCTOU). Document it as soft; never present it as a linearizable lock. - Job resources: Modal-twin flat kwargs —
cpu,memory,gpu,experimental_options(e.g.{"vm_runtime": True}for Docker/VM). Do not inventResourceSpec | DockerResources, xor validators, orisinstanceresource dispatch. Preferdocker_image()whenvm_runtimeis set; let Modal enforce GPU vs VM limits. - Admission:
repositoriesis required (non-empty).admit_reason(labels, repository)fail-closed. No admit-all mode.Job.createenforces the same rules.
- Public primitives take kwargs /
modal.Secrethandles. No ambientos.environ.getfor credentials or product config onRunner/Job/WebhookApp. - Clients and scripts may use env / Modal CLI tokens. Optional
client=like Modal entities. - In-container entrypoints (
webhook()ASGI entry,python -m runner_modal.entrypoint) may read only values mounted viasecrets=/env=at create time; KeyError if missing. - Split Secrets:
github_secret(GITHUB_TOKEN, Jobs only) andwebhook_secret(WEBHOOK_SECRET, webhook Function only). Never co-mount webhook into Jobs.
- Normal
__init__+ classmethod factories. No blocked constructors, noobject.__new__, nogetattr/hasattr/setattr, no dynamic class creation /__name__mutation. - No process-local registries or fake idempotency caches — use Modal Dict (and claim keys properly).
- Call Modal / FastAPI / httpx with real kwargs. No build-dict / strip-Nones /
**kwargsbags. - Prefer library-native APIs (Modal, FastAPI, Pydantic, httpx, tenacity, uv Image methods).
- Composition over inheritance: wrap
Sandbox/Dict/Volume; do not subclass Modal types for product API. - Frozen Pydantic models for boundary DTOs / snapshots (
ConfigDict(frozen=True)preferred). - EAFP at mutation boundaries (
Job.createraises). Optional LBYL helpers must be labeled soft. - Break import cycles with lazy imports at Modal lifecycle boundaries (
@modal.enter), not circular top-level imports.
- Soft absence →
Noneor HTTP 204 (e.g. undeployedurl, ignored webhook). Failures raise a small set:ValueError,LookupError,AuthError,ConcurrencyLimitError. BaseRunnerErroris rare. - Do not wrap Modal / httpx failures in
RunnerError. Propagate; map only product/auth cases. - FastAPI: HTTP status conveys success. Response models without
ok: bool. UseHTTPExceptionfor 401 / 400 / 503. - Sync I/O (Modal SDK, httpx): use sync
defendpoints orasyncio.to_thread. Never blockasync defhandlers with sync Modal/httpx calls. - Credentials via required named Secrets on
Runner.create(github_secret=…, webhook_secret=…)andJob.create(github_secret=…). Never putGITHUB_TOKEN,WEBHOOK_SECRET, or JIT strings in Sandboxenv=dicts. - JIT mint runs only inside the Job (
python -m runner_modal.entrypoint) fromGITHUB_TOKENinjected bygithub_secret=.WebhookApptakes an explicitwebhook_secret=; ASGI entrywebhook()requires mountedWEBHOOK_SECRET/RUNNER_MODAL_NAME(KeyError if missing). - Verify GitHub webhooks with
hmac.compare_digeston the raw body before parse. RequireX-GitHub-Delivery(no job-id fallback).
- Webhook idempotency: claim the delivery ID before side effects (
put/skip_if_exists). Bindobject_idafter successful create before relying on TTL reclaim. Never release after successful create unless terminate is confirmed. - Soft list-based capacity is enough — never fake linearizable concurrency locks.
- Shared Modal Dict writes: prefer conditional writes for init and idempotency keys. Redeploy overwrites Runner meta (last deploy wins).
- Do not full-scan / trim entire Dict stores on every request without an explicit GC strategy (separate hot path from opportunistic cleanup).
- Modules by responsibility:
runner/meta/entrypoint/webhook/exceptions. Keep nestedJobif it matches the Modal twin. - Images: install runtime deps with
uv_pip_install(*CONTROL_PLANE_DEPS|JOB_DEPS)and bakeadd_local_python_source("runner_modal", copy=True)until a PyPI release.PACKAGE_SPECdocuments the operatoruv addpin. Named Job Image"{name}-job"is published atRunner.create; webhookJob.createusesImage.from_name. No Path /add_local_dir. - Default
cache=False. Shared/cacheVolume is same-pool only — not Actions cache. - One unit test file per impl file (
test_runner.py,test_entrypoint.py,test_webhook.py,test_exceptions.py,test_meta.py). - Test HMAC failure, delivery claim, admission (repo + labels), and capacity semantics — not only exports / signatures.
- Retries (tenacity): transient network / timeouts / 5xx only. Never retry 401 / 403 / validation errors.
- Process-local
_REGISTRY/ create caches / fake idempotency - Underscore-prefixing types or helpers for privacy (
_Foo,_helper) — omit from__all__instead - Naming our types
App(conflicts withmodal.App) or other Modal entity names getattr/hasattr/object.__new__/ blocked__init__/ mutating__name__for entity identity- None-filtered
**kwargsbags into Modal APIs - Tagged resource unions / xor validators /
sandbox_kwargshelpers instead of Modal flat kwargs ok: boolon HTTP response models- Blanket
except Exception: raise RunnerError(...) - Exception taxonomy for every Modal failure mode
- Tokens or JIT in
env=;os.environ.getcredential fallbacks; minting JIT in the parent process; co-mountingWEBHOOK_SECRETinto Jobs - Blocking the asyncio event loop with Modal / httpx in
async def - Claim-after-create webhook deliveries; reclaiming pending claims that already have
object_id - Treating soft capacity as a hard reservation
- Inheriting Modal SDK types for the product API
- Free public helper functions as the primary DX
- Path /
add_local_dirinstead of uv-native Image install - Substituting export/signature tests for security and race/idempotency tests
- Admit-all repositories / empty
repositoriesonRunner.createorobjects.create
- This repo’s GitHub Actions CI and Release workflows run on
ubuntu-latest. - Maintainer CI App is
scripts/ci_app.py(Apprunner-modal-ci-app) for dogfooding Jobs, not for repo CI.