-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(core,sdk,webapp): version skew protection for chat.agent sessions #4789
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@trigger.dev/sdk": patch | ||
| --- | ||
|
|
||
| Fixed `AgentChat` silently ignoring `maxDuration`, `region` and `lockToVersion` when they were set on its `triggerConfig`. They are now applied to the session's runs. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| --- | ||
| "@trigger.dev/core": patch | ||
| "@trigger.dev/sdk": patch | ||
| --- | ||
|
|
||
| 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 resolved 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, with no chat-specific setup. If your app sends no id, nothing changes: chats run on the current version as they do today. | ||
|
|
||
| ```ts | ||
| // Opt a single chat out of pinning: | ||
| export const startChatSession = chat.createStartSessionAction<typeof myChat>("my-chat", { | ||
| triggerConfig: { externalDeploymentId: null }, | ||
| }); | ||
| ``` | ||
|
|
||
| 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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import type { Session, TaskRunStatus } from "@trigger.dev/database"; | ||
| import type { Prisma, Session, TaskRunStatus } from "@trigger.dev/database"; | ||
| import { SessionTriggerConfig as SessionTriggerConfigZod } from "@trigger.dev/core/v3"; | ||
| import type { z } from "zod"; | ||
| import { prisma, $replica } from "~/db.server"; | ||
|
|
@@ -76,6 +76,8 @@ export type EnsureRunResult = { | |
| runId: string; | ||
| /** True if this call triggered a fresh run; false if it reused an alive existing one. */ | ||
| triggered: boolean; | ||
| /** The run is parked waiting for a deployment carrying the session's external deployment id. */ | ||
| pendingVersion: boolean; | ||
| }; | ||
|
|
||
| /** | ||
|
|
@@ -123,7 +125,11 @@ export async function ensureRunForSession( | |
| ); | ||
| } | ||
| if (probe && !isFinalRunStatus(probe.status)) { | ||
| return { runId: session.currentRunId, triggered: false }; | ||
| return { | ||
| runId: session.currentRunId, | ||
| triggered: false, | ||
| pendingVersion: isPendingVersionStatus(probe.status), | ||
| }; | ||
| } | ||
| // Either the row vanished on the writer too (probe null) or its status | ||
| // is final. Either way the prior run isn't going to consume new | ||
|
|
@@ -210,7 +216,11 @@ export async function ensureRunForSession( | |
| }); | ||
| }); | ||
|
|
||
| return { runId: triggered.id, triggered: true }; | ||
| return { | ||
| runId: triggered.id, | ||
| triggered: true, | ||
| pendingVersion: isPendingVersionStatus(triggered.status), | ||
| }; | ||
| } | ||
|
|
||
| // 4. Lost the race. Cancel our triggered run; reuse the winner's. | ||
|
|
@@ -255,7 +265,11 @@ export async function ensureRunForSession( | |
| prisma | ||
| ); | ||
| if (probe && !isFinalRunStatus(probe.status)) { | ||
| return { runId: fresh.currentRunId, triggered: false }; | ||
| return { | ||
| runId: fresh.currentRunId, | ||
| triggered: false, | ||
| pendingVersion: isPendingVersionStatus(probe.status), | ||
| }; | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -272,6 +286,24 @@ export async function ensureRunForSession( | |
| }); | ||
| } | ||
|
|
||
| /** Both version pins are forwarded; `TriggerTaskService` decides which governs. */ | ||
| export function buildSessionRunOptions(config: SessionTriggerConfig) { | ||
| return { | ||
| ...(config.machine ? { machine: config.machine as never } : {}), | ||
| ...(config.queue ? { queue: { name: config.queue } } : {}), | ||
| ...(config.tags ? { tags: config.tags } : {}), | ||
| ...(config.maxAttempts !== undefined ? { maxAttempts: config.maxAttempts } : {}), | ||
| ...(config.maxDuration !== undefined ? { maxDuration: config.maxDuration } : {}), | ||
| ...(config.lockToVersion ? { lockToVersion: config.lockToVersion } : {}), | ||
| ...(config.externalDeploymentId ? { externalDeploymentId: config.externalDeploymentId } : {}), | ||
| ...(config.region ? { region: config.region } : {}), | ||
| }; | ||
| } | ||
|
|
||
| function isPendingVersionStatus(status: TaskRunStatus): boolean { | ||
| return status === "PENDING_VERSION"; | ||
| } | ||
|
|
||
| /** | ||
| * Trigger a single run for a session. Builds `TriggerTaskRequestBody` | ||
| * by shallow-merging `payloadOverrides` over `config.basePayload` and | ||
|
|
@@ -288,7 +320,7 @@ async function triggerSessionRun(params: { | |
| config: SessionTriggerConfig; | ||
| environment: AuthenticatedEnvironment; | ||
| payloadOverrides?: Record<string, unknown>; | ||
| }): Promise<{ id: string; friendlyId: string }> { | ||
| }): Promise<{ id: string; friendlyId: string; status: TaskRunStatus }> { | ||
| const { session, config, environment, payloadOverrides } = params; | ||
|
|
||
| const payload = { | ||
|
|
@@ -302,15 +334,7 @@ async function triggerSessionRun(params: { | |
| const body = { | ||
| payload, | ||
| context: {}, | ||
| options: { | ||
| ...(config.machine ? { machine: config.machine as never } : {}), | ||
| ...(config.queue ? { queue: { name: config.queue } } : {}), | ||
| ...(config.tags ? { tags: config.tags } : {}), | ||
| ...(config.maxAttempts !== undefined ? { maxAttempts: config.maxAttempts } : {}), | ||
| ...(config.maxDuration !== undefined ? { maxDuration: config.maxDuration } : {}), | ||
| ...(config.lockToVersion ? { lockToVersion: config.lockToVersion } : {}), | ||
| ...(config.region ? { region: config.region } : {}), | ||
| }, | ||
| options: buildSessionRunOptions(config), | ||
| }; | ||
|
|
||
| const service = new TriggerTaskService(); | ||
|
|
@@ -329,7 +353,11 @@ async function triggerSessionRun(params: { | |
| ); | ||
| } | ||
|
|
||
| return { id: result.run.id, friendlyId: result.run.friendlyId }; | ||
| return { | ||
| id: result.run.id, | ||
| friendlyId: result.run.friendlyId, | ||
| status: result.run.status, | ||
| }; | ||
| } | ||
|
|
||
| type SwapSessionRunParams = { | ||
|
|
@@ -359,6 +387,8 @@ type SwapSessionRunParams = { | |
| environment: AuthenticatedEnvironment; | ||
| reason: EnsureRunReason; | ||
| payloadOverrides?: Record<string, unknown>; | ||
| /** Only read when `reason` is `"upgrade"`: a string re-pins the session, absent clears the pin. */ | ||
| externalDeploymentId?: string | null; | ||
| }; | ||
|
|
||
| export type SwapSessionRunResult = { | ||
|
|
@@ -371,6 +401,8 @@ export type SwapSessionRunResult = { | |
| * next run. | ||
| */ | ||
| swapped: boolean; | ||
| /** See {@link EnsureRunResult.pendingVersion}. */ | ||
| pendingVersion: boolean; | ||
| }; | ||
|
|
||
| /** | ||
|
|
@@ -413,7 +445,15 @@ export async function swapSessionRun(params: SwapSessionRunParams): Promise<Swap | |
| trigger: undefined, | ||
| }; | ||
|
|
||
| const config = SessionTriggerConfigSchema.parse(session.triggerConfig); | ||
| const storedConfig = SessionTriggerConfigSchema.parse(session.triggerConfig); | ||
|
|
||
| // The upgrade's pin is persisted in the claim below, not applied to this run alone: the next | ||
| // continuation re-reads the stored config. `lockToVersion` is deliberately untouched. | ||
| const config = | ||
| reason === "upgrade" | ||
| ? { ...storedConfig, externalDeploymentId: params.externalDeploymentId ?? undefined } | ||
| : storedConfig; | ||
|
|
||
| const triggered = await triggerSessionRun({ | ||
| session, | ||
| config, | ||
|
|
@@ -430,6 +470,7 @@ export async function swapSessionRun(params: SwapSessionRunParams): Promise<Swap | |
| data: { | ||
| currentRunId: triggered.id, | ||
| currentRunVersion: { increment: 1 }, | ||
| ...(reason === "upgrade" ? { triggerConfig: config as Prisma.InputJsonValue } : {}), | ||
| }, | ||
| }); | ||
|
|
||
|
|
@@ -446,7 +487,11 @@ export async function swapSessionRun(params: SwapSessionRunParams): Promise<Swap | |
| error, | ||
| }); | ||
| }); | ||
| return { runId: triggered.id, swapped: true }; | ||
| return { | ||
| runId: triggered.id, | ||
| swapped: true, | ||
| pendingVersion: isPendingVersionStatus(triggered.status), | ||
| }; | ||
| } | ||
|
|
||
| // Lost the race — someone else already swapped to a new run. Cancel | ||
|
|
@@ -480,9 +525,16 @@ export async function swapSessionRun(params: SwapSessionRunParams): Promise<Swap | |
| ); | ||
| } | ||
|
|
||
| const winner = await runStore.findRun( | ||
| { id: fresh.currentRunId }, | ||
| { select: { status: true } }, | ||
| prisma | ||
| ); | ||
|
|
||
| return { | ||
| runId: fresh.currentRunId, | ||
| swapped: false, | ||
| pendingVersion: winner ? isPendingVersionStatus(winner.status) : false, | ||
|
Comment on lines
+528
to
+537
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Unused database read in preempted swap path The lost-race branch of Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| }; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Keep external deployment pinning distinct from
lockToVersion.The documentation overstates what external deployment opt-out and
chat.requestUpgrade()can change..changeset/chat-agent-version-skew-protection.md#L15: state thatchat.requestUpgrade()cannot overridelockToVersion.docs/deployment/version-skew-protection.mdx#L317-L327: clarify that these options disable external-deployment pinning only; they do not overridelockToVersion.The PR objective states that
lockToVersionremains authoritative and cannot be overridden.📍 Affects 2 files
.changeset/chat-agent-version-skew-protection.md#L15-L15(this comment)docs/deployment/version-skew-protection.mdx#L317-L327