fix(execution): classify SDK run aborts and refuse doomed provider and media requests before sending

Merged
WA
23d ago
+994-155

Summary

  • Stopped Agent runs no longer fail the block. The Anthropic, OpenAI and Groq SDKs throw APIUserAbortError with name === 'Error', so the name check ('AbortError', plus an 'APIUserAbortError' name check that could never match) missed every SDK abort. A user Stop therefore painted the Agent block failed and logged it at ERROR as internal. New isRunAbortError(error, signal) recognises the SDK classes by instanceof and treats the run's abort signal as the deciding fact: an abort-shaped error while the run is still live stays a real failure. BlockExecutor and runtime-context now use it.
  • A stopped Gemini/fetch-based Agent is no longer reported as "Provider request timed out". AgentBlockHandler.handleExecutionError rewrote any AbortError into a transport-timeout message marked third_party_server, user Stops included. A stopped run's abort now passes through unchanged. A run-timeout abort is still rewritten as before.
  • Provider catch logs use failure-log severities. providerFailureLogLevel: a user Stop logs at info, a run time limit at warn, a UserFailure at info, and everything else still at error. This applies to every provider's Error in X request catch and to OpenAI's transport-failure log.
  • Media tools check the output path before paying. generate_audio, generate_video and generate_image now validate an explicit output path (validateCopilotWorkspaceFileWriteTarget, the same check Function exports run) before calling the provider. Before, a path into a folder that had since moved was refused only at write time, after fal/Gemini had billed, and the model retried and paid again. The write still re-validates.
  • Gemini TTS rejects voices it does not have. fal's Gemini 3.1 Flash TTS accepts only its 30 prebuilt voices (list checked against fal's API docs and its own 422). Names from other providers (Brian, Liam, …) now fail before submitting, and the error lists the valid voices. This builds on #9195.
  • Gemini with no user content fails clearly. It now throws EmptyProviderInputError (#9196's abstraction; the constructor takes an optional message) instead of the SDK's opaque contents are required. Gemini needs a user turn even when a system prompt is set. The error is nonretryable and attributed to the author.
  • Log noise: a file-archive download whose client disconnected no longer logs Failed to build file archive, because a closed archive is the deciding fact. Resume failures (manager, background job, poll dispatcher) now log once per attempt through logFailureOnce instead of three times.

Out of scope: Anthropic's empty-input behaviour (it injects a Hello user turn) is left unchanged. "Assistant message prefill" 400s on newer Claude models come from a conversation ending in an assistant turn. That happens either because the author configured it that way (prefill used to be a supported technique) or because history ends on an assistant turn and the new user turn is empty. Neither has a single correct fix, so this PR does not change it.

Type of Change

  • Bug fix

Testing

  • Each new test was run against the pre-fix code to confirm it fails there:
    • block-executor.test.ts: real Anthropic/OpenAI/Groq SDK requests (a fetch that only rejects on cancel). A user Stop soft-completes, a run timeout fails the block attributed to user, and a request cancelled while the run is live fails as internal. Red against the old name check, against the SDK classes removed, and against the signal guard removed.
    • providers/anthropic/core.test.ts: real SDK client. A Stop logs at info, a run timeout at warn, a genuine 500 at error.
    • handler.test.ts: a stopped run's abort passes through unrewritten, and a run timeout is still reported as a provider timeout.
    • write-target-precheck.integration.ts (real Postgres): for audio, video and image, a moved-folder target is refused without calling the provider and without recording a cost. A target at the folder's current path still generates and writes.
    • falai.test.ts: an unsupported Gemini TTS voice is refused with nothing submitted.
    • gemini/core.test.ts: real GoogleGenAI. System-only and all-empty requests now raise EmptyProviderInputError instead of contents are required.
    • archive-presenter.test.ts: a disconnect that tears down storage with Node's bare aborted no longer logs, and a failure with the client still connected still logs.
    • resume-execution.test.ts: a failure the resume manager already logged is not logged again.
  • Targeted vitest runs over the changed areas pass (providers, executor, mothership tools, media, resume, archive). bun run lint passes, and so do check:test-patterns, comment-hygiene, utils, explicit-any, source-conventions, boundaries, client-boundary and unused-exports.
  • A narrow tsc over the changed files reports the same diagnostics before and after (no new type errors in the test files). Full type-check and audits are left to CI.

Checklist

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