Skip to content

Commit fe5b58e

Browse files
committed
feat(core,sdk,webapp): version skew protection for chat.agent sessions
A chat session now carries an external deployment id, so every run it schedules lands on the deployment that matched the app build that started the conversation: the first run, each continuation after an idle suspend, and each recovery after a crash. Public SDK surface: - `triggerConfig.externalDeploymentId`, on every entry point that starts a session (`sessions.start`, `chat.createStartSessionAction`, `chat.headStart`, `chat.handover`, `AgentChat`). Normally omitted: it is discovered wherever the session is started, with the same precedence `trigger()` uses. `null` opts one chat out. - `pendingVersion` on the session-create and `.in/append` responses, on `ChatStartSessionResult` and on `StartSessionResult`, plus a new `run-pending-version` transport event, so a chat waiting on a deployment that is still building can say so instead of appearing to stall. - `chat.requestUpgrade({ externalDeploymentId })`. Called without a target it now clears the session's pin, which is what makes upgrading away from a pinned version possible at all, and the cleared pin is persisted so the next continuation cannot bounce back. - `SessionTriggerConfigInput` and `CreateSessionInput`, the caller-facing forms of the trigger config and the create body. Parking is the right failure mode here, and an improvement on `lockToVersion`, which throws on session create and is swallowed on the append path, leaving the chat hung with no run at all. `PENDING_VERSION` is non-final, so a parked run is reused rather than re-triggered and appended messages stay durable until the deployment lands. `lockToVersion` is deliberately untouched: it still wins where both are set, and `requestUpgrade()` still cannot escape it.
1 parent 4c16387 commit fe5b58e

24 files changed

Lines changed: 1379 additions & 71 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
---
5+
6+
Chat sessions now stay on the deployment that matched the app build that started them, so a conversation keeps talking to the agent version its release shipped with across every turn, idle suspend and recovery. The id is discovered wherever you start the session, exactly as it is for `trigger()`, so a chat picks up whatever your app already sends when it triggers a task. There is no chat-specific setup.
7+
8+
```ts
9+
// Opt a single chat out of pinning:
10+
export const startChatSession = chat.createStartSessionAction<typeof myChat>("my-chat", {
11+
triggerConfig: { externalDeploymentId: null },
12+
});
13+
```
14+
15+
Messages sent while a chat waits on a deployment that is still building are stored and answered once it lands, and the transport emits a `run-pending-version` event so your UI can say so. `chat.requestUpgrade()` now clears the session's pin so the handoff can reach a new version, and accepts `{ externalDeploymentId }` to move to a specific one.

apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ const { action, loader } = createActionApiRoute(
117117
callingRunId: callingRun.id,
118118
environment: authentication.environment,
119119
reason,
120+
externalDeploymentId: body.externalDeploymentId,
120121
});
121122

122123
// Read-after-write: the swap just triggered (or claimed) the

apps/webapp/app/routes/api.v1.sessions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ const { action } = createActionApiRoute(
246246
runId: run.friendlyId,
247247
publicAccessToken,
248248
isCached,
249+
pendingVersion: ensureResult.pendingVersion,
249250
};
250251

251252
return json<CreatedSessionResponseBody>(responseBody, {

apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ const { action, loader } = createActionApiRoute(
121121
// durable and the next append will retry the ensure step. Don't
122122
// surface the error to the caller; the SSE tail just won't deliver
123123
// it until a run boots.
124-
const [ensureError] = await tryCatch(
124+
const [ensureError, ensureResult] = await tryCatch(
125125
ensureRunForSession({
126126
session,
127127
environment: authentication.environment,
@@ -236,7 +236,14 @@ const { action, loader } = createActionApiRoute(
236236
}
237237

238238
// `seq` lets the client correlate this send to the turn that consumes it.
239-
return json({ ok: true, seq: appendSeq }, { status: 200 });
239+
return json(
240+
{
241+
ok: true,
242+
seq: appendSeq,
243+
...(ensureResult?.pendingVersion ? { pendingVersion: true } : {}),
244+
},
245+
{ status: 200 }
246+
);
240247
}
241248
);
242249

apps/webapp/app/services/realtime/sessionRunManager.server.ts

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Session, TaskRunStatus } from "@trigger.dev/database";
1+
import type { Prisma, Session, TaskRunStatus } from "@trigger.dev/database";
22
import { SessionTriggerConfig as SessionTriggerConfigZod } from "@trigger.dev/core/v3";
33
import type { z } from "zod";
44
import { prisma, $replica } from "~/db.server";
@@ -76,6 +76,8 @@ export type EnsureRunResult = {
7676
runId: string;
7777
/** True if this call triggered a fresh run; false if it reused an alive existing one. */
7878
triggered: boolean;
79+
/** The run is parked waiting for a deployment carrying the session's external deployment id. */
80+
pendingVersion: boolean;
7981
};
8082

8183
/**
@@ -123,7 +125,11 @@ export async function ensureRunForSession(
123125
);
124126
}
125127
if (probe && !isFinalRunStatus(probe.status)) {
126-
return { runId: session.currentRunId, triggered: false };
128+
return {
129+
runId: session.currentRunId,
130+
triggered: false,
131+
pendingVersion: isPendingVersionStatus(probe.status),
132+
};
127133
}
128134
// Either the row vanished on the writer too (probe null) or its status
129135
// is final. Either way the prior run isn't going to consume new
@@ -210,7 +216,11 @@ export async function ensureRunForSession(
210216
});
211217
});
212218

213-
return { runId: triggered.id, triggered: true };
219+
return {
220+
runId: triggered.id,
221+
triggered: true,
222+
pendingVersion: isPendingVersionStatus(triggered.status),
223+
};
214224
}
215225

216226
// 4. Lost the race. Cancel our triggered run; reuse the winner's.
@@ -255,7 +265,11 @@ export async function ensureRunForSession(
255265
prisma
256266
);
257267
if (probe && !isFinalRunStatus(probe.status)) {
258-
return { runId: fresh.currentRunId, triggered: false };
268+
return {
269+
runId: fresh.currentRunId,
270+
triggered: false,
271+
pendingVersion: isPendingVersionStatus(probe.status),
272+
};
259273
}
260274
}
261275

@@ -272,6 +286,24 @@ export async function ensureRunForSession(
272286
});
273287
}
274288

289+
/** Both version pins are forwarded; `TriggerTaskService` decides which governs. */
290+
export function buildSessionRunOptions(config: SessionTriggerConfig) {
291+
return {
292+
...(config.machine ? { machine: config.machine as never } : {}),
293+
...(config.queue ? { queue: { name: config.queue } } : {}),
294+
...(config.tags ? { tags: config.tags } : {}),
295+
...(config.maxAttempts !== undefined ? { maxAttempts: config.maxAttempts } : {}),
296+
...(config.maxDuration !== undefined ? { maxDuration: config.maxDuration } : {}),
297+
...(config.lockToVersion ? { lockToVersion: config.lockToVersion } : {}),
298+
...(config.externalDeploymentId ? { externalDeploymentId: config.externalDeploymentId } : {}),
299+
...(config.region ? { region: config.region } : {}),
300+
};
301+
}
302+
303+
function isPendingVersionStatus(status: TaskRunStatus): boolean {
304+
return status === "PENDING_VERSION";
305+
}
306+
275307
/**
276308
* Trigger a single run for a session. Builds `TriggerTaskRequestBody`
277309
* by shallow-merging `payloadOverrides` over `config.basePayload` and
@@ -288,7 +320,7 @@ async function triggerSessionRun(params: {
288320
config: SessionTriggerConfig;
289321
environment: AuthenticatedEnvironment;
290322
payloadOverrides?: Record<string, unknown>;
291-
}): Promise<{ id: string; friendlyId: string }> {
323+
}): Promise<{ id: string; friendlyId: string; status: TaskRunStatus }> {
292324
const { session, config, environment, payloadOverrides } = params;
293325

294326
const payload = {
@@ -302,15 +334,7 @@ async function triggerSessionRun(params: {
302334
const body = {
303335
payload,
304336
context: {},
305-
options: {
306-
...(config.machine ? { machine: config.machine as never } : {}),
307-
...(config.queue ? { queue: { name: config.queue } } : {}),
308-
...(config.tags ? { tags: config.tags } : {}),
309-
...(config.maxAttempts !== undefined ? { maxAttempts: config.maxAttempts } : {}),
310-
...(config.maxDuration !== undefined ? { maxDuration: config.maxDuration } : {}),
311-
...(config.lockToVersion ? { lockToVersion: config.lockToVersion } : {}),
312-
...(config.region ? { region: config.region } : {}),
313-
},
337+
options: buildSessionRunOptions(config),
314338
};
315339

316340
const service = new TriggerTaskService();
@@ -329,7 +353,11 @@ async function triggerSessionRun(params: {
329353
);
330354
}
331355

332-
return { id: result.run.id, friendlyId: result.run.friendlyId };
356+
return {
357+
id: result.run.id,
358+
friendlyId: result.run.friendlyId,
359+
status: result.run.status,
360+
};
333361
}
334362

335363
type SwapSessionRunParams = {
@@ -359,6 +387,8 @@ type SwapSessionRunParams = {
359387
environment: AuthenticatedEnvironment;
360388
reason: EnsureRunReason;
361389
payloadOverrides?: Record<string, unknown>;
390+
/** Only read when `reason` is `"upgrade"`: a string re-pins the session, absent clears the pin. */
391+
externalDeploymentId?: string | null;
362392
};
363393

364394
export type SwapSessionRunResult = {
@@ -371,6 +401,8 @@ export type SwapSessionRunResult = {
371401
* next run.
372402
*/
373403
swapped: boolean;
404+
/** See {@link EnsureRunResult.pendingVersion}. */
405+
pendingVersion: boolean;
374406
};
375407

376408
/**
@@ -413,7 +445,15 @@ export async function swapSessionRun(params: SwapSessionRunParams): Promise<Swap
413445
trigger: undefined,
414446
};
415447

416-
const config = SessionTriggerConfigSchema.parse(session.triggerConfig);
448+
const storedConfig = SessionTriggerConfigSchema.parse(session.triggerConfig);
449+
450+
// The upgrade's pin is persisted in the claim below, not applied to this run alone: the next
451+
// continuation re-reads the stored config. `lockToVersion` is deliberately untouched.
452+
const config =
453+
reason === "upgrade"
454+
? { ...storedConfig, externalDeploymentId: params.externalDeploymentId ?? undefined }
455+
: storedConfig;
456+
417457
const triggered = await triggerSessionRun({
418458
session,
419459
config,
@@ -430,6 +470,7 @@ export async function swapSessionRun(params: SwapSessionRunParams): Promise<Swap
430470
data: {
431471
currentRunId: triggered.id,
432472
currentRunVersion: { increment: 1 },
473+
...(reason === "upgrade" ? { triggerConfig: config as Prisma.InputJsonValue } : {}),
433474
},
434475
});
435476

@@ -446,7 +487,11 @@ export async function swapSessionRun(params: SwapSessionRunParams): Promise<Swap
446487
error,
447488
});
448489
});
449-
return { runId: triggered.id, swapped: true };
490+
return {
491+
runId: triggered.id,
492+
swapped: true,
493+
pendingVersion: isPendingVersionStatus(triggered.status),
494+
};
450495
}
451496

452497
// Lost the race — someone else already swapped to a new run. Cancel
@@ -480,9 +525,16 @@ export async function swapSessionRun(params: SwapSessionRunParams): Promise<Swap
480525
);
481526
}
482527

528+
const winner = await runStore.findRun(
529+
{ id: fresh.currentRunId },
530+
{ select: { status: true } },
531+
prisma
532+
);
533+
483534
return {
484535
runId: fresh.currentRunId,
485536
swapped: false,
537+
pendingVersion: winner ? isPendingVersionStatus(winner.status) : false,
486538
};
487539
}
488540

apps/webapp/test/realtimeServices.replicaLag.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ describe("realtime-svc — replica-lag guards", () => {
348348
});
349349

350350
// Observable: the writer re-probe recovered the live run → reuse it, do NOT trigger a second run.
351-
expect(result).toEqual({ runId, triggered: false });
351+
expect(result).toEqual({ runId, triggered: false, pendingVersion: false });
352352
expect(triggerState.calls).toHaveLength(0);
353353
// The replica WAS consulted first (and, frozen, missed) — proving the recovery is the writer
354354
// re-probe, not a lucky replica hit.
@@ -423,7 +423,7 @@ describe("realtime-svc — replica-lag guards", () => {
423423
});
424424

425425
// Observable 1: the swap COMPLETED — the replica miss did not fail it.
426-
expect(result).toEqual({ runId: newRunId, swapped: true });
426+
expect(result).toEqual({ runId: newRunId, swapped: true, pendingVersion: false });
427427

428428
// Observable 2: resolveRunFriendlyId missed on the replica and degraded to the cuid, so the
429429
// previousRunId forwarded to the triggered run is the calling run's cuid (documented fallback).

0 commit comments

Comments
 (0)