diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index 49a1d53a5..d632c8dc2 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -409,6 +409,19 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // same launch-and-join core `POST .../invite` uses, run inline // here so the chat comes back from this single call already // carrying its one agent participant. + // + // The agent launch runs after the host, tenant, and settings are + // all live. Any failure here leaves a half-built channel that must + // be rolled back: the tenant and settings this handler minted are + // compensated and deleted exactly as the host-launch path above + // compensates a tenant whose host never came up, so a retry starts + // clean rather than reusing an orphaned tenant. + // + // InferenceResolutionError (no model requirements / no inference + // source) is a caller-correctable config problem → 409. Other launch + // failures (too many @mentions, transient platform errors) → 422. + // Compensation failures are logged loudly and never swallow the + // original launch error. try { const joined = await launchAndJoinAgent( { store: deps.store, platform: deps.platform, publish }, @@ -443,13 +456,40 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { 201, ); } catch (err) { + log.error( + "Agent launch failed for channel {channelId} after the host " + + "launched and settings were written; compensating the channel " + + "tenant and deleting its settings", + { channelId, tenantId: channelTenant.tenantId, err }, + ); + try { + await deps.tenancy.compensateChannelTenant(channelTenant.tenantId); + await deps.store.deleteChannelSettings(tenant.id, channelId); + } catch (compensationErr) { + log.error( + "Compensation failed after agent launch failure for channel " + + "{channelId}; the orphaned tenant {tenantId} and/or its " + + "settings require manual cleanup", + { + channelId, + tenantId: channelTenant.tenantId, + compensationErr, + }, + ); + } if (err instanceof InferenceResolutionError) { return c.json( ErrorEnvelope("not_launchable", err.resolutionMessage), 409, ); } - throw err; + return c.json( + ErrorEnvelope( + "agent_launch_failed", + err instanceof Error ? err.message : String(err), + ), + 422, + ); } }, ); diff --git a/packages/chat/src/store.ts b/packages/chat/src/store.ts index 5371e50a3..3e3493462 100644 --- a/packages/chat/src/store.ts +++ b/packages/chat/src/store.ts @@ -90,6 +90,14 @@ export interface ChatStore { tenantId: string, channelId: string, ): Promise; + /** + * Removes a channel's settings row. Used only to compensate a channel + * whose creation a downstream step (the agent launch) failed to + * complete — the channel host, tenant, and settings were all written + * before the launch failed, so rolling the channel back means deleting + * each of them in turn (see `routes.ts`'s create handler). + */ + deleteChannelSettings(tenantId: string, channelId: string): Promise; listChannelSettings( tenantId: string, kind?: string, @@ -156,6 +164,17 @@ export function createDrizzleChatStore>( return selected as ChannelSettingsRow | undefined; }, + async deleteChannelSettings(tenantId, channelId) { + await db + .delete(channelSettings) + .where( + and( + eq(channelSettings.tenantId, tenantId), + eq(channelSettings.channelId, channelId), + ), + ); + }, + async listChannelSettings(tenantId, kind) { const rows = await db .select() @@ -312,6 +331,10 @@ export function createInMemoryChatStore(): ChatStore { return settingsByKey.get(settingsKey(tenantId, channelId)); }, + async deleteChannelSettings(tenantId, channelId) { + settingsByKey.delete(settingsKey(tenantId, channelId)); + }, + async listChannelSettings(tenantId, kind) { const rows = [...settingsByKey.values()].filter( (row) => row.tenantId === tenantId, diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index 51ede1d77..a7b7fcb6a 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -180,6 +180,39 @@ describe("POST /channels", () => { "This definition declares no model requirements", ); }); + + test("agent launch failure returns 422, not 500, and compensates the channel", async () => { + const deps = buildDeps({ + platform: fakePlatform({ + invitable: [{ id: "wfd_echo", name: "Echo" }], + launchInvite: () => + Promise.reject(new Error("blocked: too many @mentions; max 5")), + }), + }); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + + const response = await app.request("/channels", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ kind: "chat", definitionId: "wfd_echo" }), + }); + + expect(response.status).toBe(422); + const body = (await response.json()) as { + error: { code: string; message: string }; + }; + expect(body.error.code).toBe("agent_launch_failed"); + expect(body.error.message).toContain("too many @mentions"); + + // The half-built channel is rolled back: its settings row is gone + // and its minted tenant is compensated, so a retry starts clean. + const tenancy = deps.tenancy as ReturnType< + typeof createInMemoryChannelTenancyStore + >; + const channels = await deps.store.listChannelSettings(TENANT.id); + expect(channels).toHaveLength(0); + expect(await tenancy.listChildChannelTenancies(TENANT.id)).toHaveLength(0); + }); }); describe("POST /channels/:id/invite", () => {