diff --git a/.changeset/chat-session-run-ttl.md b/.changeset/chat-session-run-ttl.md new file mode 100644 index 00000000000..8169b800631 --- /dev/null +++ b/.changeset/chat-session-run-ttl.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"@trigger.dev/sdk": patch +--- + +Chat server sessions can now set a `ttl` on the runs they trigger, so a run that is never picked up expires instead of waiting indefinitely. diff --git a/apps/webapp/app/services/dashboardAgent.server.ts b/apps/webapp/app/services/dashboardAgent.server.ts index 5ff460ae33f..7252ba2d178 100644 --- a/apps/webapp/app/services/dashboardAgent.server.ts +++ b/apps/webapp/app/services/dashboardAgent.server.ts @@ -86,10 +86,18 @@ export function isDashboardAgentConfigured(): boolean { return Boolean(env.DASHBOARD_AGENT_SECRET_KEY); } +// With no agent worker available a turn's run would sit queued indefinitely and +// could be dequeued much later with a stale token. Expire it instead — the turn +// is long dead by then on the client. +const DASHBOARD_AGENT_RUN_TTL = "2m"; + // Pins every agent session (and its continuation runs) to a deployed version // when DASHBOARD_AGENT_VERSION is set; unset runs on the env's current version. -export function dashboardAgentTriggerConfig(): { lockToVersion: string } | undefined { - return env.DASHBOARD_AGENT_VERSION ? { lockToVersion: env.DASHBOARD_AGENT_VERSION } : undefined; +export function dashboardAgentTriggerConfig(): { ttl: string; lockToVersion?: string } { + return { + ttl: DASHBOARD_AGENT_RUN_TTL, + ...(env.DASHBOARD_AGENT_VERSION ? { lockToVersion: env.DASHBOARD_AGENT_VERSION } : {}), + }; } export async function startDashboardAgentSession(params: { diff --git a/apps/webapp/app/services/realtime/sessionRunManager.server.ts b/apps/webapp/app/services/realtime/sessionRunManager.server.ts index a1989a9ef7a..f11bc960205 100644 --- a/apps/webapp/app/services/realtime/sessionRunManager.server.ts +++ b/apps/webapp/app/services/realtime/sessionRunManager.server.ts @@ -310,6 +310,7 @@ async function triggerSessionRun(params: { ...(config.maxDuration !== undefined ? { maxDuration: config.maxDuration } : {}), ...(config.lockToVersion ? { lockToVersion: config.lockToVersion } : {}), ...(config.region ? { region: config.region } : {}), + ...(config.ttl !== undefined ? { ttl: config.ttl } : {}), }, }; diff --git a/apps/webapp/test/realtimeServices.replicaLag.test.ts b/apps/webapp/test/realtimeServices.replicaLag.test.ts index 6a302dcfd9c..0de58c0403d 100644 --- a/apps/webapp/test/realtimeServices.replicaLag.test.ts +++ b/apps/webapp/test/realtimeServices.replicaLag.test.ts @@ -395,7 +395,7 @@ describe("realtime-svc — replica-lag guards", () => { environmentType: "DEVELOPMENT", organizationId: seed.organization.id, taskIdentifier: "my-task", - triggerConfig: { basePayload: {} }, + triggerConfig: { basePayload: {}, ttl: "2m" }, currentRunId: callingRunId, currentRunVersion: 0, streamBasinName: "session-pinned-basin", @@ -429,6 +429,8 @@ describe("realtime-svc — replica-lag guards", () => { // previousRunId forwarded to the triggered run is the calling run's cuid (documented fallback). expect(triggerState.calls).toHaveLength(1); expect(triggerState.calls[0]!.body.payload.previousRunId).toBe(callingRunId); + // The session's ttl reaches the trigger options, so an undequeued run expires. + expect(triggerState.calls[0]!.body.options.ttl).toBe("2m"); expect(versionCalls.at(-1)).toEqual({ requested: "v2", basin: null }); expect(replica.wasHit("taskRun")).toBe(true); diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index a90430953d4..14b6f54ea79 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -1850,6 +1850,11 @@ export const SessionTriggerConfig = z.object({ lockToVersion: z.string().optional(), /** Region to schedule runs in. Forwarded to `TaskRunOptions.region`. */ region: z.string().optional(), + /** + * How long a run may sit undequeued before it expires (duration string + * like `"2m"`, or seconds). Forwarded to `TaskRunOptions.ttl`. + */ + ttl: z.string().or(z.number().nonnegative().int()).optional(), /** Convenience field surfaced to chat.agent via the wire payload. */ idleTimeoutInSeconds: z.number().int().positive().max(3600).optional(), }); diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index c241930323b..dfee30f039a 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -10448,6 +10448,7 @@ function createChatStartSessionAction( const maxDuration = params.triggerConfig?.maxDuration ?? options?.triggerConfig?.maxDuration; const idleTimeoutInSeconds = params.triggerConfig?.idleTimeoutInSeconds ?? options?.triggerConfig?.idleTimeoutInSeconds; + const ttl = params.triggerConfig?.ttl ?? options?.triggerConfig?.ttl; const triggerConfig: SessionTriggerConfig = { basePayload: { @@ -10470,6 +10471,7 @@ function createChatStartSessionAction( ...(options?.triggerConfig?.region || params.triggerConfig?.region ? { region: params.triggerConfig?.region ?? options?.triggerConfig?.region } : {}), + ...(ttl !== undefined ? { ttl } : {}), ...(options?.triggerConfig?.lockToVersion || params.triggerConfig?.lockToVersion ? { lockToVersion: diff --git a/packages/trigger-sdk/src/v3/chat-server.test.ts b/packages/trigger-sdk/src/v3/chat-server.test.ts index 539fe0247ad..92ffd77502c 100644 --- a/packages/trigger-sdk/src/v3/chat-server.test.ts +++ b/packages/trigger-sdk/src/v3/chat-server.test.ts @@ -216,7 +216,7 @@ describe("chat.headStart (route handler)", () => { expect(body.triggerConfig.basePayload.idleTimeoutInSeconds).toBe(60); }); - it("merges triggerConfig tags and queue into createSession", async () => { + it("merges triggerConfig tags, queue and ttl into createSession", async () => { const requests: CapturedRequest[] = []; global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { const urlStr = typeof url === "string" ? url : url.toString(); @@ -248,6 +248,7 @@ describe("chat.headStart (route handler)", () => { triggerConfig: { tags: ["org:acme", "agentic-run:xyz"], queue: "my-queue", + ttl: "2m", }, run: async ({ chat: chatHelper }) => { return streamText({ @@ -276,6 +277,7 @@ describe("chat.headStart (route handler)", () => { const body = JSON.parse(sessionCreate!.init!.body as string); expect(body.triggerConfig.tags).toEqual(["chat:chat-1", "org:acme", "agentic-run:xyz"]); expect(body.triggerConfig.queue).toBe("my-queue"); + expect(body.triggerConfig.ttl).toBe("2m"); expect(body.triggerConfig.basePayload.trigger).toBe("handover-prepare"); expect(body.triggerConfig.basePayload.chatId).toBe("chat-1"); }); diff --git a/packages/trigger-sdk/src/v3/chat-server.ts b/packages/trigger-sdk/src/v3/chat-server.ts index 5e48e3b24b6..dc850d118ed 100644 --- a/packages/trigger-sdk/src/v3/chat-server.ts +++ b/packages/trigger-sdk/src/v3/chat-server.ts @@ -550,6 +550,7 @@ async function openHandoverSession(opts: { ? { maxDuration: opts.triggerConfig.maxDuration } : {}), ...(opts.triggerConfig?.region ? { region: opts.triggerConfig.region } : {}), + ...(opts.triggerConfig?.ttl !== undefined ? { ttl: opts.triggerConfig.ttl } : {}), ...(opts.triggerConfig?.lockToVersion ? { lockToVersion: opts.triggerConfig.lockToVersion } : {}), diff --git a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts index ca18ce59985..ca51282e614 100644 --- a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts +++ b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts @@ -115,7 +115,7 @@ describe("chat.createStartSessionAction — runtime", () => { ]); }); - it("forwards maxDuration, region, and lockToVersion from triggerConfig", async () => { + it("forwards maxDuration, region, lockToVersion, and ttl from triggerConfig", async () => { installStartFixture(); const start = chat.createStartSessionAction("fake-chat", { @@ -123,6 +123,7 @@ describe("chat.createStartSessionAction — runtime", () => { maxDuration: 120, region: "us-east-1", lockToVersion: "20260101.1", + ttl: "2m", }, }); await start({ chatId: "chat-parity" }); @@ -130,6 +131,16 @@ describe("chat.createStartSessionAction — runtime", () => { expect(lastStartBody?.triggerConfig.maxDuration).toBe(120); expect(lastStartBody?.triggerConfig.region).toBe("us-east-1"); expect(lastStartBody?.triggerConfig.lockToVersion).toBe("20260101.1"); + expect(lastStartBody?.triggerConfig.ttl).toBe("2m"); + }); + + it("omits ttl when triggerConfig does not set it", async () => { + installStartFixture(); + + const start = chat.createStartSessionAction("fake-chat"); + await start({ chatId: "chat-no-ttl" }); + + expect(lastStartBody?.triggerConfig).not.toHaveProperty("ttl"); }); it("server-mints override tokens for additional API keys", async () => {