Skip to content

feat: reserve music generation IDs before inference with idempotent submit - #143

Merged
IAnMove merged 3 commits into
mainfrom
feat/music-submission-contract
Sep 5, 2026
Merged

feat: reserve music generation IDs before inference with idempotent submit#143
IAnMove merged 3 commits into
mainfrom
feat/music-submission-contract

Conversation

@IAnMove

@IAnMove IAnMove commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Problem

POST /api/v1/stories/music-candidates/jobs already returns 202, but it minted IDs in memory, started the worker immediately, and did not dedupe. A lost HTTP response or a double click could start two inferences. Story destinations were not checked by ID.

Final behavior

  • New app/services/music_submission.py reserves command_id, generation_id, task_id, job_id and candidate_id before the MiniMax thread starts.
  • Same idempotency key + same spec hash → replay of the same IDs.
  • Same key + different spec → 409.
  • retry / new_version mint a new attempt (parent_generation_id on retry).
  • Story project/cue/candidate are resolved by ID only (a title is not a key). Optional library_revision is CAS.
  • TaskRegistry owns the task row. No GPU, no model download in this PR.
  • Existing 202 job JSON stays; additive fields: generationId, commandId, candidateId, idempotencyKey, replay.
  • Legacy POST /api/v1/stories/music-candidates (sync) is unchanged.

Scope

  • app/services/music_submission.py (new)
  • Minimal _launch_runtime.py (+36 lines)
  • tests + docs/development/MUSIC_SUBMISSION.md + fase4.md

Not in this PR: local ACE/Music3 still uses generateMusic; server-side attach of the finished song (phase 5); router extract (phase 9).

Tests

  • pytest tests/test_music_submission.py tests/test_minimax_music_jobs.py — 14 passed
  • bash scripts/validate_local.sh — OK (E2E 7/7)
  • ratchet vs 8b010a68

Risks / limits

  • Local music execution is inventoried, not rerouted.
  • Worker start still happens in launch after reservation; a start failure leaves the reservation queryable.
  • Real media generation was not run.

Base

origin/main 8b010a68 (merge #142). Do not auto-merge. Phase 5/6/8 wait for this merge.


Note

Medium Risk
Touches the critical _launch_runtime.py music job path and introduces durable idempotency/Story validation; behavior changes for duplicate submits and lost HTTP responses, though scope is narrow and well-tested without GPU.

Overview
Adds music_submission so story music jobs are accepted durably before inference: command/generation/task/job/candidate IDs, TaskRegistry rows, and on-disk idempotency records are created up front. Same idempotency key + spec replays the same IDs; mismatched spec returns 409. Story targets are validated by project/cue/candidate ID (optional library_revision CAS), not title.

POST /api/v1/stories/music-candidates/jobs now calls reservation first instead of minting IDs only in memory. Replays return an existing live job when present; if the reservation exists but the in-memory job is gone, it restarts the worker with the reserved IDs. Concurrent replays that miss the checkpoint only start one worker. The 202 body gains additive fields (generationId, commandId, candidateId, idempotencyKey, replay).

Contract is documented in MUSIC_SUBMISSION.md; fase4.md marks phase 4 tasks done. Tests cover submission dedup, Story validation, post-reserve worker failure, and launch replay/concurrency behavior.

Reviewed by Cursor Bugbot for commit a09ea53. Configure here.

…ubmit

Accept Story music jobs only after persisting command/task/candidate IDs.
The same idempotency key and spec replay; a reused key with a different
spec conflicts. Retry/new-version mint a new attempt. Existing 202 jobs
and the sync compatibility route stay.
@IAnMove

IAnMove commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

cursor review

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

PR Review — Loreframe Studio

Risk: low
Scope: 6 file(s); +769/-30; backend services, docs

Automated review from scripts/analyze_pr.py. This is a heuristic pass (no LLM) so humans still own the merge decision.

Findings

  • No heuristic issues. Still run the CI checklist below.

Changed files

  • added: app/services/music_submission.py, docs/development/MUSIC_SUBMISSION.md, tests/test_music_submission.py
  • modified: app/_launch_runtime.py, fase4.md, tests/test_minimax_music_jobs.py

CONTRIBUTING checklist

  • python scripts/verify_clean_repo.py
  • python -m compileall -q app/services app/launch.py scripts
  • cd ui && npm run build if the UI changed
  • No weights, CivitAI sidecars, or generated guides
  • Stays local-first (no required accounts / telemetry)

Posted by the repo PR review workflow. Re-runs on each push to the PR.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Code health

Quality score: 49.9/100

Higher is better. The score is a trend dashboard; the independent ratchet below remains the CI gate.

Component Weight Current Change
Cyclomatic health 45% 52.5 +0.0
File concentration 25% 55.5 +0.1
Oversized-file debt 20% 30.8 +0.1
Modularity 10% 62.3 +0.0

Change vs PR base: +0.1 points.

Metric Value
Production LOC 245,474
Production files 541
Test LOC 72,372
Functions measured 15,344
Functions complexity ≥ 15 793
Maximum complexity 667

Markdown, JSON catalogs and tests are out of this table. Only app/ runtime + ui/src TS/JS count.

Most complex functions

Complexity Where
667 app/wgp.py:7164 generate_video
374 ui/src/stores/useStore.ts:4024 Async method 'startGeneration'
355 app/_launch_runtime.py:23508 _run_generation
308 app/wgp.py:12281 generate_video_tab
271 ui/src/components/Sidebar/SceneAnimatorPanel.tsx:474 Function 'SceneAnimatorPanel'
266 ui/src/stores/useStore.ts:8566 Async method 'loadSettingsFromOutput'
258 app/services/director/planners/short_film.py:3433 ShortFilmPlanner._plan_story_driven
248 app/services/director_pipeline.py:13735 _run_video_generation
245 app/services/director_pipeline.py:7860 _run_pipeline
243 ui/src/features/agent/agentActions.ts:1128 Function 'parseAction'
226 app/services/director_pipeline.py:6689 update_comic_preview
225 ui/src/features/agent/agentActions.ts:2796 Async function 'executeAgentActions'

Trend vs baseline

Metric Δ
Production LOC +373
Test LOC +325
Functions ≥ 15 +1
Maximum complexity +0

Warnings

  • production LOC increased by +373
  • functions at complexity >= 15 increased by +1
  • hotspot app/_launch_runtime.py increased by +39 lines

Ratchet passed.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Default key blocks repeat generations
    • Sin idempotency_key ahora se reserva un id único, así un POST idéntico arranca una generación nueva en vez de reutilizar el spec_hash.
  • ✅ Fixed: Replay skips worker when job missing
    • Si el replay no encuentra checkpoint MiniMax, se persiste el job y se arranca el worker en lugar de devolver solo el cascarón queued.
  • ✅ Fixed: Reserved count defaults disagree
    • spec_snapshot ahora usa el mismo default de count=2 que el endpoint de jobs cuando el cliente omite el campo.
  • ✅ Fixed: Retry keys are not replayable
    • retry y new_version añaden un sufijo estable {key}:{intent} sin UUID, así un 202 perdido o un doble clic reutilizan el mismo intento.

Create PR

Or push these changes by commenting:

@cursor push a2053af6a5
Preview (a2053af6a5)
diff --git a/app/_launch_runtime.py b/app/_launch_runtime.py
--- a/app/_launch_runtime.py
+++ b/app/_launch_runtime.py
@@ -2000,7 +2000,7 @@
     return {"deleted": deleted, "skipped_linked": skipped_linked, "model_type": model_type}
 
 
-# ── Model pre-download ──────────────────────────────────────────────────
+# ── Model pre-download ─────────────────────────────────────────────────���
 # Backs the click-to-download icon in Settings → System → Enabled Models.
 # Fetches everything a generation would need (transformer + second-stage +
 # modules + shared assets + text encoder) without occupying the GPU, so
@@ -3393,7 +3393,7 @@
     return _load_lora_manifest()
 
 
-# ── CivitAI Browser ───────────────────────────────────────────────────
+# ── CivitAI Browser ────────────────────────────────────────────────���──
 
 CIVITAI_BASE_URL = "https://civitai.com/api/v1"
 CIVITAI_IMAGE_CDN = "https://imagecache.civitai.com/xG1nkqKTMzGDvpLrqFT7WA"
@@ -31789,8 +31789,6 @@
             public = _public_minimax_music_job(existing)
             public["replay"] = True
             return public
-        from services.music_submission import public_music_job
-        return public_music_job(reserved)
     job_id = str(reserved.get("job_id") or f"minimax-music-{uuid.uuid4().hex[:12]}")
     task_id = str(reserved.get("task_id") or f"task-minimax-music-{job_id}")
     now = time.time()
@@ -31858,6 +31856,11 @@
         "idempotencyKey": reserved.get("idempotency_key"),
     }
     with _minimax_music_jobs_lock:
+        existing = _minimax_music_jobs.get(job_id)
+        if existing:
+            public = _public_minimax_music_job(existing)
+            public["replay"] = True
+            return public
         _minimax_music_jobs[job_id] = job
         _persist_minimax_music_job(job)
     _publish_minimax_music_job(job)
@@ -31867,7 +31870,10 @@
         name=f"minimax-music-{job_id[-6:]}",
         daemon=True,
     ).start()
-    return _public_minimax_music_job(job)
+    public = _public_minimax_music_job(job)
+    if reserved.get("replay"):
+        public["replay"] = True
+    return public
 
 
 @api.get("/api/v1/stories/music-candidates/jobs/{job_id}")

diff --git a/app/services/music_submission.py b/app/services/music_submission.py
--- a/app/services/music_submission.py
+++ b/app/services/music_submission.py
@@ -78,7 +78,7 @@
     provenance = request.get("provenance") if isinstance(request.get("provenance"), Mapping) else {}
     model = _clean(request.get("model")) or "music-3.0"
     try:
-        count = max(1, min(3, int(request.get("count") or 1)))
+        count = max(1, min(3, int(request.get("count") or 2)))
     except (TypeError, ValueError, OverflowError) as exc:
         raise MusicSubmissionError(
             "MiniMax Music candidate count must be an integer from 1 to 3",
@@ -239,9 +239,10 @@
     digest = spec_hash(spec)
     key = _clean(request.get("idempotency_key") or request.get("idempotencyKey"))
     if not key:
-        key = digest
+        # HTTP idempotency is opt-in: a missing key is a new attempt.
+        key = _token_id("idem")
     if intent in {"retry", "new_version"}:
-        key = f"{key}:{intent}:{uuid.uuid4().hex[:8]}"
+        key = f"{key}:{intent}"
     store = MusicSubmissionStore(workspace_dir)
     existing = store.load(key)
     if existing:

diff --git a/tests/test_minimax_music_jobs.py b/tests/test_minimax_music_jobs.py
--- a/tests/test_minimax_music_jobs.py
+++ b/tests/test_minimax_music_jobs.py
@@ -174,6 +174,53 @@
     ]
 
 
+def test_replay_without_checkpoint_starts_the_worker(tmp_path):
+    namespace = _base_namespace(tmp_path)
+    reserved_job_id = "minimax-music-aaaaaaaaaaaa"
+    published = []
+    namespace.update({
+        "HTTPException": _HTTPException,
+        "_get_active_workspace": lambda: "default",
+        "_safe_join": lambda root, name: os.path.join(root, name),
+        "_persist_minimax_music_job": lambda job: published.append(("persist", job["jobId"])),
+        "_publish_minimax_music_job": lambda job: published.append(("publish", job["jobId"])),
+        "_run_minimax_music_job": lambda _job_id: None,
+        "_public_minimax_music_job": lambda job: {
+            key: value for key, value in job.items()
+            if key not in {"request", "_cancel_requested"}
+        },
+        "threading": SimpleNamespace(Thread=_DeferredThread),
+        "_reserve_story_music_submission": lambda _body, _workspace: {
+            "replay": True,
+            "job_id": reserved_job_id,
+            "task_id": f"task-{reserved_job_id}",
+            "generation_id": "gen-1",
+            "command_id": "cmd-1",
+            "candidate_id": "song-1",
+            "idempotency_key": "cmd-same-once",
+        },
+        "_load_minimax_music_job": lambda _job_id: None,
+    })
+    start = _load("start_story_music_candidates_job", namespace=namespace)[
+        "start_story_music_candidates_job"
+    ]
+
+    result = start({
+        "prompt": "cinematic dream pop",
+        "lyrics": "[Verse]\nAcross the night",
+        "workspace": "default",
+    })
+
+    assert result["jobId"] == reserved_job_id
+    assert result["status"] == "queued"
+    assert result["replay"] is True
+    assert result["jobId"] in namespace["_minimax_music_jobs"]
+    assert published == [
+        ("persist", reserved_job_id),
+        ("publish", reserved_job_id),
+    ]
+
+
 def test_start_rejects_a_non_numeric_candidate_count_as_bad_input(tmp_path):
     namespace = _base_namespace(tmp_path)
     namespace.update({

diff --git a/tests/test_music_submission.py b/tests/test_music_submission.py
--- a/tests/test_music_submission.py
+++ b/tests/test_music_submission.py
@@ -99,8 +99,26 @@
     assert retry["generation_id"] != first["generation_id"]
     assert retry["intent"] == "retry"
     assert retry["parent_generation_id"] == first["generation_id"]
+    replay = submit_music_generation(
+        workspace_dir=str(tmp_path),
+        request=_request(retry=True, parent_generation_id=first["generation_id"]),
+    )
+    assert replay["replay"] is True
+    assert replay["job_id"] == retry["job_id"]
+    assert replay["generation_id"] == retry["generation_id"]
 
 
+def test_missing_idempotency_key_starts_a_new_generation(tmp_path: Path):
+    payload = _request()
+    payload.pop("idempotency_key")
+    first = submit_music_generation(workspace_dir=str(tmp_path), request=payload)
+    second = submit_music_generation(workspace_dir=str(tmp_path), request=payload)
+    assert first["replay"] is False
+    assert second["replay"] is False
+    assert first["job_id"] != second["job_id"]
+    assert first["generation_id"] != second["generation_id"]
+
+
 def test_missing_project_id_is_not_resolved_by_title(tmp_path: Path):
     _write_library(tmp_path)
     with pytest.raises(MusicSubmissionError, match="was not found"):
@@ -180,3 +198,12 @@
     assert spec_hash(first) == spec_hash(second)
     assert first["output_folder"] == "night-shift"
     assert first["workspace_id"] is None
+
+
+def test_spec_snapshot_defaults_omitted_count_to_jobs_endpoint_default():
+    spec = spec_snapshot({
+        "prompt": "cinematic dream pop",
+        "lyrics": "[Verse]\nLa noche canta",
+        "output_folder": "night-shift",
+    })
+    assert spec["count"] == 2

You can send follow-ups to the cloud agent here.

Comment thread app/services/music_submission.py Outdated
Comment thread app/_launch_runtime.py Outdated
Comment thread app/services/music_submission.py Outdated
Comment thread app/services/music_submission.py Outdated
Omit a key to start a new attempt. Retry needs its own key so a lost
response can replay. Replay without a MiniMax checkpoint starts the worker
again. Default candidate count matches the jobs endpoint.
@IAnMove

IAnMove commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Replay can start a second worker
    • El insert del job ahora es exclusivo bajo el lock y el claim rechaza un child ya running, así un replay concurrente no arranca un segundo worker ni factura MiniMax dos veces.

Create PR

Or push these changes by commenting:

@cursor push 506622dbdd
Preview (506622dbdd)
diff --git a/app/_launch_runtime.py b/app/_launch_runtime.py
--- a/app/_launch_runtime.py
+++ b/app/_launch_runtime.py
@@ -2000,7 +2000,7 @@
     return {"deleted": deleted, "skipped_linked": skipped_linked, "model_type": model_type}
 
 
-# ── Model pre-download ──────────────────────────────────────────────────
+# ── Model pre-download ─────────────────────────────────────────────────���
 # Backs the click-to-download icon in Settings → System → Enabled Models.
 # Fetches everything a generation would need (transformer + second-stage +
 # modules + shared assets + text encoder) without occupying the GPU, so
@@ -3393,7 +3393,7 @@
     return _load_lora_manifest()
 
 
-# ── CivitAI Browser ───────────────────────────────────────────────────
+# ── CivitAI Browser ────────────────────────────────────────────────���──
 
 CIVITAI_BASE_URL = "https://civitai.com/api/v1"
 CIVITAI_IMAGE_CDN = "https://imagecache.civitai.com/xG1nkqKTMzGDvpLrqFT7WA"
@@ -31465,8 +31465,10 @@
             or str(job.get("status") or "") in _MINIMAX_MUSIC_TERMINAL
         ):
             return None
+        child = children[child_index]
+        if str(child.get("status") or "") not in {"queued", "waiting_resource"}:
+            return None
         now = time.time()
-        child = children[child_index]
         child.update(
             status="running",
             phase="requesting",
@@ -31858,6 +31860,12 @@
         "idempotencyKey": reserved.get("idempotency_key"),
     }
     with _minimax_music_jobs_lock:
+        live = _minimax_music_jobs.get(job_id)
+        if live is not None:
+            public = _public_minimax_music_job(live)
+            if reserved.get("replay"):
+                public["replay"] = True
+            return public
         _minimax_music_jobs[job_id] = job
         _persist_minimax_music_job(job)
     _publish_minimax_music_job(job)

diff --git a/tests/test_minimax_music_jobs.py b/tests/test_minimax_music_jobs.py
--- a/tests/test_minimax_music_jobs.py
+++ b/tests/test_minimax_music_jobs.py
@@ -218,6 +218,52 @@
     assert result["status"] == "queued"
 
 
+def test_replay_without_checkpoint_starts_only_one_worker(tmp_path):
+    namespace = _base_namespace(tmp_path)
+    started = []
+
+    class CaptureThread:
+        def __init__(self, *, target, args, **_kwargs):
+            self.args = args
+
+        def start(self):
+            started.append(self.args)
+
+    namespace.update({
+        "HTTPException": _HTTPException,
+        "_get_active_workspace": lambda: "default",
+        "_safe_join": lambda root, name: os.path.join(root, name),
+        "_persist_minimax_music_job": lambda _job: None,
+        "_publish_minimax_music_job": lambda _job: None,
+        "_run_minimax_music_job": lambda _job_id: None,
+        "_public_minimax_music_job": lambda job: {
+            key: value for key, value in job.items()
+            if key not in {"request", "_cancel_requested"}
+        },
+        "_load_minimax_music_job": lambda _job_id: None,
+        "_reserve_story_music_submission": lambda _body, _workspace: {
+            "replay": True,
+            "job_id": "minimax-music-abc123def456",
+            "task_id": "task-minimax-music-abc123def456",
+            "generation_id": "gen-1",
+        },
+        "threading": SimpleNamespace(Thread=CaptureThread),
+    })
+    start = _load("start_story_music_candidates_job", namespace=namespace)[
+        "start_story_music_candidates_job"
+    ]
+    body = {
+        "prompt": "cinematic dream pop",
+        "lyrics": "[Verse]\nAcross the night",
+        "workspace": "default",
+    }
+    first = start(body)
+    second = start(body)
+    assert started == [("minimax-music-abc123def456",)]
+    assert first["jobId"] == second["jobId"] == "minimax-music-abc123def456"
+    assert second.get("replay") is True
+
+
 def test_start_rejects_a_non_numeric_candidate_count_as_bad_input(tmp_path):
     namespace = _base_namespace(tmp_path)
     namespace.update({
@@ -363,3 +409,28 @@
         namespace["_minimax_music_jobs"][job_id]["children"][0]["status"]
         == "cancelled"
     )
+
+
+def test_claim_refuses_an_already_running_child(tmp_path):
+    namespace = _base_namespace(tmp_path)
+    _load(
+        "_minimax_music_claim_candidate",
+        namespace=namespace,
+    )
+    job_id = "minimax-music-runningchild01"
+    job = _job(job_id, 1)
+    job.update(status="running", phase="requesting")
+    job["children"][0].update(
+        status="running",
+        phase="requesting",
+        startedAt=time.time(),
+        acquired_resources=["remote:https://api.minimax.io"],
+    )
+    namespace["_minimax_music_jobs"][job_id] = job
+
+    claimed = namespace["_minimax_music_claim_candidate"](
+        job_id, 0, "remote:https://api.minimax.io",
+    )
+
+    assert claimed is None
+    assert namespace["_minimax_music_jobs"][job_id]["children"][0]["status"] == "running"

You can send follow-ups to the cloud agent here.

Comment thread app/_launch_runtime.py
Insert the in-memory job under the existing lock; if the reserved
job_id is already live, return that snapshot instead of starting a
second provider thread.
@IAnMove

IAnMove commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit a09ea53. Configure here.

@IAnMove
IAnMove merged commit ad985ed into main Sep 5, 2026
5 checks passed
@IAnMove
IAnMove deleted the feat/music-submission-contract branch September 5, 2026 11:52
@IAnMove IAnMove mentioned this pull request Sep 5, 2026
10 tasks
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.

1 participant