Skip to content

Commit 1133ad4

Browse files
0skiTrigger.dev RepoOps
authored andcommitted
feat(core,sdk,webapp): version skew protection for chat.agent sessions
Chat sessions can now be pinned to a deployment, so a conversation keeps talking to the agent version its release shipped with, across every turn, idle suspend and recovery. The deployment id is resolved wherever you start the session, exactly as it is for `trigger()`, so there is no chat-specific setup — pass `triggerConfig: { externalDeploymentId: null }` to opt a chat out. A pinned session also follows its pin on its own: when your app redeploys and re-pins it, the agent hands the conversation over at the next turn boundary instead of the new pin only applying to the next run. Set `versionSkew: "hold"` on an agent that should stay put. If a handover lands on a deployment that is still building, the chat reports a pending version rather than going quiet, and messages sent meanwhile are kept. Also fixes `AgentChat` ignoring `maxDuration`, `region` and `lockToVersion` set on its `triggerConfig`, and a chat agent answering the same message twice after a conversation moved to a new run. Mono-RevId: 1c6e161b3e4f6c3e81a50a7f24d08a43b0ba0057
1 parent 879e897 commit 1133ad4

32 files changed

Lines changed: 2254 additions & 135 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Chat sessions can now be pinned to a deployment, so a conversation keeps talking to the agent version its release shipped with, and follows the pin on its own when your app redeploys. Opt out with `triggerConfig: { externalDeploymentId: null }` or `versionSkew: "hold"`. Also fixes `AgentChat` ignoring `maxDuration`, `region` and `lockToVersion`, and a restored `AgentChat` session never picking up a new deployment id.

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

Lines changed: 2 additions & 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
@@ -133,6 +134,7 @@ const { action, loader } = createActionApiRoute(
133134
const responseBody: EndAndContinueSessionResponseBody = {
134135
runId: run?.friendlyId ?? result.runId,
135136
swapped: result.swapped,
137+
pendingVersion: result.pendingVersion,
136138
};
137139
return json<EndAndContinueSessionResponseBody>(responseBody);
138140
} catch (error) {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ const { action } = createActionApiRoute(
257257
runId: run.friendlyId,
258258
publicAccessToken,
259259
isCached,
260+
pendingVersion: ensureResult.pendingVersion,
260261
};
261262

262263
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
@@ -131,7 +131,7 @@ const { action, loader } = createActionApiRoute(
131131
// durable and the next append will retry the ensure step. Don't
132132
// surface the error to the caller; the SSE tail just won't deliver
133133
// it until a run boots.
134-
const [ensureError] = await tryCatch(
134+
const [ensureError, ensureResult] = await tryCatch(
135135
ensureRunForSession({
136136
session,
137137
environment: authentication.environment,
@@ -213,7 +213,14 @@ const { action, loader } = createActionApiRoute(
213213
);
214214

215215
// `seq` lets the client correlate this send to the turn that consumes it.
216-
return json({ ok: true, seq: appendSeq }, { status: 200 });
216+
return json(
217+
{
218+
ok: true,
219+
seq: appendSeq,
220+
...(ensureResult?.pendingVersion ? { pendingVersion: true } : {}),
221+
},
222+
{ status: 200 }
223+
);
217224
}
218225
);
219226

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
@@ -215,7 +221,11 @@ export async function ensureRunForSession(
215221
});
216222
});
217223

218-
return { runId: triggered.id, triggered: true };
224+
return {
225+
runId: triggered.id,
226+
triggered: true,
227+
pendingVersion: isPendingVersionStatus(triggered.status),
228+
};
219229
}
220230

221231
// 4. Lost the race. Cancel our triggered run; reuse the winner's.
@@ -268,7 +278,11 @@ export async function ensureRunForSession(
268278
prisma
269279
);
270280
if (probe && !isFinalRunStatus(probe.status)) {
271-
return { runId: fresh.currentRunId, triggered: false };
281+
return {
282+
runId: fresh.currentRunId,
283+
triggered: false,
284+
pendingVersion: isPendingVersionStatus(probe.status),
285+
};
272286
}
273287
}
274288

@@ -285,6 +299,24 @@ export async function ensureRunForSession(
285299
});
286300
}
287301

302+
/** Both version pins are forwarded; `TriggerTaskService` decides which governs. */
303+
export function buildSessionRunOptions(config: SessionTriggerConfig) {
304+
return {
305+
...(config.machine ? { machine: config.machine as never } : {}),
306+
...(config.queue ? { queue: { name: config.queue } } : {}),
307+
...(config.tags ? { tags: config.tags } : {}),
308+
...(config.maxAttempts !== undefined ? { maxAttempts: config.maxAttempts } : {}),
309+
...(config.maxDuration !== undefined ? { maxDuration: config.maxDuration } : {}),
310+
...(config.lockToVersion ? { lockToVersion: config.lockToVersion } : {}),
311+
...(config.externalDeploymentId ? { externalDeploymentId: config.externalDeploymentId } : {}),
312+
...(config.region ? { region: config.region } : {}),
313+
};
314+
}
315+
316+
function isPendingVersionStatus(status: TaskRunStatus): boolean {
317+
return status === "PENDING_VERSION";
318+
}
319+
288320
/**
289321
* Trigger a single run for a session. Builds `TriggerTaskRequestBody`
290322
* by shallow-merging `payloadOverrides` over `config.basePayload` and
@@ -301,7 +333,7 @@ async function triggerSessionRun(params: {
301333
config: SessionTriggerConfig;
302334
environment: AuthenticatedEnvironment;
303335
payloadOverrides?: Record<string, unknown>;
304-
}): Promise<{ id: string; friendlyId: string }> {
336+
}): Promise<{ id: string; friendlyId: string; status: TaskRunStatus }> {
305337
const { session, config, environment, payloadOverrides } = params;
306338

307339
const payload = {
@@ -315,15 +347,7 @@ async function triggerSessionRun(params: {
315347
const body = {
316348
payload,
317349
context: {},
318-
options: {
319-
...(config.machine ? { machine: config.machine as never } : {}),
320-
...(config.queue ? { queue: { name: config.queue } } : {}),
321-
...(config.tags ? { tags: config.tags } : {}),
322-
...(config.maxAttempts !== undefined ? { maxAttempts: config.maxAttempts } : {}),
323-
...(config.maxDuration !== undefined ? { maxDuration: config.maxDuration } : {}),
324-
...(config.lockToVersion ? { lockToVersion: config.lockToVersion } : {}),
325-
...(config.region ? { region: config.region } : {}),
326-
},
350+
options: buildSessionRunOptions(config),
327351
};
328352

329353
const service = new TriggerTaskService();
@@ -342,7 +366,11 @@ async function triggerSessionRun(params: {
342366
);
343367
}
344368

345-
return { id: result.run.id, friendlyId: result.run.friendlyId };
369+
return {
370+
id: result.run.id,
371+
friendlyId: result.run.friendlyId,
372+
status: result.run.status,
373+
};
346374
}
347375

348376
type SwapSessionRunParams = {
@@ -372,6 +400,8 @@ type SwapSessionRunParams = {
372400
environment: AuthenticatedEnvironment;
373401
reason: EnsureRunReason;
374402
payloadOverrides?: Record<string, unknown>;
403+
/** Only read when `reason` is `"upgrade"`: a string re-pins the session, absent clears the pin. */
404+
externalDeploymentId?: string | null;
375405
};
376406

377407
export type SwapSessionRunResult = {
@@ -384,6 +414,8 @@ export type SwapSessionRunResult = {
384414
* next run.
385415
*/
386416
swapped: boolean;
417+
/** See {@link EnsureRunResult.pendingVersion}. */
418+
pendingVersion: boolean;
387419
};
388420

389421
/**
@@ -426,7 +458,15 @@ export async function swapSessionRun(params: SwapSessionRunParams): Promise<Swap
426458
trigger: undefined,
427459
};
428460

429-
const config = SessionTriggerConfigSchema.parse(session.triggerConfig);
461+
const storedConfig = SessionTriggerConfigSchema.parse(session.triggerConfig);
462+
463+
// The upgrade's pin is persisted in the claim below, not applied to this run alone: the next
464+
// continuation re-reads the stored config. `lockToVersion` is deliberately untouched.
465+
const config =
466+
reason === "upgrade"
467+
? { ...storedConfig, externalDeploymentId: params.externalDeploymentId ?? undefined }
468+
: storedConfig;
469+
430470
const triggered = await triggerSessionRun({
431471
session,
432472
config,
@@ -447,6 +487,7 @@ export async function swapSessionRun(params: SwapSessionRunParams): Promise<Swap
447487
data: {
448488
currentRunId: triggered.id,
449489
currentRunVersion: { increment: 1 },
490+
...(reason === "upgrade" ? { triggerConfig: config as Prisma.InputJsonValue } : {}),
450491
},
451492
});
452493

@@ -463,7 +504,11 @@ export async function swapSessionRun(params: SwapSessionRunParams): Promise<Swap
463504
error,
464505
});
465506
});
466-
return { runId: triggered.id, swapped: true };
507+
return {
508+
runId: triggered.id,
509+
swapped: true,
510+
pendingVersion: isPendingVersionStatus(triggered.status),
511+
};
467512
}
468513

469514
// Lost the race — someone else already swapped to a new run. Cancel
@@ -504,9 +549,16 @@ export async function swapSessionRun(params: SwapSessionRunParams): Promise<Swap
504549
);
505550
}
506551

552+
const winner = await runStore.findRun(
553+
{ id: fresh.currentRunId },
554+
{ select: { status: true } },
555+
prisma
556+
);
557+
507558
return {
508559
runId: fresh.currentRunId,
509560
swapped: false,
561+
pendingVersion: winner ? isPendingVersionStatus(winner.status) : false,
510562
};
511563
}
512564

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)