Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion packages/chat/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,19 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono<TenantEnv> {
// 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 },
Expand Down Expand Up @@ -443,13 +456,40 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono<TenantEnv> {
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,
);
}
},
);
Expand Down
23 changes: 23 additions & 0 deletions packages/chat/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ export interface ChatStore {
tenantId: string,
channelId: string,
): Promise<ChannelSettingsRow | undefined>;
/**
* 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<void>;
listChannelSettings(
tenantId: string,
kind?: string,
Expand Down Expand Up @@ -156,6 +164,17 @@ export function createDrizzleChatStore<TSchema extends Record<string, unknown>>(
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()
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions packages/chat/test/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading