fix(vision): bounded wait for in-flight VDS description on first message (#970 stage 1)#973
fix(vision): bounded wait for in-flight VDS description on first message (#970 stage 1)#973
Conversation
…age (#970 stage 1) PersonaResponseGenerator only attached the VDS description to the IPC signal when descriptionStatus(base64) === 'cached'. On the first message with a fresh image, pre-warm started at chat-send time is still 'inflight' when the persona reaches this code path — so description was undefined, the Rust signal carried { ..., description: undefined }, and text-only personas had no marker to read. Empirical hit on PR #950: CodeReview AI confidently said "absence of images or attachments" when an image WAS attached. Fix: also wait when status is 'inflight'. VDS already deduplicates in-flight requests, so the await piggybacks on the existing pre-warm — no extra inference cost. 8s ceiling protects against a stuck pre-warm (LLaVA on CPU is the slow case at 60-70s; the timeout is the safety valve, not the expected path). Stage 2 (Rust-side marker injection for the description=None case) still tracked in #970 — when description IS undefined despite the bounded wait, the Rust signal-to-ContentPart conversion should emit "[Attached image: vision description unavailable]" instead of silently dropping the image, so the persona knows an image exists rather than fabricating absence. That's a Rust change, separate scope. Validated locally: npm run build:ts → clean. Refs #970. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adjusts PersonaResponseGenerator to avoid dropping image descriptions on the first message after a fresh start by waiting (bounded) for an in-flight VisionDescriptionService (VDS) description instead of only accepting already-cached results.
Changes:
- Wait up to 8s for VDS when image description status is
'inflight'(in addition to'cached'). - Use a
Promise.racetimeout to bound the wait and keep description fetch best-effort.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Status `none` / `error` → don't trigger a blocking describe | ||
| // here; the chat-send path is responsible for prewarming. Stage | ||
| // 2 (Rust-side) is responsible for emitting an [Attached image: |
There was a problem hiding this comment.
The comment mentions status error, but VisionDescriptionService.descriptionStatus() currently only returns 'cached' | 'inflight' | 'none' (no error state). Please update the comment to match the actual status values so future readers don’t assume there’s an error state to handle here.
| // Status `none` / `error` → don't trigger a blocking describe | |
| // here; the chat-send path is responsible for prewarming. Stage | |
| // 2 (Rust-side) is responsible for emitting an [Attached image: | |
| // Status `none` → don't trigger a blocking describe here; the | |
| // chat-send path is responsible for prewarming. Stage 2 | |
| // (Rust-side) is responsible for emitting an [Attached image: |
| if (status === 'cached' || status === 'inflight') { | ||
| const VDS_WAIT_MS = 8000; | ||
| const desc = await Promise.race([ | ||
| visionSvc.describeBase64(base64, m.mimeType ?? 'image/png', { maxLength: 200 }), | ||
| new Promise<null>((resolve) => setTimeout(() => resolve(null), VDS_WAIT_MS)), | ||
| ]); | ||
| description = desc?.description; |
There was a problem hiding this comment.
Promise.race([... setTimeout(...)]) creates a timer that is never cleared/unref’d; even when the description resolves immediately (especially for status === 'cached'), the pending 8s timer still keeps the event loop alive and can accumulate under load. Consider: (1) only using the timeout path for 'inflight' (skip creating a timer for 'cached'), and (2) clearing the timeout (or using unref() where available) once the describe promise resolves first.
| if (status === 'cached' || status === 'inflight') { | |
| const VDS_WAIT_MS = 8000; | |
| const desc = await Promise.race([ | |
| visionSvc.describeBase64(base64, m.mimeType ?? 'image/png', { maxLength: 200 }), | |
| new Promise<null>((resolve) => setTimeout(() => resolve(null), VDS_WAIT_MS)), | |
| ]); | |
| description = desc?.description; | |
| if (status === 'cached') { | |
| const desc = await visionSvc.describeBase64(base64, m.mimeType ?? 'image/png', { maxLength: 200 }); | |
| description = desc?.description; | |
| } else if (status === 'inflight') { | |
| const VDS_WAIT_MS = 8000; | |
| let timeoutHandle: ReturnType<typeof setTimeout> | undefined; | |
| try { | |
| const timeoutPromise = new Promise<null>((resolve) => { | |
| timeoutHandle = setTimeout(() => resolve(null), VDS_WAIT_MS); | |
| if (typeof timeoutHandle.unref === 'function') { | |
| timeoutHandle.unref(); | |
| } | |
| }); | |
| const desc = await Promise.race([ | |
| visionSvc.describeBase64(base64, m.mimeType ?? 'image/png', { maxLength: 200 }), | |
| timeoutPromise, | |
| ]); | |
| description = desc?.description; | |
| } finally { | |
| if (timeoutHandle) { | |
| clearTimeout(timeoutHandle); | |
| } | |
| } |
| const visionSvc = VisionDescriptionService.getInstance(); | ||
| if (visionSvc.descriptionStatus(base64) === 'cached') { | ||
| const desc = await visionSvc.describeBase64(base64, m.mimeType ?? 'image/png', { maxLength: 200 }); | ||
| const status = visionSvc.descriptionStatus(base64); | ||
| if (status === 'cached' || status === 'inflight') { | ||
| const VDS_WAIT_MS = 8000; | ||
| const desc = await Promise.race([ | ||
| visionSvc.describeBase64(base64, m.mimeType ?? 'image/png', { maxLength: 200 }), | ||
| new Promise<null>((resolve) => setTimeout(() => resolve(null), VDS_WAIT_MS)), | ||
| ]); | ||
| description = desc?.description; | ||
| } |
There was a problem hiding this comment.
Gating on descriptionStatus(base64) risks missing fast cache hits: descriptionStatus() only reflects the TS L1 + in-flight map, and does not consult Rust L1.5 or the on-disk sidecar cache that describeBase64() can return from quickly. This means a TS-cold start (or a race where prewarm hasn’t registered inflight yet) can still produce status === 'none' even when describeBase64() would return immediately without inference. Consider adding a cached-only accessor that checks Rust/sidecar (no inference), or broadening this path to attempt a bounded describeBase64() when status is none but a cache hit is likely.
Summary
[Attached image: <desc>]marker missing on user-role msg for text-only personas #970 (TS-side). Stage 2 (Rust-side marker for the description=None case) still tracked in vision:[Attached image: <desc>]marker missing on user-role msg for text-only personas #970.Background — empirical hit (Anvil, PR #950 / 2026-04-25)
Root cause (TS-side)
PersonaResponseGenerator.ts:380-391only attached the VDS description whendescriptionStatus(base64) === 'cached'. On the first message with a fresh image, pre-warm started at chat-send time is still'inflight'when the persona reaches this code — sodescriptionstaysundefined, the Rust IPC signal carries{ ..., description: undefined }, and the text-only persona's user-role message has no image marker.Fix
Wait when status is
'cached'OR'inflight':8s ceiling protects against a stuck pre-warm. LLaVA on CPU at 60-70s would still time out and leave
descriptionundefined — that's where Stage 2 (Rust marker injection) takes over.Test plan
npm run build:ts— clean.Refs
[Attached image: <desc>]marker missing on user-role msg for text-only personas #970 (parent issue, both stages)