-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix(core,sdk): stop chat.agent losing messages during recovery #4907
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
base: main
Are you sure you want to change the base?
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,6 @@ | ||
| --- | ||
| "@trigger.dev/sdk": patch | ||
| "@trigger.dev/core": patch | ||
| --- | ||
|
|
||
| `chat.agent`: a run that recovers a session with more than one in-flight user message no longer drops the unanswered ones if it restarts mid-recovery. Recovered messages now hold the resume cursor until each has been answered, so a restart re-answers the rest instead of resuming past them. Previously the cursor could advance past messages that were only held in memory, so a crash before they were dispatched lost them. |
|
ericallam marked this conversation as resolved.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2322,7 +2322,11 @@ async function findSessionInReplayWindowEnd( | |
| */ | ||
| async function installChatInputRouter( | ||
| chatId: string, | ||
| options?: { fallbackResumeFrom?: number; recoveredThrough?: number; resuming?: boolean } | ||
| options?: { | ||
| fallbackResumeFrom?: number; | ||
| recoveredSeqNums?: readonly number[]; | ||
| resuming?: boolean; | ||
| } | ||
| ): Promise<SessionChannelRouter> { | ||
| const entry = chatInputRouterEntry(chatId); | ||
| if (entry.attached) return entry.router; | ||
|
|
@@ -2353,20 +2357,13 @@ async function installChatInputRouter( | |
| } | ||
| } | ||
|
|
||
| // A boot that replayed `.in` itself has already answered everything up to | ||
| // `recoveredThrough`, so the floor has to cover it before the tail opens. | ||
| if (options?.recoveredThrough !== undefined) { | ||
| const recovered = options.recoveredThrough; | ||
| checkpoint.resumeFrom = Math.max(checkpoint.resumeFrom ?? recovered, recovered); | ||
| checkpoint.appliedThrough = Math.max( | ||
| checkpoint.appliedThrough ?? checkpoint.resumeFrom, | ||
| checkpoint.resumeFrom | ||
| ); | ||
| } | ||
|
|
||
| const router = entry.router; | ||
| router.restore(checkpoint); | ||
|
|
||
| if (options?.recoveredSeqNums && options.recoveredSeqNums.length > 0) { | ||
| router.markRecovered(options.recoveredSeqNums); | ||
| } | ||
|
|
||
| const floor = router.resumeFrom(); | ||
| if (floor !== undefined) { | ||
| sessionStreams.setLastSeqNum(chatId, "in", floor); | ||
|
|
@@ -7273,6 +7270,19 @@ function chatAgent< | |
| // `messagesInput.waitWithIdleTimeout` so recovered turns fire first. | ||
| const bootInjectedQueue: ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>[] = | ||
| []; | ||
| const recoveredSeqByPayload = new WeakMap< | ||
| ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>, | ||
| number | ||
| >(); | ||
| const dispatchBootInjected = (): ChatTaskWirePayload< | ||
| TUIMessage, | ||
| inferSchemaIn<TClientDataSchema> | ||
| > => { | ||
| const injected = bootInjectedQueue.shift()!; | ||
| const settledSeq = recoveredSeqByPayload.get(injected); | ||
| if (settledSeq !== undefined) chatInputRouter().settleRecovered(settledSeq); | ||
|
Comment on lines
+7281
to
+7283
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. 🔴 Recovered message settled before processing When a recovered turn starts, Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| return injected; | ||
| }; | ||
| const couldHavePriorState = payload.continuation === true || ctx.attempt.number > 1; | ||
|
|
||
| // `.in` resume cursor, computed at most once per boot. The boot | ||
|
|
@@ -7438,18 +7448,11 @@ function chatAgent< | |
|
|
||
| // ── session.in router ────────────────────────────────────────── | ||
| // | ||
| // Reads the turn boundary and subscribes in one call. `bootInCursor` is | ||
| // only a fallback: the boot block above may already have resolved a | ||
| // cursor from the snapshot, which is used when the boundary itself | ||
| // carries none. Everything the boot replayed off `.in` is dispatched from | ||
| // `bootInjectedQueue` below, so it goes into the floor here — folded in | ||
| // after the subscription opens, the live tail re-delivers it as a turn. | ||
| const lastRecoveredInSeq = | ||
| replayedInTail.length > 0 ? replayedInTail[replayedInTail.length - 1]!.seqNum : undefined; | ||
| const recoveredSeqNums = replayedInTail.map((r) => r.seqNum); | ||
|
|
||
| await installChatInputRouter(payload.chatId, { | ||
| fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined, | ||
| recoveredThrough: lastRecoveredInSeq, | ||
| recoveredSeqNums, | ||
| resuming: Boolean(payload.continuation) || ctx.attempt.number > 1, | ||
| }); | ||
|
|
||
|
|
@@ -7539,7 +7542,7 @@ function chatAgent< | |
| // branches: at n=1 the orphan partial is dropped and the interrupted | ||
| // user is re-dispatched as a fresh turn instead. | ||
| let seedChain: TUIMessage[]; | ||
| let recoveredTurns: TUIMessage[]; | ||
| let recoveredEntries: { message: TUIMessage; seqNum: number | undefined }[]; | ||
| if (hookChain !== undefined) { | ||
| seedChain = hookChain; | ||
| } else if (partialAssistant !== undefined && inFlightUsers.length > 1) { | ||
|
|
@@ -7548,11 +7551,20 @@ function chatAgent< | |
| seedChain = settledMessages; | ||
| } | ||
| if (hookRecoveredTurns !== undefined) { | ||
| recoveredTurns = hookRecoveredTurns; | ||
| const seqNumByRecoveredId = new Map<string, number>(); | ||
| for (const entry of replayedInTail) { | ||
| seqNumByRecoveredId.set(entry.message.id, entry.seqNum); | ||
| } | ||
| recoveredEntries = hookRecoveredTurns.map((message) => ({ | ||
| message, | ||
| seqNum: seqNumByRecoveredId.get(message.id), | ||
| })); | ||
|
Comment on lines
+7554
to
+7561
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. 🟡 Duplicate IDs release unmatched claims When recovered records share an ID, Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| } else if (partialAssistant !== undefined && inFlightUsers.length > 1) { | ||
| recoveredTurns = inFlightUsers.slice(1); | ||
| recoveredEntries = replayedInTail | ||
| .slice(1) | ||
| .map((r) => ({ message: r.message, seqNum: r.seqNum })); | ||
| } else { | ||
| recoveredTurns = inFlightUsers; | ||
| recoveredEntries = replayedInTail.map((r) => ({ message: r.message, seqNum: r.seqNum })); | ||
| } | ||
| // `beforeBoot` errors bubble — the customer opted into blocking | ||
| // persistence and a failure there should fail the run rather than | ||
|
|
@@ -7583,12 +7595,13 @@ function chatAgent< | |
| for (const entry of replayedInTail) { | ||
| metadataById.set(entry.message.id, entry.metadata); | ||
| } | ||
| for (const msg of recoveredTurns) { | ||
| const dispatchedRecoveredSeqs = new Set<number>(); | ||
| for (const { message: msg, seqNum } of recoveredEntries) { | ||
| if (wireMessageId && msg.id === wireMessageId) continue; | ||
| const recoveredMetadata = metadataById.has(msg.id) | ||
| ? metadataById.get(msg.id) | ||
| : payload.metadata; | ||
| bootInjectedQueue.push({ | ||
| const injectedPayload = { | ||
| chatId: payload.chatId, | ||
| sessionId: payload.sessionId, | ||
| metadata: recoveredMetadata, | ||
|
|
@@ -7597,7 +7610,17 @@ function chatAgent< | |
| messageId: msg.id, | ||
| continuation: payload.continuation, | ||
| previousRunId: payload.previousRunId, | ||
| } as ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>); | ||
| } as ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>; | ||
| bootInjectedQueue.push(injectedPayload); | ||
| if (seqNum !== undefined) { | ||
| recoveredSeqByPayload.set(injectedPayload, seqNum); | ||
| dispatchedRecoveredSeqs.add(seqNum); | ||
| } | ||
| } | ||
| for (const entry of replayedInTail) { | ||
| if (!dispatchedRecoveredSeqs.has(entry.seqNum)) { | ||
| chatInputRouter().settleRecovered(entry.seqNum); | ||
| } | ||
| } | ||
|
|
||
| accumulatedUIMessages = seedChain; | ||
|
|
@@ -7781,7 +7804,7 @@ function chatAgent< | |
| */ | ||
| let dispatchedRecoveredFirstTurn = false; | ||
| if (preloaded && bootInjectedQueue.length > 0) { | ||
| currentWirePayload = bootInjectedQueue.shift()!; | ||
| currentWirePayload = dispatchBootInjected(); | ||
| dispatchedRecoveredFirstTurn = true; | ||
| } | ||
|
|
||
|
|
@@ -8032,7 +8055,7 @@ function chatAgent< | |
| // waiting on the live session.in. Subsequent recovered turns | ||
| // get drained by the end-of-turn picker below. | ||
| if (bootInjectedQueue.length > 0) { | ||
| currentWirePayload = bootInjectedQueue.shift()!; | ||
| currentWirePayload = dispatchBootInjected(); | ||
| } else { | ||
| const effectiveIdleTimeout = idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds; | ||
| const effectiveTurnTimeout = | ||
|
|
@@ -9613,7 +9636,7 @@ function chatAgent< | |
| // produced these from in-flight user messages on session.in | ||
| // that the dead predecessor never acknowledged. | ||
| if (bootInjectedQueue.length > 0) { | ||
| currentWirePayload = bootInjectedQueue.shift()!; | ||
| currentWirePayload = dispatchBootInjected(); | ||
| return "continue"; | ||
| } | ||
|
|
||
|
|
@@ -9989,7 +10012,7 @@ function chatAgent< | |
| // recovered turn shouldn't strand the rest of the boot queue | ||
| // until an unrelated live message arrives. | ||
| if (bootInjectedQueue.length > 0) { | ||
| currentWirePayload = bootInjectedQueue.shift()!; | ||
| currentWirePayload = dispatchBootInjected(); | ||
| continue; | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.