Skip to content

Commit 13ec864

Browse files
committed
fix(webapp,sdk): harden dashboard agent head start and session ownership
- Log head-start warm-step failures instead of swallowing them, so a failed first turn is visible in the server logs. - Guard the new-chat create against stale responses so a slow create can't replace a chat the user just switched to. - Scope the chat ownership check by organization, not just by user. - Clear the head start idle timer on the warm-step failure path (it was only cleared on the success path).
1 parent 8a37eda commit 13ec864

5 files changed

Lines changed: 35 additions & 10 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
134134
const createChat = useCallback(
135135
async (text: string) => {
136136
setView("chat");
137+
const seq = ++openChatRequestSeq.current;
137138
setLoading(true);
138139
try {
139140
const userMessage: UIMessage = {
@@ -152,6 +153,8 @@ export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
152153
headStarted?: boolean;
153154
error?: string;
154155
};
156+
// A newer open/create (or New chat) superseded this one — drop the result.
157+
if (seq !== openChatRequestSeq.current) return;
155158
if (!res.ok || !data.chatId || !data.publicAccessToken) {
156159
setActive(null);
157160
return;
@@ -164,7 +167,7 @@ export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
164167
streaming: data.headStarted,
165168
});
166169
} finally {
167-
setLoading(false);
170+
if (seq === openChatRequestSeq.current) setLoading(false);
168171
}
169172
},
170173
[actionPath, clientData]
@@ -196,6 +199,9 @@ export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
196199
}, [active?.chatId, storageKey]);
197200

198201
const newChat = useCallback(() => {
202+
// Invalidate any in-flight open/create so its result can't replace the draft.
203+
openChatRequestSeq.current += 1;
204+
setLoading(false);
199205
setView("chat");
200206
setActive(null);
201207
}, []);

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
186186
// id). The transport falls back here to re-establish a session for an
187187
// existing chat (e.g. after its token expired), so verify ownership before
188188
// issuing one — a client-supplied chatId must belong to the caller.
189-
if (!(await chatExists(dashboardAgentDb, { chatId, userId }))) {
189+
if (!(await chatExists(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId }))) {
190190
return json({ error: "Chat not found" }, { status: 404 });
191191
}
192192
let clientData: Record<string, unknown> | undefined;
@@ -215,7 +215,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
215215
}
216216
// Only mint a session token for a chat the caller owns, so a client-supplied
217217
// chatId can't be used to get a token for someone else's session.
218-
if (!(await chatExists(dashboardAgentDb, { chatId, userId }))) {
218+
if (!(await chatExists(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId }))) {
219219
return json({ error: "Chat not found" }, { status: 404 });
220220
}
221221
return json({ token: await mintDashboardAgentToken(chatId) });

apps/webapp/app/services/dashboardAgentHeadStart.server.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { chat as chatServer } from "@trigger.dev/sdk/chat-server";
1010
import { streamText, type UIMessage } from "ai";
1111
import { env } from "~/env.server";
1212
import { dashboardAgentApiOrigin } from "~/services/dashboardAgent.server";
13+
import { logger } from "~/services/logger.server";
1314

1415
const TASK_ID = "dashboard-agent";
1516

@@ -59,6 +60,10 @@ export async function startDashboardAgentHeadStart(params: {
5960

6061
// The webapp is long-lived, so step 1's drain + the handover dispatch run in
6162
// the background after this resolves (createSession + trigger have completed).
62-
// startHeadStart already guards the promise against unhandled rejection.
63-
completion.catch(() => {});
63+
// Log a warm-step failure for observability: startHeadStart has already fired
64+
// handover-skip so the agent run exits cleanly, but the client (mounted as
65+
// streaming) then resumes an empty session.out, so the turn looks lost.
66+
completion.catch((error) => {
67+
logger.error("Dashboard agent head start failed", { chatId: params.chatId, error });
68+
});
6469
}

internal-packages/dashboard-agent-db/src/queries.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,18 @@ export async function getSession(
104104
*/
105105
export async function chatExists(
106106
db: DashboardAgentDb,
107-
params: { chatId: string; userId: string }
107+
params: { chatId: string; userId: string; organizationId: string }
108108
): Promise<boolean> {
109109
const rows = await db
110110
.select({ id: chats.id })
111111
.from(chats)
112112
.where(
113-
and(eq(chats.id, params.chatId), eq(chats.userId, params.userId), isNull(chats.deletedAt))
113+
and(
114+
eq(chats.id, params.chatId),
115+
eq(chats.organizationId, params.organizationId),
116+
eq(chats.userId, params.userId),
117+
isNull(chats.deletedAt)
118+
)
114119
)
115120
.limit(1);
116121
return rows.length > 0;

packages/trigger-sdk/src/v3/chat-server.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -683,9 +683,18 @@ async function openHandoverSession(opts: {
683683
* `finishReason`). Normal pure-text and tool-call finishes go
684684
* through `handover()` with the appropriate `isFinal` flag.
685685
*/
686+
// Clear the idle timer on every terminal path. The detached failure path
687+
// (run() throws -> handoverSkip) otherwise leaves it armed until the idle
688+
// timeout elapses, since only handoverWhenDone used to clear it.
689+
const cleanup = () => clearTimeout(idleTimer);
690+
686691
const handoverSkip = async () => {
687-
const chunk: ChatInputChunk = { kind: "handover-skip" };
688-
await apiClient.appendToSessionStream(chatId, "in", JSON.stringify(chunk));
692+
try {
693+
const chunk: ChatInputChunk = { kind: "handover-skip" };
694+
await apiClient.appendToSessionStream(chatId, "in", JSON.stringify(chunk));
695+
} finally {
696+
cleanup();
697+
}
689698
};
690699

691700
// A stable assistant messageId for this turn. The customer's
@@ -761,7 +770,7 @@ async function openHandoverSession(opts: {
761770
}
762771
throw err;
763772
} finally {
764-
clearTimeout(idleTimer);
773+
cleanup();
765774
}
766775
};
767776

0 commit comments

Comments
 (0)