fix(p2): archiving a viewed session shows no error and starts no refused worker

Archiving from the session sheet navigates away first, which unmounted SessionOptions and released its Sessions
reference; Chord cancels a released reference's calls, so the archive call rejected with "p2.sessions: released",
shown as the composer toast over the owner-notice lane. The archive now holds a reference of its own until it is done,
and the server runs its steps in a root of its own.

The archive stopped the worker with Hosts.stop, which restarts it for its holders: the page's lingering
conversation started a worker the archived session refused (resolve_failed, "Worker: Session is archived" notice).
SessionWorkerControl.suspend stops it and leaves its holders suspended until a restore (retry) or their release;
archive, a failed fork's archived copy and an archived session's index rebuild use it.
This commit is contained in:
Mario Zechner 2026-11-04 12:02:27 +01:00
parent 15a5114599
commit 0a730c8a76
9 changed files with 149 additions and 14 deletions

View file

@ -137,7 +137,7 @@ export default defineFacet((host) => {
} finally {
if (archived) {
await archivedStarts.end({ session }, ctx);
await hosts.stop({ session }, ctx);
await server().workers.suspend(session);
}
}
};

View file

@ -1,11 +1,12 @@
// Native catalog operations beneath the copied mobile Session sheet.
import { BACKGROUND_CONTEXT } from "@p2/app/v1";
import { BACKGROUND_CONTEXT, liveIn, whenLive } from "@p2/app/v1";
import {
closeLayers,
conversationRoute,
MoreIcon,
openLayer,
useComposerState,
useHost,
useNavigation,
useObject,
useReadable,
@ -16,7 +17,8 @@ import { directoryPickerLayer } from "../envs/contract.web.tsx";
import { Sessions } from "./contract.ts";
import { SessionSheet } from "./SessionSheet.tsx";
export function SessionOptions() {
const nav = useNavigation(),
const host = useHost(),
nav = useNavigation(),
route = useRoute();
const service = useObject(Sessions, {}, { variant: "owner" }),
rows = useReadable(service?.sessions);
@ -47,7 +49,14 @@ export function SessionOptions() {
onArchive={() => {
closeLayers();
void nav.replace(conversationRoute(route, undefined));
perform(service?.archive({ id: session.id }, BACKGROUND_CONTEXT));
// Leaving the session unmounts this view and releases `service`, which cancels its calls: the
// archive holds a reference of its own until it is done.
const archiving = host.hold(Sessions, {}, BACKGROUND_CONTEXT);
perform(
whenLive(archiving)
.then(() => liveIn(archiving, "owner")?.archive({ id: session.id }, BACKGROUND_CONTEXT))
.finally(() => archiving.release()),
);
}}
/>
)}

View file

@ -18,7 +18,7 @@ import {
serveKeyed,
WorkerBoundary,
} from "@p2/app/v1";
import { Hosts, Server, serverOf, WorkerInitValues } from "@p2/app/v1/server";
import { Server, serverOf, WorkerInitValues } from "@p2/app/v1/server";
import { EnvManagers, EnvService } from "../envs/contract.ts";
import { ModelConfig, ModelResources } from "../models/contract.ts";
import { ChildStore } from "../subagents/contract.ts";
@ -236,7 +236,6 @@ export default defineFacet((host) => {
return { variant: "guest", members: { sessions: sessionsView } };
}
if (!isOwner(principal)) throw forbidden("sessions are owner-only");
const hosts = self.get(Hosts, {});
const lifecycleOf = serverOf(self);
const models = self.get(ModelConfig, {});
const resources = nativeOf(self, ModelResources, "The model configuration");
@ -361,7 +360,9 @@ export default defineFacet((host) => {
} catch (error) {
if (created) {
await catalog.update({ id: copy, archived: true }, ctx).catch(() => {});
await hosts.stop({ session: copy }, ctx).catch(() => {});
await lifecycleOf()
.workers.suspend(copy)
.catch(() => {});
} else await removeSessionCopy(dataDir, copy);
throw error;
}
@ -376,10 +377,13 @@ export default defineFacet((host) => {
await catalog.update({ id, pinned }, ctx);
return null;
},
archive: async ({ id }, ctx) => {
await catalog.update({ id, archived: true }, ctx);
// Archive stops the worker whatever it is doing; its storage and history stay.
await hosts.stop({ session: id }, ctx);
archive: async ({ id }) => {
await lifecycle(async (root) => {
await catalog.update({ id, archived: true }, root);
// Archive stops the worker whatever it is doing; its storage and history stay. Its holders (a page's
// lingering conversation) wait for a restore rather than start a worker the archive refuses.
await lifecycleOf().workers.suspend(id);
});
return null;
},
restore: async ({ id }, ctx) => {