diffusers-workflow 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
  2. diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
  3. diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
  4. diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
  5. diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
  6. diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
  7. dw/__init__.py +440 -0
  8. dw/adapter_compatibility.py +226 -0
  9. dw/arguments.py +1231 -0
  10. dw/assessment_rules.py +159 -0
  11. dw/assets.py +130 -0
  12. dw/cache_blocks.json +16 -0
  13. dw/cache_blocks.py +146 -0
  14. dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
  15. dw/content_types.py +150 -0
  16. dw/dissolve_frame_errors.py +121 -0
  17. dw/docs/ACCELERATION.md +352 -0
  18. dw/docs/AGENT_LOOP.md +95 -0
  19. dw/docs/DEPENDENCIES.md +91 -0
  20. dw/docs/IP_ADAPTER.md +109 -0
  21. dw/docs/LORAS.md +131 -0
  22. dw/docs/MCP.md +517 -0
  23. dw/docs/PROMPT_WEIGHTING.md +78 -0
  24. dw/docs/QUANTIZATION.md +230 -0
  25. dw/docs/RECIPES_24GB.md +201 -0
  26. dw/docs/RELEASING.md +195 -0
  27. dw/docs/REMOTE.md +140 -0
  28. dw/docs/REPL_COMMANDS.md +121 -0
  29. dw/docs/REPL_WORKER_GUIDE.md +51 -0
  30. dw/docs/SECURITY.md +272 -0
  31. dw/docs/SECURITY_QUICKREF.md +112 -0
  32. dw/docs/SERVER.md +679 -0
  33. dw/docs/TASKS.md +1741 -0
  34. dw/docs/TESTING.md +71 -0
  35. dw/docs/WORKFLOW_GUIDE.md +2038 -0
  36. dw/docs/WORKSPACES.md +316 -0
  37. dw/download_watch.py +335 -0
  38. dw/elision.py +306 -0
  39. dw/events.py +275 -0
  40. dw/for_each.py +409 -0
  41. dw/host_memory.py +258 -0
  42. dw/host_memory_projection.py +230 -0
  43. dw/hub_cache.py +432 -0
  44. dw/introspection.py +1228 -0
  45. dw/kernel_availability.py +208 -0
  46. dw/locations.py +599 -0
  47. dw/log_setup.py +45 -0
  48. dw/loudness.py +82 -0
  49. dw/media_audio.py +217 -0
  50. dw/media_frames.py +367 -0
  51. dw/media_info.py +297 -0
  52. dw/pipeline_processors/chain.py +821 -0
  53. dw/pipeline_processors/config_objects.py +237 -0
  54. dw/pipeline_processors/pipeline.py +2297 -0
  55. dw/pipeline_processors/remote.py +46 -0
  56. dw/plan.py +920 -0
  57. dw/previous_results.py +411 -0
  58. dw/probe_paths.py +59 -0
  59. dw/prompt_schema.json +48 -0
  60. dw/prompt_weighting.py +378 -0
  61. dw/prompts.py +159 -0
  62. dw/realize.py +250 -0
  63. dw/reference_limits.py +215 -0
  64. dw/reference_names.py +125 -0
  65. dw/repl.py +338 -0
  66. dw/repl_commands.py +836 -0
  67. dw/repl_worker.py +159 -0
  68. dw/result.py +1720 -0
  69. dw/result_fps.py +82 -0
  70. dw/run.py +162 -0
  71. dw/runs.py +768 -0
  72. dw/scalar_result_validation.py +97 -0
  73. dw/schema.py +283 -0
  74. dw/security.py +1038 -0
  75. dw/select_validation.py +115 -0
  76. dw/serve.py +277 -0
  77. dw/server/__init__.py +2 -0
  78. dw/server/app.py +4586 -0
  79. dw/server/assess.py +132 -0
  80. dw/server/catalog_shape.py +487 -0
  81. dw/server/enhancers.py +129 -0
  82. dw/server/exports.py +480 -0
  83. dw/server/guides.py +257 -0
  84. dw/server/jobs.py +1561 -0
  85. dw/server/mcp_mount.py +95 -0
  86. dw/server/netinfo.py +124 -0
  87. dw/server/observed_cost.py +379 -0
  88. dw/server/sysinfo.py +71 -0
  89. dw/server/ui/assets/abap-08VXUWAP.js +1 -0
  90. dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
  91. dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
  92. dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
  93. dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
  94. dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
  95. dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
  96. dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
  97. dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
  98. dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
  99. dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
  100. dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
  101. dw/server/ui/assets/css-DIMkf-bt.js +3 -0
  102. dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
  103. dw/server/ui/assets/cssMode-CPznxfY8.js +1 -0
  104. dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
  105. dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
  106. dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
  107. dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
  108. dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
  109. dw/server/ui/assets/editor.api-CpWcotrd.js +847 -0
  110. dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
  111. dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
  112. dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
  113. dw/server/ui/assets/freemarker2-CXtRM8N4.js +3 -0
  114. dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
  115. dw/server/ui/assets/go-C-y9NEjX.js +1 -0
  116. dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
  117. dw/server/ui/assets/handlebars-N7x-6NMY.js +1 -0
  118. dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
  119. dw/server/ui/assets/html-PhsdjHSr.js +1 -0
  120. dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
  121. dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
  122. dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
  123. dw/server/ui/assets/index-DgrYhQd9.js +43 -0
  124. dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
  125. dw/server/ui/assets/java-BEtHBSE6.js +1 -0
  126. dw/server/ui/assets/javascript-BJqN9Qhv.js +1 -0
  127. dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
  128. dw/server/ui/assets/jsonMode-DbM4SWSv.js +7 -0
  129. dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
  130. dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
  131. dw/server/ui/assets/less-B9JPFI3C.js +2 -0
  132. dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
  133. dw/server/ui/assets/liquid-BWr8lEc4.js +1 -0
  134. dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.js +4 -0
  135. dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
  136. dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
  137. dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
  138. dw/server/ui/assets/mdx-DAdMi_0p.js +1 -0
  139. dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
  140. dw/server/ui/assets/monaco--ixms01u.css +1 -0
  141. dw/server/ui/assets/monaco-BGCeEqaw.js +56 -0
  142. dw/server/ui/assets/msdax-DauUninz.js +1 -0
  143. dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
  144. dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
  145. dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
  146. dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
  147. dw/server/ui/assets/perl-oz_6vUea.js +1 -0
  148. dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
  149. dw/server/ui/assets/php-nr791fC2.js +1 -0
  150. dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
  151. dw/server/ui/assets/postiats-43DmfD33.js +1 -0
  152. dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
  153. dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
  154. dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
  155. dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
  156. dw/server/ui/assets/python-Bcn70HdC.js +1 -0
  157. dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
  158. dw/server/ui/assets/r-BwWrilGY.js +1 -0
  159. dw/server/ui/assets/razor-D1HmNnby.js +1 -0
  160. dw/server/ui/assets/redis-ClamHrr6.js +1 -0
  161. dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
  162. dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
  163. dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
  164. dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
  165. dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
  166. dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
  167. dw/server/ui/assets/scheme-BeGwcela.js +1 -0
  168. dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
  169. dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
  170. dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
  171. dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
  172. dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
  173. dw/server/ui/assets/sql-NEE52Syq.js +1 -0
  174. dw/server/ui/assets/st-DbInun42.js +1 -0
  175. dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
  176. dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
  177. dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
  178. dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
  179. dw/server/ui/assets/tsMode-D6u0XmOW.js +11 -0
  180. dw/server/ui/assets/twig-De2hgUGE.js +1 -0
  181. dw/server/ui/assets/typescript-BU6v-LMV.js +1 -0
  182. dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
  183. dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
  184. dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
  185. dw/server/ui/assets/workers-Cn7cTUKr.js +1 -0
  186. dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
  187. dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
  188. dw/server/ui/index.html +17 -0
  189. dw/server/updater.py +192 -0
  190. dw/settings.py +98 -0
  191. dw/shot_span_preflight.py +116 -0
  192. dw/shots.py +359 -0
  193. dw/slice_preflight.py +148 -0
  194. dw/step.py +187 -0
  195. dw/step_cache.py +442 -0
  196. dw/subfolders.py +107 -0
  197. dw/task_domains.py +307 -0
  198. dw/tasks/assess.py +826 -0
  199. dw/tasks/audio_transcription.py +88 -0
  200. dw/tasks/audio_utils.py +1862 -0
  201. dw/tasks/background_remover.py +43 -0
  202. dw/tasks/borders.py +113 -0
  203. dw/tasks/compose_text.py +74 -0
  204. dw/tasks/concat_videos.py +300 -0
  205. dw/tasks/depth_estimator.py +54 -0
  206. dw/tasks/diffusion_upscale.py +109 -0
  207. dw/tasks/dissolve_videos.py +342 -0
  208. dw/tasks/format_messages.py +24 -0
  209. dw/tasks/gather.py +173 -0
  210. dw/tasks/grade.py +97 -0
  211. dw/tasks/image_to_text.py +43 -0
  212. dw/tasks/image_utils.py +764 -0
  213. dw/tasks/interpolate_frames.py +252 -0
  214. dw/tasks/judge.py +68 -0
  215. dw/tasks/model_cache.py +55 -0
  216. dw/tasks/pair_audio.py +268 -0
  217. dw/tasks/qr_code.py +19 -0
  218. dw/tasks/restore_faces.py +175 -0
  219. dw/tasks/rife_model.py +192 -0
  220. dw/tasks/segment.py +121 -0
  221. dw/tasks/select.py +111 -0
  222. dw/tasks/speech_generation.py +228 -0
  223. dw/tasks/stabilize.py +129 -0
  224. dw/tasks/task.py +920 -0
  225. dw/tasks/tensor_image.py +57 -0
  226. dw/tasks/text_generation.py +169 -0
  227. dw/tasks/text_sections.py +80 -0
  228. dw/tasks/upscale.py +203 -0
  229. dw/tasks/video_utils.py +624 -0
  230. dw/tasks/zoe_depth.py +71 -0
  231. dw/teacache.py +381 -0
  232. dw/teacache_models.json +99 -0
  233. dw/test.py +29 -0
  234. dw/type_helpers.py +231 -0
  235. dw/validate.py +68 -0
  236. dw/variable_constraints.py +444 -0
  237. dw/variables.py +443 -0
  238. dw/video_extensions.py +141 -0
  239. dw/vram_estimate.py +116 -0
  240. dw/worker.py +764 -0
  241. dw/workflow.py +2007 -0
  242. dw/workflow_schema.json +1346 -0
  243. dw/workflow_sources.py +383 -0
  244. dw/workflows/h3_context_ir.json +57 -0
  245. dw/workflows/test.json +31 -0
  246. dw/workspace.py +730 -0
  247. dw_mcp/__init__.py +6 -0
  248. dw_mcp/__main__.py +133 -0
  249. dw_mcp/assets.py +336 -0
  250. dw_mcp/authoring.py +114 -0
  251. dw_mcp/catalog.py +360 -0
  252. dw_mcp/client.py +486 -0
  253. dw_mcp/diagnose.py +371 -0
  254. dw_mcp/exports.py +84 -0
  255. dw_mcp/guides.py +35 -0
  256. dw_mcp/media.py +638 -0
  257. dw_mcp/models.py +97 -0
  258. dw_mcp/prompts.py +104 -0
  259. dw_mcp/server.py +1343 -0
  260. dw_mcp/workspaces.py +212 -0
dw/worker.py ADDED
@@ -0,0 +1,764 @@
1
+ """
2
+ Persistent worker process for workflow execution.
3
+ Keeps models loaded in GPU memory across multiple runs.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import queue
9
+ import logging
10
+ import threading
11
+ import traceback
12
+ from typing import Dict, Any
13
+
14
+ # Add parent directory to path for imports
15
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
16
+
17
+ from dw.workflow import workflow_from_file, workflow_from_definition
18
+ from dw.step_cache import step_cache
19
+ from dw.assets import activate_asset_dir, deactivate_asset_dir
20
+ from dw.log_setup import setup_logging, set_log_level
21
+ from dw.settings import load_settings, resolve_path
22
+ from dw.events import RunContext, WorkflowCancelled
23
+ from dw import get_device_type, empty_device_cache, device_memory_stats
24
+ from dw.host_memory import (
25
+ host_memory_fields,
26
+ host_memory_stats,
27
+ pinned_host_memory_fields,
28
+ release_host_caches,
29
+ )
30
+
31
+
32
+ def _mb(value):
33
+ """A host memory reading for a message, or 'unknown' where the platform
34
+ could not take one."""
35
+ return f"{value:.0f} MB" if value is not None else "unknown"
36
+
37
+
38
+ def _job_scoped_peak_rss_mb(info, baseline_peak_mb):
39
+ """This job's own contribution to the process-lifetime peak (#272).
40
+
41
+ `host_memory_peak_rss_mb` is `ru_maxrss` - process-lifetime, never reset -
42
+ so in the persistent worker a small job run right after a heavy one
43
+ inherits the heavy job's high-water mark as its own. `baseline_peak_mb`
44
+ is that same process-lifetime figure read at this job's first phase
45
+ boundary; because ru_maxrss is monotonic non-decreasing, growth since
46
+ then is unambiguously this job's doing. A job that caused no growth at
47
+ all (it rode an earlier, larger peak) has no meaningful "job peak" to
48
+ report as a delta, so this floors at the job's current rss instead of
49
+ reporting a number that undersells what is resident right now.
50
+ """
51
+ peak = info.get("host_memory_peak_rss_mb")
52
+ if peak is None or baseline_peak_mb is None:
53
+ return None
54
+ growth = peak - baseline_peak_mb
55
+ if growth > 0:
56
+ return growth
57
+ return info.get("host_memory_rss_mb")
58
+
59
+
60
+ logger = logging.getLogger("dw.worker")
61
+
62
+ # Memory management constants
63
+ MEMORY_GROWTH_THRESHOLD_MB = 500 # Warn if GPU memory grows by more than this
64
+
65
+ # How often (in seconds) the main loop wakes up to check whether the parent
66
+ # process is still alive when no command has arrived. Short enough that an
67
+ # orphaned worker exits promptly, long enough to avoid busy-waiting.
68
+ COMMAND_POLL_TIMEOUT_SECONDS = 5
69
+
70
+
71
+ class WorkflowWorker:
72
+ """
73
+ Persistent worker that keeps workflows and models loaded in memory.
74
+ Monitors workflow file for changes and reloads when necessary.
75
+ """
76
+
77
+ def __init__(self, command_queue, result_queue, log_level="INFO"):
78
+ """
79
+ Initialize the worker with communication queues.
80
+
81
+ Args:
82
+ command_queue: Queue for receiving commands from REPL
83
+ result_queue: Queue for sending results back to REPL
84
+ log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
85
+ """
86
+ self.command_queue = command_queue
87
+ self.result_queue = result_queue
88
+
89
+ # Capture the parent PID at startup so the main loop can detect
90
+ # orphaning (parent died/was killed without sending "shutdown") and
91
+ # exit cleanly instead of blocking forever on the command queue.
92
+ self.parent_pid = os.getppid()
93
+
94
+ # Log to the same file the rest of dw uses - the worker is a separate
95
+ # process, and ConcurrentRotatingFileHandler exists to share it safely
96
+ settings = load_settings()
97
+ setup_logging(resolve_path(settings.log_filename), log_level)
98
+
99
+ # Workflow state. Identity (path, or id for inline definitions) decides
100
+ # when the model cache is dropped wholesale - switching workflows frees
101
+ # the old one's models before the new one loads
102
+ self.workflow_identity = None
103
+ self.pending_shutdown = False
104
+
105
+ # Pipeline cache - persists across runs
106
+ self.loaded_pipelines = {}
107
+ self.shared_components = {}
108
+ # Last run's step name -> pipeline cache key. Carried across commands
109
+ # so a rerun of the SAME workflow whose arguments changed what a step
110
+ # loads (a different LoRA scale, canvas or step count all change the
111
+ # identity) releases the old stack before the new one loads. Held on
112
+ # the worker rather than on the Workflow because a command builds a
113
+ # fresh Workflow every time, which is why the release never fired
114
+ # between jobs (#150)
115
+ self.prior_step_keys = {}
116
+
117
+ # Memory tracking
118
+ self.run_count = 0
119
+ self.last_memory_mb = 0
120
+
121
+ logger.info("Worker process initialized")
122
+
123
+ def run(self):
124
+ """
125
+ Main worker loop - processes commands until shutdown.
126
+ """
127
+ logger.info("Worker entering command loop")
128
+
129
+ try:
130
+ while True:
131
+ try:
132
+ # Wait for a command from the REPL, but poll with a
133
+ # timeout rather than blocking forever. If nothing
134
+ # arrives, check whether the parent process is still
135
+ # alive - if it has died (e.g. crashed or was killed)
136
+ # without sending "shutdown", we'd otherwise sit here
137
+ # forever as an orphaned, unkillable-by-normal-means
138
+ # child. Exit cleanly instead.
139
+ try:
140
+ command = self.command_queue.get(
141
+ timeout=COMMAND_POLL_TIMEOUT_SECONDS
142
+ )
143
+ except queue.Empty:
144
+ if self._parent_is_dead():
145
+ logger.info(
146
+ f"Parent process (pid {self.parent_pid}) is gone - "
147
+ "worker exiting"
148
+ )
149
+ break
150
+ continue
151
+
152
+ command_type = command.get("type")
153
+
154
+ logger.debug(f"Received command: {command_type}")
155
+
156
+ if command_type == "execute":
157
+ self._handle_execute(command)
158
+ if self.pending_shutdown:
159
+ self._handle_shutdown()
160
+ break
161
+ elif command_type == "cancel":
162
+ # Nothing running - a cancel that raced the run's end
163
+ logger.debug("Ignoring cancel with no workflow running")
164
+ elif command_type == "shutdown":
165
+ self._handle_shutdown()
166
+ break
167
+ elif command_type == "ping":
168
+ self._handle_ping()
169
+ elif command_type == "clear_memory":
170
+ self._handle_clear_memory()
171
+ elif command_type == "memory_status":
172
+ self._handle_memory_status()
173
+ elif command_type == "probe_cache":
174
+ self._handle_probe_cache(command)
175
+ else:
176
+ self.result_queue.put(
177
+ {
178
+ "type": "error",
179
+ "message": f"Unknown command type: {command_type}",
180
+ }
181
+ )
182
+
183
+ except KeyboardInterrupt:
184
+ logger.info("Worker interrupted by keyboard")
185
+ break
186
+ except Exception as e:
187
+ logger.error(f"Error processing command: {e}", exc_info=True)
188
+ self.result_queue.put(
189
+ {
190
+ "type": "error",
191
+ "message": f"Command processing error: {str(e)}",
192
+ "traceback": traceback.format_exc(),
193
+ }
194
+ )
195
+
196
+ finally:
197
+ logger.info("Worker shutting down")
198
+ self._cleanup_all()
199
+
200
+ def _handle_execute(self, command: Dict[str, Any]):
201
+ """
202
+ Execute a workflow, reusing loaded models if possible.
203
+
204
+ The command names the workflow either by path (workflow_path) or as an
205
+ inline definition (workflow, with an optional base_dir that relative
206
+ paths inside it resolve against). Models stay cached between runs of
207
+ the same workflow identity; pipelines are cached by what they load, so
208
+ an edited workflow keeps every pipeline whose definition is unchanged.
209
+ A {"type": "cancel"} command sent during execution stops the run at
210
+ the next step boundary or diffusion step.
211
+
212
+ Args:
213
+ command: Dictionary with workflow_path or workflow (+ base_dir),
214
+ arguments, output_dir, log_level, and optionally asset_dir -
215
+ the workspace library 'asset:' resolves against
216
+ """
217
+ arguments = command["arguments"]
218
+ output_dir = command["output_dir"]
219
+ log_level = command.get("log_level", "INFO")
220
+ # Bound before the load, so a failure or a cancellation still reports
221
+ # whatever the run had written by then - the steps that did complete
222
+ # are the first thing a failed long run is asked about
223
+ workflow = None
224
+ # Bound for the same reason: the failure path evicts against what the
225
+ # run touched, and a run can fail before it has a context at all
226
+ context = None
227
+
228
+ try:
229
+ set_log_level(log_level)
230
+
231
+ workflow, identity = self._load_workflow(command, output_dir)
232
+ workflow.validate(arguments=arguments)
233
+
234
+ # Switching to a different workflow frees the old one's models
235
+ # before the new one loads - on one accelerator, holding both is
236
+ # what runs out of memory
237
+ if identity != self.workflow_identity:
238
+ if self.workflow_identity is not None:
239
+ self.result_queue.put(
240
+ {
241
+ "type": "output",
242
+ "message": "Workflow changed - releasing cached models...",
243
+ }
244
+ )
245
+ self.result_queue.put(
246
+ {"type": "output", "message": self._cleanup_all()}
247
+ )
248
+ self.workflow_identity = identity
249
+
250
+ self.result_queue.put(
251
+ {"type": "workflow_loaded", "workflow_name": workflow.name}
252
+ )
253
+ self.result_queue.put(
254
+ {
255
+ "type": "output",
256
+ "message": f"Executing workflow: {workflow.name}",
257
+ }
258
+ )
259
+
260
+ # Progress events stream to the client as they happen; the
261
+ # watcher thread keeps the command queue live so cancel works
262
+ # mid-run. Each phase boundary also gets its own memory_info
263
+ # message (#273), so get_memory answers freshly mid-run instead
264
+ # of refusing with job_running for the run's whole duration; the
265
+ # first such reading is this job's baseline for the job-scoped
266
+ # peak field carried on every memory_info from here on (#272)
267
+ job_baseline = {"peak_rss_mb": None}
268
+
269
+ def _on_event(event):
270
+ self.result_queue.put({"type": "progress", **event})
271
+ if event.get("event") == "phase":
272
+ memory_info = self._get_memory_info()
273
+ if job_baseline["peak_rss_mb"] is None:
274
+ job_baseline["peak_rss_mb"] = memory_info.get(
275
+ "host_memory_peak_rss_mb"
276
+ )
277
+ memory_info["host_memory_job_peak_rss_mb"] = (
278
+ _job_scoped_peak_rss_mb(
279
+ memory_info, job_baseline["peak_rss_mb"]
280
+ )
281
+ )
282
+ self.result_queue.put({"type": "memory_info", "info": memory_info})
283
+
284
+ context = RunContext(on_event=_on_event)
285
+ watcher = self._watch_commands(context)
286
+ # Which workspace's assets this job's 'asset:' references resolve
287
+ # against. A server holds several workspaces and each has its own
288
+ # library, so the root travels with the job rather than being
289
+ # pinned in the environment the way the shared prompt library is
290
+ asset_token = (
291
+ activate_asset_dir(command["asset_dir"])
292
+ if command.get("asset_dir")
293
+ else None
294
+ )
295
+ try:
296
+ workflow.run(
297
+ arguments,
298
+ self.loaded_pipelines,
299
+ context=context,
300
+ prior_step_keys=self.prior_step_keys,
301
+ )
302
+ finally:
303
+ self._record_step_keys(workflow)
304
+ watcher.stop()
305
+ if asset_token is not None:
306
+ deactivate_asset_dir(asset_token)
307
+
308
+ self._evict_untouched_pipelines(context)
309
+
310
+ self.run_count += 1
311
+
312
+ # Aggressive memory cleanup after execution
313
+ self._cleanup_between_runs()
314
+
315
+ # Report memory status - the job's final reading, carrying the
316
+ # same job-scoped delta the phase-boundary readings above do
317
+ # (#272). A run with no phase events at all (job_baseline never
318
+ # set) reports host_memory_job_peak_rss_mb as null rather than
319
+ # guessing a baseline after the fact.
320
+ memory_info = self._get_memory_info()
321
+ memory_info["host_memory_job_peak_rss_mb"] = _job_scoped_peak_rss_mb(
322
+ memory_info, job_baseline["peak_rss_mb"]
323
+ )
324
+ self.result_queue.put({"type": "memory_info", "info": memory_info})
325
+
326
+ self.result_queue.put(
327
+ {
328
+ "type": "success",
329
+ "message": "Workflow completed successfully",
330
+ "run_count": self.run_count,
331
+ "manifest": getattr(workflow, "manifest", []),
332
+ }
333
+ )
334
+
335
+ except WorkflowCancelled:
336
+ self._cleanup_between_runs()
337
+ self.result_queue.put(
338
+ {
339
+ "type": "cancelled",
340
+ "message": "Workflow run cancelled",
341
+ "manifest": getattr(workflow, "manifest", []),
342
+ }
343
+ )
344
+ except Exception as e:
345
+ logger.error(f"Error executing workflow: {e}", exc_info=True)
346
+ failure = {
347
+ "type": "error",
348
+ "message": f"Workflow execution error: {str(e)}",
349
+ "traceback": traceback.format_exc(),
350
+ # The files the steps before the failure wrote are on
351
+ # disk; reporting them is what keeps a run that died at
352
+ # step five from looking like one that produced nothing
353
+ "manifest": getattr(workflow, "manifest", []),
354
+ }
355
+ # The exception's traceback reaches every frame between here and
356
+ # the failure, and those frames hold whatever a half-finished load
357
+ # had built - so a collection that runs while the exception is
358
+ # still live frees none of it. The report above is a formatted
359
+ # string by now, so nothing is lost by letting the traceback go
360
+ e.__traceback__ = None
361
+ del e
362
+ # Success and cancellation both reclaim; failure did neither, so a
363
+ # half-loaded pipeline and any variant the attempt superseded
364
+ # stayed resident and the next attempt loaded on top of them
365
+ self._evict_untouched_pipelines(context)
366
+ self._cleanup_between_runs()
367
+ self.result_queue.put(failure)
368
+
369
+ def _load_workflow(self, command: Dict[str, Any], output_dir: str):
370
+ """Build the Workflow a command names, and its cache identity."""
371
+ workflow_dir = command.get("workflow_dir")
372
+ if "workflow_path" in command and command["workflow_path"] is not None:
373
+ workflow_path = command["workflow_path"]
374
+ workflow = workflow_from_file(workflow_path, output_dir, workflow_dir)
375
+ return workflow, ("path", workflow_path)
376
+
377
+ workflow_data = command["workflow"]
378
+ workflow = workflow_from_definition(
379
+ workflow_data, output_dir, command.get("base_dir"), workflow_dir
380
+ )
381
+ return workflow, ("inline", workflow_data.get("id"))
382
+
383
+ def _watch_commands(self, context):
384
+ """Watch the command queue during a run so cancel and ping still work.
385
+
386
+ Returns an object with stop(); anything that is not cancel, ping or
387
+ shutdown is refused, since one workflow runs at a time.
388
+ """
389
+ stop_event = threading.Event()
390
+ worker = self
391
+
392
+ def watch():
393
+ while not stop_event.is_set():
394
+ try:
395
+ command = worker.command_queue.get(timeout=0.25)
396
+ except queue.Empty:
397
+ continue
398
+ command_type = command.get("type")
399
+ if command_type == "cancel":
400
+ logger.info("Cancel requested")
401
+ context.cancel()
402
+ worker.result_queue.put(
403
+ {"type": "output", "message": "Cancelling..."}
404
+ )
405
+ elif command_type == "ping":
406
+ worker._handle_ping()
407
+ elif command_type == "shutdown":
408
+ # Stop the run, then let the main loop see the shutdown
409
+ context.cancel()
410
+ worker.pending_shutdown = True
411
+ else:
412
+ worker.result_queue.put(
413
+ {
414
+ "type": "error",
415
+ "message": f"Cannot handle '{command_type}' while a "
416
+ "workflow is running",
417
+ }
418
+ )
419
+
420
+ thread = threading.Thread(target=watch, daemon=True, name="command-watcher")
421
+ thread.start()
422
+
423
+ class _Watcher:
424
+ def stop(self):
425
+ stop_event.set()
426
+ thread.join()
427
+
428
+ return _Watcher()
429
+
430
+ def _handle_shutdown(self):
431
+ """Handle graceful shutdown request."""
432
+ logger.info("Shutdown requested")
433
+ self._cleanup_all()
434
+ self.result_queue.put({"type": "shutdown_complete"})
435
+
436
+ def _handle_ping(self):
437
+ """Respond to ping to prove worker is alive."""
438
+ self.result_queue.put({"type": "pong", "run_count": self.run_count})
439
+
440
+ def _handle_clear_memory(self):
441
+ """Handle explicit memory clear request."""
442
+ logger.info("Memory clear requested")
443
+ self._cleanup_all()
444
+ memory_info = self._get_memory_info()
445
+ self.result_queue.put({"type": "memory_cleared", "info": memory_info})
446
+
447
+ def _handle_memory_status(self):
448
+ """Report current memory usage."""
449
+ memory_info = self._get_memory_info()
450
+ self.result_queue.put({"type": "memory_status", "info": memory_info})
451
+
452
+ def _handle_probe_cache(self, command: Dict[str, Any]):
453
+ """Which steps the step cache would serve for a run of this command
454
+ - the plan's cached_steps (#85). Same fields as an execute command;
455
+ loads the workflow, executes nothing. A failure answers
456
+ cached: null with the reason rather than an error message, since
457
+ an unknown answer is a valid plan and a crashed probe is not. The
458
+ command's probe_id is echoed so a reply that arrives after its
459
+ caller gave up is not read as the answer to the next probe.
460
+ """
461
+ probe_id = command.get("probe_id")
462
+ try:
463
+ workflow, _ = self._load_workflow(command, command["output_dir"])
464
+ asset_token = (
465
+ activate_asset_dir(command["asset_dir"])
466
+ if command.get("asset_dir")
467
+ else None
468
+ )
469
+ try:
470
+ cached = workflow.cache_hits(command.get("arguments") or {})
471
+ finally:
472
+ if asset_token is not None:
473
+ deactivate_asset_dir(asset_token)
474
+ self.result_queue.put(
475
+ {"type": "probe_cache", "probe_id": probe_id, "cached": cached}
476
+ )
477
+ except Exception as e:
478
+ logger.debug(f"Cache probe failed: {e}")
479
+ self.result_queue.put(
480
+ {
481
+ "type": "probe_cache",
482
+ "probe_id": probe_id,
483
+ "cached": None,
484
+ "error": str(e),
485
+ }
486
+ )
487
+
488
+ def _record_step_keys(self, workflow):
489
+ """Remember which pipeline each step loaded, for the next command.
490
+
491
+ Merged rather than replaced: a run that failed at step two never
492
+ reached step five, and forgetting step five's key would leave the
493
+ variant it loaded last time with nothing to release it. Merged means
494
+ never pruned, though, so this map outlives the workflow that wrote a
495
+ given entry - a name here can belong to an earlier, different job's
496
+ step. A stale entry is harmless because Workflow.create_step_action
497
+ judges "still shared" on the running steps' CURRENT keys
498
+ (Workflow._running_pipeline_keys), never on this map's other
499
+ entries: a key is held only while another step of the executing run
500
+ loads under it now. A name this map remembers from another workflow
501
+ is not a running step, and a sibling whose key moved with this one's
502
+ no longer claims the old key, so neither saves it from release.
503
+ """
504
+ keys = getattr(workflow, "_pipeline_keys_by_step", None)
505
+ if keys:
506
+ self.prior_step_keys.update(keys)
507
+
508
+ def _evict_untouched_pipelines(self, context):
509
+ """Drop cached pipelines this run no longer touched.
510
+
511
+ An edited workflow that removed or redefined a step leaves those
512
+ behind, and so does a failed attempt whose superseded variant nothing
513
+ will ever load again. A run that failed before it had a context
514
+ touched nothing this can be judged against, so nothing is dropped -
515
+ the caller's own cleanup still runs.
516
+ """
517
+ if context is None:
518
+ return
519
+ for cache_key in list(self.loaded_pipelines):
520
+ if cache_key not in context.touched_pipelines:
521
+ logger.info("Evicting cached pipeline no longer in workflow")
522
+ del self.loaded_pipelines[cache_key]
523
+
524
+ def _cleanup_between_runs(self):
525
+ """
526
+ Aggressive memory cleanup between workflow runs.
527
+ Keeps models loaded but cleans up intermediate tensors and garbage.
528
+ """
529
+ import gc
530
+
531
+ logger.debug("Performing inter-run memory cleanup")
532
+
533
+ # Force garbage collection
534
+ gc.collect()
535
+
536
+ # Clean up GPU cache if available (CUDA or MPS). Don't synchronize
537
+ # here as it's expensive and unnecessary.
538
+ try:
539
+ empty_device_cache()
540
+ except Exception as e:
541
+ logger.warning(f"Could not clean GPU cache: {e}")
542
+
543
+ # release_host_caches() only touches blocks nothing is using - the
544
+ # pinned-host staging buffers of a step's group_offload and the
545
+ # glibc arenas a released pipeline's weights were read into - so it
546
+ # is safe here even though loaded_pipelines/shared_components are
547
+ # still warm for the next run. Without it those two caches are the
548
+ # gap between what a job's own cleanup releases and what an explicit
549
+ # clear_memory does (#368): a released pipeline's GPU memory drops at
550
+ # `release_pipeline`, but the host arenas it staged through, plus any
551
+ # SDNQ/group_offload residue from steps that never released at all
552
+ # because the job ended first, stay resident until something calls
553
+ # this.
554
+ released = release_host_caches()
555
+ if released:
556
+ logger.info(f"Inter-run cleanup returned {released:.0f} MB to the OS")
557
+
558
+ # Check for memory growth
559
+ current_memory = self._get_gpu_memory_mb()
560
+ if current_memory > 0:
561
+ if self.last_memory_mb > 0:
562
+ growth = current_memory - self.last_memory_mb
563
+ if growth > MEMORY_GROWTH_THRESHOLD_MB:
564
+ logger.warning(
565
+ f"GPU memory grew by {growth:.1f}MB "
566
+ f"({self.last_memory_mb:.1f}MB -> {current_memory:.1f}MB)"
567
+ )
568
+ self.last_memory_mb = current_memory
569
+
570
+ logger.debug("Inter-run cleanup complete")
571
+
572
+ def _cleanup_all(self):
573
+ """
574
+ Complete cleanup - clear all cached models and components.
575
+ Called when workflow changes or on shutdown.
576
+
577
+ Returns:
578
+ One line saying what host memory looks like on the other side of
579
+ it. The caller decides whether that reaches the client: on a
580
+ workflow switch it is the answer to "what did the last job leave
581
+ behind", which is the question a later OOM is asked (#98).
582
+ """
583
+ import gc
584
+ from .tasks.model_cache import clear_model_cache
585
+
586
+ logger.info("Performing full cleanup")
587
+
588
+ # Clear pipeline cache and any models task handlers cached
589
+ self.loaded_pipelines.clear()
590
+ self.shared_components.clear()
591
+ # The keys addressed entries that are now gone; keeping them would
592
+ # have a later step chase a release that has already happened
593
+ self.prior_step_keys.clear()
594
+ clear_model_cache()
595
+ # Drop cached step results too - stale results would otherwise
596
+ # survive a memory clear and keep getting served for steps whose
597
+ # models/components were just evicted
598
+ step_cache.clear()
599
+
600
+ # Reset state
601
+ self.run_count = 0
602
+ self.last_memory_mb = 0
603
+
604
+ # Force garbage collection multiple times
605
+ for _ in range(3):
606
+ gc.collect()
607
+
608
+ # Aggressive GPU cleanup (CUDA or MPS) - empty cache and synchronize
609
+ # to ensure all operations complete before we go on to reset stats.
610
+ try:
611
+ empty_device_cache(synchronize=True)
612
+
613
+ # Try to reset CUDA memory stats - no MPS equivalent exists
614
+ if get_device_type() == "cuda":
615
+ import torch
616
+
617
+ try:
618
+ torch.cuda.reset_peak_memory_stats()
619
+ torch.cuda.reset_accumulated_memory_stats()
620
+ except (RuntimeError, AttributeError) as e:
621
+ logger.debug(f"Could not reset memory stats: {e}")
622
+
623
+ except Exception as e:
624
+ logger.warning(f"Could not perform GPU cleanup: {e}")
625
+
626
+ # Dropping the references above frees the weights inside this
627
+ # process; it does not hand the arenas they were read into back to
628
+ # the kernel. On a template that needs 96% of host RAM the residue of
629
+ # the *previous* job is what the OOM killer arrives for, minutes into
630
+ # a run that is itself perfectly legal (#98) - so ask for it back
631
+ # here, where a different model family is about to be loaded, and say
632
+ # what came back rather than leaving it to be inferred from a later
633
+ # reading
634
+ released = release_host_caches()
635
+ stats = host_memory_stats()
636
+ summary = (
637
+ f"Released cached models: host RSS {_mb(stats.get('rss_mb'))}, "
638
+ f"{_mb(stats.get('available_mb'))} available"
639
+ + (f" ({released:.0f} MB returned to the OS)" if released else "")
640
+ )
641
+
642
+ logger.info(f"Full cleanup complete. {summary}")
643
+ return summary
644
+
645
+ def _parent_is_dead(self) -> bool:
646
+ """
647
+ Check whether the process that spawned this worker is still around.
648
+
649
+ On POSIX, a process gets reparented to init (traditionally pid 1,
650
+ though some systems use a subreaper) once its original parent exits,
651
+ so a changed getppid() is the standard signal that we've been
652
+ orphaned.
653
+
654
+ Returns:
655
+ True if the parent appears to be gone, False otherwise.
656
+ """
657
+ current_ppid = os.getppid()
658
+ return current_ppid != self.parent_pid or current_ppid == 1
659
+
660
+ def _get_gpu_memory_mb(self) -> float:
661
+ """
662
+ Get current GPU memory usage in MB.
663
+
664
+ Returns:
665
+ Memory usage in MB, or 0 if not available
666
+ """
667
+ try:
668
+ return device_memory_stats()["allocated_mb"]
669
+ except (RuntimeError, AttributeError) as e:
670
+ logger.debug(f"Could not get GPU memory: {e}")
671
+ return 0.0
672
+
673
+ def _get_memory_info(self) -> Dict[str, Any]:
674
+ """
675
+ Get detailed memory information.
676
+
677
+ Returns:
678
+ Dictionary with memory statistics
679
+ """
680
+ info = {
681
+ "run_count": self.run_count,
682
+ "gpu_available": False,
683
+ "gpu_memory_allocated_mb": 0.0,
684
+ "gpu_memory_reserved_mb": 0.0,
685
+ "gpu_memory_free_mb": 0.0,
686
+ "gpu_device_name": None,
687
+ }
688
+
689
+ # Host memory beside the device figures: the offloading these
690
+ # workflows use keeps weights in RAM by design, so a leak - or a run
691
+ # that simply has not let go of a model - shows here and nowhere
692
+ # else. Measured inside the worker, so the process figures are the
693
+ # worker's own
694
+ info.update(host_memory_fields())
695
+ # And what torch's pinned-host allocator is sitting on, which
696
+ # `rss_mb` includes without saying so: on a group-offloaded workflow
697
+ # it is GB of staging buffers, and it is the half of a worker's host
698
+ # residency no figure named before (#98)
699
+ info.update(pinned_host_memory_fields())
700
+
701
+ try:
702
+ stats = device_memory_stats()
703
+ info["gpu_available"] = stats["available"]
704
+ info["gpu_device_name"] = stats["device_name"]
705
+ info["gpu_memory_allocated_mb"] = stats["allocated_mb"]
706
+ info["gpu_memory_reserved_mb"] = stats["reserved_mb"]
707
+ # free/total are only ever None when CUDA's mem_get_info call
708
+ # itself failed - leave gpu_memory_free_mb at its 0.0 default and
709
+ # gpu_memory_total_mb unset in that case, same as before.
710
+ if stats["free_mb"] is not None:
711
+ info["gpu_memory_free_mb"] = stats["free_mb"]
712
+ if stats["total_mb"] is not None:
713
+ info["gpu_memory_total_mb"] = stats["total_mb"]
714
+ except (ImportError, RuntimeError, AttributeError) as e:
715
+ logger.debug(f"Could not access GPU: {e}")
716
+
717
+ # The step cache's own eviction accounting (#418) - otherwise a
718
+ # phantom-bytes regression in it has no symptom short of the cache
719
+ # collapsing to one entry, thousands of stale drops away
720
+ info["step_cache"] = step_cache.stats()
721
+
722
+ return info
723
+
724
+
725
+ def worker_main(command_queue, result_queue, log_level="INFO"):
726
+ """
727
+ Entry point for worker process.
728
+
729
+ Args:
730
+ command_queue: Queue for receiving commands
731
+ result_queue: Queue for sending results
732
+ log_level: Logging level
733
+ """
734
+ try:
735
+ worker = WorkflowWorker(command_queue, result_queue, log_level)
736
+ worker.run()
737
+ except Exception as e:
738
+ logger.error(f"Worker crashed: {e}", exc_info=True)
739
+ try:
740
+ result_queue.put(
741
+ {
742
+ "type": "worker_crashed",
743
+ "message": str(e),
744
+ "traceback": traceback.format_exc(),
745
+ }
746
+ )
747
+ except (OSError, RuntimeError) as queue_error:
748
+ logger.error(f"Failed to send crash notification to queue: {queue_error}")
749
+ sys.exit(1)
750
+
751
+
752
+ if __name__ == "__main__":
753
+ # For testing - won't normally be run directly
754
+ import multiprocessing
755
+
756
+ # Set spawn method for CUDA compatibility
757
+ if multiprocessing.get_start_method(allow_none=True) != "spawn":
758
+ multiprocessing.set_start_method("spawn", force=True)
759
+
760
+ cmd_queue = multiprocessing.Queue()
761
+ res_queue = multiprocessing.Queue()
762
+
763
+ print("Starting worker in test mode...")
764
+ worker_main(cmd_queue, res_queue, "DEBUG")