Skip to content

Develop - #102

Merged
dkackman merged 21 commits into
masterfrom
develop
Sep 13, 2026
Merged

dkackman merged 21 commits into
masterfrom
develop

Conversation

@dkackman

Copy link
Copy Markdown
Owner

No description provided.

dkackman and others added 21 commits September 12, 2026 18:48
…d frame rates, peak RSS clamp

#82: a warning a step raises about what it is writing (the >=6 dB level
spread on an unmatched join) is emitted as a 'warning' event and folded
into the job's warnings, prefixed with the step - the server's own log is
not a consumer surface.

#83: host_memory_peak_rss_mb is held at or above host_memory_rss_mb; the
two readings come from different sources and disagree by a megabyte at
idle, which makes 'peak - rss' read as un-comparable.

#84: AudioVideo carries the frame rate it is meant to play at - a task's
own fps, the rate of a file it read, a chain's fps - and result.fps
defaults to that before falling back to 8. Declaring a rate the frames
contradict now warns.

Also: docs/proposals/acknowledged-cost-binding.md, the #85 assessment.
…sed step, fps wording

#88: the fps_mismatch warning reported source/declared, so 24 fps frames
written at 8 were described as "3x speed" when they play at one third; and
concat_videos' own `fps` docstring - which is what get_task shows - never
got #84's sentence about result.fps.

#90: a sub-workflow step's path is resolved the way run_workflow's
workflow_path is - a catalog name, with or without .json, beside the
referencing file first, then this run's workflows root, then the read-only
sources dw.serve pins in DW_WORKFLOW_PATH. A stored template is composed
rather than copied. A name that reaches nothing says where it looked.
Progress from inside a composed run now carries the queued run's own step
counter (parent_index/parent_total_steps), not the child's 1-of-1.

#89: validate_workflow resolves those paths, validates the workflow each
one names under steps[N].workflow.path, refuses a composition cycle, and
warns about an argument the child declares no variable for.

#92: a composing step that declares a saving result owns the file - the
child's last step no longer writes a second copy under its own name, so a
composed run stops doubling storage and the manifest stops carrying two
entries under a step name the caller never wrote. A composed child's other
files carry the composing step's name.
…rom job history

The listing answers cost_basis: curated, and list_workflows says what that
means: a cost block is a figure a maintainer measured once on the devices it
names, never derived, so null means nobody wrote one down rather than 'this
box has never run it'. The measured-from-history half is a proposal
(docs/proposals/measured-cost-from-job-history.md) - it changes what the
field is for every consumer, and the comparability rule is a decision.
Workflow.run expands for_each before it emits, so the event stream names the
members (base@open) while the flow graph is drawn from the definition, which
keeps for_each unexpanded and a composed step as one node. Every comparison in
FlowView was between two names that could no longer be equal, so the active
step and the finished ones stopped showing. A sub-workflow's inner step
mismatched the same way, which also left the Progress list with no active row.

runstate.ts reduces both sides to one name - a for_each member to its group, a
child's step to the parent_step the engine already puts on its events. A group
is finished only when every member is, and only a top-level step_end finishes a
composed step.

The job page also now says why a step wrote nothing: the Results panel lists the
steps that ran and wrote no file, with the reason read off the definition
(result.save is false, or no result.content_type), and appears when that list is
the only thing there is to say. A workflow that keeps most of its steps in
memory read as a run whose outputs had gone missing.

Co-Authored-By: Claude <noreply@anthropic.com>
SVG text neither wraps nor takes text-overflow, so a sub-workflow step's
path (`templates/minimax/composable-reference-shot.json`) or a long step
name ran out of the node's right edge. Each label is now cut to a
character budget for its font with an ellipsis where it was cut - a name
from the end, a path from the start, since a path is told apart by how it
ends - and the node carries a tooltip with whatever did not fit. A
clipPath on the node catches what a wider glyph set still pushes past.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(ui): match a run's step names to the flow graph's nodes, and say why a step wrote nothing
…and denoise steps are uneven

wait_for_job's description is the documented way to tell a slow run from a
hung one, and its ~90 s lead-in figure only held for a prompt with an image
or audio reference. A video reference makes it ~10 min (measured 629 s for
one 5 s 960x544 clip on a 3090), so a consumer following the documentation
would have been entitled to cancel a healthy run at the 3 minute mark.

Names the video reference and its order of magnitude in wait_for_job,
get_job, SERVER.md and MCP.md, and says that once denoise_step is a number
the first_block cache makes the steps uneven - a 140 s gap between events
is a healthy H3 run, so liveness is the counter moving between polls
minutes apart rather than silence under a threshold.

Curates the two measured costs behind that: reference-to-video 8.0 min
(mean of five runs) and composable-references 27.4, both RTX 3090.
…-in is not silent

A ModularPipeline's denoise loop reports through its progress bar, but the
blocks around it reported nothing, and on H3 with a video reference the
encode before the loop runs for ten minutes. From outside that was
indistinguishable from a hang: eleven consecutive polls byte-identical
apart from the clock, with event_count stuck at 15 for nine and a half
minutes.

Each top-level block now emits a log event naming it as it starts
('MiniMaxAI/MiniMax-H3: vae_encoder'), so the last event says which part of
the lead-in the run is in and seconds_since_event resets at every boundary.
A log rather than a phase, because PHASES is a closed set a consumer
switches on. Block boundaries double as the cancellation checkpoint the
lead-in otherwise had none of.

Only a SequentialPipelineBlocks is narrated: a conditional container picks
one branch on its inputs, and walking its sub-blocks would run every branch
- a wrong answer bought with a progress message.
…e pin run_workflow does

run_workflow and validate_workflow take a per-call `workspace`, so a job can
be pinned into another workspace without switching the session - but every
tool that reaches what it produced resolved its name against the session's
workspace instead, and answered 'Path does not exist' naming a path with the
workspace segment missing. Half the workflow accepted the pin and the other
half ignored it.

The pin is one argument on the client's request methods, threaded to the
same place the session's workspace was already added, so it covers the
/outputs route and the streaming download as well as the /api ones. Naming
the default explicitly sends no selector, which is how a session that is
somewhere else reaches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d the heap, and says what it gave back

A worker that had released every model still held 14.5 GB of anonymous
memory on lem (49.8 GB -> 14.9 GB across a workflow switch, measured
2026-09-13). That residue is what kills the next job: reference-to-video
peaks at 96.4% of host RAM on a clean run, so ~2.3 GB is the whole margin,
and a stock template was OOM-killed at 314 s where the byte-identical rerun
on a fresh worker succeeded.

Two caches gc.collect() does not touch. Group offloading with use_stream
stages weights through *pinned* host memory, which torch caches per process
exactly as it caches device memory - freeing the tensors returns the blocks
to that cache, not to the OS. And freeing a large block inside the process
does not hand its pages back to the kernel either; malloc_trim asks glibc
to. Both now run in the full cleanup, and the worker reports host RSS and
what came back on the same line it already said 'releasing cached models',
so the question a later OOM asks has an answer in the event stream rather
than needing three jobs of hindsight.

The pinned cache is also now a figure get_memory reports: it is part of
host_memory_rss_mb and invisible in every gpu_* number, which is why a
worker holding GB after releasing everything looked like a leak with no
candidate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o validate catches them

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…have frames range-checked

'saving' emitted nothing at all: on a video template that is minutes with the
denoise counter frozen at its last step and no further event until step_end, so
a healthy run reads exactly like a hung one. Each file is now named as its
write begins and costed as it ends, the same shape the modular block lead-in
got in #95.

That instrumentation immediately said where the time went: 52.7 s of a 165 s
LTX-2.5 run was inside one encode_video call, against 2.4 s for the same encode
standalone on the same box. The difference is the float-frames branch - a
pipeline returning output_type='np' hands back float frames in [0, 1], and
diffusers establishes that range with three full-size temporaries before
converting, ~3 GB of allocation on a 121-frame 960x544 clip. Converting here,
frame by frame, into the torch tensor encode_video takes as given is 0.2 s and
the check never runs.

Measured on lem 2026-09-14: np float32 in, 18.4 s; converted first, 2.6 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s the run's wall clock

The catalog's 0.6 min for templates/ltx2/text-to-video was the generation
alone; the run a consumer waits through is 1.8 min cold on the RTX 3090 the
figure is written for (measured 109.8 s on lem, 2026-09-14, with the encode
fix in). The schema now says which of the two 'minutes' is, since nothing did.

The LTX-2.5 skill's '4 s per frame, about 8 minutes for 121 frames' write-out
warning was measuring the range check this cycle removed - it is 1.3 s now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread dw/workflow.py
)
confine_to = builtin_root()
resolved = os.path.join(confine_to, builtin_name)
if not os.path.isfile(resolved):
Comment thread dw/workflow_sources.py
tried.append(candidate)
for root in roots:
source = WorkflowSource(root, EXAMPLES_ORIGIN, False)
if source.contains(candidate) and os.path.isfile(candidate):
Comment thread dw/workflow_sources.py
for name in _candidate_names(path):
candidate = os.path.normpath(os.path.join(base_dir, name))
tried.append(candidate)
if os.path.isfile(candidate):
@dkackman
dkackman merged commit a15076d into master Sep 13, 2026
8 of 9 checks passed
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.

2 participants