fix(sessions): guard, gate, and test session reload

Build on the original Reload action with the fixes raised in review:

- Server reload() now refuses to run on archived (read-only) sessions
  and when the session has work in progress, mirroring archive(), so a
  reload can no longer silently abort an in-flight agent run.
- Add a sessions.reload runtime capability; the client gates both the
  reloadSession call and the Reload menu entry on it so the action only
  appears for machines whose Pi-Web runtime supports it.
- reloadSession ignores cached-new and archived sessions.
- Add server (PiSessionService + routes) and client (SessionController)
  tests covering reload success, the active-work guard, archived
  rejection, route forwarding, capability gating, and error mapping.
- Restore alphabetical parser import ordering in clients.ts.
- Add a changeset documenting the feature and the sessiond restart note.

Note: touches a session daemon code path, so pi-web-sessiond.service
must be restarted manually for the server side to take effect.
This commit is contained in:
Federico Jaramillo Martinez
2026-06-14 14:17:51 +02:00
parent ea1ec1b595
commit 82db15f894
12 changed files with 218 additions and 8 deletions
@@ -425,6 +425,74 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("reloads a session by closing the active runtime and re-opening it from disk", async () => {
const first = fakeRuntime("reload-session");
const second = fakeRuntime("reload-session");
const runtimes = [first.runtime, second.runtime];
let createCalls = 0;
const createAgentRuntime: RuntimeCreator = async () => {
await Promise.resolve();
const runtime = runtimes[createCalls];
createCalls += 1;
if (runtime === undefined) throw new Error("unexpected runtime creation");
return runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord("reload-session")]),
heartbeatIntervalMs: 60_000,
});
// Open once so there is an active runtime to reload.
await service.status(sessionRef("reload-session"));
expect(createCalls).toBe(1);
await expect(service.reload(sessionRef("reload-session"))).resolves.toBeUndefined();
// The original runtime was torn down and a fresh one opened from disk.
expect(first.calls.abort).toBe(1);
expect(first.calls.dispose).toBe(1);
expect(createCalls).toBe(2);
expect(service.activeCount()).toBe(1);
await service.dispose();
});
it("refuses to reload a session that has active work in progress", async () => {
const fake = fakeRuntime("busy-session", { isStreaming: true });
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("busy-session")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.reload(sessionRef("busy-session"))).rejects.toThrow("Stop current session activity before reloading");
expect(fake.calls.abort).toBe(0);
expect(fake.calls.dispose).toBe(0);
await service.dispose();
});
it("refuses to reload an archived session", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" }
: undefined),
archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }),
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(true),
},
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await expect(service.reload(sessionRef("archived"))).rejects.toThrow("Archived sessions are read-only");
await service.dispose();
});
it("reconciles workspace activity when listing only archived sessions", async () => {
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
+4 -2
View File
@@ -561,8 +561,10 @@ export class PiSessionService {
}
async reload(ref: PiSessionLookup): Promise<void> {
const active = await this.getActive(ref);
await this.closeActive(active.runtime.session.sessionId);
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
await this.closeActive(session.sessionId);
const reopened = await this.getActive(ref);
this.publishStatus(reopened.runtime.session);
}
+47
View File
@@ -97,15 +97,62 @@ describe("session routes", () => {
await routeApp.close();
}
});
it("reloads a session through the reload route, forwarding workspace context", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const requestCwd = resolve("/repo");
const reloadResponse = await routeApp.inject({ method: "POST", url: "/sessions/session-1/reload", payload: { cwd: requestCwd } });
expect(reloadResponse.statusCode).toBe(200);
expect(reloadResponse.json()).toEqual({ reloaded: true });
expect(routeService.reloadCalls).toEqual([{ id: "session-1", cwd: requestCwd }]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("maps reload failures to a mutation error status", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
routeService.reloadError = new Error("Stop current session activity before reloading");
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const reloadResponse = await routeApp.inject({ method: "POST", url: "/sessions/session-1/reload", payload: {} });
expect(reloadResponse.statusCode).toBe(400);
expect(reloadResponse.json()).toEqual({ error: "Stop current session activity before reloading" });
} finally {
await routeService.dispose();
await routeApp.close();
}
});
});
class CapturingRouteSessionService extends PiSessionService {
readonly calls: unknown[] = [];
readonly reloadCalls: (string | PiSessionRef)[] = [];
reloadError: Error | undefined;
constructor(eventHub: SessionEventHub) {
super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 });
}
override reload(lookup: string | PiSessionRef): Promise<void> {
this.reloadCalls.push(lookup);
if (this.reloadError !== undefined) return Promise.reject(this.reloadError);
return Promise.resolve();
}
override status(lookup: string | PiSessionRef) {
this.calls.push(lookup);
return Promise.resolve({