diff --git a/apps/server/src/routes/threads/actions.ts b/apps/server/src/routes/threads/actions.ts index a59673fa2..154e9982d 100644 --- a/apps/server/src/routes/threads/actions.ts +++ b/apps/server/src/routes/threads/actions.ts @@ -40,6 +40,10 @@ import { } from "../../services/environments/environment-cleanup-internal.js"; import { applyLoggedEnvironmentLifecycleEvent } from "../../services/environments/lifecycle-outcome.js"; import { requirePublicThread } from "../../services/lib/entity-lookup.js"; +import { + goneThreadEnvironmentDetails, + throwThreadEnvironmentUnavailable, +} from "../../services/lib/lifecycle-api-errors.js"; import { parseSafeRelativeRoutePath } from "../relative-route-path.js"; import { validatePromptAttachmentReferences } from "../../services/projects/attachments.js"; import { @@ -252,12 +256,33 @@ function queuedMessagePayloadFromSendRequest( }; } +/** + * A queued message can only ever drain into the thread's environment. A gone + * environment (destroying/destroyed) is never reprovisioned, so accepting the + * message would park it in the queue forever while the thread keeps reporting + * `idle` (#1789). Refuse with the same 409 the direct send path returns. + */ +function ensureThreadEnvironmentIsNotGone(deps: AppDeps, thread: Thread): void { + if (thread.environmentId === null) { + return; + } + const environment = getEnvironment(deps.db, thread.environmentId); + if (!environment) { + return; + } + const goneDetails = goneThreadEnvironmentDetails(environment); + if (goneDetails) { + throwThreadEnvironmentUnavailable(goneDetails); + } +} + async function createQueuedMessageForThread( deps: AppDeps, args: CreateQueuedMessageForThreadArgs, ): Promise { const { payload, thread } = args; ensureThreadIsWritable(thread); + ensureThreadEnvironmentIsNotGone(deps, thread); await validatePromptAttachmentReferences({ dataDir: deps.config.dataDir, input: payload.input,