fix: serialize session lifecycle transitions
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user