Archived
fix(sessions): stop counting session startup as work in progress
Startup progress rides the per-session activity channel with an "active" phase, because a startup phase really is in progress. But isSessionActive() treated any active activity as work, so a session that was merely *opening* enabled "Stop Active Work", disabled "Reload from disk" with the misleading "Stop current session activity before reloading" tooltip, showed the row's active-work indicator, and — for any caller that hands a startup activity to WorkspaceActivityService — reported the whole workspace as busy. Selecting an archived, read-only session reported active work while it opened. Starting is not working. publishStartupProgress now marks its reports with a new optional SessionActivity.startup field, and isSessionActive() does not count a marked activity. Every affected consumer — the session list, the core actions, the app's activity-transition handling, and the server's workspace aggregation — reads that one helper, so the correction lands in all of them at once. The marker is a new field rather than a new phase on purpose: six readers test phase === "active" directly, including the pending row's "creating · " prefix, the chat dock's active styling, and the daemon's own heartbeat re-publication. It can only ever remove the activity-phase reason for being active, so streaming, bash, compaction, and queued prompts still report as active through the status even while a startup report is the latest activity. The chat dock still shows the startup text; this changes what counts as work, not what is shown. The browser's own pending-create row keeps its previous appearance: it borrows only the daemon's phase text and drops the marker, since that row stands for a create the user is waiting on rather than a session the daemon is opening.
This commit is contained in:
@@ -305,6 +305,15 @@ describe("API parsers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the startup marker so an opening session is not mistaken for a working one", () => {
|
||||
const activity = { sessionId: "session-1", phase: "active", label: "Opening session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z", startup: true };
|
||||
|
||||
expect(parseSessionStartupProgressEvent({ type: "session.startup", activity })).toEqual({ type: "session.startup", activity });
|
||||
// A malformed marker is dropped like any other malformed field rather than
|
||||
// being coerced into "this is startup" or "this is work".
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, startup: "yes" } })).toThrow("Expected optional boolean field: startup");
|
||||
});
|
||||
|
||||
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" };
|
||||
|
||||
|
||||
@@ -300,9 +300,22 @@ function parseSessionActivity(value: unknown): SessionActivity {
|
||||
label: requireNonEmptyString(record, "label"),
|
||||
...optionalField("detail", optionalString(record, "detail")),
|
||||
at: requireNonEmptyString(record, "at"),
|
||||
...optionalField("startup", optionalActivityStartupMarker(record)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The startup marker says the activity is a session opening rather than work in
|
||||
* progress, which decides whether "Stop Active Work" is offered and whether a
|
||||
* reload is blocked. A malformed marker is rejected rather than guessed at.
|
||||
*/
|
||||
function optionalActivityStartupMarker(record: Record<string, unknown>): boolean | undefined {
|
||||
const value = record["startup"];
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "boolean") throw new Error("Expected optional boolean field: startup");
|
||||
return value;
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
@@ -16,6 +16,14 @@ describe("sessionRowActivityKind", () => {
|
||||
expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false)).toBe("session");
|
||||
});
|
||||
|
||||
it("shows no active-work indicator for a session that is only starting up", () => {
|
||||
const startup = { sessionId: "s", phase: "active" as const, label: "Opening session", detail: "Starting the Pi session", at: "now", startup: true };
|
||||
|
||||
expect(sessionRowActivityKind(session("s"), idle, startup, false)).toBeUndefined();
|
||||
// Ordinary activity is work and keeps its indicator.
|
||||
expect(sessionRowActivityKind(session("s"), idle, { sessionId: "s", phase: "active", label: "running tool", at: "now" }, false)).toBe("session");
|
||||
});
|
||||
|
||||
it("reports unread only while the session is idle", () => {
|
||||
expect(sessionRowActivityKind(session("s"), idle, undefined, false, true)).toBe("unread");
|
||||
expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false, true)).toBe("session");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
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";
|
||||
@@ -10,6 +11,7 @@ function startupActivity(patch: Partial<SessionActivity> = {}): SessionActivity
|
||||
label: "Creating session",
|
||||
detail: "Starting the Pi session",
|
||||
at: "2026-07-20T00:00:01.000Z",
|
||||
startup: true,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -76,6 +78,28 @@ describe("SessionController session startup progress", () => {
|
||||
await start;
|
||||
});
|
||||
|
||||
it("keeps a pending create row's own appearance while it borrows the daemon's phase text", 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");
|
||||
const beforeStartupProgress = isSessionActive(state.current.status, state.current.activity);
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity() });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
// This row is the browser's own pending create, not a session being opened.
|
||||
// Substituting the daemon's phase text into it must not silently change what
|
||||
// the row reports about itself, or its indicator would blink off mid-create.
|
||||
expect(state.current.activity?.phase).toBe("active");
|
||||
expect(isSessionActive(state.current.status, state.current.activity)).toBe(beforeStartupProgress);
|
||||
|
||||
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);
|
||||
@@ -136,6 +160,29 @@ describe("SessionController session startup progress", () => {
|
||||
expect(state.activity).toMatchObject({ sessionId: oldSession.id, label: "Opening session", detail: "Starting the Pi session" });
|
||||
});
|
||||
|
||||
it("shows an archived session's opening progress without reporting it as active work", () => {
|
||||
const archived = { ...oldSession, id: "archived-session", archived: true, archivedAt: "2026-05-16T00:00:00.000Z" };
|
||||
let state: AppState = { ...initialAppState(), selectedSession: archived, sessions: [archived] };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({
|
||||
type: "session.startup",
|
||||
activity: startupActivity({ sessionId: archived.id, label: "Opening session" }),
|
||||
});
|
||||
runPendingAnimationFrames();
|
||||
|
||||
// A read-only session cannot be worked on, so opening one must not make it
|
||||
// look busy — while the text a waiting user reads still arrives.
|
||||
expect(state.activity).toMatchObject({ sessionId: archived.id, label: "Opening session", detail: "Starting the Pi session" });
|
||||
expect(isSessionActive(state.status, state.activity)).toBe(false);
|
||||
});
|
||||
|
||||
it("gives an existing session's startup its own row rather than a pending start in the same workspace", async () => {
|
||||
const existing = { ...oldSession, id: "existing-session", cwd: workspace.path };
|
||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [existing] } };
|
||||
|
||||
@@ -1328,7 +1328,7 @@ export class SessionController {
|
||||
// creation request that has not returned yet.
|
||||
this.queueActivityUpdate(event.activity.phase === "idle"
|
||||
? creatingPendingSessionActivity(pending.tempId, pending.queuedSends.length)
|
||||
: { ...event.activity, sessionId: pending.tempId });
|
||||
: pendingStartActivity(event.activity, pending.tempId));
|
||||
}
|
||||
|
||||
private schedulePendingFlush(): void {
|
||||
@@ -1429,6 +1429,24 @@ function isClientPendingStartSessionInfo(session: SessionInfo | undefined): sess
|
||||
return session !== undefined && "clientPendingStart" in session && session.clientPendingStart === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-label a startup report onto the browser's own pending create row.
|
||||
*
|
||||
* The daemon's startup marker is dropped: this row stands for a create the user
|
||||
* asked for and is waiting on, which the browser has always reported as active
|
||||
* work in progress, rather than for a session the daemon is opening. Only the
|
||||
* phase text is borrowed, so the row keeps its own `creating · ` appearance.
|
||||
*/
|
||||
function pendingStartActivity(activity: SessionActivity, sessionId: string): SessionActivity {
|
||||
return {
|
||||
sessionId,
|
||||
phase: activity.phase,
|
||||
label: activity.label,
|
||||
...(activity.detail === undefined ? {} : { detail: activity.detail }),
|
||||
at: activity.at,
|
||||
};
|
||||
}
|
||||
|
||||
function creatingPendingSessionActivity(sessionId: string, queuedCount = 0): SessionActivity {
|
||||
return {
|
||||
sessionId,
|
||||
|
||||
@@ -249,6 +249,25 @@ describe("PluginRegistry", () => {
|
||||
expect(busy.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a session that is only starting up as having no work to stop or block", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsPersistedState] } };
|
||||
const startupActivity = { sessionId: "s1", phase: "active" as const, label: "Opening session", detail: "Starting the Pi session", at: "now", startup: true };
|
||||
|
||||
const opening = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), status: testStatus({ persisted: true }), activity: startupActivity, machineRuntimes: reloadRuntime }).context);
|
||||
|
||||
// Nothing is being worked on, so there is nothing to stop and no reason to
|
||||
// block a reload with "Stop current session activity before reloading".
|
||||
expect(opening.find((action) => action.id === "core:session.stop")?.enabled).toBe(false);
|
||||
expect(opening.find((action) => action.id === "core:session.reload")?.enabled).toBe(true);
|
||||
|
||||
// Real work is still real work, whatever else the session is doing.
|
||||
const working = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), status: testStatus({ persisted: true, isStreaming: true }), activity: startupActivity, machineRuntimes: reloadRuntime }).context);
|
||||
expect(working.find((action) => action.id === "core:session.stop")?.enabled).toBe(true);
|
||||
expect(working.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("routes session reload through the runtime context", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
|
||||
@@ -90,6 +90,16 @@ describe("notification socket guards", () => {
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries the startup marker through the socket boundary, marker and all", () => {
|
||||
const activity = { sessionId: "session-1", phase: "active", label: "Opening session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z", startup: true };
|
||||
|
||||
// The marker is what stops an opening session being treated as a working
|
||||
// one, so dropping it in transit would restore the defect for every frame,
|
||||
// including those relayed from a remote machine.
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", activity })).toMatchObject({ type: "session.startup", activity: { startup: true } });
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", activity: { ...activity, startup: 1 } })).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" };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user