fix: reconcile pending session starts

This commit is contained in:
Federico Jaramillo Martinez
2026-07-02 19:05:23 +02:00
parent 2665d1e4bc
commit 439520c3e4
3 changed files with 103 additions and 10 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch "@jmfederico/pi-web": patch
--- ---
Create editable chats immediately when starting sessions, queue sends until the backend session is ready, and use server-backed persistence signals for session archive/delete/reload actions. Create editable chats immediately when starting sessions, open the chat right away on mobile, queue sends until the backend session is ready, reconcile concurrent session-created broadcasts, and use server-backed persistence signals for session archive/delete/reload actions.
@@ -397,6 +397,41 @@ describe("SessionController", () => {
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true); expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true);
}); });
it("releases unrelated created-session broadcasts after pending starts settle", async () => {
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const otherClientSession: SessionInfo = { ...oldSession, id: "other-client-session", path: "/tmp/other-client-session.jsonl" };
const startRequest = deferred<SessionInfo>();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => startRequest.promise,
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
controller.applyGlobalEvent({ type: "session.created", session: started });
controller.applyGlobalEvent({ type: "session.created", session: otherClientSession });
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]);
startRequest.resolve(started);
await start;
const sessionIds = state.sessions.map((session) => session.id);
expect(sessionIds).not.toContain(temporaryId);
expect(sessionIds.filter((id) => id === started.id)).toHaveLength(1);
expect(sessionIds.filter((id) => id === otherClientSession.id)).toHaveLength(1);
});
it("preserves temporary start rows across session-list refreshes before backend resolution", async () => { it("preserves temporary start rows across session-list refreshes before backend resolution", async () => {
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>(); const startRequest = deferred<SessionInfo>();
@@ -537,6 +572,43 @@ describe("SessionController", () => {
expect(state.selectedSession).toBeUndefined(); expect(state.selectedSession).toBeUndefined();
}); });
it("stops the backend session if a discarded pending start resolves later", async () => {
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>();
const stoppedIds: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => startRequest.promise,
stop: (session) => { stoppedIds.push(sessionLookupId(session)); return Promise.resolve({ stopped: true }); },
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
await controller.send("queued before discard");
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([{ kind: "followUp", text: "queued before discard" }]);
await controller.deleteCachedNewSession(state.selectedSession);
expect(state.sessions).toEqual([]);
expect(state.selectedSession).toBeUndefined();
expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined();
startRequest.resolve(started);
await start;
expect(stoppedIds).toEqual([started.id]);
expect(state.sessions).toEqual([]);
expect(state.selectedSession).toBeUndefined();
});
it("toggles the per-session sending state around an inline attachment send and forwards attachments", async () => { it("toggles the per-session sending state around an inline attachment send and forwards attachments", async () => {
let resolvePrompt: (() => void) | undefined; let resolvePrompt: (() => void) | undefined;
let promptArgs: { attachments?: PromptAttachment[] } | undefined; let promptArgs: { attachments?: PromptAttachment[] } | undefined;
@@ -56,7 +56,7 @@ interface PendingSessionStart {
} }
interface SuppressedCreatedSession { interface SuppressedCreatedSession {
cwd: string; session: SessionInfo;
machineId: string; machineId: string;
} }
@@ -828,10 +828,11 @@ export class SessionController {
if (pending === undefined) return; if (pending === undefined) return;
this.pendingSessionStarts.delete(tempId); this.pendingSessionStarts.delete(tempId);
const queuedSends = pending.queuedSends.splice(0); const queuedSends = pending.queuedSends.splice(0);
this.clearSuppressedCreatedSessionsFor(pending.cwd, pending.machineId, session.id); const releasedCreatedSessions = this.takeSuppressedCreatedSessionsFor(pending.cwd, pending.machineId, session.id);
if (pending.discarded) { if (pending.discarded) {
clearDraft(machineSessionKey(pending.machineId, tempId)); clearDraft(machineSessionKey(pending.machineId, tempId));
this.setState({ clientQueuedSessionMessages: omitKey(this.getState().clientQueuedSessionMessages, tempId) }); this.setState({ clientQueuedSessionMessages: omitKey(this.getState().clientQueuedSessionMessages, tempId) });
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
void this.api.stop(session, pending.machineId).catch(() => { void this.api.stop(session, pending.machineId).catch(() => {
// Best-effort cleanup for a backend session whose temporary UI row was discarded before creation finished. // Best-effort cleanup for a backend session whose temporary UI row was discarded before creation finished.
}); });
@@ -857,6 +858,7 @@ export class SessionController {
...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id] } : {}), ...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id] } : {}),
error: "", error: "",
}); });
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
if (wasSelected) { if (wasSelected) {
this.updateUrl({ replace: true }); this.updateUrl({ replace: true });
await this.selectSession(cachedSession, { updateUrl: false }); await this.selectSession(cachedSession, { updateUrl: false });
@@ -868,8 +870,12 @@ export class SessionController {
const pending = this.pendingSessionStarts.get(tempId); const pending = this.pendingSessionStarts.get(tempId);
if (pending === undefined) return; if (pending === undefined) return;
this.pendingSessionStarts.delete(tempId); this.pendingSessionStarts.delete(tempId);
this.clearSuppressedCreatedSessionsFor(pending.cwd, pending.machineId); const releasedCreatedSessions = this.takeSuppressedCreatedSessionsFor(pending.cwd, pending.machineId);
if (pending.discarded || !this.isCurrentPendingStart(pending)) return; const isCurrentPendingStart = this.isCurrentPendingStart(pending);
if (pending.discarded || !isCurrentPendingStart) {
if (isCurrentPendingStart) this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
return;
}
const state = this.getState(); const state = this.getState();
const message = errorMessage(error); const message = errorMessage(error);
const activity = failedPendingSessionActivity(tempId, message, pending.queuedSends.length); const activity = failedPendingSessionActivity(tempId, message, pending.queuedSends.length);
@@ -880,6 +886,7 @@ export class SessionController {
activity: state.selectedSession?.id === tempId ? activity : state.activity, activity: state.selectedSession?.id === tempId ? activity : state.activity,
error: `Failed to start session: ${message}`, error: `Failed to start session: ${message}`,
}); });
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
} }
private isCurrentPendingStart(pending: PendingSessionStart): boolean { private isCurrentPendingStart(pending: PendingSessionStart): boolean {
@@ -893,15 +900,29 @@ export class SessionController {
private isSuppressedCreatedSession(session: SessionInfo, machineId: string): boolean { private isSuppressedCreatedSession(session: SessionInfo, machineId: string): boolean {
const suppressed = this.suppressedCreatedSessions.get(session.id); const suppressed = this.suppressedCreatedSessions.get(session.id);
return suppressed?.cwd === session.cwd && suppressed.machineId === machineId; return suppressed?.session.cwd === session.cwd && suppressed.machineId === machineId;
} }
private clearSuppressedCreatedSessionsFor(cwd: string, machineId: string, resolvedSessionId?: string): void { private takeSuppressedCreatedSessionsFor(cwd: string, machineId: string, resolvedSessionId?: string): SessionInfo[] {
if (resolvedSessionId !== undefined) this.suppressedCreatedSessions.delete(resolvedSessionId); if (resolvedSessionId !== undefined) this.suppressedCreatedSessions.delete(resolvedSessionId);
if (this.hasPendingStartFor(cwd, machineId)) return; if (this.hasPendingStartFor(cwd, machineId)) return [];
const released: SessionInfo[] = [];
for (const [sessionId, suppressed] of this.suppressedCreatedSessions) { for (const [sessionId, suppressed] of this.suppressedCreatedSessions) {
if (suppressed.cwd === cwd && suppressed.machineId === machineId) this.suppressedCreatedSessions.delete(sessionId); if (suppressed.session.cwd !== cwd || suppressed.machineId !== machineId) continue;
this.suppressedCreatedSessions.delete(sessionId);
released.push(suppressed.session);
} }
return released;
}
private applyReleasedCreatedSessions(sessions: readonly SessionInfo[], machineId: string): void {
if (sessions.length === 0 || selectedMachineId(this.getState()) !== machineId) return;
const state = this.getState();
if (state.selectedWorkspace === undefined) return;
const existingIds = new Set(state.sessions.map((session) => session.id));
const released = sessions.filter((session) => session.cwd === state.selectedWorkspace?.path && !existingIds.has(session.id));
if (released.length === 0) return;
this.setState({ sessions: [...released.reverse(), ...state.sessions] });
} }
private mergePendingStartSessions(cwd: string, sessions: SessionInfo[], machineId: string): SessionInfo[] { private mergePendingStartSessions(cwd: string, sessions: SessionInfo[], machineId: string): SessionInfo[] {
@@ -958,7 +979,7 @@ export class SessionController {
if (state.sessions.some((candidate) => candidate.id === session.id)) return; if (state.sessions.some((candidate) => candidate.id === session.id)) return;
const machineId = selectedMachineId(state); const machineId = selectedMachineId(state);
if (this.hasPendingStartFor(session.cwd, machineId)) { if (this.hasPendingStartFor(session.cwd, machineId)) {
this.suppressedCreatedSessions.set(session.id, { cwd: session.cwd, machineId }); this.suppressedCreatedSessions.set(session.id, { session, machineId });
return; return;
} }
this.setState({ sessions: [session, ...state.sessions] }); this.setState({ sessions: [session, ...state.sessions] });