Skip to content

fix(copilot): disambiguate VFS upload paths to prevent stale-row reads#4454

Merged
TheodoreSpeaks merged 2 commits intostagingfrom
fix-vfs-upload-collision
May 5, 2026
Merged

fix(copilot): disambiguate VFS upload paths to prevent stale-row reads#4454
TheodoreSpeaks merged 2 commits intostagingfrom
fix-vfs-upload-collision

Conversation

@TheodoreSpeaks
Copy link
Copy Markdown
Collaborator

Summary

  • Same-name chat attachments (e.g. multiple image.png screenshots) collided on the VFS path uploads/<originalName>. The agent's read("uploads/image.png") returned whichever row Postgres ordered first (no ORDER BY, LIMIT 1), causing it to read stale uploads instead of the latest one (real prod incident: trace 71f719d884194b6820851e2267b98cda).
  • Add display_name column on workspace_files + partial unique index on (chat_id, display_name) WHERE context='mothership'. Stable per-row for the row's lifetime — paths don't shift even if rows get hard-deleted.
  • trackChatUpload allocates a free displayName at insert time (image.png, then image (2).png, image (3).png, ...) with retry-on-23505 against the unique index. Returns the assigned name so the read-hint string given to the model uses it.
  • Resolver matches by displayName and keeps the normalizeVfsSegment fallback for macOS U+202F vs ASCII space in screenshot filenames.
  • Migration backfills existing rows: row-number-partitioned over (chat_id, original_name) in upload order; non-mothership rows get display_name = original_name. Then SET NOT NULL and create the partial unique index.

Type of Change

  • Bug fix

Testing

  • New tests: apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts (8 cases — exact match, suffixed match, U+202F fallback, list ordering, read flow) and apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts (9 cases — suffixedName for extensions/extensionless/dotfiles, UPDATE-existing path, INSERT path, 23505 retry, non-unique error rethrow).
  • All 29 related tests pass; vfs.test.ts and workspace-file-manager.test.ts still green.
  • bun run lint, bun run type-check, bun run check:api-validation:strict all pass.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel
Copy link
Copy Markdown

vercel Bot commented May 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped May 5, 2026 9:27pm

Request Review

@cursor
Copy link
Copy Markdown

cursor Bot commented May 5, 2026

PR Summary

Medium Risk
Adds a new DB column + unique index and changes chat-upload name resolution/materialization behavior; risk is mainly around migration/uniqueness constraints and edge cases for legacy rows and concurrent uploads.

Overview
Fixes ambiguous uploads/<name> resolution for same-name chat attachments by introducing a per-chat displayName for workspace_files mothership uploads and using it as the VFS-visible filename.

trackChatUpload now allocates and returns a collision-free displayName (image.png, image (2).png, …) with retry-on-unique-violation, and chat payload hints (read("uploads/..." ), materialize_file) switch to the returned name.

Upload lookup/listing is updated to resolve by displayName (with legacy originalName fallback, deterministic ordering, and normalized-segment fallback), and materialize_file uses displayName when presenting names and preserves it by copying into originalName on save. Includes a migration adding display_name plus a partial unique index, and adds focused unit tests for the new resolver and naming/collision behavior.

Reviewed by Cursor Bugbot for commit 3ba5928. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread packages/db/migrations/0202_hard_grey_gargoyle.sql Outdated
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR fixes a real production bug where same-named chat attachments collided on the VFS path uploads/<originalName>, causing stale reads. The solution introduces a display_name column with a partial unique index and a retry-on-23505 allocation loop in trackChatUpload, so every active (chat_id, display_name) pair is unique.

  • Schema + migration: display_name column added as nullable, backfilled with ROW_NUMBER()-based suffix logic that matches the TypeScript suffixedName implementation, then promoted to NOT NULL; partial unique index created last.
  • trackChatUpload: rewrites the update+insert flow as a retry loop that allocates a free displayName at insert time and returns it to callers; buildCopilotRequestPayload now uses displayName in the VFS read hint.
  • Resolver + materialize paths: findMothershipUploadRowByChatAndName and toFileRecord switch from originalName to displayName; executeSave sets originalName = row.displayName so the materialized workspace file carries the disambiguated name.

Confidence Score: 4/5

Safe to merge after addressing the UPDATE guard in trackChatUpload; all other changes are straightforward and well-tested.

The core logic in trackChatUpload has an UPDATE path that doesn't restrict itself to workspace-scoped rows. On a re-call for a row already flipped to mothership context, the loop restarts at n=1. If sibling rows with earlier display names were soft-deleted between calls, the row can be assigned a new, lower-numbered display name — silently invalidating the path the model was told to use. The rest of the change — migration backfill, schema index, resolver switch, tests — is correct and well-constructed.

workspace-file-manager.ts deserves a second look on the UPDATE WHERE clause; materialize-file.ts has a minor omission worth tidying.

Important Files Changed

Filename Overview
apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts Adds suffixedName helper and rewrites trackChatUpload with a retry loop that allocates a collision-free displayName via the DB unique index (23505 catch); UPDATE WHERE clause is missing a chatId IS NULL guard.
apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts Switches resolution from originalName to displayName; adds orderBy(uploadedAt, id) for deterministic listing; fallback normalized scan unchanged.
apps/sim/lib/copilot/tools/handlers/materialize-file.ts Uses displayName for toFileRecord name and sets originalName: row.displayName during materialization; displayName column not explicitly set in the UPDATE (values already equal, no functional impact).
packages/db/migrations/0202_hard_grey_gargoyle.sql Safe add-nullable → backfill → NOT NULL pattern; SQL suffix logic matches the TypeScript suffixedName function exactly; partial unique index conditions align with the Drizzle schema definition.
packages/db/schema.ts Adds displayName: text('display_name').notNull() to workspaceFiles; new partial unique index (chat_id, display_name) WHERE deleted_at IS NULL AND context='mothership' AND chat_id IS NOT NULL matches the migration SQL exactly.
apps/sim/lib/copilot/chat/payload.ts Destructures displayName from trackChatUpload return value and uses it in the VFS read hint and JSON/workflow hints instead of the raw filename.
apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts New test file covering exact match, suffixed match, U+202F fallback, list ordering, and read flow — 8 well-structured cases.
apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts New test file covering suffixedName edge cases and trackChatUpload UPDATE/INSERT/retry/error paths — 9 cases with correct mock setup.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[buildCopilotRequestPayload
called for chat attachment] --> B[trackChatUpload]
    B --> C{UPDATE existing row
by S3 key?}
    C -->|row found| D{23505 unique
violation?}
    C -->|no row found| E[INSERT new row
displayName = candidate]
    D -->|no| F[return displayName]
    D -->|yes - displayName taken| G[increment n
retry with suffixedName]
    E --> H{INSERT 23505?}
    H -->|no| F
    H -->|yes| G
    G --> C
    F --> I[payload hint:
read uploads/displayName]
    I --> J[findMothershipUploadRowByChatAndName
exact match on displayName]
    J --> K[read correct file
never a stale row]
Loading

Reviews (1): Last reviewed commit: "fix(copilot): disambiguate VFS upload pa..." | Re-trigger Greptile

Comment thread apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts
Comment thread apps/sim/lib/copilot/tools/handlers/materialize-file.ts Outdated
Comment thread packages/db/migrations/0202_superb_abomination.sql Outdated
Comment thread apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts
Comment thread apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 658dc6f. Configure here.

Comment thread packages/db/migrations/0202_fuzzy_darkstar.sql
Same-name chat attachments (e.g. multiple "image.png" screenshots) collided
on the VFS path uploads/<originalName>. The agent's read("uploads/image.png")
returned whichever row Postgres ordered first — leading to the agent reading
stale uploads instead of the latest one (real prod incident: trace
71f719d884194b6820851e2267b98cda).

Adds a nullable display_name column on workspace_files plus a partial unique
index on (chat_id, display_name) WHERE context='mothership' that spans the
row's entire lifetime (NOT gated on deleted_at IS NULL). VFS paths handed to
the LLM during a session must remain stable — soft-deleting a sibling cannot
free a name slot the model has already been told about, since that would
make read("uploads/<name>") silently resolve to a different file.

Migration is a clean two-statement ADD COLUMN + CREATE UNIQUE INDEX with no
backfill. Legacy rows keep display_name = NULL; readers coalesce to
original_name. Pre-existing collisions in legacy data persist (the upload
already happened — no fix possible without rewriting history) but new
uploads get full disambiguation. NULLs are distinct in PG unique indexes,
so legacy rows don't block index creation or new inserts.

trackChatUpload allocates "image.png", then "image (2).png", "image (3).png",
... with retry on 23505. Returns the assigned displayName so the read-hint
string given to the model uses it. Resolver matches displayName for new rows
and falls back to original_name for legacy NULL rows, preserving the
normalize-segment fallback for macOS U+202F screenshot names.
Same-name chat attachments (e.g. multiple "image.png" screenshots) collided
on the VFS path uploads/<originalName>. The agent's read("uploads/image.png")
returned whichever row Postgres ordered first — leading to the agent reading
stale uploads instead of the latest one (real prod incident: trace
71f719d884194b6820851e2267b98cda).

Adds a nullable display_name column on workspace_files plus a partial unique
index on (chat_id, display_name) WHERE context='mothership' that spans the
row's entire lifetime (NOT gated on deleted_at IS NULL). VFS paths handed to
the LLM during a session must remain stable — soft-deleting a sibling cannot
free a name slot the model has already been told about, since that would
make read("uploads/<name>") silently resolve to a different file.

Migration is a clean two-statement ADD COLUMN + CREATE UNIQUE INDEX with no
backfill. Legacy rows keep display_name = NULL; readers coalesce to
original_name. Pre-existing collisions in legacy data persist (the upload
already happened — no fix possible without rewriting history) but new
uploads get full disambiguation. NULLs are distinct in PG unique indexes,
so legacy rows don't block index creation or new inserts.

trackChatUpload allocates "image.png", then "image (2).png", "image (3).png",
... with retry on 23505 narrowed to the displayName index (other 23505s, e.g.
key-active collisions from concurrent same-s3Key inserts, rethrow). Returns
the assigned displayName so the read-hint string given to the model uses it.
Resolver matches displayName for new rows, falls back to original_name for
legacy NULL rows, and orders by uploaded_at DESC so legacy ambiguity resolves
to the newest upload (which fixes the original prod symptom even pre-fix).
@TheodoreSpeaks TheodoreSpeaks force-pushed the fix-vfs-upload-collision branch from e451060 to 3ba5928 Compare May 5, 2026 21:27
@TheodoreSpeaks TheodoreSpeaks merged commit c09e0a0 into staging May 5, 2026
14 checks passed
@TheodoreSpeaks TheodoreSpeaks deleted the fix-vfs-upload-collision branch May 5, 2026 21:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant