Archived
fix: reconcile pending session starts
This commit is contained in:
@@ -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] });
|
||||
|
||||
Reference in New Issue
Block a user