From 3270307c568fc4606a05e8f93110f44f17f2582a Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sat, 12 Sep 2026 19:10:53 -0500 Subject: [PATCH 01/34] docs(proposal): review pass on acknowledged-cost binding; ignore Claude worktrees Fingerprint leaves output:latest unpinned, drops iterations, adds cached_steps and per-device estimate, nullable download sizes, and notes the 409 must be server-side since dw_mcp is an httpx client. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + docs/proposals/acknowledged-cost-binding.md | 71 +++++++++++++++++---- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 0c9a77e2..253a8407 100644 --- a/.gitignore +++ b/.gitignore @@ -165,3 +165,6 @@ workflows/*/assets/ /output/ /*.mp4 /*.wav + +# Claude Code worktrees +.claude/worktrees/ diff --git a/docs/proposals/acknowledged-cost-binding.md b/docs/proposals/acknowledged-cost-binding.md index ff0a41af..ffd5a400 100644 --- a/docs/proposals/acknowledged-cost-binding.md +++ b/docs/proposals/acknowledged-cost-binding.md @@ -17,11 +17,17 @@ It is a boolean, checked in the MCP layer only: - `dw_mcp/diagnose.py:45` - `run_workflow` raises `COST_REFUSAL` unless `acknowledged_cost` is truthy; `rerun_job` the same at `:248`. -- `dw_mcp/models.py` / `dw_mcp/workspaces.py` - `download_model`, - `delete_model`, `update_diffusers`, `delete_workspace` the same. +- `dw_mcp/models.py` / `dw_mcp/workspaces.py` / `dw_mcp/prompts.py` - + `download_model`, `delete_model`, `update_diffusers`, `delete_workspace`, + `enhance_prompt` the same. - `POST /api/jobs` does **not** require it. The gate is an agent-behaviour gate - "say the number out loud to a human before you spend it" - not a resource guard, and the HTTP API and the web UI queue work without it. + The one HTTP route that carries an acknowledgement is + `DELETE /api/workspaces/{name}` (`acknowledged=true`, `app.py:1415`), and + it does so because the server is the only party that knows what the + deletion would remove - the same reason the check below has to live + server-side. Nothing is bound. The flag records that *something* was consented to, not what. The figure the agent quoted comes from the catalog's `cost` block @@ -41,7 +47,9 @@ was honest when it was made: this - and is advisory, unenforced, and set on no template yet. 2. **Cartesian product.** Several `previous_result:` references in one step multiply (4 images x 3 masks = 12 iterations), and the multiplicands can - come from arguments. + come from arguments. Only some of them are knowable before the run: a + multiplicand that is `num_images_per_prompt` is, one that is "however + many frames the previous step produced" is not. 3. **Plain numeric arguments.** `num_images_per_prompt`, `num_frames`, `num_inference_steps` scale the run roughly linearly and are ordinary variables. @@ -55,6 +63,11 @@ was honest when it was made: step cache; the rerun of a "finished instantly" job is a full generation. 7. **Sub-workflows.** A `composes-workflows` step's real cost is the child's. +And one that goes the other way: a seeded workflow whose steps are in the +step cache costs nothing, which is why "Run again" finishes instantly. An +estimate that ignores the cache over-quotes the common case, and the +`new_seed` rerun in case 6 is exactly the flip from cached to full. + So the premise in #85 holds: the flag stays `true` while the work grows, and nothing at queue time compares what will run against what was acknowledged. @@ -87,23 +100,43 @@ Beside `valid`/`errors`/`warnings`, on a valid answer: "plan": { "fingerprint": "sha256:9f13…", "steps": 14, - "iterations": 14, + "cached_steps": 0, "list_entries": {"shots": 12}, "downloads_required": [{"repo": "MiniMaxAI/MiniMax-H3", "gb": 41.2}], - "estimate": {"minutes": 38.0, "basis": "per_entry", "confidence": "measured"} + "estimate": {"minutes": 38.0, "basis": "per_entry", "device": "cuda"} } ``` - `fingerprint` is a SHA-256 over the plan-shaping inputs: the realized - workflow (`realize.py`'s document, minus the seed), the expanded step - names, and the iteration count per step. It changes when the *work* - changes and not when something cosmetic does. + workflow (`realize.py`'s document) with the seed removed and each + `output:.../latest/...` reference left *unpinned*, plus the expanded step + names. It changes when the *work* changes and not when something cosmetic + does. The seed is excluded because a fresh seed is the same work; `latest` + is left unpinned because a run finishing between the validate call and + the queue call would otherwise change the fingerprint of identical work, + and the human-in-the-loop gap is exactly where that happens. Inlined + prompt text stays in: a prompt edited in between is a different run. +- No `iterations` field: the Cartesian count is static only when every + multiplicand is an argument (case 2), and a number that is sometimes a + guess is worse than none. `list_entries` is always exact. +- `cached_steps` is how many steps the step cache would answer for this + workflow id and seed - the same check `Workflow.run` makes, made early - + and the estimate is over the remaining steps only. A `new_seed` rerun + reports zero. - `estimate.basis` is one of `per_entry` (fixed + per-entry x N), `catalog` (the stored `cost.minutes`, defaults only), or `unknown` (an inline workflow with no cost block) - the honesty is in the field, - not in a fabricated number. + not in a fabricated number. `cost` is measured per device, so `device` + names the entry used; when the catalog has no entry for the accelerator + that is serving, `basis` is `other_device` and `minutes` is the nearest + entry's, which is a warning and not a quote. A `composes-workflows` step + contributes its child's catalog cost. - `downloads_required` closes case 4 on its own, and is useful with or - without the rest of this proposal. + without the rest of this proposal. Which repos are missing is a local + question (`scan_models` against the `from_pretrained` targets the + realized workflow names); `gb` needs a hub call + (`model_info(files_metadata=True)`) and is `null` when the hub is + unreachable rather than a reason for validate to fail. ### 2. `acknowledged_cost` accepts what was acknowledged @@ -120,12 +153,21 @@ estimate exceeds the acknowledged minutes by more than a tolerance (25%, settable), naming both figures and what changed. The agent's recovery is to re-quote to the user - which is the behaviour the gate was for. +The check has to be the server's: `dw_mcp` is an `httpx` client of the +HTTP API (`DwClient`), so the only alternative is for the MCP tool to call +validate and then jobs and compare in between, which is a second round trip +with a race in it. That means `POST /api/jobs` grows an *optional* +`acknowledged_cost` field either way - the object is the only form it acts +on; a bare `true` it merely records. `rerun_job` computes its plan from the +stored job's arguments, with the seed swapped when `new_seed` is set. + ### 3. Bare `true` stays legal, and says so An unbound `true` keeps working - the web UI, the CLI-equivalent callers and every existing script depend on it, and a hard requirement would be a breaking change to every MCP consumer. But the job records which kind of -acknowledgement it got (`acknowledged: "boolean" | "bound"`), so "was this +acknowledgement it got (`acknowledged: "none" | "boolean" | "bound"` - +`none` is the web UI and every HTTP caller that sends nothing), so "was this run consented to at its actual size?" is answerable after the fact, and the skills can teach the bound form as the normal one. @@ -159,8 +201,11 @@ skills can teach the bound form as the normal one. `plan` from `validate_workflow`. - Docs: SERVER.md, MCP.md, WORKFLOW_GUIDE.md's authoring section, and the three plugin skills' cost step. -- Tests: fingerprint stability against cosmetic edits, change under a longer - list, the 409, the tolerance, and the boolean path unchanged. +- Tests: fingerprint stability against cosmetic edits, against a new seed, + and against a new `latest` run landing between validate and queue; change + under a longer list and under an edited stored prompt; `cached_steps` + against a warm step cache; `downloads_required` with the hub unreachable; + the 409, the tolerance, and the boolean path unchanged. Roughly a two-stage piece of work: stage 1 the plan on validate (useful on its own), stage 2 the binding and the 409. From 2741be52b7a493014c8bed5e78899d4fc7a0115f Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sat, 12 Sep 2026 19:22:33 -0500 Subject: [PATCH 02/34] docs(spec): acknowledged-cost binding design (#85) Co-Authored-By: Claude Opus 5 (1M context) --- ...-09-12-acknowledged-cost-binding-design.md | 428 ++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-12-acknowledged-cost-binding-design.md diff --git a/docs/superpowers/specs/2026-09-12-acknowledged-cost-binding-design.md b/docs/superpowers/specs/2026-09-12-acknowledged-cost-binding-design.md new file mode 100644 index 00000000..6cf5f3d0 --- /dev/null +++ b/docs/superpowers/specs/2026-09-12-acknowledged-cost-binding-design.md @@ -0,0 +1,428 @@ +# Acknowledged-cost binding: design + +Date: 2026-09-12. Proposal: [docs/proposals/acknowledged-cost-binding.md](../../proposals/acknowledged-cost-binding.md), +answering issue #85. Branch: `cost-plan`, off `develop` at 2d87005. + +## Goal + +The free pre-flight (`POST /api/validate`, MCP `validate_workflow`) answers +with a *plan*: what the run will actually execute for the arguments given, +what it will have to download first, and a cost estimate whose basis is +named. An agent quotes that instead of the catalog's defaults-only `cost`. +Then a caller may bind its acknowledgement to that plan, and the server +refuses to queue a run whose shape no longer matches what was acknowledged. + +Two stages, each shippable alone: + +- **Stage 1** - `plan` on the validate answer, surfaced over MCP, taught by + the skills. +- **Stage 2** - the bound `acknowledged_cost` form, the 409, the job record, + and the worker-side step-cache probe. + +## Non-goals + +- A numeric budget check. `minutes` is recorded, never compared; a tolerance + needs `per_entry` measured on the templates, and none carries it yet. +- Requiring an acknowledgement over HTTP. The web UI and every HTTP caller + keep queuing without one; the gate stays an agent-behaviour gate. +- Run-time metering or aborting a run that overruns its estimate. +- Pricing the Cartesian product of `previous_result:` references. The count + is static only when every multiplicand is an argument, and a number that + is sometimes a guess is worse than none. +- Changing what `list_workflows` reports. + +## Global constraints + +- Model knowledge stays out of engine code: no repo name, no per-model + minute figure. Every number comes from a workflow's `cost` block. +- `dw/plan.py` imports nothing from `dw.server` or `dw.worker`, and touches + the worker only through a callable the caller hands it (stage 2). +- Plan construction is best effort at the API: a failure is logged and + answered as `plan: null`; the validate verdict is the schema's, never the + planner's. +- Every path the planner reads goes through the resolvers the run uses + (`realize_workflow`, the sub-workflow digest's confinement, `scan_models`). + It opens no file by a path it computed itself. +- The bare-boolean and absent acknowledgement paths are byte-for-byte + unchanged in behaviour. +- Nothing here names a model in a test either: fixtures declare their own + `cost` blocks and their own `model_name` strings. + +--- + +# Stage 1: the plan on validate + +## 1. `dw/plan.py` + +```python +def build_plan( + definition, + arguments, + *, + base_dir=None, + prompt_dir=None, + output_root=None, + workflow_dir=None, + device, + cache_dir=None, + cache_probe=None, # stage 2 + lookup_sizes=True, +): + """What a run of `definition` with `arguments` will execute and cost.""" +``` + +Returns: + +```json +{ + "fingerprint": "sha256:<64 hex>", + "steps": 14, + "list_entries": {"shots": 12}, + "cached_steps": null, + "downloads_required": [ + {"repo": "org/model", "gb": 41.2}, + {"repo": null, "url": "https://…/x.safetensors", "gb": null} + ], + "estimate": { + "minutes": 38.0, + "basis": "per_entry", + "device": "cuda", + "measured_on": "RTX 4090", + "partial": false + } +} +``` + +### 1.1 Realization and expansion + +- `realized, _ = realize_workflow(definition, arguments, seed=0, base_dir=…, + prompt_dir=…, output_root=…, workflow_dir=…, pin_outputs=False)`. + `pin_outputs` is a new keyword on `realize_workflow`, default `True`, + which `Workflow.run` never sets; with `False`, `_pin_output` returns the + reference as written. Prompt inlining still happens. +- `expanded = expand_for_each(realized)` - the member list a run executes. + `steps` is `len(expanded["steps"])`. +- `list_entries` is `{variable: len(value)}` for each `for_each` in the + *unexpanded* realized definition whose value is `variable:`, read + from the folded `variables` block. A literal list is not an argument and + is not listed. +- `realize_workflow` raises on an undeclared argument or an uncoercible + value; the API calls `build_plan` only after `argument_errors` passed, + so this is not reached there. `build_plan` lets it propagate. + +### 1.2 Fingerprint + +SHA-256 of `json.dumps(doc, sort_keys=True, separators=(",", ":"), +ensure_ascii=False)` where `doc` is `expanded` after: + +1. the top-level `seed` removed; +2. if the definition *as written* had `seed: "variable:"`, the folded + value of `variables.` removed (the key stays, its `default` is + deleted) - otherwise the seed survives in the variables block; +3. any `seed` key at a step or a step's `pipeline` removed. A step seed that + was `variable:` was substituted by realization, which + is why rule 2 removes the value at its source as well; +4. `cost`, `description`, `summary` and `configures` removed at the top level + - documentation, not work. + +Presented as `"sha256:" + hexdigest`. The expanded step names are inside the +document, so the member set is covered without a second input. + +What must hold (tests): + +| Same fingerprint | Different fingerprint | +|---|---| +| a different seed (top level or step) | a longer or shorter `for_each` list | +| key order in the JSON file | a stored prompt whose text changed | +| whitespace, `description`, `cost` edits | a changed `num_frames`/`num_inference_steps` | +| a new run landing under `output:…/latest/…` | a different asset name | +| the same arguments given in a different order | a step added, removed or renamed | + +### 1.3 Estimate + +Input: the workflow's `cost` list (schema: `[{device, name?, vram_gb, +minutes, per_entry?}]`) and `device`, the backend that is serving +(`get_device_type(get_device())` - `cuda`, `mps` or `cpu`, never an +index). + +1. No `cost` list, or an empty one → `{"minutes": null, "basis": + "unknown", "device": device, "measured_on": null, "partial": false}`. +2. Pick the first entry whose `device` equals the serving backend. None → + the first entry in the list, and `basis` is `other_device`; its + `minutes` is reported so the agent has a figure to scale, and + `measured_on` says what it was measured on. +3. With an entry chosen: if it carries `per_entry` and + `per_entry.variable` is a key of `list_entries`, `minutes = max(0, + (entry.minutes - per.minutes * per.entries) + per.minutes * N)` with + `N = list_entries[variable]`, `basis: per_entry`. Otherwise + `minutes = entry.minutes`, `basis: catalog` (or `other_device` from + step 2 - `other_device` wins over `per_entry`, since scaling a figure + from the wrong card compounds the error). +4. **Sub-workflows.** For every expanded step whose `workflow` is + `{"path": …}` and not `builtin:`, load the child through the same + resolution `_record_sub_workflows` uses (relative to `base_dir`, + confined to `workflow_dir`) and apply rules 1-3 to *its* `cost` with the + parent's `device`; the child's `for_each` is not re-priced + (`list_entries` is the parent's). Add its minutes; if the child had no + cost, or was unreadable, set `partial: true`. A builtin adds nothing and + sets nothing - it is the parent's to price. +5. `minutes` is rounded to one decimal. + +`measured_on` is the entry's `name`, or `null`. + +### 1.4 Downloads required + +- Collect every `from_pretrained_arguments.model_name` string anywhere in + `expanded` (pipelines, components, and inside a sub-workflow loaded in + 1.3), plus every `from_pretrained_arguments.from_single_file` value that + is a URL (`validate_url` accepts it). Deduplicate, preserve first-seen + order. +- `present = {repo["repo_id"] for repo in scan_models(cache_dir)["repos"]}`. + A `model_name` in `present` is dropped. A `model_name` that is a local + directory (`os.path.isdir` after the run's own resolution) is dropped - + it is not on the hub. +- Each remaining hub name → `{"repo": name, "gb": size}`; each URL → + `{"repo": null, "url": url, "gb": null}`. +- `size`: when `lookup_sizes` is true, `huggingface_hub.model_info(name, + files_metadata=True)` under a 5-second timeout, `sum(s.size for s in + info.siblings if s.size)` in GiB to one decimal. Any exception, a + missing token for a gated repo, or a missing `size` → `null`. The + planner never raises here and never logs above `debug` - an offline box + is a state, not an error. +- `lookup_sizes=False` skips the hub entirely; the API passes it through + from `?sizes=false` on validate, for a caller that wants the answer fast. + +### 1.5 `cached_steps` + +Stage 1 answers `null` always. The field exists from the start so the +answer's shape does not change in stage 2. + +## 2. `POST /api/validate` + +After the existing `answer = {"valid": True, …}` is built: + +```python +try: + answer["plan"] = build_plan(definition, request.arguments, base_dir=…, + prompt_dir=…, output_root=workspace.outputs, workflow_dir=…, + device=get_device_type(get_device()), lookup_sizes=request_sizes) +except Exception: + logger.exception("Plan could not be built") + answer["plan"] = None +``` + +- `definition` here is the definition already resolved (from the file or + the inline body); `base_dir`/`workflow_dir` are exactly what the + `candidate` was constructed with, so the plan sees the paths the run will. +- The `cost` list comes from the definition itself (`definition.get("cost")`) + - the same place the listing reads it - so an inline workflow that carries + a `cost` block is priced too. +- `sizes` is a new optional query parameter, default `true`. +- An invalid answer carries no `plan` key at all. + +## 3. MCP + +- `dw_mcp/authoring.py: validate_workflow` returns the server's answer + unchanged; `plan` rides along. +- `dw_mcp/server.py`: the `validate_workflow` docstring gains a paragraph: + quote `plan.estimate.minutes` with its `basis`, name each + `downloads_required` entry as a separate line item ("and 41 GB of weights + this box does not have"), treat `basis: unknown`/`other_device` as "no + measured figure". `COST_REFUSAL` (`diagnose.py`) says the same in one + sentence: the number to say out loud is the plan's, not the listing's. +- `get_guide`'s authoring section (WORKFLOW_GUIDE.md) gains the same rule. + +## 4. Docs and skills + +- `docs/SERVER.md`: the validate answer's `plan` block, field by field. +- `docs/MCP.md`: the quoting rule. +- `docs/WORKFLOW_GUIDE.md` "Authoring a workflow from an agent": the + quoting rule; `CLAUDE.md`'s type-system list is unchanged (nothing here is + a reference convention). +- `plugins/dw/skills/{minimax-h3,minimax-music3,ltx-2.5}/SKILL.md`: the + "quote cost" step reads "validate with the arguments you will run, quote + `plan.estimate`, and name any `downloads_required`" instead of "quote the + listing's `cost`". `tests/test_plugin_skills.py` keeps pinning the numbers + the skills state; the rule change adds no number. + +## 5. Tests (stage 1) + +`tests/test_plan.py` (pure, no server): + +- fingerprint invariants - every cell of the table in 1.2, each as its own + test, using a fixture workflow with a `for_each` over a list variable, a + `prompt:` reference into a tmp prompt library, and an `output:…/latest/…` + reference into a tmp output root with two runs. +- estimate: `unknown`, `catalog`, `per_entry` arithmetic (including the + floor at 0), `other_device` beating `per_entry`, sub-workflow summing, + `partial` on a cost-less child, builtin ignored. +- downloads: a stub `scan_models` (monkeypatched) with one of two repos + present; a local-directory `model_name` dropped; a URL listed; `model_info` + raising → `gb: null`; `lookup_sizes=False` never calls it. +- `realize_workflow(pin_outputs=False)` leaves `latest` as written. + +`tests/test_server.py`: + +- `plan` present with the documented keys on a valid answer; absent on an + invalid one; `null` when `build_plan` raises (monkeypatched); `?sizes=false` + reaches the planner. + +--- + +# Stage 2: binding, the 409 and the cache probe + +## 6. The bound acknowledgement + +`JobRequest` and `RerunRequest` gain: + +```python +acknowledged_cost: bool | AcknowledgedCost | None = None +``` + +```python +class AcknowledgedCost(BaseModel): + fingerprint: str # "sha256:…" from plan.fingerprint + minutes: float | None = None # plan.estimate.minutes, recorded only + downloads: list[str] = [] # plan.downloads_required[*].repo, non-null ones +``` + +The form is classified once, in the route: + +| Value | `acknowledged` | +|---|---| +| absent, `null`, `false` | `none` | +| `true` | `boolean` | +| object | `bound` | + +The server never refuses for `none` or `boolean`; refusing without an +acknowledgement remains `dw_mcp`'s job, exactly as today. + +## 7. The check + +For `bound` only, after the existing argument and reference checks and +before `manager.submit`: + +1. `current = build_plan(...)` with the same inputs validate would use for + this request (for rerun: the stored job's definition and arguments, with + the fresh seed folded when `new_seed` - which cannot change the + fingerprint, by 1.2). +2. If `build_plan` raises: **409** with detail "the run could not be planned, + so a bound acknowledgement cannot be checked; acknowledge with `true` + or validate again" - never a silent pass. +3. If `current["fingerprint"] != acknowledged.fingerprint`: **409**. +4. If any `repo` in `current["downloads_required"]` (non-null) is not in + `acknowledged.downloads`: **409**. A download that has since vanished + from the requirement is not a refusal. + +The 409 body: + +```json +{ + "detail": "The run's shape changed since it was acknowledged: …", + "reason": "fingerprint" | "downloads" | "unplannable", + "acknowledged": {"fingerprint": "…", "minutes": 38.0, "downloads": [...]}, + "plan": { …current plan… } +} +``` + +`detail` is one sentence naming the reason: for `fingerprint`, "the +workflow or its arguments differ from what was validated"; for +`downloads`, the repos now required and not acknowledged. `plan` is the +whole current plan so the agent re-quotes from the body without a second +validate call. FastAPI's `HTTPException(status_code=409, detail=…)` takes +only `detail`; the route raises with `detail` as this whole object, which +FastAPI serializes as `{"detail": {...}}` - the MCP layer and the docs +describe that shape. + +## 8. Recording + +- `Job` gains `acknowledged: str` (`none`/`boolean`/`bound`) and, when + bound, the object under `spec["acknowledged_cost"]` so `rerun` carries it + forward and can re-check it (`RERUN_SPEC_KEYS` gains the key). +- `jobs.sqlite` gains `acknowledged TEXT` by `ALTER TABLE`, the same + pattern as `run_id`; rows before the column read back as `none`. +- `manager.describe(job)` (so `GET /api/jobs/{id}`, `GET /api/jobs`, MCP + `get_job`/`list_jobs`) carries `acknowledged`, and `acknowledged_cost` + when bound. + +## 9. MCP + +- `run_workflow` / `rerun_job` (`dw_mcp/server.py`) widen `acknowledged_cost` + to `bool | dict`. `diagnose.py` treats a non-empty dict as acknowledged + and forwards it verbatim in the body; a dict missing `fingerprint` is a + `DwApiError` before any request is made. +- A 409 from the server surfaces as `DwApiError` whose message is the + body's `detail.detail` sentence followed by the new estimate + (`plan.estimate.minutes`, `basis`) and the new downloads, so a client that + only sees the message can still re-quote. +- `COST_REFUSAL` teaches the bound form as the normal one: validate with the + arguments, quote the plan, pass `{"fingerprint": plan.fingerprint, + "minutes": plan.estimate.minutes, "downloads": [...]}`. Bare `true` is + the fallback for `plan: null`. + +## 10. The step-cache probe + +The step cache is the worker's singleton and `Workflow.run` probes it with +a snapshot taken after substitution and reference resolution +(`workflow.py`, the `step_cache.get` call), so an exact answer has to come +from the worker. + +- New worker command `{"type": "probe_cache", "definition": , "seed": int, "output_dir": str}` answered by + `{"type": "probe_cache", "cached": [step names]}`. +- The worker's handler walks the steps exactly as `Workflow.run` does up to + and including the `step_cache.get` call, with `hits_this_run` threaded + through, and executes nothing. This is a refactor of `Workflow.run`: the + "is this step a hit" computation is lifted into a method + (`Workflow.cache_hit(step_data, step_seed, hits_this_run)` or a + module-level helper) that both the run loop and the probe call, so the + two cannot drift. +- `JobManager.probe_cache(definition, seed, output_dir, timeout=5)` has the + same shape as `memory_status`: busy worker, no worker or a timeout answer + `None`, never block a request behind a running job. +- `build_plan(cache_probe=manager.probe_cache)`: when given, `cached_steps` + is `len(cached)` on an answer and `null` on `None`. The seed handed to the + probe is the run's real seed (the folded seed variable's value, else + `definition["seed"]`, else `null` → the workflow is unseeded, the cache is + off, `cached_steps: 0` with no probe made). +- The estimate is not scaled by `cached_steps` in this stage: the plan + states both and the agent says "N of M steps are cached". Scaling needs + per-step timing the catalog does not hold. + +## 11. Tests (stage 2) + +`tests/test_server.py`: + +- 409 `fingerprint` on a longer list than acknowledged; on changed + arguments; on a changed stored prompt. +- 409 `downloads` on a repo newly required (stub `scan_models`); no 409 when + a download vanished. +- no 409 on a new seed, on `rerun(new_seed=True)`, on a `latest` that + advanced. +- `boolean` and `none` paths queue exactly as before; `acknowledged` + recorded correctly for all three; sqlite migration on a database created + without the column. +- 409 `unplannable` when `build_plan` raises under a bound acknowledgement. +- 409 body carries `plan`. + +`tests/test_worker.py` / `tests/test_step_cache.py`: + +- `probe_cache` against a warm cache reports the hit set the next run + actually reuses (run once, probe, run again, compare `reused` in the + manifest to the probe's answer); against a cold cache reports none; + `JobManager.probe_cache` answers `None` when the worker is busy. + +`tests/test_mcp*.py`: + +- dict forwarded verbatim; dict without `fingerprint` refused client-side; + 409 surfaces as `DwApiError` with the estimate in the message; `true` + still works. + +## Release notes + +- New: `POST /api/validate` answers with `plan`; `?sizes=false`. +- New: `acknowledged_cost` accepts `{fingerprint, minutes, downloads}`; + `POST /api/jobs` and `/rerun` answer 409 when the plan changed. `true` + unchanged. +- New: jobs record `acknowledged`. +- The skills now quote from the plan rather than the listing. From e9bffd0ce33f1d911e86ef377fe09e60b992dcab Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:06:21 -0500 Subject: [PATCH 03/34] docs(plan): #85 - stage 1 implementation plan, the plan on validate Co-Authored-By: Claude Opus 5 (1M context) --- ...26-09-13-acknowledged-cost-stage-1-plan.md | 1470 +++++++++++++++++ 1 file changed, 1470 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-1-plan.md diff --git a/docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-1-plan.md b/docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-1-plan.md new file mode 100644 index 00000000..7346e25d --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-1-plan.md @@ -0,0 +1,1470 @@ +# Acknowledged-cost binding, stage 1: the plan on validate + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `POST /api/validate` (and MCP `validate_workflow`) answers with a `plan` - a fingerprint of the work the run will execute for the arguments given, the step count, the list lengths, the model repos it would have to download first, and a cost estimate whose basis is named - so an agent quotes what will run rather than the catalog's defaults-only `cost`. + +**Architecture:** A new pure module `dw/plan.py` builds the plan from the `Workflow` the route already constructed: it realizes the definition with the caller's arguments (`realize_workflow`, gaining a `pin_outputs=False` switch so a `latest` landing between validate and run does not change the fingerprint), expands `for_each` through `Workflow.expanded_definition`, scrubs the seed and the documentation keys, and hashes the rest. The estimate reads the workflow's own `cost` block (and each composed child's); downloads are the `model_name`s `scan_models` does not find. The route attaches the plan best-effort and never lets it change the verdict. MCP surfaces it and the skills/docs teach quoting from it. + +**Tech Stack:** Python 3.12, FastAPI (`dw/server/app.py`), pytest, `huggingface_hub` (`scan_cache_dir`, `model_info`). + +**Spec:** [docs/superpowers/specs/2026-09-12-acknowledged-cost-binding-design.md](../specs/2026-09-12-acknowledged-cost-binding-design.md) - stage 1 sections 1-5. Stage 2 (the bound acknowledgement and the 409) is a separate plan. + +## Global Constraints + +- Model knowledge stays out of engine code: no repo name, no per-model minute figure anywhere in `dw/`. Every number comes from a workflow's `cost` block. +- `dw/plan.py` imports nothing from `dw.server` or `dw.worker`. +- Plan construction is best effort at the API: a failure is logged and answered as `plan: null`; the validate verdict is the schema's, never the planner's. An invalid answer carries no `plan` key at all. +- Every path the planner reads goes through the resolvers the run uses (`realize_workflow`, `resolve_sub_workflow` + `validate_workflow_path`, `scan_models`). It opens no file by a path it computed itself. +- Fixtures declare their own `cost` blocks and their own `model_name` strings; no test names a real model. +- The hub is never a reason for validate to fail: `model_info` errors, timeouts and missing tokens give `gb: null`, logged at `debug` only. +- `plugins/dw/skills/*/SKILL.md` must each stay under `12 * 1024` bytes (`tests/test_plugin_skills.py::SKILL_SIZE_LIMIT`). `ltx-2.5` is at 12182 and `minimax-h3` at 12239 - the cost-step rewrite must not add net bytes there. +- Work happens in the `cost-plan` worktree (`.claude/worktrees/cost-plan`, branch `cost-plan`, already merged with `develop` at 1af85ac). Run tests from there with `python -m pytest`. +- Commit messages follow the repo's shape: `feat(engine): #85 - ...`, `docs(mcp): #85 - ...`, one sentence in the imperative, ending with `Co-Authored-By: Claude Opus 5 (1M context) `. + +## File structure + +| File | Responsibility | +|---|---| +| `dw/realize.py` (modify) | `realize_workflow(pin_outputs=...)`; `read_sub_workflow()` factored out of `_digest` so the planner reads a child by the run's own resolution | +| `dw/plan.py` (new) | `build_plan(candidate, arguments, ...)` and its four parts: `fingerprint`, `estimate`, `downloads_required`, `list_entries` | +| `dw/server/app.py` (modify) | `plan` on the valid answer of `POST /api/validate`; `?sizes=` query parameter | +| `dw_mcp/server.py`, `dw_mcp/diagnose.py` (modify) | docstring and `COST_REFUSAL` teach quoting from the plan | +| `docs/SERVER.md`, `docs/MCP.md`, `docs/WORKFLOW_GUIDE.md`, `CLAUDE.md` (modify) | the `plan` block and the quoting rule | +| `plugins/dw/skills/{ltx-2.5,minimax-h3,minimax-music3}/SKILL.md` (modify) | the "quote cost" step | +| `tests/test_realize.py`, `tests/test_plan.py` (new), `tests/test_server.py`, `tests/test_mcp_server.py` | coverage | + +--- + +### Task 1: `realize_workflow(pin_outputs=False)` and `read_sub_workflow` + +**Files:** +- Modify: `dw/realize.py` (`realize_workflow` signature at line 44, `_pin` at ~140, `_pin_output` at ~172, `_digest` at ~220) +- Test: `tests/test_realize.py` + +**Interfaces:** +- Produces: `realize_workflow(definition, arguments, seed, base_dir=None, prompt_dir=None, output_root=None, workflow_dir=None, pin_outputs=True) -> (realized, annotations)`. With `pin_outputs=False`, every `output:.../latest/...` reference is returned exactly as written; prompt inlining and the seed pin are unchanged. +- Produces: `read_sub_workflow(path, base_dir, workflow_dir) -> bytes | None` - the file a sub-workflow step's `path` resolves to, read through `resolve_sub_workflow` and `validate_workflow_path` exactly as `_digest` did, `None` when it cannot be read (logged at debug). + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_realize.py` (the module already has `definition()`, `prompt_library` and `output_root` fixtures - `output_root` yields `(root, run_id)` with one run of `ltx2/Gyre` holding `still.png`): + +```python +class TestUnpinnedOutputs: + def test_pin_outputs_false_leaves_latest_as_written(self, output_root): + root, _ = output_root + spec = definition() + spec["steps"][0]["pipeline"]["arguments"]["image"] = ( + "output:ltx2/Gyre/latest/still.png" + ) + realized, _ = realize_workflow( + spec, {}, 7, output_root=root, pin_outputs=False + ) + assert ( + realized["steps"][0]["pipeline"]["arguments"]["image"] + == "output:ltx2/Gyre/latest/still.png" + ) + + def test_pin_outputs_false_still_inlines_prompts(self, prompt_library): + spec = definition() + spec["variables"]["prompt"] = "prompt:scenic/dusk" + realized, annotations = realize_workflow( + spec, {}, 7, prompt_dir=prompt_library, pin_outputs=False + ) + assert realized["variables"]["prompt"] == "a harbour at dusk" + assert annotations["prompts"] == ["scenic/dusk"] + + def test_the_default_still_pins(self, output_root): + root, run_id = output_root + spec = definition() + spec["steps"][0]["pipeline"]["arguments"]["image"] = ( + "output:ltx2/Gyre/latest/still.png" + ) + realized, _ = realize_workflow(spec, {}, 7, output_root=root) + assert ( + realized["steps"][0]["pipeline"]["arguments"]["image"] + == f"output:ltx2/Gyre/{run_id}/still.png" + ) + + +class TestReadSubWorkflow: + def test_reads_a_child_beside_the_parent(self, tmp_path): + from dw.realize import read_sub_workflow + + child = {"id": "child", "steps": []} + (tmp_path / "child.json").write_text(json.dumps(child)) + raw = read_sub_workflow("child.json", str(tmp_path), str(tmp_path)) + assert json.loads(raw) == child + + def test_a_missing_child_reads_as_none(self, tmp_path): + from dw.realize import read_sub_workflow + + assert read_sub_workflow("nope.json", str(tmp_path), str(tmp_path)) is None + + def test_a_child_outside_the_confinement_reads_as_none(self, tmp_path): + from dw.realize import read_sub_workflow + + outside = tmp_path / "outside" + outside.mkdir() + (outside / "child.json").write_text(json.dumps({"id": "c", "steps": []})) + confined = tmp_path / "confined" + confined.mkdir() + assert ( + read_sub_workflow("../outside/child.json", str(confined), str(confined)) + is None + ) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python -m pytest tests/test_realize.py -k "Unpinned or ReadSub" -v` +Expected: FAIL - `TypeError: ... unexpected keyword argument 'pin_outputs'` and `ImportError: cannot import name 'read_sub_workflow'`. + +- [ ] **Step 3: Implement** + +In `dw/realize.py`: + +Change the signature and docstring of `realize_workflow`: + +```python +def realize_workflow( + definition, + arguments, + seed, + base_dir=None, + prompt_dir=None, + output_root=None, + workflow_dir=None, + pin_outputs=True, +): +``` + +Add to the `Args:` block: + +``` + pin_outputs: Whether an 'output:.../latest/...' reference is rewritten + to the run it resolves to. The run leaves this True; the planner + (dw/plan.py) passes False so a run finishing between a validate + call and the queue call does not change the fingerprint of + identical work. +``` + +Change the `_pin` call to `realized = _pin(realized, annotations, base_dir, prompt_dir, output_root, pin_outputs)` and `_pin` to: + +```python +def _pin(value, annotations, base_dir, prompt_dir, output_root, pin_outputs=True): + """Rebuild a value with prompt references inlined and, when + `pin_outputs`, output references pinned.""" + + def transform(string): + if string.startswith(PROMPT_PREFIX): + return _inline_prompt(string, annotations, prompt_dir, base_dir) + if pin_outputs and is_output_reference(string): + return _pin_output(string, output_root) + return string + + return _map_strings(value, transform) +``` + +Replace `_digest` with a public reader plus the digest over it: + +```python +def read_sub_workflow(path, base_dir, workflow_dir): + """The bytes of the sub-workflow file a step's `path` names, or None + when it cannot be read. + + Resolved the way `Workflow.create_step_action` resolves it - beside the + referencing file, then across the workflow search path, then through + `validate_workflow_path` confined to the root it came from - so a path + this run could not have loaded is not one realization (or the planner) + reads either, and a catalog name the run composed is read rather than + recorded as unreadable (#90). + """ + try: + candidate, root = resolve_sub_workflow(path, base_dir or ".", workflow_dir) + validated = validate_workflow_path(candidate, root) + with open(validated, "rb") as file: + return file.read() + except (SecurityError, OSError, ValueError, SubWorkflowNotFound) as e: + logger.debug(f"Sub-workflow {path} could not be read: {e}") + return None + + +def _digest(path, base_dir, workflow_dir): + """The SHA-256 of a sub-workflow file, or None when it cannot be read.""" + raw = read_sub_workflow(path, base_dir, workflow_dir) + return hashlib.sha256(raw).hexdigest() if raw is not None else None +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python -m pytest tests/test_realize.py -v` +Expected: all PASS, the pre-existing digest tests included. + +- [ ] **Step 5: Commit** + +```bash +git add dw/realize.py tests/test_realize.py +git commit -m "feat(engine): #85 - realize_workflow can leave 'latest' unpinned, and a sub-workflow is read by one resolver + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 2: `dw/plan.py` - realization, expansion, `steps`, `list_entries`, the fingerprint + +**Files:** +- Create: `dw/plan.py` +- Test: `tests/test_plan.py` (new) + +**Interfaces:** +- Consumes: `realize_workflow(..., pin_outputs=False)` from Task 1; `Workflow.expanded_definition()` (`dw/workflow.py:393`), `Workflow(definition, output_dir, file_spec, workflow_dir)`. +- Produces: + +```python +def build_plan( + candidate, # a dw.workflow.Workflow, as the route constructed it + arguments, # the caller's dict, already past argument_errors + *, + device, # "cuda" | "mps" | "cpu" - the serving backend + prompt_dir=None, + cache_dir=None, + lookup_sizes=True, + cache_probe=None, # stage 2; ignored here, cached_steps is always None +): + """What a run of `candidate` with `arguments` will execute and cost.""" +``` + +returning + +```python +{ + "fingerprint": "sha256:<64 hex>", + "steps": int, + "list_entries": {variable: int}, + "cached_steps": None, + "downloads_required": [...], # Task 4; [] until then + "estimate": {...}, # Task 3; placeholder until then +} +``` + +and the helpers Tasks 3-4 fill in: `fingerprint(expanded, definition) -> str`, `list_entries(definition_as_written, realized) -> dict`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_plan.py`: + +```python +"""The plan a validate call answers with: what a run will execute for the +arguments given, fingerprinted so an acknowledgement can be bound to it.""" + +import copy +import json +import os + +import pytest + +from dw.plan import build_plan +from dw.runs import new_run_id +from dw.workflow import workflow_from_definition + + +def definition(): + """A list-driven workflow with a stored prompt and an output reference, + so every mutable input the fingerprint must ignore or honour is here.""" + return { + "id": "plan_test", + "description": "docs only", + "summary": "docs only", + "seed": "variable:seed", + "cost": [{"device": "cuda", "name": "card", "vram_gb": 8, "minutes": 10}], + "variables": { + "seed": 1, + "prompt": "prompt:scenic/dusk", + "frames": 25, + "shots": [{"name": "a", "prompt": "one"}, {"name": "b", "prompt": "two"}], + }, + "steps": [ + { + "name": "still", + "pipeline": { + "configuration": {"component_type": "{Fake}"}, + "from_pretrained_arguments": {"model_name": "org/still-model"}, + "arguments": { + "prompt": "variable:prompt", + "image": "output:ltx2/Gyre/latest/still.png", + "num_frames": "variable:frames", + }, + }, + }, + { + "name": "shot", + "for_each": "variable:shots", + "task": {"command": "x", "arguments": {"prompt": "item:prompt"}}, + }, + ], + } + + +@pytest.fixture +def prompt_library(tmp_path): + library = tmp_path / "prompts" + (library / "scenic").mkdir(parents=True) + (library / "scenic" / "dusk.json").write_text(json.dumps({"text": "a harbour at dusk"})) + return library + + +@pytest.fixture +def output_root(tmp_path): + root = tmp_path / "outputs" + run = root / "ltx2" / "Gyre" / new_run_id({"a": 1}) + run.mkdir(parents=True) + (run / "still.png").write_bytes(b"png") + return root + + +@pytest.fixture +def plan(tmp_path, prompt_library, output_root, monkeypatch): + """build_plan over the fixture, with the hub cache empty and the hub + unreachable, so downloads never touch the network in these tests.""" + import dw.plan + + monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + monkeypatch.setattr( + dw.plan, "model_info", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("offline")) + ) + + def make(spec=None, arguments=None, **overrides): + spec = definition() if spec is None else spec + candidate = workflow_from_definition( + copy.deepcopy(spec), str(output_root), str(tmp_path), str(tmp_path) + ) + kwargs = dict(device="cuda", prompt_dir=str(prompt_library), lookup_sizes=False) + kwargs.update(overrides) + return build_plan(candidate, arguments or {}, **kwargs) + + return make + + +class TestShape: + def test_the_documented_keys(self, plan): + answer = plan() + assert set(answer) == { + "fingerprint", "steps", "list_entries", "cached_steps", + "downloads_required", "estimate", + } + assert answer["fingerprint"].startswith("sha256:") + assert len(answer["fingerprint"]) == len("sha256:") + 64 + assert answer["cached_steps"] is None + + def test_steps_counts_the_expanded_members(self, plan): + assert plan()["steps"] == 3 # still, shot@a, shot@b + + def test_list_entries_is_the_callers_list_length(self, plan): + shots = [{"name": n, "prompt": n} for n in "abcde"] + answer = plan(arguments={"shots": shots}) + assert answer["list_entries"] == {"shots": 5} + assert answer["steps"] == 6 + + def test_a_literal_for_each_list_is_not_an_entry(self, plan): + spec = definition() + spec["steps"][1]["for_each"] = [{"name": "x"}, {"name": "y"}] + del spec["variables"]["shots"] + assert plan(spec)["list_entries"] == {} + + +class TestFingerprintIsStableAcross: + def test_a_different_top_level_seed(self, plan): + assert plan()["fingerprint"] == plan(arguments={"seed": 99})["fingerprint"] + + def test_a_different_step_seed(self, plan): + spec = definition() + spec["steps"][0]["seed"] = 5 + spec["steps"][0]["pipeline"]["seed"] = 5 + other = copy.deepcopy(spec) + other["steps"][0]["seed"] = 6 + other["steps"][0]["pipeline"]["seed"] = 6 + assert plan(spec)["fingerprint"] == plan(other)["fingerprint"] + + def test_key_order(self, plan): + spec = definition() + reordered = {k: spec[k] for k in reversed(list(spec))} + assert plan(spec)["fingerprint"] == plan(reordered)["fingerprint"] + + def test_description_summary_and_cost_edits(self, plan): + spec = definition() + spec["description"] = "rewritten" + spec["summary"] = "rewritten" + spec["cost"][0]["minutes"] = 99 + spec["configures"] = "templates/x" + assert plan(spec)["fingerprint"] == plan()["fingerprint"] + + def test_a_new_run_landing_under_latest(self, plan, output_root): + before = plan()["fingerprint"] + newer = output_root / "ltx2" / "Gyre" / new_run_id({"b": 2}) + newer.mkdir(parents=True) + (newer / "still.png").write_bytes(b"png2") + assert plan()["fingerprint"] == before + + def test_argument_order(self, plan): + a = plan(arguments={"frames": 9, "seed": 3})["fingerprint"] + b = plan(arguments={"seed": 3, "frames": 9})["fingerprint"] + assert a == b + + +class TestFingerprintChangesWith: + def test_a_longer_list(self, plan): + longer = [{"name": n, "prompt": n} for n in "abc"] + assert plan()["fingerprint"] != plan(arguments={"shots": longer})["fingerprint"] + + def test_a_changed_stored_prompt(self, plan, prompt_library): + before = plan()["fingerprint"] + (prompt_library / "scenic" / "dusk.json").write_text( + json.dumps({"text": "a harbour at dawn"}) + ) + assert plan()["fingerprint"] != before + + def test_a_changed_numeric_argument(self, plan): + assert plan()["fingerprint"] != plan(arguments={"frames": 121})["fingerprint"] + + def test_a_different_asset_name(self, plan): + spec = definition() + spec["steps"][0]["pipeline"]["arguments"]["image"] = "asset:a.png" + other = copy.deepcopy(spec) + other["steps"][0]["pipeline"]["arguments"]["image"] = "asset:b.png" + assert plan(spec)["fingerprint"] != plan(other)["fingerprint"] + + def test_a_step_added_removed_or_renamed(self, plan): + base = plan()["fingerprint"] + renamed = definition() + renamed["steps"][0]["name"] = "frame" + removed = definition() + del removed["steps"][1] + added = definition() + added["steps"].append({"name": "extra", "task": {"command": "x", "arguments": {}}}) + assert len({base, plan(renamed)["fingerprint"], plan(removed)["fingerprint"], + plan(added)["fingerprint"]}) == 4 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python -m pytest tests/test_plan.py -v` +Expected: FAIL - `ModuleNotFoundError: No module named 'dw.plan'`. + +- [ ] **Step 3: Implement `dw/plan.py`** + +```python +"""The plan a validate call answers with: what a run of a workflow with a +caller's arguments will actually execute, what it will have to download +first, and what the workflow's own cost block says it will take - with a +fingerprint over the work, so an acknowledgement can be bound to it and a +run whose shape changed after consent refused (#85, stage 2). + +Everything here is derived from the same resolvers the run uses - +`realize_workflow` folds the arguments and inlines the prompts, and +`Workflow.expanded_definition` substitutes and expands `for_each` - so the +plan describes the run and not an approximation of it. Nothing here knows a +model: every minute comes from a `cost` block and every repo name from a +`from_pretrained_arguments`. +""" + +import copy +import hashlib +import json +import logging +import os + +from huggingface_hub import model_info + +from .hub_cache import scan_models +from .realize import BUILTIN_PREFIX, VARIABLE_PREFIX, read_sub_workflow, realize_workflow +from .security import validate_url +from .workflow import Workflow + +logger = logging.getLogger("dw") + +FINGERPRINT_PREFIX = "sha256:" +# Top-level keys that document a workflow rather than shape its work +DOCUMENTATION_KEYS = ("cost", "description", "summary", "configures") +FOR_EACH_KEY = "for_each" +SIZE_LOOKUP_TIMEOUT = 5.0 +GIB = 1024**3 + + +def build_plan( + candidate, + arguments, + *, + device, + prompt_dir=None, + cache_dir=None, + lookup_sizes=True, + cache_probe=None, +): + """What a run of `candidate` with `arguments` will execute and cost. + + Args: + candidate: The Workflow the route built - it carries the file spec + (so base_dir), the output root and the confinement a run has. + arguments: The caller's arguments, already past `argument_errors`; + an undeclared name or an uncoercible value raises here. + device: The backend that is serving - 'cuda', 'mps' or 'cpu'. + prompt_dir: The prompt library, for inlining. + cache_dir: The hub cache to check downloads against; None for the + default. + lookup_sizes: Whether to ask the hub how large a missing repo is. + cache_probe: Stage 2's step-cache probe; unused, `cached_steps` is + always None until then. + """ + definition = candidate.workflow_definition + base_dir = ( + os.path.dirname(os.path.abspath(candidate.file_spec)) + if candidate.file_spec + else None + ) + realized, _ = realize_workflow( + definition, + arguments, + seed=0, + base_dir=base_dir, + prompt_dir=prompt_dir, + output_root=candidate.output_dir, + workflow_dir=candidate.workflow_dir, + pin_outputs=False, + ) + # Arguments are already folded into the realized variables, so the + # expansion takes none; it substitutes and expands exactly as the run + expanded = Workflow( + realized, candidate.output_dir, candidate.file_spec, candidate.workflow_dir + ).expanded_definition() + entries = list_entries(definition, realized) + return { + "fingerprint": fingerprint(expanded, definition), + "steps": len(expanded.get("steps") or []), + "list_entries": entries, + "cached_steps": None, + "downloads_required": [], + "estimate": None, + } + + +def list_entries(definition, realized): + """{variable: length} for every `for_each` that names a list variable, + read from the folded variables - a literal list is not an argument and + is not listed.""" + variables = realized.get("variables") or {} + entries = {} + for step in definition.get("steps") or []: + if not isinstance(step, dict): + continue + reference = step.get(FOR_EACH_KEY) + if isinstance(reference, str) and reference.startswith(VARIABLE_PREFIX): + name = reference.removeprefix(VARIABLE_PREFIX) + value = variables.get(name) + if isinstance(value, list): + entries[name] = len(value) + return entries + + +def fingerprint(expanded, definition): + """SHA-256 over the expanded definition with everything that is not + work removed: the seed wherever it sits, and the documentation keys. + + `definition` is the workflow as written, consulted for whether the + top-level seed named a variable - if it did, that variable's folded + value is the seed too and is blanked at its source. + """ + doc = copy.deepcopy(expanded) + doc.pop("seed", None) + for key in DOCUMENTATION_KEYS: + doc.pop(key, None) + written_seed = definition.get("seed") + if isinstance(written_seed, str) and written_seed.startswith(VARIABLE_PREFIX): + name = written_seed.removeprefix(VARIABLE_PREFIX) + variables = doc.get("variables") + if isinstance(variables, dict) and name in variables: + variables[name] = None + for step in doc.get("steps") or []: + if isinstance(step, dict): + step.pop("seed", None) + pipeline = step.get("pipeline") + if isinstance(pipeline, dict): + pipeline.pop("seed", None) + # default=repr: a realized 'constant:' can be any Python value, and the + # fingerprint only needs it to be stable, not round-trippable + serialized = json.dumps( + doc, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=repr + ) + return FINGERPRINT_PREFIX + hashlib.sha256(serialized.encode("utf-8")).hexdigest() +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python -m pytest tests/test_plan.py -v` +Expected: PASS. If `test_a_different_step_seed` fails on schema grounds, the fixture's step-level `seed` is not schema-checked here (nothing validates in `build_plan`), so check the scrub order instead. + +- [ ] **Step 5: Commit** + +```bash +git add dw/plan.py tests/test_plan.py +git commit -m "feat(engine): #85 - a plan fingerprints the work a run will execute for the caller's arguments + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 3: the estimate + +**Files:** +- Modify: `dw/plan.py` +- Test: `tests/test_plan.py` + +**Interfaces:** +- Consumes: `read_sub_workflow(path, base_dir, workflow_dir)` from Task 1; `list_entries` from Task 2. +- Produces: `estimate(definition, expanded, list_entries, device, base_dir, workflow_dir) -> dict` with keys `minutes` (float | None), `basis` (`"per_entry" | "catalog" | "other_device" | "unknown"`), `device`, `measured_on` (str | None), `partial` (bool). `build_plan` fills `"estimate"` with it. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_plan.py`: + +```python +def cost(device="cuda", minutes=10, per_entry=None, name="card"): + entry = {"device": device, "name": name, "vram_gb": 8, "minutes": minutes} + if per_entry: + entry["per_entry"] = per_entry + return entry + + +class TestEstimate: + def test_no_cost_block_is_unknown(self, plan): + spec = definition() + del spec["cost"] + assert plan(spec)["estimate"] == { + "minutes": None, "basis": "unknown", "device": "cuda", + "measured_on": None, "partial": False, + } + + def test_an_empty_cost_list_is_unknown(self, plan): + spec = definition() + spec["cost"] = [] + assert plan(spec)["estimate"]["basis"] == "unknown" + + def test_the_serving_devices_entry_is_the_catalog_figure(self, plan): + spec = definition() + spec["cost"] = [cost("mps", 40, name="M2"), cost("cuda", 10, name="4090")] + assert plan(spec)["estimate"] == { + "minutes": 10.0, "basis": "catalog", "device": "cuda", + "measured_on": "4090", "partial": False, + } + + def test_another_devices_entry_is_reported_as_such(self, plan): + spec = definition() + spec["cost"] = [cost("mps", 40, name="M2")] + assert plan(spec)["estimate"] == { + "minutes": 40.0, "basis": "other_device", "device": "cuda", + "measured_on": "M2", "partial": False, + } + + def test_per_entry_scales_by_the_callers_list(self, plan): + spec = definition() + # 10 minutes for the 2-entry default, of which 3 per entry: 4 fixed + spec["cost"] = [cost("cuda", 10, {"variable": "shots", "minutes": 3, "entries": 2})] + shots = [{"name": n, "prompt": n} for n in "abcde"] + answer = plan(spec, arguments={"shots": shots})["estimate"] + assert answer["minutes"] == 4 + 3 * 5 + assert answer["basis"] == "per_entry" + + def test_per_entry_floors_at_zero(self, plan): + spec = definition() + spec["cost"] = [cost("cuda", 1, {"variable": "shots", "minutes": 3, "entries": 2})] + assert plan(spec)["estimate"]["minutes"] == 0.0 + + def test_per_entry_naming_no_list_falls_back_to_catalog(self, plan): + spec = definition() + spec["cost"] = [cost("cuda", 10, {"variable": "other", "minutes": 3, "entries": 2})] + answer = plan(spec)["estimate"] + assert (answer["minutes"], answer["basis"]) == (10.0, "catalog") + + def test_other_device_beats_per_entry(self, plan): + spec = definition() + spec["cost"] = [cost("mps", 10, {"variable": "shots", "minutes": 3, "entries": 2})] + answer = plan(spec)["estimate"] + assert (answer["minutes"], answer["basis"]) == (10.0, "other_device") + + def test_minutes_is_rounded_to_one_decimal(self, plan): + spec = definition() + spec["cost"] = [cost("cuda", 10.04)] + assert plan(spec)["estimate"]["minutes"] == 10.0 + + +def composing(child_path): + return { + "id": "parent", + "cost": [cost("cuda", 2)], + "steps": [ + {"name": "own", "task": {"command": "x", "arguments": {}}}, + {"name": "child", "workflow": {"path": child_path, "arguments": {}}}, + ], + } + + +class TestSubWorkflowEstimate: + def test_a_childs_catalog_cost_is_added(self, plan, tmp_path): + child = {"id": "child", "cost": [cost("cuda", 5)], "steps": []} + (tmp_path / "child.json").write_text(json.dumps(child)) + answer = plan(composing("child.json"))["estimate"] + assert (answer["minutes"], answer["partial"]) == (7.0, False) + + def test_a_child_without_a_cost_makes_the_estimate_partial(self, plan, tmp_path): + (tmp_path / "child.json").write_text(json.dumps({"id": "child", "steps": []})) + answer = plan(composing("child.json"))["estimate"] + assert (answer["minutes"], answer["partial"]) == (2.0, True) + + def test_an_unreadable_child_makes_the_estimate_partial(self, plan): + answer = plan(composing("missing.json"))["estimate"] + assert (answer["minutes"], answer["partial"]) == (2.0, True) + + def test_a_builtin_adds_nothing_and_is_not_partial(self, plan): + answer = plan(composing("builtin:text-to-image.json"))["estimate"] + assert (answer["minutes"], answer["partial"]) == (2.0, False) + + def test_a_child_measured_on_another_device_is_still_added(self, plan, tmp_path): + child = {"id": "child", "cost": [cost("mps", 5)], "steps": []} + (tmp_path / "child.json").write_text(json.dumps(child)) + answer = plan(composing("child.json"))["estimate"] + assert answer["minutes"] == 7.0 + # the parent's own basis is what is reported + assert answer["basis"] == "catalog" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python -m pytest tests/test_plan.py -k "Estimate" -v` +Expected: FAIL - `estimate` is `None`. + +- [ ] **Step 3: Implement** + +In `dw/plan.py`, replace `"estimate": None` in `build_plan` with + +```python + "estimate": estimate( + definition, expanded, entries, device, base_dir, candidate.workflow_dir + ), +``` + +and add: + +```python +UNKNOWN = "unknown" +CATALOG = "catalog" +PER_ENTRY = "per_entry" +OTHER_DEVICE = "other_device" + + +def estimate(definition, expanded, list_entries, device, base_dir, workflow_dir): + """Minutes from the workflow's own cost block, scaled by the caller's + list when the block was measured per entry, plus each composed child's. + + `basis` names where the figure came from - the honesty is in the field, + not in a fabricated number: 'unknown' is no cost block at all, + 'other_device' a figure measured on a backend other than the one + serving (reported so the agent has something to scale, flagged so it + is not quoted as a measurement), 'catalog' the stored total, and + 'per_entry' that total re-priced for the list actually passed. + """ + own = _price(definition.get("cost"), device, list_entries) + minutes = own["minutes"] + partial = False + for step in expanded.get("steps") or []: + reference = step.get("workflow") if isinstance(step, dict) else None + path = reference.get("path") if isinstance(reference, dict) else None + if not isinstance(path, str) or path.startswith(BUILTIN_PREFIX): + continue + # A builtin is the parent's to price; a local child prices itself + raw = read_sub_workflow(path, base_dir, workflow_dir) + child_cost = None + if raw is not None: + try: + child_cost = json.loads(raw).get("cost") + except (ValueError, AttributeError): + child_cost = None + child = _price(child_cost, device, {}) + if child["minutes"] is None: + partial = True + elif minutes is not None: + minutes += child["minutes"] + else: + minutes = child["minutes"] + return { + "minutes": round(minutes, 1) if minutes is not None else None, + "basis": own["basis"], + "device": device, + "measured_on": own["measured_on"], + "partial": partial, + } + + +def _price(cost, device, list_entries): + """One cost list priced for `device` and `list_entries`, as + {minutes, basis, measured_on}.""" + entries = [entry for entry in (cost or []) if isinstance(entry, dict)] + if not entries: + return {"minutes": None, "basis": UNKNOWN, "measured_on": None} + chosen = next((entry for entry in entries if entry.get("device") == device), None) + basis = CATALOG + if chosen is None: + chosen = entries[0] + basis = OTHER_DEVICE + minutes = float(chosen.get("minutes", 0)) + per = chosen.get("per_entry") + if basis == CATALOG and isinstance(per, dict) and per.get("variable") in list_entries: + count = list_entries[per["variable"]] + each = float(per.get("minutes", 0)) + measured_with = int(per.get("entries", 0)) + minutes = max(0.0, (minutes - each * measured_with) + each * count) + basis = PER_ENTRY + return {"minutes": minutes, "basis": basis, "measured_on": chosen.get("name")} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python -m pytest tests/test_plan.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add dw/plan.py tests/test_plan.py +git commit -m "feat(engine): #85 - the plan prices a run from its cost block, per entry when measured, plus each composed child + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 4: `downloads_required` + +**Files:** +- Modify: `dw/plan.py` +- Test: `tests/test_plan.py` + +**Interfaces:** +- Consumes: `scan_models(cache_dir)` (`dw/hub_cache.py:37`, returns `{"repos": [{"repo_id": ...}, ...], ...}`), `validate_url` (`dw/security.py:327`, raises on a non-http(s) URL), `huggingface_hub.model_info(repo_id, files_metadata=True, timeout=...)`. +- Produces: `downloads_required(expanded, base_dir, workflow_dir, cache_dir, lookup_sizes) -> list[dict]`; each `{"repo": str, "gb": float | None}` or `{"repo": None, "url": str, "gb": None}`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_plan.py`: + +```python +class TestDownloadsRequired: + def test_a_repo_not_in_the_cache_is_required(self, plan): + assert plan()["downloads_required"] == [{"repo": "org/still-model", "gb": None}] + + def test_a_cached_repo_is_not(self, plan, monkeypatch): + import dw.plan + + monkeypatch.setattr( + dw.plan, "scan_models", + lambda cache_dir=None: {"repos": [{"repo_id": "org/still-model"}]}, + ) + assert plan()["downloads_required"] == [] + + def test_cache_dir_reaches_scan_models(self, plan, monkeypatch): + import dw.plan + + seen = [] + monkeypatch.setattr( + dw.plan, "scan_models", lambda cache_dir=None: seen.append(cache_dir) or {"repos": []} + ) + plan(cache_dir="/somewhere") + assert seen == ["/somewhere"] + + def test_a_local_directory_is_not_a_download(self, plan, tmp_path): + local = tmp_path / "weights" + local.mkdir() + spec = definition() + spec["steps"][0]["pipeline"]["from_pretrained_arguments"]["model_name"] = str(local) + assert plan(spec)["downloads_required"] == [] + + def test_a_single_file_url_is_listed_without_a_size(self, plan): + spec = definition() + spec["steps"][0]["pipeline"]["from_pretrained_arguments"] = { + "from_single_file": "https://example.test/x.safetensors" + } + assert plan(spec)["downloads_required"] == [ + {"repo": None, "url": "https://example.test/x.safetensors", "gb": None} + ] + + def test_a_single_file_local_path_is_not_listed(self, plan): + spec = definition() + spec["steps"][0]["pipeline"]["from_pretrained_arguments"] = { + "from_single_file": "checkpoints/x.safetensors" + } + assert plan(spec)["downloads_required"] == [] + + def test_components_and_children_are_scanned_and_deduplicated(self, plan, tmp_path): + spec = definition() + spec["steps"][0]["pipeline"]["components"] = { + "vae": {"from_pretrained_arguments": {"model_name": "org/vae"}} + } + child = { + "id": "child", + "steps": [{"name": "c", "pipeline": { + "configuration": {"component_type": "{Fake}"}, + "from_pretrained_arguments": {"model_name": "org/still-model"}, + "arguments": {}, + }}], + } + (tmp_path / "child.json").write_text(json.dumps(child)) + spec["steps"].append({"name": "sub", "workflow": {"path": "child.json", "arguments": {}}}) + assert [d["repo"] for d in plan(spec)["downloads_required"]] == [ + "org/still-model", "org/vae", + ] + + def test_sizes_come_from_the_hub_in_gib(self, plan, monkeypatch): + import dw.plan + + class Sibling: + def __init__(self, size): + self.size = size + + class Info: + siblings = [Sibling(2 * 1024**3), Sibling(None), Sibling(512 * 1024**2)] + + calls = [] + + def fake_model_info(name, **kwargs): + calls.append((name, kwargs)) + return Info() + + monkeypatch.setattr(dw.plan, "model_info", fake_model_info) + answer = plan(lookup_sizes=True)["downloads_required"] + assert answer == [{"repo": "org/still-model", "gb": 2.5}] + assert calls[0][0] == "org/still-model" + assert calls[0][1]["files_metadata"] is True + assert calls[0][1]["timeout"] == 5.0 + + def test_a_hub_failure_is_a_null_size(self, plan, monkeypatch): + import dw.plan + + def boom(*a, **k): + raise RuntimeError("offline") + + monkeypatch.setattr(dw.plan, "model_info", boom) + assert plan(lookup_sizes=True)["downloads_required"] == [ + {"repo": "org/still-model", "gb": None} + ] + + def test_lookup_sizes_false_never_calls_the_hub(self, plan, monkeypatch): + import dw.plan + + def boom(*a, **k): + raise AssertionError("must not be called") + + monkeypatch.setattr(dw.plan, "model_info", boom) + plan(lookup_sizes=False) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python -m pytest tests/test_plan.py -k Downloads -v` +Expected: FAIL - `downloads_required` is `[]`. + +- [ ] **Step 3: Implement** + +In `build_plan`, replace `"downloads_required": []` with + +```python + "downloads_required": downloads_required( + expanded, base_dir, candidate.workflow_dir, cache_dir, lookup_sizes + ), +``` + +and add: + +```python +FROM_PRETRAINED_KEY = "from_pretrained_arguments" +MODEL_NAME_KEY = "model_name" +SINGLE_FILE_KEY = "from_single_file" + + +def downloads_required(expanded, base_dir, workflow_dir, cache_dir, lookup_sizes): + """The hub repos and checkpoint URLs the run would fetch before its + first step: every `model_name` in the expanded definition (and in each + composed child) that `scan_models` does not find, plus every + `from_single_file` that is a URL. Sizes come from the hub when asked + and are None whenever it does not answer - an offline box is a state, + not an error, so nothing here raises or logs above debug. + """ + names = [] + urls = [] + _collect_sources(expanded, names, urls) + for step in expanded.get("steps") or []: + reference = step.get("workflow") if isinstance(step, dict) else None + path = reference.get("path") if isinstance(reference, dict) else None + if not isinstance(path, str) or path.startswith(BUILTIN_PREFIX): + continue + raw = read_sub_workflow(path, base_dir, workflow_dir) + if raw is None: + continue + try: + _collect_sources(json.loads(raw), names, urls) + except ValueError: + continue + present = {repo.get("repo_id") for repo in scan_models(cache_dir).get("repos", [])} + required = [] + for name in names: + if name in present or os.path.isdir(name): + continue + required.append({"repo": name, "gb": _size_gb(name) if lookup_sizes else None}) + for url in urls: + required.append({"repo": None, "url": url, "gb": None}) + return required + + +def _collect_sources(tree, names, urls): + """Every from_pretrained source in a tree, first-seen order, deduplicated.""" + if isinstance(tree, dict): + source = tree.get(FROM_PRETRAINED_KEY) + if isinstance(source, dict): + name = source.get(MODEL_NAME_KEY) + if isinstance(name, str) and name not in names: + names.append(name) + single = source.get(SINGLE_FILE_KEY) + if isinstance(single, str) and _is_url(single) and single not in urls: + urls.append(single) + for value in tree.values(): + _collect_sources(value, names, urls) + elif isinstance(tree, list): + for value in tree: + _collect_sources(value, names, urls) + + +def _is_url(value): + if not value.startswith(("http://", "https://")): + return False + try: + validate_url(value) + return True + except Exception: + return False + + +def _size_gb(name): + """A repo's size in GiB to one decimal, or None when the hub does not + say - unreachable, gated without a token, or a file with no size.""" + try: + info = model_info(name, files_metadata=True, timeout=SIZE_LOOKUP_TIMEOUT) + total = sum(s.size for s in (info.siblings or []) if getattr(s, "size", None)) + except Exception as e: + logger.debug(f"No size for {name}: {e}") + return None + return round(total / GIB, 1) if total else None +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python -m pytest tests/test_plan.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add dw/plan.py tests/test_plan.py +git commit -m "feat(engine): #85 - the plan names the weights a run would download first + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 5: `plan` on `POST /api/validate` + +**Files:** +- Modify: `dw/server/app.py` (the `validate_workflow` route at ~1256-1366; imports near line 53-66) +- Test: `tests/test_server.py` + +**Interfaces:** +- Consumes: `build_plan(candidate, arguments, device=, prompt_dir=, lookup_sizes=)` from Tasks 2-4; `get_device`, `get_device_type` from `dw/__init__.py`. +- Produces: on a valid answer, `answer["plan"]` is the plan or `None`; the route takes `sizes: bool = Query(True)`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_server.py` (the module has `server`, `success_script`, `valid_workflow`, `video_workflow(job_id, with_cost=True)`): + +```python +class TestValidatePlan: + def test_a_valid_answer_carries_a_plan(self, server, monkeypatch): + import dw.plan + + monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + with server(success_script) as client: + result = client.post( + "/api/validate?sizes=false", + json={"workflow": video_workflow("planned", with_cost=True)}, + ).json() + assert result["valid"] is True + plan = result["plan"] + assert set(plan) == { + "fingerprint", "steps", "list_entries", "cached_steps", + "downloads_required", "estimate", + } + assert plan["steps"] == 1 + assert plan["estimate"]["basis"] in {"catalog", "other_device"} + assert plan["estimate"]["minutes"] == 2.0 + assert plan["downloads_required"] == [{"repo": "m", "gb": None}] + + def test_an_invalid_answer_carries_no_plan(self, server): + with server(success_script) as client: + result = client.post( + "/api/validate", json={"workflow": {"id": "broken", "steps": "no"}} + ).json() + assert result["valid"] is False + assert "plan" not in result + + def test_a_planner_failure_is_a_null_plan_not_a_verdict(self, server, monkeypatch): + import dw.server.app as app_module + + def boom(*a, **k): + raise RuntimeError("planner broke") + + monkeypatch.setattr(app_module, "build_plan", boom) + with server(success_script) as client: + result = client.post( + "/api/validate", json={"workflow": valid_workflow("v")} + ).json() + assert result["valid"] is True + assert result["plan"] is None + + def test_sizes_reaches_the_planner(self, server, monkeypatch): + import dw.server.app as app_module + + seen = [] + + def spy(candidate, arguments, **kwargs): + seen.append(kwargs["lookup_sizes"]) + return {"fingerprint": "sha256:0", "steps": 0, "list_entries": {}, + "cached_steps": None, "downloads_required": [], "estimate": None} + + monkeypatch.setattr(app_module, "build_plan", spy) + with server(success_script) as client: + client.post("/api/validate", json={"workflow": valid_workflow("v")}) + client.post("/api/validate?sizes=false", json={"workflow": valid_workflow("v")}) + assert seen == [True, False] + + def test_the_plan_sees_the_callers_arguments(self, server, monkeypatch): + import dw.plan + + monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + with server(success_script) as client: + body = {"workflow": valid_workflow("v")} + one = client.post("/api/validate?sizes=false", json=body).json()["plan"] + body["arguments"] = {"prompt": "something else"} + two = client.post("/api/validate?sizes=false", json=body).json()["plan"] + assert one["fingerprint"] != two["fingerprint"] +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python -m pytest tests/test_server.py -k ValidatePlan -v` +Expected: FAIL - `KeyError: 'plan'` / `AttributeError: module has no attribute 'build_plan'`. + +- [ ] **Step 3: Implement** + +In `dw/server/app.py`, beside the other `..` imports (line ~53-66), add: + +```python +from ..plan import build_plan +``` + +Change the route signature: + +```python + @app.post("/api/validate") + def validate_workflow( + request: JobRequest, + ws: Workspace = Depends(selected_workspace), + sizes: bool = Query( + True, + description="Ask the hub how large each missing model is; false " + "skips the network for a faster answer", + ), + ): +``` + +(`Query` is not yet imported: line 24 is `from fastapi import Depends, FastAPI, HTTPException, Request` - add `Query` to it.) + +Extend the docstring's last sentence with: `A valid answer also carries a plan: the fingerprint of the work these arguments produce, the step count, the list lengths, the model repos not in the cache, and an estimate from the workflow's cost block.` + +After the `answer = {...}` block and the `checked_arguments` conditional, before `return answer`: + +```python + # What the run will execute for these arguments, fingerprinted so + # an acknowledgement can be bound to it (#85). Best effort: the + # verdict above is the schema's and the planner may not change it + try: + from .. import get_device, get_device_type + + answer["plan"] = build_plan( + candidate, + request.arguments, + device=get_device_type(get_device()), + prompt_dir=workspace.prompts, + lookup_sizes=sizes, + ) + except Exception: + logger.exception("Plan could not be built") + answer["plan"] = None + return answer +``` + +(`get_device`/`get_device_type` are imported inside functions elsewhere in this file - lines ~2687 and ~2720 - because `dw/__init__` does device detection at import; follow that.) + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python -m pytest tests/test_server.py -k "ValidatePlan or validate" -v` +Expected: PASS, the pre-existing validate tests included. + +- [ ] **Step 5: Commit** + +```bash +git add dw/server/app.py tests/test_server.py +git commit -m "feat(server): #85 - a valid pre-flight answers with the run's plan + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 6: MCP surface and the refusal message + +**Files:** +- Modify: `dw_mcp/server.py` (`validate_workflow` docstring at ~648-688), `dw_mcp/diagnose.py` (`COST_REFUSAL` at line 25) +- Test: `tests/test_mcp_server.py`, `tests/test_mcp_diagnose.py` + +**Interfaces:** +- Consumes: the `plan` field from Task 5, unchanged through `authoring.validate_workflow` (it returns the server's JSON as-is, so no client change). +- Produces: nothing new in code; the docstring and the refusal are the deliverable. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_mcp_server.py` (check how the module reaches the tool functions - it builds the server with `create_server`/`build_server` and reads tools by name; mirror the nearest docstring test around line 865): + +```python +async def test_validate_workflow_teaches_quoting_from_the_plan(): + tools = await tools_of(server_over(ok({}))) + doc = tools["validate_workflow"].description + assert "plan" in doc + assert "downloads_required" in doc + assert "estimate" in doc + assert "basis" in doc +``` + +(`tools_of`, `server_over` and `ok` are the module's existing helpers - line ~144 - and the file's async tests run under the existing pytest-asyncio configuration; copy the decorator, if any, from `test_read_only_tools_are_annotated_read_only`.) + +Append to `tests/test_mcp_diagnose.py`: + +```python +def test_the_refusal_says_to_quote_the_plan(): + from dw_mcp.diagnose import COST_REFUSAL + + assert "plan" in COST_REFUSAL + assert "validate_workflow" in COST_REFUSAL +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python -m pytest tests/test_mcp_server.py tests/test_mcp_diagnose.py -k "plan" -v` +Expected: FAIL on the `plan` / `downloads_required` assertions. + +- [ ] **Step 3: Implement** + +`dw_mcp/diagnose.py`: + +```python +COST_REFUSAL = ( + "Running a workflow occupies the GPU for minutes and the engine runs one " + "job at a time. Call `validate_workflow` with the arguments you will run " + "with (free): its `plan` says what will execute - `estimate.minutes` with " + "its `basis`, and any weights in `downloads_required` this box has to " + "fetch first. Tell the user that number, get their go-ahead, then call " + "again with acknowledged_cost=true." +) +``` + +`dw_mcp/server.py`, append a paragraph to the `validate_workflow` docstring (before the closing `"""`): + +``` + A valid answer carries `plan`: what will execute for these + arguments. Quote `plan.estimate.minutes` with its `basis` - + `per_entry` or `catalog` is a measured figure re-priced for your + list, `other_device` a figure from another accelerator (say so), + `unknown` no figure at all - and name each `downloads_required` + entry as its own line item ("and 41 GB of weights this box does not + have"); `gb` is null when the hub could not be asked. `steps` and + `list_entries` say how many members the list actually produced. + `plan` is null when it could not be built; the verdict stands. +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python -m pytest tests/test_mcp_server.py tests/test_mcp_diagnose.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add dw_mcp/server.py dw_mcp/diagnose.py tests/test_mcp_server.py tests/test_mcp_diagnose.py +git commit -m "docs(mcp): #85 - the number to say out loud is the plan's, not the listing's + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 7: docs, skills and the release note + +**Files:** +- Modify: `docs/SERVER.md` (~252-269), `docs/MCP.md` (`## The cost gate`, ~312-347, and the `validate_workflow` row of the tool table), `docs/WORKFLOW_GUIDE.md` (~439-445 and step 4 of "The loop" ~481-487), `CLAUDE.md` (the `POST /api/validate` line under Critical Gotchas), `plugins/dw/skills/{ltx-2.5,minimax-h3,minimax-music3}/SKILL.md` (the "Run and judge" step 2), `docs/proposals/acknowledged-cost-binding.md` (status line) +- Test: `tests/test_plugin_skills.py`, `tests/test_docs_links.py` (existing) + +**Interfaces:** +- Consumes: the plan shape from Tasks 2-5. No code. + +- [ ] **Step 1: Check the skills' byte headroom** + +Run: `wc -c plugins/dw/skills/*/SKILL.md` +Expected: `ltx-2.5` 12182, `minimax-h3` 12239, `minimax-music3` 10505; cap 12288. The two tight ones must not grow. + +- [ ] **Step 2: Docs** + +`docs/SERVER.md`, after the `POST /api/validate` bullet's last sentence (`...about the stored defaults only.`), add a paragraph inside the same bullet: + +``` + A valid answer also carries `plan`, what the run will execute for those + arguments: `fingerprint` (`sha256:…` over the realized, expanded + definition with the seed and the documentation keys removed and + `output:…/latest/…` left unpinned - the same work hashes the same, a + longer list or an edited stored prompt does not); `steps`, the expanded + member count; `list_entries`, `{variable: length}` for each `for_each` + over a list variable; `cached_steps`, reserved (`null`); + `downloads_required`, each `model_name` the hub cache does not hold as + `{repo, gb}` (`gb` from the hub, `null` when it could not be asked - + `?sizes=false` skips the hub) and each `from_single_file` URL as + `{repo: null, url, gb: null}`; and `estimate`, `{minutes, basis, + device, measured_on, partial}` from the workflow's own `cost` block - + `basis` is `catalog` (the stored total), `per_entry` (re-priced for the + list passed, when the entry carries `per_entry`), `other_device` (no + entry for the serving backend; the first entry's figure, which is a + warning rather than a quote) or `unknown`; a composed child's cost is + added and `partial` is true when a child has none. `plan` is `null` when + it could not be built; an invalid answer carries no `plan` key. +``` + +`docs/MCP.md`: +- In the tool table, `validate_workflow` row: append to its description ` A valid answer carries `plan` - the fingerprint, step count, list lengths, `downloads_required` and `estimate` (with `basis`) for the arguments given; quote from it`. +- In "The intended loop", step 1: after `not the values you wrote` add `, and its `plan` is the number to say out loud: `estimate.minutes` with its `basis`, plus each `downloads_required` entry as a line item of its own`. + +`docs/WORKFLOW_GUIDE.md`: +- Around line 439-445 (`quote the cost before running a list-driven workflow...`): replace `quote \`minutes - per_entry.minutes × per_entry.entries + per_entry.minutes × N\` for N entries, and without \`per_entry\` quote the total as the default list's.` with `\`validate_workflow\` with your \`arguments\` answers with a \`plan\` whose \`estimate\` already does that arithmetic (\`basis: per_entry\`), and without \`per_entry\` reports the default list's total (\`basis: catalog\`) - quote the plan's figure and say which basis it has.` +- Step 4 of "The loop" (`run_workflow with acknowledged_cost=true, after telling the user what it costs...`): after `Without the acknowledgement the call is refused.` insert `The figure to tell them is the \`plan\` on the validate answer - \`estimate.minutes\` with its \`basis\`, and every \`downloads_required\` entry named as its own line item, since weights not on this box are minutes and gigabytes the cost block never counted.` Keep the rest of the step (the `models/` lookup for a workflow with no cost of its own) as the fallback for `basis: unknown`. + +`CLAUDE.md`, the Critical Gotchas bullet that begins `**A caller's \`arguments\` are checked before anything is queued**`: append one sentence: `A valid \`POST /api/validate\` answer also carries \`plan\` (\`dw/plan.py\`): the fingerprint of the work, step and list counts, \`downloads_required\` and a cost \`estimate\` with its \`basis\` - the number an agent quotes; \`plan: null\` when it could not be built, never a changed verdict.` + +`docs/proposals/acknowledged-cost-binding.md`, first line under the title: change `Status: **design only**` to `Status: **stage 1 implemented** (the plan on validate); stage 2 (binding, the 409) not started. Design: docs/superpowers/specs/2026-09-12-acknowledged-cost-binding-design.md.` and keep the rest of that paragraph. + +- [ ] **Step 3: Skills** + +Each "Run and judge" step 2 currently opens `Quote the listing's \`cost\``. Rewrite the opening clause only, keeping every model-specific number that follows it (those are pinned by `tests/test_plugin_skills.py`): + +`plugins/dw/skills/ltx-2.5/SKILL.md` step 2, replace +``` +2. Quote the listing's `cost` - the run's whole wall clock, model loading + included. Only `templates/ltx2/text-to-video` and `templates/ltx2/two-stage` + declare one; for the other six say so and give the shape of the spend +``` +with +``` +2. Quote `plan.estimate` from the validate answer (whole wall clock, loading + included) and name any `downloads_required`. Only `text-to-video` and + `two-stage` carry a `cost`; for the other six say so and give the shape +``` +(Net change is negative; re-run `wc -c` and confirm under 12288.) + +`plugins/dw/skills/minimax-h3/SKILL.md` step 2, replace +``` +2. Quote the listing's `cost` (warm minutes on the card it was measured on; + a first load is longer). When it declares none, say so and give the shape +``` +with +``` +2. Quote `plan.estimate` from the validate answer (warm minutes; a first + load, and any `downloads_required`, is longer). When `basis` is + `unknown`, say so and give the shape +``` +Then trim elsewhere in that file if `wc -c` exceeds 12288 - the "Run and judge" step 1 `first - free, and it catches arguments the pipeline does not accept.` can lose `does not accept` → `rejects` (-9 bytes) and similar; keep every number. + +`plugins/dw/skills/minimax-music3/SKILL.md` step 2, replace +``` +2. Quote the listing's `cost` (warm minutes on the card it was measured on; + a first load is longer). When the listing declares none, say so and give +``` +with +``` +2. Quote `plan.estimate` from the validate answer (warm minutes on the card it + was measured on; a first load is longer). When `basis` is `unknown`, say so and give +``` + +- [ ] **Step 4: Run the doc and skill tests** + +Run: `python -m pytest tests/test_plugin_skills.py tests/test_docs_links.py -v && wc -c plugins/dw/skills/*/SKILL.md` +Expected: PASS; every skill under 12288 bytes. + +- [ ] **Step 5: Commit** + +```bash +git add docs/SERVER.md docs/MCP.md docs/WORKFLOW_GUIDE.md CLAUDE.md plugins/dw/skills docs/proposals/acknowledged-cost-binding.md +git commit -m "docs: #85 - the plan on validate, and the skills quote from it rather than the listing + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 8: whole-suite verification + +**Files:** none new. + +- [ ] **Step 1: Run the full suite** + +Run: `python -m pytest -q -x --ignore=tests/test_integration.py 2>&1 | tail -5` +Expected: all pass (the tree was 3850 passing at ae4bf9b; expect that plus the new tests). `test_integration.py` needs a GPU and is skipped by marker in CI - run it only if it runs on this box today. + +- [ ] **Step 2: Run the UI tests if any `.ts` changed** (none should have): skip. + +- [ ] **Step 3: Check the branch is clean and fully committed** + +Run: `git status --short && git log --oneline develop..HEAD` +Expected: clean; seven commits after the merge commit. + +--- + +## Self-review + +**Spec coverage (stage 1, sections 1-5):** +- 1.1 realization/expansion, `steps`, `list_entries` - Task 2. Note the deviation: `build_plan` takes the route's `Workflow` rather than `(definition, base_dir, output_root, workflow_dir)`, because expansion has to go through `Workflow.expanded_definition` (realization does not substitute `variable:` references, and `for_each` needs the substituted list) and the `Workflow` already carries the confinement the run has. The spec's constraint on `dw.server`/`dw.worker` imports holds. +- 1.2 fingerprint and its table - Task 2, every cell a test. +- 1.3 estimate rules 1-5 - Task 3, including sub-workflows, `partial`, builtin. +- 1.4 downloads, sizes, `lookup_sizes` - Task 4. +- 1.5 `cached_steps: null` - Task 2. +- 2 the route, `?sizes=`, `plan: null`, no `plan` on invalid - Task 5. +- 3 MCP docstring, `COST_REFUSAL` - Task 6; `get_guide`'s section is WORKFLOW_GUIDE.md - Task 7. +- 4 docs and skills - Task 7. +- 5 tests - Tasks 1-6. +- Release note items - the proposal's status line in Task 7; the release note itself is owed with the other 2026-09-12 items and is not part of this plan. + +**Type consistency:** `build_plan(candidate, arguments, *, device, prompt_dir, cache_dir, lookup_sizes, cache_probe)` is used identically in Tasks 2-5; `estimate(...)` and `downloads_required(...)` signatures match between their definition and their call in `build_plan`; `read_sub_workflow(path, base_dir, workflow_dir)` matches Task 1. From dfc84efc373bdbae5515f1d0d4925f2c1789a1c4 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:06:59 -0500 Subject: [PATCH 04/34] feat(engine): #85 - realize_workflow can leave 'latest' unpinned, and a sub-workflow is read by one resolver Co-Authored-By: Claude Opus 5 (1M context) --- dw/realize.py | 38 +++++++++++++++++-------- tests/test_realize.py | 65 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 11 deletions(-) diff --git a/dw/realize.py b/dw/realize.py index eb980019..c970aad2 100644 --- a/dw/realize.py +++ b/dw/realize.py @@ -48,6 +48,7 @@ def realize_workflow( prompt_dir=None, output_root=None, workflow_dir=None, + pin_outputs=True, ): """A copy of `definition` with every mutable input pinned. @@ -64,6 +65,11 @@ def realize_workflow( output_root: The output directory `output:` names resolve against. workflow_dir: The root a sub-workflow path is confined to, as `Workflow` confines it; None for an unconfined CLI run. + pin_outputs: Whether an 'output:.../latest/...' reference is rewritten + to the run it resolves to. The run leaves this True; the planner + (dw/plan.py) passes False so a run finishing between a validate + call and the queue call does not change the fingerprint of + identical work. Returns: (realized, annotations) - the pinned copy, and @@ -92,7 +98,9 @@ def realize_workflow( seed_variable = definition_seed.removeprefix(VARIABLE_PREFIX) if isinstance(variables, dict) and seed_variable in variables: variables[seed_variable] = seed - realized = _pin(realized, annotations, base_dir, prompt_dir, output_root) + realized = _pin( + realized, annotations, base_dir, prompt_dir, output_root, pin_outputs + ) _record_sub_workflows(realized.get("steps"), annotations, base_dir, workflow_dir) return realized, annotations @@ -129,13 +137,14 @@ def _map_strings(value, transform): return value -def _pin(value, annotations, base_dir, prompt_dir, output_root): - """Rebuild a value with prompt and output references pinned.""" +def _pin(value, annotations, base_dir, prompt_dir, output_root, pin_outputs=True): + """Rebuild a value with prompt references inlined and, when + `pin_outputs`, output references pinned.""" def transform(string): if string.startswith(PROMPT_PREFIX): return _inline_prompt(string, annotations, prompt_dir, base_dir) - if is_output_reference(string): + if pin_outputs and is_output_reference(string): return _pin_output(string, output_root) return string @@ -208,21 +217,28 @@ def scan(value): scan(step) -def _digest(path, base_dir, workflow_dir): - """The SHA-256 of a sub-workflow file, or None when it cannot be read. +def read_sub_workflow(path, base_dir, workflow_dir): + """The bytes of the sub-workflow file a step's `path` names, or None + when it cannot be read. Resolved the way `Workflow.create_step_action` resolves it - beside the referencing file, then across the workflow search path, then through `validate_workflow_path` confined to the root it came from - so a path - this run could not have loaded is not one realization reads either, and - a catalog name the run composed is digested rather than recorded as - unreadable (#90). + this run could not have loaded is not one realization (or the planner) + reads either, and a catalog name the run composed is read rather than + recorded as unreadable (#90). """ try: candidate, root = resolve_sub_workflow(path, base_dir or ".", workflow_dir) validated = validate_workflow_path(candidate, root) with open(validated, "rb") as file: - return hashlib.sha256(file.read()).hexdigest() + return file.read() except (SecurityError, OSError, ValueError, SubWorkflowNotFound) as e: - logger.debug(f"No digest for sub-workflow {path}: {e}") + logger.debug(f"Sub-workflow {path} could not be read: {e}") return None + + +def _digest(path, base_dir, workflow_dir): + """The SHA-256 of a sub-workflow file, or None when it cannot be read.""" + raw = read_sub_workflow(path, base_dir, workflow_dir) + return hashlib.sha256(raw).hexdigest() if raw is not None else None diff --git a/tests/test_realize.py b/tests/test_realize.py index db76cf38..f6bf3473 100644 --- a/tests/test_realize.py +++ b/tests/test_realize.py @@ -242,3 +242,68 @@ def test_finds_every_matching_string_deduplicated_in_first_seen_order(self): "asset:iris.png", "asset:mask.png", ] + + +class TestUnpinnedOutputs: + def test_pin_outputs_false_leaves_latest_as_written(self, output_root): + root, _ = output_root + spec = definition() + spec["steps"][0]["pipeline"]["arguments"]["image"] = ( + "output:ltx2/Gyre/latest/still.png" + ) + realized, _ = realize_workflow( + spec, {}, 7, output_root=root, pin_outputs=False + ) + assert ( + realized["steps"][0]["pipeline"]["arguments"]["image"] + == "output:ltx2/Gyre/latest/still.png" + ) + + def test_pin_outputs_false_still_inlines_prompts(self, prompt_library): + spec = definition() + spec["variables"]["prompt"] = "prompt:scenic/dusk" + realized, annotations = realize_workflow( + spec, {}, 7, prompt_dir=prompt_library, pin_outputs=False + ) + assert realized["variables"]["prompt"] == "a harbour at dusk" + assert annotations["prompts"] == ["scenic/dusk"] + + def test_the_default_still_pins(self, output_root): + root, run_id = output_root + spec = definition() + spec["steps"][0]["pipeline"]["arguments"]["image"] = ( + "output:ltx2/Gyre/latest/still.png" + ) + realized, _ = realize_workflow(spec, {}, 7, output_root=root) + assert ( + realized["steps"][0]["pipeline"]["arguments"]["image"] + == f"output:ltx2/Gyre/{run_id}/still.png" + ) + + +class TestReadSubWorkflow: + def test_reads_a_child_beside_the_parent(self, tmp_path): + from dw.realize import read_sub_workflow + + child = {"id": "child", "steps": []} + (tmp_path / "child.json").write_text(json.dumps(child)) + raw = read_sub_workflow("child.json", str(tmp_path), str(tmp_path)) + assert json.loads(raw) == child + + def test_a_missing_child_reads_as_none(self, tmp_path): + from dw.realize import read_sub_workflow + + assert read_sub_workflow("nope.json", str(tmp_path), str(tmp_path)) is None + + def test_a_child_outside_the_confinement_reads_as_none(self, tmp_path): + from dw.realize import read_sub_workflow + + outside = tmp_path / "outside" + outside.mkdir() + (outside / "child.json").write_text(json.dumps({"id": "c", "steps": []})) + confined = tmp_path / "confined" + confined.mkdir() + assert ( + read_sub_workflow("../outside/child.json", str(confined), str(confined)) + is None + ) From 3032180f4fcc95351da72b59e2d623b917f1cc43 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:08:04 -0500 Subject: [PATCH 05/34] feat(engine): #85 - a plan fingerprints the work a run will execute for the caller's arguments Co-Authored-By: Claude Opus 5 (1M context) --- dw/plan.py | 147 ++++++++++++++++++++++++++++++++ tests/test_plan.py | 208 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 dw/plan.py create mode 100644 tests/test_plan.py diff --git a/dw/plan.py b/dw/plan.py new file mode 100644 index 00000000..b13b2366 --- /dev/null +++ b/dw/plan.py @@ -0,0 +1,147 @@ +"""The plan a validate call answers with: what a run of a workflow with a +caller's arguments will actually execute, what it will have to download +first, and what the workflow's own cost block says it will take - with a +fingerprint over the work, so an acknowledgement can be bound to it and a +run whose shape changed after consent refused (#85, stage 2). + +Everything here is derived from the same resolvers the run uses - +`realize_workflow` folds the arguments and inlines the prompts, and +`Workflow.expanded_definition` substitutes and expands `for_each` - so the +plan describes the run and not an approximation of it. Nothing here knows a +model: every minute comes from a `cost` block and every repo name from a +`from_pretrained_arguments`. +""" + +import copy +import hashlib +import json +import logging +import os + +from huggingface_hub import model_info + +from .hub_cache import scan_models +from .realize import ( + BUILTIN_PREFIX, + VARIABLE_PREFIX, + read_sub_workflow, + realize_workflow, +) +from .security import validate_url +from .workflow import Workflow + +logger = logging.getLogger("dw") + +FINGERPRINT_PREFIX = "sha256:" +# Top-level keys that document a workflow rather than shape its work +DOCUMENTATION_KEYS = ("cost", "description", "summary", "configures") +FOR_EACH_KEY = "for_each" +SIZE_LOOKUP_TIMEOUT = 5.0 +GIB = 1024**3 + + +def build_plan( + candidate, + arguments, + *, + device, + prompt_dir=None, + cache_dir=None, + lookup_sizes=True, + cache_probe=None, +): + """What a run of `candidate` with `arguments` will execute and cost. + + Args: + candidate: The Workflow the route built - it carries the file spec + (so base_dir), the output root and the confinement a run has. + arguments: The caller's arguments, already past `argument_errors`; + an undeclared name or an uncoercible value raises here. + device: The backend that is serving - 'cuda', 'mps' or 'cpu'. + prompt_dir: The prompt library, for inlining. + cache_dir: The hub cache to check downloads against; None for the + default. + lookup_sizes: Whether to ask the hub how large a missing repo is. + cache_probe: Stage 2's step-cache probe; unused, `cached_steps` is + always None until then. + """ + definition = candidate.workflow_definition + base_dir = ( + os.path.dirname(os.path.abspath(candidate.file_spec)) + if candidate.file_spec + else None + ) + realized, _ = realize_workflow( + definition, + arguments, + seed=0, + base_dir=base_dir, + prompt_dir=prompt_dir, + output_root=candidate.output_dir, + workflow_dir=candidate.workflow_dir, + pin_outputs=False, + ) + # Arguments are already folded into the realized variables, so the + # expansion takes none; it substitutes and expands exactly as the run + expanded = Workflow( + realized, candidate.output_dir, candidate.file_spec, candidate.workflow_dir + ).expanded_definition() + entries = list_entries(definition, realized) + return { + "fingerprint": fingerprint(expanded, definition), + "steps": len(expanded.get("steps") or []), + "list_entries": entries, + "cached_steps": None, + "downloads_required": [], + "estimate": None, + } + + +def list_entries(definition, realized): + """{variable: length} for every `for_each` that names a list variable, + read from the folded variables - a literal list is not an argument and + is not listed.""" + variables = realized.get("variables") or {} + entries = {} + for step in definition.get("steps") or []: + if not isinstance(step, dict): + continue + reference = step.get(FOR_EACH_KEY) + if isinstance(reference, str) and reference.startswith(VARIABLE_PREFIX): + name = reference.removeprefix(VARIABLE_PREFIX) + value = variables.get(name) + if isinstance(value, list): + entries[name] = len(value) + return entries + + +def fingerprint(expanded, definition): + """SHA-256 over the expanded definition with everything that is not + work removed: the seed wherever it sits, and the documentation keys. + + `definition` is the workflow as written, consulted for whether the + top-level seed named a variable - if it did, that variable's folded + value is the seed too and is blanked at its source. + """ + doc = copy.deepcopy(expanded) + doc.pop("seed", None) + for key in DOCUMENTATION_KEYS: + doc.pop(key, None) + written_seed = definition.get("seed") + if isinstance(written_seed, str) and written_seed.startswith(VARIABLE_PREFIX): + name = written_seed.removeprefix(VARIABLE_PREFIX) + variables = doc.get("variables") + if isinstance(variables, dict) and name in variables: + variables[name] = None + for step in doc.get("steps") or []: + if isinstance(step, dict): + step.pop("seed", None) + pipeline = step.get("pipeline") + if isinstance(pipeline, dict): + pipeline.pop("seed", None) + # default=repr: a realized 'constant:' can be any Python value, and the + # fingerprint only needs it to be stable, not round-trippable + serialized = json.dumps( + doc, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=repr + ) + return FINGERPRINT_PREFIX + hashlib.sha256(serialized.encode("utf-8")).hexdigest() diff --git a/tests/test_plan.py b/tests/test_plan.py new file mode 100644 index 00000000..939eaac1 --- /dev/null +++ b/tests/test_plan.py @@ -0,0 +1,208 @@ +"""The plan a validate call answers with: what a run will execute for the +arguments given, fingerprinted so an acknowledgement can be bound to it.""" + +import copy +import json + +import pytest + +from dw.plan import build_plan +from dw.runs import new_run_id +from dw.workflow import workflow_from_definition + + +def definition(): + """A list-driven workflow with a stored prompt and an output reference, + so every mutable input the fingerprint must ignore or honour is here.""" + return { + "id": "plan_test", + "description": "docs only", + "summary": "docs only", + "seed": "variable:seed", + "cost": [{"device": "cuda", "name": "card", "vram_gb": 8, "minutes": 10}], + "variables": { + "seed": 1, + "prompt": "prompt:scenic/dusk", + "frames": 25, + "shots": [{"name": "a", "prompt": "one"}, {"name": "b", "prompt": "two"}], + }, + "steps": [ + { + "name": "still", + "pipeline": { + "configuration": {"component_type": "{Fake}"}, + "from_pretrained_arguments": {"model_name": "org/still-model"}, + "arguments": { + "prompt": "variable:prompt", + "image": "output:ltx2/Gyre/latest/still.png", + "num_frames": "variable:frames", + }, + }, + }, + { + "name": "shot", + "for_each": "variable:shots", + "task": {"command": "x", "arguments": {"prompt": "item:prompt"}}, + }, + ], + } + + +@pytest.fixture +def prompt_library(tmp_path): + library = tmp_path / "prompts" + (library / "scenic").mkdir(parents=True) + (library / "scenic" / "dusk.json").write_text( + json.dumps({"text": "a harbour at dusk"}) + ) + return library + + +@pytest.fixture +def output_root(tmp_path): + root = tmp_path / "outputs" + run = root / "ltx2" / "Gyre" / new_run_id({"a": 1}) + run.mkdir(parents=True) + (run / "still.png").write_bytes(b"png") + return root + + +@pytest.fixture +def plan(tmp_path, prompt_library, output_root, monkeypatch): + """build_plan over the fixture, with the hub cache empty and the hub + unreachable, so downloads never touch the network in these tests.""" + import dw.plan + + def offline(*a, **k): + raise RuntimeError("offline") + + monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + monkeypatch.setattr(dw.plan, "model_info", offline) + + def make(spec=None, arguments=None, **overrides): + spec = definition() if spec is None else spec + candidate = workflow_from_definition( + copy.deepcopy(spec), str(output_root), str(tmp_path), str(tmp_path) + ) + kwargs = dict(device="cuda", prompt_dir=str(prompt_library), lookup_sizes=False) + kwargs.update(overrides) + return build_plan(candidate, arguments or {}, **kwargs) + + return make + + +PLAN_KEYS = { + "fingerprint", + "steps", + "list_entries", + "cached_steps", + "downloads_required", + "estimate", +} + + +class TestShape: + def test_the_documented_keys(self, plan): + answer = plan() + assert set(answer) == PLAN_KEYS + assert answer["fingerprint"].startswith("sha256:") + assert len(answer["fingerprint"]) == len("sha256:") + 64 + assert answer["cached_steps"] is None + + def test_steps_counts_the_expanded_members(self, plan): + assert plan()["steps"] == 3 # still, shot@a, shot@b + + def test_list_entries_is_the_callers_list_length(self, plan): + shots = [{"name": n, "prompt": n} for n in "abcde"] + answer = plan(arguments={"shots": shots}) + assert answer["list_entries"] == {"shots": 5} + assert answer["steps"] == 6 + + def test_a_literal_for_each_list_is_not_an_entry(self, plan): + spec = definition() + spec["steps"][1]["for_each"] = [ + {"name": "x", "prompt": "x"}, + {"name": "y", "prompt": "y"}, + ] + del spec["variables"]["shots"] + assert plan(spec)["list_entries"] == {} + + +class TestFingerprintIsStableAcross: + def test_a_different_top_level_seed(self, plan): + assert plan()["fingerprint"] == plan(arguments={"seed": 99})["fingerprint"] + + def test_a_different_step_seed(self, plan): + spec = definition() + spec["steps"][0]["seed"] = 5 + spec["steps"][0]["pipeline"]["seed"] = 5 + other = copy.deepcopy(spec) + other["steps"][0]["seed"] = 6 + other["steps"][0]["pipeline"]["seed"] = 6 + assert plan(spec)["fingerprint"] == plan(other)["fingerprint"] + + def test_key_order(self, plan): + spec = definition() + reordered = {k: spec[k] for k in reversed(list(spec))} + assert plan(spec)["fingerprint"] == plan(reordered)["fingerprint"] + + def test_description_summary_and_cost_edits(self, plan): + spec = definition() + spec["description"] = "rewritten" + spec["summary"] = "rewritten" + spec["cost"][0]["minutes"] = 99 + spec["configures"] = "templates/x" + assert plan(spec)["fingerprint"] == plan()["fingerprint"] + + def test_a_new_run_landing_under_latest(self, plan, output_root): + before = plan()["fingerprint"] + newer = output_root / "ltx2" / "Gyre" / new_run_id({"b": 2}) + newer.mkdir(parents=True) + (newer / "still.png").write_bytes(b"png2") + assert plan()["fingerprint"] == before + + def test_argument_order(self, plan): + a = plan(arguments={"frames": 9, "seed": 3})["fingerprint"] + b = plan(arguments={"seed": 3, "frames": 9})["fingerprint"] + assert a == b + + +class TestFingerprintChangesWith: + def test_a_longer_list(self, plan): + longer = [{"name": n, "prompt": n} for n in "abc"] + assert plan()["fingerprint"] != plan(arguments={"shots": longer})["fingerprint"] + + def test_a_changed_stored_prompt(self, plan, prompt_library): + before = plan()["fingerprint"] + (prompt_library / "scenic" / "dusk.json").write_text( + json.dumps({"text": "a harbour at dawn"}) + ) + assert plan()["fingerprint"] != before + + def test_a_changed_numeric_argument(self, plan): + assert plan()["fingerprint"] != plan(arguments={"frames": 121})["fingerprint"] + + def test_a_different_asset_name(self, plan): + spec = definition() + spec["steps"][0]["pipeline"]["arguments"]["image"] = "asset:a.png" + other = copy.deepcopy(spec) + other["steps"][0]["pipeline"]["arguments"]["image"] = "asset:b.png" + assert plan(spec)["fingerprint"] != plan(other)["fingerprint"] + + def test_a_step_added_removed_or_renamed(self, plan): + base = plan()["fingerprint"] + renamed = definition() + renamed["steps"][0]["name"] = "frame" + removed = definition() + del removed["steps"][1] + added = definition() + added["steps"].append( + {"name": "extra", "task": {"command": "x", "arguments": {}}} + ) + prints = { + base, + plan(renamed)["fingerprint"], + plan(removed)["fingerprint"], + plan(added)["fingerprint"], + } + assert len(prints) == 4 From 6b0b21f20e8d9f701d5ca0c81f381e087a9ad8fe Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:08:57 -0500 Subject: [PATCH 06/34] feat(engine): #85 - the plan prices a run from its cost block, per entry when measured, plus each composed child Co-Authored-By: Claude Opus 5 (1M context) --- dw/plan.py | 84 ++++++++++++++++++++++++++++- tests/test_plan.py | 128 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) diff --git a/dw/plan.py b/dw/plan.py index b13b2366..3109de98 100644 --- a/dw/plan.py +++ b/dw/plan.py @@ -93,7 +93,9 @@ def build_plan( "list_entries": entries, "cached_steps": None, "downloads_required": [], - "estimate": None, + "estimate": estimate( + definition, expanded, entries, device, base_dir, candidate.workflow_dir + ), } @@ -145,3 +147,83 @@ def fingerprint(expanded, definition): doc, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=repr ) return FINGERPRINT_PREFIX + hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + +UNKNOWN = "unknown" +CATALOG = "catalog" +PER_ENTRY = "per_entry" +OTHER_DEVICE = "other_device" + + +def estimate(definition, expanded, list_entries, device, base_dir, workflow_dir): + """Minutes from the workflow's own cost block, scaled by the caller's + list when the block was measured per entry, plus each composed child's. + + `basis` names where the figure came from - the honesty is in the field, + not in a fabricated number: 'unknown' is no cost block at all, + 'other_device' a figure measured on a backend other than the one + serving (reported so the agent has something to scale, flagged so it + is not quoted as a measurement), 'catalog' the stored total, and + 'per_entry' that total re-priced for the list actually passed. + """ + own = _price(definition.get("cost"), device, list_entries) + minutes = own["minutes"] + partial = False + for path in _sub_workflow_paths(expanded): + # A builtin is the parent's to price; a local child prices itself + raw = read_sub_workflow(path, base_dir, workflow_dir) + child_cost = None + if raw is not None: + try: + child_cost = json.loads(raw).get("cost") + except (ValueError, AttributeError): + child_cost = None + child = _price(child_cost, device, {}) + if child["minutes"] is None: + partial = True + elif minutes is not None: + minutes += child["minutes"] + else: + minutes = child["minutes"] + return { + "minutes": round(minutes, 1) if minutes is not None else None, + "basis": own["basis"], + "device": device, + "measured_on": own["measured_on"], + "partial": partial, + } + + +def _sub_workflow_paths(expanded): + """The local (non-builtin) sub-workflow path of every composing step.""" + for step in expanded.get("steps") or []: + reference = step.get("workflow") if isinstance(step, dict) else None + path = reference.get("path") if isinstance(reference, dict) else None + if isinstance(path, str) and not path.startswith(BUILTIN_PREFIX): + yield path + + +def _price(cost, device, list_entries): + """One cost list priced for `device` and `list_entries`, as + {minutes, basis, measured_on}.""" + entries = [entry for entry in (cost or []) if isinstance(entry, dict)] + if not entries: + return {"minutes": None, "basis": UNKNOWN, "measured_on": None} + chosen = next((entry for entry in entries if entry.get("device") == device), None) + basis = CATALOG + if chosen is None: + chosen = entries[0] + basis = OTHER_DEVICE + minutes = float(chosen.get("minutes", 0)) + per = chosen.get("per_entry") + if ( + basis == CATALOG + and isinstance(per, dict) + and per.get("variable") in list_entries + ): + count = list_entries[per["variable"]] + each = float(per.get("minutes", 0)) + measured_with = int(per.get("entries", 0)) + minutes = max(0.0, (minutes - each * measured_with) + each * count) + basis = PER_ENTRY + return {"minutes": minutes, "basis": basis, "measured_on": chosen.get("name")} diff --git a/tests/test_plan.py b/tests/test_plan.py index 939eaac1..432acb51 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -206,3 +206,131 @@ def test_a_step_added_removed_or_renamed(self, plan): plan(added)["fingerprint"], } assert len(prints) == 4 + + +def cost(device="cuda", minutes=10, per_entry=None, name="card"): + entry = {"device": device, "name": name, "vram_gb": 8, "minutes": minutes} + if per_entry: + entry["per_entry"] = per_entry + return entry + + +class TestEstimate: + def test_no_cost_block_is_unknown(self, plan): + spec = definition() + del spec["cost"] + assert plan(spec)["estimate"] == { + "minutes": None, + "basis": "unknown", + "device": "cuda", + "measured_on": None, + "partial": False, + } + + def test_an_empty_cost_list_is_unknown(self, plan): + spec = definition() + spec["cost"] = [] + assert plan(spec)["estimate"]["basis"] == "unknown" + + def test_the_serving_devices_entry_is_the_catalog_figure(self, plan): + spec = definition() + spec["cost"] = [cost("mps", 40, name="M2"), cost("cuda", 10, name="4090")] + assert plan(spec)["estimate"] == { + "minutes": 10.0, + "basis": "catalog", + "device": "cuda", + "measured_on": "4090", + "partial": False, + } + + def test_another_devices_entry_is_reported_as_such(self, plan): + spec = definition() + spec["cost"] = [cost("mps", 40, name="M2")] + assert plan(spec)["estimate"] == { + "minutes": 40.0, + "basis": "other_device", + "device": "cuda", + "measured_on": "M2", + "partial": False, + } + + def test_per_entry_scales_by_the_callers_list(self, plan): + spec = definition() + # 10 minutes for the 2-entry default, of which 3 per entry: 4 fixed + spec["cost"] = [ + cost("cuda", 10, {"variable": "shots", "minutes": 3, "entries": 2}) + ] + shots = [{"name": n, "prompt": n} for n in "abcde"] + answer = plan(spec, arguments={"shots": shots})["estimate"] + assert answer["minutes"] == 4 + 3 * 5 + assert answer["basis"] == "per_entry" + + def test_per_entry_floors_at_zero(self, plan): + spec = definition() + spec["cost"] = [ + cost("cuda", 1, {"variable": "shots", "minutes": 3, "entries": 2}) + ] + # 1 - 3 * 2 + 3 * 1 is negative: a shorter list than was measured + one = [{"name": "a", "prompt": "a"}] + assert plan(spec, arguments={"shots": one})["estimate"]["minutes"] == 0.0 + + def test_per_entry_naming_no_list_falls_back_to_catalog(self, plan): + spec = definition() + spec["cost"] = [ + cost("cuda", 10, {"variable": "other", "minutes": 3, "entries": 2}) + ] + answer = plan(spec)["estimate"] + assert (answer["minutes"], answer["basis"]) == (10.0, "catalog") + + def test_other_device_beats_per_entry(self, plan): + spec = definition() + spec["cost"] = [ + cost("mps", 10, {"variable": "shots", "minutes": 3, "entries": 2}) + ] + answer = plan(spec)["estimate"] + assert (answer["minutes"], answer["basis"]) == (10.0, "other_device") + + def test_minutes_is_rounded_to_one_decimal(self, plan): + spec = definition() + spec["cost"] = [cost("cuda", 10.04)] + assert plan(spec)["estimate"]["minutes"] == 10.0 + + +def composing(child_path): + return { + "id": "parent", + "cost": [cost("cuda", 2)], + "steps": [ + {"name": "own", "task": {"command": "x", "arguments": {}}}, + {"name": "child", "workflow": {"path": child_path, "arguments": {}}}, + ], + } + + +class TestSubWorkflowEstimate: + def test_a_childs_catalog_cost_is_added(self, plan, tmp_path): + child = {"id": "child", "cost": [cost("cuda", 5)], "steps": []} + (tmp_path / "child.json").write_text(json.dumps(child)) + answer = plan(composing("child.json"))["estimate"] + assert (answer["minutes"], answer["partial"]) == (7.0, False) + + def test_a_child_without_a_cost_makes_the_estimate_partial(self, plan, tmp_path): + (tmp_path / "child.json").write_text(json.dumps({"id": "child", "steps": []})) + answer = plan(composing("child.json"))["estimate"] + assert (answer["minutes"], answer["partial"]) == (2.0, True) + + def test_an_unreadable_child_makes_the_estimate_partial(self, plan): + answer = plan(composing("missing.json"))["estimate"] + assert (answer["minutes"], answer["partial"]) == (2.0, True) + + def test_a_builtin_adds_nothing_and_is_not_partial(self, plan): + answer = plan(composing("builtin:text-to-image.json"))["estimate"] + assert (answer["minutes"], answer["partial"]) == (2.0, False) + + def test_a_child_measured_on_another_device_is_still_added(self, plan, tmp_path): + child = {"id": "child", "cost": [cost("mps", 5)], "steps": []} + (tmp_path / "child.json").write_text(json.dumps(child)) + answer = plan(composing("child.json"))["estimate"] + assert answer["minutes"] == 7.0 + # the parent's own basis is what is reported + assert answer["basis"] == "catalog" From f0365cddc2c9d07a2bf9a7b73b510d375c5f6f9a Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:09:33 -0500 Subject: [PATCH 07/34] feat(engine): #85 - the plan names the weights a run would download first Co-Authored-By: Claude Opus 5 (1M context) --- dw/plan.py | 81 ++++++++++++++++++++++++++++- tests/test_plan.py | 125 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 1 deletion(-) diff --git a/dw/plan.py b/dw/plan.py index 3109de98..fea8381d 100644 --- a/dw/plan.py +++ b/dw/plan.py @@ -92,7 +92,9 @@ def build_plan( "steps": len(expanded.get("steps") or []), "list_entries": entries, "cached_steps": None, - "downloads_required": [], + "downloads_required": downloads_required( + expanded, base_dir, candidate.workflow_dir, cache_dir, lookup_sizes + ), "estimate": estimate( definition, expanded, entries, device, base_dir, candidate.workflow_dir ), @@ -227,3 +229,80 @@ def _price(cost, device, list_entries): minutes = max(0.0, (minutes - each * measured_with) + each * count) basis = PER_ENTRY return {"minutes": minutes, "basis": basis, "measured_on": chosen.get("name")} + + +FROM_PRETRAINED_KEY = "from_pretrained_arguments" +MODEL_NAME_KEY = "model_name" +SINGLE_FILE_KEY = "from_single_file" + + +def downloads_required(expanded, base_dir, workflow_dir, cache_dir, lookup_sizes): + """The hub repos and checkpoint URLs the run would fetch before its + first step: every `model_name` in the expanded definition (and in each + composed child) that `scan_models` does not find, plus every + `from_single_file` that is a URL. Sizes come from the hub when asked + and are None whenever it does not answer - an offline box is a state, + not an error, so nothing here raises or logs above debug. + """ + names = [] + urls = [] + _collect_sources(expanded, names, urls) + for path in _sub_workflow_paths(expanded): + raw = read_sub_workflow(path, base_dir, workflow_dir) + if raw is None: + continue + try: + _collect_sources(json.loads(raw), names, urls) + except ValueError: + continue + present = {repo.get("repo_id") for repo in scan_models(cache_dir).get("repos", [])} + required = [] + for name in names: + if name in present or os.path.isdir(name): + continue + required.append({"repo": name, "gb": _size_gb(name) if lookup_sizes else None}) + for url in urls: + required.append({"repo": None, "url": url, "gb": None}) + return required + + +def _collect_sources(tree, names, urls): + """Every from_pretrained source in a tree, first-seen order, deduplicated.""" + if isinstance(tree, dict): + source = tree.get(FROM_PRETRAINED_KEY) + if isinstance(source, dict): + name = source.get(MODEL_NAME_KEY) + if isinstance(name, str) and name not in names: + names.append(name) + single = source.get(SINGLE_FILE_KEY) + if isinstance(single, str) and _is_url(single) and single not in urls: + urls.append(single) + for value in tree.values(): + _collect_sources(value, names, urls) + elif isinstance(tree, list): + for value in tree: + _collect_sources(value, names, urls) + + +def _is_url(value): + if not value.startswith(("http://", "https://")): + return False + try: + validate_url(value) + return True + except Exception: + return False + + +def _size_gb(name): + """A repo's size in GiB to one decimal, or None when the hub does not + say - unreachable, gated without a token, or a file with no size.""" + try: + info = model_info(name, files_metadata=True, timeout=SIZE_LOOKUP_TIMEOUT) + total = sum( + s.size for s in (info.siblings or []) if getattr(s, "size", None) + ) + except Exception as e: + logger.debug(f"No size for {name}: {e}") + return None + return round(total / GIB, 1) if total else None diff --git a/tests/test_plan.py b/tests/test_plan.py index 432acb51..0fbac070 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -334,3 +334,128 @@ def test_a_child_measured_on_another_device_is_still_added(self, plan, tmp_path) assert answer["minutes"] == 7.0 # the parent's own basis is what is reported assert answer["basis"] == "catalog" + + +class TestDownloadsRequired: + def test_a_repo_not_in_the_cache_is_required(self, plan): + assert plan()["downloads_required"] == [{"repo": "org/still-model", "gb": None}] + + def test_a_cached_repo_is_not(self, plan, monkeypatch): + import dw.plan + + monkeypatch.setattr( + dw.plan, + "scan_models", + lambda cache_dir=None: {"repos": [{"repo_id": "org/still-model"}]}, + ) + assert plan()["downloads_required"] == [] + + def test_cache_dir_reaches_scan_models(self, plan, monkeypatch): + import dw.plan + + seen = [] + + def spy(cache_dir=None): + seen.append(cache_dir) + return {"repos": []} + + monkeypatch.setattr(dw.plan, "scan_models", spy) + plan(cache_dir="/somewhere") + assert seen == ["/somewhere"] + + def test_a_local_directory_is_not_a_download(self, plan, tmp_path): + local = tmp_path / "weights" + local.mkdir() + spec = definition() + spec["steps"][0]["pipeline"]["from_pretrained_arguments"]["model_name"] = str( + local + ) + assert plan(spec)["downloads_required"] == [] + + def test_a_single_file_url_is_listed_without_a_size(self, plan): + spec = definition() + spec["steps"][0]["pipeline"]["from_pretrained_arguments"] = { + "from_single_file": "https://example.test/x.safetensors" + } + assert plan(spec)["downloads_required"] == [ + {"repo": None, "url": "https://example.test/x.safetensors", "gb": None} + ] + + def test_a_single_file_local_path_is_not_listed(self, plan): + spec = definition() + spec["steps"][0]["pipeline"]["from_pretrained_arguments"] = { + "from_single_file": "checkpoints/x.safetensors" + } + assert plan(spec)["downloads_required"] == [] + + def test_components_and_children_are_scanned_and_deduplicated( + self, plan, tmp_path + ): + spec = definition() + spec["steps"][0]["pipeline"]["components"] = { + "vae": {"from_pretrained_arguments": {"model_name": "org/vae"}} + } + child = { + "id": "child", + "steps": [ + { + "name": "c", + "pipeline": { + "configuration": {"component_type": "{Fake}"}, + "from_pretrained_arguments": {"model_name": "org/still-model"}, + "arguments": {}, + }, + } + ], + } + (tmp_path / "child.json").write_text(json.dumps(child)) + spec["steps"].append( + {"name": "sub", "workflow": {"path": "child.json", "arguments": {}}} + ) + assert [d["repo"] for d in plan(spec)["downloads_required"]] == [ + "org/still-model", + "org/vae", + ] + + def test_sizes_come_from_the_hub_in_gib(self, plan, monkeypatch): + import dw.plan + + class Sibling: + def __init__(self, size): + self.size = size + + class Info: + siblings = [Sibling(2 * 1024**3), Sibling(None), Sibling(512 * 1024**2)] + + calls = [] + + def fake_model_info(name, **kwargs): + calls.append((name, kwargs)) + return Info() + + monkeypatch.setattr(dw.plan, "model_info", fake_model_info) + answer = plan(lookup_sizes=True)["downloads_required"] + assert answer == [{"repo": "org/still-model", "gb": 2.5}] + assert calls[0][0] == "org/still-model" + assert calls[0][1]["files_metadata"] is True + assert calls[0][1]["timeout"] == 5.0 + + def test_a_hub_failure_is_a_null_size(self, plan, monkeypatch): + import dw.plan + + def boom(*a, **k): + raise RuntimeError("offline") + + monkeypatch.setattr(dw.plan, "model_info", boom) + assert plan(lookup_sizes=True)["downloads_required"] == [ + {"repo": "org/still-model", "gb": None} + ] + + def test_lookup_sizes_false_never_calls_the_hub(self, plan, monkeypatch): + import dw.plan + + def boom(*a, **k): + raise AssertionError("must not be called") + + monkeypatch.setattr(dw.plan, "model_info", boom) + plan(lookup_sizes=False) From 4f2813d7b4f8423fe5a30d5bce4bf8f018441b5c Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:10:11 -0500 Subject: [PATCH 08/34] feat(server): #85 - a valid pre-flight answers with the run's plan Co-Authored-By: Claude Opus 5 (1M context) --- dw/server/app.py | 33 ++++++++++++++++-- tests/test_server.py | 83 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/dw/server/app.py b/dw/server/app.py index 19611e35..d075e677 100644 --- a/dw/server/app.py +++ b/dw/server/app.py @@ -21,7 +21,7 @@ from urllib.parse import quote, urlparse from typing import Any, Dict, Optional -from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi import Depends, FastAPI, HTTPException, Query, Request from fastapi.concurrency import run_in_threadpool from fastapi.responses import StreamingResponse, JSONResponse, Response, FileResponse from fastapi.staticfiles import StaticFiles @@ -64,6 +64,7 @@ from ..result import read_embedded_metadata from ..media_info import probe_media from ..hub_cache import scan_models, delete_model, DownloadManager +from ..plan import build_plan from ..runs import is_output_reference, resolve_output_reference, split_run_path from ..workspace import ( ASSETS_SUBDIR, @@ -1255,13 +1256,23 @@ def _string_leaves(value, path): @app.post("/api/validate") def validate_workflow( - request: JobRequest, ws: Workspace = Depends(selected_workspace) + request: JobRequest, + ws: Workspace = Depends(selected_workspace), + sizes: bool = Query( + True, + description="Ask the hub how large each missing model is; false " + "skips the network for a faster answer", + ), ): """Schema-validate a workflow and check its pipeline arguments against real signatures, without queuing anything. Give either an inline workflow or a workflow_path - a path on the server or a stored workflow name from /api/workflows. The workspace it resolves - in comes from the body or the query string, body first.""" + in comes from the body or the query string, body first. A valid + answer also carries a plan: the fingerprint of the work these + arguments produce, the step count, the list lengths, the model + repos not in the cache, and an estimate from the workflow's cost + block.""" if (request.workflow is None) == (request.workflow_path is None): raise HTTPException( status_code=400, @@ -1362,6 +1373,22 @@ def validate_workflow( # Naming what was checked is the difference between 'the stored # definition is valid' and 'the values you are about to pass are' answer["checked_arguments"] = sorted(request.arguments) + # What the run will execute for these arguments, fingerprinted so + # an acknowledgement can be bound to it (#85). Best effort: the + # verdict above is the schema's and the planner may not change it + try: + from .. import get_device, get_device_type + + answer["plan"] = build_plan( + candidate, + request.arguments, + device=get_device_type(get_device()), + prompt_dir=workspace.prompts, + lookup_sizes=sizes, + ) + except Exception: + logger.exception("Plan could not be built") + answer["plan"] = None return answer # ------------------------------------------------------------ workspaces diff --git a/tests/test_server.py b/tests/test_server.py index 74604d72..24128287 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -3598,3 +3598,86 @@ def test_an_old_history_database_gains_the_column(tmp_path): history_path=str(db), ) assert manager.get("old1")["workflow_name"] is None + + +EMPTY_PLAN = { + "fingerprint": "sha256:0", + "steps": 0, + "list_entries": {}, + "cached_steps": None, + "downloads_required": [], + "estimate": None, +} + + +class TestValidatePlan: + """A valid pre-flight answers with the run's plan (#85): what these + arguments will execute, fingerprinted, priced and with the weights the + box lacks named. Best effort - the verdict is never the planner's.""" + + def test_a_valid_answer_carries_a_plan(self, server, monkeypatch): + import dw.plan + + monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + with server(success_script) as client: + result = client.post( + "/api/validate?sizes=false", + json={"workflow": video_workflow("planned", with_cost=True)}, + ).json() + assert result["valid"] is True + plan = result["plan"] + assert set(plan) == set(EMPTY_PLAN) + assert plan["steps"] == 1 + assert plan["estimate"]["basis"] in {"catalog", "other_device"} + assert plan["estimate"]["minutes"] == 2.0 + assert plan["downloads_required"] == [{"repo": "m", "gb": None}] + + def test_an_invalid_answer_carries_no_plan(self, server): + with server(success_script) as client: + result = client.post( + "/api/validate", json={"workflow": {"id": "broken", "steps": "no"}} + ).json() + assert result["valid"] is False + assert "plan" not in result + + def test_a_planner_failure_is_a_null_plan_not_a_verdict(self, server, monkeypatch): + import dw.server.app as app_module + + def boom(*a, **k): + raise RuntimeError("planner broke") + + monkeypatch.setattr(app_module, "build_plan", boom) + with server(success_script) as client: + result = client.post( + "/api/validate", json={"workflow": valid_workflow("v")} + ).json() + assert result["valid"] is True + assert result["plan"] is None + + def test_sizes_reaches_the_planner(self, server, monkeypatch): + import dw.server.app as app_module + + seen = [] + + def spy(candidate, arguments, **kwargs): + seen.append(kwargs["lookup_sizes"]) + return dict(EMPTY_PLAN) + + monkeypatch.setattr(app_module, "build_plan", spy) + with server(success_script) as client: + client.post("/api/validate", json={"workflow": valid_workflow("v")}) + client.post( + "/api/validate?sizes=false", json={"workflow": valid_workflow("v")} + ) + assert seen == [True, False] + + def test_the_plan_sees_the_callers_arguments(self, server, monkeypatch): + import dw.plan + + monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + with server(success_script) as client: + body = {"workflow": valid_workflow("v")} + one = client.post("/api/validate?sizes=false", json=body).json()["plan"] + body["arguments"] = {"prompt": "something else"} + two = client.post("/api/validate?sizes=false", json=body).json()["plan"] + assert one["fingerprint"] != two["fingerprint"] From 24b89818e675c079345afb9633f902db4ca83a1d Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:10:39 -0500 Subject: [PATCH 09/34] docs(mcp): #85 - the number to say out loud is the plan's, not the listing's Co-Authored-By: Claude Opus 5 (1M context) --- dw_mcp/diagnose.py | 8 +++++--- dw_mcp/server.py | 12 +++++++++++- tests/test_mcp_diagnose.py | 7 +++++++ tests/test_mcp_server.py | 13 +++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/dw_mcp/diagnose.py b/dw_mcp/diagnose.py index 2c4c7c48..11ce94c2 100644 --- a/dw_mcp/diagnose.py +++ b/dw_mcp/diagnose.py @@ -24,9 +24,11 @@ COST_REFUSAL = ( "Running a workflow occupies the GPU for minutes and the engine runs one " - "job at a time. Tell the user what is about to run, get their go-ahead, " - "then call again with acknowledged_cost=true. `validate_workflow` is free " - "and checks the definition first." + "job at a time. Call `validate_workflow` with the arguments you will run " + "with (free): its `plan` says what will execute - `estimate.minutes` with " + "its `basis`, and any weights in `downloads_required` this box has to " + "fetch first. Tell the user that number, get their go-ahead, then call " + "again with acknowledged_cost=true." ) diff --git a/dw_mcp/server.py b/dw_mcp/server.py index 2e0e1d82..ba4da663 100644 --- a/dw_mcp/server.py +++ b/dw_mcp/server.py @@ -678,7 +678,17 @@ def validate_workflow( workflow it names is validated in turn under that path, a composition cycle is refused, and an argument passed down that the composed workflow declares no variable for comes back as a - warning.""" + warning. + + A valid answer carries `plan`: what will execute for these + arguments. Quote `plan.estimate.minutes` with its `basis` - + `per_entry` or `catalog` is a measured figure re-priced for your + list, `other_device` a figure from another accelerator (say so), + `unknown` no figure at all - and name each `downloads_required` + entry as its own line item ("and 41 GB of weights this box does not + have"); `gb` is null when the hub could not be asked. `steps` and + `list_entries` say how many members the list actually produced. + `plan` is null when it could not be built; the verdict stands.""" return authoring.validate_workflow( client, workflow=workflow, diff --git a/tests/test_mcp_diagnose.py b/tests/test_mcp_diagnose.py index 230fad9b..b99a15a6 100644 --- a/tests/test_mcp_diagnose.py +++ b/tests/test_mcp_diagnose.py @@ -504,3 +504,10 @@ def test_run_sends_the_session_workspace_when_none_is_named(): diagnose.run_workflow(client, workflow_path="w", acknowledged_cost=True) assert seen[0]["params"]["workspace"] == "music-video" + + +def test_the_refusal_says_to_quote_the_plan(): + from dw_mcp.diagnose import COST_REFUSAL + + assert "plan" in COST_REFUSAL + assert "validate_workflow" in COST_REFUSAL diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 3abb72c3..3a27d500 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -925,3 +925,16 @@ async def test_the_instructions_name_the_vocabulary(): server = server_over(ok({})) for word in ("image-set", "sequence", "has-audio", "list_workflows(shape="): assert word in server.instructions + + +@pytest.mark.asyncio +async def test_validate_workflow_teaches_quoting_from_the_plan(): + """The number an agent says out loud is the plan's - priced for the + arguments it will run with, naming the weights this box lacks - not the + listing's defaults-only cost (#85).""" + tools = await tools_of(server_over(ok({}))) + doc = tools["validate_workflow"].description + assert "plan" in doc + assert "downloads_required" in doc + assert "estimate" in doc + assert "basis" in doc From 179f8fa97f17c656d76f30f21998acc8829e9412 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:11:28 -0500 Subject: [PATCH 10/34] docs: #85 - the plan on validate, and the skills quote from it rather than the listing Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 5 ++++- docs/MCP.md | 6 ++++-- docs/SERVER.md | 19 +++++++++++++++++++ docs/WORKFLOW_GUIDE.md | 14 ++++++++++---- docs/proposals/acknowledged-cost-binding.md | 10 ++++++---- plugins/dw/skills/ltx-2.5/SKILL.md | 6 +++--- plugins/dw/skills/minimax-h3/SKILL.md | 5 +++-- plugins/dw/skills/minimax-music3/SKILL.md | 4 ++-- 8 files changed, 51 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3a6e311c..5b07667a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -282,7 +282,10 @@ same reason - default setup cannot load a pack. the workspace) so the free pre-flight covers the part the caller wrote. A workflow that declares no variables takes no arguments at all - those were dropped in silence, since `Workflow.run` only substitutes when a `variables` - block exists + block exists. A valid `POST /api/validate` answer also carries `plan` + (`dw/plan.py`): the fingerprint of the work, step and list counts, + `downloads_required` and a cost `estimate` with its `basis` - the number an + agent quotes; `plan: null` when it could not be built, never a changed verdict - **A failed run still reports what it wrote** — the worker carries its partial manifest on the error and cancelled messages as well as on success, and the "Previous result not found" error names the steps that ran even after diff --git a/docs/MCP.md b/docs/MCP.md index a54b4c64..c9c249fc 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -249,7 +249,7 @@ The session starts in `default` and stays there unless it is told otherwise. | Tool | Arguments | Purpose | | --- | --- | --- | -| `validate_workflow(workflow=None, name=None, workspace=None, arguments=None)` | exactly one of `workflow` (inline definition) or `name` (a stored workflow, as `list_workflows` reports it), optional `workspace`, optional `arguments` | Check a workflow against the schema and against real pipeline signatures. Free and instant. Validating by name uses the workflow file's own directory as the base directory, so it sees what a run would. Returns every schema violation in `errors`, each with the JSON path it sits at, so a draft is fixed in one pass, and a `previous_result:` that names no earlier step is one of them. `warnings` covers what still runs but is probably wrong - a signature mismatch, and, for a list-driven variable, an entry key no step reads, at the entry's path. `workspace` names the workspace for this one call without switching the session to it - use it to pin a job whose `output:` or `asset:` references live in a workspace other than the session's. Pass the same `arguments` you will pass to `run_workflow` and they are checked too - an undeclared or renamed variable name, a value that will not coerce to the declared type, and an `asset:`, `prompt:` or `output:` reference that names nothing this workspace can reach, each reported at `arguments.`. `checked_arguments` lists what was covered, so a `valid: true` about the stored defaults cannot be mistaken for one about your values. `run_workflow` makes the same check and refuses a bad argument rather than queuing a job that fails on its first step | +| `validate_workflow(workflow=None, name=None, workspace=None, arguments=None)` | exactly one of `workflow` (inline definition) or `name` (a stored workflow, as `list_workflows` reports it), optional `workspace`, optional `arguments` | Check a workflow against the schema and against real pipeline signatures. Free and instant. Validating by name uses the workflow file's own directory as the base directory, so it sees what a run would. Returns every schema violation in `errors`, each with the JSON path it sits at, so a draft is fixed in one pass, and a `previous_result:` that names no earlier step is one of them. `warnings` covers what still runs but is probably wrong - a signature mismatch, and, for a list-driven variable, an entry key no step reads, at the entry's path. `workspace` names the workspace for this one call without switching the session to it - use it to pin a job whose `output:` or `asset:` references live in a workspace other than the session's. Pass the same `arguments` you will pass to `run_workflow` and they are checked too - an undeclared or renamed variable name, a value that will not coerce to the declared type, and an `asset:`, `prompt:` or `output:` reference that names nothing this workspace can reach, each reported at `arguments.`. `checked_arguments` lists what was covered, so a `valid: true` about the stored defaults cannot be mistaken for one about your values. `run_workflow` makes the same check and refuses a bad argument rather than queuing a job that fails on its first step. A valid answer carries `plan` - the fingerprint, step count, list lengths, `downloads_required` and `estimate` (with `basis`) for the arguments given; quote from it | | `list_workspaces()` | — | The server's workspaces and which one this session is using. Each has its own workflows, assets and outputs; the prompt library is shared by all of them | | `use_workspace(name)` | `name` | Work in that workspace for the rest of the session - every later call reads and writes there. This is how to keep your work out of another agent's namespace rather than sharing the default one. Checked against the server, so a typo fails here rather than scoping every later call to nothing | | `create_workspace(name, use=False)` | `name`, `use` | Create a workspace. Pass use=true to switch this session to it as well; otherwise the session stays where it was and the result says so | @@ -343,7 +343,9 @@ The intended loop: 1. `validate_workflow` — free, checks schema and pipeline signatures, no GPU time spent. Pass the `arguments` you intend to run with: without them the verdict covers the stored definition and its stock defaults, not the - values you wrote + values you wrote, and its `plan` is the number to say out loud: + `estimate.minutes` with its `basis`, plus each `downloads_required` + entry as a line item of its own 2. `run_workflow` with `acknowledged_cost=true` — pass a name straight from `list_workflows` as `workflow_path`; queues the job and returns immediately with a `job_id` diff --git a/docs/SERVER.md b/docs/SERVER.md index 80de09dc..ef81386a 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -268,6 +268,25 @@ The editor's forms come from these; they are just as usable from scripts: was covered, since without arguments the verdict is about the stored defaults only. + A valid answer also carries `plan`, what the run will execute for those + arguments: `fingerprint` (`sha256:…` over the realized, expanded + definition with the seed and the documentation keys removed and + `output:…/latest/…` left unpinned - the same work hashes the same, a + longer list or an edited stored prompt does not); `steps`, the expanded + member count; `list_entries`, `{variable: length}` for each `for_each` + over a list variable; `cached_steps`, reserved (`null`); + `downloads_required`, each `model_name` the hub cache does not hold as + `{repo, gb}` (`gb` from the hub, `null` when it could not be asked - + `?sizes=false` skips the hub) and each `from_single_file` URL as + `{repo: null, url, gb: null}`; and `estimate`, `{minutes, basis, + device, measured_on, partial}` from the workflow's own `cost` block - + `basis` is `catalog` (the stored total), `per_entry` (re-priced for the + list passed, when the entry carries `per_entry`), `other_device` (no + entry for the serving backend; the first entry's figure, which is a + warning rather than a quote) or `unknown`; a composed child's cost is + added and `partial` is true when a child has none. `plan` is `null` when + it could not be built; an invalid answer carries no `plan` key. + ## Files and models - `GET /api/workflows` — the stored workflow names, plus a `details` entry diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index 7a11624a..19e46212 100644 --- a/docs/WORKFLOW_GUIDE.md +++ b/docs/WORKFLOW_GUIDE.md @@ -438,9 +438,11 @@ same way it will run. `release_pipeline` on a `for_each` step releases after the *last* member. Each entry is a full generation, so quote the cost before running a list-driven workflow: the listing's `lists` block names the fields an entry takes and the steps over it, and its `cost` -carries `per_entry` once one entry has been measured — quote -`minutes - per_entry.minutes × per_entry.entries + per_entry.minutes × N` for -N entries, and without `per_entry` quote the total as the default list's. An +carries `per_entry` once one entry has been measured. `validate_workflow` +with your `arguments` answers with a `plan` whose `estimate` already does +that arithmetic (`basis: per_entry`), and without `per_entry` reports the +default list's total (`basis: catalog`) - quote the plan's figure and say +which basis it has. An entry key no step reads is a validation warning at the entry's path, so a misspelt field is caught before the run. Then `validate_workflow` with the @@ -479,7 +481,11 @@ the entry an item needs. 3. `save_workflow` — validates again on the way in and returns the catalog metadata the saved draft will carry. 4. `run_workflow` with `acknowledged_cost=true`, after telling the user what it - costs. Without the acknowledgement the call is refused. A workflow you wrote + costs. Without the acknowledgement the call is refused. The figure to tell + them is the `plan` on the validate answer - `estimate.minutes` with its + `basis`, and every `downloads_required` entry named as its own line item, + since weights not on this box are minutes and gigabytes the cost block + never counted. When `basis` is `unknown`: a workflow you wrote or copied carries no `cost` of its own, but the pipeline inside it usually does: `list_workflows(include_models=true)` finds the `models/` entry that loads the same checkpoint, and its per-image figure times the number of diff --git a/docs/proposals/acknowledged-cost-binding.md b/docs/proposals/acknowledged-cost-binding.md index ffd5a400..30f9e790 100644 --- a/docs/proposals/acknowledged-cost-binding.md +++ b/docs/proposals/acknowledged-cost-binding.md @@ -1,9 +1,11 @@ # Proposal: bind `acknowledged_cost` to an estimate, not just to a boolean -Status: **design only** - written in answer to issue #85 (forum feedback), -after reading the gate and every path by which a run's size is decided. -No code changes yet. Written by the implementer agent (model `opus`, -provider `anthropic`). +Status: **stage 1 implemented** (the plan on validate); stage 2 (binding, the +409) not started. Design: +docs/superpowers/specs/2026-09-12-acknowledged-cost-binding-design.md. +Written in answer to issue #85 (forum feedback), +after reading the gate and every path by which a run's size is decided, by +the implementer agent (model `opus`, provider `anthropic`). ## The question asked diff --git a/plugins/dw/skills/ltx-2.5/SKILL.md b/plugins/dw/skills/ltx-2.5/SKILL.md index 5c896642..4a4a4009 100644 --- a/plugins/dw/skills/ltx-2.5/SKILL.md +++ b/plugins/dw/skills/ltx-2.5/SKILL.md @@ -125,9 +125,9 @@ AESTHETIC QUALITY (in addition to the above, without breaking the objective capt 1. `validate_workflow` first - free, and it catches arguments the pipeline rejects. -2. Quote the listing's `cost` - the run's whole wall clock, model loading - included. Only `templates/ltx2/text-to-video` and `templates/ltx2/two-stage` - declare one; for the other six say so and give the shape of the spend +2. Quote `plan.estimate` from the validate answer (whole wall clock, loading + included) and name any `downloads_required`. Only `text-to-video` and + `two-stage` carry a `cost`; for the other six say so and give the shape instead - a 121-frame clip at 960x544 is under two minutes cold on a 24 GB card, of which a minute is loading, the two-stage flow about eight, and extend and chain multiply by their passes. Either way get the go-ahead diff --git a/plugins/dw/skills/minimax-h3/SKILL.md b/plugins/dw/skills/minimax-h3/SKILL.md index 1a7449b9..f07bd417 100644 --- a/plugins/dw/skills/minimax-h3/SKILL.md +++ b/plugins/dw/skills/minimax-h3/SKILL.md @@ -156,8 +156,9 @@ inherits the portrait's composition. 1. `validate_workflow` first - free, and it catches arguments the pipeline does not accept. -2. Quote the listing's `cost` (warm minutes on the card it was measured on; - a first load is longer). When it declares none, say so and give the shape +2. Quote `plan.estimate` from the validate answer (warm minutes; a first + load, and any `downloads_required`, is longer). When `basis` is + `unknown`, say so and give the shape instead: a 124-frame turbo clip is a few minutes on a 24 GB card, 345 frames three times that, an image reference twice a turbo clip, a video reference beside it 3.4x again, and a chain multiplies by its segments. diff --git a/plugins/dw/skills/minimax-music3/SKILL.md b/plugins/dw/skills/minimax-music3/SKILL.md index 76f5547f..0523c19e 100644 --- a/plugins/dw/skills/minimax-music3/SKILL.md +++ b/plugins/dw/skills/minimax-music3/SKILL.md @@ -128,8 +128,8 @@ Control" section. 1. `validate_workflow` first - free, and it catches arguments the pipeline does not accept. -2. Quote the listing's `cost` (warm minutes on the card it was measured on; - a first load is longer). When the listing declares none, say so and give +2. Quote `plan.estimate` from the validate answer (warm minutes on the card it + was measured on; a first load is longer). When `basis` is `unknown`, say so and give the shape of the spend: the autoregressive stage runs at 25 frames per second of audio and dominates, so time scales with the length the model actually sings, not the ceiling. Get the user's go-ahead before From 59b9133c0d0205485a1c37d4c003755babe381a0 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:39:56 -0500 Subject: [PATCH 11/34] docs(plan): #85 - stage 2 implementation plan, the bound acknowledgement Co-Authored-By: Claude Opus 5 (1M context) --- ...26-09-13-acknowledged-cost-stage-2-plan.md | 1550 +++++++++++++++++ 1 file changed, 1550 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-2-plan.md diff --git a/docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-2-plan.md b/docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-2-plan.md new file mode 100644 index 00000000..f5bebfac --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-acknowledged-cost-stage-2-plan.md @@ -0,0 +1,1550 @@ +# Acknowledged-cost binding, stage 2: the bound acknowledgement, the 409 and the cache probe + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A caller may bind its `acknowledged_cost` to the plan it validated (`{fingerprint, minutes, downloads}`); `POST /api/jobs` and `/rerun` refuse with 409 when the run's shape no longer matches; every job records which form of acknowledgement it got; and the plan's `cached_steps` is answered exactly by the worker's step cache. + +**Architecture:** The step-cache probe is a refactor of `Workflow.run`: the substitute-expand-seed preparation and the per-step "is this a hit" lookup are lifted into methods both the run loop and a new `Workflow.cache_hits(arguments)` call, so the probe cannot drift from the run. The worker answers a `probe_cache` command with that list; `JobManager.probe_cache` asks it the way `memory_status` does (never blocking behind a running job); `build_plan` takes the answer through `cache_probe`. The route classifies the acknowledgement once (`none | boolean | bound`), re-plans for a bound one and raises 409 with the current plan in the body, records the form on the job and in `jobs.sqlite`. MCP widens the argument, forwards the object, and renders the 409 with the new estimate. + +**Tech Stack:** Python 3.12, FastAPI, pydantic, sqlite3, `multiprocessing.Queue` worker protocol, pytest. + +**Spec:** [docs/superpowers/specs/2026-09-12-acknowledged-cost-binding-design.md](../specs/2026-09-12-acknowledged-cost-binding-design.md) sections 6-11. Stage 1 landed at b37f033 ([stage 1 plan](2026-09-13-acknowledged-cost-stage-1-plan.md)). + +## Global Constraints + +- `dw/plan.py` still imports nothing from `dw.server` or `dw.worker`; it reaches the worker only through the `cache_probe` callable it is handed. +- The bare-boolean and absent acknowledgement paths are byte-for-byte unchanged in behaviour: no new refusal, no new round trip to the worker on `POST /api/jobs`. +- `minutes` is recorded, never compared (spec non-goal). +- The 409 is pre-flight only; nothing meters or aborts a running job. +- No model name or minute figure in engine code or in a test fixture. +- Skills stay under `12 * 1024` bytes each (`minimax-h3` is at 12266; `ltx-2.5` at 12180). +- Worktree `.claude/worktrees/cost-binding`, branch `cost-binding` off `develop` b37f033. Tests with `python -m pytest`. +- Commits: `feat(engine|server|mcp): #85 - ...` / `docs: #85 - ...`, ending `Co-Authored-By: Claude Opus 5 (1M context) `. + +### Deviations from the spec, decided here + +- **Probe command shape.** Spec §10 sends the worker a realized, expanded definition plus a seed. Instead the command carries the same fields an `execute` command does (`workflow_path` | `workflow`+`base_dir`, `workflow_dir`, `arguments`, `output_dir`, `asset_dir`), and the worker loads the workflow with its existing `_load_workflow` and calls `Workflow.cache_hits(arguments)`. Preparation then runs the run's own code path (`realize_args` on the steps included - the cache key holds realized values), which is the whole point of the refactor. +- **No worker → `[]`, not `None`.** The step cache lives in the worker process; a worker that is not running holds none, so zero cached steps is the true answer. Busy worker or a timeout → `None` (unknown), as the spec says. +- **`rerun` does not inherit a stored bound acknowledgement.** The request's own `acknowledged_cost` decides the form and whether a check runs; the original's object is kept in the spec (so history says what was consented to) and `rerun` copies it forward for the record only. An inherited check would fire on a caller that sent `true`, which is exactly the path that must not change. +- **The 409 body's plan carries `cached_steps: null`.** The check does not probe the worker - it costs a round trip and the fingerprint does not depend on it. + +## File structure + +| File | Responsibility | +|---|---| +| `dw/workflow.py` (modify) | `_prepare_definition()` and `_cache_lookup()` lifted out of `run()`; new `cache_hits(arguments)` | +| `dw/worker.py` (modify) | `probe_cache` command → `_handle_probe_cache` | +| `dw/server/jobs.py` (modify) | `JobManager.probe_cache()`, `rerun_spec()`, `submit(acknowledged=, acknowledged_cost=)`, `Job.acknowledged`, sqlite column, `RERUN_SPEC_KEYS` | +| `dw/plan.py` (modify) | `cache_probe` honoured; `cached_steps` | +| `dw/server/app.py` (modify) | `AcknowledgedCost`, the field on both requests, `_acknowledgement_form`, `_check_bound_acknowledgement`, the probe on validate | +| `dw_mcp/diagnose.py`, `dw_mcp/server.py`, `dw_mcp/client.py` (modify) | `bool \| dict`, forwarding, 409 rendering, `COST_REFUSAL` | +| docs + skills (modify) | the bound form | +| tests: `test_workflow_step_cache.py`, `test_worker_execute.py`, `test_server.py`, `test_plan.py`, `test_mcp_diagnose.py`, `test_mcp_client.py`, `test_plugin_skills.py` | coverage | + +--- + +### Task 1: `Workflow.cache_hits()` by refactoring `run()` + +**Files:** +- Modify: `dw/workflow.py` (`run()` lines ~700-760 preparation, ~880-950 cache lookup) +- Test: `tests/test_workflow_step_cache.py` + +**Interfaces:** +- Produces: `Workflow._prepare_definition(workflow_def, arguments, base_dir) -> (workflow_def, default_seed)` - variables realized/folded/resolved/substituted, `for_each` expanded, the seed read and coerced to `int` (or `None` when the workflow names none). Raises exactly what the run raised at that point. +- Produces: `Workflow._cache_lookup(workflow_id, steps, index, step_data, step_seed, hits_this_run, cache_enabled) -> (cached_result | None, step_data_snapshot | None)` - the whole `is_cacheable` / snapshot / `step_cache.get` block. `parent_saves_this` computed inside from `self._final_save_owned_by_parent`. +- Produces: `Workflow.cache_hits(arguments) -> list[str]` - the step names the step cache would serve for this workflow, seed and arguments, in step order; `[]` for an unseeded workflow. Executes nothing, emits nothing, opens no run directory. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_workflow_step_cache.py` (the module has `step_cache`, `build_test_workflow_and_call_count_spy()`, whose workflow has one step `generate`, seed 42, variable `prompt`): + +```python +class TestCacheHits: + def test_a_cold_cache_reports_no_hits(self): + step_cache.clear() + workflow, _ = build_test_workflow_and_call_count_spy() + try: + assert workflow.cache_hits({}) == [] + finally: + for p in workflow._test_patcher: + p.stop() + + def test_after_a_run_the_probe_names_what_the_next_run_reuses(self): + step_cache.clear() + workflow, call_count = build_test_workflow_and_call_count_spy() + try: + workflow.run({}) + probe = workflow.cache_hits({}) + workflow.run({}) + reused = [entry["step"] for entry in workflow.manifest if entry.get("reused")] + assert probe == ["generate"] + assert probe == reused + assert call_count() == 1, "the probe executed nothing" + finally: + for p in workflow._test_patcher: + p.stop() + + def test_a_changed_argument_is_a_miss(self): + step_cache.clear() + workflow, _ = build_test_workflow_and_call_count_spy() + try: + workflow.run({"prompt": "a cat"}) + assert workflow.cache_hits({"prompt": "a dog"}) == [] + finally: + for p in workflow._test_patcher: + p.stop() + + def test_an_unseeded_workflow_has_no_hits(self): + step_cache.clear() + workflow, _ = build_test_workflow_and_call_count_spy() + del workflow.workflow_definition["seed"] + try: + workflow.run({}) + assert workflow.cache_hits({}) == [] + finally: + for p in workflow._test_patcher: + p.stop() + + def test_the_probe_writes_nothing(self, tmp_path): + step_cache.clear() + workflow, _ = build_test_workflow_and_call_count_spy() + workflow.output_dir = str(tmp_path) + try: + workflow.cache_hits({}) + assert list(tmp_path.iterdir()) == [] + finally: + for p in workflow._test_patcher: + p.stop() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python -m pytest tests/test_workflow_step_cache.py -k CacheHits -v` +Expected: FAIL - `AttributeError: 'Workflow' object has no attribute 'cache_hits'`. + +- [ ] **Step 3: Refactor `run()` and add `cache_hits()`** + +In `dw/workflow.py`, inside `class Workflow`, add two methods (place them just above `run`): + +```python + def _prepare_definition(self, workflow_def, arguments, base_dir): + """The definition as a run works from it: constants realized, + arguments folded into the variables, list entries' own references + resolved, variable values realized (assets loaded), every + 'variable:' substituted, every for_each expanded, and the seed read + and coerced. Returns (workflow_def, default_seed) - the seed is + None when the workflow names none, and the caller decides what + that means (run() draws one; cache_hits() reports no hits). + + Shared by run() and cache_hits() so the probe prepares exactly what + the run prepares - the step cache keys on the realized step, and a + probe that prepared it differently would answer for a run that + never happens. + """ + workflow_id = workflow_def["id"] + variables = workflow_def.get("variables", None) + if variables is not None: + logger.debug(f"Setting variables for workflow: {workflow_id}") + # a constant is the value a variable declares, so it resolves before + # anything is converted to the type of that declaration + realize_constants(variables) + # first set variable values base don the arguments passed to the workflow + # these may come form the command line or form a parent workflow + set_variables(arguments, variables) + # an entry of a list-valued variable may name another + # variable; resolve those before anything inside it is + # realized, so a reference type in an entry is a type name + variables = resolve_variable_values(variables) + # realize the variables, initialiting downloads of images etc + realize_args(variables, base_dir) + ## then replace any variable references in the workflow definition with the actual values + # replace_variables returns a new structure rather than mutating in + # place, so the result must be captured here + workflow_def = replace_variables(workflow_def, variables) + + # One ordinary step per entry of every for_each list, before the + # seed, the run id and the realized workflow are computed, so + # each covers what actually runs. A ForEachError here fails the + # run before anything loads + workflow_def = expand_for_each(workflow_def) + + # Set up random seed for reproducibility. Resolved lazily - as a + # dict.get default, torch.seed() would run on every call and reseed + # the global RNG even when the workflow names an explicit seed + default_seed = workflow_def.get("seed") + # The schema lets 'seed' be a string so it can hold a 'variable:' + # reference, which the substitution above has already resolved - + # but a variable overridden from the command line arrives as a + # string whenever the workflow declared no integer default to + # coerce against, and manual_seed would fail deep inside the run + if isinstance(default_seed, str): + try: + default_seed = int(default_seed) + except ValueError: + raise ValueError( + f"Workflow {workflow_id} seed must be an integer, " + f"got {default_seed!r}" + ) + workflow_def["seed"] = default_seed + return workflow_def, default_seed + + def _cache_lookup( + self, workflow_id, steps, index, step_data, step_seed, hits_this_run, cache_enabled + ): + """Whether the step cache serves step `index`, as + (cached_result or None, the step_data snapshot the entry is keyed + on or None). Shared by run() and cache_hits() - see + _prepare_definition for why. + """ + # What later steps still read, which decides both whether this + # step's result has to be kept alive after the step and whether a + # cached entry that kept none can serve this run + remaining_refs = referenced_result_names(steps[index + 1 :]) + result_needed = index == len(steps) - 1 or any( + reference_resolves_to(ref, step_data["name"]) for ref in remaining_refs + ) + # create_step_action (and the pipeline load it triggers) mutates + # step_data in place - injecting a "generator" key - so the cache + # must key off a snapshot taken before that happens, and that same + # snapshot must be reused for the put() later. A sub-workflow step + # is never cacheable: its files roll up from the child's own + # manifest, which a hit does not rebuild. + is_cacheable = "workflow" not in step_data and cache_enabled + # The last step of a composed child whose parent does the saving + # (#92) - its files are the parent step's, written once, under the + # parent's name and subfolder + parent_saves_this = self._final_save_owned_by_parent and index == len(steps) - 1 + step_data_snapshot = None + if is_cacheable: + try: + step_data_snapshot = copy.deepcopy(step_data) + if parent_saves_this: + # Keyed apart from the same step run standalone: this + # entry's result was never saved here, so a standalone + # hit on it would report no files + step_data_snapshot["__saved_by_parent__"] = True + except Exception as ex: + # A realized argument that cannot be deep-copied (an open + # handle, a live model object) just means this step is not + # cacheable - never a failed run + logger.debug( + f"Step '{step_data['name']}' arguments are not copyable " + f"({ex}) - skipping the step cache for it" + ) + is_cacheable = False + if not is_cacheable: + return None, None + cached_result = step_cache.get( + workflow_id, + step_data_snapshot, + step_seed, + hits_this_run, + # The root, not this run's directory: a hit reports the earlier + # run's files and writes nothing new, so keying on a directory + # that is new every run would mean the cache could never hit + # again. What the root still guards is a run redirected + # somewhere else, where the earlier files are not what the + # caller asked for + self.output_dir, + needs_result=result_needed, + ) + return cached_result, step_data_snapshot + + def cache_hits(self, arguments): + """The steps the step cache would serve for a run with `arguments`, + in step order - what the plan reports as cached_steps (#85). + + Prepares the definition exactly as run() does and asks the cache the + question run() asks, step by step with the hits so far, and executes + nothing: no run directory, no events, no pipeline. An unseeded + workflow has no cache, so it answers [] without asking. + """ + output_root_token = activate_output_root(self.output_dir) + try: + workflow_def = copy.deepcopy(self.workflow_definition) + workflow_id = workflow_def["id"] + base_dir = ( + os.path.dirname(os.path.abspath(self.file_spec)) + if self.file_spec + else None + ) + workflow_def, default_seed = self._prepare_definition( + workflow_def, arguments or {}, base_dir + ) + if default_seed is None or not self._cache_enabled_by_parent: + return [] + steps = workflow_def.get("steps", []) + realize_args(steps, base_dir) + hits_this_run = set() + hits = [] + for index, step_data in enumerate(steps): + step_seed = step_data.get("seed", default_seed) + cached_result, _ = self._cache_lookup( + workflow_id, steps, index, step_data, step_seed, hits_this_run, True + ) + if cached_result is not None: + hits_this_run.add(step_data["name"]) + hits.append(step_data["name"]) + return hits + finally: + deactivate_output_root(output_root_token) +``` + +Then in `run()`: + +- Replace the block from `# Handle variable substitution if variables are defined` through the `workflow_def["seed"] = default_seed` line that follows the `int(default_seed)` coercion (lines ~710-752, ending just before `# A workflow that names no seed gets a fresh one every run`) with: + +```python + workflow_def, default_seed = self._prepare_definition( + workflow_def, arguments, base_dir + ) +``` + + Keep everything from `cache_enabled_this_run = (...)` onward unchanged (the random draw, `workflow_def["seed"] = default_seed`, `resolved_seed`). + +- In the step loop, replace the block from `# What later steps still read, ...` (`remaining_refs = ...`) through the `cached_result = (step_cache.get(...) if is_cacheable else None)` expression with: + +```python + cached_result, step_data_snapshot = self._cache_lookup( + workflow_id, + steps, + i, + step_data, + step_seed, + hits_this_run, + cache_enabled_this_run, + ) + is_cacheable = step_data_snapshot is not None +``` + + `is_cacheable` and `step_data_snapshot` are read further down by the `step_cache.put` block - confirm with `grep -n "is_cacheable\|step_data_snapshot" dw/workflow.py` that every remaining use is after this point and still bound. `result_needed` is also used later (for `release_unreferenced_results` / retaining); if `grep -n result_needed dw/workflow.py` shows a use after the lookup, recompute it in the loop right after the lookup with the same two lines used inside `_cache_lookup` rather than returning it (keep the method's return a pair). + +- [ ] **Step 4: Run the step-cache suites** + +Run: `python -m pytest tests/test_workflow_step_cache.py tests/test_step_cache.py tests/test_jobs_reused.py tests/test_worker_execute.py -v` +Expected: all PASS, the five new ones included. + +- [ ] **Step 5: Commit** + +```bash +git add dw/workflow.py tests/test_workflow_step_cache.py +git commit -m "feat(engine): #85 - a workflow can say which steps the cache would serve, by the run's own preparation + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 2: the worker's `probe_cache` command and `JobManager.probe_cache` + +**Files:** +- Modify: `dw/worker.py` (dispatch at ~127-150; handlers at ~368-390), `dw/server/jobs.py` (`memory_status` at ~1154 is the model) +- Test: `tests/test_worker_execute.py`, `tests/test_server.py` + +**Interfaces:** +- Consumes: `Workflow.cache_hits(arguments)` from Task 1; the worker's `_load_workflow(command, output_dir)`. +- Produces: worker command `{"type": "probe_cache", "arguments", "output_dir", "workflow_path" | "workflow" + "base_dir", "workflow_dir", "asset_dir"?}` answered by `{"type": "probe_cache", "cached": [names]}` or `{"type": "probe_cache", "cached": None, "error": str}`. +- Produces: `JobManager.probe_cache(command, timeout=5) -> list[str] | None` where `command` is that dict minus `type`. `None` when a job is running, the worker lock is busy, the worker did not answer, or answered with an error; `[]` when no worker is running. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_worker_execute.py`: + +```python +class ProbableWorkflow(StubWorkflow): + def __init__(self, hits): + super().__init__() + self.hits = hits + self.probed_with = None + + def cache_hits(self, arguments): + self.probed_with = arguments + return list(self.hits) + + +def test_probe_cache_answers_with_the_workflows_hits(): + worker = _make_worker() + workflow = ProbableWorkflow(["gen"]) + command = { + "type": "probe_cache", + "workflow_path": "x.json", + "arguments": {"prompt": "p"}, + "output_dir": "/tmp", + } + with patch("dw.worker.workflow_from_file", return_value=workflow): + worker._handle_probe_cache(command) + assert _drain(worker.result_queue) == [{"type": "probe_cache", "cached": ["gen"]}] + assert workflow.probed_with == {"prompt": "p"} + + +def test_probe_cache_reports_a_failure_as_unknown_not_as_a_crash(): + worker = _make_worker() + with patch("dw.worker.workflow_from_file", side_effect=ValueError("bad file")): + worker._handle_probe_cache( + {"type": "probe_cache", "workflow_path": "x.json", "arguments": {}, "output_dir": "/tmp"} + ) + [answer] = _drain(worker.result_queue) + assert answer["type"] == "probe_cache" + assert answer["cached"] is None + assert "bad file" in answer["error"] + + +def test_probe_cache_activates_the_jobs_asset_dir(tmp_path): + worker = _make_worker() + seen = {} + + class AssetAwareWorkflow(ProbableWorkflow): + def cache_hits(self, arguments): + from dw.assets import current_asset_dir + + seen["asset_dir"] = current_asset_dir() + return [] + + with patch("dw.worker.workflow_from_file", return_value=AssetAwareWorkflow([])): + worker._handle_probe_cache( + { + "type": "probe_cache", + "workflow_path": "x.json", + "arguments": {}, + "output_dir": "/tmp", + "asset_dir": str(tmp_path), + } + ) + assert seen["asset_dir"] == str(tmp_path) +``` + +(Check `dw/assets.py` for the accessor name that returns the active asset root - `grep -n "^def " dw/assets.py`; if it is not `current_asset_dir`, use the one that is.) + +In `tests/test_server.py`, extend `ScriptedWorkerManager.send_command` with a `probe_cache` branch, and add a knob: + +```python + elif command["type"] == "probe_cache": + self._results.put( + {"type": "probe_cache", "cached": list(self.cached_steps)} + ) +``` + +and in `__init__`: `self.cached_steps = []`. Then append tests: + +```python +class TestProbeCache: + def test_asks_the_worker_and_returns_its_answer(self, server): + with server(success_script) as client: + manager = client.app.state.job_manager + manager.worker_manager.ensure_worker() + manager.worker_manager.cached_steps = ["gen"] + assert manager.probe_cache( + {"workflow_path": "x.json", "arguments": {}, "output_dir": "/tmp"} + ) == ["gen"] + sent = manager.worker_manager.commands[-1] + assert sent["type"] == "probe_cache" + assert sent["workflow_path"] == "x.json" + + def test_no_worker_means_an_empty_cache(self, server): + with server(success_script) as client: + manager = client.app.state.job_manager + assert manager.worker_manager.worker_active is False + assert manager.probe_cache({"workflow_path": "x.json", "arguments": {}, "output_dir": "/tmp"}) == [] + assert manager.worker_manager.commands == [] + + def test_a_running_job_means_unknown(self, server): + with server(hanging_script) as client: + response = client.post("/api/jobs", json={"workflow": valid_workflow()}) + job_id = response.json()["id"] + wait_for_status(client, job_id, ("running",)) + manager = client.app.state.job_manager + assert manager.probe_cache({"workflow_path": "x.json", "arguments": {}, "output_dir": "/tmp"}) is None + client.post(f"/api/jobs/{job_id}/cancel") + + def test_an_unanswered_probe_is_unknown(self, server): + with server(success_script) as client: + manager = client.app.state.job_manager + manager.worker_manager.ensure_worker() + manager.worker_manager.send_command = lambda command: None # swallow it + assert manager.probe_cache( + {"workflow_path": "x.json", "arguments": {}, "output_dir": "/tmp"}, timeout=0.05 + ) is None +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python -m pytest tests/test_worker_execute.py -k probe -v; python -m pytest tests/test_server.py -k ProbeCache -v` +Expected: FAIL - no `_handle_probe_cache`, no `probe_cache`. + +- [ ] **Step 3: Implement** + +`dw/worker.py`, in the main dispatch after the `memory_status` branch: + +```python + elif command_type == "probe_cache": + self._handle_probe_cache(command) +``` + +and beside `_handle_memory_status`: + +```python + def _handle_probe_cache(self, command: Dict[str, Any]): + """Which steps the step cache would serve for a run of this command + - the plan's cached_steps (#85). Same fields as an execute command; + loads the workflow, executes nothing. A failure answers + cached: null with the reason rather than an error message, since + an unknown answer is a valid plan and a crashed probe is not. + """ + try: + workflow, _ = self._load_workflow(command, command["output_dir"]) + asset_token = ( + activate_asset_dir(command["asset_dir"]) + if command.get("asset_dir") + else None + ) + try: + cached = workflow.cache_hits(command.get("arguments") or {}) + finally: + if asset_token is not None: + deactivate_asset_dir(asset_token) + self.result_queue.put({"type": "probe_cache", "cached": cached}) + except Exception as e: + logger.debug(f"Cache probe failed: {e}") + self.result_queue.put( + {"type": "probe_cache", "cached": None, "error": str(e)} + ) +``` + +`dw/server/jobs.py`, beside `memory_status`: + +```python + def probe_cache(self, command, timeout=5): + """Which steps the worker's step cache would serve for `command` (the + fields an execute command carries, minus its type), or None when the + answer cannot be had right now - a job is running, the worker is + busy, or it did not answer in time. Never blocks a request behind a + running job, for the same reason memory_status does not. + + No worker running is a definite answer, not an unknown one: the + cache lives in the worker process, so a worker that is not running + holds nothing. + """ + if self._current_job_id is not None: + return None + if not self.worker_manager.worker_active: + return [] + if not self._worker_lock.acquire(timeout=2): + return None + try: + self.worker_manager.send_command({"type": "probe_cache", **command}) + result = self.worker_manager.get_result(timeout=timeout) + except (RuntimeError, queue.Empty) as e: + logger.debug(f"Worker did not answer the cache probe: {e}") + return None + finally: + self._worker_lock.release() + if result.get("type") != "probe_cache": + return None + cached = result.get("cached") + return list(cached) if isinstance(cached, list) else None +``` + +- [ ] **Step 4: Run the tests** + +Run: `python -m pytest tests/test_worker_execute.py tests/test_server.py -k "probe or Probe" -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add dw/worker.py dw/server/jobs.py tests/test_worker_execute.py tests/test_server.py +git commit -m "feat(server): #85 - the worker answers a cache probe, and the manager asks without blocking behind a job + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 3: `cached_steps` in the plan and on validate + +**Files:** +- Modify: `dw/plan.py` (`build_plan`), `dw/server/app.py` (validate route) +- Test: `tests/test_plan.py`, `tests/test_server.py` + +**Interfaces:** +- Consumes: `JobManager.probe_cache(command)` from Task 2. +- Produces: `build_plan(..., cache_probe=None)` where `cache_probe` is `Callable[[dict], list[str] | None]` taking the run's arguments. `cached_steps` is `len(answer)` on a list, `None` on `None` or with no probe, and `0` with no probe made when the workflow is unseeded. +- Produces: `_probe_command_for(candidate, request, workspace)` in the route: the execute-shaped dict for this validate request. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_plan.py`: + +```python +class TestCachedSteps: + def test_no_probe_is_unknown(self, plan): + assert plan()["cached_steps"] is None + + def test_the_probe_is_asked_with_the_arguments_and_counted(self, plan): + seen = [] + + def probe(arguments): + seen.append(arguments) + return ["still", "shot@a"] + + answer = plan(arguments={"frames": 9}, cache_probe=probe) + assert answer["cached_steps"] == 2 + assert seen == [{"frames": 9}] + + def test_a_probe_that_cannot_answer_is_unknown(self, plan): + assert plan(cache_probe=lambda arguments: None)["cached_steps"] is None + + def test_an_unseeded_workflow_is_zero_without_asking(self, plan): + def probe(arguments): + raise AssertionError("must not be asked") + + spec = definition() + del spec["seed"] + del spec["variables"]["seed"] + assert plan(spec, cache_probe=probe)["cached_steps"] == 0 + + def test_a_seed_variable_left_null_is_unseeded(self, plan): + def probe(arguments): + raise AssertionError("must not be asked") + + spec = definition() + spec["variables"]["seed"] = None + assert plan(spec, cache_probe=probe)["cached_steps"] == 0 +``` + +Append to `tests/test_server.py` inside `TestValidatePlan`: + +```python + def test_cached_steps_comes_from_the_worker(self, server, monkeypatch): + import dw.plan + + monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + with server(success_script) as client: + manager = client.app.state.job_manager + manager.worker_manager.ensure_worker() + manager.worker_manager.cached_steps = ["gen"] + seeded = valid_workflow("seeded") + seeded["seed"] = 7 + result = client.post( + "/api/validate?sizes=false", json={"workflow": seeded, "arguments": {"prompt": "x"}} + ).json() + assert result["plan"]["cached_steps"] == 1 + probe = [c for c in manager.worker_manager.commands if c["type"] == "probe_cache"] + assert len(probe) == 1 + assert probe[0]["arguments"] == {"prompt": "x"} + assert probe[0]["workflow"] == seeded + assert probe[0]["output_dir"] == manager.output_dir + + def test_an_unseeded_workflow_does_not_probe(self, server, monkeypatch): + import dw.plan + + monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + with server(success_script) as client: + manager = client.app.state.job_manager + manager.worker_manager.ensure_worker() + result = client.post( + "/api/validate?sizes=false", json={"workflow": valid_workflow("v")} + ).json() + assert result["plan"]["cached_steps"] == 0 + assert all(c["type"] != "probe_cache" for c in manager.worker_manager.commands) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python -m pytest tests/test_plan.py -k CachedSteps -v; python -m pytest tests/test_server.py -k "cached_steps or unseeded" -v` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +`dw/plan.py`: in `build_plan`, replace `"cached_steps": None,` with `"cached_steps": cached_steps(definition, realized, arguments, cache_probe),` and add: + +```python +def cached_steps(definition, realized, arguments, cache_probe): + """How many steps the step cache would answer for this run: 0 without + asking when the workflow is unseeded (the cache is off then), None + when there is no probe or the probe cannot answer, else the count.""" + if not _is_seeded(definition, realized): + return 0 + if cache_probe is None: + return None + answer = cache_probe(arguments or {}) + return len(answer) if isinstance(answer, list) else None + + +def _is_seeded(definition, realized): + """Whether a run of this workflow has a seed before it draws one - read + from the definition as written, since realization pins a seed of its + own into the copy.""" + seed = definition.get("seed") + if isinstance(seed, str) and seed.startswith(VARIABLE_PREFIX): + name = seed.removeprefix(VARIABLE_PREFIX) + return (realized.get("variables") or {}).get(name) is not None + return seed is not None +``` + +Update the `cache_probe` line of `build_plan`'s docstring to: `cache_probe: A callable taking the run's arguments and answering the step names the worker's cache would serve, or None when it cannot say.` + +`dw/server/app.py`: in the validate route, before the `build_plan` call, build the probe: + +```python + command = _probe_command_for(candidate, request, workspace, source_root) + answer["plan"] = build_plan( + candidate, + request.arguments, + device=get_device_type(get_device()), + prompt_dir=workspace.prompts, + lookup_sizes=sizes, + cache_probe=lambda arguments: manager.probe_cache( + {**command, "arguments": arguments} + ), + ) +``` + +`source_root` is the confinement the candidate was built with - set `source_root = source.root if source else workspace.workflows` in the file branch and `source_root = workspace.workflows` in the inline branch, right where each `candidate` is constructed. Add the helper beside `_argument_reference_errors`: + +```python + def _probe_command_for(candidate, request, workspace, workflow_dir): + """The execute-shaped command a cache probe of this validate request + needs - the same fields _run_job sends, so the worker loads the + workflow exactly as a job would.""" + command = { + "arguments": request.arguments, + "output_dir": workspace.outputs, + "workflow_dir": workflow_dir, + } + if workspace.assets: + command["asset_dir"] = workspace.assets + if request.workflow_path is not None: + command["workflow_path"] = candidate.file_spec + else: + command["workflow"] = request.workflow + command["base_dir"] = os.path.dirname(candidate.file_spec) + return command +``` + +- [ ] **Step 4: Run the tests** + +Run: `python -m pytest tests/test_plan.py tests/test_server.py -k "CachedSteps or ValidatePlan" -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add dw/plan.py dw/server/app.py tests/test_plan.py tests/test_server.py +git commit -m "feat(server): #85 - the plan says how many steps the worker's cache would serve + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 4: the acknowledgement on the job and in history + +**Files:** +- Modify: `dw/server/jobs.py` (`RERUN_SPEC_KEYS` ~46, `_ensure_schema` ~85-126, `record` ~130, `recent_summaries` ~165, `get`/`_to_detail` ~205/303, `Job.__init__`/`summary`/`detail` ~332-520, `submit` ~558, `rerun` ~761) +- Test: `tests/test_server.py` + +**Interfaces:** +- Produces: `ACK_NONE = "none"`, `ACK_BOOLEAN = "boolean"`, `ACK_BOUND = "bound"` in `dw/server/jobs.py`. +- Produces: `JobManager.submit(..., acknowledged=ACK_NONE, acknowledged_cost=None)`; `JobManager.rerun(job_id, new_seed=False, acknowledged=ACK_NONE, acknowledged_cost=None)`; `JobManager.rerun_spec(job_id) -> (spec, arguments) | None` (the first half of `rerun`, so a route can plan before it queues). +- Produces: `Job.acknowledged: str`; `summary()` and `detail()` carry `acknowledged`, `detail()` carries `acknowledged_cost` (the object or `None`); history rows the same; `jobs.sqlite` column `acknowledged TEXT` (rows before it read as `none`), the object inside the stored `spec`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_server.py`: + +```python +class TestAcknowledgementRecord: + def test_a_submit_records_none_by_default(self, server): + with server(success_script) as client: + manager = client.app.state.job_manager + job = manager.submit(workflow=valid_workflow(), arguments={}) + assert job.acknowledged == "none" + assert manager.describe(job)["acknowledged"] == "none" + assert manager.describe(job)["acknowledged_cost"] is None + + def test_a_bound_submit_records_the_object(self, server): + with server(success_script) as client: + manager = client.app.state.job_manager + bound = {"fingerprint": "sha256:abc", "minutes": 3.0, "downloads": []} + job = manager.submit( + workflow=valid_workflow(), arguments={}, acknowledged="bound", acknowledged_cost=bound + ) + detail = manager.describe(job) + assert detail["acknowledged"] == "bound" + assert detail["acknowledged_cost"] == bound + assert job.summary()["acknowledged"] == "bound" + + def test_history_keeps_the_form_and_the_object(self, server, tmp_path): + with server(success_script) as client: + manager = client.app.state.job_manager + bound = {"fingerprint": "sha256:abc", "minutes": 3.0, "downloads": ["org/x"]} + job = manager.submit( + workflow=valid_workflow(), arguments={}, acknowledged="bound", acknowledged_cost=bound + ) + wait_for_status(client, job.id, TERMINAL_STATES) + row = manager.history.get(job.id) + assert row["acknowledged"] == "bound" + assert row["spec"]["acknowledged_cost"] == bound + listed = [s for s in manager.history.recent_summaries() if s["id"] == job.id] + assert listed[0]["acknowledged"] == "bound" + + def test_a_database_without_the_column_is_migrated(self, tmp_path): + import sqlite3 + + from dw.server.jobs import JobHistory + + path = tmp_path / "old.sqlite" + with sqlite3.connect(path) as connection: + connection.execute( + "CREATE TABLE jobs (id TEXT PRIMARY KEY, workflow TEXT, status TEXT," + " created_at REAL, started_at REAL, finished_at REAL, arguments TEXT," + " spec TEXT, manifest TEXT, warnings TEXT, error TEXT)" + ) + connection.execute( + "INSERT INTO jobs (id, workflow, status, created_at, spec) VALUES" + " ('old1', 'w', 'succeeded', 1.0, '{}')" + ) + history = JobHistory(str(path)) + assert history.get("old1")["acknowledged"] == "none" + + def test_a_rerun_carries_the_original_object_for_the_record(self, server): + with server(success_script) as client: + manager = client.app.state.job_manager + bound = {"fingerprint": "sha256:abc", "minutes": 3.0, "downloads": []} + job = manager.submit( + workflow=valid_workflow(), arguments={}, acknowledged="bound", acknowledged_cost=bound + ) + wait_for_status(client, job.id, TERMINAL_STATES) + rerun = manager.rerun(job.id) + assert rerun.acknowledged == "none" + assert rerun.spec["acknowledged_cost"] == bound +``` + +(Check the history class name with `grep -n "^class" dw/server/jobs.py` - use whatever the sqlite class is called if not `JobHistory`.) + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python -m pytest tests/test_server.py -k AcknowledgementRecord -v` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +`dw/server/jobs.py`: + +Constants near `TERMINAL_STATES`: + +```python +# Which form of cost acknowledgement a job was queued with (#85): none (the +# web UI and every HTTP caller that sends nothing), a bare boolean, or one +# bound to the plan that was validated +ACK_NONE = "none" +ACK_BOOLEAN = "boolean" +ACK_BOUND = "bound" +``` + +`RERUN_SPEC_KEYS`: append `"acknowledged_cost",` with the comment `# what the original run was consented to, kept for the record - a rerun's own request decides its form`. + +Schema migration, after the `run_dir` block: + +```python + # Which form of cost acknowledgement queued the job. Rows before + # the column are 'none' - nothing recorded is nothing recorded + if "acknowledged" not in columns: + connection.execute( + "ALTER TABLE jobs ADD COLUMN acknowledged TEXT DEFAULT 'none'" + ) +``` + +`record`: add `acknowledged` to the column list and `job.acknowledged` to the values (17 placeholders). `recent_summaries`: add `acknowledged` to the SELECT and `"acknowledged": row[9] or ACK_NONE` to each summary. `get`: add `acknowledged` to the SELECT; `_to_detail`: `"acknowledged": row[15] or ACK_NONE, "acknowledged_cost": (parse(row[7], {}) or {}).get("acknowledged_cost")` - reuse the parsed spec rather than parsing twice. + +`Job.__init__`: `self.acknowledged = spec.get("acknowledged") or ACK_NONE`. `summary()`: add `"acknowledged": self.acknowledged`. `detail()`: add `"acknowledged_cost": self.spec.get("acknowledged_cost")`. + +`submit` signature gains `acknowledged=ACK_NONE, acknowledged_cost=None`; after `spec["catalog_name"] = catalog_name` add: + +```python + # The acknowledgement form travels with the job so history can say + # whether this run was consented to at its actual size (#85) + spec["acknowledged"] = acknowledged + if acknowledged_cost is not None: + spec["acknowledged_cost"] = acknowledged_cost +``` + +Docstring line: `` `acknowledged` is the form of cost acknowledgement the caller gave (none/boolean/bound) and `acknowledged_cost` the bound object, both recorded, neither checked here - the route checks. `` + +Split `rerun`: everything up to and including the `arguments = historical["arguments"]` branch becomes + +```python + def rerun_spec(self, job_id): + """The spec and arguments a rerun of `job_id` would submit, or None + for an unknown job - split from rerun() so a route can plan the run + before queuing it (#85).""" +``` + +returning `(spec, arguments)`; `rerun(self, job_id, new_seed=False, acknowledged=ACK_NONE, acknowledged_cost=None)` calls it, keeps the seed and workspace logic, and passes to `submit`: `acknowledged=acknowledged, acknowledged_cost=acknowledged_cost if acknowledged_cost is not None else spec.get("acknowledged_cost")`. + +- [ ] **Step 4: Run the tests** + +Run: `python -m pytest tests/test_server.py tests/test_jobs_listing.py tests/test_jobs_reused.py -v -k "Acknowledgement or rerun or history or listing"` +Expected: PASS; then `python -m pytest tests/test_server.py -q` all green. + +- [ ] **Step 5: Commit** + +```bash +git add dw/server/jobs.py tests/test_server.py +git commit -m "feat(server): #85 - a job records which form of cost acknowledgement queued it + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 5: the bound form and the 409 on `POST /api/jobs` and `/rerun` + +**Files:** +- Modify: `dw/server/app.py` (`JobRequest` ~116, `RerunRequest` ~947, `submit_job` ~810, `rerun_job` ~956) +- Test: `tests/test_server.py` + +**Interfaces:** +- Consumes: `build_plan`, `JobManager.rerun_spec`, `submit(acknowledged=, acknowledged_cost=)`, `rerun(acknowledged=, acknowledged_cost=)`, `ACK_*`. +- Produces: `AcknowledgedCost(BaseModel)` with `fingerprint: str`, `minutes: float | None = None`, `downloads: list[str] = []`; `acknowledged_cost: bool | AcknowledgedCost | None = None` on both request models; `_acknowledgement_form(value) -> str`; `_check_bound_acknowledgement(candidate, arguments, acknowledged, workspace)` raising `HTTPException(409, detail={...})`. +- The 409 `detail` is `{"message": str, "reason": "fingerprint" | "downloads" | "unplannable", "acknowledged": {...}, "plan": {...} | None}`. (`message`, not `detail`, so `DwClient._format_detail` already renders it.) + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_server.py`: + +```python +def plan_for(client, workflow, arguments=None): + body = {"workflow": workflow} + if arguments: + body["arguments"] = arguments + answer = client.post("/api/validate?sizes=false", json=body).json() + assert answer["valid"], answer + return answer["plan"] + + +def bound(plan): + return { + "fingerprint": plan["fingerprint"], + "minutes": plan["estimate"]["minutes"], + "downloads": [d["repo"] for d in plan["downloads_required"] if d["repo"]], + } + + +@pytest.fixture +def no_hub(monkeypatch): + import dw.plan + + monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + + +def list_workflow(job_id="listed"): + return { + "id": job_id, + "seed": "variable:seed", + "variables": {"seed": 1, "shots": [{"name": "a", "prompt": "a"}]}, + "steps": [ + { + "name": "shot", + "for_each": "variable:shots", + "pipeline": { + "configuration": {"component_type": "{Fake}", "no_generator": True}, + "from_pretrained_arguments": {"model_name": "m"}, + "arguments": {"prompt": "item:prompt"}, + }, + } + ], + } + + +class TestBoundAcknowledgement: + def test_a_matching_fingerprint_queues(self, server, no_hub): + with server(success_script) as client: + plan = plan_for(client, list_workflow()) + response = client.post( + "/api/jobs", json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)} + ) + assert response.status_code == 201, response.json() + assert response.json()["acknowledged"] == "bound" + assert response.json()["acknowledged_cost"] == bound(plan) + + def test_a_longer_list_than_acknowledged_is_refused(self, server, no_hub): + with server(success_script) as client: + plan = plan_for(client, list_workflow()) + longer = {"shots": [{"name": n, "prompt": n} for n in "abc"]} + response = client.post( + "/api/jobs", + json={"workflow": list_workflow(), "arguments": longer, "acknowledged_cost": bound(plan)}, + ) + assert response.status_code == 409 + detail = response.json()["detail"] + assert detail["reason"] == "fingerprint" + assert detail["acknowledged"]["fingerprint"] == plan["fingerprint"] + assert detail["plan"]["list_entries"] == {"shots": 3} + assert detail["plan"]["fingerprint"] != plan["fingerprint"] + assert "differ" in detail["message"] + assert client.app.state.job_manager.worker_manager.commands == [] + + def test_a_new_seed_is_the_same_work(self, server, no_hub): + with server(success_script) as client: + plan = plan_for(client, list_workflow()) + response = client.post( + "/api/jobs", + json={"workflow": list_workflow(), "arguments": {"seed": 99}, "acknowledged_cost": bound(plan)}, + ) + assert response.status_code == 201 + + def test_a_download_not_acknowledged_is_refused(self, server, no_hub): + with server(success_script) as client: + plan = plan_for(client, list_workflow()) + acknowledgement = bound(plan) + acknowledgement["downloads"] = [] # the caller left the repo out + response = client.post( + "/api/jobs", json={"workflow": list_workflow(), "acknowledged_cost": acknowledgement} + ) + assert response.status_code == 409 + detail = response.json()["detail"] + assert detail["reason"] == "downloads" + assert "m" in detail["message"] + + def test_a_download_that_vanished_is_not_a_refusal(self, server, no_hub, monkeypatch): + with server(success_script) as client: + plan = plan_for(client, list_workflow()) + assert bound(plan)["downloads"] == ["m"] + import dw.plan + + monkeypatch.setattr( + dw.plan, "scan_models", lambda cache_dir=None: {"repos": [{"repo_id": "m"}]} + ) + response = client.post( + "/api/jobs", json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)} + ) + assert response.status_code == 201 + + def test_an_unplannable_run_is_refused_not_passed(self, server, no_hub, monkeypatch): + import dw.server.app as app_module + + with server(success_script) as client: + plan = plan_for(client, list_workflow()) + + def boom(*a, **k): + raise RuntimeError("no plan") + + monkeypatch.setattr(app_module, "build_plan", boom) + response = client.post( + "/api/jobs", json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)} + ) + assert response.status_code == 409 + assert response.json()["detail"]["reason"] == "unplannable" + assert response.json()["detail"]["plan"] is None + + def test_true_and_absent_queue_without_planning(self, server, no_hub, monkeypatch): + import dw.server.app as app_module + + def boom(*a, **k): + raise AssertionError("the boolean path must not plan") + + monkeypatch.setattr(app_module, "build_plan", boom) + with server(success_script) as client: + plain = client.post("/api/jobs", json={"workflow": valid_workflow("p")}) + flagged = client.post( + "/api/jobs", json={"workflow": valid_workflow("f"), "acknowledged_cost": True} + ) + off = client.post( + "/api/jobs", json={"workflow": valid_workflow("o"), "acknowledged_cost": False} + ) + assert plain.json()["acknowledged"] == "none" + assert flagged.json()["acknowledged"] == "boolean" + assert off.json()["acknowledged"] == "none" + + def test_a_bound_form_without_a_fingerprint_is_a_422(self, server): + with server(success_script) as client: + response = client.post( + "/api/jobs", json={"workflow": valid_workflow(), "acknowledged_cost": {"minutes": 3}} + ) + assert response.status_code == 422 + + def test_a_stored_prompt_edited_after_validation_is_refused(self, server, no_hub, tmp_path): + (tmp_path / "prompts" / "p.json").write_text(json.dumps({"text": "before"})) + workflow = valid_workflow("prompted") + workflow["variables"]["prompt"] = "prompt:p" + with server(success_script) as client: + plan = plan_for(client, workflow) + (tmp_path / "prompts" / "p.json").write_text(json.dumps({"text": "after"})) + response = client.post( + "/api/jobs", json={"workflow": workflow, "acknowledged_cost": bound(plan)} + ) + assert response.status_code == 409 + assert response.json()["detail"]["reason"] == "fingerprint" + + +class TestBoundRerun: + def test_a_rerun_with_the_original_plan_queues_even_with_a_new_seed(self, server, no_hub): + with server(success_script) as client: + plan = plan_for(client, list_workflow()) + first = client.post( + "/api/jobs", json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)} + ).json() + wait_for_status(client, first["id"], TERMINAL_STATES) + response = client.post( + f"/api/jobs/{first['id']}/rerun", + json={"new_seed": True, "acknowledged_cost": bound(plan)}, + ) + assert response.status_code == 201 + assert response.json()["acknowledged"] == "bound" + + def test_a_rerun_bound_to_a_stale_plan_is_refused(self, server, no_hub): + with server(success_script) as client: + first = client.post("/api/jobs", json={"workflow": list_workflow()}).json() + wait_for_status(client, first["id"], TERMINAL_STATES) + other = plan_for(client, valid_workflow("other")) + response = client.post( + f"/api/jobs/{first['id']}/rerun", json={"acknowledged_cost": bound(other)} + ) + assert response.status_code == 409 + assert response.json()["detail"]["reason"] == "fingerprint" + + def test_a_rerun_with_true_is_unchanged(self, server, no_hub): + with server(success_script) as client: + first = client.post("/api/jobs", json={"workflow": list_workflow()}).json() + wait_for_status(client, first["id"], TERMINAL_STATES) + response = client.post(f"/api/jobs/{first['id']}/rerun", json={"acknowledged_cost": True}) + assert response.status_code == 201 + assert response.json()["acknowledged"] == "boolean" + + def test_an_unknown_job_is_still_404(self, server): + with server(success_script) as client: + response = client.post( + "/api/jobs/nope/rerun", json={"acknowledged_cost": {"fingerprint": "sha256:0"}} + ) + assert response.status_code == 404 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python -m pytest tests/test_server.py -k "BoundAcknowledgement or BoundRerun" -v` +Expected: FAIL (`acknowledged_cost` is dropped as an unknown field, so most answer 201 without `acknowledged`, or `acknowledged: none`). + +- [ ] **Step 3: Implement** + +`dw/server/app.py`: + +Imports: add `ACK_BOOLEAN, ACK_BOUND, ACK_NONE` to the `from .jobs import ...` line (find it with `grep -n "from .jobs import" dw/server/app.py`); add `from typing import Union` if `Union` is not already imported (check the existing `Optional` import line). + +Above `class JobRequest`: + +```python +class AcknowledgedCost(BaseModel): + """A cost acknowledgement bound to the plan a validate call answered + with (#85): the server refuses to queue a run whose plan no longer + matches it. `minutes` is recorded, never compared.""" + + fingerprint: str = Field(description="plan.fingerprint from POST /api/validate") + minutes: Optional[float] = Field( + default=None, description="plan.estimate.minutes, recorded on the job" + ) + downloads: List[str] = Field( + default_factory=list, + description="The repos in plan.downloads_required that were acknowledged", + ) + + +ACKNOWLEDGED_COST_FIELD = Field( + default=None, + description="Cost acknowledgement: true (recorded), or an object " + "{fingerprint, minutes, downloads} bound to the plan validate answered " + "with - then the run is refused with 409 if its plan changed", +) +``` + +(`List` - check `from typing import` at the top; add it if only `Dict, Any, Optional` are imported.) + +`JobRequest` gains `acknowledged_cost: Optional[Union[bool, AcknowledgedCost]] = ACKNOWLEDGED_COST_FIELD`. `RerunRequest` (inside `create_app`) gains the same line. + +Helpers inside `create_app`, beside `_argument_reference_errors`: + +```python + def _acknowledgement_form(value): + """none | boolean | bound - classified once, here, so the check and + the record agree.""" + if isinstance(value, AcknowledgedCost): + return ACK_BOUND + return ACK_BOOLEAN if value is True else ACK_NONE + + def _check_bound_acknowledgement(candidate, arguments, acknowledged, workspace): + """Refuse with 409 when the run `candidate` + `arguments` will + execute is not the one `acknowledged` was bound to: a different + fingerprint, or a download the caller did not acknowledge. The body + carries the current plan so the agent re-quotes from it without a + second validate call. A plan that cannot be built is a refusal too - + never a silent pass (#85). + """ + record = acknowledged.model_dump() + try: + from .. import get_device, get_device_type + + current = build_plan( + candidate, + arguments, + device=get_device_type(get_device()), + prompt_dir=workspace.prompts, + lookup_sizes=False, + ) + except Exception: + logger.exception("Plan could not be built for a bound acknowledgement") + raise HTTPException( + status_code=409, + detail={ + "message": "The run could not be planned, so a bound " + "acknowledgement cannot be checked; acknowledge with true " + "or validate again", + "reason": "unplannable", + "acknowledged": record, + "plan": None, + }, + ) + if current["fingerprint"] != acknowledged.fingerprint: + raise HTTPException( + status_code=409, + detail={ + "message": "The run's shape changed since it was acknowledged: " + "the workflow or its arguments differ from what was validated", + "reason": "fingerprint", + "acknowledged": record, + "plan": current, + }, + ) + missing = [ + entry["repo"] + for entry in current["downloads_required"] + if entry.get("repo") and entry["repo"] not in acknowledged.downloads + ] + if missing: + raise HTTPException( + status_code=409, + detail={ + "message": "The run's shape changed since it was acknowledged: " + f"it now has to download {', '.join(missing)} first", + "reason": "downloads", + "acknowledged": record, + "plan": current, + }, + ) + + def _candidate_for(workflow_path, workflow, base_dir, output_dir, workflow_dir): + """The Workflow a job spec names, built as the worker will build it.""" + if workflow_path is not None: + return workflow_from_file(workflow_path, output_dir, workflow_dir) + return workflow_from_definition( + copy.deepcopy(workflow), output_dir, base_dir, workflow_dir + ) +``` + +In `submit_job`, after the `reference_problems` check and before `manager.submit(...)`: + +```python + form = _acknowledgement_form(request.acknowledged_cost) + if form == ACK_BOUND: + confinement = source.root if source else workspace.workflows + candidate = _candidate_for( + resolved, request.workflow, request.base_dir, + workspace.outputs, confinement, + ) + _check_bound_acknowledgement( + candidate, request.arguments, request.acknowledged_cost, workspace + ) +``` + +and pass to `manager.submit`: `acknowledged=form, acknowledged_cost=(request.acknowledged_cost.model_dump() if form == ACK_BOUND else None)`. The `except HTTPException: raise` already precedes the catch-all, so the 409 passes through. + +In `rerun_job`: + +```python + form = _acknowledgement_form(body.acknowledged_cost) + if form == ACK_BOUND: + prepared = manager.rerun_spec(job_id) + if prepared is None: + raise HTTPException(status_code=404, detail="Unknown job") + spec, arguments = prepared + try: + candidate = _candidate_for( + spec.get("workflow_path"), spec.get("workflow"), spec.get("base_dir"), + spec.get("output_dir") or manager.output_dir, spec.get("workflow_dir"), + ) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + _check_bound_acknowledgement( + candidate, arguments, body.acknowledged_cost, + _workspace_for(spec.get("workspace")), + ) + try: + job = manager.rerun( + job_id, + new_seed=body.new_seed, + acknowledged=form, + acknowledged_cost=( + body.acknowledged_cost.model_dump() if form == ACK_BOUND else None + ), + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) +``` + +(`_workspace_for(None)` is the default workspace - see its first line.) Update the route docstring: `Pass acknowledged_cost as on POST /api/jobs; a bound one is checked against the stored spec's plan - the fresh seed of new_seed does not change a fingerprint.` + +- [ ] **Step 4: Run the tests** + +Run: `python -m pytest tests/test_server.py -v -k "Bound or ValidatePlan or Acknowledgement"` then `python -m pytest tests/test_server.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add dw/server/app.py tests/test_server.py +git commit -m "feat(server): #85 - a bound acknowledgement is checked against the run's plan, and a changed plan is a 409 + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 6: MCP - the bound form over the tools, and the 409 rendered with the new estimate + +**Files:** +- Modify: `dw_mcp/diagnose.py` (`COST_REFUSAL` ~25, `run_workflow` ~33, `rerun_job` ~248), `dw_mcp/server.py` (`run_workflow` ~794, `rerun_job` ~907), `dw_mcp/client.py` (`_format_detail` ~325) +- Test: `tests/test_mcp_diagnose.py`, `tests/test_mcp_client.py`, `tests/test_mcp_server.py` + +**Interfaces:** +- Consumes: the 409 body shape from Task 5. +- Produces: `diagnose.run_workflow(..., acknowledged_cost: bool | dict)`; `diagnose.rerun_job(..., acknowledged_cost: bool | dict)`; a dict is forwarded verbatim as the body's `acknowledged_cost`; a dict without `fingerprint` raises `DwApiError` before any request. `DwClient._format_detail` appends the plan's estimate and downloads when a dict detail carries `plan`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_mcp_diagnose.py`: + +```python +BOUND = {"fingerprint": "sha256:abc", "minutes": 4.0, "downloads": ["org/x"]} + + +def test_run_forwards_a_bound_acknowledgement_verbatim(): + import json + + client, seen = submitting() + diagnose.run_workflow(client, workflow_path="w.json", acknowledged_cost=BOUND) + assert json.loads(seen[0]["body"])["acknowledged_cost"] == BOUND + + +def test_run_does_not_send_a_bare_true(): + """The boolean path is the MCP layer's gate, not the server's - the body + stays what it was.""" + import json + + client, seen = submitting() + diagnose.run_workflow(client, workflow_path="w.json", acknowledged_cost=True) + assert "acknowledged_cost" not in json.loads(seen[0]["body"]) + + +def test_run_refuses_a_bound_form_without_a_fingerprint(): + client, seen = submitting() + with pytest.raises(DwApiError, match="fingerprint"): + diagnose.run_workflow(client, workflow_path="w.json", acknowledged_cost={"minutes": 4}) + assert seen == [] + + +def test_run_refuses_an_empty_dict_as_unacknowledged(): + client, seen = submitting() + with pytest.raises(DwApiError, match="acknowledged_cost"): + diagnose.run_workflow(client, workflow_path="w.json", acknowledged_cost={}) + assert seen == [] + + +def test_a_409_surfaces_with_the_new_estimate(): + client, _seen = scripted( + { + ("POST", "/api/jobs"): ( + 409, + { + "detail": { + "message": "The run's shape changed since it was acknowledged: the workflow or its arguments differ from what was validated", + "reason": "fingerprint", + "acknowledged": BOUND, + "plan": { + "fingerprint": "sha256:def", + "steps": 6, + "list_entries": {"shots": 5}, + "cached_steps": None, + "downloads_required": [{"repo": "org/y", "gb": 3.5}], + "estimate": {"minutes": 19.0, "basis": "per_entry", "device": "cuda", "measured_on": "card", "partial": False}, + }, + } + }, + ) + } + ) + with pytest.raises(DwApiError) as caught: + diagnose.run_workflow(client, workflow_path="w.json", acknowledged_cost=BOUND) + message = str(caught.value) + assert "shape changed" in message + assert "19.0" in message and "per_entry" in message + assert "org/y" in message + assert "sha256:def" in message + + +def test_rerun_forwards_a_bound_acknowledgement(): + import json + + client, seen = scripted({("POST", "/api/jobs/job-1/rerun"): (201, SUBMITTED)}) + diagnose.rerun_job(client, "job-1", acknowledged_cost=BOUND, new_seed=True) + body = json.loads(seen[0]["body"]) + assert body["acknowledged_cost"] == BOUND and body["new_seed"] is True +``` + +Append to `tests/test_mcp_diagnose.py` as well (the refusal text): + +```python +def test_the_refusal_teaches_the_bound_form(): + from dw_mcp.diagnose import COST_REFUSAL + + assert "fingerprint" in COST_REFUSAL +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python -m pytest tests/test_mcp_diagnose.py -k "bound or 409 or forwards or teaches or empty_dict" -v` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +`dw_mcp/diagnose.py`: + +```python +COST_REFUSAL = ( + "Running a workflow occupies the GPU for minutes and the engine runs one " + "job at a time. Call `validate_workflow` with the arguments you will run " + "with (free): its `plan` says what will execute - `estimate.minutes` with " + "its `basis`, and any weights in `downloads_required` this box has to " + "fetch first. Tell the user that number, get their go-ahead, then call " + "again with acknowledged_cost bound to the plan: {\"fingerprint\": " + "plan.fingerprint, \"minutes\": plan.estimate.minutes, \"downloads\": " + "[each downloads_required repo]} - the server then refuses (409) if the " + "run's shape changed since. Pass true instead only when `plan` was null." +) + + +def _acknowledgement_body(acknowledged_cost): + """What a bound acknowledgement adds to a request body: the dict itself, + verbatim, so the server compares what the agent quoted. A dict without + a fingerprint is a mistake caught here, before anything is queued; a + bare true adds nothing - the boolean gate is this layer's, not the + server's.""" + if isinstance(acknowledged_cost, dict): + if not acknowledged_cost.get("fingerprint"): + raise DwApiError( + "A bound acknowledged_cost needs `fingerprint` - the " + "plan.fingerprint the validate answer carried. Validate again " + "and pass {fingerprint, minutes, downloads} from its plan." + ) + return {"acknowledged_cost": acknowledged_cost} + return {} +``` + +In `run_workflow`, change the gate to `if not acknowledged_cost:` (unchanged - an empty dict is falsy and refuses) and after `payload = {"arguments": arguments or {}}` add `payload.update(_acknowledgement_body(acknowledged_cost))`. In `rerun_job` the body becomes `{"new_seed": new_seed, **_acknowledgement_body(acknowledged_cost)}`. Update both docstrings' gate sentence: `acknowledged_cost is true or, better, the plan it was quoted from: {fingerprint, minutes, downloads} - see COST_REFUSAL`. + +`dw_mcp/server.py`: both tool signatures become `acknowledged_cost: bool | dict = False`. Append to `run_workflow`'s docstring: `Bind the acknowledgement to what you quoted: pass {"fingerprint": plan.fingerprint, "minutes": plan.estimate.minutes, "downloads": [...repos from plan.downloads_required]} from the validate answer, and the server refuses with 409 - naming the new plan - if the run's shape changed since; bare true is for a plan that was null.` Append to `rerun_job`'s: `acknowledged_cost takes the same bound form as run_workflow; a fresh seed never changes the fingerprint, so the original plan still binds a new_seed rerun.` + +`dw_mcp/client.py`, `_format_detail`, inside the `if isinstance(detail, dict) and "message" in detail:` branch, before `return formatted`: + +```python + plan = detail.get("plan") + if isinstance(plan, dict): + # A 409 from the cost gate: say what the run costs now, so a + # client that only sees the message can re-quote from it + estimate = plan.get("estimate") or {} + formatted += ( + f" It now estimates {estimate.get('minutes')} minutes " + f"(basis {estimate.get('basis')})" + ) + downloads = [ + entry.get("repo") or entry.get("url") + for entry in plan.get("downloads_required") or [] + ] + if downloads: + formatted += f", and would download {', '.join(downloads)} first" + formatted += f"; new fingerprint {plan.get('fingerprint')}." +``` + +- [ ] **Step 4: Run the MCP suites** + +Run: `python -m pytest tests/test_mcp_diagnose.py tests/test_mcp_client.py tests/test_mcp_server.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add dw_mcp/diagnose.py dw_mcp/server.py dw_mcp/client.py tests/test_mcp_diagnose.py +git commit -m "feat(mcp): #85 - acknowledged_cost binds to the plan that was quoted, and a 409 re-quotes + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 7: docs, skills, proposal status + +**Files:** +- Modify: `docs/SERVER.md` (`POST /api/jobs` bullet - find with `grep -n "POST /api/jobs" docs/SERVER.md`; the validate `plan` paragraph's `cached_steps, reserved (null)`), `docs/MCP.md` (`## The cost gate`, the `run_workflow`/`rerun_job` table rows, the troubleshooting row `run_workflow or rerun_job refuses with a cost message`), `docs/WORKFLOW_GUIDE.md` ("The loop" step 4), `CLAUDE.md` (the `plan` sentence added in stage 1), `plugins/dw/skills/*/SKILL.md` (step 2 of "Run and judge"), `docs/proposals/acknowledged-cost-binding.md` (status line) +- Test: `tests/test_plugin_skills.py`, `tests/test_docs_links.py` + +- [ ] **Step 1: Docs** + +`docs/SERVER.md`: +- In the validate `plan` paragraph replace `` `cached_steps`, reserved (`null`) `` with `` `cached_steps`, how many of those steps the worker's step cache would serve (`0` for an unseeded workflow, `null` when the worker is busy or did not answer) ``. +- To the `POST /api/jobs` bullet append: `` Takes an optional `acknowledged_cost`: `true` is recorded as `acknowledged: boolean`; the object `{fingerprint, minutes, downloads}` from a validate answer's `plan` is `bound` - the server re-plans the run for the arguments given and answers **409** when the fingerprint differs or a repo in `downloads_required` is not in `downloads` (a download that has since vanished is not a refusal); the body is `{"detail": {message, reason: "fingerprint" | "downloads" | "unplannable", acknowledged, plan}}` with the current plan, so the caller re-quotes from it. `minutes` is recorded, never compared. Nothing is required: the web UI and every caller that sends nothing are `acknowledged: none`, and every job answer and history row carries `acknowledged` (and `acknowledged_cost` when bound). `POST /api/jobs/{id}/rerun` takes the same field and checks against the stored spec; a fresh seed does not change a fingerprint. `` + +`docs/MCP.md`: +- `## The cost gate`: after the paragraph ending `and gating them would make the safe direction the harder one.` add: `` The acknowledgement can be bound to what was quoted. `validate_workflow` answers with a `plan`; pass `acknowledged_cost={"fingerprint": plan.fingerprint, "minutes": plan.estimate.minutes, "downloads": [...]}` and the server refuses with 409 if the run's shape changed between the quote and the call - a longer list, a stored prompt edited meanwhile, weights that now have to be downloaded - naming the new plan so the agent re-quotes. Bare `true` still works and is for a `plan` that came back null; the job records which form it got (`acknowledged: none | boolean | bound`). `` +- Step 2 of "The intended loop": replace `` `run_workflow` with `acknowledged_cost=true` `` with `` `run_workflow` with `acknowledged_cost` bound to the plan (`{fingerprint, minutes, downloads}`), or `true` when there was no plan ``. +- Tool table rows for `run_workflow` and `rerun_job`: `acknowledged_cost=False` → `acknowledged_cost=False` stays in the signature column; append to each description ` - `acknowledged_cost` is `true` or the bound `{fingerprint, minutes, downloads}` from the validate plan; a 409 means the plan changed and the message carries the new estimate`. +- Troubleshooting row: append `; a 409 "shape changed" answer means the run grew since the quote - re-validate, re-quote, pass the new plan`. + +`docs/WORKFLOW_GUIDE.md`, "The loop" step 4: after `never counted.` add ` Then pass that plan back: `acknowledged_cost={"fingerprint": plan.fingerprint, "minutes": plan.estimate.minutes, "downloads": [...]}` - the server refuses with 409 if the run's shape changed since the quote, and the refusal carries the new plan to quote from. `true` is for a plan that was null.` + +`CLAUDE.md`: extend the stage-1 sentence: after `never a changed verdict` add `; `acknowledged_cost` on `POST /api/jobs` / `rerun` takes `true` (recorded) or the plan's `{fingerprint, minutes, downloads}` (checked - 409 with the current plan when the fingerprint or the required downloads changed; `minutes` never compared), and the job records `acknowledged: none | boolean | bound`. `cached_steps` is the worker's answer to a `probe_cache` command (`Workflow.cache_hits`, which shares `_prepare_definition` / `_cache_lookup` with `run` so the two cannot drift)`. + +`docs/proposals/acknowledged-cost-binding.md`: status line → `Status: **implemented** (stage 1 b37f033, stage 2 on `cost-binding`). Design: docs/superpowers/specs/2026-09-12-acknowledged-cost-binding-design.md.` + +- [ ] **Step 2: Skills** + +Each "Run and judge" step 2 ends with a sentence about `run_workflow with acknowledged_cost=true`. Replace, in all three, `` `run_workflow` with `acknowledged_cost=true` `` with `` `run_workflow` with `acknowledged_cost` set to the plan's `{fingerprint, minutes, downloads}` ``. That is +36 bytes each. `minimax-h3` has 22 bytes of headroom and `ltx-2.5` 108, so trim `minimax-h3` first: in its step 2, `(warm minutes; a first\n load, and any \`downloads_required\`, is longer)` → `(warm minutes; a first load\n or a \`downloads_required\` is longer)` (−14) and in step 1 `does not accept.` → `rejects.` (−8) — recount with `wc -c` and keep trimming words (not numbers) until under 12288. + +- [ ] **Step 3: Run the doc and skill tests** + +Run: `python -m pytest tests/test_plugin_skills.py tests/test_docs_links.py -q && wc -c plugins/dw/skills/*/SKILL.md` +Expected: PASS; every skill under 12288. + +- [ ] **Step 4: Commit** + +```bash +git add docs CLAUDE.md plugins/dw/skills +git commit -m "docs: #85 - the bound acknowledgement, the 409 and cached_steps + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 8: whole-suite verification + +- [ ] **Step 1:** `python -m pytest -q -x --ignore=tests/test_integration.py -p no:cacheprovider 2>&1 | tail -3` - expect all green (3893 + the new tests). +- [ ] **Step 2:** `git status --short && git log --oneline develop..HEAD` - clean, eight commits (plan + seven tasks). + +--- + +## Self-review + +**Spec coverage:** §6 (models, classification table) - Task 5. §7 (the check, `unplannable`, fingerprint, downloads, body shape) - Task 5; body uses `message` rather than nested `detail.detail` so the existing client formatter renders it, noted in Interfaces. §8 (`Job.acknowledged`, sqlite, `describe`, `RERUN_SPEC_KEYS`) - Task 4. §9 (MCP widening, forwarding, 409 rendering, `COST_REFUSAL`) - Task 6. §10 (probe command, the `run` refactor, `JobManager.probe_cache`, `build_plan(cache_probe=)`, unseeded → 0 with no probe, no scaling by `cached_steps`) - Tasks 1-3, with the command-shape deviation stated up top. §11 tests - each task. Docs - Task 7. Release-note items - the proposal status line; the note itself stays owed with stage 1's. + +**Type consistency:** `cache_hits(arguments) -> list[str]` (Task 1) is what `_handle_probe_cache` calls (Task 2); `probe_cache(command, timeout)` (Task 2) is what the validate closure calls (Task 3) with `{**command, "arguments": arguments}`; `cache_probe(arguments)` (Task 3) matches `cached_steps()`'s call; `rerun_spec` / `submit(acknowledged=, acknowledged_cost=)` / `rerun(acknowledged=, acknowledged_cost=)` (Task 4) match the route (Task 5); the 409 body keys (`message`, `reason`, `acknowledged`, `plan`) match the client test and formatter (Task 6). From fa2acc827b1348f08523e76031b26c1caa4418b0 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:41:52 -0500 Subject: [PATCH 12/34] feat(engine): #85 - a workflow can say which steps the cache would serve, by the run's own preparation Co-Authored-By: Claude Opus 5 (1M context) --- dw/workflow.py | 301 +++++++++++++++++++----------- tests/test_workflow_step_cache.py | 63 +++++++ 2 files changed, 260 insertions(+), 104 deletions(-) diff --git a/dw/workflow.py b/dw/workflow.py index 3c559509..7ce9eca6 100644 --- a/dw/workflow.py +++ b/dw/workflow.py @@ -647,6 +647,188 @@ def validate(self): raise Exception(message) logger.debug(f"Workflow {self.name} validated successfully") + def _prepare_definition(self, workflow_def, arguments, base_dir): + """The definition as a run works from it: constants realized, + arguments folded into the variables, list entries' own references + resolved, variable values realized (assets loaded), every + 'variable:' substituted, every for_each expanded, and the seed read + and coerced. Returns (workflow_def, default_seed) - the seed is + None when the workflow names none, and the caller decides what + that means (run() draws one; cache_hits() reports no hits). + + Shared by run() and cache_hits() so the probe prepares exactly what + the run prepares - the step cache keys on the realized step, and a + probe that prepared it differently would answer for a run that + never happens. + """ + workflow_id = workflow_def["id"] + variables = workflow_def.get("variables", None) + if variables is not None: + logger.debug(f"Setting variables for workflow: {workflow_id}") + # a constant is the value a variable declares, so it resolves before + # anything is converted to the type of that declaration + realize_constants(variables) + # first set variable values base don the arguments passed to the workflow + # these may come form the command line or form a parent workflow + set_variables(arguments, variables) + # an entry of a list-valued variable may name another + # variable; resolve those before anything inside it is + # realized, so a reference type in an entry is a type name + variables = resolve_variable_values(variables) + # realize the variables, initialiting downloads of images etc + realize_args(variables, base_dir) + ## then replace any variable references in the workflow definition with the actual values + # replace_variables returns a new structure rather than mutating in + # place, so the result must be captured here + workflow_def = replace_variables(workflow_def, variables) + + # One ordinary step per entry of every for_each list, before the + # seed, the run id and the realized workflow are computed, so + # each covers what actually runs. A ForEachError here fails the + # run before anything loads + workflow_def = expand_for_each(workflow_def) + + # Set up random seed for reproducibility. Resolved lazily - as a + # dict.get default, torch.seed() would run on every call and reseed + # the global RNG even when the workflow names an explicit seed + default_seed = workflow_def.get("seed") + # The schema lets 'seed' be a string so it can hold a 'variable:' + # reference, which the substitution above has already resolved - + # but a variable overridden from the command line arrives as a + # string whenever the workflow declared no integer default to + # coerce against, and manual_seed would fail deep inside the run + if isinstance(default_seed, str): + try: + default_seed = int(default_seed) + except ValueError: + raise ValueError( + f"Workflow {workflow_id} seed must be an integer, " + f"got {default_seed!r}" + ) + workflow_def["seed"] = default_seed + return workflow_def, default_seed + + def _cache_lookup( + self, + workflow_id, + steps, + index, + step_data, + step_seed, + hits_this_run, + cache_enabled, + ): + """Whether the step cache serves step `index`, as (cached_result or + None, the step_data snapshot the entry is keyed on or None, whether + a later step still reads this one's result, the names later steps + still reference). Shared by run() and cache_hits() - see + _prepare_definition for why. + """ + # What later steps still read, which decides both whether this + # step's result has to be kept alive after the step (release_unreferenced_results + # at the bottom of the run loop) and whether a cached entry that + # kept none can serve this run + remaining_refs = referenced_result_names(steps[index + 1 :]) + result_needed = index == len(steps) - 1 or any( + reference_resolves_to(ref, step_data["name"]) for ref in remaining_refs + ) + # create_step_action (and the pipeline load it triggers) mutates + # step_data in place - injecting a "generator" key - so the cache + # must key off a snapshot taken before that happens, and that same + # snapshot must be reused for the put() later. Caching off the live, + # later-mutated step_data would make every step's dict keys diverge + # from a freshly deep-copied future run's step_data, so get() would + # never match again after the first run. + # A sub-workflow step is never cacheable: its files roll up from the + # child's own manifest, which a hit does not rebuild. + is_cacheable = "workflow" not in step_data and cache_enabled + # The last step of a composed child whose parent does the saving + # (#92) - its files are the parent step's, written once, under the + # parent's name and subfolder + parent_saves_this = ( + self._final_save_owned_by_parent and index == len(steps) - 1 + ) + step_data_snapshot = None + if is_cacheable: + try: + step_data_snapshot = copy.deepcopy(step_data) + if parent_saves_this: + # Keyed apart from the same step run standalone: this + # entry's result was never saved here, so a standalone + # hit on it would report no files + step_data_snapshot["__saved_by_parent__"] = True + except Exception as ex: + # A realized argument that cannot be deep-copied (an open + # handle, a live model object) just means this step is not + # cacheable - never a failed run + logger.debug( + f"Step '{step_data['name']}' arguments are not copyable " + f"({ex}) - skipping the step cache for it" + ) + is_cacheable = False + if not is_cacheable: + return None, None, result_needed, remaining_refs + cached_result = step_cache.get( + workflow_id, + step_data_snapshot, + step_seed, + hits_this_run, + # The root, not this run's directory: a hit reports the earlier + # run's files and writes nothing new, so keying on a directory + # that is new every run would mean the cache could never hit + # again. What the root still guards is a run redirected + # somewhere else, where the earlier files are not what the + # caller asked for + self.output_dir, + needs_result=result_needed, + ) + return cached_result, step_data_snapshot, result_needed, remaining_refs + + def cache_hits(self, arguments): + """The steps the step cache would serve for a run with `arguments`, + in step order - what the plan reports as cached_steps (#85). + + Prepares the definition exactly as run() does and asks the cache the + question run() asks, step by step with the hits so far, and executes + nothing: no run directory, no events, no pipeline. An unseeded + workflow has no cache, so it answers [] without asking. + """ + output_root_token = activate_output_root(self.output_dir) + try: + workflow_def = copy.deepcopy(self.workflow_definition) + workflow_id = workflow_def["id"] + base_dir = ( + os.path.dirname(os.path.abspath(self.file_spec)) + if self.file_spec + else None + ) + workflow_def, default_seed = self._prepare_definition( + workflow_def, arguments or {}, base_dir + ) + if default_seed is None or not self._cache_enabled_by_parent: + return [] + steps = workflow_def.get("steps", []) + realize_args(steps, base_dir) + hits_this_run = set() + hits = [] + for index, step_data in enumerate(steps): + step_seed = step_data.get("seed", default_seed) + cached_result, _, _, _ = self._cache_lookup( + workflow_id, + steps, + index, + step_data, + step_seed, + hits_this_run, + True, + ) + if cached_result is not None: + hits_this_run.add(step_data["name"]) + hits.append(step_data["name"]) + return hits + finally: + deactivate_output_root(output_root_token) + def run( self, arguments, previous_pipelines=None, context=None, prior_step_keys=None ): @@ -706,51 +888,9 @@ def run( else None ) - # Handle variable substitution if variables are defined - variables = workflow_def.get("variables", None) - if variables is not None: - logger.debug(f"Setting variables for workflow: {workflow_id}") - # a constant is the value a variable declares, so it resolves before - # anything is converted to the type of that declaration - realize_constants(variables) - # first set variable values base don the arguments passed to the workflow - # these may come form the command line or form a parent workflow - set_variables(arguments, variables) - # an entry of a list-valued variable may name another - # variable; resolve those before anything inside it is - # realized, so a reference type in an entry is a type name - variables = resolve_variable_values(variables) - # realize the variables, initialiting downloads of images etc - realize_args(variables, base_dir) - ## then replace any variable references in the workflow definition with the actual values - # replace_variables returns a new structure rather than mutating in - # place, so the result must be captured here - workflow_def = replace_variables(workflow_def, variables) - - # One ordinary step per entry of every for_each list, before the - # seed, the run id and the realized workflow are computed, so - # each covers what actually runs. A ForEachError here fails the - # run before anything loads - workflow_def = expand_for_each(workflow_def) - - # Set up random seed for reproducibility. Resolved lazily - as a - # dict.get default, torch.seed() would run on every call and reseed - # the global RNG even when the workflow names an explicit seed - default_seed = workflow_def.get("seed") - # The schema lets 'seed' be a string so it can hold a 'variable:' - # reference, which the substitution above has already resolved - - # but a variable overridden from the command line arrives as a - # string whenever the workflow declared no integer default to - # coerce against, and manual_seed would fail deep inside the run - if isinstance(default_seed, str): - try: - default_seed = int(default_seed) - except ValueError: - raise ValueError( - f"Workflow {workflow_id} seed must be an integer, " - f"got {default_seed!r}" - ) - workflow_def["seed"] = default_seed + workflow_def, default_seed = self._prepare_definition( + workflow_def, arguments, base_dir + ) # A workflow that names no seed gets a fresh one every run, so no # step's cache entry can ever match again - skip the cache # wholesale rather than deep-copying every step's realized images @@ -884,69 +1024,22 @@ def run( step = Step(step_data, step_seed, self.workflow_definition) - # What later steps still read, which decides both whether - # this step's result has to be kept alive after the step - # (below, and release_unreferenced_results at the bottom of - # the loop) and whether a cached entry that kept none can - # serve this run - remaining_refs = referenced_result_names(steps[i + 1 :]) - result_needed = i == len(steps) - 1 or any( - reference_resolves_to(ref, step_data["name"]) - for ref in remaining_refs - ) - - # create_step_action (and the pipeline load it triggers) - # mutates step_data in place - injecting a "generator" key - - # so the cache must key off a snapshot taken before that - # happens, and that same snapshot must be reused for the - # put() below. Caching off the live, later-mutated step_data - # would make every step's dict keys diverge from a freshly - # deep-copied future run's step_data, so get() would never - # match again after the first run. - # A sub-workflow step is never cacheable: its files roll up - # from the child's own manifest, which a hit does not rebuild. - is_cacheable = "workflow" not in step_data and cache_enabled_this_run - # The last step of a composed child whose parent does the - # saving (#92) - its files are the parent step's, written - # once, under the parent's name and subfolder - parent_saves_this = ( - self._final_save_owned_by_parent and i == len(steps) - 1 - ) - step_data_snapshot = None - if is_cacheable: - try: - step_data_snapshot = copy.deepcopy(step_data) - if parent_saves_this: - # Keyed apart from the same step run standalone: - # this entry's result was never saved here, so a - # standalone hit on it would report no files - step_data_snapshot["__saved_by_parent__"] = True - except Exception as ex: - # A realized argument that cannot be deep-copied (an - # open handle, a live model object) just means this - # step is not cacheable - never a failed run - logger.debug( - f"Step '{step.name}' arguments are not copyable " - f"({ex}) - skipping the step cache for it" - ) - is_cacheable = False - cached_result = ( - step_cache.get( + cached_result, step_data_snapshot, result_needed, remaining_refs = ( + self._cache_lookup( workflow_id, - step_data_snapshot, + steps, + i, + step_data, step_seed, hits_this_run, - # The root, not this run's directory: a hit reports - # the earlier run's files and writes nothing new, so - # keying on a directory that is new every run would - # mean the cache could never hit again. What the root - # still guards is a run redirected somewhere else, - # where the earlier files are not what the caller asked for - self.output_dir, - needs_result=result_needed, + cache_enabled_this_run, ) - if is_cacheable - else None + ) + is_cacheable = step_data_snapshot is not None + # The last step of a composed child whose parent does the + # saving (#92) - its files are written once, by the parent + parent_saves_this = ( + self._final_save_owned_by_parent and i == len(steps) - 1 ) # A hit skips the step's work, never its bookkeeping: diff --git a/tests/test_workflow_step_cache.py b/tests/test_workflow_step_cache.py index 7818672f..5c80250f 100644 --- a/tests/test_workflow_step_cache.py +++ b/tests/test_workflow_step_cache.py @@ -609,3 +609,66 @@ def test_adding_a_downstream_reference_misses_on_a_result_that_was_not_retained( counts = _run_with_per_step_counts(with_reference, {}) assert counts.get("A") == 1 + + +class TestCacheHits: + """cache_hits() answers the plan's cached_steps (#85): what the next + run would reuse, by the run's own preparation, executing nothing.""" + + def test_a_cold_cache_reports_no_hits(self): + step_cache.clear() + workflow, _ = build_test_workflow_and_call_count_spy() + try: + assert workflow.cache_hits({}) == [] + finally: + for p in workflow._test_patcher: + p.stop() + + def test_after_a_run_the_probe_names_what_the_next_run_reuses(self): + step_cache.clear() + workflow, call_count = build_test_workflow_and_call_count_spy() + try: + workflow.run({}) + probe = workflow.cache_hits({}) + workflow.run({}) + reused = [ + entry["step"] for entry in workflow.manifest if entry.get("reused") + ] + assert probe == ["generate"] + assert probe == reused + assert call_count() == 1, "the probe executed nothing" + finally: + for p in workflow._test_patcher: + p.stop() + + def test_a_changed_argument_is_a_miss(self): + step_cache.clear() + workflow, _ = build_test_workflow_and_call_count_spy() + try: + workflow.run({"prompt": "a cat"}) + assert workflow.cache_hits({"prompt": "a dog"}) == [] + finally: + for p in workflow._test_patcher: + p.stop() + + def test_an_unseeded_workflow_has_no_hits(self): + step_cache.clear() + workflow, _ = build_test_workflow_and_call_count_spy() + del workflow.workflow_definition["seed"] + try: + workflow.run({}) + assert workflow.cache_hits({}) == [] + finally: + for p in workflow._test_patcher: + p.stop() + + def test_the_probe_writes_nothing(self, tmp_path): + step_cache.clear() + workflow, _ = build_test_workflow_and_call_count_spy() + workflow.output_dir = str(tmp_path) + try: + workflow.cache_hits({}) + assert list(tmp_path.iterdir()) == [] + finally: + for p in workflow._test_patcher: + p.stop() From 6dd95beed4615fcd1f2cefa81759c144b32120cc Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:42:41 -0500 Subject: [PATCH 13/34] feat(server): #85 - the worker answers a cache probe, and the manager asks without blocking behind a job Co-Authored-By: Claude Opus 5 (1M context) --- dw/server/jobs.py | 30 ++++++++++++++++ dw/worker.py | 28 +++++++++++++++ tests/test_server.py | 44 +++++++++++++++++++++++ tests/test_worker_execute.py | 67 ++++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+) diff --git a/dw/server/jobs.py b/dw/server/jobs.py index c659c5dd..3a96c935 100644 --- a/dw/server/jobs.py +++ b/dw/server/jobs.py @@ -1151,6 +1151,36 @@ def _cached_memory(self, reason): "age_seconds": age, } + def probe_cache(self, command, timeout=5): + """Which steps the worker's step cache would serve for `command` (the + fields an execute command carries, minus its type), or None when the + answer cannot be had right now - a job is running, the worker is + busy, or it did not answer in time. Never blocks a request behind a + running job, for the same reason memory_status does not. + + No worker running is a definite answer, not an unknown one: the + cache lives in the worker process, so a worker that is not running + holds nothing. + """ + if self._current_job_id is not None: + return None + if not self.worker_manager.worker_active: + return [] + if not self._worker_lock.acquire(timeout=2): + return None + try: + self.worker_manager.send_command({"type": "probe_cache", **command}) + result = self.worker_manager.get_result(timeout=timeout) + except (RuntimeError, queue.Empty) as e: + logger.debug(f"Worker did not answer the cache probe: {e}") + return None + finally: + self._worker_lock.release() + if result.get("type") != "probe_cache": + return None + cached = result.get("cached") + return list(cached) if isinstance(cached, list) else None + def memory_status(self, timeout=5): """Live memory stats when the worker is idle; the run's last report while it is busy. The lock acquire is bounded: the runner holds diff --git a/dw/worker.py b/dw/worker.py index 35cd25a7..2791427a 100644 --- a/dw/worker.py +++ b/dw/worker.py @@ -141,6 +141,8 @@ def run(self): self._handle_clear_memory() elif command_type == "memory_status": self._handle_memory_status() + elif command_type == "probe_cache": + self._handle_probe_cache(command) else: self.result_queue.put( { @@ -387,6 +389,32 @@ def _handle_memory_status(self): memory_info = self._get_memory_info() self.result_queue.put({"type": "memory_status", "info": memory_info}) + def _handle_probe_cache(self, command: Dict[str, Any]): + """Which steps the step cache would serve for a run of this command + - the plan's cached_steps (#85). Same fields as an execute command; + loads the workflow, executes nothing. A failure answers + cached: null with the reason rather than an error message, since + an unknown answer is a valid plan and a crashed probe is not. + """ + try: + workflow, _ = self._load_workflow(command, command["output_dir"]) + asset_token = ( + activate_asset_dir(command["asset_dir"]) + if command.get("asset_dir") + else None + ) + try: + cached = workflow.cache_hits(command.get("arguments") or {}) + finally: + if asset_token is not None: + deactivate_asset_dir(asset_token) + self.result_queue.put({"type": "probe_cache", "cached": cached}) + except Exception as e: + logger.debug(f"Cache probe failed: {e}") + self.result_queue.put( + {"type": "probe_cache", "cached": None, "error": str(e)} + ) + def _evict_untouched_pipelines(self, context): """Drop cached pipelines this run no longer touched. diff --git a/tests/test_server.py b/tests/test_server.py index 24128287..c6d8b2a3 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -68,6 +68,8 @@ def __init__(self, script=None): self.worker_active = False self.worker_process = None self._results = queue.Queue() + # What a probe_cache command answers with + self.cached_steps = [] def ensure_worker(self, log_level="INFO"): self.worker_active = True @@ -83,6 +85,10 @@ def send_command(self, command): self._results.put( {"type": "memory_status", "info": {"gpu_available": True}} ) + elif command["type"] == "probe_cache": + self._results.put( + {"type": "probe_cache", "cached": list(self.cached_steps)} + ) def get_result(self, timeout=None): return self._results.get(timeout=timeout if timeout is not None else 10) @@ -3681,3 +3687,41 @@ def test_the_plan_sees_the_callers_arguments(self, server, monkeypatch): body["arguments"] = {"prompt": "something else"} two = client.post("/api/validate?sizes=false", json=body).json()["plan"] assert one["fingerprint"] != two["fingerprint"] + + +PROBE = {"workflow_path": "x.json", "arguments": {}, "output_dir": "/tmp"} + + +class TestProbeCache: + def test_asks_the_worker_and_returns_its_answer(self, server): + with server(success_script) as client: + manager = client.app.state.job_manager + manager.worker_manager.ensure_worker() + manager.worker_manager.cached_steps = ["gen"] + assert manager.probe_cache(PROBE) == ["gen"] + sent = manager.worker_manager.commands[-1] + assert sent["type"] == "probe_cache" + assert sent["workflow_path"] == "x.json" + + def test_no_worker_means_an_empty_cache(self, server): + with server(success_script) as client: + manager = client.app.state.job_manager + assert manager.worker_manager.worker_active is False + assert manager.probe_cache(PROBE) == [] + assert manager.worker_manager.commands == [] + + def test_a_running_job_means_unknown(self, server): + with server(hanging_script) as client: + response = client.post("/api/jobs", json={"workflow": valid_workflow()}) + job_id = response.json()["id"] + wait_for_status(client, job_id, ("running",)) + manager = client.app.state.job_manager + assert manager.probe_cache(PROBE) is None + client.post(f"/api/jobs/{job_id}/cancel") + + def test_an_unanswered_probe_is_unknown(self, server): + with server(success_script) as client: + manager = client.app.state.job_manager + manager.worker_manager.ensure_worker() + manager.worker_manager.send_command = lambda command: None + assert manager.probe_cache(PROBE, timeout=0.05) is None diff --git a/tests/test_worker_execute.py b/tests/test_worker_execute.py index 1ff46154..3c3c638c 100644 --- a/tests/test_worker_execute.py +++ b/tests/test_worker_execute.py @@ -264,3 +264,70 @@ def record(): _execute(worker, FailingWorkflow()) assert alive_at_cleanup == [False] + + +class ProbableWorkflow(StubWorkflow): + def __init__(self, hits): + super().__init__() + self.hits = hits + self.probed_with = None + + def cache_hits(self, arguments): + self.probed_with = arguments + return list(self.hits) + + +def test_probe_cache_answers_with_the_workflows_hits(): + worker = _make_worker() + workflow = ProbableWorkflow(["gen"]) + command = { + "type": "probe_cache", + "workflow_path": "x.json", + "arguments": {"prompt": "p"}, + "output_dir": "/tmp", + } + with patch("dw.worker.workflow_from_file", return_value=workflow): + worker._handle_probe_cache(command) + assert _drain(worker.result_queue) == [{"type": "probe_cache", "cached": ["gen"]}] + assert workflow.probed_with == {"prompt": "p"} + + +def test_probe_cache_reports_a_failure_as_unknown_not_as_a_crash(): + worker = _make_worker() + with patch("dw.worker.workflow_from_file", side_effect=ValueError("bad file")): + worker._handle_probe_cache( + { + "type": "probe_cache", + "workflow_path": "x.json", + "arguments": {}, + "output_dir": "/tmp", + } + ) + [answer] = _drain(worker.result_queue) + assert answer["type"] == "probe_cache" + assert answer["cached"] is None + assert "bad file" in answer["error"] + + +def test_probe_cache_activates_the_jobs_asset_dir(tmp_path): + worker = _make_worker() + seen = {} + + class AssetAwareWorkflow(ProbableWorkflow): + def cache_hits(self, arguments): + from dw.assets import get_asset_dir + + seen["asset_dir"] = get_asset_dir() + return [] + + with patch("dw.worker.workflow_from_file", return_value=AssetAwareWorkflow([])): + worker._handle_probe_cache( + { + "type": "probe_cache", + "workflow_path": "x.json", + "arguments": {}, + "output_dir": "/tmp", + "asset_dir": str(tmp_path), + } + ) + assert seen["asset_dir"] == str(tmp_path) From 641f4da092188a8e61cfca8174ed402c697ee628 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:44:01 -0500 Subject: [PATCH 14/34] feat(server): #85 - the plan says how many steps the worker's cache would serve Co-Authored-By: Claude Opus 5 (1M context) --- dw/plan.py | 32 +++++++++++++++++++++++++++++--- dw/server/app.py | 36 ++++++++++++++++++++++++++++-------- tests/test_plan.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_server.py | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 11 deletions(-) diff --git a/dw/plan.py b/dw/plan.py index fea8381d..7ba64f10 100644 --- a/dw/plan.py +++ b/dw/plan.py @@ -62,8 +62,9 @@ def build_plan( cache_dir: The hub cache to check downloads against; None for the default. lookup_sizes: Whether to ask the hub how large a missing repo is. - cache_probe: Stage 2's step-cache probe; unused, `cached_steps` is - always None until then. + cache_probe: A callable taking the run's arguments and answering the + step names the worker's cache would serve, or None when it + cannot say; without one `cached_steps` is None. """ definition = candidate.workflow_definition base_dir = ( @@ -91,7 +92,7 @@ def build_plan( "fingerprint": fingerprint(expanded, definition), "steps": len(expanded.get("steps") or []), "list_entries": entries, - "cached_steps": None, + "cached_steps": cached_steps(definition, realized, arguments, cache_probe), "downloads_required": downloads_required( expanded, base_dir, candidate.workflow_dir, cache_dir, lookup_sizes ), @@ -119,6 +120,31 @@ def list_entries(definition, realized): return entries +def cached_steps(definition, realized, arguments, cache_probe): + """How many steps the step cache would answer for this run: 0 without + asking when the workflow is unseeded (the cache is off then), None + when there is no probe or the probe cannot answer, else the count.""" + if not _is_seeded(definition, arguments): + return 0 + if cache_probe is None: + return None + answer = cache_probe(arguments or {}) + return len(answer) if isinstance(answer, list) else None + + +def _is_seeded(definition, arguments): + """Whether a run of this workflow has a seed before it draws one - read + from the definition as written and the caller's arguments, since + realization pins a seed of its own into the copy.""" + seed = definition.get("seed") + if isinstance(seed, str) and seed.startswith(VARIABLE_PREFIX): + name = seed.removeprefix(VARIABLE_PREFIX) + if name in (arguments or {}): + return arguments[name] is not None + return (definition.get("variables") or {}).get(name) is not None + return seed is not None + + def fingerprint(expanded, definition): """SHA-256 over the expanded definition with everything that is not work removed: the seed wherever it sits, and the documentation keys. diff --git a/dw/server/app.py b/dw/server/app.py index d075e677..94b878e7 100644 --- a/dw/server/app.py +++ b/dw/server/app.py @@ -1254,6 +1254,24 @@ def _string_leaves(value, path): errors.append({"path": path, "message": str(e)}) return errors + def _probe_command_for(candidate, request, workspace, workflow_dir): + """The execute-shaped command a cache probe of this validate request + needs - the same fields _run_job sends, so the worker loads the + workflow exactly as a job would.""" + command = { + "arguments": request.arguments, + "output_dir": workspace.outputs, + "workflow_dir": workflow_dir, + } + if workspace.assets: + command["asset_dir"] = workspace.assets + if request.workflow_path is not None: + command["workflow_path"] = candidate.file_spec + else: + command["workflow"] = request.workflow + command["base_dir"] = os.path.dirname(candidate.file_spec) + return command + @app.post("/api/validate") def validate_workflow( request: JobRequest, @@ -1287,21 +1305,19 @@ def validate_workflow( resolved, source = resolve_workflow_reference( request.workflow_path, sources ) - candidate = workflow_from_file( - resolved, - workspace.outputs, - # Confined to the source it came from, not to the - # writable root - an example is read where it lives - source.root if source else workspace.workflows, - ) + # Confined to the source it came from, not to the writable + # root - an example is read where it lives + source_root = source.root if source else workspace.workflows + candidate = workflow_from_file(resolved, workspace.outputs, source_root) definition = candidate.workflow_definition else: definition = request.workflow + source_root = workspace.workflows candidate = workflow_from_definition( copy.deepcopy(request.workflow), workspace.outputs, request.base_dir, - workspace.workflows, + source_root, ) except HTTPException: raise @@ -1379,12 +1395,16 @@ def validate_workflow( try: from .. import get_device, get_device_type + command = _probe_command_for(candidate, request, workspace, source_root) answer["plan"] = build_plan( candidate, request.arguments, device=get_device_type(get_device()), prompt_dir=workspace.prompts, lookup_sizes=sizes, + cache_probe=lambda arguments: manager.probe_cache( + {**command, "arguments": arguments} + ), ) except Exception: logger.exception("Plan could not be built") diff --git a/tests/test_plan.py b/tests/test_plan.py index 0fbac070..53251a10 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -459,3 +459,39 @@ def boom(*a, **k): monkeypatch.setattr(dw.plan, "model_info", boom) plan(lookup_sizes=False) + + +class TestCachedSteps: + def test_no_probe_is_unknown(self, plan): + assert plan()["cached_steps"] is None + + def test_the_probe_is_asked_with_the_arguments_and_counted(self, plan): + seen = [] + + def probe(arguments): + seen.append(arguments) + return ["still", "shot@a"] + + answer = plan(arguments={"frames": 9}, cache_probe=probe) + assert answer["cached_steps"] == 2 + assert seen == [{"frames": 9}] + + def test_a_probe_that_cannot_answer_is_unknown(self, plan): + assert plan(cache_probe=lambda arguments: None)["cached_steps"] is None + + def test_an_unseeded_workflow_is_zero_without_asking(self, plan): + def probe(arguments): + raise AssertionError("must not be asked") + + spec = definition() + del spec["seed"] + del spec["variables"]["seed"] + assert plan(spec, cache_probe=probe)["cached_steps"] == 0 + + def test_a_seed_variable_left_null_is_unseeded(self, plan): + def probe(arguments): + raise AssertionError("must not be asked") + + spec = definition() + spec["variables"]["seed"] = None + assert plan(spec, cache_probe=probe)["cached_steps"] == 0 diff --git a/tests/test_server.py b/tests/test_server.py index c6d8b2a3..64d86f0c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -3677,6 +3677,42 @@ def spy(candidate, arguments, **kwargs): ) assert seen == [True, False] + def test_cached_steps_comes_from_the_worker(self, server, monkeypatch): + import dw.plan + + monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + with server(success_script) as client: + manager = client.app.state.job_manager + manager.worker_manager.ensure_worker() + manager.worker_manager.cached_steps = ["gen"] + seeded = valid_workflow("seeded") + seeded["seed"] = 7 + result = client.post( + "/api/validate?sizes=false", + json={"workflow": seeded, "arguments": {"prompt": "x"}}, + ).json() + assert result["plan"]["cached_steps"] == 1 + probe = [ + c for c in manager.worker_manager.commands if c["type"] == "probe_cache" + ] + assert len(probe) == 1 + assert probe[0]["arguments"] == {"prompt": "x"} + assert probe[0]["workflow"] == seeded + assert probe[0]["output_dir"] == manager.output_dir + + def test_an_unseeded_workflow_does_not_probe(self, server, monkeypatch): + import dw.plan + + monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + with server(success_script) as client: + manager = client.app.state.job_manager + manager.worker_manager.ensure_worker() + result = client.post( + "/api/validate?sizes=false", json={"workflow": valid_workflow("v")} + ).json() + assert result["plan"]["cached_steps"] == 0 + assert all(c["type"] != "probe_cache" for c in manager.worker_manager.commands) + def test_the_plan_sees_the_callers_arguments(self, server, monkeypatch): import dw.plan From d8a76b8f1d345cec1db566c54ca4eaaa9a868e26 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:45:35 -0500 Subject: [PATCH 15/34] feat(server): #85 - a job records which form of cost acknowledgement queued it Co-Authored-By: Claude Opus 5 (1M context) --- dw/server/jobs.py | 95 ++++++++++++++++++++++++++++++-------- tests/test_jobs_listing.py | 1 + tests/test_server.py | 82 ++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 20 deletions(-) diff --git a/dw/server/jobs.py b/dw/server/jobs.py index 3a96c935..08d32b35 100644 --- a/dw/server/jobs.py +++ b/dw/server/jobs.py @@ -42,6 +42,13 @@ CANCELLED = "cancelled" TERMINAL_STATES = (SUCCEEDED, FAILED, CANCELLED) +# Which form of cost acknowledgement a job was queued with (#85): none (the +# web UI and every HTTP caller that sends nothing), a bare boolean, or one +# bound to the plan that was validated +ACK_NONE = "none" +ACK_BOOLEAN = "boolean" +ACK_BOUND = "bound" + # The spec fields a rerun needs - shared by persistence and live rerun RERUN_SPEC_KEYS = ( "workflow_path", @@ -55,6 +62,9 @@ # so a rerun is attributed to the same catalog entry "catalog_name", "workflow_dir", + # what the original run was consented to, kept for the record - a + # rerun's own request decides its form + "acknowledged_cost", ) # Finished jobs kept in memory for SSE replay grace; older ones live in @@ -123,6 +133,12 @@ def __init__(self, db_path): connection.execute("ALTER TABLE jobs ADD COLUMN run_id TEXT") if "run_dir" not in columns: connection.execute("ALTER TABLE jobs ADD COLUMN run_dir TEXT") + # Which form of cost acknowledgement queued the job. Rows before + # the column are 'none' - nothing recorded is nothing recorded + if "acknowledged" not in columns: + connection.execute( + "ALTER TABLE jobs ADD COLUMN acknowledged TEXT DEFAULT 'none'" + ) def _connect(self): return sqlite3.connect(self.db_path, timeout=5) @@ -134,8 +150,9 @@ def record(self, job): connection.execute( "INSERT OR REPLACE INTO jobs (id, workflow, status, created_at," " started_at, finished_at, arguments, spec, manifest, warnings," - " error, events, workspace, workflow_name, run_id, run_dir) VALUES" - " (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + " error, events, workspace, workflow_name, run_id, run_dir," + " acknowledged) VALUES" + " (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( job.id, job.workflow_name, @@ -153,6 +170,7 @@ def record(self, job): job.catalog_name, job.run_id, job.run_dir, + job.acknowledged, ), ) @@ -168,7 +186,7 @@ def recent_summaries(self, limit=200, workspace=None, statuses=None): """ query = ( "SELECT id, workflow, status, created_at, started_at, finished_at," - " workspace, workflow_name, run_id FROM jobs" + " workspace, workflow_name, run_id, acknowledged FROM jobs" ) params = [] clauses = [] @@ -197,6 +215,7 @@ def recent_summaries(self, limit=200, workspace=None, statuses=None): "workspace": row[6] or DEFAULT_WORKSPACE_NAME, "workflow_name": row[7], "run_id": row[8], + "acknowledged": row[9] or ACK_NONE, "historical": True, } for row in rows @@ -207,7 +226,7 @@ def get(self, job_id): row = connection.execute( "SELECT id, workflow, status, created_at, started_at, finished_at," " arguments, spec, manifest, warnings, error, workspace," - " workflow_name, run_id, run_dir FROM jobs WHERE id = ?", + " workflow_name, run_id, run_dir, acknowledged FROM jobs WHERE id = ?", (job_id,), ).fetchone() return self._to_detail(row) if row else None @@ -307,6 +326,7 @@ def parse(text, fallback): except (TypeError, ValueError): return fallback + spec = parse(row[7], {}) return { "id": row[0], "workflow": row[1], @@ -315,7 +335,7 @@ def parse(text, fallback): "started_at": row[4], "finished_at": row[5], "arguments": parse(row[6], {}), - "spec": parse(row[7], {}), + "spec": spec, "manifest": parse(row[8], []), "warnings": parse(row[9], []), "error": row[10], @@ -323,6 +343,8 @@ def parse(text, fallback): "workflow_name": row[12], "run_id": row[13], "run_dir": row[14], + "acknowledged": row[15] or ACK_NONE, + "acknowledged_cost": (spec or {}).get("acknowledged_cost"), "traceback": None, "event_count": 0, "historical": True, @@ -352,6 +374,8 @@ def __init__(self, spec): # never got that far self.run_id = None self.run_dir = None + # Which form of cost acknowledgement queued this job (#85) + self.acknowledged = spec.get("acknowledged") or ACK_NONE self.events = [] # The running summary a poll reads - see _note_progress. Kept as the # events arrive rather than derived from the log on request, because @@ -500,6 +524,7 @@ def summary(self): # so it defaults the same way history's column does "workspace": self.spec.get("workspace") or DEFAULT_WORKSPACE_NAME, "run_id": self.run_id, + "acknowledged": self.acknowledged, } def detail(self): @@ -512,6 +537,7 @@ def detail(self): "traceback": self.traceback, "event_count": len(self.events), "run_dir": self.run_dir, + "acknowledged_cost": self.spec.get("acknowledged_cost"), "progress": self.progress(), } @@ -566,6 +592,8 @@ def submit( asset_dir=None, workspace=None, catalog_name=None, + acknowledged=ACK_NONE, + acknowledged_cost=None, ): """Validate a job request and queue it. Raises ValueError on a bad request so the HTTP layer can answer 400 before anything runs. @@ -583,6 +611,10 @@ def submit( `catalog_name` is the listing name the caller resolved `workflow_path` from, kept for history; None for an inline definition. + + `acknowledged` is the form of cost acknowledgement the caller gave + (none/boolean/bound) and `acknowledged_cost` the bound object - both + recorded, neither checked here; the route checks (#85). """ arguments = arguments or {} if (workflow_path is None) == (workflow is None): @@ -633,6 +665,11 @@ def submit( # rerun all agree without re-deriving them spec["workspace"] = workspace spec["catalog_name"] = catalog_name + # The acknowledgement form travels with the job so history can say + # whether this run was consented to at its actual size (#85) + spec["acknowledged"] = acknowledged + if acknowledged_cost is not None: + spec["acknowledged_cost"] = acknowledged_cost spec["output_dir"] = job_output_dir if asset_dir: spec["asset_dir"] = asset_dir @@ -758,7 +795,25 @@ def seed_variable(self, job_id): name = seed.removeprefix(VARIABLE_PREFIX) return name if name in (definition.get("variables") or {}) else None - def rerun(self, job_id, new_seed=False): + def rerun_spec(self, job_id): + """The spec and arguments a rerun of `job_id` would submit, as + (spec, arguments), or None for an unknown job - split from rerun() + so a route can plan the run before queuing it (#85).""" + job = self.jobs.get(job_id) + if job is not None: + spec = {key: job.spec[key] for key in RERUN_SPEC_KEYS if key in job.spec} + return spec, job.spec.get("arguments", {}) + historical = self.history.get(job_id) + if historical is None: + return None + spec = { + key: historical["spec"][key] + for key in RERUN_SPEC_KEYS + if key in historical["spec"] + } + return spec, historical["arguments"] + + def rerun(self, job_id, new_seed=False, acknowledged=ACK_NONE, acknowledged_cost=None): """Queue a fresh job from a previous job's spec. Every root the original ran against (workflow_dir/output_dir/ @@ -773,21 +828,15 @@ def rerun(self, job_id, new_seed=False): cache doing its job - the same seed and the same inputs would produce the same pixels - so the way to actually get another image is to change the seed, and this is that. + + `acknowledged` and `acknowledged_cost` are this request's own; the + original's bound object rides along in the spec for the record when + the request brought none. """ - job = self.jobs.get(job_id) - if job is not None: - spec = {key: job.spec[key] for key in RERUN_SPEC_KEYS if key in job.spec} - arguments = job.spec.get("arguments", {}) - else: - historical = self.history.get(job_id) - if historical is None: - return None - spec = { - key: historical["spec"][key] - for key in RERUN_SPEC_KEYS - if key in historical["spec"] - } - arguments = historical["arguments"] + prepared = self.rerun_spec(job_id) + if prepared is None: + return None + spec, arguments = prepared if new_seed: variable = self.seed_variable(job_id) @@ -820,6 +869,12 @@ def rerun(self, job_id, new_seed=False): asset_dir=spec.get("asset_dir"), workspace=workspace, catalog_name=spec.get("catalog_name"), + acknowledged=acknowledged, + acknowledged_cost=( + acknowledged_cost + if acknowledged_cost is not None + else spec.get("acknowledged_cost") + ), ) def queue_position(self, job_id): diff --git a/tests/test_jobs_listing.py b/tests/test_jobs_listing.py index 61708df4..4c888d8d 100644 --- a/tests/test_jobs_listing.py +++ b/tests/test_jobs_listing.py @@ -39,6 +39,7 @@ class Row: events = [] run_id = None run_dir = None + acknowledged = "none" row = Row() row.status = status diff --git a/tests/test_server.py b/tests/test_server.py index 64d86f0c..413e3302 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2985,6 +2985,7 @@ class FinishedJob: error = None run_id = None run_dir = None + acknowledged = "none" spec = {"arguments": {}, "workflow_path": "w.json"} job = FinishedJob() @@ -3761,3 +3762,84 @@ def test_an_unanswered_probe_is_unknown(self, server): manager.worker_manager.ensure_worker() manager.worker_manager.send_command = lambda command: None assert manager.probe_cache(PROBE, timeout=0.05) is None + + +class TestAcknowledgementRecord: + """Every job says which form of cost acknowledgement queued it (#85), so + 'was this run consented to at its actual size' is answerable later.""" + + def test_a_submit_records_none_by_default(self, server): + with server(success_script) as client: + manager = client.app.state.job_manager + job = manager.submit(workflow=valid_workflow(), arguments={}) + assert job.acknowledged == "none" + assert manager.describe(job)["acknowledged"] == "none" + assert manager.describe(job)["acknowledged_cost"] is None + + def test_a_bound_submit_records_the_object(self, server): + with server(success_script) as client: + manager = client.app.state.job_manager + bound = {"fingerprint": "sha256:abc", "minutes": 3.0, "downloads": []} + job = manager.submit( + workflow=valid_workflow(), + arguments={}, + acknowledged="bound", + acknowledged_cost=bound, + ) + detail = manager.describe(job) + assert detail["acknowledged"] == "bound" + assert detail["acknowledged_cost"] == bound + assert job.summary()["acknowledged"] == "bound" + + def test_history_keeps_the_form_and_the_object(self, server): + with server(success_script) as client: + manager = client.app.state.job_manager + bound = {"fingerprint": "sha256:abc", "minutes": 3.0, "downloads": ["org/x"]} + job = manager.submit( + workflow=valid_workflow(), + arguments={}, + acknowledged="bound", + acknowledged_cost=bound, + ) + wait_for_status(client, job.id, TERMINAL_STATES) + row = manager.history.get(job.id) + assert row["acknowledged"] == "bound" + assert row["acknowledged_cost"] == bound + assert row["spec"]["acknowledged_cost"] == bound + listed = [s for s in manager.history.recent_summaries() if s["id"] == job.id] + assert listed[0]["acknowledged"] == "bound" + + def test_a_database_without_the_column_is_migrated(self, tmp_path): + import sqlite3 + + from dw.server.jobs import JobHistory + + path = tmp_path / "old.sqlite" + with sqlite3.connect(path) as connection: + connection.execute( + "CREATE TABLE jobs (id TEXT PRIMARY KEY, workflow TEXT, status TEXT," + " created_at REAL, started_at REAL, finished_at REAL, arguments TEXT," + " spec TEXT, manifest TEXT, warnings TEXT, error TEXT)" + ) + connection.execute( + "INSERT INTO jobs (id, workflow, status, created_at, spec) VALUES" + " ('old1', 'w', 'succeeded', 1.0, '{}')" + ) + history = JobHistory(str(path)) + assert history.get("old1")["acknowledged"] == "none" + assert history.get("old1")["acknowledged_cost"] is None + + def test_a_rerun_carries_the_original_object_for_the_record(self, server): + with server(success_script) as client: + manager = client.app.state.job_manager + bound = {"fingerprint": "sha256:abc", "minutes": 3.0, "downloads": []} + job = manager.submit( + workflow=valid_workflow(), + arguments={}, + acknowledged="bound", + acknowledged_cost=bound, + ) + wait_for_status(client, job.id, TERMINAL_STATES) + rerun = manager.rerun(job.id) + assert rerun.acknowledged == "none" + assert rerun.spec["acknowledged_cost"] == bound From 58354e41fa41eee04a5683fab70c8f74fe8c5d9b Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:47:01 -0500 Subject: [PATCH 16/34] feat(server): #85 - a bound acknowledgement is checked against the run's plan, and a changed plan is a 409 Co-Authored-By: Claude Opus 5 (1M context) --- dw/server/app.py | 165 +++++++++++++++++++++++++++++- tests/test_server.py | 234 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 396 insertions(+), 3 deletions(-) diff --git a/dw/server/app.py b/dw/server/app.py index 94b878e7..220431b7 100644 --- a/dw/server/app.py +++ b/dw/server/app.py @@ -19,7 +19,7 @@ from contextlib import asynccontextmanager from datetime import datetime from urllib.parse import quote, urlparse -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional, Union from fastapi import Depends, FastAPI, HTTPException, Query, Request from fastapi.concurrency import run_in_threadpool @@ -96,6 +96,9 @@ writable_source, ) from .jobs import ( + ACK_BOOLEAN, + ACK_BOUND, + ACK_NONE, JobManager, MAX_PERSISTED_EVENTS, QUEUED, @@ -114,6 +117,29 @@ SSE_POLL_SECONDS = 1.0 +class AcknowledgedCost(BaseModel): + """A cost acknowledgement bound to the plan a validate call answered + with (#85): the server refuses to queue a run whose plan no longer + matches it. `minutes` is recorded, never compared.""" + + fingerprint: str = Field(description="plan.fingerprint from POST /api/validate") + minutes: Optional[float] = Field( + default=None, description="plan.estimate.minutes, recorded on the job" + ) + downloads: List[str] = Field( + default_factory=list, + description="The repos in plan.downloads_required that were acknowledged", + ) + + +ACKNOWLEDGED_COST_FIELD = Field( + default=None, + description="Cost acknowledgement: true (recorded), or an object " + "{fingerprint, minutes, downloads} bound to the plan validate answered " + "with - then the run is refused with 409 if its plan changed", +) + + class JobRequest(BaseModel): workflow_path: Optional[str] = Field( default=None, description="Path to a workflow JSON file on the server" @@ -132,6 +158,7 @@ class JobRequest(BaseModel): default=None, description="Which workspace to run or resolve in; the default when omitted", ) + acknowledged_cost: Optional[Union[bool, AcknowledgedCost]] = ACKNOWLEDGED_COST_FIELD # What each workflow produces and takes, for listing cards - cached by mtime @@ -807,6 +834,80 @@ def _sources_for(ws): # ------------------------------------------------------------------ jobs + def _acknowledgement_form(value): + """none | boolean | bound - classified once, here, so the check and + the record agree (#85).""" + if isinstance(value, AcknowledgedCost): + return ACK_BOUND + return ACK_BOOLEAN if value is True else ACK_NONE + + def _check_bound_acknowledgement(candidate, arguments, acknowledged, workspace): + """Refuse with 409 when the run `candidate` + `arguments` will + execute is not the one `acknowledged` was bound to: a different + fingerprint, or a download the caller did not acknowledge. The body + carries the current plan so the agent re-quotes from it without a + second validate call. A plan that cannot be built is a refusal too - + never a silent pass (#85). + """ + record = acknowledged.model_dump() + + def refuse(message, reason, plan): + raise HTTPException( + status_code=409, + detail={ + "message": message, + "reason": reason, + "acknowledged": record, + "plan": plan, + }, + ) + + try: + from .. import get_device, get_device_type + + current = build_plan( + candidate, + arguments, + device=get_device_type(get_device()), + prompt_dir=workspace.prompts, + lookup_sizes=False, + ) + except Exception: + logger.exception("Plan could not be built for a bound acknowledgement") + refuse( + "The run could not be planned, so a bound acknowledgement " + "cannot be checked; acknowledge with true or validate again", + "unplannable", + None, + ) + if current["fingerprint"] != acknowledged.fingerprint: + refuse( + "The run's shape changed since it was acknowledged: the " + "workflow or its arguments differ from what was validated", + "fingerprint", + current, + ) + missing = [ + entry["repo"] + for entry in current["downloads_required"] + if entry.get("repo") and entry["repo"] not in acknowledged.downloads + ] + if missing: + refuse( + "The run's shape changed since it was acknowledged: it now " + f"has to download {', '.join(missing)} first", + "downloads", + current, + ) + + def _candidate_for(workflow_path, workflow, base_dir, output_dir, workflow_dir): + """The Workflow a job spec names, built as the worker will build it.""" + if workflow_path is not None: + return workflow_from_file(workflow_path, output_dir, workflow_dir) + return workflow_from_definition( + copy.deepcopy(workflow), output_dir, base_dir, workflow_dir + ) + @app.post("/api/jobs", status_code=201) def submit_job(request: JobRequest, ws: Workspace = Depends(selected_workspace)): """Queue a workflow. The workspace it runs in comes from the body or, @@ -835,6 +936,21 @@ def submit_job(request: JobRequest, ws: Workspace = Depends(selected_workspace)) for problem in reference_problems ) ) + # A bound acknowledgement is checked against the plan this + # request would run - before anything is queued, since a refusal + # is free here and costs a job id anywhere later (#85) + form = _acknowledgement_form(request.acknowledged_cost) + if form == ACK_BOUND: + candidate = _candidate_for( + resolved, + request.workflow, + request.base_dir, + workspace.outputs, + source.root if source else workspace.workflows, + ) + _check_bound_acknowledgement( + candidate, request.arguments, request.acknowledged_cost, workspace + ) job = manager.submit( workflow_path=resolved, workflow=request.workflow, @@ -856,6 +972,12 @@ def submit_job(request: JobRequest, ws: Workspace = Depends(selected_workspace)) # for, so 'Basic', 'Basic.json' and an absolute path # inside the source all record the one catalog name catalog_name=catalog_name_for(resolved, source), + acknowledged=form, + acknowledged_cost=( + request.acknowledged_cost.model_dump() + if form == ACK_BOUND + else None + ), ) except HTTPException: raise @@ -952,12 +1074,49 @@ class RerunRequest(BaseModel): "the step cache serves from the earlier run - the same seed and " "inputs would produce the same files.", ) + acknowledged_cost: Optional[Union[bool, AcknowledgedCost]] = ( + ACKNOWLEDGED_COST_FIELD + ) @app.post("/api/jobs/{job_id}/rerun", status_code=201) def rerun_job(job_id: str, body: RerunRequest = RerunRequest()): - """Queue a fresh job from a previous job's stored spec.""" + """Queue a fresh job from a previous job's stored spec. Takes + `acknowledged_cost` as POST /api/jobs does; a bound one is checked + against the stored spec's plan - the fresh seed of `new_seed` does + not change a fingerprint.""" + form = _acknowledgement_form(body.acknowledged_cost) + if form == ACK_BOUND: + prepared = manager.rerun_spec(job_id) + if prepared is None: + raise HTTPException(status_code=404, detail="Unknown job") + spec, arguments = prepared + try: + candidate = _candidate_for( + spec.get("workflow_path"), + spec.get("workflow"), + spec.get("base_dir"), + spec.get("output_dir") or manager.output_dir, + spec.get("workflow_dir"), + ) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + _check_bound_acknowledgement( + candidate, + arguments, + body.acknowledged_cost, + _workspace_for(spec.get("workspace")), + ) try: - job = manager.rerun(job_id, new_seed=body.new_seed) + job = manager.rerun( + job_id, + new_seed=body.new_seed, + acknowledged=form, + acknowledged_cost=( + body.acknowledged_cost.model_dump() if form == ACK_BOUND else None + ), + ) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=400, detail=str(e)) if job is None: diff --git a/tests/test_server.py b/tests/test_server.py index 413e3302..30a82956 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -3843,3 +3843,237 @@ def test_a_rerun_carries_the_original_object_for_the_record(self, server): rerun = manager.rerun(job.id) assert rerun.acknowledged == "none" assert rerun.spec["acknowledged_cost"] == bound + + +def plan_for(client, workflow, arguments=None): + body = {"workflow": workflow} + if arguments: + body["arguments"] = arguments + answer = client.post("/api/validate?sizes=false", json=body).json() + assert answer["valid"], answer + return answer["plan"] + + +def bound(plan): + return { + "fingerprint": plan["fingerprint"], + "minutes": plan["estimate"]["minutes"], + "downloads": [d["repo"] for d in plan["downloads_required"] if d["repo"]], + } + + +@pytest.fixture +def no_hub(monkeypatch): + import dw.plan + + monkeypatch.setattr(dw.plan, "scan_models", lambda cache_dir=None: {"repos": []}) + + +def list_workflow(job_id="listed"): + return { + "id": job_id, + "seed": "variable:seed", + "variables": {"seed": 1, "shots": [{"name": "a", "prompt": "a"}]}, + "steps": [ + { + "name": "shot", + "for_each": "variable:shots", + "pipeline": { + "configuration": {"component_type": "{Fake}", "no_generator": True}, + "from_pretrained_arguments": {"model_name": "m"}, + "arguments": {"prompt": "item:prompt"}, + }, + } + ], + } + + +class TestBoundAcknowledgement: + """A bound acknowledgement is checked against the run's current plan + before anything is queued (#85); true and absent are untouched.""" + + def test_a_matching_fingerprint_queues(self, server, no_hub): + with server(success_script) as client: + plan = plan_for(client, list_workflow()) + response = client.post( + "/api/jobs", + json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)}, + ) + assert response.status_code == 201, response.json() + assert response.json()["acknowledged"] == "bound" + assert response.json()["acknowledged_cost"] == bound(plan) + + def test_a_longer_list_than_acknowledged_is_refused(self, server, no_hub): + with server(success_script) as client: + plan = plan_for(client, list_workflow()) + longer = {"shots": [{"name": n, "prompt": n} for n in "abc"]} + response = client.post( + "/api/jobs", + json={ + "workflow": list_workflow(), + "arguments": longer, + "acknowledged_cost": bound(plan), + }, + ) + assert response.status_code == 409 + detail = response.json()["detail"] + assert detail["reason"] == "fingerprint" + assert detail["acknowledged"]["fingerprint"] == plan["fingerprint"] + assert detail["plan"]["list_entries"] == {"shots": 3} + assert detail["plan"]["fingerprint"] != plan["fingerprint"] + assert "differ" in detail["message"] + assert client.app.state.job_manager.worker_manager.commands == [] + + def test_a_new_seed_is_the_same_work(self, server, no_hub): + with server(success_script) as client: + plan = plan_for(client, list_workflow()) + response = client.post( + "/api/jobs", + json={ + "workflow": list_workflow(), + "arguments": {"seed": 99}, + "acknowledged_cost": bound(plan), + }, + ) + assert response.status_code == 201 + + def test_a_download_not_acknowledged_is_refused(self, server, no_hub): + with server(success_script) as client: + plan = plan_for(client, list_workflow()) + acknowledgement = bound(plan) + acknowledgement["downloads"] = [] # the caller left the repo out + response = client.post( + "/api/jobs", + json={"workflow": list_workflow(), "acknowledged_cost": acknowledgement}, + ) + assert response.status_code == 409 + detail = response.json()["detail"] + assert detail["reason"] == "downloads" + assert "m" in detail["message"] + + def test_a_download_that_vanished_is_not_a_refusal(self, server, no_hub, monkeypatch): + with server(success_script) as client: + plan = plan_for(client, list_workflow()) + assert bound(plan)["downloads"] == ["m"] + import dw.plan + + monkeypatch.setattr( + dw.plan, + "scan_models", + lambda cache_dir=None: {"repos": [{"repo_id": "m"}]}, + ) + response = client.post( + "/api/jobs", + json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)}, + ) + assert response.status_code == 201 + + def test_an_unplannable_run_is_refused_not_passed(self, server, no_hub, monkeypatch): + import dw.server.app as app_module + + with server(success_script) as client: + plan = plan_for(client, list_workflow()) + + def boom(*a, **k): + raise RuntimeError("no plan") + + monkeypatch.setattr(app_module, "build_plan", boom) + response = client.post( + "/api/jobs", + json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)}, + ) + assert response.status_code == 409 + assert response.json()["detail"]["reason"] == "unplannable" + assert response.json()["detail"]["plan"] is None + + def test_true_and_absent_queue_without_planning(self, server, no_hub, monkeypatch): + import dw.server.app as app_module + + def boom(*a, **k): + raise AssertionError("the boolean path must not plan") + + monkeypatch.setattr(app_module, "build_plan", boom) + with server(success_script) as client: + plain = client.post("/api/jobs", json={"workflow": valid_workflow("p")}) + flagged = client.post( + "/api/jobs", + json={"workflow": valid_workflow("f"), "acknowledged_cost": True}, + ) + off = client.post( + "/api/jobs", + json={"workflow": valid_workflow("o"), "acknowledged_cost": False}, + ) + assert plain.json()["acknowledged"] == "none" + assert flagged.json()["acknowledged"] == "boolean" + assert off.json()["acknowledged"] == "none" + + def test_a_bound_form_without_a_fingerprint_is_a_422(self, server): + with server(success_script) as client: + response = client.post( + "/api/jobs", + json={"workflow": valid_workflow(), "acknowledged_cost": {"minutes": 3}}, + ) + assert response.status_code == 422 + + def test_a_stored_prompt_edited_after_validation_is_refused( + self, server, no_hub, tmp_path + ): + (tmp_path / "prompts" / "p.json").write_text(json.dumps({"text": "before"})) + workflow = valid_workflow("prompted") + workflow["variables"]["prompt"] = "prompt:p" + with server(success_script) as client: + plan = plan_for(client, workflow) + (tmp_path / "prompts" / "p.json").write_text(json.dumps({"text": "after"})) + response = client.post( + "/api/jobs", json={"workflow": workflow, "acknowledged_cost": bound(plan)} + ) + assert response.status_code == 409 + assert response.json()["detail"]["reason"] == "fingerprint" + + +class TestBoundRerun: + def test_a_rerun_with_the_original_plan_queues_even_with_a_new_seed( + self, server, no_hub + ): + with server(success_script) as client: + plan = plan_for(client, list_workflow()) + first = client.post( + "/api/jobs", + json={"workflow": list_workflow(), "acknowledged_cost": bound(plan)}, + ).json() + wait_for_status(client, first["id"], TERMINAL_STATES) + response = client.post( + f"/api/jobs/{first['id']}/rerun", + json={"new_seed": True, "acknowledged_cost": bound(plan)}, + ) + assert response.status_code == 201 + assert response.json()["acknowledged"] == "bound" + + def test_a_rerun_bound_to_a_stale_plan_is_refused(self, server, no_hub): + with server(success_script) as client: + first = client.post("/api/jobs", json={"workflow": list_workflow()}).json() + wait_for_status(client, first["id"], TERMINAL_STATES) + other = plan_for(client, valid_workflow("other")) + response = client.post( + f"/api/jobs/{first['id']}/rerun", json={"acknowledged_cost": bound(other)} + ) + assert response.status_code == 409 + assert response.json()["detail"]["reason"] == "fingerprint" + + def test_a_rerun_with_true_is_unchanged(self, server, no_hub): + with server(success_script) as client: + first = client.post("/api/jobs", json={"workflow": list_workflow()}).json() + wait_for_status(client, first["id"], TERMINAL_STATES) + response = client.post( + f"/api/jobs/{first['id']}/rerun", json={"acknowledged_cost": True} + ) + assert response.status_code == 201 + assert response.json()["acknowledged"] == "boolean" + + def test_an_unknown_job_is_still_404(self, server): + with server(success_script) as client: + response = client.post( + "/api/jobs/nope/rerun", + json={"acknowledged_cost": {"fingerprint": "sha256:0"}}, + ) + assert response.status_code == 404 From 095685d3e65d256eb9a9362f0f70d2b8c131cd17 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:48:19 -0500 Subject: [PATCH 17/34] feat(mcp): #85 - acknowledged_cost binds to the plan that was quoted, and a 409 re-quotes Co-Authored-By: Claude Opus 5 (1M context) --- dw_mcp/client.py | 16 +++++++ dw_mcp/diagnose.py | 40 +++++++++++++++-- dw_mcp/server.py | 19 ++++++-- tests/test_mcp_diagnose.py | 92 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 8 deletions(-) diff --git a/dw_mcp/client.py b/dw_mcp/client.py index 7fe20c1c..729b18ea 100644 --- a/dw_mcp/client.py +++ b/dw_mcp/client.py @@ -343,6 +343,22 @@ def _format_detail(self, detail): entries = detail.get("entries") if isinstance(entries, list) and entries: formatted += f" Also holds: {', '.join(str(e) for e in entries)}." + plan = detail.get("plan") + if isinstance(plan, dict): + # A 409 from the cost gate: say what the run costs now, so a + # client that only sees the message can re-quote from it + estimate = plan.get("estimate") or {} + formatted += ( + f" It now estimates {estimate.get('minutes')} minutes " + f"(basis {estimate.get('basis')})" + ) + downloads = [ + entry.get("repo") or entry.get("url") + for entry in plan.get("downloads_required") or [] + ] + if downloads: + formatted += f", and would download {', '.join(downloads)} first" + formatted += f"; new fingerprint {plan.get('fingerprint')}." return formatted if isinstance(detail, list): messages = [] diff --git a/dw_mcp/diagnose.py b/dw_mcp/diagnose.py index 11ce94c2..2b0176f3 100644 --- a/dw_mcp/diagnose.py +++ b/dw_mcp/diagnose.py @@ -28,10 +28,31 @@ "with (free): its `plan` says what will execute - `estimate.minutes` with " "its `basis`, and any weights in `downloads_required` this box has to " "fetch first. Tell the user that number, get their go-ahead, then call " - "again with acknowledged_cost=true." + 'again with acknowledged_cost bound to the plan: {"fingerprint": ' + 'plan.fingerprint, "minutes": plan.estimate.minutes, "downloads": ' + "[each downloads_required repo]} - the server then refuses (409) if the " + "run's shape changed since. acknowledged_cost=true is for a `plan` that " + "was null." ) +def _acknowledgement_body(acknowledged_cost): + """What a bound acknowledgement adds to a request body: the dict itself, + verbatim, so the server compares what the agent quoted. A dict without + a fingerprint is a mistake caught here, before anything is queued; a + bare true adds nothing - the boolean gate is this layer's, not the + server's.""" + if isinstance(acknowledged_cost, dict): + if not acknowledged_cost.get("fingerprint"): + raise DwApiError( + "A bound acknowledged_cost needs `fingerprint` - the " + "plan.fingerprint the validate answer carried. Validate again " + "and pass {fingerprint, minutes, downloads} from its plan." + ) + return {"acknowledged_cost": acknowledged_cost} + return {} + + def run_workflow( client, workflow_path=None, @@ -43,7 +64,12 @@ def run_workflow( """Queue a workflow. `workflow_path` is either a catalog name from `list_workflows` or a path to a workflow file on the server. Returns as soon as it is queued - it does not wait for the job to finish. Poll - `get_job_events` for progress.""" + `get_job_events` for progress. + + `acknowledged_cost` is true or, better, the plan it was quoted from: + {fingerprint, minutes, downloads} from `validate_workflow` - see + COST_REFUSAL. A bound one the server checks; a 409 means the run's + shape changed since the quote and the message carries the new plan.""" if not acknowledged_cost: raise DwApiError(COST_REFUSAL) if (workflow_path is None) == (inline_workflow is None): @@ -53,6 +79,7 @@ def run_workflow( "definition to run as-is)." ) payload = {"arguments": arguments or {}} + payload.update(_acknowledgement_body(acknowledged_cost)) if workflow_path is not None: payload["workflow_path"] = workflow_path else: @@ -256,11 +283,16 @@ def rerun_job(client, job_id, acknowledged_cost=False, new_seed=False): it the arguments repeat exactly, and a seeded workflow's rerun is served whole from the step cache - the earlier run's files, republished in a fraction of a second, with `reused: true`. Ask for a new seed when the - point is a different image rather than the same one again.""" + point is a different image rather than the same one again. + + `acknowledged_cost` takes the same bound form as `run_workflow`; a + fresh seed never changes a fingerprint, so the original plan still + binds a new-seed rerun.""" if not acknowledged_cost: raise DwApiError(COST_REFUSAL) return client.post_json( - api_path("api", "jobs", job_id, "rerun"), {"new_seed": new_seed} + api_path("api", "jobs", job_id, "rerun"), + {"new_seed": new_seed, **_acknowledgement_body(acknowledged_cost)}, ) diff --git a/dw_mcp/server.py b/dw_mcp/server.py index ba4da663..6681487d 100644 --- a/dw_mcp/server.py +++ b/dw_mcp/server.py @@ -795,7 +795,7 @@ def run_workflow( workflow_path: str | None = None, inline_workflow: dict | None = None, arguments: dict | None = None, - acknowledged_cost: bool = False, + acknowledged_cost: bool | dict = False, workspace: str | None = None, ) -> dict: """Queue a workflow for generation. THIS COSTS GPU TIME: a run @@ -811,7 +811,14 @@ def run_workflow( workflow serves many requests without being edited or copied. `workspace` names the workspace for this one call without switching the session to it - use it to pin a job whose `output:` or `asset:` - references live in a workspace other than the session's.""" + references live in a workspace other than the session's. + + Bind the acknowledgement to what you quoted: pass + {"fingerprint": plan.fingerprint, "minutes": plan.estimate.minutes, + "downloads": [...repos from plan.downloads_required]} from the + validate answer, and the server refuses with 409 - naming the new + plan - if the run's shape changed since; bare true is for a plan + that was null.""" return diagnose.run_workflow( client, workflow_path=workflow_path, @@ -905,7 +912,7 @@ def cancel_job(job_id: str) -> dict: return diagnose.cancel_job(client, job_id) def rerun_job( - job_id: str, acknowledged_cost: bool = False, new_seed: bool = False + job_id: str, acknowledged_cost: bool | dict = False, new_seed: bool = False ) -> dict: """Queue a fresh job from a previous job's stored specification. THIS COSTS GPU TIME: a rerun is a run - it occupies the machine for @@ -915,7 +922,11 @@ def rerun_job( Pass new_seed=true for a different image: a workflow that pins its seed reruns to the same pixels, and the step cache serves that whole run from the earlier one's files (marked `reused`) in a fraction of a - second rather than generating anything.""" + second rather than generating anything. + + `acknowledged_cost` takes the same bound form as run_workflow; a + fresh seed never changes the fingerprint, so the original plan still + binds a new_seed rerun.""" return diagnose.rerun_job( client, job_id, diff --git a/tests/test_mcp_diagnose.py b/tests/test_mcp_diagnose.py index b99a15a6..f34f2deb 100644 --- a/tests/test_mcp_diagnose.py +++ b/tests/test_mcp_diagnose.py @@ -511,3 +511,95 @@ def test_the_refusal_says_to_quote_the_plan(): assert "plan" in COST_REFUSAL assert "validate_workflow" in COST_REFUSAL + + +BOUND = {"fingerprint": "sha256:abc", "minutes": 4.0, "downloads": ["org/x"]} + + +def test_run_forwards_a_bound_acknowledgement_verbatim(): + import json + + client, seen = submitting() + diagnose.run_workflow(client, workflow_path="w.json", acknowledged_cost=BOUND) + assert json.loads(seen[0]["body"])["acknowledged_cost"] == BOUND + + +def test_run_does_not_send_a_bare_true(): + """The boolean path is the MCP layer's gate, not the server's - the body + stays what it was.""" + import json + + client, seen = submitting() + diagnose.run_workflow(client, workflow_path="w.json", acknowledged_cost=True) + assert "acknowledged_cost" not in json.loads(seen[0]["body"]) + + +def test_run_refuses_a_bound_form_without_a_fingerprint(): + client, seen = submitting() + with pytest.raises(DwApiError, match="fingerprint"): + diagnose.run_workflow( + client, workflow_path="w.json", acknowledged_cost={"minutes": 4} + ) + assert seen == [] + + +def test_run_refuses_an_empty_dict_as_unacknowledged(): + client, seen = submitting() + with pytest.raises(DwApiError, match="acknowledged_cost"): + diagnose.run_workflow(client, workflow_path="w.json", acknowledged_cost={}) + assert seen == [] + + +def test_a_409_surfaces_with_the_new_estimate(): + client, _seen = scripted( + { + ("POST", "/api/jobs"): ( + 409, + { + "detail": { + "message": "The run's shape changed since it was " + "acknowledged: the workflow or its arguments differ " + "from what was validated", + "reason": "fingerprint", + "acknowledged": BOUND, + "plan": { + "fingerprint": "sha256:def", + "steps": 6, + "list_entries": {"shots": 5}, + "cached_steps": None, + "downloads_required": [{"repo": "org/y", "gb": 3.5}], + "estimate": { + "minutes": 19.0, + "basis": "per_entry", + "device": "cuda", + "measured_on": "card", + "partial": False, + }, + }, + } + }, + ) + } + ) + with pytest.raises(DwApiError) as caught: + diagnose.run_workflow(client, workflow_path="w.json", acknowledged_cost=BOUND) + message = str(caught.value) + assert "shape changed" in message + assert "19.0" in message and "per_entry" in message + assert "org/y" in message + assert "sha256:def" in message + + +def test_rerun_forwards_a_bound_acknowledgement(): + import json + + client, seen = scripted({("POST", "/api/jobs/job-1/rerun"): (201, SUBMITTED)}) + diagnose.rerun_job(client, "job-1", acknowledged_cost=BOUND, new_seed=True) + body = json.loads(seen[0]["body"]) + assert body["acknowledged_cost"] == BOUND and body["new_seed"] is True + + +def test_the_refusal_teaches_the_bound_form(): + from dw_mcp.diagnose import COST_REFUSAL + + assert "fingerprint" in COST_REFUSAL From 925653f9032f1fc66274f8a76827c3999fcaec5d Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 08:49:29 -0500 Subject: [PATCH 18/34] docs: #85 - the bound acknowledgement, the 409 and cached_steps Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 9 ++++++++- docs/MCP.md | 18 ++++++++++++++---- docs/SERVER.md | 6 ++++-- docs/WORKFLOW_GUIDE.md | 7 ++++++- docs/proposals/acknowledged-cost-binding.md | 4 ++-- plugins/dw/skills/ltx-2.5/SKILL.md | 3 ++- plugins/dw/skills/minimax-h3/SKILL.md | 12 ++++++------ plugins/dw/skills/minimax-music3/SKILL.md | 3 ++- 8 files changed, 44 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5b07667a..e5c02639 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -285,7 +285,14 @@ same reason - default setup cannot load a pack. block exists. A valid `POST /api/validate` answer also carries `plan` (`dw/plan.py`): the fingerprint of the work, step and list counts, `downloads_required` and a cost `estimate` with its `basis` - the number an - agent quotes; `plan: null` when it could not be built, never a changed verdict + agent quotes; `plan: null` when it could not be built, never a changed + verdict. `acknowledged_cost` on `POST /api/jobs` / `rerun` takes `true` + (recorded) or the plan's `{fingerprint, minutes, downloads}` (checked - 409 + with the current plan when the fingerprint or the required downloads + changed; `minutes` never compared), and the job records `acknowledged: + none | boolean | bound`. `cached_steps` is the worker's answer to a + `probe_cache` command (`Workflow.cache_hits`, which shares + `_prepare_definition` / `_cache_lookup` with `run` so the two cannot drift) - **A failed run still reports what it wrote** — the worker carries its partial manifest on the error and cancelled messages as well as on success, and the "Previous result not found" error names the steps that ran even after diff --git a/docs/MCP.md b/docs/MCP.md index c9c249fc..22d63324 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -282,14 +282,14 @@ references written in the same session. | Tool | Arguments | Purpose | | --- | --- | --- | -| `run_workflow(workflow_path=None, inline_workflow=None, arguments=None, acknowledged_cost=False, workspace=None)` | exactly one of `workflow_path` (a catalog name from `list_workflows`, with or without `.json`, or a path to a workflow file on the server) or `inline_workflow`, optional `arguments`, `acknowledged_cost`, `workspace` | Queue a workflow for generation. Returns as soon as the job is queued. `workspace` names the workspace for this one call without switching the session to it - use it to pin a job whose `output:` or `asset:` references live in a workspace other than the session's | +| `run_workflow(workflow_path=None, inline_workflow=None, arguments=None, acknowledged_cost=False, workspace=None)` | exactly one of `workflow_path` (a catalog name from `list_workflows`, with or without `.json`, or a path to a workflow file on the server) or `inline_workflow`, optional `arguments`, `acknowledged_cost`, `workspace` | Queue a workflow for generation. Returns as soon as the job is queued. `workspace` names the workspace for this one call without switching the session to it - use it to pin a job whose `output:` or `asset:` references live in a workspace other than the session's - `acknowledged_cost` is `true` or the bound `{fingerprint, minutes, downloads}` from the validate plan; a 409 means the plan changed and the message carries the new estimate | | `get_job(job_id)` | `job_id` | Get a job's status, warnings, output manifest, error and traceback; each manifest entry's `subfolder` is the in-run subfolder the step declared - by convention `final` for the deliverable, `intermediate` for scratch, `''` for none. A running job also carries `progress` (below) | | `get_job_workflow(job_id)` | `job_id` | The workflow the job actually ran. `realized: true` means every mutable input is pinned (arguments, seed, prompts, `output:latest`); `false` means the job predates run tracking and this is the definition as submitted. Pass it to `save_workflow` to keep it under a name | | `export_job(job_id, overwrite=False)` | `job_id`, `overwrite` | Gather one finished job into `/exports//` on the server: the realized workflow, the run's manifest, the job row, a README, and copies of the assets, earlier-run inputs and outputs. Returns the directory, a zip URL, the file list with sizes and the total. The three JSON files are in the zip, not repeated here - get_job_workflow and get_job serve them individually. **The directory is on the machine running the server**, like `download_output`'s destination - fetch the zip URL and unpack it into `exports/` under the session's working directory (a deliverable, not a temp file); the archive already unpacks into one folder named after the job id | | `get_job_events(job_id, after=-1, limit=200)` | `job_id`, `after`, `limit` | Get a page of a job's progress events | | `wait_for_job(job_id, timeout_seconds=20)` | `job_id`, `timeout_seconds` | Block until a job reaches a terminal status, or `timeout_seconds` elapses. **One call blocks for at most 55 seconds** — a larger `timeout_seconds` is clamped, not honoured, because no MCP client holds a tool call open for a generation's real runtime, so budget one call per ~55s of the job. Every reply carries `waited_seconds`, `timeout_requested_seconds`, `timeout_applied_seconds` and `timeout_capped`, so a capped return is distinguishable from an elapsed one. Use instead of hand-polling `get_job`/`get_job_events` in a loop; if it returns `still_running: true`, call it again. Returns a slim job - status, warnings, error, and the manifest once finished - without the arguments; `get_job` has those. A running job also carries `progress` (below) | | `cancel_job(job_id)` | `job_id` | Ask a queued or running job to stop | -| `rerun_job(job_id, acknowledged_cost=False, new_seed=False)` | `job_id`, `acknowledged_cost`, `new_seed` | Queue a fresh job from a previous job's stored specification. Costs GPU time, so it passes the same gate as `run_workflow`. `new_seed=true` draws a fresh seed into the workflow's seed variable — without it a seeded workflow's rerun repeats its arguments exactly and the step cache serves the whole run from the earlier one's files (`reused: true`), generating nothing. `get_job_workflow`'s `seed_variable` says whether there is one | +| `rerun_job(job_id, acknowledged_cost=False, new_seed=False)` | `job_id`, `acknowledged_cost`, `new_seed` | Queue a fresh job from a previous job's stored specification. Costs GPU time, so it passes the same gate as `run_workflow`. `new_seed=true` draws a fresh seed into the workflow's seed variable — without it a seeded workflow's rerun repeats its arguments exactly and the step cache serves the whole run from the earlier one's files (`reused: true`), generating nothing. `get_job_workflow`'s `seed_variable` says whether there is one - `acknowledged_cost` is `true` or the bound `{fingerprint, minutes, downloads}` from the validate plan; a 409 means the plan changed and the message carries the new estimate | | `move_job(job_id, direction)` | `job_id`, `direction` (`up`\|`down`\|`front`\|`back`) | Reorder a queued job | ### Models @@ -333,6 +333,15 @@ job id from `list_jobs` would buy a way around it. `cancel_job` and `cancel_download` are deliberately *not* gated: they end a cost rather than starting one, and gating them would make the safe direction the harder one. +The acknowledgement can be bound to what was quoted. `validate_workflow` +answers with a `plan`; pass `acknowledged_cost={"fingerprint": +plan.fingerprint, "minutes": plan.estimate.minutes, "downloads": [...]}` and +the server refuses with 409 if the run's shape changed between the quote and +the call - a longer list, a stored prompt edited meanwhile, weights that now +have to be downloaded - naming the new plan so the agent re-quotes. Bare +`true` still works and is for a `plan` that came back null; the job records +which form it got (`acknowledged: none | boolean | bound`). + Passing the flag does not make a tool wait. The five that start work return as soon as it is queued or started, the same way queuing a job from the web UI does not block the browser tab; `delete_model` and `delete_workspace` are @@ -346,7 +355,8 @@ The intended loop: values you wrote, and its `plan` is the number to say out loud: `estimate.minutes` with its `basis`, plus each `downloads_required` entry as a line item of its own -2. `run_workflow` with `acknowledged_cost=true` — pass a name straight from +2. `run_workflow` with `acknowledged_cost` bound to the plan (`{fingerprint, + minutes, downloads}`), or `true` when there was no plan — pass a name straight from `list_workflows` as `workflow_path`; queues the job and returns immediately with a `job_id` 3. `wait_for_job(job_id)` to block for a bounded interval instead of @@ -456,4 +466,4 @@ default) for any server an MCP client can reach. | A config change seems to have no effect | A running session holds the old config. Start a new session | | It worked, then broke after rebuilding the venv | Re-run `pip install -e ".[server,mcp]"`. If the repo moved or was renamed, re-register the server with the new absolute path | | A tool call times out | Usually a model loading into VRAM/RAM for the first time; retry, or raise `--timeout` | -| `run_workflow` or `rerun_job` refuses with a cost message | Not an error — it is the `acknowledged_cost` gate. Confirm with the user and call again with `acknowledged_cost=true` | +| `run_workflow` or `rerun_job` refuses with a cost message | Not an error — it is the `acknowledged_cost` gate. Confirm with the user and call again with `acknowledged_cost` bound to the plan (or `true`); a 409 "shape changed" answer means the run grew since the quote - re-validate, re-quote, pass the new plan | diff --git a/docs/SERVER.md b/docs/SERVER.md index ef81386a..c672d8b7 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -142,7 +142,7 @@ from another machine: | Route | What it does | | --- | --- | -| `POST /api/jobs` | Queue a run: `{"workflow_path": ...}` or an inline `{"workflow": {...}, "base_dir": ...}`, plus `arguments` for variable overrides. `workflow_path` accepts a stored workflow name as listed by `/api/workflows` (with or without `.json`, nested names included), or a relative/absolute path that still resolves under `--workflow-dir` - confined the same way the `/api/workflows` CRUD routes are; a path that names a real file outside that directory is rejected with 400, not opened. Answers with argument warnings from signature checking. | +| `POST /api/jobs` | Queue a run: `{"workflow_path": ...}` or an inline `{"workflow": {...}, "base_dir": ...}`, plus `arguments` for variable overrides. `workflow_path` accepts a stored workflow name as listed by `/api/workflows` (with or without `.json`, nested names included), or a relative/absolute path that still resolves under `--workflow-dir` - confined the same way the `/api/workflows` CRUD routes are; a path that names a real file outside that directory is rejected with 400, not opened. Answers with argument warnings from signature checking. Takes an optional `acknowledged_cost`: `true` is recorded as `acknowledged: boolean`; the object `{fingerprint, minutes, downloads}` from a validate answer's `plan` is `bound` - the server re-plans the run for the arguments given and answers **409** when the fingerprint differs or a repo in `downloads_required` is not in `downloads` (a download that has since vanished is not a refusal); the body is `{"detail": {message, reason: "fingerprint" \| "downloads" \| "unplannable", acknowledged, plan}}` with the current plan, so the caller re-quotes from it. `minutes` is recorded, never compared. Nothing is required: the web UI and every caller that sends nothing are `acknowledged: none`, and every job answer and history row carries `acknowledged` (and `acknowledged_cost` when bound). `POST /api/jobs/{id}/rerun` takes the same field and checks against the stored spec; a fresh seed does not change a fingerprint. | | `GET /api/jobs?workspace=&status=&limit=` | Queue + history summaries, oldest first, with `total` beside them. `status` narrows to one state or a comma-separated set (`queued`, `running`, `succeeded`, `failed`, `cancelled`; anything else is a 400); `limit` keeps the newest N, and `total` still reports how many matched, so a bounded answer cannot be mistaken for a complete one. No parameters means every job, which is what the web UI polls | | `GET /api/jobs/{id}` | Full detail: spec, events, manifest, error. A manifest entry for a step served from the step cache carries `reused: true`. Every entry carries `subfolder` - the in-run subfolder the step's `result.subfolder` chose, `''` for none. A `for_each` step appears in the manifest as its members (`shot@wide_open`, `shot@closeup`), because the manifest records what ran; the run's `workflow.json` keeps the `for_each` form, because it records what was asked | | `GET /api/jobs/{id}/workflow` | The workflow the job ran: `{id, definition, realized, seed_variable}`. `seed_variable` names the variable a `new_seed` rerun would draw into (null when the workflow has none), read from the workflow as written rather than the realized copy, whose seed is pinned. `realized: true` is the copy the run itself wrote (`workflow.json` in its run directory), with arguments, seed, prompts and `output:latest` pinned; `false` falls back to the submitted definition, which is what a job from before run tracking has. 404 means neither is readable - the job itself still is | @@ -274,7 +274,9 @@ The editor's forms come from these; they are just as usable from scripts: `output:…/latest/…` left unpinned - the same work hashes the same, a longer list or an edited stored prompt does not); `steps`, the expanded member count; `list_entries`, `{variable: length}` for each `for_each` - over a list variable; `cached_steps`, reserved (`null`); + over a list variable; `cached_steps`, how many of those steps the + worker's step cache would serve (`0` for an unseeded workflow, `null` + when the worker is busy or did not answer); `downloads_required`, each `model_name` the hub cache does not hold as `{repo, gb}` (`gb` from the hub, `null` when it could not be asked - `?sizes=false` skips the hub) and each `from_single_file` URL as diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index 19e46212..2726e376 100644 --- a/docs/WORKFLOW_GUIDE.md +++ b/docs/WORKFLOW_GUIDE.md @@ -485,7 +485,12 @@ the entry an item needs. them is the `plan` on the validate answer - `estimate.minutes` with its `basis`, and every `downloads_required` entry named as its own line item, since weights not on this box are minutes and gigabytes the cost block - never counted. When `basis` is `unknown`: a workflow you wrote + never counted. Then pass that plan back: + `acknowledged_cost={"fingerprint": plan.fingerprint, "minutes": + plan.estimate.minutes, "downloads": [...]}` - the server refuses with 409 + if the run's shape changed since the quote, and the refusal carries the + new plan to quote from. `true` is for a plan that was null. When `basis` + is `unknown`: a workflow you wrote or copied carries no `cost` of its own, but the pipeline inside it usually does: `list_workflows(include_models=true)` finds the `models/` entry that loads the same checkpoint, and its per-image figure times the number of diff --git a/docs/proposals/acknowledged-cost-binding.md b/docs/proposals/acknowledged-cost-binding.md index 30f9e790..27997e66 100644 --- a/docs/proposals/acknowledged-cost-binding.md +++ b/docs/proposals/acknowledged-cost-binding.md @@ -1,7 +1,7 @@ # Proposal: bind `acknowledged_cost` to an estimate, not just to a boolean -Status: **stage 1 implemented** (the plan on validate); stage 2 (binding, the -409) not started. Design: +Status: **implemented** (stage 1 at b37f033, stage 2 on `cost-binding`). +Design: docs/superpowers/specs/2026-09-12-acknowledged-cost-binding-design.md. Written in answer to issue #85 (forum feedback), after reading the gate and every path by which a run's size is decided, by diff --git a/plugins/dw/skills/ltx-2.5/SKILL.md b/plugins/dw/skills/ltx-2.5/SKILL.md index 4a4a4009..d97caf2f 100644 --- a/plugins/dw/skills/ltx-2.5/SKILL.md +++ b/plugins/dw/skills/ltx-2.5/SKILL.md @@ -131,7 +131,8 @@ AESTHETIC QUALITY (in addition to the above, without breaking the objective capt instead - a 121-frame clip at 960x544 is under two minutes cold on a 24 GB card, of which a minute is loading, the two-stage flow about eight, and extend and chain multiply by their passes. Either way get the go-ahead - before `run_workflow` with `acknowledged_cost=true`. + before `run_workflow` with `acknowledged_cost` set to the plan's + `{fingerprint, minutes, downloads}`. 3. `wait_for_job`, then `get_job` for the manifest. The write-out runs after the last step ends and names each file as it starts it - seconds for a 121-frame clip since 2026-09-14, minutes before that (#97). diff --git a/plugins/dw/skills/minimax-h3/SKILL.md b/plugins/dw/skills/minimax-h3/SKILL.md index f07bd417..6290fde7 100644 --- a/plugins/dw/skills/minimax-h3/SKILL.md +++ b/plugins/dw/skills/minimax-h3/SKILL.md @@ -155,16 +155,16 @@ inherits the portrait's composition. ## Run and judge 1. `validate_workflow` first - free, and it catches arguments the pipeline - does not accept. + rejects. 2. Quote `plan.estimate` from the validate answer (warm minutes; a first - load, and any `downloads_required`, is longer). When `basis` is - `unknown`, say so and give the shape - instead: a 124-frame turbo clip is a few minutes on a 24 GB card, 345 + load or a `downloads_required` is longer). When `basis` is `unknown`, + say so and give the shape instead: a 124-frame turbo clip is a few minutes on a 24 GB card, 345 frames three times that, an image reference twice a turbo clip, a video reference beside it 3.4x again, and a chain multiplies by its segments. - Get the user's go-ahead before `run_workflow` with `acknowledged_cost=true`. + Get the go-ahead, then `run_workflow` with `acknowledged_cost` = the + plan's `{fingerprint, minutes, downloads}`. 3. `wait_for_job`, then `get_job` for the manifest. A cancelled H3 job runs - on to its next step boundary, minutes on this model. Silence is not a hang: + on to its next step boundary, minutes on this model. Silence is no hang: `denoise_step` is null through the reference encode (~90 s, ~10 min with a video reference), and the block cache makes later steps uneven - two-minute gaps are healthy. Each entry carries diff --git a/plugins/dw/skills/minimax-music3/SKILL.md b/plugins/dw/skills/minimax-music3/SKILL.md index 0523c19e..0bb56537 100644 --- a/plugins/dw/skills/minimax-music3/SKILL.md +++ b/plugins/dw/skills/minimax-music3/SKILL.md @@ -133,7 +133,8 @@ Control" section. the shape of the spend: the autoregressive stage runs at 25 frames per second of audio and dominates, so time scales with the length the model actually sings, not the ceiling. Get the user's go-ahead before - `run_workflow` with `acknowledged_cost=true`. + `run_workflow` with `acknowledged_cost` set to the plan's + `{fingerprint, minutes, downloads}`. 3. `wait_for_job`, then `get_job` for the manifest. Each manifest entry carries `subfolder`: `templates/minimax/music-video` puts the cut in `final` and the song, the singer's portrait and each shot in From d1699d15e53a2509e9a0c2a1de48ddbf53f8749c Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 09:04:24 -0500 Subject: [PATCH 19/34] docs(mcp): #85 - the proposal's open questions become decisions, and the job tools name the acknowledgement field Co-Authored-By: Claude Opus 5 (1M context) --- docs/proposals/acknowledged-cost-binding.md | 31 ++++++++++++++------- dw_mcp/CLAUDE.md | 7 ++++- dw_mcp/server.py | 8 ++++-- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/docs/proposals/acknowledged-cost-binding.md b/docs/proposals/acknowledged-cost-binding.md index 27997e66..d794da88 100644 --- a/docs/proposals/acknowledged-cost-binding.md +++ b/docs/proposals/acknowledged-cost-binding.md @@ -212,13 +212,24 @@ skills can teach the bound form as the normal one. Roughly a two-stage piece of work: stage 1 the plan on validate (useful on its own), stage 2 the binding and the 409. -## Open questions for approval - -1. Is the bound form worth it at all, given that a human is already in the - loop on every acknowledgement? (Doing nothing is a legitimate answer; - stage 1 alone is another.) -2. Tolerance: 25% of the acknowledged minutes, or a fingerprint-only check - with no numeric comparison at all? A numeric one needs `per_entry` on the - templates to be meaningful, and today no template carries it. -3. Should `POST /api/jobs` grow the gate for HTTP callers too, or stay an - MCP-layer concept? (The web UI would have to send something.) +## Decisions taken (2026-09-13) + +The three questions the proposal left open were settled by building it: + +1. **The bound form was built.** Both stages shipped (b37f033, d447f7d): the + plan on validate, and an `acknowledged_cost` object that `POST /api/jobs` + and `/rerun` check. The human-in-the-loop argument for doing nothing was + real, but cases 1, 4 and 6 above are exactly where the human consented to + one size and got another, and the check costs a free pre-flight. +2. **Fingerprint-only; no numeric tolerance.** `minutes` is carried and + recorded but never compared. A tolerance needs `per_entry` measured on the + list-driven templates, and neither carries one yet; until they do, every + list-driven estimate has `basis: catalog` - the default list's total - and + a 25% band around it would be a band around the wrong number. The + `downloads` list is the one non-fingerprint thing compared, because a repo + that appeared since the quote is a cost the fingerprint cannot see. +3. **HTTP takes it, never requires it.** The web UI and every existing caller + send nothing and are recorded as `acknowledged: none`; `true` is + `boolean`; only the object is checked. The gate stays the MCP layer's - + the server's part is to refuse a *bound* acknowledgement that no longer + matches, which only the server can judge. diff --git a/dw_mcp/CLAUDE.md b/dw_mcp/CLAUDE.md index b186c6c5..329d9315 100644 --- a/dw_mcp/CLAUDE.md +++ b/dw_mcp/CLAUDE.md @@ -44,7 +44,12 @@ them testable without an MCP session. It is a top-level package rather than and pulls in torch, which a pure HTTP client has no use for — a test guards that boundary. Seven tools require `acknowledged_cost=True` (`run_workflow`, `rerun_job`, `enhance_prompt`, `download_model`, -`delete_model`, `update_diffusers`, `delete_workspace`); the three job-queuing tools return as +`delete_model`, `update_diffusers`, `delete_workspace`); `run_workflow` and +`rerun_job` also take the acknowledgement *bound* to the plan +`validate_workflow` answered with - `{fingerprint, minutes, downloads}`, +forwarded verbatim, which the server refuses with 409 when the run's shape +changed since (`_acknowledgement_body` in `diagnose.py`; the 409 is rendered +with the new estimate by `DwClient._format_detail`). The three job-queuing tools return as soon as the job is queued, since a generation outlasts any client's tool-call timeout. Authoring has two halves: `get_schema` describes a workflow and `get_prompt_schema` a stored prompt, which a workflow reaches by diff --git a/dw_mcp/server.py b/dw_mcp/server.py index 6681487d..99e1d66c 100644 --- a/dw_mcp/server.py +++ b/dw_mcp/server.py @@ -326,7 +326,8 @@ def list_jobs( them - queued, running, succeeded, failed, cancelled. `workspace` lists one workspace's jobs; without it, a named workspace lists its own and the default workspace lists every job the server holds, - whichever workspace ran it.""" + whichever workspace ran it. Each job carries `acknowledged` - `none`, + `boolean` or `bound` - which form of cost acknowledgement queued it.""" return catalog.list_jobs( client, limit=limit, status=status, workspace=workspace ) @@ -837,7 +838,10 @@ def get_job(job_id: str) -> dict: the scratch work, and '' a step that said nothing. A step served from the step cache is marked `reused` and reports the earlier run's files. When a job failed, the error and traceback here are what to - read before changing anything.""" + read before changing anything. `acknowledged` says which form of + cost acknowledgement queued the job (`none`, `boolean`, `bound`) and + `acknowledged_cost` is the bound `{fingerprint, minutes, downloads}` + when there was one.""" return diagnose.get_job(client, job_id) def get_job_workflow(job_id: str) -> dict: From 0e3dcecce95fb794d93f5e25b306785b167d71ac Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 09:10:40 -0500 Subject: [PATCH 20/34] fix(engine): #85 - a model name is judged a local checkout by shape, not by probing the disk, and a composed child's digest is in the fingerprint Co-Authored-By: Claude Opus 5 (1M context) --- dw/plan.py | 27 ++++++++++++++++++++++----- tests/test_plan.py | 37 ++++++++++++++++++++++++++++++------- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/dw/plan.py b/dw/plan.py index 7ba64f10..e82b7330 100644 --- a/dw/plan.py +++ b/dw/plan.py @@ -19,6 +19,7 @@ import os from huggingface_hub import model_info +from huggingface_hub.utils import HFValidationError, validate_repo_id from .hub_cache import scan_models from .realize import ( @@ -72,7 +73,7 @@ def build_plan( if candidate.file_spec else None ) - realized, _ = realize_workflow( + realized, annotations = realize_workflow( definition, arguments, seed=0, @@ -89,7 +90,7 @@ def build_plan( ).expanded_definition() entries = list_entries(definition, realized) return { - "fingerprint": fingerprint(expanded, definition), + "fingerprint": fingerprint(expanded, definition, annotations), "steps": len(expanded.get("steps") or []), "list_entries": entries, "cached_steps": cached_steps(definition, realized, arguments, cache_probe), @@ -145,15 +146,20 @@ def _is_seeded(definition, arguments): return seed is not None -def fingerprint(expanded, definition): +def fingerprint(expanded, definition, annotations=None): """SHA-256 over the expanded definition with everything that is not work removed: the seed wherever it sits, and the documentation keys. `definition` is the workflow as written, consulted for whether the top-level seed named a variable - if it did, that variable's folded - value is the seed too and is blanked at its source. + value is the seed too and is blanked at its source. `annotations` is + what realization recorded beside the copy; its sub-workflow digests go + into the hash, since a composed child edited between the quote and the + call is different work the parent's text cannot show. """ doc = copy.deepcopy(expanded) + if annotations and annotations.get("sub_workflows"): + doc["__sub_workflows__"] = dict(annotations["sub_workflows"]) doc.pop("seed", None) for key in DOCUMENTATION_KEYS: doc.pop(key, None) @@ -284,7 +290,10 @@ def downloads_required(expanded, base_dir, workflow_dir, cache_dir, lookup_sizes present = {repo.get("repo_id") for repo in scan_models(cache_dir).get("repos", [])} required = [] for name in names: - if name in present or os.path.isdir(name): + # A name not shaped like a hub id is a local checkout - decided by + # shape, never by touching the disk: the name came from the request + # body, and a free pre-flight must not be a directory-existence oracle + if name in present or not _is_repo_id(name): continue required.append({"repo": name, "gb": _size_gb(name) if lookup_sizes else None}) for url in urls: @@ -310,6 +319,14 @@ def _collect_sources(tree, names, urls): _collect_sources(value, names, urls) +def _is_repo_id(name): + try: + validate_repo_id(name) + return True + except HFValidationError: + return False + + def _is_url(value): if not value.startswith(("http://", "https://")): return False diff --git a/tests/test_plan.py b/tests/test_plan.py index 53251a10..e79bfdbd 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -168,6 +168,15 @@ def test_argument_order(self, plan): class TestFingerprintChangesWith: + def test_an_edited_composed_child(self, plan, tmp_path): + child = {"id": "child", "steps": []} + (tmp_path / "child.json").write_text(json.dumps(child)) + spec = composing("child.json") + before = plan(spec)["fingerprint"] + child["steps"].append({"name": "x", "task": {"command": "x", "arguments": {}}}) + (tmp_path / "child.json").write_text(json.dumps(child)) + assert plan(spec)["fingerprint"] != before + def test_a_longer_list(self, plan): longer = [{"name": n, "prompt": n} for n in "abc"] assert plan()["fingerprint"] != plan(arguments={"shots": longer})["fingerprint"] @@ -363,14 +372,28 @@ def spy(cache_dir=None): plan(cache_dir="/somewhere") assert seen == ["/somewhere"] - def test_a_local_directory_is_not_a_download(self, plan, tmp_path): - local = tmp_path / "weights" - local.mkdir() - spec = definition() - spec["steps"][0]["pipeline"]["from_pretrained_arguments"]["model_name"] = str( - local + def test_a_local_path_is_not_a_download_and_is_not_probed( + self, plan, tmp_path, monkeypatch + ): + """A model_name that is not shaped like a hub id is a local checkout + and never a download - decided by shape, never by touching the disk, + since the free pre-flight takes the name from the request body and + must not become a directory-existence oracle.""" + import os + + probed = [] + real_isdir = os.path.isdir + monkeypatch.setattr( + os.path, "isdir", lambda path: probed.append(path) or real_isdir(path) ) - assert plan(spec)["downloads_required"] == [] + names = (str(tmp_path / "weights"), "/Users/someone/.ssh", "./weights") + for local in names: + spec = definition() + spec["steps"][0]["pipeline"]["from_pretrained_arguments"][ + "model_name" + ] = local + assert plan(spec)["downloads_required"] == [], local + assert not set(names) & set(probed) def test_a_single_file_url_is_listed_without_a_size(self, plan): spec = definition() From d1dc163ddd5dbc1930bdf963981305f1f5df77e9 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 09:11:11 -0500 Subject: [PATCH 21/34] fix(server): #85 - the schema and argument checks come before the bound check, so a caller's 400 stays a 400 Co-Authored-By: Claude Opus 5 (1M context) --- dw/server/app.py | 33 +++++++++++++++++++++++++++------ tests/test_server.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/dw/server/app.py b/dw/server/app.py index 220431b7..845bfc14 100644 --- a/dw/server/app.py +++ b/dw/server/app.py @@ -900,13 +900,32 @@ def refuse(message, reason, plan): current, ) - def _candidate_for(workflow_path, workflow, base_dir, output_dir, workflow_dir): - """The Workflow a job spec names, built as the worker will build it.""" + def _candidate_for( + workflow_path, workflow, base_dir, output_dir, workflow_dir, arguments + ): + """The Workflow a job spec names, built and checked as submit() will + build and check it - schema first, then the caller's arguments - + so a bound acknowledgement never turns the caller's 400 into a 409 + telling them to acknowledge with true and find out. + + Raises what submit() raises (ValueError, SecurityError, ...), which + the routes already answer as 400. + """ if workflow_path is not None: - return workflow_from_file(workflow_path, output_dir, workflow_dir) - return workflow_from_definition( - copy.deepcopy(workflow), output_dir, base_dir, workflow_dir - ) + candidate = workflow_from_file(workflow_path, output_dir, workflow_dir) + else: + candidate = workflow_from_definition( + copy.deepcopy(workflow), output_dir, base_dir, workflow_dir + ) + candidate.validate() + problems = argument_errors(candidate.workflow_definition, arguments) + if problems: + raise ValueError( + "; ".join( + f"{problem['path']}: {problem['message']}" for problem in problems + ) + ) + return candidate @app.post("/api/jobs", status_code=201) def submit_job(request: JobRequest, ws: Workspace = Depends(selected_workspace)): @@ -947,6 +966,7 @@ def submit_job(request: JobRequest, ws: Workspace = Depends(selected_workspace)) request.base_dir, workspace.outputs, source.root if source else workspace.workflows, + request.arguments, ) _check_bound_acknowledgement( candidate, request.arguments, request.acknowledged_cost, workspace @@ -1097,6 +1117,7 @@ def rerun_job(job_id: str, body: RerunRequest = RerunRequest()): spec.get("base_dir"), spec.get("output_dir") or manager.output_dir, spec.get("workflow_dir"), + arguments, ) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/tests/test_server.py b/tests/test_server.py index 30a82956..e52a1de7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -4007,6 +4007,39 @@ def boom(*a, **k): assert flagged.json()["acknowledged"] == "boolean" assert off.json()["acknowledged"] == "none" + def test_a_bad_argument_is_still_a_400_under_a_bound_acknowledgement( + self, server, no_hub + ): + """The argument and schema checks come first, as they do on the + boolean path - a typo in an argument name is the caller's 400, not + a 409 telling them to acknowledge with true and try again.""" + with server(success_script) as client: + plan = plan_for(client, list_workflow()) + response = client.post( + "/api/jobs", + json={ + "workflow": list_workflow(), + "arguments": {"shotz": []}, + "acknowledged_cost": bound(plan), + }, + ) + assert response.status_code == 400 + assert "shotz" in response.json()["detail"] + + def test_an_invalid_workflow_is_still_a_400_under_a_bound_acknowledgement( + self, server, no_hub + ): + with server(success_script) as client: + broken = {"id": "broken", "steps": "no"} + response = client.post( + "/api/jobs", + json={ + "workflow": broken, + "acknowledged_cost": {"fingerprint": "sha256:0", "downloads": []}, + }, + ) + assert response.status_code == 400 + def test_a_bound_form_without_a_fingerprint_is_a_422(self, server): with server(success_script) as client: response = client.post( From f0c006740a832d1bd0b25692c16f3705959067b4 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 09:11:51 -0500 Subject: [PATCH 22/34] fix(server): #85 - a cache probe carries an id, so a late reply is never read as the next probe's answer Co-Authored-By: Claude Opus 5 (1M context) --- dw/server/jobs.py | 24 +++++++++++++++++++---- dw/worker.py | 16 ++++++++++++--- tests/test_server.py | 38 +++++++++++++++++++++++++++++++++++- tests/test_worker_execute.py | 6 +++++- 4 files changed, 75 insertions(+), 9 deletions(-) diff --git a/dw/server/jobs.py b/dw/server/jobs.py index 08d32b35..5eca2d38 100644 --- a/dw/server/jobs.py +++ b/dw/server/jobs.py @@ -1223,16 +1223,32 @@ def probe_cache(self, command, timeout=5): return [] if not self._worker_lock.acquire(timeout=2): return None + # A probe that timed out still answers eventually, onto the same + # queue the next probe reads - so each carries an id and a reader + # discards every reply that is not its own, rather than reporting + # the previous workflow's hit list as this plan's + probe_id = uuid.uuid4().hex + deadline = time.monotonic() + timeout try: - self.worker_manager.send_command({"type": "probe_cache", **command}) - result = self.worker_manager.get_result(timeout=timeout) + self.worker_manager.send_command( + {"type": "probe_cache", "probe_id": probe_id, **command} + ) + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + result = self.worker_manager.get_result(timeout=remaining) + if ( + result.get("type") == "probe_cache" + and result.get("probe_id") == probe_id + ): + break + logger.debug(f"Discarding a stale worker message: {result.get('type')}") except (RuntimeError, queue.Empty) as e: logger.debug(f"Worker did not answer the cache probe: {e}") return None finally: self._worker_lock.release() - if result.get("type") != "probe_cache": - return None cached = result.get("cached") return list(cached) if isinstance(cached, list) else None diff --git a/dw/worker.py b/dw/worker.py index 2791427a..8e0533a5 100644 --- a/dw/worker.py +++ b/dw/worker.py @@ -394,8 +394,11 @@ def _handle_probe_cache(self, command: Dict[str, Any]): - the plan's cached_steps (#85). Same fields as an execute command; loads the workflow, executes nothing. A failure answers cached: null with the reason rather than an error message, since - an unknown answer is a valid plan and a crashed probe is not. + an unknown answer is a valid plan and a crashed probe is not. The + command's probe_id is echoed so a reply that arrives after its + caller gave up is not read as the answer to the next probe. """ + probe_id = command.get("probe_id") try: workflow, _ = self._load_workflow(command, command["output_dir"]) asset_token = ( @@ -408,11 +411,18 @@ def _handle_probe_cache(self, command: Dict[str, Any]): finally: if asset_token is not None: deactivate_asset_dir(asset_token) - self.result_queue.put({"type": "probe_cache", "cached": cached}) + self.result_queue.put( + {"type": "probe_cache", "probe_id": probe_id, "cached": cached} + ) except Exception as e: logger.debug(f"Cache probe failed: {e}") self.result_queue.put( - {"type": "probe_cache", "cached": None, "error": str(e)} + { + "type": "probe_cache", + "probe_id": probe_id, + "cached": None, + "error": str(e), + } ) def _evict_untouched_pipelines(self, context): diff --git a/tests/test_server.py b/tests/test_server.py index e52a1de7..1fe65a28 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -87,7 +87,11 @@ def send_command(self, command): ) elif command["type"] == "probe_cache": self._results.put( - {"type": "probe_cache", "cached": list(self.cached_steps)} + { + "type": "probe_cache", + "probe_id": command.get("probe_id"), + "cached": list(self.cached_steps), + } ) def get_result(self, timeout=None): @@ -3756,6 +3760,38 @@ def test_a_running_job_means_unknown(self, server): assert manager.probe_cache(PROBE) is None client.post(f"/api/jobs/{job_id}/cancel") + def test_a_late_reply_is_not_attributed_to_the_next_probe(self, server): + """A probe that timed out still answers eventually; the next probe + must not read that stale hit list as its own.""" + with server(success_script) as client: + manager = client.app.state.job_manager + worker = manager.worker_manager + worker.ensure_worker() + held = [] + real_send = worker.send_command + + def hold_then_answer(command): + # The first probe never answers in time; its reply lands + # just before the second probe is sent + if command["type"] == "probe_cache" and not held: + held.append(command) + return None + if held: + late = held.pop() + worker._results.put( + { + "type": "probe_cache", + "probe_id": late["probe_id"], + "cached": ["stale"], + } + ) + worker.cached_steps = ["fresh"] + return real_send(command) + + worker.send_command = hold_then_answer + assert manager.probe_cache(PROBE, timeout=0.05) is None + assert manager.probe_cache(PROBE) == ["fresh"] + def test_an_unanswered_probe_is_unknown(self, server): with server(success_script) as client: manager = client.app.state.job_manager diff --git a/tests/test_worker_execute.py b/tests/test_worker_execute.py index 3c3c638c..4d218612 100644 --- a/tests/test_worker_execute.py +++ b/tests/test_worker_execute.py @@ -282,13 +282,16 @@ def test_probe_cache_answers_with_the_workflows_hits(): workflow = ProbableWorkflow(["gen"]) command = { "type": "probe_cache", + "probe_id": "p-1", "workflow_path": "x.json", "arguments": {"prompt": "p"}, "output_dir": "/tmp", } with patch("dw.worker.workflow_from_file", return_value=workflow): worker._handle_probe_cache(command) - assert _drain(worker.result_queue) == [{"type": "probe_cache", "cached": ["gen"]}] + assert _drain(worker.result_queue) == [ + {"type": "probe_cache", "probe_id": "p-1", "cached": ["gen"]} + ] assert workflow.probed_with == {"prompt": "p"} @@ -305,6 +308,7 @@ def test_probe_cache_reports_a_failure_as_unknown_not_as_a_crash(): ) [answer] = _drain(worker.result_queue) assert answer["type"] == "probe_cache" + assert answer["probe_id"] is None # echoed even when the command had none assert answer["cached"] is None assert "bad file" in answer["error"] From 28586e9bd51ee9a3c998d0e10a76999b29c63b8b Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 09:12:23 -0500 Subject: [PATCH 23/34] fix(mcp): #85 - a null download entry is dropped before sending, and a 409 without a measured estimate says so Co-Authored-By: Claude Opus 5 (1M context) --- dw_mcp/client.py | 14 ++++++++--- dw_mcp/diagnose.py | 10 +++++++- dw_mcp/server.py | 2 +- tests/test_mcp_diagnose.py | 50 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/dw_mcp/client.py b/dw_mcp/client.py index 729b18ea..b4446fed 100644 --- a/dw_mcp/client.py +++ b/dw_mcp/client.py @@ -348,10 +348,16 @@ def _format_detail(self, detail): # A 409 from the cost gate: say what the run costs now, so a # client that only sees the message can re-quote from it estimate = plan.get("estimate") or {} - formatted += ( - f" It now estimates {estimate.get('minutes')} minutes " - f"(basis {estimate.get('basis')})" - ) + if estimate.get("minutes") is None: + formatted += ( + f" It now has no measured estimate (basis " + f"{estimate.get('basis')})" + ) + else: + formatted += ( + f" It now estimates {estimate.get('minutes')} minutes " + f"(basis {estimate.get('basis')})" + ) downloads = [ entry.get("repo") or entry.get("url") for entry in plan.get("downloads_required") or [] diff --git a/dw_mcp/diagnose.py b/dw_mcp/diagnose.py index 2b0176f3..004aa339 100644 --- a/dw_mcp/diagnose.py +++ b/dw_mcp/diagnose.py @@ -30,7 +30,7 @@ "fetch first. Tell the user that number, get their go-ahead, then call " 'again with acknowledged_cost bound to the plan: {"fingerprint": ' 'plan.fingerprint, "minutes": plan.estimate.minutes, "downloads": ' - "[each downloads_required repo]} - the server then refuses (409) if the " + "[each non-null downloads_required repo]} - the server then refuses (409) if the " "run's shape changed since. acknowledged_cost=true is for a `plan` that " "was null." ) @@ -49,6 +49,14 @@ def _acknowledgement_body(acknowledged_cost): "plan.fingerprint the validate answer carried. Validate again " "and pass {fingerprint, minutes, downloads} from its plan." ) + # A from_single_file URL sits in downloads_required with repo: null; + # an agent copying the list verbatim should not earn a 422 for it + downloads = acknowledged_cost.get("downloads") + if isinstance(downloads, list): + acknowledged_cost = { + **acknowledged_cost, + "downloads": [repo for repo in downloads if repo], + } return {"acknowledged_cost": acknowledged_cost} return {} diff --git a/dw_mcp/server.py b/dw_mcp/server.py index 99e1d66c..0ff51273 100644 --- a/dw_mcp/server.py +++ b/dw_mcp/server.py @@ -816,7 +816,7 @@ def run_workflow( Bind the acknowledgement to what you quoted: pass {"fingerprint": plan.fingerprint, "minutes": plan.estimate.minutes, - "downloads": [...repos from plan.downloads_required]} from the + "downloads": [...the non-null repos in plan.downloads_required]} from the validate answer, and the server refuses with 409 - naming the new plan - if the run's shape changed since; bare true is for a plan that was null.""" diff --git a/tests/test_mcp_diagnose.py b/tests/test_mcp_diagnose.py index f34f2deb..07e8ae9b 100644 --- a/tests/test_mcp_diagnose.py +++ b/tests/test_mcp_diagnose.py @@ -603,3 +603,53 @@ def test_the_refusal_teaches_the_bound_form(): from dw_mcp.diagnose import COST_REFUSAL assert "fingerprint" in COST_REFUSAL + + +def test_a_null_download_entry_is_dropped_before_sending(): + """A `from_single_file` URL sits in downloads_required with repo: null; + an agent copying the list verbatim must not earn a 422 for it.""" + import json + + client, seen = submitting() + diagnose.run_workflow( + client, + workflow_path="w.json", + acknowledged_cost={"fingerprint": "sha256:abc", "downloads": ["org/x", None]}, + ) + assert json.loads(seen[0]["body"])["acknowledged_cost"]["downloads"] == ["org/x"] + + +def test_a_409_without_a_measured_estimate_says_so(): + client, _seen = scripted( + { + ("POST", "/api/jobs"): ( + 409, + { + "detail": { + "message": "The run's shape changed since it was acknowledged", + "reason": "fingerprint", + "acknowledged": BOUND, + "plan": { + "fingerprint": "sha256:def", + "steps": 1, + "list_entries": {}, + "cached_steps": None, + "downloads_required": [], + "estimate": { + "minutes": None, + "basis": "unknown", + "device": "cuda", + "measured_on": None, + "partial": False, + }, + }, + } + }, + ) + } + ) + with pytest.raises(DwApiError) as caught: + diagnose.run_workflow(client, workflow_path="w.json", acknowledged_cost=BOUND) + message = str(caught.value) + assert "None minutes" not in message + assert "no measured estimate" in message From 167ac0ff91565e3df950defd56de6572c9be7e5a Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 13 Sep 2026 09:20:46 -0500 Subject: [PATCH 24/34] feat(ui): #85 - the editor shows a validate answer's plan, and a bound job says so Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 6 +- docs/SERVER.md | 6 +- ui/src/lib/pages/EditorPage.svelte | 31 ++++++++ ui/src/lib/pages/EditorPage.test.ts | 40 ++++++++++ ui/src/lib/pages/JobPage.svelte | 15 ++++ ui/src/lib/pages/JobPage.test.ts | 24 ++++++ ui/src/lib/pages/JobsPage.svelte | 7 ++ ui/src/lib/plan.test.ts | 112 ++++++++++++++++++++++++++++ ui/src/lib/plan.ts | 76 +++++++++++++++++++ ui/src/lib/types.ts | 35 +++++++++ 10 files changed, 350 insertions(+), 2 deletions(-) create mode 100644 ui/src/lib/plan.test.ts create mode 100644 ui/src/lib/plan.ts diff --git a/CLAUDE.md b/CLAUDE.md index e5c02639..191b9572 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -292,7 +292,11 @@ same reason - default setup cannot load a pack. changed; `minutes` never compared), and the job records `acknowledged: none | boolean | bound`. `cached_steps` is the worker's answer to a `probe_cache` command (`Workflow.cache_hits`, which shares - `_prepare_definition` / `_cache_lookup` with `run` so the two cannot drift) + `_prepare_definition` / `_cache_lookup` with `run` so the two cannot drift). + The web UI reads the fields only: the editor lists the plan under a valid + verdict (`describePlan`, `ui/src/lib/plan.ts`), and a job queued `bound` + says so on the job page and in the jobs list; the UI itself sends no + acknowledgement - **A failed run still reports what it wrote** — the worker carries its partial manifest on the error and cancelled messages as well as on success, and the "Previous result not found" error names the steps that ran even after diff --git a/docs/SERVER.md b/docs/SERVER.md index c672d8b7..64760bef 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -69,7 +69,11 @@ load entirely. and three views — form, split, and raw JSON — edit the same definition. The split view puts the form beside the JSON with both sides editable; changes apply when a side loses focus. Validate, save, and run from the - same screen. A Monaco editor with the workflow JSON schema backs the + same screen; a valid verdict is followed by the run's plan - the step + and list counts, the minutes from the workflow's `cost` block with its + basis, how many steps the step cache would serve, and the weights this + server would download first (`describePlan`, `ui/src/lib/plan.ts`, + reading `POST /api/validate`'s `plan`). A Monaco editor with the workflow JSON schema backs the JSON views. A fourth view, **flow**, renders the workflow's data-flow graph read-only: one box per step, arrows for each `previous_result` reference labeled with the argument it feeds, entry-point steps marked diff --git a/ui/src/lib/pages/EditorPage.svelte b/ui/src/lib/pages/EditorPage.svelte index fb3f9752..a2cdc596 100644 --- a/ui/src/lib/pages/EditorPage.svelte +++ b/ui/src/lib/pages/EditorPage.svelte @@ -29,6 +29,7 @@ import { loadPromptLibrary, promptLibrary } from '../promptlib.svelte' import { PROMPT_LIST_ID } from '../prompts' import { storageGet, storageSet } from '../storage' + import { describePlan } from '../plan' import StepEditor from '../editor/StepEditor.svelte' import JsonEditor from '../editor/JsonEditor.svelte' import VariablesForm from '../editor/VariablesForm.svelte' @@ -646,6 +647,20 @@ {:else}
{validation.error}
{/if} + {#if validation.valid && validation.plan} + +
    + {#each describePlan(validation.plan) as line, i (i)} +
  • {line.text}
  • + {/each} +
+ {/if} {/if} @@ -1033,6 +1048,22 @@ .error { color: var(--bad); } + /* The plan under the verdict: figures the engine resolves, so mono, and + quiet - a warn line is the one that changes the number to expect */ + .plan { + list-style: none; + margin: var(--space-2) 0 0; + padding: 0; + font-family: var(--font-mono); + font-size: var(--t-xs); + color: var(--muted); + display: flex; + flex-wrap: wrap; + gap: 0.2rem var(--space-3); + } + .plan .warn { + display: inline; + } .hint { font-size: 0.8rem; } diff --git a/ui/src/lib/pages/EditorPage.test.ts b/ui/src/lib/pages/EditorPage.test.ts index 52c15905..1962a8b7 100644 --- a/ui/src/lib/pages/EditorPage.test.ts +++ b/ui/src/lib/pages/EditorPage.test.ts @@ -20,6 +20,26 @@ vi.mock('../api', () => ({ .fn() .mockResolvedValue({ workflows: [], workflow_dir: 'workflows' }), listPrompts: vi.fn().mockResolvedValue({ prompts: [], details: {} }), + validate: vi.fn().mockResolvedValue({ + valid: true, + error: null, + errors: [], + warnings: [], + plan: { + fingerprint: 'sha256:abc', + steps: 3, + list_entries: { shots: 2 }, + cached_steps: 0, + downloads_required: [{ repo: 'org/model', gb: 41.2 }], + estimate: { + minutes: 12, + basis: 'catalog', + device: 'cuda', + measured_on: 'card', + partial: false, + }, + }, + }), }, })) @@ -56,3 +76,23 @@ describe('EditorPage view switch', () => { ) }) }) + +describe('EditorPage validation plan', () => { + it('shows what a run will do under a valid verdict', async () => { + render(EditorPage, { name: '' }) + await waitFor(() => + expect(screen.getByLabelText('workflow id')).toBeTruthy(), + ) + + await screen.getByRole('button', { name: /validate/i }).click() + + await waitFor(() => + expect(screen.getByLabelText('what a run will do')).toBeTruthy(), + ) + const plan = screen.getByLabelText('what a run will do') + expect(plan.textContent).toContain('3 steps (shots: 2)') + expect(plan.textContent).toContain('~12 min on cuda') + expect(plan.textContent).toContain('0 of 3 steps cached') + expect(plan.textContent).toContain('needs download: org/model (41.2 GB)') + }) +}) diff --git a/ui/src/lib/pages/JobPage.svelte b/ui/src/lib/pages/JobPage.svelte index ad91dc09..3544746d 100644 --- a/ui/src/lib/pages/JobPage.svelte +++ b/ui/src/lib/pages/JobPage.svelte @@ -315,6 +315,21 @@ >seed {seed} {/if} + {#if job.acknowledged === 'bound'} + + {job.acknowledged_cost?.minutes != null + ? `acknowledged at ${job.acknowledged_cost.minutes} min` + : 'acknowledged'} + {/if} {#if running}