From 9b8eed833b017ee703c80ddfc908084d806d6053 Mon Sep 17 00:00:00 2001 From: Jason Hoku Date: Wed, 4 Nov 2026 00:56:59 -0800 Subject: [PATCH] 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 --- README.md | 2 +- tests/test_workflow_sweep_runner.py | 33 +++++++++++++++++++++++++---- workflow_sweep_runner.py | 4 ++-- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 143d951..48f0338 100644 --- a/README.md +++ b/README.md @@ -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//workflow_sweeps/.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). diff --git a/tests/test_workflow_sweep_runner.py b/tests/test_workflow_sweep_runner.py index 75d5fba..1bb51f8 100644 --- a/tests/test_workflow_sweep_runner.py +++ b/tests/test_workflow_sweep_runner.py @@ -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 diff --git a/workflow_sweep_runner.py b/workflow_sweep_runner.py index b57f097..ff7181e 100644 --- a/workflow_sweep_runner.py +++ b/workflow_sweep_runner.py @@ -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")