Archived
feat(sessions): tell the user what a session start is waiting on
Creating or opening a session could stall for reasons the daemon knew
about and never shared. The browser invented the whole message it showed
while waiting -- "Creating session: Waiting for the backend session to be
ready" -- which says that we are waiting but never what for. A shared
ModelRuntime read during startup can be handed a network refresh that is
already in flight, and extensions may do their own network I/O while
loading, so the wait is real and previously unattributable.
The pre-session gap turned out to be a missing shared key rather than a
missing channel: publishActivity needs the PiAgentSession being built, but
the session id and cwd are both known before the first await. So create()
now publishes a new global session.startup event carrying an ordinary
SessionActivity, routed by cwd -- the one identity a browser row waiting
for a session id can match, since the client-invented pending id is
unknown to the daemon and the daemon's id is unknown to the browser.
Two phases are reported, each published before the await it describes so
the label changes during the wait rather than after it: "Starting the Pi
session" and "Loading session extensions". Both are facts, because the
service awaits exactly one call for each. A concurrent background catalog
refresh is appended as a note ("provider model lists are refreshing"),
never as the cause: the refresher can prove a refresh is running but not
that this startup joined it. ModelCatalogRefresher gains only a read-only
isRefreshInFlight() getter; cadence, timeout, and coalescing are untouched.
Reporting is event-only and synchronous. It writes no activities entry, no
workspace activity, and no unread state, so a failed creation leaves
nothing stranded, no await is added, and creation ordering and semantics
are unchanged. The window-ending idle report is skipped when a real
activity was published during startup, so an extension error survives.
The browser applies startup progress only when it can prove the target:
one non-discarded pending start in that cwd on the selected machine, or a
session whose id it already knows. A foreign workspace, another machine,
or two concurrent starts in one workspace keep today's generic wording
rather than showing one row the phase of another. An idle report restores
that generic wording, including the queued-messages variant.
docs/config.md said nothing a request triggers waits on a catalog fetch.
That is not strictly true for a refresh already in flight, so both it and
the generated docs/config.html now state the exception and say PI WEB
reports it while it happens.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Say what a slow session start is waiting on. While a session is being created or opened, the activity line now names the current startup step — starting the Pi session, or loading session extensions — and adds a note when provider model lists happen to be refreshing at the same time. When nothing can be attributed, the previous generic wording is kept rather than guessing a cause.
|
||||
+10
-3
@@ -699,9 +699,16 @@
|
||||
<h2>Background model catalog refresh</h2>
|
||||
<p>
|
||||
PI WEB shares one model runtime across all sessions, and provider model catalogs are refreshed over the
|
||||
network only on the session daemon's own background schedule. Nothing a browser or API request triggers
|
||||
waits on a provider catalog fetch, so a slow or unreachable provider cannot stall opening the model
|
||||
selector, starting a session, or the auth dialogs.
|
||||
network only on the session daemon's own background schedule. Requests never start a catalog fetch of
|
||||
their own, so a slow or unreachable provider cannot stall opening the model selector, starting a session,
|
||||
or the auth dialogs on its own account.
|
||||
</p>
|
||||
<p>
|
||||
A refresh that is <em>already</em> in flight can still briefly delay starting or opening a session,
|
||||
because the shared runtime is read while that refresh is running. PI WEB says so while you wait: the
|
||||
session's activity line names the startup step it is on and adds
|
||||
<code>provider model lists are refreshing</code> when a background refresh is running at the same time.
|
||||
That note reports what is happening concurrently, not a proven cause.
|
||||
</p>
|
||||
<p>The session daemon runs the refresh:</p>
|
||||
<ul>
|
||||
|
||||
+3
-1
@@ -238,7 +238,9 @@ Configure providers before the daemon starts: use the active agent directory's `
|
||||
|
||||
### Background model catalog refresh
|
||||
|
||||
PI WEB shares one model runtime across all sessions, and provider model catalogs are refreshed over the network only on the session daemon's own background schedule. Nothing a browser or API request triggers waits on a provider catalog fetch, so a slow or unreachable provider cannot stall opening the model selector, starting a session, or the auth dialogs.
|
||||
PI WEB shares one model runtime across all sessions, and provider model catalogs are refreshed over the network only on the session daemon's own background schedule. Requests never start a catalog fetch of their own, so a slow or unreachable provider cannot stall opening the model selector, starting a session, or the auth dialogs on its own account.
|
||||
|
||||
A refresh that is *already* in flight can still briefly delay starting or opening a session, because the shared runtime is read while that refresh is running. PI WEB says so while you wait: the session's activity line names the startup step it is on and adds `provider model lists are refreshing` when a background refresh is running at the same time. That note reports what is happening concurrently, not a proven cause.
|
||||
|
||||
The session daemon runs the refresh:
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../../shared/apiTypes";
|
||||
import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionStatus, parseSessionStreamSnapshot, parseSessionTreeNavigateResult, parseSessionTreeSnapshot, parseSessionUnreadCatalogSnapshot, parseSessionUnreadEvent, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
||||
import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionStartupProgressEvent, parseSessionStatus, parseSessionStreamSnapshot, parseSessionTreeNavigateResult, parseSessionTreeSnapshot, parseSessionUnreadCatalogSnapshot, parseSessionUnreadEvent, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
||||
|
||||
describe("API parsers", () => {
|
||||
it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
|
||||
@@ -289,6 +289,36 @@ describe("API parsers", () => {
|
||||
})).toThrow("positive safe integer");
|
||||
});
|
||||
|
||||
it("parses session startup progress with and without a wait detail", () => {
|
||||
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({
|
||||
type: "session.startup",
|
||||
cwd: "/repo",
|
||||
activity,
|
||||
});
|
||||
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({
|
||||
type: "session.startup",
|
||||
cwd: "/repo",
|
||||
activity: idle,
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("parses session cleanup preview and execute responses", () => {
|
||||
const preview = {
|
||||
generatedAt: "2026-06-25T12:00:00.000Z",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type ArchiveSessionsResponse, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionStatus, type SessionStreamSnapshot, type SessionUnreadCatalogSnapshot, type SessionUnreadEvent, type SessionUnreadSummary, type SessionWarning, type SessionWarningSeverity, type SlashCommand, type TerminalCommandRun, type TerminalCommandRunStatus, type TerminalInfo, type ThinkingLevelsResponse, type WriteWorkspaceFileResponse, type Workspace, type WorkspaceActivity, type WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot } from "../../../shared/apiTypes";
|
||||
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse, SessionActivity, SessionStartupProgressEvent, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot } from "../../../shared/apiTypes";
|
||||
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
|
||||
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
|
||||
|
||||
@@ -272,6 +272,39 @@ 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.
|
||||
*/
|
||||
export function parseSessionStartupProgressEvent(value: unknown): SessionStartupProgressEvent {
|
||||
const record = requireRecord(value);
|
||||
if (record["type"] !== "session.startup") throw new Error("Invalid session startup event type");
|
||||
return {
|
||||
type: "session.startup",
|
||||
cwd: requireNonEmptyString(record, "cwd"),
|
||||
activity: parseSessionActivity(record["activity"]),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSessionActivity(value: unknown): SessionActivity {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
sessionId: requireNonEmptyString(record, "sessionId"),
|
||||
phase: requireSessionActivityPhase(record, "phase"),
|
||||
label: requireNonEmptyString(record, "label"),
|
||||
...optionalField("detail", optionalString(record, "detail")),
|
||||
at: requireNonEmptyString(record, "at"),
|
||||
};
|
||||
}
|
||||
|
||||
function requireSessionActivityPhase(record: Record<string, unknown>, key: string): SessionActivity["phase"] {
|
||||
const value = requireString(record, key);
|
||||
if (value !== "active" && value !== "idle" && value !== "error") throw new Error(`Expected session activity phase field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseSessionUnreadSummary(value: unknown): SessionUnreadSummary {
|
||||
const record = requireRecord(value);
|
||||
const completedAt = requireBoundedNonEmptyString(
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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",
|
||||
phase: "active",
|
||||
label: "Creating session",
|
||||
detail: "Starting the Pi session",
|
||||
at: "2026-07-20T00:00:01.000Z",
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
/** The end-of-window report: no phase left to name, so no detail either. */
|
||||
function idleStartupActivity(): SessionActivity {
|
||||
return { sessionId: "backend-session", phase: "idle", label: "idle", at: "2026-07-20T00:00:02.000Z" };
|
||||
}
|
||||
|
||||
function pendingStartController(state: { current: AppState }, api: Partial<typeof defaultApi> = {}) {
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
const controller = new SessionController(
|
||||
() => state.current,
|
||||
(patch) => { state.current = { ...state.current, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{
|
||||
api: {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
...api,
|
||||
},
|
||||
socket: new FakeSocket(),
|
||||
},
|
||||
);
|
||||
return { controller, startRequest };
|
||||
}
|
||||
|
||||
describe("SessionController session startup progress", () => {
|
||||
it("shows the daemon's startup phase on a pending row while its start request is still open", async () => {
|
||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||
const { controller, startRequest } = pendingStartController(state);
|
||||
|
||||
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() });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
// The label changes while the user is waiting, before the start resolves,
|
||||
// and it is attributed to the row the user is actually looking at.
|
||||
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" }) });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(state.current.activity?.detail).toBe("Loading session extensions");
|
||||
|
||||
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
||||
await start;
|
||||
});
|
||||
|
||||
it("restores the generic wording when the daemon has nothing left to attribute", async () => {
|
||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||
const { controller, startRequest } = pendingStartController(state);
|
||||
|
||||
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() });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: idleStartupActivity() });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(state.current.activity).toMatchObject({
|
||||
sessionId: temporaryId,
|
||||
phase: "active",
|
||||
label: "Creating session",
|
||||
detail: "Waiting for the backend session to be ready",
|
||||
});
|
||||
|
||||
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
||||
await start;
|
||||
});
|
||||
|
||||
it("restores the queued-message wording when a pending row has sends waiting", async () => {
|
||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||
const { controller, startRequest } = pendingStartController(state);
|
||||
|
||||
const start = controller.startSession();
|
||||
await controller.send("queued while starting");
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: idleStartupActivity() });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(state.current.activity?.detail).toBe("1 queued message will send when the backend session is ready");
|
||||
|
||||
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
||||
await start;
|
||||
});
|
||||
|
||||
it("applies startup progress for an existing session it already knows the id of", () => {
|
||||
let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({
|
||||
type: "session.startup",
|
||||
cwd: oldSession.cwd,
|
||||
activity: startupActivity({ sessionId: oldSession.id, label: "Opening session" }),
|
||||
});
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(state.activity).toMatchObject({ sessionId: oldSession.id, label: "Opening session", detail: "Starting the Pi session" });
|
||||
});
|
||||
|
||||
it("keeps the generic wording when the startup progress cannot be attributed to one row", async () => {
|
||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||
const { controller, startRequest } = pendingStartController(state);
|
||||
|
||||
const start = controller.startSession();
|
||||
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 };
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(state.current.activity?.detail).toBe("Waiting for the backend session to be ready");
|
||||
|
||||
// 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.
|
||||
const secondStart = controller.startSession();
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
|
||||
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");
|
||||
|
||||
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
||||
await Promise.all([start, secondStart]);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../
|
||||
import { isArchivableSessionInfo, isTransientNewSessionInfo, sessionPersistenceOptionsForRuntime } from "../sessionPersistence";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
|
||||
import type { PromptAttachmentDelivery, SessionNotificationInboxEvent } from "../../../shared/apiTypes";
|
||||
import type { PromptAttachmentDelivery, SessionNotificationInboxEvent, SessionStartupProgressEvent } from "../../../shared/apiTypes";
|
||||
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
|
||||
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
|
||||
import { TrailingRefreshCoordinator } from "./trailingRefreshCoordinator";
|
||||
@@ -140,6 +140,7 @@ export class SessionController {
|
||||
else if (event.type === "activity.update") this.queueActivityUpdate(event.activity);
|
||||
else if (event.type === "session.created") this.applyCreatedSession(event.session);
|
||||
else if (event.type === "session.name") this.applySessionName(event.sessionId, event.name);
|
||||
else if (event.type === "session.startup") this.queueStartupProgress(event);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
@@ -1307,6 +1308,36 @@ export class SessionController {
|
||||
this.schedulePendingFlush();
|
||||
}
|
||||
|
||||
// Session startup progress arrives while the daemon is still constructing the
|
||||
// session, so it is routed by workspace path: a pending start knows its cwd
|
||||
// but not the session id the daemon is creating. Once the target row is
|
||||
// resolved the progress goes through the normal activity buffer, so it renders
|
||||
// exactly like any other activity and stays batched per frame.
|
||||
private queueStartupProgress(event: SessionStartupProgressEvent): void {
|
||||
const pending = this.startupProgressPendingStart(event.cwd);
|
||||
if (pending !== undefined) {
|
||||
// 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.
|
||||
this.queueActivityUpdate(event.activity.phase === "idle"
|
||||
? creatingPendingSessionActivity(pending.tempId, pending.queuedSends.length)
|
||||
: { ...event.activity, sessionId: pending.tempId });
|
||||
return;
|
||||
}
|
||||
// Opening a session the browser already knows the id of: the event applies as
|
||||
// published. Anything else (a foreign workspace, or several pending starts in
|
||||
// one workspace, where the row this belongs to cannot be proved) is dropped
|
||||
// rather than attributed to a guess.
|
||||
if (this.getState().sessions.some((session) => session.id === event.activity.sessionId)) this.queueActivityUpdate(event.activity);
|
||||
}
|
||||
|
||||
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(() => {
|
||||
|
||||
@@ -90,6 +90,19 @@ describe("notification socket guards", () => {
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
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();
|
||||
// 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();
|
||||
});
|
||||
|
||||
it("preserves existing event acceptance without treating unknown types as realtime events", () => {
|
||||
expect(parseSessionSocketEvent({ type: "command.output", level: "info", message: "legacy" })).toMatchObject({ type: "command.output" });
|
||||
expect(parseRealtimeSocketEvent({ type: "future.notification", payload: {} })).toBeUndefined();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { realtimeEvents, sessionEvents } from "./api";
|
||||
import { parseSessionNotificationInboxEvent, parseSessionUnreadEvent } from "./api/parsers";
|
||||
import { parseSessionNotificationInboxEvent, parseSessionStartupProgressEvent, parseSessionUnreadEvent } from "./api/parsers";
|
||||
import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
|
||||
@@ -160,6 +160,7 @@ export function parseSessionSocketEvent(event: unknown): SessionUiEvent | undefi
|
||||
|
||||
export function parseRealtimeSocketEvent(event: unknown): BrowserRealtimeEvent | undefined {
|
||||
if (eventType(event) === "sessions.unread") return safelyParseValidatedEvent(() => parseSessionUnreadEvent(event));
|
||||
if (eventType(event) === "session.startup") return safelyParseValidatedEvent(() => parseSessionStartupProgressEvent(event));
|
||||
if (isLegacyGlobalSessionEvent(event) || isLegacyRealtimeEvent(event)) return event;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ await runSessionDaemonStartup({
|
||||
subsessionsEnabled: spawnTargets !== undefined && config.subsessions,
|
||||
notificationStore,
|
||||
unreadStore,
|
||||
// Read-only, so session startup can tell a waiting user that provider
|
||||
// model lists are refreshing at the same time.
|
||||
catalogRefreshStatus: catalogRefresher,
|
||||
sessionManager: createPiSessionManagerGateway({
|
||||
agentDir: activeAgentProfile.dir,
|
||||
env: daemonEnvironment,
|
||||
|
||||
@@ -329,6 +329,35 @@ describe("ModelCatalogRefresher", () => {
|
||||
refresher.dispose();
|
||||
});
|
||||
|
||||
it("reports an in-flight network refresh only while one is actually running", async () => {
|
||||
const gate = deferred<RefreshResult>();
|
||||
const refresh = vi.fn(() => gate.promise);
|
||||
const refresher = new ModelCatalogRefresher({ runtime: { refresh } });
|
||||
|
||||
expect(refresher.isRefreshInFlight()).toBe(false);
|
||||
|
||||
refresher.requestRefresh();
|
||||
expect(refresher.isRefreshInFlight()).toBe(true);
|
||||
|
||||
gate.resolve(okResult());
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(refresher.isRefreshInFlight()).toBe(false);
|
||||
refresher.dispose();
|
||||
});
|
||||
|
||||
it("never reports a refresh in flight in offline mode, where no refresh is ever run", async () => {
|
||||
const runtime = createRuntime();
|
||||
const refresher = new ModelCatalogRefresher({ runtime, offline: true, initialDelayMs: 1_000, intervalMs: 60_000 });
|
||||
|
||||
refresher.start();
|
||||
refresher.requestRefresh();
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
|
||||
expect(refresher.isRefreshInFlight()).toBe(false);
|
||||
refresher.dispose();
|
||||
});
|
||||
|
||||
it("never touches the network when offline mode is enabled", async () => {
|
||||
const runtime = createRuntime();
|
||||
const { logger, info } = createLogger();
|
||||
|
||||
@@ -138,6 +138,15 @@ export class ModelCatalogRefresher {
|
||||
this.queueRefresh("forced");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a network catalog refresh is running right now. Read-only: callers
|
||||
* that report what a slow operation is concurrent with need this fact, and
|
||||
* must not be able to change the refresh schedule by asking for it.
|
||||
*/
|
||||
isRefreshInFlight(): boolean {
|
||||
return this.inflight !== undefined;
|
||||
}
|
||||
|
||||
/** Terminal: stops the schedule, drops any queued follow-up, and aborts an in-flight run. */
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PiSessionService, type PiSessionRuntime } from "./piSessionService.js";
|
||||
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js";
|
||||
import type { SessionActivity, SessionStartupProgressEvent } from "../../shared/apiTypes.js";
|
||||
|
||||
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function startupEvents(hub: CapturingSessionEventHub): SessionStartupProgressEvent[] {
|
||||
return hub.globalEvents.filter((event): event is SessionStartupProgressEvent => event.type === "session.startup");
|
||||
}
|
||||
|
||||
function startupText(hub: CapturingSessionEventHub): string[] {
|
||||
return startupEvents(hub).map(({ activity }) => activity.detail === undefined ? activity.label : `${activity.label}: ${activity.detail}`);
|
||||
}
|
||||
|
||||
function activityUpdates(hub: CapturingSessionEventHub): SessionActivity[] {
|
||||
return hub.globalEvents.flatMap((event) => event.type === "activity.update" ? [event.activity] : []);
|
||||
}
|
||||
|
||||
/** Records whether the startup channel wrote any per-workspace activity state. */
|
||||
function recordingWorkspaceActivity() {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
workspaceActivity: {
|
||||
applySessionStatus: () => { calls.push("applySessionStatus"); },
|
||||
applySessionActivity: () => { calls.push("applySessionActivity"); },
|
||||
removeSession: () => { calls.push("removeSession"); },
|
||||
reconcileSessionActivity: () => { calls.push("reconcileSessionActivity"); },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface StartupServiceOptions {
|
||||
createAgentRuntime?: () => Promise<PiSessionRuntime>;
|
||||
catalogRefreshInFlight?: boolean;
|
||||
sessionRecords?: ReturnType<typeof sessionRecord>[];
|
||||
workspaceActivity?: ReturnType<typeof recordingWorkspaceActivity>["workspaceActivity"];
|
||||
}
|
||||
|
||||
function startupService(options: StartupServiceOptions = {}) {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime();
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
archiveStore: emptyArchiveStore(),
|
||||
createAgentRuntime: options.createAgentRuntime ?? (() => Promise.resolve(fake.runtime)),
|
||||
sessionManager: sessionGateway(options.sessionRecords ?? []),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
...(options.workspaceActivity === undefined ? {} : { workspaceActivity: options.workspaceActivity }),
|
||||
...(options.catalogRefreshInFlight === undefined ? {} : {
|
||||
catalogRefreshStatus: { isRefreshInFlight: () => options.catalogRefreshInFlight === true },
|
||||
}),
|
||||
});
|
||||
return { hub, fake, service };
|
||||
}
|
||||
|
||||
describe("PiSessionService session startup progress", () => {
|
||||
it("reports the runtime construction phase while that construction is still pending", async () => {
|
||||
const runtimeResult = deferred<PiSessionRuntime>();
|
||||
const { hub, fake, service } = startupService({ createAgentRuntime: () => runtimeResult.promise });
|
||||
|
||||
const started = service.start("/workspace");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
// The proof that matters: the user is told what is being waited on before
|
||||
// the wait ends, not after it.
|
||||
expect(startupText(hub)).toEqual(["Creating session: Starting the Pi session"]);
|
||||
expect(startupEvents(hub).at(0)).toMatchObject({ cwd: "/workspace", activity: { sessionId: "session-1", phase: "active" } });
|
||||
|
||||
runtimeResult.resolve(fake.runtime);
|
||||
await started;
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("reports the extension loading phase while extension binding is still pending", async () => {
|
||||
const bindResult = deferred<undefined>();
|
||||
const { hub, fake, service } = startupService();
|
||||
fake.session.bindExtensions = () => bindResult.promise;
|
||||
|
||||
const started = service.start("/workspace");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(startupText(hub)).toEqual([
|
||||
"Creating session: Starting the Pi session",
|
||||
"Creating session: Loading session extensions",
|
||||
]);
|
||||
|
||||
bindResult.resolve(undefined);
|
||||
await started;
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("notes a concurrent provider model list refresh without claiming it is the cause", async () => {
|
||||
const runtimeResult = deferred<PiSessionRuntime>();
|
||||
const { hub, fake, service } = startupService({
|
||||
createAgentRuntime: () => runtimeResult.promise,
|
||||
catalogRefreshInFlight: true,
|
||||
});
|
||||
|
||||
const started = service.start("/workspace");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
runtimeResult.resolve(fake.runtime);
|
||||
await started;
|
||||
|
||||
expect(startupText(hub)).toEqual([
|
||||
"Creating session: Starting the Pi session · provider model lists are refreshing",
|
||||
"Creating session: Loading session extensions · provider model lists are refreshing",
|
||||
"idle",
|
||||
]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("states the phase alone when no refresh is running, and when nothing reports refresh state", async () => {
|
||||
const withStatus = startupService({ catalogRefreshInFlight: false });
|
||||
await withStatus.service.start("/workspace");
|
||||
const withoutStatus = startupService();
|
||||
await withoutStatus.service.start("/workspace");
|
||||
|
||||
for (const hub of [withStatus.hub, withoutStatus.hub]) {
|
||||
expect(startupText(hub)).toEqual([
|
||||
"Creating session: Starting the Pi session",
|
||||
"Creating session: Loading session extensions",
|
||||
"idle",
|
||||
]);
|
||||
}
|
||||
await withStatus.service.dispose();
|
||||
await withoutStatus.service.dispose();
|
||||
});
|
||||
|
||||
it("says opening rather than creating when an existing session is opened", async () => {
|
||||
const { service, hub } = startupService({ sessionRecords: [sessionRecord("session-1")] });
|
||||
|
||||
await service.status(sessionRef("session-1"));
|
||||
|
||||
expect(startupText(hub)).toEqual([
|
||||
"Opening session: Starting the Pi session",
|
||||
"Opening session: Loading session extensions",
|
||||
"idle",
|
||||
]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("ends the startup window with an idle report when creation succeeds", async () => {
|
||||
const { hub, service } = startupService();
|
||||
|
||||
await service.start("/workspace");
|
||||
|
||||
expect(startupEvents(hub).at(-1)).toMatchObject({ cwd: "/workspace", activity: { sessionId: "session-1", phase: "idle", label: "idle" } });
|
||||
expect(startupEvents(hub).at(-1)?.activity.detail).toBeUndefined();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("ends the startup window when the runtime construction itself fails", async () => {
|
||||
const failure = new Error("runtime unavailable");
|
||||
const { hub, service } = startupService({ createAgentRuntime: () => Promise.reject(failure) });
|
||||
|
||||
await expect(service.start("/workspace")).rejects.toBe(failure);
|
||||
|
||||
expect(startupText(hub)).toEqual(["Creating session: Starting the Pi session", "idle"]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("keeps a real activity published during startup instead of clearing it", async () => {
|
||||
const { hub, fake, service } = startupService();
|
||||
fake.session.bindExtensions = (bindings) => {
|
||||
bindings.onError?.({ extensionPath: "/ext/broken.js", event: "session_start", error: "boom" });
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
await service.start("/workspace");
|
||||
|
||||
expect(activityUpdates(hub).some((activity) => activity.label === "extension error")).toBe(true);
|
||||
// No idle startup report, so the extension error a user needs to see stays.
|
||||
expect(startupEvents(hub).some((event) => event.activity.phase === "idle")).toBe(false);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("keeps startup reporting event-only, writing no session or workspace activity state", async () => {
|
||||
const recorder = recordingWorkspaceActivity();
|
||||
const failure = new Error("runtime unavailable");
|
||||
const { hub, service } = startupService({
|
||||
createAgentRuntime: () => Promise.reject(failure),
|
||||
workspaceActivity: recorder.workspaceActivity,
|
||||
});
|
||||
|
||||
await expect(service.start("/workspace")).rejects.toBe(failure);
|
||||
|
||||
expect(startupEvents(hub)).toHaveLength(2);
|
||||
expect(activityUpdates(hub)).toEqual([]);
|
||||
expect(recorder.calls).toEqual([]);
|
||||
// Startup progress is global-only: it must never reach a per-session socket,
|
||||
// because no session exists to have subscribers yet.
|
||||
expect(hub.sessionEvents).toEqual([]);
|
||||
await service.dispose();
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,20 @@ export interface PiSessionLogger {
|
||||
|
||||
const noopLogger: PiSessionLogger = { info() { /* no-op */ } };
|
||||
const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
|
||||
/**
|
||||
* User-facing names for the two phases of session startup PI WEB can prove it
|
||||
* is inside: it awaits exactly one call for each, so the phase is a fact rather
|
||||
* than a guess. Deliberately free of internal symbol names and file paths.
|
||||
*/
|
||||
const STARTUP_PHASE_RUNTIME = "Starting the Pi session";
|
||||
const STARTUP_PHASE_EXTENSIONS = "Loading session extensions";
|
||||
/**
|
||||
* Appended to whichever phase is running when a background provider catalog
|
||||
* refresh happens to be in flight. It is stated as a concurrent fact, never as
|
||||
* the cause: PI WEB can verify that a refresh is running, but not that this
|
||||
* particular startup is waiting on it.
|
||||
*/
|
||||
const STARTUP_CONCURRENT_CATALOG_REFRESH = "provider model lists are refreshing";
|
||||
const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
|
||||
const MAX_PENDING_UNREAD_MUTATIONS = SESSION_UNREAD_LIMIT + 1;
|
||||
|
||||
@@ -379,6 +393,30 @@ interface PendingSessionOpen {
|
||||
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance"> {
|
||||
notificationGeneration?: SessionNotificationGeneration;
|
||||
notifications?: "enabled" | "disabled";
|
||||
/**
|
||||
* What the user asked for, so startup progress can say "Creating" instead of
|
||||
* "Opening". Only `startSession()` creates a brand new session; every other
|
||||
* caller opens an existing one, so "open" is the default.
|
||||
*/
|
||||
startupIntent?: "create" | "open";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only view of the background catalog refresher, so session startup can
|
||||
* state what it is concurrent with without being able to influence it.
|
||||
*/
|
||||
export interface CatalogRefreshStatus {
|
||||
isRefreshInFlight(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes what a session startup is waiting on while it waits. Every call is
|
||||
* synchronous and event-only, so reporting never adds an await to session
|
||||
* creation and leaves no per-session state to unwind if creation fails.
|
||||
*/
|
||||
interface SessionStartupProgressReporter {
|
||||
report(phase: string): void;
|
||||
end(): void;
|
||||
}
|
||||
|
||||
type NotificationClosePolicy =
|
||||
@@ -658,6 +696,11 @@ export interface PiSessionServiceDependencies {
|
||||
unreadStore?: SessionUnreadStore;
|
||||
/** Initial retry delay for durable unread publication failures. */
|
||||
unreadPublicationRetryDelayMs?: number;
|
||||
/**
|
||||
* Lets session startup report that provider model lists are refreshing while
|
||||
* a session is being constructed. Omit to report the startup phase alone.
|
||||
*/
|
||||
catalogRefreshStatus?: CatalogRefreshStatus;
|
||||
}
|
||||
|
||||
export class PiSessionService implements SessionRouteService {
|
||||
@@ -705,6 +748,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
private readonly notificationStore: SessionNotificationStore;
|
||||
private readonly notificationGenerationBySession = new WeakMap<PiAgentSession, SessionNotificationGeneration>();
|
||||
private readonly unreadStore: SessionUnreadStore;
|
||||
private readonly catalogRefreshStatus: CatalogRefreshStatus | undefined;
|
||||
private readonly unreadPublicationRetryInitialMs: number;
|
||||
private readonly pendingUnreadMutations: SessionUnreadMutation[] = [];
|
||||
private unreadPublication: Promise<void> | undefined;
|
||||
@@ -724,6 +768,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
|
||||
this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
|
||||
this.catalogRefreshStatus = deps.catalogRefreshStatus;
|
||||
this.unreadPublicationRetryInitialMs = Math.max(
|
||||
0,
|
||||
deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS,
|
||||
@@ -946,6 +991,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }),
|
||||
cwd,
|
||||
{
|
||||
startupIntent: "create",
|
||||
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
||||
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
|
||||
},
|
||||
@@ -2295,11 +2341,33 @@ export class PiSessionService implements SessionRouteService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a session while telling waiting browsers which phase of startup
|
||||
* they are waiting on. The reporting wraps the *whole* construction rather
|
||||
* than the inner bookkeeping `try`, because the runtime construction that runs
|
||||
* first is both the slowest phase and one that can fail on its own; a clear
|
||||
* that only ran for the later phases would leave a stale label behind.
|
||||
*/
|
||||
private async create(
|
||||
sessionManager: PiSessionManager,
|
||||
cwd: string,
|
||||
options: CreateSessionRuntimeOptions = {},
|
||||
): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
const startup = this.startupProgress(sessionManager, cwd, options.startupIntent ?? "open");
|
||||
try {
|
||||
return await this.createSessionRuntime(sessionManager, cwd, options, startup);
|
||||
} finally {
|
||||
startup.end();
|
||||
}
|
||||
}
|
||||
|
||||
private async createSessionRuntime(
|
||||
sessionManager: PiSessionManager,
|
||||
cwd: string,
|
||||
options: CreateSessionRuntimeOptions,
|
||||
startup: SessionStartupProgressReporter,
|
||||
): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
startup.report(STARTUP_PHASE_RUNTIME);
|
||||
const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession"
|
||||
&& await sessionAllowsDelegationTools(sessionManager, this.sessionManager);
|
||||
const runtime = await this.createAgentRuntime(this.createRuntime, {
|
||||
@@ -2347,6 +2415,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
} else {
|
||||
await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
|
||||
}
|
||||
startup.report(STARTUP_PHASE_EXTENSIONS);
|
||||
await this.bindSessionExtensions(runtime.session, notificationGeneration);
|
||||
this.bindRuntime(active);
|
||||
runtime.setRebindSession(async (session) => {
|
||||
@@ -2927,6 +2996,50 @@ export class PiSessionService implements SessionRouteService {
|
||||
if (this.hasActiveWork(session)) this.publishActivity(session, eventType.replaceAll("_", " "), "active");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the reporter for one session construction.
|
||||
*
|
||||
* The session id and cwd are both known before any await — a `SessionManager`
|
||||
* has its id from construction — so the daemon can name what it is starting
|
||||
* even though the `PiAgentSession` that {@link publishActivity} needs does not
|
||||
* exist yet. When either is missing there is nothing honest to route on, so
|
||||
* the reporter stays silent and the browser keeps its own generic wording.
|
||||
*/
|
||||
private startupProgress(sessionManager: PiSessionManager, cwd: string, intent: "create" | "open"): SessionStartupProgressReporter {
|
||||
const sessionId = sessionManager.getSessionId();
|
||||
if (sessionId === "" || cwd === "") return { report: noop, end: noop };
|
||||
const label = intent === "create" ? "Creating session" : "Opening session";
|
||||
return {
|
||||
report: (phase) => { this.publishStartupProgress(sessionId, cwd, label, "active", this.startupDetail(phase)); },
|
||||
end: () => {
|
||||
// A real activity published during the window (an extension error, say)
|
||||
// is the truth about this session and must survive the clear.
|
||||
if (this.activities.has(sessionId)) return;
|
||||
this.publishStartupProgress(sessionId, cwd, "idle", "idle", undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private startupDetail(phase: string): string {
|
||||
return this.catalogRefreshStatus?.isRefreshInFlight() === true
|
||||
? `${phase} · ${STARTUP_CONCURRENT_CATALOG_REFRESH}`
|
||||
: phase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report startup progress on the global channel only, keyed by `cwd` so a
|
||||
* browser row that has no session id yet can find it.
|
||||
*
|
||||
* Unlike {@link publishActivity} this deliberately records nothing: no
|
||||
* `activities` entry, no workspace activity, no unread observation. There is
|
||||
* no session to own that state, and a failed creation would leave it stranded.
|
||||
*/
|
||||
private publishStartupProgress(sessionId: string, cwd: string, label: string, phase: "active" | "idle", detail: string | undefined): void {
|
||||
const at = new Date().toISOString();
|
||||
const activity = detail === undefined ? { sessionId, phase, label, at } : { sessionId, phase, label, detail, at };
|
||||
this.events.publishGlobal({ type: "session.startup", cwd, activity });
|
||||
}
|
||||
|
||||
private publishActivity(session: PiAgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void {
|
||||
const at = new Date().toISOString();
|
||||
const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at };
|
||||
|
||||
+22
-1
@@ -433,6 +433,26 @@ export interface QueuedSessionMessage {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress of the session startup window, where the daemon is still
|
||||
* constructing the agent session and no `PiAgentSession` exists yet, so
|
||||
* `activity.update` cannot be published for it.
|
||||
*
|
||||
* `cwd` is the routing key for a browser row that is still waiting for a
|
||||
* session id: a client-invented pending start knows its workspace path but not
|
||||
* the daemon's session id. `activity.sessionId` carries the daemon's real id, so
|
||||
* the same event also serves the case where the browser already knows it (an
|
||||
* open of an existing session).
|
||||
*
|
||||
* `activity.phase === "idle"` means the startup window ended with nothing left
|
||||
* to report, so a browser that substituted its own text should restore it.
|
||||
*/
|
||||
export interface SessionStartupProgressEvent {
|
||||
type: "session.startup";
|
||||
cwd: string;
|
||||
activity: SessionActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pi-native image attachment carried with a prompt. The wire format mirrors
|
||||
* pi's own `ImageContent` shape (`{ type: "image", data, mimeType }`) so these
|
||||
@@ -977,5 +997,6 @@ type SessionUiEventBody =
|
||||
export type GlobalSessionEvent =
|
||||
| Extract<SessionUiEventBody, { type: "status.update" | "activity.update" | "session.name" | "session.created" }>
|
||||
| SessionNotificationSummaryEvent
|
||||
| SessionUnreadEvent;
|
||||
| SessionUnreadEvent
|
||||
| SessionStartupProgressEvent;
|
||||
export type RealtimeEvent = GlobalSessionEvent | TerminalUiEvent | WorkspaceActivityUiEvent;
|
||||
|
||||
Reference in New Issue
Block a user