@@ -12,6 +12,7 @@ import {
1212 IsoDateTime ,
1313 MessageId ,
1414 ModelSelection ,
15+ NonNegativeInt ,
1516 ProjectId ,
1617 ProviderInteractionMode ,
1718 RuntimeMode ,
@@ -29,7 +30,7 @@ import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";
2930import type { DraftComposerAttachment } from "../lib/composerImages" ;
3031import { scopedThreadKey } from "../lib/scopedEntities" ;
3132
32- const THREAD_OUTBOX_SCHEMA_VERSION = 6 ;
33+ const THREAD_OUTBOX_SCHEMA_VERSION = 7 ;
3334const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000 ;
3435
3536const QueuedThreadCreationSchema = Schema . Struct ( {
@@ -51,15 +52,18 @@ const ThreadOutboxDeliveryHoldSchema = Schema.Struct({
5152 "provider-binding-unresolved" ,
5253 "project-workspace-unavailable" ,
5354 "thread-missing" ,
55+ "source-epoch-stale" ,
5456 "admission-rejected" ,
5557 ] ) ,
5658 reason : Schema . String ,
5759 boundInstanceId : Schema . optional ( Schema . String ) ,
5860 queuedInstanceId : Schema . optional ( Schema . String ) ,
61+ queuedSourceEpoch : Schema . optional ( NonNegativeInt ) ,
62+ currentSourceEpoch : Schema . optional ( NonNegativeInt ) ,
5963} ) ;
6064
6165export const QueuedThreadMessageSchema = Schema . Struct ( {
62- schemaVersion : Schema . Literals ( [ 1 , 2 , 3 , 4 , 5 , THREAD_OUTBOX_SCHEMA_VERSION ] ) ,
66+ schemaVersion : Schema . Literals ( [ 1 , 2 , 3 , 4 , 5 , 6 , THREAD_OUTBOX_SCHEMA_VERSION ] ) ,
6367 environmentId : EnvironmentId ,
6468 threadId : ThreadId ,
6569 messageId : MessageId ,
@@ -69,6 +73,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({
6973 modelSelection : Schema . optional ( ModelSelection ) ,
7074 runtimeMode : Schema . optional ( RuntimeMode ) ,
7175 interactionMode : Schema . optional ( ProviderInteractionMode ) ,
76+ sourceEpoch : Schema . optional ( NonNegativeInt ) ,
7277 deliveryHold : Schema . optional ( ThreadOutboxDeliveryHoldSchema ) ,
7378 // Present when the queued item creates a brand-new thread (pending task)
7479 // instead of appending a turn to an existing one.
@@ -99,10 +104,13 @@ export interface ThreadOutboxDeliveryHold {
99104 | "provider-binding-unresolved"
100105 | "project-workspace-unavailable"
101106 | "thread-missing"
107+ | "source-epoch-stale"
102108 | "admission-rejected" ;
103109 readonly reason : string ;
104110 readonly boundInstanceId ?: string ;
105111 readonly queuedInstanceId ?: string ;
112+ readonly queuedSourceEpoch ?: number ;
113+ readonly currentSourceEpoch ?: number ;
106114}
107115
108116export interface QueuedThreadMessage {
@@ -115,6 +123,7 @@ export interface QueuedThreadMessage {
115123 readonly modelSelection ?: ModelSelectionType ;
116124 readonly runtimeMode ?: RuntimeModeType ;
117125 readonly interactionMode ?: ProviderInteractionModeType ;
126+ readonly sourceEpoch ?: number ;
118127 readonly deliveryHold ?: ThreadOutboxDeliveryHold ;
119128 readonly creation ?: QueuedThreadCreation ;
120129 readonly destination ?: QueuedThreadCreation ;
@@ -125,6 +134,7 @@ export interface ThreadSettingsSnapshot {
125134 readonly modelSelection : ModelSelectionType ;
126135 readonly runtimeMode : RuntimeModeType ;
127136 readonly interactionMode : ProviderInteractionModeType ;
137+ readonly sourceEpoch ?: number ;
128138 readonly session ?: {
129139 readonly providerInstanceId ?: ModelSelectionType [ "instanceId" ] | undefined ;
130140 } | null ;
@@ -317,6 +327,7 @@ export function retryQueuedThreadMessage(
317327 readonly modelSelection ?: ModelSelectionType ;
318328 readonly runtimeMode ?: RuntimeModeType ;
319329 readonly interactionMode ?: ProviderInteractionModeType ;
330+ readonly sourceEpoch ?: number ;
320331 } ,
321332) : QueuedThreadMessage {
322333 const { deliveryHold : _hold , ...retry } = message ;
@@ -327,6 +338,7 @@ export function retryQueuedThreadMessage(
327338 ...( input . modelSelection === undefined ? { } : { modelSelection : input . modelSelection } ) ,
328339 ...( input . runtimeMode === undefined ? { } : { runtimeMode : input . runtimeMode } ) ,
329340 ...( input . interactionMode === undefined ? { } : { interactionMode : input . interactionMode } ) ,
341+ ...( input . sourceEpoch === undefined ? { } : { sourceEpoch : input . sourceEpoch } ) ,
330342 } ;
331343}
332344
@@ -464,6 +476,22 @@ export function resolveConfirmedThreadOutboxPlan(input: {
464476} ) : ConfirmedThreadOutboxPlan {
465477 if ( input . message . deliveryHold !== undefined ) return { action : "wait" } ;
466478 const creation = input . message . creation ;
479+ if ( creation === undefined && input . thread != null ) {
480+ const queuedSourceEpoch = input . message . sourceEpoch ?? 0 ;
481+ const currentSourceEpoch = input . thread . sourceEpoch ?? 0 ;
482+ if ( queuedSourceEpoch !== currentSourceEpoch ) {
483+ return {
484+ action : "hold" ,
485+ hold : {
486+ kind : "source-epoch-stale" ,
487+ reason :
488+ "This message was composed before the thread was rolled back. Review it and explicitly reconfirm before sending." ,
489+ queuedSourceEpoch,
490+ currentSourceEpoch,
491+ } ,
492+ } ;
493+ }
494+ }
467495 if ( creation === undefined && input . thread == null ) {
468496 if ( input . shellStatus !== "live" ) return { action : "wait" } ;
469497 return {
@@ -601,6 +629,36 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean {
601629 return isTransportConnectionErrorMessage ( errorMessage ( error ) ) ;
602630}
603631
632+ export function sourceEpochMismatchHold ( error : unknown ) : ThreadOutboxDeliveryHold | null {
633+ if (
634+ typeof error !== "object" ||
635+ error === null ||
636+ ! ( "_tag" in error ) ||
637+ error . _tag !== "OrchestrationDispatchCommandError" ||
638+ ! ( "reason" in error ) ||
639+ error . reason !== "source-epoch-mismatch"
640+ ) {
641+ return null ;
642+ }
643+ return {
644+ kind : "source-epoch-stale" ,
645+ reason :
646+ "This message was composed before the thread was rolled back. Review it and explicitly reconfirm before sending." ,
647+ ...( typeof ( error as unknown as { expectedSourceEpoch ?: unknown } ) . expectedSourceEpoch ===
648+ "number"
649+ ? {
650+ queuedSourceEpoch : ( error as unknown as { expectedSourceEpoch : number } )
651+ . expectedSourceEpoch ,
652+ }
653+ : { } ) ,
654+ ...( typeof ( error as unknown as { actualSourceEpoch ?: unknown } ) . actualSourceEpoch === "number"
655+ ? {
656+ currentSourceEpoch : ( error as unknown as { actualSourceEpoch : number } ) . actualSourceEpoch ,
657+ }
658+ : { } ) ,
659+ } ;
660+ }
661+
604662export type ThreadOutboxCommandStage = "settings-sync" | "start-turn" ;
605663export type ThreadOutboxFailureAction = "retry" | "hold" ;
606664
0 commit comments