fix: serialize session lifecycle transitions

This commit is contained in:
User
2026-07-15 01:37:42 +02:00
parent 08b1f4909e
commit 6747f6f8a0
10 changed files with 1402 additions and 101 deletions
+64 -1
View File
@@ -33,6 +33,12 @@ import { useEffect, useMemo, useRef, useState } from "react";
export function AppShell() {
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const activeSessionIdRef = useRef<string | null>(null);
const resumeInvocationRef = useRef(0);
const latestResumeIntentRef = useRef<{ token: number; id: string } | null>(null);
const resumeInFlightRef = useRef(new Map<string, {
latestToken: number;
promise: Promise<void>;
}>());
const [streamCursorResetEpoch, setStreamCursorResetEpoch] = useState(0);
const [creatingSession, setCreatingSession] = useState(false);
const [awaitingQuestion, setAwaitingQuestion] = useState(false);
@@ -72,6 +78,11 @@ export function AppShell() {
setActiveSessionId(id);
}
function invalidateResumeIntent() {
resumeInvocationRef.current += 1;
latestResumeIntentRef.current = null;
}
// A background refresh can remove a session (for example from another browser).
// Keep the local selection aligned with the authoritative list.
useEffect(() => {
@@ -107,8 +118,37 @@ export function AppShell() {
});
}
async function doResume(id: string) {
const token = ++resumeInvocationRef.current;
latestResumeIntentRef.current = { token, id };
const inFlight = resumeInFlightRef.current.get(id);
if (inFlight) {
// Repeated intent for the same target shares one backend lifecycle operation and one
// commit path. Updating its token still lets s1→s2→s1 make the final s1 intent authoritative.
inFlight.latestToken = token;
return inFlight.promise;
}
const operation = {
latestToken: token,
promise: Promise.resolve(),
};
operation.promise = runResume(id, operation).finally(() => {
if (resumeInFlightRef.current.get(id) === operation) {
resumeInFlightRef.current.delete(id);
}
});
resumeInFlightRef.current.set(id, operation);
return operation.promise;
}
async function runResume(
id: string,
operation: { latestToken: number; promise: Promise<void> },
) {
try {
const result = await resumeSession(id);
const latest = latestResumeIntentRef.current;
if (latest?.token !== operation.latestToken || latest.id !== id) return;
const reconnectSameSession = activeSessionIdRef.current === id;
setPanelSession(null);
setAwaitingQuestion(false);
@@ -129,10 +169,22 @@ export function AppShell() {
setStreamCursorResetEpoch((value) => value + 1);
}
// Single-flight covers only the backend Resume and its local binding commit. A slow
// manifest read must not prevent a later explicit intent from starting a new Resume.
if (resumeInFlightRef.current.get(id) === operation) {
resumeInFlightRef.current.delete(id);
}
// Paint the persisted re-entry phase while the replacement stream starts replaying.
// The manifest's `phase` is the 1-based current phase (1..8).
try {
const m = (await getSession(id)) as { phase?: number };
const latestAfterManifest = latestResumeIntentRef.current;
if (
latestAfterManifest?.token !== operation.latestToken
|| latestAfterManifest.id !== id
|| activeSessionIdRef.current !== id
) return;
if (typeof m.phase === "number" && m.phase >= 1 && m.phase <= 8) {
setPhase(`F${m.phase}`);
}
@@ -140,7 +192,12 @@ export function AppShell() {
/* non-fatal: the first gate will set the phase */
}
} catch {
toast.error("Failed to resume session.");
if (
latestResumeIntentRef.current?.token === operation.latestToken
&& latestResumeIntentRef.current.id === id
) {
toast.error("Failed to resume session.");
}
}
}
async function move(s: SessionSummary, group: string) {
@@ -183,6 +240,9 @@ export function AppShell() {
}
async function deleteSessions(targets: SessionSummary[]) {
if (targets.some((session) => session.id === activeSessionIdRef.current)) {
invalidateResumeIntent();
}
try {
const results = await Promise.allSettled(targets.map((session) => deleteSession(session.id)));
const deletedIds = new Set(
@@ -246,6 +306,7 @@ export function AppShell() {
if (ev === "session_exit") {
// Never let a streamed event terminate the managed Pi child. Only the
// explicit “Stop & save” action is allowed to call /close.
invalidateResumeIntent();
resetSession();
selectActiveSession(null);
setAwaitingQuestion(false);
@@ -258,6 +319,7 @@ export function AppShell() {
}, [lastSystemEvent]);
function startNewSession() {
invalidateResumeIntent();
resetSession();
setAwaitingQuestion(true);
setCreatingSession(false);
@@ -290,6 +352,7 @@ export function AppShell() {
async function stopSession() {
if (!activeSessionId) return;
invalidateResumeIntent();
try {
await closeSession(activeSessionId);
} finally {