Skip to content

Commit b334ab2

Browse files
committed
fix(chat): preserve mailbox cursor across waitpoints
1 parent 6abc529 commit b334ab2

16 files changed

Lines changed: 517 additions & 95 deletions

apps/webapp/app/routes/api.v1.runs.$runFriendlyId.session-streams.wait.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { json } from "@remix-run/server-runtime";
22
import {
33
CreateSessionStreamWaitpointRequestBody,
4+
SESSION_STREAM_WAITPOINT_RECORD_CONTENT_TYPE,
5+
serializeSessionStreamWaitpointRecord,
46
type CreateSessionStreamWaitpointResponseBody,
57
} from "@trigger.dev/core/v3";
68
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
@@ -125,7 +127,8 @@ const { action, loader } = createActionApiRoute(
125127
addressingKey,
126128
body.io,
127129
result.waitpoint.id,
128-
ttlMs && ttlMs > 0 ? ttlMs : undefined
130+
ttlMs && ttlMs > 0 ? ttlMs : undefined,
131+
body.responseFormat
129132
);
130133

131134
// Race-check. If a record landed on the channel before this
@@ -155,8 +158,14 @@ const { action, loader } = createActionApiRoute(
155158
await engine.completeWaitpoint({
156159
id: result.waitpoint.id,
157160
output: {
158-
value: record.data,
159-
type: "application/json",
161+
value:
162+
body.responseFormat === "record-v1"
163+
? serializeSessionStreamWaitpointRecord(record.data, record.seqNum)
164+
: record.data,
165+
type:
166+
body.responseFormat === "record-v1"
167+
? SESSION_STREAM_WAITPOINT_RECORD_CONTENT_TYPE
168+
: "application/json",
160169
isError: false,
161170
},
162171
});

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

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
claimSessionStreamPart,
1616
drainSessionStreamWaitpoints,
1717
releaseSessionStreamPart,
18+
sessionStreamWaitpointOutput,
1819
} from "~/services/sessionStreamWaitpointCache.server";
1920
import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
2021
import { engine } from "~/v3/runEngine.server";
@@ -201,7 +202,7 @@ const { action, loader } = createActionApiRoute(
201202
// keyed on the canonical addressing key the agent registered with via
202203
// `sessions.open(...).in.wait()`, so writers and readers converge
203204
// regardless of which URL form they used.
204-
const [drainError, waitpointIds] = await tryCatch(
205+
const [drainError, waitpoints] = await tryCatch(
205206
drainSessionStreamWaitpoints(authentication.environment.id, addressingKey, params.io)
206207
);
207208
if (drainError) {
@@ -210,24 +211,20 @@ const { action, loader } = createActionApiRoute(
210211
io: params.io,
211212
error: drainError,
212213
});
213-
} else if (waitpointIds && waitpointIds.length > 0) {
214+
} else if (waitpoints && waitpoints.length > 0) {
214215
await Promise.all(
215-
waitpointIds.map(async (waitpointId) => {
216+
waitpoints.map(async (waitpoint) => {
216217
const [completeError] = await tryCatch(
217218
engine.completeWaitpoint({
218-
id: waitpointId,
219-
output: {
220-
value: part,
221-
type: "application/json",
222-
isError: false,
223-
},
219+
id: waitpoint.id,
220+
output: sessionStreamWaitpointOutput(waitpoint, part, appendSeq),
224221
})
225222
);
226223
if (completeError) {
227224
logger.error("Failed to complete session stream waitpoint", {
228225
addressingKey,
229226
io: params.io,
230-
waitpointId,
227+
waitpointId: waitpoint.id,
231228
error: completeError,
232229
});
233230
}

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.sessions.$session.$io.append.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import {
1313
resolveSessionByIdOrExternalId,
1414
} from "~/services/realtime/sessions.server";
1515
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
16-
import { drainSessionStreamWaitpoints } from "~/services/sessionStreamWaitpointCache.server";
16+
import {
17+
drainSessionStreamWaitpoints,
18+
sessionStreamWaitpointOutput,
19+
} from "~/services/sessionStreamWaitpointCache.server";
1720
import { requireUserId } from "~/services/session.server";
1821
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
1922
import { engine } from "~/v3/runEngine.server";
@@ -114,7 +117,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
114117

115118
// Drain any waitpoints registered for this channel — same as the
116119
// public append. Best-effort; failure doesn't fail the append.
117-
const [drainError, waitpointIds] = await tryCatch(
120+
const [drainError, waitpoints] = await tryCatch(
118121
drainSessionStreamWaitpoints(environment.id, addressingKey, io)
119122
);
120123
if (drainError) {
@@ -123,24 +126,20 @@ export async function action({ request, params }: ActionFunctionArgs) {
123126
io,
124127
error: drainError,
125128
});
126-
} else if (waitpointIds && waitpointIds.length > 0) {
129+
} else if (waitpoints && waitpoints.length > 0) {
127130
await Promise.all(
128-
waitpointIds.map(async (waitpointId) => {
131+
waitpoints.map(async (waitpoint) => {
129132
const [completeError] = await tryCatch(
130133
engine.completeWaitpoint({
131-
id: waitpointId,
132-
output: {
133-
value: part,
134-
type: "application/json",
135-
isError: false,
136-
},
134+
id: waitpoint.id,
135+
output: sessionStreamWaitpointOutput(waitpoint, part, appendSeq ?? undefined),
137136
})
138137
);
139138
if (completeError) {
140139
logger.error("Failed to complete session stream waitpoint (playground)", {
141140
addressingKey,
142141
io,
143-
waitpointId,
142+
waitpointId: waitpoint.id,
144143
error: completeError,
145144
});
146145
}

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

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { Redis } from "ioredis";
22
import { defaultReconnectOnError } from "@internal/redis";
3+
import {
4+
SESSION_STREAM_WAITPOINT_RECORD_CONTENT_TYPE,
5+
serializeSessionStreamWaitpointRecord,
6+
} from "@trigger.dev/core/v3";
37
import { env } from "~/env.server";
48
import { singleton } from "~/utils/singleton";
59
import { logger } from "./logger.server";
@@ -13,12 +17,35 @@ import { logger } from "./logger.server";
1317
// is shared — without it, two environments using the same externalId
1418
// would drain each other's waitpoints.
1519
const KEY_PREFIX = "ssw:";
20+
const FORMAT_KEY_PREFIX = "sswf:";
1621
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
1722

23+
export type SessionStreamWaitpoint = {
24+
id: string;
25+
responseFormat?: "record-v1";
26+
};
27+
28+
export function sessionStreamWaitpointOutput(
29+
waitpoint: SessionStreamWaitpoint,
30+
data: string,
31+
seqNum: number | undefined
32+
): { value: string; type: string; isError: false } {
33+
const hasRecordEnvelope = waitpoint.responseFormat === "record-v1" && seqNum !== undefined;
34+
return {
35+
value: hasRecordEnvelope ? serializeSessionStreamWaitpointRecord(data, seqNum) : data,
36+
type: hasRecordEnvelope ? SESSION_STREAM_WAITPOINT_RECORD_CONTENT_TYPE : "application/json",
37+
isError: false,
38+
};
39+
}
40+
1841
function buildKey(environmentId: string, addressingKey: string, io: "out" | "in"): string {
1942
return `${KEY_PREFIX}${environmentId}:${addressingKey}:${io}`;
2043
}
2144

45+
function buildFormatKey(waitpointId: string): string {
46+
return `${FORMAT_KEY_PREFIX}${waitpointId}`;
47+
}
48+
2249
// Pre-env-scoping key format, drained for one release so waitpoints from the
2350
// previous deploy still wake. Removable once this has been live > turn timeout.
2451
function buildLegacyKey(addressingKey: string, io: "out" | "in"): string {
@@ -81,13 +108,25 @@ export async function addSessionStreamWaitpoint(
81108
addressingKey: string,
82109
io: "out" | "in",
83110
waitpointId: string,
84-
ttlMs?: number
111+
ttlMs?: number,
112+
responseFormat?: "record-v1"
85113
): Promise<void> {
86114
if (!redis) return;
87115

88116
try {
89117
const key = buildKey(environmentId, addressingKey, io);
90-
await redis.eval(ADD_WAITPOINT_SCRIPT, 1, key, waitpointId, String(ttlMs ?? DEFAULT_TTL_MS));
118+
const effectiveTtlMs = ttlMs ?? DEFAULT_TTL_MS;
119+
120+
// Keep the set member as the plain waitpoint id so an older append
121+
// instance can still drain it during a rolling deploy. New instances read
122+
// the optional response format from this separate, TTL-bound key.
123+
if (responseFormat) {
124+
await redis.set(buildFormatKey(waitpointId), responseFormat, "PX", effectiveTtlMs);
125+
} else {
126+
await redis.del(buildFormatKey(waitpointId));
127+
}
128+
129+
await redis.eval(ADD_WAITPOINT_SCRIPT, 1, key, waitpointId, String(effectiveTtlMs));
91130
} catch (error) {
92131
logger.error("Failed to set session stream waitpoint cache", {
93132
environmentId,
@@ -107,7 +146,7 @@ export async function drainSessionStreamWaitpoints(
107146
environmentId: string,
108147
addressingKey: string,
109148
io: "out" | "in"
110-
): Promise<string[]> {
149+
): Promise<SessionStreamWaitpoint[]> {
111150
if (!redis) return [];
112151

113152
try {
@@ -129,7 +168,34 @@ export async function drainSessionStreamWaitpoints(
129168
if (err || !Array.isArray(members)) continue;
130169
for (const m of members as string[]) ids.add(m);
131170
}
132-
return [...ids];
171+
const waitpointIds = [...ids];
172+
if (waitpointIds.length === 0) return [];
173+
174+
let formatResults: Awaited<ReturnType<typeof pipeline.exec>> | null = null;
175+
try {
176+
const formatPipeline = redis.multi();
177+
for (const waitpointId of waitpointIds) {
178+
formatPipeline.get(buildFormatKey(waitpointId));
179+
formatPipeline.del(buildFormatKey(waitpointId));
180+
}
181+
formatResults = await formatPipeline.exec();
182+
} catch (error) {
183+
// The waitpoint ids were already drained. Complete them with raw data
184+
// rather than losing the wake-up because optional metadata was unavailable.
185+
logger.error("Failed to read session stream waitpoint response formats", {
186+
environmentId,
187+
addressingKey,
188+
io,
189+
error,
190+
});
191+
}
192+
193+
return waitpointIds.map((id, index) => {
194+
const formatEntry = formatResults?.[index * 2];
195+
const responseFormat =
196+
formatEntry && !formatEntry[0] && formatEntry[1] === "record-v1" ? "record-v1" : undefined;
197+
return { id, responseFormat };
198+
});
133199
} catch (error) {
134200
logger.error("Failed to drain session stream waitpoint cache", {
135201
environmentId,
@@ -240,7 +306,10 @@ export async function removeSessionStreamWaitpoint(
240306

241307
try {
242308
const key = buildKey(environmentId, addressingKey, io);
243-
await redis.srem(key, waitpointId);
309+
const pipeline = redis.multi();
310+
pipeline.srem(key, waitpointId);
311+
pipeline.del(buildFormatKey(waitpointId));
312+
await pipeline.exec();
244313
} catch (error) {
245314
logger.error("Failed to remove session stream waitpoint cache entry", {
246315
environmentId,

apps/webapp/app/v3/webhookEngine.server.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
claimSessionStreamPart,
1818
drainSessionStreamWaitpoints,
1919
releaseSessionStreamPart,
20+
sessionStreamWaitpointOutput,
2021
} from "~/services/sessionStreamWaitpointCache.server";
2122
import { getSecretStore } from "~/services/secrets/secretStore.server";
2223
import { singleton } from "~/utils/singleton";
@@ -229,10 +230,12 @@ function createWebhookEngine() {
229230
"in",
230231
deliveryId
231232
);
233+
let appendSeq: number | undefined;
232234
if (wonClaim) {
233-
const [appendError] = await tryCatch(
235+
const [appendError, seqNum] = await tryCatch(
234236
realtimeStream.appendPartToSessionStream(part, deliveryId, addressingKey, "in")
235237
);
238+
appendSeq = seqNum ?? undefined;
236239
if (appendError) {
237240
// Nothing landed — release the claim so a retry re-appends the same id.
238241
await releaseSessionStreamPart(environment.id, addressingKey, "in", deliveryId);
@@ -245,21 +248,21 @@ function createWebhookEngine() {
245248
}
246249

247250
// Wake any `.in` waitpoints the run registered (best-effort; the record is durable in S2).
248-
const [drainError, waitpointIds] = await tryCatch(
251+
const [drainError, waitpoints] = await tryCatch(
249252
drainSessionStreamWaitpoints(environment.id, addressingKey, "in")
250253
);
251254
if (drainError) {
252255
logger.error("deliverToSession: failed to drain session waitpoints", {
253256
externalId,
254257
error: drainError,
255258
});
256-
} else if (waitpointIds && waitpointIds.length > 0) {
259+
} else if (waitpoints && waitpoints.length > 0) {
257260
await Promise.all(
258-
waitpointIds.map((waitpointId) =>
261+
waitpoints.map((waitpoint) =>
259262
tryCatch(
260263
runEngine.completeWaitpoint({
261-
id: waitpointId,
262-
output: { value: part, type: "application/json", isError: false },
264+
id: waitpoint.id,
265+
output: sessionStreamWaitpointOutput(waitpoint, part, appendSeq),
263266
})
264267
)
265268
)

packages/core/src/v3/schemas/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1669,6 +1669,8 @@ export const CreateSessionStreamWaitpointRequestBody = z.object({
16691669
* Used to catch data that arrived before `.wait()` was called.
16701670
*/
16711671
lastSeqNum: z.number().optional(),
1672+
/** Internal capability flag: return the exact record sequence on resume. */
1673+
responseFormat: z.literal("record-v1").optional(),
16721674
});
16731675
export type CreateSessionStreamWaitpointRequestBody = z.infer<
16741676
typeof CreateSessionStreamWaitpointRequestBody

packages/core/src/v3/sessionStreams/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ export class SessionStreamsAPI implements SessionStreamManager {
9494
this.#getManager().setLastSeqNum(sessionId, io, seqNum);
9595
}
9696

97+
public consumeRecord(sessionId: string, io: SessionChannelIO, seqNum: number): void {
98+
const manager = this.#getManager();
99+
if (!manager.consumeRecord) {
100+
throw new Error("The configured Session stream manager does not support exact consumption");
101+
}
102+
manager.consumeRecord(sessionId, io, seqNum);
103+
}
104+
97105
public lastDispatchedSeqNum(sessionId: string, io: SessionChannelIO): number | undefined {
98106
return this.#getManager().lastDispatchedSeqNum(sessionId, io);
99107
}

packages/core/src/v3/sessionStreams/manager.test.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ describe("StandardSessionStreamManager — record metadata", () => {
386386
manager.disconnect();
387387
});
388388

389-
it("retains cursor barriers when disconnect clears the buffer", async () => {
389+
it("preserves buffered records across disconnect and consumes only the exact sequence", async () => {
390390
const manager = new StandardSessionStreamManager(
391391
singleShotApiClient([
392392
{
@@ -401,6 +401,12 @@ describe("StandardSessionStreamManager — record metadata", () => {
401401
chunk: { kind: "stop" },
402402
timestamp: 2000,
403403
},
404+
{
405+
id: "52",
406+
recordId: "message-2",
407+
chunk: { kind: "message", payload: { id: "u2" } },
408+
timestamp: 3000,
409+
},
404410
]),
405411
"http://localhost"
406412
);
@@ -418,11 +424,16 @@ describe("StandardSessionStreamManager — record metadata", () => {
418424

419425
expect(manager.lastDispatchedSeqNum(sessionId, io)).toBe(49);
420426
manager.disconnectStream(sessionId, io);
421-
expect(manager.peekRecord(sessionId, io)).toBeUndefined();
427+
expect(manager.peekRecord(sessionId, io)?.seqNum).toBe(50);
422428
expect(manager.lastDispatchedSeqNum(sessionId, io)).toBe(49);
423429

424-
manager.setLastDispatchedSeqNum(sessionId, io, 51);
425-
expect(manager.lastDispatchedSeqNum(sessionId, io)).toBe(49);
430+
manager.consumeRecord(sessionId, io, 50);
431+
expect(manager.peekRecord(sessionId, io)?.seqNum).toBe(52);
432+
expect(manager.lastDispatchedSeqNum(sessionId, io)).toBe(51);
433+
434+
manager.consumeRecord(sessionId, io, 52);
435+
expect(manager.peekRecord(sessionId, io)).toBeUndefined();
436+
expect(manager.lastDispatchedSeqNum(sessionId, io)).toBe(52);
426437

427438
manager.reset();
428439
expect(manager.lastDispatchedSeqNum(sessionId, io)).toBeUndefined();

0 commit comments

Comments
 (0)