Archived
fix(sessions): correlate startup progress by token instead of workspace
Startup progress could still be shown on the wrong session's row. Routing by known session id first closed the case where the browser knew the other session, but left open the case where it does not -- which the browser is designed to produce. While a create is pending for a workspace, applyCreatedSession deliberately withholds a session.created event for that workspace and stashes it, to avoid a duplicate row. So during exactly the window this feature exists for, a session created by an agent's spawn or by another tab is intentionally absent from the session list. Its startup events carried an unrecognised id and a matching cwd, and were routed onto the user's pending create row, showing a phase and a label belonging to another session. Workspace path was never evidence of identity; it was the only key both sides happened to share. Give them a real one. The browser already invents a temporary row id for a pending create, so it now sends that id with the create request as an opaque startupToken; the daemon carries it through construction, echoes it on the startup events it publishes for that construction, and the browser matches it exactly. The token is a throwaway label the daemon never interprets. It never becomes the session id: activity.sessionId still carries Pi's SessionManager id, which remains how an open of an already-known session is routed. With exact identity available, the guessing is deleted rather than gated. startupProgressPendingStart goes entirely, and with it the selected-machine comparison, the cwd filter, and the single-match ambiguity rule: a second concurrent create carries a different token, and a foreign workspace or non-selected machine carries no token this browser is waiting on, so those cases stop existing rather than needing detection. One Map lookup replaces a filtered scan. cwd comes off the event, since it existed only as the routing key and nothing else read it. No compatibility path is needed. session.startup is unreleased -- checked against the published tarball, not only git tags -- so no deployed daemon emits these events and no deployed browser parses them. An older daemon ignores the extra request field; a newer daemon talking to an older browser degrades to the pre-existing generic wording, as does any unmatched token. One silent behaviour change to state plainly: startupProgress guarded on `sessionId === "" || cwd === ""`. Removing cwd from the event removes the meaningful half of that guard, and that half had no test. The session-id half is kept, which is the half that actually protects honest reporting. The replaced ambiguity test is rewritten rather than dropped, so the same three scenarios still pin the user-visible guarantee -- no match means the generic wording stays -- now including the reproduced foreign-session case, which fails against the previous code. Session creation ordering, semantics, and queueing are unchanged; the token is a passthrough label read only to build an event.
This commit is contained in:
@@ -253,6 +253,22 @@ describe("session API compatibility", () => {
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] });
|
||||
});
|
||||
|
||||
it("carries a create's correlation token in the start request body when one is supplied", async () => {
|
||||
const fetchMock = stubSequenceFetch([
|
||||
jsonResponse(sessionInfoResponse("s 1")),
|
||||
jsonResponse(sessionInfoResponse("s 2")),
|
||||
]);
|
||||
|
||||
await sessionsApi.startSession("/repo", "remote a", "pending-session-3-k2x9");
|
||||
await sessionsApi.startSession("/repo", "remote a");
|
||||
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ cwd: "/repo", startupToken: "pending-session-3-k2x9" });
|
||||
// The token is optional, so a caller with no row to label sends none rather
|
||||
// than an empty one.
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ cwd: "/repo" });
|
||||
});
|
||||
|
||||
it("keeps legacy session-id calls free of cwd context", async () => {
|
||||
const fetchMock = stubJsonFetch({ accepted: true });
|
||||
|
||||
@@ -572,6 +588,10 @@ function requestBody(init: RequestInit | undefined): string {
|
||||
return init.body;
|
||||
}
|
||||
|
||||
function sessionInfoResponse(id: string) {
|
||||
return { id, path: `/tmp/${id}.jsonl`, cwd: "/repo", created: "now", modified: "now", messageCount: 0, firstMessage: "" };
|
||||
}
|
||||
|
||||
function piWebConfigResponse(config: PiWebConfigValues) {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
|
||||
@@ -214,7 +214,7 @@ export const sessionsApi = {
|
||||
notificationInbox: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "notifications", machineId), parseSessionNotificationInboxSnapshot),
|
||||
dismissNotification: (session: SessionLookup, daemonInstanceId: string, notificationId: string, machineId = "local") => request(sessionPath(session, "notifications/dismiss", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, notificationId }) }),
|
||||
dismissAllNotifications: (session: SessionLookup, daemonInstanceId: string, through: SessionNotificationDismissThrough, machineId = "local") => request(sessionPath(session, "notifications/dismiss-all", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, throughOrder: through.order, throughOverflowWatermark: through.overflowWatermark }) }),
|
||||
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
|
||||
startSession: (cwd: string, machineId = "local", startupToken?: string) => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify(startupToken === undefined ? { cwd } : { cwd, startupToken }) }),
|
||||
cleanupPreview: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup/preview`, parseSessionCleanupPreviewResponse, { method: "POST", body: JSON.stringify(input) }),
|
||||
cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }),
|
||||
archiveMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/archive`, parseSessionBulkArchiveResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
|
||||
|
||||
@@ -289,18 +289,18 @@ describe("API parsers", () => {
|
||||
})).toThrow("positive safe integer");
|
||||
});
|
||||
|
||||
it("parses session startup progress with and without a wait detail", () => {
|
||||
it("parses session startup progress with and without a correlation token", () => {
|
||||
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z" };
|
||||
|
||||
expect(parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity })).toEqual({
|
||||
expect(parseSessionStartupProgressEvent({ type: "session.startup", startupToken: "pending-session-1-abc", activity })).toEqual({
|
||||
type: "session.startup",
|
||||
cwd: "/repo",
|
||||
startupToken: "pending-session-1-abc",
|
||||
activity,
|
||||
});
|
||||
// An open carries no token: the activity's own session id is the only route.
|
||||
const idle = { sessionId: "session-1", phase: "idle", label: "idle", at: "2026-07-20T00:00:02.000Z" };
|
||||
expect(parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: idle })).toEqual({
|
||||
expect(parseSessionStartupProgressEvent({ type: "session.startup", activity: idle })).toEqual({
|
||||
type: "session.startup",
|
||||
cwd: "/repo",
|
||||
activity: idle,
|
||||
});
|
||||
});
|
||||
@@ -308,15 +308,17 @@ describe("API parsers", () => {
|
||||
it("rejects session startup progress that cannot be routed or rendered honestly", () => {
|
||||
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", at: "2026-07-20T00:00:01.000Z" };
|
||||
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "activity.update", cwd: "/repo", activity })).toThrow("Invalid session startup event type");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity })).toThrow("Expected string field: cwd");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "", activity })).toThrow("Expected non-empty string field: cwd");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo" })).toThrow("Expected object response");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, phase: "waiting" } })).toThrow("Expected session activity phase field: phase");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, label: 7 } })).toThrow("Expected string field: label");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, label: "" } })).toThrow("Expected non-empty string field: label");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, detail: 7 } })).toThrow("Expected optional string field: detail");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, sessionId: "" } })).toThrow("Expected non-empty string field: sessionId");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "activity.update", activity })).toThrow("Invalid session startup event type");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup" })).toThrow("Expected object response");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", startupToken: 7, activity })).toThrow("Expected optional string field: startupToken");
|
||||
// An empty token would match nothing but must still be rejected rather than
|
||||
// silently carried, so a malformed frame never reaches the routing at all.
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", startupToken: "", activity })).toThrow("Expected non-empty string field: startupToken");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, phase: "waiting" } })).toThrow("Expected session activity phase field: phase");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, label: 7 } })).toThrow("Expected string field: label");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, label: "" } })).toThrow("Expected non-empty string field: label");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, detail: 7 } })).toThrow("Expected optional string field: detail");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, sessionId: "" } })).toThrow("Expected non-empty string field: sessionId");
|
||||
});
|
||||
|
||||
it("parses session cleanup preview and execute responses", () => {
|
||||
|
||||
@@ -275,15 +275,19 @@ export function parseSessionUnreadEvent(value: unknown): SessionUnreadEvent {
|
||||
/**
|
||||
* Validate a startup progress frame. The browser substitutes its own wording
|
||||
* from this event, so a malformed frame must be dropped rather than rendered:
|
||||
* `cwd` is the routing key, and an activity missing its phase or label could
|
||||
* otherwise blank out or freeze the text a user is reading while they wait.
|
||||
* `startupToken` is the routing key when present, and an activity missing its
|
||||
* phase or label could otherwise blank out or freeze the text a user is reading
|
||||
* while they wait. An absent token is valid — an open routes by session id — but
|
||||
* a present empty one is not, since it could match no row honestly.
|
||||
*/
|
||||
export function parseSessionStartupProgressEvent(value: unknown): SessionStartupProgressEvent {
|
||||
const record = requireRecord(value);
|
||||
if (record["type"] !== "session.startup") throw new Error("Invalid session startup event type");
|
||||
const startupToken = optionalString(record, "startupToken");
|
||||
if (startupToken === "") throw new Error("Expected non-empty string field: startupToken");
|
||||
return {
|
||||
type: "session.startup",
|
||||
cwd: requireNonEmptyString(record, "cwd"),
|
||||
...optionalField("startupToken", startupToken),
|
||||
activity: parseSessionActivity(record["activity"]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { initialAppState } from "../appState";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { defaultApi, deferred, emptyPage, FakeSocket, oldSession, runPendingAnimationFrames, sessionLookupId, status, workspace, type AppState, type SessionActivity, type SessionInfo } from "./sessionController.testSupport";
|
||||
|
||||
const REMOTE_MACHINE = { id: "remote", name: "Remote", kind: "remote" as const, createdAt: "now", updatedAt: "now" };
|
||||
|
||||
function startupActivity(patch: Partial<SessionActivity> = {}): SessionActivity {
|
||||
return {
|
||||
sessionId: "backend-session",
|
||||
@@ -21,8 +19,15 @@ function idleStartupActivity(): SessionActivity {
|
||||
return { sessionId: "backend-session", phase: "idle", label: "idle", at: "2026-07-20T00:00:02.000Z" };
|
||||
}
|
||||
|
||||
interface StartCall {
|
||||
cwd: string;
|
||||
machineId: string | undefined;
|
||||
startupToken: string | undefined;
|
||||
}
|
||||
|
||||
function pendingStartController(state: { current: AppState }, api: Partial<typeof defaultApi> = {}) {
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
const startCalls: StartCall[] = [];
|
||||
const controller = new SessionController(
|
||||
() => state.current,
|
||||
(patch) => { state.current = { ...state.current, ...patch }; },
|
||||
@@ -31,7 +36,10 @@ function pendingStartController(state: { current: AppState }, api: Partial<typeo
|
||||
{
|
||||
api: {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
startSession: (cwd: string, machineId?: string, startupToken?: string) => {
|
||||
startCalls.push({ cwd, machineId, startupToken });
|
||||
return startRequest.promise;
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
...api,
|
||||
@@ -39,7 +47,7 @@ function pendingStartController(state: { current: AppState }, api: Partial<typeo
|
||||
socket: new FakeSocket(),
|
||||
},
|
||||
);
|
||||
return { controller, startRequest };
|
||||
return { controller, startRequest, startCalls };
|
||||
}
|
||||
|
||||
describe("SessionController session startup progress", () => {
|
||||
@@ -51,7 +59,7 @@ describe("SessionController session startup progress", () => {
|
||||
const temporaryId = state.current.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity() });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
// The label changes while the user is waiting, before the start resolves,
|
||||
@@ -59,7 +67,7 @@ describe("SessionController session startup progress", () => {
|
||||
expect(state.current.activity).toMatchObject({ sessionId: temporaryId, phase: "active", label: "Creating session", detail: "Starting the Pi session" });
|
||||
expect(state.current.sessionActivities[temporaryId]).toMatchObject({ detail: "Starting the Pi session" });
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity({ detail: "Loading session extensions" }) });
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity({ detail: "Loading session extensions" }) });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(state.current.activity?.detail).toBe("Loading session extensions");
|
||||
@@ -75,10 +83,10 @@ describe("SessionController session startup progress", () => {
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.current.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity() });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: idleStartupActivity() });
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: idleStartupActivity() });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(state.current.activity).toMatchObject({
|
||||
@@ -98,7 +106,9 @@ describe("SessionController session startup progress", () => {
|
||||
|
||||
const start = controller.startSession();
|
||||
await controller.send("queued while starting");
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: idleStartupActivity() });
|
||||
const temporaryId = state.current.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: idleStartupActivity() });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(state.current.activity?.detail).toBe("1 queued message will send when the backend session is ready");
|
||||
@@ -119,7 +129,6 @@ describe("SessionController session startup progress", () => {
|
||||
|
||||
controller.applyGlobalEvent({
|
||||
type: "session.startup",
|
||||
cwd: oldSession.cwd,
|
||||
activity: startupActivity({ sessionId: oldSession.id, label: "Opening session" }),
|
||||
});
|
||||
runPendingAnimationFrames();
|
||||
@@ -136,12 +145,11 @@ describe("SessionController session startup progress", () => {
|
||||
const temporaryId = state.current.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
|
||||
// Opening an existing session in the same workspace publishes the same cwd as
|
||||
// the pending create. The known id is the proof of which row it belongs to, so
|
||||
// the pending row must keep its own wording instead of the other row's phase.
|
||||
// Opening an existing session in the same workspace carries no create token,
|
||||
// so the known id is the only proof of which row it belongs to and the pending
|
||||
// row must keep its own wording instead of the other row's phase.
|
||||
controller.applyGlobalEvent({
|
||||
type: "session.startup",
|
||||
cwd: workspace.path,
|
||||
activity: startupActivity({ sessionId: existing.id, label: "Opening session" }),
|
||||
});
|
||||
runPendingAnimationFrames();
|
||||
@@ -153,7 +161,7 @@ describe("SessionController session startup progress", () => {
|
||||
await start;
|
||||
});
|
||||
|
||||
it("keeps the generic wording when the startup progress cannot be attributed to one row", async () => {
|
||||
it("keeps the generic wording when no pending row's token matches the startup progress", async () => {
|
||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||
const { controller, startRequest } = pendingStartController(state);
|
||||
|
||||
@@ -161,29 +169,54 @@ describe("SessionController session startup progress", () => {
|
||||
const temporaryId = state.current.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
|
||||
// Another workspace's startup.
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: "/elsewhere", activity: startupActivity() });
|
||||
// The selected machine's socket is the only feed for these events, so a cwd
|
||||
// that matches while another machine is selected belongs to a different row.
|
||||
state.current = { ...state.current, selectedMachine: REMOTE_MACHINE };
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
|
||||
state.current = { ...state.current, selectedMachine: undefined };
|
||||
// Another browser tab's create, or another workspace's: its token is one this
|
||||
// browser never minted, so there is no row here that it belongs to.
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: "pending-session-9-other-tab", activity: startupActivity() });
|
||||
// A session this browser has not been told about — an agent's spawned
|
||||
// subsession, say, whose `session.created` a pending create suppresses — is
|
||||
// opened rather than created, so it carries no token at all.
|
||||
controller.applyGlobalEvent({ type: "session.startup", activity: startupActivity({ sessionId: "foreign-session", label: "Opening session", detail: "Loading session extensions" }) });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(state.current.activity?.detail).toBe("Waiting for the backend session to be ready");
|
||||
expect(state.current.activity?.label).toBe("Creating session");
|
||||
|
||||
// A second concurrent start in the same workspace makes the target ambiguous,
|
||||
// so neither row is given a phase that might belong to the other.
|
||||
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
||||
await start;
|
||||
});
|
||||
|
||||
it("gives each of two concurrent creates only the progress its own token carries", async () => {
|
||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||
const { controller, startRequest } = pendingStartController(state);
|
||||
|
||||
const start = controller.startSession();
|
||||
const firstId = state.current.selectedSession?.id;
|
||||
const secondStart = controller.startSession();
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
|
||||
const secondId = state.current.selectedSession?.id;
|
||||
if (firstId === undefined || secondId === undefined || firstId === secondId) throw new Error("Expected two distinct temporary session ids");
|
||||
|
||||
// Two creates in the same workspace are indistinguishable by workspace path;
|
||||
// the token each request carried is what tells them apart.
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: secondId, activity: startupActivity({ detail: "Loading session extensions" }) });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
const secondTemporaryId = state.current.selectedSession?.id;
|
||||
expect(secondTemporaryId).not.toBe(temporaryId);
|
||||
expect(state.current.sessionActivities[temporaryId]?.detail).toBe("Waiting for the backend session to be ready");
|
||||
expect(state.current.sessionActivities[secondTemporaryId ?? ""]?.detail).toBe("Waiting for the backend session to be ready");
|
||||
expect(state.current.sessionActivities[secondId]).toMatchObject({ sessionId: secondId, detail: "Loading session extensions" });
|
||||
expect(state.current.sessionActivities[firstId]?.detail).toBe("Waiting for the backend session to be ready");
|
||||
|
||||
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
||||
await Promise.all([start, secondStart]);
|
||||
});
|
||||
|
||||
it("sends the pending row's own id as the create request's correlation token", async () => {
|
||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||
const { controller, startRequest, startCalls } = pendingStartController(state);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.current.selectedSession?.id;
|
||||
|
||||
expect(startCalls).toEqual([{ cwd: workspace.path, machineId: "local", startupToken: temporaryId }]);
|
||||
|
||||
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
||||
await start;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,7 +185,7 @@ export class SessionController {
|
||||
this.pendingSessionStarts.set(pending.tempId, pending);
|
||||
this.insertAndSelectPendingSession(pending.session);
|
||||
try {
|
||||
const session = await this.api.startSession(workspace.path, machineId);
|
||||
const session = await this.api.startSession(workspace.path, machineId, pending.tempId);
|
||||
await this.resolvePendingSessionStart(pending.tempId, session);
|
||||
} catch (error) {
|
||||
this.failPendingSessionStart(pending.tempId, error);
|
||||
@@ -1309,25 +1309,20 @@ export class SessionController {
|
||||
}
|
||||
|
||||
// Session startup progress arrives while the daemon is still constructing the
|
||||
// session, so the target row is resolved by session id when the browser knows
|
||||
// it and by workspace path when it does not: a pending start knows its cwd but
|
||||
// not the session id the daemon is creating. Once the row is resolved the
|
||||
// progress goes through the normal activity buffer, so it renders exactly like
|
||||
// any other activity and stays batched per frame.
|
||||
// session, so the target row is resolved by exact identity only: a session id
|
||||
// the browser already knows (an open), else the correlation token this browser
|
||||
// minted for its own create and the daemon echoed back. Matching neither means
|
||||
// the row is not one this browser shows — an agent's or another tab's session is
|
||||
// *deliberately* absent while a create is pending — so it is ignored rather than
|
||||
// guessed at. A resolved row goes through the normal activity buffer, rendering
|
||||
// like any other activity and staying batched per frame.
|
||||
private queueStartupProgress(event: SessionStartupProgressEvent): void {
|
||||
// A known session id is the strongest possible proof of the target, so it is
|
||||
// checked first: while a create is pending in a workspace, an *existing*
|
||||
// session in that same workspace can be opened too (another row selected,
|
||||
// another tab, a subsession), and that open publishes the same cwd. Matching
|
||||
// on cwd first would paint the pending row with another session's phase.
|
||||
if (this.getState().sessions.some((session) => session.id === event.activity.sessionId)) {
|
||||
this.queueActivityUpdate(event.activity);
|
||||
return;
|
||||
}
|
||||
// The id is unknown, so this can only be a create whose id the browser has
|
||||
// not been told yet. Route it by workspace path, the one key both sides share.
|
||||
const pending = this.startupProgressPendingStart(event.cwd);
|
||||
if (pending === undefined) return;
|
||||
const pending = event.startupToken === undefined ? undefined : this.pendingSessionStarts.get(event.startupToken);
|
||||
if (pending === undefined || pending.discarded) return;
|
||||
// An idle startup phase means the daemon has nothing left to attribute, so
|
||||
// restore this row's own generic wording rather than clearing the text of a
|
||||
// creation request that has not returned yet.
|
||||
@@ -1336,13 +1331,6 @@ export class SessionController {
|
||||
: { ...event.activity, sessionId: pending.tempId });
|
||||
}
|
||||
|
||||
private startupProgressPendingStart(cwd: string): PendingSessionStart | undefined {
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const matches = Array.from(this.pendingSessionStarts.values())
|
||||
.filter((pending) => pending.cwd === cwd && pending.machineId === machineId && !pending.discarded);
|
||||
return matches.length === 1 ? matches[0] : undefined;
|
||||
}
|
||||
|
||||
private schedulePendingFlush(): void {
|
||||
if (this.pendingFrame !== undefined) return;
|
||||
this.pendingFrame = requestAnimationFrame(() => {
|
||||
|
||||
@@ -93,14 +93,15 @@ describe("notification socket guards", () => {
|
||||
it("accepts validated session startup progress and drops malformed frames", () => {
|
||||
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z" };
|
||||
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "/repo", activity }))
|
||||
.toMatchObject({ type: "session.startup", cwd: "/repo", activity });
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "", activity })).toBeUndefined();
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "/repo" })).toBeUndefined();
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, phase: "waiting" } })).toBeUndefined();
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", startupToken: "pending-session-1-abc", activity }))
|
||||
.toMatchObject({ type: "session.startup", startupToken: "pending-session-1-abc", activity });
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", activity })).toMatchObject({ type: "session.startup", activity });
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", startupToken: "", activity })).toBeUndefined();
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup" })).toBeUndefined();
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", activity: { ...activity, phase: "waiting" } })).toBeUndefined();
|
||||
// Startup progress is global-only, so it must not be accepted as a
|
||||
// per-session frame even when it is well formed.
|
||||
expect(parseSessionSocketEvent({ type: "session.startup", cwd: "/repo", activity })).toBeUndefined();
|
||||
expect(parseSessionSocketEvent({ type: "session.startup", activity })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves existing event acceptance without treating unknown types as realtime events", () => {
|
||||
|
||||
Reference in New Issue
Block a user