Skip to content

Commit a9f5fc0

Browse files
committed
fix(rollback): fence stale clients and queued turns
Refs #200
1 parent 27ddec5 commit a9f5fc0

40 files changed

Lines changed: 1206 additions & 67 deletions

apps/mobile/src/features/threads/ThreadDetailScreen.tsx

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
type CodexArtifactTemplate,
1212
} from "@t3tools/client-runtime/codex-artifact-templates";
1313
import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
14-
import type { RollbackTarget } from "@t3tools/client-runtime/rollback";
14+
import { isRollbackActive, type RollbackTarget } from "@t3tools/client-runtime/rollback";
15+
import { getMobileRollbackStatusPresentation } from "./rollback-status-presentation";
1516
import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
1617
import type { LegendListRef } from "@legendapp/list/react-native";
1718
import { HeaderHeightContext } from "@react-navigation/elements";
@@ -305,32 +306,20 @@ function RollbackStatusSurface(props: {
305306
readonly onRecover: (action: "retry-verification" | "resume-compensation") => Promise<void>;
306307
}) {
307308
if (!props.status) return null;
308-
const severe = props.status.state === "manual-recovery" || props.status.state === "failed";
309-
const title =
310-
props.status.state === "pending"
311-
? "Rollback pending"
312-
: props.status.state === "recovering"
313-
? "Rollback recovering"
314-
: props.status.state === "manual-recovery"
315-
? "Manual recovery required"
316-
: props.status.state === "completed"
317-
? "Rollback completed"
318-
: "Rollback failed safely";
319-
const actions = props.status.allowedActions ?? [];
309+
const presentation = getMobileRollbackStatusPresentation(props.status);
310+
const { severe, title, actions } = presentation;
320311
return (
321312
<View
322-
accessibilityRole={severe ? "alert" : "summary"}
323-
accessibilityLiveRegion={severe ? "assertive" : "polite"}
313+
accessibilityRole={presentation.accessibilityRole}
314+
accessibilityLiveRegion={presentation.accessibilityLiveRegion}
324315
className={`mb-3 gap-2 rounded-2xl border px-3 py-2.5 ${
325316
severe ? "border-red-500/50 bg-red-500/10" : "border-adaptive-neutral-300-700 bg-screen"
326317
}`}
327318
>
328319
<Text className={`font-t3-semibold text-sm ${severe ? "text-red-500" : "text-foreground"}`}>
329320
{title}
330321
</Text>
331-
<Text className="font-t3-regular text-xs text-foreground-muted">
332-
{props.status.detail ?? "Pylon is verifying rollback state."}
333-
</Text>
322+
<Text className="font-t3-regular text-xs text-foreground-muted">{presentation.detail}</Text>
334323
{actions.length > 0 ? (
335324
<View className="flex-row flex-wrap gap-2">
336325
{actions.includes("retry-verification") ? (
@@ -945,6 +934,23 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
945934
pending={props.rollbackCommandPending}
946935
onRecover={props.onRecoverRollback}
947936
/>
937+
{props.localOutboxCount > 0 &&
938+
props.rollbackTargets.size > 0 &&
939+
!isRollbackActive(props.rollbackStatus) ? (
940+
<View
941+
accessibilityRole="summary"
942+
className="mb-3 rounded-xl border border-adaptive-neutral-300-700 bg-screen px-3 py-2"
943+
>
944+
<Text className="text-sm font-semibold text-foreground">
945+
Rollback paused for queued messages
946+
</Text>
947+
<Text className="mt-1 text-xs leading-5 text-foreground-muted">
948+
Send or cancel the queued{" "}
949+
{props.localOutboxCount === 1 ? "message" : "messages"} before starting
950+
rollback.
951+
</Text>
952+
</View>
953+
) : null}
948954
</View>
949955

950956
{props.activePendingApproval ||

apps/mobile/src/features/threads/ThreadRouteScreen.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
isRollbackActive,
2525
type RollbackTarget,
2626
} from "@t3tools/client-runtime/rollback";
27+
import { resolveMobileRollbackStatus } from "./rollback-status-presentation";
2728
import {
2829
requestOlderThreadTurns,
2930
threadHasOlderTurns,
@@ -726,7 +727,10 @@ function ThreadRouteContent(
726727
: deriveRollbackTargets(selectedThreadDetail),
727728
[selectedThreadDetail],
728729
);
729-
const rollbackStatus = selectedThreadDetail?.rollbackStatus ?? selectedThread?.rollbackStatus;
730+
const rollbackStatus = resolveMobileRollbackStatus(
731+
selectedThreadDetail?.rollbackStatus,
732+
selectedThread?.rollbackStatus,
733+
);
730734
const rollbackActive = isRollbackActive(rollbackStatus);
731735
const rollbackTargetIdle =
732736
selectedThreadDetail?.session !== null &&
@@ -738,6 +742,7 @@ function ThreadRouteContent(
738742
selectedThreadDetail.session.activeTurnRequestId === undefined &&
739743
selectedThreadDetail.latestTurn?.state !== "running" &&
740744
!rollbackActive &&
745+
composer.selectedThreadQueueCount === 0 &&
741746
!composer.activeThreadBusy &&
742747
!rollbackCommandPending;
743748

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
import type { OrchestrationRollbackStatus } from "@t3tools/contracts";
3+
4+
import {
5+
getMobileRollbackStatusPresentation,
6+
resolveMobileRollbackStatus,
7+
} from "./rollback-status-presentation";
8+
9+
const updatedAt = "2026-08-31T12:00:00.000Z";
10+
11+
describe("mobile rollback status presentation", () => {
12+
it("keeps the durable detail status ahead of a stale shell status", () => {
13+
const detail: OrchestrationRollbackStatus = {
14+
state: "manual-recovery",
15+
updatedAt,
16+
detail: "Restore the provider transcript, then retry verification.",
17+
allowedActions: ["retry-verification", "resume-compensation"],
18+
};
19+
const shell: OrchestrationRollbackStatus = { state: "pending", updatedAt };
20+
expect(resolveMobileRollbackStatus(detail, shell)).toBe(detail);
21+
expect(resolveMobileRollbackStatus(undefined, shell)).toBe(shell);
22+
});
23+
24+
it("announces progress politely and manual recovery assertively with exact actions", () => {
25+
expect(getMobileRollbackStatusPresentation({ state: "recovering", updatedAt })).toMatchObject({
26+
title: "Rollback recovering",
27+
severe: false,
28+
accessibilityRole: "summary",
29+
accessibilityLiveRegion: "polite",
30+
actions: [],
31+
});
32+
33+
expect(
34+
getMobileRollbackStatusPresentation({
35+
state: "manual-recovery",
36+
updatedAt,
37+
detail: "Manual repair is required.",
38+
allowedActions: ["retry-verification", "resume-compensation"],
39+
}),
40+
).toEqual({
41+
title: "Manual recovery required",
42+
detail: "Manual repair is required.",
43+
severe: true,
44+
accessibilityRole: "alert",
45+
accessibilityLiveRegion: "assertive",
46+
actions: ["retry-verification", "resume-compensation"],
47+
});
48+
});
49+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { OrchestrationRollbackStatus } from "@t3tools/contracts";
2+
3+
export interface MobileRollbackStatusPresentation {
4+
readonly title: string;
5+
readonly detail: string;
6+
readonly severe: boolean;
7+
readonly accessibilityRole: "alert" | "summary";
8+
readonly accessibilityLiveRegion: "assertive" | "polite";
9+
readonly actions: ReadonlyArray<"retry-verification" | "resume-compensation">;
10+
}
11+
12+
export function resolveMobileRollbackStatus(
13+
detailStatus: OrchestrationRollbackStatus | null | undefined,
14+
shellStatus: OrchestrationRollbackStatus | null | undefined,
15+
): OrchestrationRollbackStatus | null | undefined {
16+
return detailStatus ?? shellStatus;
17+
}
18+
19+
export function getMobileRollbackStatusPresentation(
20+
status: OrchestrationRollbackStatus,
21+
): MobileRollbackStatusPresentation {
22+
const severe = status.state === "manual-recovery" || status.state === "failed";
23+
const title =
24+
status.state === "pending"
25+
? "Rollback pending"
26+
: status.state === "recovering"
27+
? "Rollback recovering"
28+
: status.state === "manual-recovery"
29+
? "Manual recovery required"
30+
: status.state === "completed"
31+
? "Rollback completed"
32+
: "Rollback failed safely";
33+
return {
34+
title,
35+
detail: status.detail ?? "Pylon is verifying rollback state.",
36+
severe,
37+
accessibilityRole: severe ? "alert" : "summary",
38+
accessibilityLiveRegion: severe ? "assertive" : "polite",
39+
actions: status.allowedActions ?? [],
40+
};
41+
}

apps/mobile/src/state/thread-outbox-model.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
2930
import type { DraftComposerAttachment } from "../lib/composerImages";
3031
import { scopedThreadKey } from "../lib/scopedEntities";
3132

32-
const THREAD_OUTBOX_SCHEMA_VERSION = 6;
33+
const THREAD_OUTBOX_SCHEMA_VERSION = 7;
3334
const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;
3435

3536
const 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

6165
export 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

108116
export 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+
604662
export type ThreadOutboxCommandStage = "settings-sync" | "start-turn";
605663
export type ThreadOutboxFailureAction = "retry" | "hold";
606664

0 commit comments

Comments
 (0)