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
@@ -397,6 +397,41 @@ describe("SessionController", () => {
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 () => {
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>();
@@ -537,6 +572,43 @@ describe("SessionController", () => {
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 () => {
let resolvePrompt: (() => void) | undefined;
let promptArgs: { attachments?: PromptAttachment[] } | undefined;
@@ -56,7 +56,7 @@ interface PendingSessionStart {
}
interface SuppressedCreatedSession {
cwd: string;
session: SessionInfo;
machineId: string;
}
@@ -828,10 +828,11 @@ export class SessionController {
if (pending === undefined) return;
this.pendingSessionStarts.delete(tempId);
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) {
clearDraft(machineSessionKey(pending.machineId, tempId));
this.setState({ clientQueuedSessionMessages: omitKey(this.getState().clientQueuedSessionMessages, tempId) });
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
void this.api.stop(session, pending.machineId).catch(() => {
// 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] } : {}),
error: "",
});
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
if (wasSelected) {
this.updateUrl({ replace: true });
await this.selectSession(cachedSession, { updateUrl: false });
@@ -868,8 +870,12 @@ export class SessionController {
const pending = this.pendingSessionStarts.get(tempId);
if (pending === undefined) return;
this.pendingSessionStarts.delete(tempId);
this.clearSuppressedCreatedSessionsFor(pending.cwd, pending.machineId);
if (pending.discarded || !this.isCurrentPendingStart(pending)) return;
const releasedCreatedSessions = this.takeSuppressedCreatedSessionsFor(pending.cwd, pending.machineId);
const isCurrentPendingStart = this.isCurrentPendingStart(pending);
if (pending.discarded || !isCurrentPendingStart) {
if (isCurrentPendingStart) this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
return;
}
const state = this.getState();
const message = errorMessage(error);
const activity = failedPendingSessionActivity(tempId, message, pending.queuedSends.length);
@@ -880,6 +886,7 @@ export class SessionController {
activity: state.selectedSession?.id === tempId ? activity : state.activity,
error: `Failed to start session: ${message}`,
});
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
}
private isCurrentPendingStart(pending: PendingSessionStart): boolean {
@@ -893,15 +900,29 @@ export class SessionController {
private isSuppressedCreatedSession(session: SessionInfo, machineId: string): boolean {
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 (this.hasPendingStartFor(cwd, machineId)) return;
if (this.hasPendingStartFor(cwd, machineId)) return [];
const released: SessionInfo[] = [];
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[] {
@@ -958,7 +979,7 @@ export class SessionController {
if (state.sessions.some((candidate) => candidate.id === session.id)) return;
const machineId = selectedMachineId(state);
if (this.hasPendingStartFor(session.cwd, machineId)) {
this.suppressedCreatedSessions.set(session.id, { cwd: session.cwd, machineId });
this.suppressedCreatedSessions.set(session.id, { session, machineId });
return;
}
this.setState({ sessions: [session, ...state.sessions] });