Skip to content

Commit 90ec43e

Browse files
committed
fix(chat,sdk): hold the resume cursor only behind records that matter
The `session-in-event-id` header has two consumers with opposite needs. A fresh boot reads it back as the `.in` resume cursor and needs it conservative, while a client compares it against the append sequence of its own send to recognise a turn boundary that predates that send, which needs it exact. Holding the cursor behind every unconsumed record served neither: an unconsumed control record pushed the header below the sequence of the message the turn had just answered, so a client discarded its own turn-complete and stayed streaming. The cursor is now held only behind records whose loss would matter, which for chat means messages. Replaying a stop or a handover on the next boot is benign, and a handover for a turn that never ran is discarded, so control records no longer need to hold the cursor. The manager takes the rule as a per-channel predicate and defaults to holding behind everything, so a missing or throwing predicate can only make the cursor more conservative. This also ends the case where one never-consumed record pinned the cursor for the rest of the run. Two further gaps in the same machinery: The unclaimed-kind drain and the cursor rule were installed only for `chat.customAgent`. `chat.agent` builds its task directly and got neither, so the managed agent, which is the common surface, kept accumulating barriers mid-turn. Both are now installed for both surfaces, with the drain attached after each one's resume cursor is seeded so it cannot open the subscribe at seq 0. A handover-prepare boot claims the handover kinds so a signal arriving before `waitForHandover` attaches is not drained, but the claim was released only inside `waitForHandover`. A loop that never called it held the claim for the life of the run, leaving a handover record parked at the head of the channel where it wedged `chat.messages.next()` permanently. The claim is now also released at the first turn boundary, by which point the handover window has closed either way.
1 parent e5c7123 commit 90ec43e

6 files changed

Lines changed: 147 additions & 10 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ export class SessionStreamsAPI implements SessionStreamManager {
8686
return manager.peekRecord(sessionId, io);
8787
}
8888

89+
public setCursorBarrier(
90+
sessionId: string,
91+
io: SessionChannelIO,
92+
predicate: SessionStreamRecordPredicate | undefined
93+
): void {
94+
this.#getManager().setCursorBarrier?.(sessionId, io, predicate);
95+
}
96+
8997
public lastSeqNum(sessionId: string, io: SessionChannelIO): number | undefined {
9098
return this.#getManager().lastSeqNum(sessionId, io);
9199
}

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ export class StandardSessionStreamManager implements SessionStreamManager {
7070
// Kept separately from `buffer` so the committed cursor can be calculated
7171
// without depending on buffer traversal.
7272
private unconsumedSeqNums = new Map<string, Set<number>>();
73+
74+
/**
75+
* Per-channel predicate deciding which buffered records hold the persisted
76+
* cursor back. Absent means every record does, which is the conservative
77+
* default. Consumers that know their record kinds narrow it so the cursor is
78+
* only held behind records whose loss would matter.
79+
*/
80+
private cursorBarriers = new Map<string, SessionStreamRecordPredicate>();
7381
// High-water mark of seq_nums that have been *consumed* (delivered to a
7482
// once() waiter or shifted off the buffer into a once() caller) on a channel.
7583
// Distinct from `seqNums`, which advances whenever any record is
@@ -329,6 +337,37 @@ export class StandardSessionStreamManager implements SessionStreamManager {
329337
}
330338
}
331339

340+
setCursorBarrier(
341+
sessionId: string,
342+
io: SessionChannelIO,
343+
predicate: SessionStreamRecordPredicate | undefined
344+
): void {
345+
const key = keyFor(sessionId, io);
346+
if (predicate) {
347+
this.cursorBarriers.set(key, predicate);
348+
} else {
349+
this.cursorBarriers.delete(key);
350+
}
351+
}
352+
353+
/**
354+
* Fails safe: an absent or throwing predicate treats the record as a barrier,
355+
* so a mistake here can only make the cursor more conservative, never skip a
356+
* record.
357+
*/
358+
#isCursorBarrier(key: string, record: SessionStreamRecord): boolean {
359+
const predicate = this.cursorBarriers.get(key);
360+
if (!predicate) return true;
361+
try {
362+
return predicate(record);
363+
} catch (error) {
364+
if (this.debug) {
365+
console.error("[SessionStreamManager] Cursor barrier predicate error:", error);
366+
}
367+
return true;
368+
}
369+
}
370+
332371
#markUnconsumedRecord(key: string, seqNum: number): void {
333372
if (!Number.isFinite(seqNum)) return;
334373

@@ -423,6 +462,7 @@ export class StandardSessionStreamManager implements SessionStreamManager {
423462
this.seqNums.clear();
424463
this.lastDispatchedSeqNums.clear();
425464
this.unconsumedSeqNums.clear();
465+
this.cursorBarriers.clear();
426466
this.minTimestamps.clear();
427467
this.handlers.clear();
428468
this.reconnectAttempts.clear();
@@ -602,7 +642,9 @@ export class StandardSessionStreamManager implements SessionStreamManager {
602642
this.buffer.set(key, buffered);
603643
}
604644
buffered.push(record);
605-
this.#markUnconsumedRecord(key, record.seqNum);
645+
if (this.#isCursorBarrier(key, record)) {
646+
this.#markUnconsumedRecord(key, record.seqNum);
647+
}
606648
this.#drainOnceWaitersFromBuffer(key);
607649
}
608650

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ export class NoopSessionStreamManager implements SessionStreamManager {
5555
return undefined;
5656
}
5757

58+
setCursorBarrier(
59+
_sessionId: string,
60+
_io: SessionChannelIO,
61+
_predicate: SessionStreamRecordPredicate | undefined
62+
): void {}
63+
5864
lastSeqNum(_sessionId: string, _io: SessionChannelIO): number | undefined {
5965
return undefined;
6066
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ export interface SessionStreamManager {
8282
/** Non-blocking peek at the head record, including its durable metadata. */
8383
peekRecord?(sessionId: string, io: SessionChannelIO): SessionStreamRecord | undefined;
8484

85+
/**
86+
* Narrow which buffered records hold the persisted cursor back. Absent means
87+
* every record does.
88+
*/
89+
setCursorBarrier?(
90+
sessionId: string,
91+
io: SessionChannelIO,
92+
predicate: SessionStreamRecordPredicate | undefined
93+
): void;
94+
8595
/** Last S2 sequence number seen on the given channel. */
8696
lastSeqNum(sessionId: string, io: SessionChannelIO): number | undefined;
8797

packages/core/src/v3/test/test-session-stream-manager.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export class TestSessionStreamManager implements SessionStreamManager {
4141
private seqNums = new Map<string, number>();
4242
private dispatchedSeqNums = new Map<string, number>();
4343
private unconsumedSeqNums = new Map<string, Set<number>>();
44+
private cursorBarriers = new Map<string, SessionStreamRecordPredicate>();
4445

4546
on(sessionId: string, io: SessionChannelIO, handler: Handler): { off: () => void } {
4647
const key = keyFor(sessionId, io);
@@ -197,6 +198,16 @@ export class TestSessionStreamManager implements SessionStreamManager {
197198
return this.peekRecord(sessionId, io)?.data;
198199
}
199200

201+
setCursorBarrier(
202+
sessionId: string,
203+
io: SessionChannelIO,
204+
predicate: SessionStreamRecordPredicate | undefined
205+
): void {
206+
const key = keyFor(sessionId, io);
207+
if (predicate) this.cursorBarriers.set(key, predicate);
208+
else this.cursorBarriers.delete(key);
209+
}
210+
200211
peekRecord(sessionId: string, io: SessionChannelIO): SessionStreamRecord | undefined {
201212
return this.buffer.get(keyFor(sessionId, io))?.[0];
202213
}
@@ -257,6 +268,16 @@ export class TestSessionStreamManager implements SessionStreamManager {
257268
}
258269
}
259270

271+
#isCursorBarrier(key: string, record: SessionStreamRecord): boolean {
272+
const predicate = this.cursorBarriers.get(key);
273+
if (!predicate) return true;
274+
try {
275+
return predicate(record);
276+
} catch {
277+
return true;
278+
}
279+
}
280+
260281
#markUnconsumedRecord(key: string, seqNum: number): void {
261282
if (!Number.isFinite(seqNum)) return;
262283

@@ -407,7 +428,9 @@ export class TestSessionStreamManager implements SessionStreamManager {
407428
this.buffer.set(key, buffered);
408429
}
409430
buffered.push(record);
410-
this.#markUnconsumedRecord(key, record.seqNum);
431+
if (this.#isCursorBarrier(key, record)) {
432+
this.#markUnconsumedRecord(key, record.seqNum);
433+
}
411434
this.#drainOnceWaitersFromBuffer(key);
412435
}
413436

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1563,6 +1563,23 @@ export type ChatMessages = RealtimeDefinedInputStream<ChatTaskWirePayload> & {
15631563
next(options?: { timeoutInSeconds?: number }): Promise<ChatMessageRecord | undefined>;
15641564
};
15651565

1566+
/**
1567+
* Only message records hold the persisted `.in` cursor back.
1568+
*
1569+
* The `session-in-event-id` header serves two consumers with opposite needs:
1570+
* `findLatestSessionInCursor` reads it as a resume cursor and wants it
1571+
* conservative, while a client reads it to correlate its own send's
1572+
* turn-complete and wants it exact. Holding the cursor behind an unconsumed
1573+
* control record satisfies neither: resume safety does not need it (replaying a
1574+
* stop or a handover is benign, and a handover for a turn that never ran is
1575+
* discarded), while a client comparing the header against its own append
1576+
* sequence sees a value below its send and discards its own turn boundary.
1577+
* @internal
1578+
*/
1579+
function isChatCursorBarrier(record: { data: unknown }): boolean {
1580+
return (record.data as ChatInputChunk | undefined)?.kind === "message";
1581+
}
1582+
15661583
function isChatMessageRecord(record: { data: unknown }): boolean {
15671584
return (record.data as ChatInputChunk | undefined)?.kind === "message";
15681585
}
@@ -2000,6 +2017,31 @@ function releaseChatInputKinds(kinds: readonly string[]): void {
20002017
locals.set(chatInputDrainKey, attachUnclaimedChatInputDrain());
20012018
}
20022019

2020+
/**
2021+
* Narrow what holds the persisted `.in` cursor back. Sets no listener, so it is
2022+
* safe to call before the resume cursor is seeded.
2023+
* @internal
2024+
*/
2025+
function setChatCursorBarrier(chatId: string): void {
2026+
sessionStreams.setCursorBarrier(chatId, "in", isChatCursorBarrier);
2027+
}
2028+
2029+
/**
2030+
* Claim the kinds this boot has a consumer for and drain the rest.
2031+
*
2032+
* Attaches a `.in` listener, so it MUST run after the resume cursor is seeded;
2033+
* attaching first makes the subscribe open at seq 0 and replay every record the
2034+
* previous run already answered.
2035+
* @internal
2036+
*/
2037+
function attachChatInputDrain(payload: { trigger?: string }): void {
2038+
const claimed = chatClaimedKinds();
2039+
if (payload.trigger === "handover-prepare") {
2040+
for (const kind of CHAT_HANDOVER_KINDS) claimed.add(kind);
2041+
}
2042+
locals.set(chatInputDrainKey, attachUnclaimedChatInputDrain());
2043+
}
2044+
20032045
/**
20042046
* Per-turn deferred promises. Registered via `chat.defer()`, awaited
20052047
* before `onTurnComplete` fires. Reset each turn.
@@ -5527,19 +5569,13 @@ function chatCustomAgent<
55275569
locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
55285570
markChatAgentRunForStreamsWarning();
55295571
taskContext.setConversationId(payload.chatId);
5572+
setChatCursorBarrier(payload.chatId);
55305573
stampConversationIdOnActiveSpan(payload.chatId);
55315574
// Seed the `.in` resume cursor before user code attaches any `.in`
55325575
// listener — otherwise a continuation boot replays already-answered
55335576
// messages into the loop's first wait.
55345577
await seedSessionInResumeCursorForCustomLoop(payload);
5535-
// Claim the kinds this boot actually has a consumer for, then attach the
5536-
// drain for everything else. Handover kinds are only claimed on a
5537-
// handover-prepare boot, which is the only boot that waits for them.
5538-
const claimed = chatClaimedKinds();
5539-
if (payload.trigger === "handover-prepare") {
5540-
for (const kind of CHAT_HANDOVER_KINDS) claimed.add(kind);
5541-
}
5542-
locals.set(chatInputDrainKey, attachUnclaimedChatInputDrain());
5578+
attachChatInputDrain(payload);
55435579
return userRun(payload, runOptions);
55445580
},
55455581
});
@@ -5646,6 +5682,7 @@ function chatAgent<
56465682
locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
56475683
markChatAgentRunForStreamsWarning();
56485684
taskContext.setConversationId(payload.chatId);
5685+
setChatCursorBarrier(payload.chatId);
56495686

56505687
// Stamp `gen_ai.conversation.id` on the run-level span. Every
56515688
// nested span inherits the same attribute via
@@ -5932,6 +5969,8 @@ function chatAgent<
59325969
}
59335970
}
59345971

5972+
attachChatInputDrain(payload);
5973+
59355974
// ── Recovery boot + chain reconstruction ────────────────────────
59365975
if (!hydrateMessages) {
59375976
const settledMessages = mergeByIdReplaceWins<TUIMessage>(
@@ -9109,6 +9148,15 @@ function createStopSignal(): {
91099148
async function chatWriteTurnComplete(options?: {
91109149
publicAccessToken?: string;
91119150
}): Promise<{ lastEventId?: string; sessionInEventId?: string }> {
9151+
// A handover-prepare boot claims the handover kinds so a signal arriving
9152+
// before `waitForHandover` attaches is not drained. A loop that never calls
9153+
// `waitForHandover` would otherwise hold that claim for the life of the run,
9154+
// leaving any handover record parked at the head of the channel: it wedges
9155+
// `chat.messages.next()` and, before the cursor barrier narrowed, pinned the
9156+
// persisted cursor forever. By the time a turn completes the handover window
9157+
// is over either way.
9158+
releaseChatInputKinds(CHAT_HANDOVER_KINDS);
9159+
91129160
const result = await writeTurnCompleteChunk(undefined, options?.publicAccessToken);
91139161
// Same cursor written to the `session-in-event-id` header inside
91149162
// `writeTurnCompleteChunk`; surfaced here so the caller can persist it.

0 commit comments

Comments
 (0)