The core flow lives in the jobs skill. This is the long tail: how to test a job pipeline without driving a real run, and the gotchas worth knowing before your first GPU job.
Testing a job pipeline
A job pipeline is two functions plus a handler: a trigger function that calls jobs.submit, the handler that does the work, and an on_complete function that writes the result to your DB. Test the two functions and the handler separately - don't try to drive a real job run from your test suite.
The wiring is just two function calls - no real job needed
In a test run jobs.submit() is suppressed: it enqueues nothing and returns { run_guid, status: 'suppressed' } with a fresh, unique run_guid per call (a real jr_… guid, exactly like a live submit), so the handler never runs and on_complete never fires. That's fine - an on_complete function is an ordinary deployed function, and the platform fires it with a plain body: { run_guid, job_name, status, output, error, duration_ms }. So test the whole loop by calling both functions directly with synthetic data - no real job, no real audio file, and no helper to "seed a run" (there isn't one, and you don't need one):
test('split pipeline: submit records a pending row, on_complete finishes it', async (ctx) => {
// 1. Trigger fn: assert it returns the run_guid and wrote its pending row.
const { run_guid } = await ctx.fn.call('start-split', { song_guid: 'song_x' });
assert.ok(run_guid); // unique per submit — key your pending row on it as in prod
// 2. Drive on_complete yourself with the body the platform would send.
// (Call it like any function; if you gated it non-public, use ctx.fn.callAs.)
await ctx.fn.call('split-complete', {
run_guid, status: 'success',
output: { stems: { vocals: 'https://x/v.wav', drums: 'https://x/d.wav' } },
});
// 3. Assert the DB row your on_complete fn updated.
const row = await ctx.fn.call('split-status', { song_guid: 'song_x' });
assert.equal(row.status, 'done');
});
This exercises every line of app code in the pipeline. Match your on_complete fn on run_guid (unique per submit, so it disambiguates rows just like in prod) or on a field in the synthetic output, and don't rewrite production matching logic just to make it testable: the direct call above already drives it.
Smoke the real GPU handler once, from the CLI
The automated suite can't run a real job, so verify the handler itself manually - once - with a reachable input URL. Build a fixture in the sandbox, mint its URL with gipity file url (no deploy - the synced project copy is served directly; delete the file to revoke), submit, then wait:
gipity sandbox run bash 'ffmpeg -f lavfi -i sine=frequency=440:duration=5 /work/tests/fixtures/test.wav'
gipity job submit <name> --data "{"audio_url":"$(gipity file url tests/fixtures/test.wav)"}"
gipity job wait <runGuid> --timeout 300 # blocks until done + prints output; exits 2 (still running) or 1 (failed)
gipity job wait (see the CLI section above) is how you await the run: it blocks up to --timeout seconds, prints the output when the run finishes, and exits 2 if it's still running when the timeout hits - so re-run the same command to keep waiting. Never wrap a sleep+job status loop yourself, and never block on job logs --follow (it holds an SSE stream open until the run ends, so a cold-start GPU job outlasts your shell).
Expect a several-minute cold start on the FIRST GPU run. A GPU model job (LTX-Video, Stable Video Diffusion, Demucs, etc.) downloads several GB of weights to /cache on its first run - commonly 3-5 minutes of running with little log output. This is normal, not a hang; subsequent runs reuse the cached weights and are fast. Give job wait a generous --timeout (e.g. 300) for that first run, or just re-run it across turns until it exits 0.
To check WHY a run is slow (or catch an early crash) without streaming, use gipity job logs <runGuid> --no-follow. It returns instantly with a log_tail field: the most recent stdout/stderr the job has printed (an import error, a "downloading weights" line). That's the non-blocking peek - use it instead of holding --follow open, which a cold-start GPU job outlasts.
The fixture never ships in the deployed app - gipity file url serves the synced project copy, and deleting the file revokes the link. You only do this once to confirm the handler works; the wiring test above (which needs no fixture) is what belongs in your permanent suite.
Common gotchas
- GPU without Modal config - submit returns
MODAL_NOT_CONFIGURED(503). Operator must setMODAL_APP_URL+MODAL_INVOKE_SECRETon the server. - Demucs: split with the Python API, never the
demucsCLI. The model is preloaded, but the CLI's write step (and a baretorchaudio.save) routes throughtorchcodec, which is not in the image - so a CLI run separates fine and then fails at save. Run the model in-process and write stems withsoundfile(libsndfile is installed). This is the whole 4-stem recipe:import soundfile as sf, torch from demucs.pretrained import get_model from demucs.apply import apply_model from demucs.audio import AudioFile model = get_model("htdemucs"); model.eval() # weights cache in /cache/torch wav = AudioFile("/work/song.mp3").read(streams=0, samplerate=model.samplerate, channels=model.audio_channels) ref = wav.mean(0); wav = (wav - ref.mean()) / ref.std() with torch.no_grad(): stems = apply_model(model, wav[None], device="cuda")[0] # [4, ch, samples] stems = stems * ref.std() + ref.mean() for name, stem in zip(model.sources, stems): # ["drums","bass","other","vocals"] sf.write(f"/work/{name}.wav", stem.T.cpu().numpy(), model.samplerate) - Deps that conflict with the fat image - e.g. pinning
torch==2.3when the image ships a different minor.requirements.txtwins via pip override, but CUDA/torch mismatches may produce subtle bugs. Prefer nodeps:if the fat image already has what you need. - Forgetting
set -ein bash - without it, only the LAST command's exit code propagates. outputis whatever youset_output- for an agent or function callingjob_submit, the run'soutput(and what anon_completehook receives) is the value the handler passed toctx.set_output(...). See "Job output contract" above.