{"record":{"id":"6be9831c97dcfa4b","repo":"jamiepine/voicebox","slug":"only-active-generations-can-be-cancelled","errorCode":null,"errorMessage":"Only active generations can be cancelled","messagePattern":"Only active generations can be cancelled","errorType":"http","errorClass":"HTTPException","httpStatus":400,"severity":"warning","filePath":"backend/routes/generations.py","lineNumber":243,"sourceCode":"            seed=gen.seed,\n            instruct=gen.instruct,\n            mode=\"regenerate\",\n            version_id=version_id,\n        )\n    )\n\n    return models.GenerationResponse.model_validate(gen)\n\n\n@router.post(\"/generate/{generation_id}/cancel\")\nasync def cancel_generation(generation_id: str, db: Session = Depends(get_db)):\n    \"\"\"Cancel a queued or running generation.\"\"\"\n    gen = db.query(DBGeneration).filter_by(id=generation_id).first()\n    if not gen:\n        raise HTTPException(status_code=404, detail=\"Generation not found\")\n\n    if (gen.status or \"completed\") not in (\"loading_model\", \"generating\"):\n        raise HTTPException(status_code=400, detail=\"Only active generations can be cancelled\")\n\n    cancellation_state = cancel_generation_job(generation_id)\n    if cancellation_state is None:\n        # Row says active but the worker is no longer tracking it — the gen\n        # coroutine exited without writing a terminal status (most often a\n        # SQLite lock racing with the failed-status write inside the worker's\n        # exception handler). Fail the row here so the user can move on.\n        task_manager = get_task_manager()\n        task_manager.complete_generation(generation_id)\n        await history.update_generation_status(\n            generation_id=generation_id,\n            status=\"failed\",\n            db=db,\n            error=\"Generation orphaned by worker\",\n        )\n        return {\"message\": \"Orphaned generation cleared\"}\n\n    if cancellation_state == \"queued\":","sourceCodeStart":225,"sourceCodeEnd":261,"githubUrl":"https://github.com/jamiepine/voicebox/blob/51f49dea198384b4eb6087b72c17057c6eb1c1cd/backend/routes/generations.py#L225-L261","documentation":"Returned by POST /generate/{generation_id}/cancel when the generation row exists but its status is not in the active set (\"loading_model\", \"generating\"). The guard reads `(gen.status or \"completed\")`, so a NULL/empty status is treated as \"completed\" and is therefore non-cancellable. HTTP 400. The endpoint refuses to send a cancel signal to a job that is already terminal or was never started.","triggerScenarios":"Cancelling a generation that already finished (status \"completed\"), already failed (status \"failed\"), or whose status column is NULL (treated as completed). Also triggered if a generation is still \"queued\" but queued is not in the active tuple — note only loading_model/generating are accepted.","commonSituations":"User double-clicks cancel after the job already completed; the SSE status stream hasn't updated the UI yet so the user clicks cancel on a now-terminal job; legacy rows with NULL status created before the status field was added; cancelling a queued job (status==\"queued\") which the guard does NOT permit.","solutions":["Disable the cancel button in the UI as soon as the status stream reports completed/failed — do not wait for user reaction.","If the row is stuck \"queued\" but not cancellable, manually update the status to \"failed\" in the DB or call the orphan-clear path rather than relying on cancel.","Treat 400 from this endpoint as informational (job already terminal) and refresh the row's status from GET /history/{id}.","For legacy NULL-status rows, run a one-time migration setting status to a terminal value."],"exampleFix":"// before\n<button onClick={() => cancel(id)}>Cancel</button>\n\n// after: gate on known active status\n<button disabled={!['loading_model','generating'].includes(row.status)}\n        onClick={() => cancel(id)}>Cancel</button>","handlingStrategy":"validation","validationCode":"// Only call cancel when the row is in an active state\nconst ACTIVE = new Set(['loading_model', 'generating']);\nif (!ACTIVE.has(row.status)) { /* don't call cancel */ return; }\nawait fetch(`/generate/${id}/cancel`, { method:'POST' });","typeGuard":"const ACTIVE_STATES = new Set(['loading_model', 'generating']);\nfunction isActiveGeneration(gen) {\n  return gen != null && ACTIVE_STATES.has(gen.status);\n}","tryCatchPattern":"try {\n  const res = await fetch(`/generate/${id}/cancel`, { method: 'POST' });\n  if (res.status === 400) {\n    // already terminal (or queued not cancellable) — refresh status\n    await refreshStatus(id);\n    return;\n  }\n} catch (e) { console.error(e); }","preventionTips":["Gate the cancel button on the live SSE status, not on user perception.","Treat NULL status rows as completed (the server does).","Note that 'queued' status is NOT cancellable via this endpoint."],"tags":["fastapi","state-machine","generation","cancel","validation","status"],"backgroundTag":null,"analyzedSha":"51f49dea198384b4eb6087b72c17057c6eb1c1cd","analyzedAt":"2026-08-12T16:51:42.824Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}