Version: 0.1 Date: 2026-06-02 Audience: A fresh Claude chat with no access to this environment — everything needed is inlined below. Origin: A cleanup session that began verifying CR-2026-097 and branched into an upload-500 investigation. Four threads were left open. None is merged or pushed.
/Users/dunin7/loomworks-engine (DUNIN7/loomworks-engine). FastAPI + async SQLAlchemy. Run with uv run uvicorn loomworks.api.app:app --reload --host 127.0.0.1 --port 8000. Tests: uv run pytest (or .venv/bin/python -m pytest)./Users/dunin7/loomworks (DUNIN7/loomworks). Next.js 16 / React 19. npm run dev → :3001. Calls the engine directly at NEXT_PUBLIC_API_URL || http://localhost:8000 (no proxy)./Users/dunin7/loomworks-record.playground_dev (user playground), live. playground_test for pytest.localhost:9000, data dir /Users/dunin7/minio-data, bucket loomworks-uploads, creds from engine .env (OBJECT_STORE_ACCESS_KEY/SECRET_KEY = minioadmin/minioadmin). Now durable via LaunchAgent ~/Library/LaunchAgents/com.dunin7.minio.plist (RunAtLoad+KeepAlive); previously it was a manual process that died on power loss — which was the actual cause of Thread 2's "500" (see below)./Users/dunin7/loomworks-record/candidate-seeds/loomworks/loomworks-candidate-seed-v0_9.md (v0.9, committed 2026-05-26; .html render alongside). Consult this for settled product commitments before changing upload/clarification behaviour.image_vision_analysis converts before the vision call
Branch: fix-upload-vision-conversion-missing, single commit bd05d5c, off main (813f13f).
State: NOT pushed (no upstream configured), NOT merged into main. Working tree currently on this branch. Tests green (the new regression test + the Phase 58/59/60 upload-vision suite: 70 passed, zero regressions).
Why: src/loomworks/uploads/skills/image_vision_analysis.py::transform claimed (docstring only) to convert HEIC/HEIF/AVIF/TIFF to JPEG before the Vision call, but never called the converter. For those formats detect_vision_media_type returns None, so media_type fell through to the default "image/jpeg" and the raw, unconverted bytes were sent to Vision mislabeled as JPEG. (NB: this is a real latent bug, but it is not the 500 — see Thread 2. Its true symptom is a transformation_failed/HTTP-200, because execute_upload catches TransformationError at executor.py:567 and returns a result rather than raising.)
What changed (diff main..bd05d5c, 3 files, +188/−20):
src/loomworks/files/conversion.py: extracted shared core _to_jpeg(source, *, source_name) + _prepare_pillow(); added **convert_to_jpeg_bytes(data, *, source_name)** (bytes-accepting sibling, no temp-file staging). convert_to_jpeg(file_path) behaviour unchanged (now delegates to _to_jpeg).src/loomworks/uploads/skills/image_vision_analysis.py: transform now, when needs_jpeg_conversion(Path(filename)), converts content in memory via convert_to_jpeg_bytes and sends media_type="image/jpeg"; conversion failure → TransformationError. Both run_vision calls now use the (possibly converted) image_bytes. Docstring corrected.tests/test_image_vision_conversion.py: new. Synthesizes an AVIF, asserts transform hands run_vision real JPEG bytes (\xff\xd8 SOI) + image/jpeg via a stubbed run_vision; PNG control passes through unconverted.
--- a/src/loomworks/files/conversion.py
+++ b/src/loomworks/files/conversion.py
@@ def needs_jpeg_conversion(file_path: Path) -> bool: return ... @@
-def convert_to_jpeg(file_path: Path) -> bytes:
- """Decode the file with Pillow and return JPEG bytes. ..."""
- try: from pillow_heif import register_heif_opener
- ... register_heif_opener(); from PIL import Image ...
- try:
- with Image.open(file_path) as img:
- ... img.save(buf, format="JPEG", quality=90); return buf.getvalue()
- except ImageConversionError: raise
- except Exception as exc:
- raise ImageConversionError(f"Could not convert {file_path.name} to JPEG: {exc}") from exc
+def _prepare_pillow():
+ """Register the HEIF opener and return the Pillow Image module. ..."""
+ try: from pillow_heif import register_heif_opener
+ ... register_heif_opener(); from PIL import Image ...; return Image
+
+def _to_jpeg(source, *, source_name: str) -> bytes:
+ """Decode source (path or file-like) to JPEG bytes. ..."""
+ Image = _prepare_pillow()
+ try:
+ with Image.open(source) as img:
+ ... img.save(buf, format="JPEG", quality=90); return buf.getvalue()
+ except ImageConversionError: raise
+ except Exception as exc:
+ raise ImageConversionError(f"Could not convert {source_name} to JPEG: {exc}") from exc
+
+def convert_to_jpeg(file_path: Path) -> bytes:
+ """... delegates to _to_jpeg ..."""
+ return _to_jpeg(file_path, source_name=file_path.name)
+
+def convert_to_jpeg_bytes(data: bytes, *, source_name: str = "<in-memory image>") -> bytes:
+ """Bytes-accepting sibling of convert_to_jpeg. ..."""
+ return _to_jpeg(io.BytesIO(data), source_name=source_name)
--- a/src/loomworks/uploads/skills/image_vision_analysis.py
+++ b/src/loomworks/uploads/skills/image_vision_analysis.py
@@ imports @@
+from pathlib import Path
@@ inside transform(), after the late import of run_vision @@
+ from loomworks.files.conversion import (
+ ImageConversionError, convert_to_jpeg_bytes, needs_jpeg_conversion,
+ )
+ image_bytes = content
media_type = "image/jpeg"
if filename:
- detected = detect_vision_media_type(filename)
- if detected is not None:
- media_type = detected
+ if needs_jpeg_conversion(Path(filename)):
+ try:
+ image_bytes = convert_to_jpeg_bytes(content, source_name=filename)
+ except ImageConversionError as exc:
+ raise TransformationError(
+ f"image_vision_analysis could not convert {filename!r} to JPEG ...") from exc
+ media_type = "image/jpeg"
+ else:
+ detected = detect_vision_media_type(filename)
+ if detected is not None:
+ media_type = detected
@@ both run_vision(...) calls @@
- image_bytes=content,
+ image_bytes=image_bytes,
Next decision for this thread: push + open PR, or hold. It is correct and green, but not the fix for the reported 500 — confirm that's understood before merging (it shouldn't be sold as "fixes the upload 500").
The true cause of the upload 500, captured empirically (real server traceback, not inferred):
loomworks.storage.object_store.ObjectStoreError: MinIO bucket setup failed for 'loomworks-uploads'
at src/loomworks/storage/minio_backend.py:60 (_ensure_bucket)
← minio_backend.py:53 (__init__)
← src/loomworks/storage/factory.py:60 (build_object_store)
← src/loomworks/api/deps.py:84 (get_object_store) ← FastAPI dependency
← fastapi/dependencies/utils.py:678 (solve_dependencies)
underlying: urllib3 MaxRetryError HTTPConnectionPool(host='localhost', port=9000): Connection refused
Internal Server Error with no detail (NOT the uploads.py:419 "Executor failed unexpectedly: …" message the note describes).execute_upload catches TransformationError/TransformationSkillNotConfiguredError (executor.py:555,567) → returns ExecutorResult(status="transformation_failed"); operator_upload persists that and returns HTTP 200 with the per-file status (route is HTTP_200_OK; multi-file uploads report mixed per-file outcomes).
The note that needs rewriting is /Users/dunin7/loomworks-record/investigations/loomworks-upload-vision-conversion-bug-diagnosis-v0_1.md (committed 39f058f). Specifically wrong: Item-1 title ("…(the 500)"), the causal chain lines 38–49 (the → uploads.py:419 → HTTP 500 step), the key-line-ref line 62, and the "Fix part 2" framing line 67. Item 2 (AVIF double-classification) and the conversion-never-called finding remain valid — just reclassify Item 1's symptom as transformation_failed/HTTP-200, and add a new item for the real MinIO-down 500. Verbatim current text:
# Upload vision-conversion bug — diagnosis (v0.1)
**Status.** Verified diagnosis. Fix part 1 in progress on engine branch `fix-upload-vision-conversion-missing` (off `main`).
**Date.** 2026-06-02
**Environment.** Engine repo `DUNIN7/loomworks-engine`, `main` baseline. Found while tracing an upload 500 during CR-2026-097 verification.
**Method.** Pure-function reproduction — synthesized a 32×24 AVIF with PIL, exercised `convert_to_jpeg` and `image_vision_analysis.transform` (with `run_vision` stubbed; no API/network/DB). All line refs verified against `main`.
---
## Item 1 — `image_vision_analysis.transform` never converts; ships raw bytes mislabeled as `image/jpeg` (the 500)
**Symptom.** Uploading an AVIF (and, by the same path, HEIC / HEIF / TIFF) returns **HTTP 500** `"Executor failed unexpectedly: image_vision_analysis call 1 failed; call 2 not attempted"`.
**Root cause.** `src/loomworks/uploads/skills/image_vision_analysis.py::transform` claims (docstring, lines 193 & 195) to convert via `loomworks.files.conversion.convert_to_jpeg` "when needed" — but **the code never calls it**. `convert_to_jpeg` appears only in the docstring; there is no conversion and no `_needs_conversion` consultation in the skill.
The media-type selection (lines 212–216):
media_type = "image/jpeg" # default if filename: detected = detect_vision_media_type(filename) if detected is not None: media_type = detected
For any conversion-needing format, `detect_vision_media_type(filename)` returns `None`, so `media_type` silently falls through to the default `"image/jpeg"`, and the **raw, unconverted bytes** are sent to the Vision API (lines 220–225 / 265–269) mislabeled as JPEG.
Verified per-extension behaviour:
| Upload | `detect_vision_media_type` | `_needs_conversion` | media_type sent | bytes sent |
|---|---|---|---|---|
| `.avif` | `None` | **True** | `image/jpeg` ⚠️ | raw AVIF, unconverted |
| `.heic` | `None` | True | `image/jpeg` ⚠️ | raw HEIC |
| `.heif` | `None` | True | `image/jpeg` ⚠️ | raw HEIF |
| `.tiff` | `None` | True | `image/jpeg` ⚠️ | raw TIFF |
| `.jpg/.png/.webp` | correct | False | correct | correct ✅ |
**Causal chain (end to end).**
AVIF upload
→ uploads.py operator_upload → execute_upload → image_vision_analysis.transform
→ detect_vision_media_type(".avif") = None → media_type defaults to "image/jpeg"
→ run_vision(image_bytes=<raw AVIF>, media_type="image/jpeg") ← mislabeled, unconverted
→ Anthropic rejects malformed payload → ClaudeVisionError (claude_vision.py:167/182)
→ caught at transform:226 → raise TransformationError
→ uploads.py:419 blanket except Exception → HTTP 500
"Executor failed unexpectedly: …"
**Proven vs inferred.** Proven deterministically by the repro: `convert_to_jpeg` works for AVIF (produced 646 valid JPEG bytes, header `\xff\xd8\xff\xe0`), and `transform` never calls it — it sends raw AVIF bytes labelled `image/jpeg`. The final Anthropic-rejection step is inferred (no key/network in the repro), but the mislabeling is a defect regardless of how the API responds, and it matches a 500.
**Dependency theory ruled out.** `pillow_heif` 1.3.0 is installed and `PIL.features.check('avif')` is `True` on this machine — AVIF/HEIC decode works. The 500 is **not** a missing decoder.
**Key line refs.**
- `src/loomworks/uploads/skills/image_vision_analysis.py:193,195` — docstring claims conversion
- `…image_vision_analysis.py:212–216` — media_type default + detect fallthrough (the bug)
- `…image_vision_analysis.py:220–225, 264–269` — `run_vision(image_bytes=content, media_type=media_type)` (raw bytes)
- `…image_vision_analysis.py:226` — `ClaudeVisionError` → `TransformationError`
- `src/loomworks/uploads/vision/claude_vision.py:38` — native media types (no AVIF)
- `…claude_vision.py:54` — convert-first extension set
- `src/loomworks/api/routers/uploads.py:419–423` — blanket `except Exception` → 500 (see Item, fix part 2)
- `src/loomworks/files/conversion.py:58` — `convert_to_jpeg` (works; just never invoked by the skill)
**Fix part 1 (this branch).** Make `transform` actually convert when `_needs_conversion(filename)` is true, via a **bytes-accepting variant** of `convert_to_jpeg` (no temp-file staging), then send the converted bytes with `media_type="image/jpeg"`. Regression test: synthesize AVIF, assert `transform` converts and hands `run_vision` JPEG bytes + `image/jpeg` (stubbed `run_vision` captures the payload).
**Fix part 2 (held — decision pending).** Narrow the executor's blanket `except Exception` (`uploads.py:419`) so genuine format/conversion failures map to **415/422**, reserving 500 for true unexpected faults. Today every executor error — including clean format rejections — masquerades as a 500.
---
## Item 2 — AVIF is double-classified in `conversion.py` (separate inconsistency)
In `src/loomworks/files/conversion.py`, `.avif` appears in **both**:
- line 23 — `_BROWSER_RENDERABLE` (returned **untouched**): `{.jpg, .jpeg, .png, .gif, .webp, .avif}`
- line 31 — JPEG-conversion set `JPEG_CONVERSION_EXTS`: `{.heic, .heif, .tif, .tiff, .avif}`
So AVIF sits in both the "leave alone" set and the "must convert" set — contradictory. Whichever set a given code path consults first determines AVIF's routing; the file-retrieval `?format=jpeg` path and the vision path can therefore disagree on whether AVIF needs conversion. `convert_to_jpeg` also only calls `register_heif_opener()` (HEIF), relying on native PIL for AVIF — which works here but is undocumented as a dependency assumption.
**Held — decision pending.** Cleanup deferred with fix part 2. Resolve which set AVIF should belong to (likely: browser-renderable inline, but convertible when a JPEG is explicitly required) and make the two paths agree.
---
## Scope note
This is **outside CR-2026-097**. It was found during that CR's verification but is an independent upload-pathway defect; it lives on its own branch (`fix-upload-vision-conversion-missing`), not the CR WIP branch. Relates to the prior upload-pathway-cascade work (engine `040d675`).
Correction to make (when you do it): keep Item 2 and the "transform never converts" finding; demote Item 1's symptom from "HTTP 500" to "transformation_failed (HTTP 200, swallowed by executor.py:567)"; add a new Item documenting the real 500 = MinIO-down at deps.py:84/minio_backend.py:60 (now resolved + made durable); fix the uploads.py:419/fix part 2 framing (that except isn't even on the 500 path). Bump to v0.2.
Context: an image upload with no confident chain match comes back needs_clarification. The OL renders AskOperatorToClarifyCard. An Operator clicked Confirm; the UI showed "Noted — skipped." Engine state showed the card writes nothing (no chain selected, no skill ran, no version bump) — by design, per the card's own header: "Resolution callback is v1 local-state only — engine re-dispatch is a future-phase capability."
resolutionLabel null-skill → "skipped" fallthrough (frontend cosmetic bug)
File: /Users/dunin7/loomworks/src/components/chat/AskOperatorToClarifyCard.tsx
The Confirm button passes classification.classified_label as the skill; when the classifier produced all-zero scores, classified_label is null, so it calls resolve("confirm", null). resolutionLabel has no branch for confirm + null skill, so it falls through to "skipped":
// Confirm button (lines ~78–85):
onClick={() => resolve("confirm", classification.classified_label)} // classified_label === null here
// resolutionLabel (lines 107–114):
function resolutionLabel(action: ClarifyAction, skill: string | null): string {
if (action === "confirm" && skill) return `confirmed ${skill}`; // skill null → FALSE
if (action === "pick") return "you'll pick a different skill";
return "skipped"; // ← confirm+null lands here
}
Verdict: NOT mis-wired to skip, NOT an intentional "confirm-with-all-zero-scores → skip". The action recorded in local state is genuinely "confirm"; only the label is wrong. Fix: add a confirm-with-null-skill branch (e.g. "confirmed"). Note the card is only used at UploadResultCard.tsx:165 and is passed no onResolved, so all three actions are engine no-ops today regardless.
classified_label (engine; touches a settled commitment)
File: /Users/dunin7/loomworks-engine/src/loomworks/uploads/executor.py, classify_purpose_for_chains (line 186).
framing_kind defaults to "accuracy" (executor.py:109, upload_event.py:123). Only the tied-score branch sets framing_kind="purpose" (line 281). The two no-confident-candidate branches leave the default:
# no purpose declaration (lines 211–219): classified_chain=None, above_threshold=False → framing defaults "accuracy"
# no keyword match (lines 241–252): max_score==0 → classified_chain=None, above_threshold=False → framing defaults "accuracy"
# tied top score (lines 254–282): classified_chain=None → framing_kind="purpose" ← only explicit set
# unique match (lines 284+): classified_chain=X, above_threshold=True
So when there is no confident candidate, the engine emits framing_kind="accuracy" with classified_label=None. The OL accuracy card (AskOperatorToClarifyCard.tsx, rendered when framing_kind==="accuracy") then shows "I want to make sure I read this right. I think this is —." — an accuracy-question with nothing to confirm. The accuracy framing is defined for "the classifier has a candidate it's confident enough to propose" (card header + CR-2026-093 v0.3 §13.1 "two-surface separation": accuracy-question vs purpose/discovery). Emitting accuracy with a null label violates that settled separation — the no-match case should arguably use framing_kind="purpose" (discovery), like the tie case. This is the one finding here that touches a settled commitment.
Open question: When the object-store dependency get_object_store (deps.py:84) fails because MinIO/S3 is unreachable, the upload endpoint returns a bare HTTP 500 (unhandled, in dependency resolution). Should this instead be a clean 503 Service Unavailable?
Context to decide it:
operator_upload maps put_blob's ObjectStoreError → 503 (uploads.py:443). The dependency-level failure (get_object_store → _ensure_bucket) just isn't covered, so it leaks as 500. Making it 503 would be consistent with existing convention, not a new design.except Exception at uploads.py:419 to map format failures → 415/422) turned out moot: that except is not on the 500's path, and conversion/transform failures are caught earlier (executor.py:567) and returned as HTTP-200 transformation_failed, not raised. So "Part 2" as originally framed should be dropped/replaced by this 503 question.detection_failed/transformation_failed/needs_clarification) so multi-file uploads can report mixed outcomes. Any "make format-failures a 4xx" idea collides with that design — a single HTTP status can't represent partial per-file failure. The 503 question is different and safe: it's about a whole-request infrastructure outage (no per-file outcome exists yet), so a 503 is coherent.get_object_store (or build_object_store) so ObjectStoreError → HTTPException(503), plus a test. Small, isolated; not tied to Thread 1's branch.accuracy framing with a null candidate breaks that contract.uploads.py:443); the related "format-failure → 4xx" idea collides with the settled 200 + per-file status upload-response design — so the 503 reframing is the safe path, not 4xx-for-format.
Before changing clarification/upload behaviour, check the settled foundation doc: /Users/dunin7/loomworks-record/candidate-seeds/loomworks/loomworks-candidate-seed-v0_9.md.
| Thread | Where | State |
|---|---|---|
| 1. Part-1 conversion fix | engine branch fix-upload-vision-conversion-missing (bd05d5c) | committed, green; not pushed, not merged |
| 2. Record-note correction | loomworks-record/investigations/loomworks-upload-vision-conversion-bug-diagnosis-v0_1.md (39f058f) | needs rewrite to MinIO-down truth; bump to v0.2 |
| 3A. resolutionLabel fallthrough | loomworks/src/components/chat/AskOperatorToClarifyCard.tsx:107–114 | unrecorded; trivial frontend fix |
| 3B. accuracy-framing + null label | loomworks-engine/src/loomworks/uploads/executor.py:186 (classify_purpose_for_chains) | unrecorded; touches settled §13.1 separation |
| 4. object-store-outage → 503 | loomworks-engine/src/loomworks/api/deps.py:84 + uploads.py:443 precedent | open question; "Part 2 / 4xx" framing is dropped |