NEXT: a dictation recording's upload keeps its send, its own file and its own chat

TE
7d ago
+42-16

After #26953 landed (e1f81d2a)

#26953 landed as a squash that already carries two of this PR's three fixes, so the merge of feat-cmux-next (3e5cc3aaca5) keeps base's code for them:

  • The single-descriptor upload: AgentPaneDictationUpload.Recording.open opens with O_NOFOLLOW and checks the device/inode identity the recording closed with (DictationAudio.fileIdentity).
  • A host-made chip keeps its chat across a carried switch (PendingAttachment.bound).
  • The chip's × already stops the host's upload (dictation.recording.cancel), so the "Known limit" below is gone.

This PR now adds only fix 1: a host-made chip (bound) holds the waiting send even once its upload failed. A successful Retry lets the send go with the recording, and the chip's × lets the text go alone (attachmentIntake.tsx loading/unsettled; tests in dictation-recording-hold.test.tsx). Dropped and pasted files keep their behaviour. Base's test "a failed upload ... the waiting send goes without it" was replaced, because it pinned the behaviour this PR changes.

The sections below describe the PR before the merge.

Depends, for recordings over today's stager caps: on #26409 (removes the peer stager's 1 GiB cap) and on #27073 (removes the Cloud stager's 32 MiB per-file / 64 MiB total cap in CmuxNextApp/Cloud/CloudUploads.swift, and the remaining peer maximumPeerFileBytes checks). Until those land, a recording past a cap fails at .begin (upload_refused / too_large); with this PR that failure now blocks the waiting send instead of dropping the audio.

Leo's standing order: "Attachments take arbitrarily large files of any type; there is no size cap" (Leo, 2026-10-25/26).

What

#26953 sends a long dictation recording to the machine of a chat that runs elsewhere (an ssh peer's, a Cloud chat's) with dictation.recording.upload {path}, and attaches the saved chip only when the host answers. A prompt sent before that answer (Return, the Send button, or dictation's auto-send, which fires right after the session ends) went without the recording.

Now the recording is a pending chip from the moment the session ends, through the composer's existing pending-chip machinery, not a parallel one:

  • useAttachmentIntake gains attachLater(name, make, failure) (composer/attachmentIntake.tsx). It adds a PendingAttachment (the same loading tile a streaming drop shows, with a spinner and ×). useSendWhenAttached waits for it exactly as it waits for a file still being read. The tile is committed before attachLater returns (flushSync), so the auto-send made in the same task waits too.
  • The host's answer turns the tile into the saved chip. A × drops the tile, the waiting send goes without it, and a late answer stages nothing. A failed upload (a stager that refuses the size at begin, a dropped machine) leaves the tile with its note (dictation.recordingElsewhere) and Retry, and the waiting send stays blocked (PendingAttachment.holdsSend, counted in loading/unsettled even once failed): a successful Retry lets it go with the audio, and the chip's × lets the text go alone. Dropped and pasted files keep their behavior (a failed one does not hold a send). A tile whose chat the composer has left goes to that chat's kept chips (elsewhere), as a drop's does.
  • ComposerHandle.attachLater exposes it; App.tsx's dictation attach uses it for the remote case instead of awaiting the upload and attaching after.
  • Swift (small): the host takes a recording once per dictation.recording.upload. A failed upload now gives it back for its own chat (AgentPaneModel.onDictationRecordingUnsent → AgentPaneDictation.giveBackRecording), so Retry is not refused with dictation.not_a_recording. A path the pane never issued is still refused before anything is read.

Known limit: a × while the host is mid-upload does not stop that host upload; its copy lands on the chat's machine and is not attached.

Stack change (review of #26953)

This PR's first pair moved down into #26953, as a fast-forward of #26953's head: the remote recording as a pending chip that a send waits for (attachLater, red 3476359694c, green d8011ea9a4d). #26953 alone had dropped the recording from an auto-sent prompt. The "What" section above now describes #26953's part. This PR now holds three fixes:

  1. A failed upload keeps the waiting send blocked (holdsSend). Red 6c080dec186fd5264f8e2857e2b6c46eb1cbdfea, green 6a8343f8c56f1bd0c51bf06d2ebcb067dd22985c; described under "Review fix" below.
  2. The upload reads only the file it opened. AgentPaneDictationUpload used to check the path and then reopen the path for every chunk. If the issued recording was swapped for a symlink after the check, the upload read whatever file the link named and sent it to the chat's machine. Now the recording is opened once with O_NOFOLLOW and checked as a regular file through fstat on that descriptor. Its size comes from the same descriptor, and every chunk is read from it. The path is never opened again.
  3. A pending host upload keeps its chat. A carried switch (a harness switch's new session takes the prompt) used to move a pending attachLater chip and its batch to the new chat. The upload's answer, a path on the old chat's machine, then landed in the new chat. Now a host-made chip (holdsSend, batch hostMade) keeps its scope. Its answer goes to that chat's kept chips (elsewhere), never to the new chat. Re-running the upload against the new chat would be refused, because the host issues a recording only to its chat. Read and dropped files still follow a carried switch.
  • Red for 2 and 3: cf1012e30953dbe5e0ac5f3df39690c9d5e61ba0, no stubs (only a test hook on the stager).
    • Web: CMUX_WEB_TEST_RETRIES=0 CMUX_WEB_TEST_FILES="test/dictation-recording-hold.test.tsx" ../scripts/ci/run-webviews-tests.sh gives 4 pass, 1 fail ("a carried switch does not move a pending recording's upload to the new chat").
    • Fleet: swift test --filter AgentPaneDictationUploadTests should fail aRecordingSwappedForASymlinkMidUploadSendsOnlyItsOwnBytes, because the link's bytes go. aRecordingThatIsASymlinkIsRefused is expected to pass already: attributesOfItem does not follow the last path component. It keeps that behaviour from regressing.
  • Green: 588887b1fe597ca2e1e52f4ab815eeffeac4e965 (head).
    • Web per file: dictation-recording-hold 5/5, dictation-audio, composer-send-hold, composer-intake-events, composer-kept-attachments pass. tsc, oxlint, oxfmt (printWidth 120), check-concurrency.sh and prepush-gate are clean.
    • Fleet: the same filter should pass in full. Not compiled here: openRegular (open/fstat, import Darwin) and the nonisolated struct Opened: @unchecked Sendable.

Red / green

Restacked onto #26953's 50cd05879731ac815ad99b57d92c6c60e2e35cb4 (a recording is issued to the chat it was recorded in) and #26559's 5366f70396f (write failures). New SHAs:

  • Red 1344769916a1bd1cbb861c2eb303d4dcfdb494a4, green a80186800cff925a6ff3af5cebed35e03efc80f2.
  • Review-fix red d3f6046f1b82cd0216b429cd51f145a409fcc106, green (head) bffa70f164af0ceff2872d23966dbf77096408a4.

The diff is the old one except two dictation.deliver(...) calls in AgentPaneDictationUploadTests, which now pass recordedIn: "chat-a" (the #26953 signature). At the head: tsc is clean, and dictation-recording-hold, dictation-audio and composer-send-hold pass per file. Fleet: swift test --filter AgentPaneDictationUploadTests at both reds (the earlier cases fail on their expects; aStagerThatRefusesTheSizeFailsAndGivesTheRecordingBackForRetry passes at its red) and passes in full at the head.

The SHAs below are the ones before the restack:

Review fix (BLOCK: "a waiting send goes ahead without its audio"):

  • Red: 1b0a3d43e6bc6f17f596c71cb626c39e50a83db3
    • CMUX_WEB_TEST_RETRIES=0 CMUX_WEB_TEST_FILES="test/dictation-recording-hold.test.tsx" ../scripts/ci/run-webviews-tests.sh: 2 pass, 2 fail, both on expect(sent).toEqual([]) (the send went with [] after the failure): "a failed upload keeps the waiting send blocked with the failed chip; Retry's answer lets it go with the audio" and "the failed recording's x lets the waiting send go with the text alone".
    • Swift AgentPaneDictationUploadTests.aStagerThatRefusesTheSizeFailsAndGivesTheRecordingBackForRetry(code:) (upload_refused, too_large): a fake stager that refuses at .begin as the real Cloud and peer stagers do, wired to the pane's real AgentPaneDictation take/give-back. It checks the reply is that failure, nothing is staged, the recording is given back, and Retry (with the limit gone) sends it whole. The host side already did this at 1f5b2f4e869, so this case is expected to pass at red too: it pins the behavior, the red is the web one.
  • Green: 1b246e2cc82c2146a83bbd38dd2d245bec8e3b12
    • Same command: 4 pass. dictation-audio, composer-send-hold, composer-intake-events: pass. composer-paste-images fails the same 5 tests with the change stashed (image chips time out on this VM).

Earlier:

  • Red: 29668f7413c42d791b0bc0f6f889f5ebf769aca4. Tests plus stubs that compile, so the tests fail on their assertions, not on the build or a missing function.
    • Stubs: web useAttachmentIntake().attachLater (shows and makes nothing); Swift AgentPaneDictation.giveBackRecording(_:session:) (does nothing) and AgentPaneModel.onDictationRecordingUnsent (never called).
    • bun node_modules/.bin/tsc --noEmit -p .: clean.
    • cd webviews && CMUX_WEB_TEST_FILES="test/dictation-recording-hold.test.tsx" ../scripts/ci/run-webviews-tests.sh: 0 pass, 3 fail, each on an expect (no pending chip; the send goes at once).
    • It adds Swift AgentPaneDictationUploadTests cases (a failed upload gives the recording back, and a given-back recording can be taken again once for its chat). Mac run needed: swift test --filter AgentPaneDictationUploadTests in Packages/macOS/CmuxNext should compile and fail on those #expects.
  • Green: 1f5b2f4e86910ca33b0c30b222bf50c080d350c5 (the fix; the tree equals the old head e3559bb3450 plus #26559's new head a31b4ac4c62).
    • bun node_modules/.bin/tsc --noEmit -p .: clean.
    • cd webviews && CMUX_WEB_TEST_FILES="test/dictation-recording-hold.test.tsx" ../scripts/ci/run-webviews-tests.sh: 3 pass (send waits and carries the remote copy; × lets the waiting send go without it; failure shows the note and Retry, and Retry's answer becomes the chip). dictation-audio.test.tsx still passes 11/11.
    • Mac run needed: the same Swift filter should pass.

Changelog

  • Fixed: A prompt sent while a long voice recording is still uploading to a remote chat's machine now waits for it instead of going without the recording.

Observed / Not run

Observed (lane VM, Node 18, so vp was not run):

  • bun node_modules/.bin/tsc --noEmit -p .: clean.
  • bun node_modules/.bin/oxlint on the changed web files: exit 0.
  • oxfmt --check with {"printWidth":120} on the changed web files: clean.
  • bun test test/dictation-recording-hold.test.tsx test/dictation-audio.test.tsx test/composer-send-hold.test.tsx: all pass.
  • Full bun test (this VM has no browser and many timing and i18n-order reds): head 4604 pass / 779 fail, base f4844fbfc67 4589 pass / 780 fail. One failure appears only at head: dictation-audio.test.tsx "a recording kept out by the byte cap..." got the key dictation.recordingTooLong instead of its text. That is the same missing-translation-table order pollution that fails changes-tree-empty on both. The file passes 11/11 alone, three runs each at head and base, and also when run with the new test.
  • python3 scripts/cmux-next/prepush-gate.py: no new violations.
  • At 1b246e2cc82 (review fix): tsc clean; oxlint exit 0 and oxfmt (printWidth 120) clean on App.tsx, Composer.tsx, attachments.ts, composer/attachmentIntake.tsx and the test; prepush-gate no new violations.

Not run:

  • Swift: not compiled here (lane VM). AgentPaneDictation.swift, AgentPaneDictationUpload.swift, AgentPaneModel.swift, AgentPaneView.swift and AgentPaneDictationUploadTests need a Mac compile and test run. Including the new aStagerThatRefusesTheSizeFailsAndGivesTheRecordingBackForRetry at 1b0a3d43e6bc6f17f596c71cb626c39e50a83db3 and 1b246e2cc82c2146a83bbd38dd2d245bec8e3b12: swift test --filter AgentPaneDictationUploadTests in Packages/macOS/CmuxNext.
  • No screenshot: the pending, failed and Retry tiles are the existing PendingChip states, unchanged. No real recording was sent to an ssh peer or Cloud chat.

Agent session: agent session

🤖 Generated with Claude Code