fix(sweep): deterministic extra seeds

add_random_seeds_to_gens now uses config_utils.derive_extra_seeds (base seed 0, like the Generator),
so a re-queue attaches to the running sweep and a resume skips finished jobs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jason Hoku
2026-11-04 00:56:59 -08:00
co-authored by Claude Fable 5.1
parent bd18b5ae32
commit 9b8eed833b
3 changed files with 32 additions and 7 deletions
+1 -1
View File
@@ -816,7 +816,7 @@ How it behaves:
* A failed job is recorded and the sweep continues; three failures in a row pause it. Pressing Cancel in ComfyUI during a job, or clearing the queue, pauses the sweep instead of silently finishing; Resume re-runs the affected jobs.
* Manifest items get the normal fields (model, sampler, scheduler, steps, cfg, seed, denoise, vae, size, prompts) read from the workflow, plus `wf_params` (your bound values), `wf_fingerprint`, `wf_sweep_id`. Sweep items show a **JOB** button instead of REVISE (see *Dashboard for sweeps* below).
* Each sweep writes `benchmarks/<session>/workflow_sweeps/<sweep_id>.json` (templates, bindings, per-job status) so any executed prompt can be reconstructed.
* **Re-running the same sweep attaches to it.** Queueing the Generator again (or pressing Start) while the identical sweep is active does not fail and does not queue anything twice: it attaches to the running sweep (console: "Same workflow sweep already running ... attaching") and a paused sweep stays paused (Resume is the Builder's button). "Identical" means the same session name, the same ordered job fingerprints, the same *Start at job* and the same *Overwrite existing*. Anything else gets a clear refusal ("A workflow sweep is already active (session ..., sweep ..., n/m done, state). This run was NOT started. Pause or cancel it ..."; ComfyUI shows it in its error dialog, the route answers 409 with `session_name`). Limitation: with *Add random seeds* > 0 every start draws new seeds, so a re-queue never matches and always gets the refusal.
* **Re-running the same sweep attaches to it.** Queueing the Generator again (or pressing Start) while the identical sweep is active does not fail and does not queue anything twice: it attaches to the running sweep (console: "Same workflow sweep already running ... attaching") and a paused sweep stays paused (Resume is the Builder's button). "Identical" means the same session name, the same ordered job fingerprints, the same *Start at job* and the same *Overwrite existing*. Anything else gets a clear refusal ("A workflow sweep is already active (session ..., sweep ..., n/m done, state). This run was NOT started. Pause or cancel it ..."; ComfyUI shows it in its error dialog, the route answers 409 with `session_name`). *Add random seeds* are deterministic (derived from the base seed, 0 like the Generator), so the same sweep always produces the same jobs: re-queueing attaches, and a resume after a finished or interrupted run skips the jobs already in the session.
* **Loading a sweep session in the Builder** (Load Session) restores the Workflow Sweep arrays (workflow, output, bindings, custom prompts) from the `configs_json` that sweep sessions now store in their manifest meta. Sessions made before this change have no saved configs: the Builder shows a notice ("Load the workflow JSON again into a Workflow Sweep array; finished jobs are skipped by fingerprint") and leaves the arrays alone.
* v1 limits: one image output per job, one sweep at a time, a workflow sweep cannot share a Generator run with standard configs (use two Builders), no upscale/distribution/trigger-word features, and Revise does not apply. **Video outputs are not collected yet** (planned: core `SaveVideo.video` / `CreateVideo` VIDEO sockets and the frames feeding `VHS_VideoCombine.images`, through a `USCGSweepCollectVideo` node).
+29 -4
View File
@@ -683,11 +683,11 @@ def test_different_sweep_409_with_session_and_message(env):
assert len(runner._sweeps) == 1
def test_random_extra_seeds_never_attach(env):
make(env)
raw = {"configs": [entry()], "_session_settings": {"session_name": "wf_test", "add_random_seeds_to_gens": 1}}
def test_extra_seeds_deterministic_and_attach(env):
sw = make(env, settings={"add_random_seeds_to_gens": 2})
raw = {"configs": [entry()], "_session_settings": {"session_name": "wf_test", "add_random_seeds_to_gens": 2}}
status, body = asyncio.run(runner.api_start({"configs_json": json.dumps(raw)}, bridge=env.bridge, class_info=None))
assert status == 409
assert status == 200 and body["attached"] is True and body["sweep_id"] == sw.id
def test_launch_race_same_sweep(env):
@@ -771,3 +771,28 @@ def test_update_meta_preserves_items_written_since(tmp_path):
mu.update_meta(path, {"configs_json": "z"})
data = json.load(open(path))
assert data["items"] == [{"id": 9}] and data["meta"] == {"a": 1, "configs_json": "z"}
def _job_seeds(sw):
return sorted(j["conf"]["seed"] for j in sw.jobs)
def test_extra_seeds_use_derive_extra_seeds_and_base_seed(env):
cu = wfs._cu()
one = lambda **s: runner.prepare_sweep([entry((1,))], {"session_name": "s", "add_random_seeds_to_gens": 2, **s},
None, None, env.bridge, class_info=None)
a, b = one(), one()
assert _job_seeds(a) == _job_seeds(b) == sorted({1, *cu.derive_extra_seeds(0, 2)})
assert _job_seeds(one(seed=5)) != _job_seeds(a)
def test_resume_with_extra_seeds_skips_everything(env):
sw = make(env, [entry((1,))], {"add_random_seeds_to_gens": 2})
complete_all(sw, env.bridge)
assert sw.state == "completed" and len(sw.jobs) > 1
# the (fake) collector wrote no items: record what a real run leaves in the manifest
_write_manifest(env, [{"id": 100 + j["index"], "wf_fingerprint": j["fingerprint"]} for j in sw.jobs])
runner.reset_registry()
again = runner.prepare_sweep([entry((1,))], {"session_name": "wf_test", "add_random_seeds_to_gens": 2},
None, None, env.bridge, class_info=None)
assert again.to_run == 0 and again.skipped_existing == len(again.jobs) > 1
+2 -2
View File
@@ -20,7 +20,6 @@ import asyncio
import inspect
import json
import os
import random
import re
import threading
import time
@@ -368,7 +367,8 @@ def prepare_sweep(configs, session_settings=None, origin_node=None, client_id=No
except (TypeError, ValueError):
n_extra = 0
if n_extra > 0:
extra_seeds = [random.randint(0, 2**32 - 1) for _ in range(n_extra)]
# deterministic (same helper and base seed 0 as the Generator): a re-run or resume yields the same jobs
extra_seeds = wfs._cu().derive_extra_seeds(settings.get("seed", 0), n_extra)
plan = wfs.plan_workflow_configs(configs, extra_seeds, class_info=class_info)
paths = wfs.session_paths(settings.get("session_name") or "workflow_sweep")